Live after the second commit (vav :046010f0): a monetary case naming bereishit 1:1 convened three judges and came back "accepted" — the interpret seat said "resolved (5 roots, 0 verses)" with reading(mitzvot_count(0),verse_count(0),grounded_in([],[])), the other two seats said teiku, and one accept beat two abstentions (the reply showed majority 1, minority 2). Three defects the tests could not see: - The engine names roots by letter (bet_resh_aleph; json_bridge reads "bet-resh-aleph") and the pipeline sent it the Hebrew, which its verse facts never match. BereishitCycle.engine_root_name/1 builds the engine's name from the Shoresh Engine's letter names or, for the L1 fallback, from the Hebrew with finals folded; the router sends that. (The cycle's own Talmudic routing still sends the Hebrew — recorded, not changed here: it alters /peah/ask and needs its own verification.) - judge.erl: "resolved" with no verse and no mitzvah is a reading coupled to nothing (avodah_zarah.pl Form 2); the judge abstains and says "resolved on an empty trace; nothing to stand on". - panel.erl: the majority is the verdict with the most votes, abstentions included; an abstaining plurality or an accept/reject tie is teiku. EUnit 57/0 (plurality, tie, empty trace). Elixir 632/0 (engine_root_name from names, from Hebrew, nil on nothing; the no-engine case is an abstaining majority of one, teiku). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
221 lines
8.4 KiB
Erlang
221 lines
8.4 KiB
Erlang
%%%-------------------------------------------------------------------
|
|
%%% @doc Judge — individual deliberator (gen_server).
|
|
%%%
|
|
%%% Architecture §3.2:
|
|
%%% Each judge:
|
|
%%% 1. Receives the case
|
|
%%% 2. Consults the specification (L0/L1)
|
|
%%% 3. Generates an independent interpretation
|
|
%%% 4. Casts a vote with reasoning
|
|
%%% 5. Reasoning is preserved regardless of outcome
|
|
%%%
|
|
%%% Judges deliberate independently — they do not see
|
|
%%% other judges' votes until the panel collects them.
|
|
%%%
|
|
%%% Tablet 55 (2026-10-19): until this rewrite the judge chose one of
|
|
%%% six canned positions by a hash over the question, its own pid and
|
|
%%% the clock. The verdict had no relation to the case; "Torah
|
|
%%% references ground this interpretation (0 roots, 0 references
|
|
%%% examined)" was accepted on nothing — the calf, cast by the court
|
|
%%% itself. Now a judge CONSULTS: the application env `consult` names
|
|
%%% an {M, F} the pipeline registers (the Talmudic Engine behind it),
|
|
%%% and each seat on the panel performs one step of the dialectic —
|
|
%%% interpret, challenge, resolve, cycling by seat — on the case's
|
|
%%% roots and question. Resolved → accept, with the verses; a standing
|
|
%%% kushya → reject; teiku → abstain. No consult, or an engine that
|
|
%%% cannot be reached → abstain, and the reasoning says so. Dissent
|
|
%%% arises from the dialectic, never from a die.
|
|
%%% @end
|
|
%%%-------------------------------------------------------------------
|
|
-module(judge).
|
|
|
|
-behaviour(gen_server).
|
|
|
|
-include("sanhedrin.hrl").
|
|
|
|
%% API
|
|
-export([start_link/1, start_link/2, deliberate/2, stop/1]).
|
|
|
|
%% Exposed for the suite
|
|
-export([step_for_seat/1, vote_from_consultation/3]).
|
|
|
|
%% gen_server callbacks
|
|
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
|
|
|
|
-record(state, {
|
|
judge_id :: reference(),
|
|
seat :: pos_integer(),
|
|
case_data :: #sanhedrin_case{},
|
|
vote :: #vote{} | undefined,
|
|
panel_pid :: pid() | undefined
|
|
}).
|
|
|
|
-define(STEPS, [interpret, challenge, resolve]).
|
|
|
|
%%====================================================================
|
|
%% API
|
|
%%====================================================================
|
|
|
|
start_link(Case) ->
|
|
start_link(Case, 1).
|
|
|
|
%% @doc Start a judge in seat N of its panel (1-based).
|
|
start_link(Case, Seat) ->
|
|
gen_server:start_link(?MODULE, {Case, Seat}, []).
|
|
|
|
%% @doc Instruct this judge to deliberate and send vote to PanelPid.
|
|
-spec deliberate(pid(), pid()) -> ok.
|
|
deliberate(JudgePid, PanelPid) ->
|
|
gen_server:cast(JudgePid, {deliberate, PanelPid}).
|
|
|
|
%% @doc Stop this judge.
|
|
-spec stop(pid()) -> ok.
|
|
stop(JudgePid) ->
|
|
gen_server:stop(JudgePid, normal, 5000).
|
|
|
|
%% @doc The step of the dialectic a seat performs: 1 → interpret,
|
|
%% 2 → challenge, 3 → resolve, 4 → interpret, ...
|
|
-spec step_for_seat(pos_integer()) -> interpret | challenge | resolve.
|
|
step_for_seat(Seat) when is_integer(Seat), Seat >= 1 ->
|
|
lists:nth(((Seat - 1) rem length(?STEPS)) + 1, ?STEPS).
|
|
|
|
%%====================================================================
|
|
%% gen_server callbacks
|
|
%%====================================================================
|
|
|
|
init({Case, Seat}) ->
|
|
{ok, #state{
|
|
judge_id = make_ref(),
|
|
seat = Seat,
|
|
case_data = Case,
|
|
vote = undefined,
|
|
panel_pid = undefined
|
|
}};
|
|
init(Case) ->
|
|
init({Case, 1}).
|
|
|
|
handle_call(_Request, _From, State) ->
|
|
{reply, {error, unknown_request}, State}.
|
|
|
|
handle_cast({deliberate, PanelPid}, State) ->
|
|
Vote = perform_deliberation(State#state.judge_id, State#state.seat, State#state.case_data),
|
|
%% Send vote to panel
|
|
PanelPid ! {vote, Vote},
|
|
{noreply, State#state{vote = Vote, panel_pid = PanelPid}};
|
|
handle_cast(_Msg, State) ->
|
|
{noreply, State}.
|
|
|
|
handle_info(_Info, State) ->
|
|
{noreply, State}.
|
|
|
|
%%====================================================================
|
|
%% Internal — Deliberation
|
|
%%====================================================================
|
|
|
|
perform_deliberation(JudgeId, Seat, Case) ->
|
|
Step = step_for_seat(Seat),
|
|
Context = Case#sanhedrin_case.context,
|
|
Roots = maps:get(roots, Context, []),
|
|
Question = Case#sanhedrin_case.question,
|
|
Consultation = consult(Step, Case#sanhedrin_case.case_ref, Roots, Question, Context),
|
|
{Position, Reasoning, Refs, Verdict} = vote_from_consultation(Step, Consultation, length(Roots)),
|
|
#vote{
|
|
judge_id = JudgeId,
|
|
position = Position,
|
|
reasoning = Reasoning,
|
|
torah_refs = build_refs(Refs),
|
|
verdict = Verdict,
|
|
cast_at = erlang:system_time(millisecond)
|
|
}.
|
|
|
|
%% The consult callback: {M, F} in the sanhedrin application env,
|
|
%% called as M:F(Step, CaseRef, Roots, Question, Context) and expected
|
|
%% to answer {ok, #{status := binary(), text := binary(),
|
|
%% verses := [{atom(), integer(), integer()}], kushya => binary()}}
|
|
%% or {error, term()}. Anything it raises is caught: a judge that
|
|
%% cannot consult abstains; it does not fall.
|
|
consult(Step, CaseRef, Roots, Question, Context) ->
|
|
case application:get_env(sanhedrin, consult) of
|
|
{ok, {M, F}} when is_atom(M), is_atom(F) ->
|
|
try M:F(Step, CaseRef, Roots, Question, Context) of
|
|
{ok, Map} when is_map(Map) -> {ok, Map};
|
|
{error, Reason} -> {error, Reason};
|
|
Other -> {error, {bad_consultation, Other}}
|
|
catch
|
|
Class:Reason -> {error, {Class, Reason}}
|
|
end;
|
|
_ ->
|
|
{error, no_consult_registered}
|
|
end.
|
|
|
|
%% @doc The vote a consultation yields. Exposed for the suite.
|
|
-spec vote_from_consultation(interpret | challenge | resolve,
|
|
{ok, map()} | {error, term()},
|
|
non_neg_integer()) ->
|
|
{binary(), binary(), list(), accept | reject | abstain}.
|
|
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),
|
|
Verses, accept};
|
|
<<"challenged">> ->
|
|
Kushya = maps:get(kushya, Map, <<"a kushya stands">>),
|
|
{Kushya,
|
|
reasoning(Step, <<"a kushya stands against the reading">>, RootCount, Verses),
|
|
Verses, reject};
|
|
<<"halt">> ->
|
|
{Text,
|
|
reasoning(Step, <<"the halt rules fired">>, RootCount, Verses),
|
|
Verses, reject};
|
|
_ ->
|
|
{<<"Teiku — the question is not resolved from the specification">>,
|
|
reasoning(Step, <<"teiku">>, RootCount, Verses),
|
|
Verses, abstain}
|
|
end;
|
|
vote_from_consultation(Step, {error, Reason}, RootCount) ->
|
|
{<<"Teiku — the specification was not consulted">>,
|
|
iolist_to_binary([step_name(Step), <<": no consultation (">>,
|
|
io_lib:format("~p", [Reason]), <<") — ">>,
|
|
integer_to_binary(RootCount), <<" roots given; abstaining">>]),
|
|
[], abstain}.
|
|
|
|
reasoning(Step, What, RootCount, Verses) ->
|
|
iolist_to_binary([
|
|
step_name(Step), <<" via the Talmudic Engine: ">>, What,
|
|
<<" (">>, integer_to_binary(RootCount), <<" roots, ">>,
|
|
integer_to_binary(length(Verses)), <<" verses)">>
|
|
]).
|
|
|
|
step_name(interpret) -> <<"interpret">>;
|
|
step_name(challenge) -> <<"challenge">>;
|
|
step_name(resolve) -> <<"resolve">>.
|
|
|
|
build_refs(TorahRefs) ->
|
|
lists:map(
|
|
fun({Book, Chapter, Verse}) ->
|
|
#torah_ref{book = Book, chapter = Chapter, verse = Verse};
|
|
(Ref) when is_map(Ref) ->
|
|
#torah_ref{
|
|
book = maps:get(book, Ref, unknown),
|
|
chapter = maps:get(chapter, Ref, 0),
|
|
verse = maps:get(verse, Ref, 0)
|
|
};
|
|
(_) ->
|
|
#torah_ref{book = unknown, chapter = 0, verse = 0}
|
|
end,
|
|
TorahRefs
|
|
).
|