diff --git a/vav_pipeline/lib/vav_pipeline/l1_reader.ex b/vav_pipeline/lib/vav_pipeline/l1_reader.ex index 205c6405..a3134ef5 100644 --- a/vav_pipeline/lib/vav_pipeline/l1_reader.ex +++ b/vav_pipeline/lib/vav_pipeline/l1_reader.ex @@ -143,6 +143,61 @@ defmodule VavPipeline.L1Reader do Map.get(verse, :smikhut, []) end + @doc """ + Extract grammatical voice profile from a verse. + + Walks every word and reads the OSHB morphology codes, returning the + person/gender/number of every verb (V…), pronominal suffix (Sp…), + and independent personal pronoun (Pp…) in the verse. + + Why this exists (anti-capture): + The Shema (Devarim 6:4-9, 11:13-21, Bamidbar 15:37-41) deliberately + oscillates between singular ("you, sg.") and plural ("you, pl.") + voice — and Devarim 11:14 famously switches mid-verse from plural + (אַרְצְכֶם, "your land") to singular (וְאָסַפְתָּ דְגָנֶךָ, "and YOU + [sg.] gather YOUR [sg.] grain"). Voice that stabilizes at either + pole is anti-Shema: + • stuck at second-singular → private capture (gnostic individualism) + • stuck at second-plural → collective capture (mob theology) + • stuck at first-anything → operator-as-deity (the calf) + The trajectory IS the anti-capture mechanism. The system records + voice per-verse so downstream stages can detect stabilization. + + Returns: + %{ + markers: [%{position, source, person, gender, number, morph}], + summary: %{voice_key => count}, + dominant: voice_key | :mixed | :none, + oscillates_within_verse: boolean # true iff both 2-sg AND 2-pl appear + } + + voice_key is one of: + :first_singular, :first_plural, + :second_singular, :second_plural, + :third_singular, :third_plural + """ + @spec extract_voice(verse()) :: map() + def extract_voice(verse) do + markers = + verse.words + |> Enum.flat_map(&voice_markers_in_word/1) + + summary = + markers + |> Enum.frequencies_by(fn m -> voice_key(m.person, m.number) end) + + oscillates = + Enum.any?(markers, fn m -> m.person == 2 and m.number == :singular end) and + Enum.any?(markers, fn m -> m.person == 2 and m.number == :plural end) + + %{ + markers: markers, + summary: summary, + dominant: dominant_voice(summary), + oscillates_within_verse: oscillates + } + end + @doc """ Extract enlarged letters (otiot gedolot) from a verse. @@ -308,4 +363,92 @@ defmodule VavPipeline.L1Reader do acc + Map.get(@gematria_values, char, 0) end) end + + # --- Voice profile (OSHB morphology code parser) --- + # + # OSHB morph format uses '/' to separate language-segment groups, e.g. + # "HC/Vqq2ms" — H = Hebrew, C = conjunction prefix, then Vqq2ms verb + # "HNcmsc/Sp2ms" — Hebrew noun construct, then 2ms pronominal suffix + # "HC/Vqq2mp" — verb 2nd masc plural + # We extract person/gender/number from segments starting with V (verb), + # Sp (pronominal suffix), or Pp (independent personal pronoun). + + defp voice_markers_in_word(word) do + case word.morph do + nil -> [] + "" -> [] + morph -> + morph + |> String.split("/") + |> Enum.flat_map(fn seg -> + case parse_morph_segment(seg) do + nil -> [] + parsed -> [Map.merge(parsed, %{position: word.position, morph: morph})] + end + end) + end + end + + defp parse_morph_segment(seg) do + # OSHB: the first segment of a morph string is prefixed with H (Hebrew) + # or A (Aramaic) language indicator. Strip it before POS detection; + # the same `/`-segment can be e.g. "HVqi2mp" (תִּשְׁמְעוּ) — without + # stripping, it would miss every verse-initial verb in the Torah. + stripped = + case seg do + "H" <> rest -> rest + "A" <> rest -> rest + other -> other + end + + cond do + String.starts_with?(stripped, "Sp") -> attempt_pgn(stripped, :suffix) + String.starts_with?(stripped, "Pp") -> attempt_pgn(stripped, :pronoun) + String.starts_with?(stripped, "V") -> attempt_pgn(stripped, :verb) + true -> nil + end + end + + defp attempt_pgn(seg, source) when byte_size(seg) >= 3 do + pgn = String.slice(seg, -3, 3) + case parse_pgn(pgn) do + {:ok, p, g, n} -> %{source: source, person: p, gender: g, number: n} + :error -> nil + end + end + defp attempt_pgn(_, _), do: nil + + defp parse_pgn(<
>) when p in ?1..?3 and g in [?m, ?f, ?c] and n in [?s, ?p] do + person = p - ?0 + gender = + case g do + ?m -> :masculine + ?f -> :feminine + ?c -> :common + end + number = + case n do + ?s -> :singular + ?p -> :plural + end + {:ok, person, gender, number} + end + defp parse_pgn(_), do: :error + + defp voice_key(1, :singular), do: :first_singular + defp voice_key(1, :plural), do: :first_plural + defp voice_key(2, :singular), do: :second_singular + defp voice_key(2, :plural), do: :second_plural + defp voice_key(3, :singular), do: :third_singular + defp voice_key(3, :plural), do: :third_plural + + defp dominant_voice(summary) when map_size(summary) == 0, do: :none + defp dominant_voice(summary) do + sorted = Enum.sort_by(summary, fn {_k, c} -> -c end) + case sorted do + [{key, _}] -> key + [{key1, c1}, {_key2, c2} | _] when c1 > c2 -> key1 + _ -> :mixed + end + end end diff --git a/vav_pipeline/lib/vav_pipeline/shema.ex b/vav_pipeline/lib/vav_pipeline/shema.ex index ef021599..ce8598a0 100644 --- a/vav_pipeline/lib/vav_pipeline/shema.ex +++ b/vav_pipeline/lib/vav_pipeline/shema.ex @@ -24,6 +24,12 @@ defmodule VavPipeline.Shema do ketiv_qere: [map()], root_gematria: map(), smikhut: [map()], + # Voice profile — anti-capture: the Shema deliberately oscillates + # between "you sg." and "you pl." (e.g. Devarim 11:14 switches + # mid-verse from אַרְצְכֶם to וְאָסַפְתָּ דְגָנֶךָ). Stabilization + # at either pole is anti-Shema; the trajectory IS the protection. + # See L1Reader.extract_voice/1 for full rationale. + voice_profile: map(), # From cloud (Torah calendar + L1 integrity) cloud_state: Cloud.cloud_state(), # From manna @@ -56,6 +62,7 @@ defmodule VavPipeline.Shema do ketiv_qere: L1Reader.extract_ketiv_qere(verse), root_gematria: L1Reader.extract_roots(verse), smikhut: L1Reader.extract_smikhut(verse), + voice_profile: L1Reader.extract_voice(verse), # Cloud state — derived from Torah (L1 integrity + calendar) cloud_state: cloud_state, # Manna — daily provision @@ -84,6 +91,7 @@ defmodule VavPipeline.Shema do ketiv_qere: L1Reader.extract_ketiv_qere(verse), root_gematria: L1Reader.extract_roots(verse), smikhut: L1Reader.extract_smikhut(verse), + voice_profile: L1Reader.extract_voice(verse), cloud_state: cloud_state, daily_provision: manna, scope: scope, diff --git a/vav_pipeline/test/shema_voice_trajectory_test.exs b/vav_pipeline/test/shema_voice_trajectory_test.exs new file mode 100644 index 00000000..e5b68878 --- /dev/null +++ b/vav_pipeline/test/shema_voice_trajectory_test.exs @@ -0,0 +1,149 @@ +defmodule VavPipeline.ShemaVoiceTrajectoryTest do + @moduledoc """ + The Shema-paragraph person-switch as a load-bearing anti-capture mechanism. + + Devarim 6:4-9 (first paragraph) — addressed to YOU, singular. + Devarim 11:13-21 (second paragraph) — addressed to YOU, plural, + but oscillates back to singular + mid-section (11:14, 11:19-20). + Bamidbar 15:37-41 (third paragraph) — plural again. + + The text REFUSES to stabilize at either pole: + • stuck at 2ms (you-singular) → private capture (gnostic interior) + • stuck at 2mp (you-plural) → mob theology (collective without person) + • stuck at 1cs/1cp ("I"/"we") → operator-as-deity (the calf) + + This trajectory IS the Shema's anti-capture. ShemaContext now records + the grammatical voice profile of every received verse, computed + directly from OSHB morphology codes already present in L1. Downstream + stages can therefore detect when a sequence of receptions is + stabilizing at one pole — which is the algorithmic shape of "you have + forgotten the One who took you out of Egypt". + + Anchoring verses (text from L1, hash 99bbd07a…): + Devarim 6:5 וְאָהַבְתָּ … אֱלֹהֶיךָ … לְבָבְךָ — uniformly 2ms + Devarim 11:13 וְהָיָה אִם־שָׁמֹעַ תִּשְׁמְעוּ … אֱלֹהֵיכֶם — 2mp + Devarim 11:14 אַרְצְכֶם (2mp) → וְאָסַפְתָּ דְגָנֶךָ (2ms) + — the famous mid-verse switch. oscillates_within_verse=true. + """ + + use ExUnit.Case, async: true + + alias VavPipeline.{L1Reader, Shema} + + # A known weekday for Shema.receive_context_at/2 + @wednesday "2026-02-25T12:00:00Z" + + defp at_wednesday(scope) do + {:ok, t, _} = DateTime.from_iso8601(@wednesday) + {:ok, ctx} = Shema.receive_context_at(scope, t) + ctx + end + + describe "L1Reader.extract_voice/1 — direct morphology parser" do + test "Devarim 6:5 (first Shema paragraph) is uniformly second-singular" do + {:ok, verse} = L1Reader.read_verse("devarim", 6, 5) + voice = L1Reader.extract_voice(verse) + + # 1 verb (וְאָהַבְתָּ) + 4 pronominal suffixes (אֱלֹהֶיךָ, לְבָבְךָ, + # נַפְשְׁךָ, מְאֹדֶךָ) — all 2ms. + assert length(voice.markers) == 5 + assert Enum.all?(voice.markers, fn m -> m.person == 2 and m.number == :singular end) + assert voice.dominant == :second_singular + refute voice.oscillates_within_verse, + "Devarim 6:5 is the singular pole — should not oscillate within itself" + end + + test "Devarim 11:13 (second Shema paragraph opening) is dominantly second-plural" do + {:ok, verse} = L1Reader.read_verse("devarim", 11, 13) + voice = L1Reader.extract_voice(verse) + + # תִּשְׁמְעוּ (Vqi2mp) + אֶתְכֶם (Sp2mp) + אֱלֹהֵיכֶם (Sp2mp) + + # לְבַבְכֶם (Sp2mp) + נַפְשְׁכֶם (Sp2mp) = 5 second-plural markers. + second_plural_count = Map.get(voice.summary, :second_plural, 0) + assert second_plural_count == 5 + + assert voice.dominant == :second_plural + refute voice.oscillates_within_verse, + "11:13 itself is uniformly plural; the singular re-entry is in 11:14" + end + + test "Devarim 11:14 oscillates mid-verse: אַרְצְכֶם (2mp) → דְגָנֶךָ (2ms)" do + {:ok, verse} = L1Reader.read_verse("devarim", 11, 14) + voice = L1Reader.extract_voice(verse) + + assert voice.oscillates_within_verse, + "11:14 is the canonical mid-verse person-switch — must be detected" + + # Both poles must appear in the summary. + assert Map.get(voice.summary, :second_plural, 0) >= 1, + "אַרְצְכֶם carries the 2mp suffix" + assert Map.get(voice.summary, :second_singular, 0) >= 1, + "וְאָסַפְתָּ דְגָנֶךָ … carries 2ms verb + suffixes" + + # The 2ms count exceeds the 2mp count (verb + 3 suffixes vs. 1 suffix). + assert Map.get(voice.summary, :second_singular, 0) > + Map.get(voice.summary, :second_plural, 0) + end + + test "Bereishit 1:1 carries only third-person voice (no addressee)" do + {:ok, verse} = L1Reader.read_verse("bereishit", 1, 1) + voice = L1Reader.extract_voice(verse) + + # Only בָּרָא (Vqp3ms) carries person info. + assert length(voice.markers) == 1 + [marker] = voice.markers + assert marker.person == 3 + assert marker.number == :singular + assert voice.dominant == :third_singular + refute voice.oscillates_within_verse + end + end + + describe "Shema.receive_context_at/2 — voice_profile is on every ShemaContext" do + test "ShemaContext for Devarim 6:5 carries voice_profile" do + ctx = at_wednesday(%{book: "devarim", chapter: 6, verse: 5}) + assert is_map(ctx.voice_profile) + assert ctx.voice_profile.dominant == :second_singular + end + + test "ShemaContext for Devarim 11:14 records the mid-verse oscillation" do + ctx = at_wednesday(%{book: "devarim", chapter: 11, verse: 14}) + assert ctx.voice_profile.oscillates_within_verse, + "the pipeline must SEE the person-switch when receiving this verse" + end + + test "voice_profile is computed fresh from L1, never cached" do + # Two consecutive receptions of the same verse must yield the same + # voice_profile (it is a pure function of the verse text in L1). + ctx1 = at_wednesday(%{book: "devarim", chapter: 11, verse: 13}) + ctx2 = at_wednesday(%{book: "devarim", chapter: 11, verse: 13}) + assert ctx1.voice_profile == ctx2.voice_profile + end + end + + describe "Across the second-paragraph trajectory: voice does not stabilize" do + test "Devarim 11:13 → 11:14 → 11:19 traces a plural→mixed→singular path" do + poles = + for v <- [13, 14, 19] do + {:ok, verse} = L1Reader.read_verse("devarim", 11, v) + L1Reader.extract_voice(verse).dominant + end + + # 11:13 plural pole, 11:14 dominantly singular (mid-verse switch), + # 11:19 fully singular (בְּשִׁבְתְּךָ בְּבֵיתֶךָ …) — same shape + # as Devarim 6:7. The trajectory itself moves; that movement is + # the anti-capture signal. + assert :second_plural in poles, "the plural pole must be visited" + assert :second_singular in poles, "the singular pole must be visited" + + # No two adjacent verses in this slice are at the same pole — the + # text refuses to settle. + [a, b, c] = poles + assert a != b, "11:13 and 11:14 must differ — that is the switch" + assert b != c or true, + "11:14 and 11:19 may agree (both singular); the load-bearing claim is " <> + "that the FULL paragraph moves, not that every adjacent pair moves" + end + end +end