From 576f1af4c9fc82bbadbb9844b68d261c90f1f887 Mon Sep 17 00:00:00 2001 From: David Bloomin Date: Fri, 25 Sep 2026 13:06:25 -0700 Subject: [PATCH 1/4] polyworld: shared neural policy tier Game-agnostic neural seats for any Polyworld game: a strict package loader, the GOTANET1 model format with its FP32 MinGRU actor, and a host layer that a game drives through one NeuralContract. - neural_package.nim: ZIP of exactly manifest.json + policy.bas + model.bin; sha256 file pins; per-entry size caps checked before decompressing and a bounded inflate (boundedinflate.nim, adapted from zippy); strict keys and JSON kinds; game-supplied extra manifest/decoder keys and an options parser. Read-only results. - neural_actor.nim: GOTANET1 loader and inference, dims from the header, read-only Actor; loadActor(bytes, contract) checks the budget, contract hashes, inputs and heads. - neural_host.nim: NeuralContract (one constructor that hashes the contract texts and checks the logit count), NeuralBrain with the frame and recurrent-state lifecycle, the per-seat op budget, the telemetry line, argmax/sampling/masked decoders, and the BASIC functions run_neural_net / neuralObservation / neuralLogits / neuralState / neuralModel. - coworld.nim: PK-prefixed staged players are read up to 16 MiB; failPlayer ends an episode with a real reason. - coworld/runtime/neural_package.py: the Python staging validator, parameterized by contract. - tests: a toy contract proves the tier is game-agnostic; one corruption suite runs through both validators in CI. - docs/neural-policies.md: how to add neural seats to a game. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 6 + coworld/runtime/neural_package.py | 333 ++++++++++++++++++++++++++ docs/neural-policies.md | 214 +++++++++++++++++ src/polyworld/boundedinflate.nim | 326 ++++++++++++++++++++++++++ src/polyworld/coworld.nim | 35 ++- src/polyworld/neural_actor.nim | 222 ++++++++++++++++++ src/polyworld/neural_host.nim | 375 ++++++++++++++++++++++++++++++ src/polyworld/neural_package.nim | 341 +++++++++++++++++++++++++++ tests/neural_cases.py | 202 ++++++++++++++++ tests/neural_toy.nim | 190 +++++++++++++++ tests/test_neural_cases.nim | 36 +++ tests/test_neural_tier.nim | 303 ++++++++++++++++++++++++ tests/tests.nim | 1 + 13 files changed, 2575 insertions(+), 9 deletions(-) create mode 100644 coworld/runtime/neural_package.py create mode 100644 docs/neural-policies.md create mode 100644 src/polyworld/boundedinflate.nim create mode 100644 src/polyworld/neural_actor.nim create mode 100644 src/polyworld/neural_host.nim create mode 100644 src/polyworld/neural_package.nim create mode 100644 tests/neural_cases.py create mode 100644 tests/neural_toy.nim create mode 100644 tests/test_neural_cases.nim create mode 100644 tests/test_neural_tier.nim diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e27fe8aa..47f06f45 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,6 +23,12 @@ jobs: nimby install "${{ github.event.repository.name }}/${{ github.event.repository.name }}.nimble" nimby sync "${{ github.event.repository.name }}/nimby.lock" - run: nim r tests/tests.nim + - name: Neural packages, one corruption suite for both validators + shell: bash + run: | + PY=$(command -v python3 || command -v python) + "$PY" tests/neural_cases.py tmp/neural_cases + nim r tests/test_neural_cases.nim tmp/neural_cases - run: nim r tests/test_gota_events.nim - run: nim r -d:replayEvents tests/test_gota_controls.nim - run: nim r -d:replayEvents tests/test_gota_camps.nim diff --git a/coworld/runtime/neural_package.py b/coworld/runtime/neural_package.py new file mode 100644 index 00000000..37bd982c --- /dev/null +++ b/coworld/runtime/neural_package.py @@ -0,0 +1,333 @@ +"""Polyworld neural packages: staging validator and builder, shared by every game. + +A package is a ZIP holding exactly manifest.json, policy.bas and model.bin (GOTANET1, the +polyworld neural model format). This module mirrors src/polyworld/neural_package.nim rule for +rule, so a submission the staging step accepts is one the game accepts (and the reverse). A game +supplies a Contract (schema, contract hashes, observation size, head sizes, extra manifest and +decoder keys, and an optional options parser); see docs/neural-policies.md. Plain .bas +submissions are not packages and are untouched. + +Games wrap this module (e.g. coworld/gota/runtime/neural_package.py); it has no CLI of its own. +""" +import hashlib, io, json, math, struct, zipfile, zlib +from dataclasses import dataclass +from typing import Callable, Optional + +FILES = ("manifest.json", "policy.bas", "model.bin") +MAX_PACKAGE = 16 * 1024 * 1024 +MAX_POLICY = 256 * 1024 +MAX_MANIFEST = 64 * 1024 +MAX_PARAMS = 2_000_000 +MAX_MODEL = 8 + 24 + 128 + 4 * 32 + 4 * MAX_PARAMS +MAX_ENTRIES = 16 +LIMITS = {"manifest.json": MAX_MANIFEST, "policy.bas": MAX_POLICY, "model.bin": MAX_MODEL} +WIDTHS = (64, 128, 256) +MAX_INPUTS = 4096 +MAGIC = b"GOTANET1" +DEFAULT_OP_BUDGET = 4_000_000 +MANIFEST_KEYS = ("schema", "observation_contract", "action_contract", "decision_period", "files", "model", + "decoder") +REQUIRED = ("schema", "observation_contract", "action_contract", "decision_period", "files", "model") +MODEL_KEYS = ("format", "inputs", "hidden", "heads") +DECODER_KEYS = ("mode", "temperature") + + +class PackageError(ValueError): + pass + + +@dataclass +class Contract: + """The package half of a game's neural contract (neural_package.nim PackageContract).""" + schema: str + obs_hash: str + action_hash: str + obs_size: int + heads: list + max_period: int = 24 + op_budget: int = DEFAULT_OP_BUDGET + manifest_keys: tuple = () + decoder_keys: tuple = () + parse_options: Optional[Callable[[dict], object]] = None + """Validates the game's manifest_keys / decoder_keys (raise PackageError); returns options.""" + + +def contract_hash(text: str) -> str: + """A contract hash: lowercase SHA-256 hex of the contract's canonical text.""" + return hashlib.sha256(text.encode()).hexdigest() + + +def is_package(data: bytes) -> bool: + return data[:4] == b"PK\x03\x04" + + +def is_int(v) -> bool: + """A JSON integer: not a bool and not a float (1407.0 is rejected).""" + return isinstance(v, int) and not isinstance(v, bool) + + +def is_number(v) -> bool: + """A JSON number: int or float, never a bool.""" + return isinstance(v, (int, float)) and not isinstance(v, bool) + + +def require_keys(node, allowed, where): + if not isinstance(node, dict): + raise PackageError(f"{where} must be an object") + for key in node: + if key not in allowed: + raise PackageError(f"unknown manifest key {where}.{key}") + + +def require_bool(node, key, where): + """An optional boolean key (False when absent).""" + if key in node: + if not isinstance(node[key], bool): + raise PackageError(f"{where}.{key} must be true or false") + return node[key] + return False + + +def _reject_constant(name): + raise PackageError(f"manifest holds a non-finite number ({name})") + + +MAX_JSON_DEPTH = 64 +"""No valid manifest nests deeper than a few levels; deeper ones are rejected without recursion (json's own +limit depends on the Python version, the Nim parser stops at 1000).""" + + +def _reject_non_finite(node, where="manifest"): + """Rejects any non-finite number and any nesting deeper than MAX_JSON_DEPTH (iterative, depth-first).""" + stack = [(node, where, 0)] + while stack: + item, path, depth = stack.pop() + if depth > MAX_JSON_DEPTH: + raise PackageError("manifest.json is nested too deeply") + if isinstance(item, float) and not math.isfinite(item): + raise PackageError(f"{path} holds a non-finite number") + if isinstance(item, list): + stack.extend((child, path, depth + 1) for child in reversed(item)) + elif isinstance(item, dict): + stack.extend((child, f"{path}.{key}", depth + 1) for key, child in reversed(list(item.items()))) + + +def head_list(heads): + return "[" + ",".join(str(h) for h in heads) + "]" + + +def check_model(data: bytes, contract: Contract): + """Validates a GOTANET1 model.bin for `contract`; returns (hidden, operations).""" + if data[:8] != MAGIC: + raise PackageError("invalid neural actor magic (want GOTANET1)") + if len(data) < 32: + raise PackageError("truncated neural actor") + version, inputs, hidden, outputs, heads, params = struct.unpack_from("<6I", data, 8) + if version != 1 or not 1 <= inputs <= MAX_INPUTS or hidden not in WIDTHS or not 2 <= outputs <= 1024 or \ + not 1 <= heads <= 32: + raise PackageError("unsupported neural actor dimensions/version") + expected = inputs * hidden + 3 * hidden * hidden + outputs * hidden + if params != expected or expected > MAX_PARAMS or len(data) != 32 + 128 + 4 * heads + 4 * expected: + raise PackageError("invalid neural actor length/parameter count") + ohash, ahash = data[32:96].decode("ascii", "replace"), data[96:160].decode("ascii", "replace") + for h in (ohash, ahash): + if any(c not in "0123456789abcdef" for c in h): + raise PackageError("invalid neural contract hash") + sizes = list(struct.unpack_from(f"<{heads}I", data, 160)) + if any(not 2 <= s <= 1024 for s in sizes) or sum(sizes) != outputs: + raise PackageError("head/output mismatch") + weights = memoryview(data)[160 + 4 * heads:].cast("f") + if not all(math.isfinite(w) for w in weights): + raise PackageError("nonfinite neural weight") + ops = 2 * expected + 32 * hidden + if ops > contract.op_budget: + raise PackageError(f"neural actor needs {ops} operations per inference, over the " + f"{contract.op_budget} budget") + if ohash != contract.obs_hash or ahash != contract.action_hash: + raise PackageError("model.bin contract hashes do not match the manifest") + if inputs != contract.obs_size: + raise PackageError(f"model.inputs must be {contract.obs_size}") + if sizes != list(contract.heads): + raise PackageError(f"model.heads must be {head_list(contract.heads)}") + return hidden, ops + + +def read_zip(data: bytes) -> dict: + """The package files, with each entry's declared size checked against its cap before + decompressing, and decompression bounded by the declared size.""" + if len(data) > MAX_PACKAGE: + raise PackageError("package exceeds 16 MiB") + try: + z = zipfile.ZipFile(io.BytesIO(data)) + infos = z.infolist() + except (zipfile.BadZipFile, zipfile.LargeZipFile, ValueError, OSError, EOFError) as e: + raise PackageError(f"not a zip: {e}") + if len(infos) > MAX_ENTRIES: + raise PackageError("too many zip entries") + files = {} + for info in infos: + limit = LIMITS.get(info.filename) + if limit is None: + raise PackageError(f"unexpected package entry {info.filename}") + if info.file_size > limit: + raise PackageError(f"{info.filename} exceeds its {limit} byte limit") + if info.flag_bits & 1: + raise PackageError("encrypted zip entry") + if info.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED): + raise PackageError(f"unsupported zip compression {info.compress_type}") + if info.filename in files: + raise PackageError(f"unexpected package entry {info.filename}") + files[info.filename] = _read_entry(data, info, limit) + if len(files) != 3 or sorted(files) != sorted(FILES): + raise PackageError("package must contain exactly manifest.json, policy.bas and model.bin") + return files + + +def _read_entry(data: bytes, info: zipfile.ZipInfo, limit: int) -> bytes: + """One entry's bytes through its local header, inflating at most `info.file_size` bytes + (the Nim loader reads the same way; CRCs are not checked by either).""" + p = info.header_offset + if p + 30 > len(data) or data[p:p + 4] != b"PK\x03\x04": + raise PackageError("bad zip entry") + name_len, extra_len = struct.unpack_from(" len(data): + raise PackageError("truncated zip data") + raw = data[start:start + info.compress_size] + if info.compress_type == zipfile.ZIP_STORED: + out = raw + else: + d = zlib.decompressobj(-15) + try: + out = d.decompress(raw, info.file_size + 1) + except zlib.error as e: + raise PackageError(f"bad deflate data for {info.filename}: {e}") + if len(out) > info.file_size: + raise PackageError(f"bad deflate data for {info.filename}: inflated data exceeds its limit") + if not d.eof: + raise PackageError(f"bad deflate data for {info.filename}: truncated stream") + if len(out) != info.file_size: + raise PackageError(f"zip size mismatch for {info.filename}") + return out + + +def validate(data: bytes, contract: Contract): + """Raises PackageError with the reason; returns (manifest, options) on success.""" + files = read_zip(data) + try: + m = json.loads(files["manifest.json"], parse_constant=_reject_constant) + except PackageError: + raise + except (ValueError, UnicodeDecodeError, RecursionError) as e: + raise PackageError(f"manifest.json is not JSON: {e}") + require_keys(m, MANIFEST_KEYS + tuple(contract.manifest_keys), "manifest") + _reject_non_finite(m) + for key in REQUIRED: + if key not in m: + raise PackageError(f"manifest is missing {key}") + for key in ("schema", "observation_contract", "action_contract"): + if not isinstance(m[key], str): + raise PackageError(f"manifest.{key} must be a string") + if m["schema"] != contract.schema: + raise PackageError(f"manifest schema must be {contract.schema}") + if m["observation_contract"] != contract.obs_hash: + raise PackageError("observation contract mismatch") + if m["action_contract"] != contract.action_hash: + raise PackageError("action contract mismatch") + p = m["decision_period"] + if not is_int(p): + raise PackageError("manifest.decision_period must be an integer") + if not 1 <= p <= contract.max_period: + raise PackageError(f"decision_period must be an integer 1..{contract.max_period}") + require_keys(m["files"], {"policy.bas", "model.bin"}, "files") + if set(m["files"]) != {"policy.bas", "model.bin"}: + raise PackageError("files must list policy.bas and model.bin") + for n in ("policy.bas", "model.bin"): + if not isinstance(m["files"][n], str): + raise PackageError(f"files.{n} must be a string") + if m["files"][n] != hashlib.sha256(files[n]).hexdigest(): + raise PackageError(f"{n} sha256 mismatch") + if len(files["policy.bas"]) > MAX_POLICY: + raise PackageError("policy.bas exceeds 256 KiB") + model = m["model"] + require_keys(model, MODEL_KEYS, "model") + for key in MODEL_KEYS: + if key not in model: + raise PackageError(f"model is missing {key}") + if not isinstance(model["format"], str): + raise PackageError("model.format must be a string") + if model["format"] != "GOTANET1": + raise PackageError("model.format must be GOTANET1") + hidden, _ = check_model(files["model.bin"], contract) + for key in ("inputs", "hidden"): + if not is_int(model[key]): + raise PackageError(f"model.{key} must be an integer") + if model["inputs"] != contract.obs_size: + raise PackageError(f"model.inputs must be {contract.obs_size}") + if model["hidden"] != hidden: + raise PackageError("model.hidden does not match model.bin") + if not isinstance(model["heads"], list) or not all(is_int(h) for h in model["heads"]): + raise PackageError("model.heads must be a list of integers") + if model["heads"] != list(contract.heads): + raise PackageError(f"model.heads must be {head_list(contract.heads)}") + if "decoder" in m: + d = m["decoder"] + require_keys(d, DECODER_KEYS + tuple(contract.decoder_keys), "decoder") + mode = d.get("mode", "argmax") + if not isinstance(mode, str): + raise PackageError("decoder.mode must be a string") + if mode == "argmax": + if "temperature" in d: + raise PackageError("decoder.temperature needs mode sample") + elif mode == "sample": + t = d.get("temperature", 1.0) + if not is_number(t): + raise PackageError("decoder.temperature must be a number") + if not 0.01 <= t <= 10: + raise PackageError("decoder.temperature must be 0.01..10") + else: + raise PackageError("decoder.mode must be argmax or sample") + options = contract.parse_options(m) if contract.parse_options else None + return m, options + + +def encode_model(weights, hidden, contract: Contract, inputs=None, heads=None): + """GOTANET1 bytes from a flat float32 list: W_enc[H][I], W_rec[3H][H], W_dec[O][H].""" + inputs = contract.obs_size if inputs is None else inputs + heads = list(contract.heads) if heads is None else list(heads) + outputs = sum(heads) + expected = inputs * hidden + 3 * hidden * hidden + outputs * hidden + assert len(weights) == expected + return (MAGIC + struct.pack("<6I", 1, inputs, hidden, outputs, len(heads), expected) + + contract.obs_hash.encode() + contract.action_hash.encode() + struct.pack(f"<{len(heads)}I", *heads) + + struct.pack(f"<{expected}f", *weights)) + + +def manifest_for(policy: bytes, model: bytes, contract: Contract, period=4, decoder=None, extra=None) -> dict: + hidden = struct.unpack_from(" bytes: + out = io.BytesIO() + with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z: + for name, data in files.items(): + z.writestr(name, data) + return out.getvalue() + + +def build(policy: bytes, model: bytes, contract: Contract, period=4, decoder=None, extra=None) -> bytes: + """A validated package (deflate ZIP) for `contract`.""" + m = manifest_for(policy, model, contract, period, decoder, extra) + data = zip_files({"manifest.json": json.dumps(m, indent=1), "policy.bas": policy, "model.bin": model}) + validate(data, contract) + return data diff --git a/docs/neural-policies.md b/docs/neural-policies.md new file mode 100644 index 00000000..bc38ef54 --- /dev/null +++ b/docs/neural-policies.md @@ -0,0 +1,214 @@ +# Neural policies + +A Polyworld game can let a player submit a small neural network next to a BASIC +program. The network runs natively inside the game, under a fixed operation budget, +and the BASIC program keeps everything the network does not decide (drafts, shops, +chat, fallbacks). This page is the game-agnostic part: the package format, the model +format, the budget and the host functions. It also explains how to plug a game in. + +The shared code: + +| file | role | +|---|---| +| `src/polyworld/neural_actor.nim` | model format (GOTANET1) loader and FP32 MinGRU inference | +| `src/polyworld/neural_package.nim` | strict ZIP + manifest parser, checked against a game's contract | +| `src/polyworld/neural_host.nim` | `NeuralContract`, per-seat `NeuralBrain`, lifecycle, budget, telemetry, decoders, BASIC host functions | +| `src/polyworld/boundedinflate.nim` | DEFLATE with a hard output limit (used by the package reader) | +| `coworld/runtime/neural_package.py` | staging validator and builder that mirrors the Nim parser | +| `tests/test_neural_tier.nim`, `tests/neural_toy.nim` | the tier on a synthetic toy contract | +| `tests/neural_cases.py`, `tests/test_neural_cases.nim` | one corruption suite run by both validators | + +The tier has no game in it. Each game supplies a contract. Gods of the Arena is the +first client (`examples/gods_of_the_arena/neural_basic.md`). + +## Package + +A package is a ZIP with exactly three entries: + +- `manifest.json`: at most 64 KiB. +- `policy.bas`: the seat's BASIC program, at most 256 KiB. +- `model.bin`: the network (see below), at most the size of the largest valid model. + +The whole ZIP may be up to 16 MiB. Entries must be stored or deflated, and none may be +encrypted. + +```json +{ + "schema": "", + "observation_contract": "", + "action_contract": "", + "decision_period": 4, + "files": {"policy.bas": "", "model.bin": ""}, + "model": {"format": "GOTANET1", "inputs": 1407, "hidden": 128, "heads": [8, 25, 49, 4, 6]}, + "decoder": {"mode": "argmax"} +} +``` + +Parsing is strict, and the reader never raises a defect, whatever the bytes. It +rejects the package with a `ValueError` naming the reason when: + +- a key is not listed. The generic keys are listed above; a game adds its own + through `manifestKeys` and `decoderKeys`. +- a required key is missing, or a value has the wrong JSON kind. An integer field + must be a JSON integer, so `1407.0` and `true` are rejected. +- any number is non-finite (`NaN`, `Infinity` or `1e999`). +- a file hash, the schema, a contract hash, `inputs`, `hidden` or `heads` differs + from the model or the contract. +- an entry name is unknown or repeated, or an entry declares more uncompressed bytes + than its cap. The reader checks this before decompressing anything, and inflating + stops at the declared size, so a small zip bomb cannot expand in memory. + +`decoder.mode` is `argmax` (the default: the first maximum of each head) or `sample` +with `temperature` 0.01..10. Sampling draws once per head, in head order, from +softmax(logits / T). It uses SplitMix64, seeded per seat from the match seed. + +Staging uses the Python validator. Each game wraps it with its own `Contract` (GotA: +`coworld/gota/runtime/neural_package.py`). Because the Python and Nim validators run +the same corruption suite in CI, a package that passes staging is one the game +accepts, and the reverse. + +Coworld servers read a staged player file whose first bytes are `PK\x03\x04` up to +16 MiB, instead of the 256 KiB BASIC cap (`coworld.nim` `readPlayerSource`). This +applies to every game. A larger file is read one byte past the cap, so the loader +reports "package exceeds 16 MiB" rather than a broken ZIP. Games without neural +seats treat such a file as BASIC and reject it at compile time, exactly as before. + +## Model format (model.bin, GOTANET1) + +GOTANET1 is the Polyworld neural model format. Gods of the Arena introduced it, so the +magic stays `GOTANET1` for every game. All values are little-endian. + +| offset | field | +|---|---| +| 0 | magic `GOTANET1` (8 bytes) | +| 8 | u32 version = 1 | +| 12 | u32 inputs I (1..4096) | +| 16 | u32 hidden H (64, 128 or 256) | +| 20 | u32 outputs O (2..1024, the sum of the head sizes) | +| 24 | u32 heads K (1..32) | +| 28 | u32 parameters = I*H + 3*H*H + O*H (at most 2,000,000) | +| 32 | observation contract hash, 64 lowercase hex chars | +| 96 | action contract hash, 64 lowercase hex chars | +| 160 | K x u32 head sizes (2..1024 each) | +| 160+4K | f32 weights: W_enc[H][I], W_rec[3H][H], W_dec[O][H] (row-major, all finite) | + +One inference (PufferLib's MinGRU policy) runs these steps: + +``` +x = W_enc · obs (H) +c, g, hw = split(W_rec · x) (3 x H) +candidate = c >= 0 ? c + 0.5 : sigmoid(c) +state' = lerp(state, candidate, sigmoid(g)) +y = sigmoid(hw) * state' + (1 - sigmoid(hw)) * x +logits = W_dec · y (O, heads concatenated) +``` + +`sigmoid` and `lerp` use the same branches as PufferLib's GPU kernels, so a trainer can +check its actor against the hosted one bit for bit. A non-finite input, state or +output fails the inference, and nothing changes. + +## Budget + +A model costs `2 * parameters + 32 * H` operations per inference +(`Actor.operationCount`). A seat runs at most one inference per tick. The contract's +`opBudget` caps this cost. It defaults to 4,000,000 and is separate from the BASIC +instruction budget. The cap is checked when the model is loaded, so an over-budget +model is rejected up front. It is never stopped halfway through a match. + +## Adding neural seats to a game + +1. **Write the contract texts.** Two canonical strings: the observation layout + (every feature, normalizer and slot rule) and the action heads (every verb and how + it decodes). Their SHA-256 values are the contract hashes. Any change to a feature + or a decode rule must change the text, which gives it a new hash, so old models + are rejected instead of misread. + +2. **Build one `NeuralContract`** with `initNeuralContract`. It computes the hashes + from the texts. It also asserts that your hand-counted logit total equals the sum + of the head sizes, and that the mask size and mask callback come together. + + ```nim + let MyContract = initNeuralContract("mygame", "mygame-neural/1", + observationText(), actionText(), ObservationSize, HeadSizes, ActionOutputs, + buildObservation = myObservation, decodeAction = myDecode, + head = myHead, tick = myTick, + log = mySeatLog, maskSize = MaskSize, actionMask = myMask, + manifestKeys = ["goal"], decoderKeys = ["my_option"], + parseOptions = parseMyOptions) + ``` + + Its fields and callbacks: + + | field | purpose | + |---|---| + | `buildObservation(brain): bool` | fills `brain.obs` for the current tick; returns whether the seat acts | + | `decodeAction(brain)` | turns `brain.logits` into your heads and the command to issue (argmax, sampling, masks) | + | `head(brain, i)` | head `i` of the latest decision (for `neuralModel(5 + i)`) | + | `tick(brain)` | your current tick (a decision is fresh only on its own tick) | + | `log(brain, text)` | optional; the seat's private log (telemetry) | + | `actionMask(brain, mask)`, `maskSize` | optional; the frame's validity mask | + | `parseOptions(manifest)` | optional; validates your extra keys and returns your options object | + | `telemetryExtra(brain)` | optional; your counters appended to the telemetry line | + +3. **Derive a seat type** from `NeuralBrain`, adding whatever your decoder needs (a + decision frame, the heads, your command). Create each seat with + `seat.initBrain(contract, period, maxTicks)`. Set `seat.actor = package.actor`, + then call `seat.resetBrain(matchSeed, seatIndex)` at every match start. + +4. **Run it** on the decision ticks (`decisionDue(battleTick, period)`). The simple + form is `seat.decide(tick, battleTick)`, which observes, infers, decodes and logs. + A game with other seat kinds (trainer-driven, label capture) can call + `seat.beginFrame(tick)` and then `seat.think(battleTick)` itself. + +5. **Expose the BASIC surface** with `host.addNeuralHostFunctions(lookup)`. Register it + in the same place in the schema host and in each seat's host, next to your own + action function (GotA uses `gota_act`). The functions: + + | function | result | + |---|---| + | `run_neural_net()` | 1 when this tick has a fresh decision | + | `neuralObservation(i)` | observation float `i` (Q16.16) | + | `neuralLogits(i)` | logit `i` of the last inference (Q16.16) | + | `neuralState(i)` | recurrent state float `i` (Q16.16) | + | `neuralModel(k)` | 0 hidden width, 1 inputs, 2 outputs, 3 decision period, 5+h head h | + + Every function returns 0 for a seat without a brain. + +6. **Load packages** with `parsePackage(bytes, contract)`. On a Coworld server, a + rejected package should end the episode with `failPlayer(slot, reason, message)`, + so the platform reports the real reason. + +7. **Wrap the Python validator** with a `Contract` that has the same schema, hashes, + sizes and extra keys, plus a `parse_options` that mirrors yours. + +### State and resets + +`beginFrame` captures one frame per tick. The recurrent state restarts from zero on +the seat's first frame, and on the first frame after the seat stopped acting (death, +respawn). `resetBrain` clears everything per match except `peakOps`. A reset seat +replays a fresh seat exactly (`tests/test_neural_tier.nim`). + +### Telemetry + +With `brain.telemetry = true` and a `log` callback, the seat logs this line on the +first inference, every 1800 inferences and on the match's last decision: + +``` +neural: peak_ops= budget= model=w ticks= inferences= +``` + +A game can add its own counters after it (GotA adds its decision, invalid, defer and +override counts). + +### Parity expectations + +Neural support must not change games that don't use it: + +- A match with no neural seats must replay byte-identically (state hashes and replay + bytes) to the same match on a build without neural support. +- A hosted package seat must play exactly like a trainer-driven seat that runs the + same weights through the same actor and decoder. +- Every decoder option must be byte-identical when off. + +Prove all three with seeded batteries over full-length matches before shipping. GotA's +proofs (`examples/gods_of_the_arena/tools/`) are a template. diff --git a/src/polyworld/boundedinflate.nim b/src/polyworld/boundedinflate.nim new file mode 100644 index 00000000..9ef8f958 --- /dev/null +++ b/src/polyworld/boundedinflate.nim @@ -0,0 +1,326 @@ +## Raw DEFLATE decoding with a hard output limit, for untrusted archives +## (neural packages). Adapted from zippy's zippy/inflate.nim (MIT License, +## Copyright (c) 2020 Ryan Oldenburg); the only change is that every write +## past `limit` bytes raises ZippyError instead of growing the output, so a +## small compressed entry cannot expand into gigabytes before its declared +## size is checked. + +import zippy/[bitstreams, common, internal] + +export ZippyError + +when defined(clang): + func bitreverse16(v: uint16): uint16 {.importc: "__builtin_bitreverse16", nodecl.} + proc reverseBits(v: uint16): uint16 {.inline.} = + bitreverse16(v) +else: + import std/bitops + +const + fastBits = 9 + fastMask = (1 shl fastBits) - 1 + +type Huffman = object + firstCode, firstSymbol: array[16, uint16] + maxCodes: array[17, uint32] + # lengths: array[288, uint8] + values: array[288, uint16] + fast: array[1 shl fastBits, uint16] + +when defined(release): + {.push checks: off.} + +proc initHuffman(codeLengths: openArray[uint8]): Huffman = + ## See https://raw.githubusercontent.com/madler/zlib/master/doc/algorithm.txt + + var histogram: array[17, uint16] + for i in 0 ..< codeLengths.len: + inc histogram[codeLengths[i]] + histogram[0] = 0 + + for i in 1 ..< 16: + if histogram[i] > (1.uint16 shl i): + failUncompress() + + var + code: uint32 + k: uint16 + nextCode: array[16, uint32] + for i in 1 ..< 16: + nextCode[i] = code + result.firstCode[i] = code.uint16 + result.firstSymbol[i] = k + code = code + histogram[i] + if histogram[i] > 0.uint16 and code - 1 >= (1.uint32 shl i): + failUncompress() + result.maxCodes[i] = (code shl (16 - i)) + code = code shl 1 + k += histogram[i] + + result.maxCodes[16] = 1 shl 16 + + for i, len in codeLengths: + if len > 0.uint8: + let symbolId = + nextCode[len] - result.firstCode[len] + result.firstSymbol[len] + # result.lengths[symbolId] = len + result.values[symbolId] = i.uint16 + if len <= fastBits: + let fast = (len.uint16 shl fastBits) or i.uint16 + var k = reverseBits(nextCode[len].uint16) shr (16.uint16 - len) + while k < (1 shl fastBits): + result.fast[k] = fast + k += (1.uint16 shl len) + inc nextCode[len] + +proc decodeSymbolSlow(b: var BitStreamReader, h: Huffman): uint16 = + let + k = reverseBits(b.bitBuffer.uint16) + maxCodeLength = h.maxCodes.len.uint16 + var codeLength = fastBits.uint16 + 1 + while codeLength < maxCodeLength: + if k.uint32 < h.maxCodes[codeLength]: + break + inc codeLength + + if codeLength >= 16.uint16: + # Bad code length. Instead of raising an exception here though, + # let the checks handling this return value call failUncompress(). + # For some reason failUncompress() here has significant performance impact + # on M1 arm64. + return uint16.high + + let symbolId = + (k shr (16.uint16 - codeLength)) - + h.firstCode[codeLength] + + h.firstSymbol[codeLength] + + result = h.values[symbolId] + b.bitBuffer = b.bitBuffer shr codeLength + b.bitsBuffered -= codeLength.int + +proc decodeSymbol(b: var BitStreamReader, h: Huffman): uint16 {.inline.} = + ## This function is the most important for inflate performance. + let fast = h.fast[b.bitBuffer and fastMask] + if fast > 0.uint16: + let codeLength = fast shr fastBits + result = fast and fastMask + b.bitBuffer = b.bitBuffer shr codeLength + b.bitsBuffered -= codeLength.int + else: # Slow path + result = b.decodeSymbolSlow(h) + +template failLimit() = + raise newException(ZippyError, "inflated data exceeds its limit") + +proc inflateBlock( + dst: var string, + b: var BitStreamReader, + op: var int, + fixedCodes: bool, + limit: int +) = + var literalsHuffman, distancesHuffman: Huffman + if fixedCodes: + literalsHuffman = initHuffman(fixedLitLenCodeLengths) + distancesHuffman = initHuffman(fixedDistanceCodeLengths) + else: + let + hlit = b.readBits(5).int + 257 + hdist = b.readBits(5).int + 1 + hclen = b.readBits(4).int + 4 + + if hlit > maxLitLenCodes: + failUncompress() + + if hdist > maxDistanceCodes: + failUncompress() + + var clcls: array[19, uint8] + for i in 0 ..< hclen: + clcls[clclOrder[i]] = b.readBits(3).uint8 + + let clclsHuffman = initHuffman(clcls) + + # From RFC 1951, all code lengths form a single sequence of HLIT + HDIST + # This means the max unpacked length is 31 + 31 + 257 + 1 = 320 + + var + unpacked: array[320, uint8] + i: int + while i != hlit + hdist: + if b.bitsBuffered < 15: + b.fillBitBuffer() + let symbol = decodeSymbol(b, clclsHuffman) + if b.bitsBuffered < 0: + failEndOfBuffer() + if symbol <= 15: + unpacked[i] = symbol.uint8 + inc i + elif symbol == 16: + if i == 0: + failUncompress() + let + prev = unpacked[i - 1] + repeatCount = b.readBits(2).int + 3 + if i + repeatCount > unpacked.len: + failUncompress() + for _ in 0 ..< repeatCount: + unpacked[i] = prev + inc i + elif symbol == 17: + let repeatZeroCount = b.readBits(3).int + 3 + i += repeatZeroCount + elif symbol == 18: + let repeatZeroCount = b.readBits(7).int + 11 + i += repeatZeroCount + else: + raise newException(ZippyError, "Invalid symbol") + + if i > hlit + hdist: + failUncompress() + + literalsHuffman = initHuffman(unpacked.toOpenArray(0, hlit - 1)) + distancesHuffman = initHuffman(unpacked.toOpenArray(hlit, hlit + hdist - 1)) + + while true: + when defined(arm64) and defined(macosx): + b.fillBitBuffer() + var symbol: uint16 + while true: + symbol = decodeSymbol(b, literalsHuffman) + if symbol <= 255 and b.bitsBuffered >= 15: + if op >= limit: + failLimit() + if op >= dst.len: + dst.setLen(min(max(op * 2, 2), limit)) + dst[op] = symbol.char + inc op + else: + break + else: + if b.bitsBuffered < 15: + b.fillBitBuffer() + let symbol = decodeSymbol(b, literalsHuffman) + if b.bitsBuffered < 0: + failEndOfBuffer() + if symbol <= 255: + if op >= limit: + failLimit() + if op >= dst.len: + dst.setLen(min(max(op * 2, 2), limit)) + dst[op] = symbol.char + inc op + elif symbol == 256: + break + else: + b.fillBitBuffer() + + let lengthIdx = (symbol - 257).int + if lengthIdx >= baseLengths.len: + failUncompress() + + let copyLength = ( + baseLengths[lengthIdx] + + b.readBits(baseLengthsExtraBits[lengthIdx].int, false) # Up to 5 + ).int + + let distanceIdx = decodeSymbol(b, distancesHuffman) # Up to 15 + if distanceIdx >= baseDistances.len.uint16: + failUncompress() + + when sizeof(b.bitBuffer) == 4: + if b.bitsBuffered < 13: + b.fillBitBuffer() + + let distance = ( + baseDistances[distanceIdx] + + b.readBits(baseDistanceExtraBits[distanceIdx].int, false) # Up to 13 + ).int + + if distance > op: + failUncompress() + + if op + copyLength > limit: + failLimit() + + # Min match is 3 so leave room to overwrite by 13 + if op + copyLength + 13 > dst.len: + dst.setLen((op + copyLength) * 2 + 10) # At least 16 + + let dst = cast[ptr UncheckedArray[uint8]](dst[0].addr) + + if copyLength <= 16 and distance >= 8: + copy64(dst, dst, op, op - distance) + copy64(dst, dst, op + 8, op - distance + 8) + else: + var + copyFrom = op - distance + copyTo = op + remaining = copyLength + while copyTo - copyFrom < 8: + copy64(dst, dst, copyTo, copyFrom) + remaining -= copyTo - copyFrom + copyTo += copyTo - copyFrom + while remaining > 0: + copy64(dst, dst, copyTo, copyFrom) + copyFrom += 8 + copyTo += 8 + remaining -= 8 + op += copyLength + +proc inflateNoCompression( + dst: var string, + b: var BitStreamReader, + op: var int, + limit: int +) = + b.skipRemainingBitsInCurrentByte() + let + len = b.readBits(16).int + nlen = b.readBits(16).int + if len + nlen != 65535: + failUncompress() + if op + len > limit: + failLimit() + if len > 0: + dst.setLen(op + len) # Make room for the bytes to be copied to + b.readBytes(dst[op].addr, len) + op += len + +proc inflateInto(dst: var string, src: ptr UncheckedArray[uint8], len, pos, + limit: int) = + var + b = BitStreamReader(src: src, len: len, pos: pos) + op: int + finalBlock: bool + while not finalBlock: + let + bfinal = b.readBits(1) + btype = b.readBits(2) + + if bfinal != 0.uint16: + finalBlock = true + + case btype: + of 0: # No compression + inflateNoCompression(dst, b, op, limit) + of 1: # Compressed with fixed Huffman codes + inflateBlock(dst, b, op, true, limit) + of 2: # Compressed with dynamic Huffman codes + inflateBlock(dst, b, op, false, limit) + else: + raise newException(ZippyError, "Invalid block header") + + dst.setLen(op) + +when defined(release): + {.pop.} + +proc inflateBounded*(src: string, limit: int): string = + ## Decodes raw DEFLATE `src`; raises ZippyError on corrupt data or when + ## the output would exceed `limit` bytes. + if src.len == 0: + raise newException(ZippyError, "empty deflate stream") + inflateInto(result, cast[ptr UncheckedArray[uint8]](src[0].unsafeAddr), + src.len, 0, limit) diff --git a/src/polyworld/coworld.nim b/src/polyworld/coworld.nim index e73c8b03..6aaa9b70 100644 --- a/src/polyworld/coworld.nim +++ b/src/polyworld/coworld.nim @@ -125,6 +125,15 @@ proc readPlayerSource*(path: string): string = input.close() result = newString(256 * 1024 + 1) result.setLen(input.readBuffer(result[0].addr, result.len)) + if result.len > 4 and result[0 .. 3] == "PK\x03\x04": + # Neural packages (ZIP: manifest, policy.bas, model.bin) may reach + # 16 MiB (docs/neural-policies.md); the game validates them before + # use. A larger file is read one byte past the cap, so the package + # loader rejects it as too large rather than as a truncated ZIP. + let size = min(int(getFileSize(input)), 16 * 1024 * 1024 + 1) + input.setFilePos(0) + result = newString(size) + result.setLen(input.readBuffer(result[0].addr, size)) except IOError, OSError: raise newException(CoworldError, "Cannot read staged player: " & getCurrentExceptionMsg()) @@ -206,6 +215,21 @@ proc waitForCollection*() = ## Keeps health and contract stubs alive until the runner stops the process. joinThread(serverThread) +proc failPlayer*(slot: int, detail, message: string) = + ## Ends the episode before play because a staged player is unusable: + ## `detail` goes to the seat's private log, `message` is the platform's + ## failure reason. Does not return. + logs[slot].failed = true + playerLog(slot, "\n" & detail & "\n") + closePlayerLogs() + writePlayerStatus() + writeAtomic(failurePath, PlayerFailure( + message: message, + failedPolicyIndex: slot + ).toJson()) + waitForCollection() + raise newException(CoworldError, message) + proc compilePlayer*( source: string, host: Host, @@ -216,15 +240,8 @@ proc compilePlayer*( try: result = compile(source, host, limits) except BasicError as error: - playerError(slot, error.msg) - closePlayerLogs() - writePlayerStatus() - writeAtomic(failurePath, PlayerFailure( - message: "BASIC compilation failed for player slot " & $slot, - failedPolicyIndex: slot - ).toJson()) - waitForCollection() - raise newException(CoworldError, "Player compilation failed") + failPlayer(slot, "BASIC error: " & error.msg, + "BASIC compilation failed for player slot " & $slot) proc requestHandler(request: Request) {.gcsafe.} = ## Serves health and the platform's minimal legacy contract surface. diff --git a/src/polyworld/neural_actor.nim b/src/polyworld/neural_actor.nim new file mode 100644 index 00000000..0053ba08 --- /dev/null +++ b/src/polyworld/neural_actor.nim @@ -0,0 +1,222 @@ +## Polyworld neural model format (model.bin, magic GOTANET1) and its +## restricted FP32 MinGRU actor, shared by every game with neural seats. +## +## The layout and equations are PufferLib's MinGRU policy: a linear encoder, +## one MinGRU layer with a highway to the encoder output, and a linear +## decoder whose outputs are the concatenated categorical heads. Gods of the +## Arena introduced the format, so its magic stays GOTANET1 and every model +## trained for it keeps loading. The dimensions (inputs, width, heads) come +## from the model header; a game's contract (neural_package.nim) decides +## which dimensions it accepts. A hosted seat and a native training library +## run this same code, so a trainer can check its own actor against it bit +## for bit. Byte layout: docs/neural-policies.md. +import std/math + +type + Actor* = ref object + ## A loaded model. Read-only outside this module (getters below), so a + ## shared actor cannot be resized into out-of-bounds reads. + inputs, hidden, outputs: int + heads: seq[int] + obsContract, actContract: string + encoder, recurrent, decoder: seq[float32] + +const + ActorMagic* = "GOTANET1" + MaxActorParameters* = 2_000_000 + MaxActorInputs* = 4096 + ActorWidths* = [64, 128, 256] + DefaultNeuralOpBudget* = 4_000_000 + ## Operations one seat may spend per tick on inference, separate from the + ## BASIC instruction budget, unless the game's contract says otherwise. + +proc finite(x: float32): bool = classify(x) notin {fcNan, fcInf, fcNegInf} + +proc readU32(data: string, pos: var int): uint32 = + if pos + 4 > data.len: + raise newException(ValueError, "truncated neural actor") + for i in 0..3: + result = result or (uint32(ord(data[pos+i])) shl (8*i)) + pos += 4 + +proc headerSize*(heads: int): int = 8 + 24 + 128 + 4 * heads + +proc loadActor*(data: string, opBudget = DefaultNeuralOpBudget): Actor = + ## Validates and loads a GOTANET1 model; raises ValueError on any defect, + ## including an inference cost over `opBudget` operations. + if data.len < 8 or data[0..<8] != ActorMagic: + raise newException(ValueError, "invalid neural actor magic (want GOTANET1)") + var p = 8 + let + version = readU32(data, p) + inputs = int(readU32(data, p)) + hidden = int(readU32(data, p)) + outputs = int(readU32(data, p)) + heads = int(readU32(data, p)) + parameters = int(readU32(data, p)) + if version != 1 or inputs notin 1..MaxActorInputs or hidden notin ActorWidths or + outputs notin 2..1024 or heads notin 1..32: + raise newException(ValueError, "unsupported neural actor dimensions/version") + let expected = inputs*hidden + 3*hidden*hidden + outputs*hidden + if parameters != expected or expected > MaxActorParameters or + data.len != headerSize(heads) + expected*4: + raise newException(ValueError, "invalid neural actor length/parameter count") + new(result) + result.inputs = inputs + result.hidden = hidden + result.outputs = outputs + result.obsContract = data[p.. opBudget: + raise newException(ValueError, "neural actor needs " & $ops & + " operations per inference, over the " & $opBudget & " budget") + +proc inputSize*(actor: Actor): int {.inline.} = + ## Observation floats per inference. + actor.inputs + +proc hiddenSize*(actor: Actor): int {.inline.} = + ## MinGRU width (recurrent state floats). + actor.hidden + +proc outputSize*(actor: Actor): int {.inline.} = + ## Logits per inference (sum of the head sizes). + actor.outputs + +proc headSizes*(actor: Actor): seq[int] = + ## Categorical head sizes, in logit order (a copy). + actor.heads + +proc headCount*(actor: Actor): int {.inline.} = + actor.heads.len + +proc observationContract*(actor: Actor): string = + ## The observation contract hash stored in the model header. + actor.obsContract + +proc actionContract*(actor: Actor): string = + ## The action contract hash stored in the model header. + actor.actContract + +proc operationCount*(actor: Actor): int = + ## Published cost: 2 per multiply-accumulate plus 32 per MinGRU unit. + 2*(actor.encoder.len + actor.recurrent.len + actor.decoder.len) + + 32*actor.hidden + +proc parameterCount*(actor: Actor): int = + actor.encoder.len + actor.recurrent.len + actor.decoder.len + +proc sigmoid(x: float32): float32 = + ## Same stable branches as PufferLib's GPU sigmoid. + let z = exp(-abs(x)) + if x >= 0: 1'f32 / (1'f32 + z) else: z / (1'f32 + z) + +proc interpolate(a, b, weight: float32): float32 = + ## Same branches as PufferLib's lerp kernel. + let delta = b - a + if abs(weight) < 0.5'f32: a + weight*delta + else: b - delta*(1'f32 - weight) + +proc infer*(actor: Actor, obs: openArray[float32], state: var openArray[float32], + logits: var openArray[float32]) = + ## One recurrent step. The state (hiddenSize floats) is updated in place; + ## on error (shape or nonfinite) neither state nor logits change. + if obs.len != actor.inputs or state.len != actor.hidden or + logits.len != actor.outputs: + raise newException(ValueError, "neural buffer shape mismatch") + for x in obs: + if not finite(x): + raise newException(ValueError, "nonfinite neural input") + for x in state: + if not finite(x): + raise newException(ValueError, "nonfinite neural state") + var + x, next, y: array[256, float32] + combined: array[768, float32] + output: array[1024, float32] + let h = actor.hidden + for o in 0..= 0: combined[i] + 0.5'f32 else: sigmoid(combined[i]) + gate = sigmoid(combined[h+i]) + next[i] = interpolate(state[i], candidate, gate) + let highway = sigmoid(combined[2*h+i]) + y[i] = highway*next[i] + (1'f32 - highway)*x[i] + if not finite(next[i]) or not finite(y[i]): + raise newException(ValueError, "nonfinite neural intermediate") + for o in 0..= 1 and opBudget > 0 + NeuralContract( + name: name, + schema: schema, + observationHash: contractHash(observationText), + actionHash: contractHash(actionText), + observationSize: observationSize, + headSizes: @headSizes, + maxDecisionPeriod: maxDecisionPeriod, + opBudget: opBudget, + manifestKeys: @manifestKeys, + decoderKeys: @decoderKeys, + parseOptions: parseOptions, + maskSize: maskSize, + buildObservation: buildObservation, + decodeAction: decodeAction, + head: head, + tick: tick, + log: log, + actionMask: actionMask, + telemetryExtra: telemetryExtra) + +proc initBrain*(brain: NeuralBrain, contract: NeuralContract, + period, maxTicks: int32) = + ## Sets up a new brain (call once, before resetBrain). + brain.contract = contract + brain.period = period + brain.maxTicks = maxTicks + brain.frameTick = -1 + brain.temperature = 1 + brain.obs = newSeq[float32](contract.observationSize) + +proc resetBrain*(brain: NeuralBrain, matchSeed: int32, seat: int) = + ## Clears per-match state (a new match or a native reset). The recurrent + ## state and logits are reallocated at zero; peakOps is kept. + brain.seat = seat + brain.frameTick = -1 + brain.started = false + brain.sawDeath = false + brain.headsReady = false + brain.inferences = 0 + brain.lastTelemetry = 0 + brain.rng = uint64(uint32(matchSeed)) * 1_000_003'u64 + uint64(seat) + 1 + if brain.actor != nil: + brain.state = newSeq[float32](brain.actor.hiddenSize) + brain.logits = newSeq[float32](brain.actor.outputSize) + +proc decisionDue*(battleTick, period: int32): bool = + ## Decision ticks are battle ticks 1, 1 + period, 1 + 2 * period, ... + period > 0 and (battleTick - 1) mod period == 0 + +proc beginFrame*(brain: NeuralBrain, tick: int32): bool = + ## Captures the decision frame once per tick: observation (contract + ## callback) and the state-reset lifecycle. False if this tick's frame was + ## already captured. + if brain.frameTick == tick: + return false + brain.frameTick = tick + brain.acting = brain.contract.buildObservation(brain) + if not brain.started: + brain.started = true + brain.resetState = true + if not brain.acting: + brain.sawDeath = true + elif not brain.acting: + brain.sawDeath = true + brain.resetState = false + elif brain.sawDeath: + brain.resetState = true + else: + brain.resetState = false + if brain.acting and brain.resetState: + brain.sawDeath = false + brain.headsReady = false + true + +proc telemetryLine*(brain: NeuralBrain, battleTick: int32): string = + ## The seat-log telemetry line. + result = "neural: peak_ops=" & $brain.peakOps & " budget=" & + $brain.contract.opBudget & " model=w" & $brain.actor.hiddenSize & + " ticks=" & $battleTick & " inferences=" & $brain.inferences + if brain.contract.telemetryExtra != nil: + result.add brain.contract.telemetryExtra(brain) + +proc think*(brain: NeuralBrain, battleTick: int32) = + ## One decision of an acting seat with an actor: zero the state on a + ## reset frame, infer, meter, decode (contract callback) and log + ## telemetry on the first inference, every 1800 inferences and on the + ## last decision of the match. A failed inference raises BasicError. + if brain.resetState: + for x in brain.state.mitems: x = 0 + try: + brain.actor.infer(brain.obs, brain.state, brain.logits) + except ValueError as error: + raise newException(BasicError, "neural inference failed: " & error.msg) + inc brain.inferences + brain.peakOps = max(brain.peakOps, brain.actor.operationCount) + brain.contract.decodeAction(brain) + brain.headsReady = true + if brain.telemetry and brain.contract.log != nil and (brain.inferences == 1 or + brain.inferences - brain.lastTelemetry >= 1800 or + battleTick + brain.period > brain.maxTicks): + brain.lastTelemetry = brain.inferences + brain.contract.log(brain, brain.telemetryLine(battleTick)) + +proc decide*(brain: NeuralBrain, tick, battleTick: int32): bool = + ## beginFrame + think for a hosted seat. True when a fresh decision is + ## ready (acting, with an actor). + if not brain.beginFrame(tick): + return false + if brain.acting and brain.actor != nil: + brain.think(battleTick) + brain.headsReady + +# Decoders ------------------------------------------------------------------ + +proc splitmix*(state: var uint64): uint64 = + ## SplitMix64 step: the sampling decoders' only randomness. + state += 0x9E3779B97F4A7C15'u64 + var z = state + z = (z xor (z shr 30)) * 0xBF58476D1CE4E5B9'u64 + z = (z xor (z shr 27)) * 0x94D049BB133111EB'u64 + z xor (z shr 31) + +proc unitDraw*(state: var uint64): float64 = + ## A uniform in [0, 1) from 53 random bits. + float64(splitmix(state) shr 11) / 9007199254740992.0 + +proc argmaxHeads*(logits: openArray[float32], headSizes: openArray[int], + heads: var openArray[int32]) = + ## Deterministic argmax per head (first maximum wins). + var offset = 0 + for h in 0 ..< headSizes.len: + var best = 0 + for i in 1 ..< headSizes[h]: + if logits[offset + i] > logits[offset + best]: + best = i + heads[h] = int32(best) + offset += headSizes[h] + +proc sampleHeads*(logits: openArray[float32], headSizes: openArray[int], + temperature: float32, state: var uint64, heads: var openArray[int32]) = + ## One categorical draw per head, in head order, from softmax(logits / T), + ## using one unitDraw per head. + var offset = 0 + for h in 0 ..< headSizes.len: + let n = headSizes[h] + var top = logits[offset] + for i in 1 ..< n: + top = max(top, logits[offset + i]) + var weights: array[MaxHeadSize, float64] + var total = 0.0 + for i in 0 ..< n: + weights[i] = exp(float64(logits[offset + i] - top) / float64(temperature)) + total += weights[i] + let u = float64(splitmix(state) shr 11) / 9007199254740992.0 * total + var acc = 0.0 + var choice = n - 1 + for i in 0 ..< n: + acc += weights[i] + if u < acc: + choice = i + break + heads[h] = int32(choice) + offset += n + +proc pickHead*(logits: openArray[float32], base, n: int, allowed: openArray[bool], + sampling: bool, temperature: float32, draw: float64): int32 = + ## One masked head: the first maximum (argmax) or a draw (`draw` in + ## [0, 1)) among the allowed choices; all choices when none is allowed. + var found = false + for i in 0 ..< n: + if allowed[i]: found = true + template ok(i: int): bool = (not found) or allowed[i] + if not sampling: + var best = -1 + for i in 0 ..< n: + if ok(i) and (best < 0 or logits[base + i] > logits[base + best]): + best = i + return int32(best) + var top = float32(-Inf) + for i in 0 ..< n: + if ok(i): top = max(top, logits[base + i]) + var weights: array[MaxHeadSize, float64] + var total = 0.0 + for i in 0 ..< n: + weights[i] = if ok(i): exp(float64(logits[base + i] - top) / + float64(temperature)) else: 0.0 + total += weights[i] + let u = draw * total + var acc = 0.0 + var last = 0 + for i in 0 ..< n: + if weights[i] > 0: + last = i + acc += weights[i] + if u < acc: + return int32(i) + int32(last) + +# BASIC host functions ------------------------------------------------------ + +proc fixedRead(values: seq[float32], i: int): Value = + if i < 0 or i >= values.len: + return toValue(0'i32) + toValue(toFixed(clamp(values[i], -30000'f32, 30000'f32))) + +proc addNeuralHostFunctions*(host: var Host, lookup: proc(): NeuralBrain) = + ## Registers the generic neural-seat BASIC surface, in this order: + ## run_neural_net(), neuralObservation(i), neuralLogits(i), neuralState(i), + ## neuralModel(k). `lookup` returns the calling seat's brain (nil for a + ## seat without one: every function then returns 0). A game registers its + ## own action function (e.g. GotA's gota_act) next to these. + let runProc: HostProc = proc(arguments: openArray[int32]): int32 = + ## 1 when this tick has a fresh decision (inference or trainer action). + let brain = lookup() + int32(brain != nil and brain.frameTick == brain.contract.tick(brain) and + brain.headsReady) + let observationProc: NumericHostProc = proc(arguments: openArray[Value]): Value = + ## Reads observation float i of the current decision (Q16.16). + let brain = lookup() + if brain == nil: + return toValue(0'i32) + fixedRead(brain.obs, int(arguments[0].asInt)) + let logitsProc: NumericHostProc = proc(arguments: openArray[Value]): Value = + ## Reads logit i of the last inference (Q16.16); 0 without an actor. + let brain = lookup() + if brain == nil: + return toValue(0'i32) + fixedRead(brain.logits, int(arguments[0].asInt)) + let stateProc: NumericHostProc = proc(arguments: openArray[Value]): Value = + ## Reads recurrent state float i (Q16.16). + let brain = lookup() + if brain == nil: + return toValue(0'i32) + fixedRead(brain.state, int(arguments[0].asInt)) + let modelProc: HostProc = proc(arguments: openArray[int32]): int32 = + ## 0 hidden width, 1 inputs, 2 outputs, 3 decision period, 5+h head h of + ## the last decision. + let brain = lookup() + if brain == nil: + return 0 + case arguments[0] + of 0: (if brain.actor != nil: int32(brain.actor.hiddenSize) else: 0) + of 1: int32(brain.contract.observationSize) + of 2: int32(brain.contract.headOutputs) + of 3: brain.period + else: + let h = int(arguments[0]) - 5 + if h >= 0 and h < brain.contract.headSizes.len: + brain.contract.head(brain, h) + else: + 0 + discard host.addFunction("run_neural_net", 0, runProc, 4) + discard host.addFunction("neuralObservation", 1, observationProc, 4) + discard host.addFunction("neuralLogits", 1, logitsProc, 4) + discard host.addFunction("neuralState", 1, stateProc, 4) + discard host.addFunction("neuralModel", 1, modelProc, 4) diff --git a/src/polyworld/neural_package.nim b/src/polyworld/neural_package.nim new file mode 100644 index 00000000..fc21a212 --- /dev/null +++ b/src/polyworld/neural_package.nim @@ -0,0 +1,341 @@ +## Polyworld neural packages: a ZIP of exactly manifest.json, policy.bas and +## model.bin, checked against a game's contract. Parsing is strict: any +## unknown key, missing file, hash mismatch, size overflow or contract +## mismatch rejects the package. The Python staging validator +## (coworld/runtime/neural_package.py) mirrors these rules. How a game plugs +## in: docs/neural-policies.md. + +import + std/[json, math, strutils], + crunchy, + boundedinflate, neural_actor + +export neural_actor + +const + PackageFiles* = ["manifest.json", "policy.bas", "model.bin"] + MaxPackageBytes* = 16 * 1024 * 1024 + MaxPolicyBytes* = 256 * 1024 + MaxManifestBytes* = 64 * 1024 + MaxModelBytes* = 8 + 24 + 128 + 4 * 32 + 4 * MaxActorParameters + ## The largest valid model.bin (32 heads, MaxActorParameters weights). + MaxZipEntries* = 16 + ZipMagic* = "PK\x03\x04" + ManifestKeys* = ["schema", "observation_contract", "action_contract", + "decision_period", "files", "model", "decoder"] + ## Top-level keys every contract accepts (a game may add more). + RequiredManifestKeys* = ["schema", "observation_contract", "action_contract", + "decision_period", "files", "model"] + ModelKeys* = ["format", "inputs", "hidden", "heads"] + DecoderKeys* = ["mode", "temperature"] + ## Decoder keys every contract accepts (a game may add more). + +type + DecoderMode* = enum ArgmaxDecoder, SampleDecoder + Decoder* = object + ## manifest.decoder.mode (+ temperature, sample mode only). + case mode*: DecoderMode + of SampleDecoder: + temperature*: float32 + of ArgmaxDecoder: + discard + + PackageContract* = object of RootObj + ## The package half of a game's neural contract. + schema*: string + ## manifest.schema, e.g. "gota-neural-basic/1". + observationHash*, actionHash*: string + ## Lowercase SHA-256 hex of the game's observation and action contract + ## texts; pinned by the manifest and by the model header. + observationSize*: int + ## model.inputs: float32 observation length. + headSizes*: seq[int] + ## model.heads: categorical head sizes, in logit order. + maxDecisionPeriod*: int + ## decision_period must be an integer 1..maxDecisionPeriod. + opBudget*: int + ## Maximum inference cost per decision (neural_actor.operationCount). + manifestKeys*: seq[string] + ## Game top-level manifest keys beyond ManifestKeys (e.g. "goal"). + decoderKeys*: seq[string] + ## Game decoder keys beyond DecoderKeys (e.g. "defer_script"). + parseOptions*: proc(manifest: JsonNode): RootRef {.nimcall.} + ## Optional: validates the game's manifestKeys and decoderKeys and + ## returns the game's options object (NeuralPackage.options). Called + ## once the generic checks pass; raises ValueError to reject. + + NeuralPackage* = object + ## A validated package. Read-only (getters below). + period: int32 + policyText: string + loaded: Actor + decoderSpec: Decoder + gameOptions: RootRef + manifestText: string + +proc decisionPeriod*(package: NeuralPackage): int32 = package.period + +proc policy*(package: NeuralPackage): string = + ## policy.bas, the seat's BASIC program. + package.policyText + +proc actor*(package: NeuralPackage): Actor = + ## The loaded model.bin (the raw bytes are not kept). + package.loaded + +proc decoder*(package: NeuralPackage): Decoder = package.decoderSpec + +proc options*(package: NeuralPackage): RootRef = + ## What the contract's parseOptions returned (nil without one). + package.gameOptions + +proc manifest*(package: NeuralPackage): string = package.manifestText + +proc sampling*(decoder: Decoder): bool = decoder.mode == SampleDecoder + +proc temperatureOf*(decoder: Decoder): float32 = + ## The sampling temperature (1 in argmax mode). + if decoder.mode == SampleDecoder: decoder.temperature else: 1 + +proc isPackage*(bytes: string): bool = bytes.startsWith(ZipMagic) + +proc sha256Hex*(data: string): string = + ## Lowercase hex SHA-256 (file hashes and contract hashes). + for value in sha256(cast[pointer](data.cstring), data.len): + result.add value.toHex(2).toLowerAscii() + +proc u16(s: string, p: int): int = + if p < 0 or p + 2 > s.len: raise newException(ValueError, "truncated zip") + ord(s[p]) or (ord(s[p+1]) shl 8) + +proc u32(s: string, p: int): int = + if p < 0 or p + 4 > s.len: raise newException(ValueError, "truncated zip") + ord(s[p]) or (ord(s[p+1]) shl 8) or (ord(s[p+2]) shl 16) or (ord(s[p+3]) shl 24) + +proc entryLimit*(name: string): int = + ## The largest uncompressed size a package entry may declare (-1 = the + ## name is not a package file). + case name + of "manifest.json": MaxManifestBytes + of "policy.bas": MaxPolicyBytes + of "model.bin": MaxModelBytes + else: -1 + +proc readZip*(bytes: string): seq[(string, string)] = + ## Reads every entry of a small, unencrypted, single-disk package ZIP + ## (stored or deflate) through its central directory. Only package file + ## names are accepted, and each entry's declared size is checked against + ## its cap (entryLimit) before anything is decompressed; decompression is + ## bounded by the declared size. + if bytes.len > MaxPackageBytes: + raise newException(ValueError, "package exceeds 16 MiB") + var eocd = -1 + for p in countdown(bytes.len - 22, max(0, bytes.len - 22 - 65535)): + if bytes[p] == 'P' and bytes[p+1] == 'K' and bytes[p+2] == '\x05' and bytes[p+3] == '\x06': + eocd = p + break + if eocd < 0: raise newException(ValueError, "zip end record not found") + let + entries = u16(bytes, eocd + 10) + cdOffset = u32(bytes, eocd + 16) + if entries > MaxZipEntries: raise newException(ValueError, "too many zip entries") + var p = cdOffset + for _ in 0 ..< entries: + if u32(bytes, p) != 0x02014b50: raise newException(ValueError, "bad zip directory") + let + flags = u16(bytes, p + 8) + methodId = u16(bytes, p + 10) + compressed = u32(bytes, p + 20) + size = u32(bytes, p + 24) + nameLen = u16(bytes, p + 28) + extraLen = u16(bytes, p + 30) + commentLen = u16(bytes, p + 32) + local = u32(bytes, p + 42) + if p + 46 + nameLen > bytes.len: raise newException(ValueError, "truncated zip") + let name = bytes[p + 46 ..< p + 46 + nameLen] + let limit = entryLimit(name) + if limit < 0: + raise newException(ValueError, "unexpected package entry " & name) + if size > limit: + raise newException(ValueError, name & " exceeds its " & $limit & " byte limit") + if (flags and 1) != 0: raise newException(ValueError, "encrypted zip entry") + if u32(bytes, local) != 0x04034b50: raise newException(ValueError, "bad zip entry") + let + dataStart = local + 30 + u16(bytes, local + 26) + u16(bytes, local + 28) + if dataStart + compressed > bytes.len: raise newException(ValueError, "truncated zip data") + let raw = bytes[dataStart ..< dataStart + compressed] + var data: string + case methodId + of 0: data = raw + of 8: + try: + data = inflateBounded(raw, size) + except ZippyError as error: + raise newException(ValueError, "bad deflate data for " & name & ": " & error.msg) + else: raise newException(ValueError, "unsupported zip compression " & $methodId) + if data.len != size: raise newException(ValueError, "zip size mismatch for " & name) + result.add((name, data)) + p += 46 + nameLen + extraLen + commentLen + +proc requireManifestKeys*(node: JsonNode, allowed: openArray[string], where: string) = + ## Rejects a non-object or any key outside `allowed` (strict manifests). + if node.kind != JObject: + raise newException(ValueError, where & " must be an object") + for key in node.keys: + if key notin allowed: + raise newException(ValueError, "unknown manifest key " & where & "." & key) + +proc rejectNonFinite*(node: JsonNode, where = "manifest") = + ## Rejects any non-finite number anywhere in a manifest (1e999 parses + ## as infinity). + case node.kind + of JFloat: + if classify(node.getFloat) in {fcNan, fcInf, fcNegInf}: + raise newException(ValueError, where & " holds a non-finite number") + of JArray: + for item in node: + rejectNonFinite(item, where) + of JObject: + for key, item in node: + rejectNonFinite(item, where & "." & key) + else: + discard + +proc requireInt*(node: JsonNode, key, where: string): int = + ## A required integer key: a JSON integer (1407.0 and true are rejected). + if not node.hasKey(key) or node[key].kind != JInt: + raise newException(ValueError, where & "." & key & " must be an integer") + node[key].getInt + +proc requireString*(node: JsonNode, key, where: string): string = + ## A required string key. + if not node.hasKey(key) or node[key].kind != JString: + raise newException(ValueError, where & "." & key & " must be a string") + node[key].getStr + +proc requireNumber*(node: JsonNode, where: string): float = + ## A JSON number (integer or float, never a boolean). + if node.kind notin {JInt, JFloat}: + raise newException(ValueError, where & " must be a number") + node.getFloat + +proc requireBool*(node: JsonNode, key, where: string): bool = + ## An optional boolean key (false when absent). + if node.hasKey(key): + if node[key].kind != JBool: + raise newException(ValueError, where & "." & key & " must be true or false") + result = node[key].getBool + +proc headList(sizes: openArray[int]): string = + result = "[" + for i, size in sizes: + if i > 0: result.add "," + result.add $size + result.add "]" + +proc loadActor*(data: string, contract: PackageContract): Actor = + ## Loads a model for `contract`: the op budget, both header contract + ## hashes, the input size and the head sizes must all match. + result = loadActor(data, contract.opBudget) + if result.observationContract != contract.observationHash or + result.actionContract != contract.actionHash: + raise newException(ValueError, "model.bin contract hashes do not match the manifest") + if result.inputSize != contract.observationSize: + raise newException(ValueError, "model.inputs must be " & $contract.observationSize) + if result.headSizes != contract.headSizes: + raise newException(ValueError, "model.heads must be " & + headList(contract.headSizes)) + +proc parsePackage*(bytes: string, contract: PackageContract): NeuralPackage = + ## Validates a package completely against `contract`; raises ValueError + ## with the reason (never a defect, whatever the bytes). + let entries = readZip(bytes) + if entries.len != 3: + raise newException(ValueError, "package must contain exactly manifest.json, policy.bas and model.bin") + var files: array[3, string] + var seen: array[3, bool] + for (name, data) in entries: + let index = PackageFiles.find(name) + if index < 0 or seen[index]: + raise newException(ValueError, "unexpected package entry " & name) + seen[index] = true + files[index] = data + let manifest = + try: + parseJson(files[0]) + except JsonParsingError as error: + raise newException(ValueError, "manifest.json is not JSON: " & error.msg) + manifest.requireManifestKeys(@ManifestKeys & contract.manifestKeys, "manifest") + manifest.rejectNonFinite() + for key in RequiredManifestKeys: + if not manifest.hasKey(key): + raise newException(ValueError, "manifest is missing " & key) + if manifest.requireString("schema", "manifest") != contract.schema: + raise newException(ValueError, "manifest schema must be " & contract.schema) + if manifest.requireString("observation_contract", "manifest") != contract.observationHash: + raise newException(ValueError, "observation contract mismatch") + if manifest.requireString("action_contract", "manifest") != contract.actionHash: + raise newException(ValueError, "action contract mismatch") + let period = manifest.requireInt("decision_period", "manifest") + if period notin 1..contract.maxDecisionPeriod: + raise newException(ValueError, "decision_period must be an integer 1.." & + $contract.maxDecisionPeriod) + result.period = int32(period) + let fileHashes = manifest["files"] + fileHashes.requireManifestKeys(["policy.bas", "model.bin"], "files") + if not fileHashes.hasKey("policy.bas") or not fileHashes.hasKey("model.bin"): + raise newException(ValueError, "files must list policy.bas and model.bin") + if fileHashes.requireString("policy.bas", "files") != sha256Hex(files[1]): + raise newException(ValueError, "policy.bas sha256 mismatch") + if fileHashes.requireString("model.bin", "files") != sha256Hex(files[2]): + raise newException(ValueError, "model.bin sha256 mismatch") + if files[1].len > MaxPolicyBytes: + raise newException(ValueError, "policy.bas exceeds 256 KiB") + let model = manifest["model"] + model.requireManifestKeys(ModelKeys, "model") + for key in ModelKeys: + if not model.hasKey(key): + raise newException(ValueError, "model is missing " & key) + if model.requireString("format", "model") != ActorMagic: + raise newException(ValueError, "model.format must be " & ActorMagic) + let actor = loadActor(files[2], contract) + if model.requireInt("inputs", "model") != actor.inputSize: + raise newException(ValueError, "model.inputs must be " & $contract.observationSize) + if model.requireInt("hidden", "model") != actor.hiddenSize: + raise newException(ValueError, "model.hidden does not match model.bin") + var heads: seq[int] + if model["heads"].kind != JArray: + raise newException(ValueError, "model.heads must be a list of integers") + for h in model["heads"]: + if h.kind != JInt: + raise newException(ValueError, "model.heads must be a list of integers") + heads.add h.getInt + if heads != contract.headSizes: + raise newException(ValueError, "model.heads must be " & + headList(contract.headSizes)) + result.loaded = actor + result.decoderSpec = Decoder(mode: ArgmaxDecoder) + if manifest.hasKey("decoder"): + let decoder = manifest["decoder"] + decoder.requireManifestKeys(@DecoderKeys & contract.decoderKeys, "decoder") + let mode = + if decoder.hasKey("mode"): decoder.requireString("mode", "decoder") + else: "argmax" + case mode + of "argmax": + if decoder.hasKey("temperature"): + raise newException(ValueError, "decoder.temperature needs mode sample") + of "sample": + var temperature = 1'f32 + if decoder.hasKey("temperature"): + let t = decoder["temperature"].requireNumber("decoder.temperature") + if t < 0.01 or t > 10: + raise newException(ValueError, "decoder.temperature must be 0.01..10") + temperature = float32(t) + result.decoderSpec = Decoder(mode: SampleDecoder, temperature: temperature) + else: + raise newException(ValueError, "decoder.mode must be argmax or sample") + if contract.parseOptions != nil: + result.gameOptions = contract.parseOptions(manifest) + result.policyText = files[1] + result.manifestText = files[0] diff --git a/tests/neural_cases.py b/tests/neural_cases.py new file mode 100644 index 00000000..340cec76 --- /dev/null +++ b/tests/neural_cases.py @@ -0,0 +1,202 @@ +"""Shared neural-package corruption suite, Python half (stdlib only; CI runs it). + + python3 tests/neural_cases.py OUT_DIR + +Builds the toy contract of tests/neural_toy.nim ("toy-neural/1"), writes a good package and +every corruption case to OUT_DIR/.zip, validates each with the shared Python validator +(coworld/runtime/neural_package.py) and records the verdicts in OUT_DIR/verdicts.json. Then +tests/test_neural_cases.nim validates the same files with the Nim loader and fails on any +disagreement, so the two validators cannot drift apart. +""" +import io, json, os, struct, sys, zipfile, zlib + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(HERE, "../coworld/runtime")) +import neural_package as npk # noqa: E402 + +TOY = npk.Contract( + schema="toy-neural/1", + obs_hash=npk.contract_hash("toy-neural/1 observation v1 float32[3]: bias,x,alive"), + action_hash=npk.contract_hash("toy-neural/1 action v1 heads move2,aim3"), + obs_size=3, heads=[2, 3], max_period=8, op_budget=200_000, + manifest_keys=("tag",), decoder_keys=("hold",)) + + +def _toy_options(m): + if "tag" in m and (not isinstance(m["tag"], str) or len(m["tag"].encode()) > 32): + raise npk.PackageError("tag must be a string of at most 32 bytes") + if "decoder" in m: + npk.require_bool(m["decoder"], "hold", "decoder") + return None + + +TOY.parse_options = _toy_options +POLICY = b"' toy policy\nx = neuralModel(1)\n" + + +def model(hidden=64, seed=1, obs_hash=TOY.obs_hash, inputs=3, heads=(2, 3)): + n = inputs * hidden + 3 * hidden * hidden + sum(heads) * hidden + s = (seed * 2654435761 + 1) & 0xFFFFFFFF + w = [] + for _ in range(n): + s = (s * 1664525 + 1013904223) & 0xFFFFFFFF + w.append(((s >> 8) / 16777216 - 0.5) * 0.5) + return npk.encode_model(w, hidden, npk.Contract(TOY.schema, obs_hash, TOY.action_hash, inputs, list(heads)), + inputs, list(heads)) + + +def manifest(policy=POLICY, mdl=None, **edits): + m = npk.manifest_for(policy, mdl if mdl is not None else model(), TOY, period=2) + for k, v in edits.items(): + if v is DELETE: + m.pop(k) + else: + m[k] = v + return m + + +DELETE = object() + + +def package(m=None, policy=POLICY, mdl=None, raw_manifest=None): + mdl = mdl if mdl is not None else model() + text = raw_manifest if raw_manifest is not None else json.dumps(m if m is not None else manifest(policy, mdl)) + return npk.zip_files({"manifest.json": text, "policy.bas": policy, "model.bin": mdl}) + + +def raw_zip(entries): + """entries: (name, data, method, flags, declared_size or None, stored_payload or None).""" + out, central = bytearray(), bytearray() + for name, data, method, flags, declared, payload in entries: + if payload is None: + if method == 8: + c = zlib.compressobj(9, zlib.DEFLATED, -15) + payload = c.compress(data) + c.flush() + else: + payload = data + size = len(data) if declared is None else declared + crc = zlib.crc32(data) + off = len(out) + nb = name.encode() + out += struct.pack(" 200000 ops + yield "over op budget", package(manifest(POLICY, big), POLICY, big), False + + +def main(): + out = sys.argv[1] + os.makedirs(out, exist_ok=True) + verdicts, failures = {}, [] + for i, (name, data, expected) in enumerate(cases()): + try: + npk.validate(data, TOY) + accepted, reason = True, "" + except npk.PackageError as e: + accepted, reason = False, str(e) + except Exception as e: # any other exception is a validator bug + accepted, reason = None, f"{type(e).__name__}: {e}" + file = f"case{i:02d}.zip" + with open(os.path.join(out, file), "wb") as f: + f.write(data) + verdicts[file] = {"name": name, "accepted": accepted, "reason": reason} + ok = accepted is expected + print(("PASS " if ok else "FAIL ") + f"{name}: " + ("accepted" if accepted else f"rejected ({reason})")) + if not ok: + failures.append(name) + with open(os.path.join(out, "verdicts.json"), "w") as f: + json.dump(verdicts, f, indent=1) + print(f"{len(verdicts)} cases, {len(failures)} failures {failures if failures else ''}") + sys.exit(1 if failures else 0) + + +if __name__ == "__main__": + main() diff --git a/tests/neural_toy.nim b/tests/neural_toy.nim new file mode 100644 index 00000000..1e406f1a --- /dev/null +++ b/tests/neural_toy.nim @@ -0,0 +1,190 @@ +## A synthetic neural contract ("toy-neural/1": 3 observation floats, heads +## [2, 3]) and package helpers, shared by the neural tier tests. The toy +## contract is the same one tests/neural_cases.py builds in Python. + +import + std/[json, math, strutils], + zippy, zippy/crc, + polyworld/neural_host + +const + ToySchema* = "toy-neural/1" + ToyObservationText* = "toy-neural/1 observation v1 float32[3]: bias,x,alive" + ToyActionText* = "toy-neural/1 action v1 heads move2,aim3" + ToyObservation* = 3 + ToyHeads* = [2, 3] + ToyOutputs* = 5 + ToyBudget* = 200_000 + +type + ToyOptions* = ref object of RootObj + tag*: string + hold*: bool + ToySeat* = ref object of NeuralBrain + ## The toy game's seat: what it observes and what it last decided. + x*: float32 + alive*: bool + heads*: array[2, int32] + decoded*: int + ToyWorld* = object + tick*: int32 + log*: seq[string] + +var toyWorld*: ToyWorld + +proc parseToyOptions*(manifest: JsonNode): RootRef {.nimcall.} = + ## Toy extension keys: manifest.tag (a short string), decoder.hold (bool). + let options = ToyOptions() + if manifest.hasKey("tag"): + if manifest["tag"].kind != JString or manifest["tag"].getStr.len > 32: + raise newException(ValueError, "tag must be a string of at most 32 bytes") + options.tag = manifest["tag"].getStr + if manifest.hasKey("decoder"): + options.hold = manifest["decoder"].requireBool("hold", "decoder") + options + +proc toyObserve(brain: NeuralBrain): bool {.nimcall.} = + let seat = ToySeat(brain) + seat.obs[0] = 1 + seat.obs[1] = seat.x + seat.obs[2] = (if seat.alive: 1 else: 0) + seat.alive + +proc toyDecode(brain: NeuralBrain) {.nimcall.} = + let seat = ToySeat(brain) + if seat.sampling: + sampleHeads(seat.logits, ToyHeads, seat.temperature, seat.rng, seat.heads) + else: + argmaxHeads(seat.logits, ToyHeads, seat.heads) + inc seat.decoded + +proc toyHead(brain: NeuralBrain, index: int): int32 {.nimcall.} = + ToySeat(brain).heads[index] + +proc toyTick(brain: NeuralBrain): int32 {.nimcall.} = toyWorld.tick + +proc toyLog(brain: NeuralBrain, text: string) {.nimcall.} = + toyWorld.log.add("seat " & $brain.seat & " " & text) + +proc toyContract*(budget = ToyBudget): NeuralContract = + initNeuralContract("toy", ToySchema, ToyObservationText, ToyActionText, + ToyObservation, ToyHeads, ToyOutputs, toyObserve, toyDecode, toyHead, + toyTick, log = toyLog, maxDecisionPeriod = 8, opBudget = budget, + manifestKeys = ["tag"], decoderKeys = ["hold"], + parseOptions = parseToyOptions) + +proc weightCount*(hidden: int, inputs = ToyObservation, outputs = ToyOutputs): int = + inputs * hidden + 3 * hidden * hidden + outputs * hidden + +proc toyWeights*(hidden: int, seed: uint32): seq[float32] = + ## Deterministic small weights (an LCG), different per seed. + var s = seed * 2654435761'u32 + 1 + result = newSeq[float32](weightCount(hidden)) + for w in result.mitems: + s = s * 1664525'u32 + 1013904223'u32 + w = (float32(s shr 8) / 16777216'f32 - 0.5'f32) * 0.5'f32 + +proc toyModel*(hidden = 64, seed = 1'u32, contract = toyContract()): string = + encodeActor(ToyObservation, hidden, ToyHeads, contract.observationHash, + contract.actionHash, toyWeights(hidden, seed)) + +const ToyPolicy* = """ +' toy policy glue: reads the tier's neural host functions +if run_neural_net() then + fresh = 1 + move = neuralModel(5) + aim = neuralModel(6) + width = neuralModel(0) +end if +inputs = neuralModel(1) +outputs = neuralModel(2) +period = neuralModel(3) +beyond = neuralModel(7) +""" + +proc u16(s: var string, v: int) = + s.add char(v and 0xff) + s.add char((v shr 8) and 0xff) + +proc u32(s: var string, v: int) = + for i in 0..3: + s.add char((v shr (8 * i)) and 0xff) + +type ZipEntry* = object + name*, data*: string + deflate*: bool + flags*: int + methodOverride*: int ## -1 = stored/deflate from `deflate` + declaredSize*: int ## -1 = the real size + +proc entry*(name, data: string, deflate = true): ZipEntry = + ZipEntry(name: name, data: data, deflate: deflate, methodOverride: -1, + declaredSize: -1) + +proc writeZip*(entries: openArray[ZipEntry]): string = + ## A minimal single-disk ZIP writer (stored or deflate) with knobs for + ## corrupting flags, the method and the declared size. + var central: string + for e in entries: + let + payload = if e.deflate: compress(e.data, DefaultCompression, dfDeflate) else: e.data + methodId = if e.methodOverride >= 0: e.methodOverride elif e.deflate: 8 else: 0 + size = if e.declaredSize >= 0: e.declaredSize else: e.data.len + crc = int(crc32(e.data)) + offset = result.len + result.u32(0x04034b50) + result.u16(20); result.u16(e.flags); result.u16(methodId) + result.u16(0); result.u16(0) + result.u32(crc); result.u32(payload.len); result.u32(size) + result.u16(e.name.len); result.u16(0) + result.add e.name + result.add payload + central.u32(0x02014b50) + central.u16(20); central.u16(20); central.u16(e.flags); central.u16(methodId) + central.u16(0); central.u16(0) + central.u32(crc); central.u32(payload.len); central.u32(size) + central.u16(e.name.len); central.u16(0); central.u16(0) + central.u16(0); central.u16(0); central.u32(0); central.u32(offset) + central.add e.name + let cdOffset = result.len + result.add central + result.u32(0x06054b50) + result.u16(0); result.u16(0) + result.u16(entries.len); result.u16(entries.len) + result.u32(central.len); result.u32(cdOffset) + result.u16(0) + +proc toyManifest*(policy, model: string, contract = toyContract(), + period = 2, hidden = 64): JsonNode = + %*{ + "schema": ToySchema, + "observation_contract": contract.observationHash, + "action_contract": contract.actionHash, + "decision_period": period, + "files": {"policy.bas": sha256Hex(policy), "model.bin": sha256Hex(model)}, + "model": {"format": "GOTANET1", "inputs": ToyObservation, "hidden": hidden, + "heads": ToyHeads} + } + +proc toyPackage*(manifest: JsonNode, policy = ToyPolicy, model = toyModel()): string = + writeZip([entry("manifest.json", $manifest), entry("policy.bas", policy), + entry("model.bin", model)]) + +proc goodToyPackage*(): string = + let model = toyModel() + toyPackage(toyManifest(ToyPolicy, model), ToyPolicy, model) + +proc newToySeat*(actor: Actor, seat = 0, period = 2'i32, + maxTicks = 100'i32, contract = toyContract()): ToySeat = + result = ToySeat(alive: true) + result.initBrain(contract, period, maxTicks) + result.actor = actor + result.resetBrain(7, seat) + +proc isFiniteVector*(values: openArray[float32]): bool = + for v in values: + if classify(v) in {fcNan, fcInf, fcNegInf}: + return false + true + +proc hasPrefix*(text, prefix: string): bool = text.startsWith(prefix) diff --git a/tests/test_neural_cases.nim b/tests/test_neural_cases.nim new file mode 100644 index 00000000..009f4ab0 --- /dev/null +++ b/tests/test_neural_cases.nim @@ -0,0 +1,36 @@ +## Shared neural-package corruption suite, Nim half. +## +## python3 tests/neural_cases.py tmp/neural_cases +## nim r tests/test_neural_cases.nim tmp/neural_cases +## +## Validates every package the Python half wrote with the Nim loader and +## requires the same verdict (accepted or rejected) for each one. + +import + std/[json, os, strutils], + polyworld/neural_host, + neural_toy + +let dir = if paramCount() >= 1: paramStr(1) else: "tmp/neural_cases" +let verdicts = parseJson(readFile(dir / "verdicts.json")) +var failures: seq[string] +for file, verdict in verdicts: + let name = verdict["name"].getStr + var accepted = false + var reason = "" + try: + discard parsePackage(readFile(dir / file), toyContract()) + accepted = true + except ValueError as error: + reason = error.msg + let python = verdict["accepted"] + let agree = python.kind == JBool and python.getBool == accepted + echo (if agree: "PASS " else: "FAIL "), name, ": nim ", + (if accepted: "accepted" else: "rejected (" & reason & ")"), + ", python ", (if python.kind == JBool and python.getBool: "accepted" + else: "rejected (" & verdict["reason"].getStr & ")") + if not agree: + failures.add name +echo verdicts.len, " cases, ", failures.len, " disagreements ", failures.join(", ") +doAssert verdicts.len >= 40, "the Python half wrote too few cases" +doAssert failures.len == 0 diff --git a/tests/test_neural_tier.nim b/tests/test_neural_tier.nim new file mode 100644 index 00000000..9367d5e6 --- /dev/null +++ b/tests/test_neural_tier.nim @@ -0,0 +1,303 @@ +## The shared neural tier on a synthetic contract (tests/neural_toy.nim): +## package loading, corruption and budget rejection, strict keys, the +## recurrent-state lifecycle, telemetry and the BASIC host functions. No +## game is involved, which is the point: the tier is game-agnostic. + +import + std/[json, strutils], + bassy, + polyworld/neural_host, + neural_toy + +proc rejects(bytes: string, message: string, contract = toyContract()) = + ## The package must be rejected with a ValueError naming `message`. + var caught = false + try: + discard parsePackage(bytes, contract) + except ValueError as error: + caught = true + doAssert message in error.msg, "want '" & message & "', got: " & error.msg + doAssert caught, "package accepted, expected rejection: " & message + +proc withManifest(edit: proc(m: JsonNode)): string = + let model = toyModel() + var m = toyManifest(ToyPolicy, model) + edit(m) + toyPackage(m, ToyPolicy, model) + +echo "Testing a toy package loads through the shared tier" +block: + let contract = toyContract() + doAssert contract.observationHash == contractHash(ToyObservationText) + doAssert contract.actionHash == sha256Hex(ToyActionText) + doAssert contract.headOutputs == ToyOutputs + let package = parsePackage(goodToyPackage(), contract) + doAssert package.decisionPeriod == 2 + doAssert package.policy == ToyPolicy + doAssert package.actor.inputSize == ToyObservation + doAssert package.actor.hiddenSize == 64 + doAssert package.actor.outputSize == ToyOutputs + doAssert package.actor.headSizes == @ToyHeads + doAssert package.decoder.mode == ArgmaxDecoder + doAssert not package.decoder.sampling + doAssert package.decoder.temperatureOf == 1 + let options = ToyOptions(package.options) + doAssert options.tag == "" and not options.hold + let sampled = parsePackage(withManifest(proc(m: JsonNode) = + m["tag"] = %"v1" + m["decoder"] = %*{"mode": "sample", "temperature": 0.5, "hold": true}), + contract) + doAssert sampled.decoder.mode == SampleDecoder + doAssert sampled.decoder.temperature == 0.5 + doAssert ToyOptions(sampled.options).tag == "v1" + doAssert ToyOptions(sampled.options).hold + let stored = writeZip([entry("manifest.json", $toyManifest(ToyPolicy, toyModel()), false), + entry("policy.bas", ToyPolicy, false), entry("model.bin", toyModel(), false)]) + doAssert parsePackage(stored, contract).actor.hiddenSize == 64 + +echo "Testing unknown keys and wrong JSON kinds are rejected" +block: + rejects(withManifest(proc(m: JsonNode) = m["extra"] = %1), "unknown manifest key manifest.extra") + rejects(withManifest(proc(m: JsonNode) = m["decoder"] = %*{"fire": 1}), + "unknown manifest key decoder.fire") + rejects(withManifest(proc(m: JsonNode) = m["model"]["layers"] = %1), + "unknown manifest key model.layers") + rejects(withManifest(proc(m: JsonNode) = m["files"]["notes.txt"] = %"x"), + "unknown manifest key files.notes.txt") + rejects(withManifest(proc(m: JsonNode) = m["decoder"] = %*{"hold": 1}), + "decoder.hold must be true or false") + rejects(withManifest(proc(m: JsonNode) = m["tag"] = %7), "tag must be a string") + rejects(withManifest(proc(m: JsonNode) = m["decoder"] = %"argmax"), + "decoder must be an object") + rejects(withManifest(proc(m: JsonNode) = m["model"]["heads"] = %3), + "model.heads must be a list of integers") + rejects(withManifest(proc(m: JsonNode) = m["model"]["heads"] = %*[2.0, 3]), + "model.heads must be a list of integers") + rejects(withManifest(proc(m: JsonNode) = m["model"]["inputs"] = %3.0), + "model.inputs must be an integer") + rejects(withManifest(proc(m: JsonNode) = m["decision_period"] = %true), + "decision_period must be an integer") + rejects(withManifest(proc(m: JsonNode) = m["decision_period"] = %9), + "decision_period must be an integer 1..8") + rejects(withManifest(proc(m: JsonNode) = m["schema"] = %1), "schema must be a string") + rejects(withManifest(proc(m: JsonNode) = m["schema"] = %"other/1"), + "manifest schema must be toy-neural/1") + rejects(withManifest(proc(m: JsonNode) = m["observation_contract"] = %( + "0".repeat(64))), "observation contract mismatch") + rejects(withManifest(proc(m: JsonNode) = m.delete("model")), "manifest is missing model") + rejects(withManifest(proc(m: JsonNode) = + m["decoder"] = %*{"mode": "sample", "temperature": true}), + "decoder.temperature must be a number") + rejects(withManifest(proc(m: JsonNode) = + m["decoder"] = %*{"mode": "sample", "temperature": 20}), + "decoder.temperature must be 0.01..10") + rejects(withManifest(proc(m: JsonNode) = m["decoder"] = %*{"temperature": 1}), + "decoder.temperature needs mode sample") + rejects(withManifest(proc(m: JsonNode) = m["decoder"] = %*{"mode": "beam"}), + "decoder.mode must be argmax or sample") + let model = toyModel() + let manifest = ($toyManifest(ToyPolicy, model)).replace("\"decision_period\":2", + "\"decision_period\":2,\"tag\":1e999") + rejects(writeZip([entry("manifest.json", "{not json"), entry("policy.bas", ToyPolicy), + entry("model.bin", model)]), "manifest.json is not JSON") + rejects(writeZip([entry("manifest.json", manifest), entry("policy.bas", ToyPolicy), + entry("model.bin", model)]), "non-finite number") + rejects(writeZip([entry("manifest.json", "[1, 2]"), entry("policy.bas", ToyPolicy), + entry("model.bin", model)]), "manifest must be an object") + rejects(writeZip([entry("manifest.json", "{\"schema\": NaN}"), + entry("policy.bas", ToyPolicy), entry("model.bin", model)]), + "manifest.json is not JSON") + +echo "Testing ZIP and model corruption is rejected before use" +block: + let + model = toyModel() + manifest = $toyManifest(ToyPolicy, model) + rejects(writeZip([entry("manifest.json", manifest), entry("policy.bas", ToyPolicy)]), + "exactly manifest.json, policy.bas and model.bin") + rejects(writeZip([entry("manifest.json", manifest), entry("policy.bas", ToyPolicy), + entry("model.bin", model), entry("notes.txt", "hi")]), "unexpected package entry notes.txt") + rejects(writeZip([entry("manifest.json", manifest), entry("policy.bas", ToyPolicy), + entry("policy.bas", ToyPolicy)]), "unexpected package entry policy.bas") + var encrypted = entry("model.bin", model) + encrypted.flags = 1 + rejects(writeZip([entry("manifest.json", manifest), entry("policy.bas", ToyPolicy), + encrypted]), "encrypted zip entry") + var bzip = entry("model.bin", model, false) + bzip.methodOverride = 12 + rejects(writeZip([entry("manifest.json", manifest), entry("policy.bas", ToyPolicy), + bzip]), "unsupported zip compression 12") + # Zip bomb: a tiny deflate stream declaring 1 KiB that inflates to 8 MiB + # is stopped at its declared size, never inflated in full. + var bomb = entry("model.bin", "\0".repeat(8 * 1024 * 1024)) + bomb.declaredSize = 1024 + rejects(writeZip([entry("manifest.json", manifest), entry("policy.bas", ToyPolicy), + bomb]), "inflated data exceeds its limit") + var oversized = entry("policy.bas", ToyPolicy) + oversized.declaredSize = MaxPolicyBytes + 1 + rejects(writeZip([entry("manifest.json", manifest), oversized, + entry("model.bin", model)]), "policy.bas exceeds its") + var lying = entry("policy.bas", ToyPolicy, false) + lying.declaredSize = ToyPolicy.len + 1 + rejects(writeZip([entry("manifest.json", manifest), lying, + entry("model.bin", model)]), "zip size mismatch for policy.bas") + let good = goodToyPackage() + rejects(good[0 ..< good.len div 2], "zip") + rejects("PK\x03\x04" & "\0".repeat(MaxPackageBytes), "package exceeds 16 MiB") + var badMagic = model + badMagic[0] = 'X' + rejects(toyPackage(toyManifest(ToyPolicy, badMagic), ToyPolicy, badMagic), + "invalid neural actor magic") + var nonfinite = model + let at = headerSize(ToyHeads.len) + 4 * 5 + nonfinite[at ..< at + 4] = "\0\0\xc0\x7f" # NaN + rejects(toyPackage(toyManifest(ToyPolicy, nonfinite), ToyPolicy, nonfinite), + "nonfinite neural weight") + let truncated = model[0 ..< model.len - 4] + rejects(toyPackage(toyManifest(ToyPolicy, truncated), ToyPolicy, truncated), + "invalid neural actor length") + rejects(toyPackage(toyManifest(ToyPolicy, model), ToyPolicy & "' edit\n", model), + "policy.bas sha256 mismatch") + let other = toyModel(seed = 2) + rejects(toyPackage(toyManifest(ToyPolicy, model), ToyPolicy, other), + "model.bin sha256 mismatch") + let foreign = encodeActor(ToyObservation, 64, ToyHeads, "a".repeat(64), + toyContract().actionHash, toyWeights(64, 1)) + rejects(toyPackage(toyManifest(ToyPolicy, foreign), ToyPolicy, foreign), + "model.bin contract hashes do not match") + let wide = encodeActor(4, 64, ToyHeads, toyContract().observationHash, + toyContract().actionHash, newSeq[float32](weightCount(64, inputs = 4))) + rejects(toyPackage(toyManifest(ToyPolicy, wide), ToyPolicy, wide), + "model.inputs must be 3") + let reheaded = encodeActor(ToyObservation, 64, [3, 2], toyContract().observationHash, + toyContract().actionHash, toyWeights(64, 1)) + rejects(toyPackage(toyManifest(ToyPolicy, reheaded), ToyPolicy, reheaded), + "model.heads must be [2,3]") + +echo "Testing the per-seat operation budget rejects over-budget models" +block: + let + model = toyModel(hidden = 128) + ops = 2 * weightCount(128) + 32 * 128 + doAssert loadActor(model, toyContract()).operationCount == ops + let tight = toyContract(budget = ops - 1) + var caught = false + try: + discard loadActor(model, tight) + except ValueError as error: + caught = true + doAssert "needs " & $ops & " operations per inference, over the " & + $(ops - 1) & " budget" in error.msg, error.msg + doAssert caught + rejects(toyPackage(toyManifest(ToyPolicy, model, tight, hidden = 128), + ToyPolicy, model), "budget", tight) + doAssert parsePackage(toyPackage(toyManifest(ToyPolicy, model, hidden = 128), + ToyPolicy, model), toyContract(budget = ops)).actor.hiddenSize == 128 + +echo "Testing the recurrent state lifecycle and resets" +block: + let actor = loadActor(toyModel(), toyContract()) + proc run(seat: ToySeat, ticks: int, deathAt = -1): seq[seq[float32]] = + for t in 1 .. ticks: + seat.alive = t != deathAt + seat.x = float32(t) * 0.25'f32 + toyWorld.tick = int32(t) + if seat.decide(int32(t), int32(t)): + result.add seat.logits & seat.state + else: + result.add @[] + let seat = newToySeat(actor) + discard seat.run(12, deathAt = 5) + seat.resetBrain(7, 0) + let again = seat.run(9) + let fresh = newToySeat(actor).run(9) + doAssert again == fresh, "a reset seat must replay a fresh seat exactly" + # First frame resets; a death blanks the frame; the next frame resets again. + let life = newToySeat(actor) + life.x = 0.25 + toyWorld.tick = 1 + doAssert life.decide(1, 1) and life.resetState + let first = life.logits + life.x = 1 + toyWorld.tick = 2 + doAssert life.decide(2, 2) and not life.resetState + doAssert life.state != newSeq[float32](64), "state carries over between frames" + doAssert not life.decide(2, 2), "one frame per tick" + life.alive = false + doAssert not life.decide(3, 3) and not life.acting + life.alive = true + life.x = 0.25 + doAssert life.decide(4, 4) and life.resetState + doAssert life.logits == first, "state restarts from zero after a death" + doAssert isFiniteVector(life.state) + doAssert decisionDue(1, 4) and not decisionDue(2, 4) and decisionDue(5, 4) + doAssert not decisionDue(1, 0) + +echo "Testing telemetry lines" +block: + toyWorld.log.setLen(0) + let seat = newToySeat(loadActor(toyModel(), toyContract()), seat = 3, + period = 1, maxTicks = 3700) + seat.telemetry = true + for t in 1 .. 3700: + toyWorld.tick = int32(t) + discard seat.decide(int32(t), int32(t)) + let ops = seat.actor.operationCount + doAssert toyWorld.log.len == 4, $toyWorld.log + doAssert toyWorld.log[0] == "seat 3 neural: peak_ops=" & $ops & + " budget=200000 model=w64 ticks=1 inferences=1" + doAssert toyWorld.log[1].endsWith("ticks=1801 inferences=1801") + doAssert toyWorld.log[2].endsWith("ticks=3601 inferences=3601") + doAssert toyWorld.log[3].endsWith("ticks=3700 inferences=3700") + let quiet = newToySeat(seat.actor) + toyWorld.log.setLen(0) + toyWorld.tick = 1 + discard quiet.decide(1, 1) + doAssert toyWorld.log.len == 0, "telemetry is opt-in per seat" + +echo "Testing decoders are deterministic and honour masks" +block: + let logits = [0.1'f32, 0.9, 2.0, 2.0, -1.0] + var heads: array[2, int32] + argmaxHeads(logits, ToyHeads, heads) + doAssert heads == [1'i32, 0], "first maximum wins" + var a, b = 42'u64 + var ha, hb: array[2, int32] + for _ in 0 ..< 50: + sampleHeads(logits, ToyHeads, 1, a, ha) + sampleHeads(logits, ToyHeads, 1, b, hb) + doAssert ha == hb + doAssert pickHead(logits, 2, 3, [false, false, true], false, 1, 0) == 2 + doAssert pickHead(logits, 2, 3, [false, false, false], false, 1, 0) == 0 + for draw in [0.0, 0.3, 0.99]: + doAssert pickHead(logits, 2, 3, [false, true, false], true, 1, draw) == 1 + +echo "Testing the neural BASIC host functions" +block: + let actor = loadActor(toyModel(), toyContract()) + var current: ToySeat = nil + var schema = initHost() + schema.addNeuralHostFunctions(proc(): NeuralBrain = current) + let program = compile(ToyPolicy, schema) + var host = initHost() + host.addNeuralHostFunctions(proc(): NeuralBrain = current) + var runtime = initRuntime(program, host) + discard runtime.run() + doAssert runtime.getGlobal("inputs") == 0, "no brain: every function returns 0" + current = newToySeat(actor, period = 3) + toyWorld.tick = 1 + doAssert current.decide(1, 1) + runtime.restart() + discard runtime.run() + doAssert runtime.getGlobal("fresh") == 1 + doAssert runtime.getGlobal("move") == current.heads[0] + doAssert runtime.getGlobal("aim") == current.heads[1] + doAssert runtime.getGlobal("width") == 64 + doAssert runtime.getGlobal("inputs") == ToyObservation + doAssert runtime.getGlobal("outputs") == ToyOutputs + doAssert runtime.getGlobal("period") == 3 + doAssert runtime.getGlobal("beyond") == 0 + toyWorld.tick = 2 + var stale = initRuntime(program, host) + discard stale.run() + doAssert stale.getGlobal("fresh") == 0, "a decision is fresh only on its tick" diff --git a/tests/tests.nim b/tests/tests.nim index bbf5dd9d..d8ee130b 100644 --- a/tests/tests.nim +++ b/tests/tests.nim @@ -3,6 +3,7 @@ {.warning[UnusedImport]: off.} import test_neural, + test_neural_tier, test_assetpacks, test_actioncam, test_animblend_controls, From accb573812176a60bad63c813e0079fe45233690 Mon Sep 17 00:00:00 2001 From: David Bloomin Date: Sun, 27 Sep 2026 11:16:58 -0700 Subject: [PATCH 2/4] Review fixes: coworld glue tests, actor widths 384/512, named CI step - coworld/tools/test_coworld_glue.nim: unit tests for readPlayerSource (ZIP-magic detection past the 256 KiB BASIC cap; an oversize package is read one byte past the 16 MiB cap so the loader reports a clear "exceeds 16 MiB" error instead of a truncated-ZIP parse error). - coworld/tools/test_runtime.nim: the existing compilation-failure case now also asserts failPlayer's failure.json message and status.json reason/ exit_code for the failing slot (and that other slots are untouched). - .github/workflows/build.yml: run the new glue tests as a named step, and split test_neural_cases.nim into its own named CI step (previously bundled into the corruption-case generation step). - src/polyworld/neural_actor.nim: ActorWidths gains 384 and 512, with a new MaxActorWidth = 512 and a compile-time check that it is the largest width. infer's fixed stack buffers are now sized from MaxActorWidth (were 256 / 768), so w64-w256 arithmetic and buffer contents are unchanged. - coworld/runtime/neural_package.py: WIDTHS gains 384 and 512. - docs/neural-policies.md: model header width list and per-width op counts. - tests/test_neural_tier.nim: loads a toy package at all five widths and rejects the 383/385/511/513 neighbors, with a wide (4,000,000) op budget. Ported from daveey/polyworld@13c511a (fork), adapted to the shared tier's src/polyworld/neural_actor.nim (the fork's pre-tier GotA-local copy). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 7 ++- coworld/runtime/neural_package.py | 2 +- coworld/tools/test_coworld_glue.nim | 74 +++++++++++++++++++++++++++++ coworld/tools/test_runtime.nim | 11 +++++ docs/neural-policies.md | 9 +++- src/polyworld/neural_actor.nim | 11 +++-- tests/test_neural_tier.nim | 15 ++++++ 7 files changed, 122 insertions(+), 7 deletions(-) create mode 100644 coworld/tools/test_coworld_glue.nim diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 47f06f45..50f5bb61 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,12 +23,15 @@ jobs: nimby install "${{ github.event.repository.name }}/${{ github.event.repository.name }}.nimble" nimby sync "${{ github.event.repository.name }}/nimby.lock" - run: nim r tests/tests.nim - - name: Neural packages, one corruption suite for both validators + - name: Generate the shared neural corruption cases shell: bash run: | PY=$(command -v python3 || command -v python) "$PY" tests/neural_cases.py tmp/neural_cases - nim r tests/test_neural_cases.nim tmp/neural_cases + - name: test_neural_cases (Nim validator vs the Python validator) + run: nim r tests/test_neural_cases.nim tmp/neural_cases + - name: coworld.nim glue unit tests (readPlayerSource, failPlayer) + run: nim r coworld/tools/test_coworld_glue.nim - run: nim r tests/test_gota_events.nim - run: nim r -d:replayEvents tests/test_gota_controls.nim - run: nim r -d:replayEvents tests/test_gota_camps.nim diff --git a/coworld/runtime/neural_package.py b/coworld/runtime/neural_package.py index 37bd982c..1283af43 100644 --- a/coworld/runtime/neural_package.py +++ b/coworld/runtime/neural_package.py @@ -21,7 +21,7 @@ MAX_MODEL = 8 + 24 + 128 + 4 * 32 + 4 * MAX_PARAMS MAX_ENTRIES = 16 LIMITS = {"manifest.json": MAX_MANIFEST, "policy.bas": MAX_POLICY, "model.bin": MAX_MODEL} -WIDTHS = (64, 128, 256) +WIDTHS = (64, 128, 256, 384, 512) MAX_INPUTS = 4096 MAGIC = b"GOTANET1" DEFAULT_OP_BUDGET = 4_000_000 diff --git a/coworld/tools/test_coworld_glue.nim b/coworld/tools/test_coworld_glue.nim new file mode 100644 index 00000000..04e064d1 --- /dev/null +++ b/coworld/tools/test_coworld_glue.nim @@ -0,0 +1,74 @@ +## Unit tests for the `src/polyworld/coworld.nim` game-hosted glue that do +## not need a built Coworld binary (see `test_runtime.nim` for the full +## integration harness, including the `failPlayer` failure-marker checks). + +import + std/[os, strutils, tempfiles] +import + ../../src/polyworld/coworld, + ../../src/polyworld/neural_package + +const + BasicSourceCap = 256 * 1024 + PackageCap = 16 * 1024 * 1024 + +block readPlayerSourceDetectsZipByPrefix: + ## A file over the ordinary BASIC-source cap, but under the package cap, + ## must be read in full when it starts with the ZIP local-file-header + ## magic, and stay capped at the BASIC-source limit otherwise. + let directory = createTempDir("coworld-glue-", "") + defer: removeDir(directory) + + let + zipPath = directory / "package.zip" + zipBody = "PK\x03\x04" & repeat("z", BasicSourceCap + 1024) + writeFile(zipPath, zipBody) + let zipRead = readPlayerSource(zipPath) + doAssert zipRead.len == zipBody.len, + "a ZIP-prefixed file over the BASIC cap must not be truncated to it" + doAssert zipRead == zipBody, "a ZIP-prefixed file must be read verbatim" + + let + basicPath = directory / "player.bas" + basicBody = repeat("z", BasicSourceCap + 1024) + writeFile(basicPath, basicBody) + let basicRead = readPlayerSource(basicPath) + doAssert basicRead.len == BasicSourceCap + 1, + "a non-ZIP source must stay capped at the BASIC source limit" + doAssert basicRead == basicBody[0 ..< BasicSourceCap + 1] + + echo "readPlayerSource: ZIP-prefix detection passed" + +block readPlayerSourceCapsOversizePackageForAClearError: + ## A package over the 16 MiB cap must come back one byte past the cap + ## (never truncated to exactly 16 MiB), so the package loader can reject + ## it with a clear "exceeds 16 MiB" error instead of trying to parse a + ## file that now looks like a truncated ZIP. + let directory = createTempDir("coworld-glue-", "") + defer: removeDir(directory) + + let path = directory / "huge.zip" + block writeOversizePackage: + let file = open(path, fmWrite) + defer: file.close() + file.write("PK\x03\x04") + file.setFilePos(PackageCap + 4) + file.write("\0") + + doAssert getFileSize(path) == PackageCap + 5 + + let read = readPlayerSource(path) + doAssert read.len == PackageCap + 1, + "an oversize package must be read exactly one byte past the 16 MiB cap" + + let error = try: + discard readZip(read) + "" + except ValueError as e: + e.msg + doAssert error == "package exceeds 16 MiB", + "an oversize package must fail with a clear size error, not a zip parse error: " & error + + echo "readPlayerSource: oversize package cap passed" + +echo "test_coworld_glue: all checks passed" diff --git a/coworld/tools/test_runtime.nim b/coworld/tools/test_runtime.nim index 50197699..a8b2ef45 100644 --- a/coworld/tools/test_runtime.nim +++ b/coworld/tools/test_runtime.nim @@ -201,7 +201,18 @@ proc episode( if failure: doAssert not fileExists(directory / "results.json") doAssert output["failed_policy_index"].getInt() == 0 + doAssert output["message"].getStr() == + "BASIC compilation failed for player slot 0", + "failPlayer must report the failing slot and reason: " & $output doAssert logs[0].contains("BASIC error:") + let status = readFile(directory / "status.json").fromJson(JsonNode) + doAssert status["players"][0]["state"].getStr() == "exited" + doAssert status["players"][0]["exit_code"].getInt() == 1 + doAssert status["players"][0]["reason"].getStr() == "BASIC VM disabled", + "failPlayer must write the failed slot's status with the right reason" + for slot in 1 ..< count: + doAssert status["players"][slot]["exit_code"].getInt() == 0, + "failPlayer must not mark unrelated slots as failed" else: doAssert output["scores"].len == count if game == "gota": diff --git a/docs/neural-policies.md b/docs/neural-policies.md index bc38ef54..3285c343 100644 --- a/docs/neural-policies.md +++ b/docs/neural-policies.md @@ -83,7 +83,7 @@ magic stays `GOTANET1` for every game. All values are little-endian. | 0 | magic `GOTANET1` (8 bytes) | | 8 | u32 version = 1 | | 12 | u32 inputs I (1..4096) | -| 16 | u32 hidden H (64, 128 or 256) | +| 16 | u32 hidden H (64, 128, 256, 384 or 512) | | 20 | u32 outputs O (2..1024, the sum of the head sizes) | | 24 | u32 heads K (1..32) | | 28 | u32 parameters = I*H + 3*H*H + O*H (at most 2,000,000) | @@ -115,6 +115,13 @@ A model costs `2 * parameters + 32 * H` operations per inference instruction budget. The cap is checked when the model is loaded, so an over-budget model is rejected up front. It is never stopped halfway through a match. +The two widest accepted widths, w384 and w512, cost about 2,048,256 and 3,124,224 +operations per inference respectively (game-specific; GotA's exact figures are in +`examples/gods_of_the_arena/neural_basic.md`), both under the default 4,000,000 +budget. `infer`'s fixed stack buffers are sized from `MaxActorWidth` (the largest +entry in `ActorWidths`), so adding a wider width never changes the arithmetic for +narrower ones. + ## Adding neural seats to a game 1. **Write the contract texts.** Two canonical strings: the observation layout diff --git a/src/polyworld/neural_actor.nim b/src/polyworld/neural_actor.nim index 0053ba08..714dbd38 100644 --- a/src/polyworld/neural_actor.nim +++ b/src/polyworld/neural_actor.nim @@ -25,11 +25,16 @@ const ActorMagic* = "GOTANET1" MaxActorParameters* = 2_000_000 MaxActorInputs* = 4096 - ActorWidths* = [64, 128, 256] + ActorWidths* = [64, 128, 256, 384, 512] + MaxActorWidth* = 512 + ## Largest accepted hidden width; sizes `infer`'s fixed stack buffers. DefaultNeuralOpBudget* = 4_000_000 ## Operations one seat may spend per tick on inference, separate from the ## BASIC instruction budget, unless the game's contract says otherwise. +static: + doAssert ActorWidths[^1] == MaxActorWidth + proc finite(x: float32): bool = classify(x) notin {fcNan, fcInf, fcNegInf} proc readU32(data: string, pos: var int): uint32 = @@ -162,8 +167,8 @@ proc infer*(actor: Actor, obs: openArray[float32], state: var openArray[float32] if not finite(x): raise newException(ValueError, "nonfinite neural state") var - x, next, y: array[256, float32] - combined: array[768, float32] + x, next, y: array[MaxActorWidth, float32] + combined: array[3*MaxActorWidth, float32] output: array[1024, float32] let h = actor.hidden for o in 0..