%%%------------------------------------------------------------------- %%% @doc Panel — manage a deliberation panel. %%% %%% Architecture §3.2: %%% convene/3 spawns judges, deliberate/2 collects votes. %%% %%% THE CRITICAL CHECK: unanimous conviction on capital case %%% triggers suspicion of consensus — acquit. %%% %%% Minority opinions are ALWAYS preserved. %%% @end %%%------------------------------------------------------------------- -module(panel). -behaviour(gen_server). -include("sanhedrin.hrl"). %% API -export([start_link/2, deliberate_async/2, get_state/1]). %% Exported for testing -export([check_unanimous/2, compute_majority_minority/1, determine_outcome/3]). %% gen_server callbacks -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]). -record(state, { panel :: #panel{}, case_data :: #sanhedrin_case{}, timeout :: pos_integer(), escalation :: pid() | undefined, timer_ref :: reference() | undefined }). %%==================================================================== %% API %%==================================================================== start_link(Case, Size) -> gen_server:start_link(?MODULE, {Case, Size}, []). %% @doc Start deliberation asynchronously. %% The panel will collect votes and send the result %% back to escalation when done. -spec deliberate_async(pid(), pos_integer()) -> ok. deliberate_async(PanelPid, Timeout) -> gen_server:cast(PanelPid, {deliberate, Timeout}). %% @doc Get current panel state (for testing/inspection). -spec get_state(pid()) -> map(). get_state(PanelPid) -> gen_server:call(PanelPid, get_state). %%==================================================================== %% gen_server callbacks %%==================================================================== init({Case, Size}) -> PanelId = generate_id(), Now = erlang:system_time(millisecond), %% Spawn judges Judges = convene_judges(Case, Size), Panel = #panel{ panel_id = PanelId, case_ref = Case#sanhedrin_case.case_ref, size = Size, judges = Judges, votes = [], status = convened, convened_at = Now, decided_at = undefined }, {ok, #state{ panel = Panel, case_data = Case, timeout = ?TIMEOUT_MONETARY, escalation = whereis(escalation) }}. handle_call(get_state, _From, State) -> P = State#state.panel, Reply = #{ panel_id => P#panel.panel_id, case_ref => P#panel.case_ref, size => P#panel.size, status => P#panel.status, vote_count => length(P#panel.votes) }, {reply, Reply, State}; handle_call(_Request, _From, State) -> {reply, {error, unknown_request}, State}. handle_cast({deliberate, Timeout}, State) -> Panel = State#state.panel, %% Instruct all judges to deliberate lists:foreach( fun(JudgePid) -> judge:deliberate(JudgePid, self()) end, Panel#panel.judges ), %% Set timeout for deliberation TimerRef = erlang:send_after(Timeout, self(), deliberation_timeout), NewPanel = Panel#panel{status = deliberating}, {noreply, State#state{ panel = NewPanel, timeout = Timeout, timer_ref = TimerRef }}; handle_cast(_Msg, State) -> {noreply, State}. handle_info({vote, Vote}, State) -> Panel = State#state.panel, NewVotes = [Vote | Panel#panel.votes], NewPanel = Panel#panel{votes = NewVotes}, NewState = State#state{panel = NewPanel}, %% Check if all votes are in case length(NewVotes) >= Panel#panel.size of true -> %% All votes collected — decide maybe_cancel_timer(State#state.timer_ref), decide(NewState); false -> {noreply, NewState} end; handle_info(deliberation_timeout, State) -> %% Timeout — decide with whatever votes we have decide(State); handle_info(_Info, State) -> {noreply, State}. terminate(_Reason, State) -> %% Ensure all judges are stopped Panel = State#state.panel, lists:foreach( fun(JudgePid) -> case is_process_alive(JudgePid) of true -> judge:stop(JudgePid); false -> ok end end, Panel#panel.judges ), ok. %%==================================================================== %% Internal — Deliberation logic %%==================================================================== %% @doc Decide based on collected votes. %% THE CRITICAL CHECK: unanimous guilty on capital case → suspicion → acquit. decide(State) -> Panel = State#state.panel, Votes = Panel#panel.votes, Now = erlang:system_time(millisecond), {Majority, Minority} = compute_majority_minority(Votes), UnanimousCheck = check_unanimous(Panel#panel.size, Votes), Decision = #decision{ case_ref = Panel#panel.case_ref, outcome = determine_outcome(Majority, Minority, UnanimousCheck), majority = Majority, minority = Minority, panel_size = Panel#panel.size, deliberation_time_ms = Now - Panel#panel.convened_at, unanimous_check = UnanimousCheck }, %% Store all opinions via clerk (majority AND minority) store_all_opinions(Panel#panel.case_ref, Votes, Majority), %% Notify escalation case State#state.escalation of undefined -> ok; EscPid when is_pid(EscPid) -> EscPid ! {panel_decided, Panel#panel.case_ref, Decision} end, FinalPanel = Panel#panel{ status = decided, decided_at = Now }, {stop, normal, State#state{panel = FinalPanel}}. %% @doc Compute majority and minority positions. compute_majority_minority([]) -> {#{position => <<"no votes">>, reasoning => <<"no judges voted">>, torah_refs => [], vote_count => 0}, #{position => <<"no dissent">>, reasoning => <<"no judges voted">>, 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], {MajVotes, MinVotes} = case length(Accepts) >= length(Rejects) of true -> {Accepts, Rejects ++ Abstains}; false -> {Rejects, Accepts ++ Abstains} end, MajSample = case MajVotes of [First | _] -> First; [] -> #vote{position = <<"none">>, reasoning = <<"none">>, torah_refs = [], verdict = abstain, judge_id = make_ref(), cast_at = 0} end, MinSample = case MinVotes of [MFirst | _] -> MFirst; [] -> #vote{position = <<"no dissent">>, reasoning = <<"unanimous">>, torah_refs = [], verdict = abstain, judge_id = make_ref(), cast_at = 0} end, Majority = #{ position => MajSample#vote.position, reasoning => MajSample#vote.reasoning, torah_refs => MajSample#vote.torah_refs, vote_count => length(MajVotes), verdict => MajSample#vote.verdict }, Minority = #{ position => MinSample#vote.position, reasoning => MinSample#vote.reasoning, torah_refs => MinSample#vote.torah_refs, vote_count => length(MinVotes), verdict => MinSample#vote.verdict }, {Majority, Minority}. %% @doc THE CRITICAL CHECK. %% Architecture §3.2: unanimous conviction on capital case %% triggers suspicion of malfunction — acquit. %% %% "If the Sanhedrin unanimously finds guilty, the accused is acquitted." %% — Sanhedrin 17a check_unanimous(Size, Votes) when Size >= ?PANEL_CAPITAL -> AllGuilty = lists:all( fun(V) -> V#vote.verdict == reject end, Votes ), case AllGuilty andalso length(Votes) > 0 of true -> {failed, suspicion_of_consensus}; false -> passed end; check_unanimous(_Size, _Votes) -> %% Unanimous check only applies to capital+ panels passed. %% @doc Determine the outcome from majority, minority, and unanimous check. determine_outcome(_Majority, _Minority, {failed, suspicion_of_consensus}) -> %% Unanimous guilty on capital → acquit (accepted, not rejected) accepted; determine_outcome(Majority, _Minority, passed) -> MajCount = maps:get(vote_count, Majority, 0), MajVerdict = maps:get(verdict, Majority, abstain), case {MajCount, MajVerdict} of {0, _} -> teiku; {_, accept} -> accepted; {_, reject} -> rejected; {_, abstain} -> teiku end. %% @doc Store all opinions via clerk — majority AND minority preserved. store_all_opinions(CaseRef, Votes, Majority) -> MajPosition = maps:get(position, Majority, <<>>), lists:foreach( fun(Vote) -> IsMajority = (Vote#vote.position == MajPosition), Opinion = #opinion{ case_ref = CaseRef, judge_id = Vote#vote.judge_id, position = Vote#vote.position, reasoning = Vote#vote.reasoning, torah_refs = Vote#vote.torah_refs, verdict = Vote#vote.verdict, is_majority = IsMajority, stored_at = erlang:system_time(millisecond) }, clerk:store_opinion(Opinion) end, Votes ). %%==================================================================== %% Internal — Panel setup %%==================================================================== convene_judges(Case, Size) -> lists:map( fun(_N) -> {ok, Pid} = judge:start_link(Case), Pid end, lists:seq(1, Size) ). generate_id() -> Bytes = crypto:strong_rand_bytes(8), base64:encode(Bytes). maybe_cancel_timer(undefined) -> ok; maybe_cancel_timer(Ref) -> erlang:cancel_timer(Ref).