From a48c22c5a36c3f0835cf7eca321d6e2e4bd0797f Mon Sep 17 00:00:00 2001 From: Scott Karp Date: Sun, 4 Oct 2026 16:52:11 +0000 Subject: [PATCH] cycle: a named verse is read from the verse, not from the reference (tablet 32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `devarim 6:5` on the live pod: 422 TEIKU_NO_ROOTS. Tablet 30 heard the scope, but the Shoresh Engine activates roots from the text it is handed, vayomer handed it the question, and a bare reference carries no Hebrew. The 422's own guidance said "name a verse". tablet30_scope_test asserted an explicit reference was honoured and passed — on the L1 fallback, which reads the scoped verse and never looks at the question. The Day-6 pattern, by the builder who named it. - BereishitCycle.engine_text/2: when the scope came from an explicit reference AND the question has no Hebrew letter, the engine is handed the verse's own consonantal words. Default scope earns no such reading (English stays a teiku); quoted Torah and reference+Hebrew are read from the asker's words, as before. - input.scope_resolution carries how the scope was chosen into tohu. - processing.scope.roots_read_from: "verse" | "question". - test/tablet32_reference_test.exs: an engine-shaped spy under the REAL name VavPipeline.ShoreshEnginePort reports the text it was handed. 5 tests; 4 fail without the fix. 459 tests, 0 failures on seeds 0 1 7 42 99. Co-Authored-By: Claude Fable 5.1 --- .../lib/vav_pipeline/bereishit_cycle.ex | 37 +++- .../lib/vav_pipeline/bigdei_kodesh/router.ex | 10 +- vav_pipeline/test/tablet32_reference_test.exs | 186 ++++++++++++++++++ 3 files changed, 229 insertions(+), 4 deletions(-) create mode 100644 vav_pipeline/test/tablet32_reference_test.exs 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