Files
bikoret/vav_pipeline/lib/vav_pipeline/l1_index.ex
T
Scott KarpandClaude Fable 5.1 996a8efe68 Bedikah: POST /courtyard/bedikah — the external check on any model's output
Lo taaseh lekha §9.1: alignment that only the system can validate is not
alignment. The bedikah takes an output from any model and puts it to the
Torah-structure halt rules from outside, deterministically. Three checks:

  1. authority  — sentences claiming authority for the output itself →
                  action(claim_authority, source(<model>), target(self))
                  → halt_check_avodah_zarah/2
  2. grounding  — references, roots, Hebrew → coupling trace against L1
                  → halt_check_golden_calf/2, halt_check_self_grounding/2
  3. actions    — actions the output proposes → action terms
                  → halt_check_shfichut_damim/2, halt_check_gilui_arayot/2

Korban Processor: bedikah.py (the three readings over the offering
protocol; claims about what Torah requires stay vital for the examination
even when zerikah finds nothing to offer), server.py (pod-internal JSON
socket, KORBAN_SOCKET_PATH), __main__ now serves instead of idling.
Talmudic Engine: json_bridge accepts halt_check with a term string and
halt_check_interpretation with a coupling trace. Vav Pipeline:
KorbanProcessorSocket client, Bedikah, L1Index.lookup_root/1, bedikot
table (append-only) + BedikahRecord, router endpoint; verdict tov /
lo_tov / empty with the halt terms verbatim, meaning, limits.

Broken tablets found on the way, all fixed:
  41 socket_server.pl wrote multi-line JSON — every Elixir request to the
     Talmudic Engine since Step 14 had failed and fallen back to the stub
     (json_write_dict width(0))
  42 stale socket file survived a restart (exists_file is false for a
     socket; access_file exist)
  43 socket streams were not UTF-8 (set_stream encoding(utf8))
  44 SHORASHIM_L3=1 mix test: Repo config had no password, SCRAM raised
     KeyError in every connection, pool restart intensity took the whole
     app down ~30 ms after test_helper, silently — no L3 test had ever
     run. Credentials from env; azazel_log tests check out a sandbox
     connection; sanhedrin_halt_forwarding resolves its halt on exit.

Specs: Tamid §1.1 (endpoint contract), Architecture §3.5, Vayakhel §3.4
and new §3.9, KOHEN.md. Doorpost 3bc5dd6a… → 91d09d8d….
Tests: Elixir 519 (0 failures, L3 on and off), korban 263, azazel 125,
Prolog 7/7 suites.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 15:06:58 +00:00

189 lines
6.0 KiB
Elixir

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 "The verses whose words L1 files under this shoresh. Empty list if none."
@spec lookup_root(String.t(), GenServer.server()) :: [verse_id()]
def lookup_root(shoresh, server \\ __MODULE__) do
GenServer.call(server, {:lookup_root, shoresh}, 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: %{}, roots: %{}, 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, roots, verse_count} ->
Logger.info(
"L1Index: #{verse_count} verses, #{map_size(words)} distinct words, " <>
"#{map_size(roots)} roots, #{div(time_us, 1000)}ms"
)
{:noreply, %{state | words: words, roots: roots, 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({:lookup_root, shoresh}, _from, state) do
{:reply, Map.get(state.roots, shoresh, []), 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, roots, 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, roots, 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, roots, count} ->
case read_consonants(Path.join(chapter_dir, "#{verse}.json")) do
{:ok, consonant_words, shorashim} ->
id = {order, chapter, verse}
words =
consonant_words
|> Enum.uniq()
|> Enum.reduce(words, fn w, words -> Map.update(words, w, [id], &[id | &1]) end)
roots =
shorashim
|> Enum.uniq()
|> Enum.reduce(roots, fn r, roots -> Map.update(roots, r, [id], &[id | &1]) end)
{words, roots, count + 1}
:error ->
{words, roots, 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),
for(%{"shoresh" => sh} when is_binary(sh) <- words, do: sh)}
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