Files
bikoret/sanhedrin/src/judge.erl
T
Scott KarpandClaude Fable 5.1 4bffa01e0e tablet 55, third: roots the engine can read; abstentions count; an empty trace is no ground
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>
2026-10-19 18:18:50 +00:00

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