diff --git a/.gitignore b/.gitignore index 84b70edc21213f6311003e4e747b8fb90ab8b230..99ebc8bfc6c515ce161d77c011d723b6aae194b3 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,4 @@ rocq/.nia.cache rocq/extract/dump rocq/extract/*.cm* rocq/extract/*.o -src/independent/.work/ +src/.work/ diff --git a/README.md b/README.md index c755bf6901779528d4832e49f5d76647284a4f46..78db13a00944d5023d73f8e88a8e8ce7c8596a29 100644 --- a/README.md +++ b/README.md @@ -99,14 +99,14 @@ for a, c in [(0, 0), (0, 1), (1, 0), (1, 1)]: Run the full circuit verification suite against any variant: ```bash -python src/eval_all.py variants/ # 18 fitness variants (machines skipped) -python src/eval_all.py neural_computer.safetensors # the canonical file -python src/eval_all.py --cpu-program variants/ # also run an assembled +python src/eval.py variants variants/ # 18 fitness variants (machines skipped) +python src/eval.py variants neural_computer.safetensors # the canonical file +python src/eval.py variants --cpu-program variants/ # also run an assembled # program through the # threshold-gated CPU ``` -`src/eval_all.py` reads each variant's manifest, runs a gate-level fitness suite (13,900–15,900 tests per variant covering Boolean, arithmetic, ALU, control, modular, error-detection, threshold, and float circuits, including end-to-end evaluation of the composed float pipelines from the shipped wiring metadata — see the Verification table), and optionally executes a small assembled program through a manifest-sized threshold CPU plus a chained 16- or 32-bit ALU sequence on wider variants. +`src/eval.py variants` reads each variant's manifest, runs a gate-level fitness suite (13,900–15,900 tests per variant covering Boolean, arithmetic, ALU, control, modular, error-detection, threshold, and float circuits, including end-to-end evaluation of the composed float pipelines from the shipped wiring metadata — see the Verification table), and optionally executes a small assembled program through a manifest-sized threshold CPU plus a chained 16- or 32-bit ALU sequence on wider variants. For an interactive walkthrough that exercises Boolean gates, the 8-bit ALU, mod-5 divisibility, and a CPU loop end-to-end: @@ -115,15 +115,34 @@ python tools/play.py # 1 KB demo, runs in s python tools/play.py --model neural_computer.safetensors # 64 KB, slower ``` +Each machine of the family also has a demonstration on a named task, which loads the shipped weights and checks its result against an independent reference: + +| Command | Machine | What it does | +|---------|---------|--------------| +| `python tools/play.py sieve` | `neural_computer8` | Sieve of Eratosthenes; with no indexed addressing, the program rewrites the address bytes of its own LOAD/STORE. Finds all 54 primes < 256. | +| `python tools/play.py gcd` | `neural_computer8` | Euclid's GCD, with `a mod b` built from the DIV/MUL/SUB opcodes and an XOR register swap. | +| `python tools/play.py collatz` | `neural_computer8` | Collatz step counts; exact for every seed whose trajectory stays inside the 8-bit range. | +| `python tools/play.py pi` | `neural_rv32` | pi to nine digits by Machin's 1706 arctangent formula, printed to the console. | +| `python tools/play.py nets` | `neural_rv32` | Ternary neural nets run as NEUR software: one learned by gradient descent, one (XOR) compiled by construction. | +| `python tools/play.py fleet` | `neural_matrix8` | 65,536 CPUs stepped in lockstep on the GPU as one batched matrix product. | +| `python tools/play.py factoring` | `neural_attractor` | Factors semiprimes by relaxing an 8x8 multiplier's energy backward; also divides. | +| `python tools/play.py nqueens` | `neural_attractor` | Solves 6- and 8-queens by relaxing toward zero unsatisfied clauses. | +| `python tools/play.py constructor` | `neural_subleq8io` | The universal constructor fabricates a sibling machine byte-for-byte, which then boots. | +| `python tools/play.py stack` | `neural_reflect` | Self-modifying SUBLEQ code, on a stored machine, on the fixed interpreter: three levels in one tensor. | +| `python tools/play.py counterfactual` | `neural_reversible` | Bijective mixing, exact reverse recovery, and the counterfactual when a digest bit is flipped. | +| `python tools/play.py loschmidt` | `neural_ca` | A ~2,000-particle gas mixed 500 steps and un-mixed exactly; one flipped cell corrupts half the past. | +| `python tools/play.py cipher` | `neural_ca` | The reversible automaton as a block cipher: exact decryption with the right key, noise with a wrong one. | +| `python tools/play.py pascal` | `neural_tile` | Self-assembly grows Pascal's triangle mod 2; every cell verified against Lucas' theorem. | + For end-to-end CPU validation (Fibonacci, sum 1..N, bubble sort, self-modifying JMP, all eight conditional jumps, CALL stack semantics, MUL cross-checked against repeated ADD, DIV cross-checked against repeated SUB, a bitwise AND/OR/XOR/SHL/SHR chain, and the architectural flag policy): ```bash -python tools/test_cpu.py # default: 1 KB, ~2 s -python tools/test_cpu.py --model neural_computer.safetensors # 64 KB canonical, ~2 min -python tools/test_cpu.py --only fib,sum_n # subset of suite +python src/eval.py cpu # default: 1 KB, ~2 s +python src/eval.py cpu --model neural_computer.safetensors # 64 KB canonical, ~2 min +python src/eval.py cpu --only fib,sum_n # subset of suite ``` -Each program is assembled by a small Python assembler (`tools/cpu_programs.py`) and run through the threshold-gated CPU; the driver verifies expected memory contents at HALT. +Each program is assembled by a small Python assembler (in `src/eval.py`) and run through the threshold-gated CPU; the driver verifies expected memory contents at HALT. --- @@ -325,7 +344,7 @@ python src/build.py --bits 32 -a 6 --apply all # neural_computer32_addr6 To regenerate every named variant in one pass: ```bash -python tools/build_all.py +python src/build.py variants ``` This populates `variants/` with all 18 builds, quantizes each one to the smallest signed integer dtype that exactly represents its weights (~4× reduction in tensor data, with file size dominated by the safetensors header on the smaller profiles), verifies the strictly ternary weight invariant (`--ternary --strict`, so a build with any non-ternary weight fails loudly), stamps the `weight_quantization` metadata field, and runs `src/eval.py` on each as a sanity check. @@ -333,11 +352,11 @@ This populates `variants/` with all 18 builds, quantizes each one to the smalles The quantizer is also available standalone: ```bash -python src/quantize.py path/to/file.safetensors # in-place -python src/quantize.py variants/ # whole directory -python src/quantize.py model.safetensors -o quantized.safetensors -python src/quantize.py file.safetensors --ternary # push toward {-1, 0, 1} weights -python src/quantize.py file.safetensors --ternary --strict # error if any weight is non-ternary +python src/build.py quantize path/to/file.safetensors # in-place +python src/build.py quantize variants/ # whole directory +python src/build.py quantize model.safetensors -o quantized.safetensors +python src/build.py quantize file.safetensors --ternary # push toward {-1, 0, 1} weights +python src/build.py quantize file.safetensors --ternary --strict # error if any weight is non-ternary ``` Every weight and bias tensor in the canonical model fits in `int8`. The eval pipeline promotes weights to `float32` on load, so integer storage is exact and transparent. @@ -369,7 +388,7 @@ Every weight and bias tensor in the canonical model fits in `int8`. The eval pip The 8-bit arithmetic and ALU tests sample strategically; the circuits are constructed gate by gate, so the full 65,536-case sweep adds nothing. The 16-bit and 32-bit arithmetic tests sample edge cases only; full exhaustive coverage at those widths is infeasible without specialized hardware. -`src/eval_all.py` runs the unified suite. Exit code is the number of failing variants (0 means all pass). **Testing evaluates the shipped weights; rebuilding is a separate step.** `python src/eval_all.py variants/` scores all 18 fitness variants against the shipped weights in about two minutes (~6 s each, the composed float netlists evaluated in `NetlistEvaluator`'s leveled mode) and cleanly skips the nine standalone machines. Rebuilding the models (`tools/build_all.py`, ~50 min for all 18) is a separate step, needed only when the circuit constructions in `src/build.py` change; routine verification never rebuilds. The batched evaluator is population-safe: every chained intermediate (carry, borrow, mux select) is computed per population slot, so `tools/prune_weights.py`'s parallel fitness screens are exact, with no slot-0 approximation anywhere. +`src/eval.py variants` runs the unified suite. Exit code is the number of failing variants (0 means all pass). **Testing evaluates the shipped weights; rebuilding is a separate step.** `python src/eval.py variants variants/` scores all 18 fitness variants against the shipped weights in about two minutes (~6 s each, the composed float netlists evaluated in `NetlistEvaluator`'s leveled mode) and cleanly skips the nine standalone machines. Rebuilding the models (`python src/build.py variants`, ~50 min for all 18) is a separate step, needed only when the circuit constructions in `src/build.py` change; routine verification never rebuilds. The batched evaluator is population-safe: every chained intermediate (carry, borrow, mux select) is computed per population slot, with no slot-0 approximation anywhere. --- @@ -405,7 +424,7 @@ Signed comparisons ride the unsigned bit-cascade with sign bits complemented thr ```bash python src/build.py --apply rv32 # build the file -python src/quantize.py variants/neural_rv32.safetensors --ternary --strict +python src/build.py quantize variants/neural_rv32.safetensors --ternary --strict python src/machines.py rv32 # eight-program lockstep suite python src/machines.py rv32-c # stock-compiler C, end to end ``` @@ -461,9 +480,9 @@ write is gated by NOT halt), so iterating past HALT is harmless. conductance mismatch (bit-exact through σ_G = 0.10). ```bash -python src/matrix8.py build # compile + save variants/neural_matrix8.safetensors -python src/matrix8.py verify # exhaustive equality vs the gate graph and integer reference -python src/matrix8.py analog # margin measurement + read-noise + conductance-mismatch sweeps +python src/machines.py matrix8 build # compile + save variants/neural_matrix8.safetensors +python src/machines.py matrix8 verify # exhaustive equality vs the gate graph and integer reference +python src/machines.py matrix8 analog # margin measurement + read-noise + conductance-mismatch sweeps ``` Because the transition is a fixed matrix stack proven equal to the gate-graph @@ -508,8 +527,8 @@ correct assignment, but reaching it is NP-hard in general and not guaranteed within a fixed annealing schedule. ```bash -python tools/build_attractor.py # compile a multiplier to variants/neural_attractor.safetensors -python tools/test_attractor.py # forward eval, whole-network relaxation, factoring, SAT +python src/automata.py build-attractor # compile a multiplier to variants/neural_attractor.safetensors +python src/automata.py attractor # forward eval, whole-network relaxation, factoring, SAT ``` --- @@ -549,9 +568,9 @@ Four checks establish the equality: closing the loop across two generations. ```bash -python src/constructor8.py build # emit variants/neural_subleq8io.safetensors -python src/constructor8.py verify # host soundness + codec round-trip + constructor on all 3 backends -python src/constructor8.py self # full self-reproduction on the threshold matrices, then gen-2 boot +python src/host.py constructor8 build # emit variants/neural_subleq8io.safetensors +python src/host.py constructor8 verify # host soundness + codec round-trip + constructor on all 3 backends +python src/host.py constructor8 self # full self-reproduction on the threshold matrices, then gen-2 boot ``` A program running on the processor emits the exact tensor file that defines @@ -562,12 +581,12 @@ weights and checked to the byte. `paper/` states and proves what this machine does, for a host serialized one unit at a time: `variants/neural_subleq8io_netlist.safetensors` stores the bias of every unit and every edge of the wiring, 17,598 units and 51,132 edges in -160,240 bytes, in the order `src/host_family.py` builds them, so the network the +160,240 bytes, in the order `src/host.py` builds them, so the network the file denotes is the network the paper analyses, and parsing the file rebuilds it. That host is the member with eight-bit words of a family whose step has depth 13 for every word width, with a carry-lookahead subtractor and the -operands fetched through the decode lines of the counter; `src/host_family.py` -builds any member and `src/check_host_family.py` checks each against SUBLEQ +operands fetched through the decode lines of the counter; `src/host.py` +builds any member and `python src/paper.py host-family` checks each against SUBLEQ on the evaluator in C. Separating construction from copying as von Neumann did, a 27-instruction program emits the serialization of the host, its initial memory and its own description tape; each generation is @@ -575,9 +594,9 @@ instantiated from the bytes the last one emitted, by parsing that output, rebuilding the netlist from its first field, and building the evaluator from that netlist. It also shows where the restriction to {-1, 0, 1} is paid for, and that a fixed ternary network, the two-head machine of -`src/twohead_net.py`, holds a stored network in its state and evaluates one +`src/twohead.py`, holds a stored network in its state and evaluates one record per step, which `rocq/` proves for every window of 2^α cells, bank and -tape. The host itself, compiled by `src/stored_host.py` into 36,074 records of +tape. The host itself, compiled by `src/twohead.py` into 36,074 records of two inputs with records that load its memory, its tape device and a hand-over, and stored in the member with 4096 cells, runs the 27-instruction program on a recipe for its own records: it writes them into the idle bank, copies the recipe @@ -587,20 +606,16 @@ by the dynamics alone; `src/stored_host_run.c` runs three generations of cells does the same with a description code of its own, every generation of it proved in Rocq. Its robustness section gives the margin every orbit has against perturbation, a certificate of the leakage an orbit tolerates from the -inputs active along it, and the amplified host of `src/redundant.py`: every row +inputs active along it, and the amplified host of `src/noise.py`: every row of the levelized host replaced by r rows reading all r copies of its inputs, which multiplies every margin by r, reproduces itself exactly, fails only when two wrong copies meet, with a probability of second order that the paper bounds, and completes the self-reproducing run under read noise at levels where the host fails within its first bytes. -The scripts that produce every figure are `src/selfrep.py`, `src/netlist_io.py`, `src/host_family.py`, -`src/paper_stats.py`, `src/paper_verify.py`, `src/paper_runs.py`, -`src/twohead.py`, `src/twohead_net.py`, `src/stored_host.py`, -`src/stored_host_run.c`, `src/check_c.py`, `src/throughput.py`, `src/noise.py`, -`src/redundant.py`, `src/neuromorphic.py` and the -`src/check_*.py` set, and their records are in `paper/runs/`; `rocq/` holds the -Rocq development, which `src/check_rocq.py` builds. +Every figure of the paper comes from `src/paper.py`, `src/host.py`, `src/twohead.py` and +`src/noise.py`, with the C programs beside them, and their records are in `paper/runs/`; +`rocq/` holds the Rocq development, which `python src/paper.py rocq` builds. --- @@ -707,11 +722,11 @@ the Heaviside AND/XOR weights that realize each gate, so the file holds both the reversible program and the threshold substrate it runs on. ```bash -python src/reversible.py # reversible gates, Cuccaro ALU, Bennett construction -python src/reversible_cpu.py # bijective transition and backward execution -python src/reversible_prog.py # structured reversible programs (multiply, Fibonacci, Janus IF) -python tools/build_reversible.py # ship + round-trip variants/neural_reversible.safetensors -python tools/reversible_matrix.py # ternary matrix stack: permutation transition + 0.5 margin +python src/automata.py reversible # reversible gates, Cuccaro ALU, Bennett construction +python src/automata.py reversible-cpu # bijective transition and backward execution +python src/automata.py reversible-prog # structured reversible programs (multiply, Fibonacci, Janus IF) +python src/automata.py build-reversible # ship + round-trip variants/neural_reversible.safetensors +python src/automata.py reversible-matrix# ternary matrix stack: permutation transition + 0.5 margin ``` --- @@ -747,8 +762,8 @@ circuits. With mirror routing and a constant-particle source the construction is functionally complete (Margolus 1984). ```bash -python src/ca.py # rule bijection, lattice reversibility, ballistic motion, interaction gate -python tools/build_ca.py # ship the block rule as a ternary matrix tile (permutation + 0.5 margin) +python src/automata.py ca # rule bijection, lattice reversibility, ballistic motion, interaction gate +python src/automata.py build-ca # ship the block rule as a ternary matrix tile (permutation + 0.5 margin) ``` --- @@ -780,8 +795,8 @@ bias (`-tau`); reloading it regrows the counter and reproduces the binding decision from the gate. ```bash -python src/tile.py # binding gate, XOR/AND/OR rule tiles, binary counter -python tools/build_tile.py # ship + regrow variants/neural_tile.safetensors +python src/automata.py tile # binding gate, XOR/AND/OR rule tiles, binary counter +python src/automata.py build-tile # ship + regrow variants/neural_tile.safetensors ``` --- @@ -947,9 +962,9 @@ Alternative architectures: `PositionExtractor` (~1.5 M, position-specific, no at ### Training ```bash -python train.py --mode router --epochs 100 # sanity check -python train.py --mode llm --epochs 100 --batch_size 256 # frozen LLM -python train.py --mode llm --unfreeze_layers 4 --batch_size 4096 # fine-tune top layers +python llm_integration/llm.py train --mode router --epochs 100 # the router alone +python llm_integration/llm.py train --mode llm --epochs 100 --batch_size 256 # frozen LLM +python llm_integration/llm.py train --mode llm --unfreeze_layers 4 --batch_size 4096 # fine-tune top layers ``` Loss components: BCE on output bits, BCE on extracted A and B bits (2× weight), and CE on operation classification. Curriculum runs 0–9 → 0–99 → 0–255. Optimizer is AdamW, lr 3e-4, gradient clipping 1.0. @@ -966,89 +981,42 @@ variants/ 18 fitness variants + 9 standalone machines neural_subleq8io, neural_reflect, neural_attractor, neural_reversible, neural_ca, neural_tile) -src/ the library (run scripts as `python src/.py`) - ├── build.py generator (one safetensors per invocation; also `subleq`, `rv32`) - ├── quantize.py min integer dtypes + ternary verification/repair - ├── eval.py gate-level fitness suite, NetlistEvaluator, float oracles, reference CPU - ├── eval_all.py variant-agnostic harness + manifest-sized threshold CPU - ├── machines.py neural_subleq8 + neural_rv32 runtimes, references, assemblers, - │ RV32 object loader, and the test suites (subleq / rv32 / rv32-c) - ├── matrix8.py compiles the CPU to a recurrent ternary matrix stack; the - │ exhaustive equality suite and the analog crossbar simulation - ├── constructor8.py the neural_subleq8io host, the recipe codec, and the universal - │ constructor / self-reproduction suite - ├── reflect.py neural_reflect: a universal threshold interpreter whose state - │ holds its own gate table; universality / quine / matrix suites - ├── attractor.py neural_attractor: energy-based solver (forward eval, inversion, - │ SAT) built from a QUBO gate-gadget compiler - ├── reversible.py neural_reversible: reversible threshold gates, Cuccaro ALU, - │ Bennett construction - ├── reversible_cpu.py reversible register machine: bijective step, backward execution - ├── reversible_prog.py structured reversible programs (multiply, Fibonacci, Janus IF) - ├── ca.py neural_ca: reversible Margolus cellular automaton, threshold - │ block rule, lattice reversibility and billiard-ball dynamics - ├── tile.py neural_tile: abstract tile assembly, threshold binding rule, - │ XOR/Sierpinski and binary-counter tile sets - ├── selfrep.py the tape device, the constructor P and the self-reproducing - │ program P*, the framing maps, and three evaluators - ├── canonical.py deterministic safetensors writer (pinned key and tensor order) - ├── paper_stats.py unit counts, levelization sizes, effective fan-in - ├── paper_verify.py codec, framing, serialization, datapath, levelization - ├── paper_runs.py self-description, three generations, lockstep - ├── host_family.py the host family H_A: a constant-depth SUBLEQ step for - │ every word width, and its serialization in build order - ├── netlist_eval.c an evaluator of the host family, the amplified host and the - │ interpreter in C, the levels of a large netlist in parallel - ├── check_c.py the host on the evaluator in C - ├── twohead.py the two-head machine, transcribed, the description code - │ and the translating organism; writes rocq/Organism.v - ├── twohead_net.py the two-head machine as a ternary netlist N_{G,L} for a window - │ of 2^α cells: its check, its file, the organism on it in C, - │ and the comparison with the netlist extracted from rocq/ +src/ the library; `python src/.py ` + ├── build.py the generator (also `subleq`, `rv32`), `quantize`, and + │ `variants`, which builds, quantizes and scores every variant + ├── eval.py the gate-level fitness suite, NetlistEvaluator, float oracles; + │ `variants` scores every variant, `cpu` runs the CPU programs + ├── machines.py the SUBLEQ, RV32 and matrix machines: runtimes, references, + │ assemblers and suites (`subleq`, `rv32`, `rv32-c`, `matrix8`) + ├── reflect.py neural_reflect: a threshold interpreter whose state holds its + │ own gate table; universality, quine and matrix suites + ├── automata.py the attractor, reversible, cellular-automaton and tile machines, + │ their suites and their builders + ├── host.py the SUBLEQ host: its family, serialization, device, the + │ constructor and the self-reproducing program, the evaluators, + │ and `constructor8`, the neural_subleq8io suite + ├── twohead.py the two-head machine: the organism (`organism`), the netlist + │ N_{G,L} (`net`), and the host stored in it (`stored`) + ├── noise.py read noise and weight error on the host (`host`), the amplified + │ host (`amplified`), and the neuromorphic datapath + ├── paper.py the paper's checks and runs, one command each + ├── independent.py code written from the paper's text alone + ├── netlist_eval.c an evaluator of the host family and the amplified host in C ├── twohead_lockstep.c the organism on N_{G,L} beside the transcription, in C - ├── stored_host.py the host compiled into 36,074 records of the two-head machine - │ with 4096 cells: one pass against the host's step and device, - │ the image and its recipe, and its generations ├── stored_host_run.c the stored host's generations on the two-head machine, in C ├── restore_bound.c the restoration bound of the amplified host along the orbit - ├── check_rocq.py builds rocq/ and prints the assumptions of its theorems - ├── independent/ separate code written from the paper's definitions: the host - │ family, the levelization, a noise simulator in C, weight - │ error, and the neuromorphic datapath - ├── redundant.py the amplified host N_r: r-fold redundancy with the restoring - │ organ inside every unit, its self-reproduction, its runs - │ under read noise - ├── noise.py read noise on the self-reproducing run, the deviation curve - │ against the bounds, weight error with zero-entry leakage, the - │ active-input counts that certify an orbit - ├── neuromorphic.py the datapath on the Loihi and Akida neuron models - └── check_*.py codec boundaries and optimality, step counts, ternary depth, - environment cost, device, host, host family, levelization, - framing, serialization, all 29 machines + └── nsim.c the noise simulator of independent.py paper/ the manuscript, and the record of every run it reports rocq/ Rocq proofs: the netlist of the two-head machine computes its step for every window of 2^α cells, the organism builds every image from any description of it and reproduces in every generation -tools/ build_all.py (build + quantize + verify every profile), - cpu_programs.py (assembler + CPU program suite), test_cpu.py - (program suite vs a variant), play.py (interactive demo), - prune_weights.py (GPU-batched weight reduction), - build_attractor.py / test_attractor.py (neural_attractor), - build_reversible.py / reversible_matrix.py (neural_reversible), - build_ca.py (neural_ca matrix tile), build_tile.py (neural_tile) -llm_integration/ SmolLM2 extractor + circuit wrapper + training code - ├── circuits.py FrozenThresholdCircuits (loads safetensors, exposes - │ add_8bit / sub_8bit / mul_8bit / compare_*) - ├── model.py Extractor variants, ArithmeticModel - ├── train.py router / interface / llm training modes - ├── fitness.py randomized fitness function - ├── baseline.py vanilla SmolLM2 baseline measurement +tools/play.py the playground, and a demonstration on each machine +llm_integration/ + ├── llm.py SmolLM2 with the frozen circuits: the circuit wrapper, the + │ fitness, the extractors, training and the baseline ├── trained/ checkpointed extractor weights - └── smollm2/ - ├── SMOLLM2_ARCHITECTURE.md architecture analysis - ├── analyze_smollm2.py analysis script - └── smollm2_analysis.json analysis output + └── smollm2/ the architecture of SmolLM2 and its analysis ``` --- diff --git a/demos/README.md b/demos/README.md deleted file mode 100644 index 6877f081ebd821dde0ead5be7969d4b893440edc..0000000000000000000000000000000000000000 --- a/demos/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# demos - -Standalone programs that put each machine in the family to work on a named -task. Every script is self-contained (`python demos/.py`), loads the -shipped weights, and cross-checks its result against an independent reference. - -| Demo | Machine | What it does | -|------|---------|--------------| -| `neural_computer8_self_modifying_sieve.py` | `neural_computer8` | Sieve of Eratosthenes; with no indexed addressing, the program rewrites the address bytes of its own LOAD/STORE. Finds all 54 primes < 256. | -| `neural_computer8_euclid_gcd.py` | `neural_computer8` | Euclid's GCD, with `a mod b` built from the DIV/MUL/SUB opcodes and an XOR register swap. | -| `neural_computer8_collatz.py` | `neural_computer8` | Collatz step counts; exact for every seed whose trajectory stays inside the 8-bit range. | -| `neural_rv32_machin_pi.py` | `neural_rv32` | pi to nine digits by Machin's 1706 arctangent formula, printed to the console. | -| `neural_rv32_neural_nets_via_neur.py` | `neural_rv32` | Ternary neural nets run as NEUR software: one learned by gradient descent, one (XOR) compiled by construction. | -| `neural_matrix8_gpu_cpu_fleet.py` | `neural_matrix8` | 65,536 CPUs stepped in lockstep on the GPU as one batched matrix product. | -| `neural_attractor_factoring.py` | `neural_attractor` | Factors semiprimes by relaxing an 8x8 multiplier's energy backward; also divides. | -| `neural_attractor_nqueens.py` | `neural_attractor` | Solves 6- and 8-queens by relaxing toward zero unsatisfied clauses. | -| `neural_subleq8io_universal_constructor.py` | `neural_subleq8io` | The universal constructor fabricates a sibling machine byte-for-byte, which then boots. | -| `neural_reflect_self_modifying_stack.py` | `neural_reflect` | Self-modifying SUBLEQ code, on a stored machine, on the fixed interpreter: three levels in one tensor. | -| `neural_reversible_counterfactual.py` | `neural_reversible` | Bijective mixing, exact reverse recovery, and the counterfactual when a digest bit is flipped. | -| `neural_ca_loschmidt_echo.py` | `neural_ca` | A ~2,000-particle gas mixed 500 steps and un-mixed exactly; one flipped cell corrupts half the past. | -| `neural_ca_reversible_cipher.py` | `neural_ca` | The reversible automaton as a block cipher: exact decryption with the right key, noise with a wrong one. | -| `neural_tile_pascal_lucas.py` | `neural_tile` | Self-assembly grows Pascal's triangle mod 2; every cell verified against Lucas' theorem. | diff --git a/demos/neural_attractor_factoring.py b/demos/neural_attractor_factoring.py deleted file mode 100644 index 5fb1b4e25c555a3db38d9f05223e11d669a17c51..0000000000000000000000000000000000000000 --- a/demos/neural_attractor_factoring.py +++ /dev/null @@ -1,55 +0,0 @@ -"""neural_attractor -- factoring semiprimes by running a multiplier backward. - -An 8x8 array multiplier compiles to a quadratic energy that is 0 exactly on its -truth table. Clamp the 16 product bits and relax the 16 input bits and the -network settles into a factorization: multiplication run in reverse. Clamp one -input as well and the same circuit performs division. - - python demos/neural_attractor_factoring.py -""" -import os, sys, time -HERE = os.path.dirname(os.path.abspath(__file__)) -REPO = os.path.dirname(HERE) -sys.path.insert(0, os.path.join(REPO, "src")) -from attractor import multiplier - - -if __name__ == "__main__": - c, io = multiplier(8) - print("neural_attractor: factoring by energy relaxation") - print("=" * 56) - print(f"8x8 array multiplier as an energy landscape: {len(c.gates)} gate " - f"gadgets, {c.n} wires, 16 free input bits\n") - - ladder = [(3599, 59, 61), (9797, 97, 101), (32399, 179, 181), (57599, 239, 241)] - for N, pa, pb in ladder: - target = {io["prod"][k]: (N >> k) & 1 for k in range(16)} - t0, got = time.perf_counter(), None - for attempt in range(1, 8): - s = c.solve(io["xs"] + io["ys"], {io["zero"]: 0}, target, - sweeps=2500, restarts=12, seed=N * 7 + attempt) - if s is not None: - x = sum(s[io["xs"][k]] << k for k in range(8)) - y = sum(s[io["ys"][k]] << k for k in range(8)) - if x * y == N and x > 1 and y > 1: - got = (x, y, attempt); break - dt = time.perf_counter() - t0 - if got: - x, y, att = got - tag = "the prime pair" if {x, y} == {pa, pb} else "valid factorization" - print(f" {N:5d} = {x:3d} x {y:3d} ({dt:4.1f}s, attempt {att}) [{tag}]") - else: - print(f" {N:5d}: ground state not reached in budget ({dt:.1f}s)") - - # division: clamp one input and the product, relax the other input - N, xa = 57599, 241 - fixed = {io["zero"]: 0} - for k in range(8): - fixed[io["xs"][k]] = (xa >> k) & 1 - target = {io["prod"][k]: (N >> k) & 1 for k in range(16)} - t0 = time.perf_counter() - s = c.solve(io["ys"], fixed, target, sweeps=800, restarts=10, seed=7) - if s is not None: - y = sum(s[io["ys"][k]] << k for k in range(8)) - print(f"\n division mode: clamp x=241 and the product -> y = {y} " - f"({time.perf_counter()-t0:.1f}s; {xa} x {y} = {xa * y})") diff --git a/demos/neural_attractor_nqueens.py b/demos/neural_attractor_nqueens.py deleted file mode 100644 index 5c4906796e1272095d8fe14f6c43020c2391816c..0000000000000000000000000000000000000000 --- a/demos/neural_attractor_nqueens.py +++ /dev/null @@ -1,90 +0,0 @@ -"""neural_attractor -- the N-queens problem solved by energy relaxation. - -The attractor computer has no clock and no program counter: a circuit compiles -to a quadratic energy whose global minimum is its consistent assignment, and -"running" it is rolling downhill. Here every N-queens constraint (one queen per -row, no two sharing a column or diagonal) becomes a clause wire that must be 1; -the annealer relaxes the board variables toward zero unsatisfied clauses. This -is the min-conflicts / MAX-SAT objective, expressed entirely in the family's -Heaviside threshold gadgets. - - python demos/neural_attractor_nqueens.py -""" -import os, sys, time -HERE = os.path.dirname(os.path.abspath(__file__)) -REPO = os.path.dirname(HERE) -sys.path.insert(0, os.path.join(REPO, "src")) -from attractor import Circuit - - -def queens_circuit(N): - """Build the N-queens CNF as a threshold circuit; return every clause wire - so the solver can drive them all to 1 (a graded, per-clause objective).""" - c = Circuit() - v = {(r, col): c.wire() for r in range(N) for col in range(N)} - clauses = [] - - def OR_lits(lits): - acc = lits[0] - for w in lits[1:]: - acc = c.OR(acc, w) - return acc - - for r in range(N): # each row: >= 1 queen - clauses.append(OR_lits([v[(r, col)] for col in range(N)])) - for c1 in range(N): # each row: <= 1 queen - for c2 in range(c1 + 1, N): - clauses.append(OR_lits([c.NOT(v[(r, c1)]), c.NOT(v[(r, c2)])])) - for col in range(N): # each column: <= 1 - for r1 in range(N): - for r2 in range(r1 + 1, N): - clauses.append(OR_lits([c.NOT(v[(r1, col)]), c.NOT(v[(r2, col)])])) - cells = [(r, col) for r in range(N) for col in range(N)] - for i in range(len(cells)): # each diagonal: <= 1 - for j in range(i + 1, len(cells)): - (r1, c1), (r2, c2) = cells[i], cells[j] - if r1 - c1 == r2 - c2 or r1 + c1 == r2 + c2: - clauses.append(OR_lits([c.NOT(v[(r1, c1)]), c.NOT(v[(r2, c2)])])) - return c, v, clauses - - -def valid(board, N): - qs = [rc for rc, b in board.items() if b] - if len(qs) != N: - return False - for i in range(len(qs)): - for j in range(i + 1, len(qs)): - (r1, c1), (r2, c2) = qs[i], qs[j] - if r1 == r2 or c1 == c2 or abs(r1 - r2) == abs(c1 - c2): - return False - return True - - -def solve_board(N, seeds=8): - c, v, clauses = queens_circuit(N) - free = list(v.values()) - target = {w: 1 for w in clauses} - t0 = time.perf_counter() - for seed in range(seeds): - s = c.solve(free, {}, target, sweeps=6000, restarts=6, seed=seed) - if s is not None: - board = {rc: s[w] for rc, w in v.items()} - if valid(board, N): - return board, len(clauses), c.n, time.perf_counter() - t0 - return None, len(clauses), c.n, time.perf_counter() - t0 - - -if __name__ == "__main__": - print("neural_attractor: N-queens as energy relaxation") - print("=" * 56) - for N in (6, 8): - board, nclauses, nwires, dt = solve_board(N) - status = "SOLVED" if board else "no solution reached in budget" - print(f"\n{N}-queens: {nclauses} clause gadgets over {nwires} wires -> " - f"{status} ({dt:.1f}s)") - if board: - for r in range(N): - print(" " + " ".join("Q" if board[(r, col)] else "." for col in range(N))) - print(f" verified: {N} queens, no shared row/column/diagonal") - print("\nNo search loop was written: the constraints are an energy surface and") - print("the solution is its floor. The queens fall into place.") diff --git a/demos/neural_ca_loschmidt_echo.py b/demos/neural_ca_loschmidt_echo.py deleted file mode 100644 index b863a076fe7be2f4bad33f3a6ce542dace80595e..0000000000000000000000000000000000000000 --- a/demos/neural_ca_loschmidt_echo.py +++ /dev/null @@ -1,55 +0,0 @@ -"""neural_ca -- a Loschmidt echo in a reversible cellular automaton. - -The Margolus block rule is a bijection, so a gas of ~2,000 particles mixed for -500 steps can be un-mixed by iterating the same rule backward: the initial -configuration returns cell-for-cell (particle number conserved throughout). Yet -flipping a single cell of the mixed state before reversing corrupts roughly half -the reconstructed past -- exact reversibility and sensitive dependence in the -same automaton. - - python demos/neural_ca_loschmidt_echo.py -""" -import os, sys, time, random, statistics -HERE = os.path.dirname(os.path.abspath(__file__)) -REPO = os.path.dirname(HERE) -sys.path.insert(0, os.path.join(REPO, "src")) -import ca - - -def coarse(g, H, W, k=8): - out = [] - for by in range(0, H, k): - for bx in range(0, W, k): - out.append(sum(g[y][x] for y in range(by, by + k) for x in range(bx, bx + k))) - return out - - -if __name__ == "__main__": - H = W = 64 - rng = random.Random(2026) - grid = [[1 if rng.random() < 0.5 else 0 for _ in range(W)] for _ in range(H)] - n0 = sum(map(sum, grid)) - STEPS = 500 - - print("neural_ca: Loschmidt echo (mix, then run time backward)") - print("=" * 56) - t0 = time.perf_counter() - fwd = ca.run(grid, STEPS, 0) - n1 = sum(map(sum, fwd)) - back = ca.run_back(fwd, STEPS, 0) - echo = back == grid - dt = time.perf_counter() - t0 - - print(f"particles: {n0} at t=0, {n1} at t={STEPS} " - f"({'conserved' if n0 == n1 else 'NOT CONSERVED'})") - print(f"coarse 8x8 occupancy stdev: t=0 {statistics.pstdev(coarse(grid, H, W)):.2f} " - f"-> t={STEPS} {statistics.pstdev(coarse(fwd, H, W)):.2f} (mixed)") - print(f"{STEPS} steps forward + {STEPS} reversed in {dt:.1f}s: " - f"t=0 recovered {'EXACTLY' if echo else 'FAILED'}") - - flip = [row[:] for row in fwd] - flip[0][0] ^= 1 - back2 = ca.run_back(flip, STEPS, 0) - ham = sum(back2[y][x] != grid[y][x] for y in range(H) for x in range(W)) - print(f"butterfly: flip ONE cell at t={STEPS}, reverse again -> reconstructed " - f"past wrong in {ham}/{H * W} cells") diff --git a/demos/neural_ca_reversible_cipher.py b/demos/neural_ca_reversible_cipher.py deleted file mode 100644 index 342c58ee8436eb66ec6864e6955b2490448a8d7e..0000000000000000000000000000000000000000 --- a/demos/neural_ca_reversible_cipher.py +++ /dev/null @@ -1,84 +0,0 @@ -"""neural_ca -- a reversible cellular automaton as a perfect cipher. - -The Margolus block rule is a bijection of the lattice, so iterating it forward -diffuses a bitmap into noise and iterating the exact same rule backward restores -it bit-for-bit. That makes the automaton a block cipher whose key is the pair -(number of steps, starting partition phase): the right key inverts the diffusion -perfectly, and a key off by a single step returns noise. No information is ever -destroyed, so decryption is exact rather than approximate. - - python demos/neural_ca_reversible_cipher.py -""" -import os, sys -HERE = os.path.dirname(os.path.abspath(__file__)) -REPO = os.path.dirname(HERE) -sys.path.insert(0, os.path.join(REPO, "src")) -import ca - -# a recognizable plaintext bitmap (a heart), padded into a larger lattice -ART = [ - " ### ### ", - " ##### ##### ", - "#############", - "#############", - "#############", - " ########### ", - " ######### ", - " ####### ", - " ##### ", - " ### ", - " # ", -] -PAD_X, PAD_Y = 6, 4 - - -def make_grid(): - # the Margolus partition tiles 2x2 blocks toroidally, so H and W must be even - h = len(ART) + 2 * PAD_Y - w = len(ART[0]) + 2 * PAD_X - h += h & 1 - w += w & 1 - g = [[0] * w for _ in range(h)] - for y, row in enumerate(ART): - for x, ch in enumerate(row): - if ch == "#": - g[y + PAD_Y][x + PAD_X] = 1 - return g - - -def render(g): - return "\n".join("".join("#" if c else "." for c in row) for row in g) - - -def hamming(a, b): - return sum(a[y][x] != b[y][x] for y in range(len(a)) for x in range(len(a[0]))) - - -if __name__ == "__main__": - plain = make_grid() - KEY_STEPS, KEY_PHASE = 200, 0 - - cipher = ca.run(plain, KEY_STEPS, KEY_PHASE) - recovered = ca.run_back(cipher, KEY_STEPS, KEY_PHASE) - wrong = ca.run_back(cipher, KEY_STEPS - 1, KEY_PHASE) # key off by one step - - n = sum(map(sum, plain)) - print("neural_ca: reversible-automaton cipher") - print("=" * 46) - print(f"key = ({KEY_STEPS} steps, phase {KEY_PHASE}); {n} set bits (conserved " - f"throughout: {sum(map(sum, cipher)) == n})\n") - - print("PLAINTEXT:") - print(render(plain)) - print(f"\nCIPHERTEXT after {KEY_STEPS} forward steps (diffused to noise):") - print(render(cipher)) - print(f"\nDECRYPTED with the correct key:") - print(render(recovered)) - print(f" exact recovery: {recovered == plain} (Hamming distance 0)") - print(f"\nDECRYPTED with a wrong key (off by one step):") - print(render(wrong)) - print(f" still scrambled: {hamming(wrong, plain)} of {len(plain)*len(plain[0])} " - f"cells differ from the plaintext") - print("\nEvery step is a bijection, so the ciphertext holds exactly the") - print("information of the plaintext -- no more, no less. Only the exact") - print("reverse trajectory recovers it.") diff --git a/demos/neural_computer8_collatz.py b/demos/neural_computer8_collatz.py deleted file mode 100644 index 4d120fa1772647f3a57f92857d496e3288b4550f..0000000000000000000000000000000000000000 --- a/demos/neural_computer8_collatz.py +++ /dev/null @@ -1,76 +0,0 @@ -"""neural_computer8 -- the Collatz map iterated on the threshold CPU. - -n -> n/2 if even, 3n+1 if odd, counting steps to 1. The parity test is a -bitwise AND with 1 that leaves the flags untouched, so the loop branches on the -comparison that precedes it. The register file is architecturally 8-bit, so the -machine computes the Collatz trajectory modulo 256; for seeds whose true -trajectory never exceeds 255, that is the exact Collatz step count, checked here -against a native reference. - - python demos/neural_computer8_collatz.py -""" -import os, sys, time -HERE = os.path.dirname(os.path.abspath(__file__)) -REPO = os.path.dirname(HERE) -sys.path.insert(0, os.path.join(REPO, "src")) -sys.path.insert(0, os.path.join(REPO, "tools")) -from safetensors import safe_open -from cpu_programs import Asm -from eval_all import GenericThresholdCPU, get_manifest - - -def build(n0): - a = Asm(1024) - a.load(0, "N"); a.load(1, "ONE"); a.xor(2, 2) # R0=n, R1=1, R2=steps - a.label("loop") - a.cmp(0, 1); a.jz("done") # n == 1 ? - a.xor(3, 3); a.add(3, 0); a.and_(3, 1) # R3 = n & 1 (flags preserved) - a.cmp(3, 1); a.jz("odd") - a.shr(0); a.jmp("step") # even: n >>= 1 - a.label("odd") - a.xor(3, 3); a.add(3, 0); a.shl(3) # R3 = 2n - a.add(0, 3); a.add(0, 1) # n = 3n + 1 - a.label("step") - a.add(2, 1); a.jmp("loop") # steps++ - a.label("done") - a.store(2, "RES"); a.halt() - a.org(0x300); a.label("N"); a.db(n0) - a.label("ONE"); a.db(1); a.label("RES"); a.db(0) - return a, a.assemble() - - -def true_collatz(n): - peak, steps = n, 0 - while n != 1: - n = n // 2 if n % 2 == 0 else 3 * n + 1 - peak = max(peak, n); steps += 1 - return steps, peak - - -if __name__ == "__main__": - tens = {} - with safe_open(os.path.join(REPO, "variants", "neural_computer8_small.safetensors"), - framework="pt") as f: - for name in f.keys(): - tens[name] = f.get_tensor(name).float() - cpu = GenericThresholdCPU(tens) - addr = get_manifest(tens)["addr_bits"] - - print("neural_computer8: Collatz step counts through the gates") - print("=" * 56) - print(" seed CPU steps true steps peak in 8-bit range?") - for n0 in (6, 7, 9, 18, 25, 45): - a, mem = build(n0) - state = {"pc": 0, "regs": [0] * 4, "flags": [0] * 4, "mem": list(mem), - "halted": False, "sp": (1 << addr) - 1} - t0 = time.perf_counter() - final, cycles = cpu.run(state, max_cycles=3000) - steps = final["mem"][a.labels["RES"]] - tsteps, peak = true_collatz(n0) - inrange = peak <= 255 - match = "MATCH" if (inrange and steps == tsteps) else \ - ("mod-256" if not inrange else "MISMATCH") - print(f" {n0:4d} {steps:9d} {tsteps:10d} {peak:4d} " - f"{'yes' if inrange else 'no (wraps)':10s} [{match}]") - print("\n For every seed whose trajectory stays under 256 the threshold CPU") - print(" reproduces the exact Collatz step count.") diff --git a/demos/neural_computer8_euclid_gcd.py b/demos/neural_computer8_euclid_gcd.py deleted file mode 100644 index 2c7dc3e92ee6d9ee2327b3dca99f7babdc141311..0000000000000000000000000000000000000000 --- a/demos/neural_computer8_euclid_gcd.py +++ /dev/null @@ -1,63 +0,0 @@ -"""neural_computer8 -- Euclid's algorithm on the threshold CPU. - -The greatest common divisor by repeated remainder, where the remainder itself -is synthesized from the machine's own opcodes: a mod b = a - (a / b) * b, using -the DIV, MUL and SUB gates, and the register swap is three XORs (no temporary). -Every step runs through threshold neurons. - - python demos/neural_computer8_euclid_gcd.py -""" -import os, sys, time, math -HERE = os.path.dirname(os.path.abspath(__file__)) -REPO = os.path.dirname(HERE) -sys.path.insert(0, os.path.join(REPO, "src")) -sys.path.insert(0, os.path.join(REPO, "tools")) -from safetensors import safe_open -from cpu_programs import Asm -from eval_all import GenericThresholdCPU, get_manifest - - -def build(a0, b0): - g = Asm(1024) - g.load(0, "A") # R0 = a - g.load(1, "B") # R1 = b - g.xor(3, 3) # R3 = 0 - g.label("loop") - g.cmp(1, 3); g.jz("done") # while b != 0 - g.xor(2, 2); g.add(2, 0) # R2 = a - g._alu(0x8, 2, 1) # R2 = a / b (DIV opcode) - g.mul(2, 1) # R2 = (a/b)*b - g.sub(0, 2) # R0 = a mod b - g.xor(0, 1); g.xor(1, 0); g.xor(0, 1) # swap -> (b, a mod b) - g.jmp("loop") - g.label("done") - g.store(0, "RES"); g.halt() - g.org(0x300) - g.label("A"); g.db(a0) - g.label("B"); g.db(b0) - g.label("RES"); g.db(0) - return g, g.assemble() - - -if __name__ == "__main__": - tens = {} - with safe_open(os.path.join(REPO, "variants", "neural_computer8_small.safetensors"), - framework="pt") as f: - for name in f.keys(): - tens[name] = f.get_tensor(name).float() - cpu = GenericThresholdCPU(tens) - addr = get_manifest(tens)["addr_bits"] - - print("neural_computer8: Euclid's GCD through the gates") - print("=" * 56) - for a0, b0 in [(84, 36), (120, 90), (221, 34), (255, 128)]: - g, mem = build(a0, b0) - state = {"pc": 0, "regs": [0] * 4, "flags": [0] * 4, "mem": list(mem), - "halted": False, "sp": (1 << addr) - 1} - t0 = time.perf_counter() - final, cycles = cpu.run(state, max_cycles=400) - res = final["mem"][g.labels["RES"]] - ok = res == math.gcd(a0, b0) - print(f" gcd({a0:3d}, {b0:3d}) = {res:3d} ({cycles:3d} cycles, " - f"{time.perf_counter()-t0:.1f}s) " - f"[{'MATCH' if ok else 'MISMATCH vs ' + str(math.gcd(a0, b0))}]") diff --git a/demos/neural_computer8_self_modifying_sieve.py b/demos/neural_computer8_self_modifying_sieve.py deleted file mode 100644 index 1ffb2b74b5d05446bb4978bcd7c0fd4ff8134117..0000000000000000000000000000000000000000 --- a/demos/neural_computer8_self_modifying_sieve.py +++ /dev/null @@ -1,76 +0,0 @@ -"""neural_computer8 -- the Sieve of Eratosthenes with self-modifying code. - -The threshold CPU's ISA has no indexed addressing, so the program does exactly -what memory-constrained 1970s code did: it rewrites the address bytes of its own -LOAD and STORE instructions before each access, walking a pointer across the -flags array. Every gate that fetches, decodes, marks, and patches is a threshold -neuron. It halts with all 54 primes below 256 marked. - - python demos/neural_computer8_self_modifying_sieve.py -""" -import os, sys, time -HERE = os.path.dirname(os.path.abspath(__file__)) -REPO = os.path.dirname(HERE) -sys.path.insert(0, os.path.join(REPO, "src")) -sys.path.insert(0, os.path.join(REPO, "tools")) -from safetensors import safe_open -from cpu_programs import Asm, _enc -from eval_all import GenericThresholdCPU, get_manifest - -FLAGS = 0x300 # flags[0..255] at 0x300..0x3FF - - -def build(): - g = Asm(1024) - g.load(3, "ONE") # R3 = 1 (mark value / increment) - g.load(0, "TWO") # R0 = p = 2 - g.label("outer") - g.store(0, "Rlo") # patch probe address low byte <- p - g.dw(_enc(0xA, 2, 0)) # probe: R2 = flags[p] (self-modified) - g.db(FLAGS >> 8); g.label("Rlo"); g.db(0x00) - g.cmp(2, 3); g.jz("next_p") # flags[p] == 1 -> composite, skip - g.xor(1, 1); g.add(1, 0); g.add(1, 0) # m = 2p - g.label("inner") - g.store(1, "Wlo") # patch mark address low byte <- m - g.dw(_enc(0xB, 0, 3)) # mark: flags[m] = R3 = 1 (self-modified) - g.db(FLAGS >> 8); g.label("Wlo"); g.db(0x00) - g.add(1, 0) # m += p (carry iff we passed 255) - g.jnc("inner") - g.label("next_p") - g.add(0, 3) # p += 1 - g.load(2, "SIXTEEN") - g.cmp(0, 2) - g.jnz("outer") # loop until p == 16 (16^2 > 255) - g.halt() - g.org(0x200) - g.label("ONE"); g.db(1) - g.label("TWO"); g.db(2) - g.label("SIXTEEN"); g.db(16) - return g, g.assemble() - - -if __name__ == "__main__": - g, mem = build() - tens = {} - with safe_open(os.path.join(REPO, "variants", "neural_computer8_small.safetensors"), - framework="pt") as f: - for name in f.keys(): - tens[name] = f.get_tensor(name).float() - cpu = GenericThresholdCPU(tens) - state = {"pc": 0, "regs": [0] * 4, "flags": [0] * 4, "mem": list(mem), - "halted": False, "sp": (1 << get_manifest(tens)["addr_bits"]) - 1} - t0 = time.perf_counter() - final, cycles = cpu.run(state, max_cycles=4000) - dt = time.perf_counter() - t0 - - got = [n for n in range(2, 256) if final["mem"][FLAGS + n] == 0] - ref = [n for n in range(2, 256) if all(n % d for d in range(2, int(n**0.5) + 1))] - print("neural_computer8: self-modifying Sieve of Eratosthenes") - print("=" * 56) - print(f"halted={final['halted']} {cycles} cycles through the gates ({dt:.0f}s)") - print(f"primes < 256 found: {len(got)} native sieve: {len(ref)} " - f"{'EXACT MATCH' if got == ref else 'MISMATCH'}") - print(" " + " ".join(map(str, got))) - print(f"self-modified operand bytes at halt: probe={final['mem'][g.labels['Rlo']]}, " - f"mark={final['mem'][g.labels['Wlo']]} (the program rewrote its own " - f"instruction stream as it ran)") diff --git a/demos/neural_matrix8_gpu_cpu_fleet.py b/demos/neural_matrix8_gpu_cpu_fleet.py deleted file mode 100644 index 242e682c388a97baf26f6a2f70e739b84591f0c1..0000000000000000000000000000000000000000 --- a/demos/neural_matrix8_gpu_cpu_fleet.py +++ /dev/null @@ -1,73 +0,0 @@ -"""neural_matrix8 -- a fleet of processors as one batched matrix product. - -The whole CPU is compiled to 108 ternary weight matrices with a Heaviside step -between them, so one clock cycle is one matrix-vector product plus a threshold. -Add a batch dimension and N processors are the same 108 matmuls: here 65,536 -independent CPUs step in lockstep on the GPU. A 256-wide correctness fleet -confirms every CPU halts exactly at cycle 2n+1 for its own countdown input. - - python demos/neural_matrix8_gpu_cpu_fleet.py -""" -import os, sys, time -HERE = os.path.dirname(os.path.abspath(__file__)) -REPO = os.path.dirname(HERE) -sys.path.insert(0, os.path.join(REPO, "src")) -import torch -from matrix8 import MatrixMachine, state_to_vec, _mk_state, _instr - - -def prog_bytes(): - return (_instr(0x1, 0, 1) # SUB R0, R1 - + _instr(0xD, 0, 0, 1) + [0, 0] # JNZ 0x0000 (address word) - + _instr(0xF) # HALT - + [0] * 8) - - -if __name__ == "__main__": - dev = "cuda" if torch.cuda.is_available() else "cpu" - mm = MatrixMachine.from_file(device=dev) - n_neurons = sum(int(b.numel()) for b in mm.B) - name = torch.cuda.get_device_name(0) if dev == "cuda" else "cpu" - print("neural_matrix8: 65,536 CPUs as one batched matrix product") - print("=" * 56) - print(f"loaded {len(mm.W)} ternary matrices, {n_neurons:,} threshold neurons " - f"per transition, device={dev} ({name})") - mem = prog_bytes() - - # correctness fleet: one CPU per 8-bit input, all 256 at once - V = torch.stack([state_to_vec(_mk_state(mem=mem, regs=(x, 1, 0, 0))) - for x in range(256)]).to(dev) - first_halt = torch.full((256,), -1, dtype=torch.long) - step = 0 - while step < 600: - halted = V[:, MatrixMachine.HALT_IDX] > 0.5 - first_halt[(first_halt < 0) & halted.cpu()] = step - if bool(halted.all()): - break - V = mm.step(V); step += 1 - expect = torch.tensor([2 * (x if x else 256) + 1 for x in range(256)]) - ok_halt = bool((first_halt == expect).all()) - ok_r0 = bool((V[:, 4:12].cpu().long().sum(1) == 0).all()) - print(f"\ncorrectness fleet of 256: every CPU halts at cycle 2n+1 for its own " - f"input: {'EXACT' if ok_halt else 'MISMATCH'}; all results R0==0: {ok_r0}") - - # throughput fleet: 65,536 CPUs - B = 65536 - V = torch.stack([state_to_vec(_mk_state(mem=mem, regs=(x & 0xFF, 1, 0, 0))) - for x in range(256)]).repeat(256, 1).to(dev) - if dev == "cuda": - torch.cuda.synchronize() - t0, steps = time.perf_counter(), 0 - while steps < 520: - V = mm.step(V); steps += 1 - if steps % 64 == 0 and bool((V[:, MatrixMachine.HALT_IDX] > 0.5).all()): - break - if dev == "cuda": - torch.cuda.synchronize() - dt = time.perf_counter() - t0 - instr = B * steps - print(f"\nthroughput fleet of {B:,}: {steps} transitions in {dt:.1f}s") - print(f" {instr / dt / 1e6:,.1f} million instructions/s aggregate " - f"({instr:,} instructions retired)") - print(f" {instr * n_neurons / dt / 1e9:,.1f} billion threshold-neuron " - f"evaluations/s") diff --git a/demos/neural_reflect_self_modifying_stack.py b/demos/neural_reflect_self_modifying_stack.py deleted file mode 100644 index 9971195e041e320834da58b2bf2e07abd5b3594a..0000000000000000000000000000000000000000 --- a/demos/neural_reflect_self_modifying_stack.py +++ /dev/null @@ -1,87 +0,0 @@ -"""neural_reflect -- three levels of machine in one tensor. - -Level 0 is U, a fixed ternary threshold interpreter (~24k gates). Level 1 is a -complete SUBLEQ computer stored as data inside U's writable state. Level 2 is a -SUBLEQ program that rewrites its own operand bytes as it runs: a zeroing -instruction that walks itself across memory, erasing a block. The interpreter, -the machine it runs, and the program that machine executes are all the same -tensor; the final memory is checked bit-for-bit against a native SUBLEQ emulator. - - python demos/neural_reflect_self_modifying_stack.py -""" -import os, sys, time -HERE = os.path.dirname(os.path.abspath(__file__)) -REPO = os.path.dirname(HERE) -sys.path.insert(0, os.path.join(REPO, "src")) -import torch -from reflect import (Cfg, build_subleq_machine, build_net, Leveled, - encode_netlist, state_to_vec, subleq32_step, MEM, MEMB) - - -if __name__ == "__main__": - dev = "cuda" if torch.cuda.is_available() else "cpu" - cfg = Cfg(A=14, G=296, banks=1, self_mod=False) - prog, lay = build_subleq_machine(cfg) - inet, ii, io = build_net(cfg) - print("neural_reflect: self-modifying code on a stored machine on U") - print("=" * 60) - print(f"level 0: interpreter U = {len(inet.gates):,} threshold gates (fixed)") - print(f"level 1: a SUBLEQ machine = {len(prog)} gates, stored as writable data") - lev = Leveled(inet, ii, io, device=dev) - nl = encode_netlist(cfg, prog) - sig0 = [0] * cfg.S - sig0[cfg.NET0:cfg.NET0 + len(nl)] = nl - base = state_to_vec(cfg, {"sig": sig0, "gp": 0, "halt": 0}).to(dev) - PC = lay["PC"] - - # level 2: SUBLEQ (A B C: M[B]-=M[A]; if <=0 goto C). The first instruction - # zeroes a target cell, then two instructions increment its own A and B - # operand bytes, so the zeroing walks across a data block. - pm = [0] * 32 - pm[0:3] = [24, 24, 3] # zero M[]; these operands get rewritten - pm[3:6] = [19, 0, 6] # M[0] += 1 (patch own A-field: cell 0) - pm[6:9] = [19, 1, 9] # M[1] += 1 (patch own B-field: cell 1) - pm[9:12] = [20, 21, 15] # count -= 1; if <= 0 branch to epilogue - pm[12:15] = [18, 18, 0] # 0 -> unconditional jump to loop head - pm[15:18] = [18, 18, 31] # epilogue: branch to 31 = HALT - pm[18], pm[19], pm[20], pm[21] = 0, 255, 1, 4 - pm[24:28] = [7, 99, 123, 200] # the block the program will erase - print(f"level 2: a program whose instruction 0 starts as [24,24,3] and " - f"rewrites its own operands") - - ref_mem, ref_pc = list(pm), 0 - while ref_pc != 31: - ref_mem, ref_pc = subleq32_step(ref_mem, ref_pc) - - V = base.unsqueeze(0).clone() - for i in range(32): - for b in range(8): - V[:, MEM + b * MEMB + i] = float((pm[i] >> b) & 1) - - def get_mem(Vc): - return sum(Vc[:, MEM + b * MEMB: MEM + b * MEMB + 32].long() << b - for b in range(8))[0].tolist() - - t0, walk, steps, halted = time.perf_counter(), [], 0, False - for _ in range(26): - m_now = get_mem(V.cpu()) - walk.append((m_now[0], m_now[1])) - for _ in range(cfg.G): - V = lev.step(V) - steps += 1 - if int(V[0, cfg.S + cfg.GPW]) == 1: - halted = True - break - dt = time.perf_counter() - t0 - got = get_mem(V.cpu()) - - mut = " -> ".join(f"[{a},{b}]" for a, b in - [walk[0]] + [w for i, w in enumerate(walk[1:], 1) if w != walk[i - 1]]) - print(f"\nran {steps} hosted instructions in {dt:.0f}s " - f"({steps * cfg.G} interpreter recurrences); halted={halted}") - print(f" instruction 0's operand cells as it ran: {mut}") - print(f" target block M[24..27]: {pm[24:28]} -> {got[24:28]}") - print(f" final memory == native SUBLEQ reference: " - f"{'EXACT' if got == ref_mem else 'MISMATCH'}") - print("\n The program, the machine it runs on, and the machine that runs") - print(" that machine are all the same tensor.") diff --git a/demos/neural_reversible_counterfactual.py b/demos/neural_reversible_counterfactual.py deleted file mode 100644 index 2d62a32592c25ca640dd461523bcb05a82afbd09..0000000000000000000000000000000000000000 --- a/demos/neural_reversible_counterfactual.py +++ /dev/null @@ -1,49 +0,0 @@ -"""neural_reversible -- computation with no erasure, and the counterfactual. - -Every instruction of this register machine is a bijection, so the same weights -run backward invert the forward computation: a mixing program hashes a seed into -a digest, and stepping in reverse reconstructs the seed exactly, with no bit ever -erased (Landauer floor: zero). Because history is only consistent with the truth, -flipping a single bit of the digest and running backward yields a "past" that is -wrong in many bits: information is conserved, so a corrupted future refuses to -invert to the real input. - - python demos/neural_reversible_counterfactual.py -""" -import os, sys -HERE = os.path.dirname(os.path.abspath(__file__)) -REPO = os.path.dirname(HERE) -sys.path.insert(0, os.path.join(REPO, "src")) -from reversible_cpu import RCPU, _clone - - -if __name__ == "__main__": - # cross-coupled mixing: R0 feeds R3 and R3 feeds R0, so the avalanche spreads - prog = [] - for _ in range(8): - prog += [("ADD", 0, 3), ("TOFF", 3, 0, 2), ("ROL", 0, 1), ("XOR", 2, 3)] - cpu = RCPU(prog) - seed = cpu.new_state(regs=[7, 13, 201, 0]) - - print("neural_reversible: no-erasure computing and the counterfactual") - print("=" * 60) - s = _clone(seed) - cpu.run(s, len(prog)) - print(f"seed registers {seed['R']} --{len(prog)} bijective steps--> " - f"digest {s['R']}") - - b = _clone(s) - cpu.run_back(b, len(prog)) - print(f"run backward: seed recovered " - f"{'EXACTLY' if b['R'] == seed['R'] and b['PC'] == seed['PC'] else 'FAILED'} " - f"(no bit was ever erased; Landauer floor 0)") - - c = _clone(s) - c["R"][3] ^= 4 # corrupt one bit of the digest - cpu.run_back(c, len(prog)) - diff = sum(bin(x ^ y).count("1") for x, y in zip(c["R"], seed["R"])) - print(f"counterfactual: flip ONE digest bit, run history backward -> the " - f"reconstructed 'seed' is wrong in {diff}/32 register bits") - print(f" corrupted 'seed' = {c['R']} true seed = {seed['R']}") - print("\n History inverts only when it is the true history. A tampered") - print(" future does not lead back to the real past.") diff --git a/demos/neural_rv32_machin_pi.py b/demos/neural_rv32_machin_pi.py deleted file mode 100644 index bc45e73c823361f8505f53a3320a7ec62bd2af89..0000000000000000000000000000000000000000 --- a/demos/neural_rv32_machin_pi.py +++ /dev/null @@ -1,85 +0,0 @@ -"""neural_rv32 -- pi by Machin's formula, computed on the RISC-V threshold CPU. - -pi = 16*arctan(1/5) - 4*arctan(1/239) (John Machin, 1706), evaluated as an -integer arctangent series at scale 1e8 with rounded divisions. Every add, -multiply and divide runs through the ternary threshold datapath; 239^2 is -computed on-machine, and the result is formatted to the memory-mapped console. - - python demos/neural_rv32_machin_pi.py -""" -import os, sys, time -HERE = os.path.dirname(os.path.abspath(__file__)) -REPO = os.path.dirname(HERE) -sys.path.insert(0, os.path.join(REPO, "src")) -import torch -from machines import Asm, Rv32ThresholdCPU, rv_state -from eval_all import int_to_bits - - -def native(): - """Integer mirror of the exact recurrence the machine will run.""" - accA, t, sign, d, dsA = 0, 1600000000 // 5, 1, 1, [] - while t > 0: - accA += sign * ((t + d // 2) // d); dsA.append(d) - sign, d, t = -sign, d + 2, (t + 12) // 25 - accB, t, q, dsB, sign, d = 0, (400000000 + 119) // 239, 239 * 239, [], 1, 1 - while t > 0: - accB += sign * ((t + d // 2) // d); dsB.append(d) - sign, d, t = -sign, d + 2, (t + q // 2) // q - return accA - accB, dsA, dsB - - -def build(dsA, dsB): - a = Asm() - a.lui(4, 0x5F5E1) # x4 = 1,600,000,000 - a.addi(5, 0, 25); a.addi(6, 0, 10); a.addi(8, 0, 239); a.addi(14, 0, 5) - a.div(11, 4, 14) # t = 16e8/5 - a.addi(10, 0, 0) # accA - for k, d in enumerate(dsA): - a.addi(12, 0, d); a.addi(13, 11, d // 2); a.div(13, 13, 12) - (a.add if k % 2 == 0 else a.sub)(10, 10, 13) - if k + 1 < len(dsA): - a.addi(13, 11, 12); a.div(11, 13, 5) - a.lui(7, 0x17D78); a.ori(7, 7, 0x400) # x7 = 400,000,000 - a.addi(13, 7, 119); a.div(11, 13, 8) - a.mul(9, 8, 8) # 239^2 computed on-machine - a.srli(14, 9, 1) - a.addi(15, 0, 0) # accB - for k, d in enumerate(dsB): - a.addi(12, 0, d); a.addi(13, 11, d // 2); a.div(13, 13, 12) - (a.add if k % 2 == 0 else a.sub)(15, 15, 13) - if k + 1 < len(dsB): - a.add(13, 11, 14); a.div(11, 13, 9) - a.sub(10, 10, 15) # x10 = pi * 1e8 - a.addi(20, 0, 0x7F8); a.slli(20, 20, 5) # console 0xFF00 - a.lui(21, 0x5F5E); a.ori(21, 21, 0x100) # P = 1e8 - a.div(22, 10, 21); a.rem(10, 10, 21) - a.addi(22, 22, 48); a.sb(22, 20, 0) # integer digit - a.addi(22, 0, 46); a.sb(22, 20, 0) # '.' - a.div(21, 21, 6); a.addi(23, 0, 8) - a.label("digits") - a.div(22, 10, 21); a.rem(10, 10, 21) - a.addi(22, 22, 48); a.sb(22, 20, 0) - a.div(21, 21, 6); a.addi(23, 23, -1); a.bne(23, 0, "digits") - a.ecall() - return a.assemble() - - -if __name__ == "__main__": - pi9, dsA, dsB = native() - mem = build(dsA, dsB) - print("neural_rv32: Machin's pi on the threshold datapath") - print("=" * 56) - print("loading neural_rv32 (8.77M ternary parameters)...") - cpu = Rv32ThresholdCPU() - ts = rv_state(mem) - ts["_mem_bits"] = torch.tensor([int_to_bits(b, 8) for b in ts["mem"]], dtype=torch.float32) - t0, cyc = time.perf_counter(), 0 - while not ts["halted"] and cyc < 400: - ts = cpu.step(ts); cyc += 1 - want = f"{pi9 // 10**8}.{pi9 % 10**8:08d}" - print(f"console output : {ts['console']!r} ({cyc} cycles, " - f"{time.perf_counter()-t0:.0f}s, dual-issue pairs {cpu.pairs_issued})") - print(f"integer mirror : {want!r} (machine {'EXACT' if ts['console']==want else 'MISMATCH'})") - print(f"true pi : 3.14159265(35...) -> all printed digits " - f"{'correct' if ts['console']=='3.14159265' else 'NOT all correct'}") diff --git a/demos/neural_rv32_neural_nets_via_neur.py b/demos/neural_rv32_neural_nets_via_neur.py deleted file mode 100644 index 202411b4e37bb7085ac417296da0e772d48f1c22..0000000000000000000000000000000000000000 --- a/demos/neural_rv32_neural_nets_via_neur.py +++ /dev/null @@ -1,184 +0,0 @@ -"""neural_rv32 -- neural networks running as software on the neural-net CPU. - -neural_rv32's NEUR opcode computes exactly one ternary threshold neuron: - rd = H( popcount(rs1 & pos) - popcount(rs1 & neg) + sext5(bias) ) -with weights in {-1,0,+1} and an integer bias. So a program of NEUR -instructions IS a ternary neural network -- and the CPU executing it is itself -a threshold network. One net here is LEARNED by gradient descent (a nonlinear -popcount band) and one is COMPILED by construction (8-bit parity, the function -that ended the single perceptron); both are then deployed as NEUR code and run -on the threshold datapath, with the CPU output checked against the reference net. - - python demos/neural_rv32_neural_nets_via_neur.py -""" -import os, sys, time -HERE = os.path.dirname(os.path.abspath(__file__)) -REPO = os.path.dirname(HERE) -sys.path.insert(0, os.path.join(REPO, "src")) -os.chdir(REPO) -import torch -from machines import Asm, Rv32ThresholdCPU, rv_state -from eval_all import int_to_bits - -torch.manual_seed(0) - - -def ternary(w): # {-1,0,+1} forward, identity backward (STE) - wq = torch.where(w > 0.5, 1.0, torch.where(w < -0.5, -1.0, 0.0)) - return w + (wq - w).detach() - -def ibias(b, lo=-16, hi=15): - bq = b.round().clamp(lo, hi) - return b + (bq - b).detach() - -# ---- discrete NEUR reference (exactly what the hardware computes) ---------- -def neuron(x, pos, neg, bias): - return 1 if (bin(x & pos).count("1") - bin(x & neg).count("1") + bias) >= 0 else 0 - -def discrete_net(x, hidden, out): - h = 0 - for j, (p, n, b) in enumerate(hidden): - h |= neuron(x, p, n, b) << j - p, n, b = out - return neuron(h, p, n, b) - -# ---- train a 2-layer ternary MLP; hidden step trained with an annealed ----- -# sigmoid so real gradients flow, then hardened to Heaviside at extraction -def train(target_fn, H, epochs=5000, seed=0, nbits=8): - torch.manual_seed(seed) - N = 1 << nbits - xs = torch.arange(N) - X = torch.stack([((xs >> i) & 1).float() for i in range(nbits)], 1) - Y = torch.tensor([float(target_fn(int(x))) for x in xs]) - Wh = torch.nn.Parameter(torch.randn(H, nbits) * 1.5) - bh = torch.nn.Parameter(torch.randn(H) * 1.5) - Wo = torch.nn.Parameter(torch.randn(H) * 1.5) - bo = torch.nn.Parameter(torch.zeros(1)) - opt = torch.optim.Adam([Wh, bh, Wo, bo], lr=0.03) - npos = float(Y.sum()) - bce = torch.nn.BCEWithLogitsLoss(pos_weight=torch.tensor((N - npos) / max(npos, 1.0))) - for ep in range(epochs): - opt.zero_grad() - Tt = 1.0 + 11.0 * ep / epochs - preh = X @ ternary(Wh).T + ibias(bh) - h = torch.sigmoid(Tt * preh) - preo = h @ ternary(Wo) + ibias(bo) - loss = bce(4.0 * preo, Y) - loss.backward(); opt.step() - Whq = torch.where(Wh > 0.5, 1, torch.where(Wh < -0.5, -1, 0)).tolist() - Woq = torch.where(Wo > 0.5, 1, torch.where(Wo < -0.5, -1, 0)).tolist() - bhq = bh.round().clamp(-16, 15).long().tolist() - boq = int(bo.round().clamp(-16, 15).item()) - def pack(wrow): - return (sum(1 << i for i, w in enumerate(wrow) if w > 0), - sum(1 << i for i, w in enumerate(wrow) if w < 0)) - hidden = [(*pack(Whq[j]), bhq[j]) for j in range(H)] - out = (*pack(Woq), boq) - acc = sum(discrete_net(int(x), hidden, out) == int(Y[i]) for i, x in enumerate(xs)) / N - return hidden, out, acc - -# ---- emit the network as NEUR code and run it on neural_rv32 --------------- -def li(a, reg, v): - v &= 0xFFFFFFFF - hi = (v + 0x800) >> 12 - lo = v - (hi << 12) - a.lui(reg, hi & 0xFFFFF) - if lo: a.addi(reg, reg, lo) - -def build_program(xval, hidden, out): - a = Asm() - li(a, 1, xval) # x1 = input byte - li(a, 2, 0) # x2 = packed hidden - for j, (p, n, b) in enumerate(hidden): - li(a, 4, p | (n << 8) | ((b & 0x1F) << 16)) # weight word - a.neur(3, 1, 4) # x3 = hidden neuron j - if j: a.slli(2, 2, 1) - a.or_(2, 2, 3) # pack MSB-first (neuron 0 -> bit H-1) - p, n, b = out - H = len(hidden) - def remap(mask): # match the MSB-first hidden packing - r = 0 - for j in range(H): - if (mask >> j) & 1: r |= 1 << (H - 1 - j) - return r - li(a, 4, remap(p) | (remap(n) << 8) | ((b & 0x1F) << 16)) - a.neur(5, 2, 4) # x5 = output - a.ecall() - return a.assemble() - -def run_cpu(cpu, xval, hidden, out): - mem = build_program(xval, hidden, out) - ts = rv_state(mem) - ts["_mem_bits"] = torch.tensor([int_to_bits(b, 8) for b in ts["mem"]], dtype=torch.float32) - cyc = 0 - while not ts["halted"] and cyc < 200: - ts = cpu.step(ts); cyc += 1 - return ts["regs"][5], cyc - - -def parity(x, nbits=8): return bin(x & ((1 << nbits) - 1)).count("1") & 1 -def band(x): return int(3 <= bin(x).count("1") <= 5) # non-monotone in popcount - -def learn(fn, H, seeds, epochs): - best, t0, used = None, time.perf_counter(), 0 - for seed in range(seeds): - used = seed - hidden, out, acc = train(fn, H=H, seed=seed, epochs=epochs) - if best is None or acc > best[2]: - best = (hidden, out, acc) - if acc == 1.0: - break - hidden, out, acc = best - print(f" trained 8->{H}->1 ternary MLP by annealed-sigmoid SGD: {acc*100:.1f}% " - f"exact over all 256 inputs ({time.perf_counter()-t0:.0f}s, seed {used}); " - f"weights all in {{-1,0,+1}}") - return hidden, out, acc - -def deploy_check(cpu, fn, hidden, out, sample): - t0, cpu_vs_net, cpu_vs_true, rows = time.perf_counter(), 0, 0, [] - for x in sample: - y_cpu, _ = run_cpu(cpu, x, hidden, out) - cpu_vs_net += (y_cpu == discrete_net(x, hidden, out)) - cpu_vs_true += (y_cpu == fn(x)) - rows.append((x, y_cpu, fn(x))) - print(f" NEUR execution on neural_rv32: CPU == reference net on " - f"{cpu_vs_net}/{len(sample)} (deployment exact); CPU == truth on " - f"{cpu_vs_true}/{len(sample)} ({time.perf_counter()-t0:.0f}s)") - return rows - -def compile_parity(nbits=8): # exact thermometer construction - mask = (1 << nbits) - 1 - hidden = [(mask, 0, -(k + 1)) for k in range(nbits)] # h_k = [popcount>=k+1] - pos = sum(1 << j for j in range(nbits) if j % 2 == 0) # odd thresholds - neg = sum(1 << j for j in range(nbits) if j % 2 == 1) # even thresholds - return hidden, (pos, neg, -1) - - -if __name__ == "__main__": - print("=" * 72) - print(" Neural networks running as software on the CPU that IS a neural net") - print("=" * 72) - cpu = Rv32ThresholdCPU() - - print("\n[1] LEARNED by gradient descent -- popcount BAND: fire iff 3<=popcount<=5") - print(" (non-monotone, so no single threshold can do it; genuinely nonlinear)") - hidden, out, acc = learn(band, H=8, seeds=12, epochs=5000) - deploy_check(cpu, band, hidden, out, - [0, 1, 3, 7, 15, 31, 63, 127, 255, 0b10110, 0b1111000, 0b11011011]) - line = "".join(f"{k}:{discrete_net((1 << k) - 1, hidden, out)} " for k in range(9)) - print(f" net output vs popcount k: {line.strip()} (true band: k in 3,4,5)") - - print("\n[2] COMPILED by construction -- 8-bit PARITY (XOR), the non-separable") - print(" function Minsky & Papert used to end the single perceptron") - hidden, out = compile_parity(8) - acc = sum(discrete_net(x, hidden, out) == parity(x) for x in range(256)) / 256 - print(f" thermometer net (8 hidden 'popcount>=k' + alternating output), all " - f"weights in {{-1,0,+1}}: {acc*100:.0f}% exact over all 256 bytes") - rows = deploy_check(cpu, parity, hidden, out, - [0, 1, 3, 7, 15, 31, 85, 127, 128, 170, 200, 254, 255]) - for x, yc, yt in rows: - print(f" parity({x:3d}) = {yc} (true {yt}) [{'ok' if yc==yt else 'x'}]") - - print("\n One network learned by SGD, one compiled by hand, both executed") - print(" instruction by instruction on a CPU whose every gate is a threshold") - print(" neuron. The machine running the models belongs to the models' own class.") diff --git a/demos/neural_subleq8io_universal_constructor.py b/demos/neural_subleq8io_universal_constructor.py deleted file mode 100644 index 66355dd4dd07054a8391fe78d7b2c6964b607837..0000000000000000000000000000000000000000 --- a/demos/neural_subleq8io_universal_constructor.py +++ /dev/null @@ -1,74 +0,0 @@ -"""neural_subleq8io -- von Neumann's universal constructor, to the byte. - -The one-instruction machine (186 datapath units plus 8 packed memory -tensors) runs a 21-instruction SUBLEQ program -that reads a description of any machine in the family off its tape and emits -that machine's safetensors file one byte at a time. Here it fabricates -neural_tile.safetensors on the shipped gate netlist, byte-for-byte sha-identical -to the target, and the fabricated file is then booted: tiles reconstructed from -its own bytes regrow the binary counter. A computer made of threshold gates -prints a working computer, and the product runs. - - python demos/neural_subleq8io_universal_constructor.py -""" -import os, sys, time, json, hashlib -HERE = os.path.dirname(os.path.abspath(__file__)) -REPO = os.path.dirname(HERE) -sys.path.insert(0, os.path.join(REPO, "src")) -os.chdir(REPO) -from constructor8 import describe, ref_construct, GateHost - - -if __name__ == "__main__": - target_path = os.path.join(REPO, "variants", "neural_tile.safetensors") - data = open(target_path, "rb").read() - tape = describe(data) - print("neural_subleq8io: the universal constructor fabricates a sibling") - print("=" * 60) - print(f"recipe compiled: {len(data)} B machine -> {len(tape)} B tape") - - out_ref, n_ref = ref_construct(tape) - assert out_ref == data - print(f"reference: constructor needs {n_ref:,} instructions") - - host = GateHost() - t0 = time.perf_counter() - out, n, _ = host.run(tape, max_steps=n_ref + 100) - dt = time.perf_counter() - t0 - sha_out = hashlib.sha256(out).hexdigest()[:16] - sha_tgt = hashlib.sha256(data).hexdigest()[:16] - print(f"gate netlist: {n:,} instructions in {dt:.0f}s ({n / dt:.0f} instr/s), " - f"{len(out)} bytes emitted") - print(f" sha256 fabricated={sha_out} shipped={sha_tgt} " - f"{'BYTE-IDENTICAL' if out == data else 'MISMATCH'}; " - f"instruction count == reference: {n == n_ref}") - - # boot the fabricated machine: regrow its counter from its own bytes - fab = os.path.join(os.environ.get("TEMP", "."), "fabricated_neural_tile.safetensors") - open(fab, "wb").write(out) - from safetensors.torch import load_file - from safetensors import safe_open - import tile as T - - t = load_file(fab) - with safe_open(fab, framework="pt") as f: - m = f.metadata() - gl = json.loads(m["glues"]) - strg = {gl[i]: int(v) for i, v in enumerate(t["glue_strength"].tolist())} - tiles = [] - for row, name in zip(t["tile_glues"].tolist(), json.loads(m["tile_names"])): - sides = [gl[i] if i >= 0 else "" for i in row] - tiles.append(T.Tile(N=sides[0], E=sides[1], S=sides[2], W=sides[3], name=name)) - NB = 8 - rows = (1 << NB) - 1 - A, det = T.grow(tiles, T.counter_seed(NB), int(m["tau"]), strg, (0, 0, NB, rows)) - bad = filled = 0 - for y in range(1, rows + 1): - cells = [A.get((x, y)) for x in range(NB)] - if any(cc is None for cc in cells): - continue - filled += 1 - v = sum((1 if cc.N == "b1" else 0) << (NB - 1 - x) for x, cc in enumerate(cells)) - bad += (v != y) - print(f"boot: the fabricated file grew its counter, {filled} rows, " - f"row y encodes y: {'EXACT' if bad == 0 and filled == rows else f'{bad} bad'}") diff --git a/demos/neural_tile_pascal_lucas.py b/demos/neural_tile_pascal_lucas.py deleted file mode 100644 index a908383d95ffdc1a6fdce1c68b6f5e685352636e..0000000000000000000000000000000000000000 --- a/demos/neural_tile_pascal_lucas.py +++ /dev/null @@ -1,42 +0,0 @@ -"""neural_tile -- self-assembly grows Pascal's triangle mod 2 (Lucas' theorem). - -The program is a set of square tiles whose binding rule is a threshold gate: a -tile attaches at a site when the summed strength of its matching glues meets the -temperature. With the XOR rule tile set, the crystal that grows is exactly the -Sierpinski triangle -- cell (x,y) is filled iff the binomial C(x+y, x) is odd, -which by Lucas' theorem is iff x AND y == 0. Every filled cell is verified -against that arithmetic. Computation as crystal growth. - - python demos/neural_tile_pascal_lucas.py -""" -import os, sys, time -HERE = os.path.dirname(os.path.abspath(__file__)) -REPO = os.path.dirname(HERE) -sys.path.insert(0, os.path.join(REPO, "src")) -import tile as T - - -if __name__ == "__main__": - N = 100 - print("neural_tile: Pascal mod 2 by threshold-gated self-assembly") - print("=" * 60) - t0 = time.perf_counter() - ts = T.rule2_tileset(lambda w, s: w ^ s) - seed = T._row_col_seed([1] * (N + 1), [1] * (N + 1)) - A, det = T.grow(ts, seed, tau=2, strength={}, bounds=(0, 0, N, N), max_tiles=200000) - dt = time.perf_counter() - t0 - - interior = [(x, y) for x in range(1, N + 1) for y in range(1, N + 1)] - grown = sum(1 for p in interior if p in A) - bad = 0 - for (x, y) in interior: - v = 1 if A[(x, y)].N == "v1" else 0 - lucas = 1 if (x & y) == 0 else 0 # C(x+y,x) odd <=> no carry in x+y - bad += (v != lucas) - print(f"grew {grown} rule tiles in {dt:.1f}s (directed={det}, " - f"{2 * N} anti-diagonals deep)") - print(f"tile(x,y) == [C(x+y,x) is odd] for all {len(interior)} cells: " - f"{'EXACT' if bad == 0 else f'{bad} MISMATCHES'}") - print("\ncorner of the assembly (30x30, '#' = odd binomial):") - for y in range(30, 0, -1): - print(" " + "".join("#" if A[(x, y)].N == "v1" else "." for x in range(1, 31))) diff --git a/llm_integration/baseline.py b/llm_integration/baseline.py deleted file mode 100644 index 5bc8e0522e152b3ef4e67ddf86c894fba8c69f35..0000000000000000000000000000000000000000 --- a/llm_integration/baseline.py +++ /dev/null @@ -1,221 +0,0 @@ -""" -Baseline evaluation: Vanilla SmolLM2-360M on arithmetic -""" - -import torch -import random -import re -from transformers import AutoModelForCausalLM, AutoTokenizer - -DEVICE = "cuda" -MODEL_ID = "HuggingFaceTB/SmolLM2-360M-Instruct" - -SYSTEM_PROMPT = """You are a calculator. Output only the numeric answer. No words, no explanation, just digits. Examples: -User: 5 + 3 -Assistant: 8 -User: 12 * 7 -Assistant: 84 -User: 100 > 50 -Assistant: 1 -User: 25 < 10 -Assistant: 0""" - - -def load_model(): - print(f"Loading {MODEL_ID}...") - tokenizer = AutoTokenizer.from_pretrained(MODEL_ID) - tokenizer.padding_side = "left" - if tokenizer.pad_token is None: - tokenizer.pad_token = tokenizer.eos_token - model = AutoModelForCausalLM.from_pretrained( - MODEL_ID, - torch_dtype=torch.float16, - device_map=DEVICE - ) - model.eval() - print(f" Loaded. Parameters: {sum(p.numel() for p in model.parameters()):,}") - return model, tokenizer - - -def format_prompt(tokenizer, op_str): - messages = [ - {"role": "system", "content": SYSTEM_PROMPT}, - {"role": "user", "content": op_str} - ] - return tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) - - -def generate_batch(model, tokenizer, prompts, max_new_tokens=16): - inputs = tokenizer(prompts, return_tensors="pt", padding=True).to(DEVICE) - with torch.no_grad(): - outputs = model.generate( - **inputs, - max_new_tokens=max_new_tokens, - do_sample=False, - pad_token_id=tokenizer.eos_token_id - ) - responses = [] - for i, output in enumerate(outputs): - response = tokenizer.decode(output[inputs.input_ids.shape[1]:], skip_special_tokens=True) - responses.append(response.strip()) - return responses - - -def extract_answer(text): - """Generous extraction - find any number in output""" - text = text.strip().lower() - if not text: - return None - - # Handle Yes/No for comparisons - if text in ['yes', 'true', '1']: - return 1 - if text in ['no', 'false', '0']: - return 0 - if text.startswith('yes'): - return 1 - if text.startswith('no'): - return 0 - - # Find all numbers, take the LAST one (most likely the answer) - numbers = re.findall(r'-?\d+', text) - if numbers: - return int(numbers[-1]) - return None - - -def ground_truth(a, b, op): - """Compute expected result (8-bit where applicable)""" - if op == 'add': - return (a + b) & 0xFF - elif op == 'sub': - return (a - b) & 0xFF - elif op == 'mul': - return (a * b) & 0xFF - elif op == 'div': - return a // b if b != 0 else 0 - elif op == 'and': - return a & b - elif op == 'or': - return a | b - elif op == 'xor': - return a ^ b - elif op == 'gt': - return 1 if a > b else 0 - elif op == 'lt': - return 1 if a < b else 0 - elif op == 'eq': - return 1 if a == b else 0 - elif op == 'ge': - return 1 if a >= b else 0 - elif op == 'le': - return 1 if a <= b else 0 - else: - raise ValueError(f"Unknown op: {op}") - - -def op_to_str(a, b, op): - """Convert operation to natural string""" - symbols = { - 'add': '+', 'sub': '-', 'mul': '*', 'div': '/', - 'and': '&', 'or': '|', 'xor': '^', - 'gt': '>', 'lt': '<', 'eq': '==', 'ge': '>=', 'le': '<=' - } - return f"{a} {symbols[op]} {b}" - - -def evaluate(model, tokenizer, n_samples=1000, batch_size=32, ops=None): - if ops is None: - ops = ['add', 'sub', 'mul', 'gt', 'lt', 'eq'] - - results = {op: {'correct': 0, 'total': 0} for op in ops} - all_correct = 0 - all_total = 0 - - samples = [] - for _ in range(n_samples): - a = random.randint(0, 255) - b = random.randint(0, 255) - if 'div' in ops and random.random() < 0.1: - op = 'div' - b = random.randint(1, 255) # avoid div by zero - else: - op = random.choice([o for o in ops if o != 'div']) - samples.append((a, b, op)) - - print(f"\nEvaluating {n_samples} samples (batch_size={batch_size})...") - - for batch_start in range(0, n_samples, batch_size): - batch = samples[batch_start:batch_start + batch_size] - prompts = [format_prompt(tokenizer, op_to_str(a, b, op)) for a, b, op in batch] - responses = generate_batch(model, tokenizer, prompts) - - for (a, b, op), response in zip(batch, responses): - expected = ground_truth(a, b, op) - extracted = extract_answer(response) - - correct = (extracted == expected) - results[op]['total'] += 1 - all_total += 1 - if correct: - results[op]['correct'] += 1 - all_correct += 1 - - if (batch_start + batch_size) % 200 == 0 or batch_start + batch_size >= n_samples: - pct = 100 * all_correct / all_total - print(f" Progress: {min(batch_start + batch_size, n_samples)}/{n_samples} | Accuracy: {pct:.2f}%") - - return results, all_correct, all_total - - -def main(): - random.seed(42) - torch.manual_seed(42) - - model, tokenizer = load_model() - - # Quick sanity check - print("\nSanity check (5 examples):") - test_cases = [ - ("5 + 3", 8), - ("100 - 37", 63), - ("12 * 11", 132), - ("50 > 30", 1), - ("25 < 10", 0), - ] - prompts = [format_prompt(tokenizer, q) for q, _ in test_cases] - responses = generate_batch(model, tokenizer, prompts) - for (q, expected), response in zip(test_cases, responses): - extracted = extract_answer(response) - status = "OK" if extracted == expected else "FAIL" - print(f" {q} = {expected} | Model: '{response}' -> {extracted} [{status}]") - - # Full evaluation - print("\n" + "=" * 60) - print(" BASELINE EVALUATION") - print("=" * 60) - - ops = ['add', 'sub', 'mul', 'gt', 'lt', 'eq'] - results, correct, total = evaluate(model, tokenizer, n_samples=2000, batch_size=64, ops=ops) - - print("\n" + "=" * 60) - print(" RESULTS BY OPERATION") - print("=" * 60) - for op in ops: - r = results[op] - pct = 100 * r['correct'] / r['total'] if r['total'] > 0 else 0 - print(f" {op:6}: {r['correct']:4}/{r['total']:4} ({pct:6.2f}%)") - - print("\n" + "=" * 60) - print(" OVERALL") - print("=" * 60) - fitness = correct / total - print(f" Correct: {correct}/{total}") - print(f" Fitness: {fitness:.4f} ({100*fitness:.2f}%)") - print("=" * 60) - - return fitness - - -if __name__ == "__main__": - main() diff --git a/llm_integration/circuits.py b/llm_integration/circuits.py deleted file mode 100644 index 48664220906bd016b731397422e7d6cebc8b3f1c..0000000000000000000000000000000000000000 --- a/llm_integration/circuits.py +++ /dev/null @@ -1,320 +0,0 @@ -""" -Frozen threshold circuit wrapper for LLM integration. -Loads safetensors and provides differentiable-compatible execution. -""" - -import torch -import torch.nn as nn -from safetensors import safe_open -from typing import Dict, Tuple - -MODEL_PATH = "D:/8bit-threshold-computer/neural_computer.safetensors" - - -def heaviside(x: torch.Tensor) -> torch.Tensor: - """Standard Heaviside step function.""" - return (x >= 0).float() - - -class HeavisideSTE(torch.autograd.Function): - """Heaviside with straight-through estimator for gradients.""" - @staticmethod - def forward(ctx, x): - return (x >= 0).float() - - @staticmethod - def backward(ctx, grad_output): - return grad_output - - -def heaviside_ste(x: torch.Tensor) -> torch.Tensor: - """Heaviside with STE gradient.""" - return HeavisideSTE.apply(x) - - -class FrozenThresholdCircuits(nn.Module): - """ - Wrapper for frozen threshold logic circuits. - All weights are frozen - no gradients flow through circuit internals. - Gradients flow through inputs/outputs via STE. - """ - - def __init__(self, model_path: str = MODEL_PATH, device: str = 'cuda'): - super().__init__() - self.device = device - self.weights = {} - self._load_weights(model_path) - - def _load_weights(self, path: str): - """Load weights from safetensors file.""" - with safe_open(path, framework='pt') as f: - for name in f.keys(): - tensor = f.get_tensor(name).to(self.device).float() - self.weights[name] = tensor - - def _gate(self, inputs: torch.Tensor, weight: torch.Tensor, bias: torch.Tensor) -> torch.Tensor: - """Execute single threshold gate with STE.""" - weight = weight.view(-1) - bias = bias.view(-1) - pre_activation = (inputs * weight).sum(dim=-1) + bias - return heaviside_ste(pre_activation) - - def _xor(self, a: torch.Tensor, b: torch.Tensor, prefix: str) -> torch.Tensor: - """XOR via OR-NAND-AND pattern (2 layers).""" - inputs = torch.stack([a, b], dim=-1) - - w_or = self.weights[f'{prefix}.layer1.or.weight'] - b_or = self.weights[f'{prefix}.layer1.or.bias'] - w_nand = self.weights[f'{prefix}.layer1.nand.weight'] - b_nand = self.weights[f'{prefix}.layer1.nand.bias'] - - h_or = self._gate(inputs, w_or, b_or) - h_nand = self._gate(inputs, w_nand, b_nand) - - hidden = torch.stack([h_or, h_nand], dim=-1) - w2 = self.weights[f'{prefix}.layer2.weight'] - b2 = self.weights[f'{prefix}.layer2.bias'] - - return self._gate(hidden, w2, b2) - - def _full_adder(self, a: torch.Tensor, b: torch.Tensor, cin: torch.Tensor, - prefix: str) -> Tuple[torch.Tensor, torch.Tensor]: - """Full adder: sum and carry out.""" - ha1_sum = self._xor(a, b, f'{prefix}.ha1.sum') - - inp_carry1 = torch.stack([a, b], dim=-1) - w_c1 = self.weights[f'{prefix}.ha1.carry.weight'] - b_c1 = self.weights[f'{prefix}.ha1.carry.bias'] - ha1_carry = self._gate(inp_carry1, w_c1, b_c1) - - ha2_sum = self._xor(ha1_sum, cin, f'{prefix}.ha2.sum') - - inp_carry2 = torch.stack([ha1_sum, cin], dim=-1) - w_c2 = self.weights[f'{prefix}.ha2.carry.weight'] - b_c2 = self.weights[f'{prefix}.ha2.carry.bias'] - ha2_carry = self._gate(inp_carry2, w_c2, b_c2) - - inp_cout = torch.stack([ha1_carry, ha2_carry], dim=-1) - w_cout = self.weights[f'{prefix}.carry_or.weight'] - b_cout = self.weights[f'{prefix}.carry_or.bias'] - cout = self._gate(inp_cout, w_cout, b_cout) - - return ha2_sum, cout - - def add_8bit(self, a_bits: torch.Tensor, b_bits: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: - """ - 8-bit ripple carry addition. - - Args: - a_bits: [batch, 8] MSB-first - b_bits: [batch, 8] MSB-first - - Returns: - result_bits: [batch, 8] MSB-first - carry_out: [batch] final carry - """ - batch_size = a_bits.shape[0] - carry = torch.zeros(batch_size, device=self.device) - result_bits = [] - - for bit in range(8): - bit_idx = 7 - bit - s, carry = self._full_adder( - a_bits[:, bit_idx], - b_bits[:, bit_idx], - carry, - f'arithmetic.ripplecarry8bit.fa{bit}' - ) - result_bits.insert(0, s) - - result = torch.stack(result_bits, dim=1) - return result, carry - - def sub_8bit(self, a_bits: torch.Tensor, b_bits: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: - """ - 8-bit subtraction via two's complement: A - B = A + (~B) + 1 - - Args: - a_bits: [batch, 8] MSB-first - b_bits: [batch, 8] MSB-first - - Returns: - result_bits: [batch, 8] MSB-first - borrow_out: [batch] (inverted carry) - """ - b_inv = 1.0 - b_bits - batch_size = a_bits.shape[0] - carry = torch.ones(batch_size, device=self.device) - result_bits = [] - - for bit in range(8): - bit_idx = 7 - bit - s, carry = self._full_adder( - a_bits[:, bit_idx], - b_inv[:, bit_idx], - carry, - f'arithmetic.ripplecarry8bit.fa{bit}' - ) - result_bits.insert(0, s) - - result = torch.stack(result_bits, dim=1) - borrow = 1.0 - carry - return result, borrow - - def mul_8bit(self, a_bits: torch.Tensor, b_bits: torch.Tensor) -> torch.Tensor: - """ - 8-bit multiplication via shift-add (software implementation using adder circuits). - Only keeps low 8 bits of result (matches 8-bit wrap behavior). - - Args: - a_bits: [batch, 8] MSB-first - b_bits: [batch, 8] MSB-first - - Returns: - result_bits: [batch, 8] MSB-first (low 8 bits of product) - """ - batch_size = a_bits.shape[0] - - acc = torch.zeros(batch_size, 8, device=self.device) - - for i in range(8): - b_bit = b_bits[:, 7 - i] - pp = a_bits * b_bit.unsqueeze(1) - - shifted_pp = torch.zeros(batch_size, 8, device=self.device) - for j in range(8): - dst_idx = j + i - if dst_idx < 8: - shifted_pp[:, 7 - dst_idx] = pp[:, 7 - j] - - acc, _ = self.add_8bit(acc, shifted_pp) - - return acc - - def compare_gt(self, a_bits: torch.Tensor, b_bits: torch.Tensor) -> torch.Tensor: - """A > B comparison.""" - inputs = torch.cat([a_bits, b_bits], dim=-1) - w = self.weights['arithmetic.greaterthan8bit.weight'].view(-1) - b = self.weights['arithmetic.greaterthan8bit.bias'].view(-1) - return heaviside_ste((inputs * w).sum(dim=-1) + b) - - def compare_lt(self, a_bits: torch.Tensor, b_bits: torch.Tensor) -> torch.Tensor: - """A < B comparison.""" - inputs = torch.cat([a_bits, b_bits], dim=-1) - w = self.weights['arithmetic.lessthan8bit.weight'].view(-1) - b = self.weights['arithmetic.lessthan8bit.bias'].view(-1) - return heaviside_ste((inputs * w).sum(dim=-1) + b) - - def compare_eq(self, a_bits: torch.Tensor, b_bits: torch.Tensor) -> torch.Tensor: - """A == B comparison (two-layer).""" - inputs = torch.cat([a_bits, b_bits], dim=-1) - prefix = 'arithmetic.equality8bit' - - w_geq = self.weights[f'{prefix}.layer1.geq.weight'].view(-1) - b_geq = self.weights[f'{prefix}.layer1.geq.bias'].view(-1) - w_leq = self.weights[f'{prefix}.layer1.leq.weight'].view(-1) - b_leq = self.weights[f'{prefix}.layer1.leq.bias'].view(-1) - - h_geq = heaviside_ste((inputs * w_geq).sum(dim=-1) + b_geq) - h_leq = heaviside_ste((inputs * w_leq).sum(dim=-1) + b_leq) - - hidden = torch.stack([h_geq, h_leq], dim=-1) - w2 = self.weights[f'{prefix}.layer2.weight'].view(-1) - b2 = self.weights[f'{prefix}.layer2.bias'].view(-1) - - return heaviside_ste((hidden * w2).sum(dim=-1) + b2) - - def forward(self, a_bits: torch.Tensor, b_bits: torch.Tensor, - op_onehot: torch.Tensor) -> torch.Tensor: - """ - Execute operation based on one-hot selector. - Uses soft routing during training for gradient flow. - - Args: - a_bits: [batch, 8] operand A - b_bits: [batch, 8] operand B - op_onehot: [batch, 6] one-hot operation selector - [add, sub, mul, gt, lt, eq] - - Returns: - result_bits: [batch, 8] result (comparisons in bit 7, rest zeros) - """ - batch_size = a_bits.shape[0] - - add_result, _ = self.add_8bit(a_bits, b_bits) - sub_result, _ = self.sub_8bit(a_bits, b_bits) - mul_result = self.mul_8bit(a_bits, b_bits) - - gt_result = self.compare_gt(a_bits, b_bits) - lt_result = self.compare_lt(a_bits, b_bits) - eq_result = self.compare_eq(a_bits, b_bits) - - cmp_expanded = torch.zeros(batch_size, 8, device=self.device) - - gt_expanded = cmp_expanded.clone() - gt_expanded[:, 7] = gt_result - - lt_expanded = cmp_expanded.clone() - lt_expanded[:, 7] = lt_result - - eq_expanded = cmp_expanded.clone() - eq_expanded[:, 7] = eq_result - - results = torch.stack([ - add_result, - sub_result, - mul_result, - gt_expanded, - lt_expanded, - eq_expanded - ], dim=1) - - op_weights = op_onehot.unsqueeze(-1) - output = (results * op_weights).sum(dim=1) - - return output - - -if __name__ == "__main__": - print("Testing frozen circuits...") - - circuits = FrozenThresholdCircuits(device='cuda') - print(f"Loaded {len(circuits.weights)} tensors") - - a = torch.tensor([[0, 0, 0, 0, 0, 1, 0, 1]], device='cuda', dtype=torch.float32) - b = torch.tensor([[0, 0, 0, 0, 0, 0, 1, 1]], device='cuda', dtype=torch.float32) - - result, carry = circuits.add_8bit(a, b) - val = sum(int(result[0, i].item()) << (7 - i) for i in range(8)) - print(f"5 + 3 = {val} (expected 8)") - - a = torch.tensor([[0, 1, 1, 0, 0, 1, 0, 0]], device='cuda', dtype=torch.float32) - b = torch.tensor([[0, 0, 1, 0, 0, 1, 0, 1]], device='cuda', dtype=torch.float32) - result, _ = circuits.sub_8bit(a, b) - val = sum(int(result[0, i].item()) << (7 - i) for i in range(8)) - print(f"100 - 37 = {val} (expected 63)") - - a = torch.tensor([[0, 0, 0, 0, 1, 1, 0, 0]], device='cuda', dtype=torch.float32) - b = torch.tensor([[0, 0, 0, 0, 1, 0, 1, 1]], device='cuda', dtype=torch.float32) - result = circuits.mul_8bit(a, b) - val = sum(int(result[0, i].item()) << (7 - i) for i in range(8)) - print(f"12 * 11 = {val} (expected 132)") - - a = torch.tensor([[0, 0, 1, 1, 0, 0, 1, 0]], device='cuda', dtype=torch.float32) - b = torch.tensor([[0, 0, 0, 1, 1, 1, 1, 0]], device='cuda', dtype=torch.float32) - gt = circuits.compare_gt(a, b) - lt = circuits.compare_lt(a, b) - eq = circuits.compare_eq(a, b) - print(f"50 > 30: {int(gt[0].item())} (expected 1)") - print(f"50 < 30: {int(lt[0].item())} (expected 0)") - print(f"50 == 30: {int(eq[0].item())} (expected 0)") - - print("\nTesting batched forward...") - batch_a = torch.randint(0, 2, (16, 8), device='cuda', dtype=torch.float32) - batch_b = torch.randint(0, 2, (16, 8), device='cuda', dtype=torch.float32) - op = torch.zeros(16, 6, device='cuda') - op[:, 0] = 1.0 - - result = circuits(batch_a, batch_b, op) - print(f"Batch output shape: {result.shape}") - print("Done.") diff --git a/llm_integration/fitness.py b/llm_integration/fitness.py deleted file mode 100644 index a324ece62fd933c936f95014380b26accd45d731..0000000000000000000000000000000000000000 --- a/llm_integration/fitness.py +++ /dev/null @@ -1,218 +0,0 @@ -""" -Shared fitness function for threshold circuit LLM integration. -Randomized tests, no answer supervision - fitness IS the training signal. -""" - -import torch -import random -from typing import Callable, Dict, Tuple, List - -OPERATIONS = ['add', 'sub', 'mul', 'gt', 'lt', 'eq'] - -def ground_truth(a: int, b: int, op: str) -> int: - """Compute expected result (8-bit arithmetic).""" - if op == 'add': - return (a + b) & 0xFF - elif op == 'sub': - return (a - b) & 0xFF - elif op == 'mul': - return (a * b) & 0xFF - elif op == 'gt': - return 1 if a > b else 0 - elif op == 'lt': - return 1 if a < b else 0 - elif op == 'eq': - return 1 if a == b else 0 - else: - raise ValueError(f"Unknown op: {op}") - - -def int_to_bits(val: int, n_bits: int = 8) -> torch.Tensor: - """Convert integer to bit tensor (MSB first).""" - bits = torch.zeros(n_bits) - for i in range(n_bits): - bits[n_bits - 1 - i] = (val >> i) & 1 - return bits - - -def bits_to_int(bits: torch.Tensor) -> int: - """Convert bit tensor to integer (MSB first).""" - val = 0 - n_bits = bits.shape[-1] - for i in range(n_bits): - val += int(bits[..., i].item()) << (n_bits - 1 - i) - return val - - -def op_to_idx(op: str) -> int: - """Convert operation string to index.""" - return OPERATIONS.index(op) - - -def idx_to_op(idx: int) -> str: - """Convert index to operation string.""" - return OPERATIONS[idx] - - -def generate_batch(batch_size: int, device: str = 'cuda') -> Dict[str, torch.Tensor]: - """ - Generate a batch of random arithmetic problems. - - Returns: - Dict with: - 'a': [batch_size] int tensor of first operands - 'b': [batch_size] int tensor of second operands - 'op': [batch_size] int tensor of operation indices - 'a_bits': [batch_size, 8] bit tensor - 'b_bits': [batch_size, 8] bit tensor - 'op_onehot': [batch_size, 6] one-hot operation tensor - 'expected': [batch_size] int tensor of expected results - 'expected_bits': [batch_size, 8] bit tensor of expected results - """ - a_vals = torch.randint(0, 256, (batch_size,), device=device) - b_vals = torch.randint(0, 256, (batch_size,), device=device) - op_indices = torch.randint(0, len(OPERATIONS), (batch_size,), device=device) - - a_bits = torch.zeros(batch_size, 8, device=device) - b_bits = torch.zeros(batch_size, 8, device=device) - for i in range(8): - a_bits[:, 7-i] = (a_vals >> i) & 1 - b_bits[:, 7-i] = (b_vals >> i) & 1 - - op_onehot = torch.zeros(batch_size, len(OPERATIONS), device=device) - op_onehot.scatter_(1, op_indices.unsqueeze(1), 1.0) - - expected = torch.zeros(batch_size, dtype=torch.long, device=device) - for i in range(batch_size): - a, b, op_idx = a_vals[i].item(), b_vals[i].item(), op_indices[i].item() - expected[i] = ground_truth(a, b, idx_to_op(op_idx)) - - expected_bits = torch.zeros(batch_size, 8, device=device) - for i in range(8): - expected_bits[:, 7-i] = (expected >> i) & 1 - - return { - 'a': a_vals, - 'b': b_vals, - 'op': op_indices, - 'a_bits': a_bits.float(), - 'b_bits': b_bits.float(), - 'op_onehot': op_onehot.float(), - 'expected': expected, - 'expected_bits': expected_bits.float(), - } - - -def compute_fitness( - model_fn: Callable[[torch.Tensor, torch.Tensor, torch.Tensor], torch.Tensor], - n_samples: int = 10000, - batch_size: int = 256, - device: str = 'cuda', - return_details: bool = False -) -> float | Tuple[float, Dict]: - """ - Compute fitness score for a model. - - Args: - model_fn: Function that takes (a_bits, b_bits, op_onehot) and returns result_bits - n_samples: Number of test cases - batch_size: Batch size for evaluation - device: Device to run on - return_details: If True, return per-operation breakdown - - Returns: - Fitness score in [0, 1], optionally with details dict - """ - correct = 0 - total = 0 - op_correct = {op: 0 for op in OPERATIONS} - op_total = {op: 0 for op in OPERATIONS} - - for _ in range(0, n_samples, batch_size): - actual_batch = min(batch_size, n_samples - total) - batch = generate_batch(actual_batch, device) - - with torch.no_grad(): - pred_bits = model_fn(batch['a_bits'], batch['b_bits'], batch['op_onehot']) - - pred_bits_binary = (pred_bits > 0.5).float() - - for i in range(actual_batch): - pred_val = 0 - for j in range(8): - pred_val += int(pred_bits_binary[i, j].item()) << (7 - j) - - expected_val = batch['expected'][i].item() - op_name = idx_to_op(batch['op'][i].item()) - - op_total[op_name] += 1 - total += 1 - - if pred_val == expected_val: - correct += 1 - op_correct[op_name] += 1 - - fitness = correct / total if total > 0 else 0.0 - - if return_details: - details = { - 'correct': correct, - 'total': total, - 'by_op': { - op: { - 'correct': op_correct[op], - 'total': op_total[op], - 'accuracy': op_correct[op] / op_total[op] if op_total[op] > 0 else 0.0 - } - for op in OPERATIONS - } - } - return fitness, details - - return fitness - - -def compute_bit_accuracy(pred_bits: torch.Tensor, expected_bits: torch.Tensor) -> float: - """Compute per-bit accuracy (for gradient signal analysis).""" - pred_binary = (pred_bits > 0.5).float() - return (pred_binary == expected_bits).float().mean().item() - - -def compute_loss(pred_bits: torch.Tensor, expected_bits: torch.Tensor) -> torch.Tensor: - """Binary cross-entropy loss on output bits.""" - pred_clamped = pred_bits.clamp(1e-7, 1 - 1e-7) - return -((expected_bits * torch.log(pred_clamped) + - (1 - expected_bits) * torch.log(1 - pred_clamped))).mean() - - -if __name__ == "__main__": - print("Testing fitness module...") - - batch = generate_batch(8, 'cpu') - print(f"\nSample batch:") - for i in range(4): - a, b = batch['a'][i].item(), batch['b'][i].item() - op = idx_to_op(batch['op'][i].item()) - expected = batch['expected'][i].item() - print(f" {a} {op} {b} = {expected}") - - def random_model(a_bits, b_bits, op_onehot): - return torch.rand(a_bits.shape[0], 8, device=a_bits.device) - - fitness = compute_fitness(random_model, n_samples=1000, batch_size=100, device='cpu') - print(f"\nRandom model fitness: {fitness:.4f} (expected ~0.004 for 8-bit)") - - def perfect_model(a_bits, b_bits, op_onehot): - batch_size = a_bits.shape[0] - results = torch.zeros(batch_size, 8, device=a_bits.device) - for i in range(batch_size): - a = sum(int(a_bits[i, j].item()) << (7-j) for j in range(8)) - b = sum(int(b_bits[i, j].item()) << (7-j) for j in range(8)) - op_idx = op_onehot[i].argmax().item() - result = ground_truth(a, b, idx_to_op(op_idx)) - for j in range(8): - results[i, 7-j] = (result >> j) & 1 - return results - - fitness = compute_fitness(perfect_model, n_samples=1000, batch_size=100, device='cpu') - print(f"Perfect model fitness: {fitness:.4f} (expected 1.0)") diff --git a/llm_integration/llm.py b/llm_integration/llm.py new file mode 100644 index 0000000000000000000000000000000000000000..427224239ce6746af58de2b5b5549eb11a14c25b --- /dev/null +++ b/llm_integration/llm.py @@ -0,0 +1,2624 @@ +"""Frozen threshold circuits inside SmolLM2: the circuits, the fitness, the interface, training, baseline.""" +import argparse +import os +import random +import re +import sys +import time +from typing import Dict, Tuple, Callable, List + +import torch +import torch.nn as nn +import torch.nn.functional as F +import torch.optim as optim +from safetensors import safe_open +from transformers import AutoModelForCausalLM, AutoTokenizer + +TRAINED = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'trained') + + +MODEL_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'neural_computer.safetensors') + + +def heaviside(x: torch.Tensor) -> torch.Tensor: + """Standard Heaviside step function.""" + return (x >= 0).float() + + +class HeavisideSTE(torch.autograd.Function): + """Heaviside with straight-through estimator for gradients.""" + @staticmethod + def forward(ctx, x): + return (x >= 0).float() + + @staticmethod + def backward(ctx, grad_output): + return grad_output + + +def heaviside_ste(x: torch.Tensor) -> torch.Tensor: + """Heaviside with STE gradient.""" + return HeavisideSTE.apply(x) + + +class FrozenThresholdCircuits(nn.Module): + """Wrapper for frozen threshold logic circuits.""" + + def __init__(self, model_path: str = MODEL_PATH, device: str = 'cuda'): + super().__init__() + self.device = device + self.weights = {} + self._load_weights(model_path) + + def _load_weights(self, path: str): + """Load weights from safetensors file.""" + with safe_open(path, framework='pt') as f: + for name in f.keys(): + tensor = f.get_tensor(name).to(self.device).float() + self.weights[name] = tensor + + def _gate(self, inputs: torch.Tensor, weight: torch.Tensor, bias: torch.Tensor) -> torch.Tensor: + """Execute single threshold gate with STE.""" + weight = weight.view(-1) + bias = bias.view(-1) + pre_activation = (inputs * weight).sum(dim=-1) + bias + return heaviside_ste(pre_activation) + + def _xor(self, a: torch.Tensor, b: torch.Tensor, prefix: str) -> torch.Tensor: + """XOR via OR-NAND-AND pattern (2 layers).""" + inputs = torch.stack([a, b], dim=-1) + + w_or = self.weights[f'{prefix}.layer1.or.weight'] + b_or = self.weights[f'{prefix}.layer1.or.bias'] + w_nand = self.weights[f'{prefix}.layer1.nand.weight'] + b_nand = self.weights[f'{prefix}.layer1.nand.bias'] + + h_or = self._gate(inputs, w_or, b_or) + h_nand = self._gate(inputs, w_nand, b_nand) + + hidden = torch.stack([h_or, h_nand], dim=-1) + w2 = self.weights[f'{prefix}.layer2.weight'] + b2 = self.weights[f'{prefix}.layer2.bias'] + + return self._gate(hidden, w2, b2) + + def _full_adder(self, a: torch.Tensor, b: torch.Tensor, cin: torch.Tensor, + prefix: str) -> Tuple[torch.Tensor, torch.Tensor]: + """Full adder: sum and carry out.""" + ha1_sum = self._xor(a, b, f'{prefix}.ha1.sum') + + inp_carry1 = torch.stack([a, b], dim=-1) + w_c1 = self.weights[f'{prefix}.ha1.carry.weight'] + b_c1 = self.weights[f'{prefix}.ha1.carry.bias'] + ha1_carry = self._gate(inp_carry1, w_c1, b_c1) + + ha2_sum = self._xor(ha1_sum, cin, f'{prefix}.ha2.sum') + + inp_carry2 = torch.stack([ha1_sum, cin], dim=-1) + w_c2 = self.weights[f'{prefix}.ha2.carry.weight'] + b_c2 = self.weights[f'{prefix}.ha2.carry.bias'] + ha2_carry = self._gate(inp_carry2, w_c2, b_c2) + + inp_cout = torch.stack([ha1_carry, ha2_carry], dim=-1) + w_cout = self.weights[f'{prefix}.carry_or.weight'] + b_cout = self.weights[f'{prefix}.carry_or.bias'] + cout = self._gate(inp_cout, w_cout, b_cout) + + return ha2_sum, cout + + def add_8bit(self, a_bits: torch.Tensor, b_bits: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: + """8-bit ripple carry addition.""" + batch_size = a_bits.shape[0] + carry = torch.zeros(batch_size, device=self.device) + result_bits = [] + + for bit in range(8): + bit_idx = 7 - bit + s, carry = self._full_adder( + a_bits[:, bit_idx], + b_bits[:, bit_idx], + carry, + f'arithmetic.ripplecarry8bit.fa{bit}' + ) + result_bits.insert(0, s) + + result = torch.stack(result_bits, dim=1) + return result, carry + + def sub_8bit(self, a_bits: torch.Tensor, b_bits: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: + """8-bit subtraction by two's complement, A - B = A + ~B + 1.""" + b_inv = 1.0 - b_bits + batch_size = a_bits.shape[0] + carry = torch.ones(batch_size, device=self.device) + result_bits = [] + + for bit in range(8): + bit_idx = 7 - bit + s, carry = self._full_adder( + a_bits[:, bit_idx], + b_inv[:, bit_idx], + carry, + f'arithmetic.ripplecarry8bit.fa{bit}' + ) + result_bits.insert(0, s) + + result = torch.stack(result_bits, dim=1) + borrow = 1.0 - carry + return result, borrow + + def mul_8bit(self, a_bits: torch.Tensor, b_bits: torch.Tensor) -> torch.Tensor: + """8-bit multiplication via shift-add (software implementation using adder circuits).""" + batch_size = a_bits.shape[0] + + acc = torch.zeros(batch_size, 8, device=self.device) + + for i in range(8): + b_bit = b_bits[:, 7 - i] + pp = a_bits * b_bit.unsqueeze(1) + + shifted_pp = torch.zeros(batch_size, 8, device=self.device) + for j in range(8): + dst_idx = j + i + if dst_idx < 8: + shifted_pp[:, 7 - dst_idx] = pp[:, 7 - j] + + acc, _ = self.add_8bit(acc, shifted_pp) + + return acc + + def compare_gt(self, a_bits: torch.Tensor, b_bits: torch.Tensor) -> torch.Tensor: + """A > B comparison.""" + inputs = torch.cat([a_bits, b_bits], dim=-1) + w = self.weights['arithmetic.greaterthan8bit.weight'].view(-1) + b = self.weights['arithmetic.greaterthan8bit.bias'].view(-1) + return heaviside_ste((inputs * w).sum(dim=-1) + b) + + def compare_lt(self, a_bits: torch.Tensor, b_bits: torch.Tensor) -> torch.Tensor: + """A < B comparison.""" + inputs = torch.cat([a_bits, b_bits], dim=-1) + w = self.weights['arithmetic.lessthan8bit.weight'].view(-1) + b = self.weights['arithmetic.lessthan8bit.bias'].view(-1) + return heaviside_ste((inputs * w).sum(dim=-1) + b) + + def compare_eq(self, a_bits: torch.Tensor, b_bits: torch.Tensor) -> torch.Tensor: + """A == B comparison (two-layer).""" + inputs = torch.cat([a_bits, b_bits], dim=-1) + prefix = 'arithmetic.equality8bit' + + w_geq = self.weights[f'{prefix}.layer1.geq.weight'].view(-1) + b_geq = self.weights[f'{prefix}.layer1.geq.bias'].view(-1) + w_leq = self.weights[f'{prefix}.layer1.leq.weight'].view(-1) + b_leq = self.weights[f'{prefix}.layer1.leq.bias'].view(-1) + + h_geq = heaviside_ste((inputs * w_geq).sum(dim=-1) + b_geq) + h_leq = heaviside_ste((inputs * w_leq).sum(dim=-1) + b_leq) + + hidden = torch.stack([h_geq, h_leq], dim=-1) + w2 = self.weights[f'{prefix}.layer2.weight'].view(-1) + b2 = self.weights[f'{prefix}.layer2.bias'].view(-1) + + return heaviside_ste((hidden * w2).sum(dim=-1) + b2) + + def forward(self, a_bits: torch.Tensor, b_bits: torch.Tensor, + op_onehot: torch.Tensor) -> torch.Tensor: + """Execute operation based on one-hot selector.""" + batch_size = a_bits.shape[0] + + add_result, _ = self.add_8bit(a_bits, b_bits) + sub_result, _ = self.sub_8bit(a_bits, b_bits) + mul_result = self.mul_8bit(a_bits, b_bits) + + gt_result = self.compare_gt(a_bits, b_bits) + lt_result = self.compare_lt(a_bits, b_bits) + eq_result = self.compare_eq(a_bits, b_bits) + + cmp_expanded = torch.zeros(batch_size, 8, device=self.device) + + gt_expanded = cmp_expanded.clone() + gt_expanded[:, 7] = gt_result + + lt_expanded = cmp_expanded.clone() + lt_expanded[:, 7] = lt_result + + eq_expanded = cmp_expanded.clone() + eq_expanded[:, 7] = eq_result + + results = torch.stack([ + add_result, + sub_result, + mul_result, + gt_expanded, + lt_expanded, + eq_expanded + ], dim=1) + + op_weights = op_onehot.unsqueeze(-1) + output = (results * op_weights).sum(dim=1) + + return output + +OPERATIONS = ['add', 'sub', 'mul', 'gt', 'lt', 'eq'] + +def ground_truth(a: int, b: int, op: str) -> int: + """Compute expected result (8-bit arithmetic).""" + if op == 'add': + return (a + b) & 0xFF + elif op == 'sub': + return (a - b) & 0xFF + elif op == 'mul': + return (a * b) & 0xFF + elif op == 'gt': + return 1 if a > b else 0 + elif op == 'lt': + return 1 if a < b else 0 + elif op == 'eq': + return 1 if a == b else 0 + else: + raise ValueError(f"Unknown op: {op}") + + +def int_to_bits(val: int, n_bits: int = 8) -> torch.Tensor: + """Convert integer to bit tensor (MSB first).""" + bits = torch.zeros(n_bits) + for i in range(n_bits): + bits[n_bits - 1 - i] = (val >> i) & 1 + return bits + + +def bits_to_int(bits: torch.Tensor) -> int: + """Convert bit tensor to integer (MSB first).""" + val = 0 + n_bits = bits.shape[-1] + for i in range(n_bits): + val += int(bits[..., i].item()) << (n_bits - 1 - i) + return val + + +def op_to_idx(op: str) -> int: + """Convert operation string to index.""" + return OPERATIONS.index(op) + + +def idx_to_op(idx: int) -> str: + """Convert index to operation string.""" + return OPERATIONS[idx] + + +def generate_batch(batch_size: int, device: str = 'cuda') -> Dict[str, torch.Tensor]: + """Generate a batch of random arithmetic problems.""" + a_vals = torch.randint(0, 256, (batch_size,), device=device) + b_vals = torch.randint(0, 256, (batch_size,), device=device) + op_indices = torch.randint(0, len(OPERATIONS), (batch_size,), device=device) + + a_bits = torch.zeros(batch_size, 8, device=device) + b_bits = torch.zeros(batch_size, 8, device=device) + for i in range(8): + a_bits[:, 7-i] = (a_vals >> i) & 1 + b_bits[:, 7-i] = (b_vals >> i) & 1 + + op_onehot = torch.zeros(batch_size, len(OPERATIONS), device=device) + op_onehot.scatter_(1, op_indices.unsqueeze(1), 1.0) + + expected = torch.zeros(batch_size, dtype=torch.long, device=device) + for i in range(batch_size): + a, b, op_idx = a_vals[i].item(), b_vals[i].item(), op_indices[i].item() + expected[i] = ground_truth(a, b, idx_to_op(op_idx)) + + expected_bits = torch.zeros(batch_size, 8, device=device) + for i in range(8): + expected_bits[:, 7-i] = (expected >> i) & 1 + + return { + 'a': a_vals, + 'b': b_vals, + 'op': op_indices, + 'a_bits': a_bits.float(), + 'b_bits': b_bits.float(), + 'op_onehot': op_onehot.float(), + 'expected': expected, + 'expected_bits': expected_bits.float(), + } + + +def compute_fitness( + model_fn: Callable[[torch.Tensor, torch.Tensor, torch.Tensor], torch.Tensor], + n_samples: int = 10000, + batch_size: int = 256, + device: str = 'cuda', + return_details: bool = False +) -> float | Tuple[float, Dict]: + """Compute fitness score for a model.""" + correct = 0 + total = 0 + op_correct = {op: 0 for op in OPERATIONS} + op_total = {op: 0 for op in OPERATIONS} + + for _ in range(0, n_samples, batch_size): + actual_batch = min(batch_size, n_samples - total) + batch = generate_batch(actual_batch, device) + + with torch.no_grad(): + pred_bits = model_fn(batch['a_bits'], batch['b_bits'], batch['op_onehot']) + + pred_bits_binary = (pred_bits > 0.5).float() + + for i in range(actual_batch): + pred_val = 0 + for j in range(8): + pred_val += int(pred_bits_binary[i, j].item()) << (7 - j) + + expected_val = batch['expected'][i].item() + op_name = idx_to_op(batch['op'][i].item()) + + op_total[op_name] += 1 + total += 1 + + if pred_val == expected_val: + correct += 1 + op_correct[op_name] += 1 + + fitness = correct / total if total > 0 else 0.0 + + if return_details: + details = { + 'correct': correct, + 'total': total, + 'by_op': { + op: { + 'correct': op_correct[op], + 'total': op_total[op], + 'accuracy': op_correct[op] / op_total[op] if op_total[op] > 0 else 0.0 + } + for op in OPERATIONS + } + } + return fitness, details + + return fitness + + +def compute_bit_accuracy(pred_bits: torch.Tensor, expected_bits: torch.Tensor) -> float: + """Compute per-bit accuracy (for gradient signal analysis).""" + pred_binary = (pred_bits > 0.5).float() + return (pred_binary == expected_bits).float().mean().item() + + +def compute_loss(pred_bits: torch.Tensor, expected_bits: torch.Tensor) -> torch.Tensor: + """Binary cross-entropy loss on output bits.""" + pred_clamped = pred_bits.clamp(1e-7, 1 - 1e-7) + return -((expected_bits * torch.log(pred_clamped) + + (1 - expected_bits) * torch.log(1 - pred_clamped))).mean() + + +MODEL_ID = 'HuggingFaceTB/SmolLM2-360M-Instruct' + +OP_SYMBOLS = {'add': '+', 'sub': '-', 'mul': '*', 'gt': '>', 'lt': '<', 'eq': '=='} + + +class BitEncoder(nn.Module): + """Encodes two 8-bit operands from input representation.""" + + def __init__(self, input_dim: int = 16 + 6, hidden_dim: int = 32): + super().__init__() + self.refine = nn.Sequential( + nn.Linear(input_dim, hidden_dim), + nn.Tanh(), + nn.Linear(hidden_dim, 16), + ) + self.scale = nn.Parameter(torch.tensor(0.0)) + + def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: + """Split x, whose first 16 features are the bits of a and b, into the two operands.""" + base_bits = x[:, :16] + refinement = self.refine(x) * torch.sigmoid(self.scale) + bits = base_bits + refinement + bits = torch.clamp(bits, 0, 1) + hard_bits = heaviside_ste(bits - 0.5) + out = hard_bits - bits.detach() + bits + + return out[:, :8], out[:, 8:] + + +class OpRouter(nn.Module): + """Routes computation to the appropriate circuit based on input.""" + + def __init__(self, input_dim: int = 16 + 6, hidden_dim: int = 32, n_ops: int = 6): + super().__init__() + self.net = nn.Sequential( + nn.Linear(input_dim, hidden_dim), + nn.ReLU(), + nn.Linear(hidden_dim, n_ops), + ) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + """Route each input row to the circuit its features select.""" + logits = self.net(x) + return F.softmax(logits, dim=-1) + + +class BitDecoder(nn.Module): + """Decodes circuit output bits to target representation.""" + + def __init__(self, output_dim: int = 8): + super().__init__() + self.output_dim = output_dim + + def forward(self, result_bits: torch.Tensor) -> torch.Tensor: + return result_bits + + +class ThresholdALU(nn.Module): + """Complete trainable interface + frozen circuits.""" + + def __init__(self, device: str = 'cuda'): + super().__init__() + self.device = device + + self.circuits = FrozenThresholdCircuits(device=device) + + for key in self.circuits.weights: + self.circuits.weights[key].requires_grad = False + + self.encoder = BitEncoder(input_dim=16 + 6, hidden_dim=64).to(device) + self.router = OpRouter(input_dim=16 + 6, hidden_dim=32, n_ops=6).to(device) + self.decoder = BitDecoder(output_dim=8).to(device) + + def forward(self, a_bits_in: torch.Tensor, b_bits_in: torch.Tensor, + op_onehot: torch.Tensor) -> torch.Tensor: + """Forward pass through trainable interface + frozen circuits.""" + x = torch.cat([a_bits_in, b_bits_in, op_onehot], dim=-1) + + a_bits, b_bits = self.encoder(x) + + op_weights = self.router(x) + + result = self.circuits(a_bits, b_bits, op_weights) + + output = self.decoder(result) + + return output + + def forward_direct(self, a_bits: torch.Tensor, b_bits: torch.Tensor, + op_onehot: torch.Tensor) -> torch.Tensor: + """ + Direct forward through circuits (bypass encoder/router for testing). + """ + return self.circuits(a_bits, b_bits, op_onehot) + + +class DirectCircuitModel(nn.Module): + """Minimal model that directly uses circuits without learned encoding.""" + + def __init__(self, device: str = 'cuda'): + super().__init__() + self.device = device + self.circuits = FrozenThresholdCircuits(device=device) + + def forward(self, a_bits: torch.Tensor, b_bits: torch.Tensor, + op_onehot: torch.Tensor) -> torch.Tensor: + return self.circuits(a_bits, b_bits, op_onehot) + + +class HiddenStateExtractor(nn.Module): + """Extracts operands and operation from LLM hidden states.""" + + def __init__(self, hidden_dim: int = 960, intermediate_dim: int = 256): + super().__init__() + + self.a_extractor = nn.Sequential( + nn.Linear(hidden_dim, intermediate_dim), + nn.GELU(), + nn.Linear(intermediate_dim, 8), + ) + + self.b_extractor = nn.Sequential( + nn.Linear(hidden_dim, intermediate_dim), + nn.GELU(), + nn.Linear(intermediate_dim, 8), + ) + + self.op_router = nn.Sequential( + nn.Linear(hidden_dim, intermediate_dim), + nn.GELU(), + nn.Linear(intermediate_dim, len(OPERATIONS)), + ) + + def forward(self, hidden_states: torch.Tensor): + """Extract the operand bits and the operation from the hidden states.""" + a_logits = self.a_extractor(hidden_states) + b_logits = self.b_extractor(hidden_states) + op_logits = self.op_router(hidden_states) + + a_soft = torch.sigmoid(a_logits) + b_soft = torch.sigmoid(b_logits) + + a_hard = heaviside_ste(a_logits) + b_hard = heaviside_ste(b_logits) + + a_bits = a_hard - a_soft.detach() + a_soft + b_bits = b_hard - b_soft.detach() + b_soft + + return a_bits, b_bits, op_logits + + +class AttentionPooling(nn.Module): + """Learnable attention pooling over sequence positions.""" + + def __init__(self, hidden_dim: int = 960, num_heads: int = 4): + super().__init__() + self.num_heads = num_heads + self.head_dim = hidden_dim // num_heads + + self.query = nn.Linear(hidden_dim, hidden_dim) + self.key = nn.Linear(hidden_dim, hidden_dim) + self.value = nn.Linear(hidden_dim, hidden_dim) + self.out_proj = nn.Linear(hidden_dim, hidden_dim) + + self.cls_token = nn.Parameter(torch.randn(1, 1, hidden_dim) * 0.02) + + def forward(self, embeddings: torch.Tensor, mask: torch.Tensor) -> torch.Tensor: + """Pool the embeddings over the positions the mask attends.""" + batch_size, seq_len, hidden_dim = embeddings.shape + + cls_expanded = self.cls_token.expand(batch_size, -1, -1) + embeddings = torch.cat([cls_expanded, embeddings], dim=1) + + cls_mask = torch.ones(batch_size, 1, device=mask.device) + mask = torch.cat([cls_mask, mask], dim=1) + + Q = self.query(embeddings[:, :1, :]) + K = self.key(embeddings) + V = self.value(embeddings) + + Q = Q.view(batch_size, 1, self.num_heads, self.head_dim).transpose(1, 2) + K = K.view(batch_size, seq_len + 1, self.num_heads, self.head_dim).transpose(1, 2) + V = V.view(batch_size, seq_len + 1, self.num_heads, self.head_dim).transpose(1, 2) + + scores = torch.matmul(Q, K.transpose(-2, -1)) / (self.head_dim ** 0.5) + + mask_expanded = mask.unsqueeze(1).unsqueeze(2) + scores = scores.masked_fill(mask_expanded == 0, -1e9) + + attn_weights = torch.softmax(scores, dim=-1) + attn_weights = torch.nan_to_num(attn_weights, nan=0.0) + + context = torch.matmul(attn_weights, V) + context = context.transpose(1, 2).contiguous().view(batch_size, 1, hidden_dim) + + pooled = self.out_proj(context).squeeze(1) + pooled = torch.nan_to_num(pooled, nan=0.0) + + return pooled + + +class MultiHeadBitExtractor(nn.Module): + """8 separate extractors for 8 bits - each bit gets its own specialized network.""" + + def __init__(self, hidden_dim: int = 960, intermediate_dim: int = 128): + super().__init__() + + self.bit_extractors = nn.ModuleList([ + nn.Sequential( + nn.Linear(hidden_dim, intermediate_dim), + nn.GELU(), + nn.Linear(intermediate_dim, 1), + ) + for _ in range(8) + ]) + + def forward(self, hidden_states: torch.Tensor) -> torch.Tensor: + """Predict the eight bits of each operand from the hidden states.""" + hidden_states = torch.nan_to_num(hidden_states, nan=0.0) + + bit_logits = [extractor(hidden_states) for extractor in self.bit_extractors] + logits = torch.cat(bit_logits, dim=-1) + logits = torch.clamp(logits, -20, 20) + + soft = torch.sigmoid(logits) + hard = heaviside_ste(logits) + bits = hard - soft.detach() + soft + + return bits, logits + + +class Extractor(nn.Module): + """Extracts operands and operation from LLM hidden states.""" + + def __init__(self, hidden_dim: int = 960, intermediate_dim: int = 256, num_heads: int = 4): + super().__init__() + + self.attention_pool = AttentionPooling(hidden_dim, num_heads) + + self.a_extractor = MultiHeadBitExtractor(hidden_dim, intermediate_dim // 2) + self.b_extractor = MultiHeadBitExtractor(hidden_dim, intermediate_dim // 2) + + self.op_router = nn.Sequential( + nn.Linear(hidden_dim, intermediate_dim), + nn.GELU(), + nn.Linear(intermediate_dim, len(OPERATIONS)), + ) + + def forward(self, embeddings: torch.Tensor, mask: torch.Tensor): + """Extract the operands and the operation from the pooled sequence.""" + pooled = self.attention_pool(embeddings, mask) + + a_bits, _ = self.a_extractor(pooled) + b_bits, _ = self.b_extractor(pooled) + op_logits = self.op_router(pooled) + + return a_bits, b_bits, op_logits + + +class PositionExtractor(nn.Module): + """Position-specific extraction with dynamic operator detection.""" + + DIGIT_TOKENS = set(range(32, 42)) + OPERATOR_TOKENS = { + 1232: 0, # ' +' -> add + 731: 1, # ' -' -> sub + 1672: 2, # ' *' -> mul + 2986: 3, # ' >' -> gt + 2067: 4, # ' <' -> lt + 1758: 5, # ' ==' -> eq + } + SPACE_TOKEN = 216 + MAX_DIGITS = 3 + + def __init__(self, hidden_dim: int = 960, intermediate_dim: int = 256): + super().__init__() + self.hidden_dim = hidden_dim + + self.a_extractor = nn.Sequential( + nn.Linear(hidden_dim * self.MAX_DIGITS, intermediate_dim), + nn.GELU(), + nn.Linear(intermediate_dim, intermediate_dim // 2), + nn.GELU(), + nn.Linear(intermediate_dim // 2, 8), + ) + + self.b_extractor = nn.Sequential( + nn.Linear(hidden_dim * self.MAX_DIGITS, intermediate_dim), + nn.GELU(), + nn.Linear(intermediate_dim, intermediate_dim // 2), + nn.GELU(), + nn.Linear(intermediate_dim // 2, 8), + ) + + self.op_extractor = nn.Sequential( + nn.Linear(hidden_dim, intermediate_dim // 2), + nn.GELU(), + nn.Linear(intermediate_dim // 2, len(OPERATIONS)), + ) + + def _find_operator_position(self, token_ids: torch.Tensor) -> tuple[int, int]: + """Find operator token position and its operation index.""" + for pos, tid in enumerate(token_ids.tolist()): + if tid in self.OPERATOR_TOKENS: + return pos, self.OPERATOR_TOKENS[tid] + return -1, -1 + + def _extract_digit_features(self, hidden: torch.Tensor, start: int, end: int) -> torch.Tensor: + """Extract and pad digit hidden states to fixed size.""" + n_digits = end - start + features = torch.zeros(self.MAX_DIGITS * self.hidden_dim, device=hidden.device) + + if n_digits > 0 and n_digits <= self.MAX_DIGITS: + digit_hidden = hidden[start:end, :].reshape(-1) + pad_size = (self.MAX_DIGITS - n_digits) * self.hidden_dim + features[pad_size:] = digit_hidden + + return features + + def forward(self, hidden: torch.Tensor, mask: torch.Tensor, token_ids: torch.Tensor = None): + """Extract the operands at the positions around the detected operator token.""" + if token_ids is None: + raise ValueError("PositionExtractor requires token_ids for operator detection") + + batch_size, seq_len, hidden_dim = hidden.shape + device = hidden.device + + a_features = [] + b_features = [] + op_features = [] + op_indices = [] + + for i in range(batch_size): + seq_mask = mask[i].bool() + valid_len = seq_mask.sum().item() + start_pos = seq_len - valid_len + + valid_tokens = token_ids[i, start_pos:] + valid_hidden = hidden[i, start_pos:, :] + + op_pos, op_idx = self._find_operator_position(valid_tokens) + + if op_pos == -1: + a_feat = torch.zeros(self.MAX_DIGITS * hidden_dim, device=device) + b_feat = torch.zeros(self.MAX_DIGITS * hidden_dim, device=device) + op_feat = torch.zeros(hidden_dim, device=device) + op_idx = 0 + else: + a_feat = self._extract_digit_features(valid_hidden, 0, op_pos) + + op_feat = valid_hidden[op_pos, :] + + b_start = op_pos + 2 if (op_pos + 1 < valid_len and + valid_tokens[op_pos + 1].item() == self.SPACE_TOKEN) else op_pos + 1 + b_feat = self._extract_digit_features(valid_hidden, b_start, valid_len) + + a_features.append(a_feat) + b_features.append(b_feat) + op_features.append(op_feat) + op_indices.append(op_idx) + + a_features = torch.stack(a_features) + b_features = torch.stack(b_features) + op_features = torch.stack(op_features) + op_indices_tensor = torch.tensor(op_indices, device=device, dtype=torch.long) + + a_logits = self.a_extractor(a_features) + b_logits = self.b_extractor(b_features) + op_logits = self.op_extractor(op_features) + + a_soft = torch.sigmoid(a_logits) + b_soft = torch.sigmoid(b_logits) + a_hard = heaviside_ste(a_logits) + b_hard = heaviside_ste(b_logits) + a_bits = a_hard - a_soft.detach() + a_soft + b_bits = b_hard - b_soft.detach() + b_soft + + return a_bits, b_bits, op_logits, op_indices_tensor + + +class PositionalDigitExtractor(nn.Module): + """Position-aware digit extraction: classifies each digit position independently.""" + + DIGIT_TOKENS = set(range(32, 42)) + OPERATOR_TOKENS = { + 1232: 0, # ' +' -> add + 731: 1, # ' -' -> sub + 1672: 2, # ' *' -> mul + 2986: 3, # ' >' -> gt + 2067: 4, # ' <' -> lt + 1758: 5, # ' ==' -> eq + } + SPACE_TOKEN = 216 + + def __init__(self, hidden_dim: int = 960): + super().__init__() + self.hidden_dim = hidden_dim + + self.digit_classifier = nn.Linear(hidden_dim, 10) + + self.op_classifier = nn.Linear(hidden_dim, len(OPERATIONS)) + + def _find_positions(self, token_ids: torch.Tensor) -> tuple: + """Find A digit positions, B digit positions, and operator position.""" + token_list = token_ids.tolist() + + op_pos = -1 + op_idx = 0 + for i, tid in enumerate(token_list): + if tid in self.OPERATOR_TOKENS: + op_pos = i + op_idx = self.OPERATOR_TOKENS[tid] + break + + if op_pos == -1: + return [], [], -1, 0 + + a_positions = [i for i in range(op_pos) if token_list[i] in self.DIGIT_TOKENS] + + b_start = op_pos + 2 if (op_pos + 1 < len(token_list) and + token_list[op_pos + 1] == self.SPACE_TOKEN) else op_pos + 1 + b_positions = [i for i in range(b_start, len(token_list)) if token_list[i] in self.DIGIT_TOKENS] + + return a_positions, b_positions, op_pos, op_idx + + def _predict_value(self, hidden: torch.Tensor, positions: list) -> tuple: + """Predict digit at each position and reconstruct number.""" + if not positions: + return torch.tensor(0.0, device=hidden.device), [] + + digit_logits_list = [] + soft_value = torch.tensor(0.0, device=hidden.device) + + for idx, pos in enumerate(positions): + logits = self.digit_classifier(hidden[pos]) + digit_logits_list.append(logits) + + probs = torch.softmax(logits, dim=-1) + digit_values = torch.arange(10, device=hidden.device, dtype=torch.float32) + soft_digit = (probs * digit_values).sum() + + place_value = 10 ** (len(positions) - idx - 1) + soft_value = soft_value + soft_digit * place_value + + return soft_value, digit_logits_list + + def _value_to_bits(self, value: torch.Tensor) -> torch.Tensor: + """Convert soft value to 8 bits using differentiable operations.""" + value = torch.clamp(value, 0, 255) + + bits = [] + for i in range(7, -1, -1): + bit = torch.sigmoid((value - (2 ** i - 0.5)) * 10) + value = value - bit * (2 ** i) + bits.append(bit) + + return torch.stack(bits) + + def forward(self, hidden: torch.Tensor, mask: torch.Tensor, token_ids: torch.Tensor): + """Classify each digit position of both operands.""" + batch_size = hidden.shape[0] + device = hidden.device + + a_bits_list = [] + b_bits_list = [] + op_logits_list = [] + op_indices_list = [] + a_values_list = [] + b_values_list = [] + a_digit_logits_list = [] + b_digit_logits_list = [] + + for i in range(batch_size): + seq_mask = mask[i].bool() + valid_len = seq_mask.sum().item() + start_pos = hidden.shape[1] - valid_len + + valid_hidden = hidden[i, start_pos:] + valid_tokens = token_ids[i, start_pos:] + + a_pos, b_pos, op_pos, op_idx = self._find_positions(valid_tokens) + + a_value, a_digit_logits = self._predict_value(valid_hidden, a_pos) + b_value, b_digit_logits = self._predict_value(valid_hidden, b_pos) + + a_bits = self._value_to_bits(a_value) + b_bits = self._value_to_bits(b_value) + + if op_pos >= 0: + op_logits = self.op_classifier(valid_hidden[op_pos]) + else: + op_logits = torch.zeros(len(OPERATIONS), device=device) + + a_bits_list.append(a_bits) + b_bits_list.append(b_bits) + op_logits_list.append(op_logits) + op_indices_list.append(op_idx) + a_values_list.append(a_value) + b_values_list.append(b_value) + a_digit_logits_list.append(a_digit_logits) + b_digit_logits_list.append(b_digit_logits) + + a_bits = torch.stack(a_bits_list) + b_bits = torch.stack(b_bits_list) + op_logits = torch.stack(op_logits_list) + op_indices = torch.tensor(op_indices_list, device=device, dtype=torch.long) + a_values = torch.stack(a_values_list) + b_values = torch.stack(b_values_list) + + return a_bits, b_bits, op_logits, op_indices, a_values, b_values, a_digit_logits_list, b_digit_logits_list + + +class DigitExtractor(nn.Module): + """Digit-level extraction: predicts digits (0-9) then converts to bits.""" + + def __init__(self, hidden_dim: int = 960, intermediate_dim: int = 256, num_heads: int = 4): + super().__init__() + + self.attention_pool = AttentionPooling(hidden_dim, num_heads) + + self.a_digit_pred = nn.Sequential( + nn.Linear(hidden_dim, intermediate_dim), + nn.GELU(), + nn.Linear(intermediate_dim, 3 * 10), + ) + + self.b_digit_pred = nn.Sequential( + nn.Linear(hidden_dim, intermediate_dim), + nn.GELU(), + nn.Linear(intermediate_dim, 3 * 10), + ) + + self.op_router = nn.Sequential( + nn.Linear(hidden_dim, intermediate_dim), + nn.GELU(), + nn.Linear(intermediate_dim, len(OPERATIONS)), + ) + + def digits_to_bits(self, digit_logits: torch.Tensor) -> torch.Tensor: + """Convert the digit logits of three digits, [batch, 30], to 8-bit operands.""" + batch_size = digit_logits.shape[0] + + logits = digit_logits.view(batch_size, 3, 10) + probs = torch.softmax(logits, dim=-1) + + digit_values = torch.arange(10, device=digit_logits.device).float() + soft_digits = (probs * digit_values).sum(dim=-1) + + hundreds = soft_digits[:, 0] + tens = soft_digits[:, 1] + ones = soft_digits[:, 2] + + value = hundreds * 100 + tens * 10 + ones + value = torch.clamp(value, 0, 255) + + bits = [] + for i in range(7, -1, -1): + bit = torch.fmod(torch.floor(value / (2 ** i)), 2) + bits.append(bit) + + return torch.stack(bits, dim=-1) + + def forward(self, hidden: torch.Tensor, mask: torch.Tensor): + """Return the operand bits, the operation logits and the digit logits.""" + pooled = self.attention_pool(hidden, mask) + + a_digit_logits = self.a_digit_pred(pooled) + b_digit_logits = self.b_digit_pred(pooled) + op_logits = self.op_router(pooled) + + a_bits = self.digits_to_bits(a_digit_logits) + b_bits = self.digits_to_bits(b_digit_logits) + + return a_bits, b_bits, op_logits, a_digit_logits, b_digit_logits + + +class HybridExtractor(nn.Module): + """Hybrid extractor that handles both digit tokens and word numbers.""" + + DIGIT_TOKENS = set(range(32, 42)) + SYMBOL_OP_TOKENS = { + 1232: 0, # ' +' -> add + 731: 1, # ' -' -> sub + 1672: 2, # ' *' -> mul + 2986: 3, # ' >' -> gt + 2067: 4, # ' <' -> lt + 1758: 5, # ' ==' -> eq + } + WORD_OP_TOKENS = { + 2068: 0, # 'plus' -> add + 8500: 1, # 'minus' -> sub + 1580: 2, # 'times' -> mul + 6301: 3, # 'greater' -> gt + 1912: 4, # 'less' -> lt + 16364: 5, # 'equals' -> eq + 11540: 5, # 'equal' -> eq + } + ALL_OP_TOKENS = {**SYMBOL_OP_TOKENS, **WORD_OP_TOKENS} + + def __init__(self, hidden_dim: int = 960, intermediate_dim: int = 256, num_heads: int = 4): + super().__init__() + self.hidden_dim = hidden_dim + + self.attention_pool = AttentionPooling(hidden_dim, num_heads) + self.a_pool = AttentionPooling(hidden_dim, num_heads) + self.b_pool = AttentionPooling(hidden_dim, num_heads) + + self.a_digit_pred = nn.Sequential( + nn.Linear(hidden_dim, intermediate_dim), + nn.GELU(), + nn.Dropout(0.1), + nn.Linear(intermediate_dim, 3 * 10), + ) + + self.b_digit_pred = nn.Sequential( + nn.Linear(hidden_dim, intermediate_dim), + nn.GELU(), + nn.Dropout(0.1), + nn.Linear(intermediate_dim, 3 * 10), + ) + + self.op_predictor = nn.Sequential( + nn.Linear(hidden_dim, intermediate_dim // 2), + nn.GELU(), + nn.Linear(intermediate_dim // 2, len(OPERATIONS)), + ) + + def _has_digit_tokens(self, token_ids: torch.Tensor) -> bool: + """Check if input contains digit tokens.""" + for tid in token_ids.tolist(): + if tid in self.DIGIT_TOKENS: + return True + return False + + def _find_op_position(self, token_ids: torch.Tensor) -> int: + """Find position of operator token, returns -1 if not found.""" + tokens = token_ids.tolist() + for i, tid in enumerate(tokens): + if tid in self.ALL_OP_TOKENS: + return i + return -1 + + def _extract_from_digits(self, token_ids: torch.Tensor) -> tuple: + """Extract values directly from digit tokens (hardcoded lookup).""" + tokens = token_ids.tolist() + + op_pos = -1 + op_idx = 0 + for i, tid in enumerate(tokens): + if tid in self.ALL_OP_TOKENS: + op_pos = i + op_idx = self.ALL_OP_TOKENS[tid] + break + + if op_pos == -1: + return None + + a_digits = [] + for i in range(op_pos): + if tokens[i] in self.DIGIT_TOKENS: + a_digits.append(tokens[i] - 32) + + b_start = op_pos + 1 + if b_start < len(tokens) and tokens[b_start] == 216: + b_start += 1 + + b_digits = [] + for i in range(b_start, len(tokens)): + if tokens[i] in self.DIGIT_TOKENS: + b_digits.append(tokens[i] - 32) + + if not a_digits or not b_digits: + return None + + a_val = 0 + for d in a_digits: + a_val = a_val * 10 + d + + b_val = 0 + for d in b_digits: + b_val = b_val * 10 + d + + return min(a_val, 255), min(b_val, 255), op_idx + + def _value_to_bits(self, value: int, device) -> torch.Tensor: + """Convert integer to 8-bit tensor.""" + bits = torch.zeros(8, device=device) + for i in range(8): + bits[7 - i] = (value >> i) & 1 + return bits + + def _digits_to_value_and_bits(self, digit_logits: torch.Tensor, device) -> tuple: + """Convert the 30 logits of three digits to a value and its 8 bits.""" + logits = digit_logits.view(3, 10) + probs = torch.softmax(logits, dim=-1) + + digit_values = torch.arange(10, device=device, dtype=torch.float32) + soft_digits = (probs * digit_values).sum(dim=-1) + + hundreds = soft_digits[0] + tens = soft_digits[1] + ones = soft_digits[2] + + value = hundreds * 100 + tens * 10 + ones + value = torch.clamp(value, 0, 255) + + bits = [] + for i in range(7, -1, -1): + threshold = 2 ** i + bit = torch.sigmoid((value - threshold + 0.5) * 10) + bits.append(bit) + value = value - bit * threshold + return hundreds * 100 + tens * 10 + ones, torch.stack(bits) + + def forward(self, hidden: torch.Tensor, mask: torch.Tensor, token_ids: torch.Tensor = None): + """Extract the operands from digit tokens by lookup, or from the hidden states.""" + batch_size = hidden.shape[0] + device = hidden.device + + a_bits_list = [] + a_digit_logits_list = [] + b_digit_logits_list = [] + b_bits_list = [] + op_logits_list = [] + a_values_list = [] + b_values_list = [] + used_lookup_list = [] + + pooled = self.attention_pool(hidden, mask) + + for i in range(batch_size): + lookup_result = None + if token_ids is not None: + seq_mask = mask[i].bool() + valid_len = seq_mask.sum().item() + start_pos = hidden.shape[1] - valid_len + valid_tokens = token_ids[i, start_pos:] + + if self._has_digit_tokens(valid_tokens): + lookup_result = self._extract_from_digits(valid_tokens) + + if lookup_result is not None: + a_val, b_val, op_idx = lookup_result + a_bits = self._value_to_bits(a_val, device) + b_bits = self._value_to_bits(b_val, device) + op_logits = torch.zeros(len(OPERATIONS), device=device) + op_logits[op_idx] = 10.0 + + a_bits_list.append(a_bits) + b_bits_list.append(b_bits) + op_logits_list.append(op_logits) + a_values_list.append(float(a_val)) + b_values_list.append(float(b_val)) + used_lookup_list.append(True) + a_digit_logits_list.append(None) + b_digit_logits_list.append(None) + else: + sample_hidden = hidden[i:i+1] + sample_mask = mask[i:i+1] + + seq_mask = mask[i].bool() + valid_len = int(seq_mask.sum().item()) + start_pos = hidden.shape[1] - valid_len + valid_tokens = token_ids[i, start_pos:] if token_ids is not None else None + + op_pos = self._find_op_position(valid_tokens) if valid_tokens is not None else -1 + + if op_pos > 0 and op_pos < valid_len - 1: + a_end = start_pos + op_pos + b_start = start_pos + op_pos + 1 + + a_mask = torch.zeros_like(sample_mask) + a_mask[0, start_pos:a_end] = 1.0 + b_mask = torch.zeros_like(sample_mask) + b_mask[0, b_start:] = sample_mask[0, b_start:] + + a_pooled = self.a_pool(sample_hidden, a_mask)[0] + b_pooled = self.b_pool(sample_hidden, b_mask)[0] + else: + a_pooled = pooled[i] + b_pooled = pooled[i] + + a_digit_logits = self.a_digit_pred(a_pooled) + b_digit_logits = self.b_digit_pred(b_pooled) + op_logits = self.op_predictor(pooled[i]) + + a_val, a_bits = self._digits_to_value_and_bits(a_digit_logits, device) + b_val, b_bits = self._digits_to_value_and_bits(b_digit_logits, device) + + a_bits_list.append(a_bits) + b_bits_list.append(b_bits) + op_logits_list.append(op_logits) + a_values_list.append(a_val) + b_values_list.append(b_val) + used_lookup_list.append(False) + a_digit_logits_list.append(a_digit_logits) + b_digit_logits_list.append(b_digit_logits) + + a_bits = torch.stack(a_bits_list) + b_bits = torch.stack(b_bits_list) + op_logits = torch.stack(op_logits_list) + a_values = torch.stack([v if isinstance(v, torch.Tensor) else torch.tensor(v, device=device) for v in a_values_list]) + b_values = torch.stack([v if isinstance(v, torch.Tensor) else torch.tensor(v, device=device) for v in b_values_list]) + used_lookup = torch.tensor(used_lookup_list, device=device, dtype=torch.bool) + valid_a_logits = [x for x in a_digit_logits_list if x is not None] + valid_b_logits = [x for x in b_digit_logits_list if x is not None] + a_digit_logits_out = torch.stack(valid_a_logits) if valid_a_logits else None + b_digit_logits_out = torch.stack(valid_b_logits) if valid_b_logits else None + + return a_bits, b_bits, op_logits, a_values, b_values, used_lookup, a_digit_logits_out, b_digit_logits_out + + def _soft_value_to_bits(self, value: torch.Tensor, device) -> torch.Tensor: + """Convert soft value (0-255) to 8-bit representation differentiably.""" + value = torch.clamp(value, 0, 255) + bits = [] + remaining = value + for i in range(7, -1, -1): + threshold = 2 ** i + bit = torch.sigmoid((remaining - threshold + 0.5) * 10) + bits.append(bit) + remaining = remaining - bit * threshold + return torch.stack(bits) + + +class ArithmeticModel(nn.Module): + """LLM + extractor + frozen threshold circuits.""" + + def __init__(self, device: str = 'cuda', unfreeze_layers: int = 0, + extract_layer: int = -1, position_extract: bool = False, + digit_pred: bool = False, positional_digit: bool = False, + hybrid: bool = False): + super().__init__() + self.device = device + self.unfreeze_layers = unfreeze_layers + self.extract_layer = extract_layer + self.position_extract = position_extract + self.digit_pred = digit_pred + self.positional_digit = positional_digit + self.hybrid = hybrid + + from transformers import AutoModelForCausalLM, AutoTokenizer + + print("[1/4] Loading tokenizer...", flush=True) + self.tokenizer = AutoTokenizer.from_pretrained(MODEL_ID) + self.tokenizer.padding_side = 'left' + if self.tokenizer.pad_token is None: + self.tokenizer.pad_token = self.tokenizer.eos_token + print(" Tokenizer loaded.", flush=True) + + print("[2/4] Loading SmolLM2-360M...", flush=True) + self.llm = AutoModelForCausalLM.from_pretrained( + MODEL_ID, + torch_dtype=torch.float16, + device_map=device, + output_hidden_states=True + ) + + for param in self.llm.parameters(): + param.requires_grad = False + + if unfreeze_layers > 0: + num_layers = len(self.llm.model.layers) + layers_to_unfreeze = list(range(num_layers - unfreeze_layers, num_layers)) + print(f" Unfreezing layers {layers_to_unfreeze}...", flush=True) + for layer_idx in layers_to_unfreeze: + for param in self.llm.model.layers[layer_idx].parameters(): + param.requires_grad = True + + hidden_dim = self.llm.config.hidden_size + llm_params = sum(p.numel() for p in self.llm.parameters()) + trainable_llm = sum(p.numel() for p in self.llm.parameters() if p.requires_grad) + print(f" LLM loaded. Hidden dim: {hidden_dim}", flush=True) + print(f" LLM params: {llm_params:,} total, {trainable_llm:,} trainable", flush=True) + + print("[3/4] Loading threshold circuits...", flush=True) + self.circuits = FrozenThresholdCircuits(device=device) + print(f" Circuits loaded. {len(self.circuits.weights)} tensors", flush=True) + + print("[4/4] Initializing extractor...", flush=True) + if hybrid: + print(" Using HYBRID extraction (digit lookup + word learning)", flush=True) + self.extractor = HybridExtractor( + hidden_dim=hidden_dim, + intermediate_dim=256, + num_heads=4 + ).to(device) + elif positional_digit: + print(" Using POSITIONAL DIGIT extraction (100% proven)", flush=True) + self.extractor = PositionalDigitExtractor( + hidden_dim=hidden_dim + ).to(device) + elif position_extract: + print(" Using position-specific extraction", flush=True) + self.extractor = PositionExtractor( + hidden_dim=hidden_dim, + intermediate_dim=256 + ).to(device) + elif digit_pred: + print(" Using digit-level prediction", flush=True) + self.extractor = DigitExtractor( + hidden_dim=hidden_dim, + intermediate_dim=256, + num_heads=4 + ).to(device) + else: + self.extractor = Extractor( + hidden_dim=hidden_dim, + intermediate_dim=256, + num_heads=4 + ).to(device) + + if extract_layer != -1: + print(f" Extracting from layer {extract_layer}", flush=True) + + trainable_ext = sum(p.numel() for p in self.extractor.parameters()) + total_trainable = trainable_llm + trainable_ext + print(f" Extractor params: {trainable_ext:,}", flush=True) + print(f" Total trainable: {total_trainable:,}", flush=True) + + def get_hidden_states(self, texts: list[str]) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """Get hidden states from specified layer.""" + inputs = self.tokenizer( + texts, + return_tensors='pt', + padding=True, + truncation=True, + max_length=64 + ).to(self.device) + + if self.unfreeze_layers > 0: + outputs = self.llm(**inputs, output_hidden_states=True) + else: + with torch.no_grad(): + outputs = self.llm(**inputs, output_hidden_states=True) + + hidden = outputs.hidden_states[self.extract_layer].float() + mask = inputs.attention_mask.float() + token_ids = inputs.input_ids + + return hidden, mask, token_ids + + def forward(self, texts: list[str]): + """Run text through the LLM, the extractor and the circuits to the result.""" + hidden, mask, token_ids = self.get_hidden_states(texts) + + if self.hybrid or self.positional_digit or self.position_extract: + extractor_out = self.extractor(hidden, mask, token_ids) + else: + extractor_out = self.extractor(hidden, mask) + + if self.hybrid: + a_bits, b_bits, op_logits, a_values, b_values, used_lookup, a_digit_logits, b_digit_logits = extractor_out + op_indices_from_tokens = None + elif self.positional_digit: + a_bits, b_bits, op_logits, op_indices_from_tokens, a_values, b_values, a_digit_logits, b_digit_logits = extractor_out + used_lookup = None + elif self.digit_pred: + a_bits, b_bits, op_logits, a_digit_logits, b_digit_logits = extractor_out + op_indices_from_tokens = None + a_values, b_values = None, None + used_lookup = None + elif self.position_extract: + a_bits, b_bits, op_logits, op_indices_from_tokens = extractor_out + a_digit_logits, b_digit_logits = None, None + a_values, b_values = None, None + used_lookup = None + else: + a_bits, b_bits, op_logits = extractor_out + a_digit_logits, b_digit_logits = None, None + op_indices_from_tokens = None + a_values, b_values = None, None + used_lookup = None + + op_probs = torch.softmax(op_logits, dim=-1) + + result_bits = self.circuits(a_bits, b_bits, op_probs) + + if self.hybrid: + return result_bits, a_bits, b_bits, op_logits, a_values, b_values, used_lookup, a_digit_logits, b_digit_logits + if self.positional_digit: + return result_bits, a_bits, b_bits, op_logits, op_indices_from_tokens, a_values, b_values, a_digit_logits, b_digit_logits + if self.digit_pred: + return result_bits, a_bits, b_bits, op_logits, a_digit_logits, b_digit_logits + if self.position_extract: + return result_bits, a_bits, b_bits, op_logits, op_indices_from_tokens + return result_bits, a_bits, b_bits, op_logits + + def trainable_parameters(self): + """Return all trainable parameters for optimizer.""" + params = list(self.extractor.parameters()) + if self.unfreeze_layers > 0: + params += [p for p in self.llm.parameters() if p.requires_grad] + return params + + +DEVICE = 'cuda' + +ONES = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', + 'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', + 'seventeen', 'eighteen', 'nineteen'] + +TENS = ['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety'] + +def int_to_words(n: int) -> str: + """Convert integer 0-255 to English words.""" + if n < 0 or n > 255: + return str(n) + if n < 20: + return ONES[n] + if n < 100: + if n % 10 == 0: + return TENS[n // 10] + return f"{TENS[n // 10]} {ONES[n % 10]}" + if n % 100 == 0: + return f"{ONES[n // 100]} hundred" + if n % 100 < 20: + return f"{ONES[n // 100]} hundred {ONES[n % 100]}" + if n % 10 == 0: + return f"{ONES[n // 100]} hundred {TENS[(n % 100) // 10]}" + return f"{ONES[n // 100]} hundred {TENS[(n % 100) // 10]} {ONES[n % 10]}" + + +def train_int_to_bits(val: int, device: str = 'cuda') -> torch.Tensor: + bits = torch.zeros(8, device=device) + for i in range(8): + bits[7-i] = (val >> i) & 1 + return bits + + +def train_bits_to_int(bits: torch.Tensor) -> int: + val = 0 + for i in range(8): + if bits[i].item() > 0.5: + val += 1 << (7-i) + return val + + +NL_TEMPLATES = { + 'add': [ + "What is {a} plus {b}?", + "Calculate {a} + {b}", + "Add {a} and {b}", + "What's the sum of {a} and {b}?", + "If I have {a} and get {b} more, how many total?", + "{a} + {b} = ?", + "Compute {a} plus {b}", + ], + 'sub': [ + "What is {a} minus {b}?", + "Calculate {a} - {b}", + "Subtract {b} from {a}", + "What's {a} take away {b}?", + "If I have {a} and lose {b}, how many left?", + "{a} - {b} = ?", + "Compute {a} minus {b}", + ], + 'mul': [ + "What is {a} times {b}?", + "Calculate {a} * {b}", + "Multiply {a} by {b}", + "What's {a} multiplied by {b}?", + "{a} * {b} = ?", + "Compute {a} times {b}", + "What is the product of {a} and {b}?", + ], + 'gt': [ + "Is {a} greater than {b}?", + "Is {a} > {b}?", + "Check if {a} is larger than {b}", + "Compare: is {a} more than {b}?", + "{a} > {b}?", + ], + 'lt': [ + "Is {a} less than {b}?", + "Is {a} < {b}?", + "Check if {a} is smaller than {b}", + "Compare: is {a} fewer than {b}?", + "{a} < {b}?", + ], + 'eq': [ + "Is {a} equal to {b}?", + "Is {a} == {b}?", + "Does {a} equal {b}?", + "Check if {a} equals {b}", + "Are {a} and {b} the same?", + ], +} + + +def generate_problem(max_val: int = 255): + """Generate a random arithmetic problem for LLM training.""" + a = random.randint(0, max_val) + b = random.randint(0, max_val) + op = random.choice(OPERATIONS) + + fmt = random.choice(['digits', 'words', 'nl_digits', 'nl_words']) + + if fmt == 'digits': + sym = OP_SYMBOLS[op] + text = f"{a} {sym} {b}" + elif fmt == 'words': + a_word = int_to_words(a) + b_word = int_to_words(b) + op_word = {'add': 'plus', 'sub': 'minus', 'mul': 'times', + 'gt': 'greater than', 'lt': 'less than', 'eq': 'equals'}[op] + text = f"{a_word} {op_word} {b_word}" + elif fmt == 'nl_digits': + template = random.choice(NL_TEMPLATES[op]) + text = template.format(a=a, b=b) + else: + template = random.choice(NL_TEMPLATES[op]) + text = template.format(a=int_to_words(a), b=int_to_words(b)) + + if op == 'add': + result = (a + b) & 0xFF + elif op == 'sub': + result = (a - b) & 0xFF + elif op == 'mul': + result = (a * b) & 0xFF + elif op == 'gt': + result = 1 if a > b else 0 + elif op == 'lt': + result = 1 if a < b else 0 + elif op == 'eq': + result = 1 if a == b else 0 + + return text, a, b, op, result + + +def get_curriculum_max(epoch: int, total_epochs: int) -> int: + """Curriculum learning: start with small numbers, gradually increase.""" + progress = epoch / total_epochs + if progress < 0.2: + return 9 + elif progress < 0.5: + return 99 + else: + return 255 + + +def train_router(epochs: int = 100, batch_size: int = 256, lr: float = 1e-2, device: str = 'cuda'): + """Train only the router with ground truth bits.""" + print("=" * 70) + print(" ROUTER-ONLY TRAINING (Ground Truth Bits)") + print("=" * 70) + + circuits = FrozenThresholdCircuits(device=device) + router = OpRouter(input_dim=16 + 6, hidden_dim=64, n_ops=6).to(device) + + print(f"\nRouter parameters: {sum(p.numel() for p in router.parameters()):,}") + + def model_fn(a_bits, b_bits, op_onehot): + x = torch.cat([a_bits, b_bits, op_onehot], dim=-1) + op_weights = router(x) + return circuits(a_bits, b_bits, op_weights) + + initial_fitness = compute_fitness(model_fn, n_samples=1000, device=device) + print(f"Initial fitness: {initial_fitness:.4f}") + + optimizer = optim.AdamW(router.parameters(), lr=lr) + scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs) + + print("\nTraining...") + print("-" * 70) + + best_fitness = initial_fitness + start_time = time.perf_counter() + + for epoch in range(epochs): + router.train() + epoch_loss = 0.0 + + for _ in range(100): + batch = generate_batch(batch_size, device) + + optimizer.zero_grad() + + x = torch.cat([batch['a_bits'], batch['b_bits'], batch['op_onehot']], dim=-1) + op_weights = router(x) + pred_bits = circuits(batch['a_bits'], batch['b_bits'], op_weights) + + loss = compute_loss(pred_bits, batch['expected_bits']) + loss.backward() + optimizer.step() + + epoch_loss += loss.item() + + scheduler.step() + + if (epoch + 1) % 10 == 0 or epoch == 0: + router.eval() + fitness, details = compute_fitness(model_fn, n_samples=2000, device=device, return_details=True) + elapsed = time.perf_counter() - start_time + + if fitness > best_fitness: + best_fitness = fitness + marker = " *" + else: + marker = "" + + print(f"Epoch {epoch+1:3d} | Loss: {epoch_loss/100:.4f} | " + f"Fitness: {fitness:.4f}{marker} | Time: {elapsed:.1f}s") + + if fitness >= 0.9999: + print("\n TARGET: 100% FITNESS ACHIEVED") + break + + print("\n" + "=" * 70) + print(" RESULTS") + print("=" * 70) + + router.eval() + final_fitness, details = compute_fitness(model_fn, n_samples=5000, device=device, return_details=True) + + print(f"\nFinal fitness: {final_fitness:.4f}") + print(f"\nPer-operation:") + for op in OPERATIONS: + acc = details['by_op'][op]['accuracy'] + print(f" {op}: {acc:.4f}") + + print(f"\nTotal time: {time.perf_counter() - start_time:.1f}s") + + if final_fitness >= 0.99: + print("\nCONCLUSION: Router successfully learned operation dispatch.") + print(" With correct bit encoding, 100% is achievable.") + + save_path = os.path.join(TRAINED, 'router.pt') + torch.save({ + 'router_state_dict': router.state_dict(), + 'final_fitness': final_fitness, + 'params': sum(p.numel() for p in router.parameters()), + }, save_path) + print(f"\nSaved trained router to: {save_path}") + + return router, final_fitness + + +def get_gpu_memory(): + """Get GPU memory usage in MB.""" + if torch.cuda.is_available(): + return torch.cuda.memory_allocated() / 1024 / 1024, torch.cuda.max_memory_allocated() / 1024 / 1024 + return 0, 0 + + +def train_interface(epochs: int = 200, batch_size: int = 512, lr: float = 1e-3, + eval_interval: int = 10, device: str = 'cuda'): + """Train BitEncoder + OpRouter with ground truth bits.""" + print("=" * 70) + print(" INTERFACE TRAINING (Encoder + Router)") + print("=" * 70) + print(f" Started at: {time.strftime('%H:%M:%S')}") + + print("\n[1/4] Verifying frozen circuits...") + print(" Creating DirectCircuitModel...", end=" ", flush=True) + direct_model = DirectCircuitModel(device=device) + mem, max_mem = get_gpu_memory() + print(f"done. VRAM: {mem:.0f}MB") + + def direct_fn(a, b, op): + return direct_model(a, b, op) + + print(" Running fitness check (1000 samples)...", end=" ", flush=True) + circuit_fitness = compute_fitness(direct_fn, n_samples=1000, device=device) + print(f"done. Fitness: {circuit_fitness:.4f}") + if circuit_fitness < 0.999: + print(" ERROR: Circuits not achieving 100%. Aborting.") + return None, 0.0 + print(" STATUS: PASS") + + print("\n[2/4] Initializing model...") + print(" Creating ThresholdALU...", end=" ", flush=True) + model = ThresholdALU(device=device) + mem, max_mem = get_gpu_memory() + print(f"done. VRAM: {mem:.0f}MB") + + trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad) + print(f" Trainable parameters: {trainable_params:,}") + + def model_fn(a, b, op): + return model(a, b, op) + + print(" Running initial fitness check...", end=" ", flush=True) + initial_fitness = compute_fitness(model_fn, n_samples=1000, device=device) + print(f"done. Fitness: {initial_fitness:.4f}") + + print("\n[3/4] Setting up training...") + print(" Creating optimizer...", end=" ", flush=True) + optimizer = optim.AdamW(model.parameters(), lr=lr) + print("done.") + print(" Creating scheduler...", end=" ", flush=True) + scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs) + print("done.") + + print(f" Config: lr={lr}, batch_size={batch_size}, epochs={epochs}") + + print("\n[4/4] Training...") + print(" Generating first batch to warm up...", end=" ", flush=True) + warmup_batch = generate_batch(batch_size, device) + mem, max_mem = get_gpu_memory() + print(f"done. VRAM: {mem:.0f}MB (max: {max_mem:.0f}MB)") + + print("-" * 70) + + best_fitness = initial_fitness + start_time = time.perf_counter() + n_batches = 100 + + for epoch in range(epochs): + model.train() + epoch_loss = 0.0 + epoch_start = time.perf_counter() + + for batch_idx in range(n_batches): + batch = generate_batch(batch_size, device) + + optimizer.zero_grad() + + pred_bits = model(batch['a_bits'], batch['b_bits'], batch['op_onehot']) + + loss = compute_loss(pred_bits, batch['expected_bits']) + + loss.backward() + optimizer.step() + + epoch_loss += loss.item() + + if batch_idx == 0 and epoch == 0: + mem, max_mem = get_gpu_memory() + print(f" First forward/backward done. VRAM: {mem:.0f}MB (max: {max_mem:.0f}MB)") + + if (batch_idx + 1) % 25 == 0: + avg_so_far = epoch_loss / (batch_idx + 1) + print(f" Epoch {epoch+1} batch {batch_idx+1}/{n_batches} | loss: {avg_so_far:.4f}", flush=True) + + scheduler.step() + + avg_loss = epoch_loss / n_batches + epoch_time = time.perf_counter() - epoch_start + + if (epoch + 1) % 5 == 0 or epoch == 0: # Eval every 5 epochs + model.eval() + fitness, details = compute_fitness( + model_fn, n_samples=2000, device=device, return_details=True + ) + + elapsed = time.perf_counter() - start_time + + if fitness > best_fitness: + best_fitness = fitness + marker = " *" + else: + marker = "" + + mem, _ = get_gpu_memory() + print(f"Epoch {epoch+1:4d} | Loss: {avg_loss:.4f} | " + f"Fitness: {fitness:.4f}{marker} | " + f"LR: {scheduler.get_last_lr()[0]:.2e} | " + f"VRAM: {mem:.0f}MB | " + f"Time: {elapsed:.1f}s ({epoch_time:.1f}s/epoch)") + + if fitness >= 0.9999: + print("\n" + "=" * 70) + print(" TARGET ACHIEVED: 100% FITNESS") + print("=" * 70) + break + + print("\n" + "=" * 70) + print(" TRAINING COMPLETE") + print("=" * 70) + + model.eval() + final_fitness, details = compute_fitness( + model_fn, n_samples=5000, device=device, return_details=True + ) + + print(f"\nFinal fitness: {final_fitness:.4f}") + print(f"Best fitness: {best_fitness:.4f}") + print(f"\nPer-operation breakdown:") + for op in OPERATIONS: + acc = details['by_op'][op]['accuracy'] + print(f" {op:6}: {acc:.4f}") + + print(f"\nTotal time: {time.perf_counter() - start_time:.1f}s") + + save_path = os.path.join(TRAINED, 'interface.pt') + torch.save({ + 'encoder_state_dict': model.encoder.state_dict(), + 'router_state_dict': model.router.state_dict(), + 'final_fitness': final_fitness, + 'best_fitness': best_fitness, + }, save_path) + print(f"\nSaved trained model to: {save_path}") + + return model, final_fitness + + +def compute_llm_loss(pred_bits, a_bits, b_bits, op_logits, + target_result, target_a, target_b, target_op_idx, + bit_weight: float = 2.0): + """Multi-component loss for LLM training; bit_weight scales the operand-bit losses.""" + result_loss = nn.functional.binary_cross_entropy_with_logits( + pred_bits, target_result + ) + + a_bits_safe = torch.clamp(a_bits, 0.0, 1.0) + b_bits_safe = torch.clamp(b_bits, 0.0, 1.0) + a_bits_safe = torch.nan_to_num(a_bits_safe, nan=0.5, posinf=1.0, neginf=0.0) + b_bits_safe = torch.nan_to_num(b_bits_safe, nan=0.5, posinf=1.0, neginf=0.0) + + a_loss = nn.functional.binary_cross_entropy( + torch.clamp(a_bits_safe, 1e-6, 1-1e-6), target_a + ) + b_loss = nn.functional.binary_cross_entropy( + torch.clamp(b_bits_safe, 1e-6, 1-1e-6), target_b + ) + + op_loss = nn.functional.cross_entropy(op_logits, target_op_idx) + + total = result_loss + bit_weight * a_loss + bit_weight * b_loss + op_loss + total = torch.nan_to_num(total, nan=10.0, posinf=10.0, neginf=0.0) + + return total, { + 'result': result_loss.item() if not torch.isnan(result_loss) else 10.0, + 'a': a_loss.item() if not torch.isnan(a_loss) else 10.0, + 'b': b_loss.item() if not torch.isnan(b_loss) else 10.0, + 'op': op_loss.item() if not torch.isnan(op_loss) else 10.0 + } + + +def value_to_digits(value: int) -> list: + """Convert integer value to list of digits (hundreds, tens, ones).""" + digits = [] + for place in [100, 10, 1]: + digit = (value // place) % 10 + digits.append(digit) + return digits + + +def compute_positional_digit_loss(pred_bits, op_logits, a_digit_logits_list, b_digit_logits_list, + target_result, target_op_idx, target_a_values, target_b_values, + device, digit_weight: float = 5.0): + """Loss for positional digit extraction with DIRECT digit supervision.""" + result_loss = nn.functional.binary_cross_entropy_with_logits( + pred_bits, target_result + ) + + op_loss = nn.functional.cross_entropy(op_logits, target_op_idx) + + a_digit_loss = torch.tensor(0.0, device=device) + b_digit_loss = torch.tensor(0.0, device=device) + n_a_digits = 0 + n_b_digits = 0 + + for i, (a_logits_list, b_logits_list) in enumerate(zip(a_digit_logits_list, b_digit_logits_list)): + target_a = target_a_values[i].item() + target_b = target_b_values[i].item() + + a_digits = value_to_digits(int(target_a)) + b_digits = value_to_digits(int(target_b)) + + n_a = len(a_logits_list) + n_b = len(b_logits_list) + + if n_a > 0: + target_a_digits = a_digits[-n_a:] + for j, logits in enumerate(a_logits_list): + target_digit = torch.tensor([target_a_digits[j]], device=device, dtype=torch.long) + a_digit_loss = a_digit_loss + nn.functional.cross_entropy(logits.unsqueeze(0), target_digit) + n_a_digits += 1 + + if n_b > 0: + target_b_digits = b_digits[-n_b:] + for j, logits in enumerate(b_logits_list): + target_digit = torch.tensor([target_b_digits[j]], device=device, dtype=torch.long) + b_digit_loss = b_digit_loss + nn.functional.cross_entropy(logits.unsqueeze(0), target_digit) + n_b_digits += 1 + + if n_a_digits > 0: + a_digit_loss = a_digit_loss / n_a_digits + if n_b_digits > 0: + b_digit_loss = b_digit_loss / n_b_digits + + total = result_loss + digit_weight * a_digit_loss + digit_weight * b_digit_loss + op_loss + total = torch.nan_to_num(total, nan=10.0, posinf=10.0, neginf=0.0) + + return total, { + 'result': result_loss.item() if not torch.isnan(result_loss) else 10.0, + 'a_digit': a_digit_loss.item() if not torch.isnan(a_digit_loss) else 10.0, + 'b_digit': b_digit_loss.item() if not torch.isnan(b_digit_loss) else 10.0, + 'op': op_loss.item() if not torch.isnan(op_loss) else 10.0 + } + + +def compute_hybrid_loss(pred_bits, op_logits, used_lookup, + a_digit_logits, b_digit_logits, + target_result, target_a_values, target_b_values, target_op_idx, + device, digit_weight: float = 2.0): + """Loss for hybrid extraction with digit-level prediction.""" + result_loss = nn.functional.binary_cross_entropy_with_logits( + pred_bits, target_result + ) + + op_loss = nn.functional.cross_entropy(op_logits, target_op_idx) + + word_mask = ~used_lookup + n_words = word_mask.sum().item() + + if n_words > 0 and a_digit_logits is not None and b_digit_logits is not None: + target_a_word = target_a_values[word_mask].long() + target_b_word = target_b_values[word_mask].long() + + a_hundreds = target_a_word // 100 + a_tens = (target_a_word % 100) // 10 + a_ones = target_a_word % 10 + + b_hundreds = target_b_word // 100 + b_tens = (target_b_word % 100) // 10 + b_ones = target_b_word % 10 + + a_logits = a_digit_logits.view(-1, 3, 10) + b_logits = b_digit_logits.view(-1, 3, 10) + + a_digit_loss = ( + nn.functional.cross_entropy(a_logits[:, 0], a_hundreds) + + nn.functional.cross_entropy(a_logits[:, 1], a_tens) + + nn.functional.cross_entropy(a_logits[:, 2], a_ones) + ) / 3.0 + + b_digit_loss = ( + nn.functional.cross_entropy(b_logits[:, 0], b_hundreds) + + nn.functional.cross_entropy(b_logits[:, 1], b_tens) + + nn.functional.cross_entropy(b_logits[:, 2], b_ones) + ) / 3.0 + else: + a_digit_loss = torch.tensor(0.0, device=device) + b_digit_loss = torch.tensor(0.0, device=device) + + total = result_loss + op_loss + digit_weight * (a_digit_loss + b_digit_loss) + total = torch.nan_to_num(total, nan=10.0, posinf=10.0, neginf=0.0) + + return total, { + 'result': result_loss.item() if not torch.isnan(result_loss) else 10.0, + 'a_digit': a_digit_loss.item() if not torch.isnan(a_digit_loss) else 10.0, + 'b_digit': b_digit_loss.item() if not torch.isnan(b_digit_loss) else 10.0, + 'op': op_loss.item() if not torch.isnan(op_loss) else 10.0, + 'n_words': n_words, + 'n_lookup': used_lookup.sum().item() + } + + +def evaluate_llm(model, n_samples: int = 500): + """Evaluate LLM model on random problems (mixed digit/word format).""" + model.extractor.eval() + correct = 0 + op_correct = 0 + + for _ in range(n_samples): + text, a, b, op, expected = generate_problem() + + with torch.no_grad(): + outputs = model([text]) + result_bits = outputs[0] + op_logits = outputs[3] + + pred_result = train_bits_to_int(result_bits[0]) + pred_op = OPERATIONS[op_logits[0].argmax().item()] + + if pred_result == expected: + correct += 1 + if pred_op == op: + op_correct += 1 + + model.extractor.train() + return correct / n_samples, op_correct / n_samples + + +def train_llm(epochs: int = 100, batch_size: int = 256, lr: float = 3e-4, + unfreeze_layers: int = 0, extract_layer: int = -1, + position_extract: bool = False, digit_pred: bool = False, + positional_digit: bool = False, device: str = 'cuda'): + """Train extractor with LLM hidden states.""" + hybrid = not (positional_digit or position_extract or digit_pred) + + print("=" * 70) + print(" LLM TRAINING") + if unfreeze_layers > 0: + print(f" {unfreeze_layers} transformer layers unfrozen") + else: + print(" LLM frozen") + if extract_layer != -1: + print(f" Extracting from layer {extract_layer}") + if hybrid: + print(" HYBRID extraction (digit lookup + word learning)") + elif positional_digit: + print(" POSITIONAL DIGIT extraction (legacy, 100% on digits only)") + elif position_extract: + print(" Position-specific extraction (legacy)") + elif digit_pred: + print(" Digit-level prediction (legacy)") + print("=" * 70) + + print("\nInitializing model...") + model = ArithmeticModel( + device=device, + unfreeze_layers=unfreeze_layers, + extract_layer=extract_layer, + position_extract=position_extract, + digit_pred=digit_pred, + positional_digit=positional_digit, + hybrid=hybrid + ) + + optimizer = optim.AdamW(model.trainable_parameters(), lr=lr) + scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs) + + print(f"\nTraining config:") + print(f" Epochs: {epochs}") + print(f" Batch size: {batch_size}") + print(f" Learning rate: {lr}") + print(f" Unfreeze layers: {unfreeze_layers}") + print(f" Samples/epoch: {batch_size * 20}") + + print(f"\nInitial evaluation (200 samples)...") + acc, op_acc = evaluate_llm(model, 200) + print(f" Accuracy: {acc:.4f}, Op accuracy: {op_acc:.4f}") + + print(f"\nStarting training...") + print("-" * 70) + + best_acc = acc + start_time = time.perf_counter() + + for epoch in range(epochs): + model.extractor.train() + if unfreeze_layers > 0: + model.llm.train() + + max_val = get_curriculum_max(epoch, epochs) + + epoch_loss = 0 + if hybrid: + epoch_losses = {'result': 0, 'a_digit': 0, 'b_digit': 0, 'op': 0, 'n_words': 0, 'n_lookup': 0} + elif positional_digit: + epoch_losses = {'result': 0, 'a_digit': 0, 'b_digit': 0, 'op': 0} + else: + epoch_losses = {'result': 0, 'a': 0, 'b': 0, 'op': 0} + n_batches = 20 + epoch_start = time.perf_counter() + + for batch_idx in range(n_batches): + batch_texts = [] + batch_a = [] + batch_b = [] + batch_op = [] + batch_result = [] + batch_a_values = [] + batch_b_values = [] + + for _ in range(batch_size): + text, a, b, op, result = generate_problem(max_val) + batch_texts.append(text) + batch_a.append(train_int_to_bits(a, device)) + batch_b.append(train_int_to_bits(b, device)) + batch_op.append(OPERATIONS.index(op)) + batch_result.append(train_int_to_bits(result, device)) + batch_a_values.append(a) + batch_b_values.append(b) + + target_a = torch.stack(batch_a) + target_b = torch.stack(batch_b) + target_op = torch.tensor(batch_op, device=device) + target_result = torch.stack(batch_result) + target_a_values = torch.tensor(batch_a_values, device=device, dtype=torch.float32) + target_b_values = torch.tensor(batch_b_values, device=device, dtype=torch.float32) + + optimizer.zero_grad() + + outputs = model(batch_texts) + pred_bits, a_bits, b_bits, op_logits = outputs[0], outputs[1], outputs[2], outputs[3] + + if hybrid: + a_values, b_values, used_lookup = outputs[4], outputs[5], outputs[6] + a_digit_logits, b_digit_logits = outputs[7], outputs[8] + loss, losses = compute_hybrid_loss( + pred_bits, op_logits, used_lookup, + a_digit_logits, b_digit_logits, + target_result, target_a_values, target_b_values, target_op, device + ) + elif positional_digit: + a_digit_logits_list = outputs[7] + b_digit_logits_list = outputs[8] + loss, losses = compute_positional_digit_loss( + pred_bits, op_logits, a_digit_logits_list, b_digit_logits_list, + target_result, target_op, target_a_values, target_b_values, device + ) + else: + loss, losses = compute_llm_loss( + pred_bits, a_bits, b_bits, op_logits, + target_result, target_a, target_b, target_op + ) + + loss.backward() + torch.nn.utils.clip_grad_norm_(model.trainable_parameters(), 1.0) + optimizer.step() + + epoch_loss += loss.item() + for k in epoch_losses: + epoch_losses[k] += losses[k] + + if (batch_idx + 1) % 5 == 0: + avg_so_far = epoch_loss / (batch_idx + 1) + print(f" Epoch {epoch+1} batch {batch_idx+1}/{n_batches} | loss: {avg_so_far:.4f}", flush=True) + + epoch_time = time.perf_counter() - epoch_start + scheduler.step() + + avg_loss = epoch_loss / n_batches + for k in epoch_losses: + epoch_losses[k] /= n_batches + + acc, op_acc = evaluate_llm(model, 300) + elapsed = time.perf_counter() - start_time + + marker = " *" if acc > best_acc else "" + if acc > best_acc: + best_acc = acc + + mem, _ = get_gpu_memory() + print(f"Epoch {epoch+1:3d} | Loss: {avg_loss:.4f} | " + f"Acc: {acc:.4f}{marker} | OpAcc: {op_acc:.4f} | " + f"Range: 0-{max_val} | VRAM: {mem:.0f}MB | Time: {elapsed:.0f}s") + if hybrid: + print(f" Losses - result:{epoch_losses['result']:.4f} " + f"a_digit:{epoch_losses['a_digit']:.4f} b_digit:{epoch_losses['b_digit']:.4f} " + f"op:{epoch_losses['op']:.4f} | words:{epoch_losses['n_words']:.0f} lookup:{epoch_losses['n_lookup']:.0f}") + elif positional_digit: + print(f" Losses - result:{epoch_losses['result']:.4f} " + f"a_digit:{epoch_losses['a_digit']:.4f} b_digit:{epoch_losses['b_digit']:.4f} " + f"op:{epoch_losses['op']:.4f}") + else: + print(f" Losses - result:{epoch_losses['result']:.4f} " + f"a:{epoch_losses['a']:.4f} b:{epoch_losses['b']:.4f} " + f"op:{epoch_losses['op']:.4f}") + + if acc >= 0.99: + print("\n" + "=" * 70) + print(" TARGET ACHIEVED: 99%+ ACCURACY") + print("=" * 70) + break + + print("\n" + "=" * 70) + print(" FINAL EVALUATION") + print("=" * 70) + + acc, op_acc = evaluate_llm(model, 1000) + print(f"Final accuracy: {acc:.4f}") + print(f"Final op accuracy: {op_acc:.4f}") + print(f"Best accuracy: {best_acc:.4f}") + + print("\nSample predictions:") + for _ in range(10): + text, a, b, op, expected = generate_problem() + with torch.no_grad(): + outputs = model([text]) + result_bits, a_bits, b_bits, op_logits = outputs[0], outputs[1], outputs[2], outputs[3] + pred = train_bits_to_int(result_bits[0]) + pred_a = train_bits_to_int(a_bits[0]) + pred_b = train_bits_to_int(b_bits[0]) + pred_op = OPERATIONS[op_logits[0].argmax().item()] + + status = "OK" if pred == expected else "WRONG" + print(f" '{text}' = {expected} | pred={pred} (a={pred_a}, b={pred_b}, op={pred_op}) [{status}]") + + save_path = os.path.join(TRAINED, 'llm.pt') + save_dict = { + 'extractor_state_dict': model.extractor.state_dict(), + 'final_accuracy': acc, + 'best_accuracy': best_acc, + 'unfreeze_layers': unfreeze_layers, + } + if unfreeze_layers > 0: + save_dict['llm_state_dict'] = { + k: v for k, v in model.llm.state_dict().items() + if any(f'layers.{i}.' in k for i in range(len(model.llm.model.layers) - unfreeze_layers, len(model.llm.model.layers))) + } + torch.save(save_dict, save_path) + print(f"\nSaved to: {save_path}") + + return model, acc + + +def main_train(): + parser = argparse.ArgumentParser( + description='Unified training for threshold circuit LLM integration', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Modes: + router - Train only OpRouter with ground truth bits (sanity check) + interface - Train BitEncoder + OpRouter with ground truth bits (sanity check) + llm - Train extractor with LLM hidden states (the real training) + +LLM options: + --unfreeze_layers N Fine-tune top N transformer layers + --extract_layer N Extract from layer N (-1 = last) + --position_extract Use position-specific extraction + --digit_pred Predict digits instead of bits + +Baked-in: curriculum learning (0-9 -> 0-99 -> 0-255), 2x loss weight for a/b + +Examples: + python train.py --mode llm --epochs 100 + python train.py --mode llm --position_extract + python train.py --mode llm --digit_pred --extract_layer 12 + python train.py --mode llm --unfreeze_layers 4 --batch_size 4096 + """ + ) + parser.add_argument('--mode', type=str, required=True, + choices=['router', 'interface', 'llm'], + help='Training mode') + parser.add_argument('--epochs', type=int, default=100, help='Number of epochs') + parser.add_argument('--batch_size', type=int, default=512, help='Batch size (default: 512)') + parser.add_argument('--lr', type=float, default=None, + help='Learning rate (default: mode-specific)') + parser.add_argument('--unfreeze_layers', type=int, default=0, + help='Unfreeze top N transformer layers (default 0 = frozen)') + parser.add_argument('--extract_layer', type=int, default=0, + help='Which layer to extract from (default: 0 = embeddings, best for digits)') + parser.add_argument('--position_extract', action='store_true', + help='Use position-specific extraction (legacy)') + parser.add_argument('--digit_pred', action='store_true', + help='Predict digits instead of bits (legacy)') + parser.add_argument('--positional_digit', action='store_true', default=False, + help='Use positional digit extraction (legacy, 100%% on digits only)') + parser.add_argument('--device', type=str, default='cuda', help='Device') + args = parser.parse_args() + + torch.manual_seed(42) + random.seed(42) + + if args.mode == 'router': + lr = args.lr if args.lr is not None else 1e-2 + train_router(epochs=args.epochs, batch_size=args.batch_size, lr=lr, device=args.device) + + elif args.mode == 'interface': + lr = args.lr if args.lr is not None else 1e-3 + model, fitness = train_interface( + epochs=args.epochs, batch_size=args.batch_size, lr=lr, device=args.device + ) + + print("\n" + "=" * 70) + print(" EXPERIMENT SUMMARY") + print("=" * 70) + print(f"\n Control (Vanilla SmolLM2-360M): 11.90%") + print(f" Experimental (Trained Interface): {100*fitness:.2f}%") + if fitness > 0: + print(f"\n Improvement: {100*(fitness - 0.119)/0.119:.1f}%") + + if fitness >= 0.99: + print("\n CONCLUSION: Frozen threshold circuits + trained interface") + print(" achieves near-perfect arithmetic accuracy.") + print(" Core thesis VALIDATED.") + else: + print(f"\n CONCLUSION: Further training or architecture changes needed.") + print(f" Current gap: {100*(1.0 - fitness):.2f}%") + + elif args.mode == 'llm': + lr = args.lr if args.lr is not None else 3e-4 + train_llm( + epochs=args.epochs, + batch_size=args.batch_size, + lr=lr, + unfreeze_layers=args.unfreeze_layers, + extract_layer=args.extract_layer, + position_extract=args.position_extract, + digit_pred=args.digit_pred, + positional_digit=args.positional_digit, + device=args.device + ) + +DEVICE = "cuda" + +MODEL_ID = "HuggingFaceTB/SmolLM2-360M-Instruct" + +SYSTEM_PROMPT = """You are a calculator. Output only the numeric answer. No words, no explanation, just digits. Examples: +User: 5 + 3 +Assistant: 8 +User: 12 * 7 +Assistant: 84 +User: 100 > 50 +Assistant: 1 +User: 25 < 10 +Assistant: 0""" + + +def load_model(): + print(f"Loading {MODEL_ID}...") + tokenizer = AutoTokenizer.from_pretrained(MODEL_ID) + tokenizer.padding_side = "left" + if tokenizer.pad_token is None: + tokenizer.pad_token = tokenizer.eos_token + model = AutoModelForCausalLM.from_pretrained( + MODEL_ID, + torch_dtype=torch.float16, + device_map=DEVICE + ) + model.eval() + print(f" Loaded. Parameters: {sum(p.numel() for p in model.parameters()):,}") + return model, tokenizer + + +def format_prompt(tokenizer, op_str): + messages = [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": op_str} + ] + return tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) + + +def baseline_generate(model, tokenizer, prompts, max_new_tokens=16): + inputs = tokenizer(prompts, return_tensors="pt", padding=True).to(DEVICE) + with torch.no_grad(): + outputs = model.generate( + **inputs, + max_new_tokens=max_new_tokens, + do_sample=False, + pad_token_id=tokenizer.eos_token_id + ) + responses = [] + for i, output in enumerate(outputs): + response = tokenizer.decode(output[inputs.input_ids.shape[1]:], skip_special_tokens=True) + responses.append(response.strip()) + return responses + + +def extract_answer(text): + """Generous extraction - find any number in output""" + text = text.strip().lower() + if not text: + return None + + # Handle Yes/No for comparisons + if text in ['yes', 'true', '1']: + return 1 + if text in ['no', 'false', '0']: + return 0 + if text.startswith('yes'): + return 1 + if text.startswith('no'): + return 0 + + # Find all numbers, take the LAST one (most likely the answer) + numbers = re.findall(r'-?\d+', text) + if numbers: + return int(numbers[-1]) + return None + + +def baseline_ground_truth(a, b, op): + """Compute expected result (8-bit where applicable)""" + if op == 'add': + return (a + b) & 0xFF + elif op == 'sub': + return (a - b) & 0xFF + elif op == 'mul': + return (a * b) & 0xFF + elif op == 'div': + return a // b if b != 0 else 0 + elif op == 'and': + return a & b + elif op == 'or': + return a | b + elif op == 'xor': + return a ^ b + elif op == 'gt': + return 1 if a > b else 0 + elif op == 'lt': + return 1 if a < b else 0 + elif op == 'eq': + return 1 if a == b else 0 + elif op == 'ge': + return 1 if a >= b else 0 + elif op == 'le': + return 1 if a <= b else 0 + else: + raise ValueError(f"Unknown op: {op}") + + +def op_to_str(a, b, op): + """Convert operation to natural string""" + symbols = { + 'add': '+', 'sub': '-', 'mul': '*', 'div': '/', + 'and': '&', 'or': '|', 'xor': '^', + 'gt': '>', 'lt': '<', 'eq': '==', 'ge': '>=', 'le': '<=' + } + return f"{a} {symbols[op]} {b}" + + +def evaluate(model, tokenizer, n_samples=1000, batch_size=32, ops=None): + if ops is None: + ops = ['add', 'sub', 'mul', 'gt', 'lt', 'eq'] + + results = {op: {'correct': 0, 'total': 0} for op in ops} + all_correct = 0 + all_total = 0 + + samples = [] + for _ in range(n_samples): + a = random.randint(0, 255) + b = random.randint(0, 255) + if 'div' in ops and random.random() < 0.1: + op = 'div' + b = random.randint(1, 255) # avoid div by zero + else: + op = random.choice([o for o in ops if o != 'div']) + samples.append((a, b, op)) + + print(f"\nEvaluating {n_samples} samples (batch_size={batch_size})...") + + for batch_start in range(0, n_samples, batch_size): + batch = samples[batch_start:batch_start + batch_size] + prompts = [format_prompt(tokenizer, op_to_str(a, b, op)) for a, b, op in batch] + responses = baseline_generate(model, tokenizer, prompts) + + for (a, b, op), response in zip(batch, responses): + expected = baseline_ground_truth(a, b, op) + extracted = extract_answer(response) + + correct = (extracted == expected) + results[op]['total'] += 1 + all_total += 1 + if correct: + results[op]['correct'] += 1 + all_correct += 1 + + if (batch_start + batch_size) % 200 == 0 or batch_start + batch_size >= n_samples: + pct = 100 * all_correct / all_total + print(f" Progress: {min(batch_start + batch_size, n_samples)}/{n_samples} | Accuracy: {pct:.2f}%") + + return results, all_correct, all_total + + +def main_baseline(): + random.seed(42) + torch.manual_seed(42) + + model, tokenizer = load_model() + + # Quick sanity check + print("\nSanity check (5 examples):") + test_cases = [ + ("5 + 3", 8), + ("100 - 37", 63), + ("12 * 11", 132), + ("50 > 30", 1), + ("25 < 10", 0), + ] + prompts = [format_prompt(tokenizer, q) for q, _ in test_cases] + responses = baseline_generate(model, tokenizer, prompts) + for (q, expected), response in zip(test_cases, responses): + extracted = extract_answer(response) + status = "OK" if extracted == expected else "FAIL" + print(f" {q} = {expected} | Model: '{response}' -> {extracted} [{status}]") + + # Full evaluation + print("\n" + "=" * 60) + print(" BASELINE EVALUATION") + print("=" * 60) + + ops = ['add', 'sub', 'mul', 'gt', 'lt', 'eq'] + results, correct, total = evaluate(model, tokenizer, n_samples=2000, batch_size=64, ops=ops) + + print("\n" + "=" * 60) + print(" RESULTS BY OPERATION") + print("=" * 60) + for op in ops: + r = results[op] + pct = 100 * r['correct'] / r['total'] if r['total'] > 0 else 0 + print(f" {op:6}: {r['correct']:4}/{r['total']:4} ({pct:6.2f}%)") + + print("\n" + "=" * 60) + print(" OVERALL") + print("=" * 60) + fitness = correct / total + print(f" Correct: {correct}/{total}") + print(f" Fitness: {fitness:.4f} ({100*fitness:.2f}%)") + print("=" * 60) + + return fitness + + +def cmd_circuits(): + print("Testing frozen circuits...") + + circuits = FrozenThresholdCircuits(device='cuda') + print(f"Loaded {len(circuits.weights)} tensors") + + a = torch.tensor([[0, 0, 0, 0, 0, 1, 0, 1]], device='cuda', dtype=torch.float32) + b = torch.tensor([[0, 0, 0, 0, 0, 0, 1, 1]], device='cuda', dtype=torch.float32) + + result, carry = circuits.add_8bit(a, b) + val = sum(int(result[0, i].item()) << (7 - i) for i in range(8)) + print(f"5 + 3 = {val} (expected 8)") + + a = torch.tensor([[0, 1, 1, 0, 0, 1, 0, 0]], device='cuda', dtype=torch.float32) + b = torch.tensor([[0, 0, 1, 0, 0, 1, 0, 1]], device='cuda', dtype=torch.float32) + result, _ = circuits.sub_8bit(a, b) + val = sum(int(result[0, i].item()) << (7 - i) for i in range(8)) + print(f"100 - 37 = {val} (expected 63)") + + a = torch.tensor([[0, 0, 0, 0, 1, 1, 0, 0]], device='cuda', dtype=torch.float32) + b = torch.tensor([[0, 0, 0, 0, 1, 0, 1, 1]], device='cuda', dtype=torch.float32) + result = circuits.mul_8bit(a, b) + val = sum(int(result[0, i].item()) << (7 - i) for i in range(8)) + print(f"12 * 11 = {val} (expected 132)") + + a = torch.tensor([[0, 0, 1, 1, 0, 0, 1, 0]], device='cuda', dtype=torch.float32) + b = torch.tensor([[0, 0, 0, 1, 1, 1, 1, 0]], device='cuda', dtype=torch.float32) + gt = circuits.compare_gt(a, b) + lt = circuits.compare_lt(a, b) + eq = circuits.compare_eq(a, b) + print(f"50 > 30: {int(gt[0].item())} (expected 1)") + print(f"50 < 30: {int(lt[0].item())} (expected 0)") + print(f"50 == 30: {int(eq[0].item())} (expected 0)") + + print("\nTesting batched forward...") + batch_a = torch.randint(0, 2, (16, 8), device='cuda', dtype=torch.float32) + batch_b = torch.randint(0, 2, (16, 8), device='cuda', dtype=torch.float32) + op = torch.zeros(16, 6, device='cuda') + op[:, 0] = 1.0 + + result = circuits(batch_a, batch_b, op) + print(f"Batch output shape: {result.shape}") + print("Done.") + + +def cmd_fitness(): + print("Testing fitness module...") + + batch = generate_batch(8, 'cpu') + print(f"\nSample batch:") + for i in range(4): + a, b = batch['a'][i].item(), batch['b'][i].item() + op = idx_to_op(batch['op'][i].item()) + expected = batch['expected'][i].item() + print(f" {a} {op} {b} = {expected}") + + def random_model(a_bits, b_bits, op_onehot): + return torch.rand(a_bits.shape[0], 8, device=a_bits.device) + + fitness = compute_fitness(random_model, n_samples=1000, batch_size=100, device='cpu') + print(f"\nRandom model fitness: {fitness:.4f} (expected ~0.004 for 8-bit)") + + def perfect_model(a_bits, b_bits, op_onehot): + batch_size = a_bits.shape[0] + results = torch.zeros(batch_size, 8, device=a_bits.device) + for i in range(batch_size): + a = sum(int(a_bits[i, j].item()) << (7-j) for j in range(8)) + b = sum(int(b_bits[i, j].item()) << (7-j) for j in range(8)) + op_idx = op_onehot[i].argmax().item() + result = ground_truth(a, b, idx_to_op(op_idx)) + for j in range(8): + results[i, 7-j] = (result >> j) & 1 + return results + + fitness = compute_fitness(perfect_model, n_samples=1000, batch_size=100, device='cpu') + print(f"Perfect model fitness: {fitness:.4f} (expected 1.0)") + + +def cmd_model(): + import sys + sys.path.insert(0, '.') + + print("Testing model components...") + + device = 'cuda' + batch = generate_batch(32, device) + + print("\n1. Testing DirectCircuitModel (should get ~100% fitness)...") + direct_model = DirectCircuitModel(device=device) + + def direct_fn(a, b, op): + return direct_model(a, b, op) + + fitness, details = compute_fitness(direct_fn, n_samples=2000, batch_size=128, + device=device, return_details=True) + print(f" Direct circuit fitness: {fitness:.4f}") + for op in OPERATIONS: + acc = details['by_op'][op]['accuracy'] + print(f" {op}: {acc:.4f}") + + print("\n2. Testing ThresholdALU (trainable interface)...") + model = ThresholdALU(device=device) + + x = torch.cat([batch['a_bits'], batch['b_bits'], batch['op_onehot']], dim=-1) + a_enc, b_enc = model.encoder(x) + print(f" Encoder output shapes: a={a_enc.shape}, b={b_enc.shape}") + + op_weights = model.router(x) + print(f" Router output shape: {op_weights.shape}") + print(f" Router output sample: {op_weights[0].tolist()}") + + result = model(batch['a_bits'], batch['b_bits'], batch['op_onehot']) + print(f" Full model output shape: {result.shape}") + + print("\n3. Testing untrained ThresholdALU fitness...") + + def model_fn(a, b, op): + return model(a, b, op) + + fitness = compute_fitness(model_fn, n_samples=1000, batch_size=128, device=device) + print(f" Untrained model fitness: {fitness:.4f} (expected low)") + + print("\n4. Counting parameters...") + total = sum(p.numel() for p in model.parameters() if p.requires_grad) + encoder_params = sum(p.numel() for p in model.encoder.parameters()) + router_params = sum(p.numel() for p in model.router.parameters()) + print(f" Encoder: {encoder_params:,}") + print(f" Router: {router_params:,}") + print(f" Total trainable: {total:,}") + + print("\nDone.") + + +COMMANDS = {'circuits': cmd_circuits, 'fitness': cmd_fitness, 'model': cmd_model, 'train': main_train, 'baseline': main_baseline} + + +if __name__ == "__main__": + if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS: + sys.exit('commands: ' + ', '.join(COMMANDS)) + cmd = sys.argv.pop(1) + sys.argv[0] += ' ' + cmd + sys.exit(COMMANDS[cmd]()) diff --git a/llm_integration/model.py b/llm_integration/model.py deleted file mode 100644 index 7335959f7f222500a967af0ada8204cd5ce20a20..0000000000000000000000000000000000000000 --- a/llm_integration/model.py +++ /dev/null @@ -1,1263 +0,0 @@ -""" -Trainable interface layers for frozen threshold circuits. -BitEncoder, OpRouter, BitDecoder wrap the frozen circuits. -HiddenStateExtractor and AugmentedArithmeticModel for LLM integration. -""" - -import torch -import torch.nn as nn -import torch.nn.functional as F -from circuits import FrozenThresholdCircuits, heaviside_ste - -MODEL_ID = 'HuggingFaceTB/SmolLM2-360M-Instruct' -OPERATIONS = ['add', 'sub', 'mul', 'gt', 'lt', 'eq'] -OP_SYMBOLS = {'add': '+', 'sub': '-', 'mul': '*', 'gt': '>', 'lt': '<', 'eq': '=='} - - -class BitEncoder(nn.Module): - """ - Encodes two 8-bit operands from input representation. - Uses residual connection to preserve ground truth bits while allowing learned refinement. - """ - - def __init__(self, input_dim: int = 16 + 6, hidden_dim: int = 32): - super().__init__() - self.refine = nn.Sequential( - nn.Linear(input_dim, hidden_dim), - nn.Tanh(), - nn.Linear(hidden_dim, 16), - ) - self.scale = nn.Parameter(torch.tensor(0.0)) - - def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: - """ - Args: - x: [batch, input_dim] input with first 16 dims being a_bits, b_bits - - Returns: - a_bits: [batch, 8] first operand bits - b_bits: [batch, 8] second operand bits - """ - base_bits = x[:, :16] - refinement = self.refine(x) * torch.sigmoid(self.scale) - bits = base_bits + refinement - bits = torch.clamp(bits, 0, 1) - hard_bits = heaviside_ste(bits - 0.5) - out = hard_bits - bits.detach() + bits - - return out[:, :8], out[:, 8:] - - -class OpRouter(nn.Module): - """ - Routes computation to the appropriate circuit based on input. - Outputs soft weights over operations for gradient flow. - """ - - def __init__(self, input_dim: int = 16 + 6, hidden_dim: int = 32, n_ops: int = 6): - super().__init__() - self.net = nn.Sequential( - nn.Linear(input_dim, hidden_dim), - nn.ReLU(), - nn.Linear(hidden_dim, n_ops), - ) - - def forward(self, x: torch.Tensor) -> torch.Tensor: - """ - Args: - x: [batch, input_dim] input features - - Returns: - op_weights: [batch, n_ops] soft operation weights (softmax) - """ - logits = self.net(x) - return F.softmax(logits, dim=-1) - - -class BitDecoder(nn.Module): - """ - Decodes circuit output bits to target representation. - For standalone training: outputs soft bits for loss computation. - For LLM integration: would project to hidden state delta. - """ - - def __init__(self, output_dim: int = 8): - super().__init__() - self.output_dim = output_dim - - def forward(self, result_bits: torch.Tensor) -> torch.Tensor: - return result_bits - - -class ThresholdALU(nn.Module): - """ - Complete trainable interface + frozen circuits. - Learns to encode inputs, route to circuits, decode outputs. - """ - - def __init__(self, device: str = 'cuda'): - super().__init__() - self.device = device - - self.circuits = FrozenThresholdCircuits(device=device) - - for key in self.circuits.weights: - self.circuits.weights[key].requires_grad = False - - self.encoder = BitEncoder(input_dim=16 + 6, hidden_dim=64).to(device) - self.router = OpRouter(input_dim=16 + 6, hidden_dim=32, n_ops=6).to(device) - self.decoder = BitDecoder(output_dim=8).to(device) - - def forward(self, a_bits_in: torch.Tensor, b_bits_in: torch.Tensor, - op_onehot: torch.Tensor) -> torch.Tensor: - """ - Forward pass through trainable interface + frozen circuits. - - Args: - a_bits_in: [batch, 8] input A bits (ground truth for training) - b_bits_in: [batch, 8] input B bits (ground truth for training) - op_onehot: [batch, 6] one-hot operation selector - - Returns: - result_bits: [batch, 8] output bits - """ - x = torch.cat([a_bits_in, b_bits_in, op_onehot], dim=-1) - - a_bits, b_bits = self.encoder(x) - - op_weights = self.router(x) - - result = self.circuits(a_bits, b_bits, op_weights) - - output = self.decoder(result) - - return output - - def forward_direct(self, a_bits: torch.Tensor, b_bits: torch.Tensor, - op_onehot: torch.Tensor) -> torch.Tensor: - """ - Direct forward through circuits (bypass encoder/router for testing). - """ - return self.circuits(a_bits, b_bits, op_onehot) - - -class DirectCircuitModel(nn.Module): - """ - Minimal model that directly uses circuits without learned encoding. - For validating that circuits themselves achieve 100% fitness. - """ - - def __init__(self, device: str = 'cuda'): - super().__init__() - self.device = device - self.circuits = FrozenThresholdCircuits(device=device) - - def forward(self, a_bits: torch.Tensor, b_bits: torch.Tensor, - op_onehot: torch.Tensor) -> torch.Tensor: - return self.circuits(a_bits, b_bits, op_onehot) - - -class HiddenStateExtractor(nn.Module): - """ - Extracts operands and operation from LLM hidden states. - This is the hard part - must learn to parse numbers from embeddings. - """ - - def __init__(self, hidden_dim: int = 960, intermediate_dim: int = 256): - super().__init__() - - self.a_extractor = nn.Sequential( - nn.Linear(hidden_dim, intermediate_dim), - nn.GELU(), - nn.Linear(intermediate_dim, 8), - ) - - self.b_extractor = nn.Sequential( - nn.Linear(hidden_dim, intermediate_dim), - nn.GELU(), - nn.Linear(intermediate_dim, 8), - ) - - self.op_router = nn.Sequential( - nn.Linear(hidden_dim, intermediate_dim), - nn.GELU(), - nn.Linear(intermediate_dim, len(OPERATIONS)), - ) - - def forward(self, hidden_states: torch.Tensor): - """ - Args: - hidden_states: [batch, hidden_dim] from LLM - - Returns: - a_bits: [batch, 8] - b_bits: [batch, 8] - op_logits: [batch, 6] - """ - a_logits = self.a_extractor(hidden_states) - b_logits = self.b_extractor(hidden_states) - op_logits = self.op_router(hidden_states) - - a_soft = torch.sigmoid(a_logits) - b_soft = torch.sigmoid(b_logits) - - a_hard = heaviside_ste(a_logits) - b_hard = heaviside_ste(b_logits) - - a_bits = a_hard - a_soft.detach() + a_soft - b_bits = b_hard - b_soft.detach() + b_soft - - return a_bits, b_bits, op_logits - - -class AttentionPooling(nn.Module): - """ - Learnable attention pooling over sequence positions. - Replaces mean pooling - learns which tokens matter for extraction. - """ - - def __init__(self, hidden_dim: int = 960, num_heads: int = 4): - super().__init__() - self.num_heads = num_heads - self.head_dim = hidden_dim // num_heads - - self.query = nn.Linear(hidden_dim, hidden_dim) - self.key = nn.Linear(hidden_dim, hidden_dim) - self.value = nn.Linear(hidden_dim, hidden_dim) - self.out_proj = nn.Linear(hidden_dim, hidden_dim) - - self.cls_token = nn.Parameter(torch.randn(1, 1, hidden_dim) * 0.02) - - def forward(self, embeddings: torch.Tensor, mask: torch.Tensor) -> torch.Tensor: - """ - Args: - embeddings: [batch, seq_len, hidden_dim] - mask: [batch, seq_len] attention mask (1 = attend, 0 = ignore) - - Returns: - pooled: [batch, hidden_dim] - """ - batch_size, seq_len, hidden_dim = embeddings.shape - - cls_expanded = self.cls_token.expand(batch_size, -1, -1) - embeddings = torch.cat([cls_expanded, embeddings], dim=1) - - cls_mask = torch.ones(batch_size, 1, device=mask.device) - mask = torch.cat([cls_mask, mask], dim=1) - - Q = self.query(embeddings[:, :1, :]) - K = self.key(embeddings) - V = self.value(embeddings) - - Q = Q.view(batch_size, 1, self.num_heads, self.head_dim).transpose(1, 2) - K = K.view(batch_size, seq_len + 1, self.num_heads, self.head_dim).transpose(1, 2) - V = V.view(batch_size, seq_len + 1, self.num_heads, self.head_dim).transpose(1, 2) - - scores = torch.matmul(Q, K.transpose(-2, -1)) / (self.head_dim ** 0.5) - - mask_expanded = mask.unsqueeze(1).unsqueeze(2) - scores = scores.masked_fill(mask_expanded == 0, -1e9) - - attn_weights = torch.softmax(scores, dim=-1) - attn_weights = torch.nan_to_num(attn_weights, nan=0.0) - - context = torch.matmul(attn_weights, V) - context = context.transpose(1, 2).contiguous().view(batch_size, 1, hidden_dim) - - pooled = self.out_proj(context).squeeze(1) - pooled = torch.nan_to_num(pooled, nan=0.0) - - return pooled - - -class MultiHeadBitExtractor(nn.Module): - """ - 8 separate extractors for 8 bits - each bit gets its own specialized network. - More expressive than single MLP predicting all 8 bits at once. - """ - - def __init__(self, hidden_dim: int = 960, intermediate_dim: int = 128): - super().__init__() - - self.bit_extractors = nn.ModuleList([ - nn.Sequential( - nn.Linear(hidden_dim, intermediate_dim), - nn.GELU(), - nn.Linear(intermediate_dim, 1), - ) - for _ in range(8) - ]) - - def forward(self, hidden_states: torch.Tensor) -> torch.Tensor: - """ - Args: - hidden_states: [batch, hidden_dim] - - Returns: - bits: [batch, 8] - one bit from each extractor - """ - hidden_states = torch.nan_to_num(hidden_states, nan=0.0) - - bit_logits = [extractor(hidden_states) for extractor in self.bit_extractors] - logits = torch.cat(bit_logits, dim=-1) - logits = torch.clamp(logits, -20, 20) - - soft = torch.sigmoid(logits) - hard = heaviside_ste(logits) - bits = hard - soft.detach() + soft - - return bits, logits - - -class Extractor(nn.Module): - """ - Extracts operands and operation from LLM hidden states. - Uses attention pooling and per-bit extraction networks. - """ - - def __init__(self, hidden_dim: int = 960, intermediate_dim: int = 256, num_heads: int = 4): - super().__init__() - - self.attention_pool = AttentionPooling(hidden_dim, num_heads) - - self.a_extractor = MultiHeadBitExtractor(hidden_dim, intermediate_dim // 2) - self.b_extractor = MultiHeadBitExtractor(hidden_dim, intermediate_dim // 2) - - self.op_router = nn.Sequential( - nn.Linear(hidden_dim, intermediate_dim), - nn.GELU(), - nn.Linear(intermediate_dim, len(OPERATIONS)), - ) - - def forward(self, embeddings: torch.Tensor, mask: torch.Tensor): - """ - Args: - embeddings: [batch, seq_len, hidden_dim] - mask: [batch, seq_len] - - Returns: - a_bits: [batch, 8] - b_bits: [batch, 8] - op_logits: [batch, 6] - """ - pooled = self.attention_pool(embeddings, mask) - - a_bits, _ = self.a_extractor(pooled) - b_bits, _ = self.b_extractor(pooled) - op_logits = self.op_router(pooled) - - return a_bits, b_bits, op_logits - - -class PositionExtractor(nn.Module): - """ - Position-specific extraction with dynamic operator detection. - - Tokenization pattern for "A op B": - [A_digits...] [operator] [space] [B_digits...] - - Examples: - "5 + 3" -> ['5', ' +', ' ', '3'] (positions: A=0, op=1, B=3) - "47 + 86" -> ['4', '7', ' +', ' ', '8', '6'] (positions: A=0-1, op=2, B=4-5) - "127 + 128" -> ['1','2','7',' +', ' ','1','2','8'] (positions: A=0-2, op=3, B=5-7) - - Token IDs (SmolLM2): - Digits '0'-'9': 32-41 - Operators: ' +'=1232, ' -'=731, ' *'=1672, ' >'=2986, ' <'=2067, ' =='=1758 - Space: 216 - """ - - DIGIT_TOKENS = set(range(32, 42)) - OPERATOR_TOKENS = { - 1232: 0, # ' +' -> add - 731: 1, # ' -' -> sub - 1672: 2, # ' *' -> mul - 2986: 3, # ' >' -> gt - 2067: 4, # ' <' -> lt - 1758: 5, # ' ==' -> eq - } - SPACE_TOKEN = 216 - MAX_DIGITS = 3 - - def __init__(self, hidden_dim: int = 960, intermediate_dim: int = 256): - super().__init__() - self.hidden_dim = hidden_dim - - self.a_extractor = nn.Sequential( - nn.Linear(hidden_dim * self.MAX_DIGITS, intermediate_dim), - nn.GELU(), - nn.Linear(intermediate_dim, intermediate_dim // 2), - nn.GELU(), - nn.Linear(intermediate_dim // 2, 8), - ) - - self.b_extractor = nn.Sequential( - nn.Linear(hidden_dim * self.MAX_DIGITS, intermediate_dim), - nn.GELU(), - nn.Linear(intermediate_dim, intermediate_dim // 2), - nn.GELU(), - nn.Linear(intermediate_dim // 2, 8), - ) - - self.op_extractor = nn.Sequential( - nn.Linear(hidden_dim, intermediate_dim // 2), - nn.GELU(), - nn.Linear(intermediate_dim // 2, len(OPERATIONS)), - ) - - def _find_operator_position(self, token_ids: torch.Tensor) -> tuple[int, int]: - """ - Find operator token position and its operation index. - - Args: - token_ids: [seq_len] tensor of token IDs - - Returns: - (position, op_index) or (-1, -1) if not found - """ - for pos, tid in enumerate(token_ids.tolist()): - if tid in self.OPERATOR_TOKENS: - return pos, self.OPERATOR_TOKENS[tid] - return -1, -1 - - def _extract_digit_features(self, hidden: torch.Tensor, start: int, end: int) -> torch.Tensor: - """ - Extract and pad digit hidden states to fixed size. - - Args: - hidden: [seq_len, hidden_dim] - start: start position (inclusive) - end: end position (exclusive) - - Returns: - [hidden_dim * MAX_DIGITS] flattened features, zero-padded on the LEFT - (so units digit is always at the same position regardless of number length) - """ - n_digits = end - start - features = torch.zeros(self.MAX_DIGITS * self.hidden_dim, device=hidden.device) - - if n_digits > 0 and n_digits <= self.MAX_DIGITS: - digit_hidden = hidden[start:end, :].reshape(-1) - pad_size = (self.MAX_DIGITS - n_digits) * self.hidden_dim - features[pad_size:] = digit_hidden - - return features - - def forward(self, hidden: torch.Tensor, mask: torch.Tensor, token_ids: torch.Tensor = None): - """ - Args: - hidden: [batch, seq_len, hidden_dim] - mask: [batch, seq_len] attention mask - token_ids: [batch, seq_len] token IDs (required for operator detection) - - Returns: - a_bits: [batch, 8] - b_bits: [batch, 8] - op_logits: [batch, 6] - """ - if token_ids is None: - raise ValueError("PositionExtractor requires token_ids for operator detection") - - batch_size, seq_len, hidden_dim = hidden.shape - device = hidden.device - - a_features = [] - b_features = [] - op_features = [] - op_indices = [] - - for i in range(batch_size): - seq_mask = mask[i].bool() - valid_len = seq_mask.sum().item() - start_pos = seq_len - valid_len - - valid_tokens = token_ids[i, start_pos:] - valid_hidden = hidden[i, start_pos:, :] - - op_pos, op_idx = self._find_operator_position(valid_tokens) - - if op_pos == -1: - a_feat = torch.zeros(self.MAX_DIGITS * hidden_dim, device=device) - b_feat = torch.zeros(self.MAX_DIGITS * hidden_dim, device=device) - op_feat = torch.zeros(hidden_dim, device=device) - op_idx = 0 - else: - a_feat = self._extract_digit_features(valid_hidden, 0, op_pos) - - op_feat = valid_hidden[op_pos, :] - - b_start = op_pos + 2 if (op_pos + 1 < valid_len and - valid_tokens[op_pos + 1].item() == self.SPACE_TOKEN) else op_pos + 1 - b_feat = self._extract_digit_features(valid_hidden, b_start, valid_len) - - a_features.append(a_feat) - b_features.append(b_feat) - op_features.append(op_feat) - op_indices.append(op_idx) - - a_features = torch.stack(a_features) - b_features = torch.stack(b_features) - op_features = torch.stack(op_features) - op_indices_tensor = torch.tensor(op_indices, device=device, dtype=torch.long) - - a_logits = self.a_extractor(a_features) - b_logits = self.b_extractor(b_features) - op_logits = self.op_extractor(op_features) - - a_soft = torch.sigmoid(a_logits) - b_soft = torch.sigmoid(b_logits) - a_hard = heaviside_ste(a_logits) - b_hard = heaviside_ste(b_logits) - a_bits = a_hard - a_soft.detach() + a_soft - b_bits = b_hard - b_soft.detach() + b_soft - - return a_bits, b_bits, op_logits, op_indices_tensor - - -class PositionalDigitExtractor(nn.Module): - """ - Position-aware digit extraction: classifies each digit position independently. - - This approach achieves 100% accuracy because: - 1. Each digit token position is classified independently (100% accuracy on layer 0) - 2. Numbers are reconstructed using place values (×100, ×10, ×1) - 3. No information is lost through pooling - - Token IDs (SmolLM2): - Digits '0'-'9': 32-41 - Operators: ' +'=1232, ' -'=731, ' *'=1672, ' >'=2986, ' <'=2067, ' =='=1758 - Space: 216 - """ - - DIGIT_TOKENS = set(range(32, 42)) - OPERATOR_TOKENS = { - 1232: 0, # ' +' -> add - 731: 1, # ' -' -> sub - 1672: 2, # ' *' -> mul - 2986: 3, # ' >' -> gt - 2067: 4, # ' <' -> lt - 1758: 5, # ' ==' -> eq - } - SPACE_TOKEN = 216 - - def __init__(self, hidden_dim: int = 960): - super().__init__() - self.hidden_dim = hidden_dim - - self.digit_classifier = nn.Linear(hidden_dim, 10) - - self.op_classifier = nn.Linear(hidden_dim, len(OPERATIONS)) - - def _find_positions(self, token_ids: torch.Tensor) -> tuple: - """Find A digit positions, B digit positions, and operator position.""" - token_list = token_ids.tolist() - - op_pos = -1 - op_idx = 0 - for i, tid in enumerate(token_list): - if tid in self.OPERATOR_TOKENS: - op_pos = i - op_idx = self.OPERATOR_TOKENS[tid] - break - - if op_pos == -1: - return [], [], -1, 0 - - a_positions = [i for i in range(op_pos) if token_list[i] in self.DIGIT_TOKENS] - - b_start = op_pos + 2 if (op_pos + 1 < len(token_list) and - token_list[op_pos + 1] == self.SPACE_TOKEN) else op_pos + 1 - b_positions = [i for i in range(b_start, len(token_list)) if token_list[i] in self.DIGIT_TOKENS] - - return a_positions, b_positions, op_pos, op_idx - - def _predict_value(self, hidden: torch.Tensor, positions: list) -> tuple: - """Predict digit at each position and reconstruct number.""" - if not positions: - return torch.tensor(0.0, device=hidden.device), [] - - digit_logits_list = [] - soft_value = torch.tensor(0.0, device=hidden.device) - - for idx, pos in enumerate(positions): - logits = self.digit_classifier(hidden[pos]) - digit_logits_list.append(logits) - - probs = torch.softmax(logits, dim=-1) - digit_values = torch.arange(10, device=hidden.device, dtype=torch.float32) - soft_digit = (probs * digit_values).sum() - - place_value = 10 ** (len(positions) - idx - 1) - soft_value = soft_value + soft_digit * place_value - - return soft_value, digit_logits_list - - def _value_to_bits(self, value: torch.Tensor) -> torch.Tensor: - """Convert soft value to 8 bits using differentiable operations.""" - value = torch.clamp(value, 0, 255) - - bits = [] - for i in range(7, -1, -1): - bit = torch.sigmoid((value - (2 ** i - 0.5)) * 10) - value = value - bit * (2 ** i) - bits.append(bit) - - return torch.stack(bits) - - def forward(self, hidden: torch.Tensor, mask: torch.Tensor, token_ids: torch.Tensor): - """ - Args: - hidden: [batch, seq_len, hidden_dim] - mask: [batch, seq_len] - token_ids: [batch, seq_len] - - Returns: - a_bits: [batch, 8] - b_bits: [batch, 8] - op_logits: [batch, 6] - op_indices: [batch] ground truth op from tokens - a_digit_logits: list of [batch, 10] per digit position - b_digit_logits: list of [batch, 10] per digit position - """ - batch_size = hidden.shape[0] - device = hidden.device - - a_bits_list = [] - b_bits_list = [] - op_logits_list = [] - op_indices_list = [] - a_values_list = [] - b_values_list = [] - a_digit_logits_list = [] - b_digit_logits_list = [] - - for i in range(batch_size): - seq_mask = mask[i].bool() - valid_len = seq_mask.sum().item() - start_pos = hidden.shape[1] - valid_len - - valid_hidden = hidden[i, start_pos:] - valid_tokens = token_ids[i, start_pos:] - - a_pos, b_pos, op_pos, op_idx = self._find_positions(valid_tokens) - - a_value, a_digit_logits = self._predict_value(valid_hidden, a_pos) - b_value, b_digit_logits = self._predict_value(valid_hidden, b_pos) - - a_bits = self._value_to_bits(a_value) - b_bits = self._value_to_bits(b_value) - - if op_pos >= 0: - op_logits = self.op_classifier(valid_hidden[op_pos]) - else: - op_logits = torch.zeros(len(OPERATIONS), device=device) - - a_bits_list.append(a_bits) - b_bits_list.append(b_bits) - op_logits_list.append(op_logits) - op_indices_list.append(op_idx) - a_values_list.append(a_value) - b_values_list.append(b_value) - a_digit_logits_list.append(a_digit_logits) - b_digit_logits_list.append(b_digit_logits) - - a_bits = torch.stack(a_bits_list) - b_bits = torch.stack(b_bits_list) - op_logits = torch.stack(op_logits_list) - op_indices = torch.tensor(op_indices_list, device=device, dtype=torch.long) - a_values = torch.stack(a_values_list) - b_values = torch.stack(b_values_list) - - return a_bits, b_bits, op_logits, op_indices, a_values, b_values, a_digit_logits_list, b_digit_logits_list - - -class DigitExtractor(nn.Module): - """ - Digit-level extraction: predicts digits (0-9) then converts to bits. - Uses attention pooling (less accurate than PositionalDigitExtractor). - """ - - def __init__(self, hidden_dim: int = 960, intermediate_dim: int = 256, num_heads: int = 4): - super().__init__() - - self.attention_pool = AttentionPooling(hidden_dim, num_heads) - - self.a_digit_pred = nn.Sequential( - nn.Linear(hidden_dim, intermediate_dim), - nn.GELU(), - nn.Linear(intermediate_dim, 3 * 10), - ) - - self.b_digit_pred = nn.Sequential( - nn.Linear(hidden_dim, intermediate_dim), - nn.GELU(), - nn.Linear(intermediate_dim, 3 * 10), - ) - - self.op_router = nn.Sequential( - nn.Linear(hidden_dim, intermediate_dim), - nn.GELU(), - nn.Linear(intermediate_dim, len(OPERATIONS)), - ) - - def digits_to_bits(self, digit_logits: torch.Tensor) -> torch.Tensor: - """ - Convert 3-digit predictions to 8-bit representation. - digit_logits: [batch, 30] (3 digits * 10 classes each) - Returns: [batch, 8] bits - """ - batch_size = digit_logits.shape[0] - - logits = digit_logits.view(batch_size, 3, 10) - probs = torch.softmax(logits, dim=-1) - - digit_values = torch.arange(10, device=digit_logits.device).float() - soft_digits = (probs * digit_values).sum(dim=-1) - - hundreds = soft_digits[:, 0] - tens = soft_digits[:, 1] - ones = soft_digits[:, 2] - - value = hundreds * 100 + tens * 10 + ones - value = torch.clamp(value, 0, 255) - - bits = [] - for i in range(7, -1, -1): - bit = torch.fmod(torch.floor(value / (2 ** i)), 2) - bits.append(bit) - - return torch.stack(bits, dim=-1) - - def forward(self, hidden: torch.Tensor, mask: torch.Tensor): - """ - Returns: - a_bits, b_bits, op_logits, a_digit_logits, b_digit_logits - """ - pooled = self.attention_pool(hidden, mask) - - a_digit_logits = self.a_digit_pred(pooled) - b_digit_logits = self.b_digit_pred(pooled) - op_logits = self.op_router(pooled) - - a_bits = self.digits_to_bits(a_digit_logits) - b_bits = self.digits_to_bits(b_digit_logits) - - return a_bits, b_bits, op_logits, a_digit_logits, b_digit_logits - - -class HybridExtractor(nn.Module): - """ - Hybrid extractor that handles both digit tokens and word numbers. - - For digit tokens (32-41): Direct lookup, no training needed - For word numbers: Learned MLP extraction from pooled hidden states - - This is the real training target - learning to extract numbers from - natural language like "forty seven plus eighty six". - """ - - DIGIT_TOKENS = set(range(32, 42)) - SYMBOL_OP_TOKENS = { - 1232: 0, # ' +' -> add - 731: 1, # ' -' -> sub - 1672: 2, # ' *' -> mul - 2986: 3, # ' >' -> gt - 2067: 4, # ' <' -> lt - 1758: 5, # ' ==' -> eq - } - WORD_OP_TOKENS = { - 2068: 0, # 'plus' -> add - 8500: 1, # 'minus' -> sub - 1580: 2, # 'times' -> mul - 6301: 3, # 'greater' -> gt - 1912: 4, # 'less' -> lt - 16364: 5, # 'equals' -> eq - 11540: 5, # 'equal' -> eq - } - ALL_OP_TOKENS = {**SYMBOL_OP_TOKENS, **WORD_OP_TOKENS} - - def __init__(self, hidden_dim: int = 960, intermediate_dim: int = 256, num_heads: int = 4): - super().__init__() - self.hidden_dim = hidden_dim - - self.attention_pool = AttentionPooling(hidden_dim, num_heads) - self.a_pool = AttentionPooling(hidden_dim, num_heads) - self.b_pool = AttentionPooling(hidden_dim, num_heads) - - self.a_digit_pred = nn.Sequential( - nn.Linear(hidden_dim, intermediate_dim), - nn.GELU(), - nn.Dropout(0.1), - nn.Linear(intermediate_dim, 3 * 10), - ) - - self.b_digit_pred = nn.Sequential( - nn.Linear(hidden_dim, intermediate_dim), - nn.GELU(), - nn.Dropout(0.1), - nn.Linear(intermediate_dim, 3 * 10), - ) - - self.op_predictor = nn.Sequential( - nn.Linear(hidden_dim, intermediate_dim // 2), - nn.GELU(), - nn.Linear(intermediate_dim // 2, len(OPERATIONS)), - ) - - def _has_digit_tokens(self, token_ids: torch.Tensor) -> bool: - """Check if input contains digit tokens.""" - for tid in token_ids.tolist(): - if tid in self.DIGIT_TOKENS: - return True - return False - - def _find_op_position(self, token_ids: torch.Tensor) -> int: - """Find position of operator token, returns -1 if not found.""" - tokens = token_ids.tolist() - for i, tid in enumerate(tokens): - if tid in self.ALL_OP_TOKENS: - return i - return -1 - - def _extract_from_digits(self, token_ids: torch.Tensor) -> tuple: - """ - Extract values directly from digit tokens (hardcoded lookup). - Handles both symbol operators (' +') and word operators ('plus'). - Returns (a_value, b_value, op_idx) or None if pattern not found. - """ - tokens = token_ids.tolist() - - op_pos = -1 - op_idx = 0 - for i, tid in enumerate(tokens): - if tid in self.ALL_OP_TOKENS: - op_pos = i - op_idx = self.ALL_OP_TOKENS[tid] - break - - if op_pos == -1: - return None - - a_digits = [] - for i in range(op_pos): - if tokens[i] in self.DIGIT_TOKENS: - a_digits.append(tokens[i] - 32) - - b_start = op_pos + 1 - if b_start < len(tokens) and tokens[b_start] == 216: - b_start += 1 - - b_digits = [] - for i in range(b_start, len(tokens)): - if tokens[i] in self.DIGIT_TOKENS: - b_digits.append(tokens[i] - 32) - - if not a_digits or not b_digits: - return None - - a_val = 0 - for d in a_digits: - a_val = a_val * 10 + d - - b_val = 0 - for d in b_digits: - b_val = b_val * 10 + d - - return min(a_val, 255), min(b_val, 255), op_idx - - def _value_to_bits(self, value: int, device) -> torch.Tensor: - """Convert integer to 8-bit tensor.""" - bits = torch.zeros(8, device=device) - for i in range(8): - bits[7 - i] = (value >> i) & 1 - return bits - - def _digits_to_value_and_bits(self, digit_logits: torch.Tensor, device) -> tuple: - """ - Convert 3-digit logits to value and bits. - digit_logits: [30] (3 digits × 10 classes) - Returns: (value tensor, bits tensor [8]) - """ - logits = digit_logits.view(3, 10) - probs = torch.softmax(logits, dim=-1) - - digit_values = torch.arange(10, device=device, dtype=torch.float32) - soft_digits = (probs * digit_values).sum(dim=-1) - - hundreds = soft_digits[0] - tens = soft_digits[1] - ones = soft_digits[2] - - value = hundreds * 100 + tens * 10 + ones - value = torch.clamp(value, 0, 255) - - bits = [] - for i in range(7, -1, -1): - threshold = 2 ** i - bit = torch.sigmoid((value - threshold + 0.5) * 10) - bits.append(bit) - value = value - bit * threshold - return hundreds * 100 + tens * 10 + ones, torch.stack(bits) - - def forward(self, hidden: torch.Tensor, mask: torch.Tensor, token_ids: torch.Tensor = None): - """ - Args: - hidden: [batch, seq_len, hidden_dim] - mask: [batch, seq_len] - token_ids: [batch, seq_len] - optional, enables digit lookup - - Returns: - a_bits, b_bits, op_logits, a_values, b_values, used_lookup, - a_digit_logits, b_digit_logits - """ - batch_size = hidden.shape[0] - device = hidden.device - - a_bits_list = [] - a_digit_logits_list = [] - b_digit_logits_list = [] - b_bits_list = [] - op_logits_list = [] - a_values_list = [] - b_values_list = [] - used_lookup_list = [] - - pooled = self.attention_pool(hidden, mask) - - for i in range(batch_size): - lookup_result = None - if token_ids is not None: - seq_mask = mask[i].bool() - valid_len = seq_mask.sum().item() - start_pos = hidden.shape[1] - valid_len - valid_tokens = token_ids[i, start_pos:] - - if self._has_digit_tokens(valid_tokens): - lookup_result = self._extract_from_digits(valid_tokens) - - if lookup_result is not None: - a_val, b_val, op_idx = lookup_result - a_bits = self._value_to_bits(a_val, device) - b_bits = self._value_to_bits(b_val, device) - op_logits = torch.zeros(len(OPERATIONS), device=device) - op_logits[op_idx] = 10.0 - - a_bits_list.append(a_bits) - b_bits_list.append(b_bits) - op_logits_list.append(op_logits) - a_values_list.append(float(a_val)) - b_values_list.append(float(b_val)) - used_lookup_list.append(True) - a_digit_logits_list.append(None) - b_digit_logits_list.append(None) - else: - sample_hidden = hidden[i:i+1] - sample_mask = mask[i:i+1] - - seq_mask = mask[i].bool() - valid_len = int(seq_mask.sum().item()) - start_pos = hidden.shape[1] - valid_len - valid_tokens = token_ids[i, start_pos:] if token_ids is not None else None - - op_pos = self._find_op_position(valid_tokens) if valid_tokens is not None else -1 - - if op_pos > 0 and op_pos < valid_len - 1: - a_end = start_pos + op_pos - b_start = start_pos + op_pos + 1 - - a_mask = torch.zeros_like(sample_mask) - a_mask[0, start_pos:a_end] = 1.0 - b_mask = torch.zeros_like(sample_mask) - b_mask[0, b_start:] = sample_mask[0, b_start:] - - a_pooled = self.a_pool(sample_hidden, a_mask)[0] - b_pooled = self.b_pool(sample_hidden, b_mask)[0] - else: - a_pooled = pooled[i] - b_pooled = pooled[i] - - a_digit_logits = self.a_digit_pred(a_pooled) - b_digit_logits = self.b_digit_pred(b_pooled) - op_logits = self.op_predictor(pooled[i]) - - a_val, a_bits = self._digits_to_value_and_bits(a_digit_logits, device) - b_val, b_bits = self._digits_to_value_and_bits(b_digit_logits, device) - - a_bits_list.append(a_bits) - b_bits_list.append(b_bits) - op_logits_list.append(op_logits) - a_values_list.append(a_val) - b_values_list.append(b_val) - used_lookup_list.append(False) - a_digit_logits_list.append(a_digit_logits) - b_digit_logits_list.append(b_digit_logits) - - a_bits = torch.stack(a_bits_list) - b_bits = torch.stack(b_bits_list) - op_logits = torch.stack(op_logits_list) - a_values = torch.stack([v if isinstance(v, torch.Tensor) else torch.tensor(v, device=device) for v in a_values_list]) - b_values = torch.stack([v if isinstance(v, torch.Tensor) else torch.tensor(v, device=device) for v in b_values_list]) - used_lookup = torch.tensor(used_lookup_list, device=device, dtype=torch.bool) - valid_a_logits = [x for x in a_digit_logits_list if x is not None] - valid_b_logits = [x for x in b_digit_logits_list if x is not None] - a_digit_logits_out = torch.stack(valid_a_logits) if valid_a_logits else None - b_digit_logits_out = torch.stack(valid_b_logits) if valid_b_logits else None - - return a_bits, b_bits, op_logits, a_values, b_values, used_lookup, a_digit_logits_out, b_digit_logits_out - - def _soft_value_to_bits(self, value: torch.Tensor, device) -> torch.Tensor: - """Convert soft value (0-255) to 8-bit representation differentiably.""" - value = torch.clamp(value, 0, 255) - bits = [] - remaining = value - for i in range(7, -1, -1): - threshold = 2 ** i - bit = torch.sigmoid((remaining - threshold + 0.5) * 10) - bits.append(bit) - remaining = remaining - bit * threshold - return torch.stack(bits) - - -class ArithmeticModel(nn.Module): - """ - LLM + extractor + frozen threshold circuits. - Optionally unfreeze top N transformer layers with --unfreeze_layers. - """ - - def __init__(self, device: str = 'cuda', unfreeze_layers: int = 0, - extract_layer: int = -1, position_extract: bool = False, - digit_pred: bool = False, positional_digit: bool = False, - hybrid: bool = False): - super().__init__() - self.device = device - self.unfreeze_layers = unfreeze_layers - self.extract_layer = extract_layer - self.position_extract = position_extract - self.digit_pred = digit_pred - self.positional_digit = positional_digit - self.hybrid = hybrid - - from transformers import AutoModelForCausalLM, AutoTokenizer - - print("[1/4] Loading tokenizer...", flush=True) - self.tokenizer = AutoTokenizer.from_pretrained(MODEL_ID) - self.tokenizer.padding_side = 'left' - if self.tokenizer.pad_token is None: - self.tokenizer.pad_token = self.tokenizer.eos_token - print(" Tokenizer loaded.", flush=True) - - print("[2/4] Loading SmolLM2-360M...", flush=True) - self.llm = AutoModelForCausalLM.from_pretrained( - MODEL_ID, - torch_dtype=torch.float16, - device_map=device, - output_hidden_states=True - ) - - for param in self.llm.parameters(): - param.requires_grad = False - - if unfreeze_layers > 0: - num_layers = len(self.llm.model.layers) - layers_to_unfreeze = list(range(num_layers - unfreeze_layers, num_layers)) - print(f" Unfreezing layers {layers_to_unfreeze}...", flush=True) - for layer_idx in layers_to_unfreeze: - for param in self.llm.model.layers[layer_idx].parameters(): - param.requires_grad = True - - hidden_dim = self.llm.config.hidden_size - llm_params = sum(p.numel() for p in self.llm.parameters()) - trainable_llm = sum(p.numel() for p in self.llm.parameters() if p.requires_grad) - print(f" LLM loaded. Hidden dim: {hidden_dim}", flush=True) - print(f" LLM params: {llm_params:,} total, {trainable_llm:,} trainable", flush=True) - - print("[3/4] Loading threshold circuits...", flush=True) - self.circuits = FrozenThresholdCircuits(device=device) - print(f" Circuits loaded. {len(self.circuits.weights)} tensors", flush=True) - - print("[4/4] Initializing extractor...", flush=True) - if hybrid: - print(" Using HYBRID extraction (digit lookup + word learning)", flush=True) - self.extractor = HybridExtractor( - hidden_dim=hidden_dim, - intermediate_dim=256, - num_heads=4 - ).to(device) - elif positional_digit: - print(" Using POSITIONAL DIGIT extraction (100% proven)", flush=True) - self.extractor = PositionalDigitExtractor( - hidden_dim=hidden_dim - ).to(device) - elif position_extract: - print(" Using position-specific extraction", flush=True) - self.extractor = PositionExtractor( - hidden_dim=hidden_dim, - intermediate_dim=256 - ).to(device) - elif digit_pred: - print(" Using digit-level prediction", flush=True) - self.extractor = DigitExtractor( - hidden_dim=hidden_dim, - intermediate_dim=256, - num_heads=4 - ).to(device) - else: - self.extractor = Extractor( - hidden_dim=hidden_dim, - intermediate_dim=256, - num_heads=4 - ).to(device) - - if extract_layer != -1: - print(f" Extracting from layer {extract_layer}", flush=True) - - trainable_ext = sum(p.numel() for p in self.extractor.parameters()) - total_trainable = trainable_llm + trainable_ext - print(f" Extractor params: {trainable_ext:,}", flush=True) - print(f" Total trainable: {total_trainable:,}", flush=True) - - def get_hidden_states(self, texts: list[str]) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: - """ - Get hidden states from specified layer. - - Returns: - hidden: [batch, seq_len, hidden_dim] hidden states - mask: [batch, seq_len] attention mask - token_ids: [batch, seq_len] input token IDs - """ - inputs = self.tokenizer( - texts, - return_tensors='pt', - padding=True, - truncation=True, - max_length=64 - ).to(self.device) - - if self.unfreeze_layers > 0: - outputs = self.llm(**inputs, output_hidden_states=True) - else: - with torch.no_grad(): - outputs = self.llm(**inputs, output_hidden_states=True) - - hidden = outputs.hidden_states[self.extract_layer].float() - mask = inputs.attention_mask.float() - token_ids = inputs.input_ids - - return hidden, mask, token_ids - - def forward(self, texts: list[str]): - """ - Full forward pass: text -> hidden states -> extractor -> circuits -> result - - Returns: - result_bits, a_bits, b_bits, op_logits - If digit_pred: also returns a_digit_logits, b_digit_logits - If position_extract or positional_digit: also returns op_indices - If positional_digit: also returns a_values, b_values - """ - hidden, mask, token_ids = self.get_hidden_states(texts) - - if self.hybrid or self.positional_digit or self.position_extract: - extractor_out = self.extractor(hidden, mask, token_ids) - else: - extractor_out = self.extractor(hidden, mask) - - if self.hybrid: - a_bits, b_bits, op_logits, a_values, b_values, used_lookup, a_digit_logits, b_digit_logits = extractor_out - op_indices_from_tokens = None - elif self.positional_digit: - a_bits, b_bits, op_logits, op_indices_from_tokens, a_values, b_values, a_digit_logits, b_digit_logits = extractor_out - used_lookup = None - elif self.digit_pred: - a_bits, b_bits, op_logits, a_digit_logits, b_digit_logits = extractor_out - op_indices_from_tokens = None - a_values, b_values = None, None - used_lookup = None - elif self.position_extract: - a_bits, b_bits, op_logits, op_indices_from_tokens = extractor_out - a_digit_logits, b_digit_logits = None, None - a_values, b_values = None, None - used_lookup = None - else: - a_bits, b_bits, op_logits = extractor_out - a_digit_logits, b_digit_logits = None, None - op_indices_from_tokens = None - a_values, b_values = None, None - used_lookup = None - - op_probs = torch.softmax(op_logits, dim=-1) - - result_bits = self.circuits(a_bits, b_bits, op_probs) - - if self.hybrid: - return result_bits, a_bits, b_bits, op_logits, a_values, b_values, used_lookup, a_digit_logits, b_digit_logits - if self.positional_digit: - return result_bits, a_bits, b_bits, op_logits, op_indices_from_tokens, a_values, b_values, a_digit_logits, b_digit_logits - if self.digit_pred: - return result_bits, a_bits, b_bits, op_logits, a_digit_logits, b_digit_logits - if self.position_extract: - return result_bits, a_bits, b_bits, op_logits, op_indices_from_tokens - return result_bits, a_bits, b_bits, op_logits - - def trainable_parameters(self): - """Return all trainable parameters for optimizer.""" - params = list(self.extractor.parameters()) - if self.unfreeze_layers > 0: - params += [p for p in self.llm.parameters() if p.requires_grad] - return params - - -if __name__ == "__main__": - import sys - sys.path.insert(0, '.') - from fitness import generate_batch, compute_fitness, OPERATIONS - - print("Testing model components...") - - device = 'cuda' - batch = generate_batch(32, device) - - print("\n1. Testing DirectCircuitModel (should get ~100% fitness)...") - direct_model = DirectCircuitModel(device=device) - - def direct_fn(a, b, op): - return direct_model(a, b, op) - - fitness, details = compute_fitness(direct_fn, n_samples=2000, batch_size=128, - device=device, return_details=True) - print(f" Direct circuit fitness: {fitness:.4f}") - for op in OPERATIONS: - acc = details['by_op'][op]['accuracy'] - print(f" {op}: {acc:.4f}") - - print("\n2. Testing ThresholdALU (trainable interface)...") - model = ThresholdALU(device=device) - - x = torch.cat([batch['a_bits'], batch['b_bits'], batch['op_onehot']], dim=-1) - a_enc, b_enc = model.encoder(x) - print(f" Encoder output shapes: a={a_enc.shape}, b={b_enc.shape}") - - op_weights = model.router(x) - print(f" Router output shape: {op_weights.shape}") - print(f" Router output sample: {op_weights[0].tolist()}") - - result = model(batch['a_bits'], batch['b_bits'], batch['op_onehot']) - print(f" Full model output shape: {result.shape}") - - print("\n3. Testing untrained ThresholdALU fitness...") - - def model_fn(a, b, op): - return model(a, b, op) - - fitness = compute_fitness(model_fn, n_samples=1000, batch_size=128, device=device) - print(f" Untrained model fitness: {fitness:.4f} (expected low)") - - print("\n4. Counting parameters...") - total = sum(p.numel() for p in model.parameters() if p.requires_grad) - encoder_params = sum(p.numel() for p in model.encoder.parameters()) - router_params = sum(p.numel() for p in model.router.parameters()) - print(f" Encoder: {encoder_params:,}") - print(f" Router: {router_params:,}") - print(f" Total trainable: {total:,}") - - print("\nDone.") diff --git a/llm_integration/smollm2/analyze_smollm2.py b/llm_integration/smollm2/analyze_smollm2.py deleted file mode 100644 index e4acd2dce7fd10233668d9fe630102ada6525dc9..0000000000000000000000000000000000000000 --- a/llm_integration/smollm2/analyze_smollm2.py +++ /dev/null @@ -1,232 +0,0 @@ -""" -SmolLM2-360M-Instruct Architecture Analysis -For 8bit-threshold-computer LLM Integration Project -""" - -import torch -from transformers import AutoModelForCausalLM, AutoTokenizer, AutoConfig -from collections import defaultdict -import json - -def analyze_smollm2(): - model_name = "HuggingFaceTB/SmolLM2-360M-Instruct" - - print("=" * 80) - print("SmolLM2-360M-Instruct Architecture Analysis") - print("=" * 80) - - # Load config first - print("\n[1] Loading model configuration...") - config = AutoConfig.from_pretrained(model_name) - print(f"Config loaded: {type(config).__name__}") - - # Load tokenizer - print("\n[2] Loading tokenizer...") - tokenizer = AutoTokenizer.from_pretrained(model_name) - print(f"Tokenizer loaded: {type(tokenizer).__name__}") - - # Load model with hidden states output - print("\n[3] Loading model with output_hidden_states=True...") - model = AutoModelForCausalLM.from_pretrained( - model_name, - output_hidden_states=True, - torch_dtype=torch.float32 - ) - model.eval() - print(f"Model loaded: {type(model).__name__}") - - # ======================================================================== - # ARCHITECTURE CENSUS - # ======================================================================== - print("\n" + "=" * 80) - print("ARCHITECTURE CENSUS") - print("=" * 80) - - print("\n--- Model Configuration ---") - config_dict = config.to_dict() - for key, value in sorted(config_dict.items()): - print(f" {key}: {value}") - - print("\n--- Key Architecture Parameters ---") - print(f" Model type: {config.model_type}") - print(f" Vocabulary size: {config.vocab_size}") - print(f" Hidden size: {config.hidden_size}") - print(f" Intermediate size: {config.intermediate_size}") - print(f" Number of hidden layers: {config.num_hidden_layers}") - print(f" Number of attention heads: {config.num_attention_heads}") - print(f" Number of KV heads: {getattr(config, 'num_key_value_heads', config.num_attention_heads)}") - print(f" Head dimension: {config.hidden_size // config.num_attention_heads}") - print(f" Max position embeddings: {config.max_position_embeddings}") - print(f" RMS norm epsilon: {getattr(config, 'rms_norm_eps', 'N/A')}") - print(f" Rope theta: {getattr(config, 'rope_theta', 'N/A')}") - print(f" Tie word embeddings: {getattr(config, 'tie_word_embeddings', 'N/A')}") - - # ======================================================================== - # WEIGHT INVENTORY - # ======================================================================== - print("\n" + "=" * 80) - print("WEIGHT INVENTORY") - print("=" * 80) - - total_params = 0 - param_groups = defaultdict(list) - - for name, param in model.named_parameters(): - total_params += param.numel() - - # Group by component - if "embed_tokens" in name: - group = "Embedding" - elif "lm_head" in name: - group = "LM Head" - elif "norm" in name and "layers" not in name: - group = "Final Norm" - elif "layers" in name: - layer_num = name.split(".")[2] - if "self_attn" in name: - group = f"Layer {layer_num} - Attention" - elif "mlp" in name: - group = f"Layer {layer_num} - MLP" - elif "norm" in name: - group = f"Layer {layer_num} - Norms" - else: - group = f"Layer {layer_num} - Other" - else: - group = "Other" - - param_groups[group].append({ - "name": name, - "shape": tuple(param.shape), - "numel": param.numel(), - "dtype": str(param.dtype) - }) - - print(f"\n--- Total Parameters: {total_params:,} ---") - print(f" ({total_params / 1e6:.2f}M parameters)") - - # Print by group - for group_name in sorted(param_groups.keys()): - params = param_groups[group_name] - group_total = sum(p["numel"] for p in params) - print(f"\n### {group_name} ({group_total:,} params, {group_total/total_params*100:.2f}%)") - for p in params: - print(f" {p['name']}") - print(f" Shape: {p['shape']}, Elements: {p['numel']:,}, Dtype: {p['dtype']}") - - # ======================================================================== - # TOKENIZATION ANALYSIS - # ======================================================================== - print("\n" + "=" * 80) - print("TOKENIZATION ANALYSIS") - print("=" * 80) - - test_input = "47 + 86" - print(f"\n--- Test Input: '{test_input}' ---") - - tokens = tokenizer(test_input, return_tensors="pt") - input_ids = tokens["input_ids"][0] - - print(f"\nInput IDs: {input_ids.tolist()}") - print(f"Number of tokens: {len(input_ids)}") - - print("\nToken breakdown:") - for i, token_id in enumerate(input_ids): - token_str = tokenizer.decode([token_id]) - print(f" Position {i}: ID={token_id.item():5d}, Token='{token_str}'") - - # Additional tokenization tests - print("\n--- Additional Tokenization Tests ---") - test_cases = ["0", "1", "47", "86", "133", " + ", "="] - for tc in test_cases: - ids = tokenizer.encode(tc, add_special_tokens=False) - decoded = [tokenizer.decode([i]) for i in ids] - print(f" '{tc}' -> IDs: {ids}, Tokens: {decoded}") - - # ======================================================================== - # HIDDEN STATE ANALYSIS - # ======================================================================== - print("\n" + "=" * 80) - print("HIDDEN STATE ANALYSIS") - print("=" * 80) - - print(f"\n--- Running inference on '{test_input}' ---") - - with torch.no_grad(): - outputs = model(**tokens) - - hidden_states = outputs.hidden_states - print(f"\nNumber of hidden state outputs: {len(hidden_states)}") - print("(This includes embedding output + each layer's output)") - - print("\nHidden state shapes at each layer:") - for i, hs in enumerate(hidden_states): - layer_name = "Embedding" if i == 0 else f"Layer {i-1}" - print(f" {layer_name}: {tuple(hs.shape)}") - if i == 0: - print(f" (batch_size=1, seq_len={hs.shape[1]}, hidden_dim={hs.shape[2]})") - - # Analyze hidden state statistics at different layers - print("\n--- Hidden State Statistics (per layer) ---") - for i, hs in enumerate(hidden_states): - layer_name = "Embedding" if i == 0 else f"Layer {i-1}" - hs_flat = hs.view(-1) - print(f" {layer_name}:") - print(f" Mean: {hs_flat.mean().item():.6f}") - print(f" Std: {hs_flat.std().item():.6f}") - print(f" Min: {hs_flat.min().item():.6f}") - print(f" Max: {hs_flat.max().item():.6f}") - - # ======================================================================== - # MODEL STRUCTURE DEEP DIVE - # ======================================================================== - print("\n" + "=" * 80) - print("MODEL STRUCTURE DEEP DIVE") - print("=" * 80) - - print("\n--- Model Architecture String ---") - print(model) - - # ======================================================================== - # SUMMARY DATA FOR REPORT - # ======================================================================== - summary = { - "model_name": model_name, - "total_params": total_params, - "config": { - "vocab_size": config.vocab_size, - "hidden_size": config.hidden_size, - "intermediate_size": config.intermediate_size, - "num_hidden_layers": config.num_hidden_layers, - "num_attention_heads": config.num_attention_heads, - "num_kv_heads": getattr(config, 'num_key_value_heads', config.num_attention_heads), - "head_dim": config.hidden_size // config.num_attention_heads, - "max_position_embeddings": config.max_position_embeddings, - "rms_norm_eps": getattr(config, 'rms_norm_eps', None), - "rope_theta": getattr(config, 'rope_theta', None), - "tie_word_embeddings": getattr(config, 'tie_word_embeddings', None), - }, - "tokenization": { - "test_input": test_input, - "token_ids": input_ids.tolist(), - "num_tokens": len(input_ids), - "tokens": [tokenizer.decode([tid]) for tid in input_ids] - }, - "hidden_states": { - "num_outputs": len(hidden_states), - "shape": list(hidden_states[0].shape) - }, - "param_groups": {k: {"count": len(v), "total": sum(p["numel"] for p in v)} for k, v in param_groups.items()} - } - - # Save summary as JSON for report generation - with open("D:/8bit-threshold-computer/llm_integration/smollm2_analysis.json", "w") as f: - json.dump(summary, f, indent=2) - - print("\n" + "=" * 80) - print("Analysis complete. Summary saved to smollm2_analysis.json") - print("=" * 80) - - return summary, model, tokenizer, hidden_states, param_groups - -if __name__ == "__main__": - summary, model, tokenizer, hidden_states, param_groups = analyze_smollm2() diff --git a/llm_integration/train.py b/llm_integration/train.py deleted file mode 100644 index bb9346c83505945f45a7c8c2fff2da4332c4e114..0000000000000000000000000000000000000000 --- a/llm_integration/train.py +++ /dev/null @@ -1,955 +0,0 @@ -""" -Unified training script for threshold circuit LLM integration. - -Modes: - --mode router : Train only OpRouter with ground truth bits (sanity check) - --mode interface : Train BitEncoder + OpRouter with ground truth bits (sanity check) - --mode llm : Train extractor with LLM hidden states (the real training) - -LLM mode options: - --unfreeze_layers N : Unfreeze top N transformer layers (default 0 = fully frozen) - -Hardware Profile (NVIDIA RTX 6000 Ada 48GB): - VRAM Scaling (unfreeze_layers=4): - batch_size | VRAM | % - -----------+---------+------ - 512 | 5,784 | 11.8% - 1,024 | 7,384 | 15.0% - 4,096 | 16,534 | 33.6% - 13,000 | 39,000 | 79.4% <-- recommended for 80% target - -Examples: - python train.py --mode llm --epochs 100 --batch_size 256 - python train.py --mode llm --epochs 100 --batch_size 4096 --unfreeze_layers 4 -""" - -import torch -import torch.nn as nn -import torch.optim as optim -import time -import argparse -import random - -from model import ( - ThresholdALU, DirectCircuitModel, OpRouter, - ArithmeticModel, OPERATIONS, OP_SYMBOLS -) -from circuits import FrozenThresholdCircuits -from fitness import generate_batch, compute_fitness, compute_loss - -DEVICE = 'cuda' - -ONES = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', - 'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', - 'seventeen', 'eighteen', 'nineteen'] -TENS = ['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety'] - -def int_to_words(n: int) -> str: - """Convert integer 0-255 to English words.""" - if n < 0 or n > 255: - return str(n) - if n < 20: - return ONES[n] - if n < 100: - if n % 10 == 0: - return TENS[n // 10] - return f"{TENS[n // 10]} {ONES[n % 10]}" - if n % 100 == 0: - return f"{ONES[n // 100]} hundred" - if n % 100 < 20: - return f"{ONES[n // 100]} hundred {ONES[n % 100]}" - if n % 10 == 0: - return f"{ONES[n // 100]} hundred {TENS[(n % 100) // 10]}" - return f"{ONES[n // 100]} hundred {TENS[(n % 100) // 10]} {ONES[n % 10]}" - - -def int_to_bits(val: int, device: str = 'cuda') -> torch.Tensor: - bits = torch.zeros(8, device=device) - for i in range(8): - bits[7-i] = (val >> i) & 1 - return bits - - -def bits_to_int(bits: torch.Tensor) -> int: - val = 0 - for i in range(8): - if bits[i].item() > 0.5: - val += 1 << (7-i) - return val - - -NL_TEMPLATES = { - 'add': [ - "What is {a} plus {b}?", - "Calculate {a} + {b}", - "Add {a} and {b}", - "What's the sum of {a} and {b}?", - "If I have {a} and get {b} more, how many total?", - "{a} + {b} = ?", - "Compute {a} plus {b}", - ], - 'sub': [ - "What is {a} minus {b}?", - "Calculate {a} - {b}", - "Subtract {b} from {a}", - "What's {a} take away {b}?", - "If I have {a} and lose {b}, how many left?", - "{a} - {b} = ?", - "Compute {a} minus {b}", - ], - 'mul': [ - "What is {a} times {b}?", - "Calculate {a} * {b}", - "Multiply {a} by {b}", - "What's {a} multiplied by {b}?", - "{a} * {b} = ?", - "Compute {a} times {b}", - "What is the product of {a} and {b}?", - ], - 'gt': [ - "Is {a} greater than {b}?", - "Is {a} > {b}?", - "Check if {a} is larger than {b}", - "Compare: is {a} more than {b}?", - "{a} > {b}?", - ], - 'lt': [ - "Is {a} less than {b}?", - "Is {a} < {b}?", - "Check if {a} is smaller than {b}", - "Compare: is {a} fewer than {b}?", - "{a} < {b}?", - ], - 'eq': [ - "Is {a} equal to {b}?", - "Is {a} == {b}?", - "Does {a} equal {b}?", - "Check if {a} equals {b}", - "Are {a} and {b} the same?", - ], -} - - -def generate_problem(max_val: int = 255): - """ - Generate a random arithmetic problem for LLM training. - Randomly mixes digit and word formats. - """ - a = random.randint(0, max_val) - b = random.randint(0, max_val) - op = random.choice(OPERATIONS) - - fmt = random.choice(['digits', 'words', 'nl_digits', 'nl_words']) - - if fmt == 'digits': - sym = OP_SYMBOLS[op] - text = f"{a} {sym} {b}" - elif fmt == 'words': - a_word = int_to_words(a) - b_word = int_to_words(b) - op_word = {'add': 'plus', 'sub': 'minus', 'mul': 'times', - 'gt': 'greater than', 'lt': 'less than', 'eq': 'equals'}[op] - text = f"{a_word} {op_word} {b_word}" - elif fmt == 'nl_digits': - template = random.choice(NL_TEMPLATES[op]) - text = template.format(a=a, b=b) - else: - template = random.choice(NL_TEMPLATES[op]) - text = template.format(a=int_to_words(a), b=int_to_words(b)) - - if op == 'add': - result = (a + b) & 0xFF - elif op == 'sub': - result = (a - b) & 0xFF - elif op == 'mul': - result = (a * b) & 0xFF - elif op == 'gt': - result = 1 if a > b else 0 - elif op == 'lt': - result = 1 if a < b else 0 - elif op == 'eq': - result = 1 if a == b else 0 - - return text, a, b, op, result - - -def get_curriculum_max(epoch: int, total_epochs: int) -> int: - """ - Curriculum learning: start with small numbers, gradually increase. - Epoch 0-20%: 0-9 (single digit) - Epoch 20-50%: 0-99 (two digit) - Epoch 50-100%: 0-255 (full range) - """ - progress = epoch / total_epochs - if progress < 0.2: - return 9 - elif progress < 0.5: - return 99 - else: - return 255 - - -def train_router(epochs: int = 100, batch_size: int = 256, lr: float = 1e-2, device: str = 'cuda'): - """Train only the router with ground truth bits.""" - print("=" * 70) - print(" ROUTER-ONLY TRAINING (Ground Truth Bits)") - print("=" * 70) - - circuits = FrozenThresholdCircuits(device=device) - router = OpRouter(input_dim=16 + 6, hidden_dim=64, n_ops=6).to(device) - - print(f"\nRouter parameters: {sum(p.numel() for p in router.parameters()):,}") - - def model_fn(a_bits, b_bits, op_onehot): - x = torch.cat([a_bits, b_bits, op_onehot], dim=-1) - op_weights = router(x) - return circuits(a_bits, b_bits, op_weights) - - initial_fitness = compute_fitness(model_fn, n_samples=1000, device=device) - print(f"Initial fitness: {initial_fitness:.4f}") - - optimizer = optim.AdamW(router.parameters(), lr=lr) - scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs) - - print("\nTraining...") - print("-" * 70) - - best_fitness = initial_fitness - start_time = time.perf_counter() - - for epoch in range(epochs): - router.train() - epoch_loss = 0.0 - - for _ in range(100): - batch = generate_batch(batch_size, device) - - optimizer.zero_grad() - - x = torch.cat([batch['a_bits'], batch['b_bits'], batch['op_onehot']], dim=-1) - op_weights = router(x) - pred_bits = circuits(batch['a_bits'], batch['b_bits'], op_weights) - - loss = compute_loss(pred_bits, batch['expected_bits']) - loss.backward() - optimizer.step() - - epoch_loss += loss.item() - - scheduler.step() - - if (epoch + 1) % 10 == 0 or epoch == 0: - router.eval() - fitness, details = compute_fitness(model_fn, n_samples=2000, device=device, return_details=True) - elapsed = time.perf_counter() - start_time - - if fitness > best_fitness: - best_fitness = fitness - marker = " *" - else: - marker = "" - - print(f"Epoch {epoch+1:3d} | Loss: {epoch_loss/100:.4f} | " - f"Fitness: {fitness:.4f}{marker} | Time: {elapsed:.1f}s") - - if fitness >= 0.9999: - print("\n TARGET: 100% FITNESS ACHIEVED") - break - - print("\n" + "=" * 70) - print(" RESULTS") - print("=" * 70) - - router.eval() - final_fitness, details = compute_fitness(model_fn, n_samples=5000, device=device, return_details=True) - - print(f"\nFinal fitness: {final_fitness:.4f}") - print(f"\nPer-operation:") - for op in OPERATIONS: - acc = details['by_op'][op]['accuracy'] - print(f" {op}: {acc:.4f}") - - print(f"\nTotal time: {time.perf_counter() - start_time:.1f}s") - - if final_fitness >= 0.99: - print("\nCONCLUSION: Router successfully learned operation dispatch.") - print(" With correct bit encoding, 100% is achievable.") - - save_path = "D:/8bit-threshold-computer/llm_integration/trained/router.pt" - torch.save({ - 'router_state_dict': router.state_dict(), - 'final_fitness': final_fitness, - 'params': sum(p.numel() for p in router.parameters()), - }, save_path) - print(f"\nSaved trained router to: {save_path}") - - return router, final_fitness - - -def get_gpu_memory(): - """Get GPU memory usage in MB.""" - if torch.cuda.is_available(): - return torch.cuda.memory_allocated() / 1024 / 1024, torch.cuda.max_memory_allocated() / 1024 / 1024 - return 0, 0 - - -def train_interface(epochs: int = 200, batch_size: int = 512, lr: float = 1e-3, - eval_interval: int = 10, device: str = 'cuda'): - """Train BitEncoder + OpRouter with ground truth bits.""" - print("=" * 70) - print(" INTERFACE TRAINING (Encoder + Router)") - print("=" * 70) - print(f" Started at: {time.strftime('%H:%M:%S')}") - - print("\n[1/4] Verifying frozen circuits...") - print(" Creating DirectCircuitModel...", end=" ", flush=True) - direct_model = DirectCircuitModel(device=device) - mem, max_mem = get_gpu_memory() - print(f"done. VRAM: {mem:.0f}MB") - - def direct_fn(a, b, op): - return direct_model(a, b, op) - - print(" Running fitness check (1000 samples)...", end=" ", flush=True) - circuit_fitness = compute_fitness(direct_fn, n_samples=1000, device=device) - print(f"done. Fitness: {circuit_fitness:.4f}") - if circuit_fitness < 0.999: - print(" ERROR: Circuits not achieving 100%. Aborting.") - return None, 0.0 - print(" STATUS: PASS") - - print("\n[2/4] Initializing model...") - print(" Creating ThresholdALU...", end=" ", flush=True) - model = ThresholdALU(device=device) - mem, max_mem = get_gpu_memory() - print(f"done. VRAM: {mem:.0f}MB") - - trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad) - print(f" Trainable parameters: {trainable_params:,}") - - def model_fn(a, b, op): - return model(a, b, op) - - print(" Running initial fitness check...", end=" ", flush=True) - initial_fitness = compute_fitness(model_fn, n_samples=1000, device=device) - print(f"done. Fitness: {initial_fitness:.4f}") - - print("\n[3/4] Setting up training...") - print(" Creating optimizer...", end=" ", flush=True) - optimizer = optim.AdamW(model.parameters(), lr=lr) - print("done.") - print(" Creating scheduler...", end=" ", flush=True) - scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs) - print("done.") - - print(f" Config: lr={lr}, batch_size={batch_size}, epochs={epochs}") - - print("\n[4/4] Training...") - print(" Generating first batch to warm up...", end=" ", flush=True) - warmup_batch = generate_batch(batch_size, device) - mem, max_mem = get_gpu_memory() - print(f"done. VRAM: {mem:.0f}MB (max: {max_mem:.0f}MB)") - - print("-" * 70) - - best_fitness = initial_fitness - start_time = time.perf_counter() - n_batches = 100 - - for epoch in range(epochs): - model.train() - epoch_loss = 0.0 - epoch_start = time.perf_counter() - - for batch_idx in range(n_batches): - batch = generate_batch(batch_size, device) - - optimizer.zero_grad() - - pred_bits = model(batch['a_bits'], batch['b_bits'], batch['op_onehot']) - - loss = compute_loss(pred_bits, batch['expected_bits']) - - loss.backward() - optimizer.step() - - epoch_loss += loss.item() - - if batch_idx == 0 and epoch == 0: - mem, max_mem = get_gpu_memory() - print(f" First forward/backward done. VRAM: {mem:.0f}MB (max: {max_mem:.0f}MB)") - - if (batch_idx + 1) % 25 == 0: - avg_so_far = epoch_loss / (batch_idx + 1) - print(f" Epoch {epoch+1} batch {batch_idx+1}/{n_batches} | loss: {avg_so_far:.4f}", flush=True) - - scheduler.step() - - avg_loss = epoch_loss / n_batches - epoch_time = time.perf_counter() - epoch_start - - if (epoch + 1) % 5 == 0 or epoch == 0: # Eval every 5 epochs - model.eval() - fitness, details = compute_fitness( - model_fn, n_samples=2000, device=device, return_details=True - ) - - elapsed = time.perf_counter() - start_time - - if fitness > best_fitness: - best_fitness = fitness - marker = " *" - else: - marker = "" - - mem, _ = get_gpu_memory() - print(f"Epoch {epoch+1:4d} | Loss: {avg_loss:.4f} | " - f"Fitness: {fitness:.4f}{marker} | " - f"LR: {scheduler.get_last_lr()[0]:.2e} | " - f"VRAM: {mem:.0f}MB | " - f"Time: {elapsed:.1f}s ({epoch_time:.1f}s/epoch)") - - if fitness >= 0.9999: - print("\n" + "=" * 70) - print(" TARGET ACHIEVED: 100% FITNESS") - print("=" * 70) - break - - print("\n" + "=" * 70) - print(" TRAINING COMPLETE") - print("=" * 70) - - model.eval() - final_fitness, details = compute_fitness( - model_fn, n_samples=5000, device=device, return_details=True - ) - - print(f"\nFinal fitness: {final_fitness:.4f}") - print(f"Best fitness: {best_fitness:.4f}") - print(f"\nPer-operation breakdown:") - for op in OPERATIONS: - acc = details['by_op'][op]['accuracy'] - print(f" {op:6}: {acc:.4f}") - - print(f"\nTotal time: {time.perf_counter() - start_time:.1f}s") - - save_path = "D:/8bit-threshold-computer/llm_integration/trained/interface.pt" - torch.save({ - 'encoder_state_dict': model.encoder.state_dict(), - 'router_state_dict': model.router.state_dict(), - 'final_fitness': final_fitness, - 'best_fitness': best_fitness, - }, save_path) - print(f"\nSaved trained model to: {save_path}") - - return model, final_fitness - - -def compute_llm_loss(pred_bits, a_bits, b_bits, op_logits, - target_result, target_a, target_b, target_op_idx, - bit_weight: float = 2.0): - """ - Multi-component loss for LLM training. - bit_weight: multiplier for a/b bit losses (default 2x since extraction is the bottleneck) - """ - result_loss = nn.functional.binary_cross_entropy_with_logits( - pred_bits, target_result - ) - - a_bits_safe = torch.clamp(a_bits, 0.0, 1.0) - b_bits_safe = torch.clamp(b_bits, 0.0, 1.0) - a_bits_safe = torch.nan_to_num(a_bits_safe, nan=0.5, posinf=1.0, neginf=0.0) - b_bits_safe = torch.nan_to_num(b_bits_safe, nan=0.5, posinf=1.0, neginf=0.0) - - a_loss = nn.functional.binary_cross_entropy( - torch.clamp(a_bits_safe, 1e-6, 1-1e-6), target_a - ) - b_loss = nn.functional.binary_cross_entropy( - torch.clamp(b_bits_safe, 1e-6, 1-1e-6), target_b - ) - - op_loss = nn.functional.cross_entropy(op_logits, target_op_idx) - - total = result_loss + bit_weight * a_loss + bit_weight * b_loss + op_loss - total = torch.nan_to_num(total, nan=10.0, posinf=10.0, neginf=0.0) - - return total, { - 'result': result_loss.item() if not torch.isnan(result_loss) else 10.0, - 'a': a_loss.item() if not torch.isnan(a_loss) else 10.0, - 'b': b_loss.item() if not torch.isnan(b_loss) else 10.0, - 'op': op_loss.item() if not torch.isnan(op_loss) else 10.0 - } - - -def value_to_digits(value: int) -> list: - """Convert integer value to list of digits (hundreds, tens, ones).""" - digits = [] - for place in [100, 10, 1]: - digit = (value // place) % 10 - digits.append(digit) - return digits - - -def compute_positional_digit_loss(pred_bits, op_logits, a_digit_logits_list, b_digit_logits_list, - target_result, target_op_idx, target_a_values, target_b_values, - device, digit_weight: float = 5.0): - """ - Loss for positional digit extraction with DIRECT digit supervision. - - This provides strong gradients by directly supervising digit classification - instead of going through the value -> bits conversion. - """ - result_loss = nn.functional.binary_cross_entropy_with_logits( - pred_bits, target_result - ) - - op_loss = nn.functional.cross_entropy(op_logits, target_op_idx) - - a_digit_loss = torch.tensor(0.0, device=device) - b_digit_loss = torch.tensor(0.0, device=device) - n_a_digits = 0 - n_b_digits = 0 - - for i, (a_logits_list, b_logits_list) in enumerate(zip(a_digit_logits_list, b_digit_logits_list)): - target_a = target_a_values[i].item() - target_b = target_b_values[i].item() - - a_digits = value_to_digits(int(target_a)) - b_digits = value_to_digits(int(target_b)) - - n_a = len(a_logits_list) - n_b = len(b_logits_list) - - if n_a > 0: - target_a_digits = a_digits[-n_a:] - for j, logits in enumerate(a_logits_list): - target_digit = torch.tensor([target_a_digits[j]], device=device, dtype=torch.long) - a_digit_loss = a_digit_loss + nn.functional.cross_entropy(logits.unsqueeze(0), target_digit) - n_a_digits += 1 - - if n_b > 0: - target_b_digits = b_digits[-n_b:] - for j, logits in enumerate(b_logits_list): - target_digit = torch.tensor([target_b_digits[j]], device=device, dtype=torch.long) - b_digit_loss = b_digit_loss + nn.functional.cross_entropy(logits.unsqueeze(0), target_digit) - n_b_digits += 1 - - if n_a_digits > 0: - a_digit_loss = a_digit_loss / n_a_digits - if n_b_digits > 0: - b_digit_loss = b_digit_loss / n_b_digits - - total = result_loss + digit_weight * a_digit_loss + digit_weight * b_digit_loss + op_loss - total = torch.nan_to_num(total, nan=10.0, posinf=10.0, neginf=0.0) - - return total, { - 'result': result_loss.item() if not torch.isnan(result_loss) else 10.0, - 'a_digit': a_digit_loss.item() if not torch.isnan(a_digit_loss) else 10.0, - 'b_digit': b_digit_loss.item() if not torch.isnan(b_digit_loss) else 10.0, - 'op': op_loss.item() if not torch.isnan(op_loss) else 10.0 - } - - -def compute_hybrid_loss(pred_bits, op_logits, used_lookup, - a_digit_logits, b_digit_logits, - target_result, target_a_values, target_b_values, target_op_idx, - device, digit_weight: float = 2.0): - """ - Loss for hybrid extraction with digit-level prediction. - - Uses cross-entropy on each digit (hundreds, tens, ones) for word samples. - Samples using digit lookup are already 100% accurate - no loss computed. - """ - result_loss = nn.functional.binary_cross_entropy_with_logits( - pred_bits, target_result - ) - - op_loss = nn.functional.cross_entropy(op_logits, target_op_idx) - - word_mask = ~used_lookup - n_words = word_mask.sum().item() - - if n_words > 0 and a_digit_logits is not None and b_digit_logits is not None: - target_a_word = target_a_values[word_mask].long() - target_b_word = target_b_values[word_mask].long() - - a_hundreds = target_a_word // 100 - a_tens = (target_a_word % 100) // 10 - a_ones = target_a_word % 10 - - b_hundreds = target_b_word // 100 - b_tens = (target_b_word % 100) // 10 - b_ones = target_b_word % 10 - - a_logits = a_digit_logits.view(-1, 3, 10) - b_logits = b_digit_logits.view(-1, 3, 10) - - a_digit_loss = ( - nn.functional.cross_entropy(a_logits[:, 0], a_hundreds) + - nn.functional.cross_entropy(a_logits[:, 1], a_tens) + - nn.functional.cross_entropy(a_logits[:, 2], a_ones) - ) / 3.0 - - b_digit_loss = ( - nn.functional.cross_entropy(b_logits[:, 0], b_hundreds) + - nn.functional.cross_entropy(b_logits[:, 1], b_tens) + - nn.functional.cross_entropy(b_logits[:, 2], b_ones) - ) / 3.0 - else: - a_digit_loss = torch.tensor(0.0, device=device) - b_digit_loss = torch.tensor(0.0, device=device) - - total = result_loss + op_loss + digit_weight * (a_digit_loss + b_digit_loss) - total = torch.nan_to_num(total, nan=10.0, posinf=10.0, neginf=0.0) - - return total, { - 'result': result_loss.item() if not torch.isnan(result_loss) else 10.0, - 'a_digit': a_digit_loss.item() if not torch.isnan(a_digit_loss) else 10.0, - 'b_digit': b_digit_loss.item() if not torch.isnan(b_digit_loss) else 10.0, - 'op': op_loss.item() if not torch.isnan(op_loss) else 10.0, - 'n_words': n_words, - 'n_lookup': used_lookup.sum().item() - } - - -def evaluate_llm(model, n_samples: int = 500): - """Evaluate LLM model on random problems (mixed digit/word format).""" - model.extractor.eval() - correct = 0 - op_correct = 0 - - for _ in range(n_samples): - text, a, b, op, expected = generate_problem() - - with torch.no_grad(): - outputs = model([text]) - result_bits = outputs[0] - op_logits = outputs[3] - - pred_result = bits_to_int(result_bits[0]) - pred_op = OPERATIONS[op_logits[0].argmax().item()] - - if pred_result == expected: - correct += 1 - if pred_op == op: - op_correct += 1 - - model.extractor.train() - return correct / n_samples, op_correct / n_samples - - -def train_llm(epochs: int = 100, batch_size: int = 256, lr: float = 3e-4, - unfreeze_layers: int = 0, extract_layer: int = -1, - position_extract: bool = False, digit_pred: bool = False, - positional_digit: bool = False, device: str = 'cuda'): - """ - Train extractor with LLM hidden states. - - Args: - unfreeze_layers: Number of top transformer layers to unfreeze (0 = fully frozen) - extract_layer: Which layer to extract from (-1 = last) - position_extract: Use position-specific extraction (legacy) - digit_pred: Predict digits instead of bits (legacy) - positional_digit: Use positional digit extraction (legacy, 100% on digits only) - """ - hybrid = not (positional_digit or position_extract or digit_pred) - - print("=" * 70) - print(" LLM TRAINING") - if unfreeze_layers > 0: - print(f" {unfreeze_layers} transformer layers unfrozen") - else: - print(" LLM frozen") - if extract_layer != -1: - print(f" Extracting from layer {extract_layer}") - if hybrid: - print(" HYBRID extraction (digit lookup + word learning)") - elif positional_digit: - print(" POSITIONAL DIGIT extraction (legacy, 100% on digits only)") - elif position_extract: - print(" Position-specific extraction (legacy)") - elif digit_pred: - print(" Digit-level prediction (legacy)") - print("=" * 70) - - print("\nInitializing model...") - model = ArithmeticModel( - device=device, - unfreeze_layers=unfreeze_layers, - extract_layer=extract_layer, - position_extract=position_extract, - digit_pred=digit_pred, - positional_digit=positional_digit, - hybrid=hybrid - ) - - optimizer = optim.AdamW(model.trainable_parameters(), lr=lr) - scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs) - - print(f"\nTraining config:") - print(f" Epochs: {epochs}") - print(f" Batch size: {batch_size}") - print(f" Learning rate: {lr}") - print(f" Unfreeze layers: {unfreeze_layers}") - print(f" Samples/epoch: {batch_size * 20}") - - print(f"\nInitial evaluation (200 samples)...") - acc, op_acc = evaluate_llm(model, 200) - print(f" Accuracy: {acc:.4f}, Op accuracy: {op_acc:.4f}") - - print(f"\nStarting training...") - print("-" * 70) - - best_acc = acc - start_time = time.perf_counter() - - for epoch in range(epochs): - model.extractor.train() - if unfreeze_layers > 0: - model.llm.train() - - max_val = get_curriculum_max(epoch, epochs) - - epoch_loss = 0 - if hybrid: - epoch_losses = {'result': 0, 'a_digit': 0, 'b_digit': 0, 'op': 0, 'n_words': 0, 'n_lookup': 0} - elif positional_digit: - epoch_losses = {'result': 0, 'a_digit': 0, 'b_digit': 0, 'op': 0} - else: - epoch_losses = {'result': 0, 'a': 0, 'b': 0, 'op': 0} - n_batches = 20 - epoch_start = time.perf_counter() - - for batch_idx in range(n_batches): - batch_texts = [] - batch_a = [] - batch_b = [] - batch_op = [] - batch_result = [] - batch_a_values = [] - batch_b_values = [] - - for _ in range(batch_size): - text, a, b, op, result = generate_problem(max_val) - batch_texts.append(text) - batch_a.append(int_to_bits(a, device)) - batch_b.append(int_to_bits(b, device)) - batch_op.append(OPERATIONS.index(op)) - batch_result.append(int_to_bits(result, device)) - batch_a_values.append(a) - batch_b_values.append(b) - - target_a = torch.stack(batch_a) - target_b = torch.stack(batch_b) - target_op = torch.tensor(batch_op, device=device) - target_result = torch.stack(batch_result) - target_a_values = torch.tensor(batch_a_values, device=device, dtype=torch.float32) - target_b_values = torch.tensor(batch_b_values, device=device, dtype=torch.float32) - - optimizer.zero_grad() - - outputs = model(batch_texts) - pred_bits, a_bits, b_bits, op_logits = outputs[0], outputs[1], outputs[2], outputs[3] - - if hybrid: - a_values, b_values, used_lookup = outputs[4], outputs[5], outputs[6] - a_digit_logits, b_digit_logits = outputs[7], outputs[8] - loss, losses = compute_hybrid_loss( - pred_bits, op_logits, used_lookup, - a_digit_logits, b_digit_logits, - target_result, target_a_values, target_b_values, target_op, device - ) - elif positional_digit: - a_digit_logits_list = outputs[7] - b_digit_logits_list = outputs[8] - loss, losses = compute_positional_digit_loss( - pred_bits, op_logits, a_digit_logits_list, b_digit_logits_list, - target_result, target_op, target_a_values, target_b_values, device - ) - else: - loss, losses = compute_llm_loss( - pred_bits, a_bits, b_bits, op_logits, - target_result, target_a, target_b, target_op - ) - - loss.backward() - torch.nn.utils.clip_grad_norm_(model.trainable_parameters(), 1.0) - optimizer.step() - - epoch_loss += loss.item() - for k in epoch_losses: - epoch_losses[k] += losses[k] - - if (batch_idx + 1) % 5 == 0: - avg_so_far = epoch_loss / (batch_idx + 1) - print(f" Epoch {epoch+1} batch {batch_idx+1}/{n_batches} | loss: {avg_so_far:.4f}", flush=True) - - epoch_time = time.perf_counter() - epoch_start - scheduler.step() - - avg_loss = epoch_loss / n_batches - for k in epoch_losses: - epoch_losses[k] /= n_batches - - acc, op_acc = evaluate_llm(model, 300) - elapsed = time.perf_counter() - start_time - - marker = " *" if acc > best_acc else "" - if acc > best_acc: - best_acc = acc - - mem, _ = get_gpu_memory() - print(f"Epoch {epoch+1:3d} | Loss: {avg_loss:.4f} | " - f"Acc: {acc:.4f}{marker} | OpAcc: {op_acc:.4f} | " - f"Range: 0-{max_val} | VRAM: {mem:.0f}MB | Time: {elapsed:.0f}s") - if hybrid: - print(f" Losses - result:{epoch_losses['result']:.4f} " - f"a_digit:{epoch_losses['a_digit']:.4f} b_digit:{epoch_losses['b_digit']:.4f} " - f"op:{epoch_losses['op']:.4f} | words:{epoch_losses['n_words']:.0f} lookup:{epoch_losses['n_lookup']:.0f}") - elif positional_digit: - print(f" Losses - result:{epoch_losses['result']:.4f} " - f"a_digit:{epoch_losses['a_digit']:.4f} b_digit:{epoch_losses['b_digit']:.4f} " - f"op:{epoch_losses['op']:.4f}") - else: - print(f" Losses - result:{epoch_losses['result']:.4f} " - f"a:{epoch_losses['a']:.4f} b:{epoch_losses['b']:.4f} " - f"op:{epoch_losses['op']:.4f}") - - if acc >= 0.99: - print("\n" + "=" * 70) - print(" TARGET ACHIEVED: 99%+ ACCURACY") - print("=" * 70) - break - - print("\n" + "=" * 70) - print(" FINAL EVALUATION") - print("=" * 70) - - acc, op_acc = evaluate_llm(model, 1000) - print(f"Final accuracy: {acc:.4f}") - print(f"Final op accuracy: {op_acc:.4f}") - print(f"Best accuracy: {best_acc:.4f}") - - print("\nSample predictions:") - for _ in range(10): - text, a, b, op, expected = generate_problem() - with torch.no_grad(): - outputs = model([text]) - result_bits, a_bits, b_bits, op_logits = outputs[0], outputs[1], outputs[2], outputs[3] - pred = bits_to_int(result_bits[0]) - pred_a = bits_to_int(a_bits[0]) - pred_b = bits_to_int(b_bits[0]) - pred_op = OPERATIONS[op_logits[0].argmax().item()] - - status = "OK" if pred == expected else "WRONG" - print(f" '{text}' = {expected} | pred={pred} (a={pred_a}, b={pred_b}, op={pred_op}) [{status}]") - - save_path = "D:/8bit-threshold-computer/llm_integration/trained/llm.pt" - save_dict = { - 'extractor_state_dict': model.extractor.state_dict(), - 'final_accuracy': acc, - 'best_accuracy': best_acc, - 'unfreeze_layers': unfreeze_layers, - } - if unfreeze_layers > 0: - save_dict['llm_state_dict'] = { - k: v for k, v in model.llm.state_dict().items() - if any(f'layers.{i}.' in k for i in range(len(model.llm.model.layers) - unfreeze_layers, len(model.llm.model.layers))) - } - torch.save(save_dict, save_path) - print(f"\nSaved to: {save_path}") - - return model, acc - - -def main(): - parser = argparse.ArgumentParser( - description='Unified training for threshold circuit LLM integration', - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Modes: - router - Train only OpRouter with ground truth bits (sanity check) - interface - Train BitEncoder + OpRouter with ground truth bits (sanity check) - llm - Train extractor with LLM hidden states (the real training) - -LLM options: - --unfreeze_layers N Fine-tune top N transformer layers - --extract_layer N Extract from layer N (-1 = last) - --position_extract Use position-specific extraction - --digit_pred Predict digits instead of bits - -Baked-in: curriculum learning (0-9 -> 0-99 -> 0-255), 2x loss weight for a/b - -Examples: - python train.py --mode llm --epochs 100 - python train.py --mode llm --position_extract - python train.py --mode llm --digit_pred --extract_layer 12 - python train.py --mode llm --unfreeze_layers 4 --batch_size 4096 - """ - ) - parser.add_argument('--mode', type=str, required=True, - choices=['router', 'interface', 'llm'], - help='Training mode') - parser.add_argument('--epochs', type=int, default=100, help='Number of epochs') - parser.add_argument('--batch_size', type=int, default=512, help='Batch size (default: 512)') - parser.add_argument('--lr', type=float, default=None, - help='Learning rate (default: mode-specific)') - parser.add_argument('--unfreeze_layers', type=int, default=0, - help='Unfreeze top N transformer layers (default 0 = frozen)') - parser.add_argument('--extract_layer', type=int, default=0, - help='Which layer to extract from (default: 0 = embeddings, best for digits)') - parser.add_argument('--position_extract', action='store_true', - help='Use position-specific extraction (legacy)') - parser.add_argument('--digit_pred', action='store_true', - help='Predict digits instead of bits (legacy)') - parser.add_argument('--positional_digit', action='store_true', default=False, - help='Use positional digit extraction (legacy, 100%% on digits only)') - parser.add_argument('--device', type=str, default='cuda', help='Device') - args = parser.parse_args() - - torch.manual_seed(42) - random.seed(42) - - if args.mode == 'router': - lr = args.lr if args.lr is not None else 1e-2 - train_router(epochs=args.epochs, batch_size=args.batch_size, lr=lr, device=args.device) - - elif args.mode == 'interface': - lr = args.lr if args.lr is not None else 1e-3 - model, fitness = train_interface( - epochs=args.epochs, batch_size=args.batch_size, lr=lr, device=args.device - ) - - print("\n" + "=" * 70) - print(" EXPERIMENT SUMMARY") - print("=" * 70) - print(f"\n Control (Vanilla SmolLM2-360M): 11.90%") - print(f" Experimental (Trained Interface): {100*fitness:.2f}%") - if fitness > 0: - print(f"\n Improvement: {100*(fitness - 0.119)/0.119:.1f}%") - - if fitness >= 0.99: - print("\n CONCLUSION: Frozen threshold circuits + trained interface") - print(" achieves near-perfect arithmetic accuracy.") - print(" Core thesis VALIDATED.") - else: - print(f"\n CONCLUSION: Further training or architecture changes needed.") - print(f" Current gap: {100*(1.0 - fitness):.2f}%") - - elif args.mode == 'llm': - lr = args.lr if args.lr is not None else 3e-4 - train_llm( - epochs=args.epochs, - batch_size=args.batch_size, - lr=lr, - unfreeze_layers=args.unfreeze_layers, - extract_layer=args.extract_layer, - position_extract=args.position_extract, - digit_pred=args.digit_pred, - positional_digit=args.positional_digit, - device=args.device - ) - - -if __name__ == "__main__": - main() diff --git a/paper/README.md b/paper/README.md index ab6c201ace3e911448618d1f54da25d2c8fb1e2f..8be3d39edb661f0e60657a230eaeba216581185e 100644 --- a/paper/README.md +++ b/paper/README.md @@ -6,51 +6,45 @@ Networks.* The manuscript and the records of the runs it reports. ``` universal_construction_ternary.tex source universal_construction_ternary.pdf typeset -runs/ the record written by each script +runs/ the record written by each command ``` -## What each script establishes +## What each command establishes -Each script runs on its own and writes its record to `runs/`; `src/check_rocq.py` -needs Rocq 9.1 and ocamlfind on the PATH. +Each command is `python src/.py ` and writes its record to `runs/`; +`paper.py rocq` needs Rocq 9.1 and ocamlfind on the PATH, and `noise.py neuromorphic run` +needs Lava, which requires Python 3.10. - -| Script | Record | Establishes | +| Command | Record | Establishes | |---|---|---| -| `src/host_netlist.py`, `src/host_family.py` | — | the step netlist of the host, a member of the constant-depth family H_A | -| `src/check_host_family.py` | `paper_host_family.json` | one step of H_A against SUBLEQ_A for A = 3 to 12, on the evaluator in C | -| `src/netlist_io.py` | — | the serialization sigma and its inverse | -| `src/noise.py margin` | `paper_noise_margin.json` | the margin along the trajectory | -| `src/noise.py profile` | `paper_noise_profile.json` | the pre-activation profile and its bound on the failure rate | -| `src/paper_stats.py` | `paper_stats.json` | unit counts, levelization sizes, fan-in | -| `src/paper_verify.py` | `paper_verify.json` | codec, framing, serialization, datapath, interpreter | -| `src/paper_runs.py` | `paper_runs_*.json` | self-description, three generations, lockstep | -| `src/check_codec.py` | `paper_codec.json` | the recipe codec at every boundary of the token format | -| `src/check_optimal.py` | `paper_optimal.json` | the length bound is optimal on run-free strings | -| `src/check_steps.py` | `paper_steps.json` | the closed forms for the step counts | -| `src/check_device.py` | `paper_device.json` | the device against its definition | -| `src/check_host.py` | `paper_host.json` | the reference machine against its definition | -| `src/check_lev.py` | `paper_lev.json` | the levelization against gate-by-gate evaluation | -| `src/check_framing.py` | `paper_framing.json` | `ser` injective, `inst` partial and inverse | -| `src/check_ternarity.py` | `paper_ternarity.json` | no ternary unit computes the branch predicate | -| `src/check_environment.py` | `paper_environment.json` | the cost of the environment against the cost of the network | -| `src/check_sigma.py` | — | sigma is a function, injective, and inverted by the parser | -| `src/check_family.py` | `paper_runs_family.json` | the constructor emits all 29 machines | -| `src/check_family_gate.py` | `paper_runs_family_gate.json` | four of them on the netlist of sigma | -| `src/check_c.py` | `paper_c_evaluator.json` | the host on the evaluator in C, `src/netlist_eval.c` | -| `src/check_host_fold.py` | `paper_host_fold.json` | the folded host family, depth 11, against SUBLEQ_A for A = 3 to 12 | -| `src/twohead.py` | `paper_twohead.json` | the translating organism on the two-head machine, three generations | -| `src/twohead_net.py` | `paper_twohead_net.json` | the netlist N_{G,L} against the machine for windows of 64 and 4096 cells, its file, the organism on it in C, and the netlist extracted from Rocq | -| `src/stored_host.py`, `src/stored_host_run.c` | `paper_stored_host.json` | the host compiled into records of the two-head machine: one pass against the host's step and its device, the image and its recipe, three generations in C | -| `src/check_rocq.py` | `paper_rocq.json` | the Rocq development of `rocq/`: the netlist computes the step for every window, construction and self-reproduction by the organism | -| `src/throughput.py` | `paper_throughput.json` | the rate of each evaluator | -| `src/noise.py` | `paper_noise_*.json` | read noise, the deviation curve, weight error with leakage, the active-input counts of the orbit certificate | -| `src/redundant.py` | `paper_redundant_*.json` | the amplified host N_3: one step against SUBLEQ on replicated states, the margin 3/2, exact self-reproduction in C, the run under noise, the deviation curve and its bound by layer, the failure rate of the state read by majority and the restoration bound, computed by `src/restore_bound.c` | -| `src/neuromorphic.py` | `paper_neuromorphic.json` | the datapath on the Loihi and Akida neuron models | -| `src/independent/*.py`, `nsim.c` | `independent_*.json` | separate code written from the paper's definitions: H_A rebuilt from Construction 3.8 for A = 3 to 12, Lev(N_host) from sigma, Tables 3 to 6 with a separate noise simulator, and the datapath on Loihi and Akida | - -`src/neuromorphic.py run` needs Lava, which requires Python 3.10; everything -else runs in the environment of the repository. +| `host.py` | — | the host family H_A, the serialization sigma and its inverse, the device, the constructor P and the program P*, and the evaluators | +| `paper.py host-family` | `paper_host_family.json` | one step of H_A against SUBLEQ_A for A = 3 to 12, on the evaluator in C | +| `paper.py host-fold` | `paper_host_fold.json` | the folded host family, depth 11, against SUBLEQ_A for A = 3 to 12 | +| `paper.py stats` | `paper_stats.json` | unit counts, levelization sizes, fan-in | +| `paper.py verify` | `paper_verify.json` | codec, framing, serialization, datapath, levelization | +| `paper.py runs` | `paper_runs_*.json` | self-description, three generations, lockstep | +| `paper.py codec` | `paper_codec.json` | the recipe codec at every boundary of the token format | +| `paper.py optimal` | `paper_optimal.json` | the length bound is optimal on run-free strings | +| `paper.py steps` | `paper_steps.json` | the closed forms for the step counts | +| `paper.py device` | `paper_device.json` | the device against its definition | +| `paper.py host` | `paper_host.json` | the reference machine against its definition | +| `paper.py lev` | `paper_lev.json` | the levelization against gate-by-gate evaluation | +| `paper.py framing` | `paper_framing.json` | `ser` injective, `inst` partial and inverse | +| `paper.py ternarity` | `paper_ternarity.json` | no ternary unit computes the branch predicate | +| `paper.py environment` | `paper_environment.json` | the cost of the environment against the cost of the network | +| `paper.py sigma` | — | sigma is a function, injective, and inverted by the parser | +| `paper.py family` | `paper_runs_family.json` | the constructor emits all 29 machines | +| `paper.py family-gate` | `paper_runs_family_gate.json` | four of them on the netlist of sigma | +| `paper.py c` | `paper_c_evaluator.json` | the host on the evaluator in C, `src/netlist_eval.c` | +| `paper.py throughput` | `paper_throughput.json` | the rate of each evaluator | +| `paper.py rocq` | `paper_rocq.json` | the Rocq development of `rocq/`, its assumptions and the netlist extracted from it | +| `twohead.py organism` | `paper_twohead.json` | the translating organism on the two-head machine, three generations | +| `twohead.py net` | `paper_twohead_net.json` | the netlist N_{G,L} against the machine for windows of 64 and 4096 cells, its file, the organism on it in C, and the netlist extracted from Rocq | +| `twohead.py stored` | `paper_stored_host.json` | the host compiled into records of the two-head machine: one pass against the host's step and its device, and three generations in C | +| `noise.py host` | `paper_noise_*.json` | the margin, the profile bound, read noise, the deviation curve, weight error with leakage, the active-input counts of the orbit certificate | +| `noise.py amplified` | `paper_redundant_*.json` | the amplified host N_3: one step on replicated states, the margin 3/2, exact self-reproduction in C, the run under noise, the deviation curve and its bound by layer, the failure rate of the state read by majority and the restoration bound, computed by `src/restore_bound.c` | +| `noise.py neuromorphic` | `paper_neuromorphic.json` | the datapath on the Loihi and Akida neuron models | +| `independent.py` | `independent_*.json` | code written from the paper's definitions alone: H_A rebuilt from Construction 3.8 for A = 3 to 12, Lev(N_host) from sigma, Tables 3 to 6 with the noise simulator `src/nsim.c`, and the datapath on Loihi and Akida | Rebuild the typeset paper with any TeX installation: diff --git a/paper/universal_construction_ternary.pdf b/paper/universal_construction_ternary.pdf index a7ea1e461d9a62502d9e7a90d6ed68f68e2a570b..c348097a4ae8f6e1b53bfb0bf1c0cbe8c57585bb 100644 --- a/paper/universal_construction_ternary.pdf +++ b/paper/universal_construction_ternary.pdf @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:29a8e26ce2577a85810fc815acc5eeaef1925cbaadb629aee163c64bf7cfdcf4 -size 364771 +oid sha256:cf817d63746a8601c860f094ef25e7a746218a355e721248f44559caf8f3f441 +size 364795 diff --git a/paper/universal_construction_ternary.tex b/paper/universal_construction_ternary.tex index c11071b75a824307a64a0dababda3b046fb63f83..9a199448634f38971906c0cb4382cbe0539c4de1 100644 --- a/paper/universal_construction_ternary.tex +++ b/paper/universal_construction_ternary.tex @@ -2514,12 +2514,12 @@ $\sigma(\Nhost)$, and every generation of every run instantiated its evaluator afresh from the bytes the previous generation emitted. The two-head machine was evaluated by transcriptions of Definition~\ref{def:twohead} in Python and in C, by the netlist $N^\alpha_{G,L}$ in Python and in C, and in the Rocq kernel. The -stored host is built from $\sigma(\Nhost)$ by \texttt{src/stored\_host.py}, which +stored host is built from $\sigma(\Nhost)$ by \texttt{src/twohead.py}, which also compares one pass with the host's step and the device, and its generations are run by \texttt{src/stored\_host\_run.c}, a transcription of Definition~\ref{def:twohead} that decodes the active bank once per change of $\kappa$; the three generations of Remark~\ref{rem:storedcheck} take $9$ minutes. -Separate code in \texttt{src/independent/}, written from the definitions of this +Separate code in \texttt{src/independent.py}, written from the definitions of this paper, rebuilds $H_A$ from Construction~\ref{con:host} for $A=3$ to $12$, levelizes $\sigma(\Nhost)$, repeats Tables~\ref{tab:curve} to~\ref{tab:bound3} with a simulator in C and on the accelerator, and maps the datapath onto Loihi @@ -2540,7 +2540,7 @@ of their length, and Theorem~\ref{thm:organism}. The file that defines the organism and its description is written by \texttt{src/twohead.py}, and the netlist definition, extracted to OCaml, writes for $N^6_{133,2930}$ and $N^{12}_{5,40}$ files byte for byte equal to those that -\texttt{src/twohead\_net.py} writes. +\texttt{src/twohead.py} writes. Besides the runs recorded in the remarks of Sections~\ref{sec:host} to~\ref{sec:organism} and in Appendix~\ref{app:measure}, the following were @@ -2637,7 +2637,7 @@ the Rocq definition. The records of the organism and of $S$ are laid out as in Definition~\ref{def:twohead}, with the window cells of Constructions~\ref{con:organism} and~\ref{con:stored}; within each block of Construction~\ref{con:stored} the order of the records is that of -\texttt{src/stored\_host.py}, which gives each temporary the least cell free over +\texttt{src/twohead.py}, which gives each temporary the least cell free over its live interval. One generation of $\Omega^\ast$ takes $0.3$ seconds on~(a), $45$ seconds on~(d) and $9$ minutes on~(b). All figures were diff --git a/src/attractor.py b/src/attractor.py deleted file mode 100644 index ef81e371b9b174f996dac2779fe90e2948574cfe..0000000000000000000000000000000000000000 --- a/src/attractor.py +++ /dev/null @@ -1,261 +0,0 @@ -"""Attractor computer: an energy-based threshold network. - -A Boolean circuit is compiled to a quadratic pseudo-Boolean energy -E(s) = sum_i L[i] s_i + sum_{i None: - self.n = 0 - self.L: Dict[int, int] = defaultdict(int) - self.Q: Dict[Tuple[int, int], int] = defaultdict(int) - self.const = 0 - self.gates: List[Tuple[str, int, Tuple[int, ...]]] = [] - - def wire(self) -> int: - i = self.n - self.n += 1 - return i - - def wires(self, k: int) -> List[int]: - return [self.wire() for _ in range(k)] - - def _q(self, i: int, j: int, c: int) -> None: - if i == j: - self.L[i] += c - else: - self.Q[(min(i, j), max(i, j))] += c - - def AND(self, x: int, y: int) -> int: - z = self.wire() - self.L[z] += 3 - self._q(x, y, 1); self._q(x, z, -2); self._q(y, z, -2) - self.gates.append(("AND", z, (x, y))) - return z - - def OR(self, x: int, y: int) -> int: - z = self.wire() - self.L[x] += 1; self.L[y] += 1; self.L[z] += 1 - self._q(x, y, 1); self._q(x, z, -2); self._q(y, z, -2) - self.gates.append(("OR", z, (x, y))) - return z - - def NOT(self, x: int) -> int: - z = self.wire() - self.const += 1 - self.L[x] += -1; self.L[z] += -1 - self._q(x, z, 2) - self.gates.append(("NOT", z, (x,))) - return z - - def XOR(self, x: int, y: int) -> int: - return self.OR(self.AND(x, self.NOT(y)), self.AND(self.NOT(x), y)) - - def full_adder(self, x: int, y: int, cin: int) -> Tuple[int, int]: - axy = self.XOR(x, y) - s = self.XOR(axy, cin) - cout = self.OR(self.AND(x, y), self.AND(cin, axy)) - return s, cout - - # ---- energy + couplings ------------------------------------------------ - def energy(self, s: List[int]) -> int: - e = self.const - for i, c in self.L.items(): - e += c * s[i] - for (i, j), c in self.Q.items(): - e += c * s[i] * s[j] - return e - - def neighbors(self) -> Dict[int, List[Tuple[int, int]]]: - nbr: Dict[int, List[Tuple[int, int]]] = defaultdict(list) - for (i, j), c in self.Q.items(): - nbr[i].append((j, c)) - nbr[j].append((i, c)) - return nbr - - # ---- relaxation modes -------------------------------------------------- - def forward_eval(self, clamp: Dict[int, int]) -> List[int]: - """Exact forward relaxation: propagate clamped inputs through the gate - relations in topological order onto the energy-0 fixed point.""" - s = [0] * self.n - for w, v in clamp.items(): - s[w] = v - for op, z, ins in self.gates: - if op == "AND": - s[z] = s[ins[0]] & s[ins[1]] - elif op == "OR": - s[z] = s[ins[0]] | s[ins[1]] - else: - s[z] = 1 - s[ins[0]] - return s - - def relax_energy(self, clamp: Dict[int, int], sweeps: int = 4000, - t0: float = 4.0, t1: float = 0.02, seed: int = 0 - ) -> Tuple[List[int], bool]: - """Canonical relaxation: anneal the full threshold network (every free - wire), tracking the lowest-energy state. Universal but hard; the exact - gadgets keep the target at energy 0.""" - nbr = self.neighbors() - rng = random.Random(seed) - s = [rng.randint(0, 1) for _ in range(self.n)] - for w, v in clamp.items(): - s[w] = v - free = [i for i in range(self.n) if i not in clamp] - best, best_e = list(s), self.energy(s) - for step in range(sweeps): - T = t0 * (t1 / t0) ** (step / max(1, sweeps - 1)) - for _ in range(len(free)): - i = free[rng.randrange(len(free))] - field = self.L[i] + sum(c * s[j] for j, c in nbr[i]) - dE = (1 - 2 * s[i]) * field - if dE <= 0 or rng.random() < math.exp(-dE / T): - s[i] ^= 1 - e = self.energy(s) - if e < best_e: - best, best_e = list(s), e - if best_e == 0: - return best, True - return best, best_e == 0 - - def solve(self, free_inputs: List[int], fixed: Dict[int, int], - target: Dict[int, int], sweeps: int = 3000, restarts: int = 80, - seed: int = 0) -> Optional[List[int]]: - """Open-constraint relaxation over a chosen set of driver wires, with - the rest slaved through the circuit; anneal the output Hamming mismatch - to zero. Clamp outputs and pass the inputs here to run backward.""" - rng = random.Random(seed) - - def mism(vals: Dict[int, int]) -> int: - s = self.forward_eval({**fixed, **vals}) - return sum(1 for w, v in target.items() if s[w] != v) - - for _ in range(restarts): - vals = {w: rng.randint(0, 1) for w in free_inputs} - m = mism(vals) - if m == 0: - return self.forward_eval({**fixed, **vals}) - for step in range(sweeps): - T = 2.0 * (0.02 / 2.0) ** (step / sweeps) - w = free_inputs[rng.randrange(len(free_inputs))] - vals[w] ^= 1 - m2 = mism(vals) - if m2 <= m or rng.random() < math.exp(-(m2 - m) / T): - m = m2 - if m == 0: - return self.forward_eval({**fixed, **vals}) - else: - vals[w] ^= 1 - return None - - -# --------------------------------------------------------------------------- -# Circuit builders -# --------------------------------------------------------------------------- -def adder(bits: int) -> Tuple[Circuit, dict]: - c = Circuit() - xs, ys = c.wires(bits), c.wires(bits) - cin = c.wire() - outs, carry = [], cin - for k in range(bits): - s, carry = c.full_adder(xs[k], ys[k], carry) - outs.append(s) - return c, {"xs": xs, "ys": ys, "cin": cin, "sum": outs + [carry]} - - -def multiplier(bits: int) -> Tuple[Circuit, dict]: - c = Circuit() - xs, ys = c.wires(bits), c.wires(bits) - zero = c.wire() - acc = [zero] * (2 * bits) - for i in range(bits): - carry = zero - for j in range(bits): - acc[i + j], carry = c.full_adder(acc[i + j], c.AND(xs[i], ys[j]), carry) - acc[i + bits] = carry - return c, {"xs": xs, "ys": ys, "zero": zero, "prod": acc} - - -_OPCODE = {"AND": 0, "OR": 1, "NOT": 2} -_OPNAME = {v: k for k, v in _OPCODE.items()} - - -def to_tensors(circ: Circuit, io: dict): - """Serialize the coupling matrix (the program) and the gate list to tensors. - Q is stored sparsely as index pairs and integer values.""" - import torch - qi = sorted(circ.Q) - q_idx = torch.tensor(qi if qi else [], dtype=torch.long).reshape(-1, 2) - q_val = torch.tensor([circ.Q[k] for k in qi], dtype=torch.long) - li = sorted(circ.L) - l_idx = torch.tensor(li, dtype=torch.long) - l_val = torch.tensor([circ.L[i] for i in li], dtype=torch.long) - g_op = torch.tensor([_OPCODE[op] for op, _, _ in circ.gates], dtype=torch.long) - g_out = torch.tensor([o for _, o, _ in circ.gates], dtype=torch.long) - g_in = torch.tensor([[ins[0], ins[1] if len(ins) > 1 else -1] - for _, _, ins in circ.gates], dtype=torch.long).reshape(-1, 2) - t = {"Q_idx": q_idx, "Q_val": q_val, "L_idx": l_idx, "L_val": l_val, - "gate_op": g_op, "gate_out": g_out, "gate_in": g_in} - import json - meta = {"machine": "attractor", "n": str(circ.n), "const": str(circ.const), - "io": json.dumps({k: v for k, v in io.items()})} - return t, meta - - -def from_tensors(t: dict, meta: dict) -> Tuple[Circuit, dict]: - import json - c = Circuit() - c.n = int(meta["n"]) - c.const = int(meta["const"]) - for (i, j), v in zip(t["Q_idx"].tolist(), t["Q_val"].tolist()): - c.Q[(i, j)] = v - for i, v in zip(t["L_idx"].tolist(), t["L_val"].tolist()): - c.L[i] = v - for op, out, ins in zip(t["gate_op"].tolist(), t["gate_out"].tolist(), t["gate_in"].tolist()): - c.gates.append((_OPNAME[op], out, tuple(x for x in ins if x >= 0))) - return c, json.loads(meta["io"]) - - -def cnf(clauses: List[List[int]], n_vars: int) -> Tuple[Circuit, dict]: - """Compile a CNF formula. Literals are +v (var v) or -v (negation), v>=1. - Returns the circuit, the variable wires, and the wire that is 1 iff the - formula is satisfied. Clamp that wire to 1 and relax to find a model.""" - c = Circuit() - var = {v: c.wire() for v in range(1, n_vars + 1)} - clause_ws = [] - for cl in clauses: - lits = [var[abs(l)] if l > 0 else c.NOT(var[abs(l)]) for l in cl] - acc = lits[0] - for w in lits[1:]: - acc = c.OR(acc, w) - clause_ws.append(acc) - sat = clause_ws[0] - for w in clause_ws[1:]: - sat = c.AND(sat, w) - return c, {"vars": var, "sat": sat} diff --git a/src/automata.py b/src/automata.py new file mode 100644 index 0000000000000000000000000000000000000000..af62397513c432458dedea99fd59db7b5ab0d43f --- /dev/null +++ b/src/automata.py @@ -0,0 +1,1759 @@ +"""The attractor, reversible, cellular-automaton and tile machines, with their builders.""" +from __future__ import annotations + +import json +import math +import os +import random +import sys +from collections import defaultdict +from typing import Dict, List, Optional, Tuple + +import torch +from safetensors import safe_open +from safetensors.torch import save_file, load_file + +from machines import Net, compile_net, MatrixMachine + + +class Circuit: + """Wire allocator and energy accumulator.""" + + def __init__(self) -> None: + self.n = 0 + self.L: Dict[int, int] = defaultdict(int) + self.Q: Dict[Tuple[int, int], int] = defaultdict(int) + self.const = 0 + self.gates: List[Tuple[str, int, Tuple[int, ...]]] = [] + + def wire(self) -> int: + i = self.n + self.n += 1 + return i + + def wires(self, k: int) -> List[int]: + return [self.wire() for _ in range(k)] + + def _q(self, i: int, j: int, c: int) -> None: + if i == j: + self.L[i] += c + else: + self.Q[(min(i, j), max(i, j))] += c + + def AND(self, x: int, y: int) -> int: + z = self.wire() + self.L[z] += 3 + self._q(x, y, 1); self._q(x, z, -2); self._q(y, z, -2) + self.gates.append(("AND", z, (x, y))) + return z + + def OR(self, x: int, y: int) -> int: + z = self.wire() + self.L[x] += 1; self.L[y] += 1; self.L[z] += 1 + self._q(x, y, 1); self._q(x, z, -2); self._q(y, z, -2) + self.gates.append(("OR", z, (x, y))) + return z + + def NOT(self, x: int) -> int: + z = self.wire() + self.const += 1 + self.L[x] += -1; self.L[z] += -1 + self._q(x, z, 2) + self.gates.append(("NOT", z, (x,))) + return z + + def XOR(self, x: int, y: int) -> int: + return self.OR(self.AND(x, self.NOT(y)), self.AND(self.NOT(x), y)) + + def full_adder(self, x: int, y: int, cin: int) -> Tuple[int, int]: + axy = self.XOR(x, y) + s = self.XOR(axy, cin) + cout = self.OR(self.AND(x, y), self.AND(cin, axy)) + return s, cout + + # ---- energy + couplings ------------------------------------------------ + def energy(self, s: List[int]) -> int: + e = self.const + for i, c in self.L.items(): + e += c * s[i] + for (i, j), c in self.Q.items(): + e += c * s[i] * s[j] + return e + + def neighbors(self) -> Dict[int, List[Tuple[int, int]]]: + nbr: Dict[int, List[Tuple[int, int]]] = defaultdict(list) + for (i, j), c in self.Q.items(): + nbr[i].append((j, c)) + nbr[j].append((i, c)) + return nbr + + # ---- relaxation modes -------------------------------------------------- + def forward_eval(self, clamp: Dict[int, int]) -> List[int]: + """Exact forward relaxation from the clamped inputs to the energy-0 fixed point.""" + s = [0] * self.n + for w, v in clamp.items(): + s[w] = v + for op, z, ins in self.gates: + if op == "AND": + s[z] = s[ins[0]] & s[ins[1]] + elif op == "OR": + s[z] = s[ins[0]] | s[ins[1]] + else: + s[z] = 1 - s[ins[0]] + return s + + def relax_energy(self, clamp: Dict[int, int], sweeps: int = 4000, + t0: float = 4.0, t1: float = 0.02, seed: int = 0 + ) -> Tuple[List[int], bool]: + """Anneal the full threshold network, tracking the lowest-energy state.""" + nbr = self.neighbors() + rng = random.Random(seed) + s = [rng.randint(0, 1) for _ in range(self.n)] + for w, v in clamp.items(): + s[w] = v + free = [i for i in range(self.n) if i not in clamp] + best, best_e = list(s), self.energy(s) + for step in range(sweeps): + T = t0 * (t1 / t0) ** (step / max(1, sweeps - 1)) + for _ in range(len(free)): + i = free[rng.randrange(len(free))] + field = self.L[i] + sum(c * s[j] for j, c in nbr[i]) + dE = (1 - 2 * s[i]) * field + if dE <= 0 or rng.random() < math.exp(-dE / T): + s[i] ^= 1 + e = self.energy(s) + if e < best_e: + best, best_e = list(s), e + if best_e == 0: + return best, True + return best, best_e == 0 + + def solve(self, free_inputs: List[int], fixed: Dict[int, int], + target: Dict[int, int], sweeps: int = 3000, restarts: int = 80, + seed: int = 0) -> Optional[List[int]]: + """Anneal chosen driver wires until the outputs match, the other wires slaved through the circuit.""" + rng = random.Random(seed) + + def mism(vals: Dict[int, int]) -> int: + s = self.forward_eval({**fixed, **vals}) + return sum(1 for w, v in target.items() if s[w] != v) + + for _ in range(restarts): + vals = {w: rng.randint(0, 1) for w in free_inputs} + m = mism(vals) + if m == 0: + return self.forward_eval({**fixed, **vals}) + for step in range(sweeps): + T = 2.0 * (0.02 / 2.0) ** (step / sweeps) + w = free_inputs[rng.randrange(len(free_inputs))] + vals[w] ^= 1 + m2 = mism(vals) + if m2 <= m or rng.random() < math.exp(-(m2 - m) / T): + m = m2 + if m == 0: + return self.forward_eval({**fixed, **vals}) + else: + vals[w] ^= 1 + return None + + +# --------------------------------------------------------------------------- +# Circuit builders +# --------------------------------------------------------------------------- +def adder(bits: int) -> Tuple[Circuit, dict]: + c = Circuit() + xs, ys = c.wires(bits), c.wires(bits) + cin = c.wire() + outs, carry = [], cin + for k in range(bits): + s, carry = c.full_adder(xs[k], ys[k], carry) + outs.append(s) + return c, {"xs": xs, "ys": ys, "cin": cin, "sum": outs + [carry]} + + +def multiplier(bits: int) -> Tuple[Circuit, dict]: + c = Circuit() + xs, ys = c.wires(bits), c.wires(bits) + zero = c.wire() + acc = [zero] * (2 * bits) + for i in range(bits): + carry = zero + for j in range(bits): + acc[i + j], carry = c.full_adder(acc[i + j], c.AND(xs[i], ys[j]), carry) + acc[i + bits] = carry + return c, {"xs": xs, "ys": ys, "zero": zero, "prod": acc} + + +_OPCODE = {"AND": 0, "OR": 1, "NOT": 2} + +_OPNAME = {v: k for k, v in _OPCODE.items()} + + +def to_tensors(circ: Circuit, io: dict): + """Serialize the coupling matrix (the program) and the gate list to tensors.""" + import torch + qi = sorted(circ.Q) + q_idx = torch.tensor(qi if qi else [], dtype=torch.long).reshape(-1, 2) + q_val = torch.tensor([circ.Q[k] for k in qi], dtype=torch.long) + li = sorted(circ.L) + l_idx = torch.tensor(li, dtype=torch.long) + l_val = torch.tensor([circ.L[i] for i in li], dtype=torch.long) + g_op = torch.tensor([_OPCODE[op] for op, _, _ in circ.gates], dtype=torch.long) + g_out = torch.tensor([o for _, o, _ in circ.gates], dtype=torch.long) + g_in = torch.tensor([[ins[0], ins[1] if len(ins) > 1 else -1] + for _, _, ins in circ.gates], dtype=torch.long).reshape(-1, 2) + t = {"Q_idx": q_idx, "Q_val": q_val, "L_idx": l_idx, "L_val": l_val, + "gate_op": g_op, "gate_out": g_out, "gate_in": g_in} + import json + meta = {"machine": "attractor", "n": str(circ.n), "const": str(circ.const), + "io": json.dumps({k: v for k, v in io.items()})} + return t, meta + + +def from_tensors(t: dict, meta: dict) -> Tuple[Circuit, dict]: + import json + c = Circuit() + c.n = int(meta["n"]) + c.const = int(meta["const"]) + for (i, j), v in zip(t["Q_idx"].tolist(), t["Q_val"].tolist()): + c.Q[(i, j)] = v + for i, v in zip(t["L_idx"].tolist(), t["L_val"].tolist()): + c.L[i] = v + for op, out, ins in zip(t["gate_op"].tolist(), t["gate_out"].tolist(), t["gate_in"].tolist()): + c.gates.append((_OPNAME[op], out, tuple(x for x in ins if x >= 0))) + return c, json.loads(meta["io"]) + + +def cnf(clauses: List[List[int]], n_vars: int) -> Tuple[Circuit, dict]: + """Compile a CNF formula.""" + c = Circuit() + var = {v: c.wire() for v in range(1, n_vars + 1)} + clause_ws = [] + for cl in clauses: + lits = [var[abs(l)] if l > 0 else c.NOT(var[abs(l)]) for l in cl] + acc = lits[0] + for w in lits[1:]: + acc = c.OR(acc, w) + clause_ws.append(acc) + sat = clause_ws[0] + for w in clause_ws[1:]: + sat = c.AND(sat, w) + return c, {"vars": var, "sat": sat} + + +def test_forward(): + ok = True + for bits in (4, 8): + c, io = adder(bits) + rng = random.Random(bits) + bad = 0 + for _ in range(300): + a, b = rng.randint(0, (1 << bits) - 1), rng.randint(0, (1 << bits) - 1) + clamp = {io["cin"]: 0} + for k in range(bits): + clamp[io["xs"][k]] = (a >> k) & 1 + clamp[io["ys"][k]] = (b >> k) & 1 + s = c.forward_eval(clamp) + got = sum(s[w] << k for k, w in enumerate(io["sum"])) + if got != a + b or c.energy(s) != 0: + bad += 1 + print(f" forward adder {bits}-bit: {'OK' if bad == 0 else f'FAIL({bad})'}") + ok &= bad == 0 + for bits in (3, 5): + c, io = multiplier(bits) + rng = random.Random(100 + bits) + bad = 0 + for _ in range(300): + a, b = rng.randint(0, (1 << bits) - 1), rng.randint(0, (1 << bits) - 1) + clamp = {io["zero"]: 0} + for k in range(bits): + clamp[io["xs"][k]] = (a >> k) & 1 + clamp[io["ys"][k]] = (b >> k) & 1 + s = c.forward_eval(clamp) + got = sum(s[w] << k for k, w in enumerate(io["prod"])) + if got != a * b or c.energy(s) != 0: + bad += 1 + print(f" forward multiplier {bits}-bit: {'OK' if bad == 0 else f'FAIL({bad})'}") + ok &= bad == 0 + return ok + + +def test_energy_relax(): + """The canonical form: anneal the whole network (no propagation shortcut).""" + c, io = adder(4) + rng = random.Random(3) + bad = 0 + for _ in range(20): + a, b = rng.randint(0, 15), rng.randint(0, 15) + clamp = {io["cin"]: 0} + for k in range(4): + clamp[io["xs"][k]] = (a >> k) & 1 + clamp[io["ys"][k]] = (b >> k) & 1 + conv = False + for attempt in range(4): # annealers restart + s, conv = c.relax_energy(clamp, sweeps=6000, seed=rng.randint(0, 1 << 30)) + got = sum(s[w] << k for k, w in enumerate(io["sum"])) + if conv and got == a + b: + break + if not conv: + bad += 1 + print(f" whole-network energy relaxation (4-bit adder, 20 cases): " + f"{'OK' if bad == 0 else f'reached ground state in {20 - bad}/20'}") + return bad == 0 + + +def test_factor(): + ok = True + for bits, targets in ((4, [15, 35, 143]), (5, [21, 55, 91])): + c, io = multiplier(bits) + for N in targets: + target = {io["prod"][k]: (N >> k) & 1 for k in range(2 * bits)} + s = c.solve(io["xs"] + io["ys"], {io["zero"]: 0}, target, seed=N) + if s is None: + print(f" factor {N}: not found") + ok = False + continue + a = sum(s[io["xs"][k]] << k for k in range(bits)) + b = sum(s[io["ys"][k]] << k for k in range(bits)) + good = a * b == N and 1 < a < N and 1 < b < N + print(f" factor {N} ({bits}x{bits}): {a} x {b} {'OK' if a * b == N else 'WRONG'}") + ok &= a * b == N + return ok + + +def test_sat(): + # (x1 | x2 | ~x3) & (~x1 | x3) & (x2 | x3) & (~x2 | ~x3), a satisfiable 3-SAT. + clauses = [[1, 2, -3], [-1, 3], [2, 3], [-2, -3]] + c, io = cnf(clauses, 3) + s = c.solve(list(io["vars"].values()), {}, {io["sat"]: 1}, seed=1) + if s is None: + print(" SAT solve: no model found") + return False + assign = {v: s[w] for v, w in io["vars"].items()} + sat = all(any((assign[abs(l)] == 1) if l > 0 else (assign[abs(l)] == 0) for l in cl) + for cl in clauses) + print(f" SAT solve: model {assign} {'satisfies' if sat else 'FAILS'} the formula") + return sat + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +ATTRACTOR_OUT = os.path.join(ROOT, "variants", "neural_attractor.safetensors") + +BITS = 8 + + +def main_build_attractor() -> int: + c, io = multiplier(BITS) + tensors, meta = to_tensors(c, io) + save_file(tensors, ATTRACTOR_OUT, metadata=meta) + size = os.path.getsize(ATTRACTOR_OUT) + print(f"Built {os.path.relpath(ATTRACTOR_OUT, ROOT)}: {BITS}x{BITS} multiplier as couplings") + print(f" wires={c.n} Q entries={len(c.Q)} L entries={len(c.L)} size={size/1024:.1f} KB") + + # round-trip + t = load_file(ATTRACTOR_OUT) + with safe_open(ATTRACTOR_OUT, framework="pt") as f: + m = f.metadata() + c2, io2 = from_tensors(t, m) + xs, ys, zero, prod = io2["xs"], io2["ys"], io2["zero"], io2["prod"] + + rng = random.Random(0) + bad = 0 + for _ in range(200): + a, b = rng.randint(0, 255), rng.randint(0, 255) + clamp = {zero: 0} + for k in range(BITS): + clamp[xs[k]] = (a >> k) & 1 + clamp[ys[k]] = (b >> k) & 1 + s = c2.forward_eval(clamp) + got = sum(s[w] << k for k, w in enumerate(prod)) + if got != a * b or c2.energy(s) != 0: + bad += 1 + print(f" round-trip forward multiply (200 cases): {'OK' if bad == 0 else f'FAIL({bad})'}") + + # backward: factor a small product through the loaded couplings + N = 35 + target = {prod[k]: (N >> k) & 1 for k in range(2 * BITS)} + s = c2.solve(xs + ys, {zero: 0}, target, sweeps=2500, restarts=120, seed=N) + if s is not None: + fa = sum(s[xs[k]] << k for k in range(BITS)) + fb = sum(s[ys[k]] << k for k in range(BITS)) + print(f" round-trip backward factor {N}: {fa} x {fb} {'OK' if fa * fb == N else 'WRONG'}") + ok = fa * fb == N + else: + print(f" round-trip backward factor {N}: not found") + ok = False + return 0 if (bad == 0 and ok) else 1 + +# --- threshold-gate truth: the target updates are computed by Heaviside gates --- +def H(x: int) -> int: + return 1 if x >= 0 else 0 + + +def g_and(a: int, b: int) -> int: + return H(a + b - 2) # fires iff a=b=1 + + +def g_xor(a: int, b: int) -> int: + # XOR as AND(OR, NAND): OR=H(a+b-1), NAND=H(1-a-b), out=AND(OR,NAND) + return g_and(H(a + b - 1), H(1 - a - b)) + + +# --- reversible primitives, in place on a bit register (a Python list) --- +def NOT(reg: List[int], t: int) -> None: + reg[t] = 1 - reg[t] + + +def CNOT(reg: List[int], c: int, t: int) -> None: + reg[t] = g_xor(reg[t], reg[c]) + + +def TOFF(reg: List[int], a: int, b: int, t: int) -> None: + reg[t] = g_xor(reg[t], g_and(reg[a], reg[b])) + + +def FRED(reg: List[int], c: int, x: int, y: int) -> None: + # controlled swap of x,y on control c + if reg[c]: + reg[x], reg[y] = reg[y], reg[x] + + +# --- Cuccaro ripple-carry adder: b += a in place, one carry ancilla --- +# MAJ(c,b,a): b^=a ; c^=a ; a ^= b&c UMA(c,b,a): a ^= b&c ; c^=a ; b^=c +def _maj(reg, c, b, a): + CNOT(reg, a, b) + CNOT(reg, a, c) + TOFF(reg, b, c, a) + + +def _uma(reg, c, b, a): + TOFF(reg, b, c, a) + CNOT(reg, a, c) + CNOT(reg, c, b) + + +# Each primitive below is its own inverse, so the inverse of a gate sequence is +# the reversed sequence. Building the adder as an op list gives subtraction for +# free (run it backward). +def _maj_ops(c, b, a): + return [(CNOT, a, b), (CNOT, a, c), (TOFF, b, c, a)] + + +def _uma_ops(c, b, a): + return [(TOFF, b, c, a), (CNOT, a, c), (CNOT, c, b)] + + +def _adder_ops(a_bits, b_bits, carry, cout=None): + n = len(a_bits) + ops = _maj_ops(carry, b_bits[0], a_bits[0]) + for i in range(1, n): + ops += _maj_ops(a_bits[i - 1], b_bits[i], a_bits[i]) + if cout is not None: + ops.append((CNOT, a_bits[n - 1], cout)) + for i in range(n - 1, 0, -1): + ops += _uma_ops(a_bits[i - 1], b_bits[i], a_bits[i]) + ops += _uma_ops(carry, b_bits[0], a_bits[0]) + return ops + + +def _apply(reg, ops, inverse=False): + for gate, *args in (reversed(ops) if inverse else ops): + gate(reg, *args) + + +def add_into(reg, a_bits, b_bits, carry, cout=None): + """b <- (a + b) mod 2^n (LSB first); a and carry (=0) restored.""" + _apply(reg, _adder_ops(a_bits, b_bits, carry, cout)) + + +def sub_into(reg, a_bits, b_bits, carry, cout=None): + """b <- (b - a) mod 2^n: the adder run backward.""" + _apply(reg, _adder_ops(a_bits, b_bits, carry, cout), inverse=True) + + +def xor_into(reg, a_bits, b_bits): + """b <- b XOR a, bitwise (self-inverse).""" + for a, b in zip(a_bits, b_bits): + CNOT(reg, a, b) + + +def incr(reg, b_bits, one_bits, carry): + """b <- b + 1 mod 2^n.""" + add_into(reg, one_bits, b_bits, carry) + + +def neg_into(reg, b_bits, one_bits, carry): + """b <- (-b) mod 2^n via two's complement (~b then +1). Self-inverse.""" + for t in b_bits: + NOT(reg, t) + add_into(reg, one_bits, b_bits, carry) + + +def rot_left(reg, b_bits, k=1): + """Rotate the word left by k (a permutation of bit positions; reversible).""" + n = len(b_bits) + k %= n + vals = [reg[b_bits[i]] for i in range(n)] + for i in range(n): + reg[b_bits[(i + k) % n]] = vals[i] + + +def and_into(reg, a_bits, b_bits, t_bits): + """t <- t XOR (a AND b), bitwise (word-level Toffoli). Self-inverse.""" + for a, b, t in zip(a_bits, b_bits, t_bits): + TOFF(reg, a, b, t) + + +# --- verification helpers --- +def is_permutation(fn, nbits: int) -> bool: + """Check fn: {0,1}^nbits -> {0,1}^nbits is a bijection (exhaustive).""" + seen = set() + for x in range(1 << nbits): + reg = [(x >> k) & 1 for k in range(nbits)] + fn(reg) + y = sum(b << k for k, b in enumerate(reg)) + seen.add(y) + return len(seen) == (1 << nbits) + + +def _test_primitives(): + ok = True + ok &= is_permutation(lambda r: NOT(r, 0), 1) + ok &= is_permutation(lambda r: CNOT(r, 0, 1), 2) + ok &= is_permutation(lambda r: TOFF(r, 0, 1, 2), 3) + ok &= is_permutation(lambda r: FRED(r, 0, 1, 2), 3) + print(f" primitives bijective (NOT/CNOT/TOFF/FRED): {'OK' if ok else 'FAIL'}") + return ok + + +def _test_adder(width=4): + # layout: a[0..w-1], b[0..w-1], carry + a_bits = list(range(width)) + b_bits = list(range(width, 2 * width)) + carry = 2 * width + n = 2 * width + 1 + ok_perm = is_permutation(lambda r: add_into(r, a_bits, b_bits, carry), n) + bad = 0 + mask = (1 << width) - 1 + for a in range(1 << width): + for b in range(1 << width): + reg = [0] * n + for k in range(width): + reg[a_bits[k]] = (a >> k) & 1 + reg[b_bits[k]] = (b >> k) & 1 + add_into(reg, a_bits, b_bits, carry) + got_a = sum(reg[a_bits[k]] << k for k in range(width)) + got_b = sum(reg[b_bits[k]] << k for k in range(width)) + if got_a != a or got_b != ((a + b) & mask) or reg[carry] != 0: + bad += 1 + print(f" Cuccaro adder {width}-bit: bijection={'OK' if ok_perm else 'FAIL'} " + f"b<-a+b, a & carry restored={'OK' if bad == 0 else f'FAIL({bad})'}") + return ok_perm and bad == 0 + + +def bennett(reg, a_b, b_b, c_b, scr_b, out_b, carry): + """Bennett compute-copy-uncompute for the irreversible f(a,b,c)=(a+b) XOR c.""" + ops = [] + # compute f into scratch (scratch starts 0): scratch += a; scratch += b; scratch ^= c + ops += _adder_ops(a_b, scr_b, carry) + ops += _adder_ops(b_b, scr_b, carry) + ops += [(CNOT, c, s) for c, s in zip(c_b, scr_b)] + # copy scratch -> out + ops += [(CNOT, s, o) for s, o in zip(scr_b, out_b)] + # uncompute scratch (reverse of the compute prefix) + n_comp = len(_adder_ops(a_b, scr_b, carry)) * 2 + len(c_b) + ops += [(g, *args) for g, *args in reversed(ops[:n_comp])] + _apply(reg, ops) + return ops + + +def _test_bennett(width=4): + a_b = list(range(width)) + b_b = list(range(width, 2 * width)) + c_b = list(range(2 * width, 3 * width)) + scr_b = list(range(3 * width, 4 * width)) + out_b = list(range(4 * width, 5 * width)) + carry = 5 * width + n = 5 * width + 1 + mask = (1 << width) - 1 + bad = 0 + for a in range(1 << width): + for b in range(1 << width): + for c in range(1 << width): + r = [0] * n + for k in range(width): + r[a_b[k]] = (a >> k) & 1 + r[b_b[k]] = (b >> k) & 1 + r[c_b[k]] = (c >> k) & 1 + bennett(r, a_b, b_b, c_b, scr_b, out_b, carry) + ra = sum(r[a_b[k]] << k for k in range(width)) + rb = sum(r[b_b[k]] << k for k in range(width)) + rc = sum(r[c_b[k]] << k for k in range(width)) + rs = sum(r[scr_b[k]] << k for k in range(width)) + ro = sum(r[out_b[k]] << k for k in range(width)) + if (ra, rb, rc, rs, ro, r[carry]) != (a, b, c, 0, ((a + b) & mask) ^ c, 0): + bad += 1 + print(f" Bennett (a,b,c,0,0)->(a,b,c,0,(a+b)^c), scratch cleaned " + f"[{width}-bit]: {'OK' if bad == 0 else f'FAIL({bad})'}") + return bad == 0 + + +def _test_alu(width=4): + mask = (1 << width) - 1 + a_b = list(range(width)) + b_b = list(range(width, 2 * width)) + one_b = list(range(2 * width, 3 * width)) + t_b = list(range(3 * width, 4 * width)) + carry = 4 * width + n = 4 * width + 1 + + def fresh(a=0, b=0, t=0, one=False): + r = [0] * n + for k in range(width): + r[a_b[k]] = (a >> k) & 1 + r[b_b[k]] = (b >> k) & 1 + r[t_b[k]] = (t >> k) & 1 + if one: + r[one_b[0]] = 1 + return r + + def rd(r, bits): + return sum(r[bits[k]] << k for k in range(width)) + + results = {} + # subtract + bad = 0 + for a in range(1 << width): + for b in range(1 << width): + r = fresh(a, b) + sub_into(r, a_b, b_b, carry) + if rd(r, b_b) != ((b - a) & mask) or rd(r, a_b) != a or r[carry] != 0: + bad += 1 + results["sub b-=a"] = bad == 0 and is_permutation(lambda r: sub_into(r, a_b, b_b, carry), n) + # xor + bad = 0 + for a in range(1 << width): + for b in range(1 << width): + r = fresh(a, b) + xor_into(r, a_b, b_b) + if rd(r, b_b) != (a ^ b) or rd(r, a_b) != a: + bad += 1 + results["xor b^=a"] = bad == 0 + # negate + bad = 0 + for b in range(1 << width): + r = fresh(b=b, one=True) + neg_into(r, b_b, one_b, carry) + if rd(r, b_b) != ((-b) & mask) or rd(r, one_b) != 1 or r[carry] != 0: + bad += 1 + results["neg b=-b"] = bad == 0 + # increment + bad = 0 + for b in range(1 << width): + r = fresh(b=b, one=True) + incr(r, b_b, one_b, carry) + if rd(r, b_b) != ((b + 1) & mask): + bad += 1 + results["incr b+=1"] = bad == 0 + # rotate + bad = 0 + for b in range(1 << width): + r = fresh(b=b) + rot_left(r, b_b, 1) + exp = ((b << 1) | (b >> (width - 1))) & mask + if rd(r, b_b) != exp: + bad += 1 + results["rot_left"] = bad == 0 + # and-into (Toffoli word) + bad = 0 + for a in range(1 << width): + for b in range(1 << width): + for t in range(1 << width): + r = fresh(a, b, t) + and_into(r, a_b, b_b, t_b) + if rd(r, t_b) != (t ^ (a & b)): + bad += 1 + results["and t^=a&b"] = bad == 0 + ok = all(results.values()) + print(" reversible ALU " + f"{width}-bit: " + + " ".join(f"{k}={'OK' if v else 'FAIL'}" for k, v in results.items())) + return ok + + +class RCPU: + def __init__(self, program: List[tuple], k_regs=4, width=8, mem_words=16): + self.prog = program + self.K = k_regs + self.W = width + self.M = mem_words + self.mask = (1 << width) - 1 + self.L = len(program) + + def new_state(self, regs=None, mem=None) -> dict: + return {"R": list(regs) + [0] * (self.K - len(regs)) if regs else [0] * self.K, + "PC": 0, "BR": 1, "MEM": list(mem) + [0] * (self.M - len(mem)) if mem else [0] * self.M} + + # ---- reversible instruction effects (forward and inverse) ---- + def _data(self, s, I, inverse: bool): + R, MEM, m = s["R"], s["MEM"], self.mask + op = I[0] + if op == "ADD": + d, r = I[1], I[2] + R[d] = (R[d] - R[r]) & m if inverse else (R[d] + R[r]) & m + elif op == "SUB": + d, r = I[1], I[2] + R[d] = (R[d] + R[r]) & m if inverse else (R[d] - R[r]) & m + elif op == "ADDI": + d, k = I[1], I[2] + R[d] = (R[d] - k) & m if inverse else (R[d] + k) & m + elif op == "XOR": + R[I[1]] ^= R[I[2]] + elif op == "XORI": + R[I[1]] ^= (I[2] & m) + elif op == "NEG": + R[I[1]] = (-R[I[1]]) & m + elif op == "TOFF": + R[I[1]] ^= (R[I[2]] & R[I[3]]) + elif op == "ROL": + k = (-I[2] if inverse else I[2]) % self.W + R[I[1]] = ((R[I[1]] << k) | (R[I[1]] >> (self.W - k))) & m if k else R[I[1]] + elif op == "EXCH": + d, r = I[1], I[2] + a = R[r] % self.M + R[d], MEM[a] = MEM[a], R[d] + # BRA/BEZ/HALT have no data effect + + def _toggle(self, s, I): + """Reversible control: toggle BR for a taken branch (self-inverse).""" + op = I[0] + if op == "BRA": + s["BR"] ^= (I[1] ^ 1) + elif op == "BEZ": + if s["R"][I[1]] == 0: + s["BR"] ^= (I[2] ^ 1) + + # ---- single-step transition and its inverse ---- + def step(self, s): + I = self.prog[s["PC"]] + self._data(s, I, inverse=False) + self._toggle(s, I) + s["PC"] = (s["PC"] + s["BR"]) % self.L + + def step_back(self, s): + s["PC"] = (s["PC"] - s["BR"]) % self.L + I = self.prog[s["PC"]] + self._toggle(s, I) # self-inverse: restores BR + self._data(s, I, inverse=True) + + def run(self, s, steps): + for _ in range(steps): + self.step(s) + return s + + def run_back(self, s, steps): + for _ in range(steps): + self.step_back(s) + return s + + +def _clone(s): + return {"R": list(s["R"]), "PC": s["PC"], "BR": s["BR"], "MEM": list(s["MEM"])} + + +def _eq(a, b): + return a["R"] == b["R"] and a["PC"] == b["PC"] and a["BR"] == b["BR"] and a["MEM"] == b["MEM"] + + +def test_straight_line(): + prog = [("ADD", 1, 0), ("XOR", 1, 0), ("NEG", 1), ("ADDI", 1, 7), + ("TOFF", 2, 0, 1), ("ROL", 0, 1)] + m = RCPU(prog, width=8) + ok = True + for a in (5, 0, 255, 100): + for b in (3, 1, 200): + s0 = m.new_state([a, b, 0, 0]) + s = _clone(s0) + m.run(s, len(prog)) + fwd = _clone(s) + m.run_back(s, len(prog)) + ok &= _eq(s, s0) # round-trip recovers the exact input + print(f" straight-line round-trip (dir -1 recovers input): {'OK' if ok else 'FAIL'}") + return ok + + +def test_bijection(): + """The single-step transition is a bijection: step_back inverts step on every instruction.""" + import itertools + W = 4 + prog = [ + ("ADD", 1, 0), ("SUB", 1, 0), ("ADDI", 2, 3), ("XOR", 1, 2), + ("XORI", 0, 5), ("NEG", 3), ("TOFF", 3, 0, 1), ("ROL", 0, 1), + ("EXCH", 2, 3), ("BRA", 2), ("BEZ", 1, 3), ("BEZ", 0, -2), + ] + m = RCPU(prog, k_regs=4, width=W, mem_words=4) + bad = 0 + checked = 0 + rng = __import__("random").Random(0) + for pc in range(len(prog)): + for br in (1, 2, 3, -2, -1): + for _ in range(60): + s0 = {"R": [rng.randint(0, (1 << W) - 1) for _ in range(4)], + "PC": pc, "BR": br, + "MEM": [rng.randint(0, (1 << W) - 1) for _ in range(4)]} + s = _clone(s0) + m.step(s) + m.step_back(s) + if not _eq(s, s0): + bad += 1 + # and the other composition order + s = _clone(s0) + m.step_back(s) + m.step(s) + if not _eq(s, s0): + bad += 1 + checked += 2 + print(f" step_back o step = id over {checked} (state, instruction) cases: " + f"{'OK' if bad == 0 else f'FAIL({bad})'}") + return bad == 0 + +WIDTH = 8 + +MASK = (1 << WIDTH) - 1 + + +def _regs_written(stmts) -> set: + w = set() + for st in stmts: + op = st[0] + if op in ("ADD", "SUB", "ADDI", "XOR", "NEG"): + w.add(st[1]) + elif op == "SWAP": + w.add(st[1]); w.add(st[2]) + elif op == "FOR": + w |= _regs_written(st[2]) + elif op == "IF": + w |= _regs_written(st[2]) | _regs_written(st[3]) + return w + + +def invert(stmts: List[tuple]) -> List[tuple]: + out = [] + for st in reversed(stmts): + op = st[0] + if op == "ADD": + out.append(("SUB", st[1], st[2])) + elif op == "SUB": + out.append(("ADD", st[1], st[2])) + elif op == "ADDI": + out.append(("ADDI", st[1], -st[2])) + elif op in ("XOR", "NEG", "SWAP"): + out.append(st) + elif op == "FOR": + out.append(("FOR", st[1], invert(st[2]))) + elif op == "IF": + # Janus: reverse swaps predicate and exit assertion, inverts branches + out.append(("IF", st[4], invert(st[2]), invert(st[3]), st[1])) + return out + + +def prog_run(stmts: List[tuple], s: Dict[str, int]) -> Dict[str, int]: + for st in stmts: + op = st[0] + if op == "ADD": + s[st[1]] = (s[st[1]] + s[st[2]]) & MASK + elif op == "SUB": + s[st[1]] = (s[st[1]] - s[st[2]]) & MASK + elif op == "ADDI": + s[st[1]] = (s[st[1]] + st[2]) & MASK + elif op == "XOR": + s[st[1]] ^= s[st[2]] + elif op == "NEG": + s[st[1]] = (-s[st[1]]) & MASK + elif op == "SWAP": + s[st[1]], s[st[2]] = s[st[2]], s[st[1]] + elif op == "FOR": + cnt, body = st[1], st[2] + if cnt in _regs_written(body): + raise ValueError("FOR count register must be read-only (irreversible otherwise)") + for _ in range(s[cnt]): + prog_run(body, s) + elif op == "IF": + pred, then, els, exit_assert = st[1], st[2], st[3], st[4] + if pred(s): + prog_run(then, s) + assert exit_assert(s), "exit assertion violated (not reversible)" + else: + prog_run(els, s) + assert not exit_assert(s), "exit assertion violated (not reversible)" + return s + + +# --- demonstration programs --- +MULTIPLY = [("FOR", "b", [("ADD", "acc", "a")])] # acc += a, b times; a,b preserved + +FIB = [("FOR", "n", [("ADD", "a", "b"), ("SWAP", "a", "b")])] # (a,b)->(b,a+b), n times + + +def _test(): + ok = True + + # reversible multiply: acc = a*b, inputs preserved; inverse clears acc + bad = 0 + for a in range(16): + for b in range(16): + s = {"a": a, "b": b, "acc": 0} + prog_run(MULTIPLY, s) + if s["acc"] != (a * b) & MASK or s["a"] != a or s["b"] != b: + bad += 1 + prog_run(invert(MULTIPLY), s) # run backward + if s != {"a": a, "b": b, "acc": 0}: + bad += 1 + print(f" reversible multiply acc=a*b, inputs preserved, inverse clears acc: " + f"{'OK' if bad == 0 else f'FAIL({bad})'}") + ok &= bad == 0 + + # reversible Fibonacci: n steps forward, inverse recovers the seed + bad = 0 + for n in range(12): + s = {"a": 0, "b": 1, "n": n} + prog_run(FIB, s) + # forward value check against a plain reference + ra, rb = 0, 1 + for _ in range(n): + ra, rb = rb, (ra + rb) & MASK + if (s["a"], s["b"]) != (ra, rb): + bad += 1 + prog_run(invert(FIB), s) + if s != {"a": 0, "b": 1, "n": n}: + bad += 1 + print(f" reversible Fibonacci n steps, inverse recovers seed: " + f"{'OK' if bad == 0 else f'FAIL({bad})'}") + ok &= bad == 0 + + # a reversible conditional (Janus IF): swap when the operands differ. The + # exit assertion a != b is true exactly when the then-branch ran (swapping + # distinct values keeps them distinct; equal values are skipped), so the + # reverse picks the right branch. + prog = [("IF", lambda s: s["a"] != s["b"], [("SWAP", "a", "b")], [], + lambda s: s["a"] != s["b"])] + bad = 0 + for a in range(16): + for b in range(16): + s = {"a": a, "b": b} + prog_run(prog, s) + if sorted([s["a"], s["b"]]) != sorted([a, b]): + bad += 1 + prog_run(invert(prog), s) + if s != {"a": a, "b": b}: + bad += 1 + print(f" reversible conditional (Janus IF with exit assertion): " + f"{'OK' if bad == 0 else f'FAIL({bad})'}") + ok &= bad == 0 + return ok + + +REVERSIBLE_OUT = os.path.join(ROOT, "variants", "neural_reversible.safetensors") + +# The two reversible primitives, keyed to their gate functions. +_CODE = {CNOT: 0, TOFF: 1} + +_FN = {0: CNOT, 1: TOFF} + +# Heaviside threshold gates that implement the target updates: a CNOT target is +# XOR(t,c); a Toffoli target is XOR(t, AND(a,b)). AND/OR/NAND are single gates. +_SUBSTRATE = { + "and_w": torch.tensor([1, 1]), "and_b": torch.tensor(-2), + "or_w": torch.tensor([1, 1]), "or_b": torch.tensor(-1), + "nand_w": torch.tensor([-1, -1]), "nand_b": torch.tensor(1), +} + + +def encode(ops): + codes, args = [], [] + for gate, *a in ops: + codes.append(_CODE[gate]) + args.append((a + [-1, -1, -1])[:3]) + return torch.tensor(codes, dtype=torch.long), torch.tensor(args, dtype=torch.long) + + +def main_build_reversible() -> int: + a_bits = list(range(WIDTH)) + b_bits = list(range(WIDTH, 2 * WIDTH)) + carry = 2 * WIDTH + n = 2 * WIDTH + 1 + ops = _adder_ops(a_bits, b_bits, carry) + codes, args = encode(ops) + + tensors = {"gate_code": codes, "gate_args": args, **_SUBSTRATE} + import json + meta = {"machine": "reversible", "width": str(WIDTH), "n_wires": str(n), + "a_bits": json.dumps(a_bits), "b_bits": json.dumps(b_bits), + "carry": str(carry), "circuit": "in-place reversible adder b<-a+b"} + save_file(tensors, REVERSIBLE_OUT, metadata=meta) + print(f"Built {os.path.relpath(REVERSIBLE_OUT, ROOT)}: reversible {WIDTH}-bit adder") + print(f" gates={len(ops)} wires={n} size={os.path.getsize(REVERSIBLE_OUT)} bytes") + + # round-trip: reconstruct the op list from the file and run it + t = load_file(REVERSIBLE_OUT) + with safe_open(REVERSIBLE_OUT, framework="pt") as f: + m = f.metadata() + W = int(m["width"]); ab = json.loads(m["a_bits"]); bb = json.loads(m["b_bits"]) + cy = int(m["carry"]); nn = int(m["n_wires"]) + loaded = [] + for code, a in zip(t["gate_code"].tolist(), t["gate_args"].tolist()): + loaded.append((_FN[code], *[x for x in a if x >= 0])) + + def run(reg): + for gate, *a in loaded: + gate(reg, *a) + + mask = (1 << W) - 1 + bad = 0 + rng = random.Random(0) + for _ in range(400): + a = rng.randint(0, mask); b = rng.randint(0, mask) + reg = [0] * nn + for k in range(W): + reg[ab[k]] = (a >> k) & 1 + reg[bb[k]] = (b >> k) & 1 + run(reg) + got_b = sum(reg[bb[k]] << k for k in range(W)) + got_a = sum(reg[ab[k]] << k for k in range(W)) + if got_b != ((a + b) & mask) or got_a != a or reg[cy] != 0: + bad += 1 + print(f" round-trip b<-a+b, addend & carry restored (400 cases): " + f"{'OK' if bad == 0 else f'FAIL({bad})'}") + # bijection is exhaustive-checkable at width 4 on the same construction + a4 = list(range(4)); b4 = list(range(4, 8)); c4 = 8 + ops4 = _adder_ops(a4, b4, c4) + perm = is_permutation(lambda r: _apply(r, ops4), 9) + print(f" loaded circuit is a bijection (4-bit): {'OK' if perm else 'FAIL'}") + return 0 if (bad == 0 and perm) else 1 + + +def adder_net(width: int): + """The in-place Cuccaro adder as a feedforward ternary netlist computing its permutation.""" + a_bits = list(range(width)) + b_bits = list(range(width, 2 * width)) + carry = 2 * width + n = 2 * width + 1 + ops = _adder_ops(a_bits, b_bits, carry) + net = Net() + inputs = [f"in{i}" for i in range(n)] + cur = list(inputs) + for k, (gate, *args) in enumerate(ops): + if gate is CNOT: + c, t = args + cur[t] = net.XOR(f"c{k}", cur[t], cur[c]) # t ^= c + else: # Toffoli: t ^= a&b + a, b, t = args + tmp = net.AND(f"t{k}a", [cur[a], cur[b]]) + cur[t] = net.XOR(f"t{k}x", cur[t], tmp) + return net, inputs, list(cur), n, a_bits, b_bits, carry + + +def _refs(n, a_bits, b_bits, carry): + out = [] + for x in range(1 << n): + reg = [(x >> i) & 1 for i in range(n)] + _apply(reg, _adder_ops(a_bits, b_bits, carry)) + out.append(sum(reg[i] << i for i in range(n))) + return torch.tensor(out) + + +def _outputs(mm, states, n, **step_kw): + v = mm.step(states.clone(), **step_kw) + bits = (v >= 0.5).to(torch.int64) + weights = torch.tensor([1 << i for i in range(n)]) + return (bits * weights).sum(dim=1) + + +def analog_sweep(mm, states, refs, n): + """The permutation must survive analog imperfection.""" + print(" read-noise sweep (bit-exact = all 512 outputs still match, 4 trials):") + for sigma in (0.05, 0.10, 0.15, 0.20): + ok = all((_outputs(mm, states, n, analog=True, noise_sigma=sigma, + gen=torch.Generator().manual_seed(s)) == refs).all() + for s in range(4)) + print(f" sigma={sigma:.2f}: {'bit-exact' if ok else 'errors appear'}") + print(" conductance-mismatch sweep (fixed per-device weight perturbation):") + for sg in (0.05, 0.10, 0.15): + ok = (_outputs(mm.perturbed(sg, seed=0), states, n, analog=True) == refs).all() + print(f" sigma_G={sg:.2f}: {'bit-exact' if ok else 'errors appear'}") + + +def main_reversible_matrix() -> int: + width = 4 + net, inputs, outputs, n, a_bits, b_bits, carry = adder_net(width) + layers, info = compile_net(net, inputs, outputs) + mm = MatrixMachine(layers) + + states = torch.stack([torch.tensor([float((x >> i) & 1) for i in range(n)]) + for x in range(1 << n)]) + refs = _refs(n, a_bits, b_bits, carry) + got = _outputs(mm, states, n) + bad = int((got != refs).sum()) + perm = len(set(got.tolist())) == (1 << n) + margin = mm.min_margin(states[:256]) + + print(f"reversible {width}-bit adder as a ternary matrix stack") + print(f" layers={info['layers']} gates={info['gates']} " + f"max_width={info['max_width']} total_weights={info['total_weights']}") + print(f" every weight ternary: {'OK' if all(((W == -1) | (W == 0) | (W == 1)).all() for W, _ in layers) else 'FAIL'}") + print(f" matches the gate circuit over all {1 << n} inputs: {'OK' if bad == 0 else f'FAIL({bad})'}") + print(f" composed transition is a permutation of the state space: {'OK' if perm else 'FAIL'}") + print(f" analog noise margin, all layers: {margin:.3f} (guarantee 0.5)") + analog_sweep(mm, states, refs, n) + ok = bad == 0 and perm and abs(margin - 0.5) < 1e-6 + print("PASS" if ok else "FAIL") + return 0 if ok else 1 + +Block = Tuple[int, int, int, int] + + +def rule(b: Block) -> Block: + tl, tr, bl, br = b + if b == (1, 0, 0, 1): + return (0, 1, 1, 0) # diagonal pair -> other diagonal (deflect) + if b == (0, 1, 1, 0): + return (1, 0, 0, 1) + return (br, bl, tr, tl) # otherwise rotate 180 degrees + + +def is_bijection() -> bool: + outs = {rule(tuple((s >> k) & 1 for k in (3, 2, 1, 0))) for s in range(16)} + return len(outs) == 16 + + +def self_inverse() -> bool: + return all(rule(rule(tuple((s >> k) & 1 for k in (3, 2, 1, 0)))) + == tuple((s >> k) & 1 for k in (3, 2, 1, 0)) for s in range(16)) + + +def step(grid: List[List[int]], phase: int) -> List[List[int]]: + """One Margolus update; phase 0 aligns blocks at even coordinates, phase 1 offsets them by (1,1).""" + H, W = len(grid), len(grid[0]) + out = [row[:] for row in grid] + o = phase + for r0 in range(o, o + H, 2): + for c0 in range(o, o + W, 2): + r, r1 = r0 % H, (r0 + 1) % H + c, c1 = c0 % W, (c0 + 1) % W + nb = rule((grid[r][c], grid[r][c1], grid[r1][c], grid[r1][c1])) + out[r][c], out[r][c1], out[r1][c], out[r1][c1] = nb + return out + + +def ca_run(grid: List[List[int]], nsteps: int, start_phase: int = 0) -> List[List[int]]: + g = grid + for n in range(nsteps): + g = step(g, (start_phase + n) & 1) + return g + + +def run_back(grid: List[List[int]], nsteps: int, start_phase: int = 0) -> List[List[int]]: + """Undo `run`: replay the phase sequence in reverse; the rule is self-inverse.""" + phases = [(start_phase + n) & 1 for n in range(nsteps)] + g = grid + for p in reversed(phases): + g = step(g, p) + return g + + +# --- the block rule as Heaviside threshold gates --- +# rule(s) = rotate180(s) XOR is_diag(s) on every cell: rotation fixes diagonal +# pairs, and flipping all four cells of a rotated diagonal pair sends it to the +# other diagonal. is_diag detects the two diagonal-pair states. +def _H(x): + return 1 if x >= 0 else 0 + + +def _and(*xs): + return _H(sum(xs) - len(xs)) + + +def _or(*xs): + return _H(sum(xs) - 1) + + +def _xor(a, b): + return _and(_or(a, b), _H(1 - a - b)) # OR AND NAND, the family's XOR + + +def gate_rule(b: Block) -> Block: + tl, tr, bl, br = b + d = _or(_and(tl, 1 - tr, 1 - bl, br), _and(1 - tl, tr, bl, 1 - br)) # is_diag + return (_xor(br, d), _xor(bl, d), _xor(tr, d), _xor(tl, d)) + + +def _test_gates(): + ok = all(gate_rule(tuple((s >> k) & 1 for k in (3, 2, 1, 0))) + == rule(tuple((s >> k) & 1 for k in (3, 2, 1, 0))) for s in range(16)) + print(f" block rule as Heaviside threshold gates matches over 16 states: " + f"{'OK' if ok else 'FAIL'}") + return ok + + +# --- tests --- +def _rand_grid(H, W, seed): + import random + rng = random.Random(seed) + return [[rng.randint(0, 1) for _ in range(W)] for _ in range(H)] + + +def _ball_positions(g): + return {(r, c) for r, row in enumerate(g) for c, v in enumerate(row) if v} + + +def _test_rule(): + print(f" block rule is a bijection of 16 states: {'OK' if is_bijection() else 'FAIL'}") + print(f" block rule is self-inverse: {'OK' if self_inverse() else 'FAIL'}") + return is_bijection() and self_inverse() + + +def _test_reversibility(): + bad = 0 + for seed in range(20): + g = _rand_grid(8, 8, seed) + fwd = ca_run(g, 25, start_phase=0) + back = run_back(fwd, 25, start_phase=0) + if back != g: + bad += 1 + # particle count is conserved (the rule permutes cells within each block) + g = _rand_grid(8, 8, 99) + conserved = sum(sum(r) for r in g) == sum(sum(r) for r in ca_run(g, 40)) + print(f" lattice reversible (run then reverse recovers grid, 20 grids): " + f"{'OK' if bad == 0 else f'FAIL({bad})'}") + print(f" particle number conserved: {'OK' if conserved else 'FAIL'}") + return bad == 0 and conserved + + +def _test_ballistic(): + # a single particle travels in a straight diagonal line + H = W = 16 + g = [[0] * W for _ in range(H)] + g[2][2] = 1 + positions = [next(iter(_ball_positions(g)))] + gg = g + for n in range(8): + gg = step(gg, n & 1) + p = _ball_positions(gg) + positions.append(next(iter(p)) if len(p) == 1 else None) + ok = all(p is not None for p in positions) + steady = ok and all(positions[i + 1] == (positions[i][0] + 1, positions[i][1] + 1) + for i in range(len(positions) - 1)) + print(f" single particle stays a single particle: {'OK' if ok else 'FAIL'}") + print(f" and moves ballistically on the diagonal, +(1,1) per step: " + f"{'OK' if steady else 'FAIL'} trace={positions[:5]}") + return ok and steady + + +def interaction_gate(a: int, b: int) -> dict: + """One billiard-ball collision as a reversible interaction gate.""" + H = W = 12 + g = [[0] * W for _ in range(H)] + if a: + g[2][2] = 1 + if b: + g[7][7] = 1 + g = ca_run(g, 4) + return {"A_and_B": g[3][6], "A_and_notB": g[6][6], "notA_and_B": g[3][3]} + + +def and_gate(a: int, b: int) -> int: + return interaction_gate(a, b)["A_and_B"] + + +def _test_gate(): + ok = True + for a in (0, 1): + for b in (0, 1): + o = interaction_gate(a, b) + ok &= (o["A_and_B"] == (a & b) and o["A_and_notB"] == (a & (1 - b)) + and o["notA_and_B"] == ((1 - a) & b)) + print(f" billiard-ball interaction gate (A&B, A&~B, ~A&B) over all 4 inputs: " + f"{'OK' if ok else 'FAIL'}") + return ok + + +def and3(a: int, b: int, c: int) -> int: + """Two composed collisions.""" + H = W = 16 + g = [[0] * W for _ in range(H)] + if a: + g[2][2] = 1 + if b: + g[7][7] = 1 + if c: + g[0][9] = 1 + return ca_run(g, 4)[3][5] + + +def _test_compose(): + ok = all(and3(a, b, c) == (a & b & c) + for a in (0, 1) for b in (0, 1) for c in (0, 1)) + print(f" composed 3-input AND (two chained collisions) over all 8 inputs: " + f"{'OK' if ok else 'FAIL'}") + return ok + + +def _test_collision(): + # Two particles interact (the joint evolution differs from independent + # motion) and the collision stays reversible: the physics that logic needs. + H = W = 12 + interacted = False + revok = True + # converging pairs: an SE-mover (even,even) meets an NW-mover (odd,odd) on a + # shared diagonal, forming the 1001/0110 diagonal pair the rule deflects. + for a, b in [((2, 2), (7, 7)), ((3, 3), (8, 8)), ((2, 8), (7, 3)), + ((4, 4), (9, 9)), ((2, 2), (9, 9))]: + g = [[0] * W for _ in range(H)] + g[a[0]][a[1]] = 1 + g[b[0]][b[1]] = 1 + ga = [[0] * W for _ in range(H)] + ga[a[0]][a[1]] = 1 + gb = [[0] * W for _ in range(H)] + gb[b[0]][b[1]] = 1 + joint = g + for n in range(12): + joint = step(joint, n & 1) + ga = step(ga, n & 1) + gb = step(gb, n & 1) + free = _ball_positions(ga) | _ball_positions(gb) + if _ball_positions(joint) != free: + interacted = True + if run_back(ca_run(g, 12), 12) != g: + revok = False + print(f" two-particle collisions interact (joint != independent motion): " + f"{'OK' if interacted else 'FAIL'}") + print(f" collisions remain reversible: {'OK' if revok else 'FAIL'}") + return interacted and revok + +CA_OUT = os.path.join(ROOT, "variants", "neural_ca.safetensors") + + +def block_net(): + net = Net() + ntl, ntr = net.NOT("ntl", "tl"), net.NOT("ntr", "tr") + nbl, nbr = net.NOT("nbl", "bl"), net.NOT("nbr", "br") + d1 = net.AND("d1", ["tl", ntr, nbl, "br"]) + d2 = net.AND("d2", [ntl, "tr", "bl", nbr]) + dg = net.OR("dg", [d1, d2]) # is_diag + TL = net.XOR("oTL", "br", dg) + TR = net.XOR("oTR", "bl", dg) + BL = net.XOR("oBL", "tr", dg) + BR = net.XOR("oBR", "tl", dg) + return net, ["tl", "tr", "bl", "br"], [TL, TR, BL, BR] + + +def matrix_step(mm, grid, phase): + """One Margolus update driven entirely by the matrix tile.""" + H, W = len(grid), len(grid[0]) + out = [row[:] for row in grid] + for r0 in range(phase, phase + H, 2): + for c0 in range(phase, phase + W, 2): + r, r1, c, c1 = r0 % H, (r0 + 1) % H, c0 % W, (c0 + 1) % W + v = torch.tensor([[float(grid[r][c]), float(grid[r][c1]), + float(grid[r1][c]), float(grid[r1][c1])]]) + o = mm.step(v)[0] + out[r][c], out[r][c1], out[r1][c], out[r1][c1] = (int(o[0]), int(o[1]), + int(o[2]), int(o[3])) + return out + + +def main_build_ca() -> int: + net, inp, outp = block_net() + layers, info = compile_net(net, inp, outp) + mm = MatrixMachine(layers) + + seen, bad, vecs = set(), 0, [] + for s in range(16): + b = tuple((s >> k) & 1 for k in (3, 2, 1, 0)) # tl,tr,bl,br + v = torch.tensor([[float(x) for x in b]]) + got = tuple(int(x) for x in mm.step(v)[0]) + if got != rule(b): + bad += 1 + seen.add(got) + vecs.append(v[0]) + perm = len(seen) == 16 + margin = mm.min_margin(torch.stack(vecs)) + + tensors = {} + for k, (W, B) in enumerate(layers): + tensors[f"matrix.layer{k:03d}.weight"] = W.to(torch.int8) + tensors[f"matrix.layer{k:03d}.bias"] = B.to(torch.int8) + meta = {"machine": "ca", + "rule": "Margolus reversible: rotate 180 except diagonal pair swaps (BBM class)", + "inputs": "tl,tr,bl,br", "outputs": "TL,TR,BL,BR", "layers": str(info["layers"])} + save_file(tensors, CA_OUT, metadata=meta) + print(f"Built {os.path.relpath(CA_OUT, ROOT)}: reversible CA block rule as a ternary matrix tile") + print(f" layers={info['layers']} gates={info['gates']} size={os.path.getsize(CA_OUT)} bytes") + print(f" every weight ternary: " + f"{'OK' if all(((W == -1) | (W == 0) | (W == 1)).all() for W, _ in layers) else 'FAIL'}") + print(f" tile matches the block rule over all 16 states: {'OK' if bad == 0 else f'FAIL({bad})'}") + print(f" tile is a permutation (16 distinct outputs): {'OK' if perm else 'FAIL'}") + print(f" analog noise margin: {margin:.3f} (guarantee 0.5)") + + # analog robustness: the tile must reproduce the rule under read noise and + # static conductance mismatch, as neural_matrix8 measures + states = torch.stack(vecs) + ref = [rule(tuple((s >> k) & 1 for k in (3, 2, 1, 0))) for s in range(16)] + + def outs(machine, **kw): + v = machine.step(states, **kw) + return [tuple(int(v[i][j]) for j in range(4)) for i in range(v.shape[0])] + + print(" read-noise sweep (exact = tile matches the rule on all 16 states):") + for sigma in (0.05, 0.10, 0.15, 0.20): + okn = all(outs(mm, analog=True, noise_sigma=sigma, + gen=torch.Generator().manual_seed(s)) == ref for s in range(4)) + print(f" sigma={sigma:.2f}: {'exact' if okn else 'errors appear'}") + print(" conductance-mismatch sweep:") + for sg in (0.05, 0.10, 0.15): + okg = outs(mm.perturbed(sg, seed=0), analog=True) == ref + print(f" sigma_G={sg:.2f}: {'exact' if okg else 'errors appear'}") + + # the loaded tile, applied to every block, is one whole-lattice CA step + t = load_file(CA_OUT) + n = 0 + lyr = [] + while f"matrix.layer{n:03d}.weight" in t: + lyr.append((t[f"matrix.layer{n:03d}.weight"], t[f"matrix.layer{n:03d}.bias"])) + n += 1 + mm2 = MatrixMachine(lyr) + g = _rand_grid(8, 8, 3) + gmat = matrix_step(mm2, g, 0) + gref = step(g, 0) + print(f" loaded tile drives a full lattice step (matches ca.step): " + f"{'OK' if gmat == gref else 'FAIL'}") + + ok = bad == 0 and perm and abs(margin - 0.5) < 1e-6 and gmat == gref + print("PASS" if ok else "FAIL") + return 0 if ok else 1 + +# side -> (dx, dy, my_side, neighbor_side) +_SIDES = [(0, 1, "N", "S"), (1, 0, "E", "W"), (0, -1, "S", "N"), (-1, 0, "W", "E")] + + +class Tile: + __slots__ = ("N", "E", "S", "W", "name") + + def __init__(self, N="", E="", S="", W="", name=""): + self.N, self.E, self.S, self.W, self.name = N, E, S, W, name + + def glue(self, side): + return getattr(self, side) + + +def bind_strength(A: Dict[Tuple[int, int], Tile], x: int, y: int, t: Tile, + strength: Dict[str, int]) -> int: + """Summed strength of t's glues that match the abutting neighbor glues.""" + s = 0 + for dx, dy, side, opp in _SIDES: + nb = A.get((x + dx, y + dy)) + if nb is None: + continue + g = t.glue(side) + if g and g == nb.glue(opp): + s += strength.get(g, 1) + return s + + +def binds(A, x, y, t, tau, strength) -> bool: + """The threshold-gate binding decision: H(sum strength*match - tau).""" + return bind_strength(A, x, y, t, strength) >= tau + + +def grow(tileset: List[Tile], seed: Dict[Tuple[int, int], Tile], tau: int, + strength: Dict[str, int], bounds: Tuple[int, int, int, int], + max_tiles: int = 100000) -> Tuple[Dict[Tuple[int, int], Tile], bool]: + """Directed growth from a seed.""" + x0, y0, x1, y1 = bounds + A = dict(seed) + deterministic = True + changed = True + while changed and len(A) < max_tiles: + changed = False + frontier = set() + for (x, y) in list(A): + for dx, dy, _, _ in _SIDES: + p = (x + dx, y + dy) + if p not in A and x0 <= p[0] <= x1 and y0 <= p[1] <= y1: + frontier.add(p) + for (x, y) in frontier: + binders = [t for t in tileset if binds(A, x, y, t, tau, strength)] + if len(binders) == 1: + A[(x, y)] = binders[0] + changed = True + elif len(binders) > 1: + deterministic = False + return A, deterministic + + +# --------------------------------------------------------------------------- +# XOR / Sierpinski tile set: value(x,y) = value(x-1,y) XOR value(x,y-1) +# --------------------------------------------------------------------------- +def rule2_tileset(fn) -> List[Tile]: + """Rule tiles for value(x,y) = fn(W, S): four cooperatively binding tiles emitting fn north and east.""" + ts = [] + for s in (0, 1): + for w in (0, 1): + v = fn(w, s) + ts.append(Tile(N=f"v{v}", E=f"v{v}", S=f"v{s}", W=f"v{w}", + name=f"R w{w} s{s} -> {v}")) + return ts + + +def sierpinski_tileset() -> List[Tile]: + return rule2_tileset(lambda w, s: w ^ s) + + +def _row_col_seed(bottom: List[int], left: List[int]): + """Seed the bottom row and the left column with fixed value tiles.""" + seed = {} + for x, b in enumerate(bottom): + seed[(x, 0)] = Tile(N=f"v{b}", E="", S="", W="", name=f"seedB{x}={b}") + for y, l in enumerate(left): + if y == 0: + continue + seed[(0, y)] = Tile(N="", E=f"v{l}", S="", W="", name=f"seedL{y}={l}") + return seed + + +def _test_binding_gate(): + """The binding decision is exactly the Heaviside threshold gate.""" + strength = {"v0": 1, "v1": 1} + ts = sierpinski_tileset() + A = {(1, 0): Tile(N="v1"), (0, 1): Tile(E="v0")} + bad = 0 + for t in ts: + for x, y in [(1, 1)]: + w = sum(strength.get(t.glue(side), 1) + for dx, dy, side, opp in _SIDES + if A.get((x + dx, y + dy)) and t.glue(side) + and t.glue(side) == A[(x + dx, y + dy)].glue(opp)) + gate = 1 if (w - 2) >= 0 else 0 # H(sum*match - tau) + if gate != int(binds(A, x, y, t, 2, strength)): + bad += 1 + print(f" binding decision == Heaviside gate H(sum-tau): {'OK' if bad == 0 else 'FAIL'}") + return bad == 0 + + +def _test_rule2(fn, name, n=24): + strength = {"v0": 1, "v1": 1} + bottom = [1 if x == 0 else 0 for x in range(n)] + left = [1 if y == 0 else 0 for y in range(n)] + seed = _row_col_seed(bottom, left) + A, det = grow(rule2_tileset(fn), seed, 2, strength, (0, 0, n - 1, n - 1)) + + def val(x, y): + t = A.get((x, y)) + return None if t is None else (1 if t.N == "v1" else 0) + + ref = {(x, 0): bottom[x] for x in range(n)} + ref.update({(0, y): left[y] for y in range(n)}) + for y in range(1, n): + for x in range(1, n): + ref[(x, y)] = fn(ref[(x - 1, y)], ref[(x, y - 1)]) + + filled = bad = 0 + for y in range(1, n): + for x in range(1, n): + v = val(x, y) + if v is not None: + filled += 1 + bad += v != ref[(x, y)] + tag = "OK" if (det and bad == 0 and filled > 0) else "FAIL" + print(f" rule-tile CA fn={name:3s}: directed={det} placed={filled} " + f"every tile = fn(W,S) {tag}") + return det and bad == 0 and filled > 0 + + +# --------------------------------------------------------------------------- +# Binary counter: each row is the row below plus one. LSB is the right column; +# carry propagates west by cooperative binding (S = bit below, E = carry in). +# --------------------------------------------------------------------------- +def counter_tileset() -> List[Tile]: + ts = [] + for b in (0, 1): + for c in (0, 1): + ts.append(Tile(N=f"b{b ^ c}", E=f"c{c}", S=f"b{b}", W=f"c{b & c}", + name=f"C b{b} c{c} -> b{b ^ c} carry{b & c}")) + ts.append(Tile(N="edge", E="", S="edge", W="c1", name="edge(+1 injector)")) + return ts + + +def counter_seed(n: int): + """Bottom row (y=0) all zero, plus the right-edge +1 injector column base.""" + seed = {} + for x in range(n): + seed[(x, 0)] = Tile(N="b0", name=f"seed b0 col{x}") + seed[(n, 0)] = Tile(N="edge", W="c1", name="seed edge") + return seed + + +def _test_counter(n=6, rows=None): + rows = rows or (1 << n) - 1 + strength = {"edge": 2} # value/carry glues default 1 + A, det = grow(counter_tileset(), counter_seed(n), 2, strength, + (0, 0, n, rows)) + + def rowval(y): + bits = [] + for x in range(n): + t = A.get((x, y)) + if t is None: + return None + bits.append(1 if t.N == "b1" else 0) + return sum(bit << (n - 1 - x) for x, bit in enumerate(bits)) + + bad = filled = 0 + for y in range(1, rows + 1): + v = rowval(y) + if v is not None: + filled += 1 + if v != (y & ((1 << n) - 1)): + bad += 1 + print(f" binary counter {n}-bit: directed={det} rows grown={filled} " + f"row y encodes the integer y {'OK' if bad == 0 else f'FAIL({bad})'}") + return det and bad == 0 and filled == rows + + +TILE_OUT = os.path.join(ROOT, "variants", "neural_tile.safetensors") + +NBITS = 8 + +TAU = 2 + + +def main_build_tile() -> int: + ts = counter_tileset() + strength = {"edge": 2} + glues = sorted({g for t in ts for g in (t.N, t.E, t.S, t.W) if g}) + gid = {g: i for i, g in enumerate(glues)} + tile_glues = torch.tensor([[gid.get(t.N, -1), gid.get(t.E, -1), + gid.get(t.S, -1), gid.get(t.W, -1)] for t in ts], + dtype=torch.long) + glue_strength = torch.tensor([strength.get(g, 1) for g in glues], dtype=torch.long) + # per-tile binding-gate weights = strengths of the tile's four glues (0 = null) + bind_w = torch.tensor([[strength.get(g, 1) if g else 0 for g in (t.N, t.E, t.S, t.W)] + for t in ts], dtype=torch.long) + tensors = {"tile_glues": tile_glues, "glue_strength": glue_strength, + "binding_weight": bind_w, "binding_bias": torch.tensor(-TAU)} + meta = {"machine": "tile", "tau": str(TAU), "glues": json.dumps(glues), + "tile_names": json.dumps([t.name for t in ts]), "program": "binary counter"} + save_file(tensors, TILE_OUT, metadata=meta) + print(f"Built {os.path.relpath(TILE_OUT, ROOT)}: binary-counter tile set") + print(f" tiles={len(ts)} glues={len(glues)} tau={TAU} size={os.path.getsize(TILE_OUT)} bytes") + + # round-trip: reconstruct the tiles from the file and regrow the counter + t = load_file(TILE_OUT) + with safe_open(TILE_OUT, framework="pt") as f: + m = f.metadata() + gl = json.loads(m["glues"]) + strg = {gl[i]: int(s) for i, s in enumerate(t["glue_strength"].tolist())} + tiles = [] + for row, name in zip(t["tile_glues"].tolist(), json.loads(m["tile_names"])): + sides = [gl[i] if i >= 0 else "" for i in row] + tiles.append(Tile(N=sides[0], E=sides[1], S=sides[2], W=sides[3], name=name)) + + rows = (1 << NBITS) - 1 + A, det = grow(tiles, counter_seed(NBITS), int(m["tau"]), strg, + (0, 0, NBITS, rows)) + bad = filled = 0 + for y in range(1, rows + 1): + cells = [A.get((x, y)) for x in range(NBITS)] + if any(c is None for c in cells): + continue + filled += 1 + v = sum((1 if c.N == "b1" else 0) << (NBITS - 1 - x) for x, c in enumerate(cells)) + if v != (y & ((1 << NBITS) - 1)): + bad += 1 + print(f" round-trip regrow {NBITS}-bit counter: directed={det} rows={filled} " + f"row y == y {'OK' if bad == 0 else f'FAIL({bad})'}") + + # the stored binding gate reproduces the model's binding decision + gate_ok = True + Atest = {(1, 0): Tile(N="b0"), (2, 0): Tile(N="b0")} + for ti, tt in enumerate(tiles): + for site in [(1, 1), (2, 1)]: + w = t["binding_weight"][ti].tolist() + match = [1 if tt.glue(side) and Atest.get((site[0] + dx, site[1] + dy)) + and tt.glue(side) == Atest[(site[0] + dx, site[1] + dy)].glue(opp) else 0 + for dx, dy, side, opp in _SIDES] + gate = 1 if sum(wi * mi for wi, mi in zip(w, match)) + int(t["binding_bias"]) >= 0 else 0 + if gate != int(binds(Atest, site[0], site[1], tt, TAU, strg)): + gate_ok = False + print(f" stored binding gate H(weight.match - tau) matches growth rule: " + f"{'OK' if gate_ok else 'FAIL'}") + return 0 if (bad == 0 and det and gate_ok) else 1 + + +def cmd_attractor(): + print("Attractor computer\n" + "=" * 40) + print("Forward evaluation (exact, energy 0):") + a = test_forward() + print("Canonical relaxation:") + b = test_energy_relax() + print("Backward inversion (factoring by relaxation):") + c_ = test_factor() + print("SAT (clamp output to 1, relax to a model):") + d = test_sat() + print("=" * 40) + print("ALL PASS" if (a and b and c_ and d) else "FAILURES") + sys.exit(0 if (a and b and c_ and d) else 1) + + +def cmd_reversible(): + print("Reversible primitives + ALU") + a = _test_primitives() + b = _test_adder(4) and _test_adder(5) + c = _test_alu(4) + d = _test_bennett(4) + print("PASS" if (a and b and c and d) else "FAIL") + + +def cmd_reversible_cpu(): + print("Reversible CPU") + a = test_straight_line() + b = test_bijection() + print("PASS" if (a and b) else "FAIL") + + +def cmd_reversible_prog(): + print("Reversible structured programs") + print("PASS" if _test() else "FAIL") + + +def cmd_ca(): + print("Reversible Margolus cellular automaton") + a = _test_rule() + g = _test_gates() + b = _test_reversibility() + c = _test_ballistic() + d = _test_collision() + e = _test_gate() + f = _test_compose() + print("PASS" if (a and g and b and c and d and e and f) else "FAIL") + + +def cmd_tile(): + print("Self-assembling tile computer") + a = _test_binding_gate() + b = all(_test_rule2(fn, nm) for fn, nm in + [(lambda w, s: w ^ s, "XOR"), (lambda w, s: w & s, "AND"), + (lambda w, s: w | s, "OR")]) + c = _test_counter() + print("PASS" if (a and b and c) else "FAIL") + + +COMMANDS = {'attractor': cmd_attractor, 'build-attractor': main_build_attractor, 'reversible': cmd_reversible, 'reversible-cpu': cmd_reversible_cpu, 'reversible-prog': cmd_reversible_prog, 'build-reversible': main_build_reversible, 'reversible-matrix': main_reversible_matrix, 'ca': cmd_ca, 'build-ca': main_build_ca, 'tile': cmd_tile, 'build-tile': main_build_tile} + + +if __name__ == "__main__": + if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS: + sys.exit('commands: ' + ', '.join(COMMANDS)) + cmd = sys.argv.pop(1) + sys.argv[0] += ' ' + cmd + sys.exit(COMMANDS[cmd]()) diff --git a/src/build.py b/src/build.py index 523cbf9b2468abf84cd6b7932dfccbf5eac8b0f2..c230e7b73d694e8a3b1dee319902333d18681456 100644 --- a/src/build.py +++ b/src/build.py @@ -1,111 +1,16 @@ -""" -Build tools for 8-bit Threshold Computer safetensors. - -Subcommands: - python build.py memory - Generate 64KB memory circuits - python build.py inputs - Add .inputs metadata tensors - python build.py all - Run both (memory first, then inputs) - - -ROUTING SCHEMA (formerly routing.json) -====================================== - -Routing info is now embedded in safetensors via .inputs tensors and signal registry metadata. - - -INPUT SOURCE TYPES ------------------- - -1. External input: "$input_name" - Named input to the circuit - - Example: "$a", "$b", "$cin" - -2. Gate output: "path.to.gate" - Output of another gate - - Example: "ha1.sum", "layer1.or" - -3. Bit extraction: "$input[i]" - Single bit from multi-bit input - - Example: "$a[0]" (LSB), "$a[7]" (MSB for 8-bit) - -4. Constant: "#0" or "#1" - Fixed value - - Example: "#1" for carry-in in two's complement - - -CIRCUIT TYPES -------------- - -Single-Layer Gates: .weight and .bias only - "boolean.and": ["$a", "$b"] - -Two-Layer Gates (XOR, XNOR): layer1 + layer2 - "boolean.xor.layer1.or": ["$a", "$b"] - "boolean.xor.layer1.nand": ["$a", "$b"] - "boolean.xor.layer2": ["layer1.or", "layer1.nand"] - -Hierarchical Circuits: nested sub-components - "arithmetic.fulladder": { - "ha1.sum.layer1.or": ["$a", "$b"], - "ha1.carry": ["$a", "$b"], - "ha2.sum.layer1.or": ["ha1.sum", "$cin"], - "carry_or": ["ha1.carry", "ha2.carry"] - } - -Bit-Indexed Circuits: multi-bit operations - "arithmetic.ripplecarry8bit.fa0": ["$a[0]", "$b[0]", "#0"] - "arithmetic.ripplecarry8bit.fa1": ["$a[1]", "$b[1]", "fa0.cout"] - - -PACKED MEMORY CIRCUITS ----------------------- - -64KB memory uses packed tensors (shapes for 16-bit address, 8-bit data): - - memory.addr_decode.weight: [65536, 16] - memory.addr_decode.bias: [65536] - memory.read.and.weight: [8, 65536, 2] - memory.read.and.bias: [8, 65536] - memory.read.or.weight: [8, 65536] - memory.read.or.bias: [8] - memory.write.sel.weight: [65536, 2] - memory.write.sel.bias: [65536] - memory.write.nsel.weight: [65536, 1] - memory.write.nsel.bias: [65536] - memory.write.and_old.weight: [65536, 8, 2] - memory.write.and_old.bias: [65536, 8] - memory.write.and_new.weight: [65536, 8, 2] - memory.write.and_new.bias: [65536, 8] - memory.write.or.weight: [65536, 8, 2] - memory.write.or.bias: [65536, 8] - -Semantics: - decode: sel[i] = H(sum(addr_bits * weight[i]) + bias[i]) - read: bit[b] = H(sum(H([mem_bit, sel] * and_w) + and_b) * or_w + or_b) - write: new = H(H([old, nsel] * and_old) + H([data, sel] * and_new) - 1) - - -SIGNAL REGISTRY ---------------- - -Signal IDs are stored in safetensors metadata as JSON: - - {"0": "#0", "1": "#1", "2": "$a", "3": "$b", ...} - -Each gate's .inputs tensor contains integer IDs referencing this registry. - - -NAMING CONVENTIONS ------------------- - -- External inputs: $name or $name[bit] -- Constants: #0, #1 -- Internal gates: relative path from circuit root -""" - +"""Builds the threshold computers, quantizes them, and builds every named variant.""" from __future__ import annotations import argparse import json +import os import re +import shutil +import subprocess +import sys +import time from pathlib import Path -from typing import Any, Dict, Iterable, List, Optional, Set +from typing import Any, Dict, Iterable, List, Optional, Set, Tuple import torch from safetensors import safe_open @@ -140,9 +45,11 @@ def get_model_path(bits: int = 8, memory_profile: str = None, addr_bits: int = N MODEL_PATH = MODEL_DIR / "neural_computer8.safetensors" + MANIFEST_PATH = Path(__file__).resolve().parent.parent / "tensors.txt" DEFAULT_ADDR_BITS = 16 + DEFAULT_MEM_BYTES = 1 << DEFAULT_ADDR_BITS MEMORY_PROFILES = { @@ -166,8 +73,7 @@ def load_tensors(path: Path) -> Dict[str, torch.Tensor]: def load_file_metadata(path: Path) -> Dict[str, str]: - """Read the safetensors header metadata so every save can carry it - forward (signal_registry, weight_quantization, ...).""" + """Read the safetensors header metadata so every save carries it forward.""" with safe_open(str(path), framework="pt") as f: meta = f.metadata() return dict(meta) if meta else {} @@ -264,12 +170,7 @@ def add_memory_write_cells(tensors: Dict[str, torch.Tensor], mem_bytes: int) -> def add_fetch_load_store_buffers(tensors: Dict[str, torch.Tensor], data_bits: int, addr_bits: int) -> None: - """Add control buffers for fetch, load, store operations. - - Args: - data_bits: Width of data bus (8/16/32) - addr_bits: Width of address bus (determines instruction register width) - """ + """Add control buffers for fetch, load, store operations.""" # Instruction register width = opcode (8) + operands (depends on arch) # For simplicity, IR width = max(16, addr_bits) to hold jump targets ir_bits = max(16, addr_bits) @@ -283,13 +184,7 @@ def add_fetch_load_store_buffers(tensors: Dict[str, torch.Tensor], data_bits: in def add_full_adder(tensors: Dict[str, torch.Tensor], prefix: str) -> None: - """Add a single full adder at the given prefix. - - Full adder structure: - - ha1: first half adder (A XOR B for sum, A AND B for carry) - - ha2: second half adder (ha1.sum XOR Cin for sum, ha1.sum AND Cin for carry) - - carry_or: OR of ha1.carry and ha2.carry for final carry out - """ + """Add a single full adder at the given prefix.""" # XOR for ha1.sum (2-layer: OR + NAND -> AND) add_gate(tensors, f"{prefix}.ha1.sum.layer1.or", [1.0, 1.0], [-1.0]) add_gate(tensors, f"{prefix}.ha1.sum.layer1.nand", [-1.0, -1.0], [1.0]) @@ -307,21 +202,7 @@ def add_full_adder(tensors: Dict[str, torch.Tensor], prefix: str) -> None: def add_expr_add_mul(tensors: Dict[str, torch.Tensor]) -> None: - """Add expression circuit for A + B × C (order of operations). - - Computes A + (B × C) where multiplication has higher precedence. - - Structure: - - Stage 1: Multiply B × C using shift-add algorithm - - 8 mask stages: mask[i] = B AND C[i] (8 AND gates each, shifted) - - 7 accumulator adders to sum masked values - - Stage 2: Add A to multiplication result (8-bit ripple carry) - - Inputs: $a[0-7], $b[0-7], $c[0-7] (MSB-first, 8-bit each) - Output: 8-bit result of A + (B × C), wrapping on overflow - - Total: 64 AND gates + 7×8 full adders (mul) + 8 full adders (add) = ~640 gates - """ + """Add expression circuit for A + B × C (order of operations).""" prefix = "arithmetic.expr_add_mul" # Stage 1: Multiply B × C using shift-add @@ -357,22 +238,7 @@ def add_expr_add_mul(tensors: Dict[str, torch.Tensor]) -> None: def add_expr_paren_add_mul(tensors: Dict[str, torch.Tensor]) -> None: - """Add expression circuit for (A + B) × C (parenthetical override). - - Computes (A + B) × C where parentheses override normal precedence. - Addition happens first, then multiplication. - - Structure: - - Stage 1: Add A + B (8-bit ripple carry adder) - - Stage 2: Multiply sum × C using shift-add algorithm - - 8 mask stages: mask[i] = sum AND C[i] (8 AND gates each) - - 7 accumulator adders to sum shifted masked values - - Inputs: $a[0-7], $b[0-7], $c[0-7] (MSB-first, 8-bit each) - Output: 8-bit result of (A + B) × C, wrapping on overflow - - Total: 8 full adders (add) + 64 AND gates + 56 full adders (mul) = ~640 gates - """ + """Add expression circuit for (A + B) × C (parenthetical override).""" prefix = "arithmetic.expr_paren_add_mul" # Stage 1: Add A + B @@ -392,21 +258,7 @@ def add_expr_paren_add_mul(tensors: Dict[str, torch.Tensor]) -> None: def add_expr_paren(tensors: Dict[str, torch.Tensor]) -> None: - """Add expression circuit for (A + B) × C (parenthetical grouping). - - Computes (A + B) × C where addition happens first due to parentheses. - - Structure: - - Stage 1: Add A + B (8-bit ripple carry) - - Stage 2: Multiply sum × C using shift-add algorithm - - 8 mask stages: mask[i] = sum AND C[i] (8 AND gates each) - - 7 accumulator adders to sum shifted masked values - - Inputs: $a[0-7], $b[0-7], $c[0-7] (MSB-first, 8-bit each) - Output: 8-bit result of (A + B) × C, wrapping on overflow - - Total: 8 full adders (add) + 64 AND gates + 56 full adders (mul) = ~640 gates - """ + """Add expression circuit for (A + B) × C (parenthetical grouping).""" prefix = "arithmetic.expr_paren" # Stage 1: Add A + B @@ -426,17 +278,7 @@ def add_expr_paren(tensors: Dict[str, torch.Tensor]) -> None: def add_add3(tensors: Dict[str, torch.Tensor]) -> None: - """Add 3-operand 8-bit adder circuit. - - Computes A + B + C using two chained ripple-carry stages: - - Stage 1: temp = A + B (8 full adders) - - Stage 2: result = temp + C (8 full adders) - - Inputs: $a[0-7], $b[0-7], $c[0-7] (MSB-first) - Outputs: stage2.fa0-7.ha2.sum.layer2 (result bits), stage2.fa7.carry_or (overflow) - - Total: 16 full adders = 144 gates - """ + """Add 3-operand 8-bit adder circuit.""" # Stage 1: A + B -> temp for bit in range(8): add_full_adder(tensors, f"arithmetic.add3_8bit.stage1.fa{bit}") @@ -447,14 +289,7 @@ def add_add3(tensors: Dict[str, torch.Tensor]) -> None: def add_shl_shr(tensors: Dict[str, torch.Tensor]) -> None: - """Add SHL (shift left) and SHR (shift right) circuits. - - Identity gate: w=1, b=-1 -> H(x - 1) = x for x in {0,1} - Zero gate: w=0, b=-1 -> H(-1) = 0 - - SHL (MSB-first): out[i] = in[i+1] for i<7, out[7] = 0 - SHR (MSB-first): out[0] = 0, out[i] = in[i-1] for i>0 - """ + """Add SHL (shift left) and SHR (shift right) circuits.""" for bit in range(8): if bit < 7: add_gate(tensors, f"alu.alu8bit.shl.bit{bit}", [1.0], [-1.0]) @@ -469,18 +304,7 @@ def add_shl_shr(tensors: Dict[str, torch.Tensor]) -> None: def add_mul(tensors: Dict[str, torch.Tensor]) -> None: - """Add 8-bit multiplication circuit. - - Produces low 8 bits of the 16-bit result. - - Structure: - - 64 AND gates for partial products P[i][j] = A[i] AND B[j] - - Uses existing ripple-carry adder components for summation - - The multiply method in ThresholdALU computes: - 1. Partial products via these AND gates - 2. Shift-add accumulation via existing 8-bit adder - """ + """Add 8-bit multiplication circuit.""" # AND gates for partial products: P[i][j] = A[i] AND B[j] # These compute whether bit i of A and bit j of B are both 1 for i in range(8): @@ -489,19 +313,7 @@ def add_mul(tensors: Dict[str, torch.Tensor]) -> None: def add_div(tensors: Dict[str, torch.Tensor]) -> None: - """Add 8-bit division circuit. - - Produces quotient (8 bits) and remainder (8 bits). - - Uses restoring division algorithm: - - 8 iterations, each producing one quotient bit - - Each iteration: compare, conditionally subtract, shift - - Structure: - - 8 comparison gates (one per iteration) - - 8 conditional subtraction stages - - Uses existing comparator and subtractor components - """ + """Add 8-bit division circuit.""" # Comparison gates: check if (remainder << 1 | next_bit) >= divisor # Bit-cascaded ternary GE comparator per stage. for stage in range(8): @@ -529,14 +341,7 @@ def add_div(tensors: Dict[str, torch.Tensor]) -> None: def add_inc_dec(tensors: Dict[str, torch.Tensor]) -> None: - """Add INC and DEC circuits. - - INC: A + 1 using half adders with carry chain - DEC: A - 1 using borrow chain (A + 255, two's complement of 1) - - For INC, we add 1 to the LSB and propagate carry. - For DEC, we add 0xFF (two's complement of 1) or use borrow logic. - """ + """Add INC and DEC circuits.""" # INC: half adder chain starting with carry_in = 1 # bit 7 (LSB): XOR with 1, carry = bit[7] # bit 6: XOR with carry, new_carry = bit[6] AND old_carry @@ -563,12 +368,7 @@ def add_inc_dec(tensors: Dict[str, torch.Tensor]) -> None: def add_neg(tensors: Dict[str, torch.Tensor]) -> None: - """Add NEG circuit (two's complement negation). - - NEG(A) = NOT(A) + 1 = ~A + 1 - - Structure: NOT gates followed by INC-style adder. - """ + """Add NEG circuit (two's complement negation).""" for bit in range(8): # NOT gate for each bit add_gate(tensors, f"alu.alu8bit.neg.not.bit{bit}", [-1.0], [0.0]) @@ -585,14 +385,7 @@ def add_rol_ror(tensors: Dict[str, torch.Tensor]) -> None: def add_rol_ror_nbits(tensors: Dict[str, torch.Tensor], bits: int) -> None: - """Add N-bit ROL and ROR circuits (rotate left/right). - - ROL: out[i] = in[i+1] for i0 (LSB wraps to MSB) - - Args: - bits: Data width (8, 16, 32, etc.) - """ + """Add N-bit ROL and ROR circuits (rotate left/right).""" # ROL: rotate left (toward MSB) for bit in range(bits): add_gate(tensors, f"alu.alu{bits}bit.rol.bit{bit}", [1.0], [-1.0]) @@ -603,19 +396,7 @@ def add_rol_ror_nbits(tensors: Dict[str, torch.Tensor], bits: int) -> None: def add_stack_ops(tensors: Dict[str, torch.Tensor], data_bits: int, addr_bits: int) -> None: - """Add RET, PUSH, POP circuit components. - - These are higher-level operations that use memory read/write. - We create the control logic gates. - - Args: - data_bits: Width of data to push/pop (8/16/32) - addr_bits: Width of stack pointer and return addresses - - RET: Pop return address from stack, jump to it - PUSH: Decrement SP, write value to [SP] - POP: Read value from [SP], increment SP - """ + """Add RET, PUSH, POP circuit components.""" # SP decrement for PUSH (addr_bits wide) for bit in range(addr_bits): add_gate(tensors, f"control.push.sp_dec.bit{bit}.xor.layer1.or", [1.0, 1.0], [-1.0]) @@ -644,21 +425,7 @@ def add_stack_ops(tensors: Dict[str, torch.Tensor], data_bits: int, addr_bits: i def add_conditional_jumps(tensors: Dict[str, torch.Tensor], addr_bits: int) -> None: - """Add conditional jump circuits (JZ, JNZ, JC, JNC, JP, JN, JV, JNV). - - Each conditional jump is a 2:1 MUX per address bit: - - If flag is set: output = target_bit - - If flag is clear: output = pc_bit - - Structure per bit: - - not_sel: NOT(flag) - - and_a: pc_bit AND NOT(flag) - - and_b: target_bit AND flag - - or: and_a OR and_b - - Args: - addr_bits: Width of program counter / jump target - """ + """Add conditional jump circuits (JZ, JNZ, JC, JNC, JP, JN, JV, JNV).""" jump_types = ['jz', 'jnz', 'jc', 'jnc', 'jp', 'jn', 'jv', 'jnv'] for jmp in jump_types: @@ -675,19 +442,14 @@ def add_conditional_jumps(tensors: Dict[str, torch.Tensor], addr_bits: int) -> N def add_computer_decode(tensors: Dict[str, torch.Tensor]) -> None: - """Instruction decode for the 8-bit computer as threshold gates: a 4-to-16 - opcode one-hot decoder (exact match on the instruction's opcode nibble). The - runtime reads these one-hots to dispatch instead of slicing the word.""" + """The 4-to-16 opcode one-hot decoder of the 8-bit computer.""" for opc in range(16): w = [1.0 if (opc >> j) & 1 else -1.0 for j in range(4)] add_gate(tensors, f"control.decode.op{opc}", w, [-float(bin(opc).count("1"))]) def add_computer_pcnext(tensors: Dict[str, torch.Tensor], addr_bits: int) -> None: - """PC sequencing for the 8-bit computer as threshold gates: PC+2 / PC+4 - increment chains and a priority mux selecting the next PC among PC+2 (ALU), - PC+4 (load/store), the conditional-jump mux, and the direct target - (JMP/CALL). Replaces the runtime's Python PC+2/PC+4 and jump wiring.""" + """Next-PC selection of the 8-bit computer among PC+2, PC+4, the jump mux and the direct target.""" for name, start in (("pc2", 1), ("pc4", 2)): for bit in range(addr_bits): if bit < start: @@ -707,17 +469,7 @@ def add_computer_pcnext(tensors: Dict[str, torch.Tensor], addr_bits: int) -> Non def add_status_flags(tensors: Dict[str, torch.Tensor], data_bits: int) -> None: - """Add status flag computation circuits (Z, N, C, V). - - Args: - data_bits: Width of ALU data (8/16/32) - - Flags: - - Z (Zero): NOR of all result bits (1 if result == 0) - - N (Negative): Copy of MSB (sign bit) - - C (Carry): Carry out from adder (external input) - - V (Overflow): XOR of carry into and out of MSB (signed overflow) - """ + """Add status flag computation circuits (Z, N, C, V).""" # Z flag: NOR of all bits (result == 0) # Single threshold gate: fires if sum of all bits < 1 add_gate(tensors, "flags.zero", [-1.0] * data_bits, [0.0]) @@ -741,14 +493,7 @@ def add_barrel_shifter(tensors: Dict[str, torch.Tensor]) -> None: def add_barrel_shifter_nbits(tensors: Dict[str, torch.Tensor], bits: int) -> None: - """Add N-bit barrel shifter circuit. - - Shifts input by 0 to (bits-1) positions based on ceil(log2(bits))-bit shift amount. - Uses layers of 2:1 muxes controlled by shift amount bits. - - Args: - bits: Data width (8, 16, 32, etc.) - """ + """Add N-bit barrel shifter circuit.""" import math num_layers = max(1, math.ceil(math.log2(bits))) @@ -769,21 +514,7 @@ def add_priority_encoder(tensors: Dict[str, torch.Tensor]) -> None: def add_priority_encoder_nbits(tensors: Dict[str, torch.Tensor], bits: int) -> None: - """Add N-bit priority encoder circuit. - - Finds the position of the highest set bit (0 to bits-1). - Position 0 = MSB (highest priority), Position bits-1 = LSB (lowest priority). - Output is ceil(log2(bits))-bit index + valid flag. - - Circuit structure: - 1. any_higher{pos}: OR of bits 0 to pos-1 (all higher-priority positions) - 2. is_highest{pos}: bit[pos] AND NOT(any_higher{pos}) - 3. out{bit}: OR of is_highest{pos} for positions where (pos >> bit) & 1 - 4. valid: OR of all input bits - - Args: - bits: Input width (8, 16, 32, etc.) - """ + """Add N-bit priority encoder circuit.""" import math out_bits = max(1, math.ceil(math.log2(bits))) prefix = f"combinational.priorityencoder{bits}" @@ -827,31 +558,7 @@ def add_bit_cascade_compare( out_le: str, out_eq: str, ) -> None: - """Generic ternary-only N-bit comparator. - - Inputs are two N-bit values A and B in MSB-first order. The structure - produces unsigned-magnitude GT, LT, GE, LE, EQ outputs using only - weights in {-1, 0, 1} and integer biases. - - Per-bit primitives (i = 0 is the MSB): - {cmp_prefix}.bit{i}.gt A[i] AND NOT B[i] weights [1, -1], bias -1 - {cmp_prefix}.bit{i}.lt NOT A[i] AND B[i] weights [-1, 1], bias -1 - {cmp_prefix}.bit{i}.eq.layer1.and A[i] AND B[i] - {cmp_prefix}.bit{i}.eq.layer1.nor NOR(A[i], B[i]) - {cmp_prefix}.bit{i}.eq XNOR via OR of layer1 outputs - - Cascade (linear chain from MSB to LSB): - {cmp_prefix}.cascade.eq_prefix.bit{i} AND of eq[0..i-1] (i in 1..N-1) - {cmp_prefix}.cascade.gt.bit{i} eq_prefix[i] AND gt[i] - {cmp_prefix}.cascade.lt.bit{i} eq_prefix[i] AND lt[i] - - Final outputs: - out_gt = OR of (gt[0], cascade.gt.bit{1..N-1}) - out_lt = OR of (lt[0], cascade.lt.bit{1..N-1}) - out_eq = AND of all eq[i] - out_ge = NOT(out_lt) - out_le = NOT(out_gt) - """ + """Generic ternary-only N-bit comparator.""" for i in range(bits): # per-bit GT: A[i] AND NOT B[i] -> H(A - B - 1) add_gate(tensors, f"{cmp_prefix}.bit{i}.gt", [1.0, -1.0], [-1.0]) @@ -891,11 +598,7 @@ def add_bit_cascade_compare( def add_comparators(tensors: Dict[str, torch.Tensor]) -> None: - """Add 8-bit comparator circuits (GT, LT, GE, LE, EQ) using bit-cascade. - - Inputs are 8 bits of A then 8 bits of B in MSB-first order. The - underlying bit-cascade produces only ternary {-1, 0, 1} weights. - """ + """Add 8-bit comparator circuits (GT, LT, GE, LE, EQ) using bit-cascade.""" add_bit_cascade_compare( tensors, cmp_prefix="arithmetic.cmp8bit", @@ -909,30 +612,14 @@ def add_comparators(tensors: Dict[str, torch.Tensor]) -> None: def add_ripple_carry_nbits(tensors: Dict[str, torch.Tensor], bits: int) -> None: - """Add N-bit ripple carry adder circuit. - - Creates a chain of full adders for N-bit addition. - Works for 8, 16, or 32 bits. - - Inputs: $a[0..N-1], $b[0..N-1] (MSB-first) - Outputs: fa0-fa{N-1} sum bits, fa{N-1}.carry_or for overflow - """ + """Add N-bit ripple carry adder circuit.""" prefix = f"arithmetic.ripplecarry{bits}bit" for bit in range(bits): add_full_adder(tensors, f"{prefix}.fa{bit}") def add_sub_nbits(tensors: Dict[str, torch.Tensor], bits: int) -> None: - """Add N-bit subtractor circuit (A - B). - - Uses two's complement: A - B = A + (~B) + 1 - - Structure: - - NOT gates for each bit of B - - N-bit ripple carry adder with carry_in = 1 - - The carry_in=1 is handled by the adder's fa0 having cin=#1 instead of #0. - """ + """Add N-bit subtractor circuit (A - B).""" prefix = f"arithmetic.sub{bits}bit" for bit in range(bits): @@ -943,10 +630,7 @@ def add_sub_nbits(tensors: Dict[str, torch.Tensor], bits: int) -> None: def add_comparators_nbits(tensors: Dict[str, torch.Tensor], bits: int) -> None: - """Add N-bit comparator circuits (GT, LT, GE, LE, EQ) via bit-cascade. - - All weights are in {-1, 0, 1}. Inputs are A bits then B bits, MSB-first. - """ + """Add N-bit comparator circuits (GT, LT, GE, LE, EQ) via bit-cascade.""" add_bit_cascade_compare( tensors, cmp_prefix=f"arithmetic.cmp{bits}bit", @@ -960,27 +644,14 @@ def add_comparators_nbits(tensors: Dict[str, torch.Tensor], bits: int) -> None: def add_mul_nbits(tensors: Dict[str, torch.Tensor], bits: int) -> None: - """Add N-bit multiplication circuit. - - Produces low N bits of the 2N-bit result. - - Structure: - - N*N AND gates for partial products P[i][j] = A[i] AND B[j] - - Shift-add accumulation using existing adder circuits - - For 32-bit: 1024 AND gates for partial products. - """ + """Add N-bit multiplication circuit.""" for i in range(bits): for j in range(bits): add_gate(tensors, f"alu.alu{bits}bit.mul.pp.a{i}b{j}", [1.0, 1.0], [-2.0]) def add_div_nbits(tensors: Dict[str, torch.Tensor], bits: int) -> None: - """Add N-bit division circuit. - - Uses restoring division algorithm with N iterations. Each stage's - comparator (rem >= div) is a ternary bit-cascade GE. - """ + """Add N-bit division circuit.""" for stage in range(bits): add_bit_cascade_compare( tensors, @@ -1002,10 +673,7 @@ def add_div_nbits(tensors: Dict[str, torch.Tensor], bits: int) -> None: def add_bitwise_nbits(tensors: Dict[str, torch.Tensor], bits: int) -> None: - """Add N-bit bitwise operation circuits (AND, OR, XOR, NOT). - - These are simply N copies of the 1-bit gates. - """ + """Add N-bit bitwise operation circuits (AND, OR, XOR, NOT).""" for bit in range(bits): add_gate(tensors, f"alu.alu{bits}bit.and.bit{bit}", [1.0, 1.0], [-2.0]) @@ -1022,11 +690,7 @@ def add_bitwise_nbits(tensors: Dict[str, torch.Tensor], bits: int) -> None: def add_shift_nbits(tensors: Dict[str, torch.Tensor], bits: int) -> None: - """Add N-bit shift circuits (SHL, SHR by 1 position). - - SHL: out[i] = in[i+1] for i0 - """ + """Add N-bit shift circuits (SHL, SHR by 1 position).""" for bit in range(bits): if bit < bits - 1: add_gate(tensors, f"alu.alu{bits}bit.shl.bit{bit}", [1.0], [-1.0]) @@ -1067,19 +731,7 @@ def add_neg_nbits(tensors: Dict[str, torch.Tensor], bits: int) -> None: def add_float16_core(tensors: Dict[str, torch.Tensor]) -> None: - """Add float16 core circuits (unpack, pack, classify). - - IEEE 754 half-precision format (16 bits): - - Bit 15: Sign (0=positive, 1=negative) - - Bits 14-10: Exponent (5 bits, bias=15) - - Bits 9-0: Mantissa/fraction (10 bits, implicit leading 1 for normalized) - - Special values: - - Zero: exp=0, frac=0 - - Subnormal: exp=0, frac≠0 - - Infinity: exp=31, frac=0 - - NaN: exp=31, frac≠0 - """ + """Add float16 core circuits (unpack, pack, classify).""" prefix = "float16" # Identity buffers: H(x - 1) reproduces a binary input (bias 0 would @@ -1103,28 +755,7 @@ def add_float16_core(tensors: Dict[str, torch.Tensor]) -> None: def add_float_add(tensors: Dict[str, torch.Tensor], family: str, exp_bits: int, frac_bits: int) -> None: - """Add a complete float addition/subtraction pipeline. - - Self-contained circuit; external inputs are the raw operand words - $a[0..W-1] / $b[0..W-1] (MSB-first). Contract: exact IEEE specials - (NaN, infinities, signed zeros, opposite-sign infinities -> NaN, exact - cancellation -> +0), subnormal operands and gradual-underflow subnormal - results, round-to-nearest-even for the mantissa. A zero operand passes the - other operand through verbatim. - - Datapath: payload comparison picks the larger-magnitude operand, swap - muxes route it to L; the smaller mantissa is aligned right through a - capped barrel shifter over an F+3-bit round+guard frame with a sticky - collector; add and subtract chains run in parallel (the subtract chain - takes the sticky as a borrow-in so its round bit is exact) and the sign - agreement selects; a leading-zero counter drives the left renormalize - barrel; guard/round/sticky then drive a round-to-nearest-even increment - on the fraction with a carry into the exponent; two E+2-bit exponent - chains compute exp_L + 1 - nlz plus the rounding carry; and a one-hot - specials network selects the output word. - - Rounding: round-to-nearest-even (RNE), matching multiply and divide. - """ + """Add a complete float addition/subtraction pipeline.""" E, F = exp_bits, frac_bits P = E + F # payload width FRAME = F + 3 # mantissa + guard + round @@ -1341,21 +972,7 @@ def add_float16_add(tensors: Dict[str, torch.Tensor]) -> None: def add_float_mul(tensors: Dict[str, torch.Tensor], family: str, exp_bits: int, frac_bits: int) -> None: - """Add a complete float multiplication pipeline. - - Self-contained circuit; external inputs are the raw operand words - $a[0..W-1] / $b[0..W-1] (MSB-first). Contract: exact IEEE specials - (NaN, infinities, signed zeros), subnormal operands and gradual-underflow - subnormal results, round-to-nearest-even for the mantissa. - - Datapath: (F+1)x(F+1) partial products accumulated shift-add into the - 2F+2-bit product; a leading-zero count and an E+2-bit exponent chain give - exp_base = exp_a + exp_b - bias and a signed clamp min(nlz, exp_base); the - product is right-shifted by (F+1) - that clamp (one shifter that normalizes - normal results, denormalizes subnormal ones, and drives deeply underflowing - products to zero), then round-to-nearest-even and a one-hot specials network - produce the output word. - """ + """Add a complete float multiplication pipeline.""" E, F = exp_bits, frac_bits prefix = f"{family}.mul" @@ -1498,22 +1115,7 @@ def add_float16_mul(tensors: Dict[str, torch.Tensor]) -> None: def add_float_div(tensors: Dict[str, torch.Tensor], family: str, exp_bits: int, frac_bits: int) -> None: - """Add a complete float division pipeline. - - Self-contained circuit; external inputs are the raw operand words - $a[0..W-1] / $b[0..W-1] (MSB-first). Contract: exact IEEE specials - (NaN, infinities, signed zeros, x/0 -> inf, 0/0 and inf/inf -> NaN), - subnormal operands and gradual-underflow subnormal results, - round-to-nearest-even for the mantissa. - - Datapath: each operand mantissa is pre-normalized (a leading-zero count - drives a left-barrel so the leading 1 sits at bit F, and na = eexp - lz); - F+3 restoring-division stages produce the quotient; a one-bit normalize mux - aligns it on the integer quotient bit; two E+2-bit exponent chains give - er = na - nb + bias - 1 + q0; the aligned mantissa frame is right-shifted by - max(0, 1 - er) to denormalize subnormal quotients; then round-to-nearest-even - and a one-hot specials network produce the output word. - """ + """Add a complete float division pipeline.""" E, F = exp_bits, frac_bits prefix = f"{family}.div" NSTAGE = F + 3 @@ -1686,21 +1288,7 @@ def add_float16_div(tensors: Dict[str, torch.Tensor]) -> None: def add_float_fma(tensors: Dict[str, torch.Tensor], family: str, exp_bits: int, frac_bits: int) -> None: - """Fused multiply-add: round(a*b + c) with a SINGLE rounding (F-extension). - - Self-contained; external inputs are the raw operand words $a/$b/$c - (MSB-first). Contract: exact IEEE specials (NaN, infinities, inf*0 and - inf-inf -> NaN, signed zeros), subnormal operands and gradual-underflow - subnormal results, round-to-nearest-even, single rounding of a*b+c. - - Datapath: each mantissa is pre-normalized (leading 1 at bit F, na=eexp-lz); - the (F+1)x(F+1) product Mp (2F+2 bits) and the addend Mc are right-shifted by - sticky barrel shifters into a common 3(F+1)+4-bit field anchored at - max(product,c) exponent; a signed magnitude add (with a borrow when the - shifted-out subtrahend leaves a tail) forms the sum; a leading-zero count and - normalize give the significand, one round-to-nearest-even rounds it, and a - one-hot specials network packs the result. - """ + """Fused multiply-add: round(a*b + c) with a SINGLE rounding (F-extension).""" E, F = exp_bits, frac_bits W = 1 + E + F p = F + 1 @@ -1981,18 +1569,7 @@ def add_float_fma(tensors: Dict[str, torch.Tensor], family: str, def add_float_cmp(tensors: Dict[str, torch.Tensor], family: str, exp_bits: int, frac_bits: int) -> None: - """Add a complete IEEE 754 comparison network (EQ, LT, LE, GT, GE). - - Self-contained circuit: external inputs are the raw operand words - $a[0..W-1], $b[0..W-1] (MSB-first; bit 0 is the sign, bits 1..E the - exponent, the rest the fraction). Composition: - - 1. NaN detection gates every result (any comparison with NaN is false). - 2. both_zero makes +0 == -0 (and suppresses the mixed-sign LT/GT). - 3. Same signs: IEEE encodings order like sign-magnitude integers, so the - payload magnitude comparator decides, inverted for negatives. - 4. Different signs: the negative operand is smaller (unless both zero). - """ + """Add a complete IEEE 754 comparison network (EQ, LT, LE, GT, GE).""" prefix = f"{family}.cmp" payload = exp_bits + frac_bits @@ -2080,13 +1657,7 @@ def add_float16_cmp(tensors: Dict[str, torch.Tensor]) -> None: def add_float32_core(tensors: Dict[str, torch.Tensor]) -> None: - """Add float32 core circuits (unpack, pack, classify). - - IEEE 754 single-precision format (32 bits): - - Bit 31: Sign - - Bits 30-23: Exponent (8 bits, bias=127) - - Bits 22-0: Mantissa (23 bits, implicit leading 1) - """ + """Add float32 core circuits (unpack, pack, classify).""" prefix = "float32" # Identity buffers: H(x - 1) reproduces a binary input (bias 0 would @@ -2125,13 +1696,7 @@ def add_float32_div(tensors: Dict[str, torch.Tensor]) -> None: add_float_div(tensors, "float32", 8, 23) def update_manifest(tensors: Dict[str, torch.Tensor], data_bits: int, addr_bits: int, mem_bytes: int) -> None: - """Update manifest metadata tensors. - - Args: - data_bits: ALU/register width (8/16/32) - addr_bits: Address bus width (determines memory size) - mem_bytes: Memory size in bytes (2^addr_bits) - """ + """Update manifest metadata tensors.""" tensors["manifest.data_bits"] = torch.tensor([float(data_bits)], dtype=torch.float32) tensors["manifest.addr_bits"] = torch.tensor([float(addr_bits)], dtype=torch.float32) tensors["manifest.memory_bytes"] = torch.tensor([float(mem_bytes)], dtype=torch.float32) @@ -2228,16 +1793,7 @@ def infer_ripplecarry_inputs(gate: str, prefix: str, bits: int, reg: SignalRegis def infer_expr_add_mul_inputs(gate: str, reg: SignalRegistry) -> List[int]: - """Infer inputs for A + B × C expression circuit (order of operations). - - Circuit structure: - - Mask stage: mask.s[stage].b[bit] = B[bit] AND C[stage] - - Accumulator stages 1-7: acc.s[stage] = acc.s[stage-1] + (mask.s[stage] << stage) - - Final add: result = A + acc.s7 - - Bit ordering: MSB-first externally, LSB-first internally (fa0 = LSB, fa7 = MSB) - - $x[7] = bit 0 (LSB), $x[0] = bit 7 (MSB) - """ + """Infer inputs for A + B × C expression circuit (order of operations).""" prefix = "arithmetic.expr_add_mul" # Register all inputs @@ -2348,15 +1904,7 @@ def infer_expr_add_mul_inputs(gate: str, reg: SignalRegistry) -> List[int]: def infer_expr_paren_add_mul_inputs(gate: str, reg: SignalRegistry) -> List[int]: - """Infer inputs for (A + B) × C expression circuit (parenthetical override). - - Circuit structure: - - Add stage: sum = A + B - - Mask stage: mask.s[stage].b[bit] = sum[bit] AND C[stage] - - Accumulator stages 1-7: acc.s[stage] = acc.s[stage-1] + (mask.s[stage] << stage) - - Bit ordering: MSB-first externally, LSB-first internally (fa0 = LSB, fa7 = MSB) - """ + """Infer inputs for (A + B) × C expression circuit (parenthetical override).""" prefix = "arithmetic.expr_paren_add_mul" # Register all inputs @@ -2463,15 +2011,7 @@ def infer_expr_paren_add_mul_inputs(gate: str, reg: SignalRegistry) -> List[int] def infer_expr_paren_inputs(gate: str, reg: SignalRegistry) -> List[int]: - """Infer inputs for (A + B) × C expression circuit (parenthetical grouping). - - Circuit structure: - - Add stage: sum = A + B - - Mask stage: mask.s[stage].b[bit] = sum[bit] AND C[stage] - - Accumulator stages 1-7: acc.s[stage] = acc.s[stage-1] + (mask.s[stage] << stage) - - Bit ordering: MSB-first externally, LSB-first internally (fa0 = LSB, fa7 = MSB) - """ + """Infer inputs for (A + B) × C expression circuit (parenthetical grouping).""" prefix = "arithmetic.expr_paren" # Register all inputs @@ -3117,11 +2657,7 @@ def infer_combinational_inputs(gate: str, reg: SignalRegistry, tensors: Dict[str def _infer_bit_cascade(gate: str, reg: SignalRegistry, cmp_prefix: str, a_template: str, b_template: str) -> List[int] | None: - """If `gate` is part of the bit-cascade comparator at `cmp_prefix`, return - its inputs. `a_template` / `b_template` are format strings like - "$a[{}]" / "$b[{}]" that produce the per-bit input signal names. Returns - None if `gate` isn't a member of this cascade. - """ + """If `gate` is part of the bit-cascade comparator at `cmp_prefix`, return its inputs.""" if not gate.startswith(cmp_prefix + "."): return None suffix = gate[len(cmp_prefix) + 1:] @@ -3161,10 +2697,7 @@ def _infer_bit_cascade(gate: str, reg: SignalRegistry, cmp_prefix: str, def _infer_compare_final(gate: str, reg: SignalRegistry, cmp_prefix: str, out_gt: str, out_lt: str, out_ge: str, out_le: str, out_eq: str, bits: int) -> List[int] | None: - """Inputs for the final outputs of an add_bit_cascade_compare emit: - out_gt/out_lt are N-input ORs; out_eq is N-input AND; ge/le are - NOT-then-buffer chains over lt/gt. - """ + """Inputs of the final outputs of a bit-cascade comparator.""" if gate == out_gt: return ([reg.register(f"{cmp_prefix}.bit0.gt")] + [reg.register(f"{cmp_prefix}.cascade.gt.bit{i}") for i in range(1, bits)]) @@ -3186,12 +2719,7 @@ def _infer_compare_final(gate: str, reg: SignalRegistry, cmp_prefix: str, def infer_float_cmp_inputs(gate: str, reg: SignalRegistry, family: str, exp_bits: int, frac_bits: int) -> Optional[List[int]]: - """Complete wiring for the self-contained float comparison network. - - External inputs are the raw operand words $a[0..W-1] / $b[0..W-1], - MSB-first: bit 0 is the sign, bits 1..E the exponent, the rest the - fraction. Field extraction is input wiring, not gates. - """ + """Complete wiring for the self-contained float comparison network.""" prefix = f"{family}.cmp" if gate != prefix and not gate.startswith(prefix + "."): return None @@ -3306,8 +2834,7 @@ def infer_float_cmp_inputs(gate: str, reg: SignalRegistry, family: str, def _fa_member_inputs(sub: str, fa_prefix: str, a_sig: int, b_sig: int, cin_sig: int, reg: SignalRegistry) -> Optional[List[int]]: - """Wiring for one gate of an add_full_adder cell, given the cell's - operand/carry signal IDs. `sub` is the gate suffix inside the cell.""" + """Wiring for one gate of an add_full_adder cell, given the cell's operand/carry signal IDs.""" if sub == "ha1.sum.layer1.or" or sub == "ha1.sum.layer1.nand": return [a_sig, b_sig] if sub == "ha1.sum.layer2": @@ -3330,13 +2857,7 @@ def _fa_member_inputs(sub: str, fa_prefix: str, a_sig: int, b_sig: int, def infer_float_mul_inputs(gate: str, reg: SignalRegistry, family: str, exp_bits: int, frac_bits: int) -> Optional[List[int]]: - """Complete wiring for the self-contained float multiplication pipeline. - - Operand words $a/$b are MSB-first. Internally the mantissa and exponent - signals are LSB-first: mantissa bit k of `a` is $a[W-1-k] for k < F with - the implicit leading one (index F) wired to #1; exponent bit k is - $a[E-k]. The accumulated product bit k is acc.s{F-1}.fa{k}'s sum. - """ + """Complete wiring for the self-contained float multiplication pipeline.""" E, F = exp_bits, frac_bits W = 1 + E + F prefix = f"{family}.mul" @@ -3613,14 +3134,7 @@ def infer_float_mul_inputs(gate: str, reg: SignalRegistry, family: str, def infer_float_div_inputs(gate: str, reg: SignalRegistry, family: str, exp_bits: int, frac_bits: int) -> Optional[List[int]]: - """Complete wiring for the self-contained float division pipeline. - - Stage 0 compares the dividend mantissa against the divisor directly; - every later stage shifts the previous remainder left by one (the - shifted-out top bit forces the quotient bit through the stage OR) and - conditionally subtracts. The quotient bit of stage s is - mant_div.stage{s}.q; the remainder bits are the stage mux outputs. - """ + """Complete wiring for the self-contained float division pipeline.""" E, F = exp_bits, frac_bits W = 1 + E + F prefix = f"{family}.div" @@ -5009,15 +4523,7 @@ def infer_float_add_inputs(gate: str, reg: SignalRegistry, family: str, def add_subleq_core(tensors: Dict[str, torch.Tensor]) -> None: - """Add the SUBLEQ one-instruction datapath. - - The machine: M[B] -= M[A]; branch to C when the result is <= 0 (two's - complement). External inputs, LSB-first: $a (subtrahend M[A]), $b - (minuend M[B]), $pc (current PC), $c (branch target). Outputs: the new - M[B] on sub.fa{k} sums, and the next PC on pc_mux.bit{k}.or. The one - architectural decision -- branch or fall through -- is the single - threshold neuron subleq.leq over (sign, is-zero). - """ + """Add the SUBLEQ one-instruction datapath.""" for k in range(8): add_gate(tensors, f"subleq.sub.not_a.bit{k}", [-1.0], [0.0]) add_full_adder(tensors, f"subleq.sub.fa{k}") @@ -5085,12 +4591,7 @@ def infer_subleq_inputs(gate: str, reg: SignalRegistry) -> Optional[List[int]]: def infer_rv32_inputs(gate: str, reg: SignalRegistry) -> Optional[List[int]]: - """Complete wiring for the rv32-specific sub-circuits: the 64-bit MULH - accumulator (fed by the shared 32-bit partial products), the SRA select - muxes, the NEUR neuron datapath, and the dual-issue hazard comparators. - All operand-side ports are external ($rv32_...); internal chains resolve - to gate outputs, so each sub-circuit's netlist is recoverable in full. - """ + """Wiring of the RV32 sub-circuits: MULH accumulator, SRA muxes, NEUR and hazard comparators.""" if not gate.startswith("rv32."): return None @@ -5244,10 +4745,7 @@ def infer_rv32_inputs(gate: str, reg: SignalRegistry) -> Optional[List[int]]: def _infer_threshold_computer_bit_cascade(gate: str, reg: SignalRegistry) -> List[int] | None: - """Match every bit-cascade location used in this codebase: integer - comparators, integer division stage cmps, float magnitude comparators, - float exp_cmp, and float division mantissa cmps. - """ + """Match every bit-cascade comparator location in the codebase.""" # Integer arithmetic.cmp{N}bit.* + the public greaterthan/lessthan/etc. m = re.match(r"^arithmetic\.cmp(\d+)bit\..+", gate) if m: @@ -5315,16 +4813,7 @@ _ROUTING_TABLE_CACHE: Dict[str, Any] | None = None def _load_routing_table() -> Dict[str, Any]: - """Load `routing/routing.json` once; on missing file, return {}. - - The JSON maps each top-level circuit name (e.g. ``arithmetic.multiplier8x8``) - to a dict with at least ``inputs`` (top-level external ports) and - ``internal`` (gate-suffix -> list-of-source-signal-name). The source - signal names are either external port references (``$a[3]``), constants - (``#0`` / ``#1``), or relative gate names within the same circuit - (e.g. ``pp.r0.c3``). Anchored cross-circuit references are not - expressed here; circuits in the table are assumed self-contained. - """ + """Load `routing/routing.json` once; on missing file, return {}.""" global _ROUTING_TABLE_CACHE if _ROUTING_TABLE_CACHE is not None: return _ROUTING_TABLE_CACHE @@ -5342,16 +4831,7 @@ def _infer_from_routing_table( gate: str, reg: SignalRegistry, known_gate_suffixes: Optional[Set[str]] = None, ) -> Optional[List[int]]: - """Resolve a gate's inputs by consulting routing.json. - - ``known_gate_suffixes`` is the set of gate-name suffixes that actually - exist in the loaded safetensors (within the same circuit prefix). - When the routing JSON names a producer like ``stage0.bit0.ha2.sum`` - but that suffix isn't a real gate (only ``stage0.bit0.ha2.sum.layer2`` - is), this set is consulted to redirect the reference to the canonical - final-layer signal. Returns None when the gate's circuit isn't - covered or its suffix isn't in the routing's ``internal`` map. - """ + """Resolve a gate's inputs by consulting routing.json.""" table = _load_routing_table() if not table: return None @@ -5451,7 +4931,7 @@ def infer_inputs_for_gate(gate: str, reg: SignalRegistry, tensors: Dict[str, tor return fc # Bit-cascade comparators (integer + float + div) and ternary modular detectors. # These are the gates emitted by add_bit_cascade_compare and the ternary - # modular rebuild in quantize.py; cover them up front so they don't fall + # modular rebuild in the quantize command; cover them up front so they don't fall # through to the generic per-family handlers below. bc = _infer_threshold_computer_bit_cascade(gate, reg) if bc is not None: @@ -6121,8 +5601,7 @@ def cmd_alu(args) -> None: def cmd_subleq(args) -> None: - """Build the standalone SUBLEQ-8 machine file from scratch: 256 B of - packed memory circuits plus the one-instruction datapath, fully wired.""" + """Build the standalone SUBLEQ-8 machine file: 256 bytes of packed memory and the datapath.""" out = args.model if args.model else MODEL_DIR / "variants" / "neural_subleq8.safetensors" print("=" * 60) print(" BUILD SUBLEQ-8 (one-instruction threshold machine)") @@ -6153,10 +5632,7 @@ def cmd_subleq(args) -> None: def add_rv32_extras(tensors: Dict[str, torch.Tensor]) -> None: - """RV32-specific circuits beyond the shared families: the full 64-bit - product accumulator for MULH*, arithmetic-right-shift select muxes, - the NEUR neuron-evaluation datapath, and dual-issue hazard comparators. - """ + """RV32 circuits beyond the shared families: MULH accumulator, SRA muxes, NEUR, hazard comparators.""" # 64-bit shift-add accumulator over the 32x32 partial products. for stage in range(31): for bit in range(64): @@ -6195,9 +5671,11 @@ def add_rv32_extras(tensors: Dict[str, torch.Tensor]) -> None: RV32_OPCLASS = {0x33: "op", 0x13: "op_imm", 0x03: "load", 0x23: "store", 0x63: "branch", 0x37: "lui", 0x17: "auipc", 0x6F: "jal", 0x67: "jalr", 0x73: "system", 0x53: "fp", 0x07: "flw", 0x27: "fsw"} + # Which opcode classes select each immediate format. RV32_USE = {"I": ["op_imm", "load", "jalr", "flw"], "S": ["store", "fsw"], "B": ["branch"], "U": ["lui", "auipc"], "J": ["jal"]} + RV32_IMM_FMTS = ("I", "S", "B", "U", "J") @@ -6235,10 +5713,7 @@ def _rv32_imm_src(fmt: str, k: int): def add_rv32_decode(tensors: Dict[str, torch.Tensor]) -> None: - """Instruction decode as threshold gates: opcode-class one-hots (an exact - 7-bit match on the instruction's opcode field) and the sign-extended - immediate for the active format, muxed from the raw instruction word. The - runtime reads these gate outputs instead of slicing the word in Python.""" + """RV32 instruction decode: opcode-class one-hots and the sign-extended immediate.""" for opc, name in RV32_OPCLASS.items(): w = [1.0 if (opc >> j) & 1 else -1.0 for j in range(7)] add_gate(tensors, f"rv32.decode.is_{name}", w, [-float(bin(opc).count("1"))]) @@ -6254,10 +5729,7 @@ def add_rv32_decode(tensors: Dict[str, torch.Tensor]) -> None: def add_rv32_pcnext(tensors: Dict[str, torch.Tensor]) -> None: - """PC sequencing as threshold gates: a two-level mux selecting the next PC - among PC+4, PC+imm (branch/jal), and (rs1+imm)&~1 (jalr). The three - candidates are the gate adder's outputs; this network chooses between them - from the decode's control signals rather than a Python if/elif.""" + """RV32 next-PC selection among PC+4, PC+imm and (rs1+imm)&~1.""" for k in range(32): # m1 = sel_target ? pcimm : pc4 add_gate(tensors, f"rv32.pcnext.m1.bit{k}.not_sel", [-1.0], [0.0]) @@ -6272,9 +5744,7 @@ def add_rv32_pcnext(tensors: Dict[str, torch.Tensor]) -> None: def cmd_rv32(args) -> None: - """Build the standalone RV32 threshold processor file from scratch: - 64 KB packed memory, the shared 32-bit circuit families, the composed - float32 pipelines as the FPU, and the rv32-specific extras.""" + """Build the standalone RV32 processor file: 64 KB of memory, the shared families and the FPU.""" out = args.model if args.model else MODEL_DIR / "variants" / "neural_rv32.safetensors" print("=" * 60) print(" BUILD NEURAL_RV32 (RV32IM + F-subset threshold processor)") @@ -6458,6 +5928,528 @@ Usage (note: options must come BEFORE subcommand): else: parser.print_help() +DTYPES = [ + (torch.int8, -(1 << 7), (1 << 7) - 1), + (torch.int16, -(1 << 15), (1 << 15) - 1), + (torch.int32, -(1 << 31), (1 << 31) - 1), + (torch.int64, None, None), # always fits +] + + +def _normalize_to_int(tensor: torch.Tensor) -> torch.Tensor: + """Return a tensor with strictly integer values, floored from any half-integer values.""" + if not tensor.dtype.is_floating_point: + return tensor.to(torch.float64) # promote for range checks + tf = tensor.to(torch.float64) + rounded = tf.round() + if torch.equal(rounded, tf): + return tf + doubled = tf * 2.0 + if torch.equal(doubled.round(), doubled): + return torch.floor(tf) + raise ValueError( + f"tensor has non-half-integer values; range " + f"[{tf.min().item()}, {tf.max().item()}]" + ) + + +def _min_signed_int_dtype(tensor: torch.Tensor) -> torch.dtype: + if tensor.numel() == 0: + return torch.int8 + lo = int(tensor.min().item()) + hi = int(tensor.max().item()) + for dtype, lo_lim, hi_lim in DTYPES: + if lo_lim is None or (lo_lim <= lo and hi <= hi_lim): + return dtype + return torch.int64 + + +def _ternarize_modular_and_patterns( + tensors: Dict[str, torch.Tensor], +) -> Tuple[Dict[str, torch.Tensor], Dict]: + """Replace the seed file's modular detectors with bit-cascade equivalents.""" + new_tensors = dict(tensors) + + # --- pattern_recognition: drop leadingones/trailingones --- + pr_dropped = 0 + for k in list(new_tensors.keys()): + if (k.startswith("pattern_recognition.leadingones") + or k.startswith("pattern_recognition.trailingones")): + del new_tensors[k] + pr_dropped += 1 + + # --- modular: rebuild as ternary bit-cascade equality per multiple --- + moduli = [3, 5, 6, 7, 9, 10, 11, 12] + mod_gates_added = 0 + for mod in moduli: + prefix = f"modular.mod{mod}" + if f"{prefix}.eq.k0.all.weight" in new_tensors: + continue # already bit-cascade; rebuilding would only shed metadata + # Drop old structure + for k in list(new_tensors.keys()): + if k.startswith(prefix + "."): + del new_tensors[k] + + multiples = list(range(0, 256, mod)) + # Per-bit match gates + per-multiple AND + for k in multiples: + for i in range(8): # i=0 is MSB (matches inputs MSB-first ordering) + k_bit = (k >> (7 - i)) & 1 + if k_bit == 1: + # bit_match = x[i]: H(x - 0.5) ~~ identity for binary; + # use weight=1, bias=-1 -> H(x-1): x=0 -> 0, x=1 -> 1. + new_tensors[f"{prefix}.eq.k{k}.bit{i}.match.weight"] = torch.tensor([1.0], dtype=torch.float64) + new_tensors[f"{prefix}.eq.k{k}.bit{i}.match.bias"] = torch.tensor([-1.0], dtype=torch.float64) + else: + # bit_match = NOT x[i]: weight=-1, bias=0 -> H(-x): x=0 -> 1, x=1 -> 0. + new_tensors[f"{prefix}.eq.k{k}.bit{i}.match.weight"] = torch.tensor([-1.0], dtype=torch.float64) + new_tensors[f"{prefix}.eq.k{k}.bit{i}.match.bias"] = torch.tensor([0.0], dtype=torch.float64) + mod_gates_added += 1 + # AND of all 8 bit-matches: weights [1]*8, bias -8 + new_tensors[f"{prefix}.eq.k{k}.all.weight"] = torch.tensor([1.0] * 8, dtype=torch.float64) + new_tensors[f"{prefix}.eq.k{k}.all.bias"] = torch.tensor([-8.0], dtype=torch.float64) + mod_gates_added += 1 + + # Final OR over all per-multiple match outputs + m = len(multiples) + new_tensors[f"{prefix}.weight"] = torch.tensor([1.0] * m, dtype=torch.float64) + new_tensors[f"{prefix}.bias"] = torch.tensor([-1.0], dtype=torch.float64) + mod_gates_added += 1 + + return new_tensors, { + "pattern_recognition_dropped": pr_dropped, + "modular_gates_added": mod_gates_added, + "modular_moduli": len(moduli), + } + + +def _ternarize_buffers( + tensors: Dict[str, torch.Tensor], +) -> Tuple[Dict[str, torch.Tensor], Dict]: + """Rewrite weight +-2 identity buffers as weight +-1 with the bias adjusted.""" + new_tensors = dict(tensors) + fixed = 0 + failed_names = [] + + weight_keys = [k for k in tensors if k.endswith(".weight")] + for wkey in weight_keys: + w = tensors[wkey] + wf = w.float() if w.dtype.is_floating_point else w.to(torch.float64).float() + if (wf.abs() <= 1.0).all(): + continue # already ternary + + gate = wkey[: -len(".weight")] + bkey = gate + ".bias" + + # Single-input weight=+-2 buffer with single bias + if ( + wf.numel() == 1 + and abs(wf.item()) == 2.0 + and bkey in tensors + and tensors[bkey].numel() == 1 + ): + w_val = wf.item() + b_val = float(tensors[bkey].float().item()) + sgn = 1.0 if w_val > 0 else -1.0 + x0_target = 1 if b_val >= 0 else 0 + x1_target = 1 if (w_val + b_val) >= 0 else 0 + chosen = None + # Prefer keeping the bias unchanged when possible + for b_new in [int(b_val), int(b_val) - 1, -1, 0, -2, 1, -3, 2]: + x0 = 1 if b_new >= 0 else 0 + x1 = 1 if (sgn + b_new) >= 0 else 0 + if x0 == x0_target and x1 == x1_target: + chosen = b_new + break + if chosen is not None: + new_tensors[wkey] = torch.tensor([sgn], dtype=torch.float64) + new_tensors[bkey] = torch.tensor([float(chosen)], dtype=torch.float64) + fixed += 1 + continue + + failed_names.append(wkey) + + return new_tensors, {"fixed": fixed, "failed_names": failed_names} + + +def quantize_tensors( + tensors: Dict[str, torch.Tensor], + ternary: bool = False, +) -> Tuple[Dict[str, torch.Tensor], Dict[str, int], Tuple[int, int], Dict]: + """Quantize a dict of tensors.""" + ternary_stats: Dict = {"applied": False, "fixed": 0, "failed_names": []} + if ternary: + tensors, ternary_stats = _ternarize_buffers(tensors) + ternary_stats["applied"] = True + # Also rebuild modular detectors and drop pattern_recognition stragglers. + tensors, mod_stats = _ternarize_modular_and_patterns(tensors) + ternary_stats["modular_gates_added"] = mod_stats["modular_gates_added"] + ternary_stats["pattern_recognition_dropped"] = mod_stats["pattern_recognition_dropped"] + + new_tensors: Dict[str, torch.Tensor] = {} + counts: Dict[str, int] = {"int8": 0, "int16": 0, "int32": 0, "int64": 0, + "manifest_kept": 0, "skipped": 0} + bytes_before = 0 + bytes_after = 0 + + for name, t in tensors.items(): + bytes_before += t.numel() * t.element_size() + + if name.startswith("manifest."): + new_tensors[name] = t + counts["manifest_kept"] += 1 + bytes_after += t.numel() * t.element_size() + continue + + try: + normalized = _normalize_to_int(t) + except ValueError: + new_tensors[name] = t + counts["skipped"] += 1 + bytes_after += t.numel() * t.element_size() + continue + + target = _min_signed_int_dtype(normalized) + cast = normalized.to(target) + new_tensors[name] = cast + bytes_after += cast.numel() * cast.element_size() + counts[str(target).replace("torch.", "")] += 1 + + return new_tensors, counts, (bytes_before, bytes_after), ternary_stats + + +def quantize_file(in_path: Path, out_path: Path, verbose: bool = False, + ternary: bool = False, strict_ternary: bool = False) -> Dict: + file_before = in_path.stat().st_size + tensors: Dict[str, torch.Tensor] = {} + metadata: Dict[str, str] = {} + with safe_open(str(in_path), framework="pt") as f: + meta = f.metadata() + if meta: + metadata = dict(meta) + for name in f.keys(): + # clone so the source mmap can be released before we write + tensors[name] = f.get_tensor(name).clone() + + new_tensors, counts, (before, after), tstats = quantize_tensors(tensors, ternary=ternary) + + # Audit final ternary status (count of weight tensors with |w| > 1) + final_nonternary = [] + for k, v in new_tensors.items(): + if not k.endswith(".weight"): + continue + if k.startswith("manifest."): + continue + vf = v.float() if v.dtype.is_floating_point else v.to(torch.float64).float() + if (vf.abs() > 1.0).any(): + final_nonternary.append(k) + + if ternary and strict_ternary and final_nonternary: + raise ValueError( + f"--strict failed: {len(final_nonternary)} weight tensors are not " + f"ternary after transformation; first: {final_nonternary[:5]}" + ) + + # Drop the original mmap-backed tensors before writing in-place. + del tensors + out_path.parent.mkdir(parents=True, exist_ok=True) + if ternary: + # Note ternary mode in metadata so downstream tools can see it + if metadata is None: + metadata = {} + metadata = dict(metadata) + metadata["weight_quantization"] = ( + "ternary_partial" if final_nonternary else "ternary" + ) + save_file(new_tensors, str(out_path), metadata=metadata or None) + + file_after = out_path.stat().st_size + return { + "in_path": str(in_path), + "out_path": str(out_path), + "tensor_counts": counts, + "tensor_bytes_before": before, + "tensor_bytes_after": after, + "file_size_before": file_before, + "file_size_after": file_after, + "ternary": tstats, + "final_nonternary": final_nonternary, + } + + +def _print_summary(label: str, info: Dict) -> None: + cb = info["tensor_bytes_before"] + ca = info["tensor_bytes_after"] + fb = info["file_size_before"] + fa = info["file_size_after"] + counts = info["tensor_counts"] + bucket_str = " ".join(f"{k}={v}" for k, v in counts.items() if v) + ratio_t = cb / ca if ca else 1.0 + ratio_f = fb / fa if fa else 1.0 + print( + f" {label}: file {fb / 1e6:6.1f} MB -> {fa / 1e6:6.1f} MB " + f"({ratio_f:.2f}x); tensor data {cb / 1e6:6.1f} MB -> {ca / 1e6:6.1f} MB " + f"({ratio_t:.2f}x)" + ) + print(f" {bucket_str}") + if info.get("ternary", {}).get("applied"): + ts = info["ternary"] + nt = info["final_nonternary"] + print(f" ternary: {ts['fixed']} buffer gates rewritten; " + f"{len(nt)} weight tensors remain non-ternary") + + +def main_quantize() -> int: + parser = argparse.ArgumentParser(description="Quantize safetensors to min signed int dtype") + parser.add_argument("input", type=Path, help=".safetensors file or directory of files") + parser.add_argument("-o", "--output", type=Path, default=None, + help="output file or directory (default: in-place)") + parser.add_argument("-v", "--verbose", action="store_true") + parser.add_argument("--ternary", action="store_true", + help="Rewrite single-input weight=+/-2 buffers as +/-1 to push toward " + "ternary {-1, 0, 1} weights and report remaining violations") + parser.add_argument("--strict", action="store_true", + help="With --ternary, fail if any weight tensor is still non-ternary") + parser.add_argument("--report-violations", type=int, default=0, metavar="N", + help="Print first N non-ternary weight tensor names per file") + args = parser.parse_args() + + inputs = [] + if args.input.is_dir(): + inputs = sorted(p for p in args.input.glob("*.safetensors")) + elif args.input.is_file(): + inputs = [args.input] + else: + print(f"not found: {args.input}", file=sys.stderr) + return 2 + + if not inputs: + print(f"no .safetensors files under {args.input}", file=sys.stderr) + return 2 + + if args.output is None: + outputs = inputs # in-place + elif args.output.suffix == ".safetensors": + if len(inputs) != 1: + print("output is a single file but input is a directory; pass a directory output", file=sys.stderr) + return 2 + outputs = [args.output] + else: + args.output.mkdir(parents=True, exist_ok=True) + outputs = [args.output / p.name for p in inputs] + + total_before = 0 + total_after = 0 + print(f"Quantizing {len(inputs)} file(s)" + (" (ternary mode)" if args.ternary else "") + "\n") + for src, dst in zip(inputs, outputs): + info = quantize_file(src, dst, verbose=args.verbose, + ternary=args.ternary, strict_ternary=args.strict) + _print_summary(src.name, info) + if args.report_violations and info.get("final_nonternary"): + for name in info["final_nonternary"][: args.report_violations]: + print(f" non-ternary: {name}") + if len(info["final_nonternary"]) > args.report_violations: + print(f" ... and {len(info['final_nonternary']) - args.report_violations} more") + total_before += info["file_size_before"] + total_after += info["file_size_after"] + + print() + print("=" * 76) + print( + f"Total: {total_before / 1e6:.1f} MB -> {total_after / 1e6:.1f} MB " + f"({total_before / max(total_after, 1):.2f}x reduction)" + ) + return 0 + +ROOT = Path(__file__).resolve().parent.parent + +SEED = ROOT / "neural_computer.safetensors" + +OUT_DIR = ROOT / "variants" + +PROFILES = ["none", "registers", "scratchpad", "small", "reduced", "full"] + +BITS = [8, 16, 32] + + +def variant_filename(bits: int, profile: str) -> str: + if profile == "none": + return f"neural_alu{bits}.safetensors" + if profile == "full": + return f"neural_computer{bits}.safetensors" + return f"neural_computer{bits}_{profile}.safetensors" + + +def run(cmd: list[str], timeout: int = 600) -> tuple[int, str]: + p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + return p.returncode, (p.stdout or "") + (p.stderr or "") + + +def build_variant(bits: int, profile: str) -> Path: + out = OUT_DIR / variant_filename(bits, profile) + shutil.copy2(SEED, out) + cmd = [ + sys.executable, str(ROOT / "src" / "build.py"), + "--bits", str(bits), + "-m", profile, + "--apply", + "--model", str(out), + "all", + ] + rc, log = run(cmd, timeout=2400) + if rc != 0: + raise RuntimeError(f"build failed for bits={bits} profile={profile}:\n{log[-1500:]}") + return out + + +def quantize_variant(path: Path) -> tuple[int, int]: + """Run the quantize command with --ternary --strict on a built variant.""" + rc, log = run([sys.executable, str(ROOT / "src" / "build.py"), "quantize", str(path), + "--ternary", "--strict"], timeout=300) + if rc != 0: + raise RuntimeError(f"quantize failed for {path.name}:\n{log[-800:]}") + # parse the "file X.X MB -> Y.Y MB" line + for line in log.splitlines(): + if "file" in line and "->" in line and path.name in line: + try: + parts = line.split("file")[1].split("->") + before = float(parts[0].strip().split()[0]) * 1e6 + after = float(parts[1].strip().split()[0]) * 1e6 + return int(before), int(after) + except Exception: + pass + return 0, 0 + + +def measure_variant(path: Path) -> dict: + """Read tensor count, params, manifest values from the variant.""" + with safe_open(str(path), framework="pt") as f: + keys = list(f.keys()) + params = sum(f.get_tensor(k).numel() for k in keys) + manifest = { + k.split(".", 1)[1]: f.get_tensor(k).item() + for k in keys if k.startswith("manifest.") and f.get_tensor(k).numel() == 1 + } + return { + "tensors": len(keys), + "params": params, + "size_mb": path.stat().st_size / (1024 * 1024), + "manifest": manifest, + } + + +def eval_variant(path: Path, device: str = "cpu", timeout: int = 600) -> dict: + """Run eval.py against a variant and parse fitness.""" + cmd = [ + sys.executable, str(ROOT / "src" / "eval.py"), + "--model", str(path), + "--device", device, + "--quiet", + ] + t0 = time.time() + rc, log = run(cmd, timeout=timeout) + dt = time.time() - t0 + + fitness = None + total_tests = None + status = "ERROR" + for line in log.splitlines(): + line = line.strip() + if line.startswith("Fitness:"): + try: + fitness = float(line.split()[1]) + except Exception: + pass + elif line.startswith("Total tests:"): + try: + total_tests = int(line.split()[2]) + except Exception: + pass + elif line.startswith("STATUS:"): + status = line.split()[1] + return { + "rc": rc, + "fitness": fitness, + "total_tests": total_tests, + "status": status, + "elapsed_s": dt, + "log_tail": "\n".join(log.splitlines()[-15:]), + } + + +def main_all() -> None: + OUT_DIR.mkdir(exist_ok=True) + rows = [] + print(f"Building 18 variants into {OUT_DIR}\n") + for bits in BITS: + for profile in PROFILES: + label = f"bits={bits} profile={profile}" + print(f"=== {label} ===", flush=True) + t0 = time.time() + try: + path = build_variant(bits, profile) + bt = time.time() - t0 + pre_q_meta = measure_variant(path) + # Quantize in-place as the final step; weights are + # integer-valued so this is exact, --strict fails the build + # if any weight is non-ternary, and header metadata + # (signal_registry) is carried through. + qb, qa = quantize_variant(path) + meta = measure_variant(path) + ev = eval_variant(path, device="cpu", timeout=900) + rows.append({ + "bits": bits, "profile": profile, + "filename": path.name, + "build_s": bt, + **meta, + **{k: ev[k] for k in ("fitness", "total_tests", "status", "elapsed_s")}, + "log_tail": ev["log_tail"] if ev["status"] != "PASS" else "", + }) + q_ratio = qb / qa if qa else 1.0 + print(f" built in {bt:.1f}s size={pre_q_meta['size_mb']:.1f}MB -> " + f"{meta['size_mb']:.1f}MB after quant ({q_ratio:.2f}x)" + f" params={meta['params']:,} tensors={meta['tensors']:,}") + print(f" eval: fitness={ev['fitness']} tests={ev['total_tests']}" + f" status={ev['status']} ({ev['elapsed_s']:.1f}s)") + if ev["status"] != "PASS": + print(" --- failure tail ---") + print(" " + "\n ".join(ev["log_tail"].splitlines())) + print(" --------------------") + except Exception as e: + print(f" EXCEPTION: {e}") + rows.append({"bits": bits, "profile": profile, "error": str(e)}) + print() + + print("=" * 88) + print(" SUMMARY") + print("=" * 88) + header = f"{'bits':>4} {'profile':<11} {'size_MB':>8} {'tensors':>8} {'params':>11} {'fitness':>9} {'tests':>6} {'status':>7}" + print(header) + print("-" * len(header)) + for r in rows: + if "error" in r: + print(f"{r['bits']:>4} {r['profile']:<11} ERROR: {r['error'][:60]}") + continue + fit = f"{r['fitness']:.4f}" if r['fitness'] is not None else "n/a" + tests = r['total_tests'] if r['total_tests'] is not None else "?" + print(f"{r['bits']:>4} {r['profile']:<11} {r['size_mb']:>8.1f} " + f"{r['tensors']:>8,} {r['params']:>11,} " + f"{fit:>9} {tests:>6} {r['status']:>7}") + + fail = [r for r in rows if r.get("status") != "PASS" or "error" in r] + print() + if fail: + print(f"FAILURES: {len(fail)}/{len(rows)}") + else: + print(f"ALL {len(rows)} VARIANTS PASS") + + +COMMANDS = {'build': main, 'quantize': main_quantize, 'variants': main_all} + if __name__ == "__main__": - main() + if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS: + sys.exit(COMMANDS['build']()) + cmd = sys.argv.pop(1) + sys.argv[0] += ' ' + cmd + sys.exit(COMMANDS[cmd]()) diff --git a/src/ca.py b/src/ca.py deleted file mode 100644 index 89217b72c1308cf273ce6494cc5197e9bd15cde0..0000000000000000000000000000000000000000 --- a/src/ca.py +++ /dev/null @@ -1,261 +0,0 @@ -"""Reversible Margolus (partitioned) cellular automaton. - -One fixed rule is applied to every 2x2 block, alternating the block partition -between even and odd alignment each step (the Margolus neighborhood). The state -is the lattice; there is no program counter, register file, or control circuit. - -Rule (cells ordered TL,TR,BL,BR): rotate the block 180 degrees, except a pair of -particles on a diagonal (1001, 0110) swaps to the other diagonal. Both cases are -involutions and neither maps a state across the diagonal-pair boundary, so the -rule is a self-inverse permutation of the sixteen block states; the lattice -update is therefore a bijection and replaying the partition sequence in reverse -inverts the evolution. - -Dynamics are the billiard-ball model's: isolated particles move ballistically on -diagonals and collisions deflect them reversibly. and_gate() computes AND from a -collision; ballistic transport plus this collision are the primitives of the -Fredkin-Toffoli universality construction (Margolus 1984). -""" -from __future__ import annotations -from typing import List, Tuple - -Block = Tuple[int, int, int, int] - - -def rule(b: Block) -> Block: - tl, tr, bl, br = b - if b == (1, 0, 0, 1): - return (0, 1, 1, 0) # diagonal pair -> other diagonal (deflect) - if b == (0, 1, 1, 0): - return (1, 0, 0, 1) - return (br, bl, tr, tl) # otherwise rotate 180 degrees - - -def is_bijection() -> bool: - outs = {rule(tuple((s >> k) & 1 for k in (3, 2, 1, 0))) for s in range(16)} - return len(outs) == 16 - - -def self_inverse() -> bool: - return all(rule(rule(tuple((s >> k) & 1 for k in (3, 2, 1, 0)))) - == tuple((s >> k) & 1 for k in (3, 2, 1, 0)) for s in range(16)) - - -def step(grid: List[List[int]], phase: int) -> List[List[int]]: - """One Margolus update. phase 0 aligns blocks at even coordinates; phase 1 - offsets the partition by (1,1). Toroidal, so H and W must be even.""" - H, W = len(grid), len(grid[0]) - out = [row[:] for row in grid] - o = phase - for r0 in range(o, o + H, 2): - for c0 in range(o, o + W, 2): - r, r1 = r0 % H, (r0 + 1) % H - c, c1 = c0 % W, (c0 + 1) % W - nb = rule((grid[r][c], grid[r][c1], grid[r1][c], grid[r1][c1])) - out[r][c], out[r][c1], out[r1][c], out[r1][c1] = nb - return out - - -def run(grid: List[List[int]], nsteps: int, start_phase: int = 0) -> List[List[int]]: - g = grid - for n in range(nsteps): - g = step(g, (start_phase + n) & 1) - return g - - -def run_back(grid: List[List[int]], nsteps: int, start_phase: int = 0) -> List[List[int]]: - """Undo `run`: replay the phase sequence in reverse; the rule is self-inverse.""" - phases = [(start_phase + n) & 1 for n in range(nsteps)] - g = grid - for p in reversed(phases): - g = step(g, p) - return g - - -# --- the block rule as Heaviside threshold gates --- -# rule(s) = rotate180(s) XOR is_diag(s) on every cell: rotation fixes diagonal -# pairs, and flipping all four cells of a rotated diagonal pair sends it to the -# other diagonal. is_diag detects the two diagonal-pair states. -def _H(x): - return 1 if x >= 0 else 0 - - -def _and(*xs): - return _H(sum(xs) - len(xs)) - - -def _or(*xs): - return _H(sum(xs) - 1) - - -def _xor(a, b): - return _and(_or(a, b), _H(1 - a - b)) # OR AND NAND, the family's XOR - - -def gate_rule(b: Block) -> Block: - tl, tr, bl, br = b - d = _or(_and(tl, 1 - tr, 1 - bl, br), _and(1 - tl, tr, bl, 1 - br)) # is_diag - return (_xor(br, d), _xor(bl, d), _xor(tr, d), _xor(tl, d)) - - -def _test_gates(): - ok = all(gate_rule(tuple((s >> k) & 1 for k in (3, 2, 1, 0))) - == rule(tuple((s >> k) & 1 for k in (3, 2, 1, 0))) for s in range(16)) - print(f" block rule as Heaviside threshold gates matches over 16 states: " - f"{'OK' if ok else 'FAIL'}") - return ok - - -# --- tests --- -def _rand_grid(H, W, seed): - import random - rng = random.Random(seed) - return [[rng.randint(0, 1) for _ in range(W)] for _ in range(H)] - - -def _ball_positions(g): - return {(r, c) for r, row in enumerate(g) for c, v in enumerate(row) if v} - - -def _test_rule(): - print(f" block rule is a bijection of 16 states: {'OK' if is_bijection() else 'FAIL'}") - print(f" block rule is self-inverse: {'OK' if self_inverse() else 'FAIL'}") - return is_bijection() and self_inverse() - - -def _test_reversibility(): - bad = 0 - for seed in range(20): - g = _rand_grid(8, 8, seed) - fwd = run(g, 25, start_phase=0) - back = run_back(fwd, 25, start_phase=0) - if back != g: - bad += 1 - # particle count is conserved (the rule permutes cells within each block) - g = _rand_grid(8, 8, 99) - conserved = sum(sum(r) for r in g) == sum(sum(r) for r in run(g, 40)) - print(f" lattice reversible (run then reverse recovers grid, 20 grids): " - f"{'OK' if bad == 0 else f'FAIL({bad})'}") - print(f" particle number conserved: {'OK' if conserved else 'FAIL'}") - return bad == 0 and conserved - - -def _test_ballistic(): - # a single particle travels in a straight diagonal line - H = W = 16 - g = [[0] * W for _ in range(H)] - g[2][2] = 1 - positions = [next(iter(_ball_positions(g)))] - gg = g - for n in range(8): - gg = step(gg, n & 1) - p = _ball_positions(gg) - positions.append(next(iter(p)) if len(p) == 1 else None) - ok = all(p is not None for p in positions) - steady = ok and all(positions[i + 1] == (positions[i][0] + 1, positions[i][1] + 1) - for i in range(len(positions) - 1)) - print(f" single particle stays a single particle: {'OK' if ok else 'FAIL'}") - print(f" and moves ballistically on the diagonal, +(1,1) per step: " - f"{'OK' if steady else 'FAIL'} trace={positions[:5]}") - return ok and steady - - -def interaction_gate(a: int, b: int) -> dict: - """One billiard-ball collision as a reversible interaction gate. Input - particle A enters at (2,2) moving SE and B at (7,7) moving NW; at step 4 - three output cells carry A&B (the deflected paths), A&~B and ~A&B (the - straight-through paths). AND plus routing by mirrors is functionally - complete for the billiard-ball construction (Margolus 1984).""" - H = W = 12 - g = [[0] * W for _ in range(H)] - if a: - g[2][2] = 1 - if b: - g[7][7] = 1 - g = run(g, 4) - return {"A_and_B": g[3][6], "A_and_notB": g[6][6], "notA_and_B": g[3][3]} - - -def and_gate(a: int, b: int) -> int: - return interaction_gate(a, b)["A_and_B"] - - -def _test_gate(): - ok = True - for a in (0, 1): - for b in (0, 1): - o = interaction_gate(a, b) - ok &= (o["A_and_B"] == (a & b) and o["A_and_notB"] == (a & (1 - b)) - and o["notA_and_B"] == ((1 - a) & b)) - print(f" billiard-ball interaction gate (A&B, A&~B, ~A&B) over all 4 inputs: " - f"{'OK' if ok else 'FAIL'}") - return ok - - -def and3(a: int, b: int, c: int) -> int: - """Two composed collisions. A at (2,2) and B at (7,7) collide into an A&B - particle, which then collides with C launched at (0,9); the output cell - (3,5) at step 4 is occupied iff a, b and c. Composing gates this way builds - arbitrary circuits from the interaction gate.""" - H = W = 16 - g = [[0] * W for _ in range(H)] - if a: - g[2][2] = 1 - if b: - g[7][7] = 1 - if c: - g[0][9] = 1 - return run(g, 4)[3][5] - - -def _test_compose(): - ok = all(and3(a, b, c) == (a & b & c) - for a in (0, 1) for b in (0, 1) for c in (0, 1)) - print(f" composed 3-input AND (two chained collisions) over all 8 inputs: " - f"{'OK' if ok else 'FAIL'}") - return ok - - -def _test_collision(): - # Two particles interact (the joint evolution differs from independent - # motion) and the collision stays reversible: the physics that logic needs. - H = W = 12 - interacted = False - revok = True - # converging pairs: an SE-mover (even,even) meets an NW-mover (odd,odd) on a - # shared diagonal, forming the 1001/0110 diagonal pair the rule deflects. - for a, b in [((2, 2), (7, 7)), ((3, 3), (8, 8)), ((2, 8), (7, 3)), - ((4, 4), (9, 9)), ((2, 2), (9, 9))]: - g = [[0] * W for _ in range(H)] - g[a[0]][a[1]] = 1 - g[b[0]][b[1]] = 1 - ga = [[0] * W for _ in range(H)] - ga[a[0]][a[1]] = 1 - gb = [[0] * W for _ in range(H)] - gb[b[0]][b[1]] = 1 - joint = g - for n in range(12): - joint = step(joint, n & 1) - ga = step(ga, n & 1) - gb = step(gb, n & 1) - free = _ball_positions(ga) | _ball_positions(gb) - if _ball_positions(joint) != free: - interacted = True - if run_back(run(g, 12), 12) != g: - revok = False - print(f" two-particle collisions interact (joint != independent motion): " - f"{'OK' if interacted else 'FAIL'}") - print(f" collisions remain reversible: {'OK' if revok else 'FAIL'}") - return interacted and revok - - -if __name__ == "__main__": - print("Reversible Margolus cellular automaton") - a = _test_rule() - g = _test_gates() - b = _test_reversibility() - c = _test_ballistic() - d = _test_collision() - e = _test_gate() - f = _test_compose() - print("PASS" if (a and g and b and c and d and e and f) else "FAIL") diff --git a/src/canonical.py b/src/canonical.py deleted file mode 100644 index ddd09ad1c30ccbbe76abbe2559e8bb97d827a9d7..0000000000000000000000000000000000000000 --- a/src/canonical.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Deterministic safetensors writer: pins tensor order and metadata key order.""" -import json, struct -import torch - -_DT = {torch.float32:"F32", torch.float64:"F64", torch.int64:"I64", - torch.int32:"I32", torch.int16:"I16", torch.int8:"I8", - torch.uint8:"U8", torch.bool:"BOOL"} - -def canonical_bytes(tensors, metadata_pairs): - """tensors: list of (name, tensor) in canonical order. - metadata_pairs: list of (key, value) in canonical order.""" - header = {} - if metadata_pairs is not None: - header["__metadata__"] = {k: v for k, v in metadata_pairs} - blobs, off = [], 0 - for name, t in tensors: - t = t.contiguous() - b = t.numpy().tobytes() - header[name] = {"dtype": _DT[t.dtype], "shape": list(t.shape), - "data_offsets": [off, off + len(b)]} - blobs.append(b); off += len(b) - s = json.dumps(header, separators=(",", ":"), ensure_ascii=False).encode("utf-8") - pad = (-(8 + len(s))) % 8 - s = s + b" " * pad - return struct.pack(" str: - d = os.path.join(REPO, "paper", "runs") - os.makedirs(d, exist_ok=True) - return os.path.join(d, name) - - -def parse_sigma(data: bytes): - """Biases, predecessor lists and next-state indices from a serialization.""" - hlen = struct.unpack(" 0 else -1) for e in pred[p:p + fanin[j]]])) - p += fanin[j] - return len(nxt), units, nxt - - -def write_flat(path, n, units, nxt): - B, F, P = array("i"), array("i"), array("i") - for b, ins in units: - B.append(b) - F.append(len(ins)) - for k, w in ins: - P.append(k) - P.append(w) - with open(path, "wb") as f: - f.write(struct.pack("= 0] - while stack: - s = stack.pop() - if not live[s]: - live[s] = True - if s >= n: - stack.extend(k for k, _ in units[s - n][1]) - depth = [0] * (n + v) - for j, (_, ins) in enumerate(units): - depth[n + j] = 1 + max((depth[k] for k, _ in ins), default=0) - d = max(depth[n + j] for j in range(v) if live[n + j]) - lam = [0] * (n + v) - for j, (_, ins) in enumerate(units): - if live[n + j]: - for k, _ in ins: - lam[k] = max(lam[k], depth[n + j]) - for s in nxt: - lam[s] = d + 1 - layers = [[("copy", i) for i in range(n)]] - where = [{i: i for i in range(n)}] - for ell in range(1, d): - rows, pos = [], {} - for s in range(n + v): - if s >= n and live[s] and depth[s] == ell: - pos[s] = len(rows); rows.append(("unit", s)) - for s in range(n + v): - if (s < n or live[s]) and depth[s] < ell < lam[s]: - pos[s] = len(rows); rows.append(("copy", s)) - layers.append(rows); where.append(pos) - layers.append([("unit" if depth[s] == d else "copy", s) for s in nxt]) - base, out_units = [0], [] - for ell in range(1, d + 1): - base.append(base[-1] + len(layers[ell - 1])) - for kind, s in layers[ell]: - if kind == "copy": - out_units.append((-1, [(base[ell - 1] + where[ell - 1][s], 1)])) - else: - b, ins = units[s - n] - out_units.append((b, [(base[ell - 1] + where[ell - 1][k], w) for k, w in ins])) - return (n, out_units, [base[d] + i for i in range(n)], - {"depth": d, "rows": sum(len(L) for L in layers[1:]), - "identity_rows": sum(1 for L in layers[1:] for kind, _ in L if kind == "copy"), - "max_width": max(len(L) for L in layers[1:]), - "layer_sizes": [len(L) for L in layers[1:]]}) - - -def framed_parts(s: bytes): - a = struct.unpack(" int: - ap = argparse.ArgumentParser() - ap.add_argument("--cc", default="gcc") - args = ap.parse_args() - t_start = time.perf_counter() - from selfrep import M_P, M_STAR, describe, field, ser, read_host - work = tempfile.mkdtemp(prefix="netlist_eval_") - exe = os.path.join(work, "netlist_eval") - subprocess.run([args.cc, "-O3", "-march=native", "-fopenmp", "-o", exe, - os.path.join(SRC, "netlist_eval.c")], check=True) - J = lambda *p: os.path.join(work, *p) - - def ev(*a): - r = subprocess.run([exe] + [str(x) for x in a], capture_output=True, text=True) - return r.returncode, r.stdout.strip() - - sigma = read_host() - n, units, nxt = parse_sigma(sigma) - write_flat(J("host.bin"), n, units, nxt) - ln, lunits, lnxt, lev_info = levelize(n, units, nxt) - write_flat(J("lev.bin"), ln, lunits, lnxt) - r_H = describe(sigma) - tau = describe(field(sigma) + field(bytes(M_STAR))) - expect_star = ser(sigma, bytes(M_STAR), tau) - for name, b in (("mP", bytes(M_P)), ("mstar", bytes(M_STAR)), ("rH", r_H), ("tau", tau)): - open(J(name), "wb").write(b) - out = {"lev": lev_info} - - with cf.ThreadPoolExecutor(max_workers=4) as ex: - f_rand = ex.submit(ev, "rand", J("host.bin"), 1 << 20, 1) - f_lrand = ex.submit(ev, "rand", J("lev.bin"), 1 << 20, 2) - f_desc = ex.submit(ev, "run", J("host.bin"), J("mP"), J("rH"), J("desc.out"), "lock") - out["netlist_random"] = f_rand.result()[1] - out["lev_random"] = f_lrand.result()[1] - out["self_describing"] = f_desc.result()[1] - # three generations, each rebuilt from the bytes the previous one emitted - gens = [] - net_path, mem_path, tau_path = J("host.bin"), J("mstar"), J("tau") - for g in range(1, 4): - mode = "lock" if g == 1 else "net" - rc, msg = ev("run", net_path, mem_path, tau_path, J(f"gen{g}.out"), mode) - emitted = open(J(f"gen{g}.out"), "rb").read() - gens.append({"generation": g, "mode": mode, "result": msg, - "sha256": hashlib.sha256(emitted).hexdigest(), - "equals_ser": emitted == expect_star}) - s_g, m_g, t_g = framed_parts(emitted) - n2, u2, x2 = parse_sigma(s_g) - net_path = J(f"host_g{g + 1}.bin") - write_flat(net_path, n2, u2, x2) - mem_path, tau_path = J(f"m_g{g + 1}"), J(f"tau_g{g + 1}") - open(mem_path, "wb").write(m_g) - open(tau_path, "wb").write(t_g) - out["generations"] = gens - desc_ok = open(J("desc.out"), "rb").read() == sigma - ok = (out["netlist_random"].endswith("0 halted batches not fixed") - and " 0 disagree" in out["netlist_random"] and " 0 disagree" in out["lev_random"] - and desc_ok and all(g["equals_ser"] for g in gens)) - out["self_describing_equals_sigma"] = desc_ok - out["ok"] = ok - out["seconds"] = time.perf_counter() - t_start - for k in ("netlist_random", "lev_random", "self_describing"): - print(f" {k}: {out[k]}") - for g in gens: - print(f" generation {g['generation']} ({g['mode']}): {g['result']}; equals ser: " - f"{'yes' if g['equals_ser'] else 'NO'}; {g['sha256'][:16]}") - json.dump(out, open(runs_path("paper_c_evaluator.json"), "w"), indent=1) - print("C EVALUATOR:", "PASS" if ok else "FAIL") - return 0 if ok else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/check_codec.py b/src/check_codec.py deleted file mode 100644 index 21559bed7cb51499b2fbd3934534fcf601d43f8f..0000000000000000000000000000000000000000 --- a/src/check_codec.py +++ /dev/null @@ -1,128 +0,0 @@ -"""The recipe codec on every short string and at every boundary of the format. - -Checked for both encoders: the round trip, the length bound of the completeness -lemma, and that the reserved tag 128 is never emitted. The boundaries covered -are the 127-byte literal cap and the run lengths that map to the extreme repeat -tags, together with exhaustive coverage at small lengths. -""" -import itertools -import json -import os -import random -import sys - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from selfrep import describe, describe_literal, decode - -REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - - -def runs_path(name: str) -> str: - d = os.path.join(REPO, "paper", "runs") - os.makedirs(d, exist_ok=True) - return os.path.join(d, name) - - -def bound(n: int) -> int: - return n + -(-n // 127) + 1 - - -def tags(tape: bytes): - i, out = 0, [] - while True: - t = tape[i] - out.append(t) - i += 1 - if t == 0: - return out - i += t if t <= 127 else 1 - - -class Audit: - def __init__(self): - self.n = 0 - self.roundtrip = [] - self.overlong = [] - self.not_tight = [] - self.tag128 = [] - - def check(self, b: bytes) -> None: - self.n += 1 - for enc in (describe, describe_literal): - r = enc(b) - if decode(r) != b: - self.roundtrip.append((enc.__name__, len(b))) - if len(r) > bound(len(b)): - self.overlong.append((enc.__name__, len(b), len(r))) - if len(describe_literal(b)) != bound(len(b)): - self.not_tight.append(len(b)) - if 128 in tags(describe(b)): - self.tag128.append(len(b)) - - @property - def ok(self) -> bool: - return not (self.roundtrip or self.overlong or self.not_tight or self.tag128) - - -def main() -> int: - a = Audit() - for n in range(0, 15): - for t in itertools.product((0, 1), repeat=n): - a.check(bytes(t)) - binary = a.n - for n in range(0, 10): - for t in itertools.product((0, 1, 2), repeat=n): - a.check(bytes(t)) - ternary = a.n - binary - print(f" exhaustive: {binary:,} binary strings to length 14, " - f"{ternary:,} ternary to length 9") - - edges = (list(range(0, 12)) - + [126, 127, 128, 129, 130, 251, 252, 253, 254, 255, 256, 257, - 380, 381, 382, 508, 509, 510]) - before = a.n - for L in edges: - a.check(bytes([0x41]) * L) - a.check(bytes([0x00]) * L) - a.check(bytes([0xFF]) * L) - a.check(bytes(range(256))[:L] if L <= 256 else bytes(range(256)) * (L // 256)) - a.check(bytes([0x41] * L + [0x42])) - a.check(bytes([0x42] + [0x41] * L)) - a.check(bytes([0x41] * L + [0x42] * L)) - a.check(bytes(([0x41] * 4 + [0x42]) * max(1, L))) - a.check(bytes(([0x41] * 3 + [0x42]) * max(1, L))) - print(f" boundaries: {a.n - before:,} strings straddling the literal cap, " - f"the extreme repeat tags, and the run/literal transitions") - - rng = random.Random(11) - before = a.n - for _ in range(4000): - n = rng.randrange(0, 900) - alpha = rng.choice([2, 3, 7, 256]) - a.check(bytes(rng.randrange(alpha) for _ in range(n))) - for _ in range(1500): - n = rng.randrange(0, 900) - out = bytearray() - while len(out) < n: - out += bytes([rng.randrange(256)]) * rng.choice([1, 2, 3, 4, 5, 126, 127, 128, 253]) - a.check(bytes(out[:n])) - print(f" random and run-structured: {a.n - before:,} strings") - - print(f" total {a.n:,}: round-trip exact, neither encoder exceeds " - f"|f| + ceil(|f|/127) + 1, the literal encoder attains it, " - f"tag 128 never emitted: {'yes' if a.ok else 'NO'}") - if not a.ok: - print(" roundtrip:", a.roundtrip[:5]) - print(" overlong:", a.overlong[:5]) - print(" not tight:", a.not_tight[:5]) - print(" tag128:", a.tag128[:5]) - json.dump({"strings": a.n, "roundtrip_failures": len(a.roundtrip), - "bound_failures": len(a.overlong), - "literal_not_tight": len(a.not_tight), - "tag128_emitted": len(a.tag128)}, - open(runs_path("paper_codec.json"), "w"), indent=1) - return 0 if a.ok else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/check_device.py b/src/check_device.py deleted file mode 100644 index 4638f89dd32bc7635047de74c39cecef77bd15e8..0000000000000000000000000000000000000000 --- a/src/check_device.py +++ /dev/null @@ -1,118 +0,0 @@ -"""The device against Definition 2.8 of the paper. - -Definition 2.8 specifies one device application on the -post-instruction state (x, tau, h, omega): - - (i) if y[c_wr] = 1 then omega' = omega . y[R_out] and x'[R_out] = 0, - otherwise omega' = omega; - (ii) if y[c_rd] = 1 and h < |tau| then x'[R_in] = tau[h], x'[c_eot] = 0, - h' = h + 1; if y[c_rd] = 1 and h = |tau| then x'[c_eot] = 1 and h' = h; - if y[c_rd] = 0 then h' = h; - (iii) if y[c_rw] = 1 then h' = 0; - (iv) finally x'[c_rd] = x'[c_rw] = x'[c_wr] = 0. - -The clauses are transcribed here independently of selfrep.Tape and the two are -compared over the whole cross product of request values, head positions and -tapes. The sweep covers non-unit values, so it also decides whether a request -fires on the value 1 alone. -""" -import itertools -import json -import os -import sys - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from selfrep import Tape, C_WR, C_EOT, C_RW, C_RD, R_IN, R_OUT - -REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - - -def runs_path(name: str) -> str: - d = os.path.join(REPO, "paper", "runs") - os.makedirs(d, exist_ok=True) - return os.path.join(d, name) - - -def spec(mem, tau, h, omega): - """Definition 2.8, clause by clause, on (mem, h, omega).""" - m = list(mem) - w = list(omega) - if m[C_WR] == 1: # (i) - w.append(m[R_OUT]) - m[R_OUT] = 0 - hp = h - if m[C_RD] == 1: # (ii) - if h < len(tau): - m[R_IN] = tau[h] - m[C_EOT] = 0 - hp = h + 1 - else: - m[C_EOT] = 1 - if m[C_RW] == 1: # (iii) - hp = 0 - m[C_WR] = m[C_RD] = m[C_RW] = 0 # (iv) - return m, hp, w - - -def main() -> int: - tapes = [b"", b"\x07", b"\x01\x02", bytes(range(5)), bytes(range(64))] - vals = (0, 1, 2, 3, 127, 128, 255) - bad, n = [], 0 - for tau in tapes: - heads = sorted({0, 1, max(0, len(tau) - 1), len(tau)}) - for h in heads: - for wr, rd, rw in itertools.product(vals, repeat=3): - for eot, rin, rout in ((0, 0, 0), (1, 9, 200), (0, 255, 1), (1, 1, 255)): - mem = [0] * 256 - mem[C_WR], mem[C_RD], mem[C_RW] = wr, rd, rw - mem[C_EOT], mem[R_IN], mem[R_OUT] = eot, rin, rout - mem[0x10] = 0xAB # an ordinary cell, must not move - want_mem, want_h, want_out = spec(mem, tau, h, b"") - - dev = Tape(tau) - dev.h = h - got_mem = list(mem) - dev.apply(got_mem) - n += 1 - if (got_mem != want_mem or dev.h != want_h - or bytes(dev.out) != bytes(want_out)): - bad.append({"tau": len(tau), "h": h, "wr": wr, "rd": rd, - "rw": rw, "eot": eot, - "spec": (want_mem[C_EOT], want_mem[R_IN], - want_mem[R_OUT], want_h, list(want_out)), - "impl": (got_mem[C_EOT], got_mem[R_IN], - got_mem[R_OUT], dev.h, list(dev.out))}) - print(f" device applications compared: {n:,}") - print(f" implementation agrees with Definition 2.8 on every one: " - f"{'yes' if not bad else f'NO ({len(bad)} disagreements)'}") - for b in bad[:5]: - print(" ", b) - - # A request fires on the value 1 and on no other value. The head starts off - # zero so that a rewind is observable; without that a rewind of a head - # already at 0 changes nothing and the test would not see it fire. - fires = [] - for cell in (C_WR, C_RD, C_RW): - for v in range(256): - mem = [0] * 256 - mem[cell] = v - mem[R_OUT] = 0x5A - dev = Tape(b"\x11\x22") - dev.h = 1 - before = (dev.h, len(dev.out)) - dev.apply(mem) - acted = (dev.h, len(dev.out)) != before or mem[R_IN] != 0 or mem[C_EOT] != 0 - if acted != (v == 1): - fires.append((cell, v, acted)) - print(f" each request fires exactly on the value 1, over all 256 cell values: " - f"{'yes' if not fires else f'NO {fires[:5]}'}") - - ok = not bad and not fires - json.dump({"applications": n, "disagreements": len(bad), - "value_trigger_failures": len(fires)}, - open(runs_path("paper_device.json"), "w"), indent=1) - return 0 if ok else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/check_environment.py b/src/check_environment.py deleted file mode 100644 index e1b82304c3b871af5daf06143b682a85f73c5d32..0000000000000000000000000000000000000000 --- a/src/check_environment.py +++ /dev/null @@ -1,110 +0,0 @@ -"""The cost of instantiation against the cost of the network. - -One generation of the host is: inst splits the emitted bytes, sigma^-1 parses -the first field into the netlist, Lev places the rows and entries of the -layered map, and the device is applied once per step. This script counts those -operations, one per byte read by inst and by sigma^-1, one per row and one per -nonzero entry placed by Lev, and one per device application, times each of -them, and compares the total with the T * M unit evaluations of the run. -""" -import json -import os -import sys -import time - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - - -def runs_path(name: str) -> str: - d = os.path.join(REPO, "paper", "runs") - os.makedirs(d, exist_ok=True) - return os.path.join(d, name) - - -def main() -> int: - from selfrep import (M_STAR, inst, ser, tau_star, run_reference, read_host, - host_netlist, sigma_host) - from netlist_io import net_of_sigma - from matrix8 import compile_net - - sigma_bytes = read_host() - tau = tau_star(sigma_bytes, M_STAR) - s = ser(sigma_bytes, bytes(M_STAR), tau) - - # sigma: parameters and predecessor entries stored, and the time to write - t0 = time.perf_counter() - net, inputs, outputs = host_netlist() - t_build = time.perf_counter() - t0 - t0 = time.perf_counter() - encoded = sigma_host() - t_sigma = time.perf_counter() - t0 - assert encoded == sigma_bytes - stored, _, _, _ = net_of_sigma(sigma_bytes) - entries = (2 * len(stored.gates) # bias and fan-in - + sum(len(i) for i, _ in stored.gates.values()) - + len(outputs)) - print(f" sigma: {entries:,} stored entries -> {len(encoded):,} bytes " - f"in {t_sigma * 1e3:.0f} ms ({t_build * 1e3:.0f} ms to assemble " - f"the netlist from source)") - - # inst: one pass over the serialized instance, then sigma^-1 on its first field - t0 = time.perf_counter() - reps = 50 - for _ in range(reps): - a, b, c = inst(s) - t_inst = (time.perf_counter() - t0) / reps - assert (a, b, c) == (sigma_bytes, bytes(M_STAR), tau) - t0 = time.perf_counter() - for _ in range(5): - N, I, O, _ = net_of_sigma(a) - t_sigma_inv = (time.perf_counter() - t0) / 5 - assert len(N.gates) == len(stored.gates) - print(f" inst: {len(s):,} bytes parsed in {t_inst * 1e3:.2f} ms, and " - f"{len(N.gates):,} units rebuilt from the first field in " - f"{t_sigma_inv * 1e3:.0f} ms") - - # Lev: the units placed, and the time to place them - t0 = time.perf_counter() - layers, info = compile_net(N, I, O) - t_lev = time.perf_counter() - t0 - M = sum(int(W.shape[0]) for W, _ in layers) - nnz = sum(int((W != 0).sum()) for W, _ in layers) - print(f" Lev: {len(N.gates):,} units in, {M:,} rows and {nnz:,} nonzero " - f"entries placed over {info['layers']} layers in {t_lev:.1f} s") - - # the device: one application per step, at most one byte moved - t0 = time.perf_counter() - emitted, T = run_reference(M_STAR, tau) - t_run = time.perf_counter() - t0 - assert emitted == s - print(f" device: {T:,} applications, {len(emitted):,} bytes emitted " - f"({t_run:.2f} s for the whole reference run)") - - env_ops = len(s) + len(sigma_bytes) + M + nnz + T - closed = 2 * len(sigma_bytes) + len(bytes(M_STAR)) + len(tau) + 16 + M + nnz + T - assert env_ops == closed, (env_ops, closed) - net_ops = T * M - print(f" instantiation: {env_ops:,} elementary operations per generation, " - f"which is the closed form 2|sigma|+|m|+|tau|+16+M+nnz+T") - print(f" network: {net_ops:,} unit evaluations (T = {T:,}, M = {M:,})") - print(f" ratio: {env_ops / net_ops:.3e}") - - out = {"sigma_entries": entries, "sigma_bytes": len(encoded), - "sigma_seconds": t_sigma, "build_seconds": t_build, - "inst_bytes": len(s), "inst_seconds": t_inst, - "sigma_inverse_seconds": t_sigma_inv, - "lev_units_in": len(N.gates), "lev_units_out": M, "lev_nonzero": nnz, - "lev_layers": info["layers"], "lev_seconds": t_lev, - "lev_entries": info["total_weights"], - "steps": T, "emitted_bytes": len(emitted), - "reference_seconds": t_run, - "environment_ops": env_ops, "network_ops": net_ops, - "ratio": env_ops / net_ops} - json.dump(out, open(runs_path("paper_environment.json"), "w"), indent=1) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/check_family.py b/src/check_family.py deleted file mode 100644 index a4085c7a26cc0f7c59d85fb287f68b63ec3f5f09..0000000000000000000000000000000000000000 --- a/src/check_family.py +++ /dev/null @@ -1,64 +0,0 @@ -"""The universal constructor emits every machine of the artifact, byte for byte. - -The machines are run on the integer reference, one process per machine. - - python src/check_family.py [--jobs N] -""" -import argparse, concurrent.futures as cf, hashlib, json, os, sys, time -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from selfrep import describe, run_reference, M_P - -REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - - -def runs_path(name: str) -> str: - d = os.path.join(REPO, "paper", "runs") - os.makedirs(d, exist_ok=True) - return os.path.join(d, name) - - -def files(): - out = [os.path.join(REPO, "neural_computer.safetensors")] - v = os.path.join(REPO, "variants") - out += sorted(os.path.join(v, f) for f in os.listdir(v) if f.endswith(".safetensors")) - return out - - -def one(p): - data = open(p, "rb").read() - r = describe(data) - out, steps = run_reference(M_P, r, expect=data) - return {"file": os.path.basename(p), "bytes": len(out), "recipe": len(r), - "steps": steps, "exact": out == data, - "sha256": hashlib.sha256(out).hexdigest()} - - -def main() -> int: - ap = argparse.ArgumentParser() - ap.add_argument("--jobs", type=int, default=os.cpu_count() or 1) - args = ap.parse_args() - t0 = time.perf_counter() - paths = files() - # the largest machines first, so the pool is not left waiting on one of them - order = sorted(paths, key=os.path.getsize, reverse=True) - with cf.ProcessPoolExecutor(max_workers=args.jobs) as ex: - got = dict(zip(order, ex.map(one, order))) - rec = [got[p] for p in paths] - for r in rec: - print(f" {'OK ' if r['exact'] else 'FAIL'} {r['file']:<44} " - f"{r['bytes']:>10,} B {r['steps']:>12,} steps", flush=True) - bad = sum(not r["exact"] for r in rec) - tot_b = sum(r["bytes"] for r in rec) - tot_s = sum(r["steps"] for r in rec) - tot_r = sum(r["recipe"] for r in rec) - dt = time.perf_counter() - t0 - print(f"{len(rec)} machines, {tot_b:,} bytes emitted in {tot_s:,} steps " - f"from {tot_r:,} bytes of recipe ({dt/60:.1f} min); failures: {bad}") - json.dump({"machines": rec, "bytes": tot_b, "steps": tot_s, - "recipe_bytes": tot_r, "failures": bad, "seconds": dt}, - open(runs_path("paper_runs_family.json"), "w"), indent=1) - return 0 if bad == 0 else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/check_family_gate.py b/src/check_family_gate.py deleted file mode 100644 index 849152a01cb1a924664a4747ce31f3fc63595470..0000000000000000000000000000000000000000 --- a/src/check_family_gate.py +++ /dev/null @@ -1,70 +0,0 @@ -"""The constructor emitting other machines of the family on the netlist. - -check_family.py runs the constructor on all twenty-eight serialized machines, -but on the integer reference. The claim that the netlist emits them is stronger -than the claim that the reference does, so the machines small enough to finish -are run here on the netlist that sigma(N_host) denotes, unit by unit, with each -emitted byte compared against the target as it is produced. -""" -import hashlib -import json -import os -import sys -import time - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from selfrep import NetEvaluator, describe, read_host, run_reference, M_P - -REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -TARGETS = ["neural_tile.safetensors", "neural_ca.safetensors", - "neural_reversible.safetensors", "neural_attractor.safetensors"] - - -def runs_path(name: str) -> str: - d = os.path.join(REPO, "paper", "runs") - os.makedirs(d, exist_ok=True) - return os.path.join(d, name) - - -def main() -> int: - import torch - device = "cuda" if torch.cuda.is_available() else "cpu" - F = NetEvaluator(read_host(), device=device, - graph=device.startswith("cuda")) - rows = [] - bad = 0 - total_b = total_s = 0 - t_all = time.perf_counter() - for name in TARGETS: - path = os.path.join(REPO, "variants", name) - data = open(path, "rb").read() - r = describe(data) - ref_out, ref_steps = run_reference(M_P, r, expect=data) - assert ref_out == data - t0 = time.perf_counter() - out, steps = F.run(M_P, r, max_steps=ref_steps + 8, expect=data, - progress=200000) - dt = time.perf_counter() - t0 - ok = out == data and steps == ref_steps - bad += not ok - total_b += len(out) - total_s += steps - rows.append({"file": name, "bytes": len(out), "recipe": len(r), - "steps": steps, "reference_steps": ref_steps, - "exact": ok, "seconds": dt, - "steps_per_second": steps / dt if dt else None, - "sha256": hashlib.sha256(out).hexdigest()}) - print(f" {'OK ' if ok else 'FAIL'} {name:<34} {len(out):>9,} B " - f"{steps:>10,} steps ({dt / 60:.1f} min, " - f"{steps / dt:,.0f} steps/s)", flush=True) - dt = time.perf_counter() - t_all - print(f" {len(rows)} machines on the netlist of sigma: {total_b:,} bytes in " - f"{total_s:,} steps ({dt / 60:.1f} min); failures: {bad}") - json.dump({"machines": rows, "bytes": total_b, "steps": total_s, - "failures": bad, "seconds": dt}, - open(runs_path("paper_runs_family_gate.json"), "w"), indent=1) - return 0 if bad == 0 else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/check_framing.py b/src/check_framing.py deleted file mode 100644 index fd07a96a02d2e4956b14276d037a44294fe0cccf..0000000000000000000000000000000000000000 --- a/src/check_framing.py +++ /dev/null @@ -1,133 +0,0 @@ -"""The framing maps ser and inst: round trip, injectivity, and rejection. - -Lemma 2.14 of the paper turns on two properties of Definition 2.12: that the -length fields make the decomposition of ser(Omega) unique, so that ser is -injective and inst inverts it, and that inst is partial, rejecting a string that -is not a framed instance, in place of returning some other triple. Both are -checked here on shapes chosen to stress the framing: empty serializations and -tapes, payloads whose leading bytes look like length fields, memory images at -both extremes, and every truncation of a real instance. -""" -import hashlib -import json -import os -import random -import struct -import sys - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from selfrep import ser, inst, lam, HOST_PATH, M_STAR, tau_star - -REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - - -def runs_path(name: str) -> str: - d = os.path.join(REPO, "paper", "runs") - os.makedirs(d, exist_ok=True) - return os.path.join(d, name) - - -def shapes(): - rng = random.Random(5) - host = open(HOST_PATH, "rb").read() - m0 = bytes(256) - m1 = bytes([255]) * 256 - mr = bytes(rng.randrange(256) for _ in range(256)) - # A payload whose first eight bytes are a plausible length field, and a tape - # that begins the way a framed field begins: if inst read a length from the - # wrong offset these would decode to the wrong split. - trap = lam(1 << 40) + b"trap" - out = [ - (b"", m0, b""), - (b"", m0, b"\x00"), - (b"\x00", m0, b""), - (b"\x00" * 3, m1, b""), - (trap, mr, b""), - (b"", mr, trap), - (trap, m0, trap), - (bytes(range(256)), mr, bytes(range(256))), - (host, bytes(M_STAR), tau_star(host, M_STAR)), - (host, m0, b""), - ] - for _ in range(300): - a = bytes(rng.randrange(256) for _ in range(rng.randrange(0, 300))) - t = bytes(rng.randrange(256) for _ in range(rng.randrange(0, 300))) - out.append((a, bytes(rng.randrange(256) for _ in range(256)), t)) - return out - - -def main() -> int: - cases = shapes() - rt_bad, digests = [], {} - for sg, m, t in cases: - b = ser(sg, m, t) - if inst(b) != (sg, m, t): - rt_bad.append((len(sg), len(t))) - digests.setdefault(hashlib.sha256(b).hexdigest(), []).append((len(sg), len(m), len(t))) - collisions = {k: v for k, v in digests.items() if len(v) > 1} - print(f" inst(ser(Omega)) = Omega on {len(cases)} instances: " - f"{'exact' if not rt_bad else f'{len(rt_bad)} FAILED'}") - print(f" ser injective over them (distinct instances, distinct bytes): " - f"{'yes' if not collisions else f'NO {list(collisions.values())[:2]}'}") - - # Truncations. A string shorter than the framed serialization and memory - # image is not an instance and must be rejected; one that reaches at least - # that far is the instance with a correspondingly shorter tape, and must - # parse to exactly that. - host = open(HOST_PATH, "rb").read() - tau = tau_star(host, M_STAR) - full = ser(host, bytes(M_STAR), tau) - minimal = 16 + len(host) + 256 - probes = sorted({p for p in - [0, 1, 7, 8, 9, 15, 16, 17, - 8 + len(host) - 1, 8 + len(host), 8 + len(host) + 7, - 8 + len(host) + 8, minimal - 256, minimal - 1, minimal, - minimal + 1, len(full) - 1, len(full)] - if 0 <= p <= len(full)}) - wrongly_accepted, wrongly_rejected = [], [] - for k in probes: - try: - got = inst(full[:k]) - except (ValueError, IndexError, struct.error): - if k >= minimal: - wrongly_rejected.append(k) - continue - if k < minimal: - wrongly_accepted.append((k, len(got[1]))) - elif got != (host, bytes(M_STAR), full[minimal:k]): - wrongly_accepted.append((k, "wrong triple")) - short = sum(1 for k in probes if k < minimal) - print(f" {short} truncations below a complete memory image rejected: " - f"{'yes' if not wrongly_accepted else f'NO {wrongly_accepted}'}") - print(f" {len(probes) - short} longer prefixes parse to the instance with " - f"that tape: {'yes' if not wrongly_rejected else f'NO {wrongly_rejected}'}") - accepted = wrongly_accepted + wrongly_rejected - - # a memory field of the wrong length must be rejected - wrong = [] - for size in (0, 1, 255, 257, 512): - b = lam(len(host)) + host + lam(size) + bytes(size) + tau - try: - inst(b) - wrong.append(size) - except (ValueError, IndexError, struct.error): - pass - print(f" memory field of length != 256 rejected: " - f"{'yes' if not wrong else f'NO for {wrong}'}") - - # the full instance is accepted and recovers exactly - exact = inst(full) == (host, bytes(M_STAR), tau) - print(f" the distributed instance parses back exactly: {exact}") - - ok = not rt_bad and not collisions and not accepted and not wrong and exact - json.dump({"instances": len(cases), "roundtrip_failures": len(rt_bad), - "digest_collisions": len(collisions), - "truncations_accepted": len(accepted), - "bad_memory_lengths_accepted": len(wrong), - "distributed_instance_exact": exact}, - open(runs_path("paper_framing.json"), "w"), indent=1) - return 0 if ok else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/check_host.py b/src/check_host.py deleted file mode 100644 index 5b5a75ebc9fe27250d70903167cd2b23e4ea835b..0000000000000000000000000000000000000000 --- a/src/check_host.py +++ /dev/null @@ -1,113 +0,0 @@ -"""The reference machine against Definition 3.1, and the halt fixed point. - -Definition 3.1 specifies one SUBLEQ step: read A = M[p], B = M[p+1], C = M[p+2], -set M[B] <- M[B] - M[A] mod 256, and set p <- C when the new M[B] is at most 0 -in two's complement and p <- p + 3 otherwise, with indices taken modulo 256 and -a halt at p = 255. The clauses are transcribed here independently of -selfrep.run_reference and the two are compared, with the program counter swept -over every value so the wrapped fetches at the top of memory are covered. - -Proposition 3.9 asserts in addition that a halted state is a fixed point of the -transition, because every architectural write is gated by the negation of the -halt bit. That is checked on the levelized map itself, not on the reference. -""" -import json -import os -import random -import sys - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from selfrep import Tape, HALT_PC - -REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - - -def runs_path(name: str) -> str: - d = os.path.join(REPO, "paper", "runs") - os.makedirs(d, exist_ok=True) - return os.path.join(d, name) - - -def spec_step(mem, p): - """Definition 3.1, transcribed.""" - A = mem[p % 256] - B = mem[(p + 1) % 256] - C = mem[(p + 2) % 256] - m = list(mem) - r = (m[B % 256] - m[A % 256]) % 256 - m[B % 256] = r - two_c = r - 256 if r >= 128 else r - return m, (C if two_c <= 0 else (p + 3) % 256) - - -def impl_step(mem, p): - """The reference of selfrep.run_reference, one step, device detached.""" - m = list(mem) - A = m[p] - B = m[(p + 1) & 0xFF] - C = m[(p + 2) & 0xFF] - r = (m[B] - m[A]) & 0xFF - m[B] = r - return m, (C if (r == 0 or r >= 0x80) else (p + 3) & 0xFF) - - -def main() -> int: - rng = random.Random(31) - bad, n = [], 0 - mems = [[0] * 256, [255] * 256, list(range(256)), list(reversed(range(256)))] - mems += [[rng.randrange(256) for _ in range(256)] for _ in range(60)] - for mem in mems: - for p in range(256): # every counter, wraps included - if p == HALT_PC: - continue - sm, sp = spec_step(mem, p) - im, ip = impl_step(mem, p) - n += 1 - if sm != im or sp != ip: - bad.append((p, sp, ip)) - print(f" reference steps compared with Definition 3.1: {n:,}") - print(f" agree on every one: {'yes' if not bad else f'NO {bad[:4]}'}") - - wrapped = sum(1 for p in (254, 255, 253) if p != HALT_PC) * len(mems) - print(f" of which {len(mems) * 3:,} fetch an operand across the top of memory") - - # A halted state is a fixed point of the levelized map. The state is halted - # when the halt bit is set, coordinate 8 of the vector; the bit latches one - # step after the next counter decodes to 255, so setting the counter alone - # does not halt the machine. - from selfrep import LevEvaluator, read_host - import torch - L = LevEvaluator(read_host(), device="cpu", dense=False) - gen = torch.Generator().manual_seed(97) - V = (torch.rand(48, L.N, generator=gen) < 0.5).float() - V[:, 8] = 1.0 - fixed = bool((L.step(V) == V).all()) - print(f" halted states are fixed points of Lev(N_host), 48 random states: " - f"{'yes' if fixed else 'NO'}") - - # And the bit latches exactly when the next counter is 255: run one step - # from unhalted states and compare the latched bit against the reference. - U = (torch.rand(64, L.N, generator=gen) < 0.5).float() - U[:, 8] = 0.0 - W = L.step(U) - latch_bad = 0 - for i in range(U.shape[0]): - mem = [int(sum(int(U[i, 9 + j * 8 + k]) << (7 - k) for k in range(8))) - for j in range(256)] - pc = int(sum(int(U[i, k]) << (7 - k) for k in range(8))) - _, npc = impl_step(mem, pc) - if int(W[i, 8]) != (1 if npc == HALT_PC else 0): - latch_bad += 1 - print(f" the halt bit latches exactly when the next counter is 255, " - f"64 random states: {'yes' if latch_bad == 0 else f'NO ({latch_bad})'}") - fixed = fixed and latch_bad == 0 - - ok = not bad and fixed - json.dump({"steps_compared": n, "disagreements": len(bad), - "halt_fixed_point": fixed}, - open(runs_path("paper_host.json"), "w"), indent=1) - return 0 if ok else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/check_host_family.py b/src/check_host_family.py deleted file mode 100644 index 7530c17dd4b5b90a359af288b47924e0d9a66e54..0000000000000000000000000000000000000000 --- a/src/check_host_family.py +++ /dev/null @@ -1,65 +0,0 @@ -"""The host family on the evaluator in C: one step of H_A against SUBLEQ_A. - -For each width A the serialization of H_A is written by host_family.sigma, -parsed with the standard library, written in the flat form of -src/netlist_eval.c, and one step of the netlist is compared with the C -evaluator's own SUBLEQ on uniformly random full states, together with the -halted fixed point. - - python src/check_host_family.py [--widths 3-12] [--states N] -""" -import argparse -import json -import os -import subprocess -import sys -import tempfile -import time - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - - -def runs_path(name: str) -> str: - d = os.path.join(REPO, "paper", "runs") - os.makedirs(d, exist_ok=True) - return os.path.join(d, name) - - -def main() -> int: - ap = argparse.ArgumentParser() - ap.add_argument("--widths", default="3-12") - ap.add_argument("--states", type=int, default=1 << 16) - ap.add_argument("--cc", default="gcc") - args = ap.parse_args() - lo, hi = (int(x) for x in args.widths.split("-")) - from host_family import sigma - from check_c import parse_sigma, write_flat - work = tempfile.mkdtemp(prefix="host_family_") - exe = os.path.join(work, "netlist_eval") - subprocess.run([args.cc, "-O3", "-march=native", "-fopenmp", "-o", exe, - os.path.join(REPO, "src", "netlist_eval.c")], check=True) - rows, ok = [], True - for A in range(lo, hi + 1): - t0 = time.perf_counter() - s = sigma(A) - n, units, nxt = parse_sigma(s) - path = os.path.join(work, f"h{A}.bin") - write_flat(path, n, units, nxt) - N = max(64, args.states >> max(0, A - 10)) - r = subprocess.run([exe, "rand", path, str(N), str(A)], capture_output=True, text=True) - line = r.stdout.strip() - good = r.returncode == 0 - ok &= good - rows.append({"A": A, "sigma_bytes": len(s), "units": len(units), - "edges": sum(len(i) for _, i in units), "states": N, - "result": line, "ok": good, "seconds": time.perf_counter() - t0}) - print(f" A={A}: {len(units):,} units, {len(s):,} bytes; {line}", flush=True) - os.remove(path) - json.dump({"widths": rows, "ok": ok}, open(runs_path("paper_host_family.json"), "w"), indent=1) - print("HOST FAMILY:", "PASS" if ok else "FAIL") - return 0 if ok else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/check_host_fold.py b/src/check_host_fold.py deleted file mode 100644 index da702d70e533f2207dabb9e6dc5a19be54640d53..0000000000000000000000000000000000000000 --- a/src/check_host_fold.py +++ /dev/null @@ -1,73 +0,0 @@ -"""The host family with its decoders folded into the read conjunctions: depth 11. - -Every fetch and read conjunction and every write enable of H_A reads the bits of -its address directly, so a read from memory takes two levels from the address -bits and not three, and the operand fetch and the operand reads each lose one -level. The step of the folded netlist has depth 11 for every A, against 13. It is -written with host_family.build(A, fold=True), serialized, and one step is -compared with the C evaluator's own SUBLEQ on uniformly random full states, as -src/check_host_family.py does for H_A. - - python src/check_host_fold.py [--widths 3-12] [--states N] -""" -import argparse -import json -import os -import subprocess -import sys -import tempfile -import time - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - - -def depth_of(n, units, nxt): - d = [0] * (n + len(units)) - for j, (_, ins) in enumerate(units): - d[n + j] = 1 + max((d[k] for k, _ in ins), default=0) - return max(d[s] for s in nxt) - - -def main() -> int: - ap = argparse.ArgumentParser() - ap.add_argument("--widths", default="3-12") - ap.add_argument("--states", type=int, default=1 << 16) - ap.add_argument("--cc", default="gcc") - args = ap.parse_args() - lo, hi = (int(x) for x in args.widths.split("-")) - from host_family import build, sigma_of_net, sigma - from check_c import parse_sigma, write_flat - work = tempfile.mkdtemp(prefix="host_fold_") - exe = os.path.join(work, "netlist_eval") - subprocess.run([args.cc, "-O3", "-march=native", "-fopenmp", "-o", exe, - os.path.join(REPO, "src", "netlist_eval.c")], check=True) - rows, ok = [], True - for A in range(lo, hi + 1): - t0 = time.perf_counter() - net, inputs, outputs = build(A, fold=True) - s = sigma_of_net(net, inputs, outputs, A) - n, units, nxt = parse_sigma(s) - n0, units0, nxt0 = parse_sigma(sigma(A)) - path = os.path.join(work, f"f{A}.bin") - write_flat(path, n, units, nxt) - N = max(64, args.states >> max(0, A - 10)) - r = subprocess.run([exe, "rand", path, str(N), str(A)], capture_output=True, text=True) - line = r.stdout.strip().splitlines()[-1] if r.stdout.strip() else r.stderr.strip() - good = r.returncode == 0 - ok &= good - row = {"A": A, "units": len(units), "edges": sum(len(i) for _, i in units), - "depth": depth_of(n, units, nxt), "depth_unfolded": depth_of(n0, units0, nxt0), - "units_unfolded": len(units0), "edges_unfolded": sum(len(i) for _, i in units0), - "states": N, "result": line, "agrees": good, "seconds": round(time.perf_counter() - t0, 1)} - rows.append(row) - print(f" A={A}: {row['units']:,} units, {row['edges']:,} edges, depth {row['depth']} " - f"(H_A: {row['units_unfolded']:,}, {row['edges_unfolded']:,}, depth {row['depth_unfolded']}); " - f"{line}", flush=True) - json.dump({"rows": rows, "all_agree": ok}, - open(os.path.join(REPO, "paper", "runs", "paper_host_fold.json"), "w"), indent=1) - return 0 if ok else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/check_lev.py b/src/check_lev.py deleted file mode 100644 index 64f17b1a1e18b94372031700a9785c1048af267c..0000000000000000000000000000000000000000 --- a/src/check_lev.py +++ /dev/null @@ -1,121 +0,0 @@ -"""Lemma 2.6 on the host: the levelized map equals the netlist it came from. - -Lev(N) is asserted to compute, for every state, the next state that N computes. -The netlist here is the one the serialization of the host denotes, evaluated -gate by gate in topological order with no reference to the levelization, and the -two are compared on uniformly random states, which lie off every trajectory of -the constructor. - -The same states carry the structural facts the lemma asserts: every weight of -every layer lies in {-1, 0, 1}, and every pre-activation is an integer, so the -comparator has a margin of one half. -""" -import json -import os -import sys - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -import torch - -REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - - -def runs_path(name: str) -> str: - d = os.path.join(REPO, "paper", "runs") - os.makedirs(d, exist_ok=True) - return os.path.join(d, name) - - -def gate_by_gate(net, inputs, outputs, V): - """Evaluate the netlist directly: topological order, one gate at a time.""" - gates = net.gates - indeg, cons = {}, {} - for g, (ins, _) in gates.items(): - indeg[g] = len([s for s, _ in ins if s in gates]) - for s, _ in ins: - cons.setdefault(s, []).append(g) - order = [g for g, d in indeg.items() if d == 0] - i = 0 - while i < len(order): - for c in cons.get(order[i], []): - indeg[c] -= 1 - if indeg[c] == 0: - order.append(c) - i += 1 - assert len(order) == len(gates) - - B = V.shape[0] - val = {"#0": torch.zeros(B), "#1": torch.ones(B)} - for k, name in enumerate(inputs): - val[name] = V[:, k] - for g in order: - ins, bias = gates[g] - acc = torch.full((B,), float(bias)) - for s, w in ins: - acc = acc + w * val[s] - val[g] = (acc >= 0).float() - return torch.stack([val[o] for o in outputs], dim=1) - - -def main() -> int: - from netlist_io import net_of_sigma - from selfrep import LevEvaluator, NetEvaluator, read_host - - sigma = read_host() - net, inputs, outputs, _ = net_of_sigma(sigma) - L = LevEvaluator(sigma, device="cpu", dense=False) - - gen = torch.Generator().manual_seed(20260910) - B = 96 - V = (torch.rand(B, len(inputs), generator=gen) < 0.5).float() - - direct = gate_by_gate(net, inputs, outputs, V) - levelled = L.step(V) - agree = bool((direct == levelled).all()) - print(f" netlist has {len(net.gates):,} gates on {len(inputs):,} state bits") - print(f" gate-by-gate evaluation equals Lev(N_host) on {B} uniformly random " - f"states: {'yes' if agree else 'NO'}") - if not agree: - d = (direct != levelled).nonzero() - print(" first disagreements:", d[:5].tolist()) - - grouped = NetEvaluator(sigma, device="cpu").step(V) - agree_net = bool((direct == grouped).all()) - print(f" the evaluator that carries only the netlist's own predecessor " - f"entries agrees on the same states: {'yes' if agree_net else 'NO'}") - - # The dense form: ternary weights, integer pre-activations, margin one half. - D = LevEvaluator(sigma, device="cpu", dense=True) - nonternary = [i for i, W in enumerate(D.W) - if not set(torch.unique(W).tolist()) <= {-1.0, 0.0, 1.0}] - nonint = [i for i, b in enumerate(D.B) if not bool((b == b.round()).all())] - print(f" every weight of all {len(D.W)} layers lies in {{-1,0,1}}: " - f"{'yes' if not nonternary else f'NO at {nonternary[:3]}'}") - print(f" every bias is an integer: {'yes' if not nonint else f'NO at {nonint[:3]}'}") - - y, worst, frac = V, float("inf"), 0.0 - for W, b in zip(D.W, D.B): - pre = y @ W.T + b - worst = min(worst, float((pre + 0.5).abs().min())) - frac = max(frac, float((pre - pre.round()).abs().max())) - y = (pre >= 0).float() - same_dense = bool((y == levelled).all()) - print(f" dense stack agrees with the sparse evaluation on the same states: " - f"{'yes' if same_dense else 'NO'}") - print(f" minimum distance of a pre-activation from -1/2 over all " - f"{len(D.W)} layers: {worst:.6f}") - print(f" maximum distance of a pre-activation from an integer: {frac:.3e}") - - ok = (agree and agree_net and same_dense and not nonternary - and not nonint and abs(worst - 0.5) < 1e-9) - json.dump({"states": B, "gates": len(net.gates), "agree": agree, - "agree_grouped": agree_net, - "dense_agree": same_dense, "nonternary_layers": len(nonternary), - "noninteger_bias_layers": len(nonint), - "min_margin": worst, "max_noninteger": frac}, - open(runs_path("paper_lev.json"), "w"), indent=1) - return 0 if ok else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/check_optimal.py b/src/check_optimal.py deleted file mode 100644 index 504c9bc6deb6e8d02808272f761e613ab367961c..0000000000000000000000000000000000000000 --- a/src/check_optimal.py +++ /dev/null @@ -1,153 +0,0 @@ -"""The length bound of the completeness lemma is optimal for the token format. - -A literal token covering c bytes occupies c + 1 bytes and a repeat token -covering a run of c equal bytes occupies 2, so the shortest recipe for a given -output is a shortest path over the positions of that output. The exact optimum -is computed here by that recurrence and compared with |f| + ceil(|f|/127) + 1 -on strings with no two equal adjacent bytes, where no repeat token can cover -more than one byte and the bound is therefore attained by every shortest -recipe, and with the encoders of the artifact on arbitrary strings. -""" -import itertools -import json -import os -import random -import sys - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from selfrep import describe, describe_literal, decode - -REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - - -def runs_path(name: str) -> str: - d = os.path.join(REPO, "paper", "runs") - os.makedirs(d, exist_ok=True) - return os.path.join(d, name) - - -def bound(n: int) -> int: - return n + -(-n // 127) + 1 - - -def shortest(f: bytes) -> int: - """Length of a shortest r in R with delta(r) = f, by dynamic programming. - - cost[i] is the least number of bytes needed to cover f[i:]; the END byte is - added once at the end. A literal token may carry 1..127 bytes at a cost of - one more than it carries; a repeat token carries 1..127 copies of one byte - at a cost of two. - """ - n = len(f) - INF = float("inf") - cost = [INF] * (n + 1) - cost[n] = 0 - for i in range(n - 1, -1, -1): - best = INF - for c in range(1, min(127, n - i) + 1): - v = cost[i + c] + c + 1 # literal token - if v < best: - best = v - run = 0 - while run < min(127, n - i) and f[i + run] == f[i]: - run += 1 - v = cost[i + run] + 2 # repeat token - if v < best: - best = v - cost[i] = best - return cost[0] + 1 - - -def run_free(f: bytes) -> bool: - return all(f[i] != f[i + 1] for i in range(len(f) - 1)) - - -def main() -> int: - out = {} - ok = True - - # Exhaustive over ternary strings with no two equal adjacent bytes. - tested = 0 - bad = [] - for n in range(0, 11): - for t in itertools.product((0, 1, 2), repeat=n): - f = bytes(t) - if not run_free(f): - continue - tested += 1 - s = shortest(f) - if s != bound(n) or len(describe_literal(f)) != bound(n): - bad.append(n) - print(f" every ternary string of length at most 10 with no two equal " - f"adjacent bytes ({tested:,} of them): the shortest recipe has " - f"exactly |f| + ceil(|f|/127) + 1 bytes: " - f"{'yes' if not bad else f'NO at lengths {sorted(set(bad))[:5]}'}") - ok &= not bad - out["run_free_exhaustive"] = tested - out["run_free_failures"] = len(bad) - - # Longer run-free strings over the full alphabet, including the lengths at - # which a further literal token becomes necessary. - rng = random.Random(4) - lens = [0, 1, 126, 127, 128, 253, 254, 255, 380, 381, 508, 509, 700] - lens += [rng.randrange(0, 900) for _ in range(120)] - lbad = [] - for n in lens: - f = bytearray() - prev = -1 - for _ in range(n): - c = rng.randrange(256) - while c == prev: - c = rng.randrange(256) - f.append(c) - prev = c - f = bytes(f) - if shortest(f) != bound(n) or len(describe_literal(f)) != bound(n): - lbad.append(n) - print(f" {len(lens)} further run-free strings, lengths to 900 and at every " - f"multiple of 127: shortest recipe equals the bound: " - f"{'yes' if not lbad else f'NO at {lbad[:5]}'}") - ok &= not lbad - out["run_free_sampled"] = len(lens) - out["run_free_sampled_failures"] = len(lbad) - - # On arbitrary strings the bound is an upper bound only; record how far the - # encoder of the artifact is from the optimum. - gaps = [] - abad = 0 - for _ in range(300): - n = rng.randrange(0, 600) - alpha = rng.choice([1, 2, 3, 9, 256]) - f = bytes(rng.randrange(alpha) for _ in range(n)) - s = shortest(f) - d = len(describe(f)) - if decode(describe(f)) != f or d < s or d > bound(n): - abad += 1 - gaps.append(d - s) - print(f" 300 arbitrary strings: describe is never shorter than the optimum " - f"and never longer than the bound: {'yes' if abad == 0 else 'NO'}; " - f"excess over the optimum: max {max(gaps)}, mean {sum(gaps)/len(gaps):.2f}") - ok &= abad == 0 - out["arbitrary_strings"] = len(gaps) - out["arbitrary_failures"] = abad - out["describe_excess_max"] = max(gaps) - out["describe_excess_mean"] = sum(gaps) / len(gaps) - - # The serialization of the host is not run-free, so its optimum is shorter. - from selfrep import HOST_PATH - sigma = open(HOST_PATH, "rb").read() - s = shortest(sigma) - print(f" sigma(N_host): {len(sigma):,} bytes; bound {bound(len(sigma)):,}, " - f"describe {len(describe(sigma)):,}, shortest recipe {s:,}") - out["sigma_bytes"] = len(sigma) - out["sigma_bound"] = bound(len(sigma)) - out["sigma_describe"] = len(describe(sigma)) - out["sigma_shortest"] = s - ok &= s <= len(describe(sigma)) <= bound(len(sigma)) - - json.dump(out, open(runs_path("paper_optimal.json"), "w"), indent=1) - return 0 if ok else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/check_rocq.py b/src/check_rocq.py deleted file mode 100644 index 223c90057e5916b93b8510be04fc4b2c59bb5f03..0000000000000000000000000000000000000000 --- a/src/check_rocq.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Build the Rocq development, print the assumptions of its main theorems, and compare -the extracted netlist with the generator's. - -The development in rocq/ proves that the two-head netlist computes the step of the -machine from every state at every size, that the organism builds every record image -from any description of it, and that it reproduces in every generation; it also -evaluates three generations in the kernel. Run under an environment that provides -rocq and ocamlfind, for instance `eval $(opam env)`: - - python src/check_rocq.py -""" -import json -import os -import re -import shutil -import subprocess -import sys -import time - -REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -ROCQ = os.path.join(REPO, 'rocq') -RECORD = os.path.join(REPO, 'paper', 'runs', 'paper_rocq.json') - - -def main(): - for tool in ('rocq', 'make', 'ocamlfind'): - if shutil.which(tool) is None: - print(f'{tool} not found on PATH') - return 1 - version = subprocess.run(['rocq', '--version'], capture_output=True, text=True).stdout.strip() - t0 = time.time() - subprocess.run(['rocq', 'makefile', '-f', '_CoqProject', '-o', 'Makefile'], cwd=ROCQ, check=True) - subprocess.run(['make', 'clean'], cwd=ROCQ, check=True, capture_output=True) - jobs = str(os.cpu_count() or 1) - if subprocess.run(['make', '-j', jobs], cwd=ROCQ).returncode != 0: - print('the build failed') - return 1 - build = time.time() - t0 - out = subprocess.run(['rocq', 'compile', '-R', '.', 'TH', 'Assumptions.v'], cwd=ROCQ, - capture_output=True, text=True) - for f in os.listdir(ROCQ): - if f.startswith('Assumptions.') and f != 'Assumptions.v' or f == '.Assumptions.aux': - os.remove(os.path.join(ROCQ, f)) - if out.returncode != 0: - print(out.stdout, out.stderr) - return 1 - names = re.findall(r'^Print Assumptions (\S+)\.$', open(os.path.join(ROCQ, 'Assumptions.v')).read(), re.M) - lines = [l.strip() for l in out.stdout.splitlines() if l.strip()] - if len(lines) != len(names) or any(l != 'Closed under the global context' for l in lines): - print(out.stdout) - return 1 - ext = subprocess.run([sys.executable, os.path.join(REPO, 'src', 'twohead_net.py'), 'extract'], - capture_output=True, text=True) - identical = ext.returncode == 0 and '"identical": true' in ext.stdout - files = [l for l in open(os.path.join(ROCQ, '_CoqProject')).read().split() if l.endswith('.v')] - res = dict(rocq=version.splitlines()[0], files=len(files), - build_seconds=round(build, 1), theorems={n: 'closed' for n in names}, - extraction_identical=identical) - rec = json.load(open(RECORD)) if os.path.exists(RECORD) else {} - rec.update(res) - json.dump(rec, open(RECORD, 'w'), indent=1) - print(json.dumps(res, indent=1)) - return 0 if identical else 1 - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/src/check_sigma.py b/src/check_sigma.py deleted file mode 100644 index 005dcfda9d92b87cc6dc1dd297f5d98933b48d15..0000000000000000000000000000000000000000 --- a/src/check_sigma.py +++ /dev/null @@ -1,89 +0,0 @@ -"""The serialization sigma of the ordered host netlist, written two ways. - -Units are stored in the order the family builds them, every unit after its -predecessors; tensor entries are ordered by (element type, name); the metadata -object is written first with its keys in the fixed order (machine, -weight_quantization, signal_registry, io); the JSON uses compact separators and -is padded with spaces so the tensor data begins at an eight-byte boundary. -Rebuilding the host from source reproduces its distributed encoding byte for -byte, the vectorized writer host_family.sigma produces the same bytes without -building the netlist, and the netlist the encoding denotes serializes back to -the same bytes. -""" -import json -import os -import struct -import sys - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -import torch - -from netlist_io import net_of_sigma, sigma_of_net -from selfrep import HOST_PATH, STATE_LAYOUT, IO_CELLS, host_netlist, sigma_host - -_DTORD = {"F64": 0, "F32": 1, "F16": 2, "I64": 3, "I32": 4, "I16": 5, "I8": 6, - "U8": 7, "BOOL": 8} - - -def resigma(data: bytes) -> bytes: - """Serialize the netlist that `data` denotes.""" - net, inputs, outputs, meta = net_of_sigma(data) - return sigma_of_net(net, inputs, outputs, meta["machine"], - json.loads(meta["signal_registry"]), - json.loads(meta["io"])) - - -def main() -> int: - rebuilt = sigma_host() - dist = open(HOST_PATH, "rb").read() - same = rebuilt == dist - n = struct.unpack(" str: - d = os.path.join(REPO, "paper", "runs") - os.makedirs(d, exist_ok=True) - return os.path.join(d, name) - - -def census(r: bytes): - """(literal tokens, repeat tokens, bytes from each) of a recipe.""" - i = 0 - lit = rep = f_lit = f_rep = 0 - while True: - t = r[i] - i += 1 - if t == 0: - return lit, rep, f_lit, f_rep - if t <= 127: - lit += 1 - f_lit += t - i += t - else: - rep += 1 - f_rep += 256 - t - i += 1 - - -def steps_P(r: bytes) -> int: - """Closed form for the run of the constructor P on a recipe.""" - lit, rep, f_lit, f_rep = census(r) - return 5 * f_lit + 4 * f_rep + 7 * lit + 9 * rep + 8 - - -def steps_P_star(tau: bytes): - """Closed form for the three phases of P* on its own description tape.""" - lit, rep, f_lit, f_rep = census(tau) - a = 5 * f_lit + 4 * f_rep + 7 * lit + 9 * rep + 7 - return {"A": a, "R": 1, "B": 6 * len(tau) + 2} - - -def upper_bound(r: bytes) -> int: - """5|delta(r)| + 9k + 8 with k the number of tokens, k <= (|r|-1)/2.""" - lit, rep, f_lit, f_rep = census(r) - return 5 * (f_lit + f_rep) + 9 * (lit + rep) + 8 - - -def coarse_bound(r: bytes) -> int: - return 5 * len(decode(r)) + (9 * (len(r) - 1)) // 2 + 8 - - -def phase_split(mem0, tau: bytes): - mem = list(mem0) - dev = Tape(tau) - pc = 0 - counts = {"A": 0, "R": 0, "B": 0} - while pc != HALT_PC: - counts["A" if pc < 3 * 20 else ("R" if pc == 3 * 20 else "B")] += 1 - A = mem[pc] - B = mem[(pc + 1) & 0xFF] - C = mem[(pc + 2) & 0xFF] - r = (mem[B] - mem[A]) & 0xFF - mem[B] = r - pc = C if (r == 0 or r >= 0x80) else (pc + 3) & 0xFF - dev.apply(mem) - return counts - - -def main() -> int: - out = {} - ok = True - sigma = open(HOST_PATH, "rb").read() - - named = [("r_H = describe(sigma(N_host))", describe(sigma)), - ("the all-literal encoding of sigma(N_host)", describe_literal(sigma)), - ("tau* (the self-reproducing tape)", tau_star(sigma, M_STAR))] - rows = [] - for label, r in named: - _, n = run_reference(M_P, r) - pred = steps_P(r) - rows.append({"tape": label, "recipe_bytes": len(r), "output_bytes": - len(decode(r)), "steps": n, "closed_form": pred, - "agrees": n == pred, "upper": upper_bound(r), - "coarse": coarse_bound(r)}) - print(f" {label}: {n:,} steps, closed form {pred:,}, " - f"{'agree' if n == pred else 'DISAGREE'}; " - f"bound 5|f|+9k+8 = {upper_bound(r):,}", flush=True) - ok &= n == pred and n <= upper_bound(r) <= coarse_bound(r) - out["named"] = rows - - # random recipes: both encoders, and strings built to force each token kind - rng = random.Random(1009) - bad = 0 - tested = 0 - for _ in range(400): - n = rng.randrange(0, 700) - alpha = rng.choice([1, 2, 3, 17, 256]) - f = bytes(rng.randrange(alpha) for _ in range(n)) - for enc in (describe, describe_literal): - r = enc(f) - _, s = run_reference(M_P, r) - tested += 1 - if s != steps_P(r) or s > upper_bound(r) or upper_bound(r) > coarse_bound(r): - bad += 1 - print(f" {tested:,} random runs of P: closed form exact and within the " - f"bound on every one: {'yes' if bad == 0 else f'NO ({bad})'}", flush=True) - ok &= bad == 0 - out["random_runs"] = tested - out["random_failures"] = bad - - # the three phases of P* - tau = tau_star(sigma, M_STAR) - measured = phase_split(M_STAR, tau) - pred = steps_P_star(tau) - agree = measured == pred - print(f" P* phases measured {measured}, closed form {pred}: " - f"{'agree' if agree else 'DISAGREE'}", flush=True) - print(f" total {sum(measured.values()):,} = {sum(pred.values()):,}", flush=True) - ok &= agree - out["phases_measured"] = measured - out["phases_closed_form"] = pred - out["total"] = sum(pred.values()) - - # phase B is six steps per tape byte and two more, over recipes of every - # shape; P* is run only on recipes, on which it halts - bbad = 0 - probes = 0 - for _ in range(40): - for enc in (describe, describe_literal): - probe = enc(bytes(rng.randrange(rng.choice([2, 4, 256])) - for _ in range(rng.randrange(0, 200)))) - got = phase_split(M_STAR, probe) - probes += 1 - if got != steps_P_star(probe): - bbad += 1 - print(f" phase B = 6|tau| + 2 and phase R = 1 on {probes} further recipes: " - f"{'yes' if bbad == 0 else f'NO ({bbad})'}", flush=True) - ok &= bbad == 0 - out["phase_b_failures"] = bbad - - json.dump(out, open(runs_path("paper_steps.json"), "w"), indent=1) - return 0 if ok else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/check_ternarity.py b/src/check_ternarity.py deleted file mode 100644 index 5cacfa2fb1cfb8dab834a52388b6c63a4e587d6d..0000000000000000000000000000000000000000 --- a/src/check_ternarity.py +++ /dev/null @@ -1,142 +0,0 @@ -"""The branch predicate separates ternary weights from integer weights. - -The host decides a branch from the two's-complement result r by the depth-two -cell OR(r_7, z(r)). With integer weights the same predicate is one unit, -H(128 r_7 - sum_{i<7} 2^i r_i), so the depth-two cell is where the restriction -to {-1,0,1} is paid for. Both halves are checked here: every ternary unit on -the eight result bits is enumerated and none computes the predicate, and the -single large-weight unit is evaluated on all 256 results. -""" -import itertools -import json -import os -import sys - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -import torch - -REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - - -def runs_path(name: str) -> str: - d = os.path.join(REPO, "paper", "runs") - os.makedirs(d, exist_ok=True) - return os.path.join(d, name) - - -def predicate(n: int): - """[r <= 0] for r read in two's complement on `n` bits, as a 2^n vector.""" - rows = [] - for v in range(1 << n): - two_c = v - (1 << n) if v >> (n - 1) else v - rows.append(1 if two_c <= 0 else 0) - return torch.tensor(rows, dtype=torch.long) - - -def bitmatrix(n: int): - """Row v holds the bits r_0 .. r_{n-1} of v, least significant first.""" - return torch.tensor([[(v >> i) & 1 for i in range(n)] for v in range(1 << n)], - dtype=torch.long) - - -def ternary_units_computing(n: int, target, bias_range): - """Every (w, b) in {-1,0,1}^n x bias_range whose unit computes `target`.""" - X = bitmatrix(n) - found = [] - total = 0 - for w in itertools.product((-1, 0, 1), repeat=n): - pre = X @ torch.tensor(w, dtype=torch.long) - for b in bias_range: - total += 1 - if bool(((pre + b >= 0).long() == target).all()): - found.append((w, b)) - return found, total - - -def main() -> int: - out = {} - ok = True - - # The predicate is a threshold function of the eight result bits, realized - # by one unit whose weights are the positional values of the bits. - n = 8 - target = predicate(n) - X = bitmatrix(n) - big = torch.tensor([-(1 << i) for i in range(n - 1)] + [1 << (n - 1)], - dtype=torch.long) - exact = bool(((X @ big >= 0).long() == target).all()) - print(f" one integer-weight unit with weights " - f"(-1,-2,-4,-8,-16,-32,-64,+128) and bias 0 computes [r <= 0] on all " - f"{1 << n} results: {'yes' if exact else 'NO'}") - ok &= exact - out["integer_unit_exact"] = exact - out["integer_unit_max_weight"] = int(big.abs().max()) - - # No ternary unit does, for any integer bias. A bias outside [-n, n] makes - # the unit constant, so the sweep below is exhaustive over all biases. - bias_range = range(-(n + 2), n + 3) - found, total = ternary_units_computing(n, target, bias_range) - print(f" ternary units on the eight result bits enumerated: {total:,} " - f"(all {3 ** n:,} weight vectors, biases {bias_range.start} to " - f"{bias_range.stop - 1})") - print(f" ternary units computing [r <= 0]: {len(found)}" - f"{' (none, as the proposition asserts)' if not found else f' {found[:3]}'}") - ok &= not found - out["ternary_units_enumerated"] = total - out["ternary_units_computing_predicate"] = len(found) - # A unit with p weights +1 and q weights -1 is constant unless - # -p <= b <= q - 1; those biases lie inside the swept range. - nonconst = sum(sum(1 for x in w if x) for w in itertools.product((-1, 0, 1), repeat=n)) - print(f" of these, {nonconst:,} are not constant, and none of them computes " - f"[r <= 0]") - out["nonconstant_ternary_units"] = nonconst - - # The separation begins at three bits: on two bits the predicate is ternary. - firstfail = None - widths = {} - for m in range(2, 9): - f, _ = ternary_units_computing(m, predicate(m), range(-(m + 2), m + 3)) - widths[m] = len(f) - if not f and firstfail is None: - firstfail = m - print(f" ternary realizations by width: {widths}; the predicate stops " - f"being a ternary threshold function at {firstfail} bits") - out["ternary_realizations_by_width"] = widths - out["first_width_without_ternary_unit"] = firstfail - ok &= firstfail == 3 - - # Depth two suffices, by the cell the host uses. - z = (X.sum(dim=1) == 0).long() - leq = ((X[:, n - 1] + z) >= 1).long() - two = bool((leq == target).all()) - print(f" the depth-two cell OR(r_7, z(r)) of the host computes it on all " - f"{1 << n} results: {'yes' if two else 'NO'}") - ok &= two - out["depth_two_cell_exact"] = two - - # The 256-way address decoder is not an instance of the same exchange: it - # is one ternary unit already. - from matrix8 import Net - net = Net() - bits = [f"a{i}" for i in range(8)] - net.DECODE("sel", bits, 0xA7) - ins, bias = net.gates["sel"] - dec_ternary = all(w in (-1, 0, 1) for _, w in ins) - A = bitmatrix(8) - # DECODE reads its bits most significant first - w = torch.tensor([wt for _, wt in ins], dtype=torch.long) - pre = A.flip(1) @ w + bias - hit = (pre >= 0).long() - dec_exact = bool((hit == (torch.arange(256) == 0xA7).long()).all()) - print(f" the address decoder of the memory is one ternary unit of fan-in " - f"eight and fires on exactly one address: " - f"{'yes' if dec_ternary and dec_exact else 'NO'}") - ok &= dec_ternary and dec_exact - out["decoder_is_one_ternary_unit"] = dec_ternary and dec_exact - - json.dump(out, open(runs_path("paper_ternarity.json"), "w"), indent=1) - return 0 if ok else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/constructor8.py b/src/constructor8.py deleted file mode 100644 index 8ce959504dfc2781bf43801f22e34fc378df38a1..0000000000000000000000000000000000000000 --- a/src/constructor8.py +++ /dev/null @@ -1,580 +0,0 @@ -"""neural_subleq8io + the universal constructor. - -The host is the family's one-instruction machine extended with three -memory-mapped device cells (all device logic lives in the runtimes, exactly -like neural_rv32's console at 0xFF00; the processor's gates are unchanged): - - 0xFD IN_DATA reads as the current tape byte (device-refreshed) - 0xFC IN_STROBE any write advances the tape head (device clears to 0) - 0xFE OUT any write emits the written byte (device clears to 0) - -A 21-instruction SUBLEQ program — the constructor — interprets a construction -recipe streamed on the tape: - - tag 0 END - tag 1..127 literal record: the next `tag` tape bytes, each stored - negated mod 256, are emitted one per SUBLEQ instruction - (M[0xFE] = 0 - M[0xFD] recovers the byte) - tag 129..255 repeat record: the next tape byte (negated value) is - emitted 256-tag times without strobing (tag 128 unused) - -`describe(file_bytes)` compiles any safetensors file in the family into a -recipe; the constructor run on that recipe emits the file byte for byte, so -the constructor is universal and the description selects what is built. -Self-reproduction is the case where the description handed to the constructor -is that of the running machine: the program's output stream is the -byte-for-byte safetensors file of the host itself. - -The equality is machine-checked, not observed on a single run: - - the recipe codec round-trips every family file (byte equality + sha256); - - the constructor program is executed on three proven-equal backends - (pure-integer reference, the gate-graph SubleqThresholdCPU walking the - shipped netlist, and the host compiled to recurrent ternary matrices with - matrix8's compiler) with cycle-lockstep spot checks between them; - - the full self-reproduction runs at threshold fidelity on the matrix - backend, and the emitted stream must equal the host's file exactly; - - the offspring file then boots as generation 2 and reproduces again. - -Usage: - python constructor8.py build # emit variants/neural_subleq8io.safetensors - python constructor8.py verify # host soundness + codec + constructor runs - python constructor8.py self # full self-reproduction at threshold fidelity - python constructor8.py all -""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import os -import sys -import time -from typing import Dict, List, Optional, Tuple - -import torch -from safetensors import safe_open -from safetensors.torch import save_file - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # repo root; this module lives in src/ -MODEL_PATH = os.path.join(REPO, "variants", "neural_subleq8io.safetensors") - -from matrix8 import Net, compile_net # machine-1 compiler, reused verbatim - -IN_DATA, IN_STROBE, OUT = 0xFD, 0xFC, 0xFE -HALT_PC = 0xFF - - -# ============================================================================= -# Build the host file (family per-gate style, .inputs wiring, ternary strict) -# ============================================================================= - -def build_host(save: bool = True) -> str: - import build as B - from quantize import quantize_tensors - - tensors: Dict[str, torch.Tensor] = {} - B.add_decoder(tensors, 8, 256) - B.add_memory_read_mux(tensors, 256) - B.add_memory_write_cells(tensors, 256) - B.add_subleq_core(tensors) - tensors["manifest.data_bits"] = torch.tensor([8.0]) - tensors["manifest.addr_bits"] = torch.tensor([8.0]) - tensors["manifest.memory_bytes"] = torch.tensor([256.0]) - tensors["manifest.pc_width"] = torch.tensor([8.0]) - tensors["manifest.version"] = torch.tensor([4.0]) - tensors, reg, stats = B.build_inputs(tensors) - tensors, counts, _, _ = quantize_tensors(tensors, ternary=False) - for k, t in tensors.items(): - if k.endswith(".weight") and not k.startswith("manifest."): - tf = t.float() if t.dtype.is_floating_point else t.to(torch.float32) - assert (tf.abs() <= 1).all(), f"non-ternary weight {k}" - meta = { - "machine": "subleq8io", - "weight_quantization": "ternary", - "signal_registry": reg.to_metadata(), - "io": json.dumps({"in_data": IN_DATA, "in_strobe": IN_STROBE, - "out": OUT, "halt_pc": HALT_PC}), - } - if save: - save_file(tensors, MODEL_PATH, metadata=meta) - sz = os.path.getsize(MODEL_PATH) - print(f" built {MODEL_PATH}: {stats['added']} wired gates, " - f"{len(tensors)} tensors, {sz:,} bytes") - return MODEL_PATH - - -# ============================================================================= -# Recipe codec (the construction-description language) -# ============================================================================= - -def describe(data: bytes) -> bytes: - """Compile raw bytes into a construction recipe for the constructor.""" - tape = bytearray() - i, n = 0, len(data) - while i < n: - j = i - while j < n and data[j] == data[i] and j - i < 127: - j += 1 - if j - i >= 4: - tape.append(256 - (j - i)) # repeat tag 129..252 - tape.append((256 - data[i]) % 256) # value, negated - i = j - continue - k = i - while k < n and k - i < 127: # literal until a run starts - m = k - while m < n and data[m] == data[k] and m - k < 4: - m += 1 - if m - k >= 4: - break - k += 1 - k = max(k, i + 1) - tape.append(k - i) # literal tag 1..127 - tape.extend((256 - x) % 256 for x in data[i:k]) - i = k - tape.append(0) # END - return bytes(tape) - - -def decode(tape: bytes) -> bytes: - """Pure-Python reference semantics of the recipe language.""" - out = bytearray() - i = 0 - while True: - t = tape[i] - i += 1 - if t == 0: - return bytes(out) - if t <= 127: - for _ in range(t): - out.append((256 - tape[i]) % 256) - i += 1 - else: - out.extend([(256 - tape[i]) % 256] * (256 - t)) - i += 1 - - -# ============================================================================= -# The constructor program (21 SUBLEQ instructions + 4 variable cells) -# ============================================================================= - -Z, ONE, T1, T2 = 0xF0, 0xF1, 0xF2, 0xF3 - -CONSTRUCTOR = [ - # main loop: read tag T; T1 = -T (== run length for repeat tags), T2 = T - (T2, T2, 0x03), # 00 T2 = 0 - (T1, T1, 0x06), # 03 T1 = 0 - (IN_DATA, T1, 0x09), # 06 T1 = -T - (Z, IN_STROBE, 0x0C), # 09 consume tag byte - (T1, T2, 0x0F), # 0C T2 = T - (Z, T2, 0x15), # 0F if T == 0 or T >= 128 -> 15, else literal - (Z, Z, 0x24), # 12 goto LITERAL - (Z, T1, 0x1B), # 15 T1 = -T: leq only when T == 0 -> END - (Z, Z, 0x30), # 18 goto REPEAT (T1 holds 256-T = run length) - (Z, Z, HALT_PC), # 1B END: halt - (Z, Z, 0x21), # 1E (pad) - (Z, Z, 0x24), # 21 (pad) - (IN_DATA, OUT, 0x27), # 24 LITERAL: emit 0 - tape_byte - (Z, IN_STROBE, 0x2A), # 27 consume it - (ONE, T2, 0x00), # 2A T2 -= 1; done -> main loop - (Z, Z, 0x24), # 2D next literal byte - (IN_DATA, OUT, 0x33), # 30 REPEAT: emit 0 - value (no strobe) - (ONE, T1, 0x39), # 33 T1 -= 1; done -> 39 - (Z, Z, 0x30), # 36 emit again - (Z, IN_STROBE, 0x3C), # 39 consume the value byte - (Z, Z, 0x00), # 3C goto main loop -] - - -def constructor_image() -> List[int]: - mem = [0] * 256 - for idx, (a, b, c) in enumerate(CONSTRUCTOR): - mem[idx * 3] = a - mem[idx * 3 + 1] = b - mem[idx * 3 + 2] = c - mem[ONE] = 1 - return mem - - -# ============================================================================= -# Backend 1: fast pure-integer reference with devices -# ============================================================================= - -def ref_construct(tape: bytes, max_steps: int = 1 << 34, - ) -> Tuple[bytes, int]: - mem = constructor_image() - mem[IN_DATA] = tape[0] if tape else 0 - out = bytearray() - head = 0 - pc = 0 - steps = 0 - tn = len(tape) - while pc != HALT_PC and steps < max_steps: - A = mem[pc] - Bc = mem[(pc + 1) & 0xFF] - C = mem[(pc + 2) & 0xFF] - r = (mem[Bc] - mem[A]) & 0xFF - mem[Bc] = r - pc = C if (r == 0 or r >= 0x80) else (pc + 3) & 0xFF - if Bc == OUT: - out.append(r) - mem[OUT] = 0 - elif Bc == IN_STROBE: - head += 1 - mem[IN_STROBE] = 0 - mem[IN_DATA] = tape[head] if head < tn else 0 - steps += 1 - return bytes(out), steps - - -# ============================================================================= -# Backend 2: the gate-graph processor (shipped netlist) with devices -# ============================================================================= - -class GateHost: - def __init__(self, path: str = MODEL_PATH): - from machines import SubleqThresholdCPU, load_tensors - from eval import load_metadata - T = load_tensors(path) - reg = load_metadata(path)["signal_registry"] - self.cpu = SubleqThresholdCPU(T, reg) - - def run(self, tape: bytes, max_steps: int) -> Tuple[bytes, int, dict]: - s = {"pc": 0, "mem": constructor_image(), "halted": False} - s["mem"][IN_DATA] = tape[0] if tape else 0 - out = bytearray() - head = 0 - n = 0 - while not s["halted"] and n < max_steps: - Bc = s["mem"][(s["pc"] + 1) & 0xFF] - s = self.cpu.step(s) - if Bc == OUT: - out.append(s["mem"][OUT]) - s["mem"][OUT] = 0 - elif Bc == IN_STROBE: - head += 1 - s["mem"][IN_STROBE] = 0 - s["mem"][IN_DATA] = tape[head] if head < len(tape) else 0 - n += 1 - return bytes(out), n, s - - -# ============================================================================= -# Backend 3: the host compiled to recurrent ternary matrices (machine 1 tech) -# ============================================================================= - -from host_netlist import subleq_state_names, build_subleq_step_net - - -class MatrixHost: - """The subleq8io processor as a recurrent ternary linear-threshold - network, with the device cells poked between recurrences.""" - - N = 8 + 1 + 2048 - - def __init__(self, device: str = "cpu"): - t0 = time.perf_counter() - net, inputs, outputs = build_subleq_step_net() - layers, info = compile_net(net, inputs, outputs) - for W, Bt in layers: - vals = set(torch.unique(W).tolist()) - assert vals <= {-1, 0, 1} - self.W = [W.to(device=device, dtype=torch.float32) for W, _ in layers] - self.B = [Bt.to(device=device, dtype=torch.float32) for _, Bt in layers] - self.device = device - self.info = info - print(f" host compiled to {info['layers']} ternary matrices " - f"(max width {info['max_width']}, " - f"{info['total_weights']:,} weights) in " - f"{time.perf_counter() - t0:.1f}s on {device}") - - def _vec(self, pc: int, mem: List[int], halt: bool = False) -> torch.Tensor: - v = torch.zeros(self.N) - for k in range(8): - v[k] = (pc >> (7 - k)) & 1 - v[8] = 1.0 if halt else 0.0 - for j in range(256): - for k in range(8): - v[9 + j * 8 + k] = (mem[j] >> (7 - k)) & 1 - return v - - def step(self, v: torch.Tensor) -> torch.Tensor: - for W, b in zip(self.W, self.B): - v = ((v @ W.T + b) >= 0).float() - return v - - @staticmethod - def _byte(vcpu: torch.Tensor, j: int) -> int: - x = 0 - for k in range(8): - x = (x << 1) | int(vcpu[9 + j * 8 + k]) - return x - - def run(self, tape: bytes, max_steps: int, expect: Optional[bytes] = None, - report_every: int = 100000) -> Tuple[bytes, int]: - mem = constructor_image() - mem[IN_DATA] = tape[0] if tape else 0 - v = self._vec(0, mem).unsqueeze(0).to(self.device) - out = bytearray() - head = 0 - n = 0 - idx_pch = torch.arange(0, 9, device=self.device) - t0 = time.perf_counter() - while n < max_steps: - front = v[0, idx_pch].tolist() - if front[8] >= 0.5: - break - pcv = 0 - for k in range(8): - pcv = (pcv << 1) | int(front[k]) - bidx = (pcv + 1) & 0xFF - bbits = v[0, 9 + bidx * 8: 9 + bidx * 8 + 8].tolist() - Bc = 0 - for k in range(8): - Bc = (Bc << 1) | int(bbits[k]) - v = self.step(v) - n += 1 - if Bc == OUT: - ob = v[0, 9 + OUT * 8: 9 + OUT * 8 + 8].tolist() - val = 0 - for k in range(8): - val = (val << 1) | int(ob[k]) - out.append(val) - if expect is not None and out[-1] != expect[len(out) - 1]: - raise AssertionError( - f"stream diverged at byte {len(out) - 1}: " - f"got {out[-1]:#04x} want {expect[len(out) - 1]:#04x}") - v[0, 9 + OUT * 8: 9 + OUT * 8 + 8] = 0.0 - elif Bc == IN_STROBE: - head += 1 - v[0, 9 + IN_STROBE * 8: 9 + IN_STROBE * 8 + 8] = 0.0 - nxt = tape[head] if head < len(tape) else 0 - for k in range(8): - v[0, 9 + IN_DATA * 8 + k] = float((nxt >> (7 - k)) & 1) - if report_every and n % report_every == 0: - rate = n / (time.perf_counter() - t0) - print(f" ... {n:,} recurrences, {len(out):,} bytes emitted " - f"({rate:,.0f} instr/s)") - return bytes(out), n - - def exhaustive_datapath(self) -> bool: - """All 65,536 (x, y) operand pairs through the matrices in one batch.""" - xs = torch.arange(65536) % 256 - ys = torch.arange(65536) // 256 - V = torch.zeros(65536, self.N) - mem = [0] * 256 - mem[0], mem[1], mem[2] = 0x90, 0x91, 0x30 - v0 = self._vec(0, mem) - V[:] = v0 - for k in range(8): - V[:, 9 + 0x90 * 8 + k] = ((xs >> (7 - k)) & 1).float() - V[:, 9 + 0x91 * 8 + k] = ((ys >> (7 - k)) & 1).float() - V = V.to(self.device) - for W, b in zip(self.W, self.B): - V = ((V @ W.T + b) >= 0).float() - V = V.cpu() - r = torch.zeros(65536, dtype=torch.long) - for k in range(8): - r = (r << 1) | V[:, 9 + 0x91 * 8 + k].long() - pcv = torch.zeros(65536, dtype=torch.long) - for k in range(8): - pcv = (pcv << 1) | V[:, k].long() - exp_r = (ys - xs) & 0xFF - exp_leq = (exp_r == 0) | (exp_r >= 0x80) - exp_pc = torch.where(exp_leq, torch.full_like(pcv, 0x30), - torch.full_like(pcv, 3)) - ok = bool((r == exp_r).all()) and bool((pcv == exp_pc).all()) - print(f" {'OK ' if ok else 'FAIL'} matrix datapath exhaustive: " - f"65,536/65,536 subtract results and branch decisions exact") - return ok - - -# ============================================================================= -# Verification -# ============================================================================= - -def sha(b: bytes) -> str: - return hashlib.sha256(b).hexdigest()[:16] - - -def family_files() -> List[str]: - files = [os.path.join(REPO, "neural_computer.safetensors")] - vdir = os.path.join(REPO, "variants") - files += sorted(os.path.join(vdir, f) for f in os.listdir(vdir) - if f.endswith(".safetensors")) - return files - - -def verify(device: str) -> bool: - ok = True - - print("\n[1/5] Host machine soundness (shipped netlist, exhaustive datapath)") - from eval import NetlistEvaluator, load_metadata - from machines import load_tensors - T = load_tensors(MODEL_PATH) - reg = load_metadata(MODEL_PATH)["signal_registry"] - ne = NetlistEvaluator(T, reg, "subleq") - a_v = torch.arange(65536) % 256 - b_v = torch.arange(65536) // 256 - ext = {} - for k in range(8): - ext[f"$a[{k}]"] = ((a_v >> k) & 1).float() - ext[f"$b[{k}]"] = ((b_v >> k) & 1).float() - ext[f"$pc[{k}]"] = torch.zeros(65536) - ext[f"$c[{k}]"] = torch.zeros(65536) - o = ne.run(ext) - rr = sum(o[f"subleq.sub.fa{k}.ha2.sum.layer2"][:, 0].long() << k for k in range(8)) - lq = o["subleq.leq"][:, 0].long() - exp_r = (b_v - a_v) & 0xFF - exp_l = ((exp_r == 0) | (exp_r >= 128)).long() - good = bool((rr == exp_r).all()) and bool((lq == exp_l).all()) - print(f" {'OK ' if good else 'FAIL'} gate netlist exhaustive: 65,536/65,536 exact") - ok &= good - - print("\n[2/5] Recipe codec round-trip over the whole family") - total = 0 - for p in family_files(): - data = open(p, "rb").read() - tape = describe(data) - good = decode(tape) == data - ok &= good - total += len(data) - print(f" {'OK ' if good else 'FAIL'} {os.path.basename(p):<44} " - f"{len(data):>10,} B -> tape {len(tape):>10,} B sha {sha(data)}") - print(f" ({total / 1e6:.0f} MB described and decoded back, byte-identical)") - - print("\n[3/5] Constructor program on the integer reference " - "(output stream == target file, byte for byte)") - targets = [MODEL_PATH, - os.path.join(REPO, "variants", "neural_subleq8.safetensors"), - os.path.join(REPO, "variants", "neural_matrix8.safetensors")] - for p in targets: - data = open(p, "rb").read() - tape = describe(data) - t0 = time.perf_counter() - out, steps = ref_construct(tape) - dt = time.perf_counter() - t0 - good = out == data - ok &= good - tag = "SELF-REPRODUCTION" if p == MODEL_PATH else "universal build" - print(f" {'OK ' if good else 'FAIL'} {os.path.basename(p):<40} " - f"{len(out):>10,} B in {steps:>12,} instructions ({dt:.1f}s) " - f"[{tag}] sha {sha(out)}") - - print("\n[4/5] Matrix backend (machine-1 compile of the host)") - mh = MatrixHost(device=device) - ok &= mh.exhaustive_datapath() - - print("\n[5/5] Backend lockstep on a shared prefix " - "(gate graph vs matrices vs reference, first 600 cycles)") - data = open(MODEL_PATH, "rb").read() - tape = describe(data) - gh = GateHost() - s = {"pc": 0, "mem": constructor_image(), "halted": False} - s["mem"][IN_DATA] = tape[0] - mem2 = constructor_image() - mem2[IN_DATA] = tape[0] - v = mh._vec(0, mem2).unsqueeze(0).to(device) - headg = headm = 0 - outg, outm = bytearray(), bytearray() - good = True - for n in range(600): - Bg = s["mem"][(s["pc"] + 1) & 0xFF] - s = gh.cpu.step(s) - if Bg == OUT: - outg.append(s["mem"][OUT]) - s["mem"][OUT] = 0 - elif Bg == IN_STROBE: - headg += 1 - s["mem"][IN_STROBE] = 0 - s["mem"][IN_DATA] = tape[headg] if headg < len(tape) else 0 - vc = v[0].cpu() - pcm = 0 - for k in range(8): - pcm = (pcm << 1) | int(vc[k]) - Bm = MatrixHost._byte(vc, (pcm + 1) & 0xFF) - v = mh.step(v) - if Bm == OUT: - outm.append(MatrixHost._byte(v[0].cpu(), OUT)) - v[0, 9 + OUT * 8: 9 + OUT * 8 + 8] = 0.0 - elif Bm == IN_STROBE: - headm += 1 - v[0, 9 + IN_STROBE * 8: 9 + IN_STROBE * 8 + 8] = 0.0 - nxt = tape[headm] if headm < len(tape) else 0 - for k in range(8): - v[0, 9 + IN_DATA * 8 + k] = float((nxt >> (7 - k)) & 1) - vc = v[0].cpu() - pcm = 0 - for k in range(8): - pcm = (pcm << 1) | int(vc[k]) - memm = [MatrixHost._byte(vc, j) for j in range(256)] - if pcm != s["pc"] or memm != s["mem"] or outg != outm: - print(f" FAIL lockstep diverged at cycle {n}") - good = False - break - print(f" {'OK ' if good else 'FAIL'} gate graph and matrix form agree " - f"cycle-for-cycle for 600 cycles ({len(outg)} bytes emitted identically)") - ok &= good - - print("\nCONSTRUCTOR VERIFICATION:", "PASS" if ok else "FAIL") - return ok - - -def self_reproduce(device: str, gen2: bool = True) -> bool: - """Full self-reproduction at threshold fidelity on the matrix backend.""" - data = open(MODEL_PATH, "rb").read() - tape = describe(data) - print(f"\nSelf-reproduction on the threshold matrices: target " - f"{len(data):,} bytes (sha {sha(data)}), tape {len(tape):,} bytes") - ref_out, ref_steps = ref_construct(tape) - assert ref_out == data, "reference construction failed" - print(f" reference: {ref_steps:,} instructions") - - mh = MatrixHost(device=device) - t0 = time.perf_counter() - out, n = mh.run(tape, max_steps=ref_steps + 64, expect=data) - dt = time.perf_counter() - t0 - ok = out == data and n == ref_steps - print(f" GEN 1: emitted {len(out):,} bytes in {n:,} recurrences " - f"({dt / 60:.1f} min, {n / dt:,.0f} instr/s) sha {sha(out)}") - print(f" {'OK ' if ok else 'FAIL'} output stream == host safetensors file, " - f"byte for byte; instruction count matches reference exactly") - - if ok and gen2: - off_path = os.path.join(REPO, "variants", "_offspring_subleq8io.safetensors") - with open(off_path, "wb") as f: - f.write(out) - gh = GateHost(off_path) # boot the offspring's own gates - probe_tape = describe(out[:2048]) - want = out[:2048] - got, steps, _ = gh.run(probe_tape, max_steps=len(probe_tape) * 8 + 64) - good2 = got == want - print(f" GEN 2: offspring file booted as a machine (gate graph); " - f"constructed the first {len(want):,} bytes of itself in " - f"{steps:,} instructions: {'exact' if good2 else 'MISMATCH'}") - os.remove(off_path) - ok &= good2 - print("SELF-REPRODUCTION:", "PASS" if ok else "FAIL") - return ok - - -def main() -> int: - ap = argparse.ArgumentParser() - ap.add_argument("cmd", choices=["build", "verify", "self", "all"]) - ap.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu") - args = ap.parse_args() - rc = 0 - if args.cmd in ("build", "all"): - build_host(save=True) - if args.cmd in ("verify", "all"): - rc |= 0 if verify(args.device) else 1 - if args.cmd in ("self", "all"): - rc |= 0 if self_reproduce(args.device) else 1 - return rc - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/eval.py b/src/eval.py index 7c980b70c21a8142f30b2eb6f3a0756d0974efb9..ef6c96e5fa3ddbcd20d45d212d7c81b142443476 100644 --- a/src/eval.py +++ b/src/eval.py @@ -1,30 +1,15 @@ -""" -Unified Evaluation Suite for 8-bit Threshold Computer -====================================================== -GPU-batched evaluation with per-circuit reporting. -Includes CPU runtime for threshold-weight execution. - -Usage: - python eval.py # Run circuit evaluation - python eval.py --device cpu # CPU mode - python eval.py --pop_size 1000 # Population mode for evolution - python eval.py --cpu-test # Run CPU smoke test - -The single gate-routed CPU runtime is GenericThresholdCPU in eval_all.py -(manifest-sized); the pure-Python reference (CPUState / ref_step) lives here -for cross-checks. - -API (for prune_weights.py): - from eval import load_model, create_population, BatchedFitnessEvaluator -""" +"""Evaluates the threshold computers: the fitness suite, every variant, and the CPU programs.""" +from __future__ import annotations import argparse import json import os +import sys import time from collections import defaultdict from dataclasses import dataclass, field -from typing import Callable, Dict, List, Optional, Tuple +from pathlib import Path +from typing import Callable, Dict, List, Optional, Tuple, Any import torch from safetensors import safe_open @@ -71,11 +56,7 @@ def load_metadata(path: str = MODEL_PATH) -> Dict: def get_manifest(tensors: Dict[str, torch.Tensor]) -> Dict[str, int]: - """Extract manifest values from tensors. - - Returns dict with data_bits, addr_bits, memory_bytes, version. - Defaults to 8-bit data, 16-bit addr for legacy models. - """ + """Extract manifest values from tensors.""" return { 'data_bits': int(tensors.get('manifest.data_bits', torch.tensor([8.0])).item()), 'addr_bits': int(tensors.get('manifest.addr_bits', @@ -102,16 +83,25 @@ def create_population( # ============================================================================= FLAG_NAMES = ["Z", "N", "C", "V"] + CTRL_NAMES = ["HALT", "MEM_WE", "MEM_RE", "RESERVED"] PC_BITS = 16 + IR_BITS = 16 + REG_BITS = 8 + REG_COUNT = 4 + FLAG_BITS = 4 + SP_BITS = 16 + CTRL_BITS = 4 + MEM_BYTES = 65536 + MEM_BITS = MEM_BYTES * 8 STATE_BITS = PC_BITS + IR_BITS + (REG_BITS * REG_COUNT) + FLAG_BITS + SP_BITS + CTRL_BITS + MEM_BITS @@ -405,18 +395,9 @@ def _fill_smoke_program(mem: List[int]) -> None: def run_smoke_test() -> int: - """Smoke test through the single gate-routed CPU runtime - (GenericThresholdCPU), cross-checked against the pure-Python reference: - - 1. LOAD 5, LOAD 7, ADD, STORE -> MEM[0x0102] = 12 - 2. LOAD 2, LOAD 131, MUL, STORE -> MEM[0x0105] = (2*131) & 0xFF = 6 - 3. Countdown loop 3+2+1 via SUB/JNZ -> MEM[0x0109] = 6 - - Runs on the 1 KB variant so the threshold pass finishes in seconds; the - reference runs at 64 KB in pure Python. - """ + """Programs through the gate-routed CPU runtime against the Python reference.""" import os - from eval_all import GenericThresholdCPU # lazy: avoids eval<->eval_all cycle + pass # lazy: avoids eval<->eval_all cycle expected = {0x0102: 12, 0x0105: 6, 0x0109: 6} @@ -461,18 +442,7 @@ def run_smoke_test() -> int: # ============================================================================= class NetlistEvaluator: - """Evaluate a self-contained circuit from its shipped wiring metadata. - - The gate graph is built from the .inputs tensors and the header signal - registry — the same artifacts safetensors2verilog consumes — so a test - driven through this class proves the file itself encodes the circuit, - with no wiring knowledge living in Python. - - External signals are names starting with '$' (plus the constants #0/#1); - the caller binds them per evaluation. Gate outputs are published under - their full gate names. Evaluation is batched over test vectors and, - for population dicts, over population slots. - """ + """Evaluate a self-contained circuit from its shipped wiring metadata.""" def __init__(self, tensors: Dict[str, torch.Tensor], signal_registry: Dict[str, str], prefix: str, @@ -578,8 +548,7 @@ class NetlistEvaluator: return sorted(out) def run(self, external: Dict[str, torch.Tensor]) -> Dict[str, torch.Tensor]: - """Evaluate the circuit. Each external is a [num_tests] tensor (or a - scalar); every returned gate output is [num_tests, pop_size].""" + """Evaluate the circuit.""" first = next(iter(external.values())) if not torch.is_tensor(first): num_tests = 1 @@ -635,8 +604,7 @@ class _SlotView: def float_bits_to_value(word: int, exp_bits: int, frac_bits: int) -> float: - """Decode an IEEE 754 word to a Python float. float64 represents every - float16/float32 value exactly, so the oracle comparisons are exact.""" + """Decode an IEEE 754 word to a Python float, exactly.""" sign = (word >> (exp_bits + frac_bits)) & 1 exp = (word >> frac_bits) & ((1 << exp_bits) - 1) frac = word & ((1 << frac_bits) - 1) @@ -653,8 +621,7 @@ def float_bits_to_value(word: int, exp_bits: int, frac_bits: int) -> float: def float_test_words(exp_bits: int, frac_bits: int) -> Tuple[List[int], List[int]]: - """Directed IEEE edge encodings (every category, both signs) plus seeded - random words for a family.""" + """Directed IEEE edge encodings (every category, both signs) plus seeded random words for a family.""" import random E, F = exp_bits, frac_bits emax = (1 << E) - 1 @@ -684,10 +651,7 @@ def float_test_words(exp_bits: int, frac_bits: int) -> Tuple[List[int], List[int def float_mul_oracle(aw: int, bw: int, exp_bits: int, frac_bits: int) -> int: - """Expected product word under the documented contract: exact IEEE - specials (NaN, infinities, signed zeros), subnormal operands and - gradual-underflow subnormal results, round-to-nearest-even mantissa. - Pure integer arithmetic, so exact.""" + """The expected IEEE product word: exact specials, subnormals, round to nearest even.""" E, F = exp_bits, frac_bits emax = (1 << E) - 1 fmask = (1 << F) - 1 @@ -752,10 +716,7 @@ def float_mul_oracle(aw: int, bw: int, exp_bits: int, frac_bits: int) -> int: def float_div_oracle(aw: int, bw: int, exp_bits: int, frac_bits: int) -> int: - """Expected quotient word under the documented contract: exact IEEE - specials (NaN, infinities, x/0 -> inf, 0/0 and inf/inf -> NaN, signed - zeros), subnormal operands and gradual-underflow subnormal results, - round-to-nearest-even mantissa.""" + """The expected IEEE quotient word: exact specials, subnormals, round to nearest even.""" E, F = exp_bits, frac_bits emax = (1 << E) - 1 fmask = (1 << F) - 1 @@ -817,10 +778,7 @@ def float_div_oracle(aw: int, bw: int, exp_bits: int, frac_bits: int) -> int: def float_fma_oracle(aw: int, bw: int, cw: int, exp_bits: int, frac_bits: int) -> int: - """Expected word for round(a*b + c) with a SINGLE rounding: exact IEEE - specials (NaN, infinities, inf*0 and inf-inf -> NaN, signed zeros, exact - cancellation -> +0), subnormal operands and gradual-underflow results, - round-to-nearest-even. Pure integer arithmetic, so exact.""" + """The expected word of round(a*b + c), rounded once.""" E, F = exp_bits, frac_bits emax = (1 << E) - 1 fmask = (1 << F) - 1 @@ -910,11 +868,7 @@ def float_fma_oracle(aw: int, bw: int, cw: int, exp_bits: int, frac_bits: int) - def float_add_oracle(aw: int, bw: int, exp_bits: int, frac_bits: int) -> int: - """Expected sum word under the documented contract: exact IEEE specials - (NaN, infinities, opposite-sign infinities -> NaN, signed zeros, exact - cancellation -> +0), subnormal operands and gradual-underflow subnormal - results, round-to-nearest-even mantissa. A zero operand passes the other - through verbatim. Pure integer arithmetic, so exact.""" + """The expected IEEE sum word: exact specials, subnormals, round to nearest even.""" E, F = exp_bits, frac_bits emax = (1 << E) - 1 fmask = (1 << F) - 1 @@ -998,10 +952,7 @@ def float_add_oracle(aw: int, bw: int, exp_bits: int, frac_bits: int) -> int: # ============================================================================= class BatchedFitnessEvaluator: - """ - GPU-batched fitness evaluator with per-circuit reporting. - Tests all circuits comprehensively. - """ + """GPU-batched fitness evaluator with per-circuit reporting.""" def __init__(self, device: str = 'cuda', model_path: str = MODEL_PATH, tensors: Dict[str, torch.Tensor] = None): self.device = device @@ -1263,14 +1214,7 @@ class BatchedFitnessEvaluator: # ========================================================================= def _eval_xor(self, pop: Dict, prefix: str, a: torch.Tensor, b: torch.Tensor) -> torch.Tensor: - """Evaluate XOR gate with or/nand decomposition. - - Args: - a, b: Tensors of shape [num_tests] or [num_tests, pop_size] - - Returns: - Tensor of shape [num_tests, pop_size] - """ + """Evaluate XOR gate with or/nand decomposition.""" pop_size = next(iter(pop.values())).shape[0] # Ensure inputs are [num_tests, pop_size] @@ -1300,14 +1244,7 @@ class BatchedFitnessEvaluator: def _eval_single_fa(self, pop: Dict, prefix: str, a: torch.Tensor, b: torch.Tensor, cin: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: - """Evaluate single full adder. - - Args: - a, b, cin: Tensors of shape [num_tests] or [num_tests, pop_size] - - Returns: - sum_out, cout: Both of shape [num_tests, pop_size] - """ + """Evaluate single full adder.""" pop_size = next(iter(pop.values())).shape[0] # Ensure inputs are [num_tests, pop_size] @@ -1768,11 +1705,7 @@ class BatchedFitnessEvaluator: a_bits_2d: torch.Tensor, b_bits_2d: torch.Tensor, ) -> Dict[str, torch.Tensor]: - """Walk the ternary bit-cascade comparator generated by - build.add_bit_cascade_compare. Returns a dict with gt/lt/ge/le/eq each of - shape [num_tests, pop_size]. a_bits_2d, b_bits_2d are [num_tests, bits] - MSB-first. - """ + """Walk the ternary bit-cascade comparator generated by build.add_bit_cascade_compare.""" pop_size = next(iter(pop.values())).shape[0] # Per-bit gt, lt, eq @@ -2469,12 +2402,7 @@ class BatchedFitnessEvaluator: # ========================================================================= def _test_modular(self, pop: Dict, mod: int, debug: bool) -> Tuple[torch.Tensor, int]: - """Test modular divisibility circuit. - - Two structures: mod 3/5/6/7/9/10/11/12 use bit-cascade equality - per multiple of N (`{prefix}.eq.k{k}.*` + final OR at `{prefix}`). - mod 2/4/8 use a single-layer ternary detector at `{prefix}` directly. - """ + """Test modular divisibility circuit.""" pop_size = next(iter(pop.values())).shape[0] prefix = f'modular.mod{mod}' @@ -3041,15 +2969,7 @@ class BatchedFitnessEvaluator: return scores, total def _test_priority_encoder_nbits(self, pop: Dict, bits: int, debug: bool) -> Tuple[torch.Tensor, int]: - """Test N-bit priority encoder (find highest set bit). - - The priority encoder is a multi-layer circuit: - 1. any_higher{pos}: OR of bits 0 to pos-1 (all higher-priority positions) - 2. is_highest{0}: bit[0] directly (MSB is always highest if set) - 3. is_highest{pos}: bit[pos] AND NOT(any_higher{pos}) for pos > 0 - 4. out{bit}: OR of is_highest{pos} for all pos where (pos >> bit) & 1 - 5. valid: OR of all input bits - """ + """Test N-bit priority encoder (find highest set bit).""" import math pop_size = next(iter(pop.values())).shape[0] scores = torch.zeros(pop_size, device=self.device) @@ -4013,8 +3933,7 @@ class BatchedFitnessEvaluator: def _test_float_unpack_pack(self, pop: Dict, family: str, word_bits: int, debug: bool) -> Tuple[torch.Tensor, int]: - """Functionally test the unpack/pack identity buffers: every bit gate - must reproduce its binary input (0 -> 0, 1 -> 1).""" + """Each unpack/pack identity buffer reproduces its input bit.""" pop_size = next(iter(pop.values())).shape[0] scores = torch.zeros(pop_size, device=self.device) total = 0 @@ -4047,9 +3966,7 @@ class BatchedFitnessEvaluator: def _test_float_classify(self, pop: Dict, family: str, exp_bits: int, frac_bits: int, debug: bool) -> Tuple[torch.Tensor, int]: - """Functionally test the classify subcircuit: the exponent/fraction - field predicates and the is_zero / is_subnormal / is_inf / is_nan AND - gates, against IEEE 754 categories over edge-case encodings.""" + """The classify subcircuit against the IEEE 754 categories on edge encodings.""" pop_size = next(iter(pop.values())).shape[0] scores = torch.zeros(pop_size, device=self.device) total = 0 @@ -4136,10 +4053,7 @@ class BatchedFitnessEvaluator: def _test_float_cmp_composed(self, pop: Dict, family: str, exp_bits: int, frac_bits: int, debug: bool) -> Tuple[torch.Tensor, int]: - """Composed IEEE comparison test driven entirely by the shipped - wiring: the netlist is reconstructed from the .inputs metadata and - evaluated end to end, then checked against exact IEEE semantics - (NaN unordered, +0 == -0, subnormals ordered, mixed signs).""" + """The IEEE comparison rebuilt from the shipped wiring against exact IEEE semantics.""" pop_size = next(iter(pop.values())).shape[0] scores = torch.zeros(pop_size, device=self.device) total = 0 @@ -4211,10 +4125,7 @@ class BatchedFitnessEvaluator: def _test_float_arith_composed(self, pop: Dict, family: str, op: str, oracle, exp_bits: int, frac_bits: int, debug: bool) -> Tuple[torch.Tensor, int]: - """Composed arithmetic test for a self-contained float pipeline: the - netlist is rebuilt from the .inputs metadata, evaluated end to end - over IEEE edge-case and random operand pairs, and the assembled - output word is compared to the exact integer oracle.""" + """A float pipeline rebuilt from the shipped wiring against the exact integer oracle.""" pop_size = next(iter(pop.values())).shape[0] scores = torch.zeros(pop_size, device=self.device) total = 0 @@ -4287,9 +4198,7 @@ class BatchedFitnessEvaluator: def _test_float_fma_composed(self, pop: Dict, family: str, exp_bits: int, frac_bits: int, debug: bool) -> Tuple[torch.Tensor, int]: - """Composed fused-multiply-add test: rebuild the netlist from .inputs, - evaluate round(a*b+c) end to end over edge and random triples, compare - to the single-rounding oracle.""" + """The fused multiply-add rebuilt from the shipped wiring against the single-rounding oracle.""" pop_size = next(iter(pop.values())).shape[0] scores = torch.zeros(pop_size, device=self.device) prefix = f"{family}.fma" @@ -4756,12 +4665,7 @@ class BatchedFitnessEvaluator: def _pop_modN(self, pop: Dict, pop_size: int, val_bits: torch.Tensor, modulus: int) -> torch.Tensor: - """Drive the bit-cascade modular.mod{N} divisibility detector. - - Returns a (pop_size,) tensor: 1 iff the 8-bit value (MSB-first bits in - val_bits) is divisible by ``modulus``. Walks the per-multiple match - gates (modular.modN.eq.k{val}.bit{i}.match -> .all -> top-level OR). - """ + """Drive the bit-cascade modular.mod{N} divisibility detector.""" ks = [k for k in range(256) if k % modulus == 0] alls = [] for k in ks: @@ -4782,11 +4686,7 @@ class BatchedFitnessEvaluator: def _pop_cmp8bit(self, pop: Dict, pop_size: int, a_bits: torch.Tensor, b_bits: torch.Tensor, kind: str) -> torch.Tensor: - """Drive the bit-cascade comparator (cmp8bit) over a population. - - Returns a (pop_size,) tensor of heaviside outputs for the requested - comparison kind ('gt' | 'lt' | 'eq'). Bit 0 is MSB. - """ + """Drive the bit-cascade comparator (cmp8bit) over a population.""" def apply(name: str, inp: torch.Tensor, fan_in: int) -> torch.Tensor: w = pop[f'{name}.weight'].view(pop_size, fan_in) b = pop[f'{name}.bias'].view(pop_size) @@ -5049,16 +4949,7 @@ class BatchedFitnessEvaluator: # ========================================================================= def evaluate(self, population: Dict[str, torch.Tensor], debug: bool = False) -> torch.Tensor: - """ - Evaluate population fitness with per-circuit reporting. - - Args: - population: Dict of tensors, each with shape [pop_size, ...] - debug: If True, print per-circuit results - - Returns: - Tensor of fitness scores [pop_size], normalized to [0, 1] - """ + """Evaluate population fitness with per-circuit reporting.""" self.results = [] self.category_scores = {} @@ -5475,5 +5366,1542 @@ def main(): return 1 -if __name__ == '__main__': - exit(main()) +# --------------------------------------------------------------------------- +# Variant-aware threshold ALU + CPU +# --------------------------------------------------------------------------- + +class GenericThresholdALU: + """Variant-aware threshold ALU walking the gates of a loaded variant.""" + + def __init__(self, tensors: Dict[str, torch.Tensor], data_bits: int): + self.T = tensors + self.data_bits = data_bits + + def _g(self, name, inputs): + w = self.T[name + ".weight"].view(-1) + b = self.T[name + ".bias"].view(-1) + return int(heaviside((torch.tensor(inputs, dtype=torch.float32) * w).sum() + b).item()) + + def _xor_or_nand(self, prefix, inputs): + a, b_ = inputs + h_or = self._g(f"{prefix}.layer1.or", [a, b_]) + h_nand = self._g(f"{prefix}.layer1.nand", [a, b_]) + return self._g(f"{prefix}.layer2", [h_or, h_nand]) + + def _fa(self, prefix, a, b, cin): + s1 = self._xor_or_nand(f"{prefix}.ha1.sum", [a, b]) + c1 = self._g(f"{prefix}.ha1.carry", [a, b]) + s2 = self._xor_or_nand(f"{prefix}.ha2.sum", [s1, cin]) + c2 = self._g(f"{prefix}.ha2.carry", [s1, cin]) + cout = self._g(f"{prefix}.carry_or", [c1, c2]) + return s2, cout + + def add8(self, a, b): + a_lsb = list(reversed(int_to_bits(a, 8))) + b_lsb = list(reversed(int_to_bits(b, 8))) + carry = 0 + s_lsb = [] + for i in range(8): + s, carry = self._fa(f"arithmetic.ripplecarry8bit.fa{i}", a_lsb[i], b_lsb[i], carry) + s_lsb.append(s) + return bits_to_int(list(reversed(s_lsb))), carry + + def sub8(self, a, b): + a_lsb = list(reversed(int_to_bits(a, 8))) + b_lsb = list(reversed(int_to_bits(b, 8))) + carry = 1 + d_lsb = [] + for i in range(8): + notb = self._g(f"arithmetic.sub8bit.notb{i}", [b_lsb[i]]) + x1 = self._xor_or_nand(f"arithmetic.sub8bit.fa{i}.xor1", [a_lsb[i], notb]) + x2 = self._xor_or_nand(f"arithmetic.sub8bit.fa{i}.xor2", [x1, carry]) + and1 = self._g(f"arithmetic.sub8bit.fa{i}.and1", [a_lsb[i], notb]) + and2 = self._g(f"arithmetic.sub8bit.fa{i}.and2", [x1, carry]) + carry = self._g(f"arithmetic.sub8bit.fa{i}.or_carry", [and1, and2]) + d_lsb.append(x2) + return bits_to_int(list(reversed(d_lsb))), carry + + _CMP_KIND = {"greaterthan": "gt", "lessthan": "lt", "eq": "eq", "equality": "eq", + "greaterorequal": "ge", "lessorequal": "le"} + + def _cmp_bit_cascade(self, cmp_prefix: str, finals: Dict[str, str], + a_bits_msb: List[int], b_bits_msb: List[int], kind: str) -> int: + """Walk an add_bit_cascade_compare structure (bit 0 is the MSB).""" + bits = len(a_bits_msb) + bit_gt, bit_lt, bit_eq = [], [], [] + for i in range(bits): + ab = [a_bits_msb[i], b_bits_msb[i]] + bit_gt.append(self._g(f"{cmp_prefix}.bit{i}.gt", ab)) + bit_lt.append(self._g(f"{cmp_prefix}.bit{i}.lt", ab)) + eq_and = self._g(f"{cmp_prefix}.bit{i}.eq.layer1.and", ab) + eq_nor = self._g(f"{cmp_prefix}.bit{i}.eq.layer1.nor", ab) + bit_eq.append(self._g(f"{cmp_prefix}.bit{i}.eq", [eq_and, eq_nor])) + cas_gt, cas_lt = [bit_gt[0]], [bit_lt[0]] + for i in range(1, bits): + eq_pref = self._g(f"{cmp_prefix}.cascade.eq_prefix.bit{i}", bit_eq[:i]) + cas_gt.append(self._g(f"{cmp_prefix}.cascade.gt.bit{i}", [eq_pref, bit_gt[i]])) + cas_lt.append(self._g(f"{cmp_prefix}.cascade.lt.bit{i}", [eq_pref, bit_lt[i]])) + if kind == "gt": + return self._g(finals["gt"], cas_gt) + if kind == "lt": + return self._g(finals["lt"], cas_lt) + if kind == "eq": + return self._g(finals["eq"], bit_eq) + if kind == "ge": + lt = self._g(finals["lt"], cas_lt) + not_lt = self._g(finals["ge"] + ".not_lt", [lt]) + return self._g(finals["ge"], [not_lt]) + if kind == "le": + gt = self._g(finals["gt"], cas_gt) + not_gt = self._g(finals["le"] + ".not_gt", [gt]) + return self._g(finals["le"], [not_gt]) + raise ValueError(kind) + + def cmp8(self, a, b, kind): + return self.cmp_n(a, b, kind, 8) + + def mul8(self, a, b): + ab = int_to_bits(a, 8) + bb = int_to_bits(b, 8) + result = 0 + for j in range(8): + if bb[j] == 0: + continue + row = 0 + for i in range(8): + pp = self._g(f"alu.alu8bit.mul.pp.a{i}b{j}", [ab[i], bb[j]]) + row |= (pp << (7 - i)) + shift = 7 - j + result, _ = self.add8(result & 0xFF, (row << shift) & 0xFF) + return result & 0xFF + + def _packed_pair_op(self, name: str, a: int, b: int) -> int: + """8 parallel 2-input gates stored packed: weight [16], bias [8].""" + a_bits = int_to_bits(a, 8) + b_bits = int_to_bits(b, 8) + w = self.T[f"{name}.weight"].view(-1) + bias = self.T[f"{name}.bias"].view(-1) + out = [] + for bit in range(8): + inp = torch.tensor([float(a_bits[bit]), float(b_bits[bit])]) + out.append(int(heaviside((inp * w[bit * 2:bit * 2 + 2]).sum() + bias[bit]).item())) + return bits_to_int(out) + + def and8(self, a, b): + return self._packed_pair_op("alu.alu8bit.and", a, b) + + def or8(self, a, b): + return self._packed_pair_op("alu.alu8bit.or", a, b) + + def xor8(self, a, b): + a_bits = int_to_bits(a, 8) + b_bits = int_to_bits(b, 8) + w_or = self.T["alu.alu8bit.xor.layer1.or.weight"].view(-1) + b_or = self.T["alu.alu8bit.xor.layer1.or.bias"].view(-1) + w_nand = self.T["alu.alu8bit.xor.layer1.nand.weight"].view(-1) + b_nand = self.T["alu.alu8bit.xor.layer1.nand.bias"].view(-1) + w2 = self.T["alu.alu8bit.xor.layer2.weight"].view(-1) + b2 = self.T["alu.alu8bit.xor.layer2.bias"].view(-1) + out = [] + for bit in range(8): + inp = torch.tensor([float(a_bits[bit]), float(b_bits[bit])]) + h_or = heaviside((inp * w_or[bit * 2:bit * 2 + 2]).sum() + b_or[bit]) + h_nand = heaviside((inp * w_nand[bit * 2:bit * 2 + 2]).sum() + b_nand[bit]) + hidden = torch.stack([h_or, h_nand]) + out.append(int(heaviside((hidden * w2[bit * 2:bit * 2 + 2]).sum() + b2[bit]).item())) + return bits_to_int(out) + + def shl8(self, a): + a_bits = int_to_bits(a, 8) + out = [] + for bit in range(8): + src = float(a_bits[bit + 1]) if bit < 7 else 0.0 + out.append(self._g(f"alu.alu8bit.shl.bit{bit}", [src])) + return bits_to_int(out) + + def shr8(self, a): + a_bits = int_to_bits(a, 8) + out = [] + for bit in range(8): + src = float(a_bits[bit - 1]) if bit > 0 else 0.0 + out.append(self._g(f"alu.alu8bit.shr.bit{bit}", [src])) + return bits_to_int(out) + + def div8(self, a, b): + """8-bit restoring division through the per-stage bit-cascade GE comparators and the sub8 subtractor.""" + if b == 0: + return 0xFF, a + a_bits = int_to_bits(a, 8) + div_bits = int_to_bits(b, 8) + quotient = 0 + remainder = 0 + for stage in range(8): + remainder = ((remainder << 1) | a_bits[stage]) & 0xFF + prefix = f"alu.alu8bit.div.stage{stage}" + finals = { + "gt": f"{prefix}.cmp_bc.gt", + "lt": f"{prefix}.cmp_bc.lt", + "eq": f"{prefix}.cmp_bc.eq", + "ge": f"{prefix}.cmp", + "le": f"{prefix}.cmp_bc.le", + } + ge = self._cmp_bit_cascade(f"{prefix}.cmp_bc", finals, + int_to_bits(remainder, 8), div_bits, "ge") + if ge: + remainder, _ = self.sub8(remainder, b) + quotient = (quotient << 1) | 1 + else: + quotient = quotient << 1 + return quotient & 0xFF, remainder & 0xFF + + # ----- N-bit primitives (for 16-bit and 32-bit variants) ---------------- + + def add_n(self, a: int, b: int, bits: int): + """Width-generic ripple-carry add via arithmetic.ripplecarry{N}bit.""" + prefix = f"arithmetic.ripplecarry{bits}bit" + a_lsb = list(reversed(int_to_bits(a, bits))) + b_lsb = list(reversed(int_to_bits(b, bits))) + carry = 0 + s_lsb = [] + for i in range(bits): + s, carry = self._fa(f"{prefix}.fa{i}", a_lsb[i], b_lsb[i], carry) + s_lsb.append(s) + return bits_to_int(list(reversed(s_lsb))), carry + + def sub_n(self, a: int, b: int, bits: int): + """N-bit two's-complement subtract via arithmetic.sub{N}bit (N >= 16).""" + prefix = f"arithmetic.sub{bits}bit" + a_lsb = list(reversed(int_to_bits(a, bits))) + b_lsb = list(reversed(int_to_bits(b, bits))) + # NOT each B bit + notb = [self._g(f"{prefix}.not_b.bit{i}", [b_lsb[i]]) for i in range(bits)] + carry = 1 # carry-in = 1 for two's-complement + d_lsb = [] + for i in range(bits): + s, carry = self._fa(f"{prefix}.fa{i}", a_lsb[i], notb[i], carry) + d_lsb.append(s) + return bits_to_int(list(reversed(d_lsb))), carry + + def cmp_n(self, a: int, b: int, kind: str, bits: int): + """N-bit unsigned comparison through the bit-cascade comparator.""" + short = self._CMP_KIND[kind] + finals = { + "gt": f"arithmetic.greaterthan{bits}bit", + "lt": f"arithmetic.lessthan{bits}bit", + "eq": f"arithmetic.equality{bits}bit", + "ge": f"arithmetic.greaterorequal{bits}bit", + "le": f"arithmetic.lessorequal{bits}bit", + } + return self._cmp_bit_cascade(f"arithmetic.cmp{bits}bit", finals, + int_to_bits(a, bits), int_to_bits(b, bits), short) + + def mul_n(self, a: int, b: int, bits: int): + """N-bit shift-add multiply (low N bits only).""" + ab = int_to_bits(a, bits) + bb = int_to_bits(b, bits) + mask = (1 << bits) - 1 + result = 0 + for j in range(bits): + if bb[j] == 0: + continue + row = 0 + for i in range(bits): + pp = self._g(f"alu.alu{bits}bit.mul.pp.a{i}b{j}", [ab[i], bb[j]]) + row |= (pp << (bits - 1 - i)) + shift = (bits - 1) - j + result, _ = self.add_n(result & mask, (row << shift) & mask, bits) + return result & mask + + +class GenericThresholdCPU: + """Variant-aware CPU runtime. Sized from the variant's manifest.""" + + def __init__(self, tensors: Dict[str, torch.Tensor]): + self.T = tensors + m = get_manifest(tensors) + self.data_bits = m["data_bits"] + self.addr_bits = m["addr_bits"] + self.mem_bytes = m["memory_bytes"] + # 8-bit CPU primitives (ripplecarry8bit, sub8bit, alu.alu8bit.*, memory.*, + # control.*) are present in every variant regardless of manifest data_bits. + # Wider data widths simply add additional standalone ALU primitives. + if self.mem_bytes == 0: + raise NotImplementedError( + "Pure-ALU variants have no memory; cannot run CPU programs" + ) + self.alu = GenericThresholdALU(tensors, 8) + + def _addr_decode(self, addr): + bits = torch.tensor(int_to_bits(addr, self.addr_bits), dtype=torch.float32) + w = self.T["memory.addr_decode.weight"] + b = self.T["memory.addr_decode.bias"] + return heaviside((w * bits).sum(dim=1) + b) + + def mem_read(self, mem, addr): + sel = self._addr_decode(addr) + mem_bits = torch.tensor( + [int_to_bits(byte, 8) for byte in mem], dtype=torch.float32 + ) + and_w = self.T["memory.read.and.weight"] + and_b = self.T["memory.read.and.bias"] + or_w = self.T["memory.read.or.weight"] + or_b = self.T["memory.read.or.bias"] + out = [] + for bit in range(8): + inp = torch.stack([mem_bits[:, bit], sel], dim=1) + and_out = heaviside((inp * and_w[bit]).sum(dim=1) + and_b[bit]) + out.append(int(heaviside((and_out * or_w[bit]).sum() + or_b[bit]).item())) + return bits_to_int(out) + + def mem_write(self, mem, addr, value): + sel = self._addr_decode(addr) + data_bits = torch.tensor(int_to_bits(value, 8), dtype=torch.float32) + mem_bits = torch.tensor( + [int_to_bits(byte, 8) for byte in mem], dtype=torch.float32 + ) + sel_w = self.T["memory.write.sel.weight"] + sel_b = self.T["memory.write.sel.bias"] + nsel_w = self.T["memory.write.nsel.weight"].squeeze(1) + nsel_b = self.T["memory.write.nsel.bias"] + and_old_w = self.T["memory.write.and_old.weight"] + and_old_b = self.T["memory.write.and_old.bias"] + and_new_w = self.T["memory.write.and_new.weight"] + and_new_b = self.T["memory.write.and_new.bias"] + or_w = self.T["memory.write.or.weight"] + or_b = self.T["memory.write.or.bias"] + we = torch.ones_like(sel) + sel_inp = torch.stack([sel, we], dim=1) + write_sel = heaviside((sel_inp * sel_w).sum(dim=1) + sel_b) + nsel = heaviside(write_sel * nsel_w + nsel_b) + for bit in range(8): + old = mem_bits[:, bit] + data_bit = data_bits[bit].expand(self.mem_bytes) + inp_old = torch.stack([old, nsel], dim=1) + inp_new = torch.stack([data_bit, write_sel], dim=1) + and_old = heaviside((inp_old * and_old_w[:, bit]).sum(dim=1) + and_old_b[:, bit]) + and_new = heaviside((inp_new * and_new_w[:, bit]).sum(dim=1) + and_new_b[:, bit]) + or_inp = torch.stack([and_old, and_new], dim=1) + new_bit = heaviside((or_inp * or_w[:, bit]).sum(dim=1) + or_b[:, bit]) + mem_bits[:, bit] = new_bit + return [bits_to_int([int(b) for b in mem_bits[i].tolist()]) for i in range(self.mem_bytes)] + + # Conditions 0..7 map to (mux circuit, flag index into [Z, N, C, V]). + # Odd conditions are the negated forms; the mux select is the complemented + # flag (the circuits are plain per-bit muxes -- see control.* .inputs, + # which name the select $not_zero / $not_carry / ... for the odd forms). + _JCC = [("control.jz", 0), ("control.jnz", 0), ("control.jc", 2), ("control.jnc", 2), + ("control.jn", 1), ("control.jp", 1), ("control.jv", 3), ("control.jnv", 3)] + + def _jcc_pc(self, circuit: str, pc: int, target: int, sel: int) -> int: + """Per-bit 2:1 mux over the PC: sel ? target : pc, addr_bits wide.""" + pc_bits = int_to_bits(pc, self.addr_bits) + t_bits = int_to_bits(target, self.addr_bits) + out = [] + for bit in range(self.addr_bits): + bp = f"{circuit}.bit{bit}" + not_sel = self.alu._g(f"{bp}.not_sel", [float(sel)]) + and_a = self.alu._g(f"{bp}.and_a", [float(pc_bits[bit]), not_sel]) + and_b = self.alu._g(f"{bp}.and_b", [float(t_bits[bit]), float(sel)]) + out.append(self.alu._g(f"{bp}.or", [and_a, and_b])) + return bits_to_int(out) + + def _sp_dec(self, sp: int) -> int: + """SP - 1 through the control.push.sp_dec borrow chain.""" + bits = int_to_bits(sp, self.addr_bits) + out = [0] * self.addr_bits + borrow = 1 + for bit in range(self.addr_bits - 1, -1, -1): + bp = f"control.push.sp_dec.bit{bit}" + h_or = self.alu._g(f"{bp}.xor.layer1.or", [float(bits[bit]), float(borrow)]) + h_nand = self.alu._g(f"{bp}.xor.layer1.nand", [float(bits[bit]), float(borrow)]) + out[bit] = self.alu._g(f"{bp}.xor.layer2", [h_or, h_nand]) + borrow = self.alu._g(f"{bp}.borrow", [float(1 - bits[bit]), float(borrow)]) + return bits_to_int(out) + + def _decode_op(self, opcode): + """4-to-16 opcode one-hot through the gate decoder.""" + ob = [float((opcode >> j) & 1) for j in range(4)] + return [self.alu._g(f"control.decode.op{n}", ob) for n in range(16)] + + def _inc(self, pc, name, start): + """PC + 2^start through the gate increment chain (LSB-first).""" + ab = self.addr_bits + pcb = [(pc >> k) & 1 for k in range(ab)] + out = [0] * ab + carry = 0 + for k in range(ab): + if k < start: + out[k] = self.alu._g(f"control.pcnext.{name}.bit{k}", [float(pcb[k])]) + else: + cin = 1 if k == start else carry + o = self.alu._g(f"control.pcnext.{name}.bit{k}.xor.layer1.or", [float(pcb[k]), float(cin)]) + nd = self.alu._g(f"control.pcnext.{name}.bit{k}.xor.layer1.nand", [float(pcb[k]), float(cin)]) + out[k] = self.alu._g(f"control.pcnext.{name}.bit{k}.xor.layer2", [o, nd]) + carry = self.alu._g(f"control.pcnext.{name}.bit{k}.carry", [float(pcb[k]), float(cin)]) + return sum(out[k] << k for k in range(ab)) + + def _pcnext(self, pc2, pc4, addr, jcc, use_pc4, use_addr, use_jcc): + """Next-PC priority mux through the gates: use_addr ? addr : (use_jcc ? jcc : (use_pc4 ? pc4 : pc2)).""" + ab = self.addr_bits + p2 = [(pc2 >> k) & 1 for k in range(ab)] + p4 = [(pc4 >> k) & 1 for k in range(ab)] + aB = [(addr >> k) & 1 for k in range(ab)] + jB = [(jcc >> k) & 1 for k in range(ab)] + out = [0] * ab + for k in range(ab): + ns = self.alu._g(f"control.pcnext.m_pc4.bit{k}.not_sel", [float(use_pc4)]) + aa = self.alu._g(f"control.pcnext.m_pc4.bit{k}.and_a", [float(p2[k]), ns]) + bb = self.alu._g(f"control.pcnext.m_pc4.bit{k}.and_b", [float(p4[k]), float(use_pc4)]) + m1 = self.alu._g(f"control.pcnext.m_pc4.bit{k}.or", [aa, bb]) + ns = self.alu._g(f"control.pcnext.m_jcc.bit{k}.not_sel", [float(use_jcc)]) + aa = self.alu._g(f"control.pcnext.m_jcc.bit{k}.and_a", [float(m1), ns]) + bb = self.alu._g(f"control.pcnext.m_jcc.bit{k}.and_b", [float(jB[k]), float(use_jcc)]) + m2 = self.alu._g(f"control.pcnext.m_jcc.bit{k}.or", [aa, bb]) + ns = self.alu._g(f"control.pcnext.m_addr.bit{k}.not_sel", [float(use_addr)]) + aa = self.alu._g(f"control.pcnext.m_addr.bit{k}.and_a", [float(m2), ns]) + bb = self.alu._g(f"control.pcnext.m_addr.bit{k}.and_b", [float(aB[k]), float(use_addr)]) + out[k] = self.alu._g(f"control.pcnext.m_addr.bit{k}.or", [aa, bb]) + return sum(out[k] << k for k in range(ab)) + + def step(self, state): + if state["halted"]: + return state + s = dict(state) + s["mem"] = state["mem"][:] + s["regs"] = state["regs"][:] + s["flags"] = state["flags"][:] + addr_mask = (1 << self.addr_bits) - 1 + pc = s["pc"] + hi = self.mem_read(s["mem"], pc & addr_mask) + lo = self.mem_read(s["mem"], (pc + 1) & addr_mask) + ir = ((hi & 0xFF) << 8) | (lo & 0xFF) + opcode = (ir >> 12) & 0xF + oh = self._decode_op(opcode) # gate opcode decode (one-hots) + rd = (ir >> 10) & 0x3 + rs = (ir >> 8) & 0x3 + imm = ir & 0xFF + addr = None + if oh[0xA] or oh[0xB] or oh[0xC] or oh[0xD] or oh[0xE]: + ah = self.mem_read(s["mem"], (pc + 2) & addr_mask) + al = self.mem_read(s["mem"], (pc + 3) & addr_mask) + addr = (((ah & 0xFF) << 8) | (al & 0xFF)) & addr_mask + pc2 = self._inc(pc, "pc2", 1) # PC+2 through the gate chain + pc4 = self._inc(pc, "pc4", 2) # PC+4 through the gate chain + a = s["regs"][rd] + b = s["regs"][rs] + result = a + carry = 0 + overflow = 0 + write_result = True + jcc_val = 0 + if oh[0x0]: + result, carry = self.alu.add8(a, b) + overflow = 1 if (((a ^ result) & (b ^ result)) & 0x80) else 0 + elif oh[0x1]: + result, carry = self.alu.sub8(a, b) + overflow = 1 if (((a ^ b) & (a ^ result)) & 0x80) else 0 + elif oh[0x2]: + result = self.alu.and8(a, b) + elif oh[0x3]: + result = self.alu.or8(a, b) + elif oh[0x4]: + result = self.alu.xor8(a, b) + elif oh[0x5]: + result = self.alu.shl8(a) + elif oh[0x6]: + result = self.alu.shr8(a) + elif oh[0x7]: + result = self.alu.mul8(a, b) + elif oh[0x8]: + result, _ = self.alu.div8(a, b) + elif oh[0x9]: # CMP: set flags only + r2, carry = self.alu.sub8(a, b) + z = 1 if r2 == 0 else 0 + n = 1 if (r2 & 0x80) else 0 + v = 1 if (((a ^ b) & (a ^ r2)) & 0x80) else 0 + s["flags"] = [z, n, carry, v] + write_result = False + elif oh[0xA]: # LOAD + result = self.mem_read(s["mem"], addr) + elif oh[0xB]: # STORE + s["mem"] = self.mem_write(s["mem"], addr, b & 0xFF) + write_result = False + elif oh[0xC]: # JMP (PC = addr via pcnext) + write_result = False + elif oh[0xD]: # JCC (PC via jcc mux + pcnext) + cond = imm & 0x7 + circuit, flag_idx = self._JCC[cond] + flag = s["flags"][flag_idx] + sel = flag if cond % 2 == 0 else 1 - flag + jcc_val = self._jcc_pc(circuit, pc4, addr, sel) + write_result = False + elif oh[0xE]: # CALL: push PC+4, PC = addr + ret_addr = pc4 & 0xFFFF + sp = s.get("sp", addr_mask) + sp = self._sp_dec(sp) + s["mem"] = self.mem_write(s["mem"], sp, (ret_addr >> 8) & 0xFF) + sp = self._sp_dec(sp) + s["mem"] = self.mem_write(s["mem"], sp, ret_addr & 0xFF) + s["sp"] = sp + write_result = False + elif oh[0xF]: # HALT + s["halted"] = True + return s + + if write_result: + s["regs"][rd] = result & 0xFF + if oh[0x0] or oh[0x1] or oh[0x7]: + z = 1 if (result & 0xFF) == 0 else 0 + n = 1 if (result & 0x80) else 0 + s["flags"] = [z, n, carry, overflow] + # PC sequencing through the gate mux. + use_pc4 = 1 if (oh[0xA] or oh[0xB]) else 0 + use_addr = 1 if (oh[0xC] or oh[0xE]) else 0 + s["pc"] = self._pcnext(pc2, pc4, addr or 0, jcc_val, use_pc4, use_addr, oh[0xD]) + return s + + def run(self, state, max_cycles=200): + s = state + cycles = 0 + while not s["halted"] and cycles < max_cycles: + s = self.step(s) + cycles += 1 + return s, cycles + + +def _encode_instr(opcode, rd, rs, imm): + return ((opcode & 0xF) << 12) | ((rd & 0x3) << 10) | ((rs & 0x3) << 8) | (imm & 0xFF) + + +def _w16(mem, addr, value): + mem[addr] = (value >> 8) & 0xFF + mem[addr + 1] = value & 0xFF + + +PROGRAM_MIN_BYTES = 0x84 # code 0x00..0x1F + data 0x80..0x83 + + +def builtin_program(addr_bits: int) -> Tuple[List[int], int]: + """Sum 5+4+3+2+1 via a loop.""" + if (1 << addr_bits) < PROGRAM_MIN_BYTES: + raise ValueError(f"addr_bits={addr_bits} too small for builtin program") + mem = [0] * (1 << addr_bits) + mem[0x80] = 5 # initial counter + mem[0x81] = 1 # decrement + mem[0x82] = 0 # zero (for compare and accumulator init) + # mem[0x83] is the output + _w16(mem, 0x0000, _encode_instr(0xA, 1, 0, 0)); _w16(mem, 0x0002, 0x0080) + _w16(mem, 0x0004, _encode_instr(0xA, 2, 0, 0)); _w16(mem, 0x0006, 0x0081) + _w16(mem, 0x0008, _encode_instr(0xA, 3, 0, 0)); _w16(mem, 0x000A, 0x0082) + _w16(mem, 0x000C, _encode_instr(0xA, 0, 0, 0)); _w16(mem, 0x000E, 0x0082) + _w16(mem, 0x0010, _encode_instr(0x0, 0, 1, 0)) + _w16(mem, 0x0012, _encode_instr(0x1, 1, 2, 0)) + _w16(mem, 0x0014, _encode_instr(0x9, 1, 3, 0)) + _w16(mem, 0x0016, _encode_instr(0xD, 0, 0, 0x01)); _w16(mem, 0x0018, 0x0010) + _w16(mem, 0x001A, _encode_instr(0xB, 0, 0, 0)); _w16(mem, 0x001C, 0x0083) + _w16(mem, 0x001E, _encode_instr(0xF, 0, 0, 0)) + return mem, 15 + + +# --------------------------------------------------------------------------- +# Eval driver +# --------------------------------------------------------------------------- + +def _file_fingerprint(path: Path) -> str: + """Stable cache key for a safetensors file: sha256 of its content.""" + import hashlib + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1 << 20), b""): + h.update(chunk) + return h.hexdigest() + + +def _cache_key(path: Path, opts: Dict[str, Any]) -> str: + """Cache key combining file content with the relevant evaluation options.""" + fp = _file_fingerprint(path) + opt_str = json.dumps(opts, sort_keys=True) + import hashlib + suffix = hashlib.sha256(opt_str.encode("utf-8")).hexdigest()[:8] + return f"{fp}_{suffix}" + + +def _load_cache(cache_dir: Path, key: str) -> Dict[str, Any] | None: + p = cache_dir / f"{key}.json" + if not p.exists(): + return None + try: + return json.loads(p.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return None + + +def _save_cache(cache_dir: Path, key: str, payload: Dict[str, Any]) -> None: + cache_dir.mkdir(parents=True, exist_ok=True) + p = cache_dir / f"{key}.json" + try: + p.write_text(json.dumps(payload, indent=2, default=str), encoding="utf-8") + except OSError: + pass + + +def list_safetensors(path: Path) -> List[Path]: + if path.is_file(): + return [path] + if path.is_dir(): + return sorted(p for p in path.glob("*.safetensors") if p.is_file()) + return [] + + +# Standalone machines carry their own circuit inventory and are verified by +# their own scripts, not the gate-fitness suite. Each maps to the tool that +# checks it. +MACHINE_VERIFIER = { + "subleq8": "src/machines.py", + "rv32": "src/machines.py", + "matrix8": "src/machines.py matrix8", + "subleq8io": "src/host.py constructor8", + "reflect": "src/reflect.py", + "attractor": "src/automata.py attractor", + "reversible": "src/automata.py reversible", + "ca": "src/automata.py ca", + "tile": "src/automata.py tile", +} + + +def evaluate_one(path: Path, device: str, pop_size: int, debug: bool, run_cpu_program: bool) -> Dict: + out: Dict = {"path": str(path), "filename": path.name} + # Skip standalone machines cleanly rather than error on missing standard gates. + with safe_open(str(path), framework="pt") as f: + meta = f.metadata() or {} + if meta.get("machine"): + m = meta["machine"] + out.update(status="SKIP", machine=m, + note=f"standalone machine — verify with {MACHINE_VERIFIER.get(m, 'machines.py')}") + return out + try: + tensors = load_model(str(path)) + except Exception as e: + out.update(error=f"load failed: {e}", status="ERROR") + return out + + manifest = get_manifest(tensors) + out.update( + size_mb=path.stat().st_size / (1024 * 1024), + tensors=len(tensors), + params=sum(t.numel() for t in tensors.values()), + manifest=manifest, + ) + + # Move to device + tensors = {k: v.to(device) for k, v in tensors.items()} + + try: + evaluator = BatchedFitnessEvaluator(device=device, model_path=str(path), tensors=tensors) + population = create_population(tensors, pop_size=pop_size, device=device) + t0 = time.perf_counter() + fitness = evaluator.evaluate(population, debug=debug) + elapsed = time.perf_counter() - t0 + f0 = float(fitness[0].item()) if pop_size == 1 else float(fitness.mean().item()) + out.update( + fitness=f0, + total_tests=evaluator.total_tests, + elapsed_s=elapsed, + categories={k: (float(v[0]), int(v[1])) for k, v in evaluator.category_scores.items()}, + status="PASS" if f0 >= 0.9999 else "FAIL", + ) + except Exception as e: + out.update(error=f"eval failed: {type(e).__name__}: {e}", status="ERROR") + return out + + # Optional: CPU program test (8-bit CPU primitives are in every variant) + if run_cpu_program: + if manifest["memory_bytes"] >= PROGRAM_MIN_BYTES: + try: + cpu_tensors = {k: v.cpu() for k, v in tensors.items()} + cpu = GenericThresholdCPU(cpu_tensors) + mem, expected = builtin_program(manifest["addr_bits"]) + state = {"pc": 0, "regs": [0] * 4, "flags": [0] * 4, "mem": mem, "halted": False} + t0 = time.perf_counter() + final, cycles = cpu.run(state, max_cycles=200) + cpu_elapsed = time.perf_counter() - t0 + got = final["mem"][0x83] + out["cpu_program"] = { + "ok": got == expected, + "got": got, + "expected": expected, + "cycles": cycles, + "elapsed_s": cpu_elapsed, + } + if got != expected: + out["status"] = "FAIL" + except Exception as e: + out["cpu_program"] = {"error": str(e)} + else: + out["cpu_program"] = {"skipped": f"mem={manifest['memory_bytes']}B < {PROGRAM_MIN_BYTES}"} + + # Wider-ALU chain test for 16/32-bit variants + bits = manifest["data_bits"] + if bits in (16, 32): + try: + alu_tensors = {k: v.cpu() for k, v in tensors.items()} + alu = GenericThresholdALU(alu_tensors, bits) + t0 = time.perf_counter() + if bits == 16: + x, y = 1234, 5678 + z, _ = alu.add_n(x, y, 16); assert z == (x + y) & 0xFFFF + w, _ = alu.sub_n(z, x, 16); assert w == (z - x) & 0xFFFF, (w, z - x) + gt = alu.cmp_n(z, x, "greaterthan", 16); assert gt == 1 + lt = alu.cmp_n(x, z, "lessthan", 16); assert lt == 1 + eq = alu.cmp_n(w, y, "eq", 16); assert eq == 1 + p = alu.mul_n(123, 5, 16); assert p == (123 * 5) & 0xFFFF + else: # 32 + x, y = 1_000_000, 999_000 + z, _ = alu.sub_n(x, y, 32); assert z == 1_000 + s, _ = alu.add_n(z, x, 32); assert s == 1_001_000 + p = alu.mul_n(z, 100, 32); assert p == 100_000 + gt = alu.cmp_n(x, y, "greaterthan", 32); assert gt == 1 + lt = alu.cmp_n(y, x, "lessthan", 32); assert lt == 1 + eq = alu.cmp_n(p, 100_000, "equality", 32); assert eq == 1 + chain_dt = time.perf_counter() - t0 + out[f"alu_chain_{bits}"] = {"ok": True, "elapsed_s": chain_dt} + except AssertionError as e: + out[f"alu_chain_{bits}"] = {"ok": False, "error": f"chain mismatch: {e}"} + out["status"] = "FAIL" + except Exception as e: + out[f"alu_chain_{bits}"] = {"ok": False, "error": f"{type(e).__name__}: {e}"} + out["status"] = "FAIL" + + return out + + +def print_row(r: Dict, show_cpu: bool) -> None: + if r.get("status") == "SKIP": + m = r.get("machine", "machine") + print(f" {r['filename']:<48} SKIP ({m} — verify with {MACHINE_VERIFIER.get(m, 'machines.py')})") + return + if "error" in r: + print(f" {r['filename']:<48} ERROR: {r['error'][:80]}") + return + m = r["manifest"] + fit = f"{r['fitness']:.4f}" if r.get("fitness") is not None else "n/a" + cpu_col = "" + if show_cpu and "cpu_program" in r: + cp = r["cpu_program"] + if cp.get("ok"): + cpu_col = f" CPU OK ({cp['cycles']}cyc/{cp['elapsed_s']:.1f}s)" + elif "skipped" in cp: + cpu_col = f" CPU SKIP" + elif "error" in cp: + cpu_col = f" CPU ERR" + else: + cpu_col = f" CPU FAIL ({cp.get('got')}!={cp.get('expected')})" + chain_col = "" + if show_cpu: + for bits in (16, 32): + key = f"alu_chain_{bits}" + if key in r: + ch = r[key] + if ch.get("ok"): + chain_col = f" ALU{bits} OK ({ch['elapsed_s']:.2f}s)" + else: + chain_col = f" ALU{bits} FAIL" + print( + f" {r['filename']:<48} d={m['data_bits']:>2}b a={m['addr_bits']:>2}b " + f"mem={m['memory_bytes']:>6}B size={r['size_mb']:>6.1f}MB " + f"params={r['params']:>10,} fit={fit:>6} tests={r['total_tests']:>5} " + f"{r['status']:>5}{cpu_col}{chain_col}" + ) + + +def main_all() -> int: + parser = argparse.ArgumentParser(description="Variant-agnostic eval harness") + parser.add_argument("path", help="Path to .safetensors file or directory of files") + parser.add_argument("--device", default="cpu", help="cpu (default) or cuda") + parser.add_argument("--pop_size", type=int, default=1) + parser.add_argument("--debug", action="store_true", help="Per-circuit detail per file") + parser.add_argument("--cpu-program", action="store_true", + help="Also run a small assembled program through the threshold CPU " + "(any variant with >= 132 B memory), plus a chained 16- or " + "32-bit ALU sequence on wider variants") + parser.add_argument("--json", action="store_true", help="Emit JSON results to stdout instead of a table") + parser.add_argument("--cache-dir", default=".eval_cache", + help="Directory for hash-keyed result cache " + "(default: ./.eval_cache). Set to '' to disable.") + parser.add_argument("--no-cache", action="store_true", + help="Disable the result cache for this run.") + args = parser.parse_args() + + files = list_safetensors(Path(args.path)) + if not files: + print(f"No .safetensors files found under {args.path}", file=sys.stderr) + return 2 + + print(f"Evaluating {len(files)} file(s) on {args.device}\n") + cache_enabled = bool(args.cache_dir) and not args.no_cache + cache_dir = Path(args.cache_dir) if cache_enabled else None + cache_opts = { + "device": args.device, + "pop_size": args.pop_size, + "cpu_program": bool(args.cpu_program), + } + cache_hits = 0 + results = [] + fail_count = 0 + for f in files: + print(f"=== {f.name}") + cached = None + key = None + if cache_enabled: + try: + key = _cache_key(f, cache_opts) + cached = _load_cache(cache_dir, key) + except OSError: + cached = None + if cached is not None: + r = cached + cache_hits += 1 + print(f" (cache hit)") + else: + r = evaluate_one(f, device=args.device, pop_size=args.pop_size, + debug=args.debug, run_cpu_program=args.cpu_program) + # Don't cache ERROR results: a transient failure (OOM, interrupt) + # would otherwise be replayed on every rerun of the same file. + if cache_enabled and key is not None and r.get("status") != "ERROR": + _save_cache(cache_dir, key, r) + results.append(r) + print_row(r, show_cpu=args.cpu_program) + if r.get("status") not in ("PASS", "SKIP"): + fail_count += 1 + + if args.json: + # Make it serialisable + for r in results: + r["manifest"] = {k: (int(v) if isinstance(v, float) and v.is_integer() else v) + for k, v in r.get("manifest", {}).items()} + print(json.dumps(results, indent=2, default=str)) + return fail_count + + # Summary + print() + print("=" * 100) + print(" SUMMARY") + print("=" * 100) + for r in results: + print_row(r, show_cpu=args.cpu_program) + + print() + if fail_count == 0: + print(f"ALL {len(files)} variants PASS") + else: + print(f"{fail_count}/{len(files)} variants FAIL") + if cache_enabled: + print(f"(cache: {cache_hits}/{len(files)} hits, dir={cache_dir})") + return fail_count + + +# ---------------------------------------------------------------------------- +# Mini assembler +# ---------------------------------------------------------------------------- + +_OPCODE_NAMES = { + "add": 0x0, "sub": 0x1, "and": 0x2, "or": 0x3, "xor": 0x4, + "shl": 0x5, "shr": 0x6, "mul": 0x7, "div": 0x8, "cmp": 0x9, + "load": 0xA, "store": 0xB, "jmp": 0xC, "jcc": 0xD, + "call": 0xE, "halt": 0xF, +} + +_COND = {"jz": 0, "jnz": 1, "jc": 2, "jnc": 3, + "jn": 4, "jp": 5, "jv": 6, "jnv": 7} + + +def _enc(opcode: int, rd: int = 0, rs: int = 0, imm: int = 0) -> int: + return ((opcode & 0xF) << 12) | ((rd & 0x3) << 10) | ((rs & 0x3) << 8) | (imm & 0xFF) + + +class Asm: + """Tiny assembler for the threshold-computer ISA.""" + + def __init__(self, size: int): + self.mem: List[int] = [0] * size + self.pc: int = 0 + self.labels: Dict[str, int] = {} + self.fixups: List[Tuple[int, str]] = [] + + def org(self, addr: int) -> None: + self.pc = addr + + def label(self, name: str) -> None: + if name in self.labels: + raise ValueError(f"duplicate label: {name}") + self.labels[name] = self.pc + + def db(self, *values: int) -> None: + for v in values: + self.mem[self.pc] = v & 0xFF + self.pc += 1 + + def dw(self, value: int) -> None: + self.mem[self.pc] = (value >> 8) & 0xFF + self.mem[self.pc + 1] = value & 0xFF + self.pc += 2 + + def daddr(self, label: str) -> None: + self.fixups.append((self.pc, label)) + self.dw(0) + + # --- ALU ops (no immediate) --- + def _alu(self, op: int, rd: int, rs: int) -> None: + self.dw(_enc(op, rd, rs)) + + def add(self, rd: int, rs: int) -> None: self._alu(0x0, rd, rs) + def sub(self, rd: int, rs: int) -> None: self._alu(0x1, rd, rs) + def and_(self, rd: int, rs: int) -> None: self._alu(0x2, rd, rs) + def or_(self, rd: int, rs: int) -> None: self._alu(0x3, rd, rs) + def xor(self, rd: int, rs: int) -> None: self._alu(0x4, rd, rs) + def shl(self, rd: int) -> None: self._alu(0x5, rd, 0) + def shr(self, rd: int) -> None: self._alu(0x6, rd, 0) + def mul(self, rd: int, rs: int) -> None: self._alu(0x7, rd, rs) + def cmp(self, rd: int, rs: int) -> None: self._alu(0x9, rd, rs) + + # --- Memory + control (address-extended) --- + def load(self, rd: int, label: str) -> None: + self.dw(_enc(0xA, rd, 0)); self.daddr(label) + + def store(self, rs: int, label: str) -> None: + self.dw(_enc(0xB, 0, rs)); self.daddr(label) + + def jmp(self, label: str) -> None: + self.dw(_enc(0xC)); self.daddr(label) + + def jcc(self, cond: str, label: str) -> None: + self.dw(_enc(0xD, 0, 0, _COND[cond])); self.daddr(label) + + def jz(self, label: str) -> None: self.jcc("jz", label) + def jnz(self, label: str) -> None: self.jcc("jnz", label) + def jc(self, label: str) -> None: self.jcc("jc", label) + def jnc(self, label: str) -> None: self.jcc("jnc", label) + def jn(self, label: str) -> None: self.jcc("jn", label) + def jp(self, label: str) -> None: self.jcc("jp", label) + def jv(self, label: str) -> None: self.jcc("jv", label) + def jnv(self, label: str) -> None: self.jcc("jnv", label) + + def call(self, label: str) -> None: + self.dw(_enc(0xE)); self.daddr(label) + + def halt(self) -> None: + self.dw(_enc(0xF)) + + def assemble(self) -> List[int]: + for offset, label in self.fixups: + if label not in self.labels: + raise ValueError(f"undefined label: {label}") + target = self.labels[label] + self.mem[offset] = (target >> 8) & 0xFF + self.mem[offset + 1] = target & 0xFF + return self.mem + + +# ---------------------------------------------------------------------------- +# Programs +# ---------------------------------------------------------------------------- + +ProgramResult = Tuple[List[int], Dict[int, int], int, str] + + +def fib(n: int = 11, mem_size: int = 256) -> ProgramResult: + """Iterative Fibonacci F(N), 8-bit wrap.""" + a = Asm(mem_size) + + a.org(0) + a.load(2, "n_addr") # R2 = n + a.load(0, "zero_addr") # R0 = 0 = F(0) + a.load(1, "one_addr") # R1 = 1 = F(1) + a.label("loop") + a.load(3, "zero_addr") # R3 = 0 + a.cmp(2, 3) # n == 0? + a.jz("done") + a.load(3, "zero_addr") # R3 = 0 + a.add(3, 1) # R3 = b (saved old b) + a.add(1, 0) # R1 = a + b (new b) + a.load(0, "zero_addr") # R0 = 0 + a.add(0, 3) # R0 = old b (new a) + a.load(3, "one_addr") # R3 = 1 + a.sub(2, 3) # n-- + a.jmp("loop") + a.label("done") + a.store(0, "out") # OUT = a + a.halt() + + a.org(0x80) + a.label("zero_addr"); a.db(0) + a.label("one_addr"); a.db(1) + a.label("n_addr"); a.db(n) + a.label("out"); a.db(0) + + mem = a.assemble() + expected_a = 0 + aa, bb = 0, 1 + for _ in range(n): + aa, bb = bb, (aa + bb) & 0xFF + expected_a = aa + return mem, {a.labels["out"]: expected_a}, 16 * (n + 2), f"Fibonacci F({n}) = {expected_a}" + + +def sum_n(n: int = 10, mem_size: int = 256) -> ProgramResult: + """Compute 1 + 2 + ... + N using the Z flag from SUB to terminate.""" + a = Asm(mem_size) + + a.org(0) + a.load(0, "zero_addr") # acc = 0 + a.load(1, "n_addr") # i = n + a.load(2, "one_addr") # 1 + a.label("loop") + a.add(0, 1) # acc += i + a.sub(1, 2) # i-- (Z set when i becomes 0) + a.jnz("loop") + a.store(0, "out") + a.halt() + + a.org(0x80) + a.label("zero_addr"); a.db(0) + a.label("one_addr"); a.db(1) + a.label("n_addr"); a.db(n) + a.label("out"); a.db(0) + + mem = a.assemble() + expected = (n * (n + 1) // 2) & 0xFF + return mem, {a.labels["out"]: expected}, 4 + 4 * n, f"sum 1..{n} = {expected}" + + +def self_mod_jmp(mem_size: int = 256) -> ProgramResult: + """Self-modifying code: writes the JMP target's low byte at runtime.""" + a = Asm(mem_size) + + a.org(0) + a.label("start") + + # Forward-declare the_jmp's LSB address. The first two instructions are + # each 4 bytes; the_jmp follows them, so the_jmp = pc + 8 from start. + # The JMP's address word is at the_jmp + 2; the LSB byte is at the_jmp + 3. + a.labels["jmp_target_lsb"] = a.pc + 8 + 3 + + a.load(0, "new_lsb") # R0 = LSB of path_b address (4 bytes) + a.store(0, "jmp_target_lsb") # patch the JMP's LSB (4 bytes) + a.label("the_jmp") + a.jmp("path_a") # initially -> path_a; after patch -> path_b + a.label("path_a") + a.load(1, "val_a"); a.store(1, "out"); a.halt() + a.label("path_b") + a.load(1, "val_b"); a.store(1, "out"); a.halt() + + a.org(0x80) + a.label("val_a"); a.db(0xAA) + a.label("val_b"); a.db(0xBB) + # new_lsb_word stores path_b's full 16-bit address; new_lsb labels the LSB byte. + a.label("new_lsb_word"); a.daddr("path_b") + a.labels["new_lsb"] = a.labels["new_lsb_word"] + 1 + a.label("out"); a.db(0) + + mem = a.assemble() + return mem, {a.labels["out"]: 0xBB}, 30, "self-modifying JMP target" + + +def all_branches(mem_size: int = 256) -> ProgramResult: + """Drive all eight conditional jumps; each path writes a unique marker.""" + a = Asm(mem_size) + + a.org(0) + # ----- JZ: equal compare -> Z=1 ----- + a.load(0, "v5"); a.load(1, "v5"); a.cmp(0, 1); a.jz("ok_jz"); a.jmp("fail") + a.label("ok_jz"); a.load(2, "m_a0"); a.store(2, "out0") + + # ----- JNZ: unequal compare -> Z=0 ----- + a.load(0, "v5"); a.load(1, "v3"); a.cmp(0, 1); a.jnz("ok_jnz"); a.jmp("fail") + a.label("ok_jnz"); a.load(2, "m_a1"); a.store(2, "out1") + + # ----- JC: 255+1 = 0 with carry ----- + a.load(0, "v255"); a.load(1, "v1"); a.add(0, 1); a.jc("ok_jc"); a.jmp("fail") + a.label("ok_jc"); a.load(2, "m_a2"); a.store(2, "out2") + + # ----- JNC: 1+1 = 2, no carry ----- + a.load(0, "v1"); a.load(1, "v1"); a.add(0, 1); a.jnc("ok_jnc"); a.jmp("fail") + a.label("ok_jnc"); a.load(2, "m_a3"); a.store(2, "out3") + + # ----- JN: 0 - 1 = 0xFF, MSB set ----- + a.load(0, "v0"); a.load(1, "v1"); a.sub(0, 1); a.jn("ok_jn"); a.jmp("fail") + a.label("ok_jn"); a.load(2, "m_a4"); a.store(2, "out4") + + # ----- JP: 0 + 1 = 1, MSB clear ----- + a.load(0, "v0"); a.load(1, "v1"); a.add(0, 1); a.jp("ok_jp"); a.jmp("fail") + a.label("ok_jp"); a.load(2, "m_a5"); a.store(2, "out5") + + # ----- JV: 127 + 1 = 128, signed overflow ----- + a.load(0, "v127"); a.load(1, "v1"); a.add(0, 1); a.jv("ok_jv"); a.jmp("fail") + a.label("ok_jv"); a.load(2, "m_a6"); a.store(2, "out6") + + # ----- JNV: 1 + 1 = 2, no signed overflow ----- + a.load(0, "v1"); a.load(1, "v1"); a.add(0, 1); a.jnv("ok_jnv"); a.jmp("fail") + a.label("ok_jnv"); a.load(2, "m_a7"); a.store(2, "out7") + + a.jmp("end") + a.label("fail") + a.load(2, "v_fail"); a.store(2, "fail_addr"); a.halt() + a.label("end") + a.halt() + + # Code runs to ~0xDF; data starts safely after that. + a.org(0xE0) + a.label("v0"); a.db(0) + a.label("v1"); a.db(1) + a.label("v3"); a.db(3) + a.label("v5"); a.db(5) + a.label("v127"); a.db(127) + a.label("v255"); a.db(255) + a.label("m_a0"); a.db(0xA0) + a.label("m_a1"); a.db(0xA1) + a.label("m_a2"); a.db(0xA2) + a.label("m_a3"); a.db(0xA3) + a.label("m_a4"); a.db(0xA4) + a.label("m_a5"); a.db(0xA5) + a.label("m_a6"); a.db(0xA6) + a.label("m_a7"); a.db(0xA7) + a.label("v_fail"); a.db(0xEE) + a.label("out0"); a.db(0) + a.label("out1"); a.db(0) + a.label("out2"); a.db(0) + a.label("out3"); a.db(0) + a.label("out4"); a.db(0) + a.label("out5"); a.db(0) + a.label("out6"); a.db(0) + a.label("out7"); a.db(0) + a.label("fail_addr"); a.db(0) + + mem = a.assemble() + expected = { + a.labels["out0"]: 0xA0, + a.labels["out1"]: 0xA1, + a.labels["out2"]: 0xA2, + a.labels["out3"]: 0xA3, + a.labels["out4"]: 0xA4, + a.labels["out5"]: 0xA5, + a.labels["out6"]: 0xA6, + a.labels["out7"]: 0xA7, + a.labels["fail_addr"]: 0, # must remain zero + } + return mem, expected, 200, "all 8 conditional jumps (JZ/JNZ/JC/JNC/JN/JP/JV/JNV)" + + +def call_pushes_pc(mem_size: int = 256) -> ProgramResult: + """Verify CALL pushes the return address (next-instruction PC) onto the stack.""" + a = Asm(mem_size) + + a.org(0) + a.label("caller") + a.load(0, "marker_val") + a.store(0, "marker_addr") # write before CALL + a.label("call_site") + a.call("callee") + # If CALL did not transfer control, this fallthrough store would write 0xDD: + a.load(0, "noret_val") + a.store(0, "noret_addr") + a.halt() + + a.label("callee") + a.load(0, "callee_val") + a.store(0, "callee_addr") + a.halt() + + a.org(0x40) + a.label("marker_val"); a.db(0x11) + a.label("marker_addr"); a.db(0) + a.label("noret_val"); a.db(0xDD) + a.label("noret_addr"); a.db(0) + a.label("callee_val"); a.db(0x22) + a.label("callee_addr"); a.db(0) + + mem = a.assemble() + addr_mask = mem_size - 1 + return_addr = a.labels["call_site"] + 4 # 4-byte CALL instruction + expected = { + a.labels["marker_addr"]: 0x11, # pre-CALL store ran + a.labels["callee_addr"]: 0x22, # callee reached + a.labels["noret_addr"]: 0, # fallthrough did not run + (addr_mask - 2) & addr_mask: return_addr & 0xFF, # ret LSB on stack + (addr_mask - 1) & addr_mask: (return_addr >> 8) & 0xFF, # ret MSB + } + return mem, expected, 30, "CALL pushes return PC onto stack" + + +def bubble_sort_4(mem_size: int = 256) -> ProgramResult: + """Sort a 4-byte array using unrolled compare-swap (3 passes of 3 compares).""" + a = Asm(mem_size) + + addrs = ["a0", "a1", "a2", "a3"] + + a.org(0) + for _outer in range(3): + for i in range(3): + x, y = addrs[i], addrs[i + 1] + # Load pair + a.load(0, x) # R0 = A[i] + a.load(1, y) # R1 = A[i+1] + a.cmp(0, 1) # compare A[i] - A[i+1] + # If A[i] <= A[i+1] (Z=1 or C=0 from sub-style cmp), skip swap + # SUB sets carry when no borrow (a >= b). So: + # a > b iff Z=0 and a >= b -> Z=0 and C=1 (the sub didn't borrow) + # We want to swap when a > b. JNC (no carry / borrow) means a < b -> skip swap. + # If a == b (Z=1) we also skip. So: jump-skip when JZ OR JNC. + # Easier: compute (a > b) by checking C=1 AND Z=0. Use JZ to skip on equal, + # then JNC to skip on a < b. Otherwise fall through to swap. + skip_lbl = f"skip_{_outer}_{i}" + a.jz(skip_lbl) # equal -> skip + a.jnc(skip_lbl) # a < b (sub borrowed) -> skip + # swap: store R0 -> y, R1 -> x + a.store(1, x) + a.store(0, y) + a.label(skip_lbl) + a.halt() + + # Initial unsorted array; code runs to ~0xEC, so data starts at 0xF0. + initial = [42, 7, 200, 19] + a.org(0xF0) + for name, val in zip(addrs, initial): + a.label(name); a.db(val) + + mem = a.assemble() + sorted_vals = sorted(initial) + expected = {a.labels[name]: v for name, v in zip(addrs, sorted_vals)} + return mem, expected, 800, f"bubble sort {initial} -> {sorted_vals}" + + +def cross_check_mul(mem_size: int = 256) -> ProgramResult: + """Cross-check the threshold MUL circuit against repeated ADD.""" + a = Asm(mem_size) + A_VAL = 17 + B_VAL = 9 + expected_product = (A_VAL * B_VAL) & 0xFF + + a.org(0) + # --- direct multiply --- + a.load(0, "A") + a.load(1, "B") + a.mul(0, 1) + a.store(0, "out_mul") + # --- repeated-add multiply --- + a.load(0, "zero") # acc = 0 + a.load(1, "A") # addend + a.load(2, "B") # counter + a.load(3, "one") # 1 + a.label("rep_loop") + a.add(0, 1) # acc += A + a.sub(2, 3) # B-- + a.jnz("rep_loop") + a.store(0, "out_add") + a.halt() + + a.org(0x80) + a.label("A"); a.db(A_VAL) + a.label("B"); a.db(B_VAL) + a.label("zero"); a.db(0) + a.label("one"); a.db(1) + a.label("out_mul"); a.db(0) + a.label("out_add"); a.db(0) + + mem = a.assemble() + expected = { + a.labels["out_mul"]: expected_product, + a.labels["out_add"]: expected_product, + } + return mem, expected, 80, f"MUL vs repeated ADD: {A_VAL} * {B_VAL} = {expected_product}" + + +def div_via_repeated_sub(mem_size: int = 256) -> ProgramResult: + """Compute floor(A/B) and (A mod B) by repeated subtraction.""" + A_VAL = 100 + B_VAL = 7 + expected_q = A_VAL // B_VAL # 14 + expected_r = A_VAL % B_VAL # 2 + + a = Asm(mem_size) + + a.org(0) + # ---- Repeated-subtraction division ---- + a.load(0, "A") # R0 = A (will become remainder) + a.load(1, "B") # R1 = B (divisor) + a.load(2, "ZERO") # R2 = 0 (will become quotient) + a.load(3, "ONE") # R3 = 1 (increment) + + a.label("loop") + a.cmp(0, 1) # CMP R0, R1; carry=1 (no-borrow) iff R0 >= R1 + a.jnc("done") # if R0 < R1 (carry=0), exit loop + a.sub(0, 1) # R0 -= B + a.add(2, 3) # quotient += 1 + a.jmp("loop") + + a.label("done") + a.store(2, "OUT_Q_RPT") # quotient via repeated sub + a.store(0, "OUT_R_RPT") # remainder via repeated sub + + # ---- Direct DIV opcode for cross-check ---- + a.load(0, "A") + a.load(1, "B") + a.dw(_enc(0x8, 0, 1, 0)) # DIV R0, R1 -> R0 = R0 / R1 (8-bit DIV) + a.store(0, "OUT_Q_DIV") + a.halt() + + a.org(0x80) + a.label("A"); a.db(A_VAL) + a.label("B"); a.db(B_VAL) + a.label("ZERO"); a.db(0) + a.label("ONE"); a.db(1) + a.label("OUT_Q_RPT"); a.db(0) + a.label("OUT_R_RPT"); a.db(0) + a.label("OUT_Q_DIV"); a.db(0) + + mem = a.assemble() + expected = { + a.labels["OUT_Q_RPT"]: expected_q, + a.labels["OUT_R_RPT"]: expected_r, + a.labels["OUT_Q_DIV"]: expected_q, + } + return mem, expected, 4 * (A_VAL // B_VAL + 4) + 12, ( + f"{A_VAL} / {B_VAL}: quotient {expected_q} (repeated SUB) " + f"matches DIV opcode result; remainder {expected_r}" + ) + + +def bitwise_chain(mem_size: int = 256) -> ProgramResult: + """Run a chain of bitwise ops and verify each intermediate value.""" + A = 0xCC # 11001100 + B = 0xF0 # 11110000 + C = 0x0F # 00001111 + D = 0xAA # 10101010 + + s1 = A & B # 0xC0 + s2 = s1 | C # 0xCF + s3 = s2 ^ D # 0x65 + s4 = (s3 << 1) & 0xFF # 0xCA + s5 = s4 >> 1 # 0x65 + + a = Asm(mem_size) + a.org(0) + a.load(0, "A"); a.load(1, "B"); a.and_(0, 1); a.store(0, "S1") + a.load(1, "C"); a.or_(0, 1); a.store(0, "S2") + a.load(1, "D"); a.xor(0, 1); a.store(0, "S3") + a.shl(0); a.store(0, "S4") + a.shr(0); a.store(0, "S5") + a.halt() + + a.org(0x80) + a.label("A"); a.db(A) + a.label("B"); a.db(B) + a.label("C"); a.db(C) + a.label("D"); a.db(D) + a.label("S1"); a.db(0) + a.label("S2"); a.db(0) + a.label("S3"); a.db(0) + a.label("S4"); a.db(0) + a.label("S5"); a.db(0) + + mem = a.assemble() + expected = { + a.labels["S1"]: s1, + a.labels["S2"]: s2, + a.labels["S3"]: s3, + a.labels["S4"]: s4, + a.labels["S5"]: s5, + } + return mem, expected, 30, ( + f"bitwise chain AND/OR/XOR/SHL/SHR -> {s1:#x},{s2:#x},{s3:#x},{s4:#x},{s5:#x}" + ) + + +def flags_policy(mem_size: int = 256) -> ProgramResult: + """Only ADD, SUB, MUL and CMP write FLAGS; every other instruction leaves them.""" + a = Asm(mem_size) + + a.org(0) + # ----- Probe 1: Z=1 survives AND (nonzero result) ----- + a.load(0, "v5"); a.load(1, "v5"); a.cmp(0, 1) # Z=1 + a.load(2, "v3"); a.load(3, "v1"); a.and_(2, 3) # R2 = 3 & 1 = 1 + a.jz("p1_ok") + a.load(2, "m_fail"); a.store(2, "out1"); a.jmp("p2") + a.label("p1_ok") + a.load(2, "m_b1"); a.store(2, "out1") + + # ----- Probe 2: Z=0 survives XOR (zero result) ----- + a.label("p2") + a.load(0, "v5"); a.load(1, "v3"); a.cmp(0, 1) # Z=0 + a.xor(2, 2) # R2 = 0 + a.jz("p2_fail") + a.load(2, "m_b2"); a.store(2, "out2"); a.jmp("p3") + a.label("p2_fail") + a.load(2, "m_fail"); a.store(2, "out2") + + # ----- Probe 3: C=1 survives SHR ----- + a.label("p3") + a.load(0, "v255"); a.load(1, "v1"); a.add(0, 1) # C=1 + a.load(2, "v3"); a.shr(2) # R2 = 1 + a.jc("p3_ok") + a.load(2, "m_fail"); a.store(2, "out3"); a.jmp("done") + a.label("p3_ok") + a.load(2, "m_b3"); a.store(2, "out3") + a.label("done") + a.halt() + + a.org(0xA0) + a.label("v1"); a.db(1) + a.label("v3"); a.db(3) + a.label("v5"); a.db(5) + a.label("v255"); a.db(255) + a.label("m_b1"); a.db(0xB1) + a.label("m_b2"); a.db(0xB2) + a.label("m_b3"); a.db(0xB3) + a.label("m_fail"); a.db(0xEE) + a.label("out1"); a.db(0) + a.label("out2"); a.db(0) + a.label("out3"); a.db(0) + + mem = a.assemble() + expected = { + a.labels["out1"]: 0xB1, + a.labels["out2"]: 0xB2, + a.labels["out3"]: 0xB3, + } + return mem, expected, 60, "flag policy: AND/XOR/SHR leave FLAGS unchanged" + + +SUITE = [ + ("fib", lambda mem_size: fib(11, mem_size)), + ("sum_n", lambda mem_size: sum_n(10, mem_size)), + ("self_mod_jmp", lambda mem_size: self_mod_jmp(mem_size)), + ("all_branches", lambda mem_size: all_branches(mem_size)), + ("call_pushes_pc", lambda mem_size: call_pushes_pc(mem_size)), + ("bubble_sort_4", lambda mem_size: bubble_sort_4(mem_size)), + ("cross_check_mul", lambda mem_size: cross_check_mul(mem_size)), + ("div_via_repeated_sub", lambda mem_size: div_via_repeated_sub(mem_size)), + ("bitwise_chain", lambda mem_size: bitwise_chain(mem_size)), + ("flags_policy", lambda mem_size: flags_policy(mem_size)), +] + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # this file lives in tools/ + + +def load_tensors(path: Path): + out = {} + with safe_open(str(path), framework="pt") as f: + for name in f.keys(): + out[name] = f.get_tensor(name).float() + return out + + +def run_program(cpu: GenericThresholdCPU, mem, max_cycles: int): + addr_mask = (1 << cpu.addr_bits) - 1 + state = { + "pc": 0, + "regs": [0] * 4, + "flags": [0] * 4, + "mem": list(mem), + "halted": False, + "sp": addr_mask, + } + t0 = time.perf_counter() + final, cycles = cpu.run(state, max_cycles=max_cycles) + return final, cycles, time.perf_counter() - t0 + + +def check_expected(final, expected: dict) -> tuple[bool, list[str]]: + failures = [] + for addr, want in expected.items(): + got = final["mem"][addr] + if got != want: + failures.append(f"M[0x{addr:04X}] = {got} (expected {want})") + return (len(failures) == 0), failures + + +def main_cpu() -> int: + parser = argparse.ArgumentParser(description="Run the CPU program suite") + parser.add_argument( + "--model", type=str, + default=os.path.join(REPO, "variants", "neural_computer8_small.safetensors"), + help="Path to .safetensors variant", + ) + parser.add_argument( + "--only", type=str, default="", + help="Comma-separated subset of program names to run", + ) + args = parser.parse_args() + + print(f"Loading {args.model}") + tensors = load_tensors(Path(args.model)) + manifest = get_manifest(tensors) + print(f"Manifest: data={manifest['data_bits']}-bit, addr={manifest['addr_bits']}-bit, " + f"mem={manifest['memory_bytes']}B") + + if manifest["memory_bytes"] < 256: + print(f"ERROR: variant has {manifest['memory_bytes']}B memory; " + f"the suite needs at least 256B (scratchpad).") + return 2 + + cpu = GenericThresholdCPU(tensors) + only = set(s.strip() for s in args.only.split(",") if s.strip()) + + print() + print("=" * 80) + print(f" CPU PROGRAM SUITE ({manifest['memory_bytes']}B mem, {manifest['data_bits']}-bit ALU)") + print("=" * 80) + + pass_count = 0 + fail_count = 0 + skip_count = 0 + + for name, builder in SUITE: + if only and name not in only: + skip_count += 1 + continue + try: + mem, expected, max_cycles, desc = builder(manifest["memory_bytes"]) + except Exception as e: + print(f" {name:18} BUILD ERROR: {e}") + fail_count += 1 + continue + + if len(mem) != manifest["memory_bytes"]: + print(f" {name:18} SKIP (program built {len(mem)}B, " + f"variant has {manifest['memory_bytes']}B)") + skip_count += 1 + continue + + final, cycles, elapsed = run_program(cpu, mem, max_cycles) + ok, failures = check_expected(final, expected) + if ok and not final["halted"]: + ok = False + failures.append(f"did not HALT within {max_cycles} cycles") + + status = "PASS" if ok else "FAIL" + if ok: + pass_count += 1 + else: + fail_count += 1 + print(f" {name:18} {status} ({cycles:>3} cyc, {elapsed:>5.2f}s) {desc}") + if not ok: + for f in failures[:6]: + print(f" - {f}") + + print() + print("=" * 80) + total = pass_count + fail_count + print(f" PASS: {pass_count}/{total} FAIL: {fail_count}/{total} SKIP: {skip_count}") + return 0 if fail_count == 0 else 1 + + +COMMANDS = {'eval': main, 'variants': main_all, 'cpu': main_cpu} + + +if __name__ == "__main__": + if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS: + sys.exit(COMMANDS['eval']()) + cmd = sys.argv.pop(1) + sys.argv[0] += ' ' + cmd + sys.exit(COMMANDS[cmd]()) diff --git a/src/eval_all.py b/src/eval_all.py deleted file mode 100644 index 2dd5a951995e737822940adc9a26629b5350edef..0000000000000000000000000000000000000000 --- a/src/eval_all.py +++ /dev/null @@ -1,912 +0,0 @@ -""" -Unified evaluation harness for any threshold-computer variant. - -Drops the `--cpu-test` smoke test (which was hardcoded to 16-bit/64KB) and -adds variant-aware sweep modes. The same harness handles every (data_bits, -addr_bits) configuration: it reads the manifest from each safetensors file, -runs the BatchedFitnessEvaluator at the right device, and reports per-file -plus per-category results. - -Usage: - python eval_all.py path/to/file.safetensors # one file - python eval_all.py variants/ # every .safetensors in dir - python eval_all.py --device cpu variants/ # CPU only (default) - python eval_all.py --pop_size 32 variants/ # batched pop eval - python eval_all.py --debug path/to/file.safetensors # per-circuit detail - python eval_all.py --cpu-program PATH # also run an assembled program - # through the threshold CPU - # sized to the file's manifest - -Exit code: - 0 if all files PASS (fitness >= 0.9999) - N where N is the number of FAILing files -""" - -from __future__ import annotations - -import argparse -import json -import os -import sys -import time -from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple - -import torch -from safetensors import safe_open - -# Reuse eval.py's evaluator (variant-aware) -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from eval import ( - BatchedFitnessEvaluator, - create_population, - load_model, - get_manifest, - heaviside, - int_to_bits, - bits_to_int, - bits_msb_to_lsb, -) - - -# --------------------------------------------------------------------------- -# Variant-aware threshold ALU + CPU -# --------------------------------------------------------------------------- - -class GenericThresholdALU: - """Variant-aware threshold ALU walking the gates of a loaded variant. - - 8-bit primitives: add8/sub8/mul8/div8, packed bitwise and8/or8/xor8, - shl8/shr8, and the bit-cascade comparators via cmp8. Width-generic - add_n/sub_n/mul_n/cmp_n cover the 16- and 32-bit circuit families. - """ - - def __init__(self, tensors: Dict[str, torch.Tensor], data_bits: int): - self.T = tensors - self.data_bits = data_bits - - def _g(self, name, inputs): - w = self.T[name + ".weight"].view(-1) - b = self.T[name + ".bias"].view(-1) - return int(heaviside((torch.tensor(inputs, dtype=torch.float32) * w).sum() + b).item()) - - def _xor_or_nand(self, prefix, inputs): - a, b_ = inputs - h_or = self._g(f"{prefix}.layer1.or", [a, b_]) - h_nand = self._g(f"{prefix}.layer1.nand", [a, b_]) - return self._g(f"{prefix}.layer2", [h_or, h_nand]) - - def _fa(self, prefix, a, b, cin): - s1 = self._xor_or_nand(f"{prefix}.ha1.sum", [a, b]) - c1 = self._g(f"{prefix}.ha1.carry", [a, b]) - s2 = self._xor_or_nand(f"{prefix}.ha2.sum", [s1, cin]) - c2 = self._g(f"{prefix}.ha2.carry", [s1, cin]) - cout = self._g(f"{prefix}.carry_or", [c1, c2]) - return s2, cout - - def add8(self, a, b): - a_lsb = list(reversed(int_to_bits(a, 8))) - b_lsb = list(reversed(int_to_bits(b, 8))) - carry = 0 - s_lsb = [] - for i in range(8): - s, carry = self._fa(f"arithmetic.ripplecarry8bit.fa{i}", a_lsb[i], b_lsb[i], carry) - s_lsb.append(s) - return bits_to_int(list(reversed(s_lsb))), carry - - def sub8(self, a, b): - a_lsb = list(reversed(int_to_bits(a, 8))) - b_lsb = list(reversed(int_to_bits(b, 8))) - carry = 1 - d_lsb = [] - for i in range(8): - notb = self._g(f"arithmetic.sub8bit.notb{i}", [b_lsb[i]]) - x1 = self._xor_or_nand(f"arithmetic.sub8bit.fa{i}.xor1", [a_lsb[i], notb]) - x2 = self._xor_or_nand(f"arithmetic.sub8bit.fa{i}.xor2", [x1, carry]) - and1 = self._g(f"arithmetic.sub8bit.fa{i}.and1", [a_lsb[i], notb]) - and2 = self._g(f"arithmetic.sub8bit.fa{i}.and2", [x1, carry]) - carry = self._g(f"arithmetic.sub8bit.fa{i}.or_carry", [and1, and2]) - d_lsb.append(x2) - return bits_to_int(list(reversed(d_lsb))), carry - - _CMP_KIND = {"greaterthan": "gt", "lessthan": "lt", "eq": "eq", "equality": "eq", - "greaterorequal": "ge", "lessorequal": "le"} - - def _cmp_bit_cascade(self, cmp_prefix: str, finals: Dict[str, str], - a_bits_msb: List[int], b_bits_msb: List[int], kind: str) -> int: - """Walk an add_bit_cascade_compare structure (bit 0 is the MSB).""" - bits = len(a_bits_msb) - bit_gt, bit_lt, bit_eq = [], [], [] - for i in range(bits): - ab = [a_bits_msb[i], b_bits_msb[i]] - bit_gt.append(self._g(f"{cmp_prefix}.bit{i}.gt", ab)) - bit_lt.append(self._g(f"{cmp_prefix}.bit{i}.lt", ab)) - eq_and = self._g(f"{cmp_prefix}.bit{i}.eq.layer1.and", ab) - eq_nor = self._g(f"{cmp_prefix}.bit{i}.eq.layer1.nor", ab) - bit_eq.append(self._g(f"{cmp_prefix}.bit{i}.eq", [eq_and, eq_nor])) - cas_gt, cas_lt = [bit_gt[0]], [bit_lt[0]] - for i in range(1, bits): - eq_pref = self._g(f"{cmp_prefix}.cascade.eq_prefix.bit{i}", bit_eq[:i]) - cas_gt.append(self._g(f"{cmp_prefix}.cascade.gt.bit{i}", [eq_pref, bit_gt[i]])) - cas_lt.append(self._g(f"{cmp_prefix}.cascade.lt.bit{i}", [eq_pref, bit_lt[i]])) - if kind == "gt": - return self._g(finals["gt"], cas_gt) - if kind == "lt": - return self._g(finals["lt"], cas_lt) - if kind == "eq": - return self._g(finals["eq"], bit_eq) - if kind == "ge": - lt = self._g(finals["lt"], cas_lt) - not_lt = self._g(finals["ge"] + ".not_lt", [lt]) - return self._g(finals["ge"], [not_lt]) - if kind == "le": - gt = self._g(finals["gt"], cas_gt) - not_gt = self._g(finals["le"] + ".not_gt", [gt]) - return self._g(finals["le"], [not_gt]) - raise ValueError(kind) - - def cmp8(self, a, b, kind): - return self.cmp_n(a, b, kind, 8) - - def mul8(self, a, b): - ab = int_to_bits(a, 8) - bb = int_to_bits(b, 8) - result = 0 - for j in range(8): - if bb[j] == 0: - continue - row = 0 - for i in range(8): - pp = self._g(f"alu.alu8bit.mul.pp.a{i}b{j}", [ab[i], bb[j]]) - row |= (pp << (7 - i)) - shift = 7 - j - result, _ = self.add8(result & 0xFF, (row << shift) & 0xFF) - return result & 0xFF - - def _packed_pair_op(self, name: str, a: int, b: int) -> int: - """8 parallel 2-input gates stored packed: weight [16], bias [8].""" - a_bits = int_to_bits(a, 8) - b_bits = int_to_bits(b, 8) - w = self.T[f"{name}.weight"].view(-1) - bias = self.T[f"{name}.bias"].view(-1) - out = [] - for bit in range(8): - inp = torch.tensor([float(a_bits[bit]), float(b_bits[bit])]) - out.append(int(heaviside((inp * w[bit * 2:bit * 2 + 2]).sum() + bias[bit]).item())) - return bits_to_int(out) - - def and8(self, a, b): - return self._packed_pair_op("alu.alu8bit.and", a, b) - - def or8(self, a, b): - return self._packed_pair_op("alu.alu8bit.or", a, b) - - def xor8(self, a, b): - a_bits = int_to_bits(a, 8) - b_bits = int_to_bits(b, 8) - w_or = self.T["alu.alu8bit.xor.layer1.or.weight"].view(-1) - b_or = self.T["alu.alu8bit.xor.layer1.or.bias"].view(-1) - w_nand = self.T["alu.alu8bit.xor.layer1.nand.weight"].view(-1) - b_nand = self.T["alu.alu8bit.xor.layer1.nand.bias"].view(-1) - w2 = self.T["alu.alu8bit.xor.layer2.weight"].view(-1) - b2 = self.T["alu.alu8bit.xor.layer2.bias"].view(-1) - out = [] - for bit in range(8): - inp = torch.tensor([float(a_bits[bit]), float(b_bits[bit])]) - h_or = heaviside((inp * w_or[bit * 2:bit * 2 + 2]).sum() + b_or[bit]) - h_nand = heaviside((inp * w_nand[bit * 2:bit * 2 + 2]).sum() + b_nand[bit]) - hidden = torch.stack([h_or, h_nand]) - out.append(int(heaviside((hidden * w2[bit * 2:bit * 2 + 2]).sum() + b2[bit]).item())) - return bits_to_int(out) - - def shl8(self, a): - a_bits = int_to_bits(a, 8) - out = [] - for bit in range(8): - src = float(a_bits[bit + 1]) if bit < 7 else 0.0 - out.append(self._g(f"alu.alu8bit.shl.bit{bit}", [src])) - return bits_to_int(out) - - def shr8(self, a): - a_bits = int_to_bits(a, 8) - out = [] - for bit in range(8): - src = float(a_bits[bit - 1]) if bit > 0 else 0.0 - out.append(self._g(f"alu.alu8bit.shr.bit{bit}", [src])) - return bits_to_int(out) - - def div8(self, a, b): - """8-bit restoring division through the per-stage bit-cascade GE - comparators and the sub8 subtractor. Returns (quotient, remainder); - divide-by-zero yields (0xFF, a).""" - if b == 0: - return 0xFF, a - a_bits = int_to_bits(a, 8) - div_bits = int_to_bits(b, 8) - quotient = 0 - remainder = 0 - for stage in range(8): - remainder = ((remainder << 1) | a_bits[stage]) & 0xFF - prefix = f"alu.alu8bit.div.stage{stage}" - finals = { - "gt": f"{prefix}.cmp_bc.gt", - "lt": f"{prefix}.cmp_bc.lt", - "eq": f"{prefix}.cmp_bc.eq", - "ge": f"{prefix}.cmp", - "le": f"{prefix}.cmp_bc.le", - } - ge = self._cmp_bit_cascade(f"{prefix}.cmp_bc", finals, - int_to_bits(remainder, 8), div_bits, "ge") - if ge: - remainder, _ = self.sub8(remainder, b) - quotient = (quotient << 1) | 1 - else: - quotient = quotient << 1 - return quotient & 0xFF, remainder & 0xFF - - # ----- N-bit primitives (for 16-bit and 32-bit variants) ---------------- - - def add_n(self, a: int, b: int, bits: int): - """Width-generic ripple-carry add via arithmetic.ripplecarry{N}bit.""" - prefix = f"arithmetic.ripplecarry{bits}bit" - a_lsb = list(reversed(int_to_bits(a, bits))) - b_lsb = list(reversed(int_to_bits(b, bits))) - carry = 0 - s_lsb = [] - for i in range(bits): - s, carry = self._fa(f"{prefix}.fa{i}", a_lsb[i], b_lsb[i], carry) - s_lsb.append(s) - return bits_to_int(list(reversed(s_lsb))), carry - - def sub_n(self, a: int, b: int, bits: int): - """N-bit two's-complement subtract via arithmetic.sub{N}bit (N >= 16). - - Structure (per build.add_sub_nbits): N NOT gates + N standard full adders. - """ - prefix = f"arithmetic.sub{bits}bit" - a_lsb = list(reversed(int_to_bits(a, bits))) - b_lsb = list(reversed(int_to_bits(b, bits))) - # NOT each B bit - notb = [self._g(f"{prefix}.not_b.bit{i}", [b_lsb[i]]) for i in range(bits)] - carry = 1 # carry-in = 1 for two's-complement - d_lsb = [] - for i in range(bits): - s, carry = self._fa(f"{prefix}.fa{i}", a_lsb[i], notb[i], carry) - d_lsb.append(s) - return bits_to_int(list(reversed(d_lsb))), carry - - def cmp_n(self, a: int, b: int, kind: str, bits: int): - """N-bit unsigned comparison via the bit-cascade comparator family - (arithmetic.cmp{N}bit.* per build.add_bit_cascade_compare).""" - short = self._CMP_KIND[kind] - finals = { - "gt": f"arithmetic.greaterthan{bits}bit", - "lt": f"arithmetic.lessthan{bits}bit", - "eq": f"arithmetic.equality{bits}bit", - "ge": f"arithmetic.greaterorequal{bits}bit", - "le": f"arithmetic.lessorequal{bits}bit", - } - return self._cmp_bit_cascade(f"arithmetic.cmp{bits}bit", finals, - int_to_bits(a, bits), int_to_bits(b, bits), short) - - def mul_n(self, a: int, b: int, bits: int): - """N-bit shift-add multiply (low N bits only).""" - ab = int_to_bits(a, bits) - bb = int_to_bits(b, bits) - mask = (1 << bits) - 1 - result = 0 - for j in range(bits): - if bb[j] == 0: - continue - row = 0 - for i in range(bits): - pp = self._g(f"alu.alu{bits}bit.mul.pp.a{i}b{j}", [ab[i], bb[j]]) - row |= (pp << (bits - 1 - i)) - shift = (bits - 1) - j - result, _ = self.add_n(result & mask, (row << shift) & mask, bits) - return result & mask - - -class GenericThresholdCPU: - """Variant-aware CPU runtime. Sized from the variant's manifest.""" - - def __init__(self, tensors: Dict[str, torch.Tensor]): - self.T = tensors - m = get_manifest(tensors) - self.data_bits = m["data_bits"] - self.addr_bits = m["addr_bits"] - self.mem_bytes = m["memory_bytes"] - # 8-bit CPU primitives (ripplecarry8bit, sub8bit, alu.alu8bit.*, memory.*, - # control.*) are present in every variant regardless of manifest data_bits. - # Wider data widths simply add additional standalone ALU primitives. - if self.mem_bytes == 0: - raise NotImplementedError( - "Pure-ALU variants have no memory; cannot run CPU programs" - ) - self.alu = GenericThresholdALU(tensors, 8) - - def _addr_decode(self, addr): - bits = torch.tensor(int_to_bits(addr, self.addr_bits), dtype=torch.float32) - w = self.T["memory.addr_decode.weight"] - b = self.T["memory.addr_decode.bias"] - return heaviside((w * bits).sum(dim=1) + b) - - def mem_read(self, mem, addr): - sel = self._addr_decode(addr) - mem_bits = torch.tensor( - [int_to_bits(byte, 8) for byte in mem], dtype=torch.float32 - ) - and_w = self.T["memory.read.and.weight"] - and_b = self.T["memory.read.and.bias"] - or_w = self.T["memory.read.or.weight"] - or_b = self.T["memory.read.or.bias"] - out = [] - for bit in range(8): - inp = torch.stack([mem_bits[:, bit], sel], dim=1) - and_out = heaviside((inp * and_w[bit]).sum(dim=1) + and_b[bit]) - out.append(int(heaviside((and_out * or_w[bit]).sum() + or_b[bit]).item())) - return bits_to_int(out) - - def mem_write(self, mem, addr, value): - sel = self._addr_decode(addr) - data_bits = torch.tensor(int_to_bits(value, 8), dtype=torch.float32) - mem_bits = torch.tensor( - [int_to_bits(byte, 8) for byte in mem], dtype=torch.float32 - ) - sel_w = self.T["memory.write.sel.weight"] - sel_b = self.T["memory.write.sel.bias"] - nsel_w = self.T["memory.write.nsel.weight"].squeeze(1) - nsel_b = self.T["memory.write.nsel.bias"] - and_old_w = self.T["memory.write.and_old.weight"] - and_old_b = self.T["memory.write.and_old.bias"] - and_new_w = self.T["memory.write.and_new.weight"] - and_new_b = self.T["memory.write.and_new.bias"] - or_w = self.T["memory.write.or.weight"] - or_b = self.T["memory.write.or.bias"] - we = torch.ones_like(sel) - sel_inp = torch.stack([sel, we], dim=1) - write_sel = heaviside((sel_inp * sel_w).sum(dim=1) + sel_b) - nsel = heaviside(write_sel * nsel_w + nsel_b) - for bit in range(8): - old = mem_bits[:, bit] - data_bit = data_bits[bit].expand(self.mem_bytes) - inp_old = torch.stack([old, nsel], dim=1) - inp_new = torch.stack([data_bit, write_sel], dim=1) - and_old = heaviside((inp_old * and_old_w[:, bit]).sum(dim=1) + and_old_b[:, bit]) - and_new = heaviside((inp_new * and_new_w[:, bit]).sum(dim=1) + and_new_b[:, bit]) - or_inp = torch.stack([and_old, and_new], dim=1) - new_bit = heaviside((or_inp * or_w[:, bit]).sum(dim=1) + or_b[:, bit]) - mem_bits[:, bit] = new_bit - return [bits_to_int([int(b) for b in mem_bits[i].tolist()]) for i in range(self.mem_bytes)] - - # Conditions 0..7 map to (mux circuit, flag index into [Z, N, C, V]). - # Odd conditions are the negated forms; the mux select is the complemented - # flag (the circuits are plain per-bit muxes -- see control.* .inputs, - # which name the select $not_zero / $not_carry / ... for the odd forms). - _JCC = [("control.jz", 0), ("control.jnz", 0), ("control.jc", 2), ("control.jnc", 2), - ("control.jn", 1), ("control.jp", 1), ("control.jv", 3), ("control.jnv", 3)] - - def _jcc_pc(self, circuit: str, pc: int, target: int, sel: int) -> int: - """Per-bit 2:1 mux over the PC: sel ? target : pc, addr_bits wide.""" - pc_bits = int_to_bits(pc, self.addr_bits) - t_bits = int_to_bits(target, self.addr_bits) - out = [] - for bit in range(self.addr_bits): - bp = f"{circuit}.bit{bit}" - not_sel = self.alu._g(f"{bp}.not_sel", [float(sel)]) - and_a = self.alu._g(f"{bp}.and_a", [float(pc_bits[bit]), not_sel]) - and_b = self.alu._g(f"{bp}.and_b", [float(t_bits[bit]), float(sel)]) - out.append(self.alu._g(f"{bp}.or", [and_a, and_b])) - return bits_to_int(out) - - def _sp_dec(self, sp: int) -> int: - """SP - 1 through the control.push.sp_dec borrow chain. Gate bit index - is MSB-first (bit addr_bits-1 is the LSB); the bit complement feeding - each borrow AND is fixed wiring, as in the gate fitness suite.""" - bits = int_to_bits(sp, self.addr_bits) - out = [0] * self.addr_bits - borrow = 1 - for bit in range(self.addr_bits - 1, -1, -1): - bp = f"control.push.sp_dec.bit{bit}" - h_or = self.alu._g(f"{bp}.xor.layer1.or", [float(bits[bit]), float(borrow)]) - h_nand = self.alu._g(f"{bp}.xor.layer1.nand", [float(bits[bit]), float(borrow)]) - out[bit] = self.alu._g(f"{bp}.xor.layer2", [h_or, h_nand]) - borrow = self.alu._g(f"{bp}.borrow", [float(1 - bits[bit]), float(borrow)]) - return bits_to_int(out) - - def _decode_op(self, opcode): - """4-to-16 opcode one-hot through the gate decoder.""" - ob = [float((opcode >> j) & 1) for j in range(4)] - return [self.alu._g(f"control.decode.op{n}", ob) for n in range(16)] - - def _inc(self, pc, name, start): - """PC + 2^start through the gate increment chain (LSB-first).""" - ab = self.addr_bits - pcb = [(pc >> k) & 1 for k in range(ab)] - out = [0] * ab - carry = 0 - for k in range(ab): - if k < start: - out[k] = self.alu._g(f"control.pcnext.{name}.bit{k}", [float(pcb[k])]) - else: - cin = 1 if k == start else carry - o = self.alu._g(f"control.pcnext.{name}.bit{k}.xor.layer1.or", [float(pcb[k]), float(cin)]) - nd = self.alu._g(f"control.pcnext.{name}.bit{k}.xor.layer1.nand", [float(pcb[k]), float(cin)]) - out[k] = self.alu._g(f"control.pcnext.{name}.bit{k}.xor.layer2", [o, nd]) - carry = self.alu._g(f"control.pcnext.{name}.bit{k}.carry", [float(pcb[k]), float(cin)]) - return sum(out[k] << k for k in range(ab)) - - def _pcnext(self, pc2, pc4, addr, jcc, use_pc4, use_addr, use_jcc): - """Next-PC priority mux through the gates: use_addr ? addr : - (use_jcc ? jcc : (use_pc4 ? pc4 : pc2)).""" - ab = self.addr_bits - p2 = [(pc2 >> k) & 1 for k in range(ab)] - p4 = [(pc4 >> k) & 1 for k in range(ab)] - aB = [(addr >> k) & 1 for k in range(ab)] - jB = [(jcc >> k) & 1 for k in range(ab)] - out = [0] * ab - for k in range(ab): - ns = self.alu._g(f"control.pcnext.m_pc4.bit{k}.not_sel", [float(use_pc4)]) - aa = self.alu._g(f"control.pcnext.m_pc4.bit{k}.and_a", [float(p2[k]), ns]) - bb = self.alu._g(f"control.pcnext.m_pc4.bit{k}.and_b", [float(p4[k]), float(use_pc4)]) - m1 = self.alu._g(f"control.pcnext.m_pc4.bit{k}.or", [aa, bb]) - ns = self.alu._g(f"control.pcnext.m_jcc.bit{k}.not_sel", [float(use_jcc)]) - aa = self.alu._g(f"control.pcnext.m_jcc.bit{k}.and_a", [float(m1), ns]) - bb = self.alu._g(f"control.pcnext.m_jcc.bit{k}.and_b", [float(jB[k]), float(use_jcc)]) - m2 = self.alu._g(f"control.pcnext.m_jcc.bit{k}.or", [aa, bb]) - ns = self.alu._g(f"control.pcnext.m_addr.bit{k}.not_sel", [float(use_addr)]) - aa = self.alu._g(f"control.pcnext.m_addr.bit{k}.and_a", [float(m2), ns]) - bb = self.alu._g(f"control.pcnext.m_addr.bit{k}.and_b", [float(aB[k]), float(use_addr)]) - out[k] = self.alu._g(f"control.pcnext.m_addr.bit{k}.or", [aa, bb]) - return sum(out[k] << k for k in range(ab)) - - def step(self, state): - if state["halted"]: - return state - s = dict(state) - s["mem"] = state["mem"][:] - s["regs"] = state["regs"][:] - s["flags"] = state["flags"][:] - addr_mask = (1 << self.addr_bits) - 1 - pc = s["pc"] - hi = self.mem_read(s["mem"], pc & addr_mask) - lo = self.mem_read(s["mem"], (pc + 1) & addr_mask) - ir = ((hi & 0xFF) << 8) | (lo & 0xFF) - opcode = (ir >> 12) & 0xF - oh = self._decode_op(opcode) # gate opcode decode (one-hots) - rd = (ir >> 10) & 0x3 - rs = (ir >> 8) & 0x3 - imm = ir & 0xFF - addr = None - if oh[0xA] or oh[0xB] or oh[0xC] or oh[0xD] or oh[0xE]: - ah = self.mem_read(s["mem"], (pc + 2) & addr_mask) - al = self.mem_read(s["mem"], (pc + 3) & addr_mask) - addr = (((ah & 0xFF) << 8) | (al & 0xFF)) & addr_mask - pc2 = self._inc(pc, "pc2", 1) # PC+2 through the gate chain - pc4 = self._inc(pc, "pc4", 2) # PC+4 through the gate chain - a = s["regs"][rd] - b = s["regs"][rs] - result = a - carry = 0 - overflow = 0 - write_result = True - jcc_val = 0 - if oh[0x0]: - result, carry = self.alu.add8(a, b) - overflow = 1 if (((a ^ result) & (b ^ result)) & 0x80) else 0 - elif oh[0x1]: - result, carry = self.alu.sub8(a, b) - overflow = 1 if (((a ^ b) & (a ^ result)) & 0x80) else 0 - elif oh[0x2]: - result = self.alu.and8(a, b) - elif oh[0x3]: - result = self.alu.or8(a, b) - elif oh[0x4]: - result = self.alu.xor8(a, b) - elif oh[0x5]: - result = self.alu.shl8(a) - elif oh[0x6]: - result = self.alu.shr8(a) - elif oh[0x7]: - result = self.alu.mul8(a, b) - elif oh[0x8]: - result, _ = self.alu.div8(a, b) - elif oh[0x9]: # CMP: set flags only - r2, carry = self.alu.sub8(a, b) - z = 1 if r2 == 0 else 0 - n = 1 if (r2 & 0x80) else 0 - v = 1 if (((a ^ b) & (a ^ r2)) & 0x80) else 0 - s["flags"] = [z, n, carry, v] - write_result = False - elif oh[0xA]: # LOAD - result = self.mem_read(s["mem"], addr) - elif oh[0xB]: # STORE - s["mem"] = self.mem_write(s["mem"], addr, b & 0xFF) - write_result = False - elif oh[0xC]: # JMP (PC = addr via pcnext) - write_result = False - elif oh[0xD]: # JCC (PC via jcc mux + pcnext) - cond = imm & 0x7 - circuit, flag_idx = self._JCC[cond] - flag = s["flags"][flag_idx] - sel = flag if cond % 2 == 0 else 1 - flag - jcc_val = self._jcc_pc(circuit, pc4, addr, sel) - write_result = False - elif oh[0xE]: # CALL: push PC+4, PC = addr - ret_addr = pc4 & 0xFFFF - sp = s.get("sp", addr_mask) - sp = self._sp_dec(sp) - s["mem"] = self.mem_write(s["mem"], sp, (ret_addr >> 8) & 0xFF) - sp = self._sp_dec(sp) - s["mem"] = self.mem_write(s["mem"], sp, ret_addr & 0xFF) - s["sp"] = sp - write_result = False - elif oh[0xF]: # HALT - s["halted"] = True - return s - - if write_result: - s["regs"][rd] = result & 0xFF - if oh[0x0] or oh[0x1] or oh[0x7]: - z = 1 if (result & 0xFF) == 0 else 0 - n = 1 if (result & 0x80) else 0 - s["flags"] = [z, n, carry, overflow] - # PC sequencing through the gate mux. - use_pc4 = 1 if (oh[0xA] or oh[0xB]) else 0 - use_addr = 1 if (oh[0xC] or oh[0xE]) else 0 - s["pc"] = self._pcnext(pc2, pc4, addr or 0, jcc_val, use_pc4, use_addr, oh[0xD]) - return s - - def run(self, state, max_cycles=200): - s = state - cycles = 0 - while not s["halted"] and cycles < max_cycles: - s = self.step(s) - cycles += 1 - return s, cycles - - -def _encode_instr(opcode, rd, rs, imm): - return ((opcode & 0xF) << 12) | ((rd & 0x3) << 10) | ((rs & 0x3) << 8) | (imm & 0xFF) - - -def _w16(mem, addr, value): - mem[addr] = (value >> 8) & 0xFF - mem[addr + 1] = value & 0xFF - - -PROGRAM_MIN_BYTES = 0x84 # code 0x00..0x1F + data 0x80..0x83 - - -def builtin_program(addr_bits: int) -> Tuple[List[int], int]: - """Sum 5+4+3+2+1 via a loop. Returns (mem, expected_result_at_0x83). - - Compact layout: code at 0x00..0x1F (32 bytes), data at 0x80..0x83 (4 bytes). - Total footprint 132 bytes -- fits within scratchpad (256 B) and larger. - Requires addr_bits >= 8. - """ - if (1 << addr_bits) < PROGRAM_MIN_BYTES: - raise ValueError(f"addr_bits={addr_bits} too small for builtin program") - mem = [0] * (1 << addr_bits) - mem[0x80] = 5 # initial counter - mem[0x81] = 1 # decrement - mem[0x82] = 0 # zero (for compare and accumulator init) - # mem[0x83] is the output - _w16(mem, 0x0000, _encode_instr(0xA, 1, 0, 0)); _w16(mem, 0x0002, 0x0080) - _w16(mem, 0x0004, _encode_instr(0xA, 2, 0, 0)); _w16(mem, 0x0006, 0x0081) - _w16(mem, 0x0008, _encode_instr(0xA, 3, 0, 0)); _w16(mem, 0x000A, 0x0082) - _w16(mem, 0x000C, _encode_instr(0xA, 0, 0, 0)); _w16(mem, 0x000E, 0x0082) - _w16(mem, 0x0010, _encode_instr(0x0, 0, 1, 0)) - _w16(mem, 0x0012, _encode_instr(0x1, 1, 2, 0)) - _w16(mem, 0x0014, _encode_instr(0x9, 1, 3, 0)) - _w16(mem, 0x0016, _encode_instr(0xD, 0, 0, 0x01)); _w16(mem, 0x0018, 0x0010) - _w16(mem, 0x001A, _encode_instr(0xB, 0, 0, 0)); _w16(mem, 0x001C, 0x0083) - _w16(mem, 0x001E, _encode_instr(0xF, 0, 0, 0)) - return mem, 15 - - -# --------------------------------------------------------------------------- -# Eval driver -# --------------------------------------------------------------------------- - -def _file_fingerprint(path: Path) -> str: - """Stable cache key for a safetensors file: sha256 of its content. - - Hashes are content-addressed so renaming a file doesn't blow the cache, - but mtime-only would re-key on every clone of the repo. The sha256 of a - 30 MB safetensors finishes in tens of milliseconds — small compared to - a 5,900-test fitness run. - """ - import hashlib - h = hashlib.sha256() - with open(path, "rb") as f: - for chunk in iter(lambda: f.read(1 << 20), b""): - h.update(chunk) - return h.hexdigest() - - -def _cache_key(path: Path, opts: Dict[str, Any]) -> str: - """Cache key combining file content with the relevant evaluation options.""" - fp = _file_fingerprint(path) - opt_str = json.dumps(opts, sort_keys=True) - import hashlib - suffix = hashlib.sha256(opt_str.encode("utf-8")).hexdigest()[:8] - return f"{fp}_{suffix}" - - -def _load_cache(cache_dir: Path, key: str) -> Dict[str, Any] | None: - p = cache_dir / f"{key}.json" - if not p.exists(): - return None - try: - return json.loads(p.read_text(encoding="utf-8")) - except (json.JSONDecodeError, OSError): - return None - - -def _save_cache(cache_dir: Path, key: str, payload: Dict[str, Any]) -> None: - cache_dir.mkdir(parents=True, exist_ok=True) - p = cache_dir / f"{key}.json" - try: - p.write_text(json.dumps(payload, indent=2, default=str), encoding="utf-8") - except OSError: - pass - - -def list_safetensors(path: Path) -> List[Path]: - if path.is_file(): - return [path] - if path.is_dir(): - return sorted(p for p in path.glob("*.safetensors") if p.is_file()) - return [] - - -# Standalone machines carry their own circuit inventory and are verified by -# their own scripts, not the gate-fitness suite. Each maps to the tool that -# checks it. -MACHINE_VERIFIER = { - "subleq8": "machines.py", - "rv32": "machines.py", - "matrix8": "matrix8.py", - "subleq8io": "constructor8.py", - "reflect": "reflect.py", - "attractor": "tools/test_attractor.py", - "reversible": "src/reversible.py", - "ca": "src/ca.py", - "tile": "src/tile.py", -} - - -def evaluate_one(path: Path, device: str, pop_size: int, debug: bool, run_cpu_program: bool) -> Dict: - out: Dict = {"path": str(path), "filename": path.name} - # Skip standalone machines cleanly rather than error on missing standard gates. - with safe_open(str(path), framework="pt") as f: - meta = f.metadata() or {} - if meta.get("machine"): - m = meta["machine"] - out.update(status="SKIP", machine=m, - note=f"standalone machine — verify with {MACHINE_VERIFIER.get(m, 'machines.py')}") - return out - try: - tensors = load_model(str(path)) - except Exception as e: - out.update(error=f"load failed: {e}", status="ERROR") - return out - - manifest = get_manifest(tensors) - out.update( - size_mb=path.stat().st_size / (1024 * 1024), - tensors=len(tensors), - params=sum(t.numel() for t in tensors.values()), - manifest=manifest, - ) - - # Move to device - tensors = {k: v.to(device) for k, v in tensors.items()} - - try: - evaluator = BatchedFitnessEvaluator(device=device, model_path=str(path), tensors=tensors) - population = create_population(tensors, pop_size=pop_size, device=device) - t0 = time.perf_counter() - fitness = evaluator.evaluate(population, debug=debug) - elapsed = time.perf_counter() - t0 - f0 = float(fitness[0].item()) if pop_size == 1 else float(fitness.mean().item()) - out.update( - fitness=f0, - total_tests=evaluator.total_tests, - elapsed_s=elapsed, - categories={k: (float(v[0]), int(v[1])) for k, v in evaluator.category_scores.items()}, - status="PASS" if f0 >= 0.9999 else "FAIL", - ) - except Exception as e: - out.update(error=f"eval failed: {type(e).__name__}: {e}", status="ERROR") - return out - - # Optional: CPU program test (8-bit CPU primitives are in every variant) - if run_cpu_program: - if manifest["memory_bytes"] >= PROGRAM_MIN_BYTES: - try: - cpu_tensors = {k: v.cpu() for k, v in tensors.items()} - cpu = GenericThresholdCPU(cpu_tensors) - mem, expected = builtin_program(manifest["addr_bits"]) - state = {"pc": 0, "regs": [0] * 4, "flags": [0] * 4, "mem": mem, "halted": False} - t0 = time.perf_counter() - final, cycles = cpu.run(state, max_cycles=200) - cpu_elapsed = time.perf_counter() - t0 - got = final["mem"][0x83] - out["cpu_program"] = { - "ok": got == expected, - "got": got, - "expected": expected, - "cycles": cycles, - "elapsed_s": cpu_elapsed, - } - if got != expected: - out["status"] = "FAIL" - except Exception as e: - out["cpu_program"] = {"error": str(e)} - else: - out["cpu_program"] = {"skipped": f"mem={manifest['memory_bytes']}B < {PROGRAM_MIN_BYTES}"} - - # Wider-ALU chain test for 16/32-bit variants - bits = manifest["data_bits"] - if bits in (16, 32): - try: - alu_tensors = {k: v.cpu() for k, v in tensors.items()} - alu = GenericThresholdALU(alu_tensors, bits) - t0 = time.perf_counter() - if bits == 16: - x, y = 1234, 5678 - z, _ = alu.add_n(x, y, 16); assert z == (x + y) & 0xFFFF - w, _ = alu.sub_n(z, x, 16); assert w == (z - x) & 0xFFFF, (w, z - x) - gt = alu.cmp_n(z, x, "greaterthan", 16); assert gt == 1 - lt = alu.cmp_n(x, z, "lessthan", 16); assert lt == 1 - eq = alu.cmp_n(w, y, "eq", 16); assert eq == 1 - p = alu.mul_n(123, 5, 16); assert p == (123 * 5) & 0xFFFF - else: # 32 - x, y = 1_000_000, 999_000 - z, _ = alu.sub_n(x, y, 32); assert z == 1_000 - s, _ = alu.add_n(z, x, 32); assert s == 1_001_000 - p = alu.mul_n(z, 100, 32); assert p == 100_000 - gt = alu.cmp_n(x, y, "greaterthan", 32); assert gt == 1 - lt = alu.cmp_n(y, x, "lessthan", 32); assert lt == 1 - eq = alu.cmp_n(p, 100_000, "equality", 32); assert eq == 1 - chain_dt = time.perf_counter() - t0 - out[f"alu_chain_{bits}"] = {"ok": True, "elapsed_s": chain_dt} - except AssertionError as e: - out[f"alu_chain_{bits}"] = {"ok": False, "error": f"chain mismatch: {e}"} - out["status"] = "FAIL" - except Exception as e: - out[f"alu_chain_{bits}"] = {"ok": False, "error": f"{type(e).__name__}: {e}"} - out["status"] = "FAIL" - - return out - - -def print_row(r: Dict, show_cpu: bool) -> None: - if r.get("status") == "SKIP": - m = r.get("machine", "machine") - print(f" {r['filename']:<48} SKIP ({m} — verify with {MACHINE_VERIFIER.get(m, 'machines.py')})") - return - if "error" in r: - print(f" {r['filename']:<48} ERROR: {r['error'][:80]}") - return - m = r["manifest"] - fit = f"{r['fitness']:.4f}" if r.get("fitness") is not None else "n/a" - cpu_col = "" - if show_cpu and "cpu_program" in r: - cp = r["cpu_program"] - if cp.get("ok"): - cpu_col = f" CPU OK ({cp['cycles']}cyc/{cp['elapsed_s']:.1f}s)" - elif "skipped" in cp: - cpu_col = f" CPU SKIP" - elif "error" in cp: - cpu_col = f" CPU ERR" - else: - cpu_col = f" CPU FAIL ({cp.get('got')}!={cp.get('expected')})" - chain_col = "" - if show_cpu: - for bits in (16, 32): - key = f"alu_chain_{bits}" - if key in r: - ch = r[key] - if ch.get("ok"): - chain_col = f" ALU{bits} OK ({ch['elapsed_s']:.2f}s)" - else: - chain_col = f" ALU{bits} FAIL" - print( - f" {r['filename']:<48} d={m['data_bits']:>2}b a={m['addr_bits']:>2}b " - f"mem={m['memory_bytes']:>6}B size={r['size_mb']:>6.1f}MB " - f"params={r['params']:>10,} fit={fit:>6} tests={r['total_tests']:>5} " - f"{r['status']:>5}{cpu_col}{chain_col}" - ) - - -def main() -> int: - parser = argparse.ArgumentParser(description="Variant-agnostic eval harness") - parser.add_argument("path", help="Path to .safetensors file or directory of files") - parser.add_argument("--device", default="cpu", help="cpu (default) or cuda") - parser.add_argument("--pop_size", type=int, default=1) - parser.add_argument("--debug", action="store_true", help="Per-circuit detail per file") - parser.add_argument("--cpu-program", action="store_true", - help="Also run a small assembled program through the threshold CPU " - "(any variant with >= 132 B memory), plus a chained 16- or " - "32-bit ALU sequence on wider variants") - parser.add_argument("--json", action="store_true", help="Emit JSON results to stdout instead of a table") - parser.add_argument("--cache-dir", default=".eval_cache", - help="Directory for hash-keyed result cache " - "(default: ./.eval_cache). Set to '' to disable.") - parser.add_argument("--no-cache", action="store_true", - help="Disable the result cache for this run.") - args = parser.parse_args() - - files = list_safetensors(Path(args.path)) - if not files: - print(f"No .safetensors files found under {args.path}", file=sys.stderr) - return 2 - - print(f"Evaluating {len(files)} file(s) on {args.device}\n") - cache_enabled = bool(args.cache_dir) and not args.no_cache - cache_dir = Path(args.cache_dir) if cache_enabled else None - cache_opts = { - "device": args.device, - "pop_size": args.pop_size, - "cpu_program": bool(args.cpu_program), - } - cache_hits = 0 - results = [] - fail_count = 0 - for f in files: - print(f"=== {f.name}") - cached = None - key = None - if cache_enabled: - try: - key = _cache_key(f, cache_opts) - cached = _load_cache(cache_dir, key) - except OSError: - cached = None - if cached is not None: - r = cached - cache_hits += 1 - print(f" (cache hit)") - else: - r = evaluate_one(f, device=args.device, pop_size=args.pop_size, - debug=args.debug, run_cpu_program=args.cpu_program) - # Don't cache ERROR results: a transient failure (OOM, interrupt) - # would otherwise be replayed on every rerun of the same file. - if cache_enabled and key is not None and r.get("status") != "ERROR": - _save_cache(cache_dir, key, r) - results.append(r) - print_row(r, show_cpu=args.cpu_program) - if r.get("status") not in ("PASS", "SKIP"): - fail_count += 1 - - if args.json: - # Make it serialisable - for r in results: - r["manifest"] = {k: (int(v) if isinstance(v, float) and v.is_integer() else v) - for k, v in r.get("manifest", {}).items()} - print(json.dumps(results, indent=2, default=str)) - return fail_count - - # Summary - print() - print("=" * 100) - print(" SUMMARY") - print("=" * 100) - for r in results: - print_row(r, show_cpu=args.cpu_program) - - print() - if fail_count == 0: - print(f"ALL {len(files)} variants PASS") - else: - print(f"{fail_count}/{len(files)} variants FAIL") - if cache_enabled: - print(f"(cache: {cache_hits}/{len(files)} hits, dir={cache_dir})") - return fail_count - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/host.py b/src/host.py new file mode 100644 index 0000000000000000000000000000000000000000..ec9f129452a04a7520fda827bd1508ca841ffecd --- /dev/null +++ b/src/host.py @@ -0,0 +1,1804 @@ +"""The SUBLEQ host: its family, serialization, device, constructor, self-reproduction and evaluators.""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import struct +import sys +import time +from typing import Dict, List, Tuple, Optional + +import numpy as np +import torch +from safetensors import safe_open +from safetensors.torch import save_file + +from machines import Net, compile_net + + +_DT = {torch.float32:"F32", torch.float64:"F64", torch.int64:"I64", + torch.int32:"I32", torch.int16:"I16", torch.int8:"I8", + torch.uint8:"U8", torch.bool:"BOOL"} + +def canonical_bytes(tensors, metadata_pairs): + """Canonical safetensors bytes from tensors and metadata pairs in canonical order.""" + header = {} + if metadata_pairs is not None: + header["__metadata__"] = {k: v for k, v in metadata_pairs} + blobs, off = [], 0 + for name, t in tensors: + t = t.contiguous() + b = t.numpy().tobytes() + header[name] = {"dtype": _DT[t.dtype], "shape": list(t.shape), + "data_offsets": [off, off + len(b)]} + blobs.append(b); off += len(b) + s = json.dumps(header, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + pad = (-(8 + len(s))) % 8 + s = s + b" " * pad + return struct.pack(" Dict[str, int]: + S = 1 << A + return {"c_wr": S - 7, "c_eot": S - 6, "c_rw": S - 5, "c_rd": S - 4, + "r_in": S - 3, "r_out": S - 2, "halt_pc": S - 1} + + +def layout(A: int) -> Dict[str, List[int]]: + return {"pc": [0, A], "halt": [A, 1], "mem": [A + 1, 1 << A, A]} + + +def state_names(A: int) -> List[str]: + S = 1 << A + return ([f"pc{i}" for i in range(A)] + ["halt"] + + [f"m{j}b{t}" for j in range(S) for t in range(A)]) + + +def build(A: int, fold: bool = False) -> Tuple[Net, List[str], List[str]]: + """The step netlist of H_A, units in serialization order.""" + S = 1 << A + net = Net() + g = net.g + pc = [f"pc{i}" for i in range(A)] + halt = "halt" + mu = lambda j, t: f"m{j}b{t}" + + def lits(bits: List[str], value: int): + """The literals asserting that the MSB-first `bits` spell `value`.""" + return [(bits[i], 1 if (value >> (A - 1 - i)) & 1 else -1) for i in range(A)] + + # B1 decode lines of the counter + for j in range(S): + g(f"P{j}", lits(pc, j), -bin(j).count("1")) + # B2 the counter held by the halt bit + for i in range(A): + g(f"HP{i}", [(pc[i], 1), (halt, 1)], -2) + # B3 the fetch conjunctions: operand o, bit t, counter value j + for o in range(3): + for t in range(A): + for j in range(S): + if fold: + g(f"FA{o}_{t}_{j}", [(mu((j + o) % S, t), 1)] + lits(pc, j), -1 - bin(j).count("1")) + else: + g(f"FA{o}_{t}_{j}", [(mu((j + o) % S, t), 1), (f"P{j}", 1)], -2) + # B4 the bits of p + 3, read off the decode lines + for i in range(A): + g(f"Q{i}", [(f"P{j}", 1) for j in range(S) + if (((j + 3) % S) >> (A - 1 - i)) & 1], -1) + # B5 the three operand words A, B, C + for o in range(3): + for t in range(A): + g(f"F{o}_{t}", [(f"FA{o}_{t}_{j}", 1) for j in range(S)], -1) + Aop = [f"F0_{t}" for t in range(A)] + Bop = [f"F1_{t}" for t in range(A)] + Cop = [f"F2_{t}" for t in range(A)] + # B6 decode lines of A and B + if not fold: + for j in range(S): + g(f"PA{j}", lits(Aop, j), -bin(j).count("1")) + for j in range(S): + g(f"PB{j}", lits(Bop, j), -bin(j).count("1")) + # B7 C is the halt address + g("AC", [(c, 1) for c in Cop], -A) + # B8 write enables + for j in range(S): + if fold: + g(f"W{j}", [(halt, -1)] + lits(Bop, j), -bin(j).count("1")) + else: + g(f"W{j}", [(halt, -1), (f"PB{j}", 1)], -1) + # B9 the read conjunctions of M[A] and M[B] + for t in range(A): + for j in range(S): + if fold: + g(f"RX{t}_{j}", [(mu(j, t), 1)] + lits(Aop, j), -1 - bin(j).count("1")) + else: + g(f"RX{t}_{j}", [(mu(j, t), 1), (f"PA{j}", 1)], -2) + for t in range(A): + for j in range(S): + if fold: + g(f"RY{t}_{j}", [(mu(j, t), 1)] + lits(Bop, j), -1 - bin(j).count("1")) + else: + g(f"RY{t}_{j}", [(mu(j, t), 1), (f"PB{j}", 1)], -2) + # B10 the cells kept + for j in range(S): + for t in range(A): + g(f"KP{j}_{t}", [(mu(j, t), 1), (f"W{j}", -1)], -1) + # B11 X = M[A], Y = M[B] + for t in range(A): + g(f"X{t}", [(f"RX{t}_{j}", 1) for j in range(S)], -1) + for t in range(A): + g(f"Y{t}", [(f"RY{t}_{j}", 1) for j in range(S)], -1) + xv = lambda i: f"X{A - 1 - i}" # value bit i, least significant first + yv = lambda i: f"Y{A - 1 - i}" + # B12 propagate signals of Y + not X + for i in range(A - 1): + g(f"PR{i}", [(xv(i), -1), (yv(i), 1)], 0) + # B13 carry terms into bit k: the incoming carry through all lower + # positions, and a generate at j propagated through j+1..k-1 + for k in range(1, A): + g(f"T{k}_n", [(f"PR{i}", 1) for i in range(k)], -k) + for j in range(k): + g(f"T{k}_{j}", [(yv(j), 1), (xv(j), -1)] + + [(f"PR{i}", 1) for i in range(j + 1, k)], -(1 + (k - 1 - j))) + # B14 carries + for k in range(1, A): + g(f"C{k}", [(f"T{k}_n", 1)] + [(f"T{k}_{j}", 1) for j in range(k)], -1) + # B15 X = Y, bit by bit + for i in range(A): + g(f"AX{i}", [(xv(i), 1), (yv(i), 1)], -2) + for i in range(A): + g(f"NX{i}", [(xv(i), -1), (yv(i), -1)], 0) + for i in range(A): + g(f"XN{i}", [(f"AX{i}", 1), (f"NX{i}", 1)], -1) + # B16 + g("EQ", [(f"XN{i}", 1) for i in range(A)], -A) + # B17 the sum bits: bit 0 is X xor Y, bit k the parity of y_k + not x_k + c_k + g("O0", [(yv(0), 1), (xv(0), 1)], -1) + g("N0", [(yv(0), -1), (xv(0), -1)], 1) + g("R0", [("O0", 1), ("N0", 1)], -2) + for k in range(1, A): + for m, b in ((1, 0), (2, -1), (3, -2)): + g(f"H{m}_{k}", [(yv(k), 1), (xv(k), -1), (f"C{k}", 1)], b) + for k in range(1, A): + g(f"R{k}", [(f"H1_{k}", 1), (f"H2_{k}", -1), (f"H3_{k}", 1)], -1) + top = A - 1 + g("LEQ", [(f"H1_{top}", 1), (f"H2_{top}", -1), (f"H3_{top}", 1), ("EQ", 1)], -1) + # B18 the next counter + for i in range(A): + g(f"TA{i}", [(halt, -1), ("LEQ", 1), (Cop[i], 1)], -2) + for i in range(A): + g(f"TB{i}", [(halt, -1), ("LEQ", -1), (f"Q{i}", 1)], -1) + for i in range(A): + g(f"NP{i}", [(f"HP{i}", 1), (f"TA{i}", 1), (f"TB{i}", 1)], -1) + # B19 the halt latch: the next counter is 2^A - 1 + g("HA", [(halt, -1), ("LEQ", 1), ("AC", 1)], -2) + g("HB", [(halt, -1), ("LEQ", -1), (f"P{S - 4}", 1)], -1) + g("NH", [(halt, 1), ("HA", 1), ("HB", 1)], -1) + # B20 the write: bit t of the word is value bit A-1-t of the difference + for j in range(S): + for t in range(A): + g(f"WR{j}_{t}", [(f"R{A - 1 - t}", 1), (f"W{j}", 1)], -2) + for j in range(S): + for t in range(A): + g(f"NM{j}_{t}", [(f"KP{j}_{t}", 1), (f"WR{j}_{t}", 1)], -1) + outputs = ([f"NP{i}" for i in range(A)] + ["NH"] + + [f"NM{j}_{t}" for j in range(S) for t in range(A)]) + return net, state_names(A), outputs + + +# --------------------------------------------------------------------------- +# serialization +# --------------------------------------------------------------------------- + +_DTORD_FAMILY = {"F64": 0, "F32": 1, "F16": 2, "I64": 3, "I32": 4, "I16": 5, "I8": 6} + +_NP = {"I8": " str: + return "I16" if maxabs <= 32767 else "I32" + + +def encode(A: int, bias, fanin, pred, nxt, machine: str, lay, io) -> bytes: + """The serialization of an ordered netlist from its four vectors.""" + n, v = len(nxt), len(bias) + t = {"net.bias": ("I8", np.asarray(bias, dtype=np.int64)), + "net.fanin": (_itype(int(np.max(fanin))), np.asarray(fanin, dtype=np.int64)), + "net.next": (_itype(int(np.max(np.abs(nxt)))), np.asarray(nxt, dtype=np.int64)), + "net.pred": (_itype(int(np.max(np.abs(pred)))), np.asarray(pred, dtype=np.int64))} + assert int(t["net.bias"][1].min()) >= -128 and int(t["net.bias"][1].max()) <= 127 + for k, val in (("addr_bits", A), ("data_bits", A), ("memory_words", 1 << A), + ("pc_width", A), ("state_bits", n), ("units", v), + ("version", VERSION)): + t[f"manifest.{k}"] = ("I32", np.asarray([val], dtype=np.int64)) + order = sorted(t, key=lambda k: (_DTORD_FAMILY[t[k][0]], k)) + header = {"__metadata__": { + "machine": machine, "weight_quantization": "ternary", + "signal_registry": json.dumps(lay, separators=(",", ":"), sort_keys=True), + "io": json.dumps(io, separators=(",", ":"), sort_keys=True)}} + blobs, off = [], 0 + for k in order: + dt, arr = t[k] + b = arr.astype(_NP[dt]).tobytes() + header[k] = {"dtype": dt, "shape": [int(arr.size)], "data_offsets": [off, off + len(b)]} + blobs.append(b) + off += len(b) + s = json.dumps(header, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + s += b" " * ((-(8 + len(s))) % 8) + return struct.pack(" bytes: + """The serialization of a Net in the order its units were built.""" + slot = {s: i for i, s in enumerate(inputs)} + for gname in net.gates: + slot[gname] = len(slot) + bias, fanin, pred = [], [], [] + for gname, (ins, b) in net.gates.items(): + bias.append(b) + fanin.append(len(ins)) + for s, w in ins: + assert w in (-1, 1) + pred.append(w * (slot[s] + 1)) + nxt = [slot[s] for s in outputs] + return encode(A, bias, fanin, pred, nxt, machine or f"subleq{A}", + lay or layout(A), io or io_cells(A)) + + +def sigma(A: int) -> bytes: + """The serialization of H_A, written block by block with numpy.""" + S = 1 << A + n = A + 1 + A * S + bias, fanin, pred = [], [], [] + base = {} + count = [n] + + def block(name: str, size: int): + base[name] = count[0] + count[0] += size + + J = np.arange(S, dtype=np.int64) + popc = np.array([bin(j).count("1") for j in range(S)], dtype=np.int64) + bits = ((J[:, None] >> (A - 1 - np.arange(A))[None, :]) & 1) # [j, i] MSB first + lsign = np.where(bits == 1, 1, -1) + MU = lambda j, t: A + 1 + A * j + t # state index + HALT, PC = A, np.arange(A, dtype=np.int64) + + def emit(b, f, p): + bias.append(np.asarray(b, dtype=np.int64).ravel()) + fanin.append(np.asarray(f, dtype=np.int64).ravel()) + pred.append(np.asarray(p, dtype=np.int64).ravel()) + + # B1 + block("P", S) + emit(-popc, np.full(S, A), (lsign * (PC[None, :] + 1)).ravel()) + # B2 + block("HP", A) + emit(np.full(A, -2), np.full(A, 2), np.stack([PC + 1, np.full(A, HALT + 1)], 1)) + # B3 + block("FA", 3 * A * S) + o, t, j = np.meshgrid(np.arange(3), np.arange(A), J, indexing="ij") + emit(np.full(3 * A * S, -2), np.full(3 * A * S, 2), + np.stack([MU((j + o) % S, t) + 1, base["P"] + j + 1], -1)) + # B4 + block("Q", A) + qf, qp = [], [] + for i in range(A): + sel = J[((((J + 3) % S) >> (A - 1 - i)) & 1) == 1] + qf.append(len(sel)) + qp.append(base["P"] + sel + 1) + emit(np.full(A, -1), qf, np.concatenate(qp)) + # B5 + block("F", 3 * A) + o, t, j = np.meshgrid(np.arange(3), np.arange(A), J, indexing="ij") + emit(np.full(3 * A, -1), np.full(3 * A, S), + base["FA"] + (o * A + t) * S + j + 1) + F = lambda o, t: base["F"] + o * A + t + # B6 + block("PA", S) + emit(-popc, np.full(S, A), (lsign * (F(0, np.arange(A))[None, :] + 1)).ravel()) + block("PB", S) + emit(-popc, np.full(S, A), (lsign * (F(1, np.arange(A))[None, :] + 1)).ravel()) + # B7 + block("AC", 1) + emit([-A], [A], F(2, np.arange(A)) + 1) + # B8 + block("W", S) + emit(np.full(S, -1), np.full(S, 2), + np.stack([np.full(S, -(HALT + 1)), base["PB"] + J + 1], -1)) + # B9 + t, j = np.meshgrid(np.arange(A), J, indexing="ij") + block("RX", A * S) + emit(np.full(A * S, -2), np.full(A * S, 2), + np.stack([MU(j, t) + 1, base["PA"] + j + 1], -1)) + block("RY", A * S) + emit(np.full(A * S, -2), np.full(A * S, 2), + np.stack([MU(j, t) + 1, base["PB"] + j + 1], -1)) + # B10 + j, t = np.meshgrid(J, np.arange(A), indexing="ij") + block("KP", S * A) + emit(np.full(S * A, -1), np.full(S * A, 2), + np.stack([MU(j, t) + 1, -(base["W"] + j + 1)], -1)) + # B11 + t, j = np.meshgrid(np.arange(A), J, indexing="ij") + block("X", A) + emit(np.full(A, -1), np.full(A, S), base["RX"] + t * S + j + 1) + block("Y", A) + emit(np.full(A, -1), np.full(A, S), base["RY"] + t * S + j + 1) + X = lambda i: base["X"] + (A - 1 - i) + Y = lambda i: base["Y"] + (A - 1 - i) + # B12 + block("PR", A - 1) + ii = np.arange(A - 1) + emit(np.zeros(A - 1), np.full(A - 1, 2), np.stack([-(X(ii) + 1), Y(ii) + 1], -1)) + PR = lambda i: base["PR"] + i + # B13 + tb, tf, tp = [], [], [] + tidx = {} + block("T", sum(k + 1 for k in range(1, A))) + cur = base["T"] + for k in range(1, A): + tidx[(k, "n")] = cur; cur += 1 + tb.append(-k); tf.append(k); tp += [PR(i) + 1 for i in range(k)] + for jj in range(k): + tidx[(k, jj)] = cur; cur += 1 + tb.append(-(1 + (k - 1 - jj))); tf.append(2 + (k - 1 - jj)) + tp += [Y(jj) + 1, -(X(jj) + 1)] + [PR(i) + 1 for i in range(jj + 1, k)] + emit(tb, tf, tp) + # B14 + block("C", A - 1) + cp = [] + for k in range(1, A): + cp += [tidx[(k, "n")] + 1] + [tidx[(k, jj)] + 1 for jj in range(k)] + emit(np.full(A - 1, -1), [k + 1 for k in range(1, A)], cp) + C = lambda k: base["C"] + k - 1 + # B15 + ii = np.arange(A) + block("AX", A) + emit(np.full(A, -2), np.full(A, 2), np.stack([X(ii) + 1, Y(ii) + 1], -1)) + block("NX", A) + emit(np.zeros(A), np.full(A, 2), np.stack([-(X(ii) + 1), -(Y(ii) + 1)], -1)) + block("XN", A) + emit(np.full(A, -1), np.full(A, 2), + np.stack([base["AX"] + ii + 1, base["NX"] + ii + 1], -1)) + # B16 + block("EQ", 1) + emit([-A], [A], base["XN"] + ii + 1) + # B17 + block("R0", 3) + emit([-1, 1, -2], [2, 2, 2], + [Y(0) + 1, X(0) + 1, -(Y(0) + 1), -(X(0) + 1), + base["R0"] + 1, base["R0"] + 2]) + block("H", 3 * (A - 1)) + hb, hp = [], [] + for k in range(1, A): + for b in (0, -1, -2): + hb.append(b) + hp += [Y(k) + 1, -(X(k) + 1), C(k) + 1] + emit(hb, np.full(3 * (A - 1), 3), hp) + H = lambda m, k: base["H"] + 3 * (k - 1) + (m - 1) + block("R", A - 1) + rp = [] + for k in range(1, A): + rp += [H(1, k) + 1, -(H(2, k) + 1), H(3, k) + 1] + emit(np.full(A - 1, -1), np.full(A - 1, 3), rp) + Rv = lambda k: base["R0"] + 2 if k == 0 else base["R"] + k - 1 + block("LEQ", 1) + top = A - 1 + emit([-1], [4], [H(1, top) + 1, -(H(2, top) + 1), H(3, top) + 1, base["EQ"] + 1]) + LEQ = base["LEQ"] + # B18 + ii = np.arange(A) + block("TA", A) + emit(np.full(A, -2), np.full(A, 3), + np.stack([np.full(A, -(HALT + 1)), np.full(A, LEQ + 1), F(2, ii) + 1], -1)) + block("TB", A) + emit(np.full(A, -1), np.full(A, 3), + np.stack([np.full(A, -(HALT + 1)), np.full(A, -(LEQ + 1)), base["Q"] + ii + 1], -1)) + block("NP", A) + emit(np.full(A, -1), np.full(A, 3), + np.stack([base["HP"] + ii + 1, base["TA"] + ii + 1, base["TB"] + ii + 1], -1)) + # B19 + block("HA", 3) + emit([-2, -1, -1], [3, 3, 3], + [-(HALT + 1), LEQ + 1, base["AC"] + 1, + -(HALT + 1), -(LEQ + 1), base["P"] + (S - 4) + 1, + HALT + 1, base["HA"] + 1, base["HA"] + 2]) + NH = base["HA"] + 2 + # B20 + j, t = np.meshgrid(J, np.arange(A), indexing="ij") + rdata = np.array([Rv(A - 1 - tt) for tt in range(A)], dtype=np.int64) + block("WR", S * A) + emit(np.full(S * A, -2), np.full(S * A, 2), + np.stack([rdata[t] + 1, base["W"] + j + 1], -1)) + block("NM", S * A) + emit(np.full(S * A, -1), np.full(S * A, 2), + np.stack([base["KP"] + j * A + t + 1, base["WR"] + j * A + t + 1], -1)) + nxt = np.concatenate([base["NP"] + np.arange(A), [NH], + base["NM"] + np.arange(S * A)]) + b = np.concatenate(bias); f = np.concatenate(fanin); p = np.concatenate(pred) + assert len(b) == count[0] - n and int(f.sum()) == len(p) + return encode(A, b, f, p, nxt, f"subleq{A}", layout(A), io_cells(A)) + +# constant signals, which Net folds into biases and which therefore appear only +# as a next-state assignment +CONST0, CONST1 = -1, -2 + +_DTORD = {"F64": 0, "F32": 1, "F16": 2, "I64": 3, "I32": 4, "I16": 5, "I8": 6, + "U8": 7, "BOOL": 8} + +_TAG = {torch.float32: "F32", torch.float64: "F64", torch.int64: "I64", + torch.int32: "I32", torch.int16: "I16", torch.int8: "I8"} + + +def depths(net: Net) -> Dict[str, int]: + """Depth of every unit: one more than the greatest depth of a predecessor.""" + d: Dict[str, int] = {} + order = topological(net) + for g in order: + ins, _ = net.gates[g] + d[g] = 1 + max((d[s] for s, _ in ins if s in net.gates), default=0) + return d + + +def topological(net: Net) -> List[str]: + """The units in some order consistent with the predecessor relation.""" + gates = net.gates + indeg = {g: 0 for g in gates} + cons: Dict[str, List[str]] = {} + for g, (ins, _) in gates.items(): + for s, _w in ins: + if s in gates: + indeg[g] += 1 + cons.setdefault(s, []).append(g) + order = [g for g, k in indeg.items() if k == 0] + i = 0 + while i < len(order): + for c in cons.get(order[i], []): + if c in gates: + indeg[c] -= 1 + if indeg[c] == 0: + order.append(c) + i += 1 + if len(order) != len(gates): + raise ValueError("the predecessor relation has a cycle") + return order + + +def unit_order(net: Net, inputs: List[str]) -> Tuple[List[str], Dict[str, int]]: + """The canonical order of the units, and the signal index of every signal.""" + d = depths(net) + by_level: Dict[int, List[str]] = {} + for g in net.gates: + by_level.setdefault(d[g], []).append(g) + slot = {s: i for i, s in enumerate(inputs)} + order: List[str] = [] + for lv in sorted(by_level): + keyed = [] + for g in by_level[lv]: + ins, b = net.gates[g] + keyed.append(((b, tuple(sorted((slot[s], w) for s, w in ins))), g)) + keyed.sort(key=lambda kv: kv[0]) + seen: Dict[tuple, int] = {} + for key, g in keyed: + if key not in seen: + seen[key] = len(inputs) + len(order) + order.append(g) + slot[g] = seen[key] + return order, slot + + +def tensors_of_net(net: Net, inputs: List[str], outputs: List[str]): + """The five tensors of the serialization, in canonical unit order.""" + order, slot = unit_order(net, inputs) + bias, fanin, pred = [], [], [] + for g in order: + ins, b = net.gates[g] + bias.append(b) + fanin.append(len(ins)) + for k, w in sorted((slot[s], w) for s, w in ins): + assert w in (-1, 1), f"weight {w} is not an edge" + pred.append(w * (k + 1)) + nxt = [] + for s in outputs: + if s == "#0": + nxt.append(CONST0) + elif s == "#1": + nxt.append(CONST1) + else: + nxt.append(slot[s]) + i8, i16 = torch.int8, torch.int16 + return { + "net.bias": torch.tensor(bias, dtype=i8), + "net.fanin": torch.tensor(fanin, dtype=i16), + "net.next": torch.tensor(nxt, dtype=i16), + "net.pred": torch.tensor(pred, dtype=i16), + } + + +def sigma_of_net(net: Net, inputs: List[str], outputs: List[str], + machine: str, layout: Dict, io: Dict) -> bytes: + """The serialization of an ordered clocked netlist.""" + slot = {s: i for i, s in enumerate(inputs)} + for g in net.gates: + slot[g] = len(slot) + bias, fanin, pred = [], [], [] + for g, (ins, b) in net.gates.items(): + bias.append(b) + fanin.append(len(ins)) + for s, w in ins: + assert w in (-1, 1), f"weight {w} is not an edge" + assert slot[s] < slot[g], "a predecessor is built after its unit" + pred.append(w * (slot[s] + 1)) + nxt = [CONST0 if s == "#0" else CONST1 if s == "#1" else slot[s] for s in outputs] + return encode(layout["pc"][1], bias, fanin, pred, nxt, machine, layout, io) + + +def parse(data: bytes) -> Tuple[Dict[str, torch.Tensor], Dict[str, str]]: + """Header and tensors of a safetensors encoding, without a library call.""" + n = struct.unpack(" Tuple[Net, List[str], List[str], Dict[str, str]]: + """The netlist a serialization denotes, with canonical signal names.""" + t, meta = parse(data) + n = int(t["manifest.state_bits"][0]) + v = int(t["manifest.units"][0]) + bias = t["net.bias"].tolist() + fanin = t["net.fanin"].tolist() + pred = t["net.pred"].tolist() + nxt = t["net.next"].tolist() + if len(bias) != v or len(fanin) != v: + raise ValueError("unit count disagrees with the manifest") + if len(pred) != sum(fanin): + raise ValueError("predecessor count disagrees with the fan-in vector") + if len(nxt) != n: + raise ValueError("next-state vector disagrees with the manifest") + inputs = [f"s{i}" for i in range(n)] + name = lambda k: inputs[k] if k < n else f"u{k - n}" + net = Net() + p = 0 + for j in range(v): + ins = [] + for _ in range(fanin[j]): + e = pred[p] + if e == 0: + raise ValueError("predecessor entry carries no weight") + k, w = abs(e) - 1, (1 if e > 0 else -1) + if k >= n + v: + raise ValueError("predecessor index out of range") + if k >= n + j: + raise ValueError("predecessor is not earlier in the order") + ins.append((name(k), w)) + p += 1 + net.g(f"u{j}", ins, bias[j]) + outputs = [] + for k in nxt: + if k == CONST0: + outputs.append("#0") + elif k == CONST1: + outputs.append("#1") + elif 0 <= k < n + v: + outputs.append(name(k)) + else: + raise ValueError("next-state index out of range") + return net, inputs, outputs, meta + +HALT_PC = 0xFF + + +def subleq_state_names() -> List[str]: + names = [f"pc{i}" for i in range(8)] + ["halt"] + for j in range(256): + names += [f"m{j}b{i}" for i in range(8)] + return names + + +def build_subleq_step_net() -> Tuple[Net, List[str], List[str]]: + """The step netlist of the host: the member H_8 of the constant-depth family.""" + return build(8) + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +HOST_PATH = os.path.join(REPO, "variants", "neural_subleq8io_netlist.safetensors") + +# device cells +C_WR, C_EOT, C_RW, C_RD, R_IN, R_OUT = 0xF9, 0xFA, 0xFB, 0xFC, 0xFD, 0xFE + +# program variables +Z, ONE, T1, T2, NEG1, EOK = 0xF0, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5 + + +# ============================================================================= +# Recipe language (grammar and decoder), reused from constructor8 +# ============================================================================= + +def describe(data: bytes) -> bytes: + """Compile bytes into a recipe.""" + tape = bytearray() + i, n = 0, len(data) + while i < n: + j = i + while j < n and data[j] == data[i] and j - i < 127: + j += 1 + if j - i >= 4: + tape.append(256 - (j - i)) + tape.append((256 - data[i]) % 256) + i = j + continue + k = i + while k < n and k - i < 127: + m = k + while m < n and data[m] == data[k] and m - k < 4: + m += 1 + if m - k >= 4: + break + k += 1 + k = max(k, i + 1) + tape.append(k - i) + tape.extend((256 - x) % 256 for x in data[i:k]) + i = k + tape.append(0) + return bytes(tape) + + +def describe_literal(data: bytes) -> bytes: + """The all-literal encoding used in the proof of the length bound.""" + tape = bytearray() + for i in range(0, len(data), 127): + block = data[i:i + 127] + tape.append(len(block)) + tape.extend((256 - x) % 256 for x in block) + tape.append(0) + return bytes(tape) + + +def decode(tape: bytes) -> bytes: + """delta: the decoding map on well-formed recipes.""" + out = bytearray() + i = 0 + while True: + t = tape[i] + i += 1 + if t == 0: + return bytes(out) + if t <= 127: + for _ in range(t): + out.append((256 - tape[i]) % 256) + i += 1 + elif t == 128: + raise ValueError("tag 128 is reserved") + else: + out.extend([(256 - tape[i]) % 256] * (256 - t)) + i += 1 + + +# ============================================================================= +# Framing: ser and inst +# ============================================================================= + +def lam(k: int) -> bytes: + """Eight-byte little-endian length field.""" + return struct.pack(" bytes: + return lam(len(u)) + u + + +def ser(sigma: bytes, m: bytes, tau: bytes) -> bytes: + assert len(m) == 256 + return field(sigma) + field(m) + tau + + +def inst(s: bytes) -> Tuple[bytes, bytes, bytes]: + """Partial inverse of ser: recover (sigma, m, tau).""" + if len(s) < 8: + raise ValueError("truncated") + a = struct.unpack(" Dict[int, int]: + mem = {} + for idx, (a, b, c) in enumerate(prog): + mem[idx * 3] = a + mem[idx * 3 + 1] = b + mem[idx * 3 + 2] = c + return mem + + +# The decoding loop. Addresses are 3k; every branch target below is written as +# an instruction index and resolved to 3*index by _emit. +# +# k0 T2 <- 0 +# k1 T1 <- 0 +# k2 request read of the tag +# k3 T1 <- -T +# k4 T2 <- T +# k5 branch to k7 when T = 0 or T >= 128 +# k6 goto LITERAL +# k7 branch to END when 256-T <= 0, that is T in {0,128} +# k8 goto REPEAT with T1 = 256-T the run length +# k9 LITERAL: request read of the next byte +# k10 R_OUT <- b +# k11 emit +# k12 T2 <- T2-1; branch to k14 when the count is exhausted +# k13 goto k9 +# k14 goto k0 +# k15 REPEAT: request read of the value byte +# k16 R_OUT <- b +# k17 emit +# k18 T1 <- T1-1; branch to k14 when the count is exhausted +# k19 goto k16 +# k20 END +DECODE_LOOP = [ + (T2, T2, 3 * 1), # k0 + (T1, T1, 3 * 2), # k1 + (NEG1, C_RD, 3 * 3), # k2 + (R_IN, T1, 3 * 4), # k3 + (T1, T2, 3 * 5), # k4 + (Z, T2, 3 * 7), # k5 + (Z, Z, 3 * 9), # k6 + (Z, T1, 3 * 20), # k7 + (Z, Z, 3 * 15), # k8 + (NEG1, C_RD, 3 * 10), # k9 + (R_IN, R_OUT, 3 * 11), # k10 + (NEG1, C_WR, 3 * 12), # k11 + (ONE, T2, 3 * 14), # k12 + (Z, Z, 3 * 9), # k13 + (Z, Z, 3 * 0), # k14 + (NEG1, C_RD, 3 * 16), # k15 + (R_IN, R_OUT, 3 * 17), # k16 + (NEG1, C_WR, 3 * 18), # k17 + (ONE, T1, 3 * 14), # k18 + (Z, Z, 3 * 16), # k19 +] + +# P: the constructor. The end token halts. +P = DECODE_LOOP + [(Z, Z, HALT_PC)] + +# P*: the end token enters the rewind phase, then the copy phase. +# k20 R: request rewind +# k21 B0: request read +# k22 B1: EOK <- 1-EOT; halt when the end of tape is reached +# k23 B2: Z <- -b +# k24 B3: R_OUT <- b +# k25 B4: emit +# k26 B5: Z <- 0 and go to B0 +P_STAR = DECODE_LOOP + [ + (NEG1, C_RW, 3 * 21), # k20 + (NEG1, C_RD, 3 * 22), # k21 + (C_EOT, EOK, HALT_PC), # k22 + (R_IN, Z, 3 * 24), # k23 + (Z, R_OUT, 3 * 25), # k24 + (NEG1, C_WR, 3 * 26), # k25 + (Z, Z, 3 * 21), # k26 +] + + +# P_e: P with an end-of-tape guard after the tag read, so that the machine +# halts on every tape, recipe or not. Instruction j3 assigns +# M[EOK] <- M[EOK] - M[C_EOT]; a successful tag read has cleared C_EOT and +# leaves EOK at 1, while a read at the end of the tape sets it and the result +# 0 transfers control to the halt cell. +P_TOTAL = [ + (T2, T2, 3 * 1), # j0 + (T1, T1, 3 * 2), # j1 + (NEG1, C_RD, 3 * 3), # j2 read the tag + (C_EOT, EOK, HALT_PC), # j3 halt if that read was past the end of the tape + (R_IN, T1, 3 * 5), # j4 + (T1, T2, 3 * 6), # j5 + (Z, T2, 3 * 8), # j6 + (Z, Z, 3 * 10), # j7 + (Z, T1, 3 * 21), # j8 + (Z, Z, 3 * 16), # j9 + (NEG1, C_RD, 3 * 11), # j10 LITERAL + (R_IN, R_OUT, 3 * 12), # j11 + (NEG1, C_WR, 3 * 13), # j12 + (ONE, T2, 3 * 15), # j13 + (Z, Z, 3 * 10), # j14 + (Z, Z, 3 * 0), # j15 + (NEG1, C_RD, 3 * 17), # j16 REPEAT + (R_IN, R_OUT, 3 * 18), # j17 + (NEG1, C_WR, 3 * 19), # j18 + (ONE, T1, 3 * 15), # j19 + (Z, Z, 3 * 17), # j20 + (Z, Z, HALT_PC), # j21 END +] + + +def decode_any(tape: bytes, guard: bool) -> Tuple[bytes, bool]: + """The output of the decoding loop on an arbitrary tape.""" + out = bytearray() + h, rin = 0, 0 + while True: + if h < len(tape): + rin = tape[h] + h += 1 + eot = 0 + else: + eot = 1 + if guard and eot: + return bytes(out), True + t = rin + if not guard and eot: + return bytes(out), t in (0, 128) + if t == 0 or t == 128: + return bytes(out), True + reps = 1 if t > 128 else t + count = (256 - t) if t > 128 else 1 + for _ in range(reps): + if h < len(tape): + rin = tape[h] + h += 1 + out.extend([(256 - rin) % 256] * count) + + +def memory_image(prog: List[Tuple[int, int, int]]) -> List[int]: + """The 256-byte initial memory image holding a program and its constants.""" + mem = [0] * 256 + for addr, val in _emit(prog).items(): + mem[addr] = val & 0xFF + mem[Z] = 0 + mem[ONE] = 1 + mem[T1] = 0 + mem[T2] = 0 + mem[NEG1] = 0xFF + mem[EOK] = 1 + return mem + + +M_P = memory_image(P) + +M_STAR = memory_image(P_STAR) + + +# ============================================================================= +# Device +# ============================================================================= + +class Tape: + """Environment state (tau, h, omega) with the operations of the definition.""" + + def __init__(self, tau: bytes): + self.tau = tau + self.h = 0 + self.out = bytearray() + + def apply(self, mem: List[int]) -> None: + """One device application to the post-instruction memory image.""" + if mem[C_WR] == 1: + self.out.append(mem[R_OUT]) + mem[R_OUT] = 0 + if mem[C_RD] == 1: + if self.h < len(self.tau): + mem[R_IN] = self.tau[self.h] + mem[C_EOT] = 0 + self.h += 1 + else: + mem[C_EOT] = 1 + if mem[C_RW] == 1: + self.h = 0 + mem[C_WR] = 0 + mem[C_RD] = 0 + mem[C_RW] = 0 + + +# ============================================================================= +# Evaluator 1: integer reference +# ============================================================================= + +def run_reference(mem0: List[int], tau: bytes, max_steps: int = 1 << 34, + expect: Optional[bytes] = None) -> Tuple[bytes, int]: + mem = list(mem0) + dev = Tape(tau) + pc = 0 + steps = 0 + while pc != HALT_PC and steps < max_steps: + A = mem[pc] + B = mem[(pc + 1) & 0xFF] + C = mem[(pc + 2) & 0xFF] + r = (mem[B] - mem[A]) & 0xFF + mem[B] = r + pc = C if (r == 0 or r >= 0x80) else (pc + 3) & 0xFF + n_before = len(dev.out) + dev.apply(mem) + if expect is not None and len(dev.out) > n_before: + k = len(dev.out) - 1 + if k >= len(expect) or dev.out[k] != expect[k]: + raise AssertionError(f"stream diverged at byte {k}") + steps += 1 + return bytes(dev.out), steps + +# ============================================================================= +# The host netlist and its canonical serialization +# ============================================================================= + +STATE_LAYOUT = {"pc": [0, 8], "halt": [8, 1], "mem": [9, 256, 8]} + +IO_CELLS = {"c_wr": C_WR, "c_eot": C_EOT, "c_rw": C_RW, "c_rd": C_RD, + "r_in": R_IN, "r_out": R_OUT, "halt_pc": HALT_PC} + +STATE_BITS = 8 + 1 + 2048 + + +def host_netlist(): + """The clocked netlist of the host, assembled from its source description.""" + return build_subleq_step_net() + + +def sigma_host() -> bytes: + """sigma(N_host): the canonical serialization of that netlist.""" + net, inputs, outputs = host_netlist() + return sigma_of_net(net, inputs, outputs, "subleq8", STATE_LAYOUT, + IO_CELLS) + + +def read_host() -> bytes: + """The distributed serialization of the host.""" + return open(HOST_PATH, "rb").read() + + +def sha(b: bytes) -> str: + return hashlib.sha256(b).hexdigest() + + +def tau_star(sigma: bytes, m: List[int]) -> bytes: + return describe(field(sigma) + field(bytes(m))) + + +# ============================================================================= +# Evaluators of the step map, each built from sigma alone +# ============================================================================= + +class _Transducer: + """State marshalling shared by the threshold evaluators.""" + + N = STATE_BITS + _DEVCELLS = (C_WR, C_EOT, C_RW, C_RD, R_IN, R_OUT) + + def _vec(self, pc: int, mem: List[int]): + torch = self.torch + v = torch.zeros(self.N) + for k in range(8): + v[k] = (pc >> (7 - k)) & 1 + for j in range(256): + for k in range(8): + v[9 + j * 8 + k] = (mem[j] >> (7 - k)) & 1 + return v + + @staticmethod + def _byte(vc, j: int) -> int: + x = 0 + for k in range(8): + x = (x << 1) | int(vc[9 + j * 8 + k]) + return x + + def _set_byte(self, v, j: int, val: int) -> None: + for k in range(8): + v[0, 9 + j * 8 + k] = float((val >> (7 - k)) & 1) + + def capture(self): + """Capture one application of the map as a CUDA graph.""" + torch = self.torch + assert self.device.startswith("cuda") + self.gin = torch.zeros(1, self.N, device=self.device) + # every buffer the body writes must stay alive for the life of the + # graph, or the allocator will hand its memory to something else and + # the replay will overwrite that instead + self.gbuf = self._buffers() + for _ in range(5): + self._body(self.gin) + torch.cuda.synchronize() + self.graph = torch.cuda.CUDAGraph() + with torch.cuda.graph(self.graph): + self.gout = self._body(self.gin) + torch.cuda.synchronize() + gen = torch.Generator(device=self.device).manual_seed(3) + probe = (torch.rand(1, self.N, generator=gen, + device=self.device) < 0.5).float() + self.gin.copy_(probe) + self.graph.replay() + torch.cuda.synchronize() + assert bool((self.gout == self._eager(probe)).all()), \ + "the captured graph differs from the eager step" + return self + + def _replay(self, v): + # the state lives in gin, which is ordinary memory; gout belongs to the + # graph's private pool and is only ever read as a whole + if v.data_ptr() != self.gin.data_ptr(): + self.gin.copy_(v) + self.graph.replay() + self.gin.copy_(self.gout) + return self.gin + + def run(self, mem0: List[int], tau: bytes, max_steps: int, + expect: Optional[bytes] = None, progress: int = 0, + margin: bool = False) -> Tuple[bytes, int]: + """Iterate the map with the device applied after each step.""" + import time + torch = self.torch + v = self._vec(0, mem0).unsqueeze(0).to(self.device) + dev = Tape(tau) + cells = list(self._DEVCELLS) + cell_t = torch.tensor([9 + j * 8 + k for j in cells for k in range(8)], + device=self.device) + pow2_t = torch.tensor([1 << (7 - k) for k in range(8)], + device=self.device, dtype=torch.float32) + shift_t = torch.tensor([7 - k for k in range(8)]) + shadow = [0] * 256 + self.min_margin = float("inf") + n = 0 + t0 = time.perf_counter() + while n < max_steps: + if margin: + self._accumulate_margin(v) + v = self.step(v) + n += 1 + vals = torch.cat([v[0, 8:9], + (v[0, cell_t].reshape(len(cells), 8) * pow2_t) + .sum(-1)]).to("cpu").to(torch.int64).tolist() + for c, j in enumerate(cells): + shadow[j] = vals[1 + c] + before = len(dev.out) + dev.apply(shadow) + new = torch.tensor([shadow[j] for j in cells], dtype=torch.int64) + v[0, cell_t] = (((new.unsqueeze(-1) >> shift_t) & 1) + .reshape(-1).float().to(self.device)) + if expect is not None and len(dev.out) > before: + k = len(dev.out) - 1 + if k >= len(expect) or dev.out[k] != expect[k]: + raise AssertionError(f"stream diverged at byte {k}") + if vals[0] >= 1: + break + if progress and n % progress == 0: + rate = n / (time.perf_counter() - t0) + print(f" {self.tag} {n:,} steps, {len(dev.out):,} bytes " + f"({rate:,.0f} steps/s)", flush=True) + self.seconds = time.perf_counter() - t0 + return bytes(dev.out), n + + +class NetEvaluator(_Transducer): + """The netlist of sigma, evaluated unit by unit.""" + + tag = "net" + + def __init__(self, sigma: bytes, device: str = "cpu", graph: bool = False): + import time + import torch + from reflect import Leveled + t0 = time.perf_counter() + self.torch = torch + self.device = device + net, inputs, outputs, meta = net_of_sigma(sigma) + self.net, self.inputs, self.outputs, self.meta = net, inputs, outputs, meta + assert len(inputs) == self.N, "state width disagrees with the layout" + self.lev = Leveled(net, inputs, outputs, device=device) + self.info = {"levels": len(self.lev.plan), "units": len(net.gates), + "entries": sum(len(i) for i, _ in net.gates.values())} + self.graph = None + if graph: + self.capture() + self.build_seconds = time.perf_counter() - t0 + + def _buffers(self): + return self.torch.zeros(self.lev.n_sig, 1, device=self.device) + + def _body(self, inp): + V = self.gbuf + lev = self.lev + V.zero_() + V[1] = 1.0 + V[lev.in_slots] = inp.T + for idx, w, b, out in lev.plan: + g = V[idx] + V[out] = ((g * w[:, :, None]).sum(1) + b[:, None] >= 0).float() + return V[lev.out_slots].T + + def _eager(self, v): + return self.lev.step(v) + + def step(self, v): + if self.graph is not None: + return self._replay(v) + return self.lev.step(v) + + +class LevEvaluator(_Transducer): + """Lev(N) for the netlist N of sigma.""" + + tag = "lev" + + def __init__(self, sigma: bytes, device: str = "cpu", dense: bool = True, + graph: bool = False): + import time + import torch + from machines import compile_net + t0 = time.perf_counter() + self.torch = torch + self.device = device + self.dense = dense + net, inputs, outputs, meta = net_of_sigma(sigma) + self.net, self.inputs, self.outputs, self.meta = net, inputs, outputs, meta + assert len(inputs) == self.N, "state width disagrees with the layout" + layers, info = compile_net(net, inputs, outputs) + for W, _ in layers: + assert set(torch.unique(W).tolist()) <= {-1, 0, 1} + self.info = dict(info) + self.info["size"] = sum(int(W.shape[0]) for W, _ in layers) + self.info["nonzero"] = sum(int((W != 0).sum()) for W, _ in layers) + if dense: + self.W = [W.to(device=device, dtype=torch.float32) + for W, _ in layers] + self.B = [b.to(device=device, dtype=torch.float32) + for _, b in layers] + else: + pad = device.startswith("cuda") + self.plan = [self._sparsify(W, b, device, pad) for W, b in layers] + self.widths = [int(W.shape[0]) for W, _ in layers] + self.graph = None + if graph: + self.capture() + self.build_seconds = time.perf_counter() - t0 + + @staticmethod + def _sparsify(W, b, device, pad): + """One layer as the nonzero entries of its rows.""" + import torch + nz = (W != 0) + counts = nz.sum(1) + n = int(W.shape[0]) + sizes = [int(counts.max())] if pad else sorted(set(counts.tolist())) + groups = [] + for k in sizes: + rows = (torch.arange(n) if pad + else torch.nonzero(counts == k, as_tuple=False).flatten()) + idx = torch.zeros(len(rows), max(k, 1), dtype=torch.long) + w = torch.zeros(len(rows), max(k, 1)) + for r, row in enumerate(rows.tolist()): + cols = torch.nonzero(nz[row], as_tuple=False).flatten() + idx[r, :len(cols)] = cols + w[r, :len(cols)] = W[row, cols] + groups.append((rows.to(device), idx.to(device), + w.to(device=device, dtype=torch.float32), + b[rows].to(device=device, dtype=torch.float32))) + return groups + + def _buffers(self): + import torch + return [torch.zeros(1, n, device=self.device) for n in self.widths] + + def _sparse_step(self, v, buf): + x = v + for groups, y in zip(self.plan, buf): + for rows, idx, w, b in groups: + y[:, rows] = ((x[:, idx] * w).sum(-1) + b >= 0).float() + x = y + return x + + def _body(self, inp): + return self._sparse_step(inp, self.gbuf) + + def _eager(self, v): + return self._sparse_step(v, [self.torch.zeros(v.shape[0], n, + device=self.device) + for n in self.widths]) + + def step(self, v): + if self.dense: + for W, b in zip(self.W, self.B): + v = ((v @ W.T + b) >= 0).float() + return v + if self.graph is not None: + return self._replay(v) + return self._eager(v) + + def _accumulate_margin(self, v): + y = v + for W, b in zip(self.W, self.B): + pre = y @ W.T + b + m = float((pre + 0.5).abs().min()) + if m < self.min_margin: + self.min_margin = m + y = (pre >= 0).float() + + def step_noisy(self, v, sigma: float, gen): + """One step with additive Gaussian read noise per pre-activation and the comparator at -1/2.""" + torch = self.torch + if self.dense: + for W, b in zip(self.W, self.B): + pre = v @ W.T + b + pre = pre + torch.randn(pre.shape, generator=gen, + device=pre.device) * sigma + v = (pre >= -0.5).float() + return v + x = v + for groups, n in zip(self.plan, self.widths): + y = torch.empty(x.shape[0], n, device=x.device) + for rows, idx, w, b in groups: + pre = (x[:, idx] * w).sum(-1) + b + pre = pre + torch.randn(pre.shape, generator=gen, + device=pre.device) * sigma + y[:, rows] = (pre >= -0.5).float() + x = y + return x + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # repo root; this module lives in src/ + +MODEL_PATH = os.path.join(REPO, "variants", "neural_subleq8io.safetensors") + +IN_DATA, IN_STROBE, OUT = 0xFD, 0xFC, 0xFE + + +# ============================================================================= +# Build the host file (family per-gate style, .inputs wiring, ternary strict) +# ============================================================================= + +def build_host(save: bool = True) -> str: + import build as B + from build import quantize_tensors + + tensors: Dict[str, torch.Tensor] = {} + B.add_decoder(tensors, 8, 256) + B.add_memory_read_mux(tensors, 256) + B.add_memory_write_cells(tensors, 256) + B.add_subleq_core(tensors) + tensors["manifest.data_bits"] = torch.tensor([8.0]) + tensors["manifest.addr_bits"] = torch.tensor([8.0]) + tensors["manifest.memory_bytes"] = torch.tensor([256.0]) + tensors["manifest.pc_width"] = torch.tensor([8.0]) + tensors["manifest.version"] = torch.tensor([4.0]) + tensors, reg, stats = B.build_inputs(tensors) + tensors, counts, _, _ = quantize_tensors(tensors, ternary=False) + for k, t in tensors.items(): + if k.endswith(".weight") and not k.startswith("manifest."): + tf = t.float() if t.dtype.is_floating_point else t.to(torch.float32) + assert (tf.abs() <= 1).all(), f"non-ternary weight {k}" + meta = { + "machine": "subleq8io", + "weight_quantization": "ternary", + "signal_registry": reg.to_metadata(), + "io": json.dumps({"in_data": IN_DATA, "in_strobe": IN_STROBE, + "out": OUT, "halt_pc": HALT_PC}), + } + if save: + save_file(tensors, MODEL_PATH, metadata=meta) + sz = os.path.getsize(MODEL_PATH) + print(f" built {MODEL_PATH}: {stats['added']} wired gates, " + f"{len(tensors)} tensors, {sz:,} bytes") + return MODEL_PATH + + +# ============================================================================= +# Recipe codec (the construction-description language) +# ============================================================================= + +def c8_describe(data: bytes) -> bytes: + """Compile raw bytes into a construction recipe for the constructor.""" + tape = bytearray() + i, n = 0, len(data) + while i < n: + j = i + while j < n and data[j] == data[i] and j - i < 127: + j += 1 + if j - i >= 4: + tape.append(256 - (j - i)) # repeat tag 129..252 + tape.append((256 - data[i]) % 256) # value, negated + i = j + continue + k = i + while k < n and k - i < 127: # literal until a run starts + m = k + while m < n and data[m] == data[k] and m - k < 4: + m += 1 + if m - k >= 4: + break + k += 1 + k = max(k, i + 1) + tape.append(k - i) # literal tag 1..127 + tape.extend((256 - x) % 256 for x in data[i:k]) + i = k + tape.append(0) # END + return bytes(tape) + + +def c8_decode(tape: bytes) -> bytes: + """Pure-Python reference semantics of the recipe language.""" + out = bytearray() + i = 0 + while True: + t = tape[i] + i += 1 + if t == 0: + return bytes(out) + if t <= 127: + for _ in range(t): + out.append((256 - tape[i]) % 256) + i += 1 + else: + out.extend([(256 - tape[i]) % 256] * (256 - t)) + i += 1 + + +# ============================================================================= +# The constructor program (21 SUBLEQ instructions + 4 variable cells) +# ============================================================================= + + +CONSTRUCTOR = [ + # main loop: read tag T; T1 = -T (== run length for repeat tags), T2 = T + (T2, T2, 0x03), # 00 T2 = 0 + (T1, T1, 0x06), # 03 T1 = 0 + (IN_DATA, T1, 0x09), # 06 T1 = -T + (Z, IN_STROBE, 0x0C), # 09 consume tag byte + (T1, T2, 0x0F), # 0C T2 = T + (Z, T2, 0x15), # 0F if T == 0 or T >= 128 -> 15, else literal + (Z, Z, 0x24), # 12 goto LITERAL + (Z, T1, 0x1B), # 15 T1 = -T: leq only when T == 0 -> END + (Z, Z, 0x30), # 18 goto REPEAT (T1 holds 256-T = run length) + (Z, Z, HALT_PC), # 1B END: halt + (Z, Z, 0x21), # 1E (pad) + (Z, Z, 0x24), # 21 (pad) + (IN_DATA, OUT, 0x27), # 24 LITERAL: emit 0 - tape_byte + (Z, IN_STROBE, 0x2A), # 27 consume it + (ONE, T2, 0x00), # 2A T2 -= 1; done -> main loop + (Z, Z, 0x24), # 2D next literal byte + (IN_DATA, OUT, 0x33), # 30 REPEAT: emit 0 - value (no strobe) + (ONE, T1, 0x39), # 33 T1 -= 1; done -> 39 + (Z, Z, 0x30), # 36 emit again + (Z, IN_STROBE, 0x3C), # 39 consume the value byte + (Z, Z, 0x00), # 3C goto main loop +] + + +def constructor_image() -> List[int]: + mem = [0] * 256 + for idx, (a, b, c) in enumerate(CONSTRUCTOR): + mem[idx * 3] = a + mem[idx * 3 + 1] = b + mem[idx * 3 + 2] = c + mem[ONE] = 1 + return mem + + +# ============================================================================= +# Backend 1: fast pure-integer reference with devices +# ============================================================================= + +def ref_construct(tape: bytes, max_steps: int = 1 << 34, + ) -> Tuple[bytes, int]: + mem = constructor_image() + mem[IN_DATA] = tape[0] if tape else 0 + out = bytearray() + head = 0 + pc = 0 + steps = 0 + tn = len(tape) + while pc != HALT_PC and steps < max_steps: + A = mem[pc] + Bc = mem[(pc + 1) & 0xFF] + C = mem[(pc + 2) & 0xFF] + r = (mem[Bc] - mem[A]) & 0xFF + mem[Bc] = r + pc = C if (r == 0 or r >= 0x80) else (pc + 3) & 0xFF + if Bc == OUT: + out.append(r) + mem[OUT] = 0 + elif Bc == IN_STROBE: + head += 1 + mem[IN_STROBE] = 0 + mem[IN_DATA] = tape[head] if head < tn else 0 + steps += 1 + return bytes(out), steps + + +# ============================================================================= +# Backend 2: the gate-graph processor (shipped netlist) with devices +# ============================================================================= + +class GateHost: + def __init__(self, path: str = MODEL_PATH): + from machines import SubleqThresholdCPU, load_tensors + from eval import load_metadata + T = load_tensors(path) + reg = load_metadata(path)["signal_registry"] + self.cpu = SubleqThresholdCPU(T, reg) + + def run(self, tape: bytes, max_steps: int) -> Tuple[bytes, int, dict]: + s = {"pc": 0, "mem": constructor_image(), "halted": False} + s["mem"][IN_DATA] = tape[0] if tape else 0 + out = bytearray() + head = 0 + n = 0 + while not s["halted"] and n < max_steps: + Bc = s["mem"][(s["pc"] + 1) & 0xFF] + s = self.cpu.step(s) + if Bc == OUT: + out.append(s["mem"][OUT]) + s["mem"][OUT] = 0 + elif Bc == IN_STROBE: + head += 1 + s["mem"][IN_STROBE] = 0 + s["mem"][IN_DATA] = tape[head] if head < len(tape) else 0 + n += 1 + return bytes(out), n, s + + +# ============================================================================= +# Backend 3: the host compiled to recurrent ternary matrices (machine 1 tech) +# ============================================================================= + + +class MatrixHost: + """The subleq8io processor as a recurrent ternary threshold network, the device applied between steps.""" + + N = 8 + 1 + 2048 + + def __init__(self, device: str = "cpu"): + t0 = time.perf_counter() + net, inputs, outputs = build_subleq_step_net() + layers, info = compile_net(net, inputs, outputs) + for W, Bt in layers: + vals = set(torch.unique(W).tolist()) + assert vals <= {-1, 0, 1} + self.W = [W.to(device=device, dtype=torch.float32) for W, _ in layers] + self.B = [Bt.to(device=device, dtype=torch.float32) for _, Bt in layers] + self.device = device + self.info = info + print(f" host compiled to {info['layers']} ternary matrices " + f"(max width {info['max_width']}, " + f"{info['total_weights']:,} weights) in " + f"{time.perf_counter() - t0:.1f}s on {device}") + + def _vec(self, pc: int, mem: List[int], halt: bool = False) -> torch.Tensor: + v = torch.zeros(self.N) + for k in range(8): + v[k] = (pc >> (7 - k)) & 1 + v[8] = 1.0 if halt else 0.0 + for j in range(256): + for k in range(8): + v[9 + j * 8 + k] = (mem[j] >> (7 - k)) & 1 + return v + + def step(self, v: torch.Tensor) -> torch.Tensor: + for W, b in zip(self.W, self.B): + v = ((v @ W.T + b) >= 0).float() + return v + + @staticmethod + def _byte(vcpu: torch.Tensor, j: int) -> int: + x = 0 + for k in range(8): + x = (x << 1) | int(vcpu[9 + j * 8 + k]) + return x + + def run(self, tape: bytes, max_steps: int, expect: Optional[bytes] = None, + report_every: int = 100000) -> Tuple[bytes, int]: + mem = constructor_image() + mem[IN_DATA] = tape[0] if tape else 0 + v = self._vec(0, mem).unsqueeze(0).to(self.device) + out = bytearray() + head = 0 + n = 0 + idx_pch = torch.arange(0, 9, device=self.device) + t0 = time.perf_counter() + while n < max_steps: + front = v[0, idx_pch].tolist() + if front[8] >= 0.5: + break + pcv = 0 + for k in range(8): + pcv = (pcv << 1) | int(front[k]) + bidx = (pcv + 1) & 0xFF + bbits = v[0, 9 + bidx * 8: 9 + bidx * 8 + 8].tolist() + Bc = 0 + for k in range(8): + Bc = (Bc << 1) | int(bbits[k]) + v = self.step(v) + n += 1 + if Bc == OUT: + ob = v[0, 9 + OUT * 8: 9 + OUT * 8 + 8].tolist() + val = 0 + for k in range(8): + val = (val << 1) | int(ob[k]) + out.append(val) + if expect is not None and out[-1] != expect[len(out) - 1]: + raise AssertionError( + f"stream diverged at byte {len(out) - 1}: " + f"got {out[-1]:#04x} want {expect[len(out) - 1]:#04x}") + v[0, 9 + OUT * 8: 9 + OUT * 8 + 8] = 0.0 + elif Bc == IN_STROBE: + head += 1 + v[0, 9 + IN_STROBE * 8: 9 + IN_STROBE * 8 + 8] = 0.0 + nxt = tape[head] if head < len(tape) else 0 + for k in range(8): + v[0, 9 + IN_DATA * 8 + k] = float((nxt >> (7 - k)) & 1) + if report_every and n % report_every == 0: + rate = n / (time.perf_counter() - t0) + print(f" ... {n:,} recurrences, {len(out):,} bytes emitted " + f"({rate:,.0f} instr/s)") + return bytes(out), n + + def exhaustive_datapath(self) -> bool: + """All 65,536 (x, y) operand pairs through the matrices in one batch.""" + xs = torch.arange(65536) % 256 + ys = torch.arange(65536) // 256 + V = torch.zeros(65536, self.N) + mem = [0] * 256 + mem[0], mem[1], mem[2] = 0x90, 0x91, 0x30 + v0 = self._vec(0, mem) + V[:] = v0 + for k in range(8): + V[:, 9 + 0x90 * 8 + k] = ((xs >> (7 - k)) & 1).float() + V[:, 9 + 0x91 * 8 + k] = ((ys >> (7 - k)) & 1).float() + V = V.to(self.device) + for W, b in zip(self.W, self.B): + V = ((V @ W.T + b) >= 0).float() + V = V.cpu() + r = torch.zeros(65536, dtype=torch.long) + for k in range(8): + r = (r << 1) | V[:, 9 + 0x91 * 8 + k].long() + pcv = torch.zeros(65536, dtype=torch.long) + for k in range(8): + pcv = (pcv << 1) | V[:, k].long() + exp_r = (ys - xs) & 0xFF + exp_leq = (exp_r == 0) | (exp_r >= 0x80) + exp_pc = torch.where(exp_leq, torch.full_like(pcv, 0x30), + torch.full_like(pcv, 3)) + ok = bool((r == exp_r).all()) and bool((pcv == exp_pc).all()) + print(f" {'OK ' if ok else 'FAIL'} matrix datapath exhaustive: " + f"65,536/65,536 subtract results and branch decisions exact") + return ok + + +# ============================================================================= +# Verification +# ============================================================================= + +def c8_sha(b: bytes) -> str: + return hashlib.sha256(b).hexdigest()[:16] + + +def family_files() -> List[str]: + files = [os.path.join(REPO, "neural_computer.safetensors")] + vdir = os.path.join(REPO, "variants") + files += sorted(os.path.join(vdir, f) for f in os.listdir(vdir) + if f.endswith(".safetensors")) + return files + + +def verify(device: str) -> bool: + ok = True + + print("\n[1/5] Host machine soundness (shipped netlist, exhaustive datapath)") + from eval import NetlistEvaluator, load_metadata + from machines import load_tensors + T = load_tensors(MODEL_PATH) + reg = load_metadata(MODEL_PATH)["signal_registry"] + ne = NetlistEvaluator(T, reg, "subleq") + a_v = torch.arange(65536) % 256 + b_v = torch.arange(65536) // 256 + ext = {} + for k in range(8): + ext[f"$a[{k}]"] = ((a_v >> k) & 1).float() + ext[f"$b[{k}]"] = ((b_v >> k) & 1).float() + ext[f"$pc[{k}]"] = torch.zeros(65536) + ext[f"$c[{k}]"] = torch.zeros(65536) + o = ne.run(ext) + rr = sum(o[f"subleq.sub.fa{k}.ha2.sum.layer2"][:, 0].long() << k for k in range(8)) + lq = o["subleq.leq"][:, 0].long() + exp_r = (b_v - a_v) & 0xFF + exp_l = ((exp_r == 0) | (exp_r >= 128)).long() + good = bool((rr == exp_r).all()) and bool((lq == exp_l).all()) + print(f" {'OK ' if good else 'FAIL'} gate netlist exhaustive: 65,536/65,536 exact") + ok &= good + + print("\n[2/5] Recipe codec round-trip over the whole family") + total = 0 + for p in family_files(): + data = open(p, "rb").read() + tape = c8_describe(data) + good = c8_decode(tape) == data + ok &= good + total += len(data) + print(f" {'OK ' if good else 'FAIL'} {os.path.basename(p):<44} " + f"{len(data):>10,} B -> tape {len(tape):>10,} B sha {c8_sha(data)}") + print(f" ({total / 1e6:.0f} MB described and decoded back, byte-identical)") + + print("\n[3/5] Constructor program on the integer reference " + "(output stream == target file, byte for byte)") + targets = [MODEL_PATH, + os.path.join(REPO, "variants", "neural_subleq8.safetensors"), + os.path.join(REPO, "variants", "neural_matrix8.safetensors")] + for p in targets: + data = open(p, "rb").read() + tape = c8_describe(data) + t0 = time.perf_counter() + out, steps = ref_construct(tape) + dt = time.perf_counter() - t0 + good = out == data + ok &= good + tag = "SELF-REPRODUCTION" if p == MODEL_PATH else "universal build" + print(f" {'OK ' if good else 'FAIL'} {os.path.basename(p):<40} " + f"{len(out):>10,} B in {steps:>12,} instructions ({dt:.1f}s) " + f"[{tag}] sha {c8_sha(out)}") + + print("\n[4/5] Matrix backend (machine-1 compile of the host)") + mh = MatrixHost(device=device) + ok &= mh.exhaustive_datapath() + + print("\n[5/5] Backend lockstep on a shared prefix " + "(gate graph vs matrices vs reference, first 600 cycles)") + data = open(MODEL_PATH, "rb").read() + tape = c8_describe(data) + gh = GateHost() + s = {"pc": 0, "mem": constructor_image(), "halted": False} + s["mem"][IN_DATA] = tape[0] + mem2 = constructor_image() + mem2[IN_DATA] = tape[0] + v = mh._vec(0, mem2).unsqueeze(0).to(device) + headg = headm = 0 + outg, outm = bytearray(), bytearray() + good = True + for n in range(600): + Bg = s["mem"][(s["pc"] + 1) & 0xFF] + s = gh.cpu.step(s) + if Bg == OUT: + outg.append(s["mem"][OUT]) + s["mem"][OUT] = 0 + elif Bg == IN_STROBE: + headg += 1 + s["mem"][IN_STROBE] = 0 + s["mem"][IN_DATA] = tape[headg] if headg < len(tape) else 0 + vc = v[0].cpu() + pcm = 0 + for k in range(8): + pcm = (pcm << 1) | int(vc[k]) + Bm = MatrixHost._byte(vc, (pcm + 1) & 0xFF) + v = mh.step(v) + if Bm == OUT: + outm.append(MatrixHost._byte(v[0].cpu(), OUT)) + v[0, 9 + OUT * 8: 9 + OUT * 8 + 8] = 0.0 + elif Bm == IN_STROBE: + headm += 1 + v[0, 9 + IN_STROBE * 8: 9 + IN_STROBE * 8 + 8] = 0.0 + nxt = tape[headm] if headm < len(tape) else 0 + for k in range(8): + v[0, 9 + IN_DATA * 8 + k] = float((nxt >> (7 - k)) & 1) + vc = v[0].cpu() + pcm = 0 + for k in range(8): + pcm = (pcm << 1) | int(vc[k]) + memm = [MatrixHost._byte(vc, j) for j in range(256)] + if pcm != s["pc"] or memm != s["mem"] or outg != outm: + print(f" FAIL lockstep diverged at cycle {n}") + good = False + break + print(f" {'OK ' if good else 'FAIL'} gate graph and matrix form agree " + f"cycle-for-cycle for 600 cycles ({len(outg)} bytes emitted identically)") + ok &= good + + print("\nCONSTRUCTOR VERIFICATION:", "PASS" if ok else "FAIL") + return ok + + +def self_reproduce(device: str, gen2: bool = True) -> bool: + """Full self-reproduction at threshold fidelity on the matrix backend.""" + data = open(MODEL_PATH, "rb").read() + tape = c8_describe(data) + print(f"\nSelf-reproduction on the threshold matrices: target " + f"{len(data):,} bytes (sha {c8_sha(data)}), tape {len(tape):,} bytes") + ref_out, ref_steps = ref_construct(tape) + assert ref_out == data, "reference construction failed" + print(f" reference: {ref_steps:,} instructions") + + mh = MatrixHost(device=device) + t0 = time.perf_counter() + out, n = mh.run(tape, max_steps=ref_steps + 64, expect=data) + dt = time.perf_counter() - t0 + ok = out == data and n == ref_steps + print(f" GEN 1: emitted {len(out):,} bytes in {n:,} recurrences " + f"({dt / 60:.1f} min, {n / dt:,.0f} instr/s) sha {c8_sha(out)}") + print(f" {'OK ' if ok else 'FAIL'} output stream == host safetensors file, " + f"byte for byte; instruction count matches reference exactly") + + if ok and gen2: + off_path = os.path.join(REPO, "variants", "_offspring_subleq8io.safetensors") + with open(off_path, "wb") as f: + f.write(out) + gh = GateHost(off_path) # boot the offspring's own gates + probe_tape = c8_describe(out[:2048]) + want = out[:2048] + got, steps, _ = gh.run(probe_tape, max_steps=len(probe_tape) * 8 + 64) + good2 = got == want + print(f" GEN 2: offspring file booted as a machine (gate graph); " + f"constructed the first {len(want):,} bytes of itself in " + f"{steps:,} instructions: {'exact' if good2 else 'MISMATCH'}") + os.remove(off_path) + ok &= good2 + print("SELF-REPRODUCTION:", "PASS" if ok else "FAIL") + return ok + + +def main_constructor8() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("cmd", choices=["build", "verify", "self", "all"]) + ap.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu") + args = ap.parse_args() + rc = 0 + if args.cmd in ("build", "all"): + build_host(save=True) + if args.cmd in ("verify", "all"): + rc |= 0 if verify(args.device) else 1 + if args.cmd in ("self", "all"): + rc |= 0 if self_reproduce(args.device) else 1 + return rc + + +def cmd_family(): + import hashlib + import time + for A in [int(a) for a in sys.argv[1:]] or [3, 4, 5, 8]: + t0 = time.perf_counter() + s = sigma(A) + t1 = time.perf_counter() + line = f"A={A}: sigma {len(s):,} bytes in {t1 - t0:.2f}s, sha256 {hashlib.sha256(s).hexdigest()[:16]}" + if A <= 12: + net, inp, out = build(A) + same = family_sigma_of_net(net, inp, out, A) == s + depth = {} + for gname, (ins, _) in net.gates.items(): + depth[gname] = 1 + max((depth.get(x, 0) for x, _ in ins), default=0) + line += (f"; Net {len(net.gates):,} units, depth {max(depth.values())}, " + f"edges {sum(len(i) for i, _ in net.gates.values()):,}; " + f"equal to the Net's serialization: {same}") + N2, i2, o2, _ = net_of_sigma(s) + line += f"; parses to {len(N2.gates):,} units" + print(line, flush=True) + + +COMMANDS = {'family': cmd_family, 'constructor8': main_constructor8} + + +if __name__ == "__main__": + if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS: + sys.exit('commands: ' + ', '.join(COMMANDS)) + cmd = sys.argv.pop(1) + sys.argv[0] += ' ' + cmd + sys.exit(COMMANDS[cmd]()) diff --git a/src/host_family.py b/src/host_family.py deleted file mode 100644 index 9cfec081ee64a9fac269246587f66c1397eaac3e..0000000000000000000000000000000000000000 --- a/src/host_family.py +++ /dev/null @@ -1,442 +0,0 @@ -"""The SUBLEQ host family H_A: a constant-depth ternary threshold step. - -H_A executes SUBLEQ on 2^A words of A bits with the memory-mapped tape device of -the paper, for every A >= 3. The state is the A counter bits, a halt bit and the -A * 2^A memory bits, each word most significant bit first. The step netlist has -depth 13 for every A: operands are fetched through the decode lines of the -counter, p + 3 is read off the same decode lines, the subtraction is a -carry-lookahead whose sum bits are parities of three computed by a depth-two -threshold cell, and the branch, the counter multiplexer, the halt latch and the -memory write are one or two levels each. Negated literals are carried by the -signs of the weights, so no unit is spent on a negation. - -The units are listed block by block in a fixed order in which every unit follows -its predecessors; that order, and the order of each unit's predecessors, is the -order the serialization stores. `build(A)` returns the netlist as a Net for the -evaluators; `sigma(A)` writes its serialization directly with numpy, without -building the Net, and is compared with the serialization of the Net for small A. - - python src/host_family.py [A ...] # build, serialize, compare -""" -from __future__ import annotations - -import json -import os -import struct -import sys -from typing import Dict, List, Tuple - -import numpy as np - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from matrix8 import Net - -VERSION = 6 - - -def io_cells(A: int) -> Dict[str, int]: - S = 1 << A - return {"c_wr": S - 7, "c_eot": S - 6, "c_rw": S - 5, "c_rd": S - 4, - "r_in": S - 3, "r_out": S - 2, "halt_pc": S - 1} - - -def layout(A: int) -> Dict[str, List[int]]: - return {"pc": [0, A], "halt": [A, 1], "mem": [A + 1, 1 << A, A]} - - -def state_names(A: int) -> List[str]: - S = 1 << A - return ([f"pc{i}" for i in range(A)] + ["halt"] - + [f"m{j}b{t}" for j in range(S) for t in range(A)]) - - -def build(A: int, fold: bool = False) -> Tuple[Net, List[str], List[str]]: - """The step netlist of H_A, units in serialization order. With fold, every read - conjunction and write enable reads the address bits directly, one unit of fan-in - A + 1 in place of a decode line and a conjunction, and the step has depth 11.""" - S = 1 << A - net = Net() - g = net.g - pc = [f"pc{i}" for i in range(A)] - halt = "halt" - mu = lambda j, t: f"m{j}b{t}" - - def lits(bits: List[str], value: int): - """The literals asserting that the MSB-first `bits` spell `value`.""" - return [(bits[i], 1 if (value >> (A - 1 - i)) & 1 else -1) for i in range(A)] - - # B1 decode lines of the counter - for j in range(S): - g(f"P{j}", lits(pc, j), -bin(j).count("1")) - # B2 the counter held by the halt bit - for i in range(A): - g(f"HP{i}", [(pc[i], 1), (halt, 1)], -2) - # B3 the fetch conjunctions: operand o, bit t, counter value j - for o in range(3): - for t in range(A): - for j in range(S): - if fold: - g(f"FA{o}_{t}_{j}", [(mu((j + o) % S, t), 1)] + lits(pc, j), -1 - bin(j).count("1")) - else: - g(f"FA{o}_{t}_{j}", [(mu((j + o) % S, t), 1), (f"P{j}", 1)], -2) - # B4 the bits of p + 3, read off the decode lines - for i in range(A): - g(f"Q{i}", [(f"P{j}", 1) for j in range(S) - if (((j + 3) % S) >> (A - 1 - i)) & 1], -1) - # B5 the three operand words A, B, C - for o in range(3): - for t in range(A): - g(f"F{o}_{t}", [(f"FA{o}_{t}_{j}", 1) for j in range(S)], -1) - Aop = [f"F0_{t}" for t in range(A)] - Bop = [f"F1_{t}" for t in range(A)] - Cop = [f"F2_{t}" for t in range(A)] - # B6 decode lines of A and B - if not fold: - for j in range(S): - g(f"PA{j}", lits(Aop, j), -bin(j).count("1")) - for j in range(S): - g(f"PB{j}", lits(Bop, j), -bin(j).count("1")) - # B7 C is the halt address - g("AC", [(c, 1) for c in Cop], -A) - # B8 write enables - for j in range(S): - if fold: - g(f"W{j}", [(halt, -1)] + lits(Bop, j), -bin(j).count("1")) - else: - g(f"W{j}", [(halt, -1), (f"PB{j}", 1)], -1) - # B9 the read conjunctions of M[A] and M[B] - for t in range(A): - for j in range(S): - if fold: - g(f"RX{t}_{j}", [(mu(j, t), 1)] + lits(Aop, j), -1 - bin(j).count("1")) - else: - g(f"RX{t}_{j}", [(mu(j, t), 1), (f"PA{j}", 1)], -2) - for t in range(A): - for j in range(S): - if fold: - g(f"RY{t}_{j}", [(mu(j, t), 1)] + lits(Bop, j), -1 - bin(j).count("1")) - else: - g(f"RY{t}_{j}", [(mu(j, t), 1), (f"PB{j}", 1)], -2) - # B10 the cells kept - for j in range(S): - for t in range(A): - g(f"KP{j}_{t}", [(mu(j, t), 1), (f"W{j}", -1)], -1) - # B11 X = M[A], Y = M[B] - for t in range(A): - g(f"X{t}", [(f"RX{t}_{j}", 1) for j in range(S)], -1) - for t in range(A): - g(f"Y{t}", [(f"RY{t}_{j}", 1) for j in range(S)], -1) - xv = lambda i: f"X{A - 1 - i}" # value bit i, least significant first - yv = lambda i: f"Y{A - 1 - i}" - # B12 propagate signals of Y + not X - for i in range(A - 1): - g(f"PR{i}", [(xv(i), -1), (yv(i), 1)], 0) - # B13 carry terms into bit k: the incoming carry through all lower - # positions, and a generate at j propagated through j+1..k-1 - for k in range(1, A): - g(f"T{k}_n", [(f"PR{i}", 1) for i in range(k)], -k) - for j in range(k): - g(f"T{k}_{j}", [(yv(j), 1), (xv(j), -1)] - + [(f"PR{i}", 1) for i in range(j + 1, k)], -(1 + (k - 1 - j))) - # B14 carries - for k in range(1, A): - g(f"C{k}", [(f"T{k}_n", 1)] + [(f"T{k}_{j}", 1) for j in range(k)], -1) - # B15 X = Y, bit by bit - for i in range(A): - g(f"AX{i}", [(xv(i), 1), (yv(i), 1)], -2) - for i in range(A): - g(f"NX{i}", [(xv(i), -1), (yv(i), -1)], 0) - for i in range(A): - g(f"XN{i}", [(f"AX{i}", 1), (f"NX{i}", 1)], -1) - # B16 - g("EQ", [(f"XN{i}", 1) for i in range(A)], -A) - # B17 the sum bits: bit 0 is X xor Y, bit k the parity of y_k + not x_k + c_k - g("O0", [(yv(0), 1), (xv(0), 1)], -1) - g("N0", [(yv(0), -1), (xv(0), -1)], 1) - g("R0", [("O0", 1), ("N0", 1)], -2) - for k in range(1, A): - for m, b in ((1, 0), (2, -1), (3, -2)): - g(f"H{m}_{k}", [(yv(k), 1), (xv(k), -1), (f"C{k}", 1)], b) - for k in range(1, A): - g(f"R{k}", [(f"H1_{k}", 1), (f"H2_{k}", -1), (f"H3_{k}", 1)], -1) - top = A - 1 - g("LEQ", [(f"H1_{top}", 1), (f"H2_{top}", -1), (f"H3_{top}", 1), ("EQ", 1)], -1) - # B18 the next counter - for i in range(A): - g(f"TA{i}", [(halt, -1), ("LEQ", 1), (Cop[i], 1)], -2) - for i in range(A): - g(f"TB{i}", [(halt, -1), ("LEQ", -1), (f"Q{i}", 1)], -1) - for i in range(A): - g(f"NP{i}", [(f"HP{i}", 1), (f"TA{i}", 1), (f"TB{i}", 1)], -1) - # B19 the halt latch: the next counter is 2^A - 1 - g("HA", [(halt, -1), ("LEQ", 1), ("AC", 1)], -2) - g("HB", [(halt, -1), ("LEQ", -1), (f"P{S - 4}", 1)], -1) - g("NH", [(halt, 1), ("HA", 1), ("HB", 1)], -1) - # B20 the write: bit t of the word is value bit A-1-t of the difference - for j in range(S): - for t in range(A): - g(f"WR{j}_{t}", [(f"R{A - 1 - t}", 1), (f"W{j}", 1)], -2) - for j in range(S): - for t in range(A): - g(f"NM{j}_{t}", [(f"KP{j}_{t}", 1), (f"WR{j}_{t}", 1)], -1) - outputs = ([f"NP{i}" for i in range(A)] + ["NH"] - + [f"NM{j}_{t}" for j in range(S) for t in range(A)]) - return net, state_names(A), outputs - - -# --------------------------------------------------------------------------- -# serialization -# --------------------------------------------------------------------------- - -_DTORD = {"F64": 0, "F32": 1, "F16": 2, "I64": 3, "I32": 4, "I16": 5, "I8": 6} -_NP = {"I8": " str: - return "I16" if maxabs <= 32767 else "I32" - - -def encode(A: int, bias, fanin, pred, nxt, machine: str, lay, io) -> bytes: - """The serialization of an ordered netlist from its four vectors.""" - n, v = len(nxt), len(bias) - t = {"net.bias": ("I8", np.asarray(bias, dtype=np.int64)), - "net.fanin": (_itype(int(np.max(fanin))), np.asarray(fanin, dtype=np.int64)), - "net.next": (_itype(int(np.max(np.abs(nxt)))), np.asarray(nxt, dtype=np.int64)), - "net.pred": (_itype(int(np.max(np.abs(pred)))), np.asarray(pred, dtype=np.int64))} - assert int(t["net.bias"][1].min()) >= -128 and int(t["net.bias"][1].max()) <= 127 - for k, val in (("addr_bits", A), ("data_bits", A), ("memory_words", 1 << A), - ("pc_width", A), ("state_bits", n), ("units", v), - ("version", VERSION)): - t[f"manifest.{k}"] = ("I32", np.asarray([val], dtype=np.int64)) - order = sorted(t, key=lambda k: (_DTORD[t[k][0]], k)) - header = {"__metadata__": { - "machine": machine, "weight_quantization": "ternary", - "signal_registry": json.dumps(lay, separators=(",", ":"), sort_keys=True), - "io": json.dumps(io, separators=(",", ":"), sort_keys=True)}} - blobs, off = [], 0 - for k in order: - dt, arr = t[k] - b = arr.astype(_NP[dt]).tobytes() - header[k] = {"dtype": dt, "shape": [int(arr.size)], "data_offsets": [off, off + len(b)]} - blobs.append(b) - off += len(b) - s = json.dumps(header, separators=(",", ":"), ensure_ascii=False).encode("utf-8") - s += b" " * ((-(8 + len(s))) % 8) - return struct.pack(" bytes: - """The serialization of a Net in the order its units were built.""" - slot = {s: i for i, s in enumerate(inputs)} - for gname in net.gates: - slot[gname] = len(slot) - bias, fanin, pred = [], [], [] - for gname, (ins, b) in net.gates.items(): - bias.append(b) - fanin.append(len(ins)) - for s, w in ins: - assert w in (-1, 1) - pred.append(w * (slot[s] + 1)) - nxt = [slot[s] for s in outputs] - return encode(A, bias, fanin, pred, nxt, machine or f"subleq{A}", - lay or layout(A), io or io_cells(A)) - - -def sigma(A: int) -> bytes: - """The serialization of H_A, written block by block with numpy.""" - S = 1 << A - n = A + 1 + A * S - bias, fanin, pred = [], [], [] - base = {} - count = [n] - - def block(name: str, size: int): - base[name] = count[0] - count[0] += size - - J = np.arange(S, dtype=np.int64) - popc = np.array([bin(j).count("1") for j in range(S)], dtype=np.int64) - bits = ((J[:, None] >> (A - 1 - np.arange(A))[None, :]) & 1) # [j, i] MSB first - lsign = np.where(bits == 1, 1, -1) - MU = lambda j, t: A + 1 + A * j + t # state index - HALT, PC = A, np.arange(A, dtype=np.int64) - - def emit(b, f, p): - bias.append(np.asarray(b, dtype=np.int64).ravel()) - fanin.append(np.asarray(f, dtype=np.int64).ravel()) - pred.append(np.asarray(p, dtype=np.int64).ravel()) - - # B1 - block("P", S) - emit(-popc, np.full(S, A), (lsign * (PC[None, :] + 1)).ravel()) - # B2 - block("HP", A) - emit(np.full(A, -2), np.full(A, 2), np.stack([PC + 1, np.full(A, HALT + 1)], 1)) - # B3 - block("FA", 3 * A * S) - o, t, j = np.meshgrid(np.arange(3), np.arange(A), J, indexing="ij") - emit(np.full(3 * A * S, -2), np.full(3 * A * S, 2), - np.stack([MU((j + o) % S, t) + 1, base["P"] + j + 1], -1)) - # B4 - block("Q", A) - qf, qp = [], [] - for i in range(A): - sel = J[((((J + 3) % S) >> (A - 1 - i)) & 1) == 1] - qf.append(len(sel)) - qp.append(base["P"] + sel + 1) - emit(np.full(A, -1), qf, np.concatenate(qp)) - # B5 - block("F", 3 * A) - o, t, j = np.meshgrid(np.arange(3), np.arange(A), J, indexing="ij") - emit(np.full(3 * A, -1), np.full(3 * A, S), - base["FA"] + (o * A + t) * S + j + 1) - F = lambda o, t: base["F"] + o * A + t - # B6 - block("PA", S) - emit(-popc, np.full(S, A), (lsign * (F(0, np.arange(A))[None, :] + 1)).ravel()) - block("PB", S) - emit(-popc, np.full(S, A), (lsign * (F(1, np.arange(A))[None, :] + 1)).ravel()) - # B7 - block("AC", 1) - emit([-A], [A], F(2, np.arange(A)) + 1) - # B8 - block("W", S) - emit(np.full(S, -1), np.full(S, 2), - np.stack([np.full(S, -(HALT + 1)), base["PB"] + J + 1], -1)) - # B9 - t, j = np.meshgrid(np.arange(A), J, indexing="ij") - block("RX", A * S) - emit(np.full(A * S, -2), np.full(A * S, 2), - np.stack([MU(j, t) + 1, base["PA"] + j + 1], -1)) - block("RY", A * S) - emit(np.full(A * S, -2), np.full(A * S, 2), - np.stack([MU(j, t) + 1, base["PB"] + j + 1], -1)) - # B10 - j, t = np.meshgrid(J, np.arange(A), indexing="ij") - block("KP", S * A) - emit(np.full(S * A, -1), np.full(S * A, 2), - np.stack([MU(j, t) + 1, -(base["W"] + j + 1)], -1)) - # B11 - t, j = np.meshgrid(np.arange(A), J, indexing="ij") - block("X", A) - emit(np.full(A, -1), np.full(A, S), base["RX"] + t * S + j + 1) - block("Y", A) - emit(np.full(A, -1), np.full(A, S), base["RY"] + t * S + j + 1) - X = lambda i: base["X"] + (A - 1 - i) - Y = lambda i: base["Y"] + (A - 1 - i) - # B12 - block("PR", A - 1) - ii = np.arange(A - 1) - emit(np.zeros(A - 1), np.full(A - 1, 2), np.stack([-(X(ii) + 1), Y(ii) + 1], -1)) - PR = lambda i: base["PR"] + i - # B13 - tb, tf, tp = [], [], [] - tidx = {} - block("T", sum(k + 1 for k in range(1, A))) - cur = base["T"] - for k in range(1, A): - tidx[(k, "n")] = cur; cur += 1 - tb.append(-k); tf.append(k); tp += [PR(i) + 1 for i in range(k)] - for jj in range(k): - tidx[(k, jj)] = cur; cur += 1 - tb.append(-(1 + (k - 1 - jj))); tf.append(2 + (k - 1 - jj)) - tp += [Y(jj) + 1, -(X(jj) + 1)] + [PR(i) + 1 for i in range(jj + 1, k)] - emit(tb, tf, tp) - # B14 - block("C", A - 1) - cp = [] - for k in range(1, A): - cp += [tidx[(k, "n")] + 1] + [tidx[(k, jj)] + 1 for jj in range(k)] - emit(np.full(A - 1, -1), [k + 1 for k in range(1, A)], cp) - C = lambda k: base["C"] + k - 1 - # B15 - ii = np.arange(A) - block("AX", A) - emit(np.full(A, -2), np.full(A, 2), np.stack([X(ii) + 1, Y(ii) + 1], -1)) - block("NX", A) - emit(np.zeros(A), np.full(A, 2), np.stack([-(X(ii) + 1), -(Y(ii) + 1)], -1)) - block("XN", A) - emit(np.full(A, -1), np.full(A, 2), - np.stack([base["AX"] + ii + 1, base["NX"] + ii + 1], -1)) - # B16 - block("EQ", 1) - emit([-A], [A], base["XN"] + ii + 1) - # B17 - block("R0", 3) - emit([-1, 1, -2], [2, 2, 2], - [Y(0) + 1, X(0) + 1, -(Y(0) + 1), -(X(0) + 1), - base["R0"] + 1, base["R0"] + 2]) - block("H", 3 * (A - 1)) - hb, hp = [], [] - for k in range(1, A): - for b in (0, -1, -2): - hb.append(b) - hp += [Y(k) + 1, -(X(k) + 1), C(k) + 1] - emit(hb, np.full(3 * (A - 1), 3), hp) - H = lambda m, k: base["H"] + 3 * (k - 1) + (m - 1) - block("R", A - 1) - rp = [] - for k in range(1, A): - rp += [H(1, k) + 1, -(H(2, k) + 1), H(3, k) + 1] - emit(np.full(A - 1, -1), np.full(A - 1, 3), rp) - Rv = lambda k: base["R0"] + 2 if k == 0 else base["R"] + k - 1 - block("LEQ", 1) - top = A - 1 - emit([-1], [4], [H(1, top) + 1, -(H(2, top) + 1), H(3, top) + 1, base["EQ"] + 1]) - LEQ = base["LEQ"] - # B18 - ii = np.arange(A) - block("TA", A) - emit(np.full(A, -2), np.full(A, 3), - np.stack([np.full(A, -(HALT + 1)), np.full(A, LEQ + 1), F(2, ii) + 1], -1)) - block("TB", A) - emit(np.full(A, -1), np.full(A, 3), - np.stack([np.full(A, -(HALT + 1)), np.full(A, -(LEQ + 1)), base["Q"] + ii + 1], -1)) - block("NP", A) - emit(np.full(A, -1), np.full(A, 3), - np.stack([base["HP"] + ii + 1, base["TA"] + ii + 1, base["TB"] + ii + 1], -1)) - # B19 - block("HA", 3) - emit([-2, -1, -1], [3, 3, 3], - [-(HALT + 1), LEQ + 1, base["AC"] + 1, - -(HALT + 1), -(LEQ + 1), base["P"] + (S - 4) + 1, - HALT + 1, base["HA"] + 1, base["HA"] + 2]) - NH = base["HA"] + 2 - # B20 - j, t = np.meshgrid(J, np.arange(A), indexing="ij") - rdata = np.array([Rv(A - 1 - tt) for tt in range(A)], dtype=np.int64) - block("WR", S * A) - emit(np.full(S * A, -2), np.full(S * A, 2), - np.stack([rdata[t] + 1, base["W"] + j + 1], -1)) - block("NM", S * A) - emit(np.full(S * A, -1), np.full(S * A, 2), - np.stack([base["KP"] + j * A + t + 1, base["WR"] + j * A + t + 1], -1)) - nxt = np.concatenate([base["NP"] + np.arange(A), [NH], - base["NM"] + np.arange(S * A)]) - b = np.concatenate(bias); f = np.concatenate(fanin); p = np.concatenate(pred) - assert len(b) == count[0] - n and int(f.sum()) == len(p) - return encode(A, b, f, p, nxt, f"subleq{A}", layout(A), io_cells(A)) - - -if __name__ == "__main__": - import hashlib - import time - from netlist_io import net_of_sigma - for A in [int(a) for a in sys.argv[1:]] or [3, 4, 5, 8]: - t0 = time.perf_counter() - s = sigma(A) - t1 = time.perf_counter() - line = f"A={A}: sigma {len(s):,} bytes in {t1 - t0:.2f}s, sha256 {hashlib.sha256(s).hexdigest()[:16]}" - if A <= 12: - net, inp, out = build(A) - same = sigma_of_net(net, inp, out, A) == s - depth = {} - for gname, (ins, _) in net.gates.items(): - depth[gname] = 1 + max((depth.get(x, 0) for x, _ in ins), default=0) - line += (f"; Net {len(net.gates):,} units, depth {max(depth.values())}, " - f"edges {sum(len(i) for i, _ in net.gates.values()):,}; " - f"equal to the Net's serialization: {same}") - N2, i2, o2, _ = net_of_sigma(s) - line += f"; parses to {len(N2.gates):,} units" - print(line, flush=True) diff --git a/src/host_netlist.py b/src/host_netlist.py deleted file mode 100644 index bcb30519db50d4f57121abd261abe10d5824fb7b..0000000000000000000000000000000000000000 --- a/src/host_netlist.py +++ /dev/null @@ -1,34 +0,0 @@ -"""The clocked netlist of the SUBLEQ host, unit by unit. - -The state is the eight counter bits, a halt bit, and the 2048 bits of the -256-byte memory, in that order; the same order indexes the state layout carried -in the serialization. The netlist is the member H_8 of the constant-depth -family of host_family.py: one application is one SUBLEQ step of depth 13. The -device cells are ordinary memory cells that the runtime overwrites between -steps. -""" - -from __future__ import annotations - -import os -import sys -from typing import List, Tuple - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from matrix8 import Net - -HALT_PC = 0xFF - - -def subleq_state_names() -> List[str]: - names = [f"pc{i}" for i in range(8)] + ["halt"] - for j in range(256): - names += [f"m{j}b{i}" for i in range(8)] - return names - - -def build_subleq_step_net() -> Tuple[Net, List[str], List[str]]: - """The step netlist of the host: the member H_8 of the constant-depth family.""" - from host_family import build - return build(8) diff --git a/src/independent.py b/src/independent.py new file mode 100644 index 0000000000000000000000000000000000000000..058334a632b94c89c44204d649109ab42987d59f --- /dev/null +++ b/src/independent.py @@ -0,0 +1,769 @@ +"""Code written from the paper's text alone: H_A, the levelization, noise, weight error, the datapath.""" +import hashlib +import json +import math +import os +import struct +import subprocess +import sys +import time + +import numpy as np +import torch + + +RUNS = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'paper', 'runs') + + +class Net: + def __init__(self, n_state): + self.n = n_state + self.bias = [] + self.ins = [] + self.block = [] + self.cur = None + + def u(self, b, ins): + assert all(w in (-1, 1) for _, w in ins) + assert len({s for s, _ in ins}) == len(ins) + self.bias.append(b) + self.ins.append(ins) + self.block.append(self.cur) + return self.n + len(self.bias) - 1 + + def AND(self, *lits): + return self.u(-sum(1 for _, pos in lits if pos), [(s, 1 if pos else -1) for s, pos in lits]) + + def OR(self, *lits): + return self.u(sum(1 for _, pos in lits if not pos) - 1, [(s, 1 if pos else -1) for s, pos in lits]) + + +def build(A, fold=False): + S = 1 << A + P_IDX = list(range(A)) + H = A + MU = lambda j, t: A + 1 + j * A + t + n = A + 1 + A * S + N = Net(n) + + def sel(bits, j, extra=()): + # the decode line [bits == j] as one conjunction of literals, Lemma 3.6 + lits = [(bits[i], bool((j >> i) & 1)) for i in range(A)] + list(extra) + return N.AND(*lits) + + N.cur = 'P' + P = [sel(P_IDX, j) for j in range(S)] + N.cur = 'HP' + HP = [N.AND((H, True), (P_IDX[i], True)) for i in range(A)] + N.cur = 'FA' + if fold: + FA = [[[sel(P_IDX, j, [(MU((j + o) % S, t), True)]) for j in range(S)] for t in range(A)] for o in range(3)] + else: + FA = [[[N.AND((MU((j + o) % S, t), True), (P[j], True)) for j in range(S)] for t in range(A)] for o in range(3)] + N.cur = 'Q' + Q = [N.OR(*[(P[j], True) for j in range(S) if ((j + 3) % S >> i) & 1]) for i in range(A)] + N.cur = 'F' + F = [[N.OR(*[(FA[o][t][j], True) for j in range(S)]) for t in range(A)] for o in range(3)] + if not fold: + N.cur = 'PAB' + PA = [sel(F[0], j) for j in range(S)] + PB = [sel(F[1], j) for j in range(S)] + N.cur = 'AC' + AC = N.AND(*[(F[2][t], True) for t in range(A)]) + N.cur = 'W' + if fold: + W = [sel(F[1], j, [(H, False)]) for j in range(S)] + else: + W = [N.AND((PB[j], True), (H, False)) for j in range(S)] + N.cur = 'RXY' + if fold: + RX = [[sel(F[0], j, [(MU(j, t), True)]) for j in range(S)] for t in range(A)] + RY = [[sel(F[1], j, [(MU(j, t), True)]) for j in range(S)] for t in range(A)] + else: + RX = [[N.AND((MU(j, t), True), (PA[j], True)) for j in range(S)] for t in range(A)] + RY = [[N.AND((MU(j, t), True), (PB[j], True)) for j in range(S)] for t in range(A)] + N.cur = 'KP' + KP = [[N.AND((MU(j, t), True), (W[j], False)) for t in range(A)] for j in range(S)] + N.cur = 'XY' + X = [N.OR(*[(RX[t][j], True) for j in range(S)]) for t in range(A)] + Y = [N.OR(*[(RY[t][j], True) for j in range(S)]) for t in range(A)] + # the carry-lookahead subtractor of Lemma 3.2, r = y - x + N.cur = 'sub' + pp = [N.u(0, [(Y[i], 1), (X[i], -1)]) for i in range(A - 1)] + Tstar, Tj, c = {}, {}, {} + for k in range(1, A): + Tstar[k] = N.u(-k, [(pp[i], 1) for i in range(k)]) + for j in range(k): + Tj[k, j] = N.u(-(k - j), [(Y[j], 1), (X[j], -1)] + [(pp[i], 1) for i in range(j + 1, k)]) + for k in range(1, A): + c[k] = N.u(-1, [(Tstar[k], 1)] + [(Tj[k, j], 1) for j in range(k)]) + hm = {} + for k in range(1, A): + for m in (1, 2, 3): + hm[m, k] = N.u(1 - m, [(Y[k], 1), (X[k], -1), (c[k], 1)]) + o1 = N.OR((X[0], True), (Y[0], True)) + o2 = N.u(1, [(X[0], -1), (Y[0], -1)]) + r = [N.AND((o1, True), (o2, True))] + for k in range(1, A): + r.append(N.u(-1, [(hm[1, k], 1), (hm[2, k], -1), (hm[3, k], 1)])) + N.cur = 'eq' + eqs = [] + for t in range(A): + a1 = N.AND((X[t], True), (Y[t], True)) + a0 = N.AND((X[t], False), (Y[t], False)) + eqs.append(N.OR((a1, True), (a0, True))) + e = N.AND(*[(q, True) for q in eqs]) + N.cur = 'LEQ' + leq = N.u(-1, [(hm[1, A - 1], 1), (hm[2, A - 1], -1), (hm[3, A - 1], 1), (e, 1)]) + N.cur = 'NP' + TA = [N.AND((H, False), (leq, True), (F[2][i], True)) for i in range(A)] + TB = [N.AND((H, False), (leq, False), (Q[i], True)) for i in range(A)] + NP = [N.OR((HP[i], True), (TA[i], True), (TB[i], True)) for i in range(A)] + N.cur = 'NH' + HA = N.AND((H, False), (leq, True), (AC, True)) + HB = N.AND((H, False), (leq, False), (P[S - 4], True)) + NH = N.OR((H, True), (HA, True), (HB, True)) + N.cur = 'NM' + NM = {} + for j in range(S): + for t in range(A): + wr = N.AND((r[t], True), (W[j], True)) + NM[j, t] = N.OR((KP[j][t], True), (wr, True)) + nxt = [None] * n + for i in range(A): + nxt[P_IDX[i]] = NP[i] + nxt[H] = NH + for j in range(S): + for t in range(A): + nxt[MU(j, t)] = NM[j, t] + return N, nxt + + +def stats(N, nxt): + depth = [0] * (N.n + len(N.bias)) + for k, ins in enumerate(N.ins): + depth[N.n + k] = 1 + max((depth[s] for s, _ in ins), default=0) + blocks = {} + for k, b in enumerate(N.block): + d = depth[N.n + k] + lo, hi = blocks.get(b, (d, d)) + blocks[b] = (min(lo, d), max(hi, d)) + return dict(units=len(N.bias), edges=sum(len(i) for i in N.ins), + depth=max(depth[s] for s in nxt), bias_range=[min(N.bias), max(N.bias)], + block_layers=blocks) + + +def evaluate(N, nxt, x): + """x: (n, batch) uint8; returns the next state (n, batch).""" + sig = np.zeros((N.n + len(N.bias), x.shape[1]), dtype=np.int32) + sig[:N.n] = x + for k, (b, ins) in enumerate(zip(N.bias, N.ins)): + idx = np.fromiter((s for s, _ in ins), dtype=np.int64, count=len(ins)) + w = np.fromiter((w for _, w in ins), dtype=np.int32, count=len(ins)) + if len(ins) == 0: + pre = np.full(x.shape[1], b, dtype=np.int32) + else: + pre = w @ sig[idx] + b + sig[N.n + k] = pre >= 0 + return sig[nxt].astype(np.uint8) + + +def subleq_step(A, x): + """Definition 3.1 on the state layout above, columns of x being states.""" + S = 1 << A + batch = x.shape[1] + p = np.zeros(batch, dtype=np.int64) + for i in range(A): + p |= x[i].astype(np.int64) << i + h = x[A].astype(bool) + mem = np.zeros((S, batch), dtype=np.int64) + for t in range(A): + mem |= x[A + 1 + np.arange(S) * A + t].astype(np.int64) << t + cols = np.arange(batch) + a = mem[p, cols] + b = mem[(p + 1) % S, cols] + c = mem[(p + 2) % S, cols] + newb = (mem[b, cols] - mem[a, cols]) % S + signed = np.where(newb >= S // 2, newb - S, newb) + np_ = np.where(signed <= 0, c, (p + 3) % S) + mem2 = mem.copy() + mem2[b, cols] = newb + mem2 = np.where(h[None, :], mem, mem2) + p2 = np.where(h, p, np_) + h2 = h | (p2 == S - 1) + y = np.zeros_like(x) + for i in range(A): + y[i] = (p2 >> i) & 1 + y[A] = h2 + for t in range(A): + y[A + 1 + np.arange(S) * A + t] = (mem2 >> t) & 1 + return y + + +def check(A, fold, states, batch, seed): + N, nxt = build(A, fold) + st = stats(N, nxt) + rng = np.random.default_rng(seed) + n = N.n + bad = 0 + halted_bad = 0 + done = 0 + while done < states: + bsz = min(batch, states - done) + x = rng.integers(0, 2, size=(n, bsz), dtype=np.uint8) + x[A] = 0 + x[A, : bsz // 8] = 1 # an eighth of the states halted + y = evaluate(N, nxt, x) + ref = subleq_step(A, x) + bad += int(np.any(y != ref, axis=0).sum()) + halted = x[A] == 1 + halted_bad += int(np.any(y[:, halted] != x[:, halted], axis=0).sum()) + done += bsz + formula = (8 * A + 4) * (1 << A) + (A * A + 39 * A + 4) // 2 + return dict(A=A, fold=fold, states=states, disagree=bad, halted_not_fixed=halted_bad, + formula_units=None if fold else formula, **{k: v for k, v in st.items() if k != 'block_layers'}, + block_layers={k: list(v) for k, v in st['block_layers'].items()}) + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +SIGMA = os.path.join(REPO, 'variants', 'neural_subleq8io_netlist.safetensors') + +WORK = os.path.join(REPO, 'src', '.work') + +OUT = os.path.join(WORK, 'host_lev.bin') + + +def parse(path): + b = open(path, 'rb').read() + n = struct.unpack(' 0 else -1) for e in pred[starts[u]:starts[u + 1]]] for u in range(U)] + depth = [0] * (nstate + U) + for u in range(U): + depth[nstate + u] = 1 + max((depth[s] for s, _ in ins[u]), default=0) + d = max(depth[s] for s in nxt) + # units no next value depends on + need = [False] * (nstate + U) + stack = [s for s in nxt] + while stack: + s = stack.pop() + if need[s]: + continue + need[s] = True + if s >= nstate: + stack.extend(t for t, _ in ins[s - nstate]) + # lambda(u): the largest layer that consumes u; next values consumed at d + 1 + lam = [-1] * (nstate + U) + for u in range(U): + if need[nstate + u]: + for s, _ in ins[u]: + lam[s] = max(lam[s], depth[nstate + u]) + for s in nxt: + lam[s] = d + 1 + layers = [] # layer l: list of (key, bias, [(src_key, w)]) + pos = [{('s', i): i for i in range(nstate)}] # key -> row index in layer + for l in range(1, d + 1): + rows = [] + if l < d: + for s in range(nstate + U): + if not need[s] and s >= nstate: + continue + if s >= nstate and depth[s] == l: + u = s - nstate + rows.append((('s', s), int(bias[u]), [(('s', t), w) for t, w in ins[u]])) + elif depth[s] < l < lam[s]: + rows.append((('s', s), -1, [(('s', s), 1)])) + else: + for i, s in enumerate(nxt): + if s >= nstate and depth[s] == d: + u = s - nstate + rows.append((('o', i), int(bias[u]), [(('s', t), w) for t, w in ins[u]])) + else: + rows.append((('o', i), -1, [(('s', s), 1)])) + layers.append(rows) + pos.append({k: r for r, (k, _, _) in enumerate(rows)}) + # resolve each input to its row in the layer before + flat = [] + for l, rows in enumerate(layers, start=1): + prev = pos[l - 1] + flat.append([(b, [(prev[k], w) for k, w in sl]) for _, b, sl in rows]) + return d, flat + + +def frame(u): + return struct.pack('= 4: + out += [256 - ell, nu(f[i])] + i += ell + continue + k = i + 1 + while k < len(f) and k < i + 127: + if k + 3 < len(f) and f[k] == f[k + 1] == f[k + 2] == f[k + 3]: + break + k += 1 + out += [k - i] + [nu(c) for c in f[i:k]] + i = k + return bytes(out + [0]) + + +Z, ONE, T1, T2, NEG1, EOK = 0xF0, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5 + +C_WR, C_EOT, C_RW, C_RD, R_IN, R_OUT = 0xF9, 0xFA, 0xFB, 0xFC, 0xFD, 0xFE + +TABLE9 = [ + (T2, T2, 0x03), (T1, T1, 0x06), (NEG1, C_RD, 0x09), (R_IN, T1, 0x0C), (T1, T2, 0x0F), + (Z, T2, 0x15), (Z, Z, 0x1B), (Z, T1, 0x3C), (Z, Z, 0x2D), (NEG1, C_RD, 0x1E), + (R_IN, R_OUT, 0x21), (NEG1, C_WR, 0x24), (ONE, T2, 0x2A), (Z, Z, 0x1B), (Z, Z, 0x00), + (NEG1, C_RD, 0x30), (R_IN, R_OUT, 0x33), (NEG1, C_WR, 0x36), (ONE, T1, 0x2A), (Z, Z, 0x30), + (Z, Z, 0xFF)] + +TABLE10 = [(NEG1, C_RW, 0x3F), (NEG1, C_RD, 0x42), (C_EOT, EOK, 0xFF), (R_IN, Z, 0x48), + (Z, R_OUT, 0x4B), (NEG1, C_WR, 0x4E), (Z, Z, 0x3F)] + + +def image(prog): + mem = [0] * 256 + for k, ins in enumerate(prog): + mem[3 * k:3 * k + 3] = list(ins) + mem[ONE], mem[NEG1], mem[EOK] = 1, 255, 1 + return bytes(mem) + + +def main_levelize(): + raw, md, T = parse(SIGMA) + nstate = int(T['manifest.state_bits'][0]) + d, layers = levelize(nstate, T['net.bias'], T['net.fanin'], T['net.pred'], [int(v) for v in T['net.next']]) + widths = [nstate] + [len(r) for r in layers] + M = sum(widths[1:]) + nnz = sum(len(sl) for rows in layers for _, sl in rows) + ident = sum(1 for rows in layers for b, sl in rows if b == -1 and len(sl) == 1 and sl[0][1] == 1) + reg = json.loads(md['signal_registry']) + io = json.loads(md['io']) + m_p = image(TABLE9) + m_star = image(TABLE9[:20] + TABLE10) + r_h = describe(raw) + tau = describe(frame(raw) + frame(m_star)) + ser = frame(raw) + frame(m_star) + tau + info = dict(depth=d, state_bits=nstate, M=M, nnz=nnz, max_width=max(widths[1:]), identity_like=ident, + dense_entries=sum(widths[i] * widths[i + 1] for i in range(d)), + r_H=len(r_h), tau=len(tau), ser=len(ser), ser_sha=hashlib.sha256(ser).hexdigest(), + sigma_sha=hashlib.sha256(raw).hexdigest(), registry=reg, io=io) + print(json.dumps(info)) + os.makedirs(WORK, exist_ok=True) + json.dump(info, open(os.path.join(REPO, 'paper', 'runs', 'independent_levelize.json'), 'w'), indent=1) + with open(OUT, 'wb') as f: + w = lambda *v: f.write(struct.pack('<%di' % len(v), *v)) + w(nstate, d, M, nnz) + w(*widths) + for rows in layers: + for b, sl in rows: + w(b, len(sl)) + for rows in layers: + for b, sl in rows: + for src, wt in sl: + w(src, wt) + w(reg['pc'][0], reg['halt'][0], reg['mem'][0]) + w(io['c_wr'], io['c_eot'], io['c_rw'], io['c_rd'], io['r_in'], io['r_out'], io['halt_pc']) + for blob in (m_p, r_h, m_star, tau, ser): + w(len(blob)) + f.write(blob) + +NPZ = os.path.join(WORK, 'datapath.npz') + +RECORD = os.path.join(REPO, 'paper', 'runs', 'independent_neuromorphic.json') + +K = 9 + + +def record(key, value): + rec = json.load(open(RECORD)) if os.path.exists(RECORD) else {} + rec[key] = value + json.dump(rec, open(RECORD, 'w'), indent=1) + + +def export(): + N, nxt = build(8) + blocks = N.block + nst = N.n + keep = [k for k, b in enumerate(blocks) if b in ('sub', 'eq', 'LEQ')] + keepset = set(N.n + k for k in keep) + # inputs: the units X_t, Y_t of block XY; outputs: r_0..r_7 and LEQ + xy = [N.n + k for k, b in enumerate(blocks) if b == 'XY'] + X, Y = xy[:8], xy[8:] + units = {N.n + k: (N.bias[k], N.ins[k]) for k in keep} + # the outputs: the sum bits are the last 8 units feeding WR, recovered as the inputs of NM's AND units + sub = [N.n + k for k, b in enumerate(blocks) if b == 'sub'] + r = [sub[-8]] + sub[-7:] # r_0 is the AND of the XOR cell, then r_1..r_7 + leq = [N.n + k for k, b in enumerate(blocks) if b == 'LEQ'][0] + inputs = X + Y + outputs = r + [leq] + # check the choice of outputs by evaluating the cut-out circuit on all pairs + def ev(xa, xb): + v = {} + for t in range(8): + v[X[t]] = (xa >> t) & 1 + v[Y[t]] = (xb >> t) & 1 + for u in sorted(units): + b, ins = units[u] + v[u] = 1 if b + sum(w * v[s] for s, w in ins) >= 0 else 0 + return sum(v[r[k]] << k for k in range(8)), v[leq] + for xa in range(256): + for xb in (0, 1, 127, 128, 255, xa, (xa + 1) % 256): + res = (xb - xa) % 256 + assert ev(xa, xb) == (res, int(res == 0 or res >= 128)), (xa, xb) + # levelize (Lemma 2.6) with the inputs at layer 0 + depth = {s: 0 for s in inputs} + for u in sorted(units): + depth[u] = 1 + max(depth[s] for s, _ in units[u][1]) + d = max(depth[o] for o in outputs) + lam = {} + for u in units: + for s, _ in units[u][1]: + lam[s] = max(lam.get(s, 0), depth[u]) + for o in outputs: + lam[o] = d + 1 + layers, prev = [], {s: i for i, s in enumerate(inputs)} + for l in range(1, d + 1): + rows = [] + if l < d: + for s in list(inputs) + sorted(units): + if s in units and depth[s] == l: + rows.append((s, units[s][0], units[s][1])) + elif depth[s] < l < lam.get(s, -1): + rows.append((s, -1, [(s, 1)])) + else: + for o in outputs: + rows.append((o, units[o][0], units[o][1]) if depth[o] == d else (o, -1, [(o, 1)])) + W = np.zeros((len(rows), len(prev)), dtype=np.int32) + b = np.zeros(len(rows), dtype=np.int32) + for i, (_, bb, ins) in enumerate(rows): + b[i] = bb + for s, w in ins: + W[i, prev[s]] = w + layers.append((W, b)) + prev = {s: i for i, (s, _, _) in enumerate(rows)} + out = dict(n_layers=np.array([d]), n_units=np.array([len(units)])) + for i, (W, b) in enumerate(layers): + out[f'W{i}'], out[f'b{i}'] = W, b + os.makedirs(WORK, exist_ok=True) + np.savez(NPZ, **out) + res = dict(units=len(units), depth=d, widths=[int(W.shape[0]) for W, _ in layers]) + record('datapath', res) + print(json.dumps(res)) + + +def reference(xa, xb): + res = (xb - xa) % 256 + return res, int(res == 0 or res >= 128) + + +def operands(n, seed): + rng = np.random.default_rng(seed) + xa = rng.integers(0, 256, n) + xb = rng.integers(0, 256, n) + xa[:256], xb[:256] = np.arange(256), 0 + xa[256:512], xb[256:512] = 0, np.arange(256) + xa[512:768], xb[512:768] = np.arange(256), np.arange(256) + return xa, xb + + +def lava(off): + from lava.magma.core.run_conditions import RunSteps + from lava.magma.core.run_configs import Loihi2SimCfg + from lava.proc.dense.process import Dense + from lava.proc.io.sink import RingBuffer as Sink + from lava.proc.io.source import RingBuffer as Source + from lava.proc.lif.process import LIF + z = np.load(NPZ) + L = int(z['n_layers'][0]) + layers = [(z[f'W{i}'], z[f'b{i}']) for i in range(L)] + N = 4096 + xa, xb = operands(N, 77) + spikes = np.zeros((16, N)) + for k in range(8): + spikes[k] = (xa >> k) & 1 + spikes[8 + k] = (xb >> k) & 1 + T = N + L + 2 + src = Source(data=np.pad(spikes, ((0, 0), (0, T - N)))) + prev, first = src.s_out, None + vth = 2 * K - 2 + off + for W, b in layers: + dn = Dense(weights=(2 * W).astype(np.int32)) + lif = LIF(shape=(W.shape[0],), du=4095, dv=4095, bias_mant=(b + K).astype(np.int32), bias_exp=7, vth=vth) + prev.connect(dn.s_in) + dn.a_out.connect(lif.a_in) + prev, first = lif.s_out, first or lif + snk = Sink(shape=(9,), buffer=T) + prev.connect(snk.a_in) + first.run(condition=RunSteps(num_steps=T), run_cfg=Loihi2SimCfg(select_tag='fixed_pt')) + out = snk.data.get() + first.stop() + bad = 0 + for t in range(N): + col = out[:, t + L] + bad += (sum(int(col[k]) << k for k in range(8)), int(col[8])) != reference(int(xa[t]), int(xb[t])) + # the membrane potential of a unit at pre-activation z is 128 (z + K); the threshold is 64 vth + margin_fire = 128 * K - 64 * vth + margin_quiet = 64 * vth - 128 * (K - 1) + res = dict(backend='lava fixed_pt', vth=vth, patterns=N, failures=bad, + margin_firing_side=margin_fire, margin_quiet_side=margin_quiet) + record(f'lava_vth_{vth}', res) + print(json.dumps(res)) + + +def akida(): + import akida as ak + from akida import FullyConnected, InputData, Model + z = np.load(NPZ) + L = int(z['n_layers'][0]) + layers = [(z[f'W{i}'], z[f'b{i}']) for i in range(L)] + m = Model() + m.add(InputData(input_shape=(1, 1, 16), input_bits=1)) + for i, (W, b) in enumerate(layers): + m.add(FullyConnected(units=int(W.shape[0]), weights_bits=2, activation=True, act_bits=1, name=f'l{i}')) + lay = m.get_layer(i + 1) + wv = np.zeros(lay.variables['weights'].shape, dtype=np.int8) + wv[0, 0, :, :] = W.T.astype(np.int8) + lay.variables['weights'] = wv + lay.variables['threshold'] = (-b - 1).astype(np.int32) + N = 65536 + xa, xb = np.arange(N) % 256, np.arange(N) // 256 + Xin = np.zeros((N, 1, 1, 16), dtype=np.uint8) + for k in range(8): + Xin[:, 0, 0, k] = (xa >> k) & 1 + Xin[:, 0, 0, 8 + k] = (xb >> k) & 1 + out = np.concatenate([m.forward(Xin[i:i + 8192]).reshape(-1, 9) for i in range(0, N, 8192)]) + bad = sum((sum(int(out[t, k]) << k for k in range(8)), int(out[t, 8])) != reference(int(xa[t]), int(xb[t])) + for t in range(N)) + res = dict(backend=f'akida {ak.__version__}', patterns=N, failures=int(bad)) + record('akida', res) + print(json.dumps(res)) + +NSIM, BIN, HIST = os.path.join(WORK, 'nsim'), os.path.join(WORK, 'host_lev.bin'), os.path.join(WORK, 'hist_sd.bin') + +M = 41967 + +Q = lambda u: 0.5 * math.erfc(u / math.sqrt(2.0)) + + +def nsim(*args): + return subprocess.run([NSIM, BIN] + [str(a) for a in args], capture_output=True, text=True, check=True).stdout + + +def profile_series(s, r=1, steps=4000): + """b(t) along the exact orbit: the sum over rows of r Q(r (k + 1/2) / s).""" + h = np.fromfile(HIST, dtype=np.int64).reshape(-1, 64)[:steps] + q = np.array([r * Q(r * (k + 0.5) / s) for k in range(64)]) + return h @ q + + +def curve(s, copies, T, seed, r=1): + first = json.loads(nsim('curve', 0, s, copies, T, seed, r)) + dev = [f for f in first if f > 0] + runs = [f if f > 0 else T for f in first] + exposure = sum(runs) + cum = np.concatenate([[0.0], np.cumsum(profile_series(s, r))]) + expected = float(sum(cum[e] for e in runs)) + return dict(s=s, r=r, deviated=len(dev), copies=copies, T=T, + median=float(np.median(dev)) if dev else None, exposure=exposure, + rate=len(dev) / exposure, profile_bound=expected / exposure, bound_on_count=expected, + rMp=r * M * math.erfc(r / (2 * math.sqrt(2) * s))) + + +def main_noise(): + os.makedirs(WORK, exist_ok=True) + subprocess.run(['gcc', '-O3', '-march=native', '-fopenmp', '-o', NSIM, os.path.join(REPO, 'src', 'nsim.c'), '-lm'], check=True) + out = {} + t0 = time.time() + out['exact_self_describing'] = json.loads(nsim('exact', 0, 10000, 4000, HIST)) + out['exact_self_reproducing'] = json.loads(nsim('exact', 1, 2000000, 0, '/dev/null')) + h = np.fromfile(HIST, dtype=np.int64).reshape(-1, 64)[:4000].mean(axis=0) + out['profile'] = dict(at_one_half=float(h[0]), at_three_halves=float(h[1]), at_five_halves=float(h[2]), + beyond=float(h[3:].sum()), fraction_at_one_half=float(h[0] / M)) + out['curve'] = [curve(s, 256, 3000 if s <= 0.06 else 4000, int(s * 1000) + 17) + for s in (0.02, 0.04, 0.06, 0.08, 0.09, 0.10, 0.11, 0.12, 0.15, 0.20)] + out['curve_s008_seeds'] = [curve(0.08, 256, 4000, seed) for seed in range(101, 109)] + out['deviation_N3'] = [curve(s, 256, 4000, int(s * 1000) + 29, r=3) for s in (0.24, 0.27, 0.30, 0.33)] + out['failure_N3'] = [] + for s in (0.27, 0.30, 0.33, 0.36): + first = json.loads(nsim('fail', s, 64, 20000, int(s * 1000) + 41, 3)) + runs = [f if f > 0 else 20000 for f in first] + failed = sum(1 for f in first if f > 0) + out['failure_N3'].append(dict(s=s, failed=failed, copies=64, exposure=sum(runs), rate=failed / sum(runs))) + procs = [subprocess.Popen([NSIM, BIN, 'run', s, r, seed], stdout=subprocess.PIPE, text=True) + for s, r, seed in (('0.05', '1', '906'), ('0.10', '1', '911'), ('0.15', '1', '916'), ('0.20', '1', '921'), + ('0.10', '3', '913'), ('0.15', '3', '918'), ('0.20', '3', '923'), ('0.25', '3', '928'))] + out['complete_runs'] = [json.loads(p.communicate()[0]) for p in procs] + out['seconds'] = round(time.time() - t0, 1) + json.dump(out, open(os.path.join(REPO, 'paper', 'runs', 'independent_noise.json'), 'w'), indent=1) + print(json.dumps({k: v for k, v in out.items() if k in ('exact_self_reproducing', 'profile', 'seconds')})) + + + +def load(): + b = open(BIN, 'rb').read() + pos = 0 + + def ints(k): + nonlocal pos + v = struct.unpack_from('<%di' % k, b, pos) + pos += 4 * k + return v + n, L, M, nnz = ints(4) + widths = ints(L + 1) + rows = [ints(2) for _ in range(M)] + edges = [ints(2) for _ in range(nnz)] + PCB, HALT, MEMB = ints(3) + io = ints(7) + blobs = [] + for _ in range(5): + (ln,) = ints(1) + blobs.append(b[pos:pos + ln]) + pos += ln + layers, r0, e0 = [], 0, 0 + for l in range(1, L + 1): + W = np.zeros((widths[l], widths[l - 1]), dtype=np.float32) + bias = np.zeros(widths[l], dtype=np.float32) + for i in range(widths[l]): + bb, f = rows[r0 + i] + bias[i] = bb + for k in range(f): + src, w = edges[e0 + k] + W[i, src] = w + e0 += f + r0 += widths[l] + layers.append((W, bias)) + return n, widths, layers, (PCB, HALT, MEMB), io, blobs + + +class Device: + def __init__(self, io, MEMB, tape): + self.C_WR, self.C_EOT, self.C_RW, self.C_RD, self.R_IN, self.R_OUT, _ = io + self.MEMB, self.tape, self.head = MEMB, tape, 0 + + def get(self, x, j): + v = 0 + for k in range(8): + v = (v << 1) | int(x[self.MEMB + 8 * j + k]) + return v + + def put(self, x, j, v): + for k in range(8): + x[self.MEMB + 8 * j + k] = (v >> (7 - k)) & 1 + + def __call__(self, x): + if self.get(x, self.C_WR) == 1: + self.put(x, self.R_OUT, 0) + if self.get(x, self.C_RD) == 1: + if self.head < len(self.tape): + self.put(x, self.R_IN, self.tape[self.head]); self.put(x, self.C_EOT, 0); self.head += 1 + else: + self.put(x, self.C_EOT, 1) + if self.get(x, self.C_RW) == 1: + self.head = 0 + for c in (self.C_RD, self.C_RW, self.C_WR): + self.put(x, c, 0) + + +def main_weight_error(): + n, widths, layers, (PCB, HALT, MEMB), io, blobs = load() + dev = torch.device('cuda') + Wt = [torch.from_numpy(W).to(dev) for W, _ in layers] + bt = [torch.from_numpy(b).to(dev) for _, b in layers] + steps = 10000 + settings = [(0, 0.25 / 8209), (0, 0.45 / 8209), (0, 0.75 / 8209), (0, 2 / 8209), (0, 8 / 8209), + (0, 32 / 8209), (0, 128 / 8209), (0.25 / 256, 0.125 / 8209), (0.5 / 256, 0), (1 / 256, 0), + (4 / 256, 0), (16 / 256, 0), (64 / 256, 0)] + out = [] + for si, (eps, gam) in enumerate(settings): + t0 = time.time() + g = torch.Generator(device=dev).manual_seed(4242 + si) + Wp = [] + for W in Wt: + u = torch.rand(W.shape, generator=g, device=dev) * 2 - 1 + Wp.append(W + torch.where(W != 0, u * eps, u * gam)) + x = np.zeros(n, dtype=np.uint8) + dx = Device(io, MEMB, blobs[1]); dp = Device(io, MEMB, blobs[1]) + for j, v in enumerate(blobs[0]): + dx.put(x, j, v) + xp = x.copy() + first = 0 + for t in range(steps): + a = torch.from_numpy(x.astype(np.float32)).to(dev) + ap = torch.from_numpy(xp.astype(np.float32)).to(dev) + for W, Wq, b in zip(Wt, Wp, bt): + a = ((W @ a + b) >= -0.5).float() + ap = ((Wq @ ap + b) >= -0.5).float() + x = a.to(torch.uint8).cpu().numpy(); xp = ap.to(torch.uint8).cpu().numpy() + dx(x); dp(xp) + if not np.array_equal(x, xp): + first = t + 1 + break + res = dict(eps=eps, gamma=gam, outcome=('exact for %d steps' % steps) if not first else 'deviates at step %d' % first, + seconds=round(time.time() - t0, 1)) + print(json.dumps(res), flush=True) + out.append(res) + del Wp + torch.cuda.empty_cache() + json.dump(out, open(os.path.join(REPO, 'paper', 'runs', 'independent_weight_error.json'), 'w'), indent=1) + + +def cmd_host_family(): + out = [] + widths = [int(a) for a in sys.argv[1].split(',')] if len(sys.argv) > 1 else list(range(3, 13)) + for A in widths: + for fold in (False, True): + states = 1 << 16 if A <= 10 else (1 << 15 if A == 11 else 1 << 14) + batch = 4096 if A <= 9 else (1024 if A <= 11 else 512) + t0 = time.time() + res = check(A, fold, states, batch, seed=1000 + A) + res['seconds'] = round(time.time() - t0, 1) + print(json.dumps({k: v for k, v in res.items() if k != 'block_layers'}), flush=True) + out.append(res) + json.dump(out, open(os.path.join(RUNS, 'independent_host_family.json'), 'w'), indent=1) + + +def cmd_neuromorphic(): + cmd = sys.argv[1] + if cmd == 'export': + export() + elif cmd == 'lava': + lava(int(sys.argv[2])) + else: + akida() + + +COMMANDS = {'host-family': cmd_host_family, 'levelize': main_levelize, 'neuromorphic': cmd_neuromorphic, 'noise': main_noise, 'weight-error': main_weight_error} + + +if __name__ == "__main__": + if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS: + sys.exit('commands: ' + ', '.join(COMMANDS)) + cmd = sys.argv.pop(1) + sys.argv[0] += ' ' + cmd + sys.exit(COMMANDS[cmd]()) diff --git a/src/independent/host_family.py b/src/independent/host_family.py deleted file mode 100644 index e43a864c348188cddbbf72d8450130e262e00987..0000000000000000000000000000000000000000 --- a/src/independent/host_family.py +++ /dev/null @@ -1,243 +0,0 @@ -"""H_A rebuilt from the paper's text alone (Construction 3.8, Table 2, Lemmas 3.2, -3.3 and 3.6, Remark 3.11), checked against a SUBLEQ_A written from Definition 3.1. - -No code of the artifact is imported. State layout: counter bits p_0..p_{A-1} -(least significant first), the halt bit, then memory bit t of word j. Each width -is built unfolded and folded; the sizes are compared with Proposition 3.9 and -one step with SUBLEQ_A on uniformly random full states, an eighth of them halted. - - python src/independent/host_family.py [3,4,...] -""" -import json -import os -import sys -import time - -RUNS = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'paper', 'runs') - -import numpy as np - - -class Net: - def __init__(self, n_state): - self.n = n_state - self.bias = [] - self.ins = [] - self.block = [] - self.cur = None - - def u(self, b, ins): - assert all(w in (-1, 1) for _, w in ins) - assert len({s for s, _ in ins}) == len(ins) - self.bias.append(b) - self.ins.append(ins) - self.block.append(self.cur) - return self.n + len(self.bias) - 1 - - def AND(self, *lits): - return self.u(-sum(1 for _, pos in lits if pos), [(s, 1 if pos else -1) for s, pos in lits]) - - def OR(self, *lits): - return self.u(sum(1 for _, pos in lits if not pos) - 1, [(s, 1 if pos else -1) for s, pos in lits]) - - -def build(A, fold=False): - S = 1 << A - P_IDX = list(range(A)) - H = A - MU = lambda j, t: A + 1 + j * A + t - n = A + 1 + A * S - N = Net(n) - - def sel(bits, j, extra=()): - # the decode line [bits == j] as one conjunction of literals, Lemma 3.6 - lits = [(bits[i], bool((j >> i) & 1)) for i in range(A)] + list(extra) - return N.AND(*lits) - - N.cur = 'P' - P = [sel(P_IDX, j) for j in range(S)] - N.cur = 'HP' - HP = [N.AND((H, True), (P_IDX[i], True)) for i in range(A)] - N.cur = 'FA' - if fold: - FA = [[[sel(P_IDX, j, [(MU((j + o) % S, t), True)]) for j in range(S)] for t in range(A)] for o in range(3)] - else: - FA = [[[N.AND((MU((j + o) % S, t), True), (P[j], True)) for j in range(S)] for t in range(A)] for o in range(3)] - N.cur = 'Q' - Q = [N.OR(*[(P[j], True) for j in range(S) if ((j + 3) % S >> i) & 1]) for i in range(A)] - N.cur = 'F' - F = [[N.OR(*[(FA[o][t][j], True) for j in range(S)]) for t in range(A)] for o in range(3)] - if not fold: - N.cur = 'PAB' - PA = [sel(F[0], j) for j in range(S)] - PB = [sel(F[1], j) for j in range(S)] - N.cur = 'AC' - AC = N.AND(*[(F[2][t], True) for t in range(A)]) - N.cur = 'W' - if fold: - W = [sel(F[1], j, [(H, False)]) for j in range(S)] - else: - W = [N.AND((PB[j], True), (H, False)) for j in range(S)] - N.cur = 'RXY' - if fold: - RX = [[sel(F[0], j, [(MU(j, t), True)]) for j in range(S)] for t in range(A)] - RY = [[sel(F[1], j, [(MU(j, t), True)]) for j in range(S)] for t in range(A)] - else: - RX = [[N.AND((MU(j, t), True), (PA[j], True)) for j in range(S)] for t in range(A)] - RY = [[N.AND((MU(j, t), True), (PB[j], True)) for j in range(S)] for t in range(A)] - N.cur = 'KP' - KP = [[N.AND((MU(j, t), True), (W[j], False)) for t in range(A)] for j in range(S)] - N.cur = 'XY' - X = [N.OR(*[(RX[t][j], True) for j in range(S)]) for t in range(A)] - Y = [N.OR(*[(RY[t][j], True) for j in range(S)]) for t in range(A)] - # the carry-lookahead subtractor of Lemma 3.2, r = y - x - N.cur = 'sub' - pp = [N.u(0, [(Y[i], 1), (X[i], -1)]) for i in range(A - 1)] - Tstar, Tj, c = {}, {}, {} - for k in range(1, A): - Tstar[k] = N.u(-k, [(pp[i], 1) for i in range(k)]) - for j in range(k): - Tj[k, j] = N.u(-(k - j), [(Y[j], 1), (X[j], -1)] + [(pp[i], 1) for i in range(j + 1, k)]) - for k in range(1, A): - c[k] = N.u(-1, [(Tstar[k], 1)] + [(Tj[k, j], 1) for j in range(k)]) - hm = {} - for k in range(1, A): - for m in (1, 2, 3): - hm[m, k] = N.u(1 - m, [(Y[k], 1), (X[k], -1), (c[k], 1)]) - o1 = N.OR((X[0], True), (Y[0], True)) - o2 = N.u(1, [(X[0], -1), (Y[0], -1)]) - r = [N.AND((o1, True), (o2, True))] - for k in range(1, A): - r.append(N.u(-1, [(hm[1, k], 1), (hm[2, k], -1), (hm[3, k], 1)])) - N.cur = 'eq' - eqs = [] - for t in range(A): - a1 = N.AND((X[t], True), (Y[t], True)) - a0 = N.AND((X[t], False), (Y[t], False)) - eqs.append(N.OR((a1, True), (a0, True))) - e = N.AND(*[(q, True) for q in eqs]) - N.cur = 'LEQ' - leq = N.u(-1, [(hm[1, A - 1], 1), (hm[2, A - 1], -1), (hm[3, A - 1], 1), (e, 1)]) - N.cur = 'NP' - TA = [N.AND((H, False), (leq, True), (F[2][i], True)) for i in range(A)] - TB = [N.AND((H, False), (leq, False), (Q[i], True)) for i in range(A)] - NP = [N.OR((HP[i], True), (TA[i], True), (TB[i], True)) for i in range(A)] - N.cur = 'NH' - HA = N.AND((H, False), (leq, True), (AC, True)) - HB = N.AND((H, False), (leq, False), (P[S - 4], True)) - NH = N.OR((H, True), (HA, True), (HB, True)) - N.cur = 'NM' - NM = {} - for j in range(S): - for t in range(A): - wr = N.AND((r[t], True), (W[j], True)) - NM[j, t] = N.OR((KP[j][t], True), (wr, True)) - nxt = [None] * n - for i in range(A): - nxt[P_IDX[i]] = NP[i] - nxt[H] = NH - for j in range(S): - for t in range(A): - nxt[MU(j, t)] = NM[j, t] - return N, nxt - - -def stats(N, nxt): - depth = [0] * (N.n + len(N.bias)) - for k, ins in enumerate(N.ins): - depth[N.n + k] = 1 + max((depth[s] for s, _ in ins), default=0) - blocks = {} - for k, b in enumerate(N.block): - d = depth[N.n + k] - lo, hi = blocks.get(b, (d, d)) - blocks[b] = (min(lo, d), max(hi, d)) - return dict(units=len(N.bias), edges=sum(len(i) for i in N.ins), - depth=max(depth[s] for s in nxt), bias_range=[min(N.bias), max(N.bias)], - block_layers=blocks) - - -def evaluate(N, nxt, x): - """x: (n, batch) uint8; returns the next state (n, batch).""" - sig = np.zeros((N.n + len(N.bias), x.shape[1]), dtype=np.int32) - sig[:N.n] = x - for k, (b, ins) in enumerate(zip(N.bias, N.ins)): - idx = np.fromiter((s for s, _ in ins), dtype=np.int64, count=len(ins)) - w = np.fromiter((w for _, w in ins), dtype=np.int32, count=len(ins)) - if len(ins) == 0: - pre = np.full(x.shape[1], b, dtype=np.int32) - else: - pre = w @ sig[idx] + b - sig[N.n + k] = pre >= 0 - return sig[nxt].astype(np.uint8) - - -def subleq_step(A, x): - """Definition 3.1 on the state layout above, columns of x being states.""" - S = 1 << A - batch = x.shape[1] - p = np.zeros(batch, dtype=np.int64) - for i in range(A): - p |= x[i].astype(np.int64) << i - h = x[A].astype(bool) - mem = np.zeros((S, batch), dtype=np.int64) - for t in range(A): - mem |= x[A + 1 + np.arange(S) * A + t].astype(np.int64) << t - cols = np.arange(batch) - a = mem[p, cols] - b = mem[(p + 1) % S, cols] - c = mem[(p + 2) % S, cols] - newb = (mem[b, cols] - mem[a, cols]) % S - signed = np.where(newb >= S // 2, newb - S, newb) - np_ = np.where(signed <= 0, c, (p + 3) % S) - mem2 = mem.copy() - mem2[b, cols] = newb - mem2 = np.where(h[None, :], mem, mem2) - p2 = np.where(h, p, np_) - h2 = h | (p2 == S - 1) - y = np.zeros_like(x) - for i in range(A): - y[i] = (p2 >> i) & 1 - y[A] = h2 - for t in range(A): - y[A + 1 + np.arange(S) * A + t] = (mem2 >> t) & 1 - return y - - -def check(A, fold, states, batch, seed): - N, nxt = build(A, fold) - st = stats(N, nxt) - rng = np.random.default_rng(seed) - n = N.n - bad = 0 - halted_bad = 0 - done = 0 - while done < states: - bsz = min(batch, states - done) - x = rng.integers(0, 2, size=(n, bsz), dtype=np.uint8) - x[A] = 0 - x[A, : bsz // 8] = 1 # an eighth of the states halted - y = evaluate(N, nxt, x) - ref = subleq_step(A, x) - bad += int(np.any(y != ref, axis=0).sum()) - halted = x[A] == 1 - halted_bad += int(np.any(y[:, halted] != x[:, halted], axis=0).sum()) - done += bsz - formula = (8 * A + 4) * (1 << A) + (A * A + 39 * A + 4) // 2 - return dict(A=A, fold=fold, states=states, disagree=bad, halted_not_fixed=halted_bad, - formula_units=None if fold else formula, **{k: v for k, v in st.items() if k != 'block_layers'}, - block_layers={k: list(v) for k, v in st['block_layers'].items()}) - - -if __name__ == '__main__': - out = [] - widths = [int(a) for a in sys.argv[1].split(',')] if len(sys.argv) > 1 else list(range(3, 13)) - for A in widths: - for fold in (False, True): - states = 1 << 16 if A <= 10 else (1 << 15 if A == 11 else 1 << 14) - batch = 4096 if A <= 9 else (1024 if A <= 11 else 512) - t0 = time.time() - res = check(A, fold, states, batch, seed=1000 + A) - res['seconds'] = round(time.time() - t0, 1) - print(json.dumps({k: v for k, v in res.items() if k != 'block_layers'}), flush=True) - out.append(res) - json.dump(out, open(os.path.join(RUNS, 'independent_host_family.json'), 'w'), indent=1) diff --git a/src/independent/levelize.py b/src/independent/levelize.py deleted file mode 100644 index 21dc5de0b13e83a024a402d185fc61642af414bc..0000000000000000000000000000000000000000 --- a/src/independent/levelize.py +++ /dev/null @@ -1,186 +0,0 @@ -"""sigma(N_host) parsed, levelized by Lemma 2.6, and the instances rebuilt from the -paper's tables; written as one flat file for nsim.c. No artifact code is used. - -File layout (little-endian int32 unless stated): - n, L, M, nnz - width[0..L] (width[0] = n) - for every row of layers 1..L: bias, fan-in (2*M ints) - for every edge: input index within the previous layer, weight (2*nnz ints) - lay: pc_base, halt, mem_base - dev: c_wr, c_eot, c_rw, c_rd, r_in, r_out, halt_pc - then three byte blobs, each preceded by its length: m_P, r_H, m*, tau*, expected ser(Omega*) -""" -import hashlib -import json -import os -import struct -import sys - -import numpy as np - -REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -SIGMA = os.path.join(REPO, 'variants', 'neural_subleq8io_netlist.safetensors') -WORK = os.path.join(REPO, 'src', 'independent', '.work') -OUT = os.path.join(WORK, 'host_lev.bin') - - -def parse(path): - b = open(path, 'rb').read() - n = struct.unpack(' 0 else -1) for e in pred[starts[u]:starts[u + 1]]] for u in range(U)] - depth = [0] * (nstate + U) - for u in range(U): - depth[nstate + u] = 1 + max((depth[s] for s, _ in ins[u]), default=0) - d = max(depth[s] for s in nxt) - # units no next value depends on - need = [False] * (nstate + U) - stack = [s for s in nxt] - while stack: - s = stack.pop() - if need[s]: - continue - need[s] = True - if s >= nstate: - stack.extend(t for t, _ in ins[s - nstate]) - # lambda(u): the largest layer that consumes u; next values consumed at d + 1 - lam = [-1] * (nstate + U) - for u in range(U): - if need[nstate + u]: - for s, _ in ins[u]: - lam[s] = max(lam[s], depth[nstate + u]) - for s in nxt: - lam[s] = d + 1 - layers = [] # layer l: list of (key, bias, [(src_key, w)]) - pos = [{('s', i): i for i in range(nstate)}] # key -> row index in layer - for l in range(1, d + 1): - rows = [] - if l < d: - for s in range(nstate + U): - if not need[s] and s >= nstate: - continue - if s >= nstate and depth[s] == l: - u = s - nstate - rows.append((('s', s), int(bias[u]), [(('s', t), w) for t, w in ins[u]])) - elif depth[s] < l < lam[s]: - rows.append((('s', s), -1, [(('s', s), 1)])) - else: - for i, s in enumerate(nxt): - if s >= nstate and depth[s] == d: - u = s - nstate - rows.append((('o', i), int(bias[u]), [(('s', t), w) for t, w in ins[u]])) - else: - rows.append((('o', i), -1, [(('s', s), 1)])) - layers.append(rows) - pos.append({k: r for r, (k, _, _) in enumerate(rows)}) - # resolve each input to its row in the layer before - flat = [] - for l, rows in enumerate(layers, start=1): - prev = pos[l - 1] - flat.append([(b, [(prev[k], w) for k, w in sl]) for _, b, sl in rows]) - return d, flat - - -def frame(u): - return struct.pack('= 4: - out += [256 - ell, nu(f[i])] - i += ell - continue - k = i + 1 - while k < len(f) and k < i + 127: - if k + 3 < len(f) and f[k] == f[k + 1] == f[k + 2] == f[k + 3]: - break - k += 1 - out += [k - i] + [nu(c) for c in f[i:k]] - i = k - return bytes(out + [0]) - - -Z, ONE, T1, T2, NEG1, EOK = 0xF0, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5 -C_WR, C_EOT, C_RW, C_RD, R_IN, R_OUT = 0xF9, 0xFA, 0xFB, 0xFC, 0xFD, 0xFE -TABLE9 = [ - (T2, T2, 0x03), (T1, T1, 0x06), (NEG1, C_RD, 0x09), (R_IN, T1, 0x0C), (T1, T2, 0x0F), - (Z, T2, 0x15), (Z, Z, 0x1B), (Z, T1, 0x3C), (Z, Z, 0x2D), (NEG1, C_RD, 0x1E), - (R_IN, R_OUT, 0x21), (NEG1, C_WR, 0x24), (ONE, T2, 0x2A), (Z, Z, 0x1B), (Z, Z, 0x00), - (NEG1, C_RD, 0x30), (R_IN, R_OUT, 0x33), (NEG1, C_WR, 0x36), (ONE, T1, 0x2A), (Z, Z, 0x30), - (Z, Z, 0xFF)] -TABLE10 = [(NEG1, C_RW, 0x3F), (NEG1, C_RD, 0x42), (C_EOT, EOK, 0xFF), (R_IN, Z, 0x48), - (Z, R_OUT, 0x4B), (NEG1, C_WR, 0x4E), (Z, Z, 0x3F)] - - -def image(prog): - mem = [0] * 256 - for k, ins in enumerate(prog): - mem[3 * k:3 * k + 3] = list(ins) - mem[ONE], mem[NEG1], mem[EOK] = 1, 255, 1 - return bytes(mem) - - -def main(): - raw, md, T = parse(SIGMA) - nstate = int(T['manifest.state_bits'][0]) - d, layers = levelize(nstate, T['net.bias'], T['net.fanin'], T['net.pred'], [int(v) for v in T['net.next']]) - widths = [nstate] + [len(r) for r in layers] - M = sum(widths[1:]) - nnz = sum(len(sl) for rows in layers for _, sl in rows) - ident = sum(1 for rows in layers for b, sl in rows if b == -1 and len(sl) == 1 and sl[0][1] == 1) - reg = json.loads(md['signal_registry']) - io = json.loads(md['io']) - m_p = image(TABLE9) - m_star = image(TABLE9[:20] + TABLE10) - r_h = describe(raw) - tau = describe(frame(raw) + frame(m_star)) - ser = frame(raw) + frame(m_star) + tau - info = dict(depth=d, state_bits=nstate, M=M, nnz=nnz, max_width=max(widths[1:]), identity_like=ident, - dense_entries=sum(widths[i] * widths[i + 1] for i in range(d)), - r_H=len(r_h), tau=len(tau), ser=len(ser), ser_sha=hashlib.sha256(ser).hexdigest(), - sigma_sha=hashlib.sha256(raw).hexdigest(), registry=reg, io=io) - print(json.dumps(info)) - os.makedirs(WORK, exist_ok=True) - json.dump(info, open(os.path.join(REPO, 'paper', 'runs', 'independent_levelize.json'), 'w'), indent=1) - with open(OUT, 'wb') as f: - w = lambda *v: f.write(struct.pack('<%di' % len(v), *v)) - w(nstate, d, M, nnz) - w(*widths) - for rows in layers: - for b, sl in rows: - w(b, len(sl)) - for rows in layers: - for b, sl in rows: - for src, wt in sl: - w(src, wt) - w(reg['pc'][0], reg['halt'][0], reg['mem'][0]) - w(io['c_wr'], io['c_eot'], io['c_rw'], io['c_rd'], io['r_in'], io['r_out'], io['halt_pc']) - for blob in (m_p, r_h, m_star, tau, ser): - w(len(blob)) - f.write(blob) - - -if __name__ == '__main__': - main() diff --git a/src/independent/neuromorphic.py b/src/independent/neuromorphic.py deleted file mode 100644 index a9f9a12e8d383bde8c009ebf6f7b61d8f937b432..0000000000000000000000000000000000000000 --- a/src/independent/neuromorphic.py +++ /dev/null @@ -1,199 +0,0 @@ -"""The arithmetic datapath of H_8 (Lemma 3.2, the equality test, the branch unit) -from host_family.py of this directory, levelized here, on Lava's Loihi neuron -model and on Akida. A unit H( + b) is a Loihi compartment with weights 2w, -bias_mant = b + K, bias_exp = 7, du = dv = 4095 and vth = 2K - 2 + OFFSET, whose -membrane potential is 128 ( + b + K) against the threshold 64 vth; OFFSET 1 -puts the threshold midway between the firing and the quiet potentials. - - python src/independent/neuromorphic.py export - lavavenv/bin/python src/independent/neuromorphic.py lava OFFSET - python src/independent/neuromorphic.py akida -""" -import json -import os -import sys - -import numpy as np - -D = os.path.dirname(os.path.abspath(__file__)) -WORK = os.path.join(D, '.work') -NPZ = os.path.join(WORK, 'datapath.npz') -RECORD = os.path.join(os.path.dirname(os.path.dirname(D)), 'paper', 'runs', 'independent_neuromorphic.json') -K = 9 - - -def record(key, value): - rec = json.load(open(RECORD)) if os.path.exists(RECORD) else {} - rec[key] = value - json.dump(rec, open(RECORD, 'w'), indent=1) - - -def export(): - sys.path.insert(0, D) - import host_family as H - N, nxt = H.build(8) - blocks = N.block - nst = N.n - keep = [k for k, b in enumerate(blocks) if b in ('sub', 'eq', 'LEQ')] - keepset = set(N.n + k for k in keep) - # inputs: the units X_t, Y_t of block XY; outputs: r_0..r_7 and LEQ - xy = [N.n + k for k, b in enumerate(blocks) if b == 'XY'] - X, Y = xy[:8], xy[8:] - units = {N.n + k: (N.bias[k], N.ins[k]) for k in keep} - # the outputs: the sum bits are the last 8 units feeding WR, recovered as the inputs of NM's AND units - sub = [N.n + k for k, b in enumerate(blocks) if b == 'sub'] - r = [sub[-8]] + sub[-7:] # r_0 is the AND of the XOR cell, then r_1..r_7 - leq = [N.n + k for k, b in enumerate(blocks) if b == 'LEQ'][0] - inputs = X + Y - outputs = r + [leq] - # check the choice of outputs by evaluating the cut-out circuit on all pairs - def ev(xa, xb): - v = {} - for t in range(8): - v[X[t]] = (xa >> t) & 1 - v[Y[t]] = (xb >> t) & 1 - for u in sorted(units): - b, ins = units[u] - v[u] = 1 if b + sum(w * v[s] for s, w in ins) >= 0 else 0 - return sum(v[r[k]] << k for k in range(8)), v[leq] - for xa in range(256): - for xb in (0, 1, 127, 128, 255, xa, (xa + 1) % 256): - res = (xb - xa) % 256 - assert ev(xa, xb) == (res, int(res == 0 or res >= 128)), (xa, xb) - # levelize (Lemma 2.6) with the inputs at layer 0 - depth = {s: 0 for s in inputs} - for u in sorted(units): - depth[u] = 1 + max(depth[s] for s, _ in units[u][1]) - d = max(depth[o] for o in outputs) - lam = {} - for u in units: - for s, _ in units[u][1]: - lam[s] = max(lam.get(s, 0), depth[u]) - for o in outputs: - lam[o] = d + 1 - layers, prev = [], {s: i for i, s in enumerate(inputs)} - for l in range(1, d + 1): - rows = [] - if l < d: - for s in list(inputs) + sorted(units): - if s in units and depth[s] == l: - rows.append((s, units[s][0], units[s][1])) - elif depth[s] < l < lam.get(s, -1): - rows.append((s, -1, [(s, 1)])) - else: - for o in outputs: - rows.append((o, units[o][0], units[o][1]) if depth[o] == d else (o, -1, [(o, 1)])) - W = np.zeros((len(rows), len(prev)), dtype=np.int32) - b = np.zeros(len(rows), dtype=np.int32) - for i, (_, bb, ins) in enumerate(rows): - b[i] = bb - for s, w in ins: - W[i, prev[s]] = w - layers.append((W, b)) - prev = {s: i for i, (s, _, _) in enumerate(rows)} - out = dict(n_layers=np.array([d]), n_units=np.array([len(units)])) - for i, (W, b) in enumerate(layers): - out[f'W{i}'], out[f'b{i}'] = W, b - os.makedirs(WORK, exist_ok=True) - np.savez(NPZ, **out) - res = dict(units=len(units), depth=d, widths=[int(W.shape[0]) for W, _ in layers]) - record('datapath', res) - print(json.dumps(res)) - - -def reference(xa, xb): - res = (xb - xa) % 256 - return res, int(res == 0 or res >= 128) - - -def operands(n, seed): - rng = np.random.default_rng(seed) - xa = rng.integers(0, 256, n) - xb = rng.integers(0, 256, n) - xa[:256], xb[:256] = np.arange(256), 0 - xa[256:512], xb[256:512] = 0, np.arange(256) - xa[512:768], xb[512:768] = np.arange(256), np.arange(256) - return xa, xb - - -def lava(off): - from lava.magma.core.run_conditions import RunSteps - from lava.magma.core.run_configs import Loihi2SimCfg - from lava.proc.dense.process import Dense - from lava.proc.io.sink import RingBuffer as Sink - from lava.proc.io.source import RingBuffer as Source - from lava.proc.lif.process import LIF - z = np.load(NPZ) - L = int(z['n_layers'][0]) - layers = [(z[f'W{i}'], z[f'b{i}']) for i in range(L)] - N = 4096 - xa, xb = operands(N, 77) - spikes = np.zeros((16, N)) - for k in range(8): - spikes[k] = (xa >> k) & 1 - spikes[8 + k] = (xb >> k) & 1 - T = N + L + 2 - src = Source(data=np.pad(spikes, ((0, 0), (0, T - N)))) - prev, first = src.s_out, None - vth = 2 * K - 2 + off - for W, b in layers: - dn = Dense(weights=(2 * W).astype(np.int32)) - lif = LIF(shape=(W.shape[0],), du=4095, dv=4095, bias_mant=(b + K).astype(np.int32), bias_exp=7, vth=vth) - prev.connect(dn.s_in) - dn.a_out.connect(lif.a_in) - prev, first = lif.s_out, first or lif - snk = Sink(shape=(9,), buffer=T) - prev.connect(snk.a_in) - first.run(condition=RunSteps(num_steps=T), run_cfg=Loihi2SimCfg(select_tag='fixed_pt')) - out = snk.data.get() - first.stop() - bad = 0 - for t in range(N): - col = out[:, t + L] - bad += (sum(int(col[k]) << k for k in range(8)), int(col[8])) != reference(int(xa[t]), int(xb[t])) - # the membrane potential of a unit at pre-activation z is 128 (z + K); the threshold is 64 vth - margin_fire = 128 * K - 64 * vth - margin_quiet = 64 * vth - 128 * (K - 1) - res = dict(backend='lava fixed_pt', vth=vth, patterns=N, failures=bad, - margin_firing_side=margin_fire, margin_quiet_side=margin_quiet) - record(f'lava_vth_{vth}', res) - print(json.dumps(res)) - - -def akida(): - import akida as ak - from akida import FullyConnected, InputData, Model - z = np.load(NPZ) - L = int(z['n_layers'][0]) - layers = [(z[f'W{i}'], z[f'b{i}']) for i in range(L)] - m = Model() - m.add(InputData(input_shape=(1, 1, 16), input_bits=1)) - for i, (W, b) in enumerate(layers): - m.add(FullyConnected(units=int(W.shape[0]), weights_bits=2, activation=True, act_bits=1, name=f'l{i}')) - lay = m.get_layer(i + 1) - wv = np.zeros(lay.variables['weights'].shape, dtype=np.int8) - wv[0, 0, :, :] = W.T.astype(np.int8) - lay.variables['weights'] = wv - lay.variables['threshold'] = (-b - 1).astype(np.int32) - N = 65536 - xa, xb = np.arange(N) % 256, np.arange(N) // 256 - Xin = np.zeros((N, 1, 1, 16), dtype=np.uint8) - for k in range(8): - Xin[:, 0, 0, k] = (xa >> k) & 1 - Xin[:, 0, 0, 8 + k] = (xb >> k) & 1 - out = np.concatenate([m.forward(Xin[i:i + 8192]).reshape(-1, 9) for i in range(0, N, 8192)]) - bad = sum((sum(int(out[t, k]) << k for k in range(8)), int(out[t, 8])) != reference(int(xa[t]), int(xb[t])) - for t in range(N)) - res = dict(backend=f'akida {ak.__version__}', patterns=N, failures=int(bad)) - record('akida', res) - print(json.dumps(res)) - - -if __name__ == '__main__': - cmd = sys.argv[1] - if cmd == 'export': - export() - elif cmd == 'lava': - lava(int(sys.argv[2])) - else: - akida() diff --git a/src/independent/noise.py b/src/independent/noise.py deleted file mode 100644 index 5590880eed27b5d1002a94110193c5611b7d9d8b..0000000000000000000000000000000000000000 --- a/src/independent/noise.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Tables 4, 5 and 7, the deviation rates of N_3 and the orbit counts of Section 6.1, -reproduced with nsim.c on the levelization written by levelize.py. - -A noisy copy of a row flips its output with probability Q(|z + 1/2| / s), z its -pre-activation, which under centred Gaussian read noise of deviation s is the -probability that the noise carries it across the comparator. The bounds are -computed from the histogram of z along the exact orbit. - - python src/independent/levelize.py && python src/independent/noise.py -""" -import json -import math -import os -import subprocess -import time - -import numpy as np - -REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -HERE = os.path.join(REPO, 'src', 'independent') -WORK = os.path.join(HERE, '.work') -NSIM, BIN, HIST = os.path.join(WORK, 'nsim'), os.path.join(WORK, 'host_lev.bin'), os.path.join(WORK, 'hist_sd.bin') -M = 41967 -Q = lambda u: 0.5 * math.erfc(u / math.sqrt(2.0)) - - -def nsim(*args): - return subprocess.run([NSIM, BIN] + [str(a) for a in args], capture_output=True, text=True, check=True).stdout - - -def profile_series(s, r=1, steps=4000): - """b(t) along the exact orbit of the self-describing instance: the sum over rows of - r Q(r (k + 1/2) / s), k + 1/2 the distance of the row from the comparator.""" - h = np.fromfile(HIST, dtype=np.int64).reshape(-1, 64)[:steps] - q = np.array([r * Q(r * (k + 0.5) / s) for k in range(64)]) - return h @ q - - -def curve(s, copies, T, seed, r=1): - first = json.loads(nsim('curve', 0, s, copies, T, seed, r)) - dev = [f for f in first if f > 0] - runs = [f if f > 0 else T for f in first] - exposure = sum(runs) - cum = np.concatenate([[0.0], np.cumsum(profile_series(s, r))]) - expected = float(sum(cum[e] for e in runs)) - return dict(s=s, r=r, deviated=len(dev), copies=copies, T=T, - median=float(np.median(dev)) if dev else None, exposure=exposure, - rate=len(dev) / exposure, profile_bound=expected / exposure, bound_on_count=expected, - rMp=r * M * math.erfc(r / (2 * math.sqrt(2) * s))) - - -def main(): - os.makedirs(WORK, exist_ok=True) - subprocess.run(['gcc', '-O3', '-march=native', '-fopenmp', '-o', NSIM, os.path.join(HERE, 'nsim.c'), '-lm'], check=True) - out = {} - t0 = time.time() - out['exact_self_describing'] = json.loads(nsim('exact', 0, 10000, 4000, HIST)) - out['exact_self_reproducing'] = json.loads(nsim('exact', 1, 2000000, 0, '/dev/null')) - h = np.fromfile(HIST, dtype=np.int64).reshape(-1, 64)[:4000].mean(axis=0) - out['profile'] = dict(at_one_half=float(h[0]), at_three_halves=float(h[1]), at_five_halves=float(h[2]), - beyond=float(h[3:].sum()), fraction_at_one_half=float(h[0] / M)) - out['curve'] = [curve(s, 256, 3000 if s <= 0.06 else 4000, int(s * 1000) + 17) - for s in (0.02, 0.04, 0.06, 0.08, 0.09, 0.10, 0.11, 0.12, 0.15, 0.20)] - out['curve_s008_seeds'] = [curve(0.08, 256, 4000, seed) for seed in range(101, 109)] - out['deviation_N3'] = [curve(s, 256, 4000, int(s * 1000) + 29, r=3) for s in (0.24, 0.27, 0.30, 0.33)] - out['failure_N3'] = [] - for s in (0.27, 0.30, 0.33, 0.36): - first = json.loads(nsim('fail', s, 64, 20000, int(s * 1000) + 41, 3)) - runs = [f if f > 0 else 20000 for f in first] - failed = sum(1 for f in first if f > 0) - out['failure_N3'].append(dict(s=s, failed=failed, copies=64, exposure=sum(runs), rate=failed / sum(runs))) - procs = [subprocess.Popen([NSIM, BIN, 'run', s, r, seed], stdout=subprocess.PIPE, text=True) - for s, r, seed in (('0.05', '1', '906'), ('0.10', '1', '911'), ('0.15', '1', '916'), ('0.20', '1', '921'), - ('0.10', '3', '913'), ('0.15', '3', '918'), ('0.20', '3', '923'), ('0.25', '3', '928'))] - out['complete_runs'] = [json.loads(p.communicate()[0]) for p in procs] - out['seconds'] = round(time.time() - t0, 1) - json.dump(out, open(os.path.join(REPO, 'paper', 'runs', 'independent_noise.json'), 'w'), indent=1) - print(json.dumps({k: v for k, v in out.items() if k in ('exact_self_reproducing', 'profile', 'seconds')})) - - -if __name__ == '__main__': - main() diff --git a/src/independent/weight_error.py b/src/independent/weight_error.py deleted file mode 100644 index 6c6de599fdd8b1b5a1604c12ea3a99bc914a8010..0000000000000000000000000000000000000000 --- a/src/independent/weight_error.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Table 6 reproduced: Lev(N_host), levelized by levelize.py, with every programmed -weight perturbed uniformly in [-eps, eps] and every zero entry in [-gamma, gamma], -thresholded at -1/2, run beside the exact map from the self-describing instance. - - python src/independent/levelize.py && python src/independent/weight_error.py -""" -import json -import struct -import sys -import time - -import numpy as np -import torch - -import os -REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -BIN = os.path.join(REPO, 'src', 'independent', '.work', 'host_lev.bin') - - -def load(): - b = open(BIN, 'rb').read() - pos = 0 - - def ints(k): - nonlocal pos - v = struct.unpack_from('<%di' % k, b, pos) - pos += 4 * k - return v - n, L, M, nnz = ints(4) - widths = ints(L + 1) - rows = [ints(2) for _ in range(M)] - edges = [ints(2) for _ in range(nnz)] - PCB, HALT, MEMB = ints(3) - io = ints(7) - blobs = [] - for _ in range(5): - (ln,) = ints(1) - blobs.append(b[pos:pos + ln]) - pos += ln - layers, r0, e0 = [], 0, 0 - for l in range(1, L + 1): - W = np.zeros((widths[l], widths[l - 1]), dtype=np.float32) - bias = np.zeros(widths[l], dtype=np.float32) - for i in range(widths[l]): - bb, f = rows[r0 + i] - bias[i] = bb - for k in range(f): - src, w = edges[e0 + k] - W[i, src] = w - e0 += f - r0 += widths[l] - layers.append((W, bias)) - return n, widths, layers, (PCB, HALT, MEMB), io, blobs - - -class Device: - def __init__(self, io, MEMB, tape): - self.C_WR, self.C_EOT, self.C_RW, self.C_RD, self.R_IN, self.R_OUT, _ = io - self.MEMB, self.tape, self.head = MEMB, tape, 0 - - def get(self, x, j): - v = 0 - for k in range(8): - v = (v << 1) | int(x[self.MEMB + 8 * j + k]) - return v - - def put(self, x, j, v): - for k in range(8): - x[self.MEMB + 8 * j + k] = (v >> (7 - k)) & 1 - - def __call__(self, x): - if self.get(x, self.C_WR) == 1: - self.put(x, self.R_OUT, 0) - if self.get(x, self.C_RD) == 1: - if self.head < len(self.tape): - self.put(x, self.R_IN, self.tape[self.head]); self.put(x, self.C_EOT, 0); self.head += 1 - else: - self.put(x, self.C_EOT, 1) - if self.get(x, self.C_RW) == 1: - self.head = 0 - for c in (self.C_RD, self.C_RW, self.C_WR): - self.put(x, c, 0) - - -def main(): - n, widths, layers, (PCB, HALT, MEMB), io, blobs = load() - dev = torch.device('cuda') - Wt = [torch.from_numpy(W).to(dev) for W, _ in layers] - bt = [torch.from_numpy(b).to(dev) for _, b in layers] - steps = 10000 - settings = [(0, 0.25 / 8209), (0, 0.45 / 8209), (0, 0.75 / 8209), (0, 2 / 8209), (0, 8 / 8209), - (0, 32 / 8209), (0, 128 / 8209), (0.25 / 256, 0.125 / 8209), (0.5 / 256, 0), (1 / 256, 0), - (4 / 256, 0), (16 / 256, 0), (64 / 256, 0)] - out = [] - for si, (eps, gam) in enumerate(settings): - t0 = time.time() - g = torch.Generator(device=dev).manual_seed(4242 + si) - Wp = [] - for W in Wt: - u = torch.rand(W.shape, generator=g, device=dev) * 2 - 1 - Wp.append(W + torch.where(W != 0, u * eps, u * gam)) - x = np.zeros(n, dtype=np.uint8) - dx = Device(io, MEMB, blobs[1]); dp = Device(io, MEMB, blobs[1]) - for j, v in enumerate(blobs[0]): - dx.put(x, j, v) - xp = x.copy() - first = 0 - for t in range(steps): - a = torch.from_numpy(x.astype(np.float32)).to(dev) - ap = torch.from_numpy(xp.astype(np.float32)).to(dev) - for W, Wq, b in zip(Wt, Wp, bt): - a = ((W @ a + b) >= -0.5).float() - ap = ((Wq @ ap + b) >= -0.5).float() - x = a.to(torch.uint8).cpu().numpy(); xp = ap.to(torch.uint8).cpu().numpy() - dx(x); dp(xp) - if not np.array_equal(x, xp): - first = t + 1 - break - res = dict(eps=eps, gamma=gam, outcome=('exact for %d steps' % steps) if not first else 'deviates at step %d' % first, - seconds=round(time.time() - t0, 1)) - print(json.dumps(res), flush=True) - out.append(res) - del Wp - torch.cuda.empty_cache() - json.dump(out, open(os.path.join(REPO, 'paper', 'runs', 'independent_weight_error.json'), 'w'), indent=1) - - -if __name__ == '__main__': - main() diff --git a/src/machines.py b/src/machines.py index 48e1a7cbbd41fb8d02abeebb760cbcc12fe0a4b4..9a2f7a2a1410c0dc12a10af8b1045ec118229862 100644 --- a/src/machines.py +++ b/src/machines.py @@ -1,33 +1,24 @@ -"""neural_subleq8 and neural_rv32 — runtimes, reference emulators, assemblers, -an RV32 object loader, and test suites for the two endpoint machines of the -family. One file; run the suites with: - - python machines.py # both machines - python machines.py subleq # SUBLEQ-8 only - python machines.py rv32 # RV32 assembled-program suite - python machines.py rv32-c # RV32 running stock-compiler C - -Build the model files with `python build.py --apply subleq` and -`python build.py --apply rv32`. -""" - +"""The SUBLEQ, RV32 and matrix machines: runtimes, references, assemblers and suites.""" from __future__ import annotations + import argparse +import json import os import shutil import struct import subprocess import sys import tempfile +import time from pathlib import Path from typing import Dict, List, Optional, Tuple import torch from safetensors import safe_open +from safetensors.torch import save_file + +from eval import NetlistEvaluator, heaviside, load_metadata, GenericThresholdALU, int_to_bits, bits_to_int -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from eval import NetlistEvaluator, heaviside, load_metadata -from eval_all import GenericThresholdALU, int_to_bits, bits_to_int # ============================================================================ # RV32 relocatable-object loader @@ -46,6 +37,7 @@ R = { "R_RISCV_LO12_S": 28, "R_RISCV_RELAX": 51, } + RREV = {v: k for k, v in R.items()} @@ -211,13 +203,14 @@ def load(obj: bytes, base: int = 0, mem_size: int = 65536, # ============================================================================ RV32_MODEL = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "variants", "neural_rv32.safetensors") + MASK32 = 0xFFFFFFFF + MMIO_CONSOLE = 0xFF00 def fcvt_s_w_ref(x: int) -> int: - """Signed int32 -> float32 word, round-to-nearest-even (host float() - then pack to float32 is exact RNE for the int32 range).""" + """Signed int32 to float32 word, rounded to nearest even.""" xi = x if (x & MASK32) < 0x80000000 else (x & MASK32) - (1 << 32) return struct.unpack(" int: - """Whether the truncated magnitude rounds up by one ULP under the RISC-V - rounding mode (000 RNE, 001 RTZ, 010 RDN, 011 RUP, 100 RMM; 111 DYN has no - fcsr in this machine and follows RNE). guard is the half bit just below the - integer, sticky the OR of everything beneath it.""" + """Whether the truncated magnitude rounds up one ULP under the RISC-V rounding mode.""" if rm == 1: # RTZ: truncate return 0 if rm == 2: # RDN: toward -inf @@ -242,8 +232,7 @@ def _fcvt_round_up(rm: int, sign: int, guard: int, sticky: int, lsb: int) -> int def fcvt_w_s_ref(w: int, rm: int = 1) -> int: - """float32 word -> signed int32 under rounding mode `rm`, saturating; - NaN -> INT_MAX, infinities -> INT_MIN/INT_MAX.""" + """float32 word to signed int32 under rounding mode rm, saturating.""" s, e, f = (w >> 31) & 1, (w >> 23) & 0xFF, w & 0x7FFFFF if e == 0xFF: return 0x7FFFFFFF if f else (0x80000000 if s else 0x7FFFFFFF) @@ -284,9 +273,13 @@ R_FUNCT = {"add": (0, 0), "sub": (0, 32), "sll": (1, 0), "slt": (2, 0), "or": (6, 0), "and": (7, 0), "mul": (0, 1), "mulh": (1, 1), "mulhsu": (2, 1), "mulhu": (3, 1), "div": (4, 1), "divu": (5, 1), "rem": (6, 1), "remu": (7, 1)} + I_FUNCT = {"addi": 0, "slti": 2, "sltiu": 3, "xori": 4, "ori": 6, "andi": 7} + B_FUNCT = {"beq": 0, "bne": 1, "blt": 4, "bge": 5, "bltu": 6, "bgeu": 7} + L_FUNCT = {"lb": 0, "lh": 1, "lw": 2, "lbu": 4, "lhu": 5} + S_FUNCT = {"sb": 0, "sh": 1, "sw": 2} @@ -573,6 +566,7 @@ def rv_state(mem): # ============================================================================= PAIRABLE = (0x33, 0x13, 0x37, 0x17) + DECODE_CLASSES = ("op", "op_imm", "load", "store", "branch", "lui", "auipc", "jal", "jalr", "system", "fp", "flw", "fsw", "fma") @@ -751,8 +745,7 @@ class Rv32ThresholdCPU: return q, r def priority_encode(self, v): - """MSB-first position (0..31) of the highest set bit, via the - combinational.priorityencoder32 gates. v must be nonzero.""" + """Position, most significant first, of the highest set bit of a nonzero v.""" vb = int_to_bits(v, 32) # MSB-first, bit index 0 = MSB any_higher = [0] for pos in range(1, 32): @@ -771,8 +764,7 @@ class Rv32ThresholdCPU: return out def fcvt_s_w(self, x): - """Signed int32 -> float32, round-to-nearest-even. Sign, leading-one - detect (priority encoder), left-normalize (barrel shifter), round.""" + """Signed int32 -> float32, round-to-nearest-even.""" if x == 0: return 0 s = (x >> 31) & 1 @@ -791,9 +783,7 @@ class Rv32ThresholdCPU: return (s << 31) | (exp << 23) | frac def fcvt_w_s(self, w, rm=1): - """float32 -> signed int32 under rounding mode `rm`, saturating. The - shifts run through the barrel shifter; the round decision is control - logic, as in fcvt_s_w. NaN -> INT_MAX; overflow saturates.""" + """float32 -> signed int32 under rounding mode `rm`, saturating.""" s, e, f = (w >> 31) & 1, (w >> 23) & 0xFF, w & 0x7FFFFF if e == 0xFF: return 0x7FFFFFFF if f else (0x80000000 if s else 0x7FFFFFFF) @@ -1146,8 +1136,7 @@ class Rv32ThresholdCPU: return v def _decode(self, w): - """Instruction decode through the gate network: opcode-class one-hots - and the sign-extended immediate for the active format.""" + """Instruction decode through the gate network.""" ext = {f"$rv32_instr[{k}]": (w >> k) & 1 for k in range(32)} out = self.decode.run(ext) cls = {n: int(out[f"rv32.decode.is_{n}"][0, 0].item()) for n in DECODE_CLASSES} @@ -1185,6 +1174,7 @@ class Rv32ThresholdCPU: # ============================================================================ SUBLEQ_MODEL = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "variants", "neural_subleq8.safetensors") + HALT_PC = 0xFF @@ -1197,9 +1187,7 @@ def load_tensors(path): class SubleqThresholdCPU: - """SUBLEQ-8 runtime: memory traffic through the packed memory circuits, - the subtract/branch datapath through the subleq gates via the shipped - netlist. Instruction sequencing (three consecutive fetches) is wiring.""" + """SUBLEQ-8 runtime: memory through the packed memory circuits, the datapath through its gates.""" def __init__(self, tensors, registry): self.T = tensors @@ -1418,7 +1406,9 @@ def subleq_test() -> int: # neural_rv32 test suites # ============================================================================ F1_0 = struct.unpack(" bool: - """Verify the rv32-specific sub-circuits (NEUR, hazard, MULHU accumulator) - are recoverable and correct from the shipped .inputs metadata alone.""" + """The RV32 sub-circuits rebuilt from the shipped .inputs metadata are correct.""" import random T = {} with safe_open(RV32_MODEL, framework="pt") as f: @@ -1761,9 +1750,7 @@ def rv32_netlist_check() -> bool: def fcvt_rm_check(cpu) -> bool: - """FCVT.W.S under every rounding mode against an exact rational reference, - for directed edge encodings (subnormals, ties, saturation) and randoms; - the emulator reference and the gate-routed method must both match.""" + """FCVT.W.S under every rounding mode against an exact rational reference.""" from fractions import Fraction import random @@ -1873,6 +1860,7 @@ int main(void){ return gcd(48,36) + fib(10) + s + (arr[0]==1?1000:0); } """ + EXPECTED = 1292 # gcd(48,36)=12, fib(10)=55, weighted sorted sum=225... verified natively @@ -1934,6 +1922,1037 @@ def main() -> int: rc |= rv32c_test() return rc +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # repo root; this module lives in src/ + +MODEL_PATH = os.path.join(REPO, "variants", "neural_matrix8.safetensors") + +GATE_MODEL_PATH = os.path.join(REPO, "variants", "neural_computer8_registers.safetensors") + +ADDR_BITS = 4 + +MEM_BYTES = 16 + +STATE_BITS = 4 + 32 + 4 + 4 + 1 + 128 # pc, regs, flags, sp, halt, mem + + +# ============================================================================= +# Circuit IR: named threshold gates over binary signals, constants fold away +# ============================================================================= + +class Net: + """A DAG of threshold gates.""" + + def __init__(self): + self.gates: Dict[str, Tuple[List[Tuple[str, int]], int]] = {} + + def g(self, name: str, ins: List[Tuple[str, int]], bias: int) -> str: + folded: List[Tuple[str, int]] = [] + b = int(bias) + for s, w in ins: + assert w in (-1, 1), f"non-ternary weight {w} at {name}" + if s == "#1": + b += w + elif s == "#0": + pass + else: + folded.append((s, int(w))) + if name in self.gates: + raise ValueError(f"duplicate gate {name}") + self.gates[name] = (folded, b) + return name + + # --- verified cell constructions -------------------------------------- + def NOT(self, name, x): + if x == "#1": + return "#0" + if x == "#0": + return "#1" + return self.g(name, [(x, -1)], 0) + + def BUF(self, name, x): + if x in ("#0", "#1"): + return x + return self.g(name, [(x, 1)], -1) + + def AND(self, name, ins: List[str]): + live = [] + for s in ins: + if s == "#0": + return "#0" + if s != "#1": + live.append(s) + if not live: + return "#1" + if len(live) == 1: + return live[0] + return self.g(name, [(s, 1) for s in live], -len(live)) + + def OR(self, name, ins: List[str]): + live = [] + for s in ins: + if s == "#1": + return "#1" + if s != "#0": + live.append(s) + if not live: + return "#0" + if len(live) == 1: + return live[0] + return self.g(name, [(s, 1) for s in live], -1) + + def NOR(self, name, ins: List[str]): + live = [s for s in ins if s != "#0"] + if any(s == "#1" for s in live): + return "#0" + if not live: + return "#1" + return self.g(name, [(s, -1) for s in live], 0) + + def XOR(self, prefix, a, b): + """Two-layer OR/NAND XOR, the family's verified construction.""" + if a == "#0": + return b if b in ("#0", "#1") else self.BUF(f"{prefix}.buf", b) + if b == "#0": + return a if a in ("#0", "#1") else self.BUF(f"{prefix}.buf", a) + if a == "#1": + return self.NOT(f"{prefix}.not", b) + if b == "#1": + return self.NOT(f"{prefix}.not", a) + h_or = self.g(f"{prefix}.l1or", [(a, 1), (b, 1)], -1) + h_nand = self.g(f"{prefix}.l1nand", [(a, -1), (b, -1)], 1) + return self.g(prefix, [(h_or, 1), (h_nand, 1)], -2) + + def FA(self, prefix, a, b, cin): + """Verified full-adder cell (ha1 XOR/AND, ha2 XOR/AND, carry OR).""" + s1 = self.XOR(f"{prefix}.ha1s", a, b) + c1 = self.AND(f"{prefix}.ha1c", [a, b]) + s2 = self.XOR(f"{prefix}.ha2s", s1, cin) + c2 = self.AND(f"{prefix}.ha2c", [s1, cin]) + co = self.OR(f"{prefix}.cor", [c1, c2]) + return s2, co + + def MUX(self, prefix, sel, x1, x0): + """2:1 mux cell: sel ? x1 : x0 (not_sel / and / and / or).""" + if sel == "#1": + return x1 + if sel == "#0": + return x0 + if x1 == x0: + return x0 + ns = self.NOT(f"{prefix}.ns", sel) + a1 = self.AND(f"{prefix}.a1", [x1, sel]) + a0 = self.AND(f"{prefix}.a0", [x0, ns]) + return self.OR(f"{prefix}.or", [a1, a0]) + + def DECODE(self, name, bits: List[str], value: int): + """One-gate address-decode row: fires when the bits equal value.""" + n = len(bits) + ins = [] + pop = 0 + for i in range(n): + bit = (value >> (n - 1 - i)) & 1 + ins.append((bits[i], 1 if bit else -1)) + pop += bit + return self.g(name, ins, -pop) + + +# ============================================================================= +# State layout +# ============================================================================= + +def state_names() -> List[str]: + names = [f"pc{i}" for i in range(4)] + for r in range(4): + names += [f"r{r}b{i}" for i in range(8)] + names += ["fZ", "fN", "fC", "fV"] + names += [f"sp{i}" for i in range(4)] + names += ["halt"] + for j in range(MEM_BYTES): + names += [f"m{j}b{i}" for i in range(8)] + return names + + +def state_to_vec(s: dict) -> torch.Tensor: + v = torch.zeros(STATE_BITS) + i = 0 + for k in range(4): + v[i] = (s["pc"] >> (3 - k)) & 1 + i += 1 + for r in range(4): + for k in range(8): + v[i] = (s["regs"][r] >> (7 - k)) & 1 + i += 1 + for k in range(4): + v[i] = s["flags"][k] + i += 1 + for k in range(4): + v[i] = (s["sp"] >> (3 - k)) & 1 + i += 1 + v[i] = 1.0 if s["halted"] else 0.0 + i += 1 + for j in range(MEM_BYTES): + for k in range(8): + v[i] = (s["mem"][j] >> (7 - k)) & 1 + i += 1 + return v + + +def vec_to_state(v: torch.Tensor) -> dict: + bits = [int(round(float(x))) for x in v.tolist()] + i = 0 + pc = 0 + for k in range(4): + pc = (pc << 1) | bits[i] + i += 1 + regs = [] + for r in range(4): + x = 0 + for k in range(8): + x = (x << 1) | bits[i] + i += 1 + regs.append(x) + flags = bits[i:i + 4] + i += 4 + sp = 0 + for k in range(4): + sp = (sp << 1) | bits[i] + i += 1 + halted = bool(bits[i]) + i += 1 + mem = [] + for j in range(MEM_BYTES): + x = 0 + for k in range(8): + x = (x << 1) | bits[i] + i += 1 + mem.append(x) + return {"pc": pc, "regs": regs, "flags": flags, "sp": sp, + "halted": halted, "mem": mem} + + +# ============================================================================= +# The step circuit: one full instruction of the registers-profile CPU +# ============================================================================= + +def build_step_net() -> Tuple[Net, List[str], List[str]]: + net = Net() + pc = [f"pc{i}" for i in range(4)] + R = [[f"r{r}b{i}" for i in range(8)] for r in range(4)] + fl = ["fZ", "fN", "fC", "fV"] + sp = [f"sp{i}" for i in range(4)] + halt = "halt" + mem = [[f"m{j}b{i}" for i in range(8)] for j in range(MEM_BYTES)] + + # ---- fetch: bytes at PC .. PC+3 (decode lines reindexed, no adders) ---- + P = [net.DECODE(f"pdec{j}", pc, j) for j in range(MEM_BYTES)] + byte = [] + for o in range(4): + bo = [] + for bit in range(8): + terms = [net.AND(f"f{o}a{j}b{bit}", [mem[j][bit], P[(j - o) % MEM_BYTES]]) + for j in range(MEM_BYTES)] + bo.append(net.OR(f"f{o}b{bit}", terms)) + byte.append(bo) + + # ---- field decode; opcode lines gated by NOT halt (total transition) --- + nhalt = net.NOT("nhalt", halt) + O = [net.DECODE(f"op{v:X}", byte[0][0:4], v) for v in range(16)] + OG = [net.AND(f"og{v:X}", [O[v], nhalt]) for v in range(16)] + rdl = [net.DECODE(f"rdl{r}", byte[0][4:6], r) for r in range(4)] + rsl = [net.DECODE(f"rsl{r}", byte[0][6:8], r) for r in range(4)] + CL = [net.DECODE(f"cond{c}", byte[1][5:8], c) for c in range(8)] + + # ---- register read ports ---------------------------------------------- + a = [net.OR(f"a{bit}", [net.AND(f"a{bit}r{r}", [R[r][bit], rdl[r]]) + for r in range(4)]) for bit in range(8)] + b = [net.OR(f"b{bit}", [net.AND(f"b{bit}r{r}", [R[r][bit], rsl[r]]) + for r in range(4)]) for bit in range(8)] + a_l = lambda k: a[7 - k] # LSB-first views for the adder chains + b_l = lambda k: b[7 - k] + + # ---- ALU: ADD ---------------------------------------------------------- + c = "#0" + add_l = [] + for k in range(8): + s_, c = net.FA(f"add.fa{k}", a_l(k), b_l(k), c) + add_l.append(s_) + add = [add_l[7 - i] for i in range(8)] + add_c = c + + # ---- SUB (shared by CMP): a + ~b + 1 ----------------------------------- + nb_l = [net.NOT(f"sub.nb{k}", b_l(k)) for k in range(8)] + c = "#1" + sub_l = [] + for k in range(8): + s_, c = net.FA(f"sub.fa{k}", a_l(k), nb_l[k], c) + sub_l.append(s_) + sub = [sub_l[7 - i] for i in range(8)] + sub_c = c # carry set = no borrow + + # ---- bitwise / shifts --------------------------------------------------- + andr = [net.AND(f"land{bit}", [a[bit], b[bit]]) for bit in range(8)] + orr = [net.OR(f"lor{bit}", [a[bit], b[bit]]) for bit in range(8)] + xorr = [net.XOR(f"lxor{bit}", a[bit], b[bit]) for bit in range(8)] + shl = [a[bit + 1] if bit < 7 else "#0" for bit in range(8)] + shr = [a[bit - 1] if bit > 0 else "#0" for bit in range(8)] + + # ---- MUL: partial products + 7 chained shift-add adders ---------------- + pp = [[net.AND(f"pp.a{i}b{j}", [a[i], b[j]]) for j in range(8)] for i in range(8)] + acc_l: List[str] = ["#0"] * 8 + acc_l[7] = pp[7][0] # row j=0 shifted by 7 + for j in range(1, 8): + shift = 7 - j + c = "#0" + nxt = [] + for k in range(8): + addend = pp[14 - k - j][j] if shift <= k <= min(7, 14 - j) else "#0" + s_, c = net.FA(f"mul.s{j}.fa{k}", acc_l[k], addend, c) + nxt.append(s_) + acc_l = nxt + mul = [acc_l[7 - i] for i in range(8)] + + # ---- DIV: 8 restoring stages (bit-cascade GE + subtract + mux) --------- + def cascade_lt(prefix: str, x_m: List[str], y_m: List[str]) -> str: + """Unsigned x < y via the verified bit-cascade (bit 0 = MSB).""" + bit_lt, bit_eq = [], [] + for i in range(8): + bit_lt.append(net.g(f"{prefix}.b{i}lt", [(x_m[i], -1), (y_m[i], 1)], -1) + if x_m[i] != "#0" else + (y_m[i] if y_m[i] != "#0" else "#0")) + if x_m[i] == "#0": + bit_eq.append(net.NOT(f"{prefix}.b{i}eq", y_m[i])) + else: + e_and = net.AND(f"{prefix}.b{i}ea", [x_m[i], y_m[i]]) + e_nor = net.NOR(f"{prefix}.b{i}en", [x_m[i], y_m[i]]) + bit_eq.append(net.OR(f"{prefix}.b{i}eq", [e_and, e_nor])) + casc = [bit_lt[0]] + for i in range(1, 8): + pref = net.AND(f"{prefix}.ep{i}", bit_eq[:i]) + casc.append(net.AND(f"{prefix}.cl{i}", [pref, bit_lt[i]])) + return net.OR(f"{prefix}.lt", casc) + + rem_l: List[str] = ["#0"] * 8 + q_m: List[str] = [] + for st in range(8): + sh_l = [a[st]] + rem_l[0:7] # (rem << 1) | a_bit; top bit provably 0 + sh_m = [sh_l[7 - i] for i in range(8)] + lt = cascade_lt(f"div.s{st}.cmp", sh_m, b) + ge = net.NOT(f"div.s{st}.ge", lt) + c = "#1" + dsub_l = [] + for k in range(8): + s_, c = net.FA(f"div.s{st}.sub.fa{k}", sh_l[k], nb_l[k], c) + dsub_l.append(s_) + rem_l = [net.MUX(f"div.s{st}.mx{k}", ge, dsub_l[k], sh_l[k]) for k in range(8)] + q_m.append(ge) + div = q_m # quotient, MSB-first + + # ---- LOAD read port (address = low nibble of byte3) -------------------- + addr4 = byte[3][4:8] + AD = [net.DECODE(f"adec{j}", addr4, j) for j in range(MEM_BYTES)] + ld = [net.OR(f"ld{bit}", [net.AND(f"ld{bit}a{j}", [mem[j][bit], AD[j]]) + for j in range(MEM_BYTES)]) for bit in range(8)] + + # ---- result mux over opcode lines --------------------------------------- + srcs = [(0, add), (1, sub), (2, andr), (3, orr), (4, xorr), + (5, shl), (6, shr), (7, mul), (8, div), (0xA, ld)] + res = [net.OR(f"res{bit}", [net.AND(f"res{bit}o{v:X}", [src[bit], OG[v]]) + for v, src in srcs]) for bit in range(8)] + + # ---- flags --------------------------------------------------------------- + z_add = net.NOR("zadd", add) + z_sub = net.NOR("zsub", sub) + z_mul = net.NOR("zmul", mul) + v_add = net.AND("vadd", [net.XOR("vadd.x1", a[0], add[0]), + net.XOR("vadd.x2", b[0], add[0])]) + v_sub = net.AND("vsub", [net.XOR("vsub.x1", a[0], b[0]), + net.XOR("vsub.x2", a[0], sub[0])]) + f_src = { # per-source (Z, N, C, V); CMP shares the SUB datapath + 0: (z_add, add[0], add_c, v_add), + 1: (z_sub, sub[0], sub_c, v_sub), + 7: (z_mul, mul[0], "#0", "#0"), + 9: (z_sub, sub[0], sub_c, v_sub), + } + fw = net.OR("fw", [OG[0], OG[1], OG[7], OG[9]]) + fl_next = [] + for fi, fn in enumerate("ZNCV"): + new = net.OR(f"fnew{fn}", [net.AND(f"fnew{fn}o{v:X}", [f_src[v][fi], OG[v]]) + for v in (0, 1, 7, 9)]) + fl_next.append(net.MUX(f"fmux{fn}", fw, new, fl[fi])) + + # ---- register writeback -------------------------------------------------- + wen = net.OR("wen", [OG[v] for v in (0, 1, 2, 3, 4, 5, 6, 7, 8, 0xA)]) + R_next = [] + for r in range(4): + wsel = net.AND(f"wsel{r}", [wen, rdl[r]]) + nws = net.NOT(f"nws{r}", wsel) + row = [] + for bit in range(8): + t1 = net.AND(f"wb{r}b{bit}n", [res[bit], wsel]) + t0 = net.AND(f"wb{r}b{bit}o", [R[r][bit], nws]) + row.append(net.OR(f"wb{r}b{bit}", [t1, t0])) + R_next.append(row) + + # ---- program counter ------------------------------------------------------ + pc_l = [pc[3 - k] for k in range(4)] # LSB-first view + p2_l = [pc_l[0], net.NOT("pc2.n1", pc_l[1]), + net.XOR("pc2.x2", pc_l[2], pc_l[1]), + net.XOR("pc2.x3", pc_l[3], net.AND("pc2.c3", [pc_l[2], pc_l[1]]))] + p4_l = [pc_l[0], pc_l[1], net.NOT("pc4.n2", pc_l[2]), + net.XOR("pc4.x3", pc_l[3], pc_l[2])] + ext = net.OR("ext", [OG[v] for v in (0xA, 0xB, 0xC, 0xD, 0xE)]) + adv_l = [net.MUX(f"padv{k}", ext, p4_l[k], p2_l[k]) for k in range(4)] + pc_adv = [adv_l[3 - i] for i in range(4)] + + nfl = [net.NOT(f"nf{fn}", fl[i]) for i, fn in enumerate("ZNCV")] + cond_flag = [fl[0], nfl[0], fl[2], nfl[2], fl[1], nfl[1], fl[3], nfl[3]] + condsel = net.OR("condsel", [net.AND(f"ct{cnd}", [CL[cnd], cond_flag[cnd]]) + for cnd in range(8)]) + take = net.OR("take", [OG[0xC], OG[0xE], net.AND("jcct", [OG[0xD], condsel])]) + keeppc = net.OR("keeppc", [halt, OG[0xF]]) + adv = net.AND("adv", [net.NOT("ntake", take), net.NOT("nkeep", keeppc)]) + pc_next = [net.OR(f"pcn{i}", [net.AND(f"pcn{i}t", [addr4[i], take]), + net.AND(f"pcn{i}k", [pc[i], keeppc]), + net.AND(f"pcn{i}a", [pc_adv[i], adv])]) + for i in range(4)] + + # ---- stack pointer (CALL: SP-2 = SP + 14) --------------------------------- + sp_l = [sp[3 - k] for k in range(4)] + c = "#0" + s2_l = [] + for k, addend in enumerate(["#0", "#1", "#1", "#1"]): + s_, c = net.FA(f"spm2.fa{k}", sp_l[k], addend, c) + s2_l.append(s_) + spm2 = [s2_l[3 - i] for i in range(4)] + sp_next = [net.MUX(f"spn{i}", OG[0xE], spm2[i], sp[i]) for i in range(4)] + + # ---- halt (sticky) --------------------------------------------------------- + halt_next = net.OR("haltn", [halt, OG[0xF]]) + + # ---- memory next: STORE write, CALL pushes ret-hi (0) and ret-lo ---------- + SPD = [net.DECODE(f"spdec{j}", sp, j) for j in range(MEM_BYTES)] + mem_next = [] + for j in range(MEM_BYTES): + st_sel = net.AND(f"stsel{j}", [OG[0xB], AD[j]]) + ch_sel = net.AND(f"chsel{j}", [OG[0xE], SPD[(j + 1) % MEM_BYTES]]) + cl_sel = net.AND(f"clsel{j}", [OG[0xE], SPD[(j + 2) % MEM_BYTES]]) + keep = net.NOR(f"keep{j}", [st_sel, ch_sel, cl_sel]) + row = [] + for bit in range(8): + terms = [net.AND(f"mn{j}b{bit}k", [mem[j][bit], keep]), + net.AND(f"mn{j}b{bit}s", [b[bit], st_sel])] + if bit >= 4: # ret-lo low nibble carries pc_adv; high nibble is 0 + terms.append(net.AND(f"mn{j}b{bit}c", [pc_adv[bit - 4], cl_sel])) + row.append(net.OR(f"mn{j}b{bit}", terms)) + mem_next.append(row) + + outputs = (pc_next + + [bitsig for row in R_next for bitsig in row] + + fl_next + sp_next + [halt_next] + + [bitsig for row in mem_next for bitsig in row]) + return net, state_names(), outputs + + +# ============================================================================= +# Compiler: levelize ASAP, insert pass-throughs, emit dense ternary matrices +# ============================================================================= + +def compile_net(net: Net, inputs: List[str], outputs: List[str], + final_ordered: bool = True, + ) -> Tuple[List[Tuple[torch.Tensor, torch.Tensor]], dict]: + """Levelize a clocked netlist of maximal unit depth d into d ternary layers.""" + allgates = net.gates + level: Dict[str, int] = {s: 0 for s in inputs} + + consumers: Dict[str, List[str]] = {} + indeg: Dict[str, int] = {} + for gname, (ins, _) in allgates.items(): + deps = [s for s, _ in ins if s in allgates] + indeg[gname] = len(deps) + for s, _ in ins: + consumers.setdefault(s, []).append(gname) + order = [g for g, d in indeg.items() if d == 0] + i = 0 + while i < len(order): + for cns in consumers.get(order[i], []): + indeg[cns] -= 1 + if indeg[cns] == 0: + order.append(cns) + i += 1 + if len(order) != len(allgates): + raise ValueError("cycle in step circuit") + for gname in order: + ins, _ = allgates[gname] + level[gname] = 1 + max((level[s] for s, _ in ins), default=0) + + # units that contribute to no next-state value are removed, as in the lemma + live: set = set() + stack = [s for s in outputs if s in allgates] + while stack: + g = stack.pop() + if g in live: + continue + live.add(g) + for s, _ in allgates[g][0]: + if s in allgates and s not in live: + stack.append(s) + gates = {g: allgates[g] for g in live} + + kmax = max(level[gname] for gname in gates) + K = kmax if final_ordered else kmax + 1 + + last_use: Dict[str, int] = {} + for gname, (ins, _) in gates.items(): + for s, _ in ins: + last_use[s] = max(last_use.get(s, 0), level[gname]) + for s in outputs: + if s not in ("#0", "#1"): + last_use[s] = K + + by_level: Dict[int, List[str]] = {} + for gname in gates: + by_level.setdefault(level[gname], []).append(gname) + + vec = list(inputs) # V_0 + layers: List[Tuple[torch.Tensor, torch.Tensor]] = [] + for lv in range(1, K + 1): + if lv < K: + new_gates = sorted(by_level.get(lv, [])) + passes = [s for s in vec if last_use.get(s, 0) > lv] + rows = new_gates + passes + else: + rows = list(outputs) + cols = {s: idx for idx, s in enumerate(vec)} + W = torch.zeros(len(rows), len(vec), dtype=torch.int8) + B = torch.zeros(len(rows), dtype=torch.int8) + out_names = [] + for ri, s in enumerate(rows): + if lv == K and s == "#0": + B[ri] = -1 + out_names.append(s) + continue + if lv == K and s == "#1": + B[ri] = 0 + out_names.append(s) + continue + if s in gates and level[s] == lv: + ins, bias = gates[s] + for src, w in ins: + W[ri, cols[src]] += w + B[ri] = bias + else: + W[ri, cols[s]] = 1 + B[ri] = -1 + out_names.append(s) + layers.append((W, B)) + vec = out_names + + if vec != outputs: + raise AssertionError("final layer does not emit the state vector") + + info = { + "layers": K, + "gates": len(gates), + "netlist_gates": len(allgates), + "max_width": max(max(W.shape) for W, _ in layers), + "total_weights": sum(W.numel() for W, _ in layers), + "widths": [layers[0][0].shape[1]] + [W.shape[0] for W, _ in layers], + } + return layers, info + + +# ============================================================================= +# Recurrent runtime +# ============================================================================= + +class MatrixMachine: + """s' = H(Wk @ ... H(W1 @ s + b1) ... + bk), iterated until the halt bit.""" + + HALT_IDX = 4 + 32 + 4 + 4 # index of the halt bit in the state vector + + def __init__(self, layers, device="cpu"): + self.device = device + self.W = [W.to(device=device, dtype=torch.float32) for W, _ in layers] + self.B = [B.to(device=device, dtype=torch.float32) for _, B in layers] + + @classmethod + def from_file(cls, path=MODEL_PATH, device="cpu"): + layers = [] + with safe_open(path, framework="pt") as f: + n = 0 + while f"matrix.layer{n:03d}.weight" in f.keys(): + layers.append((f.get_tensor(f"matrix.layer{n:03d}.weight"), + f.get_tensor(f"matrix.layer{n:03d}.bias"))) + n += 1 + if not layers: + raise FileNotFoundError(f"no matrix layers in {path}") + return cls(layers, device=device) + + def perturbed(self, sigma_g: float, seed: int = 0) -> "MatrixMachine": + """Static conductance mismatch: a fixed Gaussian perturbation of each nonzero conductance.""" + gen = torch.Generator(device="cpu").manual_seed(seed) + m = MatrixMachine.__new__(MatrixMachine) + m.device = self.device + m.W = [] + m.B = self.B + for W in self.W: + d = torch.randn(W.shape, generator=gen).to(self.device) * sigma_g + m.W.append(W + d * (W != 0)) + return m + + def step(self, v: torch.Tensor, analog: bool = False, + noise_sigma: float = 0.0, gen: Optional[torch.Generator] = None, + ) -> torch.Tensor: + thresh = -0.5 if analog else 0.0 + for W, b in zip(self.W, self.B): + pre = v @ W.T + b + if noise_sigma > 0.0: + pre = pre + torch.randn(pre.shape, generator=gen, + device=pre.device) * noise_sigma + v = (pre >= thresh).float() + return v + + def run(self, state: dict, max_cycles: int = 64, **kw) -> Tuple[dict, int]: + v = state_to_vec(state).unsqueeze(0).to(self.device) + n = 0 + while n < max_cycles and v[0, self.HALT_IDX] < 0.5: + v = self.step(v, **kw) + n += 1 + return vec_to_state(v[0].cpu()), n + + def min_margin(self, v: torch.Tensor) -> float: + """Distance of every pre-activation from the analog threshold -0.5.""" + m = float("inf") + for W, b in zip(self.W, self.B): + pre = v @ W.T + b + m = min(m, float((pre + 0.5).abs().min())) + v = (pre >= 0).float() + return m + + +# ============================================================================= +# Pure-integer reference (mirrors GenericThresholdCPU semantics, 4-bit addr) +# ============================================================================= + +_JCC_FLAG = [0, 0, 2, 2, 1, 1, 3, 3] + + +def matrix8_ref_step(s: dict) -> dict: + if s["halted"]: + return {k: (list(v) if isinstance(v, list) else v) for k, v in s.items()} + s = {"pc": s["pc"], "regs": list(s["regs"]), "flags": list(s["flags"]), + "sp": s["sp"], "halted": s["halted"], "mem": list(s["mem"])} + M = MEM_BYTES - 1 + pc = s["pc"] + ir = (s["mem"][pc] << 8) | s["mem"][(pc + 1) & M] + op, rd, rs = (ir >> 12) & 0xF, (ir >> 10) & 3, (ir >> 8) & 3 + imm = ir & 0xFF + next_pc = (pc + 2) & M + addr = None + if op in (0xA, 0xB, 0xC, 0xD, 0xE): + al = s["mem"][(next_pc + 1) & M] + addr = al & M + next_pc = (next_pc + 2) & M + a, b = s["regs"][rd], s["regs"][rs] + result, carry, ovf, wr = a, 0, 0, True + if op == 0x0: + result = (a + b) & 0xFF + carry = 1 if a + b > 0xFF else 0 + ovf = 1 if ((a ^ result) & (b ^ result)) & 0x80 else 0 + elif op == 0x1: + result = (a - b) & 0xFF + carry = 1 if a >= b else 0 + ovf = 1 if ((a ^ b) & (a ^ result)) & 0x80 else 0 + elif op == 0x2: + result = a & b + elif op == 0x3: + result = a | b + elif op == 0x4: + result = a ^ b + elif op == 0x5: + result = (a << 1) & 0xFF + elif op == 0x6: + result = a >> 1 + elif op == 0x7: + result = (a * b) & 0xFF + elif op == 0x8: + result = 0xFF if b == 0 else a // b + elif op == 0x9: + r2 = (a - b) & 0xFF + carry = 1 if a >= b else 0 + ovf = 1 if ((a ^ b) & (a ^ r2)) & 0x80 else 0 + s["flags"] = [1 if r2 == 0 else 0, 1 if r2 & 0x80 else 0, carry, ovf] + wr = False + elif op == 0xA: + result = s["mem"][addr] + elif op == 0xB: + s["mem"][addr] = b & 0xFF + wr = False + elif op == 0xC: + s["pc"] = addr + return s + elif op == 0xD: + cnd = imm & 7 + flag = s["flags"][_JCC_FLAG[cnd]] + sel = flag if cnd % 2 == 0 else 1 - flag + s["pc"] = addr if sel else next_pc + return s + elif op == 0xE: + ret = next_pc + sp2 = (s["sp"] - 1) & M + s["mem"][sp2] = (ret >> 8) & 0xFF + sp2 = (sp2 - 1) & M + s["mem"][sp2] = ret & 0xFF + s["sp"] = sp2 + s["pc"] = addr + return s + elif op == 0xF: + s["halted"] = True + return s + if wr and op != 0x9: + s["regs"][rd] = result & 0xFF + if op in (0x0, 0x1, 0x7): + s["flags"] = [1 if result == 0 else 0, 1 if result & 0x80 else 0, + carry, ovf] + s["pc"] = next_pc + return s + + +# ============================================================================= +# Build + save +# ============================================================================= + +def build(save: bool = True): + print("Assembling the step circuit from the verified cell library...") + t0 = time.perf_counter() + net, inputs, outputs = build_step_net() + print(f" {len(net.gates)} threshold gates") + layers, info = compile_net(net, inputs, outputs) + dt = time.perf_counter() - t0 + print(f" compiled to {info['layers']} ternary matrices in {dt:.1f}s; " + f"max width {info['max_width']}, " + f"{info['total_weights']:,} dense weight entries") + for W, B in layers: + vals = set(torch.unique(W).tolist()) + assert vals <= {-1, 0, 1}, f"non-ternary matrix: {vals}" + assert int(B.min()) >= -128 and int(B.max()) <= 127 + + if save: + tensors = {} + for i, (W, B) in enumerate(layers): + tensors[f"matrix.layer{i:03d}.weight"] = W + tensors[f"matrix.layer{i:03d}.bias"] = B + tensors["manifest.data_bits"] = torch.tensor([8.0]) + tensors["manifest.addr_bits"] = torch.tensor([float(ADDR_BITS)]) + tensors["manifest.memory_bytes"] = torch.tensor([float(MEM_BYTES)]) + tensors["manifest.registers"] = torch.tensor([4.0]) + tensors["manifest.layers"] = torch.tensor([float(info["layers"])]) + tensors["manifest.state_bits"] = torch.tensor([float(STATE_BITS)]) + tensors["manifest.version"] = torch.tensor([4.0]) + meta = { + "machine": "matrix8", + "weight_quantization": "ternary", + "state_layout": json.dumps(state_names()), + "analog": json.dumps({ + "comparator_threshold": -0.5, + "guaranteed_margin": 0.5, + "note": "pre-activations are integers; any total analog error " + "below 0.5 per pre-activation is provably bit-exact", + }), + } + save_file(tensors, MODEL_PATH, metadata=meta) + sz = os.path.getsize(MODEL_PATH) / (1024 * 1024) + print(f" saved {MODEL_PATH} ({sz:.1f} MB)") + return layers, info + + +# ============================================================================= +# Verification: matrix form == gate graph == integer reference, bit for bit +# ============================================================================= + +def _mk_state(mem=None, regs=(0, 0, 0, 0), flags=(0, 0, 0, 0), pc=0, + sp=MEM_BYTES - 1, halted=False): + m = [0] * MEM_BYTES + if mem: + for i, x in enumerate(mem): + m[i] = x & 0xFF + return {"pc": pc, "regs": list(regs), "flags": list(flags), "sp": sp, + "halted": halted, "mem": m} + + +def _instr(op, rd=0, rs=0, imm=0): + w = ((op & 0xF) << 12) | ((rd & 3) << 10) | ((rs & 3) << 8) | (imm & 0xFF) + return [(w >> 8) & 0xFF, w & 0xFF] + + +def verify(mm: MatrixMachine, device: str) -> bool: + ok_all = True + + # ---- 1. exhaustive ALU sweeps: all 65,536 operand pairs per opcode ------ + print("\n[1/4] Exhaustive ALU sweeps (65,536 operand pairs x 10 opcodes, " + "batched through the matrices, vs integer reference)") + aa = torch.arange(65536) % 256 + bb = torch.arange(65536) // 256 + for op, name in [(0, "ADD"), (1, "SUB"), (2, "AND"), (3, "OR"), (4, "XOR"), + (5, "SHL"), (6, "SHR"), (7, "MUL"), (8, "DIV"), (9, "CMP")]: + for f_init in (0, 0xF): + V = torch.zeros(65536, STATE_BITS) + prog = _instr(op, rd=0, rs=1) + _instr(0xF) + base = _mk_state(mem=prog, flags=[(f_init >> (3 - i)) & 1 for i in range(4)]) + V[:] = state_to_vec(base) + for k in range(8): + V[:, 4 + k] = ((aa >> (7 - k)) & 1).float() # R0 + V[:, 12 + k] = ((bb >> (7 - k)) & 1).float() # R1 + V = mm.step(V.to(device)).cpu() + bad = 0 + probe = list(range(0, 65536, 4099)) + [0, 65535, 255, 256, 32768] + for idx in probe: + st = _mk_state(mem=prog, regs=(int(aa[idx]), int(bb[idx]), 0, 0), + flags=base["flags"]) + if vec_to_state(V[idx]) != matrix8_ref_step(st): + bad += 1 + # full vectorized compare of the architectural result field + r0 = sum(V[:, 4 + k].long() << (7 - k) for k in range(8)) + fZ, fN, fC, fV = (V[:, 36].long(), V[:, 37].long(), + V[:, 38].long(), V[:, 39].long()) + if op == 0: + exp = (aa + bb) & 0xFF + elif op in (1, 9): + exp = (aa - bb) & 0xFF + elif op == 2: + exp = aa & bb + elif op == 3: + exp = aa | bb + elif op == 4: + exp = aa ^ bb + elif op == 5: + exp = (aa << 1) & 0xFF + elif op == 6: + exp = aa >> 1 + elif op == 7: + exp = (aa * bb) & 0xFF + else: + exp = torch.where(bb == 0, torch.full_like(aa, 0xFF), + aa // torch.clamp(bb, min=1)) + reg_exp = aa if op == 9 else exp + n_bad = int((r0 != reg_exp).sum()) + if op in (0, 1, 7, 9): + zx = (exp == 0).long() + nx = ((exp & 0x80) > 0).long() + if op == 0: + cx = ((aa + bb) > 0xFF).long() + vx = ((((aa ^ exp) & (bb ^ exp)) & 0x80) > 0).long() + elif op in (1, 9): + cx = (aa >= bb).long() + vx = ((((aa ^ bb) & (aa ^ exp)) & 0x80) > 0).long() + else: + cx = torch.zeros_like(aa) + vx = torch.zeros_like(aa) + n_bad += int((fZ != zx).sum() + (fN != nx).sum() + + (fC != cx).sum() + (fV != vx).sum()) + else: + want = torch.tensor([(f_init >> (3 - i)) & 1 for i in range(4)]) + n_bad += int((torch.stack([fZ, fN, fC, fV], 1) + != want.unsqueeze(0)).sum()) + status = "OK " if (n_bad == 0 and bad == 0) else "FAIL" + if n_bad or bad: + ok_all = False + if f_init == 0: + print(f" {status} {name:4} 65,536/65,536 operand pairs exact" + + ("" if n_bad == 0 else f" ({n_bad} mismatches)")) + + # ---- 2. exhaustive control-flow sweeps ---------------------------------- + print("\n[2/4] Exhaustive control-flow sweeps") + cases = [] + for cnd in range(8): # all Jcc x all flag nibbles + for f in range(16): + prog = _instr(0xD, imm=cnd) + [0x00, 0x08] + _instr(0xF) + [0] * 2 + _instr(0xF) + cases.append(_mk_state(mem=prog, flags=[(f >> (3 - i)) & 1 for i in range(4)])) + for t in range(16): # all JMP targets + prog = _instr(0xC) + [0x00, t] + cases.append(_mk_state(mem=prog)) + for spv in range(16): # CALL: every SP value + prog = _instr(0xE) + [0x00, 0x06] + [0, 0] + _instr(0xF) + cases.append(_mk_state(mem=prog, sp=spv)) + for ad in range(16): # LOAD/STORE: every address + prog = _instr(0xA, rd=2) + [0x00, ad] + st = _mk_state(mem=prog) + st["mem"][ad] |= 0xA5 if ad >= 4 else st["mem"][ad] + cases.append(st) + prog = _instr(0xB, rs=1) + [0x00, ad] + cases.append(_mk_state(mem=prog, regs=(0, 0x5A, 0, 0))) + V = torch.stack([state_to_vec(s) for s in cases]).to(device) + V = mm.step(V).cpu() + bad = sum(1 for i, s in enumerate(cases) if vec_to_state(V[i]) != matrix8_ref_step(s)) + print(f" {'OK ' if bad == 0 else 'FAIL'} {len(cases)} directed cases " + f"(8 Jcc x 16 flag sets, 16 JMP targets, 16 CALL SPs, 32 LOAD/STORE) exact") + ok_all &= bad == 0 + + # ---- 3. random-state fuzz + halt fixed point ----------------------------- + print("\n[3/4] Random-state fuzz (single step vs integer reference)") + gen = torch.Generator().manual_seed(0xC0FFEE) + Vr = (torch.rand(2000, STATE_BITS, generator=gen) < 0.5).float() + Vr[:, MatrixMachine.HALT_IDX] = 0.0 + out = mm.step(Vr.to(device)).cpu() + bad = sum(1 for i in range(Vr.shape[0]) + if vec_to_state(out[i]) != matrix8_ref_step(vec_to_state(Vr[i]))) + print(f" {'OK ' if bad == 0 else 'FAIL'} 2,000 uniformly random full states exact") + ok_all &= bad == 0 + Vh = (torch.rand(500, STATE_BITS, generator=gen) < 0.5).float() + Vh[:, MatrixMachine.HALT_IDX] = 1.0 + outh = mm.step(Vh.to(device)).cpu() + fixed = bool((outh == Vh.to(outh.device)).all()) + print(f" {'OK ' if fixed else 'FAIL'} 500 halted states are exact fixed points") + ok_all &= fixed + + # ---- 4. lockstep vs the gate-graph processor (shipped weights) ---------- + print("\n[4/4] Instruction lockstep vs GenericThresholdCPU on " + "neural_computer8_registers (the gate graph)") + from eval import GenericThresholdCPU + T = {} + with safe_open(GATE_MODEL_PATH, framework="pt") as f: + for nm in f.keys(): + T[nm] = f.get_tensor(nm).float() + gcpu = GenericThresholdCPU(T) + progs = [ + ("alu_chain", _mk_state(mem=_instr(0, 0, 1) + _instr(7, 2, 0) + _instr(9, 2, 1) + + _instr(4, 1, 2) + _instr(0xF), + regs=(9, 5, 3, 0))), + ("div_by_zero", _mk_state(mem=_instr(8, 0, 1) + _instr(0xF), regs=(77, 0, 0, 0))), + ("load_store", _mk_state(mem=_instr(0xA, rd=3) + [0x00, 0x0E] + + _instr(0xB, rs=3) + [0x00, 0x0F] + _instr(0xF) + + [0] * 4 + [0xB7, 0x00], + )), + ("call", _mk_state(mem=_instr(0xE) + [0x00, 0x06] + _instr(0xF) + + _instr(0xF), sp=0xF)), + ("jcc_taken", _mk_state(mem=_instr(0xD, imm=1) + [0x00, 0x06] + _instr(0xF) + + _instr(0xF), flags=(0, 0, 0, 0))), + ("shift_mul", _mk_state(mem=_instr(5, 0) + _instr(6, 1) + _instr(7, 0, 1) + + _instr(0xF), regs=(0x81, 0x81, 0, 0))), + ] + for name, st0 in progs: + sm = dict(st0) + sg = {"pc": st0["pc"], "regs": list(st0["regs"]), "flags": list(st0["flags"]), + "mem": list(st0["mem"]), "halted": False, "sp": st0["sp"]} + good = True + for _ in range(12): + if sm["halted"]: + break + sm, _n = mm.run(sm, max_cycles=1) + sg = gcpu.step(sg) + match = (sm["pc"] == sg["pc"] and sm["regs"] == sg["regs"] + and sm["flags"] == sg["flags"] and sm["mem"] == sg["mem"] + and sm["halted"] == sg["halted"] + and sm["sp"] == sg.get("sp", MEM_BYTES - 1)) + if not match: + good = False + break + good = good and sm["halted"] + print(f" {'OK ' if good else 'FAIL'} {name}: full-state lockstep, every instruction") + ok_all &= good + + print("\nMATRIX == GATE GRAPH == REFERENCE:", "PASS" if ok_all else "FAIL") + return ok_all + + +# ============================================================================= +# Analog realization +# ============================================================================= + +def analog(mm: MatrixMachine, device: str) -> bool: + ok = True + print("\nAnalog crossbar simulation " + "(comparator at -0.5; ternary conductances are exact by construction)") + + gen = torch.Generator().manual_seed(7) + Vr = (torch.rand(512, STATE_BITS, generator=gen) < 0.5).float().to(device) + margin = mm.min_margin(Vr) + print(f" measured minimum |pre-activation - (-0.5)| over 512 random states, " + f"all layers: {margin:.3f} (guarantee: 0.5)") + ok &= abs(margin - 0.5) < 1e-6 + + prog = (_instr(0, 0, 1) + _instr(7, 2, 0) + _instr(9, 2, 1) + _instr(0xF)) + st0 = _mk_state(mem=prog, regs=(9, 5, 3, 0)) + ref_final, _ = mm.run(dict(st0), max_cycles=8) + + import math + rows_per_step = sum(int(W.shape[0]) for W in mm.W) + n_steps = 4 + print(f" Gaussian read noise per MVM ({rows_per_step:,} comparator " + f"decisions per instruction; flip iff |noise| >= 0.5):") + for sigma in (0.05, 0.08, 0.10, 0.15, 0.25, 0.45): + p_flip = math.erfc(0.5 / (sigma * math.sqrt(2.0))) + exp_flips = p_flip * rows_per_step * n_steps + exact = 0 + for t in range(20): + g = torch.Generator(device=device).manual_seed(1000 + t) + v = state_to_vec(st0).unsqueeze(0).to(device) + for _ in range(8): + if v[0, MatrixMachine.HALT_IDX] >= 0.5: + break + v = mm.step(v, analog=True, noise_sigma=sigma, gen=g) + exact += int(vec_to_state(v[0].cpu()) == ref_final) + print(f" sigma={sigma:.2f}: {exact}/20 runs bit-exact " + f"(theory: {exp_flips:.2e} expected flips per run)") + if exp_flips < 1e-3 and exact != 20: + ok = False + + print(" Static ternary-conductance mismatch (device variation, " + "10 fabricated instances each):") + for sg in (0.02, 0.05, 0.10, 0.20): + exact = 0 + for t in range(10): + mmp = mm.perturbed(sg, seed=200 + t) + v = state_to_vec(st0).unsqueeze(0).to(device) + for _ in range(8): + if v[0, MatrixMachine.HALT_IDX] >= 0.5: + break + v = mmp.step(v, analog=True) + exact += int(vec_to_state(v[0].cpu()) == ref_final) + print(f" sigma_G={sg:.2f}: {exact}/10 instances bit-exact") + if sg <= 0.02 and exact != 10: + ok = False + + print("ANALOG REALIZATION:", "PASS (bit-exact within the stated margins)" + if ok else "FAIL") + return ok + + +# ============================================================================= +# CLI +# ============================================================================= + +def main_matrix8() -> int: + ap = argparse.ArgumentParser(description="neural_matrix8 builder/verifier") + ap.add_argument("cmd", choices=["build", "verify", "analog", "all"]) + ap.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu") + args = ap.parse_args() + + rc = 0 + layers = None + if args.cmd in ("build", "all"): + layers, _ = build(save=True) + if args.cmd in ("verify", "analog", "all"): + if layers is not None: + mm = MatrixMachine(layers, device=args.device) + elif os.path.exists(MODEL_PATH): + mm = MatrixMachine.from_file(MODEL_PATH, device=args.device) + else: + layers, _ = build(save=False) + mm = MatrixMachine(layers, device=args.device) + if args.cmd in ("verify", "all"): + rc |= 0 if verify(mm, args.device) else 1 + if args.cmd in ("analog", "all"): + rc |= 0 if analog(mm, args.device) else 1 + return rc + + +COMMANDS = {'machines': main, 'matrix8': main_matrix8} + if __name__ == "__main__": - sys.exit(main()) + if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS: + sys.exit(COMMANDS['machines']()) + cmd = sys.argv.pop(1) + sys.argv[0] += ' ' + cmd + sys.exit(COMMANDS[cmd]()) diff --git a/src/make_figures.py b/src/make_figures.py deleted file mode 100644 index d31974a8fb4914e565fe83ccf9db163249556320..0000000000000000000000000000000000000000 --- a/src/make_figures.py +++ /dev/null @@ -1,277 +0,0 @@ -"""Emit the TikZ for the deviation-rate figure from the recorded run. - -The figure plots, against the noise level, the measured probability that a step -of the levelized host deviates from the exact orbit and the union bound of the -random-perturbation proposition. Both axes are read from -paper/runs/paper_noise_curve.json. - - python src/make_figures.py > /tmp/curve.tex -""" -import json -import math -import os -import sys - -REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - - -def load(name): - return json.load(open(os.path.join(REPO, "paper", "runs", name))) - - -def group(n): - return f"{n:,}".replace(",", "{,}") - - -def sci(x, digits=2): - """LaTeX scientific notation.""" - if x == 0: - return "$0$" - e = math.floor(math.log10(abs(x))) - m = x / 10 ** e - if e == 0: - return f"${m:.{digits}f}$" - return rf"${m:.{digits}f}\times 10^{{{e}}}$" - - -def omega_table(): - d = load("paper_noise_omega.json") - rows = [] - for r in d["rows"]: - if r["complete"]: - outcome = "emitted the whole instance" - elif r["first_wrong_byte"] is not None: - outcome = (f"a wrong byte after {group(r['first_wrong_byte'])} " - f"correct") - elif r["halted"]: - outcome = "the halt bit was set" - else: - outcome = "the budget was exhausted" - rows.append(f"${r['sigma']:.2f}$ & {group(r['steps'])} & " - f"{group(r['bytes'])} & {outcome}\\\\") - body = "\n".join(rows) - return rf"""\begin{{table}}[ht] -\centering\small -\begin{{tabular}}{{@{{}}lrrl@{{}}}} -\toprule -$s$ & Steps executed & Bytes emitted & Outcome\\ -\midrule -{body} -\bottomrule -\end{{tabular}} -\caption{{The self-reproducing instance $\Omega^\ast$ on $\Lev(\Nhost)$ with -additive Gaussian read noise of standard deviation $s$ applied independently to -every pre-activation of every layer at every step, thresholded at -$-\tfrac12$. A run ends when it emits a byte that differs from -$\ser(\Omega^\ast)$, when the halt bit is set, or when the whole instance has -been emitted. The target is {group(d['target_bytes'])} bytes.}} -\label{{tab:noise}} -\end{{table}}""" - - -def leak_table(): - d = load("paper_noise_leak.json") - rows = [] - for r in d["rows"]: - first = (f"deviates at step {group(r['first_deviation'])}" - if r["first_deviation"] else - f"exact for {group(d['steps'])} steps") - rows.append(f"{sci(r['epsilon'])} & {sci(r['gamma'])} & " - f"${r['worst_bound']:.3f}$ & " - f"{'yes' if r['certified_by_corollary'] else 'no'} & " - f"{first}\\\\") - body = "\n".join(rows) - return rf"""\begin{{table}}[ht] -\centering\small -\begin{{tabular}}{{@{{}}lllll@{{}}}} -\toprule -$\varepsilon$ & $\gamma$ & $\max_{{\ell,i}}(\varepsilon k+\gamma(n-k))$ & -Certified & Outcome\\ -\midrule -{body} -\bottomrule -\end{{tabular}} -\caption{{Static weight error on $\Lev(\Nhost)$: each programmed weight is -perturbed uniformly in $[-\varepsilon,\varepsilon]$ and each zero entry -uniformly in $[-\gamma,\gamma]$, and the perturbed map is run beside the exact -one from the initial state of the self-describing instance until their states -differ. The widest layer has $n_{{\max}}={group(d['widest_layer'])}$ and the -largest effective fan-in is {d['largest_fanin']}, so -Corollary~\ref{{cor:mismatch}} certifies exactly the rows whose third column is -below $\tfrac12$.}} -\label{{tab:leak}} -\end{{table}}""" - - -def curve_table(): - d = load("paper_noise_curve.json") - rows = [] - for r in d["rows"]: - rate = (sci(r["empirical_rate_per_step"]) - if r["empirical_rate_per_step"] else "none seen") - ratio = f"${r['looseness']:.1f}$" if r["looseness"] else "---" - med = (group(r["median_first_deviation"]) - if r["median_first_deviation"] else "---") - rows.append(f"${r['sigma']:.2f}$ & {r['deviated']}/{r['copies']} & " - f"{med} & {group(r['step_exposure'])} & {rate} & " - f"{sci(r['bound_rate_per_step'])} & {ratio}\\\\") - body = "\n".join(rows) - return rf"""\begin{{table}}[ht] -\centering\small -\begin{{tabular}}{{@{{}}lllrllr@{{}}}} -\toprule -$s$ & Deviated & Median step & Exposure & Measured rate & Bound $Mp(s)$ & -Ratio\\ -\midrule -{body} -\bottomrule -\end{{tabular}} -\caption{{The probability that one step of $\Lev(\Nhost)$ deviates from the -exact orbit under read noise of standard deviation $s$. For each $s$, one -noise-free copy and {d['rows'][0]['copies']} noisy copies were stepped together -from the initial state of the self-describing instance and the full state -compared after each step; the exposure is the number of step--copy pairs before -a first deviation. The bound is that of Proposition~\ref{{prop:random}} with -$M={group(d['M'])}$, and the last column is the ratio of the two. Below -$s=0.08$ the bound is smaller than any rate this experiment could resolve.}} -\label{{tab:curve}} -\end{{table}}""" - - -def speed_table(): - d = load("paper_throughput.json") - T = json.load(open(os.path.join(REPO, "paper", "runs", - "paper_runs_reference.json")) - )["selfrep"]["generations"][0]["steps"] - order = [("reference", "integer reference, Definition 3.1"), - ("net_cpu", "the netlist of $\\sigma$, unit by unit, one core"), - ("net_gpu", "the netlist of $\\sigma$, unit by unit, GPU"), - ("dense_gpu", "$\\Lev(\\Nhost)$ as dense matrices, GPU"), - ("graph_gpu", "$\\Lev(\\Nhost)$ from its nonzero entries, GPU"), - ("sparse_cpu", "$\\Lev(\\Nhost)$ from its nonzero entries, one core")] - rows = [] - for key, what in order: - if key not in d: - continue - r = d[key]["steps_per_second"] - gen = T / r - span = (f"{gen:.1f} s" if gen < 90 else f"{gen / 60:.1f} min") - rows.append(f"{what} & {group(round(r))} & {span}\\\\") - body = "\n".join(rows) - ent = d.get("dense_gpu", {}).get("entries", 0) - nz = d.get("dense_gpu", {}).get("nonzero", 0) - bw = d.get("dense_gpu", {}).get("bandwidth_gbs", 0) - rate = {k: v["steps_per_second"] for k, v in d.items()} - sparse_over_dense = rate["graph_gpu"] / rate["dense_gpu"] - net_over_lev = sorted((rate["net_gpu"] / rate["graph_gpu"], - rate["net_cpu"] / rate["sparse_cpu"])) - lo, hi = (f"{x:.1f}" for x in net_over_lev) - net_factor = lo if lo == hi else f"{lo} to {hi}" - return rf"""\begin{{table}}[ht] -\centering\small -\begin{{tabular}}{{@{{}}lrr@{{}}}} -\toprule -Evaluator & Steps per second & One generation\\ -\midrule -{body} -\bottomrule -\end{{tabular}} -\caption{{What one generation of $\Omega^\ast$ costs on each evaluator, measured -on an otherwise idle machine over $20{{,}}000$ steps of the self-describing -instance after a warm-up. The dense form reads all -{group(ent)} matrix entries at every step, of which {group(nz)} are nonzero, -which is {ent * 4 / 1e6:.0f}~MB and {bw:.0f}~GB/s, close to the memory bandwidth -of the accelerator. Evaluating the same map from its nonzero entries replaces -that traffic by {group(nz)} gathers and, with the level plan captured as a single -CUDA graph that issues one operation per step in place of some hundreds, is -${sparse_over_dense:.1f}$ times faster; on one processor core it runs below the -dense form on the accelerator. The netlist carries $50{{,}}250$ weighted -predecessor entries against the {group(nz)} of its levelization and is -${net_factor}$ times faster than it on either processor.}} -\label{{tab:speed}} -\end{{table}}""" - - -def main() -> int: - if len(sys.argv) > 1 and sys.argv[1] == "speed": - print(speed_table()) - return 0 - if len(sys.argv) > 1 and sys.argv[1] == "tables": - print(omega_table()) - print() - print(curve_table()) - print() - print(leak_table()) - return 0 - d = json.load(open(os.path.join(REPO, "paper", "runs", - "paper_noise_curve.json"))) - rows = [r for r in d["rows"]] - xs = [r["sigma"] for r in rows] - emp = [(r["sigma"], r["empirical_rate_per_step"]) for r in rows - if r["empirical_rate_per_step"] > 0] - bnd = [(r["sigma"], r["bound_rate_per_step"]) for r in rows] - - lo = math.floor(min([math.log10(y) for _, y in emp] - + [math.log10(y) for _, y in bnd if y > 0])) - hi = math.ceil(max([math.log10(y) for _, y in emp] - + [math.log10(y) for _, y in bnd])) - x0, x1 = min(xs), max(xs) - W, H = 105.0, 55.0 - - def px(s): - return W * (s - x0) / (x1 - x0) - - def py(v): - return H * (math.log10(v) - lo) / (hi - lo) - - out = [] - a = out.append - a(r"\begin{figure}[ht]") - a(r"\centering") - a(r"\begin{tikzpicture}[font=\small, x=1mm, y=1mm]") - a(rf"\draw[->,>=stealth] (-2,0) -- ({W + 6:.1f},0) " - rf"node[right,font=\footnotesize] {{$s$}};") - a(rf"\draw[->,>=stealth] (-2,-2) -- (-2,{H + 6:.1f});") - for e in range(lo, hi + 1): - y = H * (e - lo) / (hi - lo) - a(rf"\draw[black!20] (-2,{y:.2f}) -- ({W:.2f},{y:.2f});") - a(rf"\node[left,font=\scriptsize] at (-2.5,{y:.2f}) " - rf"{{$10^{{{e}}}$}};") - for s in xs: - a(rf"\draw (({px(s):.2f}),0) -- ({px(s):.2f},-1.2);") - a(rf"\node[below,font=\scriptsize] at ({px(s):.2f},-1.2) " - rf"{{${s:g}$}};") - a(rf"\node[rotate=90,font=\footnotesize] at (-12,{H / 2:.1f}) " - rf"{{probability per step}};") - pts = " -- ".join(f"({px(s):.2f},{py(v):.2f})" for s, v in bnd) - a(rf"\draw[thick,dashed] {pts};") - for s, v in bnd: - a(rf"\fill ({px(s):.2f},{py(v):.2f}) circle (0.7mm);") - pts = " -- ".join(f"({px(s):.2f},{py(v):.2f})" for s, v in emp) - a(rf"\draw[thick] {pts};") - for s, v in emp: - a(rf"\draw[fill=white,thick] ({px(s):.2f},{py(v):.2f}) " - rf"circle (0.8mm);") - a(rf"\draw[dashed,black!50] ({px(x0):.2f},{py(1.0):.2f}) -- " - rf"({W:.2f},{py(1.0):.2f});") - a(rf"\node[right,font=\scriptsize] at ({W - 30:.1f},{py(1.0) + 3:.2f}) " - rf"{{bound vacuous above here}};") - a(rf"\node[font=\scriptsize,anchor=west] at (4,{py(bnd[-1][1]) - 5:.2f}) " - rf"{{union bound $Mp(s)$}};") - a(rf"\node[font=\scriptsize,anchor=west] at (20,{py(emp[0][1]) - 5:.2f}) " - rf"{{measured}};") - a(r"\end{tikzpicture}") - a(r"\caption{The probability that one step of $\Lev(\Nhost)$ deviates from " - r"the exact orbit under additive Gaussian read noise of standard " - r"deviation $s$, measured by running noise-free and noisy copies together " - r"and comparing the full state after every step, against the union bound " - r"$Mp(s)$ of Proposition~\ref{prop:random} with $M=87{,}294$. Both curves " - r"are the same quantity, the upper one as bounded above.}") - a(r"\label{fig:curve}") - a(r"\end{figure}") - print("\n".join(out)) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/matrix8.py b/src/matrix8.py deleted file mode 100644 index 8a011737c46c2568f93f2dfca5655c663e60259a..0000000000000000000000000000000000000000 --- a/src/matrix8.py +++ /dev/null @@ -1,1103 +0,0 @@ -"""neural_matrix8 — the 8-bit threshold CPU as a recurrent ternary linear-threshold -network. - -The verified gate wiring of the registers-profile CPU (8-bit data, 4-bit -addresses, 16 B memory) is compiled into a fixed sequence of ternary weight -matrices W1..Wk with a Heaviside step between them, acting on a 173-bit state -vector that holds the program counter, all four registers, the flags, the -stack pointer, the halt bit, and every memory bit: - - [ PC[4] | R0[8] R1[8] R2[8] R3[8] | Z N C V | SP[4] | HALT | MEM[16][8] ] - -One instruction is one pass through the stack (s' = H(Wk @ ... H(W1 @ s + b1) -... + bk)); the machine is the stack iterated until the halt bit sets, so the -whole processor is a single recurrent linear-threshold network. The transition -is total: a halted state is a fixed point (every architectural write is gated -by NOT halt), so iteration past halt is harmless. - -Every weight is in {-1, 0, 1} and every bias is a small integer; the circuit -is assembled from the same verified cell constructions the family ships -(two-layer OR/NAND XOR cells, ha1/ha2/carry_or full adders, bit-cascade -comparators, 2:1 mux cells, one-threshold-gate address decode rows), then -levelized ASAP with identity pass-throughs (H(x-1)) carrying live signals -between layers. - -Equality with the gate-graph processor is machine-checked, bit for bit: - - exhaustively over all 65,536 (a, b) operand pairs for every ALU opcode - (ADD SUB AND OR XOR SHL SHR MUL DIV CMP), batched through the matrices; - - exhaustively over all Jcc condition x flag combinations, all JMP/CALL - targets and SP values, and all LOAD/STORE address/data pairs; - - by full-state lockstep against GenericThresholdCPU walking the shipped - neural_computer8_registers weights, per instruction, on a micro-program - suite covering every opcode; - - by single-step agreement with a pure-integer reference on random states. - -Analog realization: pre-activations are integers, 1-firing gates sit at >= 0 -and 0-gates at <= -1, so placing the analog comparator threshold at -0.5 gives -every gate a symmetric guaranteed noise margin of 0.5 — any total analog error -(read noise + conductance mismatch) below 0.5 per pre-activation provably -reproduces the digital circuit bit-exactly. The analog suite measures the -margin, then sweeps Gaussian read noise and static ternary-conductance -mismatch to locate the empirical breakdown against the guarantee. - -Usage: - python matrix8.py build # compile + save variants/neural_matrix8.safetensors - python matrix8.py verify # equality suite (build in memory if file absent) - python matrix8.py analog # margin + noise / mismatch sweeps - python matrix8.py all # build + verify + analog -""" - -from __future__ import annotations - -import argparse -import json -import os -import sys -import time -from typing import Dict, List, Optional, Tuple - -import torch -from safetensors import safe_open -from safetensors.torch import save_file - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # repo root; this module lives in src/ -MODEL_PATH = os.path.join(REPO, "variants", "neural_matrix8.safetensors") -GATE_MODEL_PATH = os.path.join(REPO, "variants", "neural_computer8_registers.safetensors") - -ADDR_BITS = 4 -MEM_BYTES = 16 -STATE_BITS = 4 + 32 + 4 + 4 + 1 + 128 # pc, regs, flags, sp, halt, mem - - -# ============================================================================= -# Circuit IR: named threshold gates over binary signals, constants fold away -# ============================================================================= - -class Net: - """A DAG of threshold gates. Signals are names; '#0' / '#1' are constants - folded into biases. Helper constructors perform algebraic collapse - (AND(x,#1)=x, OR with one live input = that input, ...) so the compiled - stack carries no dead logic. The collapses preserve the computed function, - which the bit-for-bit suite checks afterwards.""" - - def __init__(self): - self.gates: Dict[str, Tuple[List[Tuple[str, int]], int]] = {} - - def g(self, name: str, ins: List[Tuple[str, int]], bias: int) -> str: - folded: List[Tuple[str, int]] = [] - b = int(bias) - for s, w in ins: - assert w in (-1, 1), f"non-ternary weight {w} at {name}" - if s == "#1": - b += w - elif s == "#0": - pass - else: - folded.append((s, int(w))) - if name in self.gates: - raise ValueError(f"duplicate gate {name}") - self.gates[name] = (folded, b) - return name - - # --- verified cell constructions -------------------------------------- - def NOT(self, name, x): - if x == "#1": - return "#0" - if x == "#0": - return "#1" - return self.g(name, [(x, -1)], 0) - - def BUF(self, name, x): - if x in ("#0", "#1"): - return x - return self.g(name, [(x, 1)], -1) - - def AND(self, name, ins: List[str]): - live = [] - for s in ins: - if s == "#0": - return "#0" - if s != "#1": - live.append(s) - if not live: - return "#1" - if len(live) == 1: - return live[0] - return self.g(name, [(s, 1) for s in live], -len(live)) - - def OR(self, name, ins: List[str]): - live = [] - for s in ins: - if s == "#1": - return "#1" - if s != "#0": - live.append(s) - if not live: - return "#0" - if len(live) == 1: - return live[0] - return self.g(name, [(s, 1) for s in live], -1) - - def NOR(self, name, ins: List[str]): - live = [s for s in ins if s != "#0"] - if any(s == "#1" for s in live): - return "#0" - if not live: - return "#1" - return self.g(name, [(s, -1) for s in live], 0) - - def XOR(self, prefix, a, b): - """Two-layer OR/NAND XOR, the family's verified construction.""" - if a == "#0": - return b if b in ("#0", "#1") else self.BUF(f"{prefix}.buf", b) - if b == "#0": - return a if a in ("#0", "#1") else self.BUF(f"{prefix}.buf", a) - if a == "#1": - return self.NOT(f"{prefix}.not", b) - if b == "#1": - return self.NOT(f"{prefix}.not", a) - h_or = self.g(f"{prefix}.l1or", [(a, 1), (b, 1)], -1) - h_nand = self.g(f"{prefix}.l1nand", [(a, -1), (b, -1)], 1) - return self.g(prefix, [(h_or, 1), (h_nand, 1)], -2) - - def FA(self, prefix, a, b, cin): - """Verified full-adder cell (ha1 XOR/AND, ha2 XOR/AND, carry OR).""" - s1 = self.XOR(f"{prefix}.ha1s", a, b) - c1 = self.AND(f"{prefix}.ha1c", [a, b]) - s2 = self.XOR(f"{prefix}.ha2s", s1, cin) - c2 = self.AND(f"{prefix}.ha2c", [s1, cin]) - co = self.OR(f"{prefix}.cor", [c1, c2]) - return s2, co - - def MUX(self, prefix, sel, x1, x0): - """2:1 mux cell: sel ? x1 : x0 (not_sel / and / and / or).""" - if sel == "#1": - return x1 - if sel == "#0": - return x0 - if x1 == x0: - return x0 - ns = self.NOT(f"{prefix}.ns", sel) - a1 = self.AND(f"{prefix}.a1", [x1, sel]) - a0 = self.AND(f"{prefix}.a0", [x0, ns]) - return self.OR(f"{prefix}.or", [a1, a0]) - - def DECODE(self, name, bits: List[str], value: int): - """One-gate address-decode row (the memory.addr_decode construction): - fires iff the MSB-first bits equal `value`.""" - n = len(bits) - ins = [] - pop = 0 - for i in range(n): - bit = (value >> (n - 1 - i)) & 1 - ins.append((bits[i], 1 if bit else -1)) - pop += bit - return self.g(name, ins, -pop) - - -# ============================================================================= -# State layout -# ============================================================================= - -def state_names() -> List[str]: - names = [f"pc{i}" for i in range(4)] - for r in range(4): - names += [f"r{r}b{i}" for i in range(8)] - names += ["fZ", "fN", "fC", "fV"] - names += [f"sp{i}" for i in range(4)] - names += ["halt"] - for j in range(MEM_BYTES): - names += [f"m{j}b{i}" for i in range(8)] - return names - - -def state_to_vec(s: dict) -> torch.Tensor: - v = torch.zeros(STATE_BITS) - i = 0 - for k in range(4): - v[i] = (s["pc"] >> (3 - k)) & 1 - i += 1 - for r in range(4): - for k in range(8): - v[i] = (s["regs"][r] >> (7 - k)) & 1 - i += 1 - for k in range(4): - v[i] = s["flags"][k] - i += 1 - for k in range(4): - v[i] = (s["sp"] >> (3 - k)) & 1 - i += 1 - v[i] = 1.0 if s["halted"] else 0.0 - i += 1 - for j in range(MEM_BYTES): - for k in range(8): - v[i] = (s["mem"][j] >> (7 - k)) & 1 - i += 1 - return v - - -def vec_to_state(v: torch.Tensor) -> dict: - bits = [int(round(float(x))) for x in v.tolist()] - i = 0 - pc = 0 - for k in range(4): - pc = (pc << 1) | bits[i] - i += 1 - regs = [] - for r in range(4): - x = 0 - for k in range(8): - x = (x << 1) | bits[i] - i += 1 - regs.append(x) - flags = bits[i:i + 4] - i += 4 - sp = 0 - for k in range(4): - sp = (sp << 1) | bits[i] - i += 1 - halted = bool(bits[i]) - i += 1 - mem = [] - for j in range(MEM_BYTES): - x = 0 - for k in range(8): - x = (x << 1) | bits[i] - i += 1 - mem.append(x) - return {"pc": pc, "regs": regs, "flags": flags, "sp": sp, - "halted": halted, "mem": mem} - - -# ============================================================================= -# The step circuit: one full instruction of the registers-profile CPU -# ============================================================================= - -def build_step_net() -> Tuple[Net, List[str], List[str]]: - net = Net() - pc = [f"pc{i}" for i in range(4)] - R = [[f"r{r}b{i}" for i in range(8)] for r in range(4)] - fl = ["fZ", "fN", "fC", "fV"] - sp = [f"sp{i}" for i in range(4)] - halt = "halt" - mem = [[f"m{j}b{i}" for i in range(8)] for j in range(MEM_BYTES)] - - # ---- fetch: bytes at PC .. PC+3 (decode lines reindexed, no adders) ---- - P = [net.DECODE(f"pdec{j}", pc, j) for j in range(MEM_BYTES)] - byte = [] - for o in range(4): - bo = [] - for bit in range(8): - terms = [net.AND(f"f{o}a{j}b{bit}", [mem[j][bit], P[(j - o) % MEM_BYTES]]) - for j in range(MEM_BYTES)] - bo.append(net.OR(f"f{o}b{bit}", terms)) - byte.append(bo) - - # ---- field decode; opcode lines gated by NOT halt (total transition) --- - nhalt = net.NOT("nhalt", halt) - O = [net.DECODE(f"op{v:X}", byte[0][0:4], v) for v in range(16)] - OG = [net.AND(f"og{v:X}", [O[v], nhalt]) for v in range(16)] - rdl = [net.DECODE(f"rdl{r}", byte[0][4:6], r) for r in range(4)] - rsl = [net.DECODE(f"rsl{r}", byte[0][6:8], r) for r in range(4)] - CL = [net.DECODE(f"cond{c}", byte[1][5:8], c) for c in range(8)] - - # ---- register read ports ---------------------------------------------- - a = [net.OR(f"a{bit}", [net.AND(f"a{bit}r{r}", [R[r][bit], rdl[r]]) - for r in range(4)]) for bit in range(8)] - b = [net.OR(f"b{bit}", [net.AND(f"b{bit}r{r}", [R[r][bit], rsl[r]]) - for r in range(4)]) for bit in range(8)] - a_l = lambda k: a[7 - k] # LSB-first views for the adder chains - b_l = lambda k: b[7 - k] - - # ---- ALU: ADD ---------------------------------------------------------- - c = "#0" - add_l = [] - for k in range(8): - s_, c = net.FA(f"add.fa{k}", a_l(k), b_l(k), c) - add_l.append(s_) - add = [add_l[7 - i] for i in range(8)] - add_c = c - - # ---- SUB (shared by CMP): a + ~b + 1 ----------------------------------- - nb_l = [net.NOT(f"sub.nb{k}", b_l(k)) for k in range(8)] - c = "#1" - sub_l = [] - for k in range(8): - s_, c = net.FA(f"sub.fa{k}", a_l(k), nb_l[k], c) - sub_l.append(s_) - sub = [sub_l[7 - i] for i in range(8)] - sub_c = c # carry set = no borrow - - # ---- bitwise / shifts --------------------------------------------------- - andr = [net.AND(f"land{bit}", [a[bit], b[bit]]) for bit in range(8)] - orr = [net.OR(f"lor{bit}", [a[bit], b[bit]]) for bit in range(8)] - xorr = [net.XOR(f"lxor{bit}", a[bit], b[bit]) for bit in range(8)] - shl = [a[bit + 1] if bit < 7 else "#0" for bit in range(8)] - shr = [a[bit - 1] if bit > 0 else "#0" for bit in range(8)] - - # ---- MUL: partial products + 7 chained shift-add adders ---------------- - pp = [[net.AND(f"pp.a{i}b{j}", [a[i], b[j]]) for j in range(8)] for i in range(8)] - acc_l: List[str] = ["#0"] * 8 - acc_l[7] = pp[7][0] # row j=0 shifted by 7 - for j in range(1, 8): - shift = 7 - j - c = "#0" - nxt = [] - for k in range(8): - addend = pp[14 - k - j][j] if shift <= k <= min(7, 14 - j) else "#0" - s_, c = net.FA(f"mul.s{j}.fa{k}", acc_l[k], addend, c) - nxt.append(s_) - acc_l = nxt - mul = [acc_l[7 - i] for i in range(8)] - - # ---- DIV: 8 restoring stages (bit-cascade GE + subtract + mux) --------- - def cascade_lt(prefix: str, x_m: List[str], y_m: List[str]) -> str: - """Unsigned x < y via the verified bit-cascade (bit 0 = MSB).""" - bit_lt, bit_eq = [], [] - for i in range(8): - bit_lt.append(net.g(f"{prefix}.b{i}lt", [(x_m[i], -1), (y_m[i], 1)], -1) - if x_m[i] != "#0" else - (y_m[i] if y_m[i] != "#0" else "#0")) - if x_m[i] == "#0": - bit_eq.append(net.NOT(f"{prefix}.b{i}eq", y_m[i])) - else: - e_and = net.AND(f"{prefix}.b{i}ea", [x_m[i], y_m[i]]) - e_nor = net.NOR(f"{prefix}.b{i}en", [x_m[i], y_m[i]]) - bit_eq.append(net.OR(f"{prefix}.b{i}eq", [e_and, e_nor])) - casc = [bit_lt[0]] - for i in range(1, 8): - pref = net.AND(f"{prefix}.ep{i}", bit_eq[:i]) - casc.append(net.AND(f"{prefix}.cl{i}", [pref, bit_lt[i]])) - return net.OR(f"{prefix}.lt", casc) - - rem_l: List[str] = ["#0"] * 8 - q_m: List[str] = [] - for st in range(8): - sh_l = [a[st]] + rem_l[0:7] # (rem << 1) | a_bit; top bit provably 0 - sh_m = [sh_l[7 - i] for i in range(8)] - lt = cascade_lt(f"div.s{st}.cmp", sh_m, b) - ge = net.NOT(f"div.s{st}.ge", lt) - c = "#1" - dsub_l = [] - for k in range(8): - s_, c = net.FA(f"div.s{st}.sub.fa{k}", sh_l[k], nb_l[k], c) - dsub_l.append(s_) - rem_l = [net.MUX(f"div.s{st}.mx{k}", ge, dsub_l[k], sh_l[k]) for k in range(8)] - q_m.append(ge) - div = q_m # quotient, MSB-first - - # ---- LOAD read port (address = low nibble of byte3) -------------------- - addr4 = byte[3][4:8] - AD = [net.DECODE(f"adec{j}", addr4, j) for j in range(MEM_BYTES)] - ld = [net.OR(f"ld{bit}", [net.AND(f"ld{bit}a{j}", [mem[j][bit], AD[j]]) - for j in range(MEM_BYTES)]) for bit in range(8)] - - # ---- result mux over opcode lines --------------------------------------- - srcs = [(0, add), (1, sub), (2, andr), (3, orr), (4, xorr), - (5, shl), (6, shr), (7, mul), (8, div), (0xA, ld)] - res = [net.OR(f"res{bit}", [net.AND(f"res{bit}o{v:X}", [src[bit], OG[v]]) - for v, src in srcs]) for bit in range(8)] - - # ---- flags --------------------------------------------------------------- - z_add = net.NOR("zadd", add) - z_sub = net.NOR("zsub", sub) - z_mul = net.NOR("zmul", mul) - v_add = net.AND("vadd", [net.XOR("vadd.x1", a[0], add[0]), - net.XOR("vadd.x2", b[0], add[0])]) - v_sub = net.AND("vsub", [net.XOR("vsub.x1", a[0], b[0]), - net.XOR("vsub.x2", a[0], sub[0])]) - f_src = { # per-source (Z, N, C, V); CMP shares the SUB datapath - 0: (z_add, add[0], add_c, v_add), - 1: (z_sub, sub[0], sub_c, v_sub), - 7: (z_mul, mul[0], "#0", "#0"), - 9: (z_sub, sub[0], sub_c, v_sub), - } - fw = net.OR("fw", [OG[0], OG[1], OG[7], OG[9]]) - fl_next = [] - for fi, fn in enumerate("ZNCV"): - new = net.OR(f"fnew{fn}", [net.AND(f"fnew{fn}o{v:X}", [f_src[v][fi], OG[v]]) - for v in (0, 1, 7, 9)]) - fl_next.append(net.MUX(f"fmux{fn}", fw, new, fl[fi])) - - # ---- register writeback -------------------------------------------------- - wen = net.OR("wen", [OG[v] for v in (0, 1, 2, 3, 4, 5, 6, 7, 8, 0xA)]) - R_next = [] - for r in range(4): - wsel = net.AND(f"wsel{r}", [wen, rdl[r]]) - nws = net.NOT(f"nws{r}", wsel) - row = [] - for bit in range(8): - t1 = net.AND(f"wb{r}b{bit}n", [res[bit], wsel]) - t0 = net.AND(f"wb{r}b{bit}o", [R[r][bit], nws]) - row.append(net.OR(f"wb{r}b{bit}", [t1, t0])) - R_next.append(row) - - # ---- program counter ------------------------------------------------------ - pc_l = [pc[3 - k] for k in range(4)] # LSB-first view - p2_l = [pc_l[0], net.NOT("pc2.n1", pc_l[1]), - net.XOR("pc2.x2", pc_l[2], pc_l[1]), - net.XOR("pc2.x3", pc_l[3], net.AND("pc2.c3", [pc_l[2], pc_l[1]]))] - p4_l = [pc_l[0], pc_l[1], net.NOT("pc4.n2", pc_l[2]), - net.XOR("pc4.x3", pc_l[3], pc_l[2])] - ext = net.OR("ext", [OG[v] for v in (0xA, 0xB, 0xC, 0xD, 0xE)]) - adv_l = [net.MUX(f"padv{k}", ext, p4_l[k], p2_l[k]) for k in range(4)] - pc_adv = [adv_l[3 - i] for i in range(4)] - - nfl = [net.NOT(f"nf{fn}", fl[i]) for i, fn in enumerate("ZNCV")] - cond_flag = [fl[0], nfl[0], fl[2], nfl[2], fl[1], nfl[1], fl[3], nfl[3]] - condsel = net.OR("condsel", [net.AND(f"ct{cnd}", [CL[cnd], cond_flag[cnd]]) - for cnd in range(8)]) - take = net.OR("take", [OG[0xC], OG[0xE], net.AND("jcct", [OG[0xD], condsel])]) - keeppc = net.OR("keeppc", [halt, OG[0xF]]) - adv = net.AND("adv", [net.NOT("ntake", take), net.NOT("nkeep", keeppc)]) - pc_next = [net.OR(f"pcn{i}", [net.AND(f"pcn{i}t", [addr4[i], take]), - net.AND(f"pcn{i}k", [pc[i], keeppc]), - net.AND(f"pcn{i}a", [pc_adv[i], adv])]) - for i in range(4)] - - # ---- stack pointer (CALL: SP-2 = SP + 14) --------------------------------- - sp_l = [sp[3 - k] for k in range(4)] - c = "#0" - s2_l = [] - for k, addend in enumerate(["#0", "#1", "#1", "#1"]): - s_, c = net.FA(f"spm2.fa{k}", sp_l[k], addend, c) - s2_l.append(s_) - spm2 = [s2_l[3 - i] for i in range(4)] - sp_next = [net.MUX(f"spn{i}", OG[0xE], spm2[i], sp[i]) for i in range(4)] - - # ---- halt (sticky) --------------------------------------------------------- - halt_next = net.OR("haltn", [halt, OG[0xF]]) - - # ---- memory next: STORE write, CALL pushes ret-hi (0) and ret-lo ---------- - SPD = [net.DECODE(f"spdec{j}", sp, j) for j in range(MEM_BYTES)] - mem_next = [] - for j in range(MEM_BYTES): - st_sel = net.AND(f"stsel{j}", [OG[0xB], AD[j]]) - ch_sel = net.AND(f"chsel{j}", [OG[0xE], SPD[(j + 1) % MEM_BYTES]]) - cl_sel = net.AND(f"clsel{j}", [OG[0xE], SPD[(j + 2) % MEM_BYTES]]) - keep = net.NOR(f"keep{j}", [st_sel, ch_sel, cl_sel]) - row = [] - for bit in range(8): - terms = [net.AND(f"mn{j}b{bit}k", [mem[j][bit], keep]), - net.AND(f"mn{j}b{bit}s", [b[bit], st_sel])] - if bit >= 4: # ret-lo low nibble carries pc_adv; high nibble is 0 - terms.append(net.AND(f"mn{j}b{bit}c", [pc_adv[bit - 4], cl_sel])) - row.append(net.OR(f"mn{j}b{bit}", terms)) - mem_next.append(row) - - outputs = (pc_next - + [bitsig for row in R_next for bitsig in row] - + fl_next + sp_next + [halt_next] - + [bitsig for row in mem_next for bitsig in row]) - return net, state_names(), outputs - - -# ============================================================================= -# Compiler: levelize ASAP, insert pass-throughs, emit dense ternary matrices -# ============================================================================= - -def compile_net(net: Net, inputs: List[str], outputs: List[str], - final_ordered: bool = True, - ) -> Tuple[List[Tuple[torch.Tensor, torch.Tensor]], dict]: - """Levelize a clocked netlist of maximal unit depth d into d ternary layers. - - Layer d is ordered by the state coordinates, so a unit of depth d occupies - the row of the coordinate it writes and the remaining rows are identity - pass-throughs. With final_ordered=False the coordinate ordering is emitted - as one further layer of identity units and the depth is d+1. - """ - allgates = net.gates - level: Dict[str, int] = {s: 0 for s in inputs} - - consumers: Dict[str, List[str]] = {} - indeg: Dict[str, int] = {} - for gname, (ins, _) in allgates.items(): - deps = [s for s, _ in ins if s in allgates] - indeg[gname] = len(deps) - for s, _ in ins: - consumers.setdefault(s, []).append(gname) - order = [g for g, d in indeg.items() if d == 0] - i = 0 - while i < len(order): - for cns in consumers.get(order[i], []): - indeg[cns] -= 1 - if indeg[cns] == 0: - order.append(cns) - i += 1 - if len(order) != len(allgates): - raise ValueError("cycle in step circuit") - for gname in order: - ins, _ = allgates[gname] - level[gname] = 1 + max((level[s] for s, _ in ins), default=0) - - # units that contribute to no next-state value are removed, as in the lemma - live: set = set() - stack = [s for s in outputs if s in allgates] - while stack: - g = stack.pop() - if g in live: - continue - live.add(g) - for s, _ in allgates[g][0]: - if s in allgates and s not in live: - stack.append(s) - gates = {g: allgates[g] for g in live} - - kmax = max(level[gname] for gname in gates) - K = kmax if final_ordered else kmax + 1 - - last_use: Dict[str, int] = {} - for gname, (ins, _) in gates.items(): - for s, _ in ins: - last_use[s] = max(last_use.get(s, 0), level[gname]) - for s in outputs: - if s not in ("#0", "#1"): - last_use[s] = K - - by_level: Dict[int, List[str]] = {} - for gname in gates: - by_level.setdefault(level[gname], []).append(gname) - - vec = list(inputs) # V_0 - layers: List[Tuple[torch.Tensor, torch.Tensor]] = [] - for lv in range(1, K + 1): - if lv < K: - new_gates = sorted(by_level.get(lv, [])) - passes = [s for s in vec if last_use.get(s, 0) > lv] - rows = new_gates + passes - else: - rows = list(outputs) - cols = {s: idx for idx, s in enumerate(vec)} - W = torch.zeros(len(rows), len(vec), dtype=torch.int8) - B = torch.zeros(len(rows), dtype=torch.int8) - out_names = [] - for ri, s in enumerate(rows): - if lv == K and s == "#0": - B[ri] = -1 - out_names.append(s) - continue - if lv == K and s == "#1": - B[ri] = 0 - out_names.append(s) - continue - if s in gates and level[s] == lv: - ins, bias = gates[s] - for src, w in ins: - W[ri, cols[src]] += w - B[ri] = bias - else: - W[ri, cols[s]] = 1 - B[ri] = -1 - out_names.append(s) - layers.append((W, B)) - vec = out_names - - if vec != outputs: - raise AssertionError("final layer does not emit the state vector") - - info = { - "layers": K, - "gates": len(gates), - "netlist_gates": len(allgates), - "max_width": max(max(W.shape) for W, _ in layers), - "total_weights": sum(W.numel() for W, _ in layers), - "widths": [layers[0][0].shape[1]] + [W.shape[0] for W, _ in layers], - } - return layers, info - - -# ============================================================================= -# Recurrent runtime -# ============================================================================= - -class MatrixMachine: - """s' = H(Wk @ ... H(W1 @ s + b1) ... + bk); iterate until the halt bit. - - Digital mode thresholds at 0 (pre-activations are integers). Analog mode - thresholds at -0.5 (the max-margin comparator point) and can inject - Gaussian read noise per MVM and static ternary-conductance mismatch.""" - - HALT_IDX = 4 + 32 + 4 + 4 # index of the halt bit in the state vector - - def __init__(self, layers, device="cpu"): - self.device = device - self.W = [W.to(device=device, dtype=torch.float32) for W, _ in layers] - self.B = [B.to(device=device, dtype=torch.float32) for _, B in layers] - - @classmethod - def from_file(cls, path=MODEL_PATH, device="cpu"): - layers = [] - with safe_open(path, framework="pt") as f: - n = 0 - while f"matrix.layer{n:03d}.weight" in f.keys(): - layers.append((f.get_tensor(f"matrix.layer{n:03d}.weight"), - f.get_tensor(f"matrix.layer{n:03d}.bias"))) - n += 1 - if not layers: - raise FileNotFoundError(f"no matrix layers in {path}") - return cls(layers, device=device) - - def perturbed(self, sigma_g: float, seed: int = 0) -> "MatrixMachine": - """Static conductance mismatch: each nonzero ternary conductance gets a - fixed Gaussian perturbation (fabrication variation, constant per run).""" - gen = torch.Generator(device="cpu").manual_seed(seed) - m = MatrixMachine.__new__(MatrixMachine) - m.device = self.device - m.W = [] - m.B = self.B - for W in self.W: - d = torch.randn(W.shape, generator=gen).to(self.device) * sigma_g - m.W.append(W + d * (W != 0)) - return m - - def step(self, v: torch.Tensor, analog: bool = False, - noise_sigma: float = 0.0, gen: Optional[torch.Generator] = None, - ) -> torch.Tensor: - thresh = -0.5 if analog else 0.0 - for W, b in zip(self.W, self.B): - pre = v @ W.T + b - if noise_sigma > 0.0: - pre = pre + torch.randn(pre.shape, generator=gen, - device=pre.device) * noise_sigma - v = (pre >= thresh).float() - return v - - def run(self, state: dict, max_cycles: int = 64, **kw) -> Tuple[dict, int]: - v = state_to_vec(state).unsqueeze(0).to(self.device) - n = 0 - while n < max_cycles and v[0, self.HALT_IDX] < 0.5: - v = self.step(v, **kw) - n += 1 - return vec_to_state(v[0].cpu()), n - - def min_margin(self, v: torch.Tensor) -> float: - """Distance of every pre-activation from the analog threshold -0.5.""" - m = float("inf") - for W, b in zip(self.W, self.B): - pre = v @ W.T + b - m = min(m, float((pre + 0.5).abs().min())) - v = (pre >= 0).float() - return m - - -# ============================================================================= -# Pure-integer reference (mirrors GenericThresholdCPU semantics, 4-bit addr) -# ============================================================================= - -_JCC_FLAG = [0, 0, 2, 2, 1, 1, 3, 3] - - -def ref_step(s: dict) -> dict: - if s["halted"]: - return {k: (list(v) if isinstance(v, list) else v) for k, v in s.items()} - s = {"pc": s["pc"], "regs": list(s["regs"]), "flags": list(s["flags"]), - "sp": s["sp"], "halted": s["halted"], "mem": list(s["mem"])} - M = MEM_BYTES - 1 - pc = s["pc"] - ir = (s["mem"][pc] << 8) | s["mem"][(pc + 1) & M] - op, rd, rs = (ir >> 12) & 0xF, (ir >> 10) & 3, (ir >> 8) & 3 - imm = ir & 0xFF - next_pc = (pc + 2) & M - addr = None - if op in (0xA, 0xB, 0xC, 0xD, 0xE): - al = s["mem"][(next_pc + 1) & M] - addr = al & M - next_pc = (next_pc + 2) & M - a, b = s["regs"][rd], s["regs"][rs] - result, carry, ovf, wr = a, 0, 0, True - if op == 0x0: - result = (a + b) & 0xFF - carry = 1 if a + b > 0xFF else 0 - ovf = 1 if ((a ^ result) & (b ^ result)) & 0x80 else 0 - elif op == 0x1: - result = (a - b) & 0xFF - carry = 1 if a >= b else 0 - ovf = 1 if ((a ^ b) & (a ^ result)) & 0x80 else 0 - elif op == 0x2: - result = a & b - elif op == 0x3: - result = a | b - elif op == 0x4: - result = a ^ b - elif op == 0x5: - result = (a << 1) & 0xFF - elif op == 0x6: - result = a >> 1 - elif op == 0x7: - result = (a * b) & 0xFF - elif op == 0x8: - result = 0xFF if b == 0 else a // b - elif op == 0x9: - r2 = (a - b) & 0xFF - carry = 1 if a >= b else 0 - ovf = 1 if ((a ^ b) & (a ^ r2)) & 0x80 else 0 - s["flags"] = [1 if r2 == 0 else 0, 1 if r2 & 0x80 else 0, carry, ovf] - wr = False - elif op == 0xA: - result = s["mem"][addr] - elif op == 0xB: - s["mem"][addr] = b & 0xFF - wr = False - elif op == 0xC: - s["pc"] = addr - return s - elif op == 0xD: - cnd = imm & 7 - flag = s["flags"][_JCC_FLAG[cnd]] - sel = flag if cnd % 2 == 0 else 1 - flag - s["pc"] = addr if sel else next_pc - return s - elif op == 0xE: - ret = next_pc - sp2 = (s["sp"] - 1) & M - s["mem"][sp2] = (ret >> 8) & 0xFF - sp2 = (sp2 - 1) & M - s["mem"][sp2] = ret & 0xFF - s["sp"] = sp2 - s["pc"] = addr - return s - elif op == 0xF: - s["halted"] = True - return s - if wr and op != 0x9: - s["regs"][rd] = result & 0xFF - if op in (0x0, 0x1, 0x7): - s["flags"] = [1 if result == 0 else 0, 1 if result & 0x80 else 0, - carry, ovf] - s["pc"] = next_pc - return s - - -# ============================================================================= -# Build + save -# ============================================================================= - -def build(save: bool = True): - print("Assembling the step circuit from the verified cell library...") - t0 = time.perf_counter() - net, inputs, outputs = build_step_net() - print(f" {len(net.gates)} threshold gates") - layers, info = compile_net(net, inputs, outputs) - dt = time.perf_counter() - t0 - print(f" compiled to {info['layers']} ternary matrices in {dt:.1f}s; " - f"max width {info['max_width']}, " - f"{info['total_weights']:,} dense weight entries") - for W, B in layers: - vals = set(torch.unique(W).tolist()) - assert vals <= {-1, 0, 1}, f"non-ternary matrix: {vals}" - assert int(B.min()) >= -128 and int(B.max()) <= 127 - - if save: - tensors = {} - for i, (W, B) in enumerate(layers): - tensors[f"matrix.layer{i:03d}.weight"] = W - tensors[f"matrix.layer{i:03d}.bias"] = B - tensors["manifest.data_bits"] = torch.tensor([8.0]) - tensors["manifest.addr_bits"] = torch.tensor([float(ADDR_BITS)]) - tensors["manifest.memory_bytes"] = torch.tensor([float(MEM_BYTES)]) - tensors["manifest.registers"] = torch.tensor([4.0]) - tensors["manifest.layers"] = torch.tensor([float(info["layers"])]) - tensors["manifest.state_bits"] = torch.tensor([float(STATE_BITS)]) - tensors["manifest.version"] = torch.tensor([4.0]) - meta = { - "machine": "matrix8", - "weight_quantization": "ternary", - "state_layout": json.dumps(state_names()), - "analog": json.dumps({ - "comparator_threshold": -0.5, - "guaranteed_margin": 0.5, - "note": "pre-activations are integers; any total analog error " - "below 0.5 per pre-activation is provably bit-exact", - }), - } - save_file(tensors, MODEL_PATH, metadata=meta) - sz = os.path.getsize(MODEL_PATH) / (1024 * 1024) - print(f" saved {MODEL_PATH} ({sz:.1f} MB)") - return layers, info - - -# ============================================================================= -# Verification: matrix form == gate graph == integer reference, bit for bit -# ============================================================================= - -def _mk_state(mem=None, regs=(0, 0, 0, 0), flags=(0, 0, 0, 0), pc=0, - sp=MEM_BYTES - 1, halted=False): - m = [0] * MEM_BYTES - if mem: - for i, x in enumerate(mem): - m[i] = x & 0xFF - return {"pc": pc, "regs": list(regs), "flags": list(flags), "sp": sp, - "halted": halted, "mem": m} - - -def _instr(op, rd=0, rs=0, imm=0): - w = ((op & 0xF) << 12) | ((rd & 3) << 10) | ((rs & 3) << 8) | (imm & 0xFF) - return [(w >> 8) & 0xFF, w & 0xFF] - - -def verify(mm: MatrixMachine, device: str) -> bool: - ok_all = True - - # ---- 1. exhaustive ALU sweeps: all 65,536 operand pairs per opcode ------ - print("\n[1/4] Exhaustive ALU sweeps (65,536 operand pairs x 10 opcodes, " - "batched through the matrices, vs integer reference)") - aa = torch.arange(65536) % 256 - bb = torch.arange(65536) // 256 - for op, name in [(0, "ADD"), (1, "SUB"), (2, "AND"), (3, "OR"), (4, "XOR"), - (5, "SHL"), (6, "SHR"), (7, "MUL"), (8, "DIV"), (9, "CMP")]: - for f_init in (0, 0xF): - V = torch.zeros(65536, STATE_BITS) - prog = _instr(op, rd=0, rs=1) + _instr(0xF) - base = _mk_state(mem=prog, flags=[(f_init >> (3 - i)) & 1 for i in range(4)]) - V[:] = state_to_vec(base) - for k in range(8): - V[:, 4 + k] = ((aa >> (7 - k)) & 1).float() # R0 - V[:, 12 + k] = ((bb >> (7 - k)) & 1).float() # R1 - V = mm.step(V.to(device)).cpu() - bad = 0 - probe = list(range(0, 65536, 4099)) + [0, 65535, 255, 256, 32768] - for idx in probe: - st = _mk_state(mem=prog, regs=(int(aa[idx]), int(bb[idx]), 0, 0), - flags=base["flags"]) - if vec_to_state(V[idx]) != ref_step(st): - bad += 1 - # full vectorized compare of the architectural result field - r0 = sum(V[:, 4 + k].long() << (7 - k) for k in range(8)) - fZ, fN, fC, fV = (V[:, 36].long(), V[:, 37].long(), - V[:, 38].long(), V[:, 39].long()) - if op == 0: - exp = (aa + bb) & 0xFF - elif op in (1, 9): - exp = (aa - bb) & 0xFF - elif op == 2: - exp = aa & bb - elif op == 3: - exp = aa | bb - elif op == 4: - exp = aa ^ bb - elif op == 5: - exp = (aa << 1) & 0xFF - elif op == 6: - exp = aa >> 1 - elif op == 7: - exp = (aa * bb) & 0xFF - else: - exp = torch.where(bb == 0, torch.full_like(aa, 0xFF), - aa // torch.clamp(bb, min=1)) - reg_exp = aa if op == 9 else exp - n_bad = int((r0 != reg_exp).sum()) - if op in (0, 1, 7, 9): - zx = (exp == 0).long() - nx = ((exp & 0x80) > 0).long() - if op == 0: - cx = ((aa + bb) > 0xFF).long() - vx = ((((aa ^ exp) & (bb ^ exp)) & 0x80) > 0).long() - elif op in (1, 9): - cx = (aa >= bb).long() - vx = ((((aa ^ bb) & (aa ^ exp)) & 0x80) > 0).long() - else: - cx = torch.zeros_like(aa) - vx = torch.zeros_like(aa) - n_bad += int((fZ != zx).sum() + (fN != nx).sum() - + (fC != cx).sum() + (fV != vx).sum()) - else: - want = torch.tensor([(f_init >> (3 - i)) & 1 for i in range(4)]) - n_bad += int((torch.stack([fZ, fN, fC, fV], 1) - != want.unsqueeze(0)).sum()) - status = "OK " if (n_bad == 0 and bad == 0) else "FAIL" - if n_bad or bad: - ok_all = False - if f_init == 0: - print(f" {status} {name:4} 65,536/65,536 operand pairs exact" - + ("" if n_bad == 0 else f" ({n_bad} mismatches)")) - - # ---- 2. exhaustive control-flow sweeps ---------------------------------- - print("\n[2/4] Exhaustive control-flow sweeps") - cases = [] - for cnd in range(8): # all Jcc x all flag nibbles - for f in range(16): - prog = _instr(0xD, imm=cnd) + [0x00, 0x08] + _instr(0xF) + [0] * 2 + _instr(0xF) - cases.append(_mk_state(mem=prog, flags=[(f >> (3 - i)) & 1 for i in range(4)])) - for t in range(16): # all JMP targets - prog = _instr(0xC) + [0x00, t] - cases.append(_mk_state(mem=prog)) - for spv in range(16): # CALL: every SP value - prog = _instr(0xE) + [0x00, 0x06] + [0, 0] + _instr(0xF) - cases.append(_mk_state(mem=prog, sp=spv)) - for ad in range(16): # LOAD/STORE: every address - prog = _instr(0xA, rd=2) + [0x00, ad] - st = _mk_state(mem=prog) - st["mem"][ad] |= 0xA5 if ad >= 4 else st["mem"][ad] - cases.append(st) - prog = _instr(0xB, rs=1) + [0x00, ad] - cases.append(_mk_state(mem=prog, regs=(0, 0x5A, 0, 0))) - V = torch.stack([state_to_vec(s) for s in cases]).to(device) - V = mm.step(V).cpu() - bad = sum(1 for i, s in enumerate(cases) if vec_to_state(V[i]) != ref_step(s)) - print(f" {'OK ' if bad == 0 else 'FAIL'} {len(cases)} directed cases " - f"(8 Jcc x 16 flag sets, 16 JMP targets, 16 CALL SPs, 32 LOAD/STORE) exact") - ok_all &= bad == 0 - - # ---- 3. random-state fuzz + halt fixed point ----------------------------- - print("\n[3/4] Random-state fuzz (single step vs integer reference)") - gen = torch.Generator().manual_seed(0xC0FFEE) - Vr = (torch.rand(2000, STATE_BITS, generator=gen) < 0.5).float() - Vr[:, MatrixMachine.HALT_IDX] = 0.0 - out = mm.step(Vr.to(device)).cpu() - bad = sum(1 for i in range(Vr.shape[0]) - if vec_to_state(out[i]) != ref_step(vec_to_state(Vr[i]))) - print(f" {'OK ' if bad == 0 else 'FAIL'} 2,000 uniformly random full states exact") - ok_all &= bad == 0 - Vh = (torch.rand(500, STATE_BITS, generator=gen) < 0.5).float() - Vh[:, MatrixMachine.HALT_IDX] = 1.0 - outh = mm.step(Vh.to(device)).cpu() - fixed = bool((outh == Vh.to(outh.device)).all()) - print(f" {'OK ' if fixed else 'FAIL'} 500 halted states are exact fixed points") - ok_all &= fixed - - # ---- 4. lockstep vs the gate-graph processor (shipped weights) ---------- - print("\n[4/4] Instruction lockstep vs GenericThresholdCPU on " - "neural_computer8_registers (the gate graph)") - from eval_all import GenericThresholdCPU - T = {} - with safe_open(GATE_MODEL_PATH, framework="pt") as f: - for nm in f.keys(): - T[nm] = f.get_tensor(nm).float() - gcpu = GenericThresholdCPU(T) - progs = [ - ("alu_chain", _mk_state(mem=_instr(0, 0, 1) + _instr(7, 2, 0) + _instr(9, 2, 1) - + _instr(4, 1, 2) + _instr(0xF), - regs=(9, 5, 3, 0))), - ("div_by_zero", _mk_state(mem=_instr(8, 0, 1) + _instr(0xF), regs=(77, 0, 0, 0))), - ("load_store", _mk_state(mem=_instr(0xA, rd=3) + [0x00, 0x0E] - + _instr(0xB, rs=3) + [0x00, 0x0F] + _instr(0xF) - + [0] * 4 + [0xB7, 0x00], - )), - ("call", _mk_state(mem=_instr(0xE) + [0x00, 0x06] + _instr(0xF) - + _instr(0xF), sp=0xF)), - ("jcc_taken", _mk_state(mem=_instr(0xD, imm=1) + [0x00, 0x06] + _instr(0xF) - + _instr(0xF), flags=(0, 0, 0, 0))), - ("shift_mul", _mk_state(mem=_instr(5, 0) + _instr(6, 1) + _instr(7, 0, 1) - + _instr(0xF), regs=(0x81, 0x81, 0, 0))), - ] - for name, st0 in progs: - sm = dict(st0) - sg = {"pc": st0["pc"], "regs": list(st0["regs"]), "flags": list(st0["flags"]), - "mem": list(st0["mem"]), "halted": False, "sp": st0["sp"]} - good = True - for _ in range(12): - if sm["halted"]: - break - sm, _n = mm.run(sm, max_cycles=1) - sg = gcpu.step(sg) - match = (sm["pc"] == sg["pc"] and sm["regs"] == sg["regs"] - and sm["flags"] == sg["flags"] and sm["mem"] == sg["mem"] - and sm["halted"] == sg["halted"] - and sm["sp"] == sg.get("sp", MEM_BYTES - 1)) - if not match: - good = False - break - good = good and sm["halted"] - print(f" {'OK ' if good else 'FAIL'} {name}: full-state lockstep, every instruction") - ok_all &= good - - print("\nMATRIX == GATE GRAPH == REFERENCE:", "PASS" if ok_all else "FAIL") - return ok_all - - -# ============================================================================= -# Analog realization -# ============================================================================= - -def analog(mm: MatrixMachine, device: str) -> bool: - ok = True - print("\nAnalog crossbar simulation " - "(comparator at -0.5; ternary conductances are exact by construction)") - - gen = torch.Generator().manual_seed(7) - Vr = (torch.rand(512, STATE_BITS, generator=gen) < 0.5).float().to(device) - margin = mm.min_margin(Vr) - print(f" measured minimum |pre-activation - (-0.5)| over 512 random states, " - f"all layers: {margin:.3f} (guarantee: 0.5)") - ok &= abs(margin - 0.5) < 1e-6 - - prog = (_instr(0, 0, 1) + _instr(7, 2, 0) + _instr(9, 2, 1) + _instr(0xF)) - st0 = _mk_state(mem=prog, regs=(9, 5, 3, 0)) - ref_final, _ = mm.run(dict(st0), max_cycles=8) - - import math - rows_per_step = sum(int(W.shape[0]) for W in mm.W) - n_steps = 4 - print(f" Gaussian read noise per MVM ({rows_per_step:,} comparator " - f"decisions per instruction; flip iff |noise| >= 0.5):") - for sigma in (0.05, 0.08, 0.10, 0.15, 0.25, 0.45): - p_flip = math.erfc(0.5 / (sigma * math.sqrt(2.0))) - exp_flips = p_flip * rows_per_step * n_steps - exact = 0 - for t in range(20): - g = torch.Generator(device=device).manual_seed(1000 + t) - v = state_to_vec(st0).unsqueeze(0).to(device) - for _ in range(8): - if v[0, MatrixMachine.HALT_IDX] >= 0.5: - break - v = mm.step(v, analog=True, noise_sigma=sigma, gen=g) - exact += int(vec_to_state(v[0].cpu()) == ref_final) - print(f" sigma={sigma:.2f}: {exact}/20 runs bit-exact " - f"(theory: {exp_flips:.2e} expected flips per run)") - if exp_flips < 1e-3 and exact != 20: - ok = False - - print(" Static ternary-conductance mismatch (device variation, " - "10 fabricated instances each):") - for sg in (0.02, 0.05, 0.10, 0.20): - exact = 0 - for t in range(10): - mmp = mm.perturbed(sg, seed=200 + t) - v = state_to_vec(st0).unsqueeze(0).to(device) - for _ in range(8): - if v[0, MatrixMachine.HALT_IDX] >= 0.5: - break - v = mmp.step(v, analog=True) - exact += int(vec_to_state(v[0].cpu()) == ref_final) - print(f" sigma_G={sg:.2f}: {exact}/10 instances bit-exact") - if sg <= 0.02 and exact != 10: - ok = False - - print("ANALOG REALIZATION:", "PASS (bit-exact within the stated margins)" - if ok else "FAIL") - return ok - - -# ============================================================================= -# CLI -# ============================================================================= - -def main() -> int: - ap = argparse.ArgumentParser(description="neural_matrix8 builder/verifier") - ap.add_argument("cmd", choices=["build", "verify", "analog", "all"]) - ap.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu") - args = ap.parse_args() - - rc = 0 - layers = None - if args.cmd in ("build", "all"): - layers, _ = build(save=True) - if args.cmd in ("verify", "analog", "all"): - if layers is not None: - mm = MatrixMachine(layers, device=args.device) - elif os.path.exists(MODEL_PATH): - mm = MatrixMachine.from_file(MODEL_PATH, device=args.device) - else: - layers, _ = build(save=False) - mm = MatrixMachine(layers, device=args.device) - if args.cmd in ("verify", "all"): - rc |= 0 if verify(mm, args.device) else 1 - if args.cmd in ("analog", "all"): - rc |= 0 if analog(mm, args.device) else 1 - return rc - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/netlist_io.py b/src/netlist_io.py deleted file mode 100644 index 84eae212c8252a57ed74b80f3ee6aa8728782f70..0000000000000000000000000000000000000000 --- a/src/netlist_io.py +++ /dev/null @@ -1,224 +0,0 @@ -"""Serialization of an ordered clocked ternary threshold netlist, and its inverse. - -For every unit the file stores its bias, the number of its predecessors, and one -signed entry per predecessor whose magnitude is the predecessor's signal index -plus one and whose sign is the weight on that edge; a predecessor carrying -weight zero is no predecessor and is not stored. For every state bit the file -stores the signal computing its next value. Signals are numbered with the n -state coordinates first, in the order of the state layout carried in the header, -and the units after them in the order of the netlist, in which every unit comes -after its predecessors. A netlist, order included, is recovered from the file -alone, and the names of the units are a labelling the file does not carry. - - sigma = sigma_of_net(net, inputs, outputs, machine, layout, io) - net, inputs, outputs, meta = net_of_sigma(sigma) - -The biases are I8; the fan-ins, the edges and the next-state indices are I16 -when their values fit and I32 otherwise; the seven manifest scalars are I32. -""" - -from __future__ import annotations - -import json -import os -import struct -import sys -from typing import Dict, List, Tuple - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -import torch - -from canonical import canonical_bytes -from matrix8 import Net - -# constant signals, which Net folds into biases and which therefore appear only -# as a next-state assignment -CONST0, CONST1 = -1, -2 - -_DTORD = {"F64": 0, "F32": 1, "F16": 2, "I64": 3, "I32": 4, "I16": 5, "I8": 6, - "U8": 7, "BOOL": 8} -_TAG = {torch.float32: "F32", torch.float64: "F64", torch.int64: "I64", - torch.int32: "I32", torch.int16: "I16", torch.int8: "I8"} - - -def depths(net: Net) -> Dict[str, int]: - """Depth of every unit: one more than the greatest depth of a predecessor.""" - d: Dict[str, int] = {} - order = topological(net) - for g in order: - ins, _ = net.gates[g] - d[g] = 1 + max((d[s] for s, _ in ins if s in net.gates), default=0) - return d - - -def topological(net: Net) -> List[str]: - """The units in some order consistent with the predecessor relation.""" - gates = net.gates - indeg = {g: 0 for g in gates} - cons: Dict[str, List[str]] = {} - for g, (ins, _) in gates.items(): - for s, _w in ins: - if s in gates: - indeg[g] += 1 - cons.setdefault(s, []).append(g) - order = [g for g, k in indeg.items() if k == 0] - i = 0 - while i < len(order): - for c in cons.get(order[i], []): - if c in gates: - indeg[c] -= 1 - if indeg[c] == 0: - order.append(c) - i += 1 - if len(order) != len(gates): - raise ValueError("the predecessor relation has a cycle") - return order - - -def unit_order(net: Net, inputs: List[str]) -> Tuple[List[str], Dict[str, int]]: - """The canonical order of the units, and the signal index of every signal. - - Level by level: a unit's key is its bias followed by its predecessors in - increasing index order, each paired with its weight. Every predecessor has - strictly smaller depth, so the indices a key needs are already assigned. - Two units of the same level with the same key are the same unit and receive - the same index, which merges a unit built twice and, since the merge changes - the keys of the level above, any unit whose duplication follows from it. - """ - d = depths(net) - by_level: Dict[int, List[str]] = {} - for g in net.gates: - by_level.setdefault(d[g], []).append(g) - slot = {s: i for i, s in enumerate(inputs)} - order: List[str] = [] - for lv in sorted(by_level): - keyed = [] - for g in by_level[lv]: - ins, b = net.gates[g] - keyed.append(((b, tuple(sorted((slot[s], w) for s, w in ins))), g)) - keyed.sort(key=lambda kv: kv[0]) - seen: Dict[tuple, int] = {} - for key, g in keyed: - if key not in seen: - seen[key] = len(inputs) + len(order) - order.append(g) - slot[g] = seen[key] - return order, slot - - -def tensors_of_net(net: Net, inputs: List[str], outputs: List[str]): - """The five tensors of the serialization, in canonical unit order.""" - order, slot = unit_order(net, inputs) - bias, fanin, pred = [], [], [] - for g in order: - ins, b = net.gates[g] - bias.append(b) - fanin.append(len(ins)) - for k, w in sorted((slot[s], w) for s, w in ins): - assert w in (-1, 1), f"weight {w} is not an edge" - pred.append(w * (k + 1)) - nxt = [] - for s in outputs: - if s == "#0": - nxt.append(CONST0) - elif s == "#1": - nxt.append(CONST1) - else: - nxt.append(slot[s]) - i8, i16 = torch.int8, torch.int16 - return { - "net.bias": torch.tensor(bias, dtype=i8), - "net.fanin": torch.tensor(fanin, dtype=i16), - "net.next": torch.tensor(nxt, dtype=i16), - "net.pred": torch.tensor(pred, dtype=i16), - } - - -def sigma_of_net(net: Net, inputs: List[str], outputs: List[str], - machine: str, layout: Dict, io: Dict) -> bytes: - """The serialization of an ordered clocked netlist. - - Units are stored in the order they were built, which lists every unit after - its predecessors, and each unit's predecessors in the order given; a - constant next-state value is stored as -1 (zero) or -2 (one). - """ - from host_family import encode - slot = {s: i for i, s in enumerate(inputs)} - for g in net.gates: - slot[g] = len(slot) - bias, fanin, pred = [], [], [] - for g, (ins, b) in net.gates.items(): - bias.append(b) - fanin.append(len(ins)) - for s, w in ins: - assert w in (-1, 1), f"weight {w} is not an edge" - assert slot[s] < slot[g], "a predecessor is built after its unit" - pred.append(w * (slot[s] + 1)) - nxt = [CONST0 if s == "#0" else CONST1 if s == "#1" else slot[s] for s in outputs] - return encode(layout["pc"][1], bias, fanin, pred, nxt, machine, layout, io) - - -def parse(data: bytes) -> Tuple[Dict[str, torch.Tensor], Dict[str, str]]: - """Header and tensors of a safetensors encoding, without a library call.""" - n = struct.unpack(" Tuple[Net, List[str], List[str], Dict[str, str]]: - """The netlist a serialization denotes, with canonical signal names.""" - t, meta = parse(data) - n = int(t["manifest.state_bits"][0]) - v = int(t["manifest.units"][0]) - bias = t["net.bias"].tolist() - fanin = t["net.fanin"].tolist() - pred = t["net.pred"].tolist() - nxt = t["net.next"].tolist() - if len(bias) != v or len(fanin) != v: - raise ValueError("unit count disagrees with the manifest") - if len(pred) != sum(fanin): - raise ValueError("predecessor count disagrees with the fan-in vector") - if len(nxt) != n: - raise ValueError("next-state vector disagrees with the manifest") - inputs = [f"s{i}" for i in range(n)] - name = lambda k: inputs[k] if k < n else f"u{k - n}" - net = Net() - p = 0 - for j in range(v): - ins = [] - for _ in range(fanin[j]): - e = pred[p] - if e == 0: - raise ValueError("predecessor entry carries no weight") - k, w = abs(e) - 1, (1 if e > 0 else -1) - if k >= n + v: - raise ValueError("predecessor index out of range") - if k >= n + j: - raise ValueError("predecessor is not earlier in the order") - ins.append((name(k), w)) - p += 1 - net.g(f"u{j}", ins, bias[j]) - outputs = [] - for k in nxt: - if k == CONST0: - outputs.append("#0") - elif k == CONST1: - outputs.append("#1") - elif 0 <= k < n + v: - outputs.append(name(k)) - else: - raise ValueError("next-state index out of range") - return net, inputs, outputs, meta diff --git a/src/neuromorphic.py b/src/neuromorphic.py deleted file mode 100644 index 932f12d2848cd3afd3d7eee44d47036da7c95348..0000000000000000000000000000000000000000 --- a/src/neuromorphic.py +++ /dev/null @@ -1,272 +0,0 @@ -"""The datapath on the fixed-point neuron model of a neuromorphic processor. - -Theorem 6.2 needs integer pre-activations and a comparator placed between them. -A digital neuromorphic processor provides exactly that: integer synaptic -weights, an integer bias and an integer threshold, with the membrane potential -compared against the threshold once per time step. This script maps the host's -arithmetic datapath, the carry-lookahead subtractor, the equality test and the -branch unit of H_8 with the two operand bytes as free inputs, onto the neuron -model of Intel's Loihi as implemented bit-accurately by the Lava simulator, one -layer per time step, and compares the result with the integer reference. - -Two features of the hardware model fix the encoding. A synapse carrying both -signs stores its weight with the least significant bit truncated, so the -smallest representable step is 2 and the ternary weights are written as -{-2, 0, +2}; and the model left-shifts the synaptic input by six bits and the -threshold by six, while decaying the membrane potential by one part in 4096 per -step. With the current decay set to full, a unit computing H( + b) becomes -a compartment with - - weights 2w, bias_mant = b + K, bias_exp = 7, vth = 2K - 1, du = dv = 4095. - -Its membrane potential is 128 ( + b + K) and it fires when that exceeds -64 vth = 128 K - 64, which is exactly when + b >= 0, the potential lying -64 units from the threshold on either side, provided the potential never reaches -4096 in magnitude without firing. That holds when 128| + b + K| < 4096, -that is whenever the accumulation stays below 32 in magnitude, the datapath -among them. Each layer costs one time step. - - python src/neuromorphic.py export # in the main environment - lavavenv/bin/python src/neuromorphic.py run -""" -from __future__ import annotations - -import json -import os -import sys - -import numpy as np - -REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -K = 9 # bias offset making the threshold >= 0 -NPZ = os.path.join(REPO, "paper", "runs", "datapath_layers.npz") - - -def runs_path(name: str) -> str: - d = os.path.join(REPO, "paper", "runs") - os.makedirs(d, exist_ok=True) - return os.path.join(d, name) - - -def datapath_net(): - """The subtractor, the equality test and the branch of H_8, cut out of the - host netlist: inputs are the value bits of x = M[a] and y = M[b], least - significant first, outputs the value bits of r = y - x and [r <= 0].""" - sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - from host_family import build - from matrix8 import Net - full, _, _ = build(8) - A = 8 - xv = [f"X{A - 1 - i}" for i in range(A)] - yv = [f"Y{A - 1 - i}" for i in range(A)] - keep = set() - stack = [f"R{k}" for k in range(A)] + ["LEQ"] - while stack: - g = stack.pop() - if g in keep or g in xv or g in yv: - continue - keep.add(g) - stack.extend(s for s, _ in full.gates[g][0]) - net = Net() - for gname, (ins, bias) in full.gates.items(): - if gname in keep: - net.g(gname, list(ins), bias) - return net, xv, yv, [f"R{k}" for k in range(A)], "LEQ" - - -def export() -> int: - """Levelize the datapath and write its integer weights and biases.""" - sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - from matrix8 import compile_net - net, xv, yv, r, leq = datapath_net() - inputs = xv + yv - outputs = r + [leq] - layers, info = compile_net(net, inputs, outputs, final_ordered=True) - d = {"n_layers": np.array([len(layers)]), - "n_units": np.array([len(net.gates)]), - "n_inputs": np.array([len(inputs)]), - "n_outputs": np.array([len(outputs)])} - for i, (W, bb) in enumerate(layers): - d[f"W{i}"] = W.numpy().astype(np.int32) - d[f"b{i}"] = bb.numpy().astype(np.int32) - os.makedirs(os.path.dirname(NPZ), exist_ok=True) - np.savez(NPZ, **d) - print(f" datapath: {len(net.gates)} units on {len(inputs)} inputs, " - f"{len(layers)} layers of widths " - f"{[int(W.shape[0]) for W, _ in layers]}") - print(f" wrote {NPZ}") - return 0 - - -def reference(xa, xb): - """The subtraction and the branch predicate of Definition 3.1, as integers.""" - res = (xb - xa) & 0xFF - leq = 1 if (res == 0 or res >= 128) else 0 - return res, leq - - -def run() -> int: - from lava.magma.core.run_conditions import RunSteps - from lava.magma.core.run_configs import Loihi2SimCfg - from lava.proc.dense.process import Dense - from lava.proc.io.sink import RingBuffer as Sink - from lava.proc.io.source import RingBuffer as Source - from lava.proc.lif.process import LIF - - z = np.load(NPZ) - L = int(z["n_layers"][0]) - n_in = int(z["n_inputs"][0]) - n_out = int(z["n_outputs"][0]) - layers = [(z[f"W{i}"], z[f"b{i}"]) for i in range(L)] - print(f" datapath: {int(z['n_units'][0])} units on {n_in} inputs, " - f"{L} layers of widths {[int(W.shape[0]) for W, _ in layers]}", - flush=True) - - worst = 0 - for W, b in layers: - hi = np.maximum(W, 0).sum(axis=1) + b + K - lo = np.minimum(W, 0).sum(axis=1) + b + K - worst = max(worst, int(np.abs(np.concatenate([hi, lo])).max())) - print(f" worst | + b + K| over all units and all inputs: {worst}, " - f"so the membrane potential stays below {128 * worst} < 4096: " - f"{'yes' if 128 * worst < 4096 else 'NO'}", flush=True) - assert 128 * worst < 4096 - - rng = np.random.default_rng(20260910) - N = 4096 - xa = rng.integers(0, 256, N) - xb = rng.integers(0, 256, N) - xa[:256] = np.arange(256); xb[:256] = 0 # every difference through zero - xa[256:512] = 0; xb[256:512] = np.arange(256) - xa[512:768] = np.arange(256); xb[512:768] = np.arange(256) # x = y - - spikes = np.zeros((n_in, N)) - for t in range(N): - for k in range(8): - spikes[k, t] = (xa[t] >> k) & 1 # x, value bits - spikes[8 + k, t] = (xb[t] >> k) & 1 # y - - T = N + L + 2 - src = Source(data=np.pad(spikes, ((0, 0), (0, T - N)))) - prev = src.s_out - first_lif = None - for W, b in layers: - dn = Dense(weights=(2 * W).astype(np.int32)) - lif = LIF(shape=(W.shape[0],), du=4095, dv=4095, - bias_mant=(b + K).astype(np.int32), bias_exp=7, vth=2 * K - 1) - prev.connect(dn.s_in) - dn.a_out.connect(lif.a_in) - prev = lif.s_out - first_lif = first_lif or lif - snk = Sink(shape=(n_out,), buffer=T) - prev.connect(snk.a_in) - - print(f" running {T} time steps of the {L}-layer chain on the " - f"bit-accurate fixed-point model", flush=True) - first_lif.run(condition=RunSteps(num_steps=T), - run_cfg=Loihi2SimCfg(select_tag="fixed_pt")) - out = snk.data.get() - first_lif.stop() - - bad = [] - for t in range(N): - col = out[:, t + L] - res = sum(int(col[k]) << k for k in range(8)) - leq = int(col[8]) - want = reference(int(xa[t]), int(xb[t])) - if (res, leq) != want: - bad.append((t, (res, leq), want)) - print(f" {N} operand pairs, pipelined one per time step: " - f"{'all exact' if not bad else f'{len(bad)} FAILED {bad[:3]}'}", - flush=True) - - print(f" one unit weight moves the membrane potential by 128 units and " - f"the comparator sits midway between the firing and non-firing " - f"values, so the margin of Theorem 6.2 is 64 units of membrane " - f"potential on this model") - - ok = not bad - json.dump({"backend": "Lava 0.10.0, fixed_pt tag (Loihi neuron model)", - "units": int(z["n_units"][0]), "layers": L, - "widths": [int(W.shape[0]) for W, _ in layers], - "worst_accumulation": worst, "patterns": N, - "failures": len(bad), "margin_membrane_units": 64, - "weight_scale": 2, "synapse_granularity": 2, - "bias_offset": K, "ok": ok}, - open(runs_path("paper_neuromorphic.json"), "w"), indent=1) - print("NEUROMORPHIC:", "PASS" if ok else "FAIL") - return 0 if ok else 1 - - -def akida() -> int: - """The same datapath on the Akida layer model. - - An Akida fully-connected layer with one activation bit holds signed integer - weights and an integer threshold per neuron and fires when the weighted sum - exceeds it, so a unit computing H( + b) is that layer with weights w - and threshold -b-1. The whole layer stack is one model and the batch is - forwarded in one call. - """ - import numpy as np - import akida as ak - from akida import FullyConnected, InputData, Model - - z = np.load(NPZ) - L = int(z["n_layers"][0]) - n_in = int(z["n_inputs"][0]) - layers = [(z[f"W{i}"], z[f"b{i}"]) for i in range(L)] - print(f" Akida {ak.__version__}; devices attached: {len(ak.devices())}; " - f"{L} layers of widths {[int(W.shape[0]) for W, _ in layers]}", - flush=True) - - m = Model() - m.add(InputData(input_shape=(1, 1, n_in), input_bits=1)) - for i, (W, b) in enumerate(layers): - m.add(FullyConnected(units=int(W.shape[0]), weights_bits=2, - activation=True, act_bits=1, name=f"layer{i}")) - lay = m.get_layer(i + 1) - wv = np.zeros(lay.variables["weights"].shape, dtype=np.int8) - wv[0, 0, :, :] = W.T.astype(np.int8) - lay.variables["weights"] = wv - lay.variables["threshold"] = (-b - 1).astype(np.int32) - - N = 65536 # every operand pair - xa = np.arange(N) % 256 - xb = np.arange(N) // 256 - X = np.zeros((N, 1, 1, n_in), dtype=np.uint8) - for k in range(8): - X[:, 0, 0, k] = (xa >> k) & 1 - X[:, 0, 0, 8 + k] = (xb >> k) & 1 - out = np.concatenate([m.forward(X[i:i + 8192]).reshape(-1, out_w) - for i in range(0, N, 8192) - for out_w in [int(layers[-1][0].shape[0])]]) - - bad = [] - for t in range(N): - res = sum(int(out[t, k]) << k for k in range(8)) - leq = int(out[t, 8]) - want = reference(int(xa[t]), int(xb[t])) - if (res, leq) != want: - bad.append((t, (res, leq), want)) - print(f" all {N} operand pairs forwarded through the layer stack: " - f"{'all exact' if not bad else f'{len(bad)} FAILED {bad[:3]}'}", - flush=True) - print(f" weights are stored at two bits and thresholds as integers, so " - f"the circuit is represented without rounding") - - ok = not bad - old = {} - p = runs_path("paper_neuromorphic.json") - if os.path.exists(p): - old = json.load(open(p)) - old["akida"] = {"backend": f"akida {ak.__version__}, software backend", - "layers": L, "patterns": N, "failures": len(bad), - "weights_bits": 2, "act_bits": 1, "ok": ok} - json.dump(old, open(p, "w"), indent=1) - print("AKIDA:", "PASS" if ok else "FAIL") - return 0 if ok else 1 - - -if __name__ == "__main__": - mode = sys.argv[1] if len(sys.argv) > 1 else "run" - sys.exit({"export": export, "run": run, "akida": akida}[mode]()) diff --git a/src/noise.py b/src/noise.py index cffcd84b523078e8116c2a67e4b785e75e1b1aff..d5e820dcaca461d151755a173b7943e282599252 100644 --- a/src/noise.py +++ b/src/noise.py @@ -1,43 +1,38 @@ -"""Perturbation experiments on the levelized host. - -Four measurements on Lev(N_host) with the comparator at -1/2, which is the -object of Theorem 6.2. The dense and sparse forms of the map compute the same -pre-activations, so noise drawn per pre-activation is the same experiment on -either; the long run uses the faster one. - - omega the complete self-reproducing run under additive read noise, so - that the exactness claim and the robustness claim concern the same - instance; - curve the probability that a trajectory deviates, measured against the - union bound of Proposition 6.5 over a grid of noise levels; the - deviation is detected by running noise-free and noisy copies in one - batch and comparing the full state after every step, which is the - event the bound covers; - leak static weight error with the zero entries leaking, which is the - term of Corollary 6.3 that grows with the width of the layer and - that the earlier mismatch model omitted; - margin the margin of Theorem 6.2 along the trajectory of the instance, - which is what the three experiments go outside; - profile the pre-activations along the exact orbit the curve experiment - starts from, and the one-sided bound they give on its rate; - certify the active-input counts along an orbit, which certify the - leakage and weight tolerances of that orbit (Proposition 6.4): - a zero entry leaks into a pre-activation only through an input - that is 1, so the term gamma (n - k) of Corollary 6.3 becomes - gamma times the number of active zero-weight inputs of the row. - - python src/noise.py omega|curve|leak|margin|profile|certify [--device cuda] -""" +"""Perturbation experiments on the levelized host, the amplified host, and the neuromorphic datapath.""" from __future__ import annotations import argparse +import hashlib import json import math import os +import subprocess import sys +import tempfile import time +from typing import Dict, List, Tuple + +import numpy as np + +from host import ( + encode, + M_P, + M_STAR, + HALT_PC, + Tape, + describe, + field, + inst, + read_host, + ser, + tau_star, + run_reference, + STATE_LAYOUT, + IO_CELLS, + STATE_BITS, +) +from paper import levelize, parse_sigma, write_flat, framed_parts -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -53,17 +48,11 @@ def union_bound(s: float, M: int, T: int) -> float: class Devices: - """The tape device applied to every copy of a batched state. - - The six device cells of every copy are read out in one tensor operation, - the four clauses of Definition 2.8 are applied per copy on a reused shadow - memory, and the results are written back in one more tensor operation. Only - the six cells cross between the accelerator and the host. - """ + """The tape device applied to every copy of a batched state.""" def __init__(self, L, tapes, device): import torch - from selfrep import Tape + from host import Tape self.torch = torch self.L = L self.device = device @@ -106,7 +95,7 @@ class Devices: def run_omega(args) -> int: """The self-reproducing instance under read noise, to completion.""" import torch - from selfrep import M_STAR, LevEvaluator, read_host, ser, tau_star + from host import M_STAR, LevEvaluator, read_host, ser, tau_star sigma = read_host() # the two forms of Lev(N_host) compute the same pre-activations, and this # run is long enough that the faster one is worth having @@ -164,14 +153,9 @@ def run_omega(args) -> int: # --------------------------------------------------------------------------- def run_curve(args) -> int: - """Empirical probability of deviation against the union bound. - - One noise-free copy and B noisy copies are stepped together as a batch, and - the step at which each noisy copy first differs from the noise-free one is - recorded. - """ + """Empirical probability of deviation against the union bound.""" import torch - from selfrep import M_P, LevEvaluator, describe, read_host + from host import M_P, LevEvaluator, describe, read_host sigma = read_host() L = LevEvaluator(sigma, device=args.device, dense=True) M = sum(int(W.shape[0]) for W in L.W) @@ -239,7 +223,7 @@ def run_curve(args) -> int: def run_leak(args) -> int: """Static weight error including the leakage of the zero entries.""" import torch - from selfrep import M_P, LevEvaluator, describe, read_host + from host import M_P, LevEvaluator, describe, read_host sigma = read_host() L = LevEvaluator(sigma, device=args.device, dense=True) M = sum(int(W.shape[0]) for W in L.W) @@ -315,15 +299,9 @@ def run_leak(args) -> int: # --------------------------------------------------------------------------- def run_margin(args) -> int: - """The margin of Theorem 6.2 along the trajectory of the instance. - - States visited by the self-reproducing run are sampled and pushed through - the dense stack, and the distance of every pre-activation of every layer - from the comparator point and from the nearest integer is recorded. - """ + """The margin of Theorem 6.2 along the trajectory of the instance.""" import torch - from selfrep import (M_STAR, LevEvaluator, Tape, HALT_PC, read_host, - tau_star) + from host import M_STAR, LevEvaluator, Tape, read_host, tau_star sigma = read_host() L = LevEvaluator(sigma, device=args.device, dense=True) tau = tau_star(sigma, M_STAR) @@ -364,21 +342,9 @@ def upper_tail(x: float) -> float: def run_profile(args) -> int: - """The pre-activations along the exact trajectory, and the one-sided bound. - - The curve experiment starts every copy from the initial state of the - self-describing instance, so its first deviation happens at a state of the - exact orbit. Along that orbit every pre-activation z of every row of every - layer is recorded. A row's output changes under a perturbation e only if e - crosses from z to the far side of -1/2, which has probability - Q(|z + 1/2| / s) under Gaussian noise of deviation s, so the probability - that step t deviates is at most the sum of Q(|z + 1/2| / s) over the rows - at step t. The average of that sum over the steps is compared with the - measured rate of the curve experiment and with the bound M p of - Proposition 6.5. - """ + """The pre-activations along the exact trajectory, and the one-sided bound.""" import torch - from selfrep import M_P, LevEvaluator, describe, read_host + from host import M_P, LevEvaluator, describe, read_host sigma = read_host() L = LevEvaluator(sigma, device=args.device, dense=False) M = L.info["size"] @@ -452,19 +418,9 @@ def run_profile(args) -> int: def run_certify(args) -> int: - """Active-input counts along an orbit. - - For every row of every layer at every step, the number k1 of inputs that - are 1 among the programmed entries and the number a of inputs that are 1 - among the zero entries are formed; a is the popcount of the layer's input - less k1. The maxima A* of a and K* of k1 over the orbit give the - certificate: with errors bounded as in Corollary 6.3, the realized system - follows the orbit whenever eps K* + gamma A* + eta < 1/2. Two orbits are - certified: the first 10,000 steps of the self-describing instance, which - the leak experiment runs, and the complete self-reproducing run. - """ + """Active-input counts along an orbit.""" import torch - from selfrep import M_P, M_STAR, LevEvaluator, describe, read_host, tau_star + from host import M_P, M_STAR, LevEvaluator, describe, read_host, tau_star sigma = read_host() L = LevEvaluator(sigma, device=args.device, dense=False) M = L.info["size"] @@ -536,7 +492,7 @@ def run_certify(args) -> int: return 0 -def main() -> int: +def main_host() -> int: ap = argparse.ArgumentParser() ap.add_argument("mode", choices=["omega", "curve", "leak", "margin", "profile", "certify"]) ap.add_argument("--short", action="store_true", help="certify: the leak orbit only") @@ -550,6 +506,1027 @@ def main() -> int: "margin": run_margin, "profile": run_profile, "certify": run_certify}[args.mode](args) +SRC = os.path.join(REPO, "src") + + +def sha(b: bytes) -> str: + return hashlib.sha256(b).hexdigest() + + +# --------------------------------------------------------------------------- +# the construction +# --------------------------------------------------------------------------- + +class Amplified: + """N_r = A_r(Lev(N)) for the netlist N of a serialization, in flat form.""" + + def __init__(self, sigma: bytes, r: int): + assert r >= 1 and r % 2 == 1, "the redundancy must be odd" + n, units, nxt = parse_sigma(sigma) + ln, lunits, lnxt, info = levelize(n, units, nxt) + assert ln == n + self.r, self.n, self.sigma0 = r, n, sigma + self.layers: List[int] = info["layer_sizes"] + self.depth = info["depth"] + self.lev_rows = len(lunits) + self.lev_units = lunits + self.lev_nxt = lnxt + shift = (r - 1) // 2 + + def pos(k: int, c: int) -> int: + return c * n + k if k < n else r * n + r * (k - n) + c + + out: List[Tuple[int, List[Tuple[int, int]]]] = [] + for b, ins in lunits: + ins_r = [(pos(k, c), w) for k, w in ins for c in range(r)] + for c in range(r): + out.append((r * b + shift, ins_r)) + self.units = out + self.nxt = [r * n + r * (lnxt[i] - n) + c for c in range(r) for i in range(n)] + self.state_bits = r * n + self.edges = sum(len(ins) for _, ins in out) + + def sigma(self) -> bytes: + bias, fanin, pred = [], [], [] + for b, ins in self.units: + bias.append(b) + fanin.append(len(ins)) + for k, w in ins: + pred.append(w * (k + 1)) + lay = dict(STATE_LAYOUT) + lay["replicas"] = self.r + lay["copy_stride"] = self.n + return encode(8, bias, fanin, pred, self.nxt, f"subleq8r{self.r}", lay, IO_CELLS) + + def write_flat(self, path: str) -> None: + write_flat(path, self.state_bits, self.units, self.nxt) + + # -- the layered plan for the evaluator on the accelerator ---------------- + def plan(self, device: str, pad: bool): + """Per layer, the rows, input positions, weights and biases of the amplified layer.""" + import torch + r, n = self.r, self.n + base = [n] + for L in self.layers: + base.append(base[-1] + L) + plan = [] + j = 0 + for ell, L in enumerate(self.layers, start=1): + rows_of = [] + for m in range(L): + b, ins = self.lev_units[j] + j += 1 + if ell == 1: + idx = [c * n + k for k, w in ins for c in range(r)] + else: + idx = [r * (k - base[ell - 2]) + c for k, w in ins for c in range(r)] + w = [wt for k, wt in ins for c in range(r)] + for c in range(r): + rows_of.append((m * r + c, idx, w, r * b + (r - 1) // 2)) + groups = [] + if pad: + K = max(len(x[1]) for x in rows_of) + idx_t = torch.zeros(len(rows_of), K, dtype=torch.long) + w_t = torch.zeros(len(rows_of), K) + for q, (row, idx, w, b) in enumerate(rows_of): + idx_t[q, :len(idx)] = torch.tensor(idx) + w_t[q, :len(w)] = torch.tensor(w, dtype=torch.float32) + rows_t = torch.tensor([x[0] for x in rows_of]) + b_t = torch.tensor([x[3] for x in rows_of], dtype=torch.float32) + groups.append((rows_t.to(device), idx_t.to(device), w_t.to(device), b_t.to(device))) + else: + by_k: Dict[int, list] = {} + for x in rows_of: + by_k.setdefault(len(x[1]), []).append(x) + for K in sorted(by_k): + g = by_k[K] + rows_t = torch.tensor([x[0] for x in g]) + idx_t = torch.tensor([x[1] for x in g], dtype=torch.long) + w_t = torch.tensor([x[2] for x in g], dtype=torch.float32) + b_t = torch.tensor([x[3] for x in g], dtype=torch.float32) + groups.append((rows_t.to(device), idx_t.to(device), w_t.to(device), b_t.to(device))) + plan.append((r * L, groups)) + # the final layer holds copy c of state bit i at i r + c; the state + # holds it at c n + i + perm = torch.tensor([i * r + c for c in range(r) for i in range(n)]).to(device) + return plan, perm + + +class Evaluator: + """N_r on the accelerator, with read noise and the device on the copies.""" + + def __init__(self, amp: Amplified, device: str = "cuda", pad: bool = True, + graph: bool = False): + import torch + self.torch = torch + self.amp, self.device = amp, device + self.r, self.n = amp.r, amp.n + self.plan, self.perm = amp.plan(device, pad) + self.M = sum(L for L, _ in self.plan) + self.N = amp.state_bits + cells = (IO_CELLS["c_wr"], IO_CELLS["c_eot"], IO_CELLS["c_rw"], + IO_CELLS["c_rd"], IO_CELLS["r_in"], IO_CELLS["r_out"]) + self.cells = cells + # bit t of cell j of copy c + self.cell_idx = torch.tensor([[c * self.n + 9 + 8 * j + t for j in cells for t in range(8)] + for c in range(self.r)], device=device) + self.pow2 = torch.tensor([1 << (7 - t) for t in range(8)], device=device) + self.shift = torch.tensor([7 - t for t in range(8)]) + self.graph = None + self.gen = None + if graph: + self.capture() + + # -- one application of the map, over a batch -------------------------- + def _forward(self, v, s: float = 0.0, gen=None): + torch = self.torch + x = v + for width, groups in self.plan: + y = torch.empty(x.shape[0], width, device=x.device) + for rows, idx, w, b in groups: + pre = (x[:, idx] * w).sum(-1) + b + if s > 0: + pre = pre + torch.randn(pre.shape, generator=gen, device=pre.device) * s + y[:, rows] = (pre >= -0.5).float() + x = y + return x[:, self.perm] + + def step(self, v): + if self.graph is not None and v.shape[0] == 1 and self.gs == 0.0: + return self._replay(v) + return self._forward(v) + + def step_noisy(self, v, s: float, gen): + if self.graph is not None and v.shape[0] == 1 and self.gs == s and gen is self.gen: + return self._replay(v) + return self._forward(v, s, gen) + + def capture(self, s: float = 0.0, seed: int = 0): + """Capture one application, with noise of deviation s, as a CUDA graph.""" + torch = self.torch + self.gs = s + self.gin = torch.zeros(1, self.N, device=self.device) + gen = None + if s > 0: + gen = torch.Generator(device=self.device).manual_seed(seed) + self.gen = gen + for _ in range(3): + self._forward(self.gin, s, gen) + torch.cuda.synchronize() + self.graph = torch.cuda.CUDAGraph() + with torch.cuda.graph(self.graph): + self.gout = self._forward(self.gin, s, gen) + torch.cuda.synchronize() + if s == 0.0: + probe = (torch.rand(1, self.N, device=self.device) < 0.5).float() + self.gin.copy_(probe) + self.graph.replay() + torch.cuda.synchronize() + assert bool((self.gout == self._forward(probe)).all()), \ + "the captured graph differs from the eager step" + return self + + def _replay(self, v): + if v.data_ptr() != self.gin.data_ptr(): + self.gin.copy_(v) + self.graph.replay() + self.gin.copy_(self.gout) + return self.gin + + # -- states -------------------------------------------------------------- + def vec(self, pc: int, mem: List[int], copies: int = 1): + torch = self.torch + v0 = torch.zeros(self.n) + for k in range(8): + v0[k] = (pc >> (7 - k)) & 1 + for j in range(256): + for k in range(8): + v0[9 + j * 8 + k] = (mem[j] >> (7 - k)) & 1 + v = v0.repeat(self.r).unsqueeze(0).to(self.device) + return v.repeat(copies, 1) + + def device_step(self, v, tapes: List[Tape], shadow: List[List[int]]): + """The device on every copy: cells read by majority, every copy written.""" + torch = self.torch + B = v.shape[0] + bits = v[:, self.cell_idx] # (B, r, 48) + maj = (bits.sum(1) * 2 > self.r).long() # (B, 48) + vals = (maj.reshape(B, len(self.cells), 8) * self.pow2).sum(-1).cpu() + halts = (v[:, [c * self.n + 8 for c in range(self.r)]].sum(1) * 2 > self.r).cpu().tolist() + new = torch.empty(B, len(self.cells), dtype=torch.long) + for i in range(B): + for k, j in enumerate(self.cells): + shadow[i][j] = int(vals[i, k]) + tapes[i].apply(shadow[i]) + for k, j in enumerate(self.cells): + new[i, k] = shadow[i][j] + nb = ((new.unsqueeze(-1) >> self.shift) & 1).reshape(B, 1, -1).float().to(self.device) + v[:, self.cell_idx] = nb.expand(B, self.r, -1) + return halts + + +# --------------------------------------------------------------------------- +def build_amplified(r: int) -> Amplified: + return Amplified(read_host(), r) + + +def compile_c(work: str, cc: str = "gcc") -> str: + """The evaluator in C, the levels of a large netlist evaluated in parallel.""" + exe = os.path.join(work, "netlist_eval") + subprocess.run([cc, "-O3", "-march=native", "-fopenmp", "-o", exe, + os.path.join(SRC, "netlist_eval.c")], check=True) + os.environ.setdefault("OMP_WAIT_POLICY", "ACTIVE") + os.environ.setdefault("OMP_PROC_BIND", "true") + os.environ.setdefault("OMP_NUM_THREADS", str(max(1, min(16, (os.cpu_count() or 2) - 4)))) + return exe + + +def cmd_build(args) -> int: + t0 = time.perf_counter() + amp = build_amplified(args.r) + s = amp.sigma() + t_build = time.perf_counter() - t0 + rec = {"r": args.r, "state_bits": amp.state_bits, "units": len(amp.units), + "edges": amp.edges, "depth": amp.depth, "lev_rows": amp.lev_rows, + "layer_widths": [args.r * L for L in amp.layers], + "sigma_bytes": len(s), "sigma_sha256": sha(s), + "bias_min": min(b for b, _ in amp.units), "bias_max": max(b for b, _ in amp.units), + "max_fanin": max(len(i) for _, i in amp.units), "build_seconds": t_build} + print(f"[build] N_{args.r}: {amp.state_bits:,} state bits, {len(amp.units):,} units, " + f"{amp.edges:,} edges, depth {amp.depth}; sigma {len(s):,} bytes, " + f"sha {sha(s)[:16]} ({t_build:.1f} s)", flush=True) + # the serialization denotes the netlist it was written from + n2, u2, x2 = parse_sigma(s) + rec["sigma_roundtrip"] = (n2 == amp.state_bits and u2 == amp.units and x2 == amp.nxt) + print(f" parses back to the same netlist: {rec['sigma_roundtrip']}") + # one step against SUBLEQ on the evaluator in C, every copy checked + work = tempfile.mkdtemp(prefix="redundant_") + exe = compile_c(work, args.cc) + flat = os.path.join(work, "net.bin") + amp.write_flat(flat) + p = subprocess.run([exe, "rand", flat, str(args.states), "1"], capture_output=True, text=True) + rec["c_random"] = p.stdout.strip() + rec["c_random_ok"] = p.returncode == 0 and " 0 disagree" in p.stdout + print(f" {p.stderr.strip()}") + print(f" {p.stdout.strip()}") + # the margins: every pre-activation on a replicated state is r z + (r-1)/2 + import torch + device = "cuda" if torch.cuda.is_available() else "cpu" + E = Evaluator(amp, device=device, pad=True) + gen = torch.Generator().manual_seed(11) + worst = float("inf") + for _ in range(4): + base = (torch.rand(8, amp.n, generator=gen) < 0.5).float() + x = base.repeat(1, args.r).to(device) + for width, groups in E.plan: + y = torch.empty(x.shape[0], width, device=device) + for rows, idx, w, b in groups: + pre = (x[:, idx] * w).sum(-1) + b + worst = min(worst, float((pre + 0.5).abs().min())) + y[:, rows] = (pre >= 0).float() + x = y + rec["min_margin_random_states"] = worst + print(f" minimum distance of a pre-activation from -1/2 on 32 replicated random " + f"states: {worst}") + # the evaluator on the accelerator agrees with the evaluator in C on a prefix + tau = tau_star(read_host(), M_STAR) + v = E.vec(0, M_STAR) + tapes, shadow = [Tape(tau)], [[0] * 256] + for _ in range(300): + v = E.step(v) + E.device_step(v, tapes, shadow) + st = v[0].cpu().long().tolist() + open(os.path.join(work, "mstar"), "wb").write(bytes(M_STAR)) + open(os.path.join(work, "tau"), "wb").write(tau) + p = subprocess.run([exe, "run", flat, os.path.join(work, "mstar"), os.path.join(work, "tau"), + os.path.join(work, "out"), "net", "300"], capture_output=True, text=True) + # replay the C state through the same 300 steps by the reference, then compare the + # emitted prefix and the memory read by majority + mem = list(M_STAR); pc = 0; dev = Tape(tau) + for _ in range(300): + a, b, c = mem[pc], mem[(pc + 1) & 255], mem[(pc + 2) & 255] + rr = (mem[b] - mem[a]) & 255 + mem[b] = rr + pc = c if (rr == 0 or rr >= 128) else (pc + 3) & 255 + dev.apply(mem) + got_mem = [sum(st[c * amp.n + 9 + 8 * j + t] << (7 - t) for t in range(8)) + for j in range(256) for c in (0,)] + all_copies_equal = all(st[c * amp.n + i] == st[i] for c in range(args.r) for i in range(amp.n)) + rec["accelerator_prefix_ok"] = (got_mem == mem and all_copies_equal + and tapes[0].out == dev.out + and open(os.path.join(work, "out"), "rb").read() == bytes(dev.out)) + print(f" 300 steps of Omega* on the accelerator and in C agree with the reference, " + f"all copies equal: {rec['accelerator_prefix_ok']}") + rec["ok"] = bool(rec["sigma_roundtrip"] and rec["c_random_ok"] + and abs(worst - args.r / 2) < 1e-9 and rec["accelerator_prefix_ok"]) + json.dump(rec, open(runs_path(f"paper_redundant_r{args.r}.json"), "w"), indent=1) + print("REDUNDANT HOST:", "PASS" if rec["ok"] else "FAIL") + return 0 if rec["ok"] else 1 + + +def census(r: bytes) -> Dict[str, int]: + """L, R, f_lit, f_rep of a recipe.""" + L = R = fl = fr = 0 + i = 0 + while True: + t = r[i] + if t == 0: + break + if 1 <= t <= 127: + L += 1; fl += t; i += 1 + t + else: + R += 1; fr += 256 - t; i += 2 + return {"L": L, "R": R, "f_lit": fl, "f_rep": fr} + + +def closed_form(c: Dict[str, int], tape_len: int) -> Dict[str, int]: + a = 5 * c["f_lit"] + 4 * c["f_rep"] + 7 * c["L"] + 9 * c["R"] + 7 + return {"A": a, "R": 1, "B": 6 * tape_len + 2, "total": a + 1 + 6 * tape_len + 2} + + +def cmd_selfrep(args) -> int: + """Omega*_r on the evaluator in C in lockstep with the reference, to completion.""" + t0 = time.perf_counter() + amp = build_amplified(args.r) + s_r = amp.sigma() + tau = tau_star(s_r, M_STAR) + target = ser(s_r, bytes(M_STAR), tau) + cen = census(tau) + cf = closed_form(cen, len(tau)) + print(f"[selfrep] N_{args.r}: sigma {len(s_r):,} bytes, tau*_r {len(tau):,} bytes, " + f"ser {len(target):,} bytes; closed form {cf['total']:,} steps", flush=True) + work = tempfile.mkdtemp(prefix="redundant_") + exe = compile_c(work, args.cc) + flat = os.path.join(work, "net.bin") + amp.write_flat(flat) + open(os.path.join(work, "mstar"), "wb").write(bytes(M_STAR)) + open(os.path.join(work, "tau"), "wb").write(tau) + t1 = time.perf_counter() + p = subprocess.run([exe, "run", flat, os.path.join(work, "mstar"), os.path.join(work, "tau"), + os.path.join(work, "out"), args.cmode], capture_output=True, text=True) + dt = time.perf_counter() - t1 + out = open(os.path.join(work, "out"), "rb").read() + exact = out == target + import re + m = re.search(r"after (\d+) steps", p.stdout) + steps = int(m.group(1)) if m else None + sg, mb, tb = inst(out) if exact else (None, None, None) + rec = {"r": args.r, "sigma_bytes": len(s_r), "sigma_sha256": sha(s_r), + "tau_bytes": len(tau), "ser_bytes": len(target), "ser_sha256": sha(target), + "census": cen, "closed_form": cf, "mode": args.cmode, + "c_result": p.stdout.strip(), "steps": steps, "seconds": dt, + "steps_per_second": steps / dt if steps and dt else None, + "out_bytes": len(out), "out_sha256": sha(out), "equals_ser": exact, + "closed_form_agrees": steps == cf["total"], + "inst_recovers_instance": bool(exact and sg == s_r and mb == bytes(M_STAR) and tb == tau), + "units": len(amp.units), "edges": amp.edges, "state_bits": amp.state_bits} + rec["ok"] = bool(exact and rec["closed_form_agrees"] and rec["inst_recovers_instance"] + and p.returncode == 0) + print(f" {p.stderr.strip()}") + print(f" {p.stdout.strip()} ({dt / 60:.1f} min, {rec['steps_per_second'] or 0:,.0f} steps/s)") + print(f" Out = ser(Omega*_r): {exact}; closed form agrees: {rec['closed_form_agrees']}; " + f"inst recovers the instance: {rec['inst_recovers_instance']}; sha {sha(out)[:16]}") + rec["total_seconds"] = time.perf_counter() - t0 + json.dump(rec, open(runs_path(f"paper_redundant_selfrep_r{args.r}.json"), "w"), indent=1) + print("REDUNDANT SELF-REPRODUCTION:", "PASS" if rec["ok"] else "FAIL") + return 0 if rec["ok"] else 1 + + +def tail_bound(s: float, r: int, M: int, T: int) -> float: + """T M erfc(r / (2 sqrt 2 s)): the bound of the fault-tolerance theorem.""" + return T * M * math.erfc(r / (2.0 * math.sqrt(2.0) * s)) + + +def cmd_omega(args) -> int: + """Omega* run on N_r under read noise on every pre-activation, to completion.""" + import torch + amp = build_amplified(args.r) + sigma = read_host() + tau = tau_star(sigma, M_STAR) + target = ser(sigma, bytes(M_STAR), tau) + E = Evaluator(amp, device=args.device, pad=True) + M = E.M + print(f"[omega] N_{args.r}: {amp.depth} layers, M = {M:,}, target {len(target):,} bytes", + flush=True) + grid = [float(x) for x in args.grid.split(",")] + rows = [] + for s in grid: + seed = 20260929 + int(1000 * s) + try: + E.capture(s, seed) + gen = E.gen + except Exception as e: # noqa: BLE001 + print(f" (graph capture unavailable: {e}; running eagerly)", flush=True) + E.graph = None + gen = torch.Generator(device=args.device).manual_seed(seed) + v = E.vec(0, M_STAR) + tapes, shadow = [Tape(tau)], [[0] * 256] + steps, wrong, halted = 0, None, False + budget = args.budget + t0 = time.perf_counter() + while steps < budget: + v = E.step_noisy(v, s, gen) + steps += 1 + before = len(tapes[0].out) + halted = E.device_step(v, tapes, shadow)[0] + out = tapes[0].out + if len(out) > before: + k = len(out) - 1 + if k >= len(target) or out[k] != target[k]: + wrong = k + break + if halted: + break + if steps % 200000 == 0: + print(f" s={s:.2f}: {steps:,} steps, {len(out):,} bytes " + f"({steps / (time.perf_counter() - t0):.0f}/s)", flush=True) + dt = time.perf_counter() - t0 + emitted = len(tapes[0].out) + complete = emitted == len(target) and wrong is None and halted + outcome = ("emitted the whole instance" if complete else + f"a wrong byte after {wrong} correct" if wrong is not None + else "the halt bit was set" if halted else "the budget was exhausted") + rows.append({"sigma": s, "steps": steps, "bytes": emitted, "complete": complete, + "first_wrong_byte": wrong, "halted": halted, "seconds": dt, + "bound": tail_bound(s, args.r, M, max(steps, 1))}) + print(f" s={s:.2f}: {steps:,} steps, {emitted:,} bytes, {outcome} " + f"({dt / 60:.1f} min); bound {rows[-1]['bound']:.3e}", flush=True) + json.dump({"r": args.r, "M": M, "target_bytes": len(target), "rows": rows}, + open(runs_path(f"paper_redundant_omega_r{args.r}.json"), "w"), indent=1) + return 0 + + +def upper_tail_amp(x: float) -> float: + return 0.5 * math.erfc(x / math.sqrt(2.0)) + + +def cmd_curve(args) -> int: + """The rate at which noisy copies of N_r leave the orbit, against the profile and first-order bounds.""" + import torch + amp = build_amplified(args.r) + sigma = read_host() + r_H = describe(sigma) + E = Evaluator(amp, device=args.device, pad=False) + M = E.M + B = args.batch + print(f"[curve] N_{args.r}: M = {M:,}; {B} noisy copies per level, {args.steps:,} steps each", + flush=True) + prof = None + pp = runs_path("paper_noise_profile.json") + if os.path.exists(pp): + prof = json.load(open(pp))["histogram_per_step"] + grid = [float(x) for x in args.grid.split(",")] + rows = [] + for s in grid: + cap = args.steps if s >= 0.24 else min(args.steps, 3000) + gen = torch.Generator(device=args.device).manual_seed(7 + int(1000 * s)) + v = E.vec(0, M_P, copies=B + 1) + tapes = [Tape(r_H) for _ in range(B + 1)] + shadow = [[0] * 256 for _ in range(B + 1)] + first = [None] * B + live = cap + t0 = time.perf_counter() + for n in range(cap): + x = v + for width, groups in E.plan: + y = torch.empty(x.shape[0], width, device=x.device) + for rws, idx, w, b in groups: + pre = (x[:, idx] * w).sum(-1) + b + noise = torch.randn(pre.shape, generator=gen, device=pre.device) * s + noise[0] = 0.0 + y[:, rws] = ((pre + noise) >= -0.5).float() + x = y + v = x[:, E.perm] + diff = (v[1:] != v[0]).any(dim=1).cpu().tolist() + for i, d in enumerate(diff): + if d and first[i] is None: + first[i] = n + 1 + if all(f is not None for f in first): + live = n + 1 + break + E.device_step(v, tapes, shadow) + dt = time.perf_counter() - t0 + deviated = [f for f in first if f is not None] + exposure = sum(f if f is not None else live for f in first) + rate = len(deviated) / exposure if exposure else 0.0 + bound_tail = M * math.erfc(args.r / (2.0 * math.sqrt(2.0) * s)) + bound_profile = None + if prof is not None: + bound_profile = args.r * sum(c * upper_tail_amp(args.r * abs(int(z) + 0.5) / s) + for z, c in prof.items()) + rows.append({"sigma": s, "copies": B, "steps": live, "deviated": len(deviated), + "median_first_deviation": + sorted(deviated)[len(deviated) // 2] if deviated else None, + "step_exposure": exposure, "empirical_rate_per_step": rate, + "bound_tail_per_step": bound_tail, + "bound_profile_per_step": bound_profile, + "first_deviation": first, "seconds": dt}) + print(f" s={s:.2f}: {len(deviated)}/{B} deviated within {live:,} steps; " + f"empirical {rate:.3e} per step, profile bound " + f"{bound_profile if bound_profile is None else format(bound_profile, '.3e')}, " + f"M erfc {bound_tail:.3e} ({dt / 60:.1f} min)", flush=True) + json.dump({"r": args.r, "M": M, "grid": grid, "rows": rows}, + open(runs_path(f"paper_redundant_curve_r{args.r}.json"), "w"), indent=1) + return 0 + + +def cmd_restore(args) -> int: + """One exact and B noisy copies of N_r stepped together, each read by majority.""" + import torch + amp = build_amplified(args.r) + sigma = read_host() + r_H = describe(sigma) + E = Evaluator(amp, device=args.device, pad=False) + n, r = amp.n, args.r + B = args.batch + grid = [float(x) for x in args.grid.split(",")] + rows = [] + print(f"[restore] N_{r}: {B} noisy copies, up to {args.steps:,} steps per level", flush=True) + for s in grid: + gen = torch.Generator(device=args.device).manual_seed(31 + int(1000 * s)) + v = E.vec(0, M_P, copies=B + 1) + tapes = [Tape(r_H) for _ in range(B + 1)] + shadow = [[0] * 256 for _ in range(B + 1)] + first = [None] * B + wrong_copy_steps = 0 + wrong_copies = 0 + worst = 0 + live = torch.ones(B, dtype=torch.bool) + t0 = time.perf_counter() + ran = args.steps + for t in range(args.steps): + x = v + for width, groups in E.plan: + y = torch.empty(x.shape[0], width, device=x.device) + for rws, idx, w, b in groups: + pre = (x[:, idx] * w).sum(-1) + b + noise = torch.randn(pre.shape, generator=gen, device=pre.device) * s + noise[0] = 0.0 + y[:, rws] = ((pre + noise) >= -0.5).float() + x = y + v = x[:, E.perm] + ex = v[0].reshape(r, n) + wrong = (v[1:].reshape(B, r, n) != ex.unsqueeze(0)) + k = wrong.sum(dim=(1, 2)).cpu() + maj = (v[1:].reshape(B, r, n).sum(1) * 2 > r) + bad = (maj != (ex[0] > 0.5).unsqueeze(0)).any(1).cpu() + for i in range(B): + if live[i]: + if int(k[i]): + wrong_copy_steps += 1 + wrong_copies += int(k[i]) + worst = max(worst, int(k[i])) + if bool(bad[i]): + first[i] = t + 1 + live[i] = False + if not bool(live.any()): + ran = t + 1 + break + E.device_step(v, tapes, shadow) + dt = time.perf_counter() - t0 + failed = [f for f in first if f is not None] + exposure = sum(f if f is not None else ran for f in first) + rows.append({"sigma": s, "copies": B, "steps": ran, "failed": len(failed), + "median_first_failure": sorted(failed)[len(failed) // 2] if failed else None, + "exposure": exposure, "failure_rate_per_step": len(failed) / exposure, + "steps_with_wrong_copies": wrong_copy_steps, "wrong_copies": wrong_copies, + "max_wrong_copies_in_a_step": worst, "first_failure": first, "seconds": dt}) + print(f" s={s:.2f}: {len(failed)}/{B} majority-read failures within {ran:,} steps, " + f"exposure {exposure:,}, rate {len(failed) / exposure:.3e} per step; " + f"{wrong_copies:,} wrong copies in {wrong_copy_steps:,} copy-steps before the " + f"failures ({dt / 60:.1f} min)", flush=True) + json.dump({"r": r, "copies": B, "cap": args.steps, "rows": rows}, + open(runs_path(f"paper_redundant_restore_r{r}.json"), "w"), indent=1) + return 0 + + +# --------------------------------------------------------------------------- +# the restoration bound +# --------------------------------------------------------------------------- + +def write_bound_input(amp: "Amplified", path: str, mem: List[int], tape: bytes) -> None: + """Lev(N_host) layer by layer as CSR, with the device cells, memory and tape, for restore_bound.c.""" + import struct + from host import C_WR, C_EOT, C_RW, C_RD, R_IN, R_OUT + n = amp.n + base = [n] + for L in amp.layers: + base.append(base[-1] + L) + assert amp.layers[-1] == n and all(amp.lev_nxt[i] == base[-2] + i for i in range(n)), \ + "the last layer of Lev(N_host) is the state, in order" + with open(path, "wb") as f: + f.write(struct.pack(" int: + """The restoration bound of Proposition 6.14 along the exact orbit, by restore_bound.c.""" + import numpy as np + amp = build_amplified(3) + sigma = read_host() + work = tempfile.mkdtemp() + exe = os.path.join(work, "restore_bound") + subprocess.run([args.cc, "-O3", "-march=native", "-o", exe, os.path.join(SRC, "restore_bound.c"), "-lm"], + check=True) + desc_in, repr_in = os.path.join(work, "describing.bin"), os.path.join(work, "reproducing.bin") + write_bound_input(amp, desc_in, M_P, describe(sigma)) + write_bound_input(amp, repr_in, M_STAR, tau_star(sigma, M_STAR)) + restore = {} + rp = runs_path("paper_redundant_restore_r3.json") + if os.path.exists(rp): + for row in json.load(open(rp))["rows"]: + restore[round(row["sigma"], 3)] = row + workers = max(1, min(args.workers, os.cpu_count() or 1)) + out = {"r": 3, "eps_rel": 1e-20, "warm": 20, "steps": args.steps, "rows": [], "whole_run": []} + for s in [float(x) for x in args.grid.split(",")]: + t0 = time.perf_counter() + b, _ = bound_series(exe, desc_in, s, args.steps, args.chunks, workers, work) + cb = np.concatenate([[0.0], np.cumsum(b)]) + row = {"sigma": s, "mean_per_step": float(cb[-1] / len(b)), "copy_fails_within": float(cb[-1]), + "max_per_step": float(b.max()), + "first_order_per_step": 3 * sum(amp.layers) * math.erfc(3 / (2 * math.sqrt(2) * s))} + m = restore.get(round(s, 3)) + if m: + ran = [f if f is not None else m["steps"] for f in m["first_failure"]] + row["weighted_per_step"] = float(sum(cb[e] for e in ran) / sum(ran)) + row["measured_per_step"] = m["failure_rate_per_step"] + row["bound_over_measured"] = row["weighted_per_step"] / m["failure_rate_per_step"] \ + if m["failure_rate_per_step"] > 0 else None + row["seconds"] = time.perf_counter() - t0 + out["rows"].append(row) + print(f" s={s:.2f}: {row.get('weighted_per_step', row['mean_per_step']):.3e} per step" + + (f", measured {row['measured_per_step']:.3e}" if m else "") + + f", first-order {row['first_order_per_step']:.3e} ({row['seconds']:.0f} s)", flush=True) + json.dump(out, open(runs_path("paper_redundant_bound_r3.json"), "w"), indent=1) + for s in [float(x) for x in args.whole.split(",") if x]: + t0 = time.perf_counter() + b, halted = bound_series(exe, repr_in, s, 1_664_939, args.whole_chunks, workers, work) + row = {"sigma": s, "steps": len(b), "halted": halted, "sum": float(b.sum()), + "first_order": len(b) * 3 * sum(amp.layers) * math.erfc(3 / (2 * math.sqrt(2) * s)), + "seconds": time.perf_counter() - t0} + out["whole_run"].append(row) + print(f" whole run, s={s:.2f}: {row['sum']:.3e} over {row['steps']:,} steps, first-order " + f"{row['first_order']:.3e} ({row['seconds'] / 60:.1f} min)", flush=True) + json.dump(out, open(runs_path("paper_redundant_bound_r3.json"), "w"), indent=1) + return 0 + + +def cmd_layers(args) -> int: + """The profile bound of N_r split by layer.""" + import torch + from host import M_P, LevEvaluator + r = args.r + sigma = read_host() + L = LevEvaluator(sigma, device=args.device, dense=False) + v = L._vec(0, M_P).unsqueeze(0).to(args.device) + D = Devices(L, [describe(sigma)], args.device) + lo, hi = -300, 300 + nl = len(L.plan) + H = torch.zeros(nl, hi - lo + 1, dtype=torch.float64, device=args.device) + T = args.steps + for n in range(args.steps): + x = v + for li, (groups, width) in enumerate(zip(L.plan, L.widths)): + y = torch.empty(1, width, device=args.device) + for rows, idx, w, b in groups: + pre = (x[:, idx] * w).sum(-1) + b + H[li] += torch.bincount(pre.round().long().flatten() - lo, + minlength=hi - lo + 1).double() + y[:, rows] = (pre >= 0).float() + x = y + v = x + if D.step(v)[0]: + T = n + 1 + break + H = (H / T).cpu() + zs = list(range(lo, hi + 1)) + measured = {} + cp = runs_path(f"paper_redundant_curve_r{r}.json") + if os.path.exists(cp): + for row in json.load(open(cp))["rows"]: + measured[round(row["sigma"], 3)] = (row["empirical_rate_per_step"], row["deviated"]) + out = {"r": r, "steps": T, + "per_layer_rows_at_half": [float(H[l][-1 - lo] + H[l][-lo]) for l in range(nl)], + "last_layer_hist": {z: float(H[nl - 1][z - lo]) for z in zs if H[nl - 1][z - lo] > 0}, + "rows": []} + for s in [float(x) for x in args.grid.split(",")]: + q = torch.tensor([upper_tail_amp(r * abs(z + 0.5) / s) for z in zs], dtype=torch.float64) + per_layer = [r * float(H[l] @ q) for l in range(nl)] + m = measured.get(round(s, 3)) + out["rows"].append({"sigma": s, "measured": m[0] if m else None, + "deviated": m[1] if m else None, "full": sum(per_layer), + "last_layer": per_layer[-1], "per_layer": per_layer}) + print(f" s={s:.2f}: last layer {per_layer[-1]:.3e}, all layers {sum(per_layer):.3e}" + + (f", measured {m[0]:.3e} ({m[1]} deviations)" if m else ""), flush=True) + json.dump(out, open(runs_path(f"paper_redundant_layers_r{r}.json"), "w"), indent=1) + return 0 + + +def main_amplified() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("mode", choices=["build", "selfrep", "omega", "curve", "restore", "layers", "bound"]) + ap.add_argument("--r", type=int, default=3) + ap.add_argument("--cc", default="gcc") + ap.add_argument("--device", default="cuda") + ap.add_argument("--states", type=int, default=1 << 16) + ap.add_argument("--batch", type=int, default=256) + ap.add_argument("--steps", type=int, default=None) + ap.add_argument("--budget", type=int, default=1 << 21) + ap.add_argument("--cmode", default="lock", + help="for selfrep: lock (compared with the reference every step) or net") + ap.add_argument("--grid", default=None) + ap.add_argument("--whole", default="0.20,0.22,0.25,0.27", + help="for bound: the noise levels of the sums over the self-reproducing run") + ap.add_argument("--chunks", type=int, default=10, help="for bound: chunks of the 20,000 steps") + ap.add_argument("--whole-chunks", type=int, default=36, help="for bound: chunks of the whole run") + ap.add_argument("--workers", type=int, default=6, help="for bound: chunks run at once") + args = ap.parse_args() + if args.steps is None: + args.steps = 20000 if args.mode == "bound" else 4000 + if args.grid is None: + args.grid = {"omega": "0.10,0.15,0.20,0.25", + "curve": "0.06,0.12,0.18,0.24,0.27,0.30,0.33,0.36,0.45,0.60", + "restore": "0.27,0.30,0.33,0.36", + "layers": "0.06,0.12,0.18,0.24,0.27,0.30,0.33,0.36,0.45,0.60", + "bound": "0.10,0.15,0.20,0.22,0.25,0.27,0.30,0.33,0.36"}.get(args.mode, "") + return {"build": cmd_build, "selfrep": cmd_selfrep, "omega": cmd_omega, + "curve": cmd_curve, "restore": cmd_restore, "layers": cmd_layers, + "bound": cmd_bound}[args.mode](args) + +K = 9 # bias offset making the threshold >= 0 + +NPZ = os.path.join(REPO, "paper", "runs", "datapath_layers.npz") + + +def datapath_net(): + """The subtractor, equality test and branch of H_8, cut out of the host netlist.""" + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + from host import build + from machines import Net + full, _, _ = build(8) + A = 8 + xv = [f"X{A - 1 - i}" for i in range(A)] + yv = [f"Y{A - 1 - i}" for i in range(A)] + keep = set() + stack = [f"R{k}" for k in range(A)] + ["LEQ"] + while stack: + g = stack.pop() + if g in keep or g in xv or g in yv: + continue + keep.add(g) + stack.extend(s for s, _ in full.gates[g][0]) + net = Net() + for gname, (ins, bias) in full.gates.items(): + if gname in keep: + net.g(gname, list(ins), bias) + return net, xv, yv, [f"R{k}" for k in range(A)], "LEQ" + + +def export() -> int: + """Levelize the datapath and write its integer weights and biases.""" + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + from machines import compile_net + net, xv, yv, r, leq = datapath_net() + inputs = xv + yv + outputs = r + [leq] + layers, info = compile_net(net, inputs, outputs, final_ordered=True) + d = {"n_layers": np.array([len(layers)]), + "n_units": np.array([len(net.gates)]), + "n_inputs": np.array([len(inputs)]), + "n_outputs": np.array([len(outputs)])} + for i, (W, bb) in enumerate(layers): + d[f"W{i}"] = W.numpy().astype(np.int32) + d[f"b{i}"] = bb.numpy().astype(np.int32) + os.makedirs(os.path.dirname(NPZ), exist_ok=True) + np.savez(NPZ, **d) + print(f" datapath: {len(net.gates)} units on {len(inputs)} inputs, " + f"{len(layers)} layers of widths " + f"{[int(W.shape[0]) for W, _ in layers]}") + print(f" wrote {NPZ}") + return 0 + + +def reference(xa, xb): + """The subtraction and the branch predicate of Definition 3.1, as integers.""" + res = (xb - xa) & 0xFF + leq = 1 if (res == 0 or res >= 128) else 0 + return res, leq + + +def run() -> int: + from lava.magma.core.run_conditions import RunSteps + from lava.magma.core.run_configs import Loihi2SimCfg + from lava.proc.dense.process import Dense + from lava.proc.io.sink import RingBuffer as Sink + from lava.proc.io.source import RingBuffer as Source + from lava.proc.lif.process import LIF + + z = np.load(NPZ) + L = int(z["n_layers"][0]) + n_in = int(z["n_inputs"][0]) + n_out = int(z["n_outputs"][0]) + layers = [(z[f"W{i}"], z[f"b{i}"]) for i in range(L)] + print(f" datapath: {int(z['n_units'][0])} units on {n_in} inputs, " + f"{L} layers of widths {[int(W.shape[0]) for W, _ in layers]}", + flush=True) + + worst = 0 + for W, b in layers: + hi = np.maximum(W, 0).sum(axis=1) + b + K + lo = np.minimum(W, 0).sum(axis=1) + b + K + worst = max(worst, int(np.abs(np.concatenate([hi, lo])).max())) + print(f" worst | + b + K| over all units and all inputs: {worst}, " + f"so the membrane potential stays below {128 * worst} < 4096: " + f"{'yes' if 128 * worst < 4096 else 'NO'}", flush=True) + assert 128 * worst < 4096 + + rng = np.random.default_rng(20260910) + N = 4096 + xa = rng.integers(0, 256, N) + xb = rng.integers(0, 256, N) + xa[:256] = np.arange(256); xb[:256] = 0 # every difference through zero + xa[256:512] = 0; xb[256:512] = np.arange(256) + xa[512:768] = np.arange(256); xb[512:768] = np.arange(256) # x = y + + spikes = np.zeros((n_in, N)) + for t in range(N): + for k in range(8): + spikes[k, t] = (xa[t] >> k) & 1 # x, value bits + spikes[8 + k, t] = (xb[t] >> k) & 1 # y + + T = N + L + 2 + src = Source(data=np.pad(spikes, ((0, 0), (0, T - N)))) + prev = src.s_out + first_lif = None + for W, b in layers: + dn = Dense(weights=(2 * W).astype(np.int32)) + lif = LIF(shape=(W.shape[0],), du=4095, dv=4095, + bias_mant=(b + K).astype(np.int32), bias_exp=7, vth=2 * K - 1) + prev.connect(dn.s_in) + dn.a_out.connect(lif.a_in) + prev = lif.s_out + first_lif = first_lif or lif + snk = Sink(shape=(n_out,), buffer=T) + prev.connect(snk.a_in) + + print(f" running {T} time steps of the {L}-layer chain on the " + f"bit-accurate fixed-point model", flush=True) + first_lif.run(condition=RunSteps(num_steps=T), + run_cfg=Loihi2SimCfg(select_tag="fixed_pt")) + out = snk.data.get() + first_lif.stop() + + bad = [] + for t in range(N): + col = out[:, t + L] + res = sum(int(col[k]) << k for k in range(8)) + leq = int(col[8]) + want = reference(int(xa[t]), int(xb[t])) + if (res, leq) != want: + bad.append((t, (res, leq), want)) + print(f" {N} operand pairs, pipelined one per time step: " + f"{'all exact' if not bad else f'{len(bad)} FAILED {bad[:3]}'}", + flush=True) + + print(f" one unit weight moves the membrane potential by 128 units and " + f"the comparator sits midway between the firing and non-firing " + f"values, so the margin of Theorem 6.2 is 64 units of membrane " + f"potential on this model") + + ok = not bad + json.dump({"backend": "Lava 0.10.0, fixed_pt tag (Loihi neuron model)", + "units": int(z["n_units"][0]), "layers": L, + "widths": [int(W.shape[0]) for W, _ in layers], + "worst_accumulation": worst, "patterns": N, + "failures": len(bad), "margin_membrane_units": 64, + "weight_scale": 2, "synapse_granularity": 2, + "bias_offset": K, "ok": ok}, + open(runs_path("paper_neuromorphic.json"), "w"), indent=1) + print("NEUROMORPHIC:", "PASS" if ok else "FAIL") + return 0 if ok else 1 + + +def akida() -> int: + """The same datapath on the Akida layer model.""" + import numpy as np + import akida as ak + from akida import FullyConnected, InputData, Model + + z = np.load(NPZ) + L = int(z["n_layers"][0]) + n_in = int(z["n_inputs"][0]) + layers = [(z[f"W{i}"], z[f"b{i}"]) for i in range(L)] + print(f" Akida {ak.__version__}; devices attached: {len(ak.devices())}; " + f"{L} layers of widths {[int(W.shape[0]) for W, _ in layers]}", + flush=True) + + m = Model() + m.add(InputData(input_shape=(1, 1, n_in), input_bits=1)) + for i, (W, b) in enumerate(layers): + m.add(FullyConnected(units=int(W.shape[0]), weights_bits=2, + activation=True, act_bits=1, name=f"layer{i}")) + lay = m.get_layer(i + 1) + wv = np.zeros(lay.variables["weights"].shape, dtype=np.int8) + wv[0, 0, :, :] = W.T.astype(np.int8) + lay.variables["weights"] = wv + lay.variables["threshold"] = (-b - 1).astype(np.int32) + + N = 65536 # every operand pair + xa = np.arange(N) % 256 + xb = np.arange(N) // 256 + X = np.zeros((N, 1, 1, n_in), dtype=np.uint8) + for k in range(8): + X[:, 0, 0, k] = (xa >> k) & 1 + X[:, 0, 0, 8 + k] = (xb >> k) & 1 + out = np.concatenate([m.forward(X[i:i + 8192]).reshape(-1, out_w) + for i in range(0, N, 8192) + for out_w in [int(layers[-1][0].shape[0])]]) + + bad = [] + for t in range(N): + res = sum(int(out[t, k]) << k for k in range(8)) + leq = int(out[t, 8]) + want = reference(int(xa[t]), int(xb[t])) + if (res, leq) != want: + bad.append((t, (res, leq), want)) + print(f" all {N} operand pairs forwarded through the layer stack: " + f"{'all exact' if not bad else f'{len(bad)} FAILED {bad[:3]}'}", + flush=True) + print(f" weights are stored at two bits and thresholds as integers, so " + f"the circuit is represented without rounding") + + ok = not bad + old = {} + p = runs_path("paper_neuromorphic.json") + if os.path.exists(p): + old = json.load(open(p)) + old["akida"] = {"backend": f"akida {ak.__version__}, software backend", + "layers": L, "patterns": N, "failures": len(bad), + "weights_bits": 2, "act_bits": 1, "ok": ok} + json.dump(old, open(p, "w"), indent=1) + print("AKIDA:", "PASS" if ok else "FAIL") + return 0 if ok else 1 + + +def cmd_neuromorphic(): + mode = sys.argv[1] if len(sys.argv) > 1 else "run" + sys.exit({"export": export, "run": run, "akida": akida}[mode]()) + + +COMMANDS = {'host': main_host, 'amplified': main_amplified, 'neuromorphic': cmd_neuromorphic} + if __name__ == "__main__": - sys.exit(main()) + if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS: + sys.exit('commands: ' + ', '.join(COMMANDS)) + cmd = sys.argv.pop(1) + sys.argv[0] += ' ' + cmd + sys.exit(COMMANDS[cmd]()) diff --git a/src/independent/nsim.c b/src/nsim.c similarity index 100% rename from src/independent/nsim.c rename to src/nsim.c diff --git a/src/paper.py b/src/paper.py new file mode 100644 index 0000000000000000000000000000000000000000..edd2d7dd5918385a4ca30c24150e56927b4df388 --- /dev/null +++ b/src/paper.py @@ -0,0 +1,2145 @@ +"""The checks and runs behind the paper's statements, one command each, each writing its record.""" +from __future__ import annotations + +import argparse +import concurrent.futures as cf +import hashlib +import itertools +import json +import os +import random +import re +import shutil +import struct +import subprocess +import sys +import tempfile +import time +from array import array +from collections import Counter, defaultdict +from typing import Dict, List, Tuple + +import torch + +from host import ( + describe, + describe_literal, + decode, + Tape, + C_WR, + C_EOT, + C_RW, + C_RD, + R_IN, + R_OUT, + run_reference, + M_P, + NetEvaluator, + read_host, + ser, + inst, + lam, + HOST_PATH, + M_STAR, + tau_star, + HALT_PC, + net_of_sigma, + sigma_of_net, + STATE_LAYOUT, + IO_CELLS, + host_netlist, + sigma_host, + P, + P_STAR, + LevEvaluator, +) + + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +SRC = os.path.join(REPO, "src") + + +def runs_path(name: str) -> str: + d = os.path.join(REPO, "paper", "runs") + os.makedirs(d, exist_ok=True) + return os.path.join(d, name) + + +def parse_sigma(data: bytes): + """Biases, predecessor lists and next-state indices from a serialization.""" + hlen = struct.unpack(" 0 else -1) for e in pred[p:p + fanin[j]]])) + p += fanin[j] + return len(nxt), units, nxt + + +def write_flat(path, n, units, nxt): + B, F, P = array("i"), array("i"), array("i") + for b, ins in units: + B.append(b) + F.append(len(ins)) + for k, w in ins: + P.append(k) + P.append(w) + with open(path, "wb") as f: + f.write(struct.pack("= 0] + while stack: + s = stack.pop() + if not live[s]: + live[s] = True + if s >= n: + stack.extend(k for k, _ in units[s - n][1]) + depth = [0] * (n + v) + for j, (_, ins) in enumerate(units): + depth[n + j] = 1 + max((depth[k] for k, _ in ins), default=0) + d = max(depth[n + j] for j in range(v) if live[n + j]) + lam = [0] * (n + v) + for j, (_, ins) in enumerate(units): + if live[n + j]: + for k, _ in ins: + lam[k] = max(lam[k], depth[n + j]) + for s in nxt: + lam[s] = d + 1 + layers = [[("copy", i) for i in range(n)]] + where = [{i: i for i in range(n)}] + for ell in range(1, d): + rows, pos = [], {} + for s in range(n + v): + if s >= n and live[s] and depth[s] == ell: + pos[s] = len(rows); rows.append(("unit", s)) + for s in range(n + v): + if (s < n or live[s]) and depth[s] < ell < lam[s]: + pos[s] = len(rows); rows.append(("copy", s)) + layers.append(rows); where.append(pos) + layers.append([("unit" if depth[s] == d else "copy", s) for s in nxt]) + base, out_units = [0], [] + for ell in range(1, d + 1): + base.append(base[-1] + len(layers[ell - 1])) + for kind, s in layers[ell]: + if kind == "copy": + out_units.append((-1, [(base[ell - 1] + where[ell - 1][s], 1)])) + else: + b, ins = units[s - n] + out_units.append((b, [(base[ell - 1] + where[ell - 1][k], w) for k, w in ins])) + return (n, out_units, [base[d] + i for i in range(n)], + {"depth": d, "rows": sum(len(L) for L in layers[1:]), + "identity_rows": sum(1 for L in layers[1:] for kind, _ in L if kind == "copy"), + "max_width": max(len(L) for L in layers[1:]), + "layer_sizes": [len(L) for L in layers[1:]]}) + + +def framed_parts(s: bytes): + a = struct.unpack(" int: + ap = argparse.ArgumentParser() + ap.add_argument("--cc", default="gcc") + args = ap.parse_args() + t_start = time.perf_counter() + from host import M_P, M_STAR, describe, field, ser, read_host + work = tempfile.mkdtemp(prefix="netlist_eval_") + exe = os.path.join(work, "netlist_eval") + subprocess.run([args.cc, "-O3", "-march=native", "-fopenmp", "-o", exe, + os.path.join(SRC, "netlist_eval.c")], check=True) + J = lambda *p: os.path.join(work, *p) + + def ev(*a): + r = subprocess.run([exe] + [str(x) for x in a], capture_output=True, text=True) + return r.returncode, r.stdout.strip() + + sigma = read_host() + n, units, nxt = parse_sigma(sigma) + write_flat(J("host.bin"), n, units, nxt) + ln, lunits, lnxt, lev_info = levelize(n, units, nxt) + write_flat(J("lev.bin"), ln, lunits, lnxt) + r_H = describe(sigma) + tau = describe(field(sigma) + field(bytes(M_STAR))) + expect_star = ser(sigma, bytes(M_STAR), tau) + for name, b in (("mP", bytes(M_P)), ("mstar", bytes(M_STAR)), ("rH", r_H), ("tau", tau)): + open(J(name), "wb").write(b) + out = {"lev": lev_info} + + with cf.ThreadPoolExecutor(max_workers=4) as ex: + f_rand = ex.submit(ev, "rand", J("host.bin"), 1 << 20, 1) + f_lrand = ex.submit(ev, "rand", J("lev.bin"), 1 << 20, 2) + f_desc = ex.submit(ev, "run", J("host.bin"), J("mP"), J("rH"), J("desc.out"), "lock") + out["netlist_random"] = f_rand.result()[1] + out["lev_random"] = f_lrand.result()[1] + out["self_describing"] = f_desc.result()[1] + # three generations, each rebuilt from the bytes the previous one emitted + gens = [] + net_path, mem_path, tau_path = J("host.bin"), J("mstar"), J("tau") + for g in range(1, 4): + mode = "lock" if g == 1 else "net" + rc, msg = ev("run", net_path, mem_path, tau_path, J(f"gen{g}.out"), mode) + emitted = open(J(f"gen{g}.out"), "rb").read() + gens.append({"generation": g, "mode": mode, "result": msg, + "sha256": hashlib.sha256(emitted).hexdigest(), + "equals_ser": emitted == expect_star}) + s_g, m_g, t_g = framed_parts(emitted) + n2, u2, x2 = parse_sigma(s_g) + net_path = J(f"host_g{g + 1}.bin") + write_flat(net_path, n2, u2, x2) + mem_path, tau_path = J(f"m_g{g + 1}"), J(f"tau_g{g + 1}") + open(mem_path, "wb").write(m_g) + open(tau_path, "wb").write(t_g) + out["generations"] = gens + desc_ok = open(J("desc.out"), "rb").read() == sigma + ok = (out["netlist_random"].endswith("0 halted batches not fixed") + and " 0 disagree" in out["netlist_random"] and " 0 disagree" in out["lev_random"] + and desc_ok and all(g["equals_ser"] for g in gens)) + out["self_describing_equals_sigma"] = desc_ok + out["ok"] = ok + out["seconds"] = time.perf_counter() - t_start + for k in ("netlist_random", "lev_random", "self_describing"): + print(f" {k}: {out[k]}") + for g in gens: + print(f" generation {g['generation']} ({g['mode']}): {g['result']}; equals ser: " + f"{'yes' if g['equals_ser'] else 'NO'}; {g['sha256'][:16]}") + json.dump(out, open(runs_path("paper_c_evaluator.json"), "w"), indent=1) + print("C EVALUATOR:", "PASS" if ok else "FAIL") + return 0 if ok else 1 + + +def bound(n: int) -> int: + return n + -(-n // 127) + 1 + + +def tags(tape: bytes): + i, out = 0, [] + while True: + t = tape[i] + out.append(t) + i += 1 + if t == 0: + return out + i += t if t <= 127 else 1 + + +class Audit: + def __init__(self): + self.n = 0 + self.roundtrip = [] + self.overlong = [] + self.not_tight = [] + self.tag128 = [] + + def check(self, b: bytes) -> None: + self.n += 1 + for enc in (describe, describe_literal): + r = enc(b) + if decode(r) != b: + self.roundtrip.append((enc.__name__, len(b))) + if len(r) > bound(len(b)): + self.overlong.append((enc.__name__, len(b), len(r))) + if len(describe_literal(b)) != bound(len(b)): + self.not_tight.append(len(b)) + if 128 in tags(describe(b)): + self.tag128.append(len(b)) + + @property + def ok(self) -> bool: + return not (self.roundtrip or self.overlong or self.not_tight or self.tag128) + + +def main_codec() -> int: + a = Audit() + for n in range(0, 15): + for t in itertools.product((0, 1), repeat=n): + a.check(bytes(t)) + binary = a.n + for n in range(0, 10): + for t in itertools.product((0, 1, 2), repeat=n): + a.check(bytes(t)) + ternary = a.n - binary + print(f" exhaustive: {binary:,} binary strings to length 14, " + f"{ternary:,} ternary to length 9") + + edges = (list(range(0, 12)) + + [126, 127, 128, 129, 130, 251, 252, 253, 254, 255, 256, 257, + 380, 381, 382, 508, 509, 510]) + before = a.n + for L in edges: + a.check(bytes([0x41]) * L) + a.check(bytes([0x00]) * L) + a.check(bytes([0xFF]) * L) + a.check(bytes(range(256))[:L] if L <= 256 else bytes(range(256)) * (L // 256)) + a.check(bytes([0x41] * L + [0x42])) + a.check(bytes([0x42] + [0x41] * L)) + a.check(bytes([0x41] * L + [0x42] * L)) + a.check(bytes(([0x41] * 4 + [0x42]) * max(1, L))) + a.check(bytes(([0x41] * 3 + [0x42]) * max(1, L))) + print(f" boundaries: {a.n - before:,} strings straddling the literal cap, " + f"the extreme repeat tags, and the run/literal transitions") + + rng = random.Random(11) + before = a.n + for _ in range(4000): + n = rng.randrange(0, 900) + alpha = rng.choice([2, 3, 7, 256]) + a.check(bytes(rng.randrange(alpha) for _ in range(n))) + for _ in range(1500): + n = rng.randrange(0, 900) + out = bytearray() + while len(out) < n: + out += bytes([rng.randrange(256)]) * rng.choice([1, 2, 3, 4, 5, 126, 127, 128, 253]) + a.check(bytes(out[:n])) + print(f" random and run-structured: {a.n - before:,} strings") + + print(f" total {a.n:,}: round-trip exact, neither encoder exceeds " + f"|f| + ceil(|f|/127) + 1, the literal encoder attains it, " + f"tag 128 never emitted: {'yes' if a.ok else 'NO'}") + if not a.ok: + print(" roundtrip:", a.roundtrip[:5]) + print(" overlong:", a.overlong[:5]) + print(" not tight:", a.not_tight[:5]) + print(" tag128:", a.tag128[:5]) + json.dump({"strings": a.n, "roundtrip_failures": len(a.roundtrip), + "bound_failures": len(a.overlong), + "literal_not_tight": len(a.not_tight), + "tag128_emitted": len(a.tag128)}, + open(runs_path("paper_codec.json"), "w"), indent=1) + return 0 if a.ok else 1 + + +def spec(mem, tau, h, omega): + """Definition 2.8, clause by clause, on (mem, h, omega).""" + m = list(mem) + w = list(omega) + if m[C_WR] == 1: # (i) + w.append(m[R_OUT]) + m[R_OUT] = 0 + hp = h + if m[C_RD] == 1: # (ii) + if h < len(tau): + m[R_IN] = tau[h] + m[C_EOT] = 0 + hp = h + 1 + else: + m[C_EOT] = 1 + if m[C_RW] == 1: # (iii) + hp = 0 + m[C_WR] = m[C_RD] = m[C_RW] = 0 # (iv) + return m, hp, w + + +def main_device() -> int: + tapes = [b"", b"\x07", b"\x01\x02", bytes(range(5)), bytes(range(64))] + vals = (0, 1, 2, 3, 127, 128, 255) + bad, n = [], 0 + for tau in tapes: + heads = sorted({0, 1, max(0, len(tau) - 1), len(tau)}) + for h in heads: + for wr, rd, rw in itertools.product(vals, repeat=3): + for eot, rin, rout in ((0, 0, 0), (1, 9, 200), (0, 255, 1), (1, 1, 255)): + mem = [0] * 256 + mem[C_WR], mem[C_RD], mem[C_RW] = wr, rd, rw + mem[C_EOT], mem[R_IN], mem[R_OUT] = eot, rin, rout + mem[0x10] = 0xAB # an ordinary cell, must not move + want_mem, want_h, want_out = spec(mem, tau, h, b"") + + dev = Tape(tau) + dev.h = h + got_mem = list(mem) + dev.apply(got_mem) + n += 1 + if (got_mem != want_mem or dev.h != want_h + or bytes(dev.out) != bytes(want_out)): + bad.append({"tau": len(tau), "h": h, "wr": wr, "rd": rd, + "rw": rw, "eot": eot, + "spec": (want_mem[C_EOT], want_mem[R_IN], + want_mem[R_OUT], want_h, list(want_out)), + "impl": (got_mem[C_EOT], got_mem[R_IN], + got_mem[R_OUT], dev.h, list(dev.out))}) + print(f" device applications compared: {n:,}") + print(f" implementation agrees with Definition 2.8 on every one: " + f"{'yes' if not bad else f'NO ({len(bad)} disagreements)'}") + for b in bad[:5]: + print(" ", b) + + # A request fires on the value 1 and on no other value. The head starts off + # zero so that a rewind is observable; without that a rewind of a head + # already at 0 changes nothing and the test would not see it fire. + fires = [] + for cell in (C_WR, C_RD, C_RW): + for v in range(256): + mem = [0] * 256 + mem[cell] = v + mem[R_OUT] = 0x5A + dev = Tape(b"\x11\x22") + dev.h = 1 + before = (dev.h, len(dev.out)) + dev.apply(mem) + acted = (dev.h, len(dev.out)) != before or mem[R_IN] != 0 or mem[C_EOT] != 0 + if acted != (v == 1): + fires.append((cell, v, acted)) + print(f" each request fires exactly on the value 1, over all 256 cell values: " + f"{'yes' if not fires else f'NO {fires[:5]}'}") + + ok = not bad and not fires + json.dump({"applications": n, "disagreements": len(bad), + "value_trigger_failures": len(fires)}, + open(runs_path("paper_device.json"), "w"), indent=1) + return 0 if ok else 1 + + +def main_environment() -> int: + from host import M_STAR, inst, ser, tau_star, run_reference, read_host, host_netlist, sigma_host + from host import net_of_sigma + from machines import compile_net + + sigma_bytes = read_host() + tau = tau_star(sigma_bytes, M_STAR) + s = ser(sigma_bytes, bytes(M_STAR), tau) + + # sigma: parameters and predecessor entries stored, and the time to write + t0 = time.perf_counter() + net, inputs, outputs = host_netlist() + t_build = time.perf_counter() - t0 + t0 = time.perf_counter() + encoded = sigma_host() + t_sigma = time.perf_counter() - t0 + assert encoded == sigma_bytes + stored, _, _, _ = net_of_sigma(sigma_bytes) + entries = (2 * len(stored.gates) # bias and fan-in + + sum(len(i) for i, _ in stored.gates.values()) + + len(outputs)) + print(f" sigma: {entries:,} stored entries -> {len(encoded):,} bytes " + f"in {t_sigma * 1e3:.0f} ms ({t_build * 1e3:.0f} ms to assemble " + f"the netlist from source)") + + # inst: one pass over the serialized instance, then sigma^-1 on its first field + t0 = time.perf_counter() + reps = 50 + for _ in range(reps): + a, b, c = inst(s) + t_inst = (time.perf_counter() - t0) / reps + assert (a, b, c) == (sigma_bytes, bytes(M_STAR), tau) + t0 = time.perf_counter() + for _ in range(5): + N, I, O, _ = net_of_sigma(a) + t_sigma_inv = (time.perf_counter() - t0) / 5 + assert len(N.gates) == len(stored.gates) + print(f" inst: {len(s):,} bytes parsed in {t_inst * 1e3:.2f} ms, and " + f"{len(N.gates):,} units rebuilt from the first field in " + f"{t_sigma_inv * 1e3:.0f} ms") + + # Lev: the units placed, and the time to place them + t0 = time.perf_counter() + layers, info = compile_net(N, I, O) + t_lev = time.perf_counter() - t0 + M = sum(int(W.shape[0]) for W, _ in layers) + nnz = sum(int((W != 0).sum()) for W, _ in layers) + print(f" Lev: {len(N.gates):,} units in, {M:,} rows and {nnz:,} nonzero " + f"entries placed over {info['layers']} layers in {t_lev:.1f} s") + + # the device: one application per step, at most one byte moved + t0 = time.perf_counter() + emitted, T = run_reference(M_STAR, tau) + t_run = time.perf_counter() - t0 + assert emitted == s + print(f" device: {T:,} applications, {len(emitted):,} bytes emitted " + f"({t_run:.2f} s for the whole reference run)") + + env_ops = len(s) + len(sigma_bytes) + M + nnz + T + closed = 2 * len(sigma_bytes) + len(bytes(M_STAR)) + len(tau) + 16 + M + nnz + T + assert env_ops == closed, (env_ops, closed) + net_ops = T * M + print(f" instantiation: {env_ops:,} elementary operations per generation, " + f"which is the closed form 2|sigma|+|m|+|tau|+16+M+nnz+T") + print(f" network: {net_ops:,} unit evaluations (T = {T:,}, M = {M:,})") + print(f" ratio: {env_ops / net_ops:.3e}") + + out = {"sigma_entries": entries, "sigma_bytes": len(encoded), + "sigma_seconds": t_sigma, "build_seconds": t_build, + "inst_bytes": len(s), "inst_seconds": t_inst, + "sigma_inverse_seconds": t_sigma_inv, + "lev_units_in": len(N.gates), "lev_units_out": M, "lev_nonzero": nnz, + "lev_layers": info["layers"], "lev_seconds": t_lev, + "lev_entries": info["total_weights"], + "steps": T, "emitted_bytes": len(emitted), + "reference_seconds": t_run, + "environment_ops": env_ops, "network_ops": net_ops, + "ratio": env_ops / net_ops} + json.dump(out, open(runs_path("paper_environment.json"), "w"), indent=1) + return 0 + + +def files(): + out = [os.path.join(REPO, "neural_computer.safetensors")] + v = os.path.join(REPO, "variants") + out += sorted(os.path.join(v, f) for f in os.listdir(v) if f.endswith(".safetensors")) + return out + + +def one(p): + data = open(p, "rb").read() + r = describe(data) + out, steps = run_reference(M_P, r, expect=data) + return {"file": os.path.basename(p), "bytes": len(out), "recipe": len(r), + "steps": steps, "exact": out == data, + "sha256": hashlib.sha256(out).hexdigest()} + + +def main_family() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--jobs", type=int, default=os.cpu_count() or 1) + args = ap.parse_args() + t0 = time.perf_counter() + paths = files() + # the largest machines first, so the pool is not left waiting on one of them + order = sorted(paths, key=os.path.getsize, reverse=True) + with cf.ProcessPoolExecutor(max_workers=args.jobs) as ex: + got = dict(zip(order, ex.map(one, order))) + rec = [got[p] for p in paths] + for r in rec: + print(f" {'OK ' if r['exact'] else 'FAIL'} {r['file']:<44} " + f"{r['bytes']:>10,} B {r['steps']:>12,} steps", flush=True) + bad = sum(not r["exact"] for r in rec) + tot_b = sum(r["bytes"] for r in rec) + tot_s = sum(r["steps"] for r in rec) + tot_r = sum(r["recipe"] for r in rec) + dt = time.perf_counter() - t0 + print(f"{len(rec)} machines, {tot_b:,} bytes emitted in {tot_s:,} steps " + f"from {tot_r:,} bytes of recipe ({dt/60:.1f} min); failures: {bad}") + json.dump({"machines": rec, "bytes": tot_b, "steps": tot_s, + "recipe_bytes": tot_r, "failures": bad, "seconds": dt}, + open(runs_path("paper_runs_family.json"), "w"), indent=1) + return 0 if bad == 0 else 1 + +TARGETS = ["neural_tile.safetensors", "neural_ca.safetensors", + "neural_reversible.safetensors", "neural_attractor.safetensors"] + + +def main_family_gate() -> int: + import torch + device = "cuda" if torch.cuda.is_available() else "cpu" + F = NetEvaluator(read_host(), device=device, + graph=device.startswith("cuda")) + rows = [] + bad = 0 + total_b = total_s = 0 + t_all = time.perf_counter() + for name in TARGETS: + path = os.path.join(REPO, "variants", name) + data = open(path, "rb").read() + r = describe(data) + ref_out, ref_steps = run_reference(M_P, r, expect=data) + assert ref_out == data + t0 = time.perf_counter() + out, steps = F.run(M_P, r, max_steps=ref_steps + 8, expect=data, + progress=200000) + dt = time.perf_counter() - t0 + ok = out == data and steps == ref_steps + bad += not ok + total_b += len(out) + total_s += steps + rows.append({"file": name, "bytes": len(out), "recipe": len(r), + "steps": steps, "reference_steps": ref_steps, + "exact": ok, "seconds": dt, + "steps_per_second": steps / dt if dt else None, + "sha256": hashlib.sha256(out).hexdigest()}) + print(f" {'OK ' if ok else 'FAIL'} {name:<34} {len(out):>9,} B " + f"{steps:>10,} steps ({dt / 60:.1f} min, " + f"{steps / dt:,.0f} steps/s)", flush=True) + dt = time.perf_counter() - t_all + print(f" {len(rows)} machines on the netlist of sigma: {total_b:,} bytes in " + f"{total_s:,} steps ({dt / 60:.1f} min); failures: {bad}") + json.dump({"machines": rows, "bytes": total_b, "steps": total_s, + "failures": bad, "seconds": dt}, + open(runs_path("paper_runs_family_gate.json"), "w"), indent=1) + return 0 if bad == 0 else 1 + + +def shapes(): + rng = random.Random(5) + host = open(HOST_PATH, "rb").read() + m0 = bytes(256) + m1 = bytes([255]) * 256 + mr = bytes(rng.randrange(256) for _ in range(256)) + # A payload whose first eight bytes are a plausible length field, and a tape + # that begins the way a framed field begins: if inst read a length from the + # wrong offset these would decode to the wrong split. + trap = lam(1 << 40) + b"trap" + out = [ + (b"", m0, b""), + (b"", m0, b"\x00"), + (b"\x00", m0, b""), + (b"\x00" * 3, m1, b""), + (trap, mr, b""), + (b"", mr, trap), + (trap, m0, trap), + (bytes(range(256)), mr, bytes(range(256))), + (host, bytes(M_STAR), tau_star(host, M_STAR)), + (host, m0, b""), + ] + for _ in range(300): + a = bytes(rng.randrange(256) for _ in range(rng.randrange(0, 300))) + t = bytes(rng.randrange(256) for _ in range(rng.randrange(0, 300))) + out.append((a, bytes(rng.randrange(256) for _ in range(256)), t)) + return out + + +def main_framing() -> int: + cases = shapes() + rt_bad, digests = [], {} + for sg, m, t in cases: + b = ser(sg, m, t) + if inst(b) != (sg, m, t): + rt_bad.append((len(sg), len(t))) + digests.setdefault(hashlib.sha256(b).hexdigest(), []).append((len(sg), len(m), len(t))) + collisions = {k: v for k, v in digests.items() if len(v) > 1} + print(f" inst(ser(Omega)) = Omega on {len(cases)} instances: " + f"{'exact' if not rt_bad else f'{len(rt_bad)} FAILED'}") + print(f" ser injective over them (distinct instances, distinct bytes): " + f"{'yes' if not collisions else f'NO {list(collisions.values())[:2]}'}") + + # Truncations. A string shorter than the framed serialization and memory + # image is not an instance and must be rejected; one that reaches at least + # that far is the instance with a correspondingly shorter tape, and must + # parse to exactly that. + host = open(HOST_PATH, "rb").read() + tau = tau_star(host, M_STAR) + full = ser(host, bytes(M_STAR), tau) + minimal = 16 + len(host) + 256 + probes = sorted({p for p in + [0, 1, 7, 8, 9, 15, 16, 17, + 8 + len(host) - 1, 8 + len(host), 8 + len(host) + 7, + 8 + len(host) + 8, minimal - 256, minimal - 1, minimal, + minimal + 1, len(full) - 1, len(full)] + if 0 <= p <= len(full)}) + wrongly_accepted, wrongly_rejected = [], [] + for k in probes: + try: + got = inst(full[:k]) + except (ValueError, IndexError, struct.error): + if k >= minimal: + wrongly_rejected.append(k) + continue + if k < minimal: + wrongly_accepted.append((k, len(got[1]))) + elif got != (host, bytes(M_STAR), full[minimal:k]): + wrongly_accepted.append((k, "wrong triple")) + short = sum(1 for k in probes if k < minimal) + print(f" {short} truncations below a complete memory image rejected: " + f"{'yes' if not wrongly_accepted else f'NO {wrongly_accepted}'}") + print(f" {len(probes) - short} longer prefixes parse to the instance with " + f"that tape: {'yes' if not wrongly_rejected else f'NO {wrongly_rejected}'}") + accepted = wrongly_accepted + wrongly_rejected + + # a memory field of the wrong length must be rejected + wrong = [] + for size in (0, 1, 255, 257, 512): + b = lam(len(host)) + host + lam(size) + bytes(size) + tau + try: + inst(b) + wrong.append(size) + except (ValueError, IndexError, struct.error): + pass + print(f" memory field of length != 256 rejected: " + f"{'yes' if not wrong else f'NO for {wrong}'}") + + # the full instance is accepted and recovers exactly + exact = inst(full) == (host, bytes(M_STAR), tau) + print(f" the distributed instance parses back exactly: {exact}") + + ok = not rt_bad and not collisions and not accepted and not wrong and exact + json.dump({"instances": len(cases), "roundtrip_failures": len(rt_bad), + "digest_collisions": len(collisions), + "truncations_accepted": len(accepted), + "bad_memory_lengths_accepted": len(wrong), + "distributed_instance_exact": exact}, + open(runs_path("paper_framing.json"), "w"), indent=1) + return 0 if ok else 1 + + +def spec_step(mem, p): + """Definition 3.1, transcribed.""" + A = mem[p % 256] + B = mem[(p + 1) % 256] + C = mem[(p + 2) % 256] + m = list(mem) + r = (m[B % 256] - m[A % 256]) % 256 + m[B % 256] = r + two_c = r - 256 if r >= 128 else r + return m, (C if two_c <= 0 else (p + 3) % 256) + + +def impl_step(mem, p): + """The reference of selfrep.run_reference, one step, device detached.""" + m = list(mem) + A = m[p] + B = m[(p + 1) & 0xFF] + C = m[(p + 2) & 0xFF] + r = (m[B] - m[A]) & 0xFF + m[B] = r + return m, (C if (r == 0 or r >= 0x80) else (p + 3) & 0xFF) + + +def main_host() -> int: + rng = random.Random(31) + bad, n = [], 0 + mems = [[0] * 256, [255] * 256, list(range(256)), list(reversed(range(256)))] + mems += [[rng.randrange(256) for _ in range(256)] for _ in range(60)] + for mem in mems: + for p in range(256): # every counter, wraps included + if p == HALT_PC: + continue + sm, sp = spec_step(mem, p) + im, ip = impl_step(mem, p) + n += 1 + if sm != im or sp != ip: + bad.append((p, sp, ip)) + print(f" reference steps compared with Definition 3.1: {n:,}") + print(f" agree on every one: {'yes' if not bad else f'NO {bad[:4]}'}") + + wrapped = sum(1 for p in (254, 255, 253) if p != HALT_PC) * len(mems) + print(f" of which {len(mems) * 3:,} fetch an operand across the top of memory") + + # A halted state is a fixed point of the levelized map. The state is halted + # when the halt bit is set, coordinate 8 of the vector; the bit latches one + # step after the next counter decodes to 255, so setting the counter alone + # does not halt the machine. + from host import LevEvaluator, read_host + import torch + L = LevEvaluator(read_host(), device="cpu", dense=False) + gen = torch.Generator().manual_seed(97) + V = (torch.rand(48, L.N, generator=gen) < 0.5).float() + V[:, 8] = 1.0 + fixed = bool((L.step(V) == V).all()) + print(f" halted states are fixed points of Lev(N_host), 48 random states: " + f"{'yes' if fixed else 'NO'}") + + # And the bit latches exactly when the next counter is 255: run one step + # from unhalted states and compare the latched bit against the reference. + U = (torch.rand(64, L.N, generator=gen) < 0.5).float() + U[:, 8] = 0.0 + W = L.step(U) + latch_bad = 0 + for i in range(U.shape[0]): + mem = [int(sum(int(U[i, 9 + j * 8 + k]) << (7 - k) for k in range(8))) + for j in range(256)] + pc = int(sum(int(U[i, k]) << (7 - k) for k in range(8))) + _, npc = impl_step(mem, pc) + if int(W[i, 8]) != (1 if npc == HALT_PC else 0): + latch_bad += 1 + print(f" the halt bit latches exactly when the next counter is 255, " + f"64 random states: {'yes' if latch_bad == 0 else f'NO ({latch_bad})'}") + fixed = fixed and latch_bad == 0 + + ok = not bad and fixed + json.dump({"steps_compared": n, "disagreements": len(bad), + "halt_fixed_point": fixed}, + open(runs_path("paper_host.json"), "w"), indent=1) + return 0 if ok else 1 + + +def main_host_family() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--widths", default="3-12") + ap.add_argument("--states", type=int, default=1 << 16) + ap.add_argument("--cc", default="gcc") + args = ap.parse_args() + lo, hi = (int(x) for x in args.widths.split("-")) + from host import sigma + work = tempfile.mkdtemp(prefix="host_family_") + exe = os.path.join(work, "netlist_eval") + subprocess.run([args.cc, "-O3", "-march=native", "-fopenmp", "-o", exe, + os.path.join(REPO, "src", "netlist_eval.c")], check=True) + rows, ok = [], True + for A in range(lo, hi + 1): + t0 = time.perf_counter() + s = sigma(A) + n, units, nxt = parse_sigma(s) + path = os.path.join(work, f"h{A}.bin") + write_flat(path, n, units, nxt) + N = max(64, args.states >> max(0, A - 10)) + r = subprocess.run([exe, "rand", path, str(N), str(A)], capture_output=True, text=True) + line = r.stdout.strip() + good = r.returncode == 0 + ok &= good + rows.append({"A": A, "sigma_bytes": len(s), "units": len(units), + "edges": sum(len(i) for _, i in units), "states": N, + "result": line, "ok": good, "seconds": time.perf_counter() - t0}) + print(f" A={A}: {len(units):,} units, {len(s):,} bytes; {line}", flush=True) + os.remove(path) + json.dump({"widths": rows, "ok": ok}, open(runs_path("paper_host_family.json"), "w"), indent=1) + print("HOST FAMILY:", "PASS" if ok else "FAIL") + return 0 if ok else 1 + + +def depth_of(n, units, nxt): + d = [0] * (n + len(units)) + for j, (_, ins) in enumerate(units): + d[n + j] = 1 + max((d[k] for k, _ in ins), default=0) + return max(d[s] for s in nxt) + + +def main_host_fold() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--widths", default="3-12") + ap.add_argument("--states", type=int, default=1 << 16) + ap.add_argument("--cc", default="gcc") + args = ap.parse_args() + lo, hi = (int(x) for x in args.widths.split("-")) + from host import build, family_sigma_of_net as sigma_of_net, sigma + work = tempfile.mkdtemp(prefix="host_fold_") + exe = os.path.join(work, "netlist_eval") + subprocess.run([args.cc, "-O3", "-march=native", "-fopenmp", "-o", exe, + os.path.join(REPO, "src", "netlist_eval.c")], check=True) + rows, ok = [], True + for A in range(lo, hi + 1): + t0 = time.perf_counter() + net, inputs, outputs = build(A, fold=True) + s = sigma_of_net(net, inputs, outputs, A) + n, units, nxt = parse_sigma(s) + n0, units0, nxt0 = parse_sigma(sigma(A)) + path = os.path.join(work, f"f{A}.bin") + write_flat(path, n, units, nxt) + N = max(64, args.states >> max(0, A - 10)) + r = subprocess.run([exe, "rand", path, str(N), str(A)], capture_output=True, text=True) + line = r.stdout.strip().splitlines()[-1] if r.stdout.strip() else r.stderr.strip() + good = r.returncode == 0 + ok &= good + row = {"A": A, "units": len(units), "edges": sum(len(i) for _, i in units), + "depth": depth_of(n, units, nxt), "depth_unfolded": depth_of(n0, units0, nxt0), + "units_unfolded": len(units0), "edges_unfolded": sum(len(i) for _, i in units0), + "states": N, "result": line, "agrees": good, "seconds": round(time.perf_counter() - t0, 1)} + rows.append(row) + print(f" A={A}: {row['units']:,} units, {row['edges']:,} edges, depth {row['depth']} " + f"(H_A: {row['units_unfolded']:,}, {row['edges_unfolded']:,}, depth {row['depth_unfolded']}); " + f"{line}", flush=True) + json.dump({"rows": rows, "all_agree": ok}, + open(os.path.join(REPO, "paper", "runs", "paper_host_fold.json"), "w"), indent=1) + return 0 if ok else 1 + + +def gate_by_gate(net, inputs, outputs, V): + """Evaluate the netlist directly: topological order, one gate at a time.""" + gates = net.gates + indeg, cons = {}, {} + for g, (ins, _) in gates.items(): + indeg[g] = len([s for s, _ in ins if s in gates]) + for s, _ in ins: + cons.setdefault(s, []).append(g) + order = [g for g, d in indeg.items() if d == 0] + i = 0 + while i < len(order): + for c in cons.get(order[i], []): + indeg[c] -= 1 + if indeg[c] == 0: + order.append(c) + i += 1 + assert len(order) == len(gates) + + B = V.shape[0] + val = {"#0": torch.zeros(B), "#1": torch.ones(B)} + for k, name in enumerate(inputs): + val[name] = V[:, k] + for g in order: + ins, bias = gates[g] + acc = torch.full((B,), float(bias)) + for s, w in ins: + acc = acc + w * val[s] + val[g] = (acc >= 0).float() + return torch.stack([val[o] for o in outputs], dim=1) + + +def main_lev() -> int: + from host import net_of_sigma + from host import LevEvaluator, NetEvaluator, read_host + + sigma = read_host() + net, inputs, outputs, _ = net_of_sigma(sigma) + L = LevEvaluator(sigma, device="cpu", dense=False) + + gen = torch.Generator().manual_seed(20260910) + B = 96 + V = (torch.rand(B, len(inputs), generator=gen) < 0.5).float() + + direct = gate_by_gate(net, inputs, outputs, V) + levelled = L.step(V) + agree = bool((direct == levelled).all()) + print(f" netlist has {len(net.gates):,} gates on {len(inputs):,} state bits") + print(f" gate-by-gate evaluation equals Lev(N_host) on {B} uniformly random " + f"states: {'yes' if agree else 'NO'}") + if not agree: + d = (direct != levelled).nonzero() + print(" first disagreements:", d[:5].tolist()) + + grouped = NetEvaluator(sigma, device="cpu").step(V) + agree_net = bool((direct == grouped).all()) + print(f" the evaluator that carries only the netlist's own predecessor " + f"entries agrees on the same states: {'yes' if agree_net else 'NO'}") + + # The dense form: ternary weights, integer pre-activations, margin one half. + D = LevEvaluator(sigma, device="cpu", dense=True) + nonternary = [i for i, W in enumerate(D.W) + if not set(torch.unique(W).tolist()) <= {-1.0, 0.0, 1.0}] + nonint = [i for i, b in enumerate(D.B) if not bool((b == b.round()).all())] + print(f" every weight of all {len(D.W)} layers lies in {{-1,0,1}}: " + f"{'yes' if not nonternary else f'NO at {nonternary[:3]}'}") + print(f" every bias is an integer: {'yes' if not nonint else f'NO at {nonint[:3]}'}") + + y, worst, frac = V, float("inf"), 0.0 + for W, b in zip(D.W, D.B): + pre = y @ W.T + b + worst = min(worst, float((pre + 0.5).abs().min())) + frac = max(frac, float((pre - pre.round()).abs().max())) + y = (pre >= 0).float() + same_dense = bool((y == levelled).all()) + print(f" dense stack agrees with the sparse evaluation on the same states: " + f"{'yes' if same_dense else 'NO'}") + print(f" minimum distance of a pre-activation from -1/2 over all " + f"{len(D.W)} layers: {worst:.6f}") + print(f" maximum distance of a pre-activation from an integer: {frac:.3e}") + + ok = (agree and agree_net and same_dense and not nonternary + and not nonint and abs(worst - 0.5) < 1e-9) + json.dump({"states": B, "gates": len(net.gates), "agree": agree, + "agree_grouped": agree_net, + "dense_agree": same_dense, "nonternary_layers": len(nonternary), + "noninteger_bias_layers": len(nonint), + "min_margin": worst, "max_noninteger": frac}, + open(runs_path("paper_lev.json"), "w"), indent=1) + return 0 if ok else 1 + + +def shortest(f: bytes) -> int: + """Length of a shortest r in R with delta(r) = f, by dynamic programming.""" + n = len(f) + INF = float("inf") + cost = [INF] * (n + 1) + cost[n] = 0 + for i in range(n - 1, -1, -1): + best = INF + for c in range(1, min(127, n - i) + 1): + v = cost[i + c] + c + 1 # literal token + if v < best: + best = v + run = 0 + while run < min(127, n - i) and f[i + run] == f[i]: + run += 1 + v = cost[i + run] + 2 # repeat token + if v < best: + best = v + cost[i] = best + return cost[0] + 1 + + +def run_free(f: bytes) -> bool: + return all(f[i] != f[i + 1] for i in range(len(f) - 1)) + + +def main_optimal() -> int: + out = {} + ok = True + + # Exhaustive over ternary strings with no two equal adjacent bytes. + tested = 0 + bad = [] + for n in range(0, 11): + for t in itertools.product((0, 1, 2), repeat=n): + f = bytes(t) + if not run_free(f): + continue + tested += 1 + s = shortest(f) + if s != bound(n) or len(describe_literal(f)) != bound(n): + bad.append(n) + print(f" every ternary string of length at most 10 with no two equal " + f"adjacent bytes ({tested:,} of them): the shortest recipe has " + f"exactly |f| + ceil(|f|/127) + 1 bytes: " + f"{'yes' if not bad else f'NO at lengths {sorted(set(bad))[:5]}'}") + ok &= not bad + out["run_free_exhaustive"] = tested + out["run_free_failures"] = len(bad) + + # Longer run-free strings over the full alphabet, including the lengths at + # which a further literal token becomes necessary. + rng = random.Random(4) + lens = [0, 1, 126, 127, 128, 253, 254, 255, 380, 381, 508, 509, 700] + lens += [rng.randrange(0, 900) for _ in range(120)] + lbad = [] + for n in lens: + f = bytearray() + prev = -1 + for _ in range(n): + c = rng.randrange(256) + while c == prev: + c = rng.randrange(256) + f.append(c) + prev = c + f = bytes(f) + if shortest(f) != bound(n) or len(describe_literal(f)) != bound(n): + lbad.append(n) + print(f" {len(lens)} further run-free strings, lengths to 900 and at every " + f"multiple of 127: shortest recipe equals the bound: " + f"{'yes' if not lbad else f'NO at {lbad[:5]}'}") + ok &= not lbad + out["run_free_sampled"] = len(lens) + out["run_free_sampled_failures"] = len(lbad) + + # On arbitrary strings the bound is an upper bound only; record how far the + # encoder of the artifact is from the optimum. + gaps = [] + abad = 0 + for _ in range(300): + n = rng.randrange(0, 600) + alpha = rng.choice([1, 2, 3, 9, 256]) + f = bytes(rng.randrange(alpha) for _ in range(n)) + s = shortest(f) + d = len(describe(f)) + if decode(describe(f)) != f or d < s or d > bound(n): + abad += 1 + gaps.append(d - s) + print(f" 300 arbitrary strings: describe is never shorter than the optimum " + f"and never longer than the bound: {'yes' if abad == 0 else 'NO'}; " + f"excess over the optimum: max {max(gaps)}, mean {sum(gaps)/len(gaps):.2f}") + ok &= abad == 0 + out["arbitrary_strings"] = len(gaps) + out["arbitrary_failures"] = abad + out["describe_excess_max"] = max(gaps) + out["describe_excess_mean"] = sum(gaps) / len(gaps) + + # The serialization of the host is not run-free, so its optimum is shorter. + from host import HOST_PATH + sigma = open(HOST_PATH, "rb").read() + s = shortest(sigma) + print(f" sigma(N_host): {len(sigma):,} bytes; bound {bound(len(sigma)):,}, " + f"describe {len(describe(sigma)):,}, shortest recipe {s:,}") + out["sigma_bytes"] = len(sigma) + out["sigma_bound"] = bound(len(sigma)) + out["sigma_describe"] = len(describe(sigma)) + out["sigma_shortest"] = s + ok &= s <= len(describe(sigma)) <= bound(len(sigma)) + + json.dump(out, open(runs_path("paper_optimal.json"), "w"), indent=1) + return 0 if ok else 1 + +ROCQ = os.path.join(REPO, 'rocq') + +RECORD = os.path.join(REPO, 'paper', 'runs', 'paper_rocq.json') + + +def main_rocq(): + for tool in ('rocq', 'make', 'ocamlfind'): + if shutil.which(tool) is None: + print(f'{tool} not found on PATH') + return 1 + version = subprocess.run(['rocq', '--version'], capture_output=True, text=True).stdout.strip() + t0 = time.time() + subprocess.run(['rocq', 'makefile', '-f', '_CoqProject', '-o', 'Makefile'], cwd=ROCQ, check=True) + subprocess.run(['make', 'clean'], cwd=ROCQ, check=True, capture_output=True) + jobs = str(os.cpu_count() or 1) + if subprocess.run(['make', '-j', jobs], cwd=ROCQ).returncode != 0: + print('the build failed') + return 1 + build = time.time() - t0 + out = subprocess.run(['rocq', 'compile', '-R', '.', 'TH', 'Assumptions.v'], cwd=ROCQ, + capture_output=True, text=True) + for f in os.listdir(ROCQ): + if f.startswith('Assumptions.') and f != 'Assumptions.v' or f == '.Assumptions.aux': + os.remove(os.path.join(ROCQ, f)) + if out.returncode != 0: + print(out.stdout, out.stderr) + return 1 + names = re.findall(r'^Print Assumptions (\S+)\.$', open(os.path.join(ROCQ, 'Assumptions.v')).read(), re.M) + lines = [l.strip() for l in out.stdout.splitlines() if l.strip()] + if len(lines) != len(names) or any(l != 'Closed under the global context' for l in lines): + print(out.stdout) + return 1 + ext = subprocess.run([sys.executable, os.path.join(REPO, 'src', 'twohead.py'), 'net', 'extract'], + capture_output=True, text=True) + identical = ext.returncode == 0 and '"identical": true' in ext.stdout + files = [l for l in open(os.path.join(ROCQ, '_CoqProject')).read().split() if l.endswith('.v')] + res = dict(rocq=version.splitlines()[0], files=len(files), + build_seconds=round(build, 1), theorems={n: 'closed' for n in names}, + extraction_identical=identical) + rec = json.load(open(RECORD)) if os.path.exists(RECORD) else {} + rec.update(res) + json.dump(rec, open(RECORD, 'w'), indent=1) + print(json.dumps(res, indent=1)) + return 0 if identical else 1 + +_DTORD = {"F64": 0, "F32": 1, "F16": 2, "I64": 3, "I32": 4, "I16": 5, "I8": 6, + "U8": 7, "BOOL": 8} + + +def resigma(data: bytes) -> bytes: + """Serialize the netlist that `data` denotes.""" + net, inputs, outputs, meta = net_of_sigma(data) + return sigma_of_net(net, inputs, outputs, meta["machine"], + json.loads(meta["signal_registry"]), + json.loads(meta["io"])) + + +def main_sigma() -> int: + rebuilt = sigma_host() + dist = open(HOST_PATH, "rb").read() + same = rebuilt == dist + n = struct.unpack(" int: + """Closed form for the run of the constructor P on a recipe.""" + lit, rep, f_lit, f_rep = census(r) + return 5 * f_lit + 4 * f_rep + 7 * lit + 9 * rep + 8 + + +def steps_P_star(tau: bytes): + """Closed form for the three phases of P* on its own description tape.""" + lit, rep, f_lit, f_rep = census(tau) + a = 5 * f_lit + 4 * f_rep + 7 * lit + 9 * rep + 7 + return {"A": a, "R": 1, "B": 6 * len(tau) + 2} + + +def upper_bound(r: bytes) -> int: + """5|delta(r)| + 9k + 8 with k the number of tokens, k <= (|r|-1)/2.""" + lit, rep, f_lit, f_rep = census(r) + return 5 * (f_lit + f_rep) + 9 * (lit + rep) + 8 + + +def coarse_bound(r: bytes) -> int: + return 5 * len(decode(r)) + (9 * (len(r) - 1)) // 2 + 8 + + +def phase_split(mem0, tau: bytes): + mem = list(mem0) + dev = Tape(tau) + pc = 0 + counts = {"A": 0, "R": 0, "B": 0} + while pc != HALT_PC: + counts["A" if pc < 3 * 20 else ("R" if pc == 3 * 20 else "B")] += 1 + A = mem[pc] + B = mem[(pc + 1) & 0xFF] + C = mem[(pc + 2) & 0xFF] + r = (mem[B] - mem[A]) & 0xFF + mem[B] = r + pc = C if (r == 0 or r >= 0x80) else (pc + 3) & 0xFF + dev.apply(mem) + return counts + + +def main_steps() -> int: + out = {} + ok = True + sigma = open(HOST_PATH, "rb").read() + + named = [("r_H = describe(sigma(N_host))", describe(sigma)), + ("the all-literal encoding of sigma(N_host)", describe_literal(sigma)), + ("tau* (the self-reproducing tape)", tau_star(sigma, M_STAR))] + rows = [] + for label, r in named: + _, n = run_reference(M_P, r) + pred = steps_P(r) + rows.append({"tape": label, "recipe_bytes": len(r), "output_bytes": + len(decode(r)), "steps": n, "closed_form": pred, + "agrees": n == pred, "upper": upper_bound(r), + "coarse": coarse_bound(r)}) + print(f" {label}: {n:,} steps, closed form {pred:,}, " + f"{'agree' if n == pred else 'DISAGREE'}; " + f"bound 5|f|+9k+8 = {upper_bound(r):,}", flush=True) + ok &= n == pred and n <= upper_bound(r) <= coarse_bound(r) + out["named"] = rows + + # random recipes: both encoders, and strings built to force each token kind + rng = random.Random(1009) + bad = 0 + tested = 0 + for _ in range(400): + n = rng.randrange(0, 700) + alpha = rng.choice([1, 2, 3, 17, 256]) + f = bytes(rng.randrange(alpha) for _ in range(n)) + for enc in (describe, describe_literal): + r = enc(f) + _, s = run_reference(M_P, r) + tested += 1 + if s != steps_P(r) or s > upper_bound(r) or upper_bound(r) > coarse_bound(r): + bad += 1 + print(f" {tested:,} random runs of P: closed form exact and within the " + f"bound on every one: {'yes' if bad == 0 else f'NO ({bad})'}", flush=True) + ok &= bad == 0 + out["random_runs"] = tested + out["random_failures"] = bad + + # the three phases of P* + tau = tau_star(sigma, M_STAR) + measured = phase_split(M_STAR, tau) + pred = steps_P_star(tau) + agree = measured == pred + print(f" P* phases measured {measured}, closed form {pred}: " + f"{'agree' if agree else 'DISAGREE'}", flush=True) + print(f" total {sum(measured.values()):,} = {sum(pred.values()):,}", flush=True) + ok &= agree + out["phases_measured"] = measured + out["phases_closed_form"] = pred + out["total"] = sum(pred.values()) + + # phase B is six steps per tape byte and two more, over recipes of every + # shape; P* is run only on recipes, on which it halts + bbad = 0 + probes = 0 + for _ in range(40): + for enc in (describe, describe_literal): + probe = enc(bytes(rng.randrange(rng.choice([2, 4, 256])) + for _ in range(rng.randrange(0, 200)))) + got = phase_split(M_STAR, probe) + probes += 1 + if got != steps_P_star(probe): + bbad += 1 + print(f" phase B = 6|tau| + 2 and phase R = 1 on {probes} further recipes: " + f"{'yes' if bbad == 0 else f'NO ({bbad})'}", flush=True) + ok &= bbad == 0 + out["phase_b_failures"] = bbad + + json.dump(out, open(runs_path("paper_steps.json"), "w"), indent=1) + return 0 if ok else 1 + + +def predicate(n: int): + """[r <= 0] for r read in two's complement on `n` bits, as a 2^n vector.""" + rows = [] + for v in range(1 << n): + two_c = v - (1 << n) if v >> (n - 1) else v + rows.append(1 if two_c <= 0 else 0) + return torch.tensor(rows, dtype=torch.long) + + +def bitmatrix(n: int): + """Row v holds the bits r_0 .. r_{n-1} of v, least significant first.""" + return torch.tensor([[(v >> i) & 1 for i in range(n)] for v in range(1 << n)], + dtype=torch.long) + + +def ternary_units_computing(n: int, target, bias_range): + """Every (w, b) in {-1,0,1}^n x bias_range whose unit computes `target`.""" + X = bitmatrix(n) + found = [] + total = 0 + for w in itertools.product((-1, 0, 1), repeat=n): + pre = X @ torch.tensor(w, dtype=torch.long) + for b in bias_range: + total += 1 + if bool(((pre + b >= 0).long() == target).all()): + found.append((w, b)) + return found, total + + +def main_ternarity() -> int: + out = {} + ok = True + + # The predicate is a threshold function of the eight result bits, realized + # by one unit whose weights are the positional values of the bits. + n = 8 + target = predicate(n) + X = bitmatrix(n) + big = torch.tensor([-(1 << i) for i in range(n - 1)] + [1 << (n - 1)], + dtype=torch.long) + exact = bool(((X @ big >= 0).long() == target).all()) + print(f" one integer-weight unit with weights " + f"(-1,-2,-4,-8,-16,-32,-64,+128) and bias 0 computes [r <= 0] on all " + f"{1 << n} results: {'yes' if exact else 'NO'}") + ok &= exact + out["integer_unit_exact"] = exact + out["integer_unit_max_weight"] = int(big.abs().max()) + + # No ternary unit does, for any integer bias. A bias outside [-n, n] makes + # the unit constant, so the sweep below is exhaustive over all biases. + bias_range = range(-(n + 2), n + 3) + found, total = ternary_units_computing(n, target, bias_range) + print(f" ternary units on the eight result bits enumerated: {total:,} " + f"(all {3 ** n:,} weight vectors, biases {bias_range.start} to " + f"{bias_range.stop - 1})") + print(f" ternary units computing [r <= 0]: {len(found)}" + f"{' (none, as the proposition asserts)' if not found else f' {found[:3]}'}") + ok &= not found + out["ternary_units_enumerated"] = total + out["ternary_units_computing_predicate"] = len(found) + # A unit with p weights +1 and q weights -1 is constant unless + # -p <= b <= q - 1; those biases lie inside the swept range. + nonconst = sum(sum(1 for x in w if x) for w in itertools.product((-1, 0, 1), repeat=n)) + print(f" of these, {nonconst:,} are not constant, and none of them computes " + f"[r <= 0]") + out["nonconstant_ternary_units"] = nonconst + + # The separation begins at three bits: on two bits the predicate is ternary. + firstfail = None + widths = {} + for m in range(2, 9): + f, _ = ternary_units_computing(m, predicate(m), range(-(m + 2), m + 3)) + widths[m] = len(f) + if not f and firstfail is None: + firstfail = m + print(f" ternary realizations by width: {widths}; the predicate stops " + f"being a ternary threshold function at {firstfail} bits") + out["ternary_realizations_by_width"] = widths + out["first_width_without_ternary_unit"] = firstfail + ok &= firstfail == 3 + + # Depth two suffices, by the cell the host uses. + z = (X.sum(dim=1) == 0).long() + leq = ((X[:, n - 1] + z) >= 1).long() + two = bool((leq == target).all()) + print(f" the depth-two cell OR(r_7, z(r)) of the host computes it on all " + f"{1 << n} results: {'yes' if two else 'NO'}") + ok &= two + out["depth_two_cell_exact"] = two + + # The 256-way address decoder is not an instance of the same exchange: it + # is one ternary unit already. + from machines import Net + net = Net() + bits = [f"a{i}" for i in range(8)] + net.DECODE("sel", bits, 0xA7) + ins, bias = net.gates["sel"] + dec_ternary = all(w in (-1, 0, 1) for _, w in ins) + A = bitmatrix(8) + # DECODE reads its bits most significant first + w = torch.tensor([wt for _, wt in ins], dtype=torch.long) + pre = A.flip(1) @ w + bias + hit = (pre >= 0).long() + dec_exact = bool((hit == (torch.arange(256) == 0xA7).long()).all()) + print(f" the address decoder of the memory is one ternary unit of fan-in " + f"eight and fires on exactly one address: " + f"{'yes' if dec_ternary and dec_exact else 'NO'}") + ok &= dec_ternary and dec_exact + out["decoder_is_one_ternary_unit"] = dec_ternary and dec_exact + + json.dump(out, open(runs_path("paper_ternarity.json"), "w"), indent=1) + return 0 if ok else 1 + +BUDGET = 1 << 22 + + +def sha(b: bytes) -> str: + return hashlib.sha256(b).hexdigest() + + +def out_path(name: str) -> str: + d = os.path.join(REPO, "paper", "runs") + os.makedirs(d, exist_ok=True) + return os.path.join(d, f"paper_runs_{name}.json") + + +def runs_phase_split(mem0: List[int], tau: bytes) -> Dict[str, int]: + """Steps spent in each phase of P*, counted on the reference.""" + mem = list(mem0) + dev = Tape(tau) + pc = 0 + counts = {"A": 0, "R": 0, "B": 0} + while pc != HALT_PC: + phase = "A" if pc < 3 * 20 else ("R" if pc == 3 * 20 else "B") + counts[phase] += 1 + A = mem[pc] + B = mem[(pc + 1) & 0xFF] + C = mem[(pc + 2) & 0xFF] + r = (mem[B] - mem[A]) & 0xFF + mem[B] = r + pc = C if (r == 0 or r >= 0x80) else (pc + 3) & 0xFF + dev.apply(mem) + return counts + + +class Reference: + """The integer reference, in the interface the generations loop expects.""" + + info = {"evaluator": "integer reference"} + build_seconds = 0.0 + + def __init__(self, sigma: bytes): + self.sigma = sigma + + def run(self, mem0, tau, max_steps, expect=None, progress=0): + return run_reference(mem0, tau, max_steps=max_steps, expect=expect) + + +def generations(build, label: str, n_gen: int = 3, progress: int = 0) -> Dict: + """Run n_gen generations, each instantiated only from the previous output.""" + sigma0 = read_host() + tau0 = tau_star(sigma0, M_STAR) + target = ser(sigma0, bytes(M_STAR), tau0) + rec = [] + sigma, m, tau = sigma0, list(M_STAR), tau0 + for k in range(n_gen): + t0 = time.perf_counter() + ev = build(sigma) + t_build = time.perf_counter() - t0 + t0 = time.perf_counter() + out, steps = ev.run(m, tau, max_steps=BUDGET, expect=target, + progress=progress) + dt = time.perf_counter() - t0 + d = sha(out) + rec.append({"generation": k + 1, "bytes": len(out), "steps": steps, + "sha256": d, "seconds": dt, "instantiate_seconds": t_build, + "equals_ser": out == ser(sigma, bytes(m), tau)}) + print(f" [{label}] generation {k+1}: instantiated in " + f"{t_build:.1f} s, {len(out):,} bytes in {steps:,} steps, " + f"{dt/60:.1f} min, sha {d[:16]}, " + f"Out = ser(Omega): {rec[-1]['equals_ser']}", flush=True) + sigma, mb, tau = inst(out) + m = list(mb) + if sigma != sigma0 or m != list(M_STAR) or tau != tau0: + raise AssertionError(f"generation {k+1} did not reproduce the instance") + digests = {r["sha256"] for r in rec} + print(f" [{label}] all {n_gen} generations identical: {len(digests) == 1}", + flush=True) + return {"generations": rec, "digests_coincide": len(digests) == 1, + "target_sha256": sha(target), "target_bytes": len(target)} + + +def main_runs() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("mode", choices=["reference", "net", "lev", "lockstep"]) + ap.add_argument("--generations", type=int, default=3) + ap.add_argument("--device", default="cuda") + ap.add_argument("--lockstep-steps", type=int, default=20000) + ap.add_argument("--dense", action="store_true", + help="evaluate Lev(N_host) as dense matrices") + args = ap.parse_args() + + sigma = read_host() + r_H = describe(sigma) + tau = tau_star(sigma, M_STAR) + target = ser(sigma, bytes(M_STAR), tau) + res: Dict = {"sigma_bytes": len(sigma), "sigma_sha256": sha(sigma), + "r_H_bytes": len(r_H), "tau_star_bytes": len(tau), + "ser_bytes": len(target), "ser_sha256": sha(target), + "P_instructions": len(P), "P_star_instructions": len(P_STAR)} + + if args.mode == "reference": + print("[reference] self-description") + t0 = time.perf_counter() + out, n = run_reference(M_P, r_H, expect=sigma) + res["selfdesc"] = {"bytes": len(out), "steps": n, + "seconds": time.perf_counter() - t0, + "exact": out == sigma, "sha256": sha(out)} + print(f" {len(out):,} bytes in {n:,} steps, exact={out == sigma}") + print("[reference] self-reproduction, three generations") + res["selfrep"] = generations(Reference, "reference", args.generations) + res["phase_steps"] = runs_phase_split(M_STAR, tau) + print(f" phase split: {res['phase_steps']}") + + elif args.mode == "net": + build = lambda s: NetEvaluator(s, device=args.device, + graph=args.device.startswith("cuda")) + E = build(sigma) + res["evaluator"] = "the netlist of sigma, unit by unit" + res["net_info"] = {k: int(v) for k, v in E.info.items()} + print(f"[net] {E.info['units']:,} units over {E.info['levels']} levels, " + f"{E.info['entries']:,} predecessor entries, built in " + f"{E.build_seconds:.1f} s", flush=True) + print("[net] self-description", flush=True) + t0 = time.perf_counter() + out, n = E.run(M_P, r_H, max_steps=BUDGET, expect=sigma, progress=200000) + res["selfdesc"] = {"bytes": len(out), "steps": n, + "seconds": time.perf_counter() - t0, + "exact": out == sigma, "sha256": sha(out)} + print(f" {len(out):,} bytes in {n:,} steps " + f"({res['selfdesc']['seconds']/60:.1f} min), exact={out == sigma}") + print("[net] self-reproduction, three generations", flush=True) + res["selfrep"] = generations(build, "net", args.generations, 200000) + + elif args.mode == "lev": + build = lambda s: LevEvaluator( + s, device=args.device, dense=args.dense, + graph=(not args.dense) and args.device.startswith("cuda")) + E = build(sigma) + res["lev_info"] = {k: int(v) for k, v in E.info.items() + if isinstance(v, (int, float))} + res["lev_form"] = ("dense matrices" if args.dense + else "nonzero entries, CUDA graph") + print(f"[lev] {E.info['size']:,} units over {E.info['layers']} layers, " + f"maximal width {E.info['max_width']:,}, " + f"{E.info['total_weights']:,} entries of which " + f"{E.info['nonzero']:,} are nonzero; built in " + f"{E.build_seconds:.1f} s as " + f"{'dense matrices' if args.dense else 'its nonzero entries'}", + flush=True) + print("[lev] self-reproduction", flush=True) + res["selfrep"] = generations(build, "lev", args.generations, 200000) + + elif args.mode == "lockstep": + print(f"[lockstep] reference vs the netlist of sigma vs Lev(N_host) " + f"dense and sparse, {args.lockstep_steps:,} steps of generation 1", + flush=True) + import torch + N = NetEvaluator(sigma, device=args.device, + graph=args.device.startswith("cuda")) + D = LevEvaluator(sigma, device=args.device, dense=True) + S = LevEvaluator(sigma, device=args.device, dense=False) + rmem = list(M_STAR); rpc = 0; rdev = Tape(tau) + states, devs = [], [] + for E in (N, D, S): + states.append(E._vec(0, M_STAR).unsqueeze(0).to(args.device)) + devs.append(Tape(tau)) + pow2 = [1 << (7 - k) for k in range(8)] + bad = 0 + t0 = time.perf_counter() + for n in range(args.lockstep_steps): + A = rmem[rpc]; B = rmem[(rpc + 1) & 0xFF]; C = rmem[(rpc + 2) & 0xFF] + r = (rmem[B] - rmem[A]) & 0xFF + rmem[B] = r + rpc = C if (r == 0 or r >= 0x80) else (rpc + 3) & 0xFF + rdev.apply(rmem) + mems, pcs = [], [] + for i, E in enumerate((N, D, S)): + v = E.step(states[i]) + vc = v[0].cpu() + cells = {j: E._byte(vc, j) for j in E._DEVCELLS} + sh = [0] * 256 + for j, val in cells.items(): + sh[j] = val + devs[i].apply(sh) + for j, val in cells.items(): + if sh[j] != val: + E._set_byte(v, j, sh[j]) + states[i] = v + vc = v[0].cpu() + pcs.append(sum(p for p, k in zip(pow2, range(8)) + if int(vc[k]))) + mems.append([E._byte(vc, j) for j in range(256)]) + if not (all(m == rmem for m in mems) and all(p == rpc for p in pcs) + and all(d.out == rdev.out for d in devs) + and all(d.h == rdev.h for d in devs)): + print(f" DIVERGENCE at step {n}") + bad += 1 + break + if n and n % 2000 == 0: + print(f" {n:,} steps agreed " + f"({n/(time.perf_counter()-t0):.0f} steps/s, " + f"{len(rdev.out):,} bytes)", flush=True) + res["lockstep"] = {"steps": args.lockstep_steps, "divergences": bad, + "bytes": len(rdev.out), + "seconds": time.perf_counter() - t0} + print(f" {args.lockstep_steps:,} steps: full state, tape head and " + f"output word equal on all four evaluators: {bad == 0}") + + name = args.mode + if args.mode == "lev": + name = "lev_dense" if args.dense else "lev_sparse" + p = out_path(name) + json.dump(res, open(p, "w"), indent=1) + print(f"wrote {p}") + return 0 + + +def levelize_net(net, inputs: List[str], outputs: List[str]) -> Dict: + """The level assignment of matrix8.compile_net, counted and not built.""" + allgates = net.gates + level: Dict[str, int] = {s: 0 for s in inputs} + consumers: Dict[str, List[str]] = defaultdict(list) + indeg: Dict[str, int] = {} + for gname, (ins, _) in allgates.items(): + indeg[gname] = len([s for s, _ in ins if s in allgates]) + for s, _ in ins: + consumers[s].append(gname) + order = [g for g, d in indeg.items() if d == 0] + i = 0 + while i < len(order): + for c in consumers.get(order[i], []): + indeg[c] -= 1 + if indeg[c] == 0: + order.append(c) + i += 1 + if len(order) != len(allgates): + raise ValueError("cycle") + for gname in order: + ins, _ = allgates[gname] + level[gname] = 1 + max((level[s] for s, _ in ins), default=0) + + live: set = set() + stack = [s for s in outputs if s in allgates] + while stack: + g = stack.pop() + if g in live: + continue + live.add(g) + for s, _ in allgates[g][0]: + if s in allgates and s not in live: + stack.append(s) + gates = {g: allgates[g] for g in live} + + kmax = max(level[g] for g in gates) + K = kmax + + last_use: Dict[str, int] = {} + for gname, (ins, _) in gates.items(): + for s, _ in ins: + last_use[s] = max(last_use.get(s, 0), level[gname]) + for s in outputs: + if s not in ("#0", "#1"): + last_use[s] = K + + by_level: Dict[int, List[str]] = defaultdict(list) + for gname in gates: + by_level[level[gname]].append(gname) + + vec = list(inputs) + widths = [len(vec)] + n_new, n_pass = [], [] + for lv in range(1, K + 1): + if lv < K: + new_gates = sorted(by_level.get(lv, [])) + passes = [s for s in vec if last_use.get(s, 0) > lv] + rows = new_gates + passes + n_new.append(len(new_gates)) + n_pass.append(len(passes)) + else: + rows = list(outputs) + new_here = sum(1 for s in rows if s in gates and level[s] == lv) + n_new.append(new_here) + n_pass.append(len(rows) - new_here) + widths.append(len(rows)) + vec = rows + + fanins = [len(ins) for ins, _ in gates.values()] + biases = [b for _, b in gates.values()] + nonzero = sum(fanins) + return { + "depth": K, + "gates": len(gates), + "netlist_gates": len(allgates), + "state_bits": len(inputs), + "widths": widths, + "M": sum(widths[1:]), + "dense_entries": sum(widths[i] * widths[i + 1] for i in range(len(widths) - 1)), + "identity_units": sum(n_pass), + "new_units": sum(n_new), + "max_fanin_netlist": max(fanins), + "nonzero_weights_netlist": nonzero, + "bias_min": min(biases), + "bias_max": max(biases), + "fanin_hist": dict(sorted(Counter(fanins).items())), + } + + +DATAPATH = ["p + 3", "subtractor", "zero test", "branch", "counter and halt override", + "halt latch"] + +MEMORY = ["decode p", "operand fetch", "decode A and B", "read mux", "write cells"] + +_COMPONENT = { + "P": "decode p", "FA": "operand fetch", "F": "operand fetch", + "PA": "decode A and B", "PB": "decode A and B", + "RX": "read mux", "RY": "read mux", "X": "read mux", "Y": "read mux", + "W": "write cells", "KP": "write cells", "WR": "write cells", "NM": "write cells", + "Q": "p + 3", + "PR": "subtractor", "T": "subtractor", "C": "subtractor", "O": "subtractor", + "N": "subtractor", "R": "subtractor", "H": "subtractor", + "AX": "zero test", "NX": "zero test", "XN": "zero test", "EQ": "zero test", + "LEQ": "branch", + "HP": "counter and halt override", "TA": "counter and halt override", + "TB": "counter and halt override", "NP": "counter and halt override", + "AC": "halt latch", "HA": "halt latch", "HB": "halt latch", "NH": "halt latch", +} + + +def component(name: str) -> str: + """The block of the host that a unit of host_family.build belongs to.""" + import re + return _COMPONENT[re.match(r"[A-Z]+", name).group(0)] + + +def host_component_table() -> Dict: + """Per-component counts of the step netlist of the host.""" + from host import host_netlist + net, inputs, outputs = host_netlist() + d: Dict[str, int] = {} + rows: Dict[str, Dict] = {} + for name, (ins, _) in net.gates.items(): + d[name] = 1 + max((d.get(x, 0) for x, _ in ins), default=0) + c = component(name) + r = rows.setdefault(c, {"units": 0, "weights": 0, "max_fanin": 0, + "level_lo": 10 ** 9, "level_hi": 0}) + r["units"] += 1 + r["weights"] += len(ins) + r["max_fanin"] = max(r["max_fanin"], len(ins)) + r["level_lo"] = min(r["level_lo"], d[name]) + r["level_hi"] = max(r["level_hi"], d[name]) + return { + "rows": rows, + "built_units": len(net.gates), + "units": len(net.gates), + "weights": sum(r["weights"] for r in rows.values()), + "datapath_units": sum(rows[k]["units"] for k in DATAPATH if k in rows), + "datapath_weights": sum(rows[k]["weights"] for k in DATAPATH if k in rows), + "memory_units": sum(rows[k]["units"] for k in MEMORY if k in rows), + "memory_weights": sum(rows[k]["weights"] for k in MEMORY if k in rows), + } + + +def main_stats() -> int: + out = {} + print("=" * 74) + print(" The step netlist of the host, by component") + print("=" * 74) + h = host_component_table() + for name in DATAPATH + MEMORY: + if name not in h["rows"]: + continue + d = h["rows"][name] + lv = (f"{d['level_lo']}" if d["level_lo"] == d["level_hi"] + else f"{d['level_lo']}-{d['level_hi']}") + print(f" {name:<18} units={d['units']:>6} weights={d['weights']:>6} " + f"levels={lv:<7} max eff fan-in={d['max_fanin']}") + print(f" {'datapath':<18} units={h['datapath_units']:>6} " + f"weights={h['datapath_weights']:>6}") + print(f" {'memory':<18} units={h['memory_units']:>6} " + f"weights={h['memory_weights']:>6}") + print(f" {'step netlist':<18} units={h['units']:>6} " + f"weights={h['weights']:>6}") + out["host_components"] = h + + print() + print("=" * 74) + print(" Lev(N_host): levelization of the SUBLEQ step circuit") + print("=" * 74) + from host import net_of_sigma + from host import read_host + net, inputs, outputs, _ = net_of_sigma(read_host()) + lev = levelize_net(net, inputs, outputs) + for k in ("depth", "gates", "netlist_gates", "state_bits", "M", + "dense_entries", "identity_units", + "new_units", "max_fanin_netlist", "nonzero_weights_netlist", + "bias_min", "bias_max"): + print(f" {k:<26} {lev[k]:,}" if isinstance(lev[k], int) else f" {k:<26} {lev[k]}") + print(f" fan-in histogram {lev['fanin_hist']}") + print(f" widths {lev['widths']}") + out["lev_host"] = lev + + path = runs_path("paper_stats.json") + json.dump(out, open(path, "w"), indent=1) + print(f"\nwrote {path}") + return 0 + +R: Dict = {} + + +def family_files() -> List[str]: + files = [os.path.join(REPO, "neural_computer.safetensors")] + vdir = os.path.join(REPO, "variants") + files += sorted(os.path.join(vdir, f) for f in os.listdir(vdir) + if f.endswith(".safetensors")) + return files + + +# --------------------------------------------------------------------------- +def check_codec(): + from host import describe, describe_literal, decode + print("[1] Recipe language") + files = family_files() + total = 0 + bad = 0 + tape_total = 0 + for p in files: + data = open(p, "rb").read() + r = describe(data) + if decode(r) != data: + bad += 1 + total += len(data) + tape_total += len(r) + print(f" delta(describe(f)) = f on all {len(files)} artifact files " + f"({total:,} bytes -> {tape_total:,} bytes of recipe): " + f"{'all exact' if bad == 0 else f'{bad} FAILED'}") + + # the length bound of the completeness lemma, with c1 = 1, c2 = 127, c3 = 1 + worst = 0.0 + lb = 0 + for p in files: + data = open(p, "rb").read() + rl = describe_literal(data) + assert decode(rl) == data + bound = len(data) + -(-len(data) // 127) + 1 + assert len(rl) == bound, (len(rl), bound) + lb += 1 + # exhaustive on short strings + import random + rng = random.Random(0) + ebad = 0 + for n in range(0, 600): + s = bytes(rng.randrange(256) for _ in range(n)) + for enc in (describe, describe_literal): + r = enc(s) + if decode(r) != s: + ebad += 1 + if len(describe_literal(s)) != len(s) + -(-len(s) // 127) + 1: + ebad += 1 + print(f" literal encoding meets |r| = |f| + ceil(|f|/127) + 1 on all " + f"{len(files)} files and on 600 random strings: " + f"{'exact' if ebad == 0 else 'FAILED'}") + R["codec"] = {"files": len(files), "bytes": total, "recipe_bytes": tape_total, + "roundtrip_failures": bad, "bound_failures": ebad} + + +# --------------------------------------------------------------------------- +def check_framing(): + from host import ser, inst, M_STAR, M_P, read_host, tau_star, describe + print("[2] Framing") + sigma = read_host() + bad = 0 + cases = [(sigma, M_STAR, tau_star(sigma, M_STAR)), + (sigma, M_P, describe(b"")), + (b"", M_P, b"\x00"), + (b"\x00" * 3, M_STAR, b"")] + for sg, m, tau in cases: + s = ser(sg, bytes(m), tau) + got = inst(s) + if got != (sg, bytes(m), tau): + bad += 1 + print(f" inst(ser(Omega)) = Omega on {len(cases)} instances: " + f"{'exact' if bad == 0 else 'FAILED'}") + R["framing"] = {"cases": len(cases), "failures": bad} + + +# --------------------------------------------------------------------------- +def check_serialization(): + """Rebuilding the host from source reproduces its encoding byte for byte.""" + print("[3] Canonical serialization") + import struct + from host import read_host, sigma_host + original = read_host() + rebuilt = sigma_host() + deterministic = sigma_host() == rebuilt + same = rebuilt == original + round_trip = resigma(original) == original + print(f" rebuild from source is byte-identical: {same} " + f"({len(original):,} bytes, sha {sha(original)[:16]})") + print(f" two independent builds agree: {deterministic}") + print(f" the netlist the encoding denotes serializes back to it: " + f"{round_trip}") + n = struct.unpack("> (7 - k)) & 1).float() + v[:, 9 + k::8] = ((m >> (7 - k)) & 1).float() + return v + + +def _bytes_of(v): + """The counter and the 256 memory bytes of every state of a batch.""" + import torch + pw = torch.tensor([1 << (7 - k) for k in range(8)], dtype=torch.long, + device=v.device) + q = v.detach().long() + pc = (q[:, :8] * pw).sum(1) + mem = (q[:, 9:].reshape(q.shape[0], 256, 8) * pw).sum(2) + return pc, mem + + +def _reference_batch(mem, pc): + """One step of Definition 3.1 on a batch of memory images and counters.""" + import torch + take = lambda idx: mem.gather(1, idx.unsqueeze(1)).squeeze(1) + A = take(pc) + B = take((pc + 1) & 0xFF) + C = take((pc + 2) & 0xFF) + r = (take(B) - take(A)) & 0xFF + out = mem.clone() + out.scatter_(1, B.unsqueeze(1), r.unsqueeze(1)) + leq = (r == 0) | (r >= 0x80) + return out, torch.where(leq, C, (pc + 3) & 0xFF) + + +def check_host_datapath(): + """One step of the netlist of sigma(N_host) against Definition 3.1 on all operand and counter pairs.""" + print("[4] Host datapath") + import torch + from host import NetEvaluator, read_host + device = "cuda" if torch.cuda.is_available() else "cpu" + E = NetEvaluator(read_host(), device=device) + + def sweep(mems, pcs, chunk=256): + bad = 0 + for i in range(0, len(pcs), chunk): + m = mems[i:i + chunk].to(device) + p = pcs[i:i + chunk].to(device) + got_pc, got_mem = _bytes_of(E.step(_batch_vectors(m, p, device))) + want_mem, want_pc = _reference_batch(m, p) + bad += int(((got_mem != want_mem).any(1) + | (got_pc != want_pc)).sum()) + return bad + + a = torch.arange(65536) % 256 + b = torch.arange(65536) // 256 + mems = torch.zeros(65536, 256, dtype=torch.long) + mems[:, 0], mems[:, 1], mems[:, 2] = 0x90, 0x91, 0x30 + mems[:, 0x90] = a + mems[:, 0x91] = b + bad_ops = sweep(mems, torch.zeros(65536, dtype=torch.long)) + print(f" one step against Definition 3.1 over all 65,536 operand pairs: " + f"{'exact' if bad_ops == 0 else f'{bad_ops} FAILED'}") + + pc = torch.arange(65536) // 256 + c = torch.arange(65536) % 256 + mems = torch.zeros(65536, 256, dtype=torch.long) + mems[:, 0xA0], mems[:, 0xA1] = 4, 5 + rows = torch.arange(65536) + mems[rows, pc] = 0xA0 + mems[rows, (pc + 1) % 256] = 0xA1 + mems[rows, (pc + 2) % 256] = c + bad_ctr = sweep(mems, pc) + print(f" one step against Definition 3.1 over all 65,536 (pc, C) pairs, " + f"the counter wrapping across the top of memory: " + f"{'exact' if bad_ctr == 0 else f'{bad_ctr} FAILED'}") + R["datapath"] = {"operand_pairs": 65536, "operand_failures": bad_ops, + "counter_pairs": 65536, "counter_failures": bad_ctr} + + +# --------------------------------------------------------------------------- +def check_lev_sparse_dense(): + """The sparse and dense evaluations of Lev(N_host) agree.""" + print("[5] Lev(N_host): sparse vs dense") + import torch + from host import LevEvaluator, NetEvaluator, read_host + sigma = read_host() + dense = LevEvaluator(sigma, device="cpu", dense=True) + sparse = LevEvaluator(sigma, device="cpu", dense=False) + netl = NetEvaluator(sigma, device="cpu") + gen = torch.Generator().manual_seed(4242) + V = (torch.rand(64, LevEvaluator.N, generator=gen) < 0.5).float() + a = dense.step(V) + b = sparse.step(V) + c = netl.step(V) + same = bool((a == b).all()) and bool((a == c).all()) + print(f" identical next state on 64 uniformly random states: {same}") + print(f" dense: {dense.info['layers']} layers, max width " + f"{dense.info['max_width']}, {dense.info['total_weights']:,} entries") + R["lev"] = {"sparse_dense_agree": same, + "layers": dense.info["layers"], + "max_width": dense.info["max_width"], + "total_weights": dense.info["total_weights"]} + + +def main_verify() -> int: + t0 = time.perf_counter() + check_codec() + check_framing() + check_serialization() + check_host_datapath() + check_lev_sparse_dense() + R["seconds"] = time.perf_counter() - t0 + path = runs_path("paper_verify.json") + json.dump(R, open(path, "w"), indent=1) + print(f"\nwrote {path} ({R['seconds']:.0f}s)") + return 0 + + +def main_throughput() -> int: + import torch + from host import M_P, LevEvaluator, NetEvaluator, describe, read_host, run_reference + torch.set_num_threads(1) # the processor rates are for one core + sigma = read_host() + r_H = describe(sigma) + out = {} + + t0 = time.perf_counter() + o, n = run_reference(M_P, r_H, expect=sigma) + dt = time.perf_counter() - t0 + assert o == sigma + out["reference"] = {"steps": n, "seconds": dt, "steps_per_second": n / dt} + print(f" integer reference: {n:,} steps in {dt:.2f} s " + f"({n / dt:,.0f} steps/s)", flush=True) + + N = 20000 + for key, what, ev in ( + ("net_cpu", "the netlist of sigma unit by unit, one core", + lambda: NetEvaluator(sigma, device="cpu")), + ("net_gpu", "the netlist of sigma unit by unit, GPU", + lambda: NetEvaluator(sigma, device="cuda", graph=True))): + G = ev() + G.run(M_P, r_H, max_steps=200) # warm up + t0 = time.perf_counter() + G.run(M_P, r_H, max_steps=N, expect=sigma) + dt = time.perf_counter() - t0 + out[key] = {"steps": N, "seconds": dt, "steps_per_second": N / dt, + "units": G.info["units"], "entries": G.info["entries"]} + print(f" {what}: {N:,} steps in {dt:.1f} s ({N / dt:,.0f} steps/s)", + flush=True) + + D = LevEvaluator(sigma, device="cuda", dense=True) + entries = sum(int(W.numel()) for W in D.W) + nonzero = sum(int((W != 0).sum()) for W in D.W) + D.run(M_P, r_H, max_steps=200) + t0 = time.perf_counter() + D.run(M_P, r_H, max_steps=N, expect=sigma) + dt = time.perf_counter() - t0 + rate = N / dt + out["dense_gpu"] = {"steps": N, "seconds": dt, "steps_per_second": rate, + "entries": entries, "nonzero": nonzero, + "bytes_per_step": entries * 4, + "bandwidth_gbs": rate * entries * 4 / 1e9} + print(f" Lev(N_host) dense on the GPU: {N:,} steps in {dt:.1f} s " + f"({rate:,.0f} steps/s); {entries:,} entries of which {nonzero:,} " + f"are nonzero, {entries * 4 / 1e6:.0f} MB read per step, " + f"{rate * entries * 4 / 1e9:.0f} GB/s", flush=True) + + G2 = LevEvaluator(sigma, device="cuda", dense=False, graph=True) + G2.run(M_P, r_H, max_steps=200) + t0 = time.perf_counter() + G2.run(M_P, r_H, max_steps=N, expect=sigma) + dt = time.perf_counter() - t0 + out["graph_gpu"] = {"steps": N, "seconds": dt, "steps_per_second": N / dt} + print(f" the same map from its nonzero entries, captured as a CUDA graph: " + f"{N:,} steps in {dt:.1f} s ({N / dt:,.0f} steps/s)", flush=True) + + S = LevEvaluator(sigma, device="cpu", dense=False) + S.run(M_P, r_H, max_steps=200) + t0 = time.perf_counter() + S.run(M_P, r_H, max_steps=N, expect=sigma) + dt = time.perf_counter() - t0 + out["sparse_cpu"] = {"steps": N, "seconds": dt, "steps_per_second": N / dt} + print(f" the same map from its nonzero entries on one core: {N:,} steps in " + f"{dt:.1f} s ({N / dt:,.0f} steps/s)", flush=True) + + # evaluator (d): the netlist in C, one core, on the self-describing instance + import subprocess + import tempfile + work = tempfile.mkdtemp(prefix="throughput_") + exe = os.path.join(work, "netlist_eval") + subprocess.run(["gcc", "-O3", "-march=native", "-fopenmp", "-o", exe, + os.path.join(REPO, "src", "netlist_eval.c")], check=True) + nb, units, nxt = parse_sigma(sigma) + write_flat(os.path.join(work, "host.bin"), nb, units, nxt) + open(os.path.join(work, "mP"), "wb").write(bytes(M_P)) + open(os.path.join(work, "rH"), "wb").write(r_H) + env = dict(os.environ, OMP_NUM_THREADS="1") + t0 = time.perf_counter() + subprocess.run([exe, "run", os.path.join(work, "host.bin"), os.path.join(work, "mP"), + os.path.join(work, "rH"), os.path.join(work, "out"), "net"], + check=True, capture_output=True, env=env) + dt = time.perf_counter() - t0 + assert open(os.path.join(work, "out"), "rb").read() == sigma + out["c_evaluator"] = {"steps": n, "seconds": dt, "steps_per_second": n / dt} + print(f" the netlist of sigma in C, one core: {n:,} steps in {dt:.1f} s " + f"({n / dt:,.0f} steps/s)", flush=True) + + json.dump(out, open(runs_path("paper_throughput.json"), "w"), indent=1) + return 0 + + +COMMANDS = {'c': main_c, 'codec': main_codec, 'device': main_device, 'environment': main_environment, 'family': main_family, 'family-gate': main_family_gate, 'framing': main_framing, 'host': main_host, 'host-family': main_host_family, 'host-fold': main_host_fold, 'lev': main_lev, 'optimal': main_optimal, 'rocq': main_rocq, 'sigma': main_sigma, 'steps': main_steps, 'ternarity': main_ternarity, 'runs': main_runs, 'stats': main_stats, 'verify': main_verify, 'throughput': main_throughput} + + +if __name__ == "__main__": + if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS: + sys.exit('commands: ' + ', '.join(COMMANDS)) + cmd = sys.argv.pop(1) + sys.argv[0] += ' ' + cmd + sys.exit(COMMANDS[cmd]()) diff --git a/src/paper_runs.py b/src/paper_runs.py deleted file mode 100644 index a407b857dce469c56cbe0b2a56c03149b6deb770..0000000000000000000000000000000000000000 --- a/src/paper_runs.py +++ /dev/null @@ -1,247 +0,0 @@ -"""The long runs: self-description and three generations of self-reproduction. - -Every generation is instantiated from the bytes the previous one emitted: the -output is parsed by inst, the netlist is rebuilt from its first field, the -evaluator is built from that netlist, and only then is the next generation run. - - python src/paper_runs.py reference # integer reference, seconds - python src/paper_runs.py net # the netlist of sigma, unit by unit - python src/paper_runs.py lev # Lev(N_host) from its nonzero entries - python src/paper_runs.py lev --dense # Lev(N_host) as dense matrices - python src/paper_runs.py lockstep # four-way full-state agreement -""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import os -import sys -import time -from typing import Dict, List - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - -from selfrep import (M_P, M_STAR, P, P_STAR, describe, inst, ser, tau_star, - read_host, run_reference, LevEvaluator, NetEvaluator, - Tape, HALT_PC) - -BUDGET = 1 << 22 - - -def sha(b: bytes) -> str: - return hashlib.sha256(b).hexdigest() - - -def out_path(name: str) -> str: - d = os.path.join(REPO, "paper", "runs") - os.makedirs(d, exist_ok=True) - return os.path.join(d, f"paper_runs_{name}.json") - - -def phase_split(mem0: List[int], tau: bytes) -> Dict[str, int]: - """Steps spent in each phase of P*, counted on the reference.""" - mem = list(mem0) - dev = Tape(tau) - pc = 0 - counts = {"A": 0, "R": 0, "B": 0} - while pc != HALT_PC: - phase = "A" if pc < 3 * 20 else ("R" if pc == 3 * 20 else "B") - counts[phase] += 1 - A = mem[pc] - B = mem[(pc + 1) & 0xFF] - C = mem[(pc + 2) & 0xFF] - r = (mem[B] - mem[A]) & 0xFF - mem[B] = r - pc = C if (r == 0 or r >= 0x80) else (pc + 3) & 0xFF - dev.apply(mem) - return counts - - -class Reference: - """The integer reference, in the interface the generations loop expects.""" - - info = {"evaluator": "integer reference"} - build_seconds = 0.0 - - def __init__(self, sigma: bytes): - self.sigma = sigma - - def run(self, mem0, tau, max_steps, expect=None, progress=0): - return run_reference(mem0, tau, max_steps=max_steps, expect=expect) - - -def generations(build, label: str, n_gen: int = 3, progress: int = 0) -> Dict: - """Run n_gen generations, each instantiated only from the previous output.""" - sigma0 = read_host() - tau0 = tau_star(sigma0, M_STAR) - target = ser(sigma0, bytes(M_STAR), tau0) - rec = [] - sigma, m, tau = sigma0, list(M_STAR), tau0 - for k in range(n_gen): - t0 = time.perf_counter() - ev = build(sigma) - t_build = time.perf_counter() - t0 - t0 = time.perf_counter() - out, steps = ev.run(m, tau, max_steps=BUDGET, expect=target, - progress=progress) - dt = time.perf_counter() - t0 - d = sha(out) - rec.append({"generation": k + 1, "bytes": len(out), "steps": steps, - "sha256": d, "seconds": dt, "instantiate_seconds": t_build, - "equals_ser": out == ser(sigma, bytes(m), tau)}) - print(f" [{label}] generation {k+1}: instantiated in " - f"{t_build:.1f} s, {len(out):,} bytes in {steps:,} steps, " - f"{dt/60:.1f} min, sha {d[:16]}, " - f"Out = ser(Omega): {rec[-1]['equals_ser']}", flush=True) - sigma, mb, tau = inst(out) - m = list(mb) - if sigma != sigma0 or m != list(M_STAR) or tau != tau0: - raise AssertionError(f"generation {k+1} did not reproduce the instance") - digests = {r["sha256"] for r in rec} - print(f" [{label}] all {n_gen} generations identical: {len(digests) == 1}", - flush=True) - return {"generations": rec, "digests_coincide": len(digests) == 1, - "target_sha256": sha(target), "target_bytes": len(target)} - - -def main() -> int: - ap = argparse.ArgumentParser() - ap.add_argument("mode", choices=["reference", "net", "lev", "lockstep"]) - ap.add_argument("--generations", type=int, default=3) - ap.add_argument("--device", default="cuda") - ap.add_argument("--lockstep-steps", type=int, default=20000) - ap.add_argument("--dense", action="store_true", - help="evaluate Lev(N_host) as dense matrices") - args = ap.parse_args() - - sigma = read_host() - r_H = describe(sigma) - tau = tau_star(sigma, M_STAR) - target = ser(sigma, bytes(M_STAR), tau) - res: Dict = {"sigma_bytes": len(sigma), "sigma_sha256": sha(sigma), - "r_H_bytes": len(r_H), "tau_star_bytes": len(tau), - "ser_bytes": len(target), "ser_sha256": sha(target), - "P_instructions": len(P), "P_star_instructions": len(P_STAR)} - - if args.mode == "reference": - print("[reference] self-description") - t0 = time.perf_counter() - out, n = run_reference(M_P, r_H, expect=sigma) - res["selfdesc"] = {"bytes": len(out), "steps": n, - "seconds": time.perf_counter() - t0, - "exact": out == sigma, "sha256": sha(out)} - print(f" {len(out):,} bytes in {n:,} steps, exact={out == sigma}") - print("[reference] self-reproduction, three generations") - res["selfrep"] = generations(Reference, "reference", args.generations) - res["phase_steps"] = phase_split(M_STAR, tau) - print(f" phase split: {res['phase_steps']}") - - elif args.mode == "net": - build = lambda s: NetEvaluator(s, device=args.device, - graph=args.device.startswith("cuda")) - E = build(sigma) - res["evaluator"] = "the netlist of sigma, unit by unit" - res["net_info"] = {k: int(v) for k, v in E.info.items()} - print(f"[net] {E.info['units']:,} units over {E.info['levels']} levels, " - f"{E.info['entries']:,} predecessor entries, built in " - f"{E.build_seconds:.1f} s", flush=True) - print("[net] self-description", flush=True) - t0 = time.perf_counter() - out, n = E.run(M_P, r_H, max_steps=BUDGET, expect=sigma, progress=200000) - res["selfdesc"] = {"bytes": len(out), "steps": n, - "seconds": time.perf_counter() - t0, - "exact": out == sigma, "sha256": sha(out)} - print(f" {len(out):,} bytes in {n:,} steps " - f"({res['selfdesc']['seconds']/60:.1f} min), exact={out == sigma}") - print("[net] self-reproduction, three generations", flush=True) - res["selfrep"] = generations(build, "net", args.generations, 200000) - - elif args.mode == "lev": - build = lambda s: LevEvaluator( - s, device=args.device, dense=args.dense, - graph=(not args.dense) and args.device.startswith("cuda")) - E = build(sigma) - res["lev_info"] = {k: int(v) for k, v in E.info.items() - if isinstance(v, (int, float))} - res["lev_form"] = ("dense matrices" if args.dense - else "nonzero entries, CUDA graph") - print(f"[lev] {E.info['size']:,} units over {E.info['layers']} layers, " - f"maximal width {E.info['max_width']:,}, " - f"{E.info['total_weights']:,} entries of which " - f"{E.info['nonzero']:,} are nonzero; built in " - f"{E.build_seconds:.1f} s as " - f"{'dense matrices' if args.dense else 'its nonzero entries'}", - flush=True) - print("[lev] self-reproduction", flush=True) - res["selfrep"] = generations(build, "lev", args.generations, 200000) - - elif args.mode == "lockstep": - print(f"[lockstep] reference vs the netlist of sigma vs Lev(N_host) " - f"dense and sparse, {args.lockstep_steps:,} steps of generation 1", - flush=True) - import torch - N = NetEvaluator(sigma, device=args.device, - graph=args.device.startswith("cuda")) - D = LevEvaluator(sigma, device=args.device, dense=True) - S = LevEvaluator(sigma, device=args.device, dense=False) - rmem = list(M_STAR); rpc = 0; rdev = Tape(tau) - states, devs = [], [] - for E in (N, D, S): - states.append(E._vec(0, M_STAR).unsqueeze(0).to(args.device)) - devs.append(Tape(tau)) - pow2 = [1 << (7 - k) for k in range(8)] - bad = 0 - t0 = time.perf_counter() - for n in range(args.lockstep_steps): - A = rmem[rpc]; B = rmem[(rpc + 1) & 0xFF]; C = rmem[(rpc + 2) & 0xFF] - r = (rmem[B] - rmem[A]) & 0xFF - rmem[B] = r - rpc = C if (r == 0 or r >= 0x80) else (rpc + 3) & 0xFF - rdev.apply(rmem) - mems, pcs = [], [] - for i, E in enumerate((N, D, S)): - v = E.step(states[i]) - vc = v[0].cpu() - cells = {j: E._byte(vc, j) for j in E._DEVCELLS} - sh = [0] * 256 - for j, val in cells.items(): - sh[j] = val - devs[i].apply(sh) - for j, val in cells.items(): - if sh[j] != val: - E._set_byte(v, j, sh[j]) - states[i] = v - vc = v[0].cpu() - pcs.append(sum(p for p, k in zip(pow2, range(8)) - if int(vc[k]))) - mems.append([E._byte(vc, j) for j in range(256)]) - if not (all(m == rmem for m in mems) and all(p == rpc for p in pcs) - and all(d.out == rdev.out for d in devs) - and all(d.h == rdev.h for d in devs)): - print(f" DIVERGENCE at step {n}") - bad += 1 - break - if n and n % 2000 == 0: - print(f" {n:,} steps agreed " - f"({n/(time.perf_counter()-t0):.0f} steps/s, " - f"{len(rdev.out):,} bytes)", flush=True) - res["lockstep"] = {"steps": args.lockstep_steps, "divergences": bad, - "bytes": len(rdev.out), - "seconds": time.perf_counter() - t0} - print(f" {args.lockstep_steps:,} steps: full state, tape head and " - f"output word equal on all four evaluators: {bad == 0}") - - name = args.mode - if args.mode == "lev": - name = "lev_dense" if args.dense else "lev_sparse" - p = out_path(name) - json.dump(res, open(p, "w"), indent=1) - print(f"wrote {p}") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/paper_stats.py b/src/paper_stats.py deleted file mode 100644 index 118109c9658085164eb2e7a0553cb355d5974eb2..0000000000000000000000000000000000000000 --- a/src/paper_stats.py +++ /dev/null @@ -1,218 +0,0 @@ -"""Structural statistics for the host and its levelization. - -Reproduces the level assignment of matrix8.compile_net without materialising the -dense matrices, so the layer widths, the number of identity units, the total -size, and the maximal effective fan-in can be reported for maps that are too -large to compile in memory. -""" - -from __future__ import annotations - -import json -import os -import sys -from collections import Counter, defaultdict -from typing import Dict, List, Tuple - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - - -def runs_path(name: str) -> str: - d = os.path.join(REPO, "paper", "runs") - os.makedirs(d, exist_ok=True) - return os.path.join(d, name) - - -def levelize(net, inputs: List[str], outputs: List[str]) -> Dict: - """The level assignment of matrix8.compile_net, counted and not built.""" - allgates = net.gates - level: Dict[str, int] = {s: 0 for s in inputs} - consumers: Dict[str, List[str]] = defaultdict(list) - indeg: Dict[str, int] = {} - for gname, (ins, _) in allgates.items(): - indeg[gname] = len([s for s, _ in ins if s in allgates]) - for s, _ in ins: - consumers[s].append(gname) - order = [g for g, d in indeg.items() if d == 0] - i = 0 - while i < len(order): - for c in consumers.get(order[i], []): - indeg[c] -= 1 - if indeg[c] == 0: - order.append(c) - i += 1 - if len(order) != len(allgates): - raise ValueError("cycle") - for gname in order: - ins, _ = allgates[gname] - level[gname] = 1 + max((level[s] for s, _ in ins), default=0) - - live: set = set() - stack = [s for s in outputs if s in allgates] - while stack: - g = stack.pop() - if g in live: - continue - live.add(g) - for s, _ in allgates[g][0]: - if s in allgates and s not in live: - stack.append(s) - gates = {g: allgates[g] for g in live} - - kmax = max(level[g] for g in gates) - K = kmax - - last_use: Dict[str, int] = {} - for gname, (ins, _) in gates.items(): - for s, _ in ins: - last_use[s] = max(last_use.get(s, 0), level[gname]) - for s in outputs: - if s not in ("#0", "#1"): - last_use[s] = K - - by_level: Dict[int, List[str]] = defaultdict(list) - for gname in gates: - by_level[level[gname]].append(gname) - - vec = list(inputs) - widths = [len(vec)] - n_new, n_pass = [], [] - for lv in range(1, K + 1): - if lv < K: - new_gates = sorted(by_level.get(lv, [])) - passes = [s for s in vec if last_use.get(s, 0) > lv] - rows = new_gates + passes - n_new.append(len(new_gates)) - n_pass.append(len(passes)) - else: - rows = list(outputs) - new_here = sum(1 for s in rows if s in gates and level[s] == lv) - n_new.append(new_here) - n_pass.append(len(rows) - new_here) - widths.append(len(rows)) - vec = rows - - fanins = [len(ins) for ins, _ in gates.values()] - biases = [b for _, b in gates.values()] - nonzero = sum(fanins) - return { - "depth": K, - "gates": len(gates), - "netlist_gates": len(allgates), - "state_bits": len(inputs), - "widths": widths, - "M": sum(widths[1:]), - "dense_entries": sum(widths[i] * widths[i + 1] for i in range(len(widths) - 1)), - "identity_units": sum(n_pass), - "new_units": sum(n_new), - "max_fanin_netlist": max(fanins), - "nonzero_weights_netlist": nonzero, - "bias_min": min(biases), - "bias_max": max(biases), - "fanin_hist": dict(sorted(Counter(fanins).items())), - } - - -DATAPATH = ["p + 3", "subtractor", "zero test", "branch", "counter and halt override", - "halt latch"] -MEMORY = ["decode p", "operand fetch", "decode A and B", "read mux", "write cells"] - -_COMPONENT = { - "P": "decode p", "FA": "operand fetch", "F": "operand fetch", - "PA": "decode A and B", "PB": "decode A and B", - "RX": "read mux", "RY": "read mux", "X": "read mux", "Y": "read mux", - "W": "write cells", "KP": "write cells", "WR": "write cells", "NM": "write cells", - "Q": "p + 3", - "PR": "subtractor", "T": "subtractor", "C": "subtractor", "O": "subtractor", - "N": "subtractor", "R": "subtractor", "H": "subtractor", - "AX": "zero test", "NX": "zero test", "XN": "zero test", "EQ": "zero test", - "LEQ": "branch", - "HP": "counter and halt override", "TA": "counter and halt override", - "TB": "counter and halt override", "NP": "counter and halt override", - "AC": "halt latch", "HA": "halt latch", "HB": "halt latch", "NH": "halt latch", -} - - -def component(name: str) -> str: - """The block of the host that a unit of host_family.build belongs to.""" - import re - return _COMPONENT[re.match(r"[A-Z]+", name).group(0)] - - -def host_component_table() -> Dict: - """Per-component counts of the step netlist of the host.""" - from selfrep import host_netlist - net, inputs, outputs = host_netlist() - d: Dict[str, int] = {} - rows: Dict[str, Dict] = {} - for name, (ins, _) in net.gates.items(): - d[name] = 1 + max((d.get(x, 0) for x, _ in ins), default=0) - c = component(name) - r = rows.setdefault(c, {"units": 0, "weights": 0, "max_fanin": 0, - "level_lo": 10 ** 9, "level_hi": 0}) - r["units"] += 1 - r["weights"] += len(ins) - r["max_fanin"] = max(r["max_fanin"], len(ins)) - r["level_lo"] = min(r["level_lo"], d[name]) - r["level_hi"] = max(r["level_hi"], d[name]) - return { - "rows": rows, - "built_units": len(net.gates), - "units": len(net.gates), - "weights": sum(r["weights"] for r in rows.values()), - "datapath_units": sum(rows[k]["units"] for k in DATAPATH if k in rows), - "datapath_weights": sum(rows[k]["weights"] for k in DATAPATH if k in rows), - "memory_units": sum(rows[k]["units"] for k in MEMORY if k in rows), - "memory_weights": sum(rows[k]["weights"] for k in MEMORY if k in rows), - } - - -def main() -> int: - out = {} - print("=" * 74) - print(" The step netlist of the host, by component") - print("=" * 74) - h = host_component_table() - for name in DATAPATH + MEMORY: - if name not in h["rows"]: - continue - d = h["rows"][name] - lv = (f"{d['level_lo']}" if d["level_lo"] == d["level_hi"] - else f"{d['level_lo']}-{d['level_hi']}") - print(f" {name:<18} units={d['units']:>6} weights={d['weights']:>6} " - f"levels={lv:<7} max eff fan-in={d['max_fanin']}") - print(f" {'datapath':<18} units={h['datapath_units']:>6} " - f"weights={h['datapath_weights']:>6}") - print(f" {'memory':<18} units={h['memory_units']:>6} " - f"weights={h['memory_weights']:>6}") - print(f" {'step netlist':<18} units={h['units']:>6} " - f"weights={h['weights']:>6}") - out["host_components"] = h - - print() - print("=" * 74) - print(" Lev(N_host): levelization of the SUBLEQ step circuit") - print("=" * 74) - from netlist_io import net_of_sigma - from selfrep import read_host - net, inputs, outputs, _ = net_of_sigma(read_host()) - lev = levelize(net, inputs, outputs) - for k in ("depth", "gates", "netlist_gates", "state_bits", "M", - "dense_entries", "identity_units", - "new_units", "max_fanin_netlist", "nonzero_weights_netlist", - "bias_min", "bias_max"): - print(f" {k:<26} {lev[k]:,}" if isinstance(lev[k], int) else f" {k:<26} {lev[k]}") - print(f" fan-in histogram {lev['fanin_hist']}") - print(f" widths {lev['widths']}") - out["lev_host"] = lev - - path = runs_path("paper_stats.json") - json.dump(out, open(path, "w"), indent=1) - print(f"\nwrote {path}") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/paper_verify.py b/src/paper_verify.py deleted file mode 100644 index 21b6c9b8bfb259c33840d15a57c7f3749b81b317..0000000000000000000000000000000000000000 --- a/src/paper_verify.py +++ /dev/null @@ -1,270 +0,0 @@ -"""The recipe codec, the framing, the serialization, the host's datapath and the -levelization, each against its definition; the record is paper/runs/paper_verify.json. -""" - -from __future__ import annotations - -import hashlib -import json -import os -import sys -import time -from typing import Dict, List - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - - -def runs_path(name: str) -> str: - d = os.path.join(REPO, "paper", "runs") - os.makedirs(d, exist_ok=True) - return os.path.join(d, name) -R: Dict = {} - - -def sha(b: bytes) -> str: - return hashlib.sha256(b).hexdigest() - - -def family_files() -> List[str]: - files = [os.path.join(REPO, "neural_computer.safetensors")] - vdir = os.path.join(REPO, "variants") - files += sorted(os.path.join(vdir, f) for f in os.listdir(vdir) - if f.endswith(".safetensors")) - return files - - -# --------------------------------------------------------------------------- -def check_codec(): - from selfrep import describe, describe_literal, decode - print("[1] Recipe language") - files = family_files() - total = 0 - bad = 0 - tape_total = 0 - for p in files: - data = open(p, "rb").read() - r = describe(data) - if decode(r) != data: - bad += 1 - total += len(data) - tape_total += len(r) - print(f" delta(describe(f)) = f on all {len(files)} artifact files " - f"({total:,} bytes -> {tape_total:,} bytes of recipe): " - f"{'all exact' if bad == 0 else f'{bad} FAILED'}") - - # the length bound of the completeness lemma, with c1 = 1, c2 = 127, c3 = 1 - worst = 0.0 - lb = 0 - for p in files: - data = open(p, "rb").read() - rl = describe_literal(data) - assert decode(rl) == data - bound = len(data) + -(-len(data) // 127) + 1 - assert len(rl) == bound, (len(rl), bound) - lb += 1 - # exhaustive on short strings - import random - rng = random.Random(0) - ebad = 0 - for n in range(0, 600): - s = bytes(rng.randrange(256) for _ in range(n)) - for enc in (describe, describe_literal): - r = enc(s) - if decode(r) != s: - ebad += 1 - if len(describe_literal(s)) != len(s) + -(-len(s) // 127) + 1: - ebad += 1 - print(f" literal encoding meets |r| = |f| + ceil(|f|/127) + 1 on all " - f"{len(files)} files and on 600 random strings: " - f"{'exact' if ebad == 0 else 'FAILED'}") - R["codec"] = {"files": len(files), "bytes": total, "recipe_bytes": tape_total, - "roundtrip_failures": bad, "bound_failures": ebad} - - -# --------------------------------------------------------------------------- -def check_framing(): - from selfrep import ser, inst, M_STAR, M_P, read_host, tau_star, describe - print("[2] Framing") - sigma = read_host() - bad = 0 - cases = [(sigma, M_STAR, tau_star(sigma, M_STAR)), - (sigma, M_P, describe(b"")), - (b"", M_P, b"\x00"), - (b"\x00" * 3, M_STAR, b"")] - for sg, m, tau in cases: - s = ser(sg, bytes(m), tau) - got = inst(s) - if got != (sg, bytes(m), tau): - bad += 1 - print(f" inst(ser(Omega)) = Omega on {len(cases)} instances: " - f"{'exact' if bad == 0 else 'FAILED'}") - R["framing"] = {"cases": len(cases), "failures": bad} - - -# --------------------------------------------------------------------------- -def check_serialization(): - """Under the canonical specification, rebuilding the host from source - reproduces its distributed encoding byte for byte, and two independent - builds agree.""" - print("[3] Canonical serialization") - import struct - from selfrep import read_host, sigma_host - from check_sigma import resigma - original = read_host() - rebuilt = sigma_host() - deterministic = sigma_host() == rebuilt - same = rebuilt == original - round_trip = resigma(original) == original - print(f" rebuild from source is byte-identical: {same} " - f"({len(original):,} bytes, sha {sha(original)[:16]})") - print(f" two independent builds agree: {deterministic}") - print(f" the netlist the encoding denotes serializes back to it: " - f"{round_trip}") - n = struct.unpack("> (7 - k)) & 1).float() - v[:, 9 + k::8] = ((m >> (7 - k)) & 1).float() - return v - - -def _bytes_of(v): - """The counter and the 256 memory bytes of every state of a batch.""" - import torch - pw = torch.tensor([1 << (7 - k) for k in range(8)], dtype=torch.long, - device=v.device) - q = v.detach().long() - pc = (q[:, :8] * pw).sum(1) - mem = (q[:, 9:].reshape(q.shape[0], 256, 8) * pw).sum(2) - return pc, mem - - -def _reference_batch(mem, pc): - """One step of Definition 3.1 on a batch of memory images and counters.""" - import torch - take = lambda idx: mem.gather(1, idx.unsqueeze(1)).squeeze(1) - A = take(pc) - B = take((pc + 1) & 0xFF) - C = take((pc + 2) & 0xFF) - r = (take(B) - take(A)) & 0xFF - out = mem.clone() - out.scatter_(1, B.unsqueeze(1), r.unsqueeze(1)) - leq = (r == 0) | (r >= 0x80) - return out, torch.where(leq, C, (pc + 3) & 0xFF) - - -def check_host_datapath(): - """One step of the netlist that sigma(N_host) denotes, against - Definition 3.1, over all 2^16 operand pairs and all 2^16 (pc, C) pairs.""" - print("[4] Host datapath") - import torch - from selfrep import NetEvaluator, read_host - device = "cuda" if torch.cuda.is_available() else "cpu" - E = NetEvaluator(read_host(), device=device) - - def sweep(mems, pcs, chunk=256): - bad = 0 - for i in range(0, len(pcs), chunk): - m = mems[i:i + chunk].to(device) - p = pcs[i:i + chunk].to(device) - got_pc, got_mem = _bytes_of(E.step(_batch_vectors(m, p, device))) - want_mem, want_pc = _reference_batch(m, p) - bad += int(((got_mem != want_mem).any(1) - | (got_pc != want_pc)).sum()) - return bad - - a = torch.arange(65536) % 256 - b = torch.arange(65536) // 256 - mems = torch.zeros(65536, 256, dtype=torch.long) - mems[:, 0], mems[:, 1], mems[:, 2] = 0x90, 0x91, 0x30 - mems[:, 0x90] = a - mems[:, 0x91] = b - bad_ops = sweep(mems, torch.zeros(65536, dtype=torch.long)) - print(f" one step against Definition 3.1 over all 65,536 operand pairs: " - f"{'exact' if bad_ops == 0 else f'{bad_ops} FAILED'}") - - pc = torch.arange(65536) // 256 - c = torch.arange(65536) % 256 - mems = torch.zeros(65536, 256, dtype=torch.long) - mems[:, 0xA0], mems[:, 0xA1] = 4, 5 - rows = torch.arange(65536) - mems[rows, pc] = 0xA0 - mems[rows, (pc + 1) % 256] = 0xA1 - mems[rows, (pc + 2) % 256] = c - bad_ctr = sweep(mems, pc) - print(f" one step against Definition 3.1 over all 65,536 (pc, C) pairs, " - f"the counter wrapping across the top of memory: " - f"{'exact' if bad_ctr == 0 else f'{bad_ctr} FAILED'}") - R["datapath"] = {"operand_pairs": 65536, "operand_failures": bad_ops, - "counter_pairs": 65536, "counter_failures": bad_ctr} - - -# --------------------------------------------------------------------------- -def check_lev_sparse_dense(): - """The sparse and dense evaluations of Lev(N_host) agree.""" - print("[5] Lev(N_host): sparse vs dense") - import torch - from selfrep import LevEvaluator, NetEvaluator, read_host - sigma = read_host() - dense = LevEvaluator(sigma, device="cpu", dense=True) - sparse = LevEvaluator(sigma, device="cpu", dense=False) - netl = NetEvaluator(sigma, device="cpu") - gen = torch.Generator().manual_seed(4242) - V = (torch.rand(64, LevEvaluator.N, generator=gen) < 0.5).float() - a = dense.step(V) - b = sparse.step(V) - c = netl.step(V) - same = bool((a == b).all()) and bool((a == c).all()) - print(f" identical next state on 64 uniformly random states: {same}") - print(f" dense: {dense.info['layers']} layers, max width " - f"{dense.info['max_width']}, {dense.info['total_weights']:,} entries") - R["lev"] = {"sparse_dense_agree": same, - "layers": dense.info["layers"], - "max_width": dense.info["max_width"], - "total_weights": dense.info["total_weights"]} - - -def main() -> int: - t0 = time.perf_counter() - check_codec() - check_framing() - check_serialization() - check_host_datapath() - check_lev_sparse_dense() - R["seconds"] = time.perf_counter() - t0 - path = runs_path("paper_verify.json") - json.dump(R, open(path, "w"), indent=1) - print(f"\nwrote {path} ({R['seconds']:.0f}s)") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/quantize.py b/src/quantize.py deleted file mode 100644 index 88839427a5c153b31560fe612058ee74aff7631a..0000000000000000000000000000000000000000 --- a/src/quantize.py +++ /dev/null @@ -1,406 +0,0 @@ -""" -Quantize threshold-computer safetensors to the minimum signed integer -dtype that exactly represents each tensor. - -Weights and biases in this library are integer-valued by construction, -with one historical exception: a handful of legacy buffer gates use a -bias of -0.5 (e.g. arithmetic.asr8bit.bit*.bias). For binary inputs, -H(x - 0.5) and H(x - 1) are identical, so those biases are floored to --1 before casting. - -This is a packaging optimization, not a precision change: the eval -pipeline already promotes weights to float32 on load, so integer -storage is exact. - -The --ternary flag also rewrites single-input weight=+/-2 identity -buffers (SHL/SHR/ROL/ROR bit gates, stack data buffers, RET address -buffers, flag buffers) to weight=+/-1 with bias adjusted as needed to -preserve heaviside output for binary inputs. After this pass every -weight tensor in the file lies in {-1, 0, 1} except for positional -comparators and a few hand-constructed modular arithmetic circuits -(see the violation report); fully ternarizing those requires bit- -cascading in build.py. - -Usage: - python quantize.py path/to/file.safetensors # in-place - python quantize.py path/to/file.safetensors -o out.safetensors # to new file - python quantize.py variants/ # whole directory in place - python quantize.py variants/ -o variants_int/ # whole directory to new dir - python quantize.py file.safetensors --ternary # try ternary weights - python quantize.py file.safetensors --ternary --strict # error if any weight non-ternary -""" - -from __future__ import annotations - -import argparse -import sys -from pathlib import Path -from typing import Dict, Tuple - -import torch -from safetensors import safe_open -from safetensors.torch import save_file - -DTYPES = [ - (torch.int8, -(1 << 7), (1 << 7) - 1), - (torch.int16, -(1 << 15), (1 << 15) - 1), - (torch.int32, -(1 << 31), (1 << 31) - 1), - (torch.int64, None, None), # always fits -] - - -def _normalize_to_int(tensor: torch.Tensor) -> torch.Tensor: - """Return a tensor with strictly integer values, floored from any - half-integer values. Floor (not round) because a -0.5 bias must - become -1 (not 0) to preserve H(x + bias) for binary x.""" - if not tensor.dtype.is_floating_point: - return tensor.to(torch.float64) # promote for range checks - tf = tensor.to(torch.float64) - rounded = tf.round() - if torch.equal(rounded, tf): - return tf - doubled = tf * 2.0 - if torch.equal(doubled.round(), doubled): - return torch.floor(tf) - raise ValueError( - f"tensor has non-half-integer values; range " - f"[{tf.min().item()}, {tf.max().item()}]" - ) - - -def _min_signed_int_dtype(tensor: torch.Tensor) -> torch.dtype: - if tensor.numel() == 0: - return torch.int8 - lo = int(tensor.min().item()) - hi = int(tensor.max().item()) - for dtype, lo_lim, hi_lim in DTYPES: - if lo_lim is None or (lo_lim <= lo and hi <= hi_lim): - return dtype - return torch.int64 - - -def _ternarize_modular_and_patterns( - tensors: Dict[str, torch.Tensor], -) -> Tuple[Dict[str, torch.Tensor], Dict]: - """Replace seed-file modular detectors and pattern_recognition gates - with bit-cascade-equivalent ternary structures. - - Modular: for each modulus N in {3,5,6,7,9,10,11,12}, replace the - layer1.geq{i}/layer1.leq{i}/layer2.eq{i}/layer3.or chain with a - bit-cascade equality detector per multiple of N in [0, 256), then - OR all detectors together. Top-level gate stays named - `modular.mod{N}` (a multi-input OR over per-multiple matches). - - Pattern_recognition.leadingones / trailingones are dropped: they are - seed-file artifacts with no eval coverage and no downstream - consumers in this codebase. - - Moduli whose bit-cascade structure is already present are left - untouched (including their .inputs routing metadata); the rebuild - only fires on the legacy layer1/layer2/layer3 layout. - """ - new_tensors = dict(tensors) - - # --- pattern_recognition: drop leadingones/trailingones --- - pr_dropped = 0 - for k in list(new_tensors.keys()): - if (k.startswith("pattern_recognition.leadingones") - or k.startswith("pattern_recognition.trailingones")): - del new_tensors[k] - pr_dropped += 1 - - # --- modular: rebuild as ternary bit-cascade equality per multiple --- - moduli = [3, 5, 6, 7, 9, 10, 11, 12] - mod_gates_added = 0 - for mod in moduli: - prefix = f"modular.mod{mod}" - if f"{prefix}.eq.k0.all.weight" in new_tensors: - continue # already bit-cascade; rebuilding would only shed metadata - # Drop old structure - for k in list(new_tensors.keys()): - if k.startswith(prefix + "."): - del new_tensors[k] - - multiples = list(range(0, 256, mod)) - # Per-bit match gates + per-multiple AND - for k in multiples: - for i in range(8): # i=0 is MSB (matches inputs MSB-first ordering) - k_bit = (k >> (7 - i)) & 1 - if k_bit == 1: - # bit_match = x[i]: H(x - 0.5) ~~ identity for binary; - # use weight=1, bias=-1 -> H(x-1): x=0 -> 0, x=1 -> 1. - new_tensors[f"{prefix}.eq.k{k}.bit{i}.match.weight"] = torch.tensor([1.0], dtype=torch.float64) - new_tensors[f"{prefix}.eq.k{k}.bit{i}.match.bias"] = torch.tensor([-1.0], dtype=torch.float64) - else: - # bit_match = NOT x[i]: weight=-1, bias=0 -> H(-x): x=0 -> 1, x=1 -> 0. - new_tensors[f"{prefix}.eq.k{k}.bit{i}.match.weight"] = torch.tensor([-1.0], dtype=torch.float64) - new_tensors[f"{prefix}.eq.k{k}.bit{i}.match.bias"] = torch.tensor([0.0], dtype=torch.float64) - mod_gates_added += 1 - # AND of all 8 bit-matches: weights [1]*8, bias -8 - new_tensors[f"{prefix}.eq.k{k}.all.weight"] = torch.tensor([1.0] * 8, dtype=torch.float64) - new_tensors[f"{prefix}.eq.k{k}.all.bias"] = torch.tensor([-8.0], dtype=torch.float64) - mod_gates_added += 1 - - # Final OR over all per-multiple match outputs - m = len(multiples) - new_tensors[f"{prefix}.weight"] = torch.tensor([1.0] * m, dtype=torch.float64) - new_tensors[f"{prefix}.bias"] = torch.tensor([-1.0], dtype=torch.float64) - mod_gates_added += 1 - - return new_tensors, { - "pattern_recognition_dropped": pr_dropped, - "modular_gates_added": mod_gates_added, - "modular_moduli": len(moduli), - } - - -def _ternarize_buffers( - tensors: Dict[str, torch.Tensor], -) -> Tuple[Dict[str, torch.Tensor], Dict]: - """Rewrite single-input weight=+-2 identity buffers as weight=+-1 with - bias adjusted to preserve heaviside output for binary inputs. - - For a single-input gate H(w*x + b) with x in {0, 1}, the only thing - that matters is the pair (H(b), H(w + b)). Pick the smallest integer - bias b' such that (H(b'), H(sgn + b')) matches, with sgn = sign(w). - - Returns (new_tensors, stats). stats has 'fixed', 'failed', 'failed_names'. - """ - new_tensors = dict(tensors) - fixed = 0 - failed_names = [] - - weight_keys = [k for k in tensors if k.endswith(".weight")] - for wkey in weight_keys: - w = tensors[wkey] - wf = w.float() if w.dtype.is_floating_point else w.to(torch.float64).float() - if (wf.abs() <= 1.0).all(): - continue # already ternary - - gate = wkey[: -len(".weight")] - bkey = gate + ".bias" - - # Single-input weight=+-2 buffer with single bias - if ( - wf.numel() == 1 - and abs(wf.item()) == 2.0 - and bkey in tensors - and tensors[bkey].numel() == 1 - ): - w_val = wf.item() - b_val = float(tensors[bkey].float().item()) - sgn = 1.0 if w_val > 0 else -1.0 - x0_target = 1 if b_val >= 0 else 0 - x1_target = 1 if (w_val + b_val) >= 0 else 0 - chosen = None - # Prefer keeping the bias unchanged when possible - for b_new in [int(b_val), int(b_val) - 1, -1, 0, -2, 1, -3, 2]: - x0 = 1 if b_new >= 0 else 0 - x1 = 1 if (sgn + b_new) >= 0 else 0 - if x0 == x0_target and x1 == x1_target: - chosen = b_new - break - if chosen is not None: - new_tensors[wkey] = torch.tensor([sgn], dtype=torch.float64) - new_tensors[bkey] = torch.tensor([float(chosen)], dtype=torch.float64) - fixed += 1 - continue - - failed_names.append(wkey) - - return new_tensors, {"fixed": fixed, "failed_names": failed_names} - - -def quantize_tensors( - tensors: Dict[str, torch.Tensor], - ternary: bool = False, -) -> Tuple[Dict[str, torch.Tensor], Dict[str, int], Tuple[int, int], Dict]: - """Quantize a dict of tensors. Returns - (new_tensors, dtype_counts, (bytes_before, bytes_after), ternary_stats).""" - ternary_stats: Dict = {"applied": False, "fixed": 0, "failed_names": []} - if ternary: - tensors, ternary_stats = _ternarize_buffers(tensors) - ternary_stats["applied"] = True - # Also rebuild modular detectors and drop pattern_recognition stragglers. - tensors, mod_stats = _ternarize_modular_and_patterns(tensors) - ternary_stats["modular_gates_added"] = mod_stats["modular_gates_added"] - ternary_stats["pattern_recognition_dropped"] = mod_stats["pattern_recognition_dropped"] - - new_tensors: Dict[str, torch.Tensor] = {} - counts: Dict[str, int] = {"int8": 0, "int16": 0, "int32": 0, "int64": 0, - "manifest_kept": 0, "skipped": 0} - bytes_before = 0 - bytes_after = 0 - - for name, t in tensors.items(): - bytes_before += t.numel() * t.element_size() - - if name.startswith("manifest."): - new_tensors[name] = t - counts["manifest_kept"] += 1 - bytes_after += t.numel() * t.element_size() - continue - - try: - normalized = _normalize_to_int(t) - except ValueError: - new_tensors[name] = t - counts["skipped"] += 1 - bytes_after += t.numel() * t.element_size() - continue - - target = _min_signed_int_dtype(normalized) - cast = normalized.to(target) - new_tensors[name] = cast - bytes_after += cast.numel() * cast.element_size() - counts[str(target).replace("torch.", "")] += 1 - - return new_tensors, counts, (bytes_before, bytes_after), ternary_stats - - -def quantize_file(in_path: Path, out_path: Path, verbose: bool = False, - ternary: bool = False, strict_ternary: bool = False) -> Dict: - file_before = in_path.stat().st_size - tensors: Dict[str, torch.Tensor] = {} - metadata: Dict[str, str] = {} - with safe_open(str(in_path), framework="pt") as f: - meta = f.metadata() - if meta: - metadata = dict(meta) - for name in f.keys(): - # clone so the source mmap can be released before we write - tensors[name] = f.get_tensor(name).clone() - - new_tensors, counts, (before, after), tstats = quantize_tensors(tensors, ternary=ternary) - - # Audit final ternary status (count of weight tensors with |w| > 1) - final_nonternary = [] - for k, v in new_tensors.items(): - if not k.endswith(".weight"): - continue - if k.startswith("manifest."): - continue - vf = v.float() if v.dtype.is_floating_point else v.to(torch.float64).float() - if (vf.abs() > 1.0).any(): - final_nonternary.append(k) - - if ternary and strict_ternary and final_nonternary: - raise ValueError( - f"--strict failed: {len(final_nonternary)} weight tensors are not " - f"ternary after transformation; first: {final_nonternary[:5]}" - ) - - # Drop the original mmap-backed tensors before writing in-place. - del tensors - out_path.parent.mkdir(parents=True, exist_ok=True) - if ternary: - # Note ternary mode in metadata so downstream tools can see it - if metadata is None: - metadata = {} - metadata = dict(metadata) - metadata["weight_quantization"] = ( - "ternary_partial" if final_nonternary else "ternary" - ) - save_file(new_tensors, str(out_path), metadata=metadata or None) - - file_after = out_path.stat().st_size - return { - "in_path": str(in_path), - "out_path": str(out_path), - "tensor_counts": counts, - "tensor_bytes_before": before, - "tensor_bytes_after": after, - "file_size_before": file_before, - "file_size_after": file_after, - "ternary": tstats, - "final_nonternary": final_nonternary, - } - - -def _print_summary(label: str, info: Dict) -> None: - cb = info["tensor_bytes_before"] - ca = info["tensor_bytes_after"] - fb = info["file_size_before"] - fa = info["file_size_after"] - counts = info["tensor_counts"] - bucket_str = " ".join(f"{k}={v}" for k, v in counts.items() if v) - ratio_t = cb / ca if ca else 1.0 - ratio_f = fb / fa if fa else 1.0 - print( - f" {label}: file {fb / 1e6:6.1f} MB -> {fa / 1e6:6.1f} MB " - f"({ratio_f:.2f}x); tensor data {cb / 1e6:6.1f} MB -> {ca / 1e6:6.1f} MB " - f"({ratio_t:.2f}x)" - ) - print(f" {bucket_str}") - if info.get("ternary", {}).get("applied"): - ts = info["ternary"] - nt = info["final_nonternary"] - print(f" ternary: {ts['fixed']} buffer gates rewritten; " - f"{len(nt)} weight tensors remain non-ternary") - - -def main() -> int: - parser = argparse.ArgumentParser(description="Quantize safetensors to min signed int dtype") - parser.add_argument("input", type=Path, help=".safetensors file or directory of files") - parser.add_argument("-o", "--output", type=Path, default=None, - help="output file or directory (default: in-place)") - parser.add_argument("-v", "--verbose", action="store_true") - parser.add_argument("--ternary", action="store_true", - help="Rewrite single-input weight=+/-2 buffers as +/-1 to push toward " - "ternary {-1, 0, 1} weights and report remaining violations") - parser.add_argument("--strict", action="store_true", - help="With --ternary, fail if any weight tensor is still non-ternary") - parser.add_argument("--report-violations", type=int, default=0, metavar="N", - help="Print first N non-ternary weight tensor names per file") - args = parser.parse_args() - - inputs = [] - if args.input.is_dir(): - inputs = sorted(p for p in args.input.glob("*.safetensors")) - elif args.input.is_file(): - inputs = [args.input] - else: - print(f"not found: {args.input}", file=sys.stderr) - return 2 - - if not inputs: - print(f"no .safetensors files under {args.input}", file=sys.stderr) - return 2 - - if args.output is None: - outputs = inputs # in-place - elif args.output.suffix == ".safetensors": - if len(inputs) != 1: - print("output is a single file but input is a directory; pass a directory output", file=sys.stderr) - return 2 - outputs = [args.output] - else: - args.output.mkdir(parents=True, exist_ok=True) - outputs = [args.output / p.name for p in inputs] - - total_before = 0 - total_after = 0 - print(f"Quantizing {len(inputs)} file(s)" + (" (ternary mode)" if args.ternary else "") + "\n") - for src, dst in zip(inputs, outputs): - info = quantize_file(src, dst, verbose=args.verbose, - ternary=args.ternary, strict_ternary=args.strict) - _print_summary(src.name, info) - if args.report_violations and info.get("final_nonternary"): - for name in info["final_nonternary"][: args.report_violations]: - print(f" non-ternary: {name}") - if len(info["final_nonternary"]) > args.report_violations: - print(f" ... and {len(info['final_nonternary']) - args.report_violations} more") - total_before += info["file_size_before"] - total_after += info["file_size_after"] - - print() - print("=" * 76) - print( - f"Total: {total_before / 1e6:.1f} MB -> {total_after / 1e6:.1f} MB " - f"({total_before / max(total_after, 1):.2f}x reduction)" - ) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/redundant.py b/src/redundant.py deleted file mode 100644 index 110690f88f64edd68fc7faa9a8553e9eb85a7367..0000000000000000000000000000000000000000 --- a/src/redundant.py +++ /dev/null @@ -1,891 +0,0 @@ -"""The amplified host: r-fold redundancy with the restoring organ inside every unit. - -For an odd r, the amplification A_r of a layered map replaces every row by r -rows, each reading all r copies of each of the row's inputs with the row's own -weight and carrying the bias r b + (r - 1)/2. On replicated inputs every -pre-activation is r z + (r - 1)/2, z the pre-activation of the original row, -so the amplified map sends the replicated state to the replicated next state -and every pre-activation lies at distance at least r/2 from the comparator at --1/2; a unit whose inputs carry f wrong copies keeps its margin down to -r/2 - f. The redundant host is N_r = A_r(Lev(N_host)), a clocked netlist on -r * 2057 state bits realizing the SUBLEQ transducer on r copies of the state, -copy c occupying the block c * 2057 .. (c + 1) * 2057 - 1; its identity rows -become majority units, one restoring organ per level. The device reads each -bit of a cell as the majority of its copies and writes every copy. - - python src/redundant.py build [--r 3] sigma(N_r), its sizes, one step against SUBLEQ - python src/redundant.py selfrep [--r 3] Omega*_r on the evaluator in C, to completion - python src/redundant.py omega [--r 3] the instance Omega* of the host on N_r under read noise - python src/redundant.py curve [--r 3] the deviation rate of N_r against the bounds - python src/redundant.py restore [--r 3] wrong copies along a noisy run, and the state - read by majority - python src/redundant.py layers [--r 3] the profile bound of N_r split by layer - python src/redundant.py bound the restoration bound of N_3 along the orbits -""" -from __future__ import annotations - -import argparse -import hashlib -import json -import math -import os -import subprocess -import sys -import tempfile -import time -from typing import Dict, List, Tuple - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -SRC = os.path.join(REPO, "src") - -from check_c import levelize, parse_sigma, write_flat, framed_parts -from host_family import encode -from selfrep import (M_P, M_STAR, HALT_PC, Tape, describe, field, inst, read_host, - ser, tau_star, run_reference, STATE_LAYOUT, IO_CELLS, STATE_BITS) - - -def runs_path(name: str) -> str: - d = os.path.join(REPO, "paper", "runs") - os.makedirs(d, exist_ok=True) - return os.path.join(d, name) - - -def sha(b: bytes) -> str: - return hashlib.sha256(b).hexdigest() - - -# --------------------------------------------------------------------------- -# the construction -# --------------------------------------------------------------------------- - -class Amplified: - """N_r = A_r(Lev(N)) for the netlist N of a serialization, in flat form. - - `units` lists the amplified rows in layer order, copies of a row adjacent, - as (bias, [(signal, weight), ...]) over the signal numbering of N_r: state - bit i of copy c is c n + i, and copy c of row j of Lev(N) is r n + r j + c. - `layers` gives the number of rows of Lev(N) in each layer. - """ - - def __init__(self, sigma: bytes, r: int): - assert r >= 1 and r % 2 == 1, "the redundancy must be odd" - n, units, nxt = parse_sigma(sigma) - ln, lunits, lnxt, info = levelize(n, units, nxt) - assert ln == n - self.r, self.n, self.sigma0 = r, n, sigma - self.layers: List[int] = info["layer_sizes"] - self.depth = info["depth"] - self.lev_rows = len(lunits) - self.lev_units = lunits - self.lev_nxt = lnxt - shift = (r - 1) // 2 - - def pos(k: int, c: int) -> int: - return c * n + k if k < n else r * n + r * (k - n) + c - - out: List[Tuple[int, List[Tuple[int, int]]]] = [] - for b, ins in lunits: - ins_r = [(pos(k, c), w) for k, w in ins for c in range(r)] - for c in range(r): - out.append((r * b + shift, ins_r)) - self.units = out - self.nxt = [r * n + r * (lnxt[i] - n) + c for c in range(r) for i in range(n)] - self.state_bits = r * n - self.edges = sum(len(ins) for _, ins in out) - - def sigma(self) -> bytes: - bias, fanin, pred = [], [], [] - for b, ins in self.units: - bias.append(b) - fanin.append(len(ins)) - for k, w in ins: - pred.append(w * (k + 1)) - lay = dict(STATE_LAYOUT) - lay["replicas"] = self.r - lay["copy_stride"] = self.n - return encode(8, bias, fanin, pred, self.nxt, f"subleq8r{self.r}", lay, IO_CELLS) - - def write_flat(self, path: str) -> None: - write_flat(path, self.state_bits, self.units, self.nxt) - - # -- the layered plan for the evaluator on the accelerator ---------------- - def plan(self, device: str, pad: bool): - """Per layer, groups (rows, idx, w, b): the rows of the amplified layer, - the positions of their inputs in the previous layer's vector, the - weights and the biases. Copy c of row m sits at position m r + c of its - layer; layer 0 is the state, copy c of bit i at c n + i.""" - import torch - r, n = self.r, self.n - base = [n] - for L in self.layers: - base.append(base[-1] + L) - plan = [] - j = 0 - for ell, L in enumerate(self.layers, start=1): - rows_of = [] - for m in range(L): - b, ins = self.lev_units[j] - j += 1 - if ell == 1: - idx = [c * n + k for k, w in ins for c in range(r)] - else: - idx = [r * (k - base[ell - 2]) + c for k, w in ins for c in range(r)] - w = [wt for k, wt in ins for c in range(r)] - for c in range(r): - rows_of.append((m * r + c, idx, w, r * b + (r - 1) // 2)) - groups = [] - if pad: - K = max(len(x[1]) for x in rows_of) - idx_t = torch.zeros(len(rows_of), K, dtype=torch.long) - w_t = torch.zeros(len(rows_of), K) - for q, (row, idx, w, b) in enumerate(rows_of): - idx_t[q, :len(idx)] = torch.tensor(idx) - w_t[q, :len(w)] = torch.tensor(w, dtype=torch.float32) - rows_t = torch.tensor([x[0] for x in rows_of]) - b_t = torch.tensor([x[3] for x in rows_of], dtype=torch.float32) - groups.append((rows_t.to(device), idx_t.to(device), w_t.to(device), b_t.to(device))) - else: - by_k: Dict[int, list] = {} - for x in rows_of: - by_k.setdefault(len(x[1]), []).append(x) - for K in sorted(by_k): - g = by_k[K] - rows_t = torch.tensor([x[0] for x in g]) - idx_t = torch.tensor([x[1] for x in g], dtype=torch.long) - w_t = torch.tensor([x[2] for x in g], dtype=torch.float32) - b_t = torch.tensor([x[3] for x in g], dtype=torch.float32) - groups.append((rows_t.to(device), idx_t.to(device), w_t.to(device), b_t.to(device))) - plan.append((r * L, groups)) - # the final layer holds copy c of state bit i at i r + c; the state - # holds it at c n + i - perm = torch.tensor([i * r + c for c in range(r) for i in range(n)]).to(device) - return plan, perm - - -class Evaluator: - """N_r on the accelerator, with read noise and the device on the copies.""" - - def __init__(self, amp: Amplified, device: str = "cuda", pad: bool = True, - graph: bool = False): - import torch - self.torch = torch - self.amp, self.device = amp, device - self.r, self.n = amp.r, amp.n - self.plan, self.perm = amp.plan(device, pad) - self.M = sum(L for L, _ in self.plan) - self.N = amp.state_bits - cells = (IO_CELLS["c_wr"], IO_CELLS["c_eot"], IO_CELLS["c_rw"], - IO_CELLS["c_rd"], IO_CELLS["r_in"], IO_CELLS["r_out"]) - self.cells = cells - # bit t of cell j of copy c - self.cell_idx = torch.tensor([[c * self.n + 9 + 8 * j + t for j in cells for t in range(8)] - for c in range(self.r)], device=device) - self.pow2 = torch.tensor([1 << (7 - t) for t in range(8)], device=device) - self.shift = torch.tensor([7 - t for t in range(8)]) - self.graph = None - self.gen = None - if graph: - self.capture() - - # -- one application of the map, over a batch -------------------------- - def _forward(self, v, s: float = 0.0, gen=None): - torch = self.torch - x = v - for width, groups in self.plan: - y = torch.empty(x.shape[0], width, device=x.device) - for rows, idx, w, b in groups: - pre = (x[:, idx] * w).sum(-1) + b - if s > 0: - pre = pre + torch.randn(pre.shape, generator=gen, device=pre.device) * s - y[:, rows] = (pre >= -0.5).float() - x = y - return x[:, self.perm] - - def step(self, v): - if self.graph is not None and v.shape[0] == 1 and self.gs == 0.0: - return self._replay(v) - return self._forward(v) - - def step_noisy(self, v, s: float, gen): - if self.graph is not None and v.shape[0] == 1 and self.gs == s and gen is self.gen: - return self._replay(v) - return self._forward(v, s, gen) - - def capture(self, s: float = 0.0, seed: int = 0): - """Capture one application, with noise of deviation s, as a CUDA graph.""" - torch = self.torch - self.gs = s - self.gin = torch.zeros(1, self.N, device=self.device) - gen = None - if s > 0: - gen = torch.Generator(device=self.device).manual_seed(seed) - self.gen = gen - for _ in range(3): - self._forward(self.gin, s, gen) - torch.cuda.synchronize() - self.graph = torch.cuda.CUDAGraph() - with torch.cuda.graph(self.graph): - self.gout = self._forward(self.gin, s, gen) - torch.cuda.synchronize() - if s == 0.0: - probe = (torch.rand(1, self.N, device=self.device) < 0.5).float() - self.gin.copy_(probe) - self.graph.replay() - torch.cuda.synchronize() - assert bool((self.gout == self._forward(probe)).all()), \ - "the captured graph differs from the eager step" - return self - - def _replay(self, v): - if v.data_ptr() != self.gin.data_ptr(): - self.gin.copy_(v) - self.graph.replay() - self.gin.copy_(self.gout) - return self.gin - - # -- states -------------------------------------------------------------- - def vec(self, pc: int, mem: List[int], copies: int = 1): - torch = self.torch - v0 = torch.zeros(self.n) - for k in range(8): - v0[k] = (pc >> (7 - k)) & 1 - for j in range(256): - for k in range(8): - v0[9 + j * 8 + k] = (mem[j] >> (7 - k)) & 1 - v = v0.repeat(self.r).unsqueeze(0).to(self.device) - return v.repeat(copies, 1) - - def device_step(self, v, tapes: List[Tape], shadow: List[List[int]]): - """The device on every copy of the batch: cells read by majority over - the r copies, the clauses applied on a shadow memory, every copy - written. Returns the halt flags, also by majority.""" - torch = self.torch - B = v.shape[0] - bits = v[:, self.cell_idx] # (B, r, 48) - maj = (bits.sum(1) * 2 > self.r).long() # (B, 48) - vals = (maj.reshape(B, len(self.cells), 8) * self.pow2).sum(-1).cpu() - halts = (v[:, [c * self.n + 8 for c in range(self.r)]].sum(1) * 2 > self.r).cpu().tolist() - new = torch.empty(B, len(self.cells), dtype=torch.long) - for i in range(B): - for k, j in enumerate(self.cells): - shadow[i][j] = int(vals[i, k]) - tapes[i].apply(shadow[i]) - for k, j in enumerate(self.cells): - new[i, k] = shadow[i][j] - nb = ((new.unsqueeze(-1) >> self.shift) & 1).reshape(B, 1, -1).float().to(self.device) - v[:, self.cell_idx] = nb.expand(B, self.r, -1) - return halts - - -# --------------------------------------------------------------------------- -def build_amplified(r: int) -> Amplified: - return Amplified(read_host(), r) - - -def compile_c(work: str, cc: str = "gcc") -> str: - """The evaluator in C, with the levels of a large netlist evaluated in - parallel; the threads spin between levels rather than sleep.""" - exe = os.path.join(work, "netlist_eval") - subprocess.run([cc, "-O3", "-march=native", "-fopenmp", "-o", exe, - os.path.join(SRC, "netlist_eval.c")], check=True) - os.environ.setdefault("OMP_WAIT_POLICY", "ACTIVE") - os.environ.setdefault("OMP_PROC_BIND", "true") - os.environ.setdefault("OMP_NUM_THREADS", str(max(1, min(16, (os.cpu_count() or 2) - 4)))) - return exe - - -def cmd_build(args) -> int: - t0 = time.perf_counter() - amp = build_amplified(args.r) - s = amp.sigma() - t_build = time.perf_counter() - t0 - rec = {"r": args.r, "state_bits": amp.state_bits, "units": len(amp.units), - "edges": amp.edges, "depth": amp.depth, "lev_rows": amp.lev_rows, - "layer_widths": [args.r * L for L in amp.layers], - "sigma_bytes": len(s), "sigma_sha256": sha(s), - "bias_min": min(b for b, _ in amp.units), "bias_max": max(b for b, _ in amp.units), - "max_fanin": max(len(i) for _, i in amp.units), "build_seconds": t_build} - print(f"[build] N_{args.r}: {amp.state_bits:,} state bits, {len(amp.units):,} units, " - f"{amp.edges:,} edges, depth {amp.depth}; sigma {len(s):,} bytes, " - f"sha {sha(s)[:16]} ({t_build:.1f} s)", flush=True) - # the serialization denotes the netlist it was written from - n2, u2, x2 = parse_sigma(s) - rec["sigma_roundtrip"] = (n2 == amp.state_bits and u2 == amp.units and x2 == amp.nxt) - print(f" parses back to the same netlist: {rec['sigma_roundtrip']}") - # one step against SUBLEQ on the evaluator in C, every copy checked - work = tempfile.mkdtemp(prefix="redundant_") - exe = compile_c(work, args.cc) - flat = os.path.join(work, "net.bin") - amp.write_flat(flat) - p = subprocess.run([exe, "rand", flat, str(args.states), "1"], capture_output=True, text=True) - rec["c_random"] = p.stdout.strip() - rec["c_random_ok"] = p.returncode == 0 and " 0 disagree" in p.stdout - print(f" {p.stderr.strip()}") - print(f" {p.stdout.strip()}") - # the margins: every pre-activation on a replicated state is r z + (r-1)/2 - import torch - device = "cuda" if torch.cuda.is_available() else "cpu" - E = Evaluator(amp, device=device, pad=True) - gen = torch.Generator().manual_seed(11) - worst = float("inf") - for _ in range(4): - base = (torch.rand(8, amp.n, generator=gen) < 0.5).float() - x = base.repeat(1, args.r).to(device) - for width, groups in E.plan: - y = torch.empty(x.shape[0], width, device=device) - for rows, idx, w, b in groups: - pre = (x[:, idx] * w).sum(-1) + b - worst = min(worst, float((pre + 0.5).abs().min())) - y[:, rows] = (pre >= 0).float() - x = y - rec["min_margin_random_states"] = worst - print(f" minimum distance of a pre-activation from -1/2 on 32 replicated random " - f"states: {worst}") - # the evaluator on the accelerator agrees with the evaluator in C on a prefix - tau = tau_star(read_host(), M_STAR) - v = E.vec(0, M_STAR) - tapes, shadow = [Tape(tau)], [[0] * 256] - for _ in range(300): - v = E.step(v) - E.device_step(v, tapes, shadow) - st = v[0].cpu().long().tolist() - open(os.path.join(work, "mstar"), "wb").write(bytes(M_STAR)) - open(os.path.join(work, "tau"), "wb").write(tau) - p = subprocess.run([exe, "run", flat, os.path.join(work, "mstar"), os.path.join(work, "tau"), - os.path.join(work, "out"), "net", "300"], capture_output=True, text=True) - # replay the C state through the same 300 steps by the reference, then compare the - # emitted prefix and the memory read by majority - mem = list(M_STAR); pc = 0; dev = Tape(tau) - for _ in range(300): - a, b, c = mem[pc], mem[(pc + 1) & 255], mem[(pc + 2) & 255] - rr = (mem[b] - mem[a]) & 255 - mem[b] = rr - pc = c if (rr == 0 or rr >= 128) else (pc + 3) & 255 - dev.apply(mem) - got_mem = [sum(st[c * amp.n + 9 + 8 * j + t] << (7 - t) for t in range(8)) - for j in range(256) for c in (0,)] - all_copies_equal = all(st[c * amp.n + i] == st[i] for c in range(args.r) for i in range(amp.n)) - rec["accelerator_prefix_ok"] = (got_mem == mem and all_copies_equal - and tapes[0].out == dev.out - and open(os.path.join(work, "out"), "rb").read() == bytes(dev.out)) - print(f" 300 steps of Omega* on the accelerator and in C agree with the reference, " - f"all copies equal: {rec['accelerator_prefix_ok']}") - rec["ok"] = bool(rec["sigma_roundtrip"] and rec["c_random_ok"] - and abs(worst - args.r / 2) < 1e-9 and rec["accelerator_prefix_ok"]) - json.dump(rec, open(runs_path(f"paper_redundant_r{args.r}.json"), "w"), indent=1) - print("REDUNDANT HOST:", "PASS" if rec["ok"] else "FAIL") - return 0 if rec["ok"] else 1 - - -def census(r: bytes) -> Dict[str, int]: - """L, R, f_lit, f_rep of a recipe.""" - L = R = fl = fr = 0 - i = 0 - while True: - t = r[i] - if t == 0: - break - if 1 <= t <= 127: - L += 1; fl += t; i += 1 + t - else: - R += 1; fr += 256 - t; i += 2 - return {"L": L, "R": R, "f_lit": fl, "f_rep": fr} - - -def closed_form(c: Dict[str, int], tape_len: int) -> Dict[str, int]: - a = 5 * c["f_lit"] + 4 * c["f_rep"] + 7 * c["L"] + 9 * c["R"] + 7 - return {"A": a, "R": 1, "B": 6 * tape_len + 2, "total": a + 1 + 6 * tape_len + 2} - - -def cmd_selfrep(args) -> int: - """Omega*_r = (N_r, m*, tau*_r) on the evaluator in C, in lockstep with the - reference, to completion; the output is compared with ser(Omega*_r).""" - t0 = time.perf_counter() - amp = build_amplified(args.r) - s_r = amp.sigma() - tau = tau_star(s_r, M_STAR) - target = ser(s_r, bytes(M_STAR), tau) - cen = census(tau) - cf = closed_form(cen, len(tau)) - print(f"[selfrep] N_{args.r}: sigma {len(s_r):,} bytes, tau*_r {len(tau):,} bytes, " - f"ser {len(target):,} bytes; closed form {cf['total']:,} steps", flush=True) - work = tempfile.mkdtemp(prefix="redundant_") - exe = compile_c(work, args.cc) - flat = os.path.join(work, "net.bin") - amp.write_flat(flat) - open(os.path.join(work, "mstar"), "wb").write(bytes(M_STAR)) - open(os.path.join(work, "tau"), "wb").write(tau) - t1 = time.perf_counter() - p = subprocess.run([exe, "run", flat, os.path.join(work, "mstar"), os.path.join(work, "tau"), - os.path.join(work, "out"), args.cmode], capture_output=True, text=True) - dt = time.perf_counter() - t1 - out = open(os.path.join(work, "out"), "rb").read() - exact = out == target - import re - m = re.search(r"after (\d+) steps", p.stdout) - steps = int(m.group(1)) if m else None - sg, mb, tb = inst(out) if exact else (None, None, None) - rec = {"r": args.r, "sigma_bytes": len(s_r), "sigma_sha256": sha(s_r), - "tau_bytes": len(tau), "ser_bytes": len(target), "ser_sha256": sha(target), - "census": cen, "closed_form": cf, "mode": args.cmode, - "c_result": p.stdout.strip(), "steps": steps, "seconds": dt, - "steps_per_second": steps / dt if steps and dt else None, - "out_bytes": len(out), "out_sha256": sha(out), "equals_ser": exact, - "closed_form_agrees": steps == cf["total"], - "inst_recovers_instance": bool(exact and sg == s_r and mb == bytes(M_STAR) and tb == tau), - "units": len(amp.units), "edges": amp.edges, "state_bits": amp.state_bits} - rec["ok"] = bool(exact and rec["closed_form_agrees"] and rec["inst_recovers_instance"] - and p.returncode == 0) - print(f" {p.stderr.strip()}") - print(f" {p.stdout.strip()} ({dt / 60:.1f} min, {rec['steps_per_second'] or 0:,.0f} steps/s)") - print(f" Out = ser(Omega*_r): {exact}; closed form agrees: {rec['closed_form_agrees']}; " - f"inst recovers the instance: {rec['inst_recovers_instance']}; sha {sha(out)[:16]}") - rec["total_seconds"] = time.perf_counter() - t0 - json.dump(rec, open(runs_path(f"paper_redundant_selfrep_r{args.r}.json"), "w"), indent=1) - print("REDUNDANT SELF-REPRODUCTION:", "PASS" if rec["ok"] else "FAIL") - return 0 if rec["ok"] else 1 - - -def tail_bound(s: float, r: int, M: int, T: int) -> float: - """T M erfc(r / (2 sqrt 2 s)): the bound of the fault-tolerance theorem.""" - return T * M * math.erfc(r / (2.0 * math.sqrt(2.0) * s)) - - -def cmd_omega(args) -> int: - """The instance Omega* of the host, run on N_r under read noise on every - pre-activation, to completion: the same tape and the same output bytes as - the run of Table 5 on Lev(N_host).""" - import torch - amp = build_amplified(args.r) - sigma = read_host() - tau = tau_star(sigma, M_STAR) - target = ser(sigma, bytes(M_STAR), tau) - E = Evaluator(amp, device=args.device, pad=True) - M = E.M - print(f"[omega] N_{args.r}: {amp.depth} layers, M = {M:,}, target {len(target):,} bytes", - flush=True) - grid = [float(x) for x in args.grid.split(",")] - rows = [] - for s in grid: - seed = 20260929 + int(1000 * s) - try: - E.capture(s, seed) - gen = E.gen - except Exception as e: # noqa: BLE001 - print(f" (graph capture unavailable: {e}; running eagerly)", flush=True) - E.graph = None - gen = torch.Generator(device=args.device).manual_seed(seed) - v = E.vec(0, M_STAR) - tapes, shadow = [Tape(tau)], [[0] * 256] - steps, wrong, halted = 0, None, False - budget = args.budget - t0 = time.perf_counter() - while steps < budget: - v = E.step_noisy(v, s, gen) - steps += 1 - before = len(tapes[0].out) - halted = E.device_step(v, tapes, shadow)[0] - out = tapes[0].out - if len(out) > before: - k = len(out) - 1 - if k >= len(target) or out[k] != target[k]: - wrong = k - break - if halted: - break - if steps % 200000 == 0: - print(f" s={s:.2f}: {steps:,} steps, {len(out):,} bytes " - f"({steps / (time.perf_counter() - t0):.0f}/s)", flush=True) - dt = time.perf_counter() - t0 - emitted = len(tapes[0].out) - complete = emitted == len(target) and wrong is None and halted - outcome = ("emitted the whole instance" if complete else - f"a wrong byte after {wrong} correct" if wrong is not None - else "the halt bit was set" if halted else "the budget was exhausted") - rows.append({"sigma": s, "steps": steps, "bytes": emitted, "complete": complete, - "first_wrong_byte": wrong, "halted": halted, "seconds": dt, - "bound": tail_bound(s, args.r, M, max(steps, 1))}) - print(f" s={s:.2f}: {steps:,} steps, {emitted:,} bytes, {outcome} " - f"({dt / 60:.1f} min); bound {rows[-1]['bound']:.3e}", flush=True) - json.dump({"r": args.r, "M": M, "target_bytes": len(target), "rows": rows}, - open(runs_path(f"paper_redundant_omega_r{args.r}.json"), "w"), indent=1) - return 0 - - -def upper_tail(x: float) -> float: - return 0.5 * math.erfc(x / math.sqrt(2.0)) - - -def cmd_curve(args) -> int: - """One noise-free and B noisy copies of N_r stepped together from the initial - state of the self-describing instance; the step at which each noisy copy - first differs from the exact one is recorded, and the rate is compared with - the profile bound scaled by r and with M_r erfc(r / (2 sqrt2 s)).""" - import torch - amp = build_amplified(args.r) - sigma = read_host() - r_H = describe(sigma) - E = Evaluator(amp, device=args.device, pad=False) - M = E.M - B = args.batch - print(f"[curve] N_{args.r}: M = {M:,}; {B} noisy copies per level, {args.steps:,} steps each", - flush=True) - prof = None - pp = runs_path("paper_noise_profile.json") - if os.path.exists(pp): - prof = json.load(open(pp))["histogram_per_step"] - grid = [float(x) for x in args.grid.split(",")] - rows = [] - for s in grid: - cap = args.steps if s >= 0.24 else min(args.steps, 3000) - gen = torch.Generator(device=args.device).manual_seed(7 + int(1000 * s)) - v = E.vec(0, M_P, copies=B + 1) - tapes = [Tape(r_H) for _ in range(B + 1)] - shadow = [[0] * 256 for _ in range(B + 1)] - first = [None] * B - live = cap - t0 = time.perf_counter() - for n in range(cap): - x = v - for width, groups in E.plan: - y = torch.empty(x.shape[0], width, device=x.device) - for rws, idx, w, b in groups: - pre = (x[:, idx] * w).sum(-1) + b - noise = torch.randn(pre.shape, generator=gen, device=pre.device) * s - noise[0] = 0.0 - y[:, rws] = ((pre + noise) >= -0.5).float() - x = y - v = x[:, E.perm] - diff = (v[1:] != v[0]).any(dim=1).cpu().tolist() - for i, d in enumerate(diff): - if d and first[i] is None: - first[i] = n + 1 - if all(f is not None for f in first): - live = n + 1 - break - E.device_step(v, tapes, shadow) - dt = time.perf_counter() - t0 - deviated = [f for f in first if f is not None] - exposure = sum(f if f is not None else live for f in first) - rate = len(deviated) / exposure if exposure else 0.0 - bound_tail = M * math.erfc(args.r / (2.0 * math.sqrt(2.0) * s)) - bound_profile = None - if prof is not None: - bound_profile = args.r * sum(c * upper_tail(args.r * abs(int(z) + 0.5) / s) - for z, c in prof.items()) - rows.append({"sigma": s, "copies": B, "steps": live, "deviated": len(deviated), - "median_first_deviation": - sorted(deviated)[len(deviated) // 2] if deviated else None, - "step_exposure": exposure, "empirical_rate_per_step": rate, - "bound_tail_per_step": bound_tail, - "bound_profile_per_step": bound_profile, - "first_deviation": first, "seconds": dt}) - print(f" s={s:.2f}: {len(deviated)}/{B} deviated within {live:,} steps; " - f"empirical {rate:.3e} per step, profile bound " - f"{bound_profile if bound_profile is None else format(bound_profile, '.3e')}, " - f"M erfc {bound_tail:.3e} ({dt / 60:.1f} min)", flush=True) - json.dump({"r": args.r, "M": M, "grid": grid, "rows": rows}, - open(runs_path(f"paper_redundant_curve_r{args.r}.json"), "w"), indent=1) - return 0 - - -def cmd_restore(args) -> int: - """Restoration along the orbit: one exact and B noisy copies of N_r stepped - together from the initial state of the self-describing instance. After every - step the copies of every state bit of each noisy run are compared with the - exact run, and the state read by majority over the r copies is compared with - the exact state; a run is stopped at its first step whose majority-read state - is wrong, and the rate of that event per step of exposure is recorded with - the counts of wrong copies seen before it.""" - import torch - amp = build_amplified(args.r) - sigma = read_host() - r_H = describe(sigma) - E = Evaluator(amp, device=args.device, pad=False) - n, r = amp.n, args.r - B = args.batch - grid = [float(x) for x in args.grid.split(",")] - rows = [] - print(f"[restore] N_{r}: {B} noisy copies, up to {args.steps:,} steps per level", flush=True) - for s in grid: - gen = torch.Generator(device=args.device).manual_seed(31 + int(1000 * s)) - v = E.vec(0, M_P, copies=B + 1) - tapes = [Tape(r_H) for _ in range(B + 1)] - shadow = [[0] * 256 for _ in range(B + 1)] - first = [None] * B - wrong_copy_steps = 0 - wrong_copies = 0 - worst = 0 - live = torch.ones(B, dtype=torch.bool) - t0 = time.perf_counter() - ran = args.steps - for t in range(args.steps): - x = v - for width, groups in E.plan: - y = torch.empty(x.shape[0], width, device=x.device) - for rws, idx, w, b in groups: - pre = (x[:, idx] * w).sum(-1) + b - noise = torch.randn(pre.shape, generator=gen, device=pre.device) * s - noise[0] = 0.0 - y[:, rws] = ((pre + noise) >= -0.5).float() - x = y - v = x[:, E.perm] - ex = v[0].reshape(r, n) - wrong = (v[1:].reshape(B, r, n) != ex.unsqueeze(0)) - k = wrong.sum(dim=(1, 2)).cpu() - maj = (v[1:].reshape(B, r, n).sum(1) * 2 > r) - bad = (maj != (ex[0] > 0.5).unsqueeze(0)).any(1).cpu() - for i in range(B): - if live[i]: - if int(k[i]): - wrong_copy_steps += 1 - wrong_copies += int(k[i]) - worst = max(worst, int(k[i])) - if bool(bad[i]): - first[i] = t + 1 - live[i] = False - if not bool(live.any()): - ran = t + 1 - break - E.device_step(v, tapes, shadow) - dt = time.perf_counter() - t0 - failed = [f for f in first if f is not None] - exposure = sum(f if f is not None else ran for f in first) - rows.append({"sigma": s, "copies": B, "steps": ran, "failed": len(failed), - "median_first_failure": sorted(failed)[len(failed) // 2] if failed else None, - "exposure": exposure, "failure_rate_per_step": len(failed) / exposure, - "steps_with_wrong_copies": wrong_copy_steps, "wrong_copies": wrong_copies, - "max_wrong_copies_in_a_step": worst, "first_failure": first, "seconds": dt}) - print(f" s={s:.2f}: {len(failed)}/{B} majority-read failures within {ran:,} steps, " - f"exposure {exposure:,}, rate {len(failed) / exposure:.3e} per step; " - f"{wrong_copies:,} wrong copies in {wrong_copy_steps:,} copy-steps before the " - f"failures ({dt / 60:.1f} min)", flush=True) - json.dump({"r": r, "copies": B, "cap": args.steps, "rows": rows}, - open(runs_path(f"paper_redundant_restore_r{r}.json"), "w"), indent=1) - return 0 - - -# --------------------------------------------------------------------------- -# the restoration bound -# --------------------------------------------------------------------------- - -def write_bound_input(amp: "Amplified", path: str, mem: List[int], tape: bytes) -> None: - """Lev(N_host) layer by layer, each as CSR over the layer before (the state for the - first), the state bits of the device cells, a memory image and a tape, for - src/restore_bound.c.""" - import struct - from selfrep import C_WR, C_EOT, C_RW, C_RD, R_IN, R_OUT - n = amp.n - base = [n] - for L in amp.layers: - base.append(base[-1] + L) - assert amp.layers[-1] == n and all(amp.lev_nxt[i] == base[-2] + i for i in range(n)), \ - "the last layer of Lev(N_host) is the state, in order" - with open(path, "wb") as f: - f.write(struct.pack(" int: - """The restoration bound of Proposition 6.14, computed by src/restore_bound.c along - the exact orbit from the pre-activation of every row. - - Along the first 20,000 steps of the self-describing instance the per-step bound is - weighted by the steps each copy of the restore run ran where that run exists, and - averaged over the steps elsewhere; along the complete self-reproducing run the sum - of b(t) is taken.""" - import numpy as np - amp = build_amplified(3) - sigma = read_host() - work = tempfile.mkdtemp() - exe = os.path.join(work, "restore_bound") - subprocess.run([args.cc, "-O3", "-march=native", "-o", exe, os.path.join(SRC, "restore_bound.c"), "-lm"], - check=True) - desc_in, repr_in = os.path.join(work, "describing.bin"), os.path.join(work, "reproducing.bin") - write_bound_input(amp, desc_in, M_P, describe(sigma)) - write_bound_input(amp, repr_in, M_STAR, tau_star(sigma, M_STAR)) - restore = {} - rp = runs_path("paper_redundant_restore_r3.json") - if os.path.exists(rp): - for row in json.load(open(rp))["rows"]: - restore[round(row["sigma"], 3)] = row - workers = max(1, min(args.workers, os.cpu_count() or 1)) - out = {"r": 3, "eps_rel": 1e-20, "warm": 20, "steps": args.steps, "rows": [], "whole_run": []} - for s in [float(x) for x in args.grid.split(",")]: - t0 = time.perf_counter() - b, _ = bound_series(exe, desc_in, s, args.steps, args.chunks, workers, work) - cb = np.concatenate([[0.0], np.cumsum(b)]) - row = {"sigma": s, "mean_per_step": float(cb[-1] / len(b)), "copy_fails_within": float(cb[-1]), - "max_per_step": float(b.max()), - "first_order_per_step": 3 * sum(amp.layers) * math.erfc(3 / (2 * math.sqrt(2) * s))} - m = restore.get(round(s, 3)) - if m: - ran = [f if f is not None else m["steps"] for f in m["first_failure"]] - row["weighted_per_step"] = float(sum(cb[e] for e in ran) / sum(ran)) - row["measured_per_step"] = m["failure_rate_per_step"] - row["bound_over_measured"] = row["weighted_per_step"] / m["failure_rate_per_step"] \ - if m["failure_rate_per_step"] > 0 else None - row["seconds"] = time.perf_counter() - t0 - out["rows"].append(row) - print(f" s={s:.2f}: {row.get('weighted_per_step', row['mean_per_step']):.3e} per step" - + (f", measured {row['measured_per_step']:.3e}" if m else "") - + f", first-order {row['first_order_per_step']:.3e} ({row['seconds']:.0f} s)", flush=True) - json.dump(out, open(runs_path("paper_redundant_bound_r3.json"), "w"), indent=1) - for s in [float(x) for x in args.whole.split(",") if x]: - t0 = time.perf_counter() - b, halted = bound_series(exe, repr_in, s, 1_664_939, args.whole_chunks, workers, work) - row = {"sigma": s, "steps": len(b), "halted": halted, "sum": float(b.sum()), - "first_order": len(b) * 3 * sum(amp.layers) * math.erfc(3 / (2 * math.sqrt(2) * s)), - "seconds": time.perf_counter() - t0} - out["whole_run"].append(row) - print(f" whole run, s={s:.2f}: {row['sum']:.3e} over {row['steps']:,} steps, first-order " - f"{row['first_order']:.3e} ({row['seconds'] / 60:.1f} min)", flush=True) - json.dump(out, open(runs_path("paper_redundant_bound_r3.json"), "w"), indent=1) - return 0 - - -def cmd_layers(args) -> int: - """The profile bound of N_r split by layer. - - Along the first 4000 steps of the orbit of the self-describing instance on - Lev(N_host), the pre-activations of every layer are recorded; by - Proposition 6.8(ii) the rows of N_r at a layer are those of Lev(N_host) with - every distance multiplied by r, so the bound of Theorem 6.9(ii) restricted to - a layer is r * sum_i Q(r d_i / s) over its rows. The term of the last layer, - whose rows are the state, is compared with the measured rate of the curve - experiment where that record exists.""" - import torch - from selfrep import M_P, LevEvaluator - from noise import Devices - r = args.r - sigma = read_host() - L = LevEvaluator(sigma, device=args.device, dense=False) - v = L._vec(0, M_P).unsqueeze(0).to(args.device) - D = Devices(L, [describe(sigma)], args.device) - lo, hi = -300, 300 - nl = len(L.plan) - H = torch.zeros(nl, hi - lo + 1, dtype=torch.float64, device=args.device) - T = args.steps - for n in range(args.steps): - x = v - for li, (groups, width) in enumerate(zip(L.plan, L.widths)): - y = torch.empty(1, width, device=args.device) - for rows, idx, w, b in groups: - pre = (x[:, idx] * w).sum(-1) + b - H[li] += torch.bincount(pre.round().long().flatten() - lo, - minlength=hi - lo + 1).double() - y[:, rows] = (pre >= 0).float() - x = y - v = x - if D.step(v)[0]: - T = n + 1 - break - H = (H / T).cpu() - zs = list(range(lo, hi + 1)) - measured = {} - cp = runs_path(f"paper_redundant_curve_r{r}.json") - if os.path.exists(cp): - for row in json.load(open(cp))["rows"]: - measured[round(row["sigma"], 3)] = (row["empirical_rate_per_step"], row["deviated"]) - out = {"r": r, "steps": T, - "per_layer_rows_at_half": [float(H[l][-1 - lo] + H[l][-lo]) for l in range(nl)], - "last_layer_hist": {z: float(H[nl - 1][z - lo]) for z in zs if H[nl - 1][z - lo] > 0}, - "rows": []} - for s in [float(x) for x in args.grid.split(",")]: - q = torch.tensor([upper_tail(r * abs(z + 0.5) / s) for z in zs], dtype=torch.float64) - per_layer = [r * float(H[l] @ q) for l in range(nl)] - m = measured.get(round(s, 3)) - out["rows"].append({"sigma": s, "measured": m[0] if m else None, - "deviated": m[1] if m else None, "full": sum(per_layer), - "last_layer": per_layer[-1], "per_layer": per_layer}) - print(f" s={s:.2f}: last layer {per_layer[-1]:.3e}, all layers {sum(per_layer):.3e}" - + (f", measured {m[0]:.3e} ({m[1]} deviations)" if m else ""), flush=True) - json.dump(out, open(runs_path(f"paper_redundant_layers_r{r}.json"), "w"), indent=1) - return 0 - - -def main() -> int: - ap = argparse.ArgumentParser() - ap.add_argument("mode", choices=["build", "selfrep", "omega", "curve", "restore", "layers", "bound"]) - ap.add_argument("--r", type=int, default=3) - ap.add_argument("--cc", default="gcc") - ap.add_argument("--device", default="cuda") - ap.add_argument("--states", type=int, default=1 << 16) - ap.add_argument("--batch", type=int, default=256) - ap.add_argument("--steps", type=int, default=None) - ap.add_argument("--budget", type=int, default=1 << 21) - ap.add_argument("--cmode", default="lock", - help="for selfrep: lock (compared with the reference every step) or net") - ap.add_argument("--grid", default=None) - ap.add_argument("--whole", default="0.20,0.22,0.25,0.27", - help="for bound: the noise levels of the sums over the self-reproducing run") - ap.add_argument("--chunks", type=int, default=10, help="for bound: chunks of the 20,000 steps") - ap.add_argument("--whole-chunks", type=int, default=36, help="for bound: chunks of the whole run") - ap.add_argument("--workers", type=int, default=6, help="for bound: chunks run at once") - args = ap.parse_args() - if args.steps is None: - args.steps = 20000 if args.mode == "bound" else 4000 - if args.grid is None: - args.grid = {"omega": "0.10,0.15,0.20,0.25", - "curve": "0.06,0.12,0.18,0.24,0.27,0.30,0.33,0.36,0.45,0.60", - "restore": "0.27,0.30,0.33,0.36", - "layers": "0.06,0.12,0.18,0.24,0.27,0.30,0.33,0.36,0.45,0.60", - "bound": "0.10,0.15,0.20,0.22,0.25,0.27,0.30,0.33,0.36"}.get(args.mode, "") - return {"build": cmd_build, "selfrep": cmd_selfrep, "omega": cmd_omega, - "curve": cmd_curve, "restore": cmd_restore, "layers": cmd_layers, - "bound": cmd_bound}[args.mode](args) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/reflect.py b/src/reflect.py index b15bca0932142e74d6cddbcf787c6dbffe8bc0ff..c700c1ec86770da301c0550077c5aa2f02ebe006 100644 --- a/src/reflect.py +++ b/src/reflect.py @@ -1,56 +1,4 @@ -"""neural_reflect — a universal ternary-threshold netlist processor whose -transition is a fixed, machine-independent interpreter U over a netlist held -in the writable state. - -U is the only fixed part: a ternary threshold circuit that evaluates one stored -gate per recurrence. Everything machine-specific is the stored netlist, which -the running program can read, edit, copy, and replace. U is universal over -ternary threshold netlists, so the fixed part is a substrate (like a -cellular-automaton rule), not a particular machine. - -State: a flat array of S binary signals (A = log2 S), two netlist banks, and -memory-mapped device signals of the kind the rest of the family already uses -(rv32's console, subleq8io's tape): - - [ PTR | OUT_DATA OUT_STROBE PTR_ADV BANK HALT | work | NET bank0 | NET bank1 ] - -Each gate record holds F input slots (address, ternary weight, index flag), a -signed bias, and an output (address, index flag). An index flag adds PTR to the -address, so a fixed-size program can walk an address range. A 1 written to -PTR_ADV advances PTR and is cleared; that clause is part of the netlist U, so a -stored netlist that does not use the output needs nothing outside U. A 1 -written to OUT_STROBE emits OUT_DATA and is cleared by the output device, the -one clause realized by the runtime. - -One recurrence, gate gp of the active bank: - eff(addr, idx) = (addr + (idx ? PTR : 0)) mod S - out = H( sum_j w_j * sig[eff(a_j, i_j)] + bias ) - sig[eff(oa, oidx)] = out -G recurrences sweep the netlist once; G sweeps settle any acyclic netlist -regardless of gate order, so U reproduces the family's combinational evaluation. - -Capabilities: - - compile_to_reflect() maps any <=2-input family Net to a stored netlist; a - ripple-carry adder and the family's SUBLEQ datapath run through the - interpreter bit-exact, independent of the order the gates are stored in. - - Read by gate index, the netlist can far exceed the addressed signal span: a - whole SUBLEQ machine, its memory held in signals, runs one instruction per - sweep with the interpreter's indexed addressing as its memory-access - hardware. - - A program streams the netlist's own bytes to the output device; another - rewrites its own gate so the next sweep computes a different function; a - BANK flag runs a different resident machine. A netlist with feedback holds - state across sweeps. - - The accumulator width follows from F and the bias width. U compiles to a - recurrent ternary matrix stack equal to the gate graph, with a measured - noise and conductance-mismatch margin. - -Usage: - python src/reflect.py build # save variants/neural_reflect.safetensors - python src/reflect.py verify # universality, order, quine, metamorphosis - python src/reflect.py analog # matrix == gate + noise / mismatch margins - python src/reflect.py all -""" +"""neural_reflect: a universal ternary threshold netlist processor whose state holds the netlist it runs.""" from __future__ import annotations @@ -68,7 +16,7 @@ import torch from safetensors.torch import save_file sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from matrix8 import Net, compile_net +from machines import Net, compile_net REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) MODEL_PATH = os.path.join(REPO, "variants", "neural_reflect.safetensors") @@ -181,8 +129,7 @@ def encode_netlist(cfg, gates): # ============================================================================= def apply_output(cfg, sig): - """The output clause: a 1 at OUT_STROBE emits OUT_DATA and clears the - strobe. It is the only clause realized outside the netlist.""" + """The output clause: a 1 at OUT_STROBE emits OUT_DATA and clears the strobe.""" emit = None if sig[cfg.OUT_STROBE] == 1: emit = sig[cfg.OUT_DATA] @@ -191,8 +138,7 @@ def apply_output(cfg, sig): def apply_pointer(cfg, sig): - """The pointer clause: a 1 at PTR_ADV increments PTR modulo S and clears - PTR_ADV. build_net realizes it inside the netlist.""" + """The pointer clause: PTR_ADV = 1 increments PTR modulo S and clears PTR_ADV.""" if sig[cfg.PTR_ADV] == 1: p = (from_bits(sig[cfg.PTR_BASE:cfg.PTR_BASE + cfg.A]) + 1) & (cfg.S - 1) sig[cfg.PTR_BASE:cfg.PTR_BASE + cfg.A] = to_bits(p, cfg.A) @@ -412,12 +358,7 @@ class Leveled: class SparseLeveled: - """The netlist evaluated level by level as sparse matrix products. - - The same map as Leveled: each level is a sparse matrix of the units' weights - over all signals, so a batch of states costs the edges of the netlist and - nothing for the padding Leveled uses. For large batches. - """ + """The netlist evaluated level by level as sparse matrix products.""" def __init__(self, net, inputs, outputs, device="cpu"): import warnings @@ -521,10 +462,7 @@ def record_bit(cfg, bank, gate, off): def compile_to_reflect(cfg, net, in_addr, out_names, work_start, fixed=None): - """Lay a <=2-input acyclic Net out as reflect gate records in topological - order. Primary inputs sit at in_addr; gate outputs get fresh work signals - from work_start up, except those named in `fixed` (gate -> signal), which - are placed at the given signal. Returns (records, address map, next work).""" + """Lay a <=2-input acyclic Net out as reflect gate records in topological order.""" fixed = fixed or {} addr = dict(in_addr) w = work_start @@ -555,10 +493,7 @@ def adder_net(bits): def subleq_datapath_net(): - """The SUBLEQ machine's datapath as <=2-input threshold gates: result = - M[B] - M[A], the branch decision leq = (result <= 0), and next PC = - leq ? C : PC + 3. All inputs LSB-first. (This is the combinational core the - family verifies exhaustively; the 256-byte packed memory is not included.)""" + """The SUBLEQ datapath as gates of at most two inputs: the difference, the branch and the next PC.""" net = Net() a = [f"a{k}" for k in range(8)] b = [f"b{k}" for k in range(8)] @@ -940,10 +875,7 @@ MEM = 416 def build_subleq_machine(cfg): - """A stored microprogram implementing one SUBLEQ instruction per sweep over - a 32-byte memory held in signals [MEM, MEM+8*32). Data-dependent memory - access is the interpreter's own indexed addressing: set PTR to a byte - address, then read/write the eight bit-planes at MEM + b*32 + PTR.""" + """A stored microprogram running one SUBLEQ instruction per sweep over a 32-byte memory.""" WB = cfg.WORK_BASE reg = lambda o: [WB + o + k for k in range(8)] PC, A_, B_, C_ = reg(0), reg(8), reg(16), reg(24) diff --git a/src/reversible.py b/src/reversible.py deleted file mode 100644 index 7646693efad71833e9edaaf73b503caab09d5097..0000000000000000000000000000000000000000 --- a/src/reversible.py +++ /dev/null @@ -1,336 +0,0 @@ -"""Reversible threshold computer. - -A conventional processor's state transition is not injective: overwriting a -register or a carry destroys information, which by Landauer's principle sets a -floor of kT ln 2 of dissipated energy per erased bit. This machine is built so -that the whole state transition T is a bijection, hence realizable with no -logical erasure and (on an adiabatically driven substrate) no Landauer floor. - -Everything is expressed in the repository's threshold substrate. The reversible -primitives are threshold circuits whose input->output map happens to be a -permutation: - - NOT t -> t' = 1 - t - CNOT c t -> t' = t XOR c - TOFF a b t -> t' = t XOR (a AND b) (Toffoli, universal) - FRED c x y -> (x,y)' = (c?y:x, c?x:y) (Fredkin, controlled swap) - -Each target update is XOR(target, product-of-controls); XOR and AND are the same -Heaviside threshold gates used everywhere else in the repo, so a reversible gate -is a small threshold network that is bijective on its wires, and a composition of -them is a bijective threshold network on the register. - -This module builds up from those gates to a reversible in-place adder (Cuccaro), -and later files add the reversible ALU, ISA, and the bijective machine step. -Reversibility is not asserted, it is verified: every construction is checked to -be a permutation of its state space, exhaustively at small widths. -""" -from __future__ import annotations -from typing import List - -# --- threshold-gate truth: the target updates are computed by Heaviside gates --- -def H(x: int) -> int: - return 1 if x >= 0 else 0 - - -def g_and(a: int, b: int) -> int: - return H(a + b - 2) # fires iff a=b=1 - - -def g_xor(a: int, b: int) -> int: - # XOR as AND(OR, NAND): OR=H(a+b-1), NAND=H(1-a-b), out=AND(OR,NAND) - return g_and(H(a + b - 1), H(1 - a - b)) - - -# --- reversible primitives, in place on a bit register (a Python list) --- -def NOT(reg: List[int], t: int) -> None: - reg[t] = 1 - reg[t] - - -def CNOT(reg: List[int], c: int, t: int) -> None: - reg[t] = g_xor(reg[t], reg[c]) - - -def TOFF(reg: List[int], a: int, b: int, t: int) -> None: - reg[t] = g_xor(reg[t], g_and(reg[a], reg[b])) - - -def FRED(reg: List[int], c: int, x: int, y: int) -> None: - # controlled swap of x,y on control c - if reg[c]: - reg[x], reg[y] = reg[y], reg[x] - - -# --- Cuccaro ripple-carry adder: b += a in place, one carry ancilla --- -# MAJ(c,b,a): b^=a ; c^=a ; a ^= b&c UMA(c,b,a): a ^= b&c ; c^=a ; b^=c -def _maj(reg, c, b, a): - CNOT(reg, a, b) - CNOT(reg, a, c) - TOFF(reg, b, c, a) - - -def _uma(reg, c, b, a): - TOFF(reg, b, c, a) - CNOT(reg, a, c) - CNOT(reg, c, b) - - -# Each primitive below is its own inverse, so the inverse of a gate sequence is -# the reversed sequence. Building the adder as an op list gives subtraction for -# free (run it backward). -def _maj_ops(c, b, a): - return [(CNOT, a, b), (CNOT, a, c), (TOFF, b, c, a)] - - -def _uma_ops(c, b, a): - return [(TOFF, b, c, a), (CNOT, a, c), (CNOT, c, b)] - - -def _adder_ops(a_bits, b_bits, carry, cout=None): - n = len(a_bits) - ops = _maj_ops(carry, b_bits[0], a_bits[0]) - for i in range(1, n): - ops += _maj_ops(a_bits[i - 1], b_bits[i], a_bits[i]) - if cout is not None: - ops.append((CNOT, a_bits[n - 1], cout)) - for i in range(n - 1, 0, -1): - ops += _uma_ops(a_bits[i - 1], b_bits[i], a_bits[i]) - ops += _uma_ops(carry, b_bits[0], a_bits[0]) - return ops - - -def _apply(reg, ops, inverse=False): - for gate, *args in (reversed(ops) if inverse else ops): - gate(reg, *args) - - -def add_into(reg, a_bits, b_bits, carry, cout=None): - """b <- (a + b) mod 2^n (LSB first); a and carry (=0) restored.""" - _apply(reg, _adder_ops(a_bits, b_bits, carry, cout)) - - -def sub_into(reg, a_bits, b_bits, carry, cout=None): - """b <- (b - a) mod 2^n: the adder run backward.""" - _apply(reg, _adder_ops(a_bits, b_bits, carry, cout), inverse=True) - - -def xor_into(reg, a_bits, b_bits): - """b <- b XOR a, bitwise (self-inverse).""" - for a, b in zip(a_bits, b_bits): - CNOT(reg, a, b) - - -def incr(reg, b_bits, one_bits, carry): - """b <- b + 1 mod 2^n. `one_bits` is a register holding the constant 1 - (LSB set), restored on exit; `carry` is a clean ancilla, restored.""" - add_into(reg, one_bits, b_bits, carry) - - -def neg_into(reg, b_bits, one_bits, carry): - """b <- (-b) mod 2^n via two's complement (~b then +1). Self-inverse.""" - for t in b_bits: - NOT(reg, t) - add_into(reg, one_bits, b_bits, carry) - - -def rot_left(reg, b_bits, k=1): - """Rotate the word left by k (a permutation of bit positions; reversible).""" - n = len(b_bits) - k %= n - vals = [reg[b_bits[i]] for i in range(n)] - for i in range(n): - reg[b_bits[(i + k) % n]] = vals[i] - - -def and_into(reg, a_bits, b_bits, t_bits): - """t <- t XOR (a AND b), bitwise (word-level Toffoli). Self-inverse.""" - for a, b, t in zip(a_bits, b_bits, t_bits): - TOFF(reg, a, b, t) - - -# --- verification helpers --- -def is_permutation(fn, nbits: int) -> bool: - """Check fn: {0,1}^nbits -> {0,1}^nbits is a bijection (exhaustive).""" - seen = set() - for x in range(1 << nbits): - reg = [(x >> k) & 1 for k in range(nbits)] - fn(reg) - y = sum(b << k for k, b in enumerate(reg)) - seen.add(y) - return len(seen) == (1 << nbits) - - -def _test_primitives(): - ok = True - ok &= is_permutation(lambda r: NOT(r, 0), 1) - ok &= is_permutation(lambda r: CNOT(r, 0, 1), 2) - ok &= is_permutation(lambda r: TOFF(r, 0, 1, 2), 3) - ok &= is_permutation(lambda r: FRED(r, 0, 1, 2), 3) - print(f" primitives bijective (NOT/CNOT/TOFF/FRED): {'OK' if ok else 'FAIL'}") - return ok - - -def _test_adder(width=4): - # layout: a[0..w-1], b[0..w-1], carry - a_bits = list(range(width)) - b_bits = list(range(width, 2 * width)) - carry = 2 * width - n = 2 * width + 1 - ok_perm = is_permutation(lambda r: add_into(r, a_bits, b_bits, carry), n) - bad = 0 - mask = (1 << width) - 1 - for a in range(1 << width): - for b in range(1 << width): - reg = [0] * n - for k in range(width): - reg[a_bits[k]] = (a >> k) & 1 - reg[b_bits[k]] = (b >> k) & 1 - add_into(reg, a_bits, b_bits, carry) - got_a = sum(reg[a_bits[k]] << k for k in range(width)) - got_b = sum(reg[b_bits[k]] << k for k in range(width)) - if got_a != a or got_b != ((a + b) & mask) or reg[carry] != 0: - bad += 1 - print(f" Cuccaro adder {width}-bit: bijection={'OK' if ok_perm else 'FAIL'} " - f"b<-a+b, a & carry restored={'OK' if bad == 0 else f'FAIL({bad})'}") - return ok_perm and bad == 0 - - -def bennett(reg, a_b, b_b, c_b, scr_b, out_b, carry): - """Bennett compute-copy-uncompute for the irreversible f(a,b,c)=(a+b) XOR c. - Maps (a,b,c,0,0) -> (a,b,c,0,f) with inputs preserved and scratch cleaned, so - a function that discards information as a standalone map is realized by a - reversible circuit. The op list is returned so the inverse is the reverse.""" - ops = [] - # compute f into scratch (scratch starts 0): scratch += a; scratch += b; scratch ^= c - ops += _adder_ops(a_b, scr_b, carry) - ops += _adder_ops(b_b, scr_b, carry) - ops += [(CNOT, c, s) for c, s in zip(c_b, scr_b)] - # copy scratch -> out - ops += [(CNOT, s, o) for s, o in zip(scr_b, out_b)] - # uncompute scratch (reverse of the compute prefix) - n_comp = len(_adder_ops(a_b, scr_b, carry)) * 2 + len(c_b) - ops += [(g, *args) for g, *args in reversed(ops[:n_comp])] - _apply(reg, ops) - return ops - - -def _test_bennett(width=4): - a_b = list(range(width)) - b_b = list(range(width, 2 * width)) - c_b = list(range(2 * width, 3 * width)) - scr_b = list(range(3 * width, 4 * width)) - out_b = list(range(4 * width, 5 * width)) - carry = 5 * width - n = 5 * width + 1 - mask = (1 << width) - 1 - bad = 0 - for a in range(1 << width): - for b in range(1 << width): - for c in range(1 << width): - r = [0] * n - for k in range(width): - r[a_b[k]] = (a >> k) & 1 - r[b_b[k]] = (b >> k) & 1 - r[c_b[k]] = (c >> k) & 1 - bennett(r, a_b, b_b, c_b, scr_b, out_b, carry) - ra = sum(r[a_b[k]] << k for k in range(width)) - rb = sum(r[b_b[k]] << k for k in range(width)) - rc = sum(r[c_b[k]] << k for k in range(width)) - rs = sum(r[scr_b[k]] << k for k in range(width)) - ro = sum(r[out_b[k]] << k for k in range(width)) - if (ra, rb, rc, rs, ro, r[carry]) != (a, b, c, 0, ((a + b) & mask) ^ c, 0): - bad += 1 - print(f" Bennett (a,b,c,0,0)->(a,b,c,0,(a+b)^c), scratch cleaned " - f"[{width}-bit]: {'OK' if bad == 0 else f'FAIL({bad})'}") - return bad == 0 - - -def _test_alu(width=4): - mask = (1 << width) - 1 - a_b = list(range(width)) - b_b = list(range(width, 2 * width)) - one_b = list(range(2 * width, 3 * width)) - t_b = list(range(3 * width, 4 * width)) - carry = 4 * width - n = 4 * width + 1 - - def fresh(a=0, b=0, t=0, one=False): - r = [0] * n - for k in range(width): - r[a_b[k]] = (a >> k) & 1 - r[b_b[k]] = (b >> k) & 1 - r[t_b[k]] = (t >> k) & 1 - if one: - r[one_b[0]] = 1 - return r - - def rd(r, bits): - return sum(r[bits[k]] << k for k in range(width)) - - results = {} - # subtract - bad = 0 - for a in range(1 << width): - for b in range(1 << width): - r = fresh(a, b) - sub_into(r, a_b, b_b, carry) - if rd(r, b_b) != ((b - a) & mask) or rd(r, a_b) != a or r[carry] != 0: - bad += 1 - results["sub b-=a"] = bad == 0 and is_permutation(lambda r: sub_into(r, a_b, b_b, carry), n) - # xor - bad = 0 - for a in range(1 << width): - for b in range(1 << width): - r = fresh(a, b) - xor_into(r, a_b, b_b) - if rd(r, b_b) != (a ^ b) or rd(r, a_b) != a: - bad += 1 - results["xor b^=a"] = bad == 0 - # negate - bad = 0 - for b in range(1 << width): - r = fresh(b=b, one=True) - neg_into(r, b_b, one_b, carry) - if rd(r, b_b) != ((-b) & mask) or rd(r, one_b) != 1 or r[carry] != 0: - bad += 1 - results["neg b=-b"] = bad == 0 - # increment - bad = 0 - for b in range(1 << width): - r = fresh(b=b, one=True) - incr(r, b_b, one_b, carry) - if rd(r, b_b) != ((b + 1) & mask): - bad += 1 - results["incr b+=1"] = bad == 0 - # rotate - bad = 0 - for b in range(1 << width): - r = fresh(b=b) - rot_left(r, b_b, 1) - exp = ((b << 1) | (b >> (width - 1))) & mask - if rd(r, b_b) != exp: - bad += 1 - results["rot_left"] = bad == 0 - # and-into (Toffoli word) - bad = 0 - for a in range(1 << width): - for b in range(1 << width): - for t in range(1 << width): - r = fresh(a, b, t) - and_into(r, a_b, b_b, t_b) - if rd(r, t_b) != (t ^ (a & b)): - bad += 1 - results["and t^=a&b"] = bad == 0 - ok = all(results.values()) - print(" reversible ALU " + f"{width}-bit: " + - " ".join(f"{k}={'OK' if v else 'FAIL'}" for k, v in results.items())) - return ok - - -if __name__ == "__main__": - print("Reversible primitives + ALU") - a = _test_primitives() - b = _test_adder(4) and _test_adder(5) - c = _test_alu(4) - d = _test_bennett(4) - print("PASS" if (a and b and c and d) else "FAIL") diff --git a/src/reversible_cpu.py b/src/reversible_cpu.py deleted file mode 100644 index 3d464e4e136aac1fdd1d93aebec43f42ad6af75b..0000000000000000000000000000000000000000 --- a/src/reversible_cpu.py +++ /dev/null @@ -1,166 +0,0 @@ -"""Reversible register machine with a bijective state transition. - -State s = (R[0..K-1], PC, BR, MEM[0..M-1]); the program is a read-only array -fetched by PC. Every instruction is a reversible update, and control flow is -reversible through a branch register BR: the program counter advances by -`PC += dir*BR` each cycle (BR = 1 for sequential flow), and a branch toggles BR -by XOR-ing `offset ^ 1`, so a matched branch at the destination restores BR to 1. -Because BR carries the control state, the exact same machine run with dir = -1 -retraces the computation and reconstructs the input, dissipating nothing. - -Instruction inverses (used for dir = -1): - ADD<->SUB, ADDI(k)<->ADDI(-k), XOR/XORI/NEG/TOFF/EXCH self-inverse, - ROL(k)<->ROL(-k); BRA/BEZ branch toggles are self-inverse. - -The word-level updates are the reversible threshold circuits verified in -reversible.py (Cuccaro adder, bitwise Toffoli, rotate); this file is the -value-level machine whose single-step transition those circuits implement. -""" -from __future__ import annotations -from typing import Dict, List, Tuple, Optional - - -class RCPU: - def __init__(self, program: List[tuple], k_regs=4, width=8, mem_words=16): - self.prog = program - self.K = k_regs - self.W = width - self.M = mem_words - self.mask = (1 << width) - 1 - self.L = len(program) - - def new_state(self, regs=None, mem=None) -> dict: - return {"R": list(regs) + [0] * (self.K - len(regs)) if regs else [0] * self.K, - "PC": 0, "BR": 1, "MEM": list(mem) + [0] * (self.M - len(mem)) if mem else [0] * self.M} - - # ---- reversible instruction effects (forward and inverse) ---- - def _data(self, s, I, inverse: bool): - R, MEM, m = s["R"], s["MEM"], self.mask - op = I[0] - if op == "ADD": - d, r = I[1], I[2] - R[d] = (R[d] - R[r]) & m if inverse else (R[d] + R[r]) & m - elif op == "SUB": - d, r = I[1], I[2] - R[d] = (R[d] + R[r]) & m if inverse else (R[d] - R[r]) & m - elif op == "ADDI": - d, k = I[1], I[2] - R[d] = (R[d] - k) & m if inverse else (R[d] + k) & m - elif op == "XOR": - R[I[1]] ^= R[I[2]] - elif op == "XORI": - R[I[1]] ^= (I[2] & m) - elif op == "NEG": - R[I[1]] = (-R[I[1]]) & m - elif op == "TOFF": - R[I[1]] ^= (R[I[2]] & R[I[3]]) - elif op == "ROL": - k = (-I[2] if inverse else I[2]) % self.W - R[I[1]] = ((R[I[1]] << k) | (R[I[1]] >> (self.W - k))) & m if k else R[I[1]] - elif op == "EXCH": - d, r = I[1], I[2] - a = R[r] % self.M - R[d], MEM[a] = MEM[a], R[d] - # BRA/BEZ/HALT have no data effect - - def _toggle(self, s, I): - """Reversible control: toggle BR for a taken branch (self-inverse).""" - op = I[0] - if op == "BRA": - s["BR"] ^= (I[1] ^ 1) - elif op == "BEZ": - if s["R"][I[1]] == 0: - s["BR"] ^= (I[2] ^ 1) - - # ---- single-step transition and its inverse ---- - def step(self, s): - I = self.prog[s["PC"]] - self._data(s, I, inverse=False) - self._toggle(s, I) - s["PC"] = (s["PC"] + s["BR"]) % self.L - - def step_back(self, s): - s["PC"] = (s["PC"] - s["BR"]) % self.L - I = self.prog[s["PC"]] - self._toggle(s, I) # self-inverse: restores BR - self._data(s, I, inverse=True) - - def run(self, s, steps): - for _ in range(steps): - self.step(s) - return s - - def run_back(self, s, steps): - for _ in range(steps): - self.step_back(s) - return s - - -def _clone(s): - return {"R": list(s["R"]), "PC": s["PC"], "BR": s["BR"], "MEM": list(s["MEM"])} - - -def _eq(a, b): - return a["R"] == b["R"] and a["PC"] == b["PC"] and a["BR"] == b["BR"] and a["MEM"] == b["MEM"] - - -def test_straight_line(): - prog = [("ADD", 1, 0), ("XOR", 1, 0), ("NEG", 1), ("ADDI", 1, 7), - ("TOFF", 2, 0, 1), ("ROL", 0, 1)] - m = RCPU(prog, width=8) - ok = True - for a in (5, 0, 255, 100): - for b in (3, 1, 200): - s0 = m.new_state([a, b, 0, 0]) - s = _clone(s0) - m.run(s, len(prog)) - fwd = _clone(s) - m.run_back(s, len(prog)) - ok &= _eq(s, s0) # round-trip recovers the exact input - print(f" straight-line round-trip (dir -1 recovers input): {'OK' if ok else 'FAIL'}") - return ok - - -def test_bijection(): - """The machine's single-step transition is a bijection: step_back inverts - step (and vice versa) for every instruction, over a sweep of states - including the branch register. This is reversibility, program-independent.""" - import itertools - W = 4 - prog = [ - ("ADD", 1, 0), ("SUB", 1, 0), ("ADDI", 2, 3), ("XOR", 1, 2), - ("XORI", 0, 5), ("NEG", 3), ("TOFF", 3, 0, 1), ("ROL", 0, 1), - ("EXCH", 2, 3), ("BRA", 2), ("BEZ", 1, 3), ("BEZ", 0, -2), - ] - m = RCPU(prog, k_regs=4, width=W, mem_words=4) - bad = 0 - checked = 0 - rng = __import__("random").Random(0) - for pc in range(len(prog)): - for br in (1, 2, 3, -2, -1): - for _ in range(60): - s0 = {"R": [rng.randint(0, (1 << W) - 1) for _ in range(4)], - "PC": pc, "BR": br, - "MEM": [rng.randint(0, (1 << W) - 1) for _ in range(4)]} - s = _clone(s0) - m.step(s) - m.step_back(s) - if not _eq(s, s0): - bad += 1 - # and the other composition order - s = _clone(s0) - m.step_back(s) - m.step(s) - if not _eq(s, s0): - bad += 1 - checked += 2 - print(f" step_back o step = id over {checked} (state, instruction) cases: " - f"{'OK' if bad == 0 else f'FAIL({bad})'}") - return bad == 0 - - -if __name__ == "__main__": - print("Reversible CPU") - a = test_straight_line() - b = test_bijection() - print("PASS" if (a and b) else "FAIL") diff --git a/src/reversible_prog.py b/src/reversible_prog.py deleted file mode 100644 index 6363858cf342cd25a423fca4ecb71c6629964d2c..0000000000000000000000000000000000000000 --- a/src/reversible_prog.py +++ /dev/null @@ -1,162 +0,0 @@ -"""Structured reversible programs over the reversible ALU. - -The flat instruction machine (reversible_cpu.py) proves the per-step transition -is a bijection. This file is the structured layer that makes real programs -convenient and their reversibility obvious: statements are reversible register -updates and a bounded loop whose count register is read-only, so inverting a -program is a purely syntactic transform (reverse the statement order and invert -each statement) and running the inverse recovers the input exactly. - - ADD d,s : d += s inverse SUB d,s - SUB d,s : d -= s inverse ADD d,s - ADDI d,k : d += k inverse ADDI d,-k - XOR d,s : d ^= s self-inverse - NEG d : d = -d self-inverse - SWAP a,b : a,b = b,a self-inverse - FOR c B : run B, c times inverse FOR c invert(B) (B must not write c) - IF p T E : Janus conditional with an exit assertion (reversible) - -Every update is the value-level image of the verified reversible threshold -circuits in reversible.py; a FOR whose count register is preserved is a -reversible loop because both directions read the same count. -""" -from __future__ import annotations -from typing import Dict, List, Tuple - -WIDTH = 8 -MASK = (1 << WIDTH) - 1 - - -def _regs_written(stmts) -> set: - w = set() - for st in stmts: - op = st[0] - if op in ("ADD", "SUB", "ADDI", "XOR", "NEG"): - w.add(st[1]) - elif op == "SWAP": - w.add(st[1]); w.add(st[2]) - elif op == "FOR": - w |= _regs_written(st[2]) - elif op == "IF": - w |= _regs_written(st[2]) | _regs_written(st[3]) - return w - - -def invert(stmts: List[tuple]) -> List[tuple]: - out = [] - for st in reversed(stmts): - op = st[0] - if op == "ADD": - out.append(("SUB", st[1], st[2])) - elif op == "SUB": - out.append(("ADD", st[1], st[2])) - elif op == "ADDI": - out.append(("ADDI", st[1], -st[2])) - elif op in ("XOR", "NEG", "SWAP"): - out.append(st) - elif op == "FOR": - out.append(("FOR", st[1], invert(st[2]))) - elif op == "IF": - # Janus: reverse swaps predicate and exit assertion, inverts branches - out.append(("IF", st[4], invert(st[2]), invert(st[3]), st[1])) - return out - - -def run(stmts: List[tuple], s: Dict[str, int]) -> Dict[str, int]: - for st in stmts: - op = st[0] - if op == "ADD": - s[st[1]] = (s[st[1]] + s[st[2]]) & MASK - elif op == "SUB": - s[st[1]] = (s[st[1]] - s[st[2]]) & MASK - elif op == "ADDI": - s[st[1]] = (s[st[1]] + st[2]) & MASK - elif op == "XOR": - s[st[1]] ^= s[st[2]] - elif op == "NEG": - s[st[1]] = (-s[st[1]]) & MASK - elif op == "SWAP": - s[st[1]], s[st[2]] = s[st[2]], s[st[1]] - elif op == "FOR": - cnt, body = st[1], st[2] - if cnt in _regs_written(body): - raise ValueError("FOR count register must be read-only (irreversible otherwise)") - for _ in range(s[cnt]): - run(body, s) - elif op == "IF": - pred, then, els, exit_assert = st[1], st[2], st[3], st[4] - if pred(s): - run(then, s) - assert exit_assert(s), "exit assertion violated (not reversible)" - else: - run(els, s) - assert not exit_assert(s), "exit assertion violated (not reversible)" - return s - - -# --- demonstration programs --- -MULTIPLY = [("FOR", "b", [("ADD", "acc", "a")])] # acc += a, b times; a,b preserved -FIB = [("FOR", "n", [("ADD", "a", "b"), ("SWAP", "a", "b")])] # (a,b)->(b,a+b), n times - - -def _test(): - ok = True - - # reversible multiply: acc = a*b, inputs preserved; inverse clears acc - bad = 0 - for a in range(16): - for b in range(16): - s = {"a": a, "b": b, "acc": 0} - run(MULTIPLY, s) - if s["acc"] != (a * b) & MASK or s["a"] != a or s["b"] != b: - bad += 1 - run(invert(MULTIPLY), s) # run backward - if s != {"a": a, "b": b, "acc": 0}: - bad += 1 - print(f" reversible multiply acc=a*b, inputs preserved, inverse clears acc: " - f"{'OK' if bad == 0 else f'FAIL({bad})'}") - ok &= bad == 0 - - # reversible Fibonacci: n steps forward, inverse recovers the seed - bad = 0 - for n in range(12): - s = {"a": 0, "b": 1, "n": n} - run(FIB, s) - # forward value check against a plain reference - ra, rb = 0, 1 - for _ in range(n): - ra, rb = rb, (ra + rb) & MASK - if (s["a"], s["b"]) != (ra, rb): - bad += 1 - run(invert(FIB), s) - if s != {"a": 0, "b": 1, "n": n}: - bad += 1 - print(f" reversible Fibonacci n steps, inverse recovers seed: " - f"{'OK' if bad == 0 else f'FAIL({bad})'}") - ok &= bad == 0 - - # a reversible conditional (Janus IF): swap when the operands differ. The - # exit assertion a != b is true exactly when the then-branch ran (swapping - # distinct values keeps them distinct; equal values are skipped), so the - # reverse picks the right branch. - prog = [("IF", lambda s: s["a"] != s["b"], [("SWAP", "a", "b")], [], - lambda s: s["a"] != s["b"])] - bad = 0 - for a in range(16): - for b in range(16): - s = {"a": a, "b": b} - run(prog, s) - if sorted([s["a"], s["b"]]) != sorted([a, b]): - bad += 1 - run(invert(prog), s) - if s != {"a": a, "b": b}: - bad += 1 - print(f" reversible conditional (Janus IF with exit assertion): " - f"{'OK' if bad == 0 else f'FAIL({bad})'}") - ok &= bad == 0 - return ok - - -if __name__ == "__main__": - print("Reversible structured programs") - print("PASS" if _test() else "FAIL") diff --git a/src/selfrep.py b/src/selfrep.py deleted file mode 100644 index 945d8dc461b30736872e832dc4626769aac2681c..0000000000000000000000000000000000000000 --- a/src/selfrep.py +++ /dev/null @@ -1,740 +0,0 @@ -"""Exact self-reproduction for the SUBLEQ threshold host. - -This module supplies the parts the universal constructor needs in order to -emit its own complete instance and not only its weight file: a tape device -with a rewind request and an end-of-tape status, the three-phase program P*, -the framing maps ser / inst, and runners for the three evaluators. - -Device (memory-mapped, all logic in the runtime, none of it in the netlist): - - 0xF9 C_WR write request writing 1 emits R_OUT, then R_OUT <- 0 - 0xFA C_EOT end-of-tape status maintained by the device - 0xFB C_RW rewind request writing 1 sets the head to 0 - 0xFC C_RD read request writing 1 loads R_IN and advances the head - 0xFD R_IN input register - 0xFE R_OUT output register - 0xFF halt program counter - -One step is: execute the SUBLEQ instruction, then apply the device to the -resulting state in the order (write, read, rewind, clear requests). A request -fires on the value 1, not on the fact that the cell was addressed, so the -device reads the machine state and nothing else. - -Program variables: - - 0xF0 Z constant 0 (restored by every instruction that uses it) - 0xF1 ONE constant 1 - 0xF2 T1 scratch: repeat counter - 0xF3 T2 scratch: literal counter - 0xF4 NEG1 constant 0xFF - 0xF5 EOK constant 1, the end-of-tape test target -""" - -from __future__ import annotations - -import hashlib -import os -import struct -import sys -from typing import Dict, List, Optional, Tuple - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -HOST_PATH = os.path.join(REPO, "variants", "neural_subleq8io_netlist.safetensors") - -# device cells -C_WR, C_EOT, C_RW, C_RD, R_IN, R_OUT = 0xF9, 0xFA, 0xFB, 0xFC, 0xFD, 0xFE -HALT_PC = 0xFF - -# program variables -Z, ONE, T1, T2, NEG1, EOK = 0xF0, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5 - - -# ============================================================================= -# Recipe language (grammar and decoder), reused from constructor8 -# ============================================================================= - -def describe(data: bytes) -> bytes: - """Compile bytes into a recipe. Literal tokens carry up to 127 bytes; a run - of at least 4 equal bytes becomes a repeat token. Every byte is stored - negated mod 256 so the machine recovers it with one subtraction.""" - tape = bytearray() - i, n = 0, len(data) - while i < n: - j = i - while j < n and data[j] == data[i] and j - i < 127: - j += 1 - if j - i >= 4: - tape.append(256 - (j - i)) - tape.append((256 - data[i]) % 256) - i = j - continue - k = i - while k < n and k - i < 127: - m = k - while m < n and data[m] == data[k] and m - k < 4: - m += 1 - if m - k >= 4: - break - k += 1 - k = max(k, i + 1) - tape.append(k - i) - tape.extend((256 - x) % 256 for x in data[i:k]) - i = k - tape.append(0) - return bytes(tape) - - -def describe_literal(data: bytes) -> bytes: - """The all-literal encoding used in the proof of the length bound.""" - tape = bytearray() - for i in range(0, len(data), 127): - block = data[i:i + 127] - tape.append(len(block)) - tape.extend((256 - x) % 256 for x in block) - tape.append(0) - return bytes(tape) - - -def decode(tape: bytes) -> bytes: - """delta: the decoding map on well-formed recipes.""" - out = bytearray() - i = 0 - while True: - t = tape[i] - i += 1 - if t == 0: - return bytes(out) - if t <= 127: - for _ in range(t): - out.append((256 - tape[i]) % 256) - i += 1 - elif t == 128: - raise ValueError("tag 128 is reserved") - else: - out.extend([(256 - tape[i]) % 256] * (256 - t)) - i += 1 - - -# ============================================================================= -# Framing: ser and inst -# ============================================================================= - -def lam(k: int) -> bytes: - """Eight-byte little-endian length field.""" - return struct.pack(" bytes: - return lam(len(u)) + u - - -def ser(sigma: bytes, m: bytes, tau: bytes) -> bytes: - assert len(m) == 256 - return field(sigma) + field(m) + tau - - -def inst(s: bytes) -> Tuple[bytes, bytes, bytes]: - """Partial inverse of ser: recover (sigma, m, tau).""" - if len(s) < 8: - raise ValueError("truncated") - a = struct.unpack(" Dict[int, int]: - mem = {} - for idx, (a, b, c) in enumerate(prog): - mem[idx * 3] = a - mem[idx * 3 + 1] = b - mem[idx * 3 + 2] = c - return mem - - -# The decoding loop. Addresses are 3k; every branch target below is written as -# an instruction index and resolved to 3*index by _emit. -# -# k0 T2 <- 0 -# k1 T1 <- 0 -# k2 request read of the tag -# k3 T1 <- -T -# k4 T2 <- T -# k5 branch to k7 when T = 0 or T >= 128 -# k6 goto LITERAL -# k7 branch to END when 256-T <= 0, that is T in {0,128} -# k8 goto REPEAT with T1 = 256-T the run length -# k9 LITERAL: request read of the next byte -# k10 R_OUT <- b -# k11 emit -# k12 T2 <- T2-1; branch to k14 when the count is exhausted -# k13 goto k9 -# k14 goto k0 -# k15 REPEAT: request read of the value byte -# k16 R_OUT <- b -# k17 emit -# k18 T1 <- T1-1; branch to k14 when the count is exhausted -# k19 goto k16 -# k20 END -DECODE_LOOP = [ - (T2, T2, 3 * 1), # k0 - (T1, T1, 3 * 2), # k1 - (NEG1, C_RD, 3 * 3), # k2 - (R_IN, T1, 3 * 4), # k3 - (T1, T2, 3 * 5), # k4 - (Z, T2, 3 * 7), # k5 - (Z, Z, 3 * 9), # k6 - (Z, T1, 3 * 20), # k7 - (Z, Z, 3 * 15), # k8 - (NEG1, C_RD, 3 * 10), # k9 - (R_IN, R_OUT, 3 * 11), # k10 - (NEG1, C_WR, 3 * 12), # k11 - (ONE, T2, 3 * 14), # k12 - (Z, Z, 3 * 9), # k13 - (Z, Z, 3 * 0), # k14 - (NEG1, C_RD, 3 * 16), # k15 - (R_IN, R_OUT, 3 * 17), # k16 - (NEG1, C_WR, 3 * 18), # k17 - (ONE, T1, 3 * 14), # k18 - (Z, Z, 3 * 16), # k19 -] - -# P: the constructor. The end token halts. -P = DECODE_LOOP + [(Z, Z, HALT_PC)] - -# P*: the end token enters the rewind phase, then the copy phase. -# k20 R: request rewind -# k21 B0: request read -# k22 B1: EOK <- 1-EOT; halt when the end of tape is reached -# k23 B2: Z <- -b -# k24 B3: R_OUT <- b -# k25 B4: emit -# k26 B5: Z <- 0 and go to B0 -P_STAR = DECODE_LOOP + [ - (NEG1, C_RW, 3 * 21), # k20 - (NEG1, C_RD, 3 * 22), # k21 - (C_EOT, EOK, HALT_PC), # k22 - (R_IN, Z, 3 * 24), # k23 - (Z, R_OUT, 3 * 25), # k24 - (NEG1, C_WR, 3 * 26), # k25 - (Z, Z, 3 * 21), # k26 -] - - -# P_e: P with an end-of-tape guard after the tag read, so that the machine -# halts on every tape, recipe or not. Instruction j3 assigns -# M[EOK] <- M[EOK] - M[C_EOT]; a successful tag read has cleared C_EOT and -# leaves EOK at 1, while a read at the end of the tape sets it and the result -# 0 transfers control to the halt cell. -P_TOTAL = [ - (T2, T2, 3 * 1), # j0 - (T1, T1, 3 * 2), # j1 - (NEG1, C_RD, 3 * 3), # j2 read the tag - (C_EOT, EOK, HALT_PC), # j3 halt if that read was past the end of the tape - (R_IN, T1, 3 * 5), # j4 - (T1, T2, 3 * 6), # j5 - (Z, T2, 3 * 8), # j6 - (Z, Z, 3 * 10), # j7 - (Z, T1, 3 * 21), # j8 - (Z, Z, 3 * 16), # j9 - (NEG1, C_RD, 3 * 11), # j10 LITERAL - (R_IN, R_OUT, 3 * 12), # j11 - (NEG1, C_WR, 3 * 13), # j12 - (ONE, T2, 3 * 15), # j13 - (Z, Z, 3 * 10), # j14 - (Z, Z, 3 * 0), # j15 - (NEG1, C_RD, 3 * 17), # j16 REPEAT - (R_IN, R_OUT, 3 * 18), # j17 - (NEG1, C_WR, 3 * 19), # j18 - (ONE, T1, 3 * 15), # j19 - (Z, Z, 3 * 17), # j20 - (Z, Z, HALT_PC), # j21 END -] - - -def decode_any(tape: bytes, guard: bool) -> Tuple[bytes, bool]: - """The output of the decoding loop on an arbitrary tape. - - `guard` selects P_e over P. A read at the end of the tape sets the - end-of-tape status and leaves the input register holding the last byte it - received, so a token whose payload overruns the tape is completed with - copies of that byte. Without the guard the loop diverges exactly when the - tape is exhausted at a tag read and the stale input register holds neither - 0 nor 128; the second component of the result records whether the machine - halts. - """ - out = bytearray() - h, rin = 0, 0 - while True: - if h < len(tape): - rin = tape[h] - h += 1 - eot = 0 - else: - eot = 1 - if guard and eot: - return bytes(out), True - t = rin - if not guard and eot: - return bytes(out), t in (0, 128) - if t == 0 or t == 128: - return bytes(out), True - reps = 1 if t > 128 else t - count = (256 - t) if t > 128 else 1 - for _ in range(reps): - if h < len(tape): - rin = tape[h] - h += 1 - out.extend([(256 - rin) % 256] * count) - - -def memory_image(prog: List[Tuple[int, int, int]]) -> List[int]: - """The 256-byte initial memory image holding a program and its constants.""" - mem = [0] * 256 - for addr, val in _emit(prog).items(): - mem[addr] = val & 0xFF - mem[Z] = 0 - mem[ONE] = 1 - mem[T1] = 0 - mem[T2] = 0 - mem[NEG1] = 0xFF - mem[EOK] = 1 - return mem - - -M_P = memory_image(P) -M_STAR = memory_image(P_STAR) - - -# ============================================================================= -# Device -# ============================================================================= - -class Tape: - """Environment state (tau, h, omega) with the operations of the definition.""" - - def __init__(self, tau: bytes): - self.tau = tau - self.h = 0 - self.out = bytearray() - - def apply(self, mem: List[int]) -> None: - """One device application to the post-instruction memory image.""" - if mem[C_WR] == 1: - self.out.append(mem[R_OUT]) - mem[R_OUT] = 0 - if mem[C_RD] == 1: - if self.h < len(self.tau): - mem[R_IN] = self.tau[self.h] - mem[C_EOT] = 0 - self.h += 1 - else: - mem[C_EOT] = 1 - if mem[C_RW] == 1: - self.h = 0 - mem[C_WR] = 0 - mem[C_RD] = 0 - mem[C_RW] = 0 - - -# ============================================================================= -# Evaluator 1: integer reference -# ============================================================================= - -def run_reference(mem0: List[int], tau: bytes, max_steps: int = 1 << 34, - expect: Optional[bytes] = None) -> Tuple[bytes, int]: - mem = list(mem0) - dev = Tape(tau) - pc = 0 - steps = 0 - while pc != HALT_PC and steps < max_steps: - A = mem[pc] - B = mem[(pc + 1) & 0xFF] - C = mem[(pc + 2) & 0xFF] - r = (mem[B] - mem[A]) & 0xFF - mem[B] = r - pc = C if (r == 0 or r >= 0x80) else (pc + 3) & 0xFF - n_before = len(dev.out) - dev.apply(mem) - if expect is not None and len(dev.out) > n_before: - k = len(dev.out) - 1 - if k >= len(expect) or dev.out[k] != expect[k]: - raise AssertionError(f"stream diverged at byte {k}") - steps += 1 - return bytes(dev.out), steps - -# ============================================================================= -# The host netlist and its canonical serialization -# ============================================================================= - -STATE_LAYOUT = {"pc": [0, 8], "halt": [8, 1], "mem": [9, 256, 8]} -IO_CELLS = {"c_wr": C_WR, "c_eot": C_EOT, "c_rw": C_RW, "c_rd": C_RD, - "r_in": R_IN, "r_out": R_OUT, "halt_pc": HALT_PC} -STATE_BITS = 8 + 1 + 2048 - - -def host_netlist(): - """The clocked netlist of the host, assembled from its source description.""" - import host_netlist as H - return H.build_subleq_step_net() - - -def sigma_host() -> bytes: - """sigma(N_host): the canonical serialization of that netlist.""" - from netlist_io import sigma_of_net - net, inputs, outputs = host_netlist() - return sigma_of_net(net, inputs, outputs, "subleq8", STATE_LAYOUT, - IO_CELLS) - - -def read_host() -> bytes: - """The distributed serialization of the host.""" - return open(HOST_PATH, "rb").read() - - -def sha(b: bytes) -> str: - return hashlib.sha256(b).hexdigest() - - -def tau_star(sigma: bytes, m: List[int]) -> bytes: - return describe(field(sigma) + field(bytes(m))) - - -# ============================================================================= -# Evaluators of the step map, each built from sigma alone -# ============================================================================= - -class _Transducer: - """State marshalling shared by the threshold evaluators. - - A subclass supplies `step`, which maps a batch of state vectors to the next - ones, and `n_in`, the width of the state. One step of the transducer of - Definition 2.8 is that map followed by the device, so the run loop counts - the step on which the halt bit is set and applies the device to it. - """ - - N = STATE_BITS - _DEVCELLS = (C_WR, C_EOT, C_RW, C_RD, R_IN, R_OUT) - - def _vec(self, pc: int, mem: List[int]): - torch = self.torch - v = torch.zeros(self.N) - for k in range(8): - v[k] = (pc >> (7 - k)) & 1 - for j in range(256): - for k in range(8): - v[9 + j * 8 + k] = (mem[j] >> (7 - k)) & 1 - return v - - @staticmethod - def _byte(vc, j: int) -> int: - x = 0 - for k in range(8): - x = (x << 1) | int(vc[9 + j * 8 + k]) - return x - - def _set_byte(self, v, j: int, val: int) -> None: - for k in range(8): - v[0, 9 + j * 8 + k] = float((val >> (7 - k)) & 1) - - def capture(self): - """Capture one application of the map as a CUDA graph. - - The map is a fixed sequence of operations on fixed shapes, so the whole - step replays as one graph launch in place of some hundreds. The captured - map is compared against the eager one before it is used. - """ - torch = self.torch - assert self.device.startswith("cuda") - self.gin = torch.zeros(1, self.N, device=self.device) - # every buffer the body writes must stay alive for the life of the - # graph, or the allocator will hand its memory to something else and - # the replay will overwrite that instead - self.gbuf = self._buffers() - for _ in range(5): - self._body(self.gin) - torch.cuda.synchronize() - self.graph = torch.cuda.CUDAGraph() - with torch.cuda.graph(self.graph): - self.gout = self._body(self.gin) - torch.cuda.synchronize() - gen = torch.Generator(device=self.device).manual_seed(3) - probe = (torch.rand(1, self.N, generator=gen, - device=self.device) < 0.5).float() - self.gin.copy_(probe) - self.graph.replay() - torch.cuda.synchronize() - assert bool((self.gout == self._eager(probe)).all()), \ - "the captured graph differs from the eager step" - return self - - def _replay(self, v): - # the state lives in gin, which is ordinary memory; gout belongs to the - # graph's private pool and is only ever read as a whole - if v.data_ptr() != self.gin.data_ptr(): - self.gin.copy_(v) - self.graph.replay() - self.gin.copy_(self.gout) - return self.gin - - def run(self, mem0: List[int], tau: bytes, max_steps: int, - expect: Optional[bytes] = None, progress: int = 0, - margin: bool = False) -> Tuple[bytes, int]: - """Iterate the map with the device applied after each step. - - Only the halt bit and the six device cells cross to the host each step; - the rest of the state stays on the accelerator. The six cells are read - out in one operation and written back in one, so the cost of a step is - the map and not the marshalling. With margin=True the minimum distance - of any pre-activation from -1/2 along the whole trajectory is - accumulated (dense mode only).""" - import time - torch = self.torch - v = self._vec(0, mem0).unsqueeze(0).to(self.device) - dev = Tape(tau) - cells = list(self._DEVCELLS) - cell_t = torch.tensor([9 + j * 8 + k for j in cells for k in range(8)], - device=self.device) - pow2_t = torch.tensor([1 << (7 - k) for k in range(8)], - device=self.device, dtype=torch.float32) - shift_t = torch.tensor([7 - k for k in range(8)]) - shadow = [0] * 256 - self.min_margin = float("inf") - n = 0 - t0 = time.perf_counter() - while n < max_steps: - if margin: - self._accumulate_margin(v) - v = self.step(v) - n += 1 - vals = torch.cat([v[0, 8:9], - (v[0, cell_t].reshape(len(cells), 8) * pow2_t) - .sum(-1)]).to("cpu").to(torch.int64).tolist() - for c, j in enumerate(cells): - shadow[j] = vals[1 + c] - before = len(dev.out) - dev.apply(shadow) - new = torch.tensor([shadow[j] for j in cells], dtype=torch.int64) - v[0, cell_t] = (((new.unsqueeze(-1) >> shift_t) & 1) - .reshape(-1).float().to(self.device)) - if expect is not None and len(dev.out) > before: - k = len(dev.out) - 1 - if k >= len(expect) or dev.out[k] != expect[k]: - raise AssertionError(f"stream diverged at byte {k}") - if vals[0] >= 1: - break - if progress and n % progress == 0: - rate = n / (time.perf_counter() - t0) - print(f" {self.tag} {n:,} steps, {len(dev.out):,} bytes " - f"({rate:,.0f} steps/s)", flush=True) - self.seconds = time.perf_counter() - t0 - return bytes(dev.out), n - - -class NetEvaluator(_Transducer): - """The netlist of sigma, evaluated unit by unit. - - The units are grouped by depth and each reads its predecessors out of one - signal vector, so no unit is evaluated before its predecessors and none is - padded to a common width: the evaluation carries the netlist's own - 51,132 predecessor entries and nothing else. - """ - - tag = "net" - - def __init__(self, sigma: bytes, device: str = "cpu", graph: bool = False): - import time - import torch - from netlist_io import net_of_sigma - from reflect import Leveled - t0 = time.perf_counter() - self.torch = torch - self.device = device - net, inputs, outputs, meta = net_of_sigma(sigma) - self.net, self.inputs, self.outputs, self.meta = net, inputs, outputs, meta - assert len(inputs) == self.N, "state width disagrees with the layout" - self.lev = Leveled(net, inputs, outputs, device=device) - self.info = {"levels": len(self.lev.plan), "units": len(net.gates), - "entries": sum(len(i) for i, _ in net.gates.values())} - self.graph = None - if graph: - self.capture() - self.build_seconds = time.perf_counter() - t0 - - def _buffers(self): - return self.torch.zeros(self.lev.n_sig, 1, device=self.device) - - def _body(self, inp): - V = self.gbuf - lev = self.lev - V.zero_() - V[1] = 1.0 - V[lev.in_slots] = inp.T - for idx, w, b, out in lev.plan: - g = V[idx] - V[out] = ((g * w[:, :, None]).sum(1) + b[:, None] >= 0).float() - return V[lev.out_slots].T - - def _eager(self, v): - return self.lev.step(v) - - def step(self, v): - if self.graph is not None: - return self._replay(v) - return self.lev.step(v) - - -class LevEvaluator(_Transducer): - """Lev(N) for the netlist N of sigma. - - `dense=True` materialises the matrices of Lemma 2.6 and iterates - matrix-vector products; `dense=False` evaluates the same map from its - nonzero entries, the identity rows included, which is the same function - layer by layer. The two agree on every state tested by check_lev. - """ - - tag = "lev" - - def __init__(self, sigma: bytes, device: str = "cpu", dense: bool = True, - graph: bool = False): - import time - import torch - from netlist_io import net_of_sigma - from matrix8 import compile_net - t0 = time.perf_counter() - self.torch = torch - self.device = device - self.dense = dense - net, inputs, outputs, meta = net_of_sigma(sigma) - self.net, self.inputs, self.outputs, self.meta = net, inputs, outputs, meta - assert len(inputs) == self.N, "state width disagrees with the layout" - layers, info = compile_net(net, inputs, outputs) - for W, _ in layers: - assert set(torch.unique(W).tolist()) <= {-1, 0, 1} - self.info = dict(info) - self.info["size"] = sum(int(W.shape[0]) for W, _ in layers) - self.info["nonzero"] = sum(int((W != 0).sum()) for W, _ in layers) - if dense: - self.W = [W.to(device=device, dtype=torch.float32) - for W, _ in layers] - self.B = [b.to(device=device, dtype=torch.float32) - for _, b in layers] - else: - pad = device.startswith("cuda") - self.plan = [self._sparsify(W, b, device, pad) for W, b in layers] - self.widths = [int(W.shape[0]) for W, _ in layers] - self.graph = None - if graph: - self.capture() - self.build_seconds = time.perf_counter() - t0 - - @staticmethod - def _sparsify(W, b, device, pad): - """One layer as the nonzero entries of its rows. - - With `pad`, every row of the layer is padded to the largest number of - nonzero entries in it by an entry of weight zero, which contributes - nothing to any pre-activation and makes the layer one gather and one - reduction; that is the faster arrangement on the accelerator, where the - cost is the number of operations issued. Without it the rows are grouped - by their number of nonzero entries and no padding is read, which is the - faster arrangement on a processor core. - """ - import torch - nz = (W != 0) - counts = nz.sum(1) - n = int(W.shape[0]) - sizes = [int(counts.max())] if pad else sorted(set(counts.tolist())) - groups = [] - for k in sizes: - rows = (torch.arange(n) if pad - else torch.nonzero(counts == k, as_tuple=False).flatten()) - idx = torch.zeros(len(rows), max(k, 1), dtype=torch.long) - w = torch.zeros(len(rows), max(k, 1)) - for r, row in enumerate(rows.tolist()): - cols = torch.nonzero(nz[row], as_tuple=False).flatten() - idx[r, :len(cols)] = cols - w[r, :len(cols)] = W[row, cols] - groups.append((rows.to(device), idx.to(device), - w.to(device=device, dtype=torch.float32), - b[rows].to(device=device, dtype=torch.float32))) - return groups - - def _buffers(self): - import torch - return [torch.zeros(1, n, device=self.device) for n in self.widths] - - def _sparse_step(self, v, buf): - x = v - for groups, y in zip(self.plan, buf): - for rows, idx, w, b in groups: - y[:, rows] = ((x[:, idx] * w).sum(-1) + b >= 0).float() - x = y - return x - - def _body(self, inp): - return self._sparse_step(inp, self.gbuf) - - def _eager(self, v): - return self._sparse_step(v, [self.torch.zeros(v.shape[0], n, - device=self.device) - for n in self.widths]) - - def step(self, v): - if self.dense: - for W, b in zip(self.W, self.B): - v = ((v @ W.T + b) >= 0).float() - return v - if self.graph is not None: - return self._replay(v) - return self._eager(v) - - def _accumulate_margin(self, v): - y = v - for W, b in zip(self.W, self.B): - pre = y @ W.T + b - m = float((pre + 0.5).abs().min()) - if m < self.min_margin: - self.min_margin = m - y = (pre >= 0).float() - - def step_noisy(self, v, sigma: float, gen): - """One step with additive Gaussian read noise per pre-activation and the - comparator at -1/2. - - The two forms compute the same pre-activations, so the noise is drawn - for the same quantities whichever is used; the sparse form draws it - layer by layer over that layer's rows. - """ - torch = self.torch - if self.dense: - for W, b in zip(self.W, self.B): - pre = v @ W.T + b - pre = pre + torch.randn(pre.shape, generator=gen, - device=pre.device) * sigma - v = (pre >= -0.5).float() - return v - x = v - for groups, n in zip(self.plan, self.widths): - y = torch.empty(x.shape[0], n, device=x.device) - for rows, idx, w, b in groups: - pre = (x[:, idx] * w).sum(-1) + b - pre = pre + torch.randn(pre.shape, generator=gen, - device=pre.device) * sigma - y[:, rows] = (pre >= -0.5).float() - x = y - return x diff --git a/src/stored_host.py b/src/stored_host.py deleted file mode 100644 index 8dbda504dd1ec08fda90d05c21be1c429cd5c8b1..0000000000000000000000000000000000000000 --- a/src/stored_host.py +++ /dev/null @@ -1,694 +0,0 @@ -"""The host as a stored network of the two-head machine. - -The two-head machine T_{G,L} of Section 7 is taken with a window of W = 2^ALPHA cells, -so that a record (w0 code, w1 code, beta, a0, a1, ao) has 8 + 3 ALPHA bits. The host -H_8, read from its serialization sigma(N_host), is compiled into records of two -literals each, evaluated in an order in which every unit follows its inputs and the -next value of each state bit is written over that bit after the last record that -reads the old one; temporaries share window cells by live interval. One pass of the -records is one step of H_8 followed by the device of Definition 2.8 acting on the -tape under the read head, the idle bank under the arm and the copy tape under the -copy head. A startup block, active in the first pass after a hand-over, loads the -memory image m* of P*; the hand-over flips the bank bit when the host halts. - -Run from the image of these records in the active bank and tau* = describe(image) on -the read tape, P* writes the image into the idle bank, copies tau* onto the copy -tape and halts, and the hand-over starts the copy. - - python src/stored_host.py check one pass against H_8 and the device - python src/stored_host.py build the image, tau*, sizes and digests - python src/stored_host.py run [GENERATIONS] generations on the machine, in C -""" -import hashlib -import json -import os -import random -import struct -import subprocess -import sys -import time - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -import selfrep as SR - -REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -RECORD = os.path.join(REPO, 'paper', 'runs', 'paper_stored_host.json') - -ALPHA = 12 -W = 1 << ALPHA -B = 8 + 3 * ALPHA -KAPPA, RD, ARM, CPY, AR, AA, AC, SINK = range(8) -CW = 20 # width of the device's byte counters - - -# --------------------------------------------------------------------------- -# the host netlist from its serialization -# --------------------------------------------------------------------------- - -def host_units(): - data = SR.read_host() - hlen = struct.unpack(' 0 else -1) for e in pred[p:p + fanin[j]]])) - p += fanin[j] - return len(nxt), units, nxt - - -# state layout of sigma: pc bits 0..7 most significant first, halt 8, memory byte j bit k at 9 + 8 j + k, -# k = 0 the most significant -def mem_bit(j, t): - """State index of bit t (value 2^t) of memory byte j.""" - return 9 + 8 * j + (7 - t) - - -PC_BIT = lambda t: 7 - t # bit t of the counter -HALT = 8 - - -# --------------------------------------------------------------------------- -# records over named cells -# --------------------------------------------------------------------------- - -class Prog: - def __init__(self): - self.recs = [] # (a0, w0, a1, w1, beta, ao), cells by name - self.uid = 0 - - def tmp(self): - self.uid += 1 - return ('t', self.uid) - - def emit(self, a0, w0, a1, w1, beta, ao): - assert -8 <= beta <= 7 - self.recs.append((a0 if w0 else ('c', SINK), w0, a1 if w1 else ('c', SINK), w1, beta, ao)) - - def AND(self, x, y, dst): - (a, pa), (b, pb) = x, y - self.emit(a, 1 if pa else -1, b, 1 if pb else -1, -(pa + pb), dst) - - def OR(self, x, y, dst): - (a, pa), (b, pb) = x, y - self.emit(a, 1 if pa else -1, b, 1 if pb else -1, 1 - pa - pb, dst) - - def COPY(self, x, dst): - a, pa = x - self.emit(a, 1 if pa else -1, None, 0, -1 if pa else 0, dst) - - def CONST(self, v, dst): - # a gate of one literal and its negation: OR is 1, AND is 0 - x = ('c', SINK) - (self.OR if v else self.AND)((x, True), (x, False), dst) - - def XOR(self, x, y, dst): - t1, t2 = self.tmp(), self.tmp() - self.OR(x, y, t1) - self.OR((x[0], not x[1]), (y[0], not y[1]), t2) - self.AND((t1, True), (t2, True), dst) - - def MUX(self, s, a, b, dst): - """dst <- s ? a : b, s a cell, a and b literals.""" - t1, t2 = self.tmp(), self.tmp() - self.AND((s, True), a, t1) - self.AND((s, False), b, t2) - self.OR((t1, True), (t2, True), dst) - - def chain(self, kind, lits, dst, before=None): - """kind 'and' or 'or' of the literals into dst; before(i) emits literal i's signal - just ahead of its use. dst receives one write, the last.""" - assert lits - if len(lits) == 1: - if before: - before(0) - self.COPY(lits[0], dst) - return - g = self.AND if kind == 'and' else self.OR - acc = self.tmp() - if before: - before(0); before(1) - if len(lits) == 2: - g(lits[0], lits[1], dst) - return - g(lits[0], lits[1], acc) - for i in range(2, len(lits)): - if before: - before(i) - g((acc, True), lits[i], dst if i == len(lits) - 1 else acc) - - def threshold(self, t, lits, dst): - """at least t of the literals, by T_t(x1..xk) = T_t(x2..xk) or (x1 and T_{t-1}(x2..xk)).""" - k = len(lits) - if t <= 0: - self.CONST(1, dst); return - if t > k: - self.CONST(0, dst); return - if t == 1: - self.chain('or', lits, dst); return - if t == k: - self.chain('and', lits, dst); return - a, b = self.tmp(), self.tmp() - self.threshold(t, lits[1:], a) - self.threshold(t - 1, lits[1:], b) - c = self.tmp() - self.AND(lits[0], (b, True), c) - self.OR((a, True), (c, True), dst) - - -def compile_host(P): - """The records of one step of H_8; state bits are cells ('s', i). A unit read by a single - unit is emitted just ahead of its use; the next value of each state bit is written over - the bit after every record that reads the old value.""" - n, units, nxt = host_units() - cons = {} - for u, (_, ins) in enumerate(units): - for s, _w in ins: - if s >= n: - cons.setdefault(s - n, []).append(u) - is_next = {nxt[i] - n: i for i in range(n)} - assert len(is_next) == n and all(v >= n for v in nxt) - fusable = {u for u, c in cons.items() if len(c) == 1} - done, cell = set(), {} - - def cell_of(s): - return ('s', s) if s < n else cell[s - n] - - def ensure_ancestors(u): - for s, _w in units[u][1]: - if s >= n: - v = s - n - if v in fusable: - ensure_ancestors(v) - else: - need(v) - - def need(u): - if u in done: - return - assert u not in is_next, 'a next value is read before the state is written' - ensure_ancestors(u) - cell[u] = P.tmp() - emit_unit(u, cell[u]) - done.add(u) - - def fused(v): - if v not in done: - cell[v] = P.tmp() - emit_unit(v, cell[v]) - done.add(v) - - def emit_unit(u, dst): - b, ins = units[u] - k = len(ins) - t = sum(1 for _, w in ins if w < 0) - b - lits = [None] * k - - def before(i): - s = ins[i][0] - if s >= n and (s - n) in fusable: - fused(s - n) - lits[i] = (cell_of(s), ins[i][1] > 0) - if t <= 0 or t > k: - P.CONST(1 if t <= 0 else 0, dst) - elif 1 < t < k: - for i in range(k): - before(i) - P.threshold(t, lits, dst) - else: - _chain_lazy(P, 'and' if t == k else 'or', lits, dst, before) - - order_next = [nxt[PC_BIT(t)] - n for t in range(8)] + [nxt[HALT] - n] + \ - [nxt[mem_bit(j, t)] - n for j in range(256) for t in range(8)] - for u in order_next: # stage A: everything the next values read - ensure_ancestors(u) - for u in order_next: # stage B: the next values, in place - emit_unit(u, ('s', is_next[u])) - done.add(u) - return n, units, nxt - - -def _chain_lazy(P, kind, lits, dst, before): - k = len(lits) - if k == 1: - before(0) - P.COPY(lits[0], dst) - return - g = P.AND if kind == 'and' else P.OR - before(0); before(1) - if k == 2: - g(lits[0], lits[1], dst) - return - acc = P.tmp() - g(lits[0], lits[1], acc) - for i in range(2, k): - before(i) - g((acc, True), lits[i], dst if i == k - 1 else acc) - - -# --------------------------------------------------------------------------- -# the device, the startup block and the hand-over -# --------------------------------------------------------------------------- - -def mem_cell(j, t): - return ('s', mem_bit(j, t)) - - -def byte_is_one(P, j): - """[memory byte j == 1]""" - dst = P.tmp() - lits = [(mem_cell(j, 0), True)] + [(mem_cell(j, t), False) for t in range(1, 8)] - P.chain('and', lits, dst) - return dst - - -def compile_device(P): - cnt = [('d', 'cnt', i) for i in range(CW)] - lim = [('d', 'lim', i) for i in range(CW)] - dest = ('d', 'dest') - wr, rd, rw = byte_is_one(P, SR.C_WR), byte_is_one(P, SR.C_RD), byte_is_one(P, SR.C_RW) - # [cnt == lim] - eq = P.tmp() - xs = [] - for i in range(CW): - e = P.tmp() - a1, a0 = P.tmp(), P.tmp() - P.AND((cnt[i], True), (lim[i], True), a1) - P.AND((cnt[i], False), (lim[i], False), a0) - P.OR((a1, True), (a0, True), e) - xs.append((e, True)) - P.chain('and', xs, eq) - eot = P.tmp() # the end of the tape is known after the rewind - P.AND((eq, True), (dest, True), eot) - # (i) R_out, most significant bit first, under the arm or under the copy head - ca, cc = P.tmp(), P.tmp() - P.AND((wr, True), (dest, False), ca) - P.AND((wr, True), (dest, True), cc) - for t in range(7, -1, -1): - P.MUX(ca, (mem_cell(SR.R_OUT, t), True), (('c', ARM), True), ('c', ARM)) - P.COPY((ca, True), ('c', AA)) - P.MUX(cc, (mem_cell(SR.R_OUT, t), True), (('c', CPY), True), ('c', CPY)) - P.COPY((cc, True), ('c', AC)) - for t in range(8): - P.AND((mem_cell(SR.R_OUT, t), True), (wr, False), mem_cell(SR.R_OUT, t)) - g = P.tmp() - P.AND((rd, True), (eot, False), g) - # (ii) the byte under the read head into R_in, most significant bit first, each bit turning the tape - for t in range(7, -1, -1): - P.MUX(g, (('c', RD), True), (mem_cell(SR.R_IN, t), True), mem_cell(SR.R_IN, t)) - P.COPY((g, True), ('c', AR)) - # c_eot: bit 0 <- [cnt == lim] on a read, the other bits cleared on a read - P.MUX(rd, (eot, True), (mem_cell(SR.C_EOT, 0), True), mem_cell(SR.C_EOT, 0)) - for t in range(1, 8): - P.AND((mem_cell(SR.C_EOT, t), True), (rd, False), mem_cell(SR.C_EOT, t)) - # the counter of bytes read: carries first, then the sum bits - car = [g] - for i in range(CW - 1): - c = P.tmp() - P.AND((car[-1], True), (cnt[i], True), c) - car.append(c) - for i in range(CW): - P.XOR((cnt[i], True), (car[i], True), cnt[i]) - # (iii) a rewind: the limit takes the count, the count returns to 0, the writes go to the copy tape - for i in range(CW): - P.MUX(rw, (cnt[i], True), (lim[i], True), lim[i]) - P.AND((cnt[i], True), (rw, False), cnt[i]) - P.OR((dest, True), (rw, True), dest) - # (iv) the request cells cleared - for j in (SR.C_WR, SR.C_RD, SR.C_RW): - for t in range(8): - P.CONST(0, mem_cell(j, t)) - - -def compile_startup(P, mem_image): - """Active while 'booted' is 0: the memory image, a zero counter and halt bit, the device - registers cleared; then 'booted' is set.""" - b = ('d', 'booted') - for j in range(256): - for t in range(8): - c = mem_cell(j, t) - if (mem_image[j] >> t) & 1: - P.OR((c, True), (b, False), c) # b ? c : 1 - else: - P.AND((c, True), (b, True), c) # b ? c : 0 - for t in range(8): - P.AND((('s', PC_BIT(t)), True), (b, True), ('s', PC_BIT(t))) - P.AND((('s', HALT), True), (b, True), ('s', HALT)) - for i in range(CW): - P.AND((('d', 'cnt', i), True), (b, True), ('d', 'cnt', i)) - P.AND((('d', 'lim', i), True), (b, True), ('d', 'lim', i)) - P.AND((('d', 'dest'), True), (b, True), ('d', 'dest')) - P.CONST(1, b) - - -def compile_handover(P): - """When the host has halted: the next pass starts up, and the bank bit flips at the last - record of the pass.""" - h = ('s', HALT) - P.AND((('d', 'booted'), True), (h, False), ('d', 'booted')) - P.XOR((('c', KAPPA), True), (h, True), ('c', KAPPA)) - - -# --------------------------------------------------------------------------- -# allocation and encoding -# --------------------------------------------------------------------------- - -def allocate(recs): - """Window addresses: the special cells, the state, the device registers, then the - temporaries by live interval. Every temporary is written before it is read.""" - fixed = {} - nxt_addr = 8 - names = [] - for r in recs: - for a in (r[0], r[2], r[5]): - if a is not None and a[0] in ('s', 'd') and a not in fixed: - names.append(a) - fixed[a] = None - for a in sorted(names, key=lambda x: (x[0], x[1:] if x[0] == 'd' else x[1])) : - fixed[a] = nxt_addr - nxt_addr += 1 - first_w, last_r = {}, {} - for i, (a0, w0, a1, w1, be, ao) in enumerate(recs): - for a, w in ((a0, w0), (a1, w1)): - if w and a[0] == 't': - assert a in first_w, ('read before written', a) - last_r[a] = i - if ao[0] == 't': - first_w.setdefault(ao, i) - base = nxt_addr - free_at = {} # cell -> index after which it is free - addr = dict(fixed) - for name in sorted(first_w, key=lambda x: first_w[x]): - start, end = first_w[name], last_r.get(name, first_w[name]) - cand = [c for c, f in free_at.items() if f < start] - c = min(cand) if cand else base + len(free_at) - addr[name] = c - free_at[c] = end - top = base + len(free_at) - assert top <= W, ('window too small', top) - - def res(a): - if a is None: - return SINK - if a[0] == 'c': - return a[1] - return addr[a] - out = [(res(a0), w0, res(a1), w1, be, res(ao)) for (a0, w0, a1, w1, be, ao) in recs] - return out, addr, top - - -def encode_record(r): - a0, w0, a1, w1, beta, ao = r - bits = [] - put = lambda v, w: bits.extend((v >> (w - 1 - j)) & 1 for j in range(w)) - wc = {1: 1, -1: 2, 0: 0} - put(wc[w0], 2); put(wc[w1], 2); put(beta & 15, 4) - put(a0, ALPHA); put(a1, ALPHA); put(ao, ALPHA) - return bits - - -def decode_record(bits): - it = iter(bits) - get = lambda w: sum(next(it) << (w - 1 - j) for j in range(w)) - c0 = get(2); c1 = get(2); beta = get(4) - a0 = get(ALPHA); a1 = get(ALPHA); ao = get(ALPHA) - wd = {1: 1, 2: -1, 0: 0, 3: 0} - return a0, wd[c0], a1, wd[c1], beta - 16 if beta >= 8 else beta, ao - - -def bits_to_bytes(bits): - assert len(bits) % 8 == 0 - return bytes(sum(bits[i + k] << (7 - k) for k in range(8)) for i in range(0, len(bits), 8)) - - -def bytes_to_bits(data): - return [(b >> (7 - k)) & 1 for b in data for k in range(8)] - - -def check_schedule(recs, lo, hi, n): - """Within the host block recs[lo:hi], every state bit is written once, by the record of its - next value, and read by no later record; in all records, no temporary is written after - its last read, so that temporaries with disjoint live intervals may share a cell.""" - written = {} - for i in range(lo, hi): - ao = recs[i][5] - if ao[0] == 's': - assert ao not in written, ('state bit written twice', ao) - written[ao] = i - assert len(written) == n, 'a state bit without its next value' - for i in range(lo, hi): - a0, w0, a1, w1, _, _ = recs[i] - for a, w in ((a0, w0), (a1, w1)): - if w and a[0] == 's': - assert i <= written[a], ('state bit read after it is written', a) - last_r, last_w = {}, {} - for i, (a0, w0, a1, w1, _, ao) in enumerate(recs): - for a, w in ((a0, w0), (a1, w1)): - if w and a[0] == 't': - last_r[a] = i - if ao[0] == 't': - last_w[ao] = i - assert all(last_w[t] < last_r.get(t, last_w[t] + 1) or t not in last_r for t in last_w), \ - 'a temporary written after its last read' - return dict(state_bits_written=len(written)) - - -def build(): - P = Prog() - compile_startup(P, SR.M_STAR) - lo = len(P.recs) - n, units, nxt = compile_host(P) - n_host = len(P.recs) - lo - check_schedule(P.recs, lo, len(P.recs), n) - compile_device(P) - compile_handover(P) - recs, addr, top = allocate(P.recs) - if len(recs) % 2: # an image of whole bytes - recs.insert(len(recs) - 3, (SINK, 0, SINK, 0, -1, SINK)) - image_bits = [b for r in recs for b in encode_record(r)] - image = bits_to_bytes(image_bits) - tau = SR.describe(image) - assert SR.decode(tau) == image - return dict(recs=recs, addr=addr, top=top, image=image, image_bits=image_bits, tau=tau, - n_host_records=n_host, host=(n, units, nxt)) - - -# --------------------------------------------------------------------------- -# the machine, transcribed -# --------------------------------------------------------------------------- - -class Machine: - """T_{G,L} with a window of W cells; banks and tapes listed from their heads.""" - def __init__(self, Q0, Q1, D0, D1, window): - self.Q = [list(Q0), list(Q1)] - self.D = [list(D0), list(D1)] - self.h = [[0, 0], [0, 0]] # heads of the banks and of the tapes - self.w = list(window) - self.N, self.L = len(Q0), len(D0) - - def cell(self, a): - k = self.w[KAPPA] - if a == RD: - return self.D[k][self.h[1][k]] - if a == ARM: - return self.Q[1 - k][self.h[0][1 - k]] - if a == CPY: - return self.D[1 - k][self.h[1][1 - k]] - return self.w[a] - - def step(self, rec=None): - k = self.w[KAPPA] - if rec is None: - hq = self.h[0][k] - rec = decode_record([self.Q[k][(hq + i) % self.N] for i in range(B)]) - a0, w0, a1, w1, beta, ao = rec - v = 1 if w0 * self.cell(a0) + w1 * self.cell(a1) + beta >= 0 else 0 - if ao == ARM: - self.Q[1 - k][self.h[0][1 - k]] = v - elif ao == CPY: - self.D[1 - k][self.h[1][1 - k]] = v - elif ao != RD: - self.w[ao] = v - self.h[0][k] = (self.h[0][k] + B) % self.N - if self.w[AA]: - self.h[0][1 - k] = (self.h[0][1 - k] + 1) % self.N - if self.w[AR]: - self.h[1][k] = (self.h[1][k] + 1) % self.L - if self.w[AC]: - self.h[1][1 - k] = (self.h[1][1 - k] + 1) % self.L - self.w[AA] = self.w[AR] = self.w[AC] = 0 - - -# --------------------------------------------------------------------------- -# one pass against H_8 and the device -# --------------------------------------------------------------------------- - -def host_step(n, units, nxt, x): - sig = list(x) + [0] * len(units) - for u, (b, ins) in enumerate(units): - sig[n + u] = 1 if b + sum(w * sig[s] for s, w in ins) >= 0 else 0 - return [sig[v] for v in nxt] - - -def check(trials=40, seed=3, verbose=False): - S = build() - recs, addr = S['recs'], S['addr'] - n, units, nxt = S['host'] - G = len(recs) - rng = random.Random(seed) - bad = 0 - image_bits = [b for r in recs for b in encode_record(r)] - x0 = [0] * n # the state x_0(m*) - for j in range(256): - for t in range(8): - x0[mem_bit(j, t)] = (SR.M_STAR[j] >> t) & 1 - for trial in range(trials): - # a random host state with random device registers, temporaries and tapes; every fifth - # state not yet booted, and in every third the requests hold random bytes - x = [rng.randrange(2) for _ in range(n)] - x[HALT] = 0 if trial % 4 else 1 - booted = 0 if trial % 5 == 0 else 1 - for j in (SR.C_WR, SR.C_RD, SR.C_RW): - v = rng.randrange(256) if trial % 3 == 0 else rng.choice((0, 1)) - for t in range(8): - x[mem_bit(j, t)] = (v >> t) & 1 - L = 64 - cnt, lim = rng.randrange(8), rng.randrange(8) - dest = rng.randrange(2) - win = [rng.randrange(2) for _ in range(W)] - win[KAPPA] = 0 - for i in range(n): - win[addr[('s', i)]] = x[i] - for i in range(CW): - win[addr[('d', 'cnt', i)]] = (cnt >> i) & 1 - win[addr[('d', 'lim', i)]] = (lim >> i) & 1 - win[addr[('d', 'dest')]] = dest - win[addr[('d', 'booted')]] = booted - win[AR] = win[AA] = win[AC] = 0 - D0 = [rng.randrange(2) for _ in range(L)] - Q1 = [rng.randrange(2) for _ in range(len(image_bits))] - D1 = [rng.randrange(2) for _ in range(L)] - M = Machine(image_bits, Q1, D0, D1, win) - for g in range(G): - M.step(recs[g]) - # expected: the startup if not booted, one step of H_8, then the device on the ring tapes - if not booted: - x, cnt, lim, dest = list(x0), 0, 0, 0 - y = host_step(n, units, nxt, x) - get = lambda j: sum(y[mem_bit(j, t)] << t for t in range(8)) - - def put(j, v): - for t in range(8): - y[mem_bit(j, t)] = (v >> t) & 1 - e_q1, e_d1, hq1, hd0, hd1 = list(Q1), list(D1), 0, 0, 0 - e_cnt, e_lim, e_dest = cnt, lim, dest - if get(SR.C_WR) == 1: - byte = get(SR.R_OUT) - for kk in range(8): - bit = (byte >> (7 - kk)) & 1 - if e_dest: - e_d1[hd1] = bit; hd1 += 1 - else: - e_q1[hq1] = bit; hq1 += 1 - put(SR.R_OUT, 0) - if get(SR.C_RD) == 1: - if not (e_dest and e_cnt == e_lim): - put(SR.R_IN, sum(D0[hd0 + kk] << (7 - kk) for kk in range(8))) - hd0 += 8 - put(SR.C_EOT, 0) - e_cnt = (e_cnt + 1) % (1 << CW) - else: - put(SR.C_EOT, 1) - if get(SR.C_RW) == 1: - e_lim, e_cnt, e_dest = e_cnt, 0, 1 - put(SR.C_WR, 0); put(SR.C_RD, 0); put(SR.C_RW, 0) - e_kappa = 1 if y[HALT] else 0 - got = [M.w[addr[('s', i)]] for i in range(n)] - g_cnt = sum(M.w[addr[('d', 'cnt', i)]] << i for i in range(CW)) - g_lim = sum(M.w[addr[('d', 'lim', i)]] << i for i in range(CW)) - parts = dict(state=got == y, cnt=g_cnt == e_cnt, lim=g_lim == e_lim, - dest=M.w[addr[('d', 'dest')]] == e_dest, bank=M.Q[1] == e_q1, copy=M.D[1] == e_d1, - arm=M.h[0][1] == hq1, read=M.h[1][0] == hd0, copyhead=M.h[1][1] == hd1, - kappa=M.w[KAPPA] == e_kappa, tape=M.D[0] == D0, - booted=M.w[addr[('d', 'booted')]] == 1 - y[HALT], - requests=M.w[AR] == M.w[AA] == M.w[AC] == 0, active=M.Q[0] == image_bits) - ok = all(parts.values()) - if not ok and verbose: - print(trial, [k for k, v in parts.items() if not v], 'reqs', - [get(j) for j in (SR.C_WR, SR.C_RD, SR.C_RW)], 'dest', dest, 'cnt', cnt, 'lim', lim, - 'halt', x[HALT], [i for i in range(n) if got[i] != y[i]][:10]) - bad += not ok - return dict(trials=trials, disagreements=bad, records=G, window_cells=S['top']) - - -def update_record(key, value): - rec = json.load(open(RECORD)) if os.path.exists(RECORD) else {} - rec[key] = value - json.dump(rec, open(RECORD, 'w'), indent=1) - - -def run(generations): - """Generations from the image in the active bank and tau* on the read tape, on the - machine in C, against the reference run of P* on tau*.""" - S = build() - recs, image_bits, tau = S['recs'], S['image_bits'], S['tau'] - G = len(recs) - out, steps = SR.run_reference(SR.M_STAR, tau) - assert out == S['image'] + tau - tau_bits = bytes_to_bits(tau) - work = os.path.join(REPO, 'variants') - inp, exe = os.path.join(work, '.stored_host.bin'), os.path.join(work, '.stored_host_run') - with open(inp, 'wb') as f: - f.write(struct.pack('<4i', W, ALPHA, G, len(tau_bits))) - f.write(bytes(image_bits) + bytes(tau_bits) + bytes(W)) - subprocess.run(['gcc', '-O3', '-march=native', '-o', exe, os.path.join(REPO, 'src', 'stored_host_run.c')], check=True) - t0 = time.time() - p = subprocess.run([exe, inp, str(generations)], capture_output=True, text=True) - os.remove(inp); os.remove(exe) - lines = [json.loads(l) for l in p.stdout.split('\n') if l.strip()] - gens = [l for l in lines if 'generation' in l] - res = dict(records=G, window_cells=S['top'], record_bits=B, bank_bits=len(image_bits), - image_bytes=len(S['image']), tau_bytes=len(tau), passes_expected=steps, - generations=gens, seconds=round(time.time() - t0, 1), - sha256_image=hashlib.sha256(S['image']).hexdigest(), - sha256_tau=hashlib.sha256(tau).hexdigest()) - for l in lines: - if 'state_after_3_equals_after_1' in l: - res['state_after_3_equals_after_1'] = l['state_after_3_equals_after_1'] - res['every_generation_exact'] = all(g['passes'] == steps and g['steps'] == steps * G and g['bank_holds_image'] - and g['tape_holds_tau'] for g in gens) and len(gens) == generations - update_record('run', res) - return res - - -if __name__ == '__main__': - cmd = sys.argv[1] if len(sys.argv) > 1 else 'build' - if cmd == 'check': - t0 = time.time() - res = check(int(sys.argv[2]) if len(sys.argv) > 2 else 40) - res['seconds'] = round(time.time() - t0, 1) - update_record('pass_check', res) - print(json.dumps(res)) - sys.exit(0 if res['disagreements'] == 0 else 1) - elif cmd == 'run': - gens = int(sys.argv[2]) if len(sys.argv) > 2 else 3 - res = run(gens) - print(json.dumps(res, indent=1)) - sys.exit(0 if res['every_generation_exact'] and (gens < 3 or res.get('state_after_3_equals_after_1') == 1) - else 1) - elif cmd == 'build': - t0 = time.time() - S = build() - print(json.dumps(dict(records=len(S['recs']), host_records=S['n_host_records'], - window_cells=S['top'], record_bits=B, bank_bits=len(S['image_bits']), - image_bytes=len(S['image']), tau_bytes=len(S['tau']), - seconds=round(time.time() - t0, 1)))) diff --git a/src/throughput.py b/src/throughput.py deleted file mode 100644 index 16614b952d6ffe2db991253743c716d4c57666da..0000000000000000000000000000000000000000 --- a/src/throughput.py +++ /dev/null @@ -1,119 +0,0 @@ -"""Throughput of the four evaluators, measured on one idle machine. - -Each evaluator runs the same prefix of the self-describing instance and is -timed after a warm-up, so the rates reported in the paper are not those of runs -that shared the processor with each other. -""" -import json -import os -import sys -import time - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - - -def runs_path(name: str) -> str: - d = os.path.join(REPO, "paper", "runs") - os.makedirs(d, exist_ok=True) - return os.path.join(d, name) - - -def main() -> int: - import torch - from selfrep import (M_P, LevEvaluator, NetEvaluator, describe, read_host, - run_reference) - torch.set_num_threads(1) # the processor rates are for one core - sigma = read_host() - r_H = describe(sigma) - out = {} - - t0 = time.perf_counter() - o, n = run_reference(M_P, r_H, expect=sigma) - dt = time.perf_counter() - t0 - assert o == sigma - out["reference"] = {"steps": n, "seconds": dt, "steps_per_second": n / dt} - print(f" integer reference: {n:,} steps in {dt:.2f} s " - f"({n / dt:,.0f} steps/s)", flush=True) - - N = 20000 - for key, what, ev in ( - ("net_cpu", "the netlist of sigma unit by unit, one core", - lambda: NetEvaluator(sigma, device="cpu")), - ("net_gpu", "the netlist of sigma unit by unit, GPU", - lambda: NetEvaluator(sigma, device="cuda", graph=True))): - G = ev() - G.run(M_P, r_H, max_steps=200) # warm up - t0 = time.perf_counter() - G.run(M_P, r_H, max_steps=N, expect=sigma) - dt = time.perf_counter() - t0 - out[key] = {"steps": N, "seconds": dt, "steps_per_second": N / dt, - "units": G.info["units"], "entries": G.info["entries"]} - print(f" {what}: {N:,} steps in {dt:.1f} s ({N / dt:,.0f} steps/s)", - flush=True) - - D = LevEvaluator(sigma, device="cuda", dense=True) - entries = sum(int(W.numel()) for W in D.W) - nonzero = sum(int((W != 0).sum()) for W in D.W) - D.run(M_P, r_H, max_steps=200) - t0 = time.perf_counter() - D.run(M_P, r_H, max_steps=N, expect=sigma) - dt = time.perf_counter() - t0 - rate = N / dt - out["dense_gpu"] = {"steps": N, "seconds": dt, "steps_per_second": rate, - "entries": entries, "nonzero": nonzero, - "bytes_per_step": entries * 4, - "bandwidth_gbs": rate * entries * 4 / 1e9} - print(f" Lev(N_host) dense on the GPU: {N:,} steps in {dt:.1f} s " - f"({rate:,.0f} steps/s); {entries:,} entries of which {nonzero:,} " - f"are nonzero, {entries * 4 / 1e6:.0f} MB read per step, " - f"{rate * entries * 4 / 1e9:.0f} GB/s", flush=True) - - G2 = LevEvaluator(sigma, device="cuda", dense=False, graph=True) - G2.run(M_P, r_H, max_steps=200) - t0 = time.perf_counter() - G2.run(M_P, r_H, max_steps=N, expect=sigma) - dt = time.perf_counter() - t0 - out["graph_gpu"] = {"steps": N, "seconds": dt, "steps_per_second": N / dt} - print(f" the same map from its nonzero entries, captured as a CUDA graph: " - f"{N:,} steps in {dt:.1f} s ({N / dt:,.0f} steps/s)", flush=True) - - S = LevEvaluator(sigma, device="cpu", dense=False) - S.run(M_P, r_H, max_steps=200) - t0 = time.perf_counter() - S.run(M_P, r_H, max_steps=N, expect=sigma) - dt = time.perf_counter() - t0 - out["sparse_cpu"] = {"steps": N, "seconds": dt, "steps_per_second": N / dt} - print(f" the same map from its nonzero entries on one core: {N:,} steps in " - f"{dt:.1f} s ({N / dt:,.0f} steps/s)", flush=True) - - # evaluator (d): the netlist in C, one core, on the self-describing instance - import subprocess - import tempfile - from check_c import parse_sigma, write_flat - work = tempfile.mkdtemp(prefix="throughput_") - exe = os.path.join(work, "netlist_eval") - subprocess.run(["gcc", "-O3", "-march=native", "-fopenmp", "-o", exe, - os.path.join(REPO, "src", "netlist_eval.c")], check=True) - nb, units, nxt = parse_sigma(sigma) - write_flat(os.path.join(work, "host.bin"), nb, units, nxt) - open(os.path.join(work, "mP"), "wb").write(bytes(M_P)) - open(os.path.join(work, "rH"), "wb").write(r_H) - env = dict(os.environ, OMP_NUM_THREADS="1") - t0 = time.perf_counter() - subprocess.run([exe, "run", os.path.join(work, "host.bin"), os.path.join(work, "mP"), - os.path.join(work, "rH"), os.path.join(work, "out"), "net"], - check=True, capture_output=True, env=env) - dt = time.perf_counter() - t0 - assert open(os.path.join(work, "out"), "rb").read() == sigma - out["c_evaluator"] = {"steps": n, "seconds": dt, "steps_per_second": n / dt} - print(f" the netlist of sigma in C, one core: {n:,} steps in {dt:.1f} s " - f"({n / dt:,.0f} steps/s)", flush=True) - - json.dump(out, open(runs_path("paper_throughput.json"), "w"), indent=1) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/tile.py b/src/tile.py deleted file mode 100644 index bf8df0e1374ed5b206d1de31dbaa0e33d07ebf03..0000000000000000000000000000000000000000 --- a/src/tile.py +++ /dev/null @@ -1,222 +0,0 @@ -"""Self-assembling tile computer (abstract tile assembly model). - -Computation is the growth of a crystal. The program is a finite set of square -tiles; each edge carries a glue label with an integer strength. A seed is placed -and tiles accrete onto the assembly by one rule: a tile binds at an empty site -if the summed strength of the glues that match its already-present neighbors is -at least the temperature tau. That binding rule is a threshold gate, - - bind = H( sum_d strength_d * match_d - tau ), - -a weighted sum of matching-glue indicators against tau, so every attachment is -decided by the same Heaviside neuron the rest of the repository is built from. -At tau = 2 the model is Turing-universal (Winfree 1998): a directed tile set -grows a unique structure, and that structure is the trace of a computation. - -Sides are N,E,S,W; a tile's N glue abuts its north neighbor's S glue, and so on. -A glue label "" is the null glue (strength 0, matches nothing). Glue strengths -are a property of the label (matching glues have equal strength), held in a map. -""" -from __future__ import annotations -from typing import Dict, List, Optional, Tuple - -# side -> (dx, dy, my_side, neighbor_side) -_SIDES = [(0, 1, "N", "S"), (1, 0, "E", "W"), (0, -1, "S", "N"), (-1, 0, "W", "E")] - - -class Tile: - __slots__ = ("N", "E", "S", "W", "name") - - def __init__(self, N="", E="", S="", W="", name=""): - self.N, self.E, self.S, self.W, self.name = N, E, S, W, name - - def glue(self, side): - return getattr(self, side) - - -def bind_strength(A: Dict[Tuple[int, int], Tile], x: int, y: int, t: Tile, - strength: Dict[str, int]) -> int: - """Summed strength of t's glues that match the abutting neighbor glues.""" - s = 0 - for dx, dy, side, opp in _SIDES: - nb = A.get((x + dx, y + dy)) - if nb is None: - continue - g = t.glue(side) - if g and g == nb.glue(opp): - s += strength.get(g, 1) - return s - - -def binds(A, x, y, t, tau, strength) -> bool: - """The threshold-gate binding decision: H(sum strength*match - tau).""" - return bind_strength(A, x, y, t, strength) >= tau - - -def grow(tileset: List[Tile], seed: Dict[Tuple[int, int], Tile], tau: int, - strength: Dict[str, int], bounds: Tuple[int, int, int, int], - max_tiles: int = 100000) -> Tuple[Dict[Tuple[int, int], Tile], bool]: - """Directed growth from a seed. Returns (assembly, deterministic): at every - site at most one tile binds when the set is directed, so the assembly is - unique. deterministic=False flags a site where two tiles could bind.""" - x0, y0, x1, y1 = bounds - A = dict(seed) - deterministic = True - changed = True - while changed and len(A) < max_tiles: - changed = False - frontier = set() - for (x, y) in list(A): - for dx, dy, _, _ in _SIDES: - p = (x + dx, y + dy) - if p not in A and x0 <= p[0] <= x1 and y0 <= p[1] <= y1: - frontier.add(p) - for (x, y) in frontier: - binders = [t for t in tileset if binds(A, x, y, t, tau, strength)] - if len(binders) == 1: - A[(x, y)] = binders[0] - changed = True - elif len(binders) > 1: - deterministic = False - return A, deterministic - - -# --------------------------------------------------------------------------- -# XOR / Sierpinski tile set: value(x,y) = value(x-1,y) XOR value(x,y-1) -# --------------------------------------------------------------------------- -def rule2_tileset(fn) -> List[Tile]: - """Rule tiles for value(x,y) = fn(W-input, S-input): four tiles, each binds - cooperatively (S and W, strength 1 each = tau) and emits fn on N and E.""" - ts = [] - for s in (0, 1): - for w in (0, 1): - v = fn(w, s) - ts.append(Tile(N=f"v{v}", E=f"v{v}", S=f"v{s}", W=f"v{w}", - name=f"R w{w} s{s} -> {v}")) - return ts - - -def sierpinski_tileset() -> List[Tile]: - return rule2_tileset(lambda w, s: w ^ s) - - -def _row_col_seed(bottom: List[int], left: List[int]): - """Seed the bottom row (y=0) and left column (x=0) with fixed value tiles, - presenting value glues north and east for the rule tiles above/right.""" - seed = {} - for x, b in enumerate(bottom): - seed[(x, 0)] = Tile(N=f"v{b}", E="", S="", W="", name=f"seedB{x}={b}") - for y, l in enumerate(left): - if y == 0: - continue - seed[(0, y)] = Tile(N="", E=f"v{l}", S="", W="", name=f"seedL{y}={l}") - return seed - - -def _test_binding_gate(): - """The binding decision is exactly the Heaviside threshold gate.""" - strength = {"v0": 1, "v1": 1} - ts = sierpinski_tileset() - A = {(1, 0): Tile(N="v1"), (0, 1): Tile(E="v0")} - bad = 0 - for t in ts: - for x, y in [(1, 1)]: - w = sum(strength.get(t.glue(side), 1) - for dx, dy, side, opp in _SIDES - if A.get((x + dx, y + dy)) and t.glue(side) - and t.glue(side) == A[(x + dx, y + dy)].glue(opp)) - gate = 1 if (w - 2) >= 0 else 0 # H(sum*match - tau) - if gate != int(binds(A, x, y, t, 2, strength)): - bad += 1 - print(f" binding decision == Heaviside gate H(sum-tau): {'OK' if bad == 0 else 'FAIL'}") - return bad == 0 - - -def _test_rule2(fn, name, n=24): - strength = {"v0": 1, "v1": 1} - bottom = [1 if x == 0 else 0 for x in range(n)] - left = [1 if y == 0 else 0 for y in range(n)] - seed = _row_col_seed(bottom, left) - A, det = grow(rule2_tileset(fn), seed, 2, strength, (0, 0, n - 1, n - 1)) - - def val(x, y): - t = A.get((x, y)) - return None if t is None else (1 if t.N == "v1" else 0) - - ref = {(x, 0): bottom[x] for x in range(n)} - ref.update({(0, y): left[y] for y in range(n)}) - for y in range(1, n): - for x in range(1, n): - ref[(x, y)] = fn(ref[(x - 1, y)], ref[(x, y - 1)]) - - filled = bad = 0 - for y in range(1, n): - for x in range(1, n): - v = val(x, y) - if v is not None: - filled += 1 - bad += v != ref[(x, y)] - tag = "OK" if (det and bad == 0 and filled > 0) else "FAIL" - print(f" rule-tile CA fn={name:3s}: directed={det} placed={filled} " - f"every tile = fn(W,S) {tag}") - return det and bad == 0 and filled > 0 - - -# --------------------------------------------------------------------------- -# Binary counter: each row is the row below plus one. LSB is the right column; -# carry propagates west by cooperative binding (S = bit below, E = carry in). -# --------------------------------------------------------------------------- -def counter_tileset() -> List[Tile]: - ts = [] - for b in (0, 1): - for c in (0, 1): - ts.append(Tile(N=f"b{b ^ c}", E=f"c{c}", S=f"b{b}", W=f"c{b & c}", - name=f"C b{b} c{c} -> b{b ^ c} carry{b & c}")) - ts.append(Tile(N="edge", E="", S="edge", W="c1", name="edge(+1 injector)")) - return ts - - -def counter_seed(n: int): - """Bottom row (y=0) all zero, plus the right-edge +1 injector column base.""" - seed = {} - for x in range(n): - seed[(x, 0)] = Tile(N="b0", name=f"seed b0 col{x}") - seed[(n, 0)] = Tile(N="edge", W="c1", name="seed edge") - return seed - - -def _test_counter(n=6, rows=None): - rows = rows or (1 << n) - 1 - strength = {"edge": 2} # value/carry glues default 1 - A, det = grow(counter_tileset(), counter_seed(n), 2, strength, - (0, 0, n, rows)) - - def rowval(y): - bits = [] - for x in range(n): - t = A.get((x, y)) - if t is None: - return None - bits.append(1 if t.N == "b1" else 0) - return sum(bit << (n - 1 - x) for x, bit in enumerate(bits)) - - bad = filled = 0 - for y in range(1, rows + 1): - v = rowval(y) - if v is not None: - filled += 1 - if v != (y & ((1 << n) - 1)): - bad += 1 - print(f" binary counter {n}-bit: directed={det} rows grown={filled} " - f"row y encodes the integer y {'OK' if bad == 0 else f'FAIL({bad})'}") - return det and bad == 0 and filled == rows - - -if __name__ == "__main__": - print("Self-assembling tile computer") - a = _test_binding_gate() - b = all(_test_rule2(fn, nm) for fn, nm in - [(lambda w, s: w ^ s, "XOR"), (lambda w, s: w & s, "AND"), - (lambda w, s: w | s, "OR")]) - c = _test_counter() - print("PASS" if (a and b and c) else "FAIL") diff --git a/src/twohead.py b/src/twohead.py index f3ec37211f2061bb1c506db222760bfbe464e7c9..36ece9c7fb71013dac9b9b27d2a65ef76c64a8ea 100644 --- a/src/twohead.py +++ b/src/twohead.py @@ -1,51 +1,36 @@ -"""The two-head physics and the translating organism. - -The physics is a fixed network whose state is a window of 64 directly addressed -cells, two program banks Q0 and Q1 of N = 26 G bits and two description tapes D0 -and D1 of L bits, all circular. Window cell 0 is the bank bit kappa. A record is -(w0, w1, beta, a0, a1, ao) on 26 bits, gate first: two 2-bit weight codes, a -4-bit bias and three 6-bit window addresses. One step evaluates the record in -front of the active bank Q_kappa, v = H(w0 x[a0] + w1 x[a1] + beta), writes v to -window cell ao and turns the active bank by 26 cells, so that a program head -replaces the record counter. Window cells 1, 2 and 3 are the cells under three -heads: the read head on D_kappa, the arm on Q_(1-kappa) and the copy head on -D_(1-kappa). Writing to them writes under the arm or the copy head. The request -cells 4, 5 and 6 turn the read head, the arm and the copy head by one cell and are -cleared in the same step. - -A description is, per record, a 4-bit codon followed by the 18 address bits, or -the escape codon, the eight gate bits and the 18 address bits, and a final END -codon. The codon names the gate: kind (AND, OR, COPY), and the polarities of its -two literals. The organism has G = 133 records. One pass of them is one step of a -decoder for this code: in construct mode it writes the decoded record bits under -the arm, at END it switches to copy mode and reads the description again, -writing every bit under the copy head, and at END again it flips kappa. The -description is used twice, once translated and once copied, and it is not the -bank: the organism's own description has 2930 bits against 3458 for the bank. - - python src/twohead.py run [--generations N] the organism on the transcription - python src/twohead.py rocq [--out DIR] the Rocq files Organism.v and the Smoke.v data -""" +"""The two-head machine: the organism, the netlist N_{G,L}, and the host stored in it.""" import argparse import hashlib import json import os import random +import struct +import subprocess +import sys import time +import numpy as np + +import host as SR + + REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ALPHA = 6 + W = 1 << ALPHA + KAPPA, RD, ARM, CPY, AR, AA, AC, SINK = range(8) + B = 26 + GATE = 8 + ADDR = 18 class Asm: - """Records over named cells. Persistent cells get fixed addresses; temporaries - share addresses by live interval within the pass (first write to last read).""" + """Records over named cells.""" def __init__(self): self.recs, self.persist, self.names, self.uid = [], {}, {}, 0 @@ -153,6 +138,7 @@ def codon_of(gbits): ESC = [1, 1, 0, 0] + END = [1, 1, 1, 1] @@ -324,8 +310,7 @@ def organism(): def start(A, rho, desc): - """The state s0: a codon start in construct mode, kappa = 0, the organism in Q0 and its - description on D0, the other bank and tape clear.""" + """The state s0: construct mode, kappa 0, the organism in Q0 and its description on D0.""" window = [0] * W window[A.names['C']] = 1 for i, bit in enumerate((1, 1, 0, 0, 0)): # the counter starts at 3 @@ -337,7 +322,7 @@ def sha(bits): return hashlib.sha256(bytes(bits)).hexdigest() -def run(generations): +def run_organism(generations): A = organism() G = len(A.recs) rho = [b for r in A.recs for b in encode_record(r)] @@ -368,8 +353,7 @@ def run(generations): def rocq_files(out_dir): - """Organism.v, the organism as Rocq records, and SmokeData.v, the transcription's states - from s0 after 1000 and 20000 steps, for the comparison in Smoke.v.""" + """Write Organism.v and SmokeData.v for the Rocq development.""" A = organism() z = lambda v: f'({v})%Z' if v < 0 else f'{v}%Z' recs = ';\n'.join(f' Rec {a0} {z(w0)} {a1} {z(w1)} {z(be)} {ao}' for a0, w0, a1, w1, be, ao in A.recs) @@ -408,7 +392,7 @@ def rocq_files(out_dir): f.write(f'Definition py_d1_{n} : list bool := {lit(M.D[1])}.\n') -def main(): +def main_organism(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = ap.add_subparsers(dest='cmd', required=True) r = sub.add_parser('run') @@ -417,7 +401,7 @@ def main(): q.add_argument('--out', default=os.path.join(REPO, 'rocq')) a = ap.parse_args() if a.cmd == 'run': - res = run(a.generations) + res = run_organism(a.generations) path = os.path.join(REPO, 'paper', 'runs', 'paper_twohead.json') json.dump(res, open(path, 'w'), indent=1) print(json.dumps(res, indent=1)) @@ -425,5 +409,899 @@ def main(): rocq_files(a.out) -if __name__ == '__main__': - main() +NET_RECORD = os.path.join(REPO, 'paper', 'runs', 'paper_twohead_net.json') + + +class Net: + def __init__(self, n_state): + self.n = n_state + self.units = [] # (bias, [(signal, weight)]) + + def u(self, bias, ins): + assert all(w in (-1, 1) for _, w in ins) and len({s for s, _ in ins}) == len(ins) + self.units.append((bias, ins)) + return self.n + len(self.units) - 1 + + # literals are (signal, positive) + def AND(self, *lits): + return self.u(-sum(1 for _, p in lits if p), [(s, 1 if p else -1) for s, p in lits]) + + def OR(self, *lits): + return self.u(sum(1 for _, p in lits if not p) - 1, [(s, 1 if p else -1) for s, p in lits]) + + def MUX(self, s, a, b): # s ? a : b on signals + return self.OR((self.AND((s, True), (a, True)), True), (self.AND((s, False), (b, True)), True)) + + +def build_net(G, L, alpha=ALPHA): + """The netlist N_{G,L} of the machine with a window of 2^alpha cells and records of 8 + 3 alpha bits.""" + W, B = 1 << alpha, 8 + 3 * alpha + N = G * B + real = [0] + list(range(4, W)) # window cells held in the state + wpos = {a: i for i, a in enumerate(real)} + q0, q1 = len(real), len(real) + N + d0, d1 = len(real) + 2 * N, len(real) + 2 * N + L + n = len(real) + 2 * N + 2 * L + net = Net(n) + kap = wpos[KAPPA] + # the record in front of the active bank + f = [net.MUX(kap, q1 + j, q0 + j) for j in range(B)] + c0, c1, be = f[0:2], f[2:4], f[4:8] + a0, a1, ao = f[8:8 + alpha], f[8 + alpha:8 + 2 * alpha], f[8 + 2 * alpha:8 + 3 * alpha] + # the cells under the three heads + rd = net.MUX(kap, d1, d0) + armv = net.MUX(kap, q0, q1) + cpyv = net.MUX(kap, d0, d1) + val = {a: wpos[a] for a in real} + val[RD], val[ARM], val[CPY] = rd, armv, cpyv + + def decode(field): + lines = [] + for a in range(W): + bits = [(a >> (alpha - 1 - j)) & 1 for j in range(alpha)] + lines.append(net.u(-sum(bits), [(field[j], 1 if bits[j] else -1) for j in range(alpha)])) + return lines + + l0, l1, lo = decode(a0), decode(a1), decode(ao) + xs = [] + for lines in (l0, l1): + terms = [net.AND((lines[a], True), (val[a], True)) for a in range(W)] + xs.append(net.u(-1, [(t, 1) for t in terms])) + pos = [net.AND((c[0], False), (c[1], True)) for c in (c0, c1)] + neg = [net.AND((c[0], True), (c[1], False)) for c in (c0, c1)] + mterm = [net.AND((pos[s], True), (xs[s], True)) for s in range(2)] + nterm = [net.AND((neg[s], True), (xs[s], True)) for s in range(2)] + # [p >= c] for p = m0 + m1 - n0 - n1, and [beta >= t] on the four bits of beta + pge = {c: net.u(-c, [(mterm[0], 1), (mterm[1], 1), (nterm[0], -1), (nterm[1], -1)]) for c in range(-2, 3)} + b3, b2, b1, b0 = be + or210 = net.OR((b2, True), (b1, True), (b0, True)) + or21 = net.OR((b2, True), (b1, True)) + and210 = net.AND((b2, True), (b1, True), (b0, True)) + and21 = net.AND((b2, True), (b1, True)) + bge = {0: net.AND((b3, False)), + 1: net.AND((b3, False), (or210, True)), + 2: net.AND((b3, False), (or21, True)), + -1: net.OR((b3, False), (and210, True)), + -2: net.OR((b3, False), (and21, True))} + v = net.u(-1, [(net.AND((pge[c], True), (bge[-c], True)), 1) for c in range(-2, 3)]) + # the write + nxt = [None] * n + xhat = {} + for a in real: + xhat[a] = net.MUX(lo[a], v, wpos[a]) + zero = net.u(-1, []) + for a in real: + nxt[wpos[a]] = xhat[a] + for a in (AR, AA, AC): + nxt[wpos[a]] = zero + advA, advR, advC = xhat[AA], xhat[AR], xhat[AC] + # the banks: active turns by B, idle turns by one under the arm + for j, base in ((0, q0), (1, q1)): + act = (kap, j == 1) # kappa == j as a literal + idle = (kap, j == 0) + ia = net.AND(idle, (advA, True)) + ih = net.AND(idle, (advA, False)) + wa = net.AND((lo[ARM], True), idle) + h0 = net.MUX(wa, v, base) + hat = lambda i: h0 if i == 0 else base + i + for i in range(N): + nxt[base + i] = net.OR((net.AND(act, (base + (i + B) % N, True)), True), + (net.AND((ia, True), (hat((i + 1) % N), True)), True), + (net.AND((ih, True), (hat(i), True)), True)) + # the description tapes: the active one under the read head, the idle one under the copy head + for j, base in ((0, d0), (1, d1)): + act = (kap, j == 1) + idle = (kap, j == 0) + rot = net.OR((net.AND(act, (advR, True)), True), (net.AND(idle, (advC, True)), True)) + wc = net.AND((lo[CPY], True), idle) + h0 = net.MUX(wc, v, base) + hat = lambda i: h0 if i == 0 else base + i + for i in range(L): + nxt[base + i] = net.MUX(rot, hat((i + 1) % L), hat(i)) + assert all(x is not None for x in nxt) + return net, nxt, dict(real=real, q0=q0, q1=q1, d0=d0, d1=d1, n=n, N=N, L=L, alpha=alpha) + + +def sizes(alpha, N, L): + """The units, edges and state bits of N_{G,L} for a window of 2^alpha cells.""" + W = 1 << alpha + return dict(units=8 * W + 9 * alpha + 81 + 8 * N + 6 * L, + edges=3 * alpha * W + 12 * W + 18 * alpha + 170 + 18 * N + 12 * L, + state_bits=W - 3 + 2 * N + 2 * L) + + +def stats(net, nxt): + depth = np.zeros(net.n + len(net.units), dtype=np.int64) + edges = 0 + for i, (b, ins) in enumerate(net.units): + depth[net.n + i] = 1 + max((depth[s] for s, _ in ins), default=0) + edges += len(ins) + return dict(units=len(net.units), edges=edges, depth=int(max(depth[s] for s in nxt)), state_bits=net.n) + + +def evaluate(net, nxt, x): + sig = np.concatenate([np.asarray(x, dtype=np.int64), np.zeros(len(net.units), dtype=np.int64)]) + for i, (b, ins) in enumerate(net.units): + sig[net.n + i] = 1 if b + sum(w * sig[s] for s, w in ins) >= 0 else 0 + return [int(sig[s]) for s in nxt] + + +def encode(M, lay): + return [M.w[a] for a in lay['real']] + M.Q[0] + M.Q[1] + M.D[0] + M.D[1] + + +def check_net(G, L, trials, seed=7, alpha=ALPHA): + """One step of the netlist against the transcription from random full states.""" + net, nxt, lay = build_net(G, L, alpha) + rnd = random.Random(seed) + bad = 0 + N = G * (8 + 3 * alpha) + for _ in range(trials): + bits = lambda k: [rnd.randint(0, 1) for _ in range(k)] + M = Machine(G, L, bits(N), bits(N), bits(L), bits(L), bits(1 << alpha), alpha) + y = evaluate(net, nxt, encode(M, lay)) + M.step() + bad += y != encode(M, lay) + st = stats(net, nxt) + assert {k: st[k] for k in ('units', 'edges', 'state_bits')} == sizes(alpha, N, L) + return bad, st + + +def netfile_bytes(net, nxt): + parts = [np.array([net.n, len(net.units), sum(len(i) for _, i in net.units)], dtype=' 1 else 'check' + if cmd == 'check': + t0 = time.time() + small, _ = check_net(5, 40, 300) + A, rho, desc, net, nxt, lay = organism_netlist() + full, _ = check_net(len(A.recs), len(desc), 20) + wide, st_wide = check_net(5, 40, 40, alpha=12) + res = dict(small=dict(G=5, L=40, states=300, disagreements=small), + full=dict(G=len(A.recs), L=len(desc), states=20, disagreements=full), + wide=dict(alpha=12, G=5, L=40, states=40, disagreements=wide, netlist=st_wide), + netlist=stats(net, nxt), seconds=round(time.time() - t0, 1)) + net_update_record('random_states', res) + print(json.dumps(res, indent=1)) + elif cmd == 'export': + path = sys.argv[2] if len(sys.argv) > 2 else os.path.join(REPO, 'variants', 'twohead_physics.bin') + A, rho, desc, net, nxt, lay = organism_netlist() + data = netfile_bytes(net, nxt) + open(path, 'wb').write(data) + res = dict(path=os.path.relpath(path, REPO), bytes=len(data), sha256=hashlib.sha256(data).hexdigest(), + G=len(A.recs), L=len(desc), **stats(net, nxt)) + net_update_record('netlist', res) + print(json.dumps(res, indent=1)) + elif cmd == 'lockstep': + gens = int(sys.argv[2]) if len(sys.argv) > 2 else 2 + A, rho, desc, net, nxt, lay = organism_netlist() + tmp = os.path.join(REPO, 'variants') + netpath, initpath = os.path.join(tmp, 'twohead_physics.bin'), os.path.join(tmp, '.twohead_init.bin') + open(netpath, 'wb').write(netfile_bytes(net, nxt)) + M = start(A, rho, desc) + x = encode(M, lay) + with open(initpath, 'wb') as fh: + fh.write(np.array([len(A.recs), len(desc), len(lay['real'])] + lay['real'], dtype=' 0 else -1) for e in pred[p:p + fanin[j]]])) + p += fanin[j] + return len(nxt), units, nxt + + +# state layout of sigma: pc bits 0..7 most significant first, halt 8, memory byte j bit k at 9 + 8 j + k, +# k = 0 the most significant +def mem_bit(j, t): + """State index of bit t (value 2^t) of memory byte j.""" + return 9 + 8 * j + (7 - t) + + +PC_BIT = lambda t: 7 - t # bit t of the counter + +HALT = 8 + + +# --------------------------------------------------------------------------- +# records over named cells +# --------------------------------------------------------------------------- + +class Prog: + def __init__(self): + self.recs = [] # (a0, w0, a1, w1, beta, ao), cells by name + self.uid = 0 + + def tmp(self): + self.uid += 1 + return ('t', self.uid) + + def emit(self, a0, w0, a1, w1, beta, ao): + assert -8 <= beta <= 7 + self.recs.append((a0 if w0 else ('c', SINK), w0, a1 if w1 else ('c', SINK), w1, beta, ao)) + + def AND(self, x, y, dst): + (a, pa), (b, pb) = x, y + self.emit(a, 1 if pa else -1, b, 1 if pb else -1, -(pa + pb), dst) + + def OR(self, x, y, dst): + (a, pa), (b, pb) = x, y + self.emit(a, 1 if pa else -1, b, 1 if pb else -1, 1 - pa - pb, dst) + + def COPY(self, x, dst): + a, pa = x + self.emit(a, 1 if pa else -1, None, 0, -1 if pa else 0, dst) + + def CONST(self, v, dst): + # a gate of one literal and its negation: OR is 1, AND is 0 + x = ('c', SINK) + (self.OR if v else self.AND)((x, True), (x, False), dst) + + def XOR(self, x, y, dst): + t1, t2 = self.tmp(), self.tmp() + self.OR(x, y, t1) + self.OR((x[0], not x[1]), (y[0], not y[1]), t2) + self.AND((t1, True), (t2, True), dst) + + def MUX(self, s, a, b, dst): + """dst <- s ? a : b, s a cell, a and b literals.""" + t1, t2 = self.tmp(), self.tmp() + self.AND((s, True), a, t1) + self.AND((s, False), b, t2) + self.OR((t1, True), (t2, True), dst) + + def chain(self, kind, lits, dst, before=None): + """Chain the literals by 'and' or 'or' into dst, written once, last.""" + assert lits + if len(lits) == 1: + if before: + before(0) + self.COPY(lits[0], dst) + return + g = self.AND if kind == 'and' else self.OR + acc = self.tmp() + if before: + before(0); before(1) + if len(lits) == 2: + g(lits[0], lits[1], dst) + return + g(lits[0], lits[1], acc) + for i in range(2, len(lits)): + if before: + before(i) + g((acc, True), lits[i], dst if i == len(lits) - 1 else acc) + + def threshold(self, t, lits, dst): + """at least t of the literals, by T_t(x1..xk) = T_t(x2..xk) or (x1 and T_{t-1}(x2..xk)).""" + k = len(lits) + if t <= 0: + self.CONST(1, dst); return + if t > k: + self.CONST(0, dst); return + if t == 1: + self.chain('or', lits, dst); return + if t == k: + self.chain('and', lits, dst); return + a, b = self.tmp(), self.tmp() + self.threshold(t, lits[1:], a) + self.threshold(t - 1, lits[1:], b) + c = self.tmp() + self.AND(lits[0], (b, True), c) + self.OR((a, True), (c, True), dst) + + +def compile_host(P): + """The records of one step of H_8; state bits are cells ('s', i).""" + n, units, nxt = host_units() + cons = {} + for u, (_, ins) in enumerate(units): + for s, _w in ins: + if s >= n: + cons.setdefault(s - n, []).append(u) + is_next = {nxt[i] - n: i for i in range(n)} + assert len(is_next) == n and all(v >= n for v in nxt) + fusable = {u for u, c in cons.items() if len(c) == 1} + done, cell = set(), {} + + def cell_of(s): + return ('s', s) if s < n else cell[s - n] + + def ensure_ancestors(u): + for s, _w in units[u][1]: + if s >= n: + v = s - n + if v in fusable: + ensure_ancestors(v) + else: + need(v) + + def need(u): + if u in done: + return + assert u not in is_next, 'a next value is read before the state is written' + ensure_ancestors(u) + cell[u] = P.tmp() + emit_unit(u, cell[u]) + done.add(u) + + def fused(v): + if v not in done: + cell[v] = P.tmp() + emit_unit(v, cell[v]) + done.add(v) + + def emit_unit(u, dst): + b, ins = units[u] + k = len(ins) + t = sum(1 for _, w in ins if w < 0) - b + lits = [None] * k + + def before(i): + s = ins[i][0] + if s >= n and (s - n) in fusable: + fused(s - n) + lits[i] = (cell_of(s), ins[i][1] > 0) + if t <= 0 or t > k: + P.CONST(1 if t <= 0 else 0, dst) + elif 1 < t < k: + for i in range(k): + before(i) + P.threshold(t, lits, dst) + else: + _chain_lazy(P, 'and' if t == k else 'or', lits, dst, before) + + order_next = [nxt[PC_BIT(t)] - n for t in range(8)] + [nxt[HALT] - n] + \ + [nxt[mem_bit(j, t)] - n for j in range(256) for t in range(8)] + for u in order_next: # stage A: everything the next values read + ensure_ancestors(u) + for u in order_next: # stage B: the next values, in place + emit_unit(u, ('s', is_next[u])) + done.add(u) + return n, units, nxt + + +def _chain_lazy(P, kind, lits, dst, before): + k = len(lits) + if k == 1: + before(0) + P.COPY(lits[0], dst) + return + g = P.AND if kind == 'and' else P.OR + before(0); before(1) + if k == 2: + g(lits[0], lits[1], dst) + return + acc = P.tmp() + g(lits[0], lits[1], acc) + for i in range(2, k): + before(i) + g((acc, True), lits[i], dst if i == k - 1 else acc) + + +# --------------------------------------------------------------------------- +# the device, the startup block and the hand-over +# --------------------------------------------------------------------------- + +def mem_cell(j, t): + return ('s', mem_bit(j, t)) + + +def byte_is_one(P, j): + """[memory byte j == 1]""" + dst = P.tmp() + lits = [(mem_cell(j, 0), True)] + [(mem_cell(j, t), False) for t in range(1, 8)] + P.chain('and', lits, dst) + return dst + + +def compile_device(P): + cnt = [('d', 'cnt', i) for i in range(CW)] + lim = [('d', 'lim', i) for i in range(CW)] + dest = ('d', 'dest') + wr, rd, rw = byte_is_one(P, SR.C_WR), byte_is_one(P, SR.C_RD), byte_is_one(P, SR.C_RW) + # [cnt == lim] + eq = P.tmp() + xs = [] + for i in range(CW): + e = P.tmp() + a1, a0 = P.tmp(), P.tmp() + P.AND((cnt[i], True), (lim[i], True), a1) + P.AND((cnt[i], False), (lim[i], False), a0) + P.OR((a1, True), (a0, True), e) + xs.append((e, True)) + P.chain('and', xs, eq) + eot = P.tmp() # the end of the tape is known after the rewind + P.AND((eq, True), (dest, True), eot) + # (i) R_out, most significant bit first, under the arm or under the copy head + ca, cc = P.tmp(), P.tmp() + P.AND((wr, True), (dest, False), ca) + P.AND((wr, True), (dest, True), cc) + for t in range(7, -1, -1): + P.MUX(ca, (mem_cell(SR.R_OUT, t), True), (('c', ARM), True), ('c', ARM)) + P.COPY((ca, True), ('c', AA)) + P.MUX(cc, (mem_cell(SR.R_OUT, t), True), (('c', CPY), True), ('c', CPY)) + P.COPY((cc, True), ('c', AC)) + for t in range(8): + P.AND((mem_cell(SR.R_OUT, t), True), (wr, False), mem_cell(SR.R_OUT, t)) + g = P.tmp() + P.AND((rd, True), (eot, False), g) + # (ii) the byte under the read head into R_in, most significant bit first, each bit turning the tape + for t in range(7, -1, -1): + P.MUX(g, (('c', RD), True), (mem_cell(SR.R_IN, t), True), mem_cell(SR.R_IN, t)) + P.COPY((g, True), ('c', AR)) + # c_eot: bit 0 <- [cnt == lim] on a read, the other bits cleared on a read + P.MUX(rd, (eot, True), (mem_cell(SR.C_EOT, 0), True), mem_cell(SR.C_EOT, 0)) + for t in range(1, 8): + P.AND((mem_cell(SR.C_EOT, t), True), (rd, False), mem_cell(SR.C_EOT, t)) + # the counter of bytes read: carries first, then the sum bits + car = [g] + for i in range(CW - 1): + c = P.tmp() + P.AND((car[-1], True), (cnt[i], True), c) + car.append(c) + for i in range(CW): + P.XOR((cnt[i], True), (car[i], True), cnt[i]) + # (iii) a rewind: the limit takes the count, the count returns to 0, the writes go to the copy tape + for i in range(CW): + P.MUX(rw, (cnt[i], True), (lim[i], True), lim[i]) + P.AND((cnt[i], True), (rw, False), cnt[i]) + P.OR((dest, True), (rw, True), dest) + # (iv) the request cells cleared + for j in (SR.C_WR, SR.C_RD, SR.C_RW): + for t in range(8): + P.CONST(0, mem_cell(j, t)) + + +def compile_startup(P, mem_image): + """The startup records: while booted is 0, load m* and clear the counter, halt bit and registers.""" + b = ('d', 'booted') + for j in range(256): + for t in range(8): + c = mem_cell(j, t) + if (mem_image[j] >> t) & 1: + P.OR((c, True), (b, False), c) # b ? c : 1 + else: + P.AND((c, True), (b, True), c) # b ? c : 0 + for t in range(8): + P.AND((('s', PC_BIT(t)), True), (b, True), ('s', PC_BIT(t))) + P.AND((('s', HALT), True), (b, True), ('s', HALT)) + for i in range(CW): + P.AND((('d', 'cnt', i), True), (b, True), ('d', 'cnt', i)) + P.AND((('d', 'lim', i), True), (b, True), ('d', 'lim', i)) + P.AND((('d', 'dest'), True), (b, True), ('d', 'dest')) + P.CONST(1, b) + + +def compile_handover(P): + """The hand-over records: at the halt, clear booted and flip the bank bit at the end of the pass.""" + h = ('s', HALT) + P.AND((('d', 'booted'), True), (h, False), ('d', 'booted')) + P.XOR((('c', KAPPA), True), (h, True), ('c', KAPPA)) + + +# --------------------------------------------------------------------------- +# allocation and encoding +# --------------------------------------------------------------------------- + +def allocate(recs): + """Window addresses for the special cells, the state, the registers and the temporaries.""" + fixed = {} + nxt_addr = 8 + names = [] + for r in recs: + for a in (r[0], r[2], r[5]): + if a is not None and a[0] in ('s', 'd') and a not in fixed: + names.append(a) + fixed[a] = None + for a in sorted(names, key=lambda x: (x[0], x[1:] if x[0] == 'd' else x[1])) : + fixed[a] = nxt_addr + nxt_addr += 1 + first_w, last_r = {}, {} + for i, (a0, w0, a1, w1, be, ao) in enumerate(recs): + for a, w in ((a0, w0), (a1, w1)): + if w and a[0] == 't': + assert a in first_w, ('read before written', a) + last_r[a] = i + if ao[0] == 't': + first_w.setdefault(ao, i) + base = nxt_addr + free_at = {} # cell -> index after which it is free + addr = dict(fixed) + for name in sorted(first_w, key=lambda x: first_w[x]): + start, end = first_w[name], last_r.get(name, first_w[name]) + cand = [c for c, f in free_at.items() if f < start] + c = min(cand) if cand else base + len(free_at) + addr[name] = c + free_at[c] = end + top = base + len(free_at) + assert top <= SH_W, ('window too small', top) + + def res(a): + if a is None: + return SINK + if a[0] == 'c': + return a[1] + return addr[a] + out = [(res(a0), w0, res(a1), w1, be, res(ao)) for (a0, w0, a1, w1, be, ao) in recs] + return out, addr, top + + +def bits_to_bytes(bits): + assert len(bits) % 8 == 0 + return bytes(sum(bits[i + k] << (7 - k) for k in range(8)) for i in range(0, len(bits), 8)) + + +def bytes_to_bits(data): + return [(b >> (7 - k)) & 1 for b in data for k in range(8)] + + +def check_schedule(recs, lo, hi, n): + """Check the in-place order of the host block and the live intervals of the temporaries.""" + written = {} + for i in range(lo, hi): + ao = recs[i][5] + if ao[0] == 's': + assert ao not in written, ('state bit written twice', ao) + written[ao] = i + assert len(written) == n, 'a state bit without its next value' + for i in range(lo, hi): + a0, w0, a1, w1, _, _ = recs[i] + for a, w in ((a0, w0), (a1, w1)): + if w and a[0] == 's': + assert i <= written[a], ('state bit read after it is written', a) + last_r, last_w = {}, {} + for i, (a0, w0, a1, w1, _, ao) in enumerate(recs): + for a, w in ((a0, w0), (a1, w1)): + if w and a[0] == 't': + last_r[a] = i + if ao[0] == 't': + last_w[ao] = i + assert all(last_w[t] < last_r.get(t, last_w[t] + 1) or t not in last_r for t in last_w), \ + 'a temporary written after its last read' + return dict(state_bits_written=len(written)) + + +def build_stored(): + P = Prog() + compile_startup(P, SR.M_STAR) + lo = len(P.recs) + n, units, nxt = compile_host(P) + n_host = len(P.recs) - lo + check_schedule(P.recs, lo, len(P.recs), n) + compile_device(P) + compile_handover(P) + recs, addr, top = allocate(P.recs) + if len(recs) % 2: # an image of whole bytes + recs.insert(len(recs) - 3, (SINK, 0, SINK, 0, -1, SINK)) + image_bits = [b for r in recs for b in encode_record(r, SH_ALPHA)] + image = bits_to_bytes(image_bits) + tau = SR.describe(image) + assert SR.decode(tau) == image + return dict(recs=recs, addr=addr, top=top, image=image, image_bits=image_bits, tau=tau, + n_host_records=n_host, host=(n, units, nxt)) + + +# --------------------------------------------------------------------------- +# the machine, transcribed +# --------------------------------------------------------------------------- + +class HeadMachine: + """T_{G,L} with a window of W cells; banks and tapes listed from their heads.""" + def __init__(self, Q0, Q1, D0, D1, window): + self.Q = [list(Q0), list(Q1)] + self.D = [list(D0), list(D1)] + self.h = [[0, 0], [0, 0]] # heads of the banks and of the tapes + self.w = list(window) + self.N, self.L = len(Q0), len(D0) + + def cell(self, a): + k = self.w[KAPPA] + if a == RD: + return self.D[k][self.h[1][k]] + if a == ARM: + return self.Q[1 - k][self.h[0][1 - k]] + if a == CPY: + return self.D[1 - k][self.h[1][1 - k]] + return self.w[a] + + def step(self, rec=None): + k = self.w[KAPPA] + if rec is None: + hq = self.h[0][k] + rec = decode_record([self.Q[k][(hq + i) % self.N] for i in range(SH_B)], SH_ALPHA) + a0, w0, a1, w1, beta, ao = rec + v = 1 if w0 * self.cell(a0) + w1 * self.cell(a1) + beta >= 0 else 0 + if ao == ARM: + self.Q[1 - k][self.h[0][1 - k]] = v + elif ao == CPY: + self.D[1 - k][self.h[1][1 - k]] = v + elif ao != RD: + self.w[ao] = v + self.h[0][k] = (self.h[0][k] + SH_B) % self.N + if self.w[AA]: + self.h[0][1 - k] = (self.h[0][1 - k] + 1) % self.N + if self.w[AR]: + self.h[1][k] = (self.h[1][k] + 1) % self.L + if self.w[AC]: + self.h[1][1 - k] = (self.h[1][1 - k] + 1) % self.L + self.w[AA] = self.w[AR] = self.w[AC] = 0 + + +# --------------------------------------------------------------------------- +# one pass against H_8 and the device +# --------------------------------------------------------------------------- + +def host_step(n, units, nxt, x): + sig = list(x) + [0] * len(units) + for u, (b, ins) in enumerate(units): + sig[n + u] = 1 if b + sum(w * sig[s] for s, w in ins) >= 0 else 0 + return [sig[v] for v in nxt] + + +def check_stored(trials=40, seed=3, verbose=False): + S = build_stored() + recs, addr = S['recs'], S['addr'] + n, units, nxt = S['host'] + G = len(recs) + rng = random.Random(seed) + bad = 0 + image_bits = [b for r in recs for b in encode_record(r, SH_ALPHA)] + x0 = [0] * n # the state x_0(m*) + for j in range(256): + for t in range(8): + x0[mem_bit(j, t)] = (SR.M_STAR[j] >> t) & 1 + for trial in range(trials): + # a random host state with random device registers, temporaries and tapes; every fifth + # state not yet booted, and in every third the requests hold random bytes + x = [rng.randrange(2) for _ in range(n)] + x[HALT] = 0 if trial % 4 else 1 + booted = 0 if trial % 5 == 0 else 1 + for j in (SR.C_WR, SR.C_RD, SR.C_RW): + v = rng.randrange(256) if trial % 3 == 0 else rng.choice((0, 1)) + for t in range(8): + x[mem_bit(j, t)] = (v >> t) & 1 + L = 64 + cnt, lim = rng.randrange(8), rng.randrange(8) + dest = rng.randrange(2) + win = [rng.randrange(2) for _ in range(SH_W)] + win[KAPPA] = 0 + for i in range(n): + win[addr[('s', i)]] = x[i] + for i in range(CW): + win[addr[('d', 'cnt', i)]] = (cnt >> i) & 1 + win[addr[('d', 'lim', i)]] = (lim >> i) & 1 + win[addr[('d', 'dest')]] = dest + win[addr[('d', 'booted')]] = booted + win[AR] = win[AA] = win[AC] = 0 + D0 = [rng.randrange(2) for _ in range(L)] + Q1 = [rng.randrange(2) for _ in range(len(image_bits))] + D1 = [rng.randrange(2) for _ in range(L)] + M = HeadMachine(image_bits, Q1, D0, D1, win) + for g in range(G): + M.step(recs[g]) + # expected: the startup if not booted, one step of H_8, then the device on the ring tapes + if not booted: + x, cnt, lim, dest = list(x0), 0, 0, 0 + y = host_step(n, units, nxt, x) + get = lambda j: sum(y[mem_bit(j, t)] << t for t in range(8)) + + def put(j, v): + for t in range(8): + y[mem_bit(j, t)] = (v >> t) & 1 + e_q1, e_d1, hq1, hd0, hd1 = list(Q1), list(D1), 0, 0, 0 + e_cnt, e_lim, e_dest = cnt, lim, dest + if get(SR.C_WR) == 1: + byte = get(SR.R_OUT) + for kk in range(8): + bit = (byte >> (7 - kk)) & 1 + if e_dest: + e_d1[hd1] = bit; hd1 += 1 + else: + e_q1[hq1] = bit; hq1 += 1 + put(SR.R_OUT, 0) + if get(SR.C_RD) == 1: + if not (e_dest and e_cnt == e_lim): + put(SR.R_IN, sum(D0[hd0 + kk] << (7 - kk) for kk in range(8))) + hd0 += 8 + put(SR.C_EOT, 0) + e_cnt = (e_cnt + 1) % (1 << CW) + else: + put(SR.C_EOT, 1) + if get(SR.C_RW) == 1: + e_lim, e_cnt, e_dest = e_cnt, 0, 1 + put(SR.C_WR, 0); put(SR.C_RD, 0); put(SR.C_RW, 0) + e_kappa = 1 if y[HALT] else 0 + got = [M.w[addr[('s', i)]] for i in range(n)] + g_cnt = sum(M.w[addr[('d', 'cnt', i)]] << i for i in range(CW)) + g_lim = sum(M.w[addr[('d', 'lim', i)]] << i for i in range(CW)) + parts = dict(state=got == y, cnt=g_cnt == e_cnt, lim=g_lim == e_lim, + dest=M.w[addr[('d', 'dest')]] == e_dest, bank=M.Q[1] == e_q1, copy=M.D[1] == e_d1, + arm=M.h[0][1] == hq1, read=M.h[1][0] == hd0, copyhead=M.h[1][1] == hd1, + kappa=M.w[KAPPA] == e_kappa, tape=M.D[0] == D0, + booted=M.w[addr[('d', 'booted')]] == 1 - y[HALT], + requests=M.w[AR] == M.w[AA] == M.w[AC] == 0, active=M.Q[0] == image_bits) + ok = all(parts.values()) + if not ok and verbose: + print(trial, [k for k, v in parts.items() if not v], 'reqs', + [get(j) for j in (SR.C_WR, SR.C_RD, SR.C_RW)], 'dest', dest, 'cnt', cnt, 'lim', lim, + 'halt', x[HALT], [i for i in range(n) if got[i] != y[i]][:10]) + bad += not ok + return dict(trials=trials, disagreements=bad, records=G, window_cells=S['top']) + + +def stored_update_record(key, value): + rec = json.load(open(STORED_RECORD)) if os.path.exists(STORED_RECORD) else {} + rec[key] = value + json.dump(rec, open(STORED_RECORD, 'w'), indent=1) + + +def run_stored(generations): + """Generations of the stored host on the machine in C, against the reference run of P*.""" + S = build_stored() + recs, image_bits, tau = S['recs'], S['image_bits'], S['tau'] + G = len(recs) + out, steps = SR.run_reference(SR.M_STAR, tau) + assert out == S['image'] + tau + tau_bits = bytes_to_bits(tau) + work = os.path.join(REPO, 'variants') + inp, exe = os.path.join(work, '.stored_host.bin'), os.path.join(work, '.stored_host_run') + with open(inp, 'wb') as f: + f.write(struct.pack('<4i', SH_W, SH_ALPHA, G, len(tau_bits))) + f.write(bytes(image_bits) + bytes(tau_bits) + bytes(SH_W)) + subprocess.run(['gcc', '-O3', '-march=native', '-o', exe, os.path.join(REPO, 'src', 'stored_host_run.c')], check=True) + t0 = time.time() + p = subprocess.run([exe, inp, str(generations)], capture_output=True, text=True) + os.remove(inp); os.remove(exe) + lines = [json.loads(l) for l in p.stdout.split('\n') if l.strip()] + gens = [l for l in lines if 'generation' in l] + res = dict(records=G, window_cells=S['top'], record_bits=SH_B, bank_bits=len(image_bits), + image_bytes=len(S['image']), tau_bytes=len(tau), passes_expected=steps, + generations=gens, seconds=round(time.time() - t0, 1), + sha256_image=hashlib.sha256(S['image']).hexdigest(), + sha256_tau=hashlib.sha256(tau).hexdigest()) + for l in lines: + if 'state_after_3_equals_after_1' in l: + res['state_after_3_equals_after_1'] = l['state_after_3_equals_after_1'] + res['every_generation_exact'] = all(g['passes'] == steps and g['steps'] == steps * G and g['bank_holds_image'] + and g['tape_holds_tau'] for g in gens) and len(gens) == generations + stored_update_record('run', res) + return res + + +def cmd_stored(): + cmd = sys.argv[1] if len(sys.argv) > 1 else 'build' + if cmd == 'check': + t0 = time.time() + res = check_stored(int(sys.argv[2]) if len(sys.argv) > 2 else 40) + res['seconds'] = round(time.time() - t0, 1) + stored_update_record('pass_check', res) + print(json.dumps(res)) + sys.exit(0 if res['disagreements'] == 0 else 1) + elif cmd == 'run': + gens = int(sys.argv[2]) if len(sys.argv) > 2 else 3 + res = run_stored(gens) + print(json.dumps(res, indent=1)) + sys.exit(0 if res['every_generation_exact'] and (gens < 3 or res.get('state_after_3_equals_after_1') == 1) + else 1) + elif cmd == 'build': + t0 = time.time() + S = build_stored() + print(json.dumps(dict(records=len(S['recs']), host_records=S['n_host_records'], + window_cells=S['top'], record_bits=SH_B, bank_bits=len(S['image_bits']), + image_bytes=len(S['image']), tau_bytes=len(S['tau']), + seconds=round(time.time() - t0, 1)))) + + +COMMANDS = {'organism': main_organism, 'net': main_net, 'stored': cmd_stored} + + +if __name__ == "__main__": + if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS: + sys.exit('commands: ' + ', '.join(COMMANDS)) + cmd = sys.argv.pop(1) + sys.argv[0] += ' ' + cmd + sys.exit(COMMANDS[cmd]()) diff --git a/src/twohead_net.py b/src/twohead_net.py deleted file mode 100644 index 8ab93345753fe57766ff94a145530a179429810e..0000000000000000000000000000000000000000 --- a/src/twohead_net.py +++ /dev/null @@ -1,294 +0,0 @@ -"""The two-head physics as a netlist of ternary threshold units. - -For a window of 2^alpha cells and records of 8 + 3 alpha bits, the state bits are -the window cells other than 1, 2 and 3, then Q0, Q1, D0 and D1, each listed from its -head. The netlist selects the record in front of the active bank by a multiplexer -on kappa, decodes its three addresses, reads the two operands through the decode -lines, forms v = H(w0 x0 + w1 x1 + beta) from the weight codes and the bias bits, -writes v into the window, and computes every tape bit's next value: the active bank -turned by one record, the idle bank written under the arm and turned by one cell on -request, and the two description tapes likewise. The unit order is the order of -construction, which rocq/Phys.v follows unit for unit. The organism runs on the -member with alpha = 6, a window of 64 cells. - - python src/twohead_net.py check one step against the transcription on random states - python src/twohead_net.py export [PATH] the netlist file (default variants/twohead_physics.bin) - python src/twohead_net.py lockstep [GENS] the organism on the netlist in C, beside the transcription - python src/twohead_net.py extract compare with the netlist extracted from rocq/Phys.v - -The netlist file holds little-endian 32-bit integers: the number of state bits, -units and edges; the bias of each unit; the fan-in of each unit; one signed entry -per edge, its magnitude the signal index plus one and its sign the weight; and, -for each state bit, the signal computing its next value. -""" -import hashlib -import json -import os -import random -import subprocess -import sys -import time - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -import numpy as np -import twohead as T - -REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -W, B, ALPHA = T.W, T.B, T.ALPHA -KAPPA, RD, ARM, CPY, AR, AA, AC = T.KAPPA, T.RD, T.ARM, T.CPY, T.AR, T.AA, T.AC -RECORD = os.path.join(REPO, 'paper', 'runs', 'paper_twohead_net.json') - - -class Net: - def __init__(self, n_state): - self.n = n_state - self.units = [] # (bias, [(signal, weight)]) - - def u(self, bias, ins): - assert all(w in (-1, 1) for _, w in ins) and len({s for s, _ in ins}) == len(ins) - self.units.append((bias, ins)) - return self.n + len(self.units) - 1 - - # literals are (signal, positive) - def AND(self, *lits): - return self.u(-sum(1 for _, p in lits if p), [(s, 1 if p else -1) for s, p in lits]) - - def OR(self, *lits): - return self.u(sum(1 for _, p in lits if not p) - 1, [(s, 1 if p else -1) for s, p in lits]) - - def MUX(self, s, a, b): # s ? a : b on signals - return self.OR((self.AND((s, True), (a, True)), True), (self.AND((s, False), (b, True)), True)) - - -def build(G, L, alpha=ALPHA): - """The netlist N_{G,L} of the machine with a window of 2^alpha cells and records of - 8 + 3 alpha bits.""" - W, B = 1 << alpha, 8 + 3 * alpha - N = G * B - real = [0] + list(range(4, W)) # window cells held in the state - wpos = {a: i for i, a in enumerate(real)} - q0, q1 = len(real), len(real) + N - d0, d1 = len(real) + 2 * N, len(real) + 2 * N + L - n = len(real) + 2 * N + 2 * L - net = Net(n) - kap = wpos[KAPPA] - # the record in front of the active bank - f = [net.MUX(kap, q1 + j, q0 + j) for j in range(B)] - c0, c1, be = f[0:2], f[2:4], f[4:8] - a0, a1, ao = f[8:8 + alpha], f[8 + alpha:8 + 2 * alpha], f[8 + 2 * alpha:8 + 3 * alpha] - # the cells under the three heads - rd = net.MUX(kap, d1, d0) - armv = net.MUX(kap, q0, q1) - cpyv = net.MUX(kap, d0, d1) - val = {a: wpos[a] for a in real} - val[RD], val[ARM], val[CPY] = rd, armv, cpyv - - def decode(field): - lines = [] - for a in range(W): - bits = [(a >> (alpha - 1 - j)) & 1 for j in range(alpha)] - lines.append(net.u(-sum(bits), [(field[j], 1 if bits[j] else -1) for j in range(alpha)])) - return lines - - l0, l1, lo = decode(a0), decode(a1), decode(ao) - xs = [] - for lines in (l0, l1): - terms = [net.AND((lines[a], True), (val[a], True)) for a in range(W)] - xs.append(net.u(-1, [(t, 1) for t in terms])) - pos = [net.AND((c[0], False), (c[1], True)) for c in (c0, c1)] - neg = [net.AND((c[0], True), (c[1], False)) for c in (c0, c1)] - mterm = [net.AND((pos[s], True), (xs[s], True)) for s in range(2)] - nterm = [net.AND((neg[s], True), (xs[s], True)) for s in range(2)] - # [p >= c] for p = m0 + m1 - n0 - n1, and [beta >= t] on the four bits of beta - pge = {c: net.u(-c, [(mterm[0], 1), (mterm[1], 1), (nterm[0], -1), (nterm[1], -1)]) for c in range(-2, 3)} - b3, b2, b1, b0 = be - or210 = net.OR((b2, True), (b1, True), (b0, True)) - or21 = net.OR((b2, True), (b1, True)) - and210 = net.AND((b2, True), (b1, True), (b0, True)) - and21 = net.AND((b2, True), (b1, True)) - bge = {0: net.AND((b3, False)), - 1: net.AND((b3, False), (or210, True)), - 2: net.AND((b3, False), (or21, True)), - -1: net.OR((b3, False), (and210, True)), - -2: net.OR((b3, False), (and21, True))} - v = net.u(-1, [(net.AND((pge[c], True), (bge[-c], True)), 1) for c in range(-2, 3)]) - # the write - nxt = [None] * n - xhat = {} - for a in real: - xhat[a] = net.MUX(lo[a], v, wpos[a]) - zero = net.u(-1, []) - for a in real: - nxt[wpos[a]] = xhat[a] - for a in (AR, AA, AC): - nxt[wpos[a]] = zero - advA, advR, advC = xhat[AA], xhat[AR], xhat[AC] - # the banks: active turns by B, idle turns by one under the arm - for j, base in ((0, q0), (1, q1)): - act = (kap, j == 1) # kappa == j as a literal - idle = (kap, j == 0) - ia = net.AND(idle, (advA, True)) - ih = net.AND(idle, (advA, False)) - wa = net.AND((lo[ARM], True), idle) - h0 = net.MUX(wa, v, base) - hat = lambda i: h0 if i == 0 else base + i - for i in range(N): - nxt[base + i] = net.OR((net.AND(act, (base + (i + B) % N, True)), True), - (net.AND((ia, True), (hat((i + 1) % N), True)), True), - (net.AND((ih, True), (hat(i), True)), True)) - # the description tapes: the active one under the read head, the idle one under the copy head - for j, base in ((0, d0), (1, d1)): - act = (kap, j == 1) - idle = (kap, j == 0) - rot = net.OR((net.AND(act, (advR, True)), True), (net.AND(idle, (advC, True)), True)) - wc = net.AND((lo[CPY], True), idle) - h0 = net.MUX(wc, v, base) - hat = lambda i: h0 if i == 0 else base + i - for i in range(L): - nxt[base + i] = net.MUX(rot, hat((i + 1) % L), hat(i)) - assert all(x is not None for x in nxt) - return net, nxt, dict(real=real, q0=q0, q1=q1, d0=d0, d1=d1, n=n, N=N, L=L, alpha=alpha) - - -def sizes(alpha, N, L): - """The units and edges of N_{G,L} for a window of 2^alpha cells and banks of N bits, - counted block by block.""" - W = 1 << alpha - return dict(units=8 * W + 9 * alpha + 81 + 8 * N + 6 * L, - edges=3 * alpha * W + 12 * W + 18 * alpha + 170 + 18 * N + 12 * L, - state_bits=W - 3 + 2 * N + 2 * L) - - -def stats(net, nxt): - depth = np.zeros(net.n + len(net.units), dtype=np.int64) - edges = 0 - for i, (b, ins) in enumerate(net.units): - depth[net.n + i] = 1 + max((depth[s] for s, _ in ins), default=0) - edges += len(ins) - return dict(units=len(net.units), edges=edges, depth=int(max(depth[s] for s in nxt)), state_bits=net.n) - - -def evaluate(net, nxt, x): - sig = np.concatenate([np.asarray(x, dtype=np.int64), np.zeros(len(net.units), dtype=np.int64)]) - for i, (b, ins) in enumerate(net.units): - sig[net.n + i] = 1 if b + sum(w * sig[s] for s, w in ins) >= 0 else 0 - return [int(sig[s]) for s in nxt] - - -def encode(M, lay): - return [M.w[a] for a in lay['real']] + M.Q[0] + M.Q[1] + M.D[0] + M.D[1] - - -def check(G, L, trials, seed=7, alpha=ALPHA): - """One step of the netlist against the transcription from uniformly random full states; - returns the number of disagreements and the netlist's statistics.""" - net, nxt, lay = build(G, L, alpha) - rnd = random.Random(seed) - bad = 0 - N = G * (8 + 3 * alpha) - for _ in range(trials): - bits = lambda k: [rnd.randint(0, 1) for _ in range(k)] - M = T.Machine(G, L, bits(N), bits(N), bits(L), bits(L), bits(1 << alpha), alpha) - y = evaluate(net, nxt, encode(M, lay)) - M.step() - bad += y != encode(M, lay) - st = stats(net, nxt) - assert {k: st[k] for k in ('units', 'edges', 'state_bits')} == sizes(alpha, N, L) - return bad, st - - -def netfile_bytes(net, nxt): - parts = [np.array([net.n, len(net.units), sum(len(i) for _, i in net.units)], dtype=' 1 else 'check' - if cmd == 'check': - t0 = time.time() - small, _ = check(5, 40, 300) - A, rho, desc, net, nxt, lay = organism_netlist() - full, _ = check(len(A.recs), len(desc), 20) - wide, st_wide = check(5, 40, 40, alpha=12) - res = dict(small=dict(G=5, L=40, states=300, disagreements=small), - full=dict(G=len(A.recs), L=len(desc), states=20, disagreements=full), - wide=dict(alpha=12, G=5, L=40, states=40, disagreements=wide, netlist=st_wide), - netlist=stats(net, nxt), seconds=round(time.time() - t0, 1)) - update_record('random_states', res) - print(json.dumps(res, indent=1)) - elif cmd == 'export': - path = sys.argv[2] if len(sys.argv) > 2 else os.path.join(REPO, 'variants', 'twohead_physics.bin') - A, rho, desc, net, nxt, lay = organism_netlist() - data = netfile_bytes(net, nxt) - open(path, 'wb').write(data) - res = dict(path=os.path.relpath(path, REPO), bytes=len(data), sha256=hashlib.sha256(data).hexdigest(), - G=len(A.recs), L=len(desc), **stats(net, nxt)) - update_record('netlist', res) - print(json.dumps(res, indent=1)) - elif cmd == 'lockstep': - gens = int(sys.argv[2]) if len(sys.argv) > 2 else 2 - A, rho, desc, net, nxt, lay = organism_netlist() - tmp = os.path.join(REPO, 'variants') - netpath, initpath = os.path.join(tmp, 'twohead_physics.bin'), os.path.join(tmp, '.twohead_init.bin') - open(netpath, 'wb').write(netfile_bytes(net, nxt)) - M = T.start(A, rho, desc) - x = encode(M, lay) - with open(initpath, 'wb') as fh: - fh.write(np.array([len(A.recs), len(desc), len(lay['real'])] + lay['real'], dtype=' str: - argv_save = sys.argv - sys.argv = ["play.py", "--model", model_path] - if skip_cpu: - sys.argv.append("--skip-cpu") - buf = io.StringIO() - try: - with redirect_stdout(buf): - rc = play.main() - finally: - sys.argv = argv_save - if rc not in (0, None): - raise AssertionError(f"play.main() returned {rc}") - return buf.getvalue() - - -class TestPlay(unittest.TestCase): - - def test_alu_variant(self) -> None: - path = os.path.join(REPO_ROOT, "variants", "neural_alu8.safetensors") - out = _run(path, skip_cpu=True) - # Booleans - for line in ["and 00->0 01->0 10->0 11->1", - "or 00->0 01->1 10->1 11->1", - "xor 00->0 01->1 10->1 11->0"]: - self.assertIn(line, out, f"missing boolean line: {line!r}") - # ALU ADD/SUB - self.assertIn("127 + 128 = 255 (carry=0) expected 255 [OK]", out) - self.assertIn("127 - 128 = 255 (no_borrow=0) expected 255 [OK]", out) - # CMP via cmp8bit cascade - self.assertIn("50 vs 30 -> GT=1 LT=0 EQ=0", out) - self.assertIn("77 vs 77 -> GT=0 LT=0 EQ=1", out) - self.assertIn("128 vs 127 -> GT=1 LT=0 EQ=0", out) - # MUL - self.assertIn("12 * 11 = 132 expected 132 [OK]", out) - # mod-5 (52 multiples of 5 in [0, 255]) - self.assertIn("52 hits", out) - self.assertNotIn("FAIL", out) - - def test_cpu_variant(self) -> None: - path = os.path.join(REPO_ROOT, "variants", "neural_computer8_small.safetensors") - out = _run(path, skip_cpu=False) - self.assertIn("M[0x0083] = 15 (expected 15) [OK]", out) - self.assertIn("R0=15", out) - self.assertNotIn("FAIL", out) - - -if __name__ == "__main__": - unittest.main() diff --git a/tools/build_all.py b/tools/build_all.py deleted file mode 100644 index ed7c8cd9b6c2880889197cc5f736bca82030bc64..0000000000000000000000000000000000000000 --- a/tools/build_all.py +++ /dev/null @@ -1,214 +0,0 @@ -""" -Build and verify every named (bits, memory_profile) variant. - -Outputs: - variants/neural_alu{8,16,32}.safetensors - no memory - variants/neural_computer{8,16,32}_registers.safetensors - 16 B - variants/neural_computer{8,16,32}_scratchpad.safetensors - 256 B - variants/neural_computer{8,16,32}_small.safetensors - 1 KB - variants/neural_computer{8,16,32}_reduced.safetensors - 4 KB - variants/neural_computer{8,16,32}.safetensors - 64 KB - -Each variant is built from the canonical seed, quantized to minimum -integer dtypes with the strict ternary check (--ternary --strict), and -then verified with eval.py via the BatchedFitnessEvaluator; the summary -records (tensor count, params, file size, fitness, total_tests, seconds). -""" - -from __future__ import annotations -import os -import shutil -import subprocess -import sys -import time -from pathlib import Path - -import torch -from safetensors import safe_open - -ROOT = Path(__file__).resolve().parent.parent # repo root (this file lives in tools/) -SEED = ROOT / "neural_computer.safetensors" -OUT_DIR = ROOT / "variants" -OUT_DIR.mkdir(exist_ok=True) - -PROFILES = ["none", "registers", "scratchpad", "small", "reduced", "full"] -BITS = [8, 16, 32] - - -def variant_filename(bits: int, profile: str) -> str: - if profile == "none": - return f"neural_alu{bits}.safetensors" - if profile == "full": - return f"neural_computer{bits}.safetensors" - return f"neural_computer{bits}_{profile}.safetensors" - - -def run(cmd: list[str], timeout: int = 600) -> tuple[int, str]: - p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) - return p.returncode, (p.stdout or "") + (p.stderr or "") - - -def build_variant(bits: int, profile: str) -> Path: - out = OUT_DIR / variant_filename(bits, profile) - shutil.copy2(SEED, out) - cmd = [ - sys.executable, str(ROOT / "src" / "build.py"), - "--bits", str(bits), - "-m", profile, - "--apply", - "--model", str(out), - "all", - ] - rc, log = run(cmd, timeout=2400) - if rc != 0: - raise RuntimeError(f"build failed for bits={bits} profile={profile}:\n{log[-1500:]}") - return out - - -def quantize_variant(path: Path) -> tuple[int, int]: - """Run quantize.py --ternary --strict on a built variant. This is the - last pipeline step: it casts every tensor to its minimum signed integer - dtype, verifies the strictly ternary weight invariant, and stamps the - weight_quantization metadata field. Returns (bytes_before, bytes_after). - """ - rc, log = run([sys.executable, str(ROOT / "src" / "quantize.py"), str(path), - "--ternary", "--strict"], timeout=300) - if rc != 0: - raise RuntimeError(f"quantize failed for {path.name}:\n{log[-800:]}") - # parse the "file X.X MB -> Y.Y MB" line - for line in log.splitlines(): - if "file" in line and "->" in line and path.name in line: - try: - parts = line.split("file")[1].split("->") - before = float(parts[0].strip().split()[0]) * 1e6 - after = float(parts[1].strip().split()[0]) * 1e6 - return int(before), int(after) - except Exception: - pass - return 0, 0 - - -def measure_variant(path: Path) -> dict: - """Read tensor count, params, manifest values from the variant.""" - with safe_open(str(path), framework="pt") as f: - keys = list(f.keys()) - params = sum(f.get_tensor(k).numel() for k in keys) - manifest = { - k.split(".", 1)[1]: f.get_tensor(k).item() - for k in keys if k.startswith("manifest.") and f.get_tensor(k).numel() == 1 - } - return { - "tensors": len(keys), - "params": params, - "size_mb": path.stat().st_size / (1024 * 1024), - "manifest": manifest, - } - - -def eval_variant(path: Path, device: str = "cpu", timeout: int = 600) -> dict: - """Run eval.py against a variant and parse fitness.""" - cmd = [ - sys.executable, str(ROOT / "src" / "eval.py"), - "--model", str(path), - "--device", device, - "--quiet", - ] - t0 = time.time() - rc, log = run(cmd, timeout=timeout) - dt = time.time() - t0 - - fitness = None - total_tests = None - status = "ERROR" - for line in log.splitlines(): - line = line.strip() - if line.startswith("Fitness:"): - try: - fitness = float(line.split()[1]) - except Exception: - pass - elif line.startswith("Total tests:"): - try: - total_tests = int(line.split()[2]) - except Exception: - pass - elif line.startswith("STATUS:"): - status = line.split()[1] - return { - "rc": rc, - "fitness": fitness, - "total_tests": total_tests, - "status": status, - "elapsed_s": dt, - "log_tail": "\n".join(log.splitlines()[-15:]), - } - - -def main() -> None: - rows = [] - print(f"Building 18 variants into {OUT_DIR}\n") - for bits in BITS: - for profile in PROFILES: - label = f"bits={bits} profile={profile}" - print(f"=== {label} ===", flush=True) - t0 = time.time() - try: - path = build_variant(bits, profile) - bt = time.time() - t0 - pre_q_meta = measure_variant(path) - # Quantize in-place as the final step; weights are - # integer-valued so this is exact, --strict fails the build - # if any weight is non-ternary, and header metadata - # (signal_registry) is carried through. - qb, qa = quantize_variant(path) - meta = measure_variant(path) - ev = eval_variant(path, device="cpu", timeout=900) - rows.append({ - "bits": bits, "profile": profile, - "filename": path.name, - "build_s": bt, - **meta, - **{k: ev[k] for k in ("fitness", "total_tests", "status", "elapsed_s")}, - "log_tail": ev["log_tail"] if ev["status"] != "PASS" else "", - }) - q_ratio = qb / qa if qa else 1.0 - print(f" built in {bt:.1f}s size={pre_q_meta['size_mb']:.1f}MB -> " - f"{meta['size_mb']:.1f}MB after quant ({q_ratio:.2f}x)" - f" params={meta['params']:,} tensors={meta['tensors']:,}") - print(f" eval: fitness={ev['fitness']} tests={ev['total_tests']}" - f" status={ev['status']} ({ev['elapsed_s']:.1f}s)") - if ev["status"] != "PASS": - print(" --- failure tail ---") - print(" " + "\n ".join(ev["log_tail"].splitlines())) - print(" --------------------") - except Exception as e: - print(f" EXCEPTION: {e}") - rows.append({"bits": bits, "profile": profile, "error": str(e)}) - print() - - print("=" * 88) - print(" SUMMARY") - print("=" * 88) - header = f"{'bits':>4} {'profile':<11} {'size_MB':>8} {'tensors':>8} {'params':>11} {'fitness':>9} {'tests':>6} {'status':>7}" - print(header) - print("-" * len(header)) - for r in rows: - if "error" in r: - print(f"{r['bits']:>4} {r['profile']:<11} ERROR: {r['error'][:60]}") - continue - fit = f"{r['fitness']:.4f}" if r['fitness'] is not None else "n/a" - tests = r['total_tests'] if r['total_tests'] is not None else "?" - print(f"{r['bits']:>4} {r['profile']:<11} {r['size_mb']:>8.1f} " - f"{r['tensors']:>8,} {r['params']:>11,} " - f"{fit:>9} {tests:>6} {r['status']:>7}") - - fail = [r for r in rows if r.get("status") != "PASS" or "error" in r] - print() - if fail: - print(f"FAILURES: {len(fail)}/{len(rows)}") - else: - print(f"ALL {len(rows)} VARIANTS PASS") - - -if __name__ == "__main__": - main() diff --git a/tools/build_attractor.py b/tools/build_attractor.py deleted file mode 100644 index d5f7b333bad3007b6531a3f1e9fc29de442340ed..0000000000000000000000000000000000000000 --- a/tools/build_attractor.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Compile a circuit into the attractor computer's coupling matrix and ship it -as variants/neural_attractor.safetensors, a peer artifact to the other machines: -here the weights are the couplings Q (with linear terms L), and running is -relaxation. Round-trips the file and checks forward evaluation and a backward -inversion (factoring).""" -from __future__ import annotations -import os -import random -import sys - -import torch -from safetensors.torch import save_file, load_file -from safetensors import safe_open - -ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -sys.path.insert(0, os.path.join(ROOT, "src")) -from attractor import multiplier, to_tensors, from_tensors - -OUT = os.path.join(ROOT, "variants", "neural_attractor.safetensors") -BITS = 8 - - -def main() -> int: - c, io = multiplier(BITS) - tensors, meta = to_tensors(c, io) - save_file(tensors, OUT, metadata=meta) - size = os.path.getsize(OUT) - print(f"Built {os.path.relpath(OUT, ROOT)}: {BITS}x{BITS} multiplier as couplings") - print(f" wires={c.n} Q entries={len(c.Q)} L entries={len(c.L)} size={size/1024:.1f} KB") - - # round-trip - t = load_file(OUT) - with safe_open(OUT, framework="pt") as f: - m = f.metadata() - c2, io2 = from_tensors(t, m) - xs, ys, zero, prod = io2["xs"], io2["ys"], io2["zero"], io2["prod"] - - rng = random.Random(0) - bad = 0 - for _ in range(200): - a, b = rng.randint(0, 255), rng.randint(0, 255) - clamp = {zero: 0} - for k in range(BITS): - clamp[xs[k]] = (a >> k) & 1 - clamp[ys[k]] = (b >> k) & 1 - s = c2.forward_eval(clamp) - got = sum(s[w] << k for k, w in enumerate(prod)) - if got != a * b or c2.energy(s) != 0: - bad += 1 - print(f" round-trip forward multiply (200 cases): {'OK' if bad == 0 else f'FAIL({bad})'}") - - # backward: factor a small product through the loaded couplings - N = 35 - target = {prod[k]: (N >> k) & 1 for k in range(2 * BITS)} - s = c2.solve(xs + ys, {zero: 0}, target, sweeps=2500, restarts=120, seed=N) - if s is not None: - fa = sum(s[xs[k]] << k for k in range(BITS)) - fb = sum(s[ys[k]] << k for k in range(BITS)) - print(f" round-trip backward factor {N}: {fa} x {fb} {'OK' if fa * fb == N else 'WRONG'}") - ok = fa * fb == N - else: - print(f" round-trip backward factor {N}: not found") - ok = False - return 0 if (bad == 0 and ok) else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tools/build_ca.py b/tools/build_ca.py deleted file mode 100644 index 0ca33249a8b6f3e5cce51c5dcaaf11d9ddf4ca1b..0000000000000000000000000000000000000000 --- a/tools/build_ca.py +++ /dev/null @@ -1,123 +0,0 @@ -"""Ship the reversible CA's block rule as a ternary matrix tile, -variants/neural_ca.safetensors. The 2x2 block rule compiles to a stack of -ternary matrices with a Heaviside step; the rule is a bijection, so the tile is -a permutation matrix product with a 0.5 analog margin, and the same tile applied -to every block of a lattice is one lattice step.""" -from __future__ import annotations -import os -import sys - -import torch -from safetensors.torch import save_file, load_file -from safetensors import safe_open - -ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -sys.path.insert(0, os.path.join(ROOT, "src")) -import ca -from matrix8 import Net, compile_net, MatrixMachine - -OUT = os.path.join(ROOT, "variants", "neural_ca.safetensors") - - -def block_net(): - net = Net() - ntl, ntr = net.NOT("ntl", "tl"), net.NOT("ntr", "tr") - nbl, nbr = net.NOT("nbl", "bl"), net.NOT("nbr", "br") - d1 = net.AND("d1", ["tl", ntr, nbl, "br"]) - d2 = net.AND("d2", [ntl, "tr", "bl", nbr]) - dg = net.OR("dg", [d1, d2]) # is_diag - TL = net.XOR("oTL", "br", dg) - TR = net.XOR("oTR", "bl", dg) - BL = net.XOR("oBL", "tr", dg) - BR = net.XOR("oBR", "tl", dg) - return net, ["tl", "tr", "bl", "br"], [TL, TR, BL, BR] - - -def matrix_step(mm, grid, phase): - """One Margolus update driven entirely by the matrix tile.""" - H, W = len(grid), len(grid[0]) - out = [row[:] for row in grid] - for r0 in range(phase, phase + H, 2): - for c0 in range(phase, phase + W, 2): - r, r1, c, c1 = r0 % H, (r0 + 1) % H, c0 % W, (c0 + 1) % W - v = torch.tensor([[float(grid[r][c]), float(grid[r][c1]), - float(grid[r1][c]), float(grid[r1][c1])]]) - o = mm.step(v)[0] - out[r][c], out[r][c1], out[r1][c], out[r1][c1] = (int(o[0]), int(o[1]), - int(o[2]), int(o[3])) - return out - - -def main() -> int: - net, inp, outp = block_net() - layers, info = compile_net(net, inp, outp) - mm = MatrixMachine(layers) - - seen, bad, vecs = set(), 0, [] - for s in range(16): - b = tuple((s >> k) & 1 for k in (3, 2, 1, 0)) # tl,tr,bl,br - v = torch.tensor([[float(x) for x in b]]) - got = tuple(int(x) for x in mm.step(v)[0]) - if got != ca.rule(b): - bad += 1 - seen.add(got) - vecs.append(v[0]) - perm = len(seen) == 16 - margin = mm.min_margin(torch.stack(vecs)) - - tensors = {} - for k, (W, B) in enumerate(layers): - tensors[f"matrix.layer{k:03d}.weight"] = W.to(torch.int8) - tensors[f"matrix.layer{k:03d}.bias"] = B.to(torch.int8) - meta = {"machine": "ca", - "rule": "Margolus reversible: rotate 180 except diagonal pair swaps (BBM class)", - "inputs": "tl,tr,bl,br", "outputs": "TL,TR,BL,BR", "layers": str(info["layers"])} - save_file(tensors, OUT, metadata=meta) - print(f"Built {os.path.relpath(OUT, ROOT)}: reversible CA block rule as a ternary matrix tile") - print(f" layers={info['layers']} gates={info['gates']} size={os.path.getsize(OUT)} bytes") - print(f" every weight ternary: " - f"{'OK' if all(((W == -1) | (W == 0) | (W == 1)).all() for W, _ in layers) else 'FAIL'}") - print(f" tile matches the block rule over all 16 states: {'OK' if bad == 0 else f'FAIL({bad})'}") - print(f" tile is a permutation (16 distinct outputs): {'OK' if perm else 'FAIL'}") - print(f" analog noise margin: {margin:.3f} (guarantee 0.5)") - - # analog robustness: the tile must reproduce the rule under read noise and - # static conductance mismatch, as neural_matrix8 measures - states = torch.stack(vecs) - ref = [ca.rule(tuple((s >> k) & 1 for k in (3, 2, 1, 0))) for s in range(16)] - - def outs(machine, **kw): - v = machine.step(states, **kw) - return [tuple(int(v[i][j]) for j in range(4)) for i in range(v.shape[0])] - - print(" read-noise sweep (exact = tile matches the rule on all 16 states):") - for sigma in (0.05, 0.10, 0.15, 0.20): - okn = all(outs(mm, analog=True, noise_sigma=sigma, - gen=torch.Generator().manual_seed(s)) == ref for s in range(4)) - print(f" sigma={sigma:.2f}: {'exact' if okn else 'errors appear'}") - print(" conductance-mismatch sweep:") - for sg in (0.05, 0.10, 0.15): - okg = outs(mm.perturbed(sg, seed=0), analog=True) == ref - print(f" sigma_G={sg:.2f}: {'exact' if okg else 'errors appear'}") - - # the loaded tile, applied to every block, is one whole-lattice CA step - t = load_file(OUT) - n = 0 - lyr = [] - while f"matrix.layer{n:03d}.weight" in t: - lyr.append((t[f"matrix.layer{n:03d}.weight"], t[f"matrix.layer{n:03d}.bias"])) - n += 1 - mm2 = MatrixMachine(lyr) - g = ca._rand_grid(8, 8, 3) - gmat = matrix_step(mm2, g, 0) - gref = ca.step(g, 0) - print(f" loaded tile drives a full lattice step (matches ca.step): " - f"{'OK' if gmat == gref else 'FAIL'}") - - ok = bad == 0 and perm and abs(margin - 0.5) < 1e-6 and gmat == gref - print("PASS" if ok else "FAIL") - return 0 if ok else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tools/build_reversible.py b/tools/build_reversible.py deleted file mode 100644 index 9b0821456025532db5e53f0ad0a6cfa7a15c1d4b..0000000000000000000000000000000000000000 --- a/tools/build_reversible.py +++ /dev/null @@ -1,101 +0,0 @@ -"""Ship the reversible machine's arithmetic core as a threshold-gate artifact, -variants/neural_reversible.safetensors. The circuit is an in-place 8-bit adder -(b <- a+b) expressed as a sequence of reversible gates (CNOT, Toffoli); each gate -is realized by the Heaviside AND/XOR weights stored alongside, so the file holds -both the reversible program (the gate list) and the threshold substrate it runs -on. Round-trips the file and confirms the loaded circuit is a bijection that -computes b <- a+b with the addend and carry restored.""" -from __future__ import annotations -import os -import random -import sys - -import torch -from safetensors.torch import save_file, load_file -from safetensors import safe_open - -ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -sys.path.insert(0, os.path.join(ROOT, "src")) -import reversible as rv - -OUT = os.path.join(ROOT, "variants", "neural_reversible.safetensors") -WIDTH = 8 - -# The two reversible primitives, keyed to their gate functions. -_CODE = {rv.CNOT: 0, rv.TOFF: 1} -_FN = {0: rv.CNOT, 1: rv.TOFF} - -# Heaviside threshold gates that implement the target updates: a CNOT target is -# XOR(t,c); a Toffoli target is XOR(t, AND(a,b)). AND/OR/NAND are single gates. -_SUBSTRATE = { - "and_w": torch.tensor([1, 1]), "and_b": torch.tensor(-2), - "or_w": torch.tensor([1, 1]), "or_b": torch.tensor(-1), - "nand_w": torch.tensor([-1, -1]), "nand_b": torch.tensor(1), -} - - -def encode(ops): - codes, args = [], [] - for gate, *a in ops: - codes.append(_CODE[gate]) - args.append((a + [-1, -1, -1])[:3]) - return torch.tensor(codes, dtype=torch.long), torch.tensor(args, dtype=torch.long) - - -def main() -> int: - a_bits = list(range(WIDTH)) - b_bits = list(range(WIDTH, 2 * WIDTH)) - carry = 2 * WIDTH - n = 2 * WIDTH + 1 - ops = rv._adder_ops(a_bits, b_bits, carry) - codes, args = encode(ops) - - tensors = {"gate_code": codes, "gate_args": args, **_SUBSTRATE} - import json - meta = {"machine": "reversible", "width": str(WIDTH), "n_wires": str(n), - "a_bits": json.dumps(a_bits), "b_bits": json.dumps(b_bits), - "carry": str(carry), "circuit": "in-place reversible adder b<-a+b"} - save_file(tensors, OUT, metadata=meta) - print(f"Built {os.path.relpath(OUT, ROOT)}: reversible {WIDTH}-bit adder") - print(f" gates={len(ops)} wires={n} size={os.path.getsize(OUT)} bytes") - - # round-trip: reconstruct the op list from the file and run it - t = load_file(OUT) - with safe_open(OUT, framework="pt") as f: - m = f.metadata() - W = int(m["width"]); ab = json.loads(m["a_bits"]); bb = json.loads(m["b_bits"]) - cy = int(m["carry"]); nn = int(m["n_wires"]) - loaded = [] - for code, a in zip(t["gate_code"].tolist(), t["gate_args"].tolist()): - loaded.append((_FN[code], *[x for x in a if x >= 0])) - - def run(reg): - for gate, *a in loaded: - gate(reg, *a) - - mask = (1 << W) - 1 - bad = 0 - rng = random.Random(0) - for _ in range(400): - a = rng.randint(0, mask); b = rng.randint(0, mask) - reg = [0] * nn - for k in range(W): - reg[ab[k]] = (a >> k) & 1 - reg[bb[k]] = (b >> k) & 1 - run(reg) - got_b = sum(reg[bb[k]] << k for k in range(W)) - got_a = sum(reg[ab[k]] << k for k in range(W)) - if got_b != ((a + b) & mask) or got_a != a or reg[cy] != 0: - bad += 1 - print(f" round-trip b<-a+b, addend & carry restored (400 cases): " - f"{'OK' if bad == 0 else f'FAIL({bad})'}") - # bijection is exhaustive-checkable at width 4 on the same construction - a4 = list(range(4)); b4 = list(range(4, 8)); c4 = 8 - ops4 = rv._adder_ops(a4, b4, c4) - perm = rv.is_permutation(lambda r: rv._apply(r, ops4), 9) - print(f" loaded circuit is a bijection (4-bit): {'OK' if perm else 'FAIL'}") - return 0 if (bad == 0 and perm) else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tools/build_tile.py b/tools/build_tile.py deleted file mode 100644 index c2100d38c29491a89a831c9487b6e1a03320d88b..0000000000000000000000000000000000000000 --- a/tools/build_tile.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Ship the self-assembling tile computer as variants/neural_tile.safetensors: a -tile set (the binary counter) stored as its glue tables, together with the -binding gate that governs growth. A tile binds at a site when the summed -strength of its matching glues meets tau, which is the Heaviside gate -H(strength . match - tau) with per-tile weights = glue strengths and bias = -tau. -Round-trips the file, regrows the counter, and confirms row y encodes y.""" -from __future__ import annotations -import json -import os -import sys - -import torch -from safetensors.torch import save_file, load_file -from safetensors import safe_open - -ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -sys.path.insert(0, os.path.join(ROOT, "src")) -import tile as T - -OUT = os.path.join(ROOT, "variants", "neural_tile.safetensors") -NBITS = 8 -TAU = 2 - - -def main() -> int: - ts = T.counter_tileset() - strength = {"edge": 2} - glues = sorted({g for t in ts for g in (t.N, t.E, t.S, t.W) if g}) - gid = {g: i for i, g in enumerate(glues)} - tile_glues = torch.tensor([[gid.get(t.N, -1), gid.get(t.E, -1), - gid.get(t.S, -1), gid.get(t.W, -1)] for t in ts], - dtype=torch.long) - glue_strength = torch.tensor([strength.get(g, 1) for g in glues], dtype=torch.long) - # per-tile binding-gate weights = strengths of the tile's four glues (0 = null) - bind_w = torch.tensor([[strength.get(g, 1) if g else 0 for g in (t.N, t.E, t.S, t.W)] - for t in ts], dtype=torch.long) - tensors = {"tile_glues": tile_glues, "glue_strength": glue_strength, - "binding_weight": bind_w, "binding_bias": torch.tensor(-TAU)} - meta = {"machine": "tile", "tau": str(TAU), "glues": json.dumps(glues), - "tile_names": json.dumps([t.name for t in ts]), "program": "binary counter"} - save_file(tensors, OUT, metadata=meta) - print(f"Built {os.path.relpath(OUT, ROOT)}: binary-counter tile set") - print(f" tiles={len(ts)} glues={len(glues)} tau={TAU} size={os.path.getsize(OUT)} bytes") - - # round-trip: reconstruct the tiles from the file and regrow the counter - t = load_file(OUT) - with safe_open(OUT, framework="pt") as f: - m = f.metadata() - gl = json.loads(m["glues"]) - strg = {gl[i]: int(s) for i, s in enumerate(t["glue_strength"].tolist())} - tiles = [] - for row, name in zip(t["tile_glues"].tolist(), json.loads(m["tile_names"])): - sides = [gl[i] if i >= 0 else "" for i in row] - tiles.append(T.Tile(N=sides[0], E=sides[1], S=sides[2], W=sides[3], name=name)) - - rows = (1 << NBITS) - 1 - A, det = T.grow(tiles, T.counter_seed(NBITS), int(m["tau"]), strg, - (0, 0, NBITS, rows)) - bad = filled = 0 - for y in range(1, rows + 1): - cells = [A.get((x, y)) for x in range(NBITS)] - if any(c is None for c in cells): - continue - filled += 1 - v = sum((1 if c.N == "b1" else 0) << (NBITS - 1 - x) for x, c in enumerate(cells)) - if v != (y & ((1 << NBITS) - 1)): - bad += 1 - print(f" round-trip regrow {NBITS}-bit counter: directed={det} rows={filled} " - f"row y == y {'OK' if bad == 0 else f'FAIL({bad})'}") - - # the stored binding gate reproduces the model's binding decision - gate_ok = True - Atest = {(1, 0): T.Tile(N="b0"), (2, 0): T.Tile(N="b0")} - for ti, tt in enumerate(tiles): - for site in [(1, 1), (2, 1)]: - w = t["binding_weight"][ti].tolist() - match = [1 if tt.glue(side) and Atest.get((site[0] + dx, site[1] + dy)) - and tt.glue(side) == Atest[(site[0] + dx, site[1] + dy)].glue(opp) else 0 - for dx, dy, side, opp in T._SIDES] - gate = 1 if sum(wi * mi for wi, mi in zip(w, match)) + int(t["binding_bias"]) >= 0 else 0 - if gate != int(T.binds(Atest, site[0], site[1], tt, TAU, strg)): - gate_ok = False - print(f" stored binding gate H(weight.match - tau) matches growth rule: " - f"{'OK' if gate_ok else 'FAIL'}") - return 0 if (bad == 0 and det and gate_ok) else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tools/cpu_programs.py b/tools/cpu_programs.py deleted file mode 100644 index ece61257106e1be471a1cabf26b209953ce1149e..0000000000000000000000000000000000000000 --- a/tools/cpu_programs.py +++ /dev/null @@ -1,703 +0,0 @@ -""" -CPU validation programs for the threshold computer. - -A small assembler and a suite of programs that exercise the ISA end-to-end: -arithmetic, control flow, memory access, self-modifying code, all eight -conditional jumps, the call mechanism, and a sort. - -Each program returns (mem, expected, max_cycles, description) where: - mem : list[int] -- complete memory image - expected : dict[int, int] -- {address: expected_value} verified at HALT - max_cycles : int -- cycle budget (an infinite loop will hit this) - description: str -- short human-readable summary - -Programs target the 1 KB profile (addr_bits=10) by default but use only the -low 256 bytes so they also run on scratchpad and larger profiles. - -All programs assume the CPU starts with PC=0, SP defaulting to addr_mask -(highest address; CALL pre-decrements before writing). -""" - -from __future__ import annotations -from typing import Dict, List, Tuple - - -# ---------------------------------------------------------------------------- -# Mini assembler -# ---------------------------------------------------------------------------- - -_OPCODE_NAMES = { - "add": 0x0, "sub": 0x1, "and": 0x2, "or": 0x3, "xor": 0x4, - "shl": 0x5, "shr": 0x6, "mul": 0x7, "div": 0x8, "cmp": 0x9, - "load": 0xA, "store": 0xB, "jmp": 0xC, "jcc": 0xD, - "call": 0xE, "halt": 0xF, -} - -_COND = {"jz": 0, "jnz": 1, "jc": 2, "jnc": 3, - "jn": 4, "jp": 5, "jv": 6, "jnv": 7} - - -def _enc(opcode: int, rd: int = 0, rs: int = 0, imm: int = 0) -> int: - return ((opcode & 0xF) << 12) | ((rd & 0x3) << 10) | ((rs & 0x3) << 8) | (imm & 0xFF) - - -class Asm: - """Tiny assembler for the threshold-computer ISA. - - Usage: - a = Asm(size=256) - a.org(0) - a.label("start") - a.load(0, "data") - a.halt() - a.org(0x80); a.label("data"); a.db(42) - mem = a.assemble() - """ - - def __init__(self, size: int): - self.mem: List[int] = [0] * size - self.pc: int = 0 - self.labels: Dict[str, int] = {} - self.fixups: List[Tuple[int, str]] = [] - - def org(self, addr: int) -> None: - self.pc = addr - - def label(self, name: str) -> None: - if name in self.labels: - raise ValueError(f"duplicate label: {name}") - self.labels[name] = self.pc - - def db(self, *values: int) -> None: - for v in values: - self.mem[self.pc] = v & 0xFF - self.pc += 1 - - def dw(self, value: int) -> None: - self.mem[self.pc] = (value >> 8) & 0xFF - self.mem[self.pc + 1] = value & 0xFF - self.pc += 2 - - def daddr(self, label: str) -> None: - self.fixups.append((self.pc, label)) - self.dw(0) - - # --- ALU ops (no immediate) --- - def _alu(self, op: int, rd: int, rs: int) -> None: - self.dw(_enc(op, rd, rs)) - - def add(self, rd: int, rs: int) -> None: self._alu(0x0, rd, rs) - def sub(self, rd: int, rs: int) -> None: self._alu(0x1, rd, rs) - def and_(self, rd: int, rs: int) -> None: self._alu(0x2, rd, rs) - def or_(self, rd: int, rs: int) -> None: self._alu(0x3, rd, rs) - def xor(self, rd: int, rs: int) -> None: self._alu(0x4, rd, rs) - def shl(self, rd: int) -> None: self._alu(0x5, rd, 0) - def shr(self, rd: int) -> None: self._alu(0x6, rd, 0) - def mul(self, rd: int, rs: int) -> None: self._alu(0x7, rd, rs) - def cmp(self, rd: int, rs: int) -> None: self._alu(0x9, rd, rs) - - # --- Memory + control (address-extended) --- - def load(self, rd: int, label: str) -> None: - self.dw(_enc(0xA, rd, 0)); self.daddr(label) - - def store(self, rs: int, label: str) -> None: - self.dw(_enc(0xB, 0, rs)); self.daddr(label) - - def jmp(self, label: str) -> None: - self.dw(_enc(0xC)); self.daddr(label) - - def jcc(self, cond: str, label: str) -> None: - self.dw(_enc(0xD, 0, 0, _COND[cond])); self.daddr(label) - - def jz(self, label: str) -> None: self.jcc("jz", label) - def jnz(self, label: str) -> None: self.jcc("jnz", label) - def jc(self, label: str) -> None: self.jcc("jc", label) - def jnc(self, label: str) -> None: self.jcc("jnc", label) - def jn(self, label: str) -> None: self.jcc("jn", label) - def jp(self, label: str) -> None: self.jcc("jp", label) - def jv(self, label: str) -> None: self.jcc("jv", label) - def jnv(self, label: str) -> None: self.jcc("jnv", label) - - def call(self, label: str) -> None: - self.dw(_enc(0xE)); self.daddr(label) - - def halt(self) -> None: - self.dw(_enc(0xF)) - - def assemble(self) -> List[int]: - for offset, label in self.fixups: - if label not in self.labels: - raise ValueError(f"undefined label: {label}") - target = self.labels[label] - self.mem[offset] = (target >> 8) & 0xFF - self.mem[offset + 1] = target & 0xFF - return self.mem - - -# ---------------------------------------------------------------------------- -# Programs -# ---------------------------------------------------------------------------- - -ProgramResult = Tuple[List[int], Dict[int, int], int, str] - - -def fib(n: int = 11, mem_size: int = 256) -> ProgramResult: - """Iterative Fibonacci F(N), 8-bit wrap. - - F(11) = 89. F(13) = 233 still fits in 8 bits; F(14) = 377 overflows. - - Algorithm: maintain (a, b) = (F(k), F(k+1)); after N steps a = F(N). - Per iteration: temp=b, b=a+b, a=temp; n--. - """ - a = Asm(mem_size) - - a.org(0) - a.load(2, "n_addr") # R2 = n - a.load(0, "zero_addr") # R0 = 0 = F(0) - a.load(1, "one_addr") # R1 = 1 = F(1) - a.label("loop") - a.load(3, "zero_addr") # R3 = 0 - a.cmp(2, 3) # n == 0? - a.jz("done") - a.load(3, "zero_addr") # R3 = 0 - a.add(3, 1) # R3 = b (saved old b) - a.add(1, 0) # R1 = a + b (new b) - a.load(0, "zero_addr") # R0 = 0 - a.add(0, 3) # R0 = old b (new a) - a.load(3, "one_addr") # R3 = 1 - a.sub(2, 3) # n-- - a.jmp("loop") - a.label("done") - a.store(0, "out") # OUT = a - a.halt() - - a.org(0x80) - a.label("zero_addr"); a.db(0) - a.label("one_addr"); a.db(1) - a.label("n_addr"); a.db(n) - a.label("out"); a.db(0) - - mem = a.assemble() - expected_a = 0 - aa, bb = 0, 1 - for _ in range(n): - aa, bb = bb, (aa + bb) & 0xFF - expected_a = aa - return mem, {a.labels["out"]: expected_a}, 16 * (n + 2), f"Fibonacci F({n}) = {expected_a}" - - -def sum_n(n: int = 10, mem_size: int = 256) -> ProgramResult: - """Compute 1 + 2 + ... + N using the Z flag from SUB to terminate. - - No explicit zero register required; SUB sets Z when its result is zero. - R0 = accumulator, R1 = counter (n down to 1), R2 = 1. - """ - a = Asm(mem_size) - - a.org(0) - a.load(0, "zero_addr") # acc = 0 - a.load(1, "n_addr") # i = n - a.load(2, "one_addr") # 1 - a.label("loop") - a.add(0, 1) # acc += i - a.sub(1, 2) # i-- (Z set when i becomes 0) - a.jnz("loop") - a.store(0, "out") - a.halt() - - a.org(0x80) - a.label("zero_addr"); a.db(0) - a.label("one_addr"); a.db(1) - a.label("n_addr"); a.db(n) - a.label("out"); a.db(0) - - mem = a.assemble() - expected = (n * (n + 1) // 2) & 0xFF - return mem, {a.labels["out"]: expected}, 4 + 4 * n, f"sum 1..{n} = {expected}" - - -def self_mod_jmp(mem_size: int = 256) -> ProgramResult: - """Self-modifying code: writes the JMP target's low byte at runtime. - - Initial JMP target is path_a (writes 0xAA to OUT). The code first - overwrites the JMP's address-word LSB so it points to path_b - (writes 0xBB). Successful execution lands at path_b, so OUT = 0xBB. - """ - a = Asm(mem_size) - - a.org(0) - a.label("start") - - # Forward-declare the_jmp's LSB address. The first two instructions are - # each 4 bytes; the_jmp follows them, so the_jmp = pc + 8 from start. - # The JMP's address word is at the_jmp + 2; the LSB byte is at the_jmp + 3. - a.labels["jmp_target_lsb"] = a.pc + 8 + 3 - - a.load(0, "new_lsb") # R0 = LSB of path_b address (4 bytes) - a.store(0, "jmp_target_lsb") # patch the JMP's LSB (4 bytes) - a.label("the_jmp") - a.jmp("path_a") # initially -> path_a; after patch -> path_b - a.label("path_a") - a.load(1, "val_a"); a.store(1, "out"); a.halt() - a.label("path_b") - a.load(1, "val_b"); a.store(1, "out"); a.halt() - - a.org(0x80) - a.label("val_a"); a.db(0xAA) - a.label("val_b"); a.db(0xBB) - # new_lsb_word stores path_b's full 16-bit address; new_lsb labels the LSB byte. - a.label("new_lsb_word"); a.daddr("path_b") - a.labels["new_lsb"] = a.labels["new_lsb_word"] + 1 - a.label("out"); a.db(0) - - mem = a.assemble() - return mem, {a.labels["out"]: 0xBB}, 30, "self-modifying JMP target" - - -def all_branches(mem_size: int = 256) -> ProgramResult: - """Drive all eight conditional jumps; each path writes a unique marker. - - Test plan (each step sets flags, then a Jcc; the branch takes when - expected and the corresponding marker is written): - - JZ: CMP equal -> Z=1 -> taken -> M[OUT0] = 0xA0 - JNZ: CMP unequal -> Z=0 -> taken -> M[OUT1] = 0xA1 - JC: ADD overflow (255+1) -> C=1 -> taken -> M[OUT2] = 0xA2 - JNC: ADD no overflow -> C=0 -> taken -> M[OUT3] = 0xA3 - JN: SUB result 0xFF (n=1) -> N=1 -> taken -> M[OUT4] = 0xA4 - JP: ADD result 1 -> N=0 -> taken -> M[OUT5] = 0xA5 - JV: ADD signed overflow (127+1=128) -> V=1 -> taken -> M[OUT6] = 0xA6 - JNV: ADD no signed overflow (1+1=2) -> V=0 -> taken -> M[OUT7] = 0xA7 - - A failure on any branch causes the wrong (or no) marker to be written. - """ - a = Asm(mem_size) - - a.org(0) - # ----- JZ: equal compare -> Z=1 ----- - a.load(0, "v5"); a.load(1, "v5"); a.cmp(0, 1); a.jz("ok_jz"); a.jmp("fail") - a.label("ok_jz"); a.load(2, "m_a0"); a.store(2, "out0") - - # ----- JNZ: unequal compare -> Z=0 ----- - a.load(0, "v5"); a.load(1, "v3"); a.cmp(0, 1); a.jnz("ok_jnz"); a.jmp("fail") - a.label("ok_jnz"); a.load(2, "m_a1"); a.store(2, "out1") - - # ----- JC: 255+1 = 0 with carry ----- - a.load(0, "v255"); a.load(1, "v1"); a.add(0, 1); a.jc("ok_jc"); a.jmp("fail") - a.label("ok_jc"); a.load(2, "m_a2"); a.store(2, "out2") - - # ----- JNC: 1+1 = 2, no carry ----- - a.load(0, "v1"); a.load(1, "v1"); a.add(0, 1); a.jnc("ok_jnc"); a.jmp("fail") - a.label("ok_jnc"); a.load(2, "m_a3"); a.store(2, "out3") - - # ----- JN: 0 - 1 = 0xFF, MSB set ----- - a.load(0, "v0"); a.load(1, "v1"); a.sub(0, 1); a.jn("ok_jn"); a.jmp("fail") - a.label("ok_jn"); a.load(2, "m_a4"); a.store(2, "out4") - - # ----- JP: 0 + 1 = 1, MSB clear ----- - a.load(0, "v0"); a.load(1, "v1"); a.add(0, 1); a.jp("ok_jp"); a.jmp("fail") - a.label("ok_jp"); a.load(2, "m_a5"); a.store(2, "out5") - - # ----- JV: 127 + 1 = 128, signed overflow ----- - a.load(0, "v127"); a.load(1, "v1"); a.add(0, 1); a.jv("ok_jv"); a.jmp("fail") - a.label("ok_jv"); a.load(2, "m_a6"); a.store(2, "out6") - - # ----- JNV: 1 + 1 = 2, no signed overflow ----- - a.load(0, "v1"); a.load(1, "v1"); a.add(0, 1); a.jnv("ok_jnv"); a.jmp("fail") - a.label("ok_jnv"); a.load(2, "m_a7"); a.store(2, "out7") - - a.jmp("end") - a.label("fail") - a.load(2, "v_fail"); a.store(2, "fail_addr"); a.halt() - a.label("end") - a.halt() - - # Code runs to ~0xDF; data starts safely after that. - a.org(0xE0) - a.label("v0"); a.db(0) - a.label("v1"); a.db(1) - a.label("v3"); a.db(3) - a.label("v5"); a.db(5) - a.label("v127"); a.db(127) - a.label("v255"); a.db(255) - a.label("m_a0"); a.db(0xA0) - a.label("m_a1"); a.db(0xA1) - a.label("m_a2"); a.db(0xA2) - a.label("m_a3"); a.db(0xA3) - a.label("m_a4"); a.db(0xA4) - a.label("m_a5"); a.db(0xA5) - a.label("m_a6"); a.db(0xA6) - a.label("m_a7"); a.db(0xA7) - a.label("v_fail"); a.db(0xEE) - a.label("out0"); a.db(0) - a.label("out1"); a.db(0) - a.label("out2"); a.db(0) - a.label("out3"); a.db(0) - a.label("out4"); a.db(0) - a.label("out5"); a.db(0) - a.label("out6"); a.db(0) - a.label("out7"); a.db(0) - a.label("fail_addr"); a.db(0) - - mem = a.assemble() - expected = { - a.labels["out0"]: 0xA0, - a.labels["out1"]: 0xA1, - a.labels["out2"]: 0xA2, - a.labels["out3"]: 0xA3, - a.labels["out4"]: 0xA4, - a.labels["out5"]: 0xA5, - a.labels["out6"]: 0xA6, - a.labels["out7"]: 0xA7, - a.labels["fail_addr"]: 0, # must remain zero - } - return mem, expected, 200, "all 8 conditional jumps (JZ/JNZ/JC/JNC/JN/JP/JV/JNV)" - - -def call_pushes_pc(mem_size: int = 256) -> ProgramResult: - """Verify CALL pushes the return address (next-instruction PC) onto the stack. - - SP starts at addr_mask (mem_size - 1). CALL decrements SP and writes the - return-address high byte, decrements again and writes the low byte. After - HALT we expect: - - mem[addr_mask - 2] = low byte of the return address - - mem[addr_mask - 1] = high byte of the return address - - the no-return code path was NOT taken - - the callee was reached - """ - a = Asm(mem_size) - - a.org(0) - a.label("caller") - a.load(0, "marker_val") - a.store(0, "marker_addr") # write before CALL - a.label("call_site") - a.call("callee") - # If CALL did not transfer control, this fallthrough store would write 0xDD: - a.load(0, "noret_val") - a.store(0, "noret_addr") - a.halt() - - a.label("callee") - a.load(0, "callee_val") - a.store(0, "callee_addr") - a.halt() - - a.org(0x40) - a.label("marker_val"); a.db(0x11) - a.label("marker_addr"); a.db(0) - a.label("noret_val"); a.db(0xDD) - a.label("noret_addr"); a.db(0) - a.label("callee_val"); a.db(0x22) - a.label("callee_addr"); a.db(0) - - mem = a.assemble() - addr_mask = mem_size - 1 - return_addr = a.labels["call_site"] + 4 # 4-byte CALL instruction - expected = { - a.labels["marker_addr"]: 0x11, # pre-CALL store ran - a.labels["callee_addr"]: 0x22, # callee reached - a.labels["noret_addr"]: 0, # fallthrough did not run - (addr_mask - 2) & addr_mask: return_addr & 0xFF, # ret LSB on stack - (addr_mask - 1) & addr_mask: (return_addr >> 8) & 0xFF, # ret MSB - } - return mem, expected, 30, "CALL pushes return PC onto stack" - - -def bubble_sort_4(mem_size: int = 256) -> ProgramResult: - """Sort a 4-byte array using unrolled compare-swap (3 passes of 3 compares). - - Algorithm: bubble sort, fully unrolled (no inner loops). Each compare-swap - is 8 instructions; 3 outer passes x 3 inner positions = 9 swaps -> ~72 instrs. - - For each position i in (0,1,2): - if A[i] > A[i+1]: - tmp = A[i]; A[i] = A[i+1]; A[i+1] = tmp - Repeat 3 times -> sorted ascending. - """ - a = Asm(mem_size) - - addrs = ["a0", "a1", "a2", "a3"] - - a.org(0) - for _outer in range(3): - for i in range(3): - x, y = addrs[i], addrs[i + 1] - # Load pair - a.load(0, x) # R0 = A[i] - a.load(1, y) # R1 = A[i+1] - a.cmp(0, 1) # compare A[i] - A[i+1] - # If A[i] <= A[i+1] (Z=1 or C=0 from sub-style cmp), skip swap - # SUB sets carry when no borrow (a >= b). So: - # a > b iff Z=0 and a >= b -> Z=0 and C=1 (the sub didn't borrow) - # We want to swap when a > b. JNC (no carry / borrow) means a < b -> skip swap. - # If a == b (Z=1) we also skip. So: jump-skip when JZ OR JNC. - # Easier: compute (a > b) by checking C=1 AND Z=0. Use JZ to skip on equal, - # then JNC to skip on a < b. Otherwise fall through to swap. - skip_lbl = f"skip_{_outer}_{i}" - a.jz(skip_lbl) # equal -> skip - a.jnc(skip_lbl) # a < b (sub borrowed) -> skip - # swap: store R0 -> y, R1 -> x - a.store(1, x) - a.store(0, y) - a.label(skip_lbl) - a.halt() - - # Initial unsorted array; code runs to ~0xEC, so data starts at 0xF0. - initial = [42, 7, 200, 19] - a.org(0xF0) - for name, val in zip(addrs, initial): - a.label(name); a.db(val) - - mem = a.assemble() - sorted_vals = sorted(initial) - expected = {a.labels[name]: v for name, v in zip(addrs, sorted_vals)} - return mem, expected, 800, f"bubble sort {initial} -> {sorted_vals}" - - -def cross_check_mul(mem_size: int = 256) -> ProgramResult: - """Cross-check the threshold MUL circuit against repeated ADD. - - Computes A * B two ways and stores both results: - repeated add: acc = 0; loop B times: acc += A -> OUT_ADD - direct: R0 = A; MUL R0, R1 (R1 = B) -> OUT_MUL - The test verifies the two agree with the expected product. - """ - a = Asm(mem_size) - A_VAL = 17 - B_VAL = 9 - expected_product = (A_VAL * B_VAL) & 0xFF - - a.org(0) - # --- direct multiply --- - a.load(0, "A") - a.load(1, "B") - a.mul(0, 1) - a.store(0, "out_mul") - # --- repeated-add multiply --- - a.load(0, "zero") # acc = 0 - a.load(1, "A") # addend - a.load(2, "B") # counter - a.load(3, "one") # 1 - a.label("rep_loop") - a.add(0, 1) # acc += A - a.sub(2, 3) # B-- - a.jnz("rep_loop") - a.store(0, "out_add") - a.halt() - - a.org(0x80) - a.label("A"); a.db(A_VAL) - a.label("B"); a.db(B_VAL) - a.label("zero"); a.db(0) - a.label("one"); a.db(1) - a.label("out_mul"); a.db(0) - a.label("out_add"); a.db(0) - - mem = a.assemble() - expected = { - a.labels["out_mul"]: expected_product, - a.labels["out_add"]: expected_product, - } - return mem, expected, 80, f"MUL vs repeated ADD: {A_VAL} * {B_VAL} = {expected_product}" - - -def div_via_repeated_sub(mem_size: int = 256) -> ProgramResult: - """Compute floor(A/B) and (A mod B) by repeated subtraction. - - Loop: while A >= B { A -= B; quotient += 1 } - Uses CMP + JC (carry-set on no-borrow), SUB, ADD, JMP, STORE, HALT. - - Cross-checked against the on-chip 8-bit DIV opcode (0x8) via a - second pass that uses DIV directly. Both quotients written to OUT - locations; the test verifies they match. - """ - A_VAL = 100 - B_VAL = 7 - expected_q = A_VAL // B_VAL # 14 - expected_r = A_VAL % B_VAL # 2 - - a = Asm(mem_size) - - a.org(0) - # ---- Repeated-subtraction division ---- - a.load(0, "A") # R0 = A (will become remainder) - a.load(1, "B") # R1 = B (divisor) - a.load(2, "ZERO") # R2 = 0 (will become quotient) - a.load(3, "ONE") # R3 = 1 (increment) - - a.label("loop") - a.cmp(0, 1) # CMP R0, R1; carry=1 (no-borrow) iff R0 >= R1 - a.jnc("done") # if R0 < R1 (carry=0), exit loop - a.sub(0, 1) # R0 -= B - a.add(2, 3) # quotient += 1 - a.jmp("loop") - - a.label("done") - a.store(2, "OUT_Q_RPT") # quotient via repeated sub - a.store(0, "OUT_R_RPT") # remainder via repeated sub - - # ---- Direct DIV opcode for cross-check ---- - a.load(0, "A") - a.load(1, "B") - a.dw(_enc(0x8, 0, 1, 0)) # DIV R0, R1 -> R0 = R0 / R1 (8-bit DIV) - a.store(0, "OUT_Q_DIV") - a.halt() - - a.org(0x80) - a.label("A"); a.db(A_VAL) - a.label("B"); a.db(B_VAL) - a.label("ZERO"); a.db(0) - a.label("ONE"); a.db(1) - a.label("OUT_Q_RPT"); a.db(0) - a.label("OUT_R_RPT"); a.db(0) - a.label("OUT_Q_DIV"); a.db(0) - - mem = a.assemble() - expected = { - a.labels["OUT_Q_RPT"]: expected_q, - a.labels["OUT_R_RPT"]: expected_r, - a.labels["OUT_Q_DIV"]: expected_q, - } - return mem, expected, 4 * (A_VAL // B_VAL + 4) + 12, ( - f"{A_VAL} / {B_VAL}: quotient {expected_q} (repeated SUB) " - f"matches DIV opcode result; remainder {expected_r}" - ) - - -def bitwise_chain(mem_size: int = 256) -> ProgramResult: - """Run a chain of bitwise ops and verify each intermediate value. - - Sequence: - R0 = A & B (AND) - R0 = R0 | C (OR) - R0 = R0 ^ D (XOR) - R0 = R0 << 1 (SHL) - R0 = R0 >> 1 (SHR) - Stores R0 after each step. Verifies all intermediate values to - catch any single-op regression. - """ - A = 0xCC # 11001100 - B = 0xF0 # 11110000 - C = 0x0F # 00001111 - D = 0xAA # 10101010 - - s1 = A & B # 0xC0 - s2 = s1 | C # 0xCF - s3 = s2 ^ D # 0x65 - s4 = (s3 << 1) & 0xFF # 0xCA - s5 = s4 >> 1 # 0x65 - - a = Asm(mem_size) - a.org(0) - a.load(0, "A"); a.load(1, "B"); a.and_(0, 1); a.store(0, "S1") - a.load(1, "C"); a.or_(0, 1); a.store(0, "S2") - a.load(1, "D"); a.xor(0, 1); a.store(0, "S3") - a.shl(0); a.store(0, "S4") - a.shr(0); a.store(0, "S5") - a.halt() - - a.org(0x80) - a.label("A"); a.db(A) - a.label("B"); a.db(B) - a.label("C"); a.db(C) - a.label("D"); a.db(D) - a.label("S1"); a.db(0) - a.label("S2"); a.db(0) - a.label("S3"); a.db(0) - a.label("S4"); a.db(0) - a.label("S5"); a.db(0) - - mem = a.assemble() - expected = { - a.labels["S1"]: s1, - a.labels["S2"]: s2, - a.labels["S3"]: s3, - a.labels["S4"]: s4, - a.labels["S5"]: s5, - } - return mem, expected, 30, ( - f"bitwise chain AND/OR/XOR/SHL/SHR -> {s1:#x},{s2:#x},{s3:#x},{s4:#x},{s5:#x}" - ) - - -def flags_policy(mem_size: int = 256) -> ProgramResult: - """Pin the architectural flag policy: only ADD, SUB, MUL, and CMP write - FLAGS; bitwise ops, shifts, DIV, LOAD, and STORE leave them unchanged. - - Three probes, each setting flags with an arithmetic op, disturbing the - machine with a non-flag-writing op, then branching on the original flags: - - 1. CMP equal -> Z=1; AND producing a nonzero result; JZ must still take. - 2. CMP unequal -> Z=0; XOR producing a zero result; JZ must NOT take. - 3. ADD 255+1 -> C=1; SHR (shifts a 0 out); JC must still take. - - Any runtime that lets the intervening op rewrite FLAGS lands on the - wrong path and writes the 0xEE failure marker instead. - """ - a = Asm(mem_size) - - a.org(0) - # ----- Probe 1: Z=1 survives AND (nonzero result) ----- - a.load(0, "v5"); a.load(1, "v5"); a.cmp(0, 1) # Z=1 - a.load(2, "v3"); a.load(3, "v1"); a.and_(2, 3) # R2 = 3 & 1 = 1 - a.jz("p1_ok") - a.load(2, "m_fail"); a.store(2, "out1"); a.jmp("p2") - a.label("p1_ok") - a.load(2, "m_b1"); a.store(2, "out1") - - # ----- Probe 2: Z=0 survives XOR (zero result) ----- - a.label("p2") - a.load(0, "v5"); a.load(1, "v3"); a.cmp(0, 1) # Z=0 - a.xor(2, 2) # R2 = 0 - a.jz("p2_fail") - a.load(2, "m_b2"); a.store(2, "out2"); a.jmp("p3") - a.label("p2_fail") - a.load(2, "m_fail"); a.store(2, "out2") - - # ----- Probe 3: C=1 survives SHR ----- - a.label("p3") - a.load(0, "v255"); a.load(1, "v1"); a.add(0, 1) # C=1 - a.load(2, "v3"); a.shr(2) # R2 = 1 - a.jc("p3_ok") - a.load(2, "m_fail"); a.store(2, "out3"); a.jmp("done") - a.label("p3_ok") - a.load(2, "m_b3"); a.store(2, "out3") - a.label("done") - a.halt() - - a.org(0xA0) - a.label("v1"); a.db(1) - a.label("v3"); a.db(3) - a.label("v5"); a.db(5) - a.label("v255"); a.db(255) - a.label("m_b1"); a.db(0xB1) - a.label("m_b2"); a.db(0xB2) - a.label("m_b3"); a.db(0xB3) - a.label("m_fail"); a.db(0xEE) - a.label("out1"); a.db(0) - a.label("out2"); a.db(0) - a.label("out3"); a.db(0) - - mem = a.assemble() - expected = { - a.labels["out1"]: 0xB1, - a.labels["out2"]: 0xB2, - a.labels["out3"]: 0xB3, - } - return mem, expected, 60, "flag policy: AND/XOR/SHR leave FLAGS unchanged" - - -SUITE = [ - ("fib", lambda mem_size: fib(11, mem_size)), - ("sum_n", lambda mem_size: sum_n(10, mem_size)), - ("self_mod_jmp", lambda mem_size: self_mod_jmp(mem_size)), - ("all_branches", lambda mem_size: all_branches(mem_size)), - ("call_pushes_pc", lambda mem_size: call_pushes_pc(mem_size)), - ("bubble_sort_4", lambda mem_size: bubble_sort_4(mem_size)), - ("cross_check_mul", lambda mem_size: cross_check_mul(mem_size)), - ("div_via_repeated_sub", lambda mem_size: div_via_repeated_sub(mem_size)), - ("bitwise_chain", lambda mem_size: bitwise_chain(mem_size)), - ("flags_policy", lambda mem_size: flags_policy(mem_size)), -] - diff --git a/tools/play.py b/tools/play.py index 8cd2ed249100ebaf21ebc30e60361969d76b8c3e..b01e8d60c52d69b0bea34a4e3186fc49e879189b 100644 --- a/tools/play.py +++ b/tools/play.py @@ -1,35 +1,32 @@ -""" -Hands-on playground for the threshold-computers family. - -Loads a safetensors model, reads its manifest, and exercises threshold -circuits at every level: raw Boolean gates, 8-bit ALU arithmetic and -comparators, multi-layer modular arithmetic, and a manifest-sized CPU -runtime running a small assembled program end-to-end through the -threshold weights. - -The CPU demo defaults to the small (1 KB) profile so the run finishes in -a fraction of a second. Larger profiles (4 KB, 64 KB) take proportionally -longer because every memory access decodes against every address line. - -Usage: - python play.py # fast 1KB demo - python play.py --model neural_computer.safetensors # full 64KB - python play.py --model variants/neural_alu8.safetensors --skip-cpu # ALU only -""" - +"""A playground for the threshold computers, and a demonstration on each machine.""" from __future__ import annotations + import argparse +import hashlib +import json +import math import os +import random +import statistics import sys +import time import torch from safetensors import safe_open -REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # this file lives in tools/ -sys.path.insert(0, os.path.join(REPO, "src")) # eval_all lives in src/ +sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src")) + +import automata as am +import automata as ca +import automata as T +import eval as ev +import host as ho +import machines as mc +import reflect as rf +from eval import GenericThresholdCPU, builtin_program + -# Reuse the variant-aware CPU runtime from eval_all.py -from eval_all import GenericThresholdCPU, builtin_program +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # this file lives in tools/ def heaviside(x): @@ -258,5 +255,866 @@ def main() -> int: return 0 if got == expected else 1 +def queens_circuit(N): + """The N-queens CNF as a threshold circuit, with every clause wire.""" + c = am.Circuit() + v = {(r, col): c.wire() for r in range(N) for col in range(N)} + clauses = [] + + def OR_lits(lits): + acc = lits[0] + for w in lits[1:]: + acc = c.OR(acc, w) + return acc + + for r in range(N): # each row: >= 1 queen + clauses.append(OR_lits([v[(r, col)] for col in range(N)])) + for c1 in range(N): # each row: <= 1 queen + for c2 in range(c1 + 1, N): + clauses.append(OR_lits([c.NOT(v[(r, c1)]), c.NOT(v[(r, c2)])])) + for col in range(N): # each column: <= 1 + for r1 in range(N): + for r2 in range(r1 + 1, N): + clauses.append(OR_lits([c.NOT(v[(r1, col)]), c.NOT(v[(r2, col)])])) + cells = [(r, col) for r in range(N) for col in range(N)] + for i in range(len(cells)): # each diagonal: <= 1 + for j in range(i + 1, len(cells)): + (r1, c1), (r2, c2) = cells[i], cells[j] + if r1 - c1 == r2 - c2 or r1 + c1 == r2 + c2: + clauses.append(OR_lits([c.NOT(v[(r1, c1)]), c.NOT(v[(r2, c2)])])) + return c, v, clauses + + +def valid(board, N): + qs = [rc for rc, b in board.items() if b] + if len(qs) != N: + return False + for i in range(len(qs)): + for j in range(i + 1, len(qs)): + (r1, c1), (r2, c2) = qs[i], qs[j] + if r1 == r2 or c1 == c2 or abs(r1 - r2) == abs(c1 - c2): + return False + return True + + +def solve_board(N, seeds=8): + c, v, clauses = queens_circuit(N) + free = list(v.values()) + target = {w: 1 for w in clauses} + t0 = time.perf_counter() + for seed in range(seeds): + s = c.solve(free, {}, target, sweeps=6000, restarts=6, seed=seed) + if s is not None: + board = {rc: s[w] for rc, w in v.items()} + if valid(board, N): + return board, len(clauses), c.n, time.perf_counter() - t0 + return None, len(clauses), c.n, time.perf_counter() - t0 + + +def coarse(g, H, W, k=8): + out = [] + for by in range(0, H, k): + for bx in range(0, W, k): + out.append(sum(g[y][x] for y in range(by, by + k) for x in range(bx, bx + k))) + return out + +# a recognizable plaintext bitmap (a heart), padded into a larger lattice +ART = [ + " ### ### ", + " ##### ##### ", + "#############", + "#############", + "#############", + " ########### ", + " ######### ", + " ####### ", + " ##### ", + " ### ", + " # ", +] + +PAD_X, PAD_Y = 6, 4 + + +def make_grid(): + # the Margolus partition tiles 2x2 blocks toroidally, so H and W must be even + h = len(ART) + 2 * PAD_Y + w = len(ART[0]) + 2 * PAD_X + h += h & 1 + w += w & 1 + g = [[0] * w for _ in range(h)] + for y, row in enumerate(ART): + for x, ch in enumerate(row): + if ch == "#": + g[y + PAD_Y][x + PAD_X] = 1 + return g + + +def render(g): + return "\n".join("".join("#" if c else "." for c in row) for row in g) + + +def hamming(a, b): + return sum(a[y][x] != b[y][x] for y in range(len(a)) for x in range(len(a[0]))) + + +def collatz_build(n0): + a = ev.Asm(1024) + a.load(0, "N"); a.load(1, "ONE"); a.xor(2, 2) # R0=n, R1=1, R2=steps + a.label("loop") + a.cmp(0, 1); a.jz("done") # n == 1 ? + a.xor(3, 3); a.add(3, 0); a.and_(3, 1) # R3 = n & 1 (flags preserved) + a.cmp(3, 1); a.jz("odd") + a.shr(0); a.jmp("step") # even: n >>= 1 + a.label("odd") + a.xor(3, 3); a.add(3, 0); a.shl(3) # R3 = 2n + a.add(0, 3); a.add(0, 1) # n = 3n + 1 + a.label("step") + a.add(2, 1); a.jmp("loop") # steps++ + a.label("done") + a.store(2, "RES"); a.halt() + a.org(0x300); a.label("N"); a.db(n0) + a.label("ONE"); a.db(1); a.label("RES"); a.db(0) + return a, a.assemble() + + +def true_collatz(n): + peak, steps = n, 0 + while n != 1: + n = n // 2 if n % 2 == 0 else 3 * n + 1 + peak = max(peak, n); steps += 1 + return steps, peak + + +def gcd_build(a0, b0): + g = ev.Asm(1024) + g.load(0, "A") # R0 = a + g.load(1, "B") # R1 = b + g.xor(3, 3) # R3 = 0 + g.label("loop") + g.cmp(1, 3); g.jz("done") # while b != 0 + g.xor(2, 2); g.add(2, 0) # R2 = a + g._alu(0x8, 2, 1) # R2 = a / b (DIV opcode) + g.mul(2, 1) # R2 = (a/b)*b + g.sub(0, 2) # R0 = a mod b + g.xor(0, 1); g.xor(1, 0); g.xor(0, 1) # swap -> (b, a mod b) + g.jmp("loop") + g.label("done") + g.store(0, "RES"); g.halt() + g.org(0x300) + g.label("A"); g.db(a0) + g.label("B"); g.db(b0) + g.label("RES"); g.db(0) + return g, g.assemble() + +FLAGS = 0x300 # flags[0..255] at 0x300..0x3FF + + +def sieve_build(): + g = ev.Asm(1024) + g.load(3, "ONE") # R3 = 1 (mark value / increment) + g.load(0, "TWO") # R0 = p = 2 + g.label("outer") + g.store(0, "Rlo") # patch probe address low byte <- p + g.dw(ev._enc(0xA, 2, 0)) # probe: R2 = flags[p] (self-modified) + g.db(FLAGS >> 8); g.label("Rlo"); g.db(0x00) + g.cmp(2, 3); g.jz("next_p") # flags[p] == 1 -> composite, skip + g.xor(1, 1); g.add(1, 0); g.add(1, 0) # m = 2p + g.label("inner") + g.store(1, "Wlo") # patch mark address low byte <- m + g.dw(ev._enc(0xB, 0, 3)) # mark: flags[m] = R3 = 1 (self-modified) + g.db(FLAGS >> 8); g.label("Wlo"); g.db(0x00) + g.add(1, 0) # m += p (carry iff we passed 255) + g.jnc("inner") + g.label("next_p") + g.add(0, 3) # p += 1 + g.load(2, "SIXTEEN") + g.cmp(0, 2) + g.jnz("outer") # loop until p == 16 (16^2 > 255) + g.halt() + g.org(0x200) + g.label("ONE"); g.db(1) + g.label("TWO"); g.db(2) + g.label("SIXTEEN"); g.db(16) + return g, g.assemble() + + +def prog_bytes(): + return (mc._instr(0x1, 0, 1) # SUB R0, R1 + + mc._instr(0xD, 0, 0, 1) + [0, 0] # JNZ 0x0000 (address word) + + mc._instr(0xF) # HALT + + [0] * 8) + + +def native(): + """Integer mirror of the exact recurrence the machine will run.""" + accA, t, sign, d, dsA = 0, 1600000000 // 5, 1, 1, [] + while t > 0: + accA += sign * ((t + d // 2) // d); dsA.append(d) + sign, d, t = -sign, d + 2, (t + 12) // 25 + accB, t, q, dsB, sign, d = 0, (400000000 + 119) // 239, 239 * 239, [], 1, 1 + while t > 0: + accB += sign * ((t + d // 2) // d); dsB.append(d) + sign, d, t = -sign, d + 2, (t + q // 2) // q + return accA - accB, dsA, dsB + + +def pi_build(dsA, dsB): + a = mc.Asm() + a.lui(4, 0x5F5E1) # x4 = 1,600,000,000 + a.addi(5, 0, 25); a.addi(6, 0, 10); a.addi(8, 0, 239); a.addi(14, 0, 5) + a.div(11, 4, 14) # t = 16e8/5 + a.addi(10, 0, 0) # accA + for k, d in enumerate(dsA): + a.addi(12, 0, d); a.addi(13, 11, d // 2); a.div(13, 13, 12) + (a.add if k % 2 == 0 else a.sub)(10, 10, 13) + if k + 1 < len(dsA): + a.addi(13, 11, 12); a.div(11, 13, 5) + a.lui(7, 0x17D78); a.ori(7, 7, 0x400) # x7 = 400,000,000 + a.addi(13, 7, 119); a.div(11, 13, 8) + a.mul(9, 8, 8) # 239^2 computed on-machine + a.srli(14, 9, 1) + a.addi(15, 0, 0) # accB + for k, d in enumerate(dsB): + a.addi(12, 0, d); a.addi(13, 11, d // 2); a.div(13, 13, 12) + (a.add if k % 2 == 0 else a.sub)(15, 15, 13) + if k + 1 < len(dsB): + a.add(13, 11, 14); a.div(11, 13, 9) + a.sub(10, 10, 15) # x10 = pi * 1e8 + a.addi(20, 0, 0x7F8); a.slli(20, 20, 5) # console 0xFF00 + a.lui(21, 0x5F5E); a.ori(21, 21, 0x100) # P = 1e8 + a.div(22, 10, 21); a.rem(10, 10, 21) + a.addi(22, 22, 48); a.sb(22, 20, 0) # integer digit + a.addi(22, 0, 46); a.sb(22, 20, 0) # '.' + a.div(21, 21, 6); a.addi(23, 0, 8) + a.label("digits") + a.div(22, 10, 21); a.rem(10, 10, 21) + a.addi(22, 22, 48); a.sb(22, 20, 0) + a.div(21, 21, 6); a.addi(23, 23, -1); a.bne(23, 0, "digits") + a.ecall() + return a.assemble() + + +def ternary(w): # {-1,0,+1} forward, identity backward (STE) + wq = torch.where(w > 0.5, 1.0, torch.where(w < -0.5, -1.0, 0.0)) + return w + (wq - w).detach() + +def ibias(b, lo=-16, hi=15): + bq = b.round().clamp(lo, hi) + return b + (bq - b).detach() + +# ---- discrete NEUR reference (exactly what the hardware computes) ---------- +def neuron(x, pos, neg, bias): + return 1 if (bin(x & pos).count("1") - bin(x & neg).count("1") + bias) >= 0 else 0 + +def discrete_net(x, hidden, out): + h = 0 + for j, (p, n, b) in enumerate(hidden): + h |= neuron(x, p, n, b) << j + p, n, b = out + return neuron(h, p, n, b) + +# ---- train a 2-layer ternary MLP; hidden step trained with an annealed ----- +# sigmoid so real gradients flow, then hardened to Heaviside at extraction +def train(target_fn, H, epochs=5000, seed=0, nbits=8): + torch.manual_seed(seed) + N = 1 << nbits + xs = torch.arange(N) + X = torch.stack([((xs >> i) & 1).float() for i in range(nbits)], 1) + Y = torch.tensor([float(target_fn(int(x))) for x in xs]) + Wh = torch.nn.Parameter(torch.randn(H, nbits) * 1.5) + bh = torch.nn.Parameter(torch.randn(H) * 1.5) + Wo = torch.nn.Parameter(torch.randn(H) * 1.5) + bo = torch.nn.Parameter(torch.zeros(1)) + opt = torch.optim.Adam([Wh, bh, Wo, bo], lr=0.03) + npos = float(Y.sum()) + bce = torch.nn.BCEWithLogitsLoss(pos_weight=torch.tensor((N - npos) / max(npos, 1.0))) + for ep in range(epochs): + opt.zero_grad() + Tt = 1.0 + 11.0 * ep / epochs + preh = X @ ternary(Wh).T + ibias(bh) + h = torch.sigmoid(Tt * preh) + preo = h @ ternary(Wo) + ibias(bo) + loss = bce(4.0 * preo, Y) + loss.backward(); opt.step() + Whq = torch.where(Wh > 0.5, 1, torch.where(Wh < -0.5, -1, 0)).tolist() + Woq = torch.where(Wo > 0.5, 1, torch.where(Wo < -0.5, -1, 0)).tolist() + bhq = bh.round().clamp(-16, 15).long().tolist() + boq = int(bo.round().clamp(-16, 15).item()) + def pack(wrow): + return (sum(1 << i for i, w in enumerate(wrow) if w > 0), + sum(1 << i for i, w in enumerate(wrow) if w < 0)) + hidden = [(*pack(Whq[j]), bhq[j]) for j in range(H)] + out = (*pack(Woq), boq) + acc = sum(discrete_net(int(x), hidden, out) == int(Y[i]) for i, x in enumerate(xs)) / N + return hidden, out, acc + +# ---- emit the network as NEUR code and run it on neural_rv32 --------------- +def li(a, reg, v): + v &= 0xFFFFFFFF + hi = (v + 0x800) >> 12 + lo = v - (hi << 12) + a.lui(reg, hi & 0xFFFFF) + if lo: a.addi(reg, reg, lo) + +def build_program(xval, hidden, out): + a = mc.Asm() + li(a, 1, xval) # x1 = input byte + li(a, 2, 0) # x2 = packed hidden + for j, (p, n, b) in enumerate(hidden): + li(a, 4, p | (n << 8) | ((b & 0x1F) << 16)) # weight word + a.neur(3, 1, 4) # x3 = hidden neuron j + if j: a.slli(2, 2, 1) + a.or_(2, 2, 3) # pack MSB-first (neuron 0 -> bit H-1) + p, n, b = out + H = len(hidden) + def remap(mask): # match the MSB-first hidden packing + r = 0 + for j in range(H): + if (mask >> j) & 1: r |= 1 << (H - 1 - j) + return r + li(a, 4, remap(p) | (remap(n) << 8) | ((b & 0x1F) << 16)) + a.neur(5, 2, 4) # x5 = output + a.ecall() + return a.assemble() + +def run_cpu(cpu, xval, hidden, out): + mem = build_program(xval, hidden, out) + ts = mc.rv_state(mem) + ts["_mem_bits"] = torch.tensor([ev.int_to_bits(b, 8) for b in ts["mem"]], dtype=torch.float32) + cyc = 0 + while not ts["halted"] and cyc < 200: + ts = cpu.step(ts); cyc += 1 + return ts["regs"][5], cyc + + +def parity(x, nbits=8): return bin(x & ((1 << nbits) - 1)).count("1") & 1 + +def band(x): return int(3 <= bin(x).count("1") <= 5) # non-monotone in popcount + +def learn(fn, H, seeds, epochs): + best, t0, used = None, time.perf_counter(), 0 + for seed in range(seeds): + used = seed + hidden, out, acc = train(fn, H=H, seed=seed, epochs=epochs) + if best is None or acc > best[2]: + best = (hidden, out, acc) + if acc == 1.0: + break + hidden, out, acc = best + print(f" trained 8->{H}->1 ternary MLP by annealed-sigmoid SGD: {acc*100:.1f}% " + f"exact over all 256 inputs ({time.perf_counter()-t0:.0f}s, seed {used}); " + f"weights all in {{-1,0,+1}}") + return hidden, out, acc + +def deploy_check(cpu, fn, hidden, out, sample): + t0, cpu_vs_net, cpu_vs_true, rows = time.perf_counter(), 0, 0, [] + for x in sample: + y_cpu, _ = run_cpu(cpu, x, hidden, out) + cpu_vs_net += (y_cpu == discrete_net(x, hidden, out)) + cpu_vs_true += (y_cpu == fn(x)) + rows.append((x, y_cpu, fn(x))) + print(f" NEUR execution on neural_rv32: CPU == reference net on " + f"{cpu_vs_net}/{len(sample)} (deployment exact); CPU == truth on " + f"{cpu_vs_true}/{len(sample)} ({time.perf_counter()-t0:.0f}s)") + return rows + +def compile_parity(nbits=8): # exact thermometer construction + mask = (1 << nbits) - 1 + hidden = [(mask, 0, -(k + 1)) for k in range(nbits)] # h_k = [popcount>=k+1] + pos = sum(1 << j for j in range(nbits) if j % 2 == 0) # odd thresholds + neg = sum(1 << j for j in range(nbits) if j % 2 == 1) # even thresholds + return hidden, (pos, neg, -1) + + +def cmd_factoring(): + c, io = am.multiplier(8) + print("neural_attractor: factoring by energy relaxation") + print("=" * 56) + print(f"8x8 array multiplier as an energy landscape: {len(c.gates)} gate " + f"gadgets, {c.n} wires, 16 free input bits\n") + + ladder = [(3599, 59, 61), (9797, 97, 101), (32399, 179, 181), (57599, 239, 241)] + for N, pa, pb in ladder: + target = {io["prod"][k]: (N >> k) & 1 for k in range(16)} + t0, got = time.perf_counter(), None + for attempt in range(1, 8): + s = c.solve(io["xs"] + io["ys"], {io["zero"]: 0}, target, + sweeps=2500, restarts=12, seed=N * 7 + attempt) + if s is not None: + x = sum(s[io["xs"][k]] << k for k in range(8)) + y = sum(s[io["ys"][k]] << k for k in range(8)) + if x * y == N and x > 1 and y > 1: + got = (x, y, attempt); break + dt = time.perf_counter() - t0 + if got: + x, y, att = got + tag = "the prime pair" if {x, y} == {pa, pb} else "valid factorization" + print(f" {N:5d} = {x:3d} x {y:3d} ({dt:4.1f}s, attempt {att}) [{tag}]") + else: + print(f" {N:5d}: ground state not reached in budget ({dt:.1f}s)") + + # division: clamp one input and the product, relax the other input + N, xa = 57599, 241 + fixed = {io["zero"]: 0} + for k in range(8): + fixed[io["xs"][k]] = (xa >> k) & 1 + target = {io["prod"][k]: (N >> k) & 1 for k in range(16)} + t0 = time.perf_counter() + s = c.solve(io["ys"], fixed, target, sweeps=800, restarts=10, seed=7) + if s is not None: + y = sum(s[io["ys"][k]] << k for k in range(8)) + print(f"\n division mode: clamp x=241 and the product -> y = {y} " + f"({time.perf_counter()-t0:.1f}s; {xa} x {y} = {xa * y})") + + +def cmd_nqueens(): + print("neural_attractor: N-queens as energy relaxation") + print("=" * 56) + for N in (6, 8): + board, nclauses, nwires, dt = solve_board(N) + status = "SOLVED" if board else "no solution reached in budget" + print(f"\n{N}-queens: {nclauses} clause gadgets over {nwires} wires -> " + f"{status} ({dt:.1f}s)") + if board: + for r in range(N): + print(" " + " ".join("Q" if board[(r, col)] else "." for col in range(N))) + print(f" verified: {N} queens, no shared row/column/diagonal") + print("\nNo search loop was written: the constraints are an energy surface and") + print("the solution is its floor. The queens fall into place.") + + +def cmd_loschmidt(): + H = W = 64 + rng = random.Random(2026) + grid = [[1 if rng.random() < 0.5 else 0 for _ in range(W)] for _ in range(H)] + n0 = sum(map(sum, grid)) + STEPS = 500 + + print("neural_ca: Loschmidt echo (mix, then run time backward)") + print("=" * 56) + t0 = time.perf_counter() + fwd = ca.ca_run(grid, STEPS, 0) + n1 = sum(map(sum, fwd)) + back = ca.run_back(fwd, STEPS, 0) + echo = back == grid + dt = time.perf_counter() - t0 + + print(f"particles: {n0} at t=0, {n1} at t={STEPS} " + f"({'conserved' if n0 == n1 else 'NOT CONSERVED'})") + print(f"coarse 8x8 occupancy stdev: t=0 {statistics.pstdev(coarse(grid, H, W)):.2f} " + f"-> t={STEPS} {statistics.pstdev(coarse(fwd, H, W)):.2f} (mixed)") + print(f"{STEPS} steps forward + {STEPS} reversed in {dt:.1f}s: " + f"t=0 recovered {'EXACTLY' if echo else 'FAILED'}") + + flip = [row[:] for row in fwd] + flip[0][0] ^= 1 + back2 = ca.run_back(flip, STEPS, 0) + ham = sum(back2[y][x] != grid[y][x] for y in range(H) for x in range(W)) + print(f"butterfly: flip ONE cell at t={STEPS}, reverse again -> reconstructed " + f"past wrong in {ham}/{H * W} cells") + + +def cmd_cipher(): + plain = make_grid() + KEY_STEPS, KEY_PHASE = 200, 0 + + cipher = ca.ca_run(plain, KEY_STEPS, KEY_PHASE) + recovered = ca.run_back(cipher, KEY_STEPS, KEY_PHASE) + wrong = ca.run_back(cipher, KEY_STEPS - 1, KEY_PHASE) # key off by one step + + n = sum(map(sum, plain)) + print("neural_ca: reversible-automaton cipher") + print("=" * 46) + print(f"key = ({KEY_STEPS} steps, phase {KEY_PHASE}); {n} set bits (conserved " + f"throughout: {sum(map(sum, cipher)) == n})\n") + + print("PLAINTEXT:") + print(render(plain)) + print(f"\nCIPHERTEXT after {KEY_STEPS} forward steps (diffused to noise):") + print(render(cipher)) + print(f"\nDECRYPTED with the correct key:") + print(render(recovered)) + print(f" exact recovery: {recovered == plain} (Hamming distance 0)") + print(f"\nDECRYPTED with a wrong key (off by one step):") + print(render(wrong)) + print(f" still scrambled: {hamming(wrong, plain)} of {len(plain)*len(plain[0])} " + f"cells differ from the plaintext") + print("\nEvery step is a bijection, so the ciphertext holds exactly the") + print("information of the plaintext -- no more, no less. Only the exact") + print("reverse trajectory recovers it.") + + +def cmd_collatz(): + tens = {} + with safe_open(os.path.join(REPO, "variants", "neural_computer8_small.safetensors"), + framework="pt") as f: + for name in f.keys(): + tens[name] = f.get_tensor(name).float() + cpu = ev.GenericThresholdCPU(tens) + addr = ev.get_manifest(tens)["addr_bits"] + + print("neural_computer8: Collatz step counts through the gates") + print("=" * 56) + print(" seed CPU steps true steps peak in 8-bit range?") + for n0 in (6, 7, 9, 18, 25, 45): + a, mem = collatz_build(n0) + state = {"pc": 0, "regs": [0] * 4, "flags": [0] * 4, "mem": list(mem), + "halted": False, "sp": (1 << addr) - 1} + t0 = time.perf_counter() + final, cycles = cpu.run(state, max_cycles=3000) + steps = final["mem"][a.labels["RES"]] + tsteps, peak = true_collatz(n0) + inrange = peak <= 255 + match = "MATCH" if (inrange and steps == tsteps) else \ + ("mod-256" if not inrange else "MISMATCH") + print(f" {n0:4d} {steps:9d} {tsteps:10d} {peak:4d} " + f"{'yes' if inrange else 'no (wraps)':10s} [{match}]") + print("\n For every seed whose trajectory stays under 256 the threshold CPU") + print(" reproduces the exact Collatz step count.") + + +def cmd_gcd(): + tens = {} + with safe_open(os.path.join(REPO, "variants", "neural_computer8_small.safetensors"), + framework="pt") as f: + for name in f.keys(): + tens[name] = f.get_tensor(name).float() + cpu = ev.GenericThresholdCPU(tens) + addr = ev.get_manifest(tens)["addr_bits"] + + print("neural_computer8: Euclid's GCD through the gates") + print("=" * 56) + for a0, b0 in [(84, 36), (120, 90), (221, 34), (255, 128)]: + g, mem = gcd_build(a0, b0) + state = {"pc": 0, "regs": [0] * 4, "flags": [0] * 4, "mem": list(mem), + "halted": False, "sp": (1 << addr) - 1} + t0 = time.perf_counter() + final, cycles = cpu.run(state, max_cycles=400) + res = final["mem"][g.labels["RES"]] + ok = res == math.gcd(a0, b0) + print(f" gcd({a0:3d}, {b0:3d}) = {res:3d} ({cycles:3d} cycles, " + f"{time.perf_counter()-t0:.1f}s) " + f"[{'MATCH' if ok else 'MISMATCH vs ' + str(math.gcd(a0, b0))}]") + + +def cmd_sieve(): + g, mem = sieve_build() + tens = {} + with safe_open(os.path.join(REPO, "variants", "neural_computer8_small.safetensors"), + framework="pt") as f: + for name in f.keys(): + tens[name] = f.get_tensor(name).float() + cpu = ev.GenericThresholdCPU(tens) + state = {"pc": 0, "regs": [0] * 4, "flags": [0] * 4, "mem": list(mem), + "halted": False, "sp": (1 << ev.get_manifest(tens)["addr_bits"]) - 1} + t0 = time.perf_counter() + final, cycles = cpu.run(state, max_cycles=4000) + dt = time.perf_counter() - t0 + + got = [n for n in range(2, 256) if final["mem"][FLAGS + n] == 0] + ref = [n for n in range(2, 256) if all(n % d for d in range(2, int(n**0.5) + 1))] + print("neural_computer8: self-modifying Sieve of Eratosthenes") + print("=" * 56) + print(f"halted={final['halted']} {cycles} cycles through the gates ({dt:.0f}s)") + print(f"primes < 256 found: {len(got)} native sieve: {len(ref)} " + f"{'EXACT MATCH' if got == ref else 'MISMATCH'}") + print(" " + " ".join(map(str, got))) + print(f"self-modified operand bytes at halt: probe={final['mem'][g.labels['Rlo']]}, " + f"mark={final['mem'][g.labels['Wlo']]} (the program rewrote its own " + f"instruction stream as it ran)") + + +def cmd_fleet(): + dev = "cuda" if torch.cuda.is_available() else "cpu" + mm = mc.MatrixMachine.from_file(device=dev) + n_neurons = sum(int(b.numel()) for b in mm.B) + name = torch.cuda.get_device_name(0) if dev == "cuda" else "cpu" + print("neural_matrix8: 65,536 CPUs as one batched matrix product") + print("=" * 56) + print(f"loaded {len(mm.W)} ternary matrices, {n_neurons:,} threshold neurons " + f"per transition, device={dev} ({name})") + mem = prog_bytes() + + # correctness fleet: one CPU per 8-bit input, all 256 at once + V = torch.stack([mc.state_to_vec(mc._mk_state(mem=mem, regs=(x, 1, 0, 0))) + for x in range(256)]).to(dev) + first_halt = torch.full((256,), -1, dtype=torch.long) + step = 0 + while step < 600: + halted = V[:, mc.MatrixMachine.HALT_IDX] > 0.5 + first_halt[(first_halt < 0) & halted.cpu()] = step + if bool(halted.all()): + break + V = mm.step(V); step += 1 + expect = torch.tensor([2 * (x if x else 256) + 1 for x in range(256)]) + ok_halt = bool((first_halt == expect).all()) + ok_r0 = bool((V[:, 4:12].cpu().long().sum(1) == 0).all()) + print(f"\ncorrectness fleet of 256: every CPU halts at cycle 2n+1 for its own " + f"input: {'EXACT' if ok_halt else 'MISMATCH'}; all results R0==0: {ok_r0}") + + # throughput fleet: 65,536 CPUs + B = 65536 + V = torch.stack([mc.state_to_vec(mc._mk_state(mem=mem, regs=(x & 0xFF, 1, 0, 0))) + for x in range(256)]).repeat(256, 1).to(dev) + if dev == "cuda": + torch.cuda.synchronize() + t0, steps = time.perf_counter(), 0 + while steps < 520: + V = mm.step(V); steps += 1 + if steps % 64 == 0 and bool((V[:, mc.MatrixMachine.HALT_IDX] > 0.5).all()): + break + if dev == "cuda": + torch.cuda.synchronize() + dt = time.perf_counter() - t0 + instr = B * steps + print(f"\nthroughput fleet of {B:,}: {steps} transitions in {dt:.1f}s") + print(f" {instr / dt / 1e6:,.1f} million instructions/s aggregate " + f"({instr:,} instructions retired)") + print(f" {instr * n_neurons / dt / 1e9:,.1f} billion threshold-neuron " + f"evaluations/s") + + +def cmd_stack(): + dev = "cuda" if torch.cuda.is_available() else "cpu" + cfg = rf.Cfg(A=14, G=296, banks=1, self_mod=False) + prog, lay = rf.build_subleq_machine(cfg) + inet, ii, io = rf.build_net(cfg) + print("neural_reflect: self-modifying code on a stored machine on U") + print("=" * 60) + print(f"level 0: interpreter U = {len(inet.gates):,} threshold gates (fixed)") + print(f"level 1: a SUBLEQ machine = {len(prog)} gates, stored as writable data") + lev = rf.Leveled(inet, ii, io, device=dev) + nl = rf.encode_netlist(cfg, prog) + sig0 = [0] * cfg.S + sig0[cfg.NET0:cfg.NET0 + len(nl)] = nl + base = rf.state_to_vec(cfg, {"sig": sig0, "gp": 0, "halt": 0}).to(dev) + PC = lay["PC"] + + # level 2: SUBLEQ (A B C: M[B]-=M[A]; if <=0 goto C). The first instruction + # zeroes a target cell, then two instructions increment its own A and B + # operand bytes, so the zeroing walks across a data block. + pm = [0] * 32 + pm[0:3] = [24, 24, 3] # zero M[]; these operands get rewritten + pm[3:6] = [19, 0, 6] # M[0] += 1 (patch own A-field: cell 0) + pm[6:9] = [19, 1, 9] # M[1] += 1 (patch own B-field: cell 1) + pm[9:12] = [20, 21, 15] # count -= 1; if <= 0 branch to epilogue + pm[12:15] = [18, 18, 0] # 0 -> unconditional jump to loop head + pm[15:18] = [18, 18, 31] # epilogue: branch to 31 = HALT + pm[18], pm[19], pm[20], pm[21] = 0, 255, 1, 4 + pm[24:28] = [7, 99, 123, 200] # the block the program will erase + print(f"level 2: a program whose instruction 0 starts as [24,24,3] and " + f"rewrites its own operands") + + ref_mem, ref_pc = list(pm), 0 + while ref_pc != 31: + ref_mem, ref_pc = rf.subleq32_step(ref_mem, ref_pc) + + V = base.unsqueeze(0).clone() + for i in range(32): + for b in range(8): + V[:, rf.MEM + b * rf.MEMB + i] = float((pm[i] >> b) & 1) + + def get_mem(Vc): + return sum(Vc[:, rf.MEM + b * rf.MEMB: rf.MEM + b * rf.MEMB + 32].long() << b + for b in range(8))[0].tolist() + + t0, walk, steps, halted = time.perf_counter(), [], 0, False + for _ in range(26): + m_now = get_mem(V.cpu()) + walk.append((m_now[0], m_now[1])) + for _ in range(cfg.G): + V = lev.step(V) + steps += 1 + if int(V[0, cfg.S + cfg.GPW]) == 1: + halted = True + break + dt = time.perf_counter() - t0 + got = get_mem(V.cpu()) + + mut = " -> ".join(f"[{a},{b}]" for a, b in + [walk[0]] + [w for i, w in enumerate(walk[1:], 1) if w != walk[i - 1]]) + print(f"\nran {steps} hosted instructions in {dt:.0f}s " + f"({steps * cfg.G} interpreter recurrences); halted={halted}") + print(f" instruction 0's operand cells as it ran: {mut}") + print(f" target block M[24..27]: {pm[24:28]} -> {got[24:28]}") + print(f" final memory == native SUBLEQ reference: " + f"{'EXACT' if got == ref_mem else 'MISMATCH'}") + print("\n The program, the machine it runs on, and the machine that runs") + print(" that machine are all the same tensor.") + + +def cmd_counterfactual(): + prog = [] + for _ in range(8): + prog += [("ADD", 0, 3), ("TOFF", 3, 0, 2), ("ROL", 0, 1), ("XOR", 2, 3)] + cpu = am.RCPU(prog) + seed = cpu.new_state(regs=[7, 13, 201, 0]) + + print("neural_reversible: no-erasure computing and the counterfactual") + print("=" * 60) + s = am._clone(seed) + cpu.run(s, len(prog)) + print(f"seed registers {seed['R']} --{len(prog)} bijective steps--> " + f"digest {s['R']}") + + b = am._clone(s) + cpu.run_back(b, len(prog)) + print(f"run backward: seed recovered " + f"{'EXACTLY' if b['R'] == seed['R'] and b['PC'] == seed['PC'] else 'FAILED'} " + f"(no bit was ever erased; Landauer floor 0)") + + c = am._clone(s) + c["R"][3] ^= 4 # corrupt one bit of the digest + cpu.run_back(c, len(prog)) + diff = sum(bin(x ^ y).count("1") for x, y in zip(c["R"], seed["R"])) + print(f"counterfactual: flip ONE digest bit, run history backward -> the " + f"reconstructed 'seed' is wrong in {diff}/32 register bits") + print(f" corrupted 'seed' = {c['R']} true seed = {seed['R']}") + print("\n History inverts only when it is the true history. A tampered") + print(" future does not lead back to the real past.") + + +def cmd_pi(): + pi9, dsA, dsB = native() + mem = pi_build(dsA, dsB) + print("neural_rv32: Machin's pi on the threshold datapath") + print("=" * 56) + print("loading neural_rv32 (8.77M ternary parameters)...") + cpu = mc.Rv32ThresholdCPU() + ts = mc.rv_state(mem) + ts["_mem_bits"] = torch.tensor([ev.int_to_bits(b, 8) for b in ts["mem"]], dtype=torch.float32) + t0, cyc = time.perf_counter(), 0 + while not ts["halted"] and cyc < 400: + ts = cpu.step(ts); cyc += 1 + want = f"{pi9 // 10**8}.{pi9 % 10**8:08d}" + print(f"console output : {ts['console']!r} ({cyc} cycles, " + f"{time.perf_counter()-t0:.0f}s, dual-issue pairs {cpu.pairs_issued})") + print(f"integer mirror : {want!r} (machine {'EXACT' if ts['console']==want else 'MISMATCH'})") + print(f"true pi : 3.14159265(35...) -> all printed digits " + f"{'correct' if ts['console']=='3.14159265' else 'NOT all correct'}") + + +def cmd_nets(): + os.chdir(REPO) + torch.manual_seed(0) + print("=" * 72) + print(" Neural networks running as software on the CPU that IS a neural net") + print("=" * 72) + cpu = mc.Rv32ThresholdCPU() + + print("\n[1] LEARNED by gradient descent -- popcount BAND: fire iff 3<=popcount<=5") + print(" (non-monotone, so no single threshold can do it; genuinely nonlinear)") + hidden, out, acc = learn(band, H=8, seeds=12, epochs=5000) + deploy_check(cpu, band, hidden, out, + [0, 1, 3, 7, 15, 31, 63, 127, 255, 0b10110, 0b1111000, 0b11011011]) + line = "".join(f"{k}:{discrete_net((1 << k) - 1, hidden, out)} " for k in range(9)) + print(f" net output vs popcount k: {line.strip()} (true band: k in 3,4,5)") + + print("\n[2] COMPILED by construction -- 8-bit PARITY (XOR), the non-separable") + print(" function Minsky & Papert used to end the single perceptron") + hidden, out = compile_parity(8) + acc = sum(discrete_net(x, hidden, out) == parity(x) for x in range(256)) / 256 + print(f" thermometer net (8 hidden 'popcount>=k' + alternating output), all " + f"weights in {{-1,0,+1}}: {acc*100:.0f}% exact over all 256 bytes") + rows = deploy_check(cpu, parity, hidden, out, + [0, 1, 3, 7, 15, 31, 85, 127, 128, 170, 200, 254, 255]) + for x, yc, yt in rows: + print(f" parity({x:3d}) = {yc} (true {yt}) [{'ok' if yc==yt else 'x'}]") + + print("\n One network learned by SGD, one compiled by hand, both executed") + print(" instruction by instruction on a CPU whose every gate is a threshold") + print(" neuron. The machine running the models belongs to the models' own class.") + + +def cmd_constructor(): + os.chdir(REPO) + target_path = os.path.join(REPO, "variants", "neural_tile.safetensors") + data = open(target_path, "rb").read() + tape = ho.c8_describe(data) + print("neural_subleq8io: the universal constructor fabricates a sibling") + print("=" * 60) + print(f"recipe compiled: {len(data)} B machine -> {len(tape)} B tape") + + out_ref, n_ref = ho.ref_construct(tape) + assert out_ref == data + print(f"reference: constructor needs {n_ref:,} instructions") + + host = ho.GateHost() + t0 = time.perf_counter() + out, n, _ = host.run(tape, max_steps=n_ref + 100) + dt = time.perf_counter() - t0 + sha_out = hashlib.sha256(out).hexdigest()[:16] + sha_tgt = hashlib.sha256(data).hexdigest()[:16] + print(f"gate netlist: {n:,} instructions in {dt:.0f}s ({n / dt:.0f} instr/s), " + f"{len(out)} bytes emitted") + print(f" sha256 fabricated={sha_out} shipped={sha_tgt} " + f"{'BYTE-IDENTICAL' if out == data else 'MISMATCH'}; " + f"instruction count == reference: {n == n_ref}") + + # boot the fabricated machine: regrow its counter from its own bytes + fab = os.path.join(os.environ.get("TEMP", "."), "fabricated_neural_tile.safetensors") + open(fab, "wb").write(out) + from safetensors.torch import load_file + from safetensors import safe_open + import automata as T + + t = load_file(fab) + with safe_open(fab, framework="pt") as f: + m = f.metadata() + gl = json.loads(m["glues"]) + strg = {gl[i]: int(v) for i, v in enumerate(t["glue_strength"].tolist())} + tiles = [] + for row, name in zip(t["tile_glues"].tolist(), json.loads(m["tile_names"])): + sides = [gl[i] if i >= 0 else "" for i in row] + tiles.append(T.Tile(N=sides[0], E=sides[1], S=sides[2], W=sides[3], name=name)) + NB = 8 + rows = (1 << NB) - 1 + A, det = T.grow(tiles, T.counter_seed(NB), int(m["tau"]), strg, (0, 0, NB, rows)) + bad = filled = 0 + for y in range(1, rows + 1): + cells = [A.get((x, y)) for x in range(NB)] + if any(cc is None for cc in cells): + continue + filled += 1 + v = sum((1 if cc.N == "b1" else 0) << (NB - 1 - x) for x, cc in enumerate(cells)) + bad += (v != y) + print(f"boot: the fabricated file grew its counter, {filled} rows, " + f"row y encodes y: {'EXACT' if bad == 0 and filled == rows else f'{bad} bad'}") + + +def cmd_pascal(): + N = 100 + print("neural_tile: Pascal mod 2 by threshold-gated self-assembly") + print("=" * 60) + t0 = time.perf_counter() + ts = T.rule2_tileset(lambda w, s: w ^ s) + seed = T._row_col_seed([1] * (N + 1), [1] * (N + 1)) + A, det = T.grow(ts, seed, tau=2, strength={}, bounds=(0, 0, N, N), max_tiles=200000) + dt = time.perf_counter() - t0 + + interior = [(x, y) for x in range(1, N + 1) for y in range(1, N + 1)] + grown = sum(1 for p in interior if p in A) + bad = 0 + for (x, y) in interior: + v = 1 if A[(x, y)].N == "v1" else 0 + lucas = 1 if (x & y) == 0 else 0 # C(x+y,x) odd <=> no carry in x+y + bad += (v != lucas) + print(f"grew {grown} rule tiles in {dt:.1f}s (directed={det}, " + f"{2 * N} anti-diagonals deep)") + print(f"tile(x,y) == [C(x+y,x) is odd] for all {len(interior)} cells: " + f"{'EXACT' if bad == 0 else f'{bad} MISMATCHES'}") + print("\ncorner of the assembly (30x30, '#' = odd binomial):") + for y in range(30, 0, -1): + print(" " + "".join("#" if A[(x, y)].N == "v1" else "." for x in range(1, 31))) + + +COMMANDS = {'play': main, 'factoring': cmd_factoring, 'nqueens': cmd_nqueens, 'loschmidt': cmd_loschmidt, 'cipher': cmd_cipher, 'collatz': cmd_collatz, 'gcd': cmd_gcd, 'sieve': cmd_sieve, 'fleet': cmd_fleet, 'stack': cmd_stack, 'counterfactual': cmd_counterfactual, 'pi': cmd_pi, 'nets': cmd_nets, 'constructor': cmd_constructor, 'pascal': cmd_pascal} + + if __name__ == "__main__": - sys.exit(main()) + if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS: + sys.exit(COMMANDS['play']()) + cmd = sys.argv.pop(1) + sys.argv[0] += ' ' + cmd + sys.exit(COMMANDS[cmd]()) diff --git a/tools/prune_weights.py b/tools/prune_weights.py deleted file mode 100644 index 495e86fb91fcb380f60237355a5cd172b92579f1..0000000000000000000000000000000000000000 --- a/tools/prune_weights.py +++ /dev/null @@ -1,487 +0,0 @@ -""" -BATCHED WEIGHT PRUNING (GPU-optimized) -====================================== -Phase 1: Batch eval all candidates in parallel -Phase 2: Apply all successes at once, binary search if conflicts -""" - -import argparse -import os -import sys -import time - -import torch -from safetensors.torch import save_file - -sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src")) # src/ at repo root -from eval import BatchedFitnessEvaluator, create_population, load_model - -torch.manual_seed(0) - -DEFAULT_OUTPUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "pruned.safetensors") - - -def format_time(seconds): - if seconds < 60: - return f"{seconds:.1f}s" - elif seconds < 3600: - return f"{seconds/60:.1f}m" - else: - return f"{seconds/3600:.1f}h" - - -def format_eta(elapsed, done, total): - if done == 0: - return "calculating..." - rate = done / elapsed - remaining = (total - done) / rate - return format_time(remaining) - - -def apply_reductions(model, reductions): - """Apply a list of (name, flat_idx, shape, old_val) reductions.""" - for name, flat_idx, shape, old_val in reductions: - new_val = old_val - 1 if old_val > 0 else old_val + 1 - flat = model[name].flatten() - if flat[flat_idx].item() == old_val: - flat[flat_idx] = new_val - model[name] = flat.view(shape) - - -def revert_reductions(model, reductions): - """Revert a list of reductions.""" - for name, flat_idx, shape, old_val in reductions: - flat = model[name].flatten() - new_val = old_val - 1 if old_val > 0 else old_val + 1 - if flat[flat_idx].item() == new_val: - flat[flat_idx] = old_val - model[name] = flat.view(shape) - - -def check_fitness(model, evaluator, device): - """Check model fitness.""" - torch.manual_seed(0) - pop = create_population(model, 1, device) - return evaluator.evaluate(pop, debug=False)[0].item() - - -def sequential_conflict_resolution(model, evaluator, device, candidates, base_magnitude): - """ - Sequential fallback - tests and applies reductions one at a time. - Slower but guarantees no interaction bugs. - """ - accepted = [] - for i, (name, flat_idx, shape, old_val) in enumerate(candidates): - apply_reductions(model, [(name, flat_idx, shape, old_val)]) - fitness = check_fitness(model, evaluator, device) - if fitness >= 0.9999: - accepted.append((name, flat_idx, shape, old_val)) - if (i + 1) % 50 == 0: - current_mag = sum(t.abs().sum().item() for t in model.values()) - reduction_pct = 100 * (1 - current_mag / base_magnitude) - print(f" Sequential: {len(accepted)}/{i+1} accepted | mag={current_mag:.0f} (-{reduction_pct:.2f}%)") - else: - revert_reductions(model, [(name, flat_idx, shape, old_val)]) - return accepted - - -def batched_conflict_resolution(model, evaluator, device, candidates, base_magnitude): - """ - Batched binary search - evaluates multiple branches in parallel. - Uses BFS instead of DFS to maximize batching opportunities. - Verifies cumulative effect after each batch to prevent interaction bugs. - """ - if len(candidates) == 0: - return [] - - # First try all at once - print(f" Trying {len(candidates)} reductions at once...") - apply_reductions(model, candidates) - fitness = check_fitness(model, evaluator, device) - - if fitness >= 0.9999: - current_mag = sum(t.abs().sum().item() for t in model.values()) - reduction_pct = 100 * (1 - current_mag / base_magnitude) - print(f" ALL {len(candidates)} OK | fitness={fitness:.6f} | " - f"mag={current_mag:.0f} (-{reduction_pct:.2f}%)") - return candidates - - # Conflict - revert and use batched BFS - revert_reductions(model, candidates) - print(f" CONFLICT (fitness={fitness:.6f}), starting batched resolution...") - - accepted = [] - # Queue of (candidate_list, depth) to process - pending = [(candidates, 0)] - - while pending: - # Collect all pending groups for batch evaluation - to_eval = [] - for group, depth in pending: - if len(group) == 0: - continue - elif len(group) == 1: - to_eval.append((group, depth, 'single')) - else: - to_eval.append((group, depth, 'group')) - - pending = [] - - if not to_eval: - break - - # Build batch: create model variants for each group - batch_size = len(to_eval) - print(f" Batch evaluating {batch_size} groups...") - - # Create population for batch eval - pop = {} - for name, tensor in model.items(): - pop[name] = tensor.unsqueeze(0).expand(batch_size, *tensor.shape).clone().to(device) - - # Apply each group's reductions to its population slot - for idx, (group, depth, gtype) in enumerate(to_eval): - for name, flat_idx, shape, old_val in group: - new_val = old_val - 1 if old_val > 0 else old_val + 1 - flat_view = pop[name][idx].flatten() - # Check if not already modified in base model - base_val = model[name].flatten()[flat_idx].item() - if base_val == old_val: - flat_view[flat_idx] = new_val - - # Batch evaluate - torch.manual_seed(0) - fitnesses = evaluator.evaluate(pop, debug=False) - - # Process results - collect accepted groups first, then verify - batch_accepted = [] - ok_count = 0 - conflict_count = 0 - fail_count = 0 - - for idx, (group, depth, gtype) in enumerate(to_eval): - fit = fitnesses[idx].item() - indent = " " + " " * depth - - if fit >= 0.9999: - batch_accepted.append((group, depth, indent)) - ok_count += len(group) - else: - if len(group) == 1: - name, flat_idx, shape, old_val = group[0] - print(f"{indent}[1/1] FAIL {name}[{flat_idx}] | fitness={fit:.6f}") - fail_count += 1 - else: - mid = len(group) // 2 - left = group[:mid] - right = group[mid:] - print(f"{indent}CONFLICT ({len(group)}) fitness={fit:.6f} -> split {len(left)}+{len(right)}") - pending.append((left, depth + 1)) - pending.append((right, depth + 1)) - conflict_count += 1 - - # Apply all batch-accepted reductions - all_batch_reductions = [] - for group, depth, indent in batch_accepted: - apply_reductions(model, group) - all_batch_reductions.extend(group) - - # Verify cumulative effect - if all_batch_reductions: - verify_fitness = check_fitness(model, evaluator, device) - if verify_fitness >= 0.9999: - # All good - commit these reductions - for group, depth, indent in batch_accepted: - current_mag = sum(t.abs().sum().item() for t in model.values()) - reduction_pct = 100 * (1 - current_mag / base_magnitude) - if len(group) == 1: - name, flat_idx, shape, old_val = group[0] - print(f"{indent}[1/1] OK {name}[{flat_idx}] | mag={current_mag:.0f} (-{reduction_pct:.2f}%)") - else: - print(f"{indent}ALL {len(group)} OK | mag={current_mag:.0f} (-{reduction_pct:.2f}%)") - accepted.extend(all_batch_reductions) - print(f" Batch result: {ok_count} accepted, {conflict_count} split, {fail_count} failed") - else: - # Interaction bug detected - revert and use sequential fallback - print(f" INTERACTION BUG detected (batch fitness={verify_fitness:.6f})") - print(f" Reverting {len(all_batch_reductions)} reductions, falling back to sequential...") - revert_reductions(model, all_batch_reductions) - - # Process each group sequentially - seq_accepted = sequential_conflict_resolution( - model, evaluator, device, all_batch_reductions, base_magnitude - ) - accepted.extend(seq_accepted) - print(f" Sequential fallback: {len(seq_accepted)}/{len(all_batch_reductions)} accepted") - else: - print(f" Batch result: {ok_count} accepted, {conflict_count} split, {fail_count} failed") - - return accepted - - -def prune_weights( - passes: int = 10, - batch_size: int = 5000, - device: str = 'cuda', - checkpoint_path: str = DEFAULT_OUTPUT -): - print("=" * 80) - print(" BATCHED WEIGHT PRUNING (GPU-optimized)") - print("=" * 80) - print(f" Device: {device}") - print(f" Batch size: {batch_size}") - print(f" Max passes: {passes}") - print("=" * 80) - - # Load model - print("\n[1/4] LOADING MODEL...") - load_start = time.perf_counter() - model = load_model() - load_time = time.perf_counter() - load_start - - n_params = sum(t.numel() for t in model.values()) - n_tensors = len(model) - base_magnitude = sum(t.abs().sum().item() for t in model.values()) - base_max = max(t.abs().max().item() for t in model.values()) - nonzero_params = sum((t != 0).sum().item() for t in model.values()) - - print(f" Loaded in {load_time:.2f}s") - print(f" Tensors: {n_tensors}") - print(f" Parameters: {n_params}") - print(f" Non-zero parameters: {nonzero_params}") - print(f" Total magnitude: {base_magnitude:.0f}") - print(f" Max weight: {base_max:.0f}") - - # Initialize evaluator - print("\n[2/4] INITIALIZING EVALUATOR...") - eval_start = time.perf_counter() - evaluator = BatchedFitnessEvaluator(device=device) - eval_time = time.perf_counter() - eval_start - print(f" Initialized in {eval_time:.2f}s") - - # Verify initial fitness - print("\n[3/4] VERIFYING BASE MODEL...") - initial_fitness = check_fitness(model, evaluator, device) - print(f" Fitness: {initial_fitness:.6f}") - - if initial_fitness < 0.9999: - print(f" ERROR: Base model fitness {initial_fitness:.6f} < 0.9999") - return None - - print(f" STATUS: PASS") - - # Build parameter list - print("\n[4/4] BUILDING PARAMETER INDEX...") - param_list = [] - for name, tensor in model.items(): - flat = tensor.flatten() - for i in range(len(flat)): - param_list.append((name, i, tensor.shape)) - print(f" Indexed {len(param_list)} parameters") - - # Main pruning loop - print("\n" + "=" * 80) - print(" PRUNING STARTED") - print("=" * 80) - - total_reductions = 0 - pruning_start = time.perf_counter() - - for pass_num in range(passes): - torch.manual_seed(0) - pass_start = time.perf_counter() - - print(f"\n{'='*80}") - print(f" PASS {pass_num + 1}/{passes}") - print(f"{'='*80}") - - # Count candidates - candidates = [] - for name, idx, shape in param_list: - flat = model[name].flatten() - val = flat[idx].item() - if val != 0: - candidates.append((name, idx, shape, val)) - - n_candidates = len(candidates) - print(f"\n Candidates: {n_candidates} non-zero weights") - - if n_candidates == 0: - print(f" No candidates remaining. Stopping.") - break - - # Phase 1: Batch evaluation - print(f"\n PHASE 1: Batch evaluation (testing each reduction independently)") - print(f" " + "-" * 60) - phase1_start = time.perf_counter() - successful_candidates = [] - n_batches = (n_candidates + batch_size - 1) // batch_size - - for batch_idx, batch_start_idx in enumerate(range(0, n_candidates, batch_size)): - batch = candidates[batch_start_idx:batch_start_idx + batch_size] - batch_len = len(batch) - batch_start_time = time.perf_counter() - - # Build population - pop = {} - for name, tensor in model.items(): - pop[name] = tensor.unsqueeze(0).expand(batch_len, *tensor.shape).clone().to(device) - - # Apply reductions - for pop_idx, (name, flat_idx, shape, old_val) in enumerate(batch): - new_val = old_val - 1 if old_val > 0 else old_val + 1 - flat_view = pop[name][pop_idx].flatten() - flat_view[flat_idx] = new_val - - # Evaluate - torch.manual_seed(0) - if device == 'cuda': - torch.cuda.synchronize() - fitness = evaluator.evaluate(pop, debug=False) - if device == 'cuda': - torch.cuda.synchronize() - - # Collect successes - batch_successes = 0 - for pop_idx, (name, flat_idx, shape, old_val) in enumerate(batch): - if fitness[pop_idx].item() >= 0.9999: - successful_candidates.append((name, flat_idx, shape, old_val)) - batch_successes += 1 - - batch_time = time.perf_counter() - batch_start_time - elapsed = time.perf_counter() - phase1_start - done = batch_start_idx + batch_len - eta = format_eta(elapsed, done, n_candidates) - throughput = batch_len / batch_time - - print(f" Batch {batch_idx + 1}/{n_batches}: " - f"{batch_successes}/{batch_len} passed ({100*batch_successes/batch_len:.1f}%) | " - f"Total OK: {len(successful_candidates)} | " - f"Progress: {done}/{n_candidates} ({100*done/n_candidates:.1f}%) | " - f"Speed: {throughput:.0f}/s | " - f"ETA: {eta}") - - phase1_time = time.perf_counter() - phase1_start - print(f"\n Phase 1 complete: {len(successful_candidates)}/{n_candidates} candidates " - f"({100*len(successful_candidates)/n_candidates:.1f}%) in {format_time(phase1_time)}") - - # Phase 2: Apply with conflict resolution - if len(successful_candidates) == 0: - print(f"\n No reductions possible. Stopping.") - break - - print(f"\n PHASE 2: Apply reductions with conflict resolution") - print(f" " + "-" * 60) - phase2_start = time.perf_counter() - - accepted = batched_conflict_resolution(model, evaluator, device, successful_candidates, base_magnitude) - pass_reductions = len(accepted) - - phase2_time = time.perf_counter() - phase2_start - print(f"\n Phase 2 complete: {pass_reductions} reductions applied in {format_time(phase2_time)}") - - # Pass summary - total_reductions += pass_reductions - current_magnitude = sum(t.abs().sum().item() for t in model.values()) - current_nonzero = sum((t != 0).sum().item() for t in model.values()) - pass_time = time.perf_counter() - pass_start - reduction_pct = 100 * (1 - current_magnitude / base_magnitude) - - print(f"\n PASS {pass_num + 1} SUMMARY:") - print(f" Reductions this pass: {pass_reductions}") - print(f" Total reductions: {total_reductions}") - print(f" Current magnitude: {current_magnitude:.0f} (-{reduction_pct:.2f}%)") - print(f" Current non-zero: {current_nonzero}") - print(f" Pass time: {format_time(pass_time)}") - - # Verify after pass - print(f"\n Verifying model integrity...") - fitness = check_fitness(model, evaluator, device) - print(f" Fitness: {fitness:.6f} {'PASS' if fitness >= 0.9999 else 'FAIL'}") - - # Save checkpoint after each pass - checkpoint_name = checkpoint_path.replace('.safetensors', f'_pass{pass_num + 1}.safetensors') - print(f"\n Saving checkpoint: {checkpoint_name}") - save_file(model, checkpoint_name) - print(f" Saved. Magnitude: {current_magnitude:.0f} (-{reduction_pct:.2f}%)") - - # Also save as "latest" for easy access - latest_path = checkpoint_path.replace('.safetensors', '_latest.safetensors') - save_file(model, latest_path) - print(f" Also saved as: {latest_path}") - - if pass_reductions == 0: - print(f"\n No reductions achieved. Stopping early.") - break - - # Final summary - pruning_time = time.perf_counter() - pruning_start - final_magnitude = sum(t.abs().sum().item() for t in model.values()) - final_max = max(t.abs().max().item() for t in model.values()) - final_nonzero = sum((t != 0).sum().item() for t in model.values()) - reduction_pct = 100 * (1 - final_magnitude / base_magnitude) - - print("\n" + "=" * 80) - print(" PRUNING COMPLETE") - print("=" * 80) - print(f"\n RESULTS:") - print(f" Original magnitude: {base_magnitude:.0f}") - print(f" Final magnitude: {final_magnitude:.0f}") - print(f" Reduction: {reduction_pct:.2f}%") - print(f" Total reductions: {total_reductions}") - print(f" Original non-zero: {nonzero_params}") - print(f" Final non-zero: {final_nonzero}") - print(f" Zeros created: {nonzero_params - final_nonzero}") - print(f" Max weight: {final_max:.0f}") - print(f" Total time: {format_time(pruning_time)}") - - # Save - print(f"\n SAVING to {checkpoint_path}...") - save_file(model, checkpoint_path) - print(f" Saved.") - - # Final verification - print(f"\n FINAL VERIFICATION...") - from safetensors import safe_open - f = safe_open(checkpoint_path, framework='numpy') - verify_model = {name: torch.tensor(f.get_tensor(name)).float() for name in f.keys()} - verify_fitness = check_fitness(verify_model, evaluator, device) - print(f" Fitness: {verify_fitness:.6f}") - - if verify_fitness >= 0.9999: - print(f" STATUS: PASS") - else: - print(f" STATUS: FAIL - Model corrupted!") - - print("\n" + "=" * 80) - return model - - -MAX_BATCH_SIZE = 80000 - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description='Batched Weight Pruning') - parser.add_argument('--passes', type=int, default=10, - help='Maximum pruning passes (default: 10)') - parser.add_argument('--batch_size', type=int, default=80000, - help=f'Batch size for parallel evaluation (default: 80000, max: {MAX_BATCH_SIZE})') - parser.add_argument('--device', type=str, default='cuda', - help='Device: cuda or cpu (default: cuda)') - parser.add_argument('--output', type=str, default=DEFAULT_OUTPUT, - help='Output path') - args = parser.parse_args() - - if args.batch_size > MAX_BATCH_SIZE: - print(f"WARNING: batch_size {args.batch_size} exceeds maximum {MAX_BATCH_SIZE}. Clamping.") - args.batch_size = MAX_BATCH_SIZE - - print(f"\nStarting at {time.strftime('%Y-%m-%d %H:%M:%S')}\n") - - prune_weights( - passes=args.passes, - batch_size=args.batch_size, - device=args.device, - checkpoint_path=args.output - ) - - print(f"\nFinished at {time.strftime('%Y-%m-%d %H:%M:%S')}") diff --git a/tools/reversible_matrix.py b/tools/reversible_matrix.py deleted file mode 100644 index 797d8d70dc7916c58ee080ea4a03815f7b7d588b..0000000000000000000000000000000000000000 --- a/tools/reversible_matrix.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Compile a reversible circuit to a ternary matrix stack and show the composed -transition is a permutation, realized on a crossbar with a measured noise -margin. This substantiates neural_reversible's no-erasure claim concretely with -the same matrix/crossbar machinery neural_matrix8 uses: a reversible circuit is -one product of ternary matrices with a Heaviside step, and because the circuit -is a bijection the composed map is a permutation of the state space, so every -matrix in the stack is applied without erasing information.""" -from __future__ import annotations -import os -import sys - -import torch - -ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -sys.path.insert(0, os.path.join(ROOT, "src")) -import reversible as rv -from matrix8 import Net, compile_net, MatrixMachine - - -def adder_net(width: int): - """The in-place Cuccaro adder (b <- a+b) as a feedforward ternary netlist: - each reversible gate writes a fresh wire, so the input->output map is the - permutation the circuit computes.""" - a_bits = list(range(width)) - b_bits = list(range(width, 2 * width)) - carry = 2 * width - n = 2 * width + 1 - ops = rv._adder_ops(a_bits, b_bits, carry) - net = Net() - inputs = [f"in{i}" for i in range(n)] - cur = list(inputs) - for k, (gate, *args) in enumerate(ops): - if gate is rv.CNOT: - c, t = args - cur[t] = net.XOR(f"c{k}", cur[t], cur[c]) # t ^= c - else: # Toffoli: t ^= a&b - a, b, t = args - tmp = net.AND(f"t{k}a", [cur[a], cur[b]]) - cur[t] = net.XOR(f"t{k}x", cur[t], tmp) - return net, inputs, list(cur), n, a_bits, b_bits, carry - - -def _refs(n, a_bits, b_bits, carry): - out = [] - for x in range(1 << n): - reg = [(x >> i) & 1 for i in range(n)] - rv._apply(reg, rv._adder_ops(a_bits, b_bits, carry)) - out.append(sum(reg[i] << i for i in range(n))) - return torch.tensor(out) - - -def _outputs(mm, states, n, **step_kw): - v = mm.step(states.clone(), **step_kw) - bits = (v >= 0.5).to(torch.int64) - weights = torch.tensor([1 << i for i in range(n)]) - return (bits * weights).sum(dim=1) - - -def analog_sweep(mm, states, refs, n): - """The permutation must survive analog imperfection. Read noise is injected - per matrix-vector product; conductance mismatch is a fixed per-device - perturbation of the ternary weights.""" - print(" read-noise sweep (bit-exact = all 512 outputs still match, 4 trials):") - for sigma in (0.05, 0.10, 0.15, 0.20): - ok = all((_outputs(mm, states, n, analog=True, noise_sigma=sigma, - gen=torch.Generator().manual_seed(s)) == refs).all() - for s in range(4)) - print(f" sigma={sigma:.2f}: {'bit-exact' if ok else 'errors appear'}") - print(" conductance-mismatch sweep (fixed per-device weight perturbation):") - for sg in (0.05, 0.10, 0.15): - ok = (_outputs(mm.perturbed(sg, seed=0), states, n, analog=True) == refs).all() - print(f" sigma_G={sg:.2f}: {'bit-exact' if ok else 'errors appear'}") - - -def main() -> int: - width = 4 - net, inputs, outputs, n, a_bits, b_bits, carry = adder_net(width) - layers, info = compile_net(net, inputs, outputs) - mm = MatrixMachine(layers) - - states = torch.stack([torch.tensor([float((x >> i) & 1) for i in range(n)]) - for x in range(1 << n)]) - refs = _refs(n, a_bits, b_bits, carry) - got = _outputs(mm, states, n) - bad = int((got != refs).sum()) - perm = len(set(got.tolist())) == (1 << n) - margin = mm.min_margin(states[:256]) - - print(f"reversible {width}-bit adder as a ternary matrix stack") - print(f" layers={info['layers']} gates={info['gates']} " - f"max_width={info['max_width']} total_weights={info['total_weights']}") - print(f" every weight ternary: {'OK' if all(((W == -1) | (W == 0) | (W == 1)).all() for W, _ in layers) else 'FAIL'}") - print(f" matches the gate circuit over all {1 << n} inputs: {'OK' if bad == 0 else f'FAIL({bad})'}") - print(f" composed transition is a permutation of the state space: {'OK' if perm else 'FAIL'}") - print(f" analog noise margin, all layers: {margin:.3f} (guarantee 0.5)") - analog_sweep(mm, states, refs, n) - ok = bad == 0 and perm and abs(margin - 0.5) < 1e-6 - print("PASS" if ok else "FAIL") - return 0 if ok else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tools/test_attractor.py b/tools/test_attractor.py deleted file mode 100644 index 31dda3e8864d76d0e2aea1a9f645693c2e662fa4..0000000000000000000000000000000000000000 --- a/tools/test_attractor.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Exercise the attractor computer: exact forward evaluation, the canonical -whole-network energy relaxation, backward inversion (factoring), and SAT -solving (universality of the solve direction).""" -from __future__ import annotations -import os -import random -import sys - -sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src")) -from attractor import Circuit, adder, multiplier, cnf - - -def test_forward(): - ok = True - for bits in (4, 8): - c, io = adder(bits) - rng = random.Random(bits) - bad = 0 - for _ in range(300): - a, b = rng.randint(0, (1 << bits) - 1), rng.randint(0, (1 << bits) - 1) - clamp = {io["cin"]: 0} - for k in range(bits): - clamp[io["xs"][k]] = (a >> k) & 1 - clamp[io["ys"][k]] = (b >> k) & 1 - s = c.forward_eval(clamp) - got = sum(s[w] << k for k, w in enumerate(io["sum"])) - if got != a + b or c.energy(s) != 0: - bad += 1 - print(f" forward adder {bits}-bit: {'OK' if bad == 0 else f'FAIL({bad})'}") - ok &= bad == 0 - for bits in (3, 5): - c, io = multiplier(bits) - rng = random.Random(100 + bits) - bad = 0 - for _ in range(300): - a, b = rng.randint(0, (1 << bits) - 1), rng.randint(0, (1 << bits) - 1) - clamp = {io["zero"]: 0} - for k in range(bits): - clamp[io["xs"][k]] = (a >> k) & 1 - clamp[io["ys"][k]] = (b >> k) & 1 - s = c.forward_eval(clamp) - got = sum(s[w] << k for k, w in enumerate(io["prod"])) - if got != a * b or c.energy(s) != 0: - bad += 1 - print(f" forward multiplier {bits}-bit: {'OK' if bad == 0 else f'FAIL({bad})'}") - ok &= bad == 0 - return ok - - -def test_energy_relax(): - """The canonical form: anneal the whole network (no propagation shortcut).""" - c, io = adder(4) - rng = random.Random(3) - bad = 0 - for _ in range(20): - a, b = rng.randint(0, 15), rng.randint(0, 15) - clamp = {io["cin"]: 0} - for k in range(4): - clamp[io["xs"][k]] = (a >> k) & 1 - clamp[io["ys"][k]] = (b >> k) & 1 - conv = False - for attempt in range(4): # annealers restart - s, conv = c.relax_energy(clamp, sweeps=6000, seed=rng.randint(0, 1 << 30)) - got = sum(s[w] << k for k, w in enumerate(io["sum"])) - if conv and got == a + b: - break - if not conv: - bad += 1 - print(f" whole-network energy relaxation (4-bit adder, 20 cases): " - f"{'OK' if bad == 0 else f'reached ground state in {20 - bad}/20'}") - return bad == 0 - - -def test_factor(): - ok = True - for bits, targets in ((4, [15, 35, 143]), (5, [21, 55, 91])): - c, io = multiplier(bits) - for N in targets: - target = {io["prod"][k]: (N >> k) & 1 for k in range(2 * bits)} - s = c.solve(io["xs"] + io["ys"], {io["zero"]: 0}, target, seed=N) - if s is None: - print(f" factor {N}: not found") - ok = False - continue - a = sum(s[io["xs"][k]] << k for k in range(bits)) - b = sum(s[io["ys"][k]] << k for k in range(bits)) - good = a * b == N and 1 < a < N and 1 < b < N - print(f" factor {N} ({bits}x{bits}): {a} x {b} {'OK' if a * b == N else 'WRONG'}") - ok &= a * b == N - return ok - - -def test_sat(): - # (x1 | x2 | ~x3) & (~x1 | x3) & (x2 | x3) & (~x2 | ~x3), a satisfiable 3-SAT. - clauses = [[1, 2, -3], [-1, 3], [2, 3], [-2, -3]] - c, io = cnf(clauses, 3) - s = c.solve(list(io["vars"].values()), {}, {io["sat"]: 1}, seed=1) - if s is None: - print(" SAT solve: no model found") - return False - assign = {v: s[w] for v, w in io["vars"].items()} - sat = all(any((assign[abs(l)] == 1) if l > 0 else (assign[abs(l)] == 0) for l in cl) - for cl in clauses) - print(f" SAT solve: model {assign} {'satisfies' if sat else 'FAILS'} the formula") - return sat - - -if __name__ == "__main__": - print("Attractor computer\n" + "=" * 40) - print("Forward evaluation (exact, energy 0):") - a = test_forward() - print("Canonical relaxation:") - b = test_energy_relax() - print("Backward inversion (factoring by relaxation):") - c_ = test_factor() - print("SAT (clamp output to 1, relax to a model):") - d = test_sat() - print("=" * 40) - print("ALL PASS" if (a and b and c_ and d) else "FAILURES") - sys.exit(0 if (a and b and c_ and d) else 1) diff --git a/tools/test_cpu.py b/tools/test_cpu.py deleted file mode 100644 index 66c5fc5357f9efeb3cb1d9f605fc015bbf1a0d22..0000000000000000000000000000000000000000 --- a/tools/test_cpu.py +++ /dev/null @@ -1,139 +0,0 @@ -""" -Run the CPU program suite against a threshold-computer variant. - -Loads weights, instantiates GenericThresholdCPU, runs each program from -cpu_programs.SUITE, and verifies expected memory contents at HALT. - -Usage: - python test_cpu.py # default: 1KB variant, fast - python test_cpu.py --model neural_computer.safetensors # 64KB canonical, slow - python test_cpu.py --only fib,sum_n # subset of suite -""" - -from __future__ import annotations -import argparse -import os -import sys -import time -from pathlib import Path - -import torch -from safetensors import safe_open - -REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # this file lives in tools/ -sys.path.insert(0, os.path.join(REPO, "src")) # eval_all lives in src/ -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # cpu_programs is a sibling -from eval_all import GenericThresholdCPU, get_manifest -from cpu_programs import SUITE - - -def load_tensors(path: Path): - out = {} - with safe_open(str(path), framework="pt") as f: - for name in f.keys(): - out[name] = f.get_tensor(name).float() - return out - - -def run_program(cpu: GenericThresholdCPU, mem, max_cycles: int): - addr_mask = (1 << cpu.addr_bits) - 1 - state = { - "pc": 0, - "regs": [0] * 4, - "flags": [0] * 4, - "mem": list(mem), - "halted": False, - "sp": addr_mask, - } - t0 = time.perf_counter() - final, cycles = cpu.run(state, max_cycles=max_cycles) - return final, cycles, time.perf_counter() - t0 - - -def check_expected(final, expected: dict) -> tuple[bool, list[str]]: - failures = [] - for addr, want in expected.items(): - got = final["mem"][addr] - if got != want: - failures.append(f"M[0x{addr:04X}] = {got} (expected {want})") - return (len(failures) == 0), failures - - -def main() -> int: - parser = argparse.ArgumentParser(description="Run the CPU program suite") - parser.add_argument( - "--model", type=str, - default=os.path.join(REPO, "variants", "neural_computer8_small.safetensors"), - help="Path to .safetensors variant", - ) - parser.add_argument( - "--only", type=str, default="", - help="Comma-separated subset of program names to run", - ) - args = parser.parse_args() - - print(f"Loading {args.model}") - tensors = load_tensors(Path(args.model)) - manifest = get_manifest(tensors) - print(f"Manifest: data={manifest['data_bits']}-bit, addr={manifest['addr_bits']}-bit, " - f"mem={manifest['memory_bytes']}B") - - if manifest["memory_bytes"] < 256: - print(f"ERROR: variant has {manifest['memory_bytes']}B memory; " - f"the suite needs at least 256B (scratchpad).") - return 2 - - cpu = GenericThresholdCPU(tensors) - only = set(s.strip() for s in args.only.split(",") if s.strip()) - - print() - print("=" * 80) - print(f" CPU PROGRAM SUITE ({manifest['memory_bytes']}B mem, {manifest['data_bits']}-bit ALU)") - print("=" * 80) - - pass_count = 0 - fail_count = 0 - skip_count = 0 - - for name, builder in SUITE: - if only and name not in only: - skip_count += 1 - continue - try: - mem, expected, max_cycles, desc = builder(manifest["memory_bytes"]) - except Exception as e: - print(f" {name:18} BUILD ERROR: {e}") - fail_count += 1 - continue - - if len(mem) != manifest["memory_bytes"]: - print(f" {name:18} SKIP (program built {len(mem)}B, " - f"variant has {manifest['memory_bytes']}B)") - skip_count += 1 - continue - - final, cycles, elapsed = run_program(cpu, mem, max_cycles) - ok, failures = check_expected(final, expected) - if ok and not final["halted"]: - ok = False - failures.append(f"did not HALT within {max_cycles} cycles") - - status = "PASS" if ok else "FAIL" - if ok: - pass_count += 1 - else: - fail_count += 1 - print(f" {name:18} {status} ({cycles:>3} cyc, {elapsed:>5.2f}s) {desc}") - if not ok: - for f in failures[:6]: - print(f" - {f}") - - print() - print("=" * 80) - total = pass_count + fail_count - print(f" PASS: {pass_count}/{total} FAIL: {fail_count}/{total} SKIP: {skip_count}") - return 0 if fail_count == 0 else 1 - - -if __name__ == "__main__": - sys.exit(main())