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).
171 lines
5.8 KiB
Python
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)
|