diff --git a/arborist/v7/__init__.py b/arborist/v7/__init__.py new file mode 100644 index 0000000..c0a139e --- /dev/null +++ b/arborist/v7/__init__.py @@ -0,0 +1,14 @@ +"""v7 plastic-training primitives — Merkle-AGI v7 substrate building blocks. + +Currently namespace stub. Public exports added as v7 lands: + +- :mod:`arborist.v7.anchor_prg` — HMAC-SHA-512 anchor-map for the M1 + mitigation (ticket #000035 / #000018 §5.2 + §9.10). + +The v7 plastic-training surface is still research-scope (see ticket +#000037 §17.2); this package houses the deterministic primitives the +spec depends on so they can land + be unit-tested ahead of the +deployment target. +""" + +from __future__ import annotations diff --git a/arborist/v7/anchor_prg.py b/arborist/v7/anchor_prg.py new file mode 100644 index 0000000..d29ec04 --- /dev/null +++ b/arborist/v7/anchor_prg.py @@ -0,0 +1,150 @@ +"""φ_PRG — HMAC-SHA-512 anchor map for v7 § 9.10 (ticket #000035). + +The v7 plastic-training spec proposes φ_PRG as the M1 mitigation for +the soft-hash covert channel analyzed in ticket #000018: the anchor +map MUST be a cryptographic PRG independent of parameter geometry, +so that a data-poisoning or gradient-shaping adversary cannot bias +the training signal toward SHA-256 buckets they control. + +Construction — HMAC-SHA-512 in NIST SP 800-108 KDF counter mode: + + Output(SEED, C(M), n_bytes) := + i = 0 + out = b"" + while len(out) < n_bytes: + out += HMAC-SHA-512(SEED, C(M) || i.to_bytes(4, 'big')) + i += 1 + return out[:n_bytes] + + Float conversion (uint32_be → [-1, 1)): + f(u32) := 2 * (u32 / 2**32) - 1 + +Security: HMAC-SHA-512 is a PRF under the standard SHA-512 + HMAC +assumption. Distinguishing advantage from random is bounded by the +SHA-512 collision-resistance bound (~2^256), which structurally +matches the substrate's SHA-256 hard-hash family. See ticket #000035 +§2.1 for the full reasoning, §2.2 for why this construction won over +AES-256-CTR and ChaCha20. + +Hard rules (per #000035 §2.4): + +- Seed is **published** (committed in the v7 boot manifest as + ``phi_prg_seed``); secrecy is NOT the security property. The + property is computational indistinguishability of the OUTPUT from + random, which holds even when the seed is public. +- Per-checkpoint seed rotation is the M2 mitigation, orthogonal to + this module — leave it to the v7 manifest layer. +- 32-byte hard-hash input matches the substrate's SHA-256 surface; + shorter inputs raise ``ValueError`` rather than silently padding. +""" + +from __future__ import annotations + +import hashlib +import hmac + +# v7 manifest will publish the canonical seed when the spec lands. +# Until then, callers MUST pass a seed explicitly; the module-level +# constant exists so that test fixtures and KAT data have a stable +# placeholder to reference. Bytes-literal so accidentally substituting +# a string raises a clear TypeError at hmac.new() time. +PHI_PRG_VERSION = "phi-prg-v1-hmac-sha512" + +# Placeholder seed for tests + KAT generation. Replaced at deployment +# time with the v7 manifest's ``phi_prg_seed`` field. The placeholder +# is 32 bytes so it matches the deployment shape; the value itself is +# the SHA-256 of a fixed string for reproducibility, NOT a security +# claim. Callers in production code path should pass their own seed. +PLACEHOLDER_SEED: bytes = hashlib.sha256( + b"arborist v7 phi_prg placeholder seed -- ticket #000035" +).digest() + + +def phi_prg( + hard_hash_32: bytes, + dim_h: int, + *, + seed: bytes = PLACEHOLDER_SEED, +) -> list[float]: + """Compute the anchor vector φ_PRG(C(M), dim_h) from a 32-byte hard hash. + + Parameters + ---------- + hard_hash_32 + The committed hard-hash ``C(M)`` of the model (32 bytes, + SHA-256 output). ``ValueError`` if not exactly 32 bytes — + accepting shorter inputs would silently pad and break the + PRF security argument. + dim_h + Length of the output anchor vector. Must be a positive int. + seed + Published HMAC key. Defaults to ``PLACEHOLDER_SEED``; + deployment code must override with the v7 manifest seed. + + Returns + ------- + list[float] + ``dim_h`` floats uniformly distributed on ``[-1, 1)``, + deterministically derived from ``(seed, hard_hash_32)``. + + Raises + ------ + ValueError + If ``hard_hash_32`` is not exactly 32 bytes, or if + ``dim_h`` is not a positive integer. + """ + if not isinstance(hard_hash_32, (bytes, bytearray)) or len(hard_hash_32) != 32: + raise ValueError( + "hard_hash_32 must be exactly 32 bytes (SHA-256 output); " + f"got {len(hard_hash_32)!r} bytes" + if isinstance(hard_hash_32, (bytes, bytearray)) + else f"got {type(hard_hash_32).__name__}" + ) + if not isinstance(dim_h, int) or dim_h <= 0: + raise ValueError(f"dim_h must be a positive int; got {dim_h!r}") + + raw = _expand(seed, bytes(hard_hash_32), dim_h * 4) + return _bytes_to_floats(raw) + + +def _expand(seed: bytes, hard_hash: bytes, n_bytes: int) -> bytes: + """SP 800-108 counter-mode KDF over HMAC-SHA-512. + + Block size is the HMAC-SHA-512 output (64 bytes); we ceil-divide + to the smallest counter range that yields ``n_bytes`` output, then + truncate the last block. Counter is big-endian 4-byte unsigned; + overflow at 2^32 - 1 blocks (i.e. 256 GB output) raises + ``OverflowError`` from ``int.to_bytes`` rather than silently + wrapping. Production dim_h won't approach that. + """ + out = bytearray() + counter = 0 + while len(out) < n_bytes: + msg = hard_hash + counter.to_bytes(4, "big") + out += hmac.new(seed, msg, hashlib.sha512).digest() + counter += 1 + return bytes(out[:n_bytes]) + + +def _bytes_to_floats(raw: bytes) -> list[float]: + """Map each big-endian uint32 to a float in [-1, 1). + + Per #000035 §2.3: ``f(u32) = 2 * (u32 / 2**32) - 1``. Distribution + is uniform on ``[-1, 1)`` modulo 2^-32 quantization, which is well + below any downstream precision the anchor vector cares about. + """ + n = len(raw) // 4 + if len(raw) != n * 4: + raise ValueError( + f"raw byte length {len(raw)} not a multiple of 4; " + "indicates an upstream bug in _expand truncation" + ) + floats: list[float] = [] + inv_2_32 = 1.0 / (1 << 32) + for i in range(n): + u32 = int.from_bytes(raw[4 * i : 4 * (i + 1)], "big") + floats.append(2.0 * (u32 * inv_2_32) - 1.0) + return floats + + +__all__ = ["phi_prg", "PHI_PRG_VERSION", "PLACEHOLDER_SEED"] diff --git a/bench/fixtures/phi-prg/known-answer-tests.jsonl b/bench/fixtures/phi-prg/known-answer-tests.jsonl new file mode 100644 index 0000000..6e351cd --- /dev/null +++ b/bench/fixtures/phi-prg/known-answer-tests.jsonl @@ -0,0 +1,16 @@ +# arborist v7 phi_prg known-answer tests — version phi-prg-v1-hmac-sha512 +# Pinned (seed, hard_hash, dim_h) → SHA-256 of raw byte output +# (HMAC-SHA-512 counter-mode expansion before float conversion). +# Algorithm change MUST bump PHI_PRG_VERSION and create a new +# fixture file; do not overwrite — old runs replay against old data. + +{"label": "placeholder-seed/zero-hash/dim_h=1", "seed_hex": "ef532720a49159beb6816d98e13a162bac63c531b631bd1adb0fcca96b467ff3", "hard_hash_hex": "0000000000000000000000000000000000000000000000000000000000000000", "dim_h": 1, "output_sha256": "93618e085f1afae3368cabb57b328f2f01a81cb45c768e02264b13d5ec52732a", "output_bytes": 4} +{"label": "placeholder-seed/zero-hash/dim_h=8", "seed_hex": "ef532720a49159beb6816d98e13a162bac63c531b631bd1adb0fcca96b467ff3", "hard_hash_hex": "0000000000000000000000000000000000000000000000000000000000000000", "dim_h": 8, "output_sha256": "b7e7af7180105100e94fcb4361799e2820d78f9cf2636643dea2a367ea16beb8", "output_bytes": 32} +{"label": "placeholder-seed/zero-hash/dim_h=32", "seed_hex": "ef532720a49159beb6816d98e13a162bac63c531b631bd1adb0fcca96b467ff3", "hard_hash_hex": "0000000000000000000000000000000000000000000000000000000000000000", "dim_h": 32, "output_sha256": "537bf81bdb0bc6300ffe9e9853ecf42a19ee4ea10be11d76624dd83d5528d55a", "output_bytes": 128} +{"label": "placeholder-seed/all-ones-hash/dim_h=16", "seed_hex": "ef532720a49159beb6816d98e13a162bac63c531b631bd1adb0fcca96b467ff3", "hard_hash_hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "dim_h": 16, "output_sha256": "2466a75eda007980acea20ed9f1d8676c36700b6da15220455268151a0f6072c", "output_bytes": 64} +{"label": "seed=A/hash=B/dim_h=64", "seed_hex": "14ab2dab0d3cddeaa58ec70632d3ff4f5de2c514004a92144e260dfe384d912e", "hard_hash_hex": "a23cb10b94660f062b467f313a9dc9d84f2dc2748c3627c661082dda7f55e3bb", "dim_h": 64, "output_sha256": "c6b107e73d4b4f23f3ee8991ab953dc4ecc5af35405bf5a97ce66a0fd41c4245", "output_bytes": 256} +{"label": "seed=A/hash=B'/dim_h=64 (one-bit-flip from prior)", "seed_hex": "14ab2dab0d3cddeaa58ec70632d3ff4f5de2c514004a92144e260dfe384d912e", "hard_hash_hex": "223cb10b94660f062b467f313a9dc9d84f2dc2748c3627c661082dda7f55e3bb", "dim_h": 64, "output_sha256": "a29cbf930ae89cd10c816fc5dd03b97354ee0a303e62e92822f9331c42b94f96", "output_bytes": 256} +{"label": "seed=A'/hash=B/dim_h=64 (one-bit-flip seed)", "seed_hex": "94ab2dab0d3cddeaa58ec70632d3ff4f5de2c514004a92144e260dfe384d912e", "hard_hash_hex": "a23cb10b94660f062b467f313a9dc9d84f2dc2748c3627c661082dda7f55e3bb", "dim_h": 64, "output_sha256": "ce1b60357bbd3354f65a16c1e51bd12de186bbb7765a728fae04fdbb09973f31", "output_bytes": 256} +{"label": "block-boundary/dim_h=16", "seed_hex": "cfd60c2bda64ebcefbb23a5b28d98269c9c4f8b8ac77f6f9ca7a0f4865b10f58", "hard_hash_hex": "724cd966a7bfe78ba802877510ffb90c67f385a1d3135e4e1b8a1b38f744c6da", "dim_h": 16, "output_sha256": "75d1eb90b4d385b8d475fa53539eac129b0abfab9e784c0f3be5cf2738c40dec", "output_bytes": 64} +{"label": "block-boundary/dim_h=17", "seed_hex": "cfd60c2bda64ebcefbb23a5b28d98269c9c4f8b8ac77f6f9ca7a0f4865b10f58", "hard_hash_hex": "724cd966a7bfe78ba802877510ffb90c67f385a1d3135e4e1b8a1b38f744c6da", "dim_h": 17, "output_sha256": "3d9226cff6da50e5610cceba54b191223b2dfd5bbbdd3bd0f807062762fb3827", "output_bytes": 68} +{"label": "stress/dim_h=4096", "seed_hex": "0ddd62c311f88ebe2d4f6cd5d9d1374474dfd645e012043648dd966a71785c95", "hard_hash_hex": "e605ede3d9d0d13c6d7d32c5c424b998677eef0689a0d9f0fa4ebd1bb4307cb9", "dim_h": 4096, "output_sha256": "5072d05b17eb4f4b3356bfc66f772330337dfcc1b3bb8fa87fc0ae0568e9a387", "output_bytes": 16384} diff --git a/docs/tickets/ticket-000035-prg-choice-phi-prg.md b/docs/tickets/ticket-000035-prg-choice-phi-prg.md index 8d5e000..8efca26 100644 --- a/docs/tickets/ticket-000035-prg-choice-phi-prg.md +++ b/docs/tickets/ticket-000035-prg-choice-phi-prg.md @@ -1,6 +1,6 @@ # Ticket #000035 — PRG choice for φ_PRG -**Status:** open · awaiting go/no-go +**Status:** in progress · Phase 1 landed 2026-05-10; v7 §9.10 spec amendment text pinned in §3.4 below, awaits v7 spec maintainer review **Opened:** 2026-05-09 **Scope:** Pin a specific cryptographic PRG construction for the M1 mitigation (PRG-based anchor map) proposed in #000018 §5.2 + @@ -246,10 +246,50 @@ embed_hard_to_vec when the M1 mitigation is enabled. ## 7. Status -**Open · awaiting go/no-go.** Doc-only spec amendment + reference -implementation. Lands when v7 plastic-training has a deployment -target. +**In progress · Phase 1 landed 2026-05-10.** Reference +implementation shipped ahead of v7 plastic-training deployment +target so the cryptographic primitive is unit-tested + KAT-pinned +the moment v7 needs it. -Closure criterion: `arborist/v7/anchor_prg.py` ships, tests pass, -known-answer-test fixture pinned, v7 § 9.10 amendment text -accepted into the v7 spec. +### Phase 1 — reference implementation (landed 2026-05-10) + +- ``arborist/v7/__init__.py`` — namespace stub (v7 is currently + paper-stage per ticket #000037 §17.2; this is the first concrete + module landed under the namespace). +- ``arborist/v7/anchor_prg.py`` — ``phi_prg(hard_hash_32, dim_h, *, + seed)`` per §3.1; HMAC-SHA-512 counter-mode KDF; pure stdlib + (``hashlib`` + ``hmac``); no third-party dependency. Module also + exports ``PHI_PRG_VERSION = "phi-prg-v1-hmac-sha512"`` so future + algorithm rotations can be detected at the call site without + string-comparing module paths. +- ``tests/test_anchor_prg.py`` — 20 tests covering determinism, + range invariants, chi² loose-uniformity sanity, dim_h boundary + (1, 16384), seed-bit-flip avalanche, hash-bit-flip avalanche, + input validation (short/long/non-bytes hashes; non-positive / + non-int dim_h), module export shape, and KAT regression. +- ``bench/fixtures/phi-prg/known-answer-tests.jsonl`` — 10 pinned + KAT vectors covering the placeholder seed (smoke), block-boundary + cases (dim_h=16 = exactly one HMAC-SHA-512 block; dim_h=17 = two + blocks with truncation), seed/hash one-bit-flip variants, and a + 4096-element stress sample to catch counter-rollover bugs. + Bytes-output ``SHA-256`` is the durable contract; float layout + changes do NOT invalidate the fixture. + +### Phase 2 — v7 §9.10 amendment landing (not yet open) + +§3.4 of this ticket holds the draft spec amendment text. Phase 2 +lands it into the v7 spec proper when: + +1. The v7 plastic-training spec gains an active deployment target, + AND +2. The spec maintainer (currently fox) reviews the §3.4 text and + confirms the §9.10 placement. + +Until both signals fire, the amendment text stays here as a draft +under the v7 spec maintainer's eyes. + +### Closure criterion + +Closes when Phase 2 lands the §9.10 amendment into the v7 spec and +``#000018 §9.2`` (which pins the open question "which PRG?") is +resolved as "HMAC-SHA-512 with 32-byte committed seed." diff --git a/tests/test_anchor_prg.py b/tests/test_anchor_prg.py new file mode 100644 index 0000000..6780966 --- /dev/null +++ b/tests/test_anchor_prg.py @@ -0,0 +1,270 @@ +"""Tests for arborist.v7.anchor_prg per ticket #000035 §3.2. + +Coverage matches the ticket's acceptance criteria: + +- Determinism: same (seed, hard_hash, dim_h) → byte-identical output. +- Distinguishing-from-random sanity: chi² test on a sample of outputs + (loose threshold; just catches gross PRG bugs like cycling on the + counter or HMAC mis-keying). +- Boundary: dim_h=1 and large dim_h both produce sensible outputs. +- Seed-change avalanche: flipping one bit of the seed yields a result + uncorrelated with the original (Hamming distance ≈ output_size / 2). +- Hash-input avalanche: flipping one bit of the hard hash same. +- Input validation: short hashes / non-positive dim_h raise ValueError. +- Range invariant: every output float is in [-1, 1). +- KAT (known-answer-test) vectors regression-pinned via the + ``bench/fixtures/phi-prg/known-answer-tests.jsonl`` fixture. +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import pytest + +from arborist.v7.anchor_prg import ( + PHI_PRG_VERSION, + PLACEHOLDER_SEED, + _bytes_to_floats, + _expand, + phi_prg, +) + + +# ----------------------------------------------------------- determinism + + +def test_phi_prg_deterministic_same_inputs(): + h = hashlib.sha256(b"deterministic-input").digest() + v1 = phi_prg(h, dim_h=64) + v2 = phi_prg(h, dim_h=64) + assert v1 == v2 + + +def test_phi_prg_deterministic_with_explicit_seed(): + h = hashlib.sha256(b"x").digest() + seed = hashlib.sha256(b"my-seed").digest() + v1 = phi_prg(h, dim_h=32, seed=seed) + v2 = phi_prg(h, dim_h=32, seed=seed) + assert v1 == v2 + + +# ----------------------------------------------------------- range invariant + + +def test_phi_prg_outputs_in_unit_interval(): + h = hashlib.sha256(b"range-check").digest() + v = phi_prg(h, dim_h=2048) + for x in v: + # Spec: [-1, 1) — strict upper bound is critical because + # 2 * (2^32 - 1) / 2^32 - 1 = (2^33 - 2 - 2^32) / 2^32 + # = (2^32 - 2) / 2^32 < 1. + assert -1.0 <= x < 1.0, f"out of range: {x}" + + +def test_bytes_to_floats_zero_maps_to_minus_one(): + assert _bytes_to_floats(b"\x00\x00\x00\x00") == [-1.0] + + +def test_bytes_to_floats_max_uint32_just_below_one(): + # 0xFFFFFFFF → 2 * (1 - 2^-32) - 1 = 1 - 2^-31, strictly < 1. + out = _bytes_to_floats(b"\xff\xff\xff\xff") + assert len(out) == 1 + assert out[0] < 1.0 + assert out[0] > 1.0 - 1e-9 + + +def test_bytes_to_floats_midpoint_maps_to_zero(): + # 0x80000000 → 2 * 0.5 - 1 = 0.0. + assert _bytes_to_floats(b"\x80\x00\x00\x00") == [0.0] + + +# ----------------------------------------------------------- chi² sanity + + +def test_phi_prg_chi2_loose_uniformity(): + """Bin a 4096-float sample into 16 buckets on [-1, 1); expect + counts within a generous chi² acceptance region. Threshold is + deliberately wide — this catches catastrophic PRG bugs (e.g. + counter cycling, all-zero output) but doesn't claim + cryptographic-grade evidence. + """ + h = hashlib.sha256(b"uniformity-sample").digest() + n = 4096 + nbuckets = 16 + samples = phi_prg(h, dim_h=n) + counts = [0] * nbuckets + for x in samples: + # Map [-1, 1) → [0, nbuckets) + idx = int((x + 1.0) * 0.5 * nbuckets) + if idx == nbuckets: # x just below 1.0 due to float + idx = nbuckets - 1 + counts[idx] += 1 + expected = n / nbuckets + chi2 = sum((c - expected) ** 2 / expected for c in counts) + # df = 15, 99.9th percentile ≈ 37.7. We accept up to 60 to leave + # headroom against single-sample tail behaviour without making the + # test useless. + assert chi2 < 60.0, f"χ² = {chi2:.2f}; counts = {counts}" + + +# ----------------------------------------------------------- boundary + + +def test_phi_prg_dim_h_one(): + h = hashlib.sha256(b"boundary-1").digest() + v = phi_prg(h, dim_h=1) + assert len(v) == 1 + assert -1.0 <= v[0] < 1.0 + + +def test_phi_prg_dim_h_large_consistent_length(): + # 2^14 = 16384 floats → 64 KB output → 1024 HMAC blocks. + # Cheap enough for a unit test. + h = hashlib.sha256(b"boundary-large").digest() + v = phi_prg(h, dim_h=16384) + assert len(v) == 16384 + + +# ----------------------------------------------------------- avalanche + + +def _hamming_bits(a: bytes, b: bytes) -> int: + assert len(a) == len(b) + return sum(bin(x ^ y).count("1") for x, y in zip(a, b)) + + +def test_phi_prg_seed_bit_flip_avalanches(): + """Flip one bit of the seed; expect the byte-output Hamming + distance to be ≈ output_size_bits / 2 ± noise. This is the + standard PRF avalanche property; failure indicates the seed + isn't actually keying HMAC (e.g. constant collision).""" + h = hashlib.sha256(b"avalanche-seed").digest() + seed_a = bytes(32) + seed_b = bytes([0x80]) + bytes(31) # flip top bit of byte 0 + raw_a = _expand(seed_a, h, 256) + raw_b = _expand(seed_b, h, 256) + bits_total = len(raw_a) * 8 + diff = _hamming_bits(raw_a, raw_b) + # 256 bytes = 2048 bits; expected ≈ 1024 ± few sigma. + # Conservative: 35-65% of bits flipped. + assert 0.35 * bits_total < diff < 0.65 * bits_total, ( + f"avalanche failed: {diff}/{bits_total} bits differ " + f"({100*diff/bits_total:.1f}%)" + ) + + +def test_phi_prg_hash_bit_flip_avalanches(): + """Flip one bit of the hard hash input; same avalanche property.""" + seed = hashlib.sha256(b"av-seed").digest() + h_a = bytes(32) + h_b = bytes([0x01]) + bytes(31) + raw_a = _expand(seed, h_a, 256) + raw_b = _expand(seed, h_b, 256) + bits_total = len(raw_a) * 8 + diff = _hamming_bits(raw_a, raw_b) + assert 0.35 * bits_total < diff < 0.65 * bits_total, ( + f"avalanche failed: {diff}/{bits_total} bits differ " + f"({100*diff/bits_total:.1f}%)" + ) + + +# ----------------------------------------------------------- validation + + +def test_phi_prg_rejects_short_hash(): + with pytest.raises(ValueError, match="32 bytes"): + phi_prg(b"too short", dim_h=8) + + +def test_phi_prg_rejects_long_hash(): + with pytest.raises(ValueError, match="32 bytes"): + phi_prg(b"\x00" * 33, dim_h=8) + + +def test_phi_prg_rejects_non_bytes_hash(): + with pytest.raises(ValueError): + phi_prg("not bytes", dim_h=8) # type: ignore[arg-type] + + +def test_phi_prg_rejects_zero_dim_h(): + h = hashlib.sha256(b"x").digest() + with pytest.raises(ValueError, match="positive"): + phi_prg(h, dim_h=0) + + +def test_phi_prg_rejects_negative_dim_h(): + h = hashlib.sha256(b"x").digest() + with pytest.raises(ValueError, match="positive"): + phi_prg(h, dim_h=-1) + + +def test_phi_prg_rejects_non_int_dim_h(): + h = hashlib.sha256(b"x").digest() + with pytest.raises(ValueError): + phi_prg(h, dim_h=8.5) # type: ignore[arg-type] + + +# ----------------------------------------------------------- module shape + + +def test_module_exports_version_string(): + assert PHI_PRG_VERSION == "phi-prg-v1-hmac-sha512" + + +def test_placeholder_seed_is_32_bytes(): + assert isinstance(PLACEHOLDER_SEED, bytes) + assert len(PLACEHOLDER_SEED) == 32 + + +# ----------------------------------------------------------- KAT regression + + +KAT_FIXTURE = ( + Path(__file__).parent.parent + / "bench" + / "fixtures" + / "phi-prg" + / "known-answer-tests.jsonl" +) + + +@pytest.mark.skipif( + not KAT_FIXTURE.exists(), + reason="KAT fixture not yet generated; run scripts/generate_phi_prg_kat.py", +) +def test_phi_prg_known_answer_tests(): + """Re-run every (seed, hard_hash, dim_h) triple in the pinned KAT + fixture; bytes-output SHA-256 must match the recorded value. + + The fixture is generated once and committed; any future change to + the algorithm (e.g. switching from HMAC-SHA-512 to a different + construction) MUST bump ``PHI_PRG_VERSION`` and produce a new + fixture file under ``bench/fixtures/phi-prg/`` rather than + overwrite this one. Old runs replay against the old fixture. + """ + for line in KAT_FIXTURE.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + kat = json.loads(line) + seed = bytes.fromhex(kat["seed_hex"]) + hard_hash = bytes.fromhex(kat["hard_hash_hex"]) + dim_h = int(kat["dim_h"]) + out_floats = phi_prg(hard_hash, dim_h=dim_h, seed=seed) + # Pin the bytes-form so the fixture is independent of any + # future float-format choice (list[float] vs array.array vs + # numpy.ndarray). bytes-output SHA-256 is the durable + # contract. We reconstruct the bytes via _expand to keep the + # fixture format stable. + raw = _expand(seed, hard_hash, dim_h * 4) + digest = hashlib.sha256(raw).hexdigest() + assert digest == kat["output_sha256"], ( + f"KAT mismatch for label={kat.get('label')!r}: " + f"expected {kat['output_sha256']}, got {digest}" + ) + # Also assert the float list has the expected length so a + # bug in _bytes_to_floats truncation is caught. + assert len(out_floats) == dim_h