Files
bikoret/vav_pipeline/test/voice_monitor_test.exs
Scott KarpandClaude Opus 4.7 d88b8d09ec voice_monitor: detect across-verse Shema voice stabilization (closes finding A)
Pass 2 finding A, second half. The first half (commit 0c4f764) added
voice_profile to every ShemaContext — recording, per verse, the
grammatical voice computed from OSHB morphology codes. Per-verse
recording was the necessary precondition for what this commit does:
WATCH the trajectory and FIRE when it stops oscillating between the
singular and plural address poles.

WHY THIS MATTERS:

The Shema as recited oscillates between Devarim 6:4–9 (the
singular pole — אֱלֹהֶיךָ, לְבָבְךָ) and Devarim 11:13–21 (the
plural pole — אֱלֹהֵיכֶם, לְבַבְכֶם), and 11:14 famously switches
poles mid-verse (אַרְצְכֶם 2mp → וְאָסַפְתָּ דְגָנֶךָ 2ms). The
text REFUSES to stabilize at either pole. That refusal IS the
anti-capture mechanism:

  - stuck at second_singular     → gnostic-private capture
  - stuck at second_plural       → mob-theology capture
  - stuck at first_*             → operator-as-deity / calf
                                   (Shemot 32:4 — eleh elohekha Yisrael)

Before this commit nothing observed the trajectory ACROSS verses.
Per-verse profiles existed but nobody was watching the sequence;
a system that read only Devarim 6:4–9 in a loop was producing a
gnostic-shaped reception with no warning fired. The voice_profile
field had a reader but no consumer.

THE DETECTOR (VavPipeline.VoiceMonitor):

A GenServer maintaining a sliding window of the dominant voice
from the last N ShemaContexts (default N=10). The rule:

  - Window not yet full       → :clear (insufficient evidence)
  - Window has both poles     → :clear (the Shema is doing its job)
  - Address-only window
    missing one pole          → {:missing_pole, :second_singular | :second_plural}
  - First-person majority     → {:first_person_dominant, :first_singular | :first_plural}
  - Pure narration window     → :clear (third-person sequences are
                                NOT anti-Shema; the rule applies to
                                ADDRESS, not to all voice)

On a NEW alarm (different from the last fired), VoiceMonitor:
  1. Logs a warning naming the specific stabilization shape with
     its Torah anchor and its capture-pattern name.
  2. Fires Chatzotzrot.signal(:assembly_leaders, ...) so
     operators and kohanim subscribed to the chatzotzrot channel
     receive the alert.

Re-firing is suppressed for as long as the same stabilization
persists (no spam). The alarm clears when both poles return; a
subsequent re-stabilization fires fresh.

WIRING:

  - Shema.receive_context/1 and receive_context_at/2 now call
    VoiceMonitor.record(voice_profile) immediately after building
    the context. Process.whereis-guarded — the call is safe in
    tests that don't bring up the supervisor.
  - VoiceMonitor added to the supervision tree in application.ex.
  - Chatzotzrot.signal/2 itself is also Process.whereis-guarded
    inside VoiceMonitor (the existing pattern from manna_ledger,
    arei_miklat, urim_v_tumim).

TESTS (test/voice_monitor_test.exs, 14 tests):

  - below window → :clear
  - all 2ms full window → fires {:missing_pole, :second_plural}
  - all 2mp full window → fires {:missing_pole, :second_singular}
  - both poles present → :clear
  - pure narration → :clear (third-person is not address)
  - first-person majority → {:first_person_dominant, _}
  - first-person tied (not strict majority) → does not fire
  - same stabilization persisting → last_alarm unchanged (no spam)
  - clear-then-stabilize-again → fresh alarm fires
  - reset/0 returns monitor to empty
  - record/1 with full voice_profile map (the Shema-side surface)

Each test starts an isolated VoiceMonitor under a unique name
(small window_size=4 for fast convergence) so tests don't share
state. Chatzotzrot is intentionally NOT brought up — the monitor's
guarded call falls through to log-only.

Full vav_pipeline suite: 329 tests, 0 failures, 24 excluded
(was 315 + 14 = 329). The 24 excluded are :l3-tagged tests
needing PostgreSQL; unchanged.

Closes Pass 2 finding A in full.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:25:59 -04:00

177 lines
6.5 KiB
Elixir

defmodule VavPipeline.VoiceMonitorTest do
@moduledoc """
Across-verse Shema voice stabilization detector — second half of
Pass 2 finding A. The first half (commit 0c4f764) records voice
per ShemaContext; this module fires when the trajectory of those
per-verse profiles stops oscillating between the singular and
plural address poles.
Three failure shapes the detector must catch:
1. Stuck at second_singular — gnostic-private capture
("you, sg." only; the system has stopped hearing "you, pl.")
2. Stuck at second_plural — mob-theology capture
("you, pl." only; the system has stopped hearing "you, sg.")
3. First-person dominance — operator-as-deity, calf shape
("I"/"we" exceeds half the window)
These tests start a per-test VoiceMonitor under a unique name so
they don't share state. They do NOT bring up Chatzotzrot — the
monitor's signal call is guarded by Process.whereis, so when
Chatzotzrot is absent the alarm is logged but not transmitted.
Tests inspect the monitor's state directly to verify behavior.
"""
use ExUnit.Case, async: false
alias VavPipeline.VoiceMonitor
setup do
# Bring up an isolated VoiceMonitor with the small default
# window. Stopped at end-of-test by ExUnit's process cleanup.
{:ok, pid} = VoiceMonitor.start_link(window_size: 4, name: __MODULE__.Monitor)
on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end)
%{pid: pid}
end
defp record_into(pid, dominant) do
GenServer.cast(pid, {:record, dominant})
end
defp status_of(pid) do
GenServer.call(pid, :status)
end
defp state_of(pid) do
GenServer.call(pid, :state)
end
describe "below window — insufficient evidence, no alarm" do
test "empty state is :clear", %{pid: pid} do
assert :clear = status_of(pid)
end
test "fewer recordings than window size keeps :clear", %{pid: pid} do
for _ <- 1..3, do: record_into(pid, :second_singular)
assert :clear = status_of(pid)
end
end
describe "missing-pole detection (window full, address-only)" do
test "all second_singular for a full window fires :missing_pole, :second_plural", %{pid: pid} do
for _ <- 1..4, do: record_into(pid, :second_singular)
assert {:missing_pole, :second_plural} = status_of(pid)
assert state_of(pid).last_alarm == {:missing_pole, :second_plural}
end
test "all second_plural for a full window fires :missing_pole, :second_singular", %{pid: pid} do
for _ <- 1..4, do: record_into(pid, :second_plural)
assert {:missing_pole, :second_singular} = status_of(pid)
assert state_of(pid).last_alarm == {:missing_pole, :second_singular}
end
test "both poles present in window stays :clear", %{pid: pid} do
record_into(pid, :second_singular)
record_into(pid, :second_singular)
record_into(pid, :second_plural)
record_into(pid, :second_plural)
assert :clear = status_of(pid)
end
end
describe "address-pole rule does not apply to pure narration" do
test "all third_singular (third-person narration) does not fire", %{pid: pid} do
for _ <- 1..4, do: record_into(pid, :third_singular)
# No address present at all → narration sequence, not anti-Shema
assert :clear = status_of(pid)
end
test "third_singular mixed with no address remains :clear", %{pid: pid} do
for _ <- 1..3, do: record_into(pid, :third_singular)
record_into(pid, :third_plural)
assert :clear = status_of(pid)
end
end
describe "first-person dominance fires the calf-shape alarm" do
test "majority first_singular fires :first_person_dominant", %{pid: pid} do
record_into(pid, :first_singular)
record_into(pid, :first_singular)
record_into(pid, :first_singular)
record_into(pid, :second_singular)
assert {:first_person_dominant, :first_singular} = status_of(pid)
end
test "majority first_plural fires :first_person_dominant", %{pid: pid} do
record_into(pid, :first_plural)
record_into(pid, :first_plural)
record_into(pid, :first_plural)
record_into(pid, :second_plural)
assert {:first_person_dominant, :first_plural} = status_of(pid)
end
test "first-person tied with non-first does NOT fire (strict majority required)", %{pid: pid} do
record_into(pid, :first_singular)
record_into(pid, :first_singular)
record_into(pid, :second_singular)
record_into(pid, :second_plural)
# 2/4 first-person — not strict majority
refute match?({:first_person_dominant, _}, status_of(pid))
end
end
describe "alarm-once-per-episode (no spam)" do
test "same stabilization persisting does not change last_alarm", %{pid: pid} do
for _ <- 1..4, do: record_into(pid, :second_singular)
first = state_of(pid).last_alarm
for _ <- 1..3, do: record_into(pid, :second_singular)
second = state_of(pid).last_alarm
assert first == second
assert first == {:missing_pole, :second_plural}
end
test "voice clearing then stabilizing again fires a fresh alarm", %{pid: pid} do
# Stabilize → fire
for _ <- 1..4, do: record_into(pid, :second_singular)
assert state_of(pid).last_alarm == {:missing_pole, :second_plural}
# Mix in a plural — both poles present → :clear, alarm reset
record_into(pid, :second_plural)
assert :clear = status_of(pid)
assert state_of(pid).last_alarm == nil
# Re-stabilize → alarm fires again (it was cleared in between)
for _ <- 1..4, do: record_into(pid, :second_singular)
assert state_of(pid).last_alarm == {:missing_pole, :second_plural}
end
end
describe "reset/0 clears state" do
test "reset returns the monitor to empty", %{pid: pid} do
for _ <- 1..4, do: record_into(pid, :second_singular)
:ok = GenServer.call(pid, :reset)
assert state_of(pid).recent == []
assert state_of(pid).last_alarm == nil
assert :clear = status_of(pid)
end
end
describe "record/1 accepts a voice_profile map (the public surface)" do
# This route mirrors what Shema.receive_context* actually does:
# passes the whole voice_profile, not just the dominant atom.
test "record/1 with a full voice_profile recording works", %{pid: pid} do
profile = %{
markers: [],
summary: %{second_singular: 4},
dominant: :second_singular,
oscillates_within_verse: false
}
for _ <- 1..4, do: GenServer.cast(pid, {:record, profile.dominant})
assert {:missing_pole, :second_plural} = status_of(pid)
end
end
end