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>
This commit is contained in:
1 parent
b1d5ac69e4
commit
d88b8d09ec
4 files changed
+468
-4
No files matched your search
@@ -0,0 +1,176 @@
|
||||
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
|
||||
Reference in new issue
Block a user