diff --git a/sanhedrin/src/judge.erl b/sanhedrin/src/judge.erl index 3d071c88..83609612 100644 --- a/sanhedrin/src/judge.erl +++ b/sanhedrin/src/judge.erl @@ -157,7 +157,16 @@ vote_from_consultation(Step, {ok, Map}, RootCount) -> Status = maps:get(status, Map, <<"teiku">>), Text = maps:get(text, Map, <<>>), Verses = maps:get(verses, Map, []), + Grounding = maps:get(grounding, Map, []), case Status of + <<"resolved">> when Verses =:= [], Grounding =:= [] -> + %% The engine says "resolved" over an empty trace — no verse, + %% no mitzvah. That is a reading that stands on nothing, and + %% a vote to accept it would be the calf (avodah_zarah.pl + %% Form 2: the uncoupled output). Abstain, and say why. + {<<"Teiku — the reading is coupled to no verse and no mitzvah">>, + reasoning(Step, <<"resolved on an empty trace; nothing to stand on">>, RootCount, Verses), + [], abstain}; <<"resolved">> -> {Text, reasoning(Step, <<"resolved">>, RootCount, Verses), diff --git a/sanhedrin/src/panel.erl b/sanhedrin/src/panel.erl index b357e734..89282f6a 100644 --- a/sanhedrin/src/panel.erl +++ b/sanhedrin/src/panel.erl @@ -207,15 +207,21 @@ compute_majority_minority([]) -> torah_refs => [], vote_count => 0}}; compute_majority_minority(Votes) -> - %% Count accepts vs rejects Accepts = [V || V <- Votes, V#vote.verdict == accept], Rejects = [V || V <- Votes, V#vote.verdict == reject], Abstains = [V || V <- Votes, V#vote.verdict == abstain], + NA = length(Accepts), NR = length(Rejects), NB = length(Abstains), + %% The majority is the verdict with the most votes — abstentions + %% count (tablet 55: live, one accept "won" against two + %% abstentions, and the reply showed majority 1, minority 2). An + %% abstaining plurality, or an accept/reject tie, is teiku + %% (determine_outcome reads the majority's verdict). {MajVotes, MinVotes} = - case length(Accepts) >= length(Rejects) of - true -> {Accepts, Rejects ++ Abstains}; - false -> {Rejects, Accepts ++ Abstains} + if + NA > NR, NA >= NB -> {Accepts, Rejects ++ Abstains}; + NR > NA, NR >= NB -> {Rejects, Accepts ++ Abstains}; + true -> {Abstains, Accepts ++ Rejects} end, MajSample = case MajVotes of diff --git a/sanhedrin/test/sanhedrin_SUITE.erl b/sanhedrin/test/sanhedrin_SUITE.erl index f5bdf8a3..ab980a09 100644 --- a/sanhedrin/test/sanhedrin_SUITE.erl +++ b/sanhedrin/test/sanhedrin_SUITE.erl @@ -506,6 +506,37 @@ majority_verdict_reject_test() -> {Maj, _Min} = panel:compute_majority_minority(Votes), ?assertEqual(reject, maps:get(verdict, Maj)). +%% Tablet 55: abstentions count. One accept against two abstentions is +%% not an accepting majority — it is teiku. +majority_abstaining_plurality_is_teiku_test() -> + Votes = [make_vote(accept), make_vote(abstain), make_vote(abstain)], + {Maj, Min} = panel:compute_majority_minority(Votes), + ?assertEqual(abstain, maps:get(verdict, Maj)), + ?assertEqual(2, maps:get(vote_count, Maj)), + ?assertEqual(1, maps:get(vote_count, Min)), + ?assertEqual(teiku, panel:determine_outcome(Maj, Min, passed)). + +majority_accept_reject_tie_is_teiku_test() -> + Votes = [make_vote(accept), make_vote(reject)], + {Maj, _Min} = panel:compute_majority_minority(Votes), + ?assertEqual(abstain, maps:get(verdict, Maj)), + ?assertEqual(0, maps:get(vote_count, Maj)). + +%% Tablet 55: "resolved" over an empty trace — no verse, no mitzvah — +%% is nothing to stand on; the judge abstains rather than accept. +judge_resolved_on_empty_trace_abstains_test() -> + {Pos, Reason, Refs, Verdict} = + judge:vote_from_consultation(interpret, + {ok, #{status => <<"resolved">>, text => <<"reading(0,0)">>, verses => [], grounding => []}}, 5), + ?assertEqual(abstain, Verdict), + ?assertEqual([], Refs), + ?assertMatch({_, _}, binary:match(Pos, <<"Teiku">>)), + ?assertMatch({_, _}, binary:match(Reason, <<"empty trace">>)), + %% a mitzvah without a verse is still ground + {_, _, _, V2} = judge:vote_from_consultation(interpret, + {ok, #{status => <<"resolved">>, text => <<"r">>, verses => [], grounding => [<<"mitzvah(5)">>]}}, 1), + ?assertEqual(accept, V2). + %% ============================================================ %% determine_outcome tests — accepted, rejected, teiku reachable %% ============================================================ diff --git a/vav_pipeline/lib/vav_pipeline/bereishit_cycle.ex b/vav_pipeline/lib/vav_pipeline/bereishit_cycle.ex index 51ae2f1f..6cfcd2f1 100644 --- a/vav_pipeline/lib/vav_pipeline/bereishit_cycle.ex +++ b/vav_pipeline/lib/vav_pipeline/bereishit_cycle.ex @@ -970,6 +970,40 @@ defmodule VavPipeline.BereishitCycle do 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 # ============================================================ diff --git a/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex b/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex index bb93f7ba..ee03f76d 100644 --- a/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex +++ b/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex @@ -1250,7 +1250,11 @@ defmodule VavPipeline.BigdeiKodesh.Router do {:ok, %{ - roots: Enum.map(roots_full, & &1.shoresh), + # The engine's verse facts name roots by letter — bet_resh_aleph — + # and json_bridge reads "bet-resh-aleph". The cycle's root carries + # the letter names beside the Hebrew; the Hebrew never matches + # (live, 2026-10-19: 5 roots sent, 0 verses found, "resolved"). + roots: roots_full |> Enum.map(&VavPipeline.BereishitCycle.engine_root_name/1) |> Enum.reject(&is_nil/1), roots_full: roots_full, torah_ref: {String.to_atom(scope.book), scope.chapter, scope.verse}, read_from: Map.get(state, :roots_read_from) diff --git a/vav_pipeline/test/bigdei_kodesh_test.exs b/vav_pipeline/test/bigdei_kodesh_test.exs index 7a5441d0..723dea15 100644 --- a/vav_pipeline/test/bigdei_kodesh_test.exs +++ b/vav_pipeline/test/bigdei_kodesh_test.exs @@ -403,8 +403,10 @@ defmodule VavPipeline.BigdeiKodeshTest do body = Jason.decode!(conn.resp_body) assert body["deliberation"]["outcome"] == "teiku" assert body["deliberation"]["panel_size"] == 1 - assert body["deliberation"]["majority"]["votes"] == 0 - assert body["deliberation"]["minority"]["reasoning"] =~ "no consultation" + # the one judge abstained: an abstaining majority of one, no dissent + assert body["deliberation"]["majority"]["votes"] == 1 + assert body["deliberation"]["majority"]["reasoning"] =~ "no consultation" + assert body["deliberation"]["minority"]["votes"] == 0 end # Tablet 55: with a consultation behind the judges, the dialectic diff --git a/vav_pipeline/test/tablet55_engine_root_name_test.exs b/vav_pipeline/test/tablet55_engine_root_name_test.exs new file mode 100644 index 00000000..c245b289 --- /dev/null +++ b/vav_pipeline/test/tablet55_engine_root_name_test.exs @@ -0,0 +1,27 @@ +defmodule VavPipeline.Tablet55EngineRootNameTest do + @moduledoc """ + Tablet 55: the Talmudic Engine names roots by letter (bet_resh_aleph, + read from "bet-resh-aleph"). The pipeline was sending it the Hebrew, + which its verse facts never match — roots in, no verses out, and a + "resolved" reading over the emptiness. + """ + use ExUnit.Case, async: true + + alias VavPipeline.BereishitCycle + + test "from the Shoresh Engine's letter names" do + assert BereishitCycle.engine_root_name(%{shoresh: "ברא", shoresh_letters: ["bet", "resh", "aleph"]}) == + "bet-resh-aleph" + end + + test "from the Hebrew alone (the L1 fallback), finals folded" do + assert BereishitCycle.engine_root_name(%{shoresh: "דבר", shoresh_letters: []}) == "dalet-bet-resh" + assert BereishitCycle.engine_root_name(%{shoresh: "מלך"}) == "mem-lamed-kaf" + end + + test "nothing nameable → nil, never a Hebrew string the engine cannot read" do + assert BereishitCycle.engine_root_name(%{shoresh: nil}) == nil + assert BereishitCycle.engine_root_name(%{shoresh: "x1"}) == nil + assert BereishitCycle.engine_root_name(%{}) == nil + end +end