The Shoresh Engine never opened L1 to find a root. extractRoots called every word of two or three letters a root and dropped every longer one: את → א-ת-ה (padded with a He the text never wrote), ואת → ו-א-ת, בכל → ב-כ-ל; ויאמר, ואהבת, אלהים → nothing. Handed all of Devarim 6:5 it found no א-ה-ב. Binyan was "Qal" for everything, nouns included. shoresh-engine: - Shoresh.Roots: an index over all of L1 (5853 verses, built once at server start, <1 s). A word resolves, in order: from the verse in scope (its own shoresh, with the binyan read from the verb's morphology; else its lemma's shoresh — לבבך → לבב); else the way Torah most often reads that surface form (its commonest lemma decides, so אשר the particle has no root); else a root the asker named, if Torah knows it. A word in the verse that L1 gives no root has none, whatever a homograph elsewhere carries. Unknown words yield nothing. - Roots are rendered as L1 records them: two letters stay two letters. - binyan is null unless a verb stands in the scoped verse. - L1 is found via L1_MOUNT_POINT, not the literal l1/ relative to cwd — which is why parse_tree was null on the pod. - Adds "surface" and "derivation" to each root (verse|lemma|torah| named_root). 22 new HSpec examples; 55 examples, 0 failures. vav_pipeline: - normalize_root places a lemma-derived root at the word it was heard from (L1 has no shoresh on לבבך to search by). - A binyan is printed only where there is one; "binyan unknown" on a noun claimed a question that does not exist. - test/tablet33_engine_roots_test.exs: the new shape through the router. - liveness_test: clear a halt leaked by halt_monitor_test (the seed 0/191243 flake, present since before tablet 30). 461 tests, 0 failures on seeds 0 42 191243. Verified before push with BOTH built images, the real engine binary and a local PostgreSQL 16 (SSL): Devarim 6:5 → א-ה-ב (Qal, word 1), י-ה-ו, א-ל-ה, כ-ל (words 5,7,9), ל-ב-ב, נ-פ-ש, מ-א-ד; 4 interpretations written to L3, 0 L3_WRITE_FAILED, health healthy. NOT repaired here: the quality of L1's own shoresh field, which the engine is now faithful to — ויהי is recorded as יהי, שמים as שמ, יהוה as יהו, הבאתי as בא. That is the quarry's tablet (scripts/build_l1.py) and it changes the L1 hash; a decision, not a patch. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1037 lines
36 KiB
Elixir
1037 lines
36 KiB
Elixir
defmodule VavPipeline.BereishitCycle do
|
||
@moduledoc """
|
||
The Bereishit processing cycle — the six days of creation as computation.
|
||
|
||
Architecture §3.3:
|
||
Each step chains to the next via |> (vav-consecutive).
|
||
|
||
The cycle operates WITHIN the ShemaContext, never modifying it.
|
||
The context was received by Shema (nishma). Now we do (na'aseh).
|
||
|
||
Steps:
|
||
1. tohu — decompose input into undifferentiated state
|
||
2. vayomer — produce structured symbolic output (Shoresh Engine)
|
||
3. havdalah — separate into distinguished categories
|
||
4. kriah — assign stable names
|
||
5. ki_tov — evaluate against specification
|
||
6. erev_voker — log cycle completion, enforce boundary, RECORD
|
||
|
||
Latency budget (Construction spec §2.5): 5000ms total
|
||
|
||
## Kohen Day-6 findings (2026-09-07) and what changed here
|
||
|
||
The first real cycles on a live pod showed three things this module
|
||
did that its unit tests never caught, because every unit test ran the
|
||
L1 fallback (no Port in the test VM) and none ran the cycle to L3:
|
||
|
||
1. **Real engine output crashed havdalah.** `separate_by_boundaries/2`
|
||
read `root.position`. Only `vayomer_fallback/2` ever set it; the
|
||
Shoresh Engine's `roots_activated[]` (vayakhel §3.1) has no
|
||
`position` — the engine reports a ROOT, not where in the verse it
|
||
sits. The word index is the ShemaContext's to give
|
||
(`context.verse.words`), which is what havdalah's own @doc always
|
||
said. `normalize_root/2` now anchors every root to the verse from
|
||
the context, engine-sourced or L1-sourced alike.
|
||
|
||
2. **Emptiness was graded tov.** English input → engine returns zero
|
||
roots → `separate_by_boundaries([], _)` returned `[[]]` → kriah
|
||
named the empty group `"group_0"` with no `:shoresh` → the
|
||
`{:lo_tov, "empty results"}` guard in Specification never saw
|
||
emptiness. The ger asking in English received HTTP 200 and the
|
||
words "Processing complete". Now: no roots → no groups → no named
|
||
results → lo_tov, and the router turns that into a chatat-shaped
|
||
answer that says what the system cannot yet do (tamid §1.2, §4.1:
|
||
"It does not fabricate results from partial capacity").
|
||
|
||
3. **Nothing was ever written to L3.** `L3.Store.record_interpretation/1`,
|
||
`record_minority/1` and `record_teiku/1` had zero callers. The
|
||
cycle deliberated and logged; the oral tradition stayed empty.
|
||
PRD §5.2 — "accumulates learning in L3 through deliberate,
|
||
structured processes" — was unimplemented on the write side.
|
||
`erev_voker/2` now records: an Interpretation for a served result,
|
||
a TeikuRecord for an unresolved one, and NOTHING for a rejected
|
||
offering (BUILD_KORBAN.md:123 — rejected, not processed into empty
|
||
propositions).
|
||
|
||
Also: `Manna.enforce_anti_hoarding/0` was called at the end of EVERY
|
||
request, which zeroes the ledger and clears its date — so a per-cycle
|
||
`consume` would have handed every request a fresh full day's
|
||
provision. The manna boundary is the DAY (Shemot 16:19 — until
|
||
MORNING), so release happens on the date roll, in
|
||
`Manna.release_at_day_boundary/0`, and consumption happens in
|
||
`VavPipeline.process/1`.
|
||
"""
|
||
|
||
alias VavPipeline.{Specification, CycleLog, Manna, HaltConditions,
|
||
ShoreshEngineClient, TalmudicEngineClient, SanhedrinClient,
|
||
Chatzotzrot}
|
||
|
||
require Logger
|
||
|
||
@type cycle_result ::
|
||
{:ok, map()}
|
||
| {:rejected, String.t()}
|
||
| {:unresolved, map()}
|
||
| {:halted, atom(), String.t()}
|
||
|
||
@doc """
|
||
Execute the Bereishit cycle on input within received context.
|
||
"""
|
||
@spec execute(VavPipeline.input(), VavPipeline.Shema.shema_context()) :: cycle_result()
|
||
def execute(input, context) do
|
||
started_at = System.monotonic_time(:millisecond)
|
||
|
||
result =
|
||
input
|
||
|> tohu(context)
|
||
|> vayomer(context)
|
||
|> havdalah(context)
|
||
|> kriah(context)
|
||
|> ki_tov(context)
|
||
|
||
completed_at = System.monotonic_time(:millisecond)
|
||
elapsed = completed_at - started_at
|
||
|
||
Logger.info("BereishitCycle: completed in #{elapsed}ms — #{inspect(elem(result, 0))}")
|
||
|
||
# erev_voker — cycle boundary
|
||
erev_voker(result, context)
|
||
end
|
||
|
||
@doc """
|
||
Tohu — decompose input into undifferentiated state.
|
||
|
||
Strip structure, extract raw content. This is the formless void
|
||
before vayomer brings order.
|
||
|
||
The case_ref is minted HERE, once, so every later stage — Talmudic
|
||
routing, Sanhedrin escalation, the L3 record — refers to the same
|
||
cycle by the same name.
|
||
"""
|
||
@spec tohu(VavPipeline.input(), VavPipeline.Shema.shema_context()) :: map()
|
||
def tohu(input, _context) 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.
|
||
probe: Map.get(input, :probe, false) == true,
|
||
started_at: DateTime.utc_now()
|
||
}
|
||
end
|
||
|
||
@doc """
|
||
Vayomer — produce structured symbolic output.
|
||
|
||
The Shoresh Engine receives the ShemaContext — it reads te'amim,
|
||
ketiv/qere, gematria as part of its analysis.
|
||
|
||
Calls the Shoresh Engine via the Erlang Port (Step 14). Falls back to
|
||
the L1 data in the ShemaContext if the Port is unavailable.
|
||
|
||
Every root — from either source — is normalized by `normalize_root/2`
|
||
into one shape and anchored to the verse in the context. Downstream
|
||
stages, the router and the L3 record all read that one shape.
|
||
"""
|
||
@spec vayomer(map(), VavPipeline.Shema.shema_context()) :: map()
|
||
def vayomer(state, 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(text, 0, 60))} — the input carries no " <>
|
||
"Hebrew root the engine can read; ki_tov will not call this tov"
|
||
)
|
||
end
|
||
|
||
Map.merge(state, %{
|
||
# A verse may carry the same word twice (אור in Bereishit 1:3);
|
||
# the engine answers per word, and each normalized root already
|
||
# lists every position. One root, once.
|
||
roots_activated:
|
||
raw_roots
|
||
|> Enum.map(&normalize_root(&1, context, :engine))
|
||
|> Enum.uniq_by(& &1.shoresh),
|
||
parse_tree: get_result(engine_result, :parse_tree, "parse_tree", nil),
|
||
dual_readings: get_result(engine_result, :dual_readings, "dual_readings", []),
|
||
teamim_parse: context.teamim_marks,
|
||
ketiv_qere: context.ketiv_qere,
|
||
root_source: :engine,
|
||
stage: :vayomer
|
||
})
|
||
|
||
{:error, reason} ->
|
||
Logger.warning("BereishitCycle.vayomer: engine error (#{reason}), using L1 fallback")
|
||
vayomer_fallback(state, context)
|
||
end
|
||
end
|
||
|
||
@doc """
|
||
Havdalah — separate into distinguished categories.
|
||
|
||
Te'amim inform the separation — the text's own divisions guide havdalah.
|
||
Uses the te'amim marks from the ShemaContext to determine boundaries.
|
||
|
||
A root's place in the verse comes from the context (set by
|
||
`normalize_root/2`), never from the engine. Roots that do not occur in
|
||
the verse in scope (a Hebrew word the asker typed that is not in this
|
||
pasuk) cannot be divided by this pasuk's te'amim; they form their own
|
||
trailing group rather than being dropped or crashing the stage.
|
||
"""
|
||
@spec havdalah(map(), VavPipeline.Shema.shema_context()) :: map()
|
||
def havdalah(utterance, context) do
|
||
# Te'amim melekh marks (disjunctors) create boundaries — 0-based word index
|
||
melekh_positions =
|
||
context.teamim_marks
|
||
|> Enum.filter(fn m -> m.role == "melekh" end)
|
||
|> Enum.map(fn m -> m.word_index end)
|
||
|> Enum.sort()
|
||
|
||
roots = Map.get(utterance, :roots_activated, [])
|
||
groups = separate_by_boundaries(roots, melekh_positions)
|
||
|
||
Map.merge(utterance, %{
|
||
separated_groups: groups,
|
||
separation_boundaries: melekh_positions,
|
||
stage: :havdalah
|
||
})
|
||
end
|
||
|
||
@doc """
|
||
Kriah — assign stable names to each distinction.
|
||
|
||
Each separated group receives a name based on its primary root.
|
||
|
||
A group with no roots is not named. There is nothing to call it —
|
||
naming emptiness `"group_0"` was how an empty cycle reached ki_tov
|
||
looking like a result. `Specification.evaluate/2` sees `[]` and says
|
||
lo_tov, as it always could.
|
||
"""
|
||
@spec kriah(map(), VavPipeline.Shema.shema_context()) :: map()
|
||
def kriah(distinctions, _context) do
|
||
named =
|
||
distinctions
|
||
|> Map.get(:separated_groups, [])
|
||
|> Enum.reject(&(&1 == []))
|
||
|> Enum.with_index()
|
||
|> Enum.map(fn {group, idx} ->
|
||
primary_root = List.first(group)
|
||
|
||
%{
|
||
name: primary_root.shoresh,
|
||
shoresh: primary_root.shoresh,
|
||
roots: group,
|
||
positions: group |> Enum.map(& &1.position) |> Enum.reject(&is_nil/1),
|
||
index: idx
|
||
}
|
||
end)
|
||
|
||
Map.merge(distinctions, %{
|
||
named_results: named,
|
||
stage: :kriah
|
||
})
|
||
end
|
||
|
||
@doc """
|
||
Ki Tov — evaluate against specification.
|
||
|
||
Ketiv/qere positions are checked — both readings inform evaluation.
|
||
Returns {:ok, results}, {:rejected, reason}, or {:unresolved, results}.
|
||
"""
|
||
@spec ki_tov(map(), VavPipeline.Shema.shema_context()) :: VavPipeline.cycle_result()
|
||
def ki_tov(named, context) do
|
||
results = Map.get(named, :named_results, [])
|
||
|
||
case Specification.evaluate(results, context.ketiv_qere) do
|
||
:tov ->
|
||
{:ok,
|
||
Map.merge(named, %{
|
||
evaluation: :tov,
|
||
stage: :ki_tov
|
||
})}
|
||
|
||
{:lo_tov, reason} ->
|
||
{:rejected, reason}
|
||
|
||
:teiku ->
|
||
{:unresolved,
|
||
Map.merge(named, %{
|
||
evaluation: :teiku,
|
||
stage: :ki_tov
|
||
})}
|
||
end
|
||
end
|
||
|
||
@doc """
|
||
Erev Voker — log cycle completion, enforce boundary, record.
|
||
|
||
Order matters:
|
||
1. log the cycle (CycleLog)
|
||
2. release manna surplus IF the day has turned (Shemot 16:19 —
|
||
until morning; the boundary is the day, not the request)
|
||
3. route unresolved results (Talmudic Engine → Sanhedrin)
|
||
4. check halt conditions on the final output
|
||
5. compose the answer and its sources from the ShemaContext
|
||
6. record to L3 — Interpretation / TeikuRecord / nothing
|
||
|
||
Step 6 is skipped for a tamid probe. Step 6 writes nothing for a
|
||
rejected offering: BUILD_KORBAN.md:123 — "the offering without vital
|
||
organs is rejected, not processed into empty propositions."
|
||
"""
|
||
@spec erev_voker(VavPipeline.cycle_result(), VavPipeline.Shema.shema_context()) ::
|
||
VavPipeline.cycle_result()
|
||
def erev_voker(result, context) do
|
||
phase =
|
||
case result do
|
||
{:ok, _} -> :voker
|
||
{:rejected, _} -> :erev
|
||
{:unresolved, _} -> :erev
|
||
_ -> :erev
|
||
end
|
||
|
||
CycleLog.record(result, phase)
|
||
|
||
# Al yoter mimenu ad boker — release surplus at the DAY boundary.
|
||
Manna.release_at_day_boundary()
|
||
|
||
final =
|
||
case result do
|
||
{:ok, data} ->
|
||
check_halt_on_output(data)
|
||
|
||
{:unresolved, data} ->
|
||
case route_if_unresolved(data, context) do
|
||
{:ok, routed_data} -> check_halt_on_output(routed_data)
|
||
other -> other
|
||
end
|
||
|
||
other ->
|
||
other
|
||
end
|
||
|
||
final = compose(final, context)
|
||
record_to_l3(final, context)
|
||
final
|
||
end
|
||
|
||
# ============================================================
|
||
# Compose — the answer and its sources, from the context
|
||
# ============================================================
|
||
|
||
# The answer is the system's own reasoning over L1, stated plainly:
|
||
# which roots, where in which verse, what the te'amim divided, what
|
||
# ki_tov found. Not a model's prose. The sources are the verse(s) the
|
||
# context actually held — tamid §1.1's {book, chapter, verse, text}.
|
||
defp compose({:ok, data}, context) do
|
||
{:ok,
|
||
Map.merge(data, %{
|
||
interpretation: Map.get(data, :interpretation) || answer_text(data, context),
|
||
sources: sources_for(data, context)
|
||
})}
|
||
end
|
||
|
||
defp compose({:unresolved, data}, context) do
|
||
{:unresolved,
|
||
Map.merge(data, %{
|
||
interpretation: Map.get(data, :interpretation) || teiku_text(data, context),
|
||
sources: sources_for(data, context)
|
||
})}
|
||
end
|
||
|
||
defp compose(other, _context), do: other
|
||
|
||
@doc false
|
||
def answer_text(data, context) do
|
||
verse = context.verse
|
||
roots = Map.get(data, :roots_activated, [])
|
||
groups = Map.get(data, :named_results, [])
|
||
|
||
root_lines =
|
||
Enum.map(roots, fn r ->
|
||
where =
|
||
case r.positions do
|
||
[] -> "not in this verse"
|
||
ps -> "word " <> Enum.join(Enum.map(ps, &to_string/1), ", ")
|
||
end
|
||
|
||
# A binyan is said only where the engine read one off a verb in
|
||
# this verse. A noun has none; saying "unknown" (or, before
|
||
# tablet 33, "Qal" for everything) claims a question that does
|
||
# not exist.
|
||
binyan = if r.binyan, do: "#{r.binyan}, ", else: ""
|
||
|
||
"#{hyphenate(r.shoresh)} (gematria #{r.gematria}, #{binyan}#{where})"
|
||
end)
|
||
|
||
group_line =
|
||
case groups do
|
||
[] -> ""
|
||
gs ->
|
||
names = Enum.map(gs, fn g -> hyphenate(g.name) end)
|
||
" Te'amim divide them into #{length(gs)} group(s): #{Enum.join(names, " | ")}."
|
||
end
|
||
|
||
deliberation =
|
||
case Map.get(data, :resolution) do
|
||
:talmudic ->
|
||
text = get_in(data, [:talmudic_interpretation, "interpretation", "text"]) || ""
|
||
" Talmudic Engine: #{text}"
|
||
|
||
:sanhedrin ->
|
||
pos = get_in(data, [:sanhedrin_decision, :majority, :position]) || ""
|
||
" Sanhedrin majority: #{pos}"
|
||
|
||
_ ->
|
||
""
|
||
end
|
||
|
||
"Roots activated in #{verse.ref}: #{Enum.join(root_lines, "; ")}." <>
|
||
group_line <>
|
||
deliberation <>
|
||
" Ki tov: the output aligns with the specification; ketiv/qere readings preserved."
|
||
end
|
||
|
||
@doc false
|
||
def teiku_text(data, context) do
|
||
verse = context.verse
|
||
roots = Map.get(data, :roots_activated, []) |> Enum.map(&hyphenate(&1.shoresh))
|
||
|
||
base = "Teiku on #{verse.ref} — roots #{inspect(roots)} could not be resolved against the specification."
|
||
|
||
case Map.get(data, :talmudic_interpretation) do
|
||
%{"interpretation" => %{"text" => t}} when is_binary(t) and t != "" -> base <> " Talmudic Engine: #{t}"
|
||
_ -> base
|
||
end
|
||
end
|
||
|
||
defp sources_for(data, context) do
|
||
verse = context.verse
|
||
|
||
primary = %{
|
||
book: verse.book,
|
||
chapter: verse.chapter,
|
||
verse: verse.verse,
|
||
ref: verse.ref,
|
||
text: verse.text_hebrew
|
||
}
|
||
|
||
talmudic_verses =
|
||
case get_in(data, [:talmudic_interpretation, "interpretation", "verses"]) do
|
||
vs when is_list(vs) -> Enum.map(vs, fn v -> %{ref: to_string(v)} end)
|
||
_ -> []
|
||
end
|
||
|
||
[primary | talmudic_verses]
|
||
end
|
||
|
||
# ============================================================
|
||
# L3 — the oral tradition is written here
|
||
# ============================================================
|
||
|
||
# Devarim 17:9 — v'darashta v'higidu lekha. The inquiry is recorded or
|
||
# it did not accumulate. Never crashes the cycle: a served answer is
|
||
# not turned into a 500 because the record failed — but the failure
|
||
# is loud (Logger.error + chatzotzrot :assembly_leaders), because an
|
||
# oral tradition that silently stops growing is the Day-6 finding
|
||
# all over again.
|
||
defp record_to_l3(final, context) do
|
||
cond do
|
||
probe?(final) ->
|
||
:skipped_probe
|
||
|
||
not l3_available?() ->
|
||
Logger.info("BereishitCycle: L3 not available — cycle not recorded (dev/test without Repo)")
|
||
:skipped_no_repo
|
||
|
||
true ->
|
||
case l3_attrs(final, context) do
|
||
:none ->
|
||
:none
|
||
|
||
{:interpretation, attrs, minority} ->
|
||
# A validation failure comes back as {:error, changeset}; a
|
||
# database that is DOWN comes back as a raise
|
||
# (DBConnection.ConnectionError, Postgrex.Error). Both are
|
||
# the same fact for the cycle — this record did not land —
|
||
# and neither may turn a served answer into a 500.
|
||
safely(attrs.case_ref, :interpretation, fn ->
|
||
case VavPipeline.L3.Store.record_interpretation(attrs) do
|
||
{:ok, interp} ->
|
||
Logger.info("L3: interpretation #{interp.id} recorded for #{attrs.case_ref} (#{attrs.status})")
|
||
maybe_record_minority(interp.id, minority)
|
||
{:ok, interp}
|
||
|
||
{:error, why} ->
|
||
l3_failure(attrs.case_ref, :interpretation, why)
|
||
end
|
||
end)
|
||
|
||
{:teiku, attrs} ->
|
||
safely(attrs.case_ref, :teiku, fn ->
|
||
case VavPipeline.L3.Store.record_teiku(attrs) do
|
||
{:ok, rec} ->
|
||
Logger.info("L3: teiku #{rec.id} registered for #{attrs.case_ref}")
|
||
{:ok, rec}
|
||
|
||
{:error, why} ->
|
||
l3_failure(attrs.case_ref, :teiku, why)
|
||
end
|
||
end)
|
||
end
|
||
end
|
||
end
|
||
|
||
defp safely(case_ref, kind, fun) do
|
||
fun.()
|
||
rescue
|
||
e -> l3_failure(case_ref, kind, e)
|
||
catch
|
||
:exit, reason -> l3_failure(case_ref, kind, {:exit, reason})
|
||
end
|
||
|
||
defp maybe_record_minority(_id, nil), do: :none
|
||
|
||
defp maybe_record_minority(interpretation_id, minority) do
|
||
attrs = Map.put(minority, :interpretation_id, interpretation_id)
|
||
|
||
safely("interpretation #{interpretation_id}", :minority, fn ->
|
||
case VavPipeline.L3.Store.record_minority(attrs) do
|
||
{:ok, op} -> Logger.info("L3: minority opinion #{op.id} preserved")
|
||
{:error, why} -> l3_failure("interpretation #{interpretation_id}", :minority, why)
|
||
end
|
||
end)
|
||
end
|
||
|
||
defp l3_failure(case_ref, kind, why) do
|
||
Logger.error("L3_WRITE_FAILED: #{kind} for #{case_ref} — #{inspect(why)}")
|
||
|
||
if Process.whereis(Chatzotzrot) do
|
||
Chatzotzrot.signal(:assembly_leaders,
|
||
reason: "L3 write failed (#{kind}) for #{case_ref} — the oral tradition did not record this cycle"
|
||
)
|
||
end
|
||
|
||
{:error, why}
|
||
end
|
||
|
||
defp l3_available?, do: Process.whereis(VavPipeline.Repo) != nil
|
||
|
||
defp probe?({_, data}) when is_map(data), do: Map.get(data, :probe, false) == true
|
||
defp probe?(_), do: false
|
||
|
||
@doc """
|
||
Build the L3 record for a finished cycle. Pure — no I/O — so it is
|
||
testable without PostgreSQL.
|
||
|
||
Returns one of:
|
||
{:interpretation, attrs, minority_attrs | nil} — a served result
|
||
{:teiku, attrs} — an unresolved one
|
||
:none — rejected / halted:
|
||
nothing to record
|
||
"""
|
||
@spec l3_attrs(VavPipeline.cycle_result(), map()) ::
|
||
{:interpretation, map(), map() | nil} | {:teiku, map()} | :none
|
||
def l3_attrs({:ok, data}, context) do
|
||
verse = context.verse
|
||
roots = Map.get(data, :roots_activated, [])
|
||
scope_ref = verse_ref_string(verse)
|
||
|
||
base = %{
|
||
case_ref: Map.get(data, :case_ref, generate_case_ref()),
|
||
interpretation: Map.get(data, :interpretation) || answer_text(data, context),
|
||
status: "accepted",
|
||
torah_references: [scope_ref],
|
||
roots_involved: Enum.map(roots, & &1.shoresh),
|
||
shema_context_hash: shema_context_hash(context),
|
||
coupling_trace: coupling_trace(roots, verse)
|
||
}
|
||
|
||
case Map.get(data, :resolution) do
|
||
:sanhedrin ->
|
||
decision = data.sanhedrin_decision
|
||
majority = Map.get(decision, :majority, %{})
|
||
minority = Map.get(decision, :minority, %{})
|
||
|
||
attrs =
|
||
Map.merge(base, %{
|
||
reasoning: Map.get(majority, :reasoning) || Map.get(majority, :position) || "Sanhedrin majority",
|
||
torah_references: Enum.uniq([scope_ref | to_string_list(Map.get(majority, :torah_refs, []))]),
|
||
panel_size: max(Map.get(decision, :panel_size, 1), 1),
|
||
vote_count_for: Map.get(majority, :vote_count, 1),
|
||
vote_count_against: Map.get(minority, :vote_count, 0),
|
||
processing_component: "sanhedrin"
|
||
})
|
||
|
||
minority_attrs =
|
||
case Map.get(minority, :reasoning) do
|
||
r when is_binary(r) and r != "" ->
|
||
%{
|
||
dissenting_reasoning: r,
|
||
torah_references: to_string_list(Map.get(minority, :torah_refs, [])),
|
||
grounding_type: nil
|
||
}
|
||
|
||
_ ->
|
||
nil
|
||
end
|
||
|
||
{:interpretation, attrs, minority_attrs}
|
||
|
||
:talmudic ->
|
||
interp = get_in(data, [:talmudic_interpretation, "interpretation"]) || %{}
|
||
|
||
attrs =
|
||
Map.merge(base, %{
|
||
reasoning: Map.get(interp, "text") || "Talmudic Engine resolved the case",
|
||
torah_references: Enum.uniq([scope_ref | to_string_list(Map.get(interp, "verses", []))]),
|
||
panel_size: 1,
|
||
vote_count_for: 1,
|
||
vote_count_against: 0,
|
||
processing_component: "talmudic_engine"
|
||
})
|
||
|
||
{:interpretation, attrs, nil}
|
||
|
||
_ ->
|
||
attrs =
|
||
Map.merge(base, %{
|
||
reasoning: single_judge_reasoning(data, context),
|
||
panel_size: 1,
|
||
vote_count_for: 1,
|
||
vote_count_against: 0,
|
||
processing_component: "bereishit_cycle"
|
||
})
|
||
|
||
{:interpretation, attrs, nil}
|
||
end
|
||
end
|
||
|
||
def l3_attrs({:unresolved, data}, context) do
|
||
verse = context.verse
|
||
scope_ref = verse_ref_string(verse)
|
||
roots = Map.get(data, :roots_activated, []) |> Enum.map(& &1.shoresh)
|
||
|
||
interp = get_in(data, [:talmudic_interpretation, "interpretation"]) || %{}
|
||
challenge = get_in(data, [:talmudic_interpretation, "challenge"]) || %{}
|
||
decision = Map.get(data, :sanhedrin_decision) || %{}
|
||
|
||
{a, refs_a} =
|
||
cond do
|
||
is_binary(Map.get(interp, "text")) and Map.get(interp, "text") != "" ->
|
||
{Map.get(interp, "text"), to_string_list(Map.get(interp, "verses", []))}
|
||
|
||
is_map(Map.get(decision, :majority)) ->
|
||
m = decision.majority
|
||
{Map.get(m, :position) || "Sanhedrin majority", to_string_list(Map.get(m, :torah_refs, []))}
|
||
|
||
true ->
|
||
{"Bereishit cycle could not ground roots #{inspect(roots)} in the specification", []}
|
||
end
|
||
|
||
{b, refs_b} =
|
||
cond do
|
||
is_binary(Map.get(challenge, "kushya")) and Map.get(challenge, "kushya") != "" ->
|
||
{Map.get(challenge, "kushya"), []}
|
||
|
||
is_map(Map.get(decision, :minority)) and (Map.get(decision.minority, :position) || "") != "" ->
|
||
m = decision.minority
|
||
{Map.get(m, :position), to_string_list(Map.get(m, :torah_refs, []))}
|
||
|
||
true ->
|
||
{"No second reading was produced; the tension is between the roots and L1 grounding", []}
|
||
end
|
||
|
||
attrs = %{
|
||
case_ref: Map.get(data, :case_ref, generate_case_ref()),
|
||
tension_description:
|
||
"Unresolved cycle on #{verse.ref} — roots #{inspect(roots)}; " <>
|
||
"evaluation #{inspect(Map.get(data, :evaluation, :teiku))}" <>
|
||
routing_note(data),
|
||
interpretation_a: a,
|
||
interpretation_b: b,
|
||
torah_refs_a: Enum.uniq([scope_ref | refs_a]),
|
||
torah_refs_b: refs_b,
|
||
challenge_sequence:
|
||
case Map.get(challenge, "kushya") do
|
||
k when is_binary(k) and k != "" -> [k]
|
||
_ -> []
|
||
end
|
||
}
|
||
|
||
{:teiku, attrs}
|
||
end
|
||
|
||
def l3_attrs(_rejected_or_halted, _context), do: :none
|
||
|
||
defp routing_note(data) do
|
||
cond do
|
||
Map.has_key?(data, :routing_error) -> "; Talmudic routing failed: #{data.routing_error}"
|
||
Map.has_key?(data, :escalation_error) -> "; Sanhedrin escalation failed: #{data.escalation_error}"
|
||
true -> ""
|
||
end
|
||
end
|
||
|
||
defp single_judge_reasoning(data, context) do
|
||
roots = Map.get(data, :roots_activated, [])
|
||
groups = Map.get(data, :named_results, [])
|
||
source = Map.get(data, :root_source, :engine)
|
||
|
||
"#{length(roots)} root(s) activated by #{source_label(source)} from " <>
|
||
"#{inspect(String.slice(Map.get(data, :raw, ""), 0, 120))} within #{context.verse.ref}; " <>
|
||
"te'amim separated #{length(groups)} group(s) at melekh boundaries " <>
|
||
"#{inspect(Map.get(data, :separation_boundaries, []))}; ki_tov: tov " <>
|
||
"(#{length(context.ketiv_qere)} ketiv/qere position(s), all preserved). " <>
|
||
"Single-judge minchah; no Sanhedrin deliberation."
|
||
end
|
||
|
||
defp source_label(:engine), do: "the Shoresh Engine"
|
||
defp source_label(:l1), do: "L1 fallback (Shoresh Engine unavailable)"
|
||
defp source_label(other), do: to_string(other)
|
||
|
||
# coupled(root(ברא), verse(bereishit, 1, 1)) — the L1-anchored shape
|
||
# VavPipeline.L3.CouplingTrace accepts. Only roots that actually occur
|
||
# in the verse are coupled to it; a root the asker typed that is not
|
||
# in this pasuk has no L1 anchor here and is not written as if it did.
|
||
defp coupling_trace(roots, verse) do
|
||
book = verse.book |> to_string() |> String.downcase()
|
||
|
||
roots
|
||
|> Enum.filter(fn r -> r.positions != [] end)
|
||
|> Enum.map(fn r -> "coupled(root(#{r.shoresh}), verse(#{book}, #{verse.chapter}, #{verse.verse}))" end)
|
||
|> Enum.uniq()
|
||
end
|
||
|
||
defp shema_context_hash(context) do
|
||
v = context.verse
|
||
|
||
:crypto.hash(:sha256, "#{v.ref}|#{v.text_hebrew}|#{Map.get(context, :doorpost_hash, "")}")
|
||
|> Base.encode16(case: :lower)
|
||
end
|
||
|
||
defp verse_ref_string(verse), do: verse.ref || "#{verse.book} #{verse.chapter}:#{verse.verse}"
|
||
|
||
defp to_string_list(list) when is_list(list), do: Enum.map(list, &to_string/1)
|
||
defp to_string_list(_), do: []
|
||
|
||
# --- Halt Check ---
|
||
|
||
defp check_halt_on_output(data) do
|
||
results = Map.get(data, :named_results, [])
|
||
|
||
case HaltConditions.check_all(results) do
|
||
:clear -> {:ok, data}
|
||
{:halt, condition, reason} -> {:halted, condition, reason}
|
||
end
|
||
end
|
||
|
||
# --- Routing ---
|
||
|
||
@doc false
|
||
defp route_if_unresolved(data, context) do
|
||
roots = Map.get(data, :roots_activated, [])
|
||
context_roots = Enum.map(roots, & &1.shoresh)
|
||
|
||
case_ref = Map.get(data, :case_ref, generate_case_ref())
|
||
|
||
talmudic_context = %{
|
||
context_roots: context_roots,
|
||
question: "Interpret roots #{inspect(context_roots)} in context"
|
||
}
|
||
|
||
# First: consult the Talmudic Engine
|
||
case TalmudicEngineClient.interpret(case_ref, talmudic_context) do
|
||
{:ok, %{"status" => "resolved"} = interpretation} ->
|
||
Logger.info("BereishitCycle: Talmudic Engine resolved case #{case_ref}")
|
||
{:ok, Map.merge(data, %{
|
||
talmudic_interpretation: interpretation,
|
||
resolution: :talmudic,
|
||
stage: :erev_voker
|
||
})}
|
||
|
||
{:ok, %{"status" => "teiku"}} ->
|
||
Logger.info("BereishitCycle: Talmudic Engine returned teiku, escalating to Sanhedrin")
|
||
escalate_to_sanhedrin(data, context, case_ref, context_roots)
|
||
|
||
{:ok, interpretation} ->
|
||
{:unresolved, Map.merge(data, %{
|
||
talmudic_interpretation: interpretation,
|
||
stage: :erev_voker
|
||
})}
|
||
|
||
{:error, reason} ->
|
||
Logger.warning("BereishitCycle: Talmudic routing failed: #{reason}")
|
||
{:unresolved, Map.put(data, :routing_error, reason)}
|
||
end
|
||
end
|
||
|
||
defp escalate_to_sanhedrin(data, _context, case_ref, context_roots) do
|
||
sanhedrin_params = %{
|
||
case_ref: case_ref,
|
||
complexity: :simple,
|
||
question: "Deliberate on unresolved interpretation of #{inspect(context_roots)}",
|
||
context: %{
|
||
roots: context_roots,
|
||
named_results: Map.get(data, :named_results, [])
|
||
},
|
||
requested_by: "bereishit_cycle"
|
||
}
|
||
|
||
case SanhedrinClient.submit_case(sanhedrin_params) do
|
||
{:ok, decision} ->
|
||
outcome = Map.get(decision, :outcome, :teiku)
|
||
Logger.info("BereishitCycle: Sanhedrin decided #{outcome} for #{case_ref}")
|
||
|
||
case outcome do
|
||
:accepted ->
|
||
{:ok, Map.merge(data, %{
|
||
sanhedrin_decision: decision,
|
||
resolution: :sanhedrin,
|
||
stage: :erev_voker
|
||
})}
|
||
|
||
_ ->
|
||
{:unresolved, Map.merge(data, %{
|
||
sanhedrin_decision: decision,
|
||
stage: :erev_voker
|
||
})}
|
||
end
|
||
|
||
{:error, reason} ->
|
||
Logger.warning("BereishitCycle: Sanhedrin escalation failed: #{reason}")
|
||
{:unresolved, Map.put(data, :escalation_error, reason)}
|
||
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
|
||
roots =
|
||
context.verse.words
|
||
|> Enum.filter(fn w -> w.shoresh != nil end)
|
||
|> Enum.map(fn w ->
|
||
%{
|
||
shoresh: w.shoresh,
|
||
word: w.consonants,
|
||
position: w.position,
|
||
morph: w.morph,
|
||
gematria: Map.get(context.root_gematria, w.shoresh, 0)
|
||
}
|
||
end)
|
||
|> Enum.map(&normalize_root(&1, context, :l1))
|
||
|
||
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,
|
||
stage: :vayomer
|
||
})
|
||
end
|
||
|
||
# ============================================================
|
||
# Root normalization — one shape, anchored to the verse
|
||
# ============================================================
|
||
|
||
@doc """
|
||
Normalize a root from either source into the cycle's one root shape:
|
||
|
||
%{
|
||
shoresh: "ברא", # Hebrew consonantal root
|
||
shoresh_letters: ["bet", ...], # transliterated (engine) or []
|
||
gematria: 203,
|
||
binyan: "Qal" | nil,
|
||
domain: "unknown" | nil,
|
||
torah_refs: [...],
|
||
positions: [2], # 1-based word positions IN THE VERSE
|
||
position: 2 | nil, # first of those, or nil
|
||
source: :engine | :l1,
|
||
engine: %{...} # the raw engine map, kept for audit
|
||
}
|
||
|
||
`positions` comes from `context.verse.words` — the ShemaContext, not
|
||
the engine. vayakhel §3.1's `roots_activated[]` has no position field;
|
||
the engine reports WHAT root, the Torah text reports WHERE.
|
||
"""
|
||
@spec normalize_root(map(), map(), :engine | :l1) :: map()
|
||
def normalize_root(root, context, source) do
|
||
shoresh =
|
||
Map.get(root, :shoresh) ||
|
||
Map.get(root, "shoresh_hebrew") ||
|
||
letters_to_hebrew(Map.get(root, "shoresh"))
|
||
|
||
# Where the root stands in the verse: every word L1 gives this
|
||
# shoresh, and — for a root the engine heard through a word's lemma
|
||
# (לבבך → לבב; L1 records no shoresh on the suffixed noun itself) —
|
||
# the word it was heard from (tablet 33).
|
||
positions =
|
||
case shoresh do
|
||
nil ->
|
||
[]
|
||
|
||
s ->
|
||
(verse_positions_for(s, context) ++
|
||
surface_positions_for(Map.get(root, "surface"), context))
|
||
|> Enum.uniq()
|
||
|> Enum.sort()
|
||
end
|
||
|
||
given_position = Map.get(root, :position)
|
||
|
||
%{
|
||
shoresh: shoresh,
|
||
shoresh_letters: Map.get(root, "shoresh") || Map.get(root, :shoresh_letters) || [],
|
||
gematria: Map.get(root, :gematria) || Map.get(root, "gematria_value") || 0,
|
||
binyan: Map.get(root, :binyan) || Map.get(root, "binyan"),
|
||
domain: Map.get(root, :domain) || Map.get(root, "domain"),
|
||
torah_refs: Map.get(root, :torah_refs) || Map.get(root, "torah_refs") || [],
|
||
word: Map.get(root, :word) || Map.get(root, "surface"),
|
||
derivation: Map.get(root, "derivation"),
|
||
morph: Map.get(root, :morph),
|
||
positions: positions,
|
||
position: given_position || List.first(positions),
|
||
source: source,
|
||
engine: if(source == :engine, do: root, else: nil)
|
||
}
|
||
end
|
||
|
||
defp verse_positions_for(shoresh, context) do
|
||
words = get_in(context, [:verse, :words]) || []
|
||
|
||
words
|
||
|> Enum.filter(fn w -> w.shoresh == shoresh end)
|
||
|> Enum.map(& &1.position)
|
||
|> Enum.sort()
|
||
end
|
||
|
||
defp surface_positions_for(nil, _context), do: []
|
||
|
||
defp surface_positions_for(surface, context) do
|
||
(get_in(context, [:verse, :words]) || [])
|
||
|> Enum.filter(fn w -> w.consonants == surface end)
|
||
|> Enum.map(& &1.position)
|
||
end
|
||
|
||
# The engine's "shoresh" is a list of consonant NAMES ("bet","resh",
|
||
# "aleph"). shoresh_hebrew is the authoritative Hebrew form and is
|
||
# always present in practice; this is a last resort that never
|
||
# fabricates — unknown names return nil, not a wrong root.
|
||
@consonant_names %{
|
||
"aleph" => "א", "bet" => "ב", "gimel" => "ג", "dalet" => "ד", "he" => "ה",
|
||
"vav" => "ו", "zayin" => "ז", "chet" => "ח", "tet" => "ט", "yod" => "י",
|
||
"kaf" => "כ", "lamed" => "ל", "mem" => "מ", "nun" => "נ", "samekh" => "ס",
|
||
"ayin" => "ע", "pe" => "פ", "tsadi" => "צ", "qof" => "ק", "resh" => "ר",
|
||
"shin" => "ש", "tav" => "ת"
|
||
}
|
||
|
||
defp letters_to_hebrew(nil), do: nil
|
||
|
||
defp letters_to_hebrew(letters) when is_list(letters) do
|
||
mapped = Enum.map(letters, fn l -> Map.get(@consonant_names, String.downcase(to_string(l))) end)
|
||
if Enum.any?(mapped, &is_nil/1), do: nil, else: Enum.join(mapped)
|
||
end
|
||
|
||
defp letters_to_hebrew(_), do: nil
|
||
|
||
# ============================================================
|
||
# Private helpers
|
||
# ============================================================
|
||
|
||
# Get a value from a map that may have atom or string keys
|
||
defp get_result(map, atom_key, string_key, default) do
|
||
Map.get(map, atom_key, Map.get(map, string_key, default))
|
||
end
|
||
|
||
defp extract_content(input) do
|
||
Map.get(input, :content, "") |> String.trim()
|
||
end
|
||
|
||
defp generate_case_ref do
|
||
"cycle-#{:erlang.system_time(:millisecond)}-" <>
|
||
(:crypto.strong_rand_bytes(3) |> Base.encode16(case: :lower))
|
||
end
|
||
|
||
@doc false
|
||
def hyphenate(nil), do: ""
|
||
def hyphenate(s) when is_binary(s), do: s |> String.graphemes() |> Enum.join("-")
|
||
|
||
# Separate roots into groups based on melekh boundary positions.
|
||
#
|
||
# No roots → no groups. NOT [[]]: an empty group would be "named" by
|
||
# kriah and graded by ki_tov as if something had been said.
|
||
defp separate_by_boundaries([], _boundaries), do: []
|
||
|
||
defp separate_by_boundaries(roots, boundaries) do
|
||
# Roots the verse places, in verse order; roots it does not place, apart.
|
||
{placed, unplaced} = Enum.split_with(roots, fn r -> is_integer(r.position) end)
|
||
placed = Enum.sort_by(placed, & &1.position)
|
||
|
||
sorted_boundaries = Enum.sort(boundaries)
|
||
|
||
placed_groups =
|
||
case {placed, sorted_boundaries} do
|
||
{[], _} ->
|
||
[]
|
||
|
||
{ps, []} ->
|
||
[ps]
|
||
|
||
{ps, bs} ->
|
||
{groups, current} =
|
||
Enum.reduce(ps, {[], []}, fn root, {groups, current} ->
|
||
pos = root.position - 1
|
||
|
||
if pos in bs and current != [] do
|
||
{groups ++ [current], [root]}
|
||
else
|
||
{groups, current ++ [root]}
|
||
end
|
||
end)
|
||
|
||
groups ++ [current]
|
||
end
|
||
|
||
case unplaced do
|
||
[] -> placed_groups
|
||
us -> placed_groups ++ [us]
|
||
end
|
||
end
|
||
end
|