%%%------------------------------------------------------------------- %%% @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 ).