Files
bikoret/sanhedrin/src/panel.erl
T
Scott KarpandClaude Sonnet 4.6 80ec364da6 Phase 1-2 build: Steps 5-6, 8-13
Shoresh Engine (Haskell, Step 5)
- 12 modules: Types, Lexicon, Binyanim, Teamim, Gematria, Graph,
  KetivQere, Smikhut, Havdalah, Kriah, KiTov, Firmware
- 33 EUnit tests, 0 failures
- Firmware module verifies L1 SHA-256 hash at startup

Talmudic Engine (SWI-Prolog, Step 6)
- 15 modules: halt, reasoning, specification, interface
- 57 PlUnit tests, 0 failures
- Three cardinal halt conditions: avodah_zarah, shfichut_damim, gilui_arayot

Vav Pipeline (Elixir, Steps 8-11)
- 14 modules: BereishitCycle, Shema, Cloud, Manna, L1Reader,
  Specification, HaltConditions, ShabbatTimer, CycleLog, Chatzotzrot,
  ShoreshEngineClient, TalmudicEngineClient, Application
- 46 ExUnit tests, 0 failures
- Nishma-first architecture: hears before it speaks

Sanhedrin Framework (Erlang/OTP, Step 12)
- 8 modules: sanhedrin_app, sanhedrin_sup, escalation, panel_sup,
  panel, judge, clerk, halt_monitor
- 46 EUnit tests, 0 failures (includes fix: rejected outcome now reachable)
- Panel sizes: 1/3/23/71 — unanimous capital guilty → acquit (Sanhedrin 17a)
- Minority opinions append-only, never deleted

Korban Processor (Python, Step 13)
- 6 modules: korban, shechitah, semichah, zerikah, haktarah, router

Builder tooling: AGENTS.md, opencode.json, memory/, src/tools/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 14:02:34 -05:00

324 lines
9.9 KiB
Erlang

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