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>
177 lines
6.5 KiB
Elixir
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
|