core/tests/test_audio_crdt_merge.py
Shay dd529ad408
feat(adr-0181-p5): audio Delta-CRDT arena + merge kernel (sequential==concurrent proof) (#477)
Wires AudioCompilationUnits into the Delta-CRDT substrate (ADR-0180 §2.1/§2.2)
at the Python layer — audio is the first concrete exerciser of ADR-0180.

- sensorium/audio/arena.py:
  - AudioArena (§2.1): thread-local, share-nothing, lock-free accumulation;
    non-destructive snapshot(); thread_local_audio_arena() accessor.
  - AudioDelta (§2.2): canonical, content-addressed merge_key order + dedup;
    join is commutative/associative/idempotent.
  - merge_audio_deltas (§2.2): the Merge Kernel — folds arena deltas into one
    content-addressed, deduped, totally ordered set.
  - audio_merge_trace_hash: PCM-free, order-invariant trace hash — the
    sequential==concurrent proof anchor (ADR-0181 §4.2 A-3/A-6).

Mirrors the Rust LocalArena/SemilatticeDelta/merge_kernel (core-rs/src/vault.rs,
ADR-0180 §4.1) so the layers stay in parity when the Rust↔Python binding lands
(§1.5.5, deferred — pure-CPU/Python path first).

10 failable proof tests in tests/test_audio_crdt_merge.py covering the
load-bearing hash(Sequential)==hash(Concurrent) gate (real threads), the three
semilattice legs, content-addressed ordering, dedup idempotence, and trace
hygiene. Mutation-verified: disabling the content sort fails 6 of 10 loudly.

Additive only: no core layer touched (ADR-0013). Audio tests 35/35; architectural
invariants 40/40. Zero impact on existing eval lanes (no serving path changed).
2026-05-29 13:22:44 -07:00

171 lines
5.8 KiB
Python

"""
ADR-0181 PR-5 — audio Delta-CRDT merge proof obligations.
The load-bearing gate is `hash(Sequential_Ingest) == hash(Concurrent_CRDT_Ingest)`
(ADR-0180 §4.3 / ADR-0181 §4.2 A-2, A-3): a set of `AudioCompilationUnit`s folds
to the same merged Vault contribution — and the same trace hash — regardless of
the order arenas were filled or flushed in.
Per CLAUDE.md §Schema-Defined Proof Obligations, each test FAILS LOUDLY under the
violation it names: if `_canonicalize` stopped sorting by `merge_key` (ordered by
arrival instead), the permutation / sequential==concurrent / content-order tests
break; if it stopped deduplicating, the idempotence test breaks.
"""
from __future__ import annotations
import json
import random
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import numpy as np
import pytest
from evals.audio_sensorium.synth import synthesize
from sensorium.audio.arena import (
AudioArena,
AudioDelta,
audio_merge_trace_hash,
merge_audio_deltas,
reset_thread_local_audio_arena,
thread_local_audio_arena,
)
from sensorium.audio.canonical import canonicalize
from sensorium.audio.compiler import AudioCompiler
from sensorium.audio.trace import audio_evidence_trace
SR = 24_000
_EVAL_DIR = Path("evals/audio_sensorium")
def _fixtures() -> list[dict]:
return json.loads((_EVAL_DIR / "fixtures.json").read_text())["fixtures"]
@pytest.fixture(scope="module")
def units() -> list:
"""Real AudioCompilationUnits — one per eval fixture, distinct merge_keys."""
compiler = AudioCompiler()
out = []
for fx in _fixtures():
signal = canonicalize(synthesize(fx), SR)
out.append(compiler.compile_signal(signal))
return out
# --- the load-bearing property (ADR-0181 §4.2 A-2 / A-3) -------------------
def test_sequential_equals_concurrent(units):
"""hash(Sequential) == hash(Concurrent). Single-arena fixture-order ingest
vs one unit per thread-local arena flushed under real thread scheduling."""
seq_arena = AudioArena()
for unit in units:
seq_arena.push(unit)
seq_hash = audio_merge_trace_hash(merge_audio_deltas([seq_arena.snapshot()]))
def worker(unit) -> AudioDelta:
# Pools reuse threads; reset so each task gets a fresh share-nothing arena.
reset_thread_local_audio_arena()
arena = thread_local_audio_arena()
arena.push(unit)
return arena.snapshot()
for _ in range(5): # exercise scheduling non-determinism
order = units[:]
random.shuffle(order)
with ThreadPoolExecutor(max_workers=len(order)) as pool:
deltas = list(pool.map(worker, order))
conc_hash = audio_merge_trace_hash(merge_audio_deltas(deltas))
assert conc_hash == seq_hash
def test_merge_is_permutation_invariant(units):
base = merge_audio_deltas([AudioDelta.from_units(units)])
for _ in range(8):
shuffled = units[:]
random.shuffle(shuffled)
merged = merge_audio_deltas([AudioDelta.from_units([u]) for u in shuffled])
assert merged.merge_keys == base.merge_keys
assert audio_merge_trace_hash(merged) == audio_merge_trace_hash(base)
# --- semilattice legs (ADR-0180 §2.2) -------------------------------------
def test_merge_is_idempotent(units):
delta = AudioDelta.from_units(units)
once = merge_audio_deltas([delta])
twice = merge_audio_deltas([delta, delta])
assert once.merge_keys == twice.merge_keys
assert audio_merge_trace_hash(once) == audio_merge_trace_hash(twice)
# distinct fixtures => no spurious duplicates introduced or dropped
assert len(twice) == len(units)
def test_join_is_commutative_and_associative(units):
a = AudioDelta.from_units(units[:2])
b = AudioDelta.from_units(units[2:4])
c = AudioDelta.from_units(units[4:])
assert a.join(b).merge_keys == b.join(a).merge_keys
assert a.join(b).join(c).merge_keys == a.join(b.join(c)).merge_keys
def test_merge_kernel_equals_semilattice_fold(units):
deltas = [AudioDelta.from_units([u]) for u in units]
folded = deltas[0]
for d in deltas[1:]:
folded = folded.join(d)
assert merge_audio_deltas(deltas).merge_keys == folded.merge_keys
# --- content-addressing + dedup correctness --------------------------------
def test_merge_order_is_content_addressed(units):
delta = AudioDelta.from_units(units)
assert list(delta.merge_keys) == sorted(delta.merge_keys)
# all distinct fixtures retained
assert len(delta) == len({u.merge_key for u in units})
def test_arena_push_order_independent(units):
forward = AudioArena()
for u in units:
forward.push(u)
backward = AudioArena()
for u in reversed(units):
backward.push(u)
assert forward.snapshot().merge_keys == backward.snapshot().merge_keys
def test_arena_snapshot_non_draining(units):
arena = AudioArena()
arena.push(units[0])
_ = arena.snapshot()
# flush/GC is the kernel's job; snapshot must not lose the unit across the
# eventual-consistency window (ADR-0180 §3.2).
assert len(arena) == 1
assert len(arena.snapshot()) == 1
# --- trace-hash sensitivity + hygiene (ADR-0181 §4.2 A-6) ------------------
def test_trace_hash_changes_with_content(units):
"""Guards against a vacuous (constant) hash: dropping a unit must change it."""
full = AudioDelta.from_units(units)
subset = AudioDelta.from_units(units[:-1])
assert audio_merge_trace_hash(full) != audio_merge_trace_hash(subset)
def test_merge_trace_hash_no_pcm(units):
delta = AudioDelta.from_units(units)
for unit in delta.units:
trace = audio_evidence_trace(unit)
for value in trace.values():
assert not isinstance(value, (np.ndarray, bytes, bytearray))
assert "samples" not in trace
# stable across repeated calls
assert audio_merge_trace_hash(delta) == audio_merge_trace_hash(delta)