ticket #000035 Phase 1: arborist/v7/anchor_prg HMAC-SHA-512 PRG

Lands the M1 mitigation cryptographic primitive that ticket #000018
§5.2 + §9.2 specified, scoped per ticket #000035 §3.1-§3.3. Pure
stdlib (hashlib + hmac); no third-party dependency.

arborist/v7/__init__.py
=======================
First module landed under the v7 namespace. v7 plastic-training is
currently paper-stage (per #000037 §17.2); this is where its
deterministic primitives accumulate ahead of an active deployment
target so the building blocks are unit-tested + KAT-pinned the
moment v7 needs them.

arborist/v7/anchor_prg.py
=========================
Implements ``phi_prg(hard_hash_32, dim_h, *, seed) -> list[float]``
per #000035 §3.1. Construction is SP 800-108 KDF in counter mode
over HMAC-SHA-512:

  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: f(u32) := 2 * (u32 / 2**32) - 1
                              ↑ uniform on [-1, 1)

Security argument from #000035 §2.1: HMAC-SHA-512 is a PRF under
the standard SHA-512 + HMAC assumption; distinguishing advantage
from random bounded by SHA-512 collision-resistance (~2^256), which
structurally matches the substrate's SHA-256 hard-hash family. The
seed is published, not secret — secrecy is not the security
property; the property is computational indistinguishability of the
output from random, which holds even when the seed is public.

Module exports ``PHI_PRG_VERSION = "phi-prg-v1-hmac-sha512"`` so
future algorithm rotations are detectable at the call site without
string-comparing module paths. Per #000035 risk §6.1, a
``phi_prg_version`` field in the v7 manifest will let future
deployments swap to a successor PRF without breaking historical
replay; this version string is the runtime-side mirror.

Hard-hash input length checked exactly at 32 bytes — silently
padding shorter input would break the PRF security argument.
``dim_h`` validated as positive int.

tests/test_anchor_prg.py
========================
20 tests covering #000035 §3.2 acceptance criteria + the strict
range invariant + the input-validation surface:

- Determinism: same (seed, hard_hash, dim_h) → identical floats.
- Range: every output in [-1, 1) with strict upper bound. Three
  edge cases pinned: u32=0 → -1.0, u32=2^31 → 0.0,
  u32=2^32-1 → just below 1.0.
- Chi² loose-uniformity: 4096-sample bin-test (df=15) with a
  generous threshold (60); catches catastrophic PRG bugs (counter
  cycling, mis-keyed HMAC) without claiming cryptographic-grade
  evidence.
- Boundary: dim_h=1 + dim_h=16384 both produce sensible output.
- Avalanche, seed-bit: flip top bit of seed[0]; require 35-65% of
  output bits flipped (PRF avalanche property).
- Avalanche, hash-bit: same surface for the hard-hash input.
- Validation rejects: short hashes, long hashes, non-bytes hashes,
  zero / negative / non-int dim_h.
- Module export shape: PHI_PRG_VERSION + PLACEHOLDER_SEED.
- KAT regression: pinned vectors verified against
  ``bench/fixtures/phi-prg/known-answer-tests.jsonl``.

bench/fixtures/phi-prg/known-answer-tests.jsonl
================================================
10 KAT vectors generated against the placeholder seed + custom
seed/hash combinations; covers smoke (placeholder seed × small
dim_h), block boundaries (HMAC-SHA-512 blocks are 64 bytes, so
dim_h=16 is exactly one block, dim_h=17 is two blocks with
truncation), seed/hash one-bit-flip variants, and a 4096-element
stress sample.

Each row pins the SHA-256 of the raw byte stream (not the float
list) — that's the durable contract; switching from list[float] to
array.array('f', ...) or numpy arrays at the float layer would not
invalidate the fixture. Algorithm changes MUST bump
PHI_PRG_VERSION and create a new fixture file under
bench/fixtures/phi-prg/; old runs replay against old data per the
v7 spec replay discipline.

#000035 status flip
===================
docs/tickets/ticket-000035-prg-choice-phi-prg.md §7 updated from
"open · awaiting go/no-go" to "in progress · Phase 1 landed
2026-05-10". §7 now carries Phase 1 close-out details + Phase 2
gating criteria (active v7 deployment target + spec maintainer
review of §3.4 amendment text). The §3.4 v7 §9.10 amendment text
stays as the draft awaiting Phase 2 landing.

docs/TICKETS.md index row was already updated by fox in commit
ed470dc; my edit was idempotent.

Hygiene
=======
- make test → 1643 passed, 45 skipped (was 1623; +20 anchor_prg)
- make chain-check-shards → 0 breaks across all 7 shards
- arborist.v7 namespace picked up automatically by the existing
  pyproject.toml [tool.setuptools.packages.find] include="arborist*"
  glob; no setup change required.
- fox's in-flight #000037 ticket modifications + a parallel
  #000031 ticket update left untouched.
This commit is contained in:
russell@unturf.com 2026-05-10 08:44:18 -04:00
parent ed470dc4b9
commit 06c95a03ab
No known key found for this signature in database
5 changed files with 497 additions and 7 deletions

14
arborist/v7/__init__.py Normal file
View file

@ -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

150
arborist/v7/anchor_prg.py Normal file
View file

@ -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"]

View file

@ -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}

View file

@ -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."

270
tests/test_anchor_prg.py Normal file
View file

@ -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