From af7522e977e567f940e093db956086b3f96cb700 Mon Sep 17 00:00:00 2001 From: Scott Karp Date: Thu, 12 Mar 2026 20:29:24 -0400 Subject: [PATCH] =?UTF-8?q?Phase=202=20review=20corrections=20=E2=80=94=20?= =?UTF-8?q?three=20spec=20violations=20fixed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Chatzotzrot signal mapping corrected to match Architecture §3.12: assembly_all→tekiah, assembly_leaders→teruah, breaking_camp→tekiah_teruah, moadim→tekiah_tekiah. Four of five patterns were swapped. All five now match Bamidbar 10. 2. Manna parse_datetime(nil) bug fixed: missing expires_at now fails validation instead of silently returning DateTime.utc_now() which created a race condition where manna appeared expired. 3. Festival detection implemented in cloud.ex: pre-computed Gregorian dates for Pesach, Shavuot, Rosh Hashanah, Yom Kippur, Sukkot, Shemini Atzeret (2025-2028). Pipeline now halts on Yom Tov as Torah requires. Step 15 verification now covers festival rest. 76 tests, 0 failures (68 prior + 8 new). Co-Authored-By: Claude Opus 4.6 --- vav_pipeline/lib/vav_pipeline/chatzotzrot.ex | 10 +- vav_pipeline/lib/vav_pipeline/cloud.ex | 103 ++++++++++++++++--- vav_pipeline/lib/vav_pipeline/manna.ex | 24 +++-- vav_pipeline/test/step15_shema_test.exs | 17 +++ vav_pipeline/test/vav_pipeline_test.exs | 52 ++++++++-- 5 files changed, 176 insertions(+), 30 deletions(-) diff --git a/vav_pipeline/lib/vav_pipeline/chatzotzrot.ex b/vav_pipeline/lib/vav_pipeline/chatzotzrot.ex index f9affc38..81167e07 100644 --- a/vav_pipeline/lib/vav_pipeline/chatzotzrot.ex +++ b/vav_pipeline/lib/vav_pipeline/chatzotzrot.ex @@ -44,12 +44,12 @@ defmodule VavPipeline.Chatzotzrot do signal end - # Signal type → trumpet pattern mapping (Bamidbar 10) - defp pattern_for(:assembly_all), do: :tekiah_tekiah - defp pattern_for(:assembly_leaders), do: :tekiah - defp pattern_for(:breaking_camp), do: :teruah + # Signal type → trumpet pattern mapping (Bamidbar 10, Architecture §3.12) + defp pattern_for(:assembly_all), do: :tekiah + defp pattern_for(:assembly_leaders), do: :teruah + defp pattern_for(:breaking_camp), do: :tekiah_teruah defp pattern_for(:war), do: :teruah_teruah - defp pattern_for(:moadim), do: :tekiah_teruah + defp pattern_for(:moadim), do: :tekiah_tekiah defp urgency_for(:breaking_camp), do: :immediate defp urgency_for(:war), do: :immediate diff --git a/vav_pipeline/lib/vav_pipeline/cloud.ex b/vav_pipeline/lib/vav_pipeline/cloud.ex index a7ab21b8..241c333c 100644 --- a/vav_pipeline/lib/vav_pipeline/cloud.ex +++ b/vav_pipeline/lib/vav_pipeline/cloud.ex @@ -86,20 +86,26 @@ defmodule VavPipeline.Cloud do end @doc """ - Check if now is a major festival (Yom Tov). + Check if now is a major festival (Yom Tov) where work is prohibited. - Uses fixed Hebrew calendar dates converted to approximate Gregorian. - Production will need a proper Hebrew calendar library. - For Phase 2 dev, returns false — festivals will be added in Phase 3 - with a proper luach implementation. + Torah specifies these rest days (Vayikra 23): + - Pesach: 15 and 21 Nisan (first and last day) + - Shavuot: 6 Sivan + - Rosh Hashanah: 1-2 Tishrei + - Yom Kippur: 10 Tishrei + - Sukkot: 15 Tishrei + - Shemini Atzeret: 22 Tishrei + + Uses a pre-computed Gregorian date table for known years. + Festival days run sunset-to-sunset; simplified to full calendar day. """ @spec festival?(DateTime.t()) :: boolean() - def festival?(_now) do - # Placeholder — requires Hebrew calendar conversion - # Festivals: Pesach (15-21 Nisan), Shavuot (6 Sivan), - # Rosh Hashanah (1-2 Tishrei), Yom Kippur (10 Tishrei), - # Sukkot (15-21 Tishrei), Shemini Atzeret (22 Tishrei) - false + def festival?(now) do + date = DateTime.to_date(now) + year = date.year + + festival_dates(year) + |> Enum.any?(fn festival_date -> Date.compare(date, festival_date) == :eq end) end @doc """ @@ -122,7 +128,80 @@ defmodule VavPipeline.Cloud do end @spec current_festival(DateTime.t()) :: atom() - defp current_festival(_now), do: :unknown + defp current_festival(now) do + date = DateTime.to_date(now) + year = date.year + + # Check each festival period + dates = festival_date_map(year) + + cond do + date in Map.get(dates, :pesach, []) -> :pesach + date in Map.get(dates, :shavuot, []) -> :shavuot + date in Map.get(dates, :rosh_hashanah, []) -> :rosh_hashanah + date in Map.get(dates, :yom_kippur, []) -> :yom_kippur + date in Map.get(dates, :sukkot, []) -> :sukkot + date in Map.get(dates, :shemini_atzeret, []) -> :shemini_atzeret + true -> :unknown + end + end + + # Festival dates for known years (Gregorian dates of Yom Tov rest days). + # Outside diaspora — Torah days only, not second-day diaspora additions. + # + # Source: fixed Hebrew calendar. Each year mapped from Hebrew calendar. + # Years outside the table fall back to :unknown (no festival detection). + @festival_table %{ + # Hebrew 5786 (2025-2026) + 2025 => %{ + pesach: [~D[2025-04-13], ~D[2025-04-19]], + shavuot: [~D[2025-06-02]], + rosh_hashanah: [~D[2025-09-23], ~D[2025-09-24]], + yom_kippur: [~D[2025-10-02]], + sukkot: [~D[2025-10-07]], + shemini_atzeret: [~D[2025-10-14]] + }, + # Hebrew 5787 (2026-2027) + 2026 => %{ + pesach: [~D[2026-04-02], ~D[2026-04-08]], + shavuot: [~D[2026-05-22]], + rosh_hashanah: [~D[2026-09-12], ~D[2026-09-13]], + yom_kippur: [~D[2026-09-21]], + sukkot: [~D[2026-09-26]], + shemini_atzeret: [~D[2026-10-03]] + }, + # Hebrew 5788 (2027-2028) + 2027 => %{ + pesach: [~D[2027-04-22], ~D[2027-04-28]], + shavuot: [~D[2027-06-11]], + rosh_hashanah: [~D[2027-10-02], ~D[2027-10-03]], + yom_kippur: [~D[2027-10-11]], + sukkot: [~D[2027-10-16]], + shemini_atzeret: [~D[2027-10-23]] + }, + # Hebrew 5789 (2028-2029) + 2028 => %{ + pesach: [~D[2028-04-11], ~D[2028-04-17]], + shavuot: [~D[2028-05-31]], + rosh_hashanah: [~D[2028-09-21], ~D[2028-09-22]], + yom_kippur: [~D[2028-09-30]], + sukkot: [~D[2028-10-05]], + shemini_atzeret: [~D[2028-10-12]] + } + } + + # Flat list of all festival dates for a given year (for festival? check) + defp festival_dates(year) do + case Map.get(@festival_table, year) do + nil -> [] + dates -> dates |> Map.values() |> List.flatten() + end + end + + # Structured map for current_festival lookup + defp festival_date_map(year) do + Map.get(@festival_table, year, %{}) + end @spec next_motzei_shabbat(DateTime.t()) :: DateTime.t() defp next_motzei_shabbat(now) do diff --git a/vav_pipeline/lib/vav_pipeline/manna.ex b/vav_pipeline/lib/vav_pipeline/manna.ex index c87da37e..3bf04d0b 100644 --- a/vav_pipeline/lib/vav_pipeline/manna.ex +++ b/vav_pipeline/lib/vav_pipeline/manna.ex @@ -137,9 +137,8 @@ defmodule VavPipeline.Manna do defp validate_manna(data, now) do with {:ok, date} <- validate_date(data["date"], now), {:ok, provision} <- validate_provision(data["provision"]), - {:ok, expires_at} <- parse_datetime(data["expires_at"]), - :ok <- check_expiry(expires_at, now), - {:ok, written_at} <- parse_datetime(data["written_at"]) do + {:ok, expires_at} <- parse_expires_at(data["expires_at"]), + :ok <- check_expiry(expires_at, now) do {:ok, %{ date: date, @@ -147,7 +146,7 @@ defmodule VavPipeline.Manna do type: parse_type(data["type"]), expires_at: expires_at, written_by: data["written_by"] || "unknown", - written_at: written_at + written_at: parse_written_at(data["written_at"]) }} end end @@ -187,12 +186,23 @@ defmodule VavPipeline.Manna do end end - defp parse_datetime(nil), do: {:ok, DateTime.utc_now()} + # expires_at is required — missing means invalid provision + defp parse_expires_at(nil), do: {:error, "MANNA_INVALID: missing expires_at"} - defp parse_datetime(str) do + defp parse_expires_at(str) do case DateTime.from_iso8601(str) do {:ok, dt, _offset} -> {:ok, dt} - {:error, _} -> {:error, "MANNA_INVALID: bad datetime #{str}"} + {:error, _} -> {:error, "MANNA_INVALID: bad expires_at datetime #{str}"} + end + end + + # written_at is metadata — missing is tolerable + defp parse_written_at(nil), do: nil + + defp parse_written_at(str) do + case DateTime.from_iso8601(str) do + {:ok, dt, _offset} -> dt + {:error, _} -> nil end end diff --git a/vav_pipeline/test/step15_shema_test.exs b/vav_pipeline/test/step15_shema_test.exs index 62471e39..e8b19b82 100644 --- a/vav_pipeline/test/step15_shema_test.exs +++ b/vav_pipeline/test/step15_shema_test.exs @@ -205,6 +205,23 @@ defmodule Step15ShemaTest do assert {:transition, :shabbat_rest} = result end + test "festival cloud state prevents processing — Pesach" do + # 15 Nisan 5787 = 2 April 2026 + {:ok, pesach, _} = DateTime.from_iso8601("2026-04-02T12:00:00Z") + {:ok, context} = Shema.receive_context_at(@scope, pesach) + + assert {:major_transition, :festival_rest, %{festival: :pesach}} = context.cloud_state + + # Pipeline would return {:transition, :festival_rest} + result = + case context.cloud_state do + {:major_transition, signal, _params} -> {:transition, signal} + _ -> :would_process + end + + assert {:transition, :festival_rest} = result + end + test "cloud_not_visible triggers waiting" do # Manually construct context with cloud_not_visible {:ok, wednesday, _} = DateTime.from_iso8601(@wednesday_noon) diff --git a/vav_pipeline/test/vav_pipeline_test.exs b/vav_pipeline/test/vav_pipeline_test.exs index 00c1f225..cb4eb98b 100644 --- a/vav_pipeline/test/vav_pipeline_test.exs +++ b/vav_pipeline/test/vav_pipeline_test.exs @@ -133,6 +133,35 @@ defmodule VavPipelineTest do {:ok, non_shmitah, _} = DateTime.from_iso8601("2026-06-15T12:00:00Z") assert VavPipeline.Cloud.shmitah_year?(non_shmitah) == false end + + test "Pesach is a festival" do + # 15 Nisan 5787 = 2 April 2026 + {:ok, pesach, _} = DateTime.from_iso8601("2026-04-02T12:00:00Z") + assert VavPipeline.Cloud.festival?(pesach) == true + end + + test "Rosh Hashanah is a festival" do + # 1 Tishrei 5787 = 12 September 2026 + {:ok, rosh_hashanah, _} = DateTime.from_iso8601("2026-09-12T12:00:00Z") + assert VavPipeline.Cloud.festival?(rosh_hashanah) == true + end + + test "Yom Kippur is a festival" do + # 10 Tishrei 5787 = 21 September 2026 + {:ok, yom_kippur, _} = DateTime.from_iso8601("2026-09-21T12:00:00Z") + assert VavPipeline.Cloud.festival?(yom_kippur) == true + end + + test "regular weekday is not a festival" do + {:ok, tuesday, _} = DateTime.from_iso8601("2026-03-10T12:00:00Z") + assert VavPipeline.Cloud.festival?(tuesday) == false + end + + test "festival triggers major transition" do + {:ok, pesach, _} = DateTime.from_iso8601("2026-04-02T12:00:00Z") + {:ok, state} = VavPipeline.Cloud.check_at(pesach) + assert {:major_transition, :festival_rest, %{festival: :pesach}} = state + end end # ============================================================ @@ -235,22 +264,33 @@ defmodule VavPipelineTest do # Chatzotzrot Tests # ============================================================ describe "Chatzotzrot" do - test "breaking camp signal" do + test "breaking camp signal — tekiah_teruah (Architecture §3.12)" do signal = VavPipeline.Chatzotzrot.signal(:breaking_camp) assert signal.signal_type == :breaking_camp - assert signal.pattern == :teruah + assert signal.pattern == :tekiah_teruah assert signal.details.urgency == :immediate end - test "assembly signal" do + test "assembly_all signal — tekiah (Architecture §3.12)" do signal = VavPipeline.Chatzotzrot.signal(:assembly_all) assert signal.signal_type == :assembly_all + assert signal.pattern == :tekiah + end + + test "assembly_leaders signal — teruah (Architecture §3.12)" do + signal = VavPipeline.Chatzotzrot.signal(:assembly_leaders) + assert signal.signal_type == :assembly_leaders + assert signal.pattern == :teruah + end + + test "moadim signal — tekiah_tekiah (Architecture §3.12)" do + signal = VavPipeline.Chatzotzrot.signal(:moadim) assert signal.pattern == :tekiah_tekiah end - test "moadim signal" do - signal = VavPipeline.Chatzotzrot.signal(:moadim) - assert signal.pattern == :tekiah_teruah + test "war signal — teruah_teruah (Architecture §3.12)" do + signal = VavPipeline.Chatzotzrot.signal(:war) + assert signal.pattern == :teruah_teruah end end