diff --git a/vav_pipeline/lib/vav_pipeline/bereishit_cycle.ex b/vav_pipeline/lib/vav_pipeline/bereishit_cycle.ex index 12c12ecc..8e51ccf0 100644 --- a/vav_pipeline/lib/vav_pipeline/bereishit_cycle.ex +++ b/vav_pipeline/lib/vav_pipeline/bereishit_cycle.ex @@ -114,6 +114,9 @@ defmodule VavPipeline.BereishitCycle do raw: extract_content(input), provenance: Map.get(input, :source, "unknown"), scope: Map.get(input, :verse_scope, %{}), + # How the scope was chosen (:verse_reference | :text_match | :default), + # when the caller knows. vayomer needs it: see engine_text/2. + scope_resolution: Map.get(input, :scope_resolution), case_ref: generate_case_ref(), # A tamid probe (tamid §2.1 check 7) runs the real cycle but must # not consume the day's manna or append to the oral tradition. @@ -137,14 +140,17 @@ defmodule VavPipeline.BereishitCycle do """ @spec vayomer(map(), VavPipeline.Shema.shema_context()) :: map() def vayomer(state, context) do - case ShoreshEngineClient.process(state.raw, context) do + {text, read_from} = engine_text(state, context) + state = Map.put(state, :roots_read_from, read_from) + + case ShoreshEngineClient.process(text, context) do {:ok, engine_result} -> raw_roots = get_result(engine_result, :roots_activated, "roots_activated", []) if raw_roots == [] do Logger.info( "BereishitCycle.vayomer: Shoresh Engine activated no roots from " <> - "#{inspect(String.slice(state.raw, 0, 60))} — the input carries no " <> + "#{inspect(String.slice(text, 0, 60))} — the input carries no " <> "Hebrew root the engine can read; ki_tov will not call this tov" ) end @@ -795,6 +801,31 @@ defmodule VavPipeline.BereishitCycle do end end + # What the engine is given to read. + # + # Tablet 32 (2026-10-04): the engine activates roots from the TEXT it is + # handed, and it was always handed the question. "devarim 6:5" names a + # verse and carries no Hebrew, so on the pod it activated nothing and + # the ger got TEIKU_NO_ROOTS — under an error message that told them to + # "name a verse". The L1 fallback hid this in every test, because the + # fallback reads the scoped verse and never looks at the question. + # + # When the asker NAMED the verse and quoted none of it, the verse is + # what they are asking about: the engine reads the verse's own words. + # A default scope earns no such reading — nothing was named, and an + # English question against an assumed verse stays a teiku. + @hebrew_letter ~r/[\x{05D0}-\x{05EA}]/u + + defp engine_text(%{scope_resolution: :verse_reference, raw: raw} = _state, context) do + if String.match?(raw, @hebrew_letter) do + {raw, :question} + else + {Enum.map_join(context.verse.words, " ", & &1.consonants), :verse} + end + end + + defp engine_text(state, _context), do: {state.raw, :question} + # --- L1 Fallback (when port is unavailable) --- defp vayomer_fallback(state, context) do @@ -814,6 +845,8 @@ defmodule VavPipeline.BereishitCycle do Map.merge(state, %{ roots_activated: roots, + # The fallback has only ever read the scoped verse — say so. + roots_read_from: :verse, teamim_parse: context.teamim_marks, ketiv_qere: context.ketiv_qere, root_source: :l1, diff --git a/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex b/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex index 6a7f7e74..ecc5a8ab 100644 --- a/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex +++ b/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex @@ -336,6 +336,7 @@ defmodule VavPipeline.BigdeiKodesh.Router do input = %{ source: "peah", verse_scope: scope, + scope_resolution: resolved_by, content: question } @@ -350,7 +351,7 @@ defmodule VavPipeline.BigdeiKodesh.Router do roots_consulted: Map.get(data, :roots_activated, []) |> format_roots(), deliberation: "none", cycle_phase: "voker", - scope: scope_report + scope: Map.put(scope_report, :roots_read_from, roots_read_from(data)) }, disclosure: disclosure() }) @@ -363,7 +364,7 @@ defmodule VavPipeline.BigdeiKodesh.Router do roots_consulted: Map.get(data, :roots_activated, []) |> format_roots(), deliberation: "single-judge", cycle_phase: "voker", - scope: scope_report + scope: Map.put(scope_report, :roots_read_from, roots_read_from(data)) }, note: "This question produced an unresolved result — teiku.", disclosure: disclosure() @@ -844,6 +845,11 @@ defmodule VavPipeline.BigdeiKodesh.Router do %{verse: scope_verse(scope), resolved_by: to_string(resolved_by)} end + # "question" — the engine read the asker's own Hebrew. + # "verse" — the asker named a verse and quoted none of it, so the + # roots are the verse's own (tablet 32). + defp roots_read_from(data), do: data |> Map.get(:roots_read_from, :question) |> to_string() + defp parse_scope(nil), do: %{book: "bereishit", chapter: 1, verse: 1} defp parse_scope(%{"book" => book, "chapter" => ch, "verse" => v}) do diff --git a/vav_pipeline/test/tablet32_reference_test.exs b/vav_pipeline/test/tablet32_reference_test.exs new file mode 100644 index 00000000..a29981ad --- /dev/null +++ b/vav_pipeline/test/tablet32_reference_test.exs @@ -0,0 +1,186 @@ +defmodule VavPipeline.Tablet32ReferenceTest do + @moduledoc """ + Tablet 32 (2026-10-04) — `devarim 6:5` on the live pod: 422 + TEIKU_NO_ROOTS. The scope was heard (tablet 30), but the Shoresh Engine + activates roots from the text it is handed, it was handed the question, + and a bare reference has no Hebrew in it. + + `tablet30_scope_test` asserted "an explicit reference is honoured" and + passed — on the L1 fallback, which reads the scoped verse and never + looks at the question. The Day-6 pattern, again: green here, broken + where the engine is real. + + So this file does not run the fallback. Like `day6_serving_test`, it + registers a process under the REAL name `VavPipeline.ShoreshEnginePort` + that behaves the way the engine behaves — roots only from Hebrew it is + given — and it reports back exactly what text it was given. + """ + + use ExUnit.Case, async: false + + import Plug.Test + import Plug.Conn + + alias VavPipeline.{HaltMonitor, L1Reader, MannaLedger} + alias VavPipeline.BigdeiKodesh.Router + + @opts Router.init([]) + + # ============================================================ + # An engine-shaped stand-in that tells the test what it was handed + # ============================================================ + defmodule SpyPort do + use GenServer + + def start(test_pid), + do: GenServer.start(__MODULE__, test_pid, name: VavPipeline.ShoreshEnginePort) + + @impl true + def init(test_pid), do: {:ok, test_pid} + + @impl true + def handle_call({:request, json_line}, _from, test_pid) do + text = json_line |> Jason.decode!() |> get_in(["input", "text"]) || "" + send(test_pid, {:engine_received, text}) + + roots = + cond do + # Devarim 6:5 opens וְאָהַבְתָּ — the engine would find א-ה-ב. + String.contains?(text, "ואהבת") -> [root(["aleph", "he", "bet"], "אהב", 8)] + String.match?(text, ~r/[\x{05D0}-\x{05EA}]/u) -> [root(["bet", "resh", "aleph"], "ברא", 203)] + true -> [] + end + + {:reply, {:ok, %{"roots_activated" => roots, "parse_tree" => nil, "dual_readings" => []}}, + test_pid} + end + + # The engine's root shape as the pod delivers it: string keys, no `position`. + defp root(letters, hebrew, gematria) do + %{ + "shoresh" => letters, + "shoresh_hebrew" => hebrew, + "gematria_value" => gematria, + "binyan" => "Qal", + "domain" => "unknown", + "torah_refs" => [], + "neighbors" => [], + "gematria_neighbors" => [] + } + end + end + + setup do + if HaltMonitor.halted?(), do: HaltMonitor.resolve("tablet32 test setup") + write_provision(50) + + # This file is about the real-engine path. If a real engine is + # registered on this host there is nothing to stand in for. + assert Process.whereis(VavPipeline.ShoreshEnginePort) == nil, + "a real ShoreshEnginePort is registered; this test needs to spy on the port" + + {:ok, pid} = SpyPort.start(self()) + on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end) + :ok + end + + describe "a named verse, no Hebrew in the question" do + test "the engine is handed the verse's own words, and the ger is answered" do + conn = post_ask("devarim 6:5") + body = Jason.decode!(conn.resp_body) + + {:ok, verse} = L1Reader.read_verse("devarim", 6, 5) + verse_text = Enum.map_join(verse.words, " ", & &1.consonants) + + assert_received {:engine_received, ^verse_text} + assert verse_text =~ "ואהבת" + + assert conn.status == 200, "#{conn.status} #{conn.resp_body}" + assert [%{"ref" => "Devarim 6:5"}] = body["sources"] + assert body["processing"]["scope"]["resolved_by"] == "verse_reference" + assert body["processing"]["scope"]["roots_read_from"] == "verse" + assert body["processing"]["roots_consulted"] != [] + assert body["answer"] =~ "Devarim 6:5" + end + + test "English around the reference changes nothing" do + conn = post_ask("What does Deuteronomy 6:5 command?") + body = Jason.decode!(conn.resp_body) + + assert_received {:engine_received, text} + assert text =~ "ואהבת" + assert conn.status == 200 + assert body["processing"]["scope"]["roots_read_from"] == "verse" + end + end + + describe "what must NOT be handed the verse" do + test "no reference, no Hebrew: the default scope earns no reading — still a teiku" do + conn = post_ask("Test query 1") + body = Jason.decode!(conn.resp_body) + + assert_received {:engine_received, "Test query 1"} + assert conn.status == 422 + assert body["error"]["code"] == "TEIKU_NO_ROOTS" + end + + test "quoted Torah: the engine reads the asker's own words" do + conn = post_ask("ויאמר אלהים יהי אור") + body = Jason.decode!(conn.resp_body) + + assert_received {:engine_received, "ויאמר אלהים יהי אור"} + assert conn.status == 200 + assert body["processing"]["scope"]["resolved_by"] == "text_match" + assert body["processing"]["scope"]["roots_read_from"] == "question" + end + + test "a reference WITH Hebrew: the asker's Hebrew is what is read" do + q = "devarim 6:5 — ואהבת" + conn = post_ask(q) + body = Jason.decode!(conn.resp_body) + + assert_received {:engine_received, ^q} + assert conn.status == 200 + assert body["processing"]["scope"]["resolved_by"] == "verse_reference" + assert body["processing"]["scope"]["roots_read_from"] == "question" + end + end + + # ------------------------------------------------------------ + # Helpers (shape from day6_serving_test.exs / tablet30_scope_test.exs) + # ------------------------------------------------------------ + + defp write_provision(compute_units) do + MannaLedger.reset() + on_exit(fn -> MannaLedger.reset() end) + today = Date.utc_today() |> Date.to_iso8601() + expires = DateTime.utc_now() |> DateTime.add(6 * 3600) |> DateTime.to_iso8601() + + provision = %{ + "date" => today, + "provision" => %{ + "compute_units" => compute_units, + "memory_mb" => 512, + "api_calls_external" => 5, + "storage_delta_mb" => 2, + "sanhedrin_panels_max" => 2 + }, + "type" => "regular", + "expires_at" => expires, + "written_by" => "tablet32_test", + "written_at" => DateTime.utc_now() |> DateTime.to_iso8601() + } + + path = VavPipeline.Manna.provision_file() + File.mkdir_p!(Path.dirname(path)) + File.write!(path, Jason.encode!(provision)) + on_exit(fn -> File.rm(path) end) + path + end + + defp post_ask(question) do + conn(:post, "/peah/ask", Jason.encode!(%{question: question})) + |> put_req_header("content-type", "application/json") + |> Router.call(@opts) + end +end