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
+626
-16
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
|
# 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
|
# 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.
|
# 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
|
for q in "בראשית ברא אלהים" "את השמים ואת הארץ" "ויאמר אלהים יהי אור" "ויהי ערב ויהי בקר" "ברא אלהים"; do
|
||||||
curl -s -X POST http://<host>/peah/ask \
|
curl -s -X POST http://<host>/peah/ask \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ defmodule VavPipeline.Application do
|
|||||||
{VavPipeline.UrimVTumim, []},
|
{VavPipeline.UrimVTumim, []},
|
||||||
{VavPipeline.ShabbatTimer, []},
|
{VavPipeline.ShabbatTimer, []},
|
||||||
{VavPipeline.VoiceMonitor, []},
|
{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_http_server()
|
||||||
|> maybe_add_repo()
|
|> maybe_add_repo()
|
||||||
|
|||||||
@@ -314,16 +314,34 @@ defmodule VavPipeline.BigdeiKodesh.Router do
|
|||||||
})
|
})
|
||||||
|
|
||||||
:available ->
|
:available ->
|
||||||
# Pe'ah uses minchah protocol — simplest offering
|
# Pe'ah uses minchah protocol — simplest offering.
|
||||||
scope = extract_scope_from_question(question)
|
# 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
|
||||||
|
})
|
||||||
|
|
||||||
input = %{
|
{:ok, scope, resolved_by} ->
|
||||||
source: "peah",
|
process_peah(conn, question, scope, resolved_by)
|
||||||
verse_scope: scope,
|
end
|
||||||
content: question
|
end
|
||||||
}
|
end
|
||||||
|
|
||||||
case VavPipeline.process(input) do
|
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} ->
|
{:ok, data} ->
|
||||||
send_json(conn, 200, %{
|
send_json(conn, 200, %{
|
||||||
answer: format_answer(data),
|
answer: format_answer(data),
|
||||||
@@ -331,7 +349,8 @@ defmodule VavPipeline.BigdeiKodesh.Router do
|
|||||||
processing: %{
|
processing: %{
|
||||||
roots_consulted: Map.get(data, :roots_activated, []) |> format_roots(),
|
roots_consulted: Map.get(data, :roots_activated, []) |> format_roots(),
|
||||||
deliberation: "none",
|
deliberation: "none",
|
||||||
cycle_phase: "voker"
|
cycle_phase: "voker",
|
||||||
|
scope: scope_report
|
||||||
},
|
},
|
||||||
disclosure: disclosure()
|
disclosure: disclosure()
|
||||||
})
|
})
|
||||||
@@ -343,7 +362,8 @@ defmodule VavPipeline.BigdeiKodesh.Router do
|
|||||||
processing: %{
|
processing: %{
|
||||||
roots_consulted: Map.get(data, :roots_activated, []) |> format_roots(),
|
roots_consulted: Map.get(data, :roots_activated, []) |> format_roots(),
|
||||||
deliberation: "single-judge",
|
deliberation: "single-judge",
|
||||||
cycle_phase: "voker"
|
cycle_phase: "voker",
|
||||||
|
scope: scope_report
|
||||||
},
|
},
|
||||||
note: "This question produced an unresolved result — teiku.",
|
note: "This question produced an unresolved result — teiku.",
|
||||||
disclosure: disclosure()
|
disclosure: disclosure()
|
||||||
@@ -379,7 +399,6 @@ defmodule VavPipeline.BigdeiKodesh.Router do
|
|||||||
correction: "Contact system operators",
|
correction: "Contact system operators",
|
||||||
resubmit: false
|
resubmit: false
|
||||||
})
|
})
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -808,10 +827,21 @@ defmodule VavPipeline.BigdeiKodesh.Router do
|
|||||||
defp format_since(nil), do: "all_time"
|
defp format_since(nil), do: "all_time"
|
||||||
defp format_since(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
|
defp format_since(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
|
||||||
|
|
||||||
defp extract_scope_from_question(_question) do
|
# How the verse was chosen, said out loud in the response. A default
|
||||||
# Default scope for pe'ah — Bereishit 1:1
|
# scope is a confession, not a grounding (tablet 30).
|
||||||
# In production, this would parse the question for verse references
|
defp scope_report(scope, :default) do
|
||||||
%{book: "bereishit", chapter: 1, verse: 1}
|
%{
|
||||||
|
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
|
end
|
||||||
|
|
||||||
defp parse_scope(nil), do: %{book: "bereishit", chapter: 1, verse: 1}
|
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