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>
324 lines
9.9 KiB
Erlang
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).
|