Files
bikoret/vav_pipeline/lib/vav_pipeline/bereishit_cycle.ex
T
Scott KarpandClaude Fable 5.1 6d3411fcfa engine: a word's root is what L1 records for it (tablet 33)
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>
2026-10-04 17:28:08 +00:00

1037 lines
36 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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