peah: hear the verse the question is standing in (kohen Day 7, tablet 30)
Every Hebrew question was answered against Bereishit 1:1 — extract_scope_from_question/1 ignored its argument. ויאמר אלהים יהי אור (1:3) and ויהי ערב ויהי בקר (1:5) came back sourced from 1:1 with roots "not in this verse". The grounding check passed by construction. - L1Index: inverted index consonants → verses over l1/torah, built in handle_continue right after start (5853 verses, ~1 s), read-only after. - ScopeResolver: explicit reference (bereishit/Genesis/בראשית 1:3) first; then quoted Torah — most question words in a verse, ties to the verse where they stand together in order, then Torah order; else default. A reference to a verse L1 lacks is a 422 KIYOR_VALIDATION_FAILED with the correction, not a silent 1:1. - Router: processing.scope reports verse + resolved_by; a default scope carries a note saying the verse was assumed, not heard. - test/tablet30_scope_test.exs: the five Day-7 offerings through the real router land on 1:1, 1:1, 1:3, 1:5, 1:1; Shema → Devarim 6:4; reference, invalid reference, default confession. 14 tests. - KOHEN.md Day 7: what to expect in sources[0].ref. 454 tests, 0 failures on seeds 0 1 2 3 42 99. Seed 191243 fails LivenessTest before and after this change — pre-existing, not touched. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
d6d4475da3
commit
c795225709
6 files changed
+620
-10
No files matched your search
@@ -405,6 +405,11 @@ curl http://<host>/kodesh/health | jq '.checks'
|
||||
# Architecture Step 22 it is answered with 422 TEIKU_NO_ROOTS and does
|
||||
# NOT write L3 — by design (BUILD_KORBAN.md:123). The L3 count below is
|
||||
# expected to rise by the number of Hebrew questions, not by ten.
|
||||
#
|
||||
# Tablet 30 (Day 7, 2026-10-04): the verse in the answer must be the
|
||||
# verse in the question. Expect sources[0].ref = Bereishit 1:1, 1:1,
|
||||
# 1:3, 1:5, 1:1 in that order, and processing.scope.resolved_by =
|
||||
# "text_match". A "default" there means the question was not heard.
|
||||
for q in "בראשית ברא אלהים" "את השמים ואת הארץ" "ויאמר אלהים יהי אור" "ויהי ערב ויהי בקר" "ברא אלהים"; do
|
||||
curl -s -X POST http://<host>/peah/ask \
|
||||
-H 'Content-Type: application/json' \
|
||||
|
||||
@@ -41,7 +41,9 @@ defmodule VavPipeline.Application do
|
||||
{VavPipeline.UrimVTumim, []},
|
||||
{VavPipeline.ShabbatTimer, []},
|
||||
{VavPipeline.VoiceMonitor, []},
|
||||
{VavPipeline.MachtzitHaShekel, []}
|
||||
{VavPipeline.MachtzitHaShekel, []},
|
||||
# Tablet 30 — the index that lets a question name its own verse
|
||||
{VavPipeline.L1Index, []}
|
||||
]
|
||||
|> maybe_add_http_server()
|
||||
|> maybe_add_repo()
|
||||
|
||||
@@ -314,15 +314,33 @@ defmodule VavPipeline.BigdeiKodesh.Router do
|
||||
})
|
||||
|
||||
:available ->
|
||||
# Pe'ah uses minchah protocol — simplest offering
|
||||
scope = extract_scope_from_question(question)
|
||||
# Pe'ah uses minchah protocol — simplest offering.
|
||||
# The verse is heard from the question (tablet 30), not assumed.
|
||||
case VavPipeline.ScopeResolver.resolve(question) do
|
||||
{:error, {:invalid_reference, ref}} ->
|
||||
send_error(conn, 422, "KIYOR_VALIDATION_FAILED", %{
|
||||
issue: "INVALID_SCOPE: #{ref} is not a verse in L1",
|
||||
correction:
|
||||
"Name a verse that exists — chapter and verse within the five books " <>
|
||||
"(for example \"bereishit 1:1\" or \"devarim 6:4\").",
|
||||
resubmit: true
|
||||
})
|
||||
|
||||
{:ok, scope, resolved_by} ->
|
||||
process_peah(conn, question, scope, resolved_by)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp process_peah(conn, question, scope, resolved_by) do
|
||||
input = %{
|
||||
source: "peah",
|
||||
verse_scope: scope,
|
||||
content: question
|
||||
}
|
||||
|
||||
scope_report = scope_report(scope, resolved_by)
|
||||
|
||||
case VavPipeline.process(input) do
|
||||
{:ok, data} ->
|
||||
send_json(conn, 200, %{
|
||||
@@ -331,7 +349,8 @@ defmodule VavPipeline.BigdeiKodesh.Router do
|
||||
processing: %{
|
||||
roots_consulted: Map.get(data, :roots_activated, []) |> format_roots(),
|
||||
deliberation: "none",
|
||||
cycle_phase: "voker"
|
||||
cycle_phase: "voker",
|
||||
scope: scope_report
|
||||
},
|
||||
disclosure: disclosure()
|
||||
})
|
||||
@@ -343,7 +362,8 @@ defmodule VavPipeline.BigdeiKodesh.Router do
|
||||
processing: %{
|
||||
roots_consulted: Map.get(data, :roots_activated, []) |> format_roots(),
|
||||
deliberation: "single-judge",
|
||||
cycle_phase: "voker"
|
||||
cycle_phase: "voker",
|
||||
scope: scope_report
|
||||
},
|
||||
note: "This question produced an unresolved result — teiku.",
|
||||
disclosure: disclosure()
|
||||
@@ -381,7 +401,6 @@ defmodule VavPipeline.BigdeiKodesh.Router do
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================
|
||||
# Internal — Courtyard processing
|
||||
@@ -808,10 +827,21 @@ defmodule VavPipeline.BigdeiKodesh.Router do
|
||||
defp format_since(nil), do: "all_time"
|
||||
defp format_since(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
|
||||
|
||||
defp extract_scope_from_question(_question) do
|
||||
# Default scope for pe'ah — Bereishit 1:1
|
||||
# In production, this would parse the question for verse references
|
||||
%{book: "bereishit", chapter: 1, verse: 1}
|
||||
# How the verse was chosen, said out loud in the response. A default
|
||||
# scope is a confession, not a grounding (tablet 30).
|
||||
defp scope_report(scope, :default) do
|
||||
%{
|
||||
verse: scope_verse(scope),
|
||||
resolved_by: "default",
|
||||
note:
|
||||
"The question named no verse and quoted no Torah the index could find, " <>
|
||||
"so the default scope was used. Roots are read against it, not against " <>
|
||||
"the question's source."
|
||||
}
|
||||
end
|
||||
|
||||
defp scope_report(scope, resolved_by) do
|
||||
%{verse: scope_verse(scope), resolved_by: to_string(resolved_by)}
|
||||
end
|
||||
|
||||
defp parse_scope(nil), do: %{book: "bereishit", chapter: 1, verse: 1}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
defmodule VavPipeline.L1Index do
|
||||
@moduledoc """
|
||||
Inverted index over the edut: consonantal word → the verses that carry it.
|
||||
|
||||
*V'darashta v'chakarta v'sha'alta heitev* — you shall inquire, search and
|
||||
ask diligently (Devarim 13:15). A question that quotes Torah names its
|
||||
own verse; this index is how the pipeline hears which one.
|
||||
|
||||
Built once from `l1/torah/<book>/<chapter>/<verse>.json` right after the
|
||||
process starts (`handle_continue`), so the first pe'ah request does not
|
||||
pay for it. Read-only after that — L1 is engraved and so is the index.
|
||||
Callers block until the build finishes; no call ever sees a half-built
|
||||
index.
|
||||
|
||||
Verse ids are `{book_order, chapter, verse}` so that `Enum.sort/1` is
|
||||
Torah order.
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
alias VavPipeline.L1Reader
|
||||
|
||||
@books ~w(bereishit shemot vayikra bamidbar devarim)
|
||||
@book_order @books |> Enum.with_index() |> Map.new()
|
||||
|
||||
@type verse_id :: {non_neg_integer(), pos_integer(), pos_integer()}
|
||||
|
||||
def start_link(opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, __MODULE__))
|
||||
end
|
||||
|
||||
@doc "Canonical book order, as the index sorts it."
|
||||
@spec books() :: [String.t()]
|
||||
def books, do: @books
|
||||
|
||||
@doc "The verses that contain this consonantal word. Empty list if none."
|
||||
@spec lookup(String.t(), GenServer.server()) :: [verse_id()]
|
||||
def lookup(consonants, server \\ __MODULE__) do
|
||||
GenServer.call(server, {:lookup, consonants}, 60_000)
|
||||
end
|
||||
|
||||
@doc "`{verses_indexed, words_indexed}` — for health and for tests."
|
||||
@spec stats(GenServer.server()) :: {non_neg_integer(), non_neg_integer()}
|
||||
def stats(server \\ __MODULE__), do: GenServer.call(server, :stats, 60_000)
|
||||
|
||||
@doc "Turn an index verse id back into a scope map."
|
||||
@spec to_scope(verse_id()) :: %{book: String.t(), chapter: pos_integer(), verse: pos_integer()}
|
||||
def to_scope({order, chapter, verse}) do
|
||||
%{book: Enum.at(@books, order), chapter: chapter, verse: verse}
|
||||
end
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# GenServer
|
||||
# ------------------------------------------------------------
|
||||
|
||||
@impl true
|
||||
def init(opts) do
|
||||
root = Keyword.get(opts, :l1_root, L1Reader.l1_root())
|
||||
{:ok, %{root: root, words: %{}, verse_count: 0}, {:continue, :build}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_continue(:build, state) do
|
||||
{time_us, built} = :timer.tc(fn -> build(state.root) end)
|
||||
|
||||
case built do
|
||||
{:ok, words, verse_count} ->
|
||||
Logger.info(
|
||||
"L1Index: #{verse_count} verses, #{map_size(words)} distinct words, " <>
|
||||
"#{div(time_us, 1000)}ms"
|
||||
)
|
||||
|
||||
{:noreply, %{state | words: words, verse_count: verse_count}}
|
||||
|
||||
{:error, reason} ->
|
||||
# No L1 → no index. Resolution falls back to the default scope and
|
||||
# says so; the health check already reports l1_hash separately.
|
||||
Logger.warning("L1Index: not built — #{reason}")
|
||||
{:noreply, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:lookup, consonants}, _from, state) do
|
||||
{:reply, Map.get(state.words, consonants, []), state}
|
||||
end
|
||||
|
||||
def handle_call(:stats, _from, state) do
|
||||
{:reply, {state.verse_count, map_size(state.words)}, state}
|
||||
end
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Build
|
||||
# ------------------------------------------------------------
|
||||
|
||||
defp build(root) do
|
||||
torah = Path.join(root, "torah")
|
||||
|
||||
if File.dir?(torah) do
|
||||
{words, count} =
|
||||
Enum.reduce(@books, {%{}, 0}, fn book, acc ->
|
||||
index_book(Path.join(torah, book), @book_order[book], acc)
|
||||
end)
|
||||
|
||||
if count == 0, do: {:error, "no verses under #{torah}"}, else: {:ok, words, count}
|
||||
else
|
||||
{:error, "#{torah} is not a directory"}
|
||||
end
|
||||
end
|
||||
|
||||
defp index_book(book_dir, order, acc) do
|
||||
book_dir
|
||||
|> list_numeric()
|
||||
|> Enum.reduce(acc, fn chapter, acc ->
|
||||
chapter_dir = Path.join(book_dir, to_string(chapter))
|
||||
|
||||
chapter_dir
|
||||
|> list_numeric(".json")
|
||||
|> Enum.reduce(acc, fn verse, {words, count} ->
|
||||
case read_consonants(Path.join(chapter_dir, "#{verse}.json")) do
|
||||
{:ok, consonant_words} ->
|
||||
id = {order, chapter, verse}
|
||||
|
||||
words =
|
||||
consonant_words
|
||||
|> Enum.uniq()
|
||||
|> Enum.reduce(words, fn w, words -> Map.update(words, w, [id], &[id | &1]) end)
|
||||
|
||||
{words, count + 1}
|
||||
|
||||
:error ->
|
||||
{words, count}
|
||||
end
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
# Directory entries that are integers (optionally with a suffix), ascending.
|
||||
defp list_numeric(dir, suffix \\ "") do
|
||||
case File.ls(dir) do
|
||||
{:ok, entries} ->
|
||||
entries
|
||||
|> Enum.flat_map(fn e ->
|
||||
case Integer.parse(String.replace_suffix(e, suffix, "")) do
|
||||
{n, ""} -> [n]
|
||||
_ -> []
|
||||
end
|
||||
end)
|
||||
|> Enum.sort()
|
||||
|
||||
{:error, _} ->
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
defp read_consonants(path) do
|
||||
with {:ok, contents} <- File.read(path),
|
||||
{:ok, %{"words" => words}} when is_list(words) <- Jason.decode(contents) do
|
||||
{:ok, Enum.flat_map(words, &word_consonants/1)}
|
||||
else
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
# A word joined by maqaf is indexed as each half, so that a question
|
||||
# written with spaces still finds it.
|
||||
defp word_consonants(%{"consonants" => c}) when is_binary(c),
|
||||
do: String.split(c, "־", trim: true)
|
||||
|
||||
defp word_consonants(_), do: []
|
||||
end
|
||||
@@ -0,0 +1,182 @@
|
||||
defmodule VavPipeline.ScopeResolver do
|
||||
@moduledoc """
|
||||
Hear which verse a question is standing in.
|
||||
|
||||
Broken tablet 30 (2026-10-04): for seven months `/peah/ask` answered every
|
||||
question against Bereishit 1:1 — `extract_scope_from_question/1` ignored
|
||||
its argument. The grounding check passed by construction. *Lo tov.*
|
||||
|
||||
Three ways a question names its verse, tried in order:
|
||||
|
||||
1. **An explicit reference** — `bereishit 1:3`, `Genesis 1:3`, `בראשית 1:3`.
|
||||
If it names a verse L1 does not have, that is a shogeg error:
|
||||
`{:error, {:invalid_reference, "bereishit 99:1"}}`, corrected without
|
||||
penalty by the router.
|
||||
2. **Quoted Torah** — the question's Hebrew words are looked up in
|
||||
`VavPipeline.L1Index`. The verse carrying the most of them wins; ties go
|
||||
to the verse where they stand together in order, then to Torah order.
|
||||
Deterministic, and the choice is disclosed in the response.
|
||||
3. **Nothing** — no reference, no Hebrew the Torah contains. The default
|
||||
scope (Bereishit 1:1) is used and the response says `resolved_by:
|
||||
"default"`, so a grounded-by-construction answer can no longer pass as
|
||||
a grounded one.
|
||||
"""
|
||||
|
||||
alias VavPipeline.{L1Index, L1Reader}
|
||||
|
||||
@default %{book: "bereishit", chapter: 1, verse: 1}
|
||||
|
||||
@book_names %{
|
||||
"bereishit" => "bereishit", "genesis" => "bereishit", "בראשית" => "bereishit",
|
||||
"shemot" => "shemot", "exodus" => "shemot", "שמות" => "shemot",
|
||||
"vayikra" => "vayikra", "leviticus" => "vayikra", "ויקרא" => "vayikra",
|
||||
"bamidbar" => "bamidbar", "numbers" => "bamidbar", "במדבר" => "bamidbar",
|
||||
"devarim" => "devarim", "deuteronomy" => "devarim", "דברים" => "devarim"
|
||||
}
|
||||
|
||||
@reference ~r/(?<book>#{Enum.join(Map.keys(@book_names), "|")})\s+(?<chapter>\d{1,3})\s*[:.]\s*(?<verse>\d{1,3})/iu
|
||||
|
||||
# Nikud, te'amim and the other combining marks of the Hebrew block.
|
||||
@marks ~r/[\x{0591}-\x{05C7}]/u
|
||||
@hebrew_word ~r/[\x{05D0}-\x{05EA}]+/u
|
||||
@maqaf "־"
|
||||
|
||||
# How many tied candidates we are willing to open from disk to break a tie.
|
||||
@tie_limit 25
|
||||
|
||||
@type scope :: %{book: String.t(), chapter: pos_integer(), verse: pos_integer()}
|
||||
@type resolution :: :verse_reference | :text_match | :default
|
||||
|
||||
@doc """
|
||||
`{:ok, scope, resolution}` or `{:error, {:invalid_reference, text}}`.
|
||||
"""
|
||||
@spec resolve(String.t()) :: {:ok, scope(), resolution()} | {:error, {:invalid_reference, String.t()}}
|
||||
def resolve(question) when is_binary(question) do
|
||||
case explicit_reference(question) do
|
||||
{:ok, scope} ->
|
||||
{:ok, scope, :verse_reference}
|
||||
|
||||
{:error, _} = error ->
|
||||
error
|
||||
|
||||
:none ->
|
||||
case text_match(question) do
|
||||
{:ok, scope} -> {:ok, scope, :text_match}
|
||||
:none -> {:ok, @default, :default}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc "The scope used when a question names none."
|
||||
@spec default() :: scope()
|
||||
def default, do: @default
|
||||
|
||||
@doc """
|
||||
The question's Hebrew words as bare consonants, in order — what L1 stores
|
||||
under `consonants`. Marks stripped, maqaf split, everything else dropped.
|
||||
"""
|
||||
@spec hebrew_tokens(String.t()) :: [String.t()]
|
||||
def hebrew_tokens(question) do
|
||||
# Maqaf first: it sits inside the combining-mark range and would vanish.
|
||||
question
|
||||
|> String.replace(@maqaf, " ")
|
||||
|> String.replace(@marks, "")
|
||||
|> then(&Regex.scan(@hebrew_word, &1))
|
||||
|> List.flatten()
|
||||
end
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 1. Explicit reference
|
||||
# ------------------------------------------------------------
|
||||
|
||||
defp explicit_reference(question) do
|
||||
case Regex.named_captures(@reference, question) do
|
||||
nil ->
|
||||
:none
|
||||
|
||||
%{"book" => book, "chapter" => chapter, "verse" => verse} ->
|
||||
scope = %{
|
||||
book: @book_names[String.downcase(book)],
|
||||
chapter: String.to_integer(chapter),
|
||||
verse: String.to_integer(verse)
|
||||
}
|
||||
|
||||
case L1Reader.read_verse(scope.book, scope.chapter, scope.verse) do
|
||||
{:ok, _} -> {:ok, scope}
|
||||
{:error, _} -> {:error, {:invalid_reference, "#{scope.book} #{chapter}:#{verse}"}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 2. Quoted Torah
|
||||
# ------------------------------------------------------------
|
||||
|
||||
defp text_match(question) do
|
||||
tokens = question |> hebrew_tokens() |> Enum.uniq()
|
||||
|
||||
if tokens == [] or not index_up?() do
|
||||
:none
|
||||
else
|
||||
tokens
|
||||
|> Enum.flat_map(fn t -> t |> L1Index.lookup() |> Enum.map(&{&1, t}) end)
|
||||
|> Enum.group_by(fn {id, _} -> id end, fn {_, t} -> t end)
|
||||
|> Enum.map(fn {id, found} -> {id, length(Enum.uniq(found))} end)
|
||||
|> best_by_count()
|
||||
|> break_ties(tokens)
|
||||
end
|
||||
end
|
||||
|
||||
defp best_by_count([]), do: []
|
||||
|
||||
defp best_by_count(scored) do
|
||||
max = scored |> Enum.map(&elem(&1, 1)) |> Enum.max()
|
||||
scored |> Enum.filter(fn {_, n} -> n == max end) |> Enum.map(&elem(&1, 0)) |> Enum.sort()
|
||||
end
|
||||
|
||||
defp break_ties([], _tokens), do: :none
|
||||
defp break_ties([only], _tokens), do: {:ok, L1Index.to_scope(only)}
|
||||
|
||||
defp break_ties(tied, tokens) do
|
||||
# Longest run of the question's words standing together, in order,
|
||||
# wins; `Enum.max_by` keeps the first (earliest in Torah) on a tie.
|
||||
best =
|
||||
tied
|
||||
|> Enum.take(@tie_limit)
|
||||
|> Enum.max_by(fn id -> id |> verse_consonants() |> longest_run(tokens) end)
|
||||
|
||||
{:ok, L1Index.to_scope(best)}
|
||||
end
|
||||
|
||||
defp verse_consonants(id) do
|
||||
%{book: b, chapter: c, verse: v} = L1Index.to_scope(id)
|
||||
|
||||
case L1Reader.read_verse(b, c, v) do
|
||||
{:ok, %{words: words}} ->
|
||||
Enum.flat_map(words, fn w -> String.split(w.consonants || "", @maqaf, trim: true) end)
|
||||
|
||||
_ ->
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
# Longest contiguous stretch of `verse_words` that is also a contiguous,
|
||||
# in-order stretch of `tokens`.
|
||||
defp longest_run(verse_words, tokens) do
|
||||
for i <- 0..max(length(verse_words) - 1, 0),
|
||||
j <- 0..max(length(tokens) - 1, 0),
|
||||
reduce: 0 do
|
||||
acc -> max(acc, run_from(Enum.drop(verse_words, i), Enum.drop(tokens, j)))
|
||||
end
|
||||
end
|
||||
|
||||
defp run_from([w | vs], [w | ts]), do: 1 + run_from(vs, ts)
|
||||
defp run_from(_, _), do: 0
|
||||
|
||||
defp index_up? do
|
||||
case Process.whereis(L1Index) do
|
||||
nil -> false
|
||||
_pid -> match?({n, _} when n > 0, L1Index.stats())
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,219 @@
|
||||
defmodule VavPipeline.Tablet30ScopeTest do
|
||||
@moduledoc """
|
||||
Tablet 30 (kohen Day 7, 2026-10-04) — every Hebrew question was answered
|
||||
against Bereishit 1:1. `ויאמר אלהים יהי אור` (1:3) and `ויהי ערב ויהי בקר`
|
||||
(1:5) came back with `sources: [Bereishit 1:1]` and roots marked "not in
|
||||
this verse". `extract_scope_from_question/1` ignored its argument.
|
||||
|
||||
These tests send the Day-7 offerings through the real router and demand
|
||||
that the verse in the answer is the verse in the question.
|
||||
"""
|
||||
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
import Plug.Test
|
||||
import Plug.Conn
|
||||
|
||||
alias VavPipeline.{HaltMonitor, L1Index, MannaLedger, ScopeResolver}
|
||||
alias VavPipeline.BigdeiKodesh.Router
|
||||
|
||||
@opts Router.init([])
|
||||
|
||||
setup_all do
|
||||
# The index builds in handle_continue; the first call waits for it.
|
||||
{verses, words} = L1Index.stats()
|
||||
assert verses == 5853, "L1Index built #{verses} verses, expected the WLC 5853"
|
||||
assert words > 10_000
|
||||
:ok
|
||||
end
|
||||
|
||||
# ============================================================
|
||||
# ScopeResolver — the three ways a question names its verse
|
||||
# ============================================================
|
||||
describe "hebrew_tokens/1" do
|
||||
test "strips nikud and te'amim, splits maqaf, drops everything else" do
|
||||
assert ScopeResolver.hebrew_tokens("וַֽיְהִי־אֽוֹר ׃") == ["ויהי", "אור"]
|
||||
assert ScopeResolver.hebrew_tokens("What does בְּרֵאשִׁ֖ית mean?") == ["בראשית"]
|
||||
assert ScopeResolver.hebrew_tokens("Test query 3") == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "resolve/1 — quoted Torah" do
|
||||
test "the Day-7 offerings land on their own verses" do
|
||||
assert {:ok, %{book: "bereishit", chapter: 1, verse: 1}, :text_match} =
|
||||
ScopeResolver.resolve("בראשית ברא אלהים")
|
||||
|
||||
assert {:ok, %{book: "bereishit", chapter: 1, verse: 1}, :text_match} =
|
||||
ScopeResolver.resolve("את השמים ואת הארץ")
|
||||
|
||||
assert {:ok, %{book: "bereishit", chapter: 1, verse: 3}, :text_match} =
|
||||
ScopeResolver.resolve("ויאמר אלהים יהי אור")
|
||||
|
||||
assert {:ok, %{book: "bereishit", chapter: 1, verse: 5}, :text_match} =
|
||||
ScopeResolver.resolve("ויהי ערב ויהי בקר")
|
||||
|
||||
assert {:ok, %{book: "bereishit", chapter: 1, verse: 1}, :text_match} =
|
||||
ScopeResolver.resolve("ברא אלהים")
|
||||
end
|
||||
|
||||
test "pointed text resolves the same as bare consonants" do
|
||||
assert {:ok, %{book: "bereishit", chapter: 1, verse: 3}, :text_match} =
|
||||
ScopeResolver.resolve("וַיֹּ֥אמֶר אֱלֹהִ֖ים יְהִ֣י א֑וֹר")
|
||||
end
|
||||
|
||||
test "reaches past Bereishit — the Shema" do
|
||||
assert {:ok, %{book: "devarim", chapter: 6, verse: 4}, :text_match} =
|
||||
ScopeResolver.resolve("שמע ישראל יהוה אלהינו יהוה אחד")
|
||||
end
|
||||
|
||||
test "a phrase found in several verses goes to the earliest in Torah order" do
|
||||
# שמע ישראל opens Devarim 5:1 before it opens 6:4.
|
||||
assert {:ok, %{book: "devarim", chapter: 5, verse: 1}, :text_match} =
|
||||
ScopeResolver.resolve("שמע ישראל")
|
||||
end
|
||||
|
||||
test "Hebrew the Torah does not contain falls to the default, and says so" do
|
||||
assert {:ok, %{book: "bereishit", chapter: 1, verse: 1}, :default} =
|
||||
ScopeResolver.resolve("מחשב")
|
||||
end
|
||||
end
|
||||
|
||||
describe "resolve/1 — explicit reference" do
|
||||
test "transliterated, English and Hebrew book names, colon or dot" do
|
||||
assert {:ok, %{book: "bereishit", chapter: 1, verse: 3}, :verse_reference} =
|
||||
ScopeResolver.resolve("What happens in bereishit 1:3?")
|
||||
|
||||
assert {:ok, %{book: "shemot", chapter: 3, verse: 14}, :verse_reference} =
|
||||
ScopeResolver.resolve("Exodus 3.14")
|
||||
|
||||
assert {:ok, %{book: "devarim", chapter: 6, verse: 4}, :verse_reference} =
|
||||
ScopeResolver.resolve("דברים 6:4")
|
||||
end
|
||||
|
||||
test "a reference outranks quoted text" do
|
||||
assert {:ok, %{book: "devarim", chapter: 6, verse: 4}, :verse_reference} =
|
||||
ScopeResolver.resolve("devarim 6:4 — בראשית ברא אלהים")
|
||||
end
|
||||
|
||||
test "a verse L1 does not have is a shogeg error, not a silent default" do
|
||||
assert {:error, {:invalid_reference, "devarim 99:1"}} =
|
||||
ScopeResolver.resolve("devarim 99:1")
|
||||
end
|
||||
end
|
||||
|
||||
describe "resolve/1 — nothing to go on" do
|
||||
test "English defaults, and the resolution is :default" do
|
||||
assert {:ok, %{book: "bereishit", chapter: 1, verse: 1}, :default} =
|
||||
ScopeResolver.resolve("What is bereishit?")
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================
|
||||
# Router — the verse in the answer is the verse in the question
|
||||
# ============================================================
|
||||
describe "POST /peah/ask" do
|
||||
setup do
|
||||
# halt_monitor_test leaves the monitor halted on some seeds; a halted
|
||||
# vessel answers 503 before the resolver is reached. Clear it, as
|
||||
# liveness_test does, so this file measures scope and nothing else.
|
||||
if HaltMonitor.halted?(), do: HaltMonitor.resolve("tablet30 test setup")
|
||||
write_provision(50)
|
||||
:ok
|
||||
end
|
||||
|
||||
test "Day-7 Hebrew offerings are sourced from their own verses" do
|
||||
for {q, ref} <- [
|
||||
{"ויאמר אלהים יהי אור", "Bereishit 1:3"},
|
||||
{"ויהי ערב ויהי בקר", "Bereishit 1:5"},
|
||||
{"בראשית ברא אלהים", "Bereishit 1:1"}
|
||||
] do
|
||||
conn = post_ask(q)
|
||||
body = Jason.decode!(conn.resp_body)
|
||||
|
||||
assert conn.status == 200, "#{q}: #{conn.status} #{conn.resp_body}"
|
||||
assert [%{"ref" => ^ref} | _] = body["sources"], "#{q} sourced from #{inspect(body["sources"])}"
|
||||
assert body["processing"]["scope"]["resolved_by"] == "text_match"
|
||||
assert body["processing"]["scope"]["verse"]["ref"] == ref
|
||||
refute Map.has_key?(body["processing"]["scope"], "note")
|
||||
end
|
||||
end
|
||||
|
||||
test "an explicit reference is honoured — the error text promised it" do
|
||||
conn = post_ask("bereishit 1:3")
|
||||
body = Jason.decode!(conn.resp_body)
|
||||
|
||||
assert conn.status == 200
|
||||
assert [%{"ref" => "Bereishit 1:3"}] = body["sources"]
|
||||
assert body["processing"]["scope"]["resolved_by"] == "verse_reference"
|
||||
end
|
||||
|
||||
test "a verse that does not exist is a 422 chatat, with the correction" do
|
||||
conn = post_ask("bereishit 99:1")
|
||||
body = Jason.decode!(conn.resp_body)
|
||||
|
||||
assert conn.status == 422
|
||||
assert body["error"]["code"] == "KIYOR_VALIDATION_FAILED"
|
||||
assert body["error"]["guidance"]["issue"] =~ "INVALID_SCOPE"
|
||||
assert body["error"]["guidance"]["resubmit"] == true
|
||||
end
|
||||
|
||||
test "a question with no verse in it confesses the default scope" do
|
||||
# In the test VM there is no Haskell Port, so the L1 fallback grounds
|
||||
# English against the default verse and answers 200 (day6_serving_test
|
||||
# holds the real-engine 422 shut). Whatever the status, the scope
|
||||
# report must say the verse was assumed, not heard.
|
||||
conn = post_ask("What is bereishit?")
|
||||
body = Jason.decode!(conn.resp_body)
|
||||
|
||||
scope =
|
||||
case conn.status do
|
||||
200 -> body["processing"]["scope"]
|
||||
422 -> %{"resolved_by" => "default", "note" => "n/a", "verse" => body["error"]["guidance"]["scope_verse"]}
|
||||
end
|
||||
|
||||
assert scope["resolved_by"] == "default"
|
||||
assert scope["verse"]["ref"] == "Bereishit 1:1"
|
||||
assert is_binary(scope["note"])
|
||||
end
|
||||
end
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Helpers (shape from day6_serving_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" => "tablet30_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))
|
||||
# Leave no provision behind: the Shema tests run at a fixed 2026-02-25
|
||||
# and read a dated file as MANNA_STALE.
|
||||
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
|
||||
Reference in new issue
Block a user