Files
bikoret/vav_pipeline/lib/vav_pipeline/bereishit_cycle.ex
T
Scott KarpandClaude Fable 5.1 4e0eb2f983 cycle: the engine is consulted on every resolved question
The Talmudic route ran only on a teiku from ki_tov, and ki_tov returns
teiku only for a root with no Hebrew — which the Shoresh Engine never
leaves. So the engine had never read a real question. Now every cycle
that resolves tov consults it with the roots it activated
(consult_engine/2, on the socket directly — never the client's stub;
an unreachable engine is reported "not consulted" and the cycle's
answer stands). A tamid probe does not consult.

The reading is said plainly (engine_reading_text/1): "Talmudic Engine
(resolved): 11 verse(s): Bereishit 1:1, …; 4 mitzvah/mitzvot:
know_hashem (1, positive), …" — the engine's own term form is no longer
pasted into a reader's answer. The verses join the sources as
references ("Bereishit 1:3", engine form beside), the L3 interpretation
carries them in torah_references with the reading in its reasoning and
processing_component "bereishit_cycle+talmudic_engine".

Proven against the engine running locally on its socket, bereishit 1:1
through VavPipeline.process/1: consulted :ok, 11 verses in the answer
and the sources, L3 torah_references 12; engine stopped → "not
consulted (Cannot connect)", answer stands, L3 component
bereishit_cycle; tamid probe → not consulted. Elixir 632/0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 19:02:51 +00:00

1184 lines
43 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, TalmudicEngineSocket,
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} ->
# Every question is read against the engine, not only a teiku
# that never comes (2026-10-19: ki_tov returns teiku only on a
# root with no Hebrew, and the Shoresh Engine never leaves one;
# the Talmudic route had never been walked by a real question).
data |> consult_engine(context) |> check_halt_on_output()
{: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
# ============================================================
# The engine, consulted on every resolved cycle
# ============================================================
# Architecture §3.3: the Talmudic Engine grounds a reading in verses
# and mitzvot over the case's roots. The cycle's own ki_tov says the
# roots stand in L1; the engine says what stands on them. Called
# directly on the socket — never the client's stub (tablet 41): an
# engine that cannot be reached is reported as not consulted, and the
# cycle's answer stands without it. A tamid probe does not consult
# (the fire is checked, the engine is not loaded by the check).
defp consult_engine(%{probe: true} = data, _context), do: data
defp consult_engine(data, _context) do
roots = Map.get(data, :roots_activated, [])
names = roots |> Enum.map(&engine_root_name/1) |> Enum.reject(&is_nil/1)
case_ref = Map.get(data, :case_ref, generate_case_ref())
cond do
names == [] ->
Map.put(data, :talmudic_consulted, {:skipped, :no_roots})
Process.whereis(TalmudicEngineSocket) == nil ->
Map.put(data, :talmudic_consulted, {:error, :engine_socket_not_started})
true ->
case TalmudicEngineSocket.interpret(case_ref, %{context_roots: names, question: Map.get(data, :raw, "")}) do
{:ok, %{"status" => _} = reply} ->
Map.merge(data, %{talmudic_interpretation: reply, talmudic_consulted: :ok})
{:ok, other} ->
Logger.warning("BereishitCycle: engine answered an unexpected shape for #{case_ref}: #{inspect(other)}")
Map.put(data, :talmudic_consulted, {:error, :unexpected_reply})
{:error, reason} ->
Logger.warning("BereishitCycle: engine not consulted for #{case_ref}: #{inspect(reason)}")
Map.put(data, :talmudic_consulted, {:error, reason})
end
end
end
@doc """
The engine's reading, said plainly: how many verses and mitzvot it
coupled to the roots, and which. `nil` when there is no reading.
"""
@spec engine_reading_text(map()) :: String.t() | nil
def engine_reading_text(data) do
case Map.get(data, :talmudic_interpretation) do
%{"interpretation" => interp} = reply when is_map(interp) ->
verses = interp |> Map.get("verses", []) |> List.wrap() |> Enum.map(&verse_ref_display/1) |> Enum.reject(&is_nil/1) |> Enum.uniq()
mitzvot = interp |> Map.get("grounding", []) |> List.wrap() |> Enum.map(&mitzvah_display/1) |> Enum.reject(&is_nil/1) |> Enum.uniq()
status = Map.get(reply, "status", "")
cond do
verses == [] and mitzvot == [] ->
"Talmudic Engine (#{status}): no verse or mitzvah is coupled to these roots."
true ->
v = if verses == [], do: "no verse", else: "#{length(verses)} verse(s): #{Enum.join(verses, ", ")}"
m = if mitzvot == [], do: "no mitzvah", else: "#{length(mitzvot)} mitzvah/mitzvot: #{Enum.join(mitzvot, ", ")}"
"Talmudic Engine (#{status}): #{v}; #{m}."
end
_ ->
nil
end
end
# "verse_ref(bereishit,1,1,'...')" / "verse(shemot,3,14)" → "Bereishit 1:1"
defp verse_ref_display(s) do
case Regex.run(~r/verse(?:_ref)?\((bereishit|shemot|vayikra|bamidbar|devarim)\s*,\s*(\d+)\s*,\s*(\d+)/, to_string(s)) do
[_, book, ch, vs] -> "#{String.capitalize(book)} #{ch}:#{vs}"
_ -> nil
end
end
# "mitzvah(1,positive,know_hashem,always)" → "know_hashem (1, positive)"
defp mitzvah_display(s) do
case Regex.run(~r/mitzvah\((\d+)\s*,\s*(\w+)\s*,\s*(\w+)/, to_string(s)) do
[_, id, kind, name] -> "#{name} (#{id}, #{kind})"
_ -> nil
end
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 ->
" " <> (engine_reading_text(data) || "Talmudic Engine resolved the case.")
:sanhedrin ->
pos = get_in(data, [:sanhedrin_decision, :majority, :position]) || ""
" Sanhedrin majority: #{pos}"
_ ->
# Consulted after ki_tov (every resolved question), or not.
case {engine_reading_text(data), Map.get(data, :talmudic_consulted)} do
{text, _} when is_binary(text) -> " " <> text
{nil, {:error, reason}} -> " Talmudic Engine: not consulted (#{inspect(reason)})."
_ -> ""
end
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
}
# The engine's verses as references a reader can follow ("Bereishit
# 1:3"), the engine's own form kept beside them; the scope verse is
# not repeated.
talmudic_verses =
case get_in(data, [:talmudic_interpretation, "interpretation", "verses"]) do
vs when is_list(vs) ->
vs
|> Enum.map(fn v -> %{ref: verse_ref_display(v) || to_string(v), engine_form: to_string(v)} end)
|> Enum.uniq_by(& &1.ref)
|> Enum.reject(&(&1.ref == verse.ref))
_ ->
[]
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}
_ ->
# The engine's reading, when it was consulted, rides in the record:
# its verses beside the scope, its text in the reasoning, and the
# component names both readers.
interp = get_in(data, [:talmudic_interpretation, "interpretation"]) || %{}
engine_verses = interp |> Map.get("verses", []) |> to_string_list()
reading = engine_reading_text(data)
attrs =
Map.merge(base, %{
reasoning:
if(reading, do: single_judge_reasoning(data, context) <> " " <> reading, else: single_judge_reasoning(data, context)),
torah_references: Enum.uniq([scope_ref | engine_verses]),
panel_size: 1,
vote_count_for: 1,
vote_count_against: 0,
processing_component: if(reading, do: "bereishit_cycle+talmudic_engine", else: "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, [])
# Tablet 55: the engine names roots by letter (bet_resh_aleph); the
# Hebrew was sent since Step 14 and never matched a verse fact, so
# every Talmudic interpretation was "resolved" over an empty trace.
context_roots = roots |> Enum.map(&engine_root_name/1) |> Enum.reject(&is_nil/1)
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
# The inverse: Hebrew consonant → letter name, finals folded to their base.
@hebrew_to_name Map.new(@consonant_names, fn {name, heb} -> {heb, name} end)
|> Map.merge(%{"ך" => "kaf", "ם" => "mem", "ן" => "nun", "ף" => "pe", "ץ" => "tsadi"})
@doc """
The root as the Talmudic Engine names it — letter names joined by `-`
(`json_bridge` `parse_root_name` → `bet_resh_aleph`, the form of its
verse facts). From the letter names the Shoresh Engine gives when it
has them, else from the Hebrew (the L1 fallback carries only that).
Tablet 55: the engine was being sent the Hebrew, which its facts never
match — 5 roots in, 0 verses out, and "resolved" over the emptiness.
`nil` when the root carries nothing nameable.
"""
@spec engine_root_name(map()) :: String.t() | nil
def engine_root_name(root) when is_map(root) do
letters = Map.get(root, :shoresh_letters) || []
names =
if is_list(letters) and letters != [] do
Enum.map(letters, &String.downcase(to_string(&1)))
else
root
|> Map.get(:shoresh)
|> case do
s when is_binary(s) -> s |> String.graphemes() |> Enum.map(&Map.get(@hebrew_to_name, &1))
_ -> [nil]
end
end
if names != [] and Enum.all?(names, &is_binary/1), do: Enum.join(names, "-"), else: nil
end
def engine_root_name(_), 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