diff --git a/core/physics/biography_wiring.py b/core/physics/biography_wiring.py index c9ca97f4..91abf3e8 100644 --- a/core/physics/biography_wiring.py +++ b/core/physics/biography_wiring.py @@ -122,8 +122,10 @@ class BiographyProvenanceRecord: def _content_id(payload: Mapping[str, Any]) -> str: - raw = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str) - return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:24] + # Full 256-bit digest (64 hex); no ``default=str`` — non-serializable + # payload elements fail closed with a typed ``TypeError`` (ADR-0244 §2.7). + raw = json.dumps(payload, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(raw.encode("utf-8")).hexdigest() def biography_provenance_record( diff --git a/core/physics/cognitive_lifecycle.py b/core/physics/cognitive_lifecycle.py index e18253d7..c16daee5 100644 --- a/core/physics/cognitive_lifecycle.py +++ b/core/physics/cognitive_lifecycle.py @@ -124,13 +124,33 @@ class EgressValidationError(CognitiveLifecycleError): # --- Content addressing ---------------------------------------------------------- +def _le_f64_bytes(arr: np.ndarray) -> bytes: + """Canonical little-endian float64 bytes for cross-platform-stable digests. + + ADR-0244 §2.7 byte-order guard: coerce to little-endian float64 before + hashing so the digest is identical on every little-endian platform and + deterministic on big-endian ones. Byte-wise a no-op on the M1/x86 targets, + but an explicit contract rather than an implicit platform assumption — and a + coercion, not an ``assert`` (the assert form is stripped under ``-O``). + """ + contiguous = np.ascontiguousarray(arr, dtype=np.float64) + return contiguous.astype(np.dtype(" str: - raw = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str) - return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:24] + # Full 256-bit digest (64 hex). No ``default=str``: a non-serializable + # payload element fails closed with a typed ``TypeError`` at the + # serialization boundary rather than silently collapsing distinct objects + # onto identical string forms (ADR-0244 §2.7). + raw = json.dumps(payload, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(raw.encode("utf-8")).hexdigest() def _psi_digest(psi: np.ndarray) -> str: - return hashlib.sha256(np.ascontiguousarray(psi, dtype=np.float64).tobytes()).hexdigest()[:24] + # Full 256-bit digest over canonical little-endian float64 bytes — no + # 96-bit truncation (birthday-collision floor at 2^48), no platform + # byte-order ambiguity (ADR-0244 §2.7). + return hashlib.sha256(_le_f64_bytes(psi)).hexdigest() def _as_psi(x: np.ndarray, name: str, *, error: type[CognitiveLifecycleError]) -> np.ndarray: @@ -271,7 +291,7 @@ class ProblemHamiltonian: _content_id( { "domain": str(self.domain), - "matrix_sha": hashlib.sha256(arr.tobytes()).hexdigest(), + "matrix_sha": hashlib.sha256(_le_f64_bytes(arr)).hexdigest(), "metadata": {k: str(v) for k, v in sorted(meta.items())}, } ), diff --git a/core/physics/self_authorship.py b/core/physics/self_authorship.py index 55ef6f88..8fafd782 100644 --- a/core/physics/self_authorship.py +++ b/core/physics/self_authorship.py @@ -39,8 +39,10 @@ class AuthorshipProposal: def _content_id(payload: Mapping[str, Any]) -> str: - raw = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str) - return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:24] + # Full 256-bit digest (64 hex); no ``default=str`` — non-serializable + # payload elements fail closed with a typed ``TypeError`` (ADR-0244 §2.7). + raw = json.dumps(payload, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(raw.encode("utf-8")).hexdigest() class SelfAuthorshipMiner: diff --git a/tests/test_adr_0243_cognitive_lifecycle.py b/tests/test_adr_0243_cognitive_lifecycle.py index 497286d1..6f249f23 100644 --- a/tests/test_adr_0243_cognitive_lifecycle.py +++ b/tests/test_adr_0243_cognitive_lifecycle.py @@ -569,8 +569,8 @@ def test_egress_refuses_certificate_not_bound_to_state(): cert = outcome.relaxation.certificate # Independent gold for the binding: byte digest of the certified state. gold_digest = hashlib.sha256( - np.ascontiguousarray(psi, dtype=np.float64).tobytes() - ).hexdigest()[:24] + np.ascontiguousarray(psi, dtype=np.float64).astype(np.dtype(" np.ndarray: + return np.ascontiguousarray(make_rotor_from_angle(0.7, 6), dtype=np.float64) + + +# --- Mandate 4: full 256-bit digests (no 96-bit truncation) ----------------------- + + +@pytest.mark.parametrize("content_id", _CONTENT_ID_FUNCS) +def test_content_id_is_full_256_bit_hex(content_id) -> None: + digest = content_id({"a": 1, "b": ["x", 2.0], "c": True}) + assert len(digest) == 64 + int(digest, 16) # valid lowercase hex + + +def test_psi_digest_is_full_256_bit_hex() -> None: + digest = _psi_digest(_sample_psi()) + assert len(digest) == 64 + int(digest, 16) + + +def test_full_digest_extends_the_old_truncated_prefix() -> None: + """The widening is an un-truncation, not a different hash: the old 24-hex + form is exactly the 64-hex digest's prefix (same underlying bytes).""" + psi = _sample_psi() + full = _psi_digest(psi) + legacy_prefix = hashlib.sha256(_le_f64_bytes(psi)).hexdigest()[:24] + assert full[:24] == legacy_prefix + + +# --- Mandate 4: fail closed on silent coercion ------------------------------------ + + +@pytest.mark.parametrize("content_id", _CONTENT_ID_FUNCS) +def test_content_id_fails_closed_on_non_serializable(content_id) -> None: + class _Opaque: + pass + + with pytest.raises(TypeError): + content_id({"bad": _Opaque()}) + + +@pytest.mark.parametrize("content_id", _CONTENT_ID_FUNCS) +def test_content_id_is_deterministic(content_id) -> None: + payload = {"k": "v", "n": 3, "xs": [1, 2, 3]} + assert content_id(payload) == content_id(dict(payload)) + + +# --- Mandate 5: canonical little-endian byte-order contract ----------------------- + + +def test_le_bytes_are_byte_order_canonical() -> None: + """A native-endian array and an explicit big-endian-dtype array carrying the + same values must hash identically — the coercion, not the platform, decides + the bytes.""" + psi = _sample_psi() + native = np.ascontiguousarray(psi, dtype=np.float64) + big_endian = native.astype(np.dtype(">f8")) + assert _le_f64_bytes(native) == _le_f64_bytes(big_endian) + assert _psi_digest(native) == _psi_digest(big_endian) + + +def test_le_bytes_match_explicit_little_endian_layout() -> None: + psi = _sample_psi() + expected = np.ascontiguousarray(psi, dtype=np.dtype("