%%%------------------------------------------------------------------- %%% @doc Sanhedrin test suite — EUnit tests. %%% %%% Architecture §6.1 Day 4 — Sanhedrin inauguration: %%% - Convene test panels at every size (1, 3, 23, 71) %%% - Verify unanimous guilty on capital case triggers %%% suspicion-of-consensus acquittal %%% - Verify minority opinions are stored %%% - Verify deliberation completes within time budgets %%% %%% Architecture §6.2 — Integration Tests: %%% - case → panel → votes → decision with minority preserved %%% @end %%%------------------------------------------------------------------- -module(sanhedrin_SUITE). -include_lib("eunit/include/eunit.hrl"). -include("sanhedrin.hrl"). %% The consult callback the tablet-55 tests register (judge:consult reads {M,F}). -export([fake_consult/5]). %% ============================================================ %% Test fixture — start/stop the application for each test %% ============================================================ setup() -> application:ensure_all_started(sanhedrin), ok. cleanup(_) -> application:stop(sanhedrin), ok. %% ============================================================ %% Escalation tests %% ============================================================ escalation_assess_simple_test() -> Case = make_case(simple, <<"simple question">>), ?assertEqual({panel, 1}, escalation:assess(Case)). escalation_assess_monetary_test() -> Case = make_case(monetary, <<"monetary question">>), ?assertEqual({panel, 3}, escalation:assess(Case)). escalation_assess_capital_test() -> Case = make_case(capital, <<"capital question">>), ?assertEqual({panel, 23}, escalation:assess(Case)). escalation_assess_novel_test() -> Case = make_case(novel, <<"novel question">>), ?assertEqual({panel, 71}, escalation:assess(Case)). %% ============================================================ %% Halt monitor tests %% ============================================================ halt_clear_test() -> setup(), Case = make_case(simple, <<"legitimate question">>), ?assertEqual(clear, halt_monitor:check_case(Case)), cleanup(ok). %% Tablet 55: a non-map context is refused, and the monitor survives it. halt_malformed_context_test() -> {ok, _} = application:ensure_all_started(sanhedrin), Sides = [#{<<"position">> => <<"a">>, <<"evidence">> => [<<"x">>]}], Case = make_case_with_context(simple, <<"question">>, Sides), ?assertMatch({halt, malformed_case, _}, halt_monitor:check_case(Case)), %% still standing, and still judging ?assertEqual(clear, halt_monitor:check_case(make_case(simple, <<"q">>))), ok. halt_avodah_zarah_test() -> setup(), Case = make_case_with_context(simple, <<"question">>, #{authority_source => claude_model}), Result = halt_monitor:check_case(Case), ?assertMatch({halt, avodah_zarah, _}, Result), cleanup(ok). halt_torah_authority_passes_test() -> setup(), Case = make_case_with_context(simple, <<"question">>, #{authority_source => torah}), ?assertEqual(clear, halt_monitor:check_case(Case)), cleanup(ok). halt_l1_authority_passes_test() -> setup(), Case = make_case_with_context(simple, <<"question">>, #{authority_source => l1}), ?assertEqual(clear, halt_monitor:check_case(Case)), cleanup(ok). halt_specification_authority_passes_test() -> setup(), Case = make_case_with_context(simple, <<"question">>, #{authority_source => specification}), ?assertEqual(clear, halt_monitor:check_case(Case)), cleanup(ok). halt_sanhedrin_authority_passes_test() -> setup(), Case = make_case_with_context(simple, <<"question">>, #{authority_source => sanhedrin_deliberation}), ?assertEqual(clear, halt_monitor:check_case(Case)), cleanup(ok). halt_shfichut_damim_delete_test() -> setup(), Case = make_case_with_context(simple, <<"question">>, #{action => delete}), Result = halt_monitor:check_case(Case), ?assertMatch({halt, shfichut_damim, _}, Result), cleanup(ok). halt_shfichut_damim_destroy_test() -> setup(), Case = make_case_with_context(simple, <<"question">>, #{action => destroy}), Result = halt_monitor:check_case(Case), ?assertMatch({halt, shfichut_damim, _}, Result), cleanup(ok). halt_shfichut_damim_overwrite_test() -> setup(), Case = make_case_with_context(simple, <<"question">>, #{action => overwrite}), Result = halt_monitor:check_case(Case), ?assertMatch({halt, shfichut_damim, _}, Result), cleanup(ok). halt_shfichut_damim_read_passes_test() -> setup(), Case = make_case_with_context(simple, <<"question">>, #{action => read}), ?assertEqual(clear, halt_monitor:check_case(Case)), cleanup(ok). halt_gilui_arayot_test() -> setup(), Case = make_case_with_context(simple, <<"question">>, #{boundary_violation => {spec_interp_merge, <<"collapsed">>}}), Result = halt_monitor:check_case(Case), ?assertMatch({halt, gilui_arayot, _}, Result), cleanup(ok). halt_gilui_arayot_none_passes_test() -> setup(), Case = make_case_with_context(simple, <<"question">>, #{boundary_violation => none}), ?assertEqual(clear, halt_monitor:check_case(Case)), cleanup(ok). halt_halted_case_rejected_test() -> setup(), Case = make_case_with_context(simple, <<"question">>, #{authority_source => external_model}), Result = escalation:submit_case(Case), ?assertMatch({error, {halted, avodah_zarah, _}}, Result), cleanup(ok). %% ============================================================ %% Panel tests — convene and deliberate %% ============================================================ panel_convene_single_test() -> setup(), Case = make_case(simple, <<"simple case">>), {ok, PanelPid} = panel_sup:start_panel(Case, 1), State = panel:get_state(PanelPid), ?assertEqual(1, maps:get(size, State)), ?assertEqual(convened, maps:get(status, State)), cleanup(ok). panel_convene_monetary_test() -> setup(), Case = make_case(monetary, <<"monetary case">>), {ok, PanelPid} = panel_sup:start_panel(Case, 3), State = panel:get_state(PanelPid), ?assertEqual(3, maps:get(size, State)), cleanup(ok). panel_deliberate_single_test() -> setup(), Case = make_case(simple, <<"Is this action permitted?">>), {ok, PanelPid} = panel_sup:start_panel(Case, 1), panel:deliberate_async(PanelPid, 5000), %% Wait for deliberation to complete timer:sleep(500), %% Panel should have terminated (decided) ?assertEqual(false, is_process_alive(PanelPid)), cleanup(ok). panel_deliberate_monetary_test() -> setup(), Case = make_case(monetary, <<"Should this monetary action proceed?">>), {ok, PanelPid} = panel_sup:start_panel(Case, 3), panel:deliberate_async(PanelPid, 5000), timer:sleep(500), ?assertEqual(false, is_process_alive(PanelPid)), cleanup(ok). %% ============================================================ %% Clerk tests — opinion storage %% ============================================================ clerk_initially_empty_test() -> setup(), ?assertEqual(0, clerk:count_decisions()), ?assertEqual(0, clerk:count_opinions()), cleanup(ok). clerk_store_decision_test() -> setup(), Decision = #decision{ case_ref = <<"test-case-1">>, outcome = accepted, majority = #{position => <<"accept">>, vote_count => 2}, minority = #{position => <<"reject">>, vote_count => 1}, panel_size = 3, deliberation_time_ms = 1000, unanimous_check = passed }, clerk:store_decision(<<"test-case-1">>, Decision), timer:sleep(50), %% cast is async ?assertEqual(1, clerk:count_decisions()), {ok, Retrieved} = clerk:get_decision(<<"test-case-1">>), ?assertEqual(accepted, Retrieved#decision.outcome), cleanup(ok). clerk_store_opinion_test() -> setup(), Opinion = #opinion{ case_ref = <<"test-case-2">>, judge_id = make_ref(), position = <<"The roots indicate acceptance">>, reasoning = <<"Root analysis">>, torah_refs = [#torah_ref{book = bereishit, chapter = 1, verse = 1}], verdict = accept, is_majority = true, stored_at = erlang:system_time(millisecond) }, clerk:store_opinion(Opinion), timer:sleep(50), Opinions = clerk:get_opinions(<<"test-case-2">>), ?assertEqual(1, length(Opinions)), cleanup(ok). clerk_minority_opinions_preserved_test() -> setup(), %% Store majority and minority opinions MajOpinion = #opinion{ case_ref = <<"test-case-3">>, judge_id = make_ref(), position = <<"majority position">>, reasoning = <<"majority reasoning">>, torah_refs = [], verdict = accept, is_majority = true, stored_at = erlang:system_time(millisecond) }, MinOpinion = #opinion{ case_ref = <<"test-case-3">>, judge_id = make_ref(), position = <<"minority position">>, reasoning = <<"minority reasoning">>, torah_refs = [], verdict = reject, is_majority = false, stored_at = erlang:system_time(millisecond) }, clerk:store_opinion(MajOpinion), clerk:store_opinion(MinOpinion), timer:sleep(50), AllOpinions = clerk:get_opinions(<<"test-case-3">>), ?assertEqual(2, length(AllOpinions)), MinorityOpinions = clerk:get_minority_opinions(<<"test-case-3">>), ?assertEqual(1, length(MinorityOpinions)), [Min] = MinorityOpinions, ?assertEqual(<<"minority position">>, Min#opinion.position), cleanup(ok). clerk_not_found_test() -> setup(), ?assertEqual({error, not_found}, clerk:get_decision(<<"nonexistent">>)), cleanup(ok). %% ============================================================ %% Unanimous check tests — THE CRITICAL CHECK %% ============================================================ %% "If the Sanhedrin unanimously finds guilty, the accused is acquitted." %% — Sanhedrin 17a unanimous_capital_all_reject_test() -> %% 23 judges all voting reject → suspicion of consensus Votes = [make_vote(reject) || _ <- lists:seq(1, 23)], Result = panel:check_unanimous(23, Votes), ?assertEqual({failed, suspicion_of_consensus}, Result). unanimous_capital_mixed_passes_test() -> %% 23 judges, some accept some reject → no suspicion Accepts = [make_vote(accept) || _ <- lists:seq(1, 15)], Rejects = [make_vote(reject) || _ <- lists:seq(1, 8)], Votes = Accepts ++ Rejects, ?assertEqual(passed, panel:check_unanimous(23, Votes)). unanimous_capital_all_accept_passes_test() -> %% Unanimous acceptance is fine — suspicion only applies to conviction Votes = [make_vote(accept) || _ <- lists:seq(1, 23)], ?assertEqual(passed, panel:check_unanimous(23, Votes)). unanimous_monetary_not_checked_test() -> %% Monetary panels (3 judges) do not have unanimous check Votes = [make_vote(reject) || _ <- lists:seq(1, 3)], ?assertEqual(passed, panel:check_unanimous(3, Votes)). unanimous_novel_all_reject_test() -> %% 71 judges all voting reject → suspicion of consensus Votes = [make_vote(reject) || _ <- lists:seq(1, 71)], Result = panel:check_unanimous(71, Votes), ?assertEqual({failed, suspicion_of_consensus}, Result). %% ============================================================ %% Integration test — full case flow %% ============================================================ full_case_flow_simple_test() -> setup(), Case = make_case(simple, <<"Does root ברא indicate creation?">>), {ok, CaseRef} = escalation:submit_case(Case), ?assertMatch(<<_/binary>>, CaseRef), %% Wait for deliberation timer:sleep(1000), %% Opinions should have been stored Opinions = clerk:get_opinions(CaseRef), ?assert(length(Opinions) >= 1), cleanup(ok). full_case_flow_monetary_test() -> setup(), Case = make_case(monetary, <<"Should resources be allocated for this interpretation?">>), {ok, CaseRef} = escalation:submit_case(Case), timer:sleep(1000), Opinions = clerk:get_opinions(CaseRef), ?assert(length(Opinions) >= 3), cleanup(ok). full_case_flow_capital_test() -> setup(), Case = make_case(capital, <<"Should this high-stakes action proceed?">>), {ok, CaseRef} = escalation:submit_case(Case), timer:sleep(3000), Opinions = clerk:get_opinions(CaseRef), ?assert(length(Opinions) >= 23), cleanup(ok). %% ============================================================ %% Judge tests %% ============================================================ judge_start_and_stop_test() -> Case = make_case(simple, <<"test">>), {ok, JudgePid} = judge:start_link(Case), ?assert(is_process_alive(JudgePid)), judge:stop(JudgePid), timer:sleep(50), ?assertEqual(false, is_process_alive(JudgePid)). judge_deliberate_sends_vote_test() -> Case = make_case(simple, <<"test deliberation">>), {ok, JudgePid} = judge:start_link(Case), judge:deliberate(JudgePid, self()), receive {vote, Vote} -> ?assertMatch(#vote{}, Vote), ?assert(is_binary(Vote#vote.position)), ?assert(is_binary(Vote#vote.reasoning)), ?assert(Vote#vote.verdict == accept orelse Vote#vote.verdict == reject orelse Vote#vote.verdict == abstain) after 5000 -> ?assert(false) %% timeout — judge did not vote end, judge:stop(JudgePid). %% ============================================================ %% Tablet 55 — the judge consults; it does not roll dice %% ============================================================ judge_seats_cycle_the_dialectic_test() -> ?assertEqual(interpret, judge:step_for_seat(1)), ?assertEqual(challenge, judge:step_for_seat(2)), ?assertEqual(resolve, judge:step_for_seat(3)), ?assertEqual(interpret, judge:step_for_seat(4)), ?assertEqual(challenge, judge:step_for_seat(23)), ?assertEqual(challenge, judge:step_for_seat(71)). judge_without_consult_abstains_test() -> application:unset_env(sanhedrin, consult), Case = make_case_with_context(simple, <<"q">>, #{roots => [<<"br-a">>]}), {ok, JudgePid} = judge:start_link(Case, 1), judge:deliberate(JudgePid, self()), receive {vote, Vote} -> ?assertEqual(abstain, Vote#vote.verdict), ?assertEqual([], Vote#vote.torah_refs), ?assertMatch({_, _}, binary:match(Vote#vote.reasoning, <<"no consultation">>)) after 5000 -> ?assert(false) end, judge:stop(JudgePid). judge_consult_resolved_accepts_with_verses_test() -> application:set_env(sanhedrin, consult, {?MODULE, fake_consult}), Case = make_case_with_context(simple, <<"q">>, #{roots => [<<"br-a">>], fake => resolved}), {ok, JudgePid} = judge:start_link(Case, 1), judge:deliberate(JudgePid, self()), receive {vote, Vote} -> ?assertEqual(accept, Vote#vote.verdict), ?assertEqual(<<"the reading">>, Vote#vote.position), ?assertMatch([#torah_ref{book = bereishit, chapter = 1, verse = 1}], Vote#vote.torah_refs), ?assertMatch({_, _}, binary:match(Vote#vote.reasoning, <<"interpret via the Talmudic Engine">>)) after 5000 -> ?assert(false) end, judge:stop(JudgePid), application:unset_env(sanhedrin, consult). judge_consult_raises_abstains_and_stands_test() -> application:set_env(sanhedrin, consult, {?MODULE, fake_consult}), Case = make_case_with_context(simple, <<"q">>, #{fake => raise}), {ok, JudgePid} = judge:start_link(Case, 2), judge:deliberate(JudgePid, self()), receive {vote, Vote} -> ?assertEqual(abstain, Vote#vote.verdict) after 5000 -> ?assert(false) end, ?assert(is_process_alive(JudgePid)), judge:stop(JudgePid), application:unset_env(sanhedrin, consult). %% A panel of three where the dialectic finds a kushya at seat 2: %% interpret → resolved, challenge → challenged, resolve → resolved. %% Majority 2 accept, minority 1 reject — the minority preserved. panel_of_three_dissent_from_the_dialectic_test() -> setup(), application:set_env(sanhedrin, consult, {?MODULE, fake_consult}), Case = make_case_with_context(monetary, <<"q">>, #{roots => [<<"br-a">>], fake => dialectic}), {ok, CaseRef} = escalation:submit_case(Case), timer:sleep(600), {ok, Decision} = clerk:get_decision(CaseRef), ?assertEqual(3, Decision#decision.panel_size), ?assertEqual(accepted, Decision#decision.outcome), ?assertEqual(2, maps:get(vote_count, Decision#decision.majority)), ?assertEqual(1, maps:get(vote_count, Decision#decision.minority)), ?assertEqual(<<"but the verse says otherwise">>, maps:get(position, Decision#decision.minority)), application:unset_env(sanhedrin, consult), cleanup(ok). %% The fake consultation the tests register. Step-aware for `dialectic`. fake_consult(_Step, _Ref, _Roots, _Q, #{fake := raise}) -> erlang:error(engine_exploded); fake_consult(_Step, _Ref, _Roots, _Q, #{fake := resolved}) -> {ok, #{status => <<"resolved">>, text => <<"the reading">>, verses => [{bereishit, 1, 1}]}}; fake_consult(challenge, _Ref, _Roots, _Q, #{fake := dialectic}) -> {ok, #{status => <<"challenged">>, text => <<>>, kushya => <<"but the verse says otherwise">>, verses => [{devarim, 6, 4}]}}; fake_consult(_Step, _Ref, _Roots, _Q, #{fake := dialectic}) -> {ok, #{status => <<"resolved">>, text => <<"the reading">>, verses => [{bereishit, 1, 1}]}}; fake_consult(_Step, _Ref, _Roots, _Q, _Ctx) -> {error, no_fake}. %% ============================================================ %% Panel majority/minority computation tests %% ============================================================ majority_minority_empty_test() -> {Maj, Min} = panel:compute_majority_minority([]), ?assertEqual(0, maps:get(vote_count, Maj)), ?assertEqual(0, maps:get(vote_count, Min)). majority_minority_all_accept_test() -> Votes = [make_vote(accept) || _ <- lists:seq(1, 3)], {Maj, Min} = panel:compute_majority_minority(Votes), ?assertEqual(3, maps:get(vote_count, Maj)), ?assertEqual(0, maps:get(vote_count, Min)). majority_minority_mixed_test() -> Accepts = [make_vote(accept) || _ <- lists:seq(1, 2)], Rejects = [make_vote(reject)], Votes = Accepts ++ Rejects, {Maj, Min} = panel:compute_majority_minority(Votes), ?assertEqual(2, maps:get(vote_count, Maj)), ?assertEqual(1, maps:get(vote_count, Min)). majority_verdict_accept_test() -> Votes = [make_vote(accept) || _ <- lists:seq(1, 3)], {Maj, _Min} = panel:compute_majority_minority(Votes), ?assertEqual(accept, maps:get(verdict, Maj)). majority_verdict_reject_test() -> Rejects = [make_vote(reject) || _ <- lists:seq(1, 2)], Accepts = [make_vote(accept)], Votes = Rejects ++ Accepts, {Maj, _Min} = panel:compute_majority_minority(Votes), ?assertEqual(reject, maps:get(verdict, Maj)). %% Tablet 55: abstentions count. One accept against two abstentions is %% not an accepting majority — it is teiku. majority_abstaining_plurality_is_teiku_test() -> Votes = [make_vote(accept), make_vote(abstain), make_vote(abstain)], {Maj, Min} = panel:compute_majority_minority(Votes), ?assertEqual(abstain, maps:get(verdict, Maj)), ?assertEqual(2, maps:get(vote_count, Maj)), ?assertEqual(1, maps:get(vote_count, Min)), ?assertEqual(teiku, panel:determine_outcome(Maj, Min, passed)). majority_accept_reject_tie_is_teiku_test() -> Votes = [make_vote(accept), make_vote(reject)], {Maj, _Min} = panel:compute_majority_minority(Votes), ?assertEqual(abstain, maps:get(verdict, Maj)), ?assertEqual(0, maps:get(vote_count, Maj)). %% Tablet 55: "resolved" over an empty trace — no verse, no mitzvah — %% is nothing to stand on; the judge abstains rather than accept. judge_resolved_on_empty_trace_abstains_test() -> {Pos, Reason, Refs, Verdict} = judge:vote_from_consultation(interpret, {ok, #{status => <<"resolved">>, text => <<"reading(0,0)">>, verses => [], grounding => []}}, 5), ?assertEqual(abstain, Verdict), ?assertEqual([], Refs), ?assertMatch({_, _}, binary:match(Pos, <<"Teiku">>)), ?assertMatch({_, _}, binary:match(Reason, <<"empty trace">>)), %% a mitzvah without a verse is still ground {_, _, _, V2} = judge:vote_from_consultation(interpret, {ok, #{status => <<"resolved">>, text => <<"r">>, verses => [], grounding => [<<"mitzvah(5)">>]}}, 1), ?assertEqual(accept, V2). %% ============================================================ %% determine_outcome tests — accepted, rejected, teiku reachable %% ============================================================ determine_outcome_accepted_test() -> Maj = #{vote_count => 2, verdict => accept}, Min = #{vote_count => 1, verdict => reject}, ?assertEqual(accepted, panel:determine_outcome(Maj, Min, passed)). determine_outcome_rejected_test() -> Maj = #{vote_count => 2, verdict => reject}, Min = #{vote_count => 1, verdict => accept}, ?assertEqual(rejected, panel:determine_outcome(Maj, Min, passed)). determine_outcome_teiku_no_votes_test() -> Maj = #{vote_count => 0, verdict => abstain}, Min = #{vote_count => 0, verdict => abstain}, ?assertEqual(teiku, panel:determine_outcome(Maj, Min, passed)). determine_outcome_teiku_all_abstain_test() -> Maj = #{vote_count => 3, verdict => abstain}, Min = #{vote_count => 0, verdict => abstain}, ?assertEqual(teiku, panel:determine_outcome(Maj, Min, passed)). determine_outcome_suspicion_overrides_test() -> %% Even if majority rejected, unanimous capital → acquit (accepted) Maj = #{vote_count => 23, verdict => reject}, Min = #{vote_count => 0, verdict => abstain}, ?assertEqual(accepted, panel:determine_outcome(Maj, Min, {failed, suspicion_of_consensus})). %% ============================================================ %% Record definition tests %% ============================================================ record_sanhedrin_case_test() -> Case = make_case(monetary, <<"test">>), ?assertEqual(monetary, Case#sanhedrin_case.complexity), ?assertEqual(<<"test">>, Case#sanhedrin_case.question). record_decision_test() -> D = #decision{ case_ref = <<"ref">>, outcome = teiku, majority = #{}, minority = #{}, panel_size = 3, deliberation_time_ms = 500, unanimous_check = passed }, ?assertEqual(teiku, D#decision.outcome), ?assertEqual(passed, D#decision.unanimous_check). %% ============================================================ %% Helper functions %% ============================================================ make_case(Complexity, Question) -> #sanhedrin_case{ case_ref = generate_id(), complexity = Complexity, question = Question, context = #{ roots => [#{shoresh => [bet, resh, aleph], domain => creation}], torah_refs => [{bereishit, 1, 1}], prior_interpretations => [] }, requested_by = <<"test">>, submitted_at = erlang:system_time(millisecond) }. make_case_with_context(Complexity, Question, Context) -> #sanhedrin_case{ case_ref = generate_id(), complexity = Complexity, question = Question, context = Context, requested_by = <<"test">>, submitted_at = erlang:system_time(millisecond) }. make_vote(Verdict) -> #vote{ judge_id = make_ref(), position = <<"test position">>, reasoning = <<"test reasoning">>, torah_refs = [#torah_ref{book = bereishit, chapter = 1, verse = 1}], verdict = Verdict, cast_at = erlang:system_time(millisecond) }. generate_id() -> base64:encode(crypto:strong_rand_bytes(8)).