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:
parent
ed470dc4b9
commit
06c95a03ab
5 changed files with 497 additions and 7 deletions
14
arborist/v7/__init__.py
Normal file
14
arborist/v7/__init__.py
Normal 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
150
arborist/v7/anchor_prg.py
Normal 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"]
|
||||
16
bench/fixtures/phi-prg/known-answer-tests.jsonl
Normal file
16
bench/fixtures/phi-prg/known-answer-tests.jsonl
Normal 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}
|
||||
|
|
@ -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
270
tests/test_anchor_prg.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue