arborist/tests/test_anchor_prg.py
russell@unturf.com 101b101281
ticket #000035: flip φ_PRG counter big-endian → little-endian to match v7 TLV
v7's canonical integer byte-order was confirmed little-endian by
inspecting merkle-agi-dag_v7.txt §A1 — every to_bytes / astype in the
TLV encoding is little-endian (TLV length prefixes to_bytes(4,'little'),
enc_int to_bytes(8,'little'), quantized tensors '<i8'); no big-endian
anywhere. Per dav1d's 2026-05-11 review rule ("if v7 TLV canonical
integer encoding is little-endian, flip §3.4 to little-endian before
KAT freeze"), flip done — this is the -le variant.

Implementation (arborist/substrate/anchor_prg.py):
- PHI_PRG_VERSION → "phi-prg-v1-hmac-sha512-le" (still "v1";
  the -le suffix records the endianness; future re-flip MUST bump).
- _expand: counter.to_bytes(4, 'big') → 'little'.
- _bytes_to_floats: int.from_bytes(..., 'big') → 'little' (the
  uint32-word interpretation, for full consistency with v7).
- Module + function docstrings updated: little-endian throughout,
  with the merkle-agi-dag_v7.txt §A1 verification note.
- Note: at counter=0 the bytes are identical regardless of
  endianness, so 5 of the 10 KAT entries (dim_h ≤ 16, single block)
  keep the same output_sha256; the 5 multi-block entries (dim_h 17/
  32/64×3/4096) change.

KAT fixture (bench/fixtures/phi-prg/known-answer-tests.jsonl):
- Regenerated under the little-endian counter. Each entry now also
  carries a "version" field (phi-prg-v1-hmac-sha512-le). Header
  comment updated.

Tests (tests/test_anchor_prg.py, 30 → 31):
- test_module_exports_version_string: assert the -le suffix.
- test_bytes_to_floats_midpoint_maps_to_zero: 2^31 is b'\x00\x00\x00\x80'
  in little-endian, not b'\x80\x00\x00\x00'.
- New test_bytes_to_floats_reads_little_endian: pins the byte-order
  so an accidental re-flip is caught.
- test_phi_prg_first_block_matches_direct_hmac: uint32-word reads
  little-endian (counter=0 bytes unchanged either way).
- test_phi_prg_known_answer_tests: assert kat['version'] == module
  version when present.

Spec text (#000035 §3.4): folded the little-endian variant of
dav1d's §9.10 wording — counter_le32, uint32_le word reads, an
"all integers little-endian, matching v7 TLV §A1" preamble, and an
"Endianness — RESOLVED 2026-05-11" note replacing the open
big-vs-little question. soft-hash-channel-analysis.md §9.2/§11 +
#000035 status + TICKETS.md row updated. AUTOCOUNT for
test_anchor_prg.py bumped 30 → 31; PHI_PRG_VERSION refs in docs
bumped to -le.

Full suite: 2312 passed, 28 skipped.
2026-05-11 07:47:35 -04:00

425 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for arborist.substrate.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.substrate.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():
# u32 = 2^31 → 2 * 0.5 - 1 = 0.0. Words are read little-endian
# (matches v7's TLV convention), so the byte representation of
# 2^31 is b"\x00\x00\x00\x80", not b"\x80\x00\x00\x00".
assert int.from_bytes(b"\x00\x00\x00\x80", "little") == 1 << 31
assert _bytes_to_floats(b"\x00\x00\x00\x80") == [0.0]
def test_bytes_to_floats_reads_little_endian():
"""Sanity-pin the endianness: 0x00000001 in little-endian byte
layout is b"\x01\x00\x00\x00" → u32=1 → 2*(1/2^32)-1 ≈ -1.0.
The big-endian reading (u32 = 0x01000000 = 2^24) would give a
very different float — this test catches an accidental flip back."""
out = _bytes_to_floats(b"\x01\x00\x00\x00")
assert out[0] == pytest.approx(2.0 * (1 / 2**32) - 1.0, abs=1e-12)
assert out[0] < -0.999999
# ----------------------------------------------------------- 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}%)"
)
# ----------------------------------------------------------- monotonicity / closure
def test_phi_prg_output_length_exactly_dim_h():
"""``phi_prg(h, n)`` must produce exactly ``n`` floats.
Closure check pattern (cf. fox's `test_total_equals_sum_of_three_contributions`
in test_t3_bound_calculator.py): the output length is the
contract; off-by-one or truncation bugs in `_expand` would
surface here. Parametrized to widen the cone."""
h = hashlib.sha256(b"len-check").digest()
for n in (1, 2, 4, 7, 16, 17, 64, 1024):
v = phi_prg(h, dim_h=n)
assert len(v) == n, f"dim_h={n}: expected {n} floats, got {len(v)}"
def test_phi_prg_output_is_prefix_extending():
"""``phi_prg(h, n)`` must equal the first ``n`` entries of
``phi_prg(h, n+k)``.
Streaming-counter-mode invariant: the HMAC-SHA-512 expansion
is deterministic counter-based, so increasing dim_h adds
strictly more bytes at the tail without re-deriving the
head. A bug that re-keyed HMAC per-call (e.g. seed mutation)
would surface here.
Pattern from fox's `test_monotone_in_window_length`: scaling
one input dimension while holding others fixed is a
closed-form invariant the function MUST satisfy.
"""
h = hashlib.sha256(b"prefix-extend").digest()
short = phi_prg(h, dim_h=8)
long = phi_prg(h, dim_h=24)
assert long[:8] == short, (
"phi_prg should be prefix-stable: "
f"long[:8] = {long[:8][:3]!r}... vs short = {short[:3]!r}..."
)
# ----------------------------------------------------------- hand-formula
def test_phi_prg_first_block_matches_direct_hmac():
"""First HMAC-SHA-512 block of phi_prg's output should match
a direct hmac.new(seed, hard_hash + counter_le_4, sha512).digest()
invocation. Hand-computed against the function's spec (#000035
§3.1/§3.4 + module §1). Counter and uint32-word reads are both
little-endian (matches v7's TLV convention; dav1d review
2026-05-11) — at counter=0 the bytes are b'\\x00\\x00\\x00\\x00'
regardless of endianness, but the float-word interpretation is
the part this test pins as little-endian.
Pattern from fox's `test_b1_exact_formula`: don't rely on
KAT regression alone — compute the first-principles math in
the test file and assert exact agreement. Catches algorithm
drift that KAT regenerated against a buggy version would miss.
"""
import hmac
seed = hashlib.sha256(b"hand-formula-seed").digest()
h = hashlib.sha256(b"hand-formula-hash").digest()
# Spec: out = HMAC-SHA-512(seed, hard_hash || counter_le_4) for
# counter = 0, 1, 2, ...; concatenated; truncated to dim_h * 4 bytes.
expected_block_0 = hmac.new(
seed, h + (0).to_bytes(4, "little"), hashlib.sha512
).digest()
# First block is 64 bytes = 16 uint32s = 16 floats. dim_h=16
# consumes exactly the first block.
floats = phi_prg(h, dim_h=16, seed=seed)
# Convert expected_block_0 to floats per §3.4 spec: uint32 read
# little-endian, then 2*(u32/2^32)-1.
expected_floats = []
for i in range(16):
u32 = int.from_bytes(expected_block_0[4 * i : 4 * (i + 1)], "little")
expected_floats.append(2.0 * (u32 / 2 ** 32) - 1.0)
for j, (got, exp) in enumerate(zip(floats, expected_floats)):
assert got == pytest.approx(exp, abs=1e-12), (
f"first-block float mismatch at index {j}: got {got}, "
f"expected {exp}"
)
def test_phi_prg_seed_changes_every_byte_independently():
"""Pattern from fox's monotone tests: scaling one input
independently shouldn't bleed into other parts of the output.
For phi_prg this is harder to assert directly — HMAC mixes
everything — but we can pin: changing the seed should change
EVERY output float (not zero of them)."""
h = hashlib.sha256(b"seed-bleed-check").digest()
seed_a = hashlib.sha256(b"seed-A").digest()
seed_b = hashlib.sha256(b"seed-B").digest()
out_a = phi_prg(h, dim_h=64, seed=seed_a)
out_b = phi_prg(h, dim_h=64, seed=seed_b)
# Every position should differ — under HMAC-SHA-512 PRF,
# the probability that any specific 32-bit float matches by
# chance is 2^-32, so 64 positions × 2^-32 ≈ 2^-26 false
# positives expected. None expected in practice.
matches = sum(1 for a, b in zip(out_a, out_b) if a == b)
assert matches == 0, (
f"{matches}/64 positions matched between different seeds; "
"PRF bleed check failed"
)
# ----------------------------------------------------------- validation (parametrized)
@pytest.mark.parametrize("bad_hash", [
b"too short", # too few bytes
b"\x00" * 33, # too many bytes
b"", # empty
b"\x00" * 31, # off by one short
])
def test_phi_prg_rejects_wrong_size_hash(bad_hash):
"""Pattern from fox: parametrize over the invalid-input cone
rather than spawn a separate test function per case."""
with pytest.raises(ValueError, match="32 bytes"):
phi_prg(bad_hash, dim_h=8)
@pytest.mark.parametrize("bad_dim", [0, -1, -100])
def test_phi_prg_rejects_non_positive_dim_h(bad_dim):
h = hashlib.sha256(b"x").digest()
with pytest.raises(ValueError, match="positive"):
phi_prg(h, dim_h=bad_dim)
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_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]
@pytest.mark.parametrize("bad_dim", [True, False])
def test_phi_prg_rejects_bool_dim_h(bad_dim):
"""isinstance(True, int) is True in Python — bool must be rejected
explicitly so `dim_h=True` doesn't silently become dim_h=1
(dav1d review 2026-05-11)."""
h = hashlib.sha256(b"x").digest()
with pytest.raises(ValueError, match="positive"):
phi_prg(h, dim_h=bad_dim) # type: ignore[arg-type]
def test_phi_prg_rejects_dim_h_above_counter_ceiling():
"""dim_h > 16·2^32 exhausts the 4-byte counter; reject with a
clear ValueError naming the ceiling rather than overflowing
deep in _expand (#000035 §3.4, dav1d review 2026-05-11)."""
h = hashlib.sha256(b"x").digest()
ceiling = 16 * (1 << 32)
# at the ceiling is fine to *request* (we don't actually compute it
# — that would need 256 GB; just check the boundary classification)
with pytest.raises(ValueError, match=r"16.2\^32|counter"):
phi_prg(h, dim_h=ceiling + 1)
# ----------------------------------------------------------- module shape
def test_module_exports_version_string():
# The "-le" suffix records the 2026-05-11 big-endian → little-endian
# counter flip (dav1d review; matches v7's TLV convention). A future
# endianness or formula change MUST bump this token + regen the KATs.
assert PHI_PRG_VERSION == "phi-prg-v1-hmac-sha512-le"
# versioned-default discipline (calculator-test-patterns.md §2):
# "v1" substring present so future major-version rotations
# (v2-blake3-expansion etc.) are detectable at the call site
# without string-comparing module paths.
assert "v1" in PHI_PRG_VERSION
assert PHI_PRG_VERSION.endswith("-le")
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}"
)
# Version pin (present in 2026-05-11+ fixtures): a KAT recorded
# under a different PHI_PRG_VERSION must not be silently replayed
# against the current algorithm.
if "version" in kat:
assert kat["version"] == PHI_PRG_VERSION, (
f"KAT version mismatch for label={kat.get('label')!r}: "
f"fixture {kat['version']}, module {PHI_PRG_VERSION}"
)
# 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