tablet 55, second: the judge consults — the Sanhedrin no longer rolls dice

judge.erl chose one of six canned positions by a hash over the question,
its own pid and the clock; a panel of one "accepted" on zero roots and
zero references under "Torah references ground this interpretation" —
the calf, cast by the court itself. Architecture §3.2 says a judge
"consults the specification (L0/L1)"; Construction §3.2 says the
pipeline hands the case roots and torah_refs. Neither happened.

Sanhedrin (Erlang):
- judge.erl: seat N performs step N of the dialectic — interpret,
  challenge, resolve, cycling — through a consult {M, F} in the app env.
  resolved → accept with the verses; challenged (a kushya stands) →
  reject; halt → reject; teiku → abstain. No consult, an error, or a
  consult that raises → abstain, and the reasoning says so; the judge
  stands. judge:start_link/2 takes the seat; panel.erl seats its judges.
- EUnit 54/0: seats cycle; no consult → abstain; resolved → accept with
  #torah_ref{}; a raising consult → abstain and alive; a panel of three
  with a kushya at seat 2 → accepted 2–1, minority preserved with verse.

Pipeline (Elixir):
- VavPipeline.SanhedrinConsult: what a judge asks — TalmudicEngineSocket
  interpret/challenge/resolve over the case's roots, direct (the client's
  stub fallbacks are NOT used: a court that accepts on a stub is the
  calf). Engine absent → {:error, _} → the judge abstains. Registered in
  the sanhedrin env at application start.
- router process_sanhedrin: the case is heard before it is submitted —
  ScopeResolver → Shema context (cloud honoured) → tohu → vayomer
  (Shoresh Engine over the verse) → roots; torah_refs = the scope;
  complexity_hint names the panel (422 on an unknown one); one unit of
  sanhedrin_panels_max per case (503 MANNA_DEPLETED when spent); the
  decision engraved in L3 as an interpretation (panel_size, votes
  for/against, processing_component "sanhedrin", coupling root→verse)
  with the minority beside it (grounding_type achorai); the reply in
  the Tamid's shape (position, reasoning, sources, votes) plus
  grounding {scope, roots_consulted, recorded}.
- Tests: Elixir 628/0 (router 30, incl. monetary → panel 3; no engine →
  teiku with 0 votes, never accepted; fake consult → 2–1 with sources;
  manna → 503; unknown hint → 422); L3 test: interpretation + minority
  rows land.

Pre-existing and untouched: with --include l3 the gate and hakhel
tests fail 17 times on main as well (the gate's local upstream double
refuses connections under the L3 flag) — a separate finding.

Doorpost pin unchanged (no @spec_files touched).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Scott KarpandClaude Fable 5.1 committed 2026-10-19 17:04:46 +00:00
1 parent 7c40200dfc
commit 046010f0ed
8 files changed
+734 -107

No files matched your search

+115 -93
View File
@@ -11,6 +11,20 @@
%%% %%%
%%% Judges deliberate independently — they do not see %%% Judges deliberate independently — they do not see
%%% other judges' votes until the panel collects them. %%% 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 %%% @end
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-module(judge). -module(judge).
@@ -20,24 +34,34 @@
-include("sanhedrin.hrl"). -include("sanhedrin.hrl").
%% API %% API
-export([start_link/1, deliberate/2, stop/1]). -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 %% gen_server callbacks
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]). -export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
-record(state, { -record(state, {
judge_id :: reference(), judge_id :: reference(),
seat :: pos_integer(),
case_data :: #sanhedrin_case{}, case_data :: #sanhedrin_case{},
vote :: #vote{} | undefined, vote :: #vote{} | undefined,
panel_pid :: pid() | undefined panel_pid :: pid() | undefined
}). }).
-define(STEPS, [interpret, challenge, resolve]).
%%==================================================================== %%====================================================================
%% API %% API
%%==================================================================== %%====================================================================
start_link(Case) -> start_link(Case) ->
gen_server:start_link(?MODULE, 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. %% @doc Instruct this judge to deliberate and send vote to PanelPid.
-spec deliberate(pid(), pid()) -> ok. -spec deliberate(pid(), pid()) -> ok.
@@ -49,28 +73,35 @@ deliberate(JudgePid, PanelPid) ->
stop(JudgePid) -> stop(JudgePid) ->
gen_server:stop(JudgePid, normal, 5000). 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 %% gen_server callbacks
%%==================================================================== %%====================================================================
init(Case) -> init({Case, Seat}) ->
JudgeId = make_ref(),
{ok, #state{ {ok, #state{
judge_id = JudgeId, judge_id = make_ref(),
seat = Seat,
case_data = Case, case_data = Case,
vote = undefined, vote = undefined,
panel_pid = undefined panel_pid = undefined
}}. }};
init(Case) ->
init({Case, 1}).
handle_call(_Request, _From, State) -> handle_call(_Request, _From, State) ->
{reply, {error, unknown_request}, State}. {reply, {error, unknown_request}, State}.
handle_cast({deliberate, PanelPid}, State) -> handle_cast({deliberate, PanelPid}, State) ->
Vote = perform_deliberation(State#state.judge_id, State#state.case_data), Vote = perform_deliberation(State#state.judge_id, State#state.seat, State#state.case_data),
%% Send vote to panel %% Send vote to panel
PanelPid ! {vote, Vote}, PanelPid ! {vote, Vote},
{noreply, State#state{vote = Vote, panel_pid = PanelPid}}; {noreply, State#state{vote = Vote, panel_pid = PanelPid}};
handle_cast(_Msg, State) -> handle_cast(_Msg, State) ->
{noreply, State}. {noreply, State}.
@@ -78,112 +109,103 @@ handle_info(_Info, State) ->
{noreply, State}. {noreply, State}.
%%==================================================================== %%====================================================================
%% Internal — Deliberation logic %% Internal — Deliberation
%%==================================================================== %%====================================================================
%% @doc Perform independent deliberation. perform_deliberation(JudgeId, Seat, Case) ->
%% Step = step_for_seat(Seat),
%% Phase 2: Each judge examines the case context (roots, torah_refs)
%% and produces an independent interpretation. In Phase 3, judges
%% will call the Talmudic Engine for deeper reasoning.
%%
%% The deliberation is non-deterministic by design — judges may
%% reach different conclusions from the same evidence.
perform_deliberation(JudgeId, Case) ->
Context = Case#sanhedrin_case.context, Context = Case#sanhedrin_case.context,
Question = Case#sanhedrin_case.question,
%% Extract roots and references from context
Roots = maps:get(roots, Context, []), Roots = maps:get(roots, Context, []),
TorahRefs = maps:get(torah_refs, Context, []), Question = Case#sanhedrin_case.question,
Consultation = consult(Step, Case#sanhedrin_case.case_ref, Roots, Question, Context),
%% Generate interpretation based on case content {Position, Reasoning, Refs, Verdict} = vote_from_consultation(Step, Consultation, length(Roots)),
{Position, Reasoning, Verdict} = interpret(Question, Roots, TorahRefs),
%% Build Torah references for this vote
VoteRefs = build_refs(TorahRefs),
#vote{ #vote{
judge_id = JudgeId, judge_id = JudgeId,
position = Position, position = Position,
reasoning = Reasoning, reasoning = Reasoning,
torah_refs = VoteRefs, torah_refs = build_refs(Refs),
verdict = Verdict, verdict = Verdict,
cast_at = erlang:system_time(millisecond) cast_at = erlang:system_time(millisecond)
}. }.
%% @doc Generate an interpretation. %% The consult callback: {M, F} in the sanhedrin application env,
%% %% called as M:F(Step, CaseRef, Roots, Question, Context) and expected
%% Phase 2: Uses deterministic logic based on case content. %% to answer {ok, #{status := binary(), text := binary(),
%% Each judge applies a slightly different weighting — simulated %% verses := [{atom(), integer(), integer()}], kushya => binary()}}
%% by examining different aspects of the roots and references. %% or {error, term()}. Anything it raises is caught: a judge that
%% %% cannot consult abstains; it does not fall.
%% Phase 3: Will delegate to Talmudic Engine for real dialectical consult(Step, CaseRef, Roots, Question, Context) ->
%% reasoning (interpret → challenge → resolve). case application:get_env(sanhedrin, consult) of
interpret(Question, Roots, TorahRefs) -> {ok, {M, F}} when is_atom(M), is_atom(F) ->
%% Base interpretation on root analysis try M:F(Step, CaseRef, Roots, Question, Context) of
RootCount = length(Roots), {ok, Map} when is_map(Map) -> {ok, Map};
RefCount = length(TorahRefs), {error, Reason} -> {error, Reason};
Other -> {error, {bad_consultation, Other}}
%% Deterministic but varied deliberation: catch
%% Judges use their unique ID (via process randomness) to Class:Reason -> {error, {Class, Reason}}
%% introduce variation, simulating independent thought. end;
Hash = erlang:phash2({Question, self(), erlang:monotonic_time()}), _ ->
{error, no_consult_registered}
case Hash rem 6 of
0 ->
{<<"The roots indicate acceptance — the action aligns with Torah principle">>,
format_reasoning(<<"Root analysis supports this interpretation">>, RootCount, RefCount),
accept};
1 ->
{<<"The roots indicate rejection — the action contradicts specification">>,
format_reasoning(<<"Root tension detected — contradicting principles found">>, RootCount, RefCount),
reject};
2 ->
{<<"The roots support acceptance with qualification">>,
format_reasoning(<<"Conditional acceptance — roots align but context requires care">>, RootCount, RefCount),
accept};
3 ->
{<<"Insufficient grounding — the question cannot be resolved from available sources">>,
format_reasoning(<<"Teiku — the evidence is insufficient for determination">>, RootCount, RefCount),
abstain};
4 ->
{<<"The references confirm the action is consistent with specification">>,
format_reasoning(<<"Torah references ground this interpretation">>, RootCount, RefCount),
accept};
5 ->
{<<"The references reveal a tension that prohibits this action">>,
format_reasoning(<<"Prohibition detected via root-reference cross-analysis">>, RootCount, RefCount),
reject}
end. end.
format_reasoning(Base, RootCount, RefCount) -> %% @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, []),
case Status of
<<"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([ iolist_to_binary([
Base, step_name(Step), <<" via the Talmudic Engine: ">>, What,
<<" (">>, <<" (">>, integer_to_binary(RootCount), <<" roots, ">>,
integer_to_binary(RootCount), integer_to_binary(length(Verses)), <<" verses)">>
<<" roots, ">>,
integer_to_binary(RefCount),
<<" references examined)">>
]). ]).
step_name(interpret) -> <<"interpret">>;
step_name(challenge) -> <<"challenge">>;
step_name(resolve) -> <<"resolve">>.
build_refs(TorahRefs) -> build_refs(TorahRefs) ->
lists:map( lists:map(
fun(Ref) when is_tuple(Ref) -> fun({Book, Chapter, Verse}) ->
case Ref of #torah_ref{book = Book, chapter = Chapter, verse = Verse};
{Book, Chapter, Verse} ->
#torah_ref{book = Book, chapter = Chapter, verse = Verse};
_ ->
#torah_ref{book = unknown, chapter = 0, verse = 0}
end;
(Ref) when is_map(Ref) -> (Ref) when is_map(Ref) ->
#torah_ref{ #torah_ref{
book = maps:get(book, Ref, unknown), book = maps:get(book, Ref, unknown),
chapter = maps:get(chapter, Ref, 0), chapter = maps:get(chapter, Ref, 0),
verse = maps:get(verse, Ref, 0) verse = maps:get(verse, Ref, 0)
}; };
(_) -> (_) ->
#torah_ref{book = unknown, chapter = 0, verse = 0} #torah_ref{book = unknown, chapter = 0, verse = 0}
end, end,
TorahRefs TorahRefs
). ).
+3 -2
View File
@@ -307,9 +307,10 @@ store_all_opinions(CaseRef, Votes, Majority) ->
%%==================================================================== %%====================================================================
convene_judges(Case, Size) -> convene_judges(Case, Size) ->
%% Seat N performs step N of the dialectic (judge:step_for_seat/1).
lists:map( lists:map(
fun(_N) -> fun(Seat) ->
{ok, Pid} = judge:start_link(Case), {ok, Pid} = judge:start_link(Case, Seat),
Pid Pid
end, end,
lists:seq(1, Size) lists:seq(1, Size)
+89
View File
@@ -17,6 +17,9 @@
-include_lib("eunit/include/eunit.hrl"). -include_lib("eunit/include/eunit.hrl").
-include("sanhedrin.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 %% Test fixture — start/stop the application for each test
%% ============================================================ %% ============================================================
@@ -382,6 +385,92 @@ judge_deliberate_sends_vote_test() ->
end, end,
judge:stop(JudgePid). 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 %% Panel majority/minority computation tests
%% ============================================================ %% ============================================================
+21 -4
View File
@@ -170,10 +170,27 @@ SANHEDRIN_UNAVAILABLE) instead of crashing. The spec's own example now
deliberates, and the test asserts that the gate is still there deliberates, and the test asserts that the gate is still there
afterwards. afterwards.
Not fixed, and recorded here so it is not mistaken for fixed: the Not fixed in the first commit, and recorded here so it was not mistaken
panel of one, the empty references under a grounded-sounding sentence, for fixed: the panel of one, the empty references under a
the ignored hint, the unspent manna, the unrecorded case. The row in grounded-sounding sentence, the ignored hint, the unspent manna, the
Tamid is still larger than the thing behind it. The lesson is tablet unrecorded case. The row in Tamid was still larger than the thing
behind it.
Fixed in the second commit, the same day. The judge had been choosing
one of six canned positions by a hash over the question, its own pid
and the clock — "non-deterministic by design", the comment said, with
a note that a later phase would consult the engine. The phase never
came; the dice stayed. Now a judge consults: the pipeline registers
the Talmudic Engine as what a judge asks, and each seat on a panel
performs one step of the dialectic over the case's roots — interpret,
challenge, resolve, cycling by seat. A resolved reading is a vote to
accept, with its verses; a standing kushya is a vote to reject; teiku
is abstention; and a judge with nothing to consult abstains and says
so. Dissent arises from the dialectic, not from a die. The chatzer
path hears the question before submitting it, as pe'ah does — scope,
verse, Shoresh Engine, roots — honours the complexity hint, spends one
unit of the day's panel manna, engraves the decision and its minority
in L3, and answers in the Tamid's own shape. The lesson is tablet
37's again — a spec row is a claim to test — with one thing added: the 37's again — a spec row is a claim to test — with one thing added: the
system's own inner court is held to the same check as a model at the system's own inner court is held to the same check as a model at the
gate. When the Sanhedrin accepts on nothing, it is the calf, whoever gate. When the Sanhedrin accepts on nothing, it is the calf, whoever
@@ -26,6 +26,11 @@ defmodule VavPipeline.Application do
@impl true @impl true
def start(_type, _args) do def start(_type, _args) do
# Tablet 55: the Sanhedrin's judges consult the specification through
# this callback (the Talmudic Engine behind it). Registered before the
# supervision tree so the first panel convened can already consult.
Application.put_env(:sanhedrin, :consult, {VavPipeline.SanhedrinConsult, :consult})
shoresh_binary = Application.get_env(:vav_pipeline, :shoresh_engine_binary, "shoresh-engine") shoresh_binary = Application.get_env(:vav_pipeline, :shoresh_engine_binary, "shoresh-engine")
shoresh_cwd = Application.get_env(:vav_pipeline, :shoresh_engine_cwd, nil) shoresh_cwd = Application.get_env(:vav_pipeline, :shoresh_engine_cwd, nil)
talmudic_socket = Application.get_env(:vav_pipeline, :talmudic_socket_path, "/tmp/bikoret/talmudic.sock") talmudic_socket = Application.get_env(:vav_pipeline, :talmudic_socket_path, "/tmp/bikoret/talmudic.sock")
@@ -1090,26 +1090,49 @@ defmodule VavPipeline.BigdeiKodesh.Router do
# with the Tamid's own example request. The sides ride inside the # with the Tamid's own example request. The sides ride inside the
# map, their shape checked at the door; and a refusal from the # map, their shape checked at the door; and a refusal from the
# Sanhedrin is answered, not matched into a 500. # Sanhedrin is answered, not matched into a 500.
with {:ok, sides} <- sanhedrin_sides(params["sides"]) do #
# And the case is RESOLVED before it is submitted — Construction
# §3.2: the pipeline hands the Sanhedrin roots and torah_refs. The
# question is heard the way pe'ah hears it (scope → verse → Shoresh
# Engine → roots); the complexity hint sets the panel; a panel
# spends one unit of the day's panel manna; the decision is
# engraved in L3; the answer takes the Tamid's shape.
with {:ok, sides} <- sanhedrin_sides(params["sides"]),
{:ok, complexity} <- sanhedrin_complexity(params["complexity_hint"]),
{:ok, scope, resolved_by} <- sanhedrin_scope(case_text),
{:ok, resolved} <- sanhedrin_resolve(case_text, scope, resolved_by),
:ok <- sanhedrin_gather_panel() do
case_ref = generate_id() case_ref = generate_id()
case VavPipeline.SanhedrinClient.submit_case(%{ case VavPipeline.SanhedrinClient.submit_case(%{
case_ref: case_ref, case_ref: case_ref,
complexity: :simple, complexity: complexity,
question: case_text, question: case_text,
context: %{sides: sides}, context: %{
sides: sides,
roots: resolved.roots,
torah_refs: [resolved.torah_ref],
authority_source: :torah
},
requested_by: "chatzer" requested_by: "chatzer"
}) do }) do
{:ok, decision} -> {:ok, decision} ->
recorded = sanhedrin_record_l3(case_ref, case_text, decision, resolved)
send_json(conn, 200, %{ send_json(conn, 200, %{
case_ref: decision.case_ref, case_ref: decision.case_ref,
deliberation: %{ deliberation: %{
panel_size: decision.panel_size, panel_size: decision.panel_size,
majority: decision.majority, majority: sanhedrin_side(decision.majority),
minority: decision.minority, minority: sanhedrin_side(decision.minority),
outcome: decision.outcome, outcome: decision.outcome,
processing_time_ms: decision.deliberation_time_ms processing_time_ms: decision.deliberation_time_ms
}, },
grounding: %{
scope: scope_report(scope, resolved_by),
roots_consulted: format_roots(resolved.roots_full),
recorded: recorded
},
note: "This is the system's reasoning, not a binding ruling. A human beit din may reach a different conclusion.", note: "This is the system's reasoning, not a binding ruling. A human beit din may reach a different conclusion.",
disclosure: disclosure() disclosure: disclosure()
}) })
@@ -1129,12 +1152,47 @@ defmodule VavPipeline.BigdeiKodesh.Router do
}) })
end end
else else
{:error, issue} -> {:error, :sides, issue} ->
send_error(conn, 422, "KIYOR_VALIDATION_FAILED", %{ send_error(conn, 422, "KIYOR_VALIDATION_FAILED", %{
issue: issue, issue: issue,
correction: "'sides' is an optional list of {\"position\": string, \"evidence\": [string]}", correction: "'sides' is an optional list of {\"position\": string, \"evidence\": [string]}",
resubmit: true resubmit: true
}) })
{:error, :complexity, issue} ->
send_error(conn, 422, "KIYOR_VALIDATION_FAILED", %{
issue: issue,
correction: "'complexity_hint' is one of simple | monetary | capital | novel (panels of 1 | 3 | 23 | 71)",
resubmit: true
})
{:error, {:invalid_reference, ref}} ->
send_error(conn, 422, "KIYOR_VALIDATION_FAILED", %{
issue: "INVALID_SCOPE: #{ref} is not a verse in L1",
correction: "Name a verse that exists — chapter and verse within the five books.",
resubmit: true
})
{:waiting, reason} when reason in [:no_provision, :provision_exhausted] ->
send_error(conn, 503, "MANNA_DEPLETED", %{
issue: "No panel can be convened today: sanhedrin_panels_max is #{reason}",
correction: "Retry tomorrow, or raise MANNA_SANHEDRIN_PANELS_MAX in the ConfigMap",
resubmit: true
})
{:halted, what, reason} ->
send_error(conn, 503, "HALT_#{String.upcase(to_string(what))}", %{
issue: "The case could not be heard: #{inspect(reason)}",
correction: "Retry when the halt clears",
resubmit: true
})
{:transition, signal} ->
send_error(conn, 503, "CLOUD_MOVING", %{
issue: "The cloud is in transition (#{signal})",
correction: "Retry when the cloud settles",
resubmit: true
})
end end
end end
end end
@@ -1147,10 +1205,184 @@ defmodule VavPipeline.BigdeiKodesh.Router do
defp sanhedrin_sides(sides) when is_list(sides) do defp sanhedrin_sides(sides) when is_list(sides) do
if Enum.all?(sides, &side_ok?/1), if Enum.all?(sides, &side_ok?/1),
do: {:ok, sides}, do: {:ok, sides},
else: {:error, "'sides' must be a list of {position, evidence[]} objects"} else: {:error, :sides, "'sides' must be a list of {position, evidence[]} objects"}
end end
defp sanhedrin_sides(_), do: {:error, "'sides' must be a list"} defp sanhedrin_sides(_), do: {:error, :sides, "'sides' must be a list"}
# The complexity hint names the panel (sanhedrin.hrl): absent → simple.
@sanhedrin_complexities %{"simple" => :simple, "monetary" => :monetary, "capital" => :capital, "novel" => :novel}
defp sanhedrin_complexity(nil), do: {:ok, :simple}
defp sanhedrin_complexity(hint) when is_binary(hint) do
case Map.fetch(@sanhedrin_complexities, hint) do
{:ok, c} -> {:ok, c}
:error -> {:error, :complexity, "'complexity_hint' #{inspect(hint)} is not one of simple | monetary | capital | novel"}
end
end
defp sanhedrin_complexity(other), do: {:error, :complexity, "'complexity_hint' must be a string, got #{inspect(other)}"}
# The scope: heard from the question (tablet 30), a named verse or a text
# match, else the default.
defp sanhedrin_scope(case_text), do: VavPipeline.ScopeResolver.resolve(case_text)
# The roots: the first two stages of the Bereishit cycle on the question —
# tohu (decompose) and vayomer (the Shoresh Engine over the verse in scope,
# or the question's own Hebrew). Nishma first: the Shema context is
# received, and the cloud is honoured, exactly as process/1 does.
defp sanhedrin_resolve(case_text, scope, resolved_by) do
case VavPipeline.Shema.receive_context(scope) do
{:error, reason} ->
{:halted, :context_failed, reason}
{:ok, context} ->
case context.cloud_state do
:camped ->
input = %{source: "chatzer", verse_scope: scope, scope_resolution: resolved_by, content: case_text}
state =
input
|> VavPipeline.BereishitCycle.tohu(context)
|> VavPipeline.BereishitCycle.vayomer(context)
roots_full = Map.get(state, :roots_activated, [])
{:ok,
%{
roots: Enum.map(roots_full, & &1.shoresh),
roots_full: roots_full,
torah_ref: {String.to_atom(scope.book), scope.chapter, scope.verse},
read_from: Map.get(state, :roots_read_from)
}}
:moving ->
{:transition, :cloud_lifted}
{:major_transition, signal, _} ->
{:transition, signal}
other ->
{:transition, other}
end
end
end
# One panel per case from the day's manna (Vayakhel §3.7 — the ledger
# provisions sanhedrin_panels_max). The provision is loaded first, as
# Gate.gather_call does (idempotent per date). Without a ledger
# (dev/test) nothing is gathered.
defp sanhedrin_gather_panel do
if Process.whereis(VavPipeline.MannaLedger) do
case VavPipeline.MannaLedger.load_provision() do
{:ok, :no_provision} ->
{:waiting, :no_provision}
{:error, _} ->
{:waiting, :no_provision}
{:ok, _} ->
case VavPipeline.MannaLedger.consume(:sanhedrin_panels_max, 1) do
{:ok, _} -> :ok
{:error, :provision_exhausted} -> {:waiting, :provision_exhausted}
{:error, :no_provision} -> {:waiting, :no_provision}
end
end
else
:ok
end
end
# The Tamid's shape for a side: position, reasoning, sources, votes.
defp sanhedrin_side(side) when is_map(side) do
%{
position: Map.get(side, :position, Map.get(side, "position", "")),
reasoning: Map.get(side, :reasoning, Map.get(side, "reasoning", "")),
sources: side |> Map.get(:torah_refs, Map.get(side, "torah_refs", [])) |> Enum.map(&sanhedrin_ref_string/1),
votes: Map.get(side, :vote_count, Map.get(side, "vote_count", 0))
}
end
defp sanhedrin_side(_), do: %{position: "", reasoning: "", sources: [], votes: 0}
# #torah_ref{} arrives from Erlang as {:torah_ref, book, ch, vs}; a tuple
# {book, ch, vs} or a map may also appear.
defp sanhedrin_ref_string({:torah_ref, book, ch, vs}), do: "#{book} #{ch}:#{vs}"
defp sanhedrin_ref_string({book, ch, vs}), do: "#{book} #{ch}:#{vs}"
defp sanhedrin_ref_string(%{book: b, chapter: c, verse: v}), do: "#{b} #{c}:#{v}"
defp sanhedrin_ref_string(other), do: to_string(inspect(other))
# Devarim 17:9 — the inquiry is recorded or it did not accumulate. The
# decision goes to L3 as an interpretation (panel_size, votes for and
# against, processing_component "sanhedrin"), the minority beside it
# when it exists. Never turns a served decision into a 500: the record
# failing is logged and reported in the reply as `recorded`.
defp sanhedrin_record_l3(case_ref, case_text, decision, resolved) do
if Process.whereis(VavPipeline.Repo) do
maj = decision.majority
min = decision.minority
{book, ch, vs} = resolved.torah_ref
verse_s = "#{book} #{ch}:#{vs}"
coupling =
case resolved.roots do
[] -> ["coupled(case(#{case_ref}), verse(#{book}, #{ch}, #{vs}))"]
roots -> Enum.map(roots, fn r -> "coupled(root(#{r}), verse(#{book}, #{ch}, #{vs}))" end)
end
attrs = %{
case_ref: case_ref,
interpretation: to_string(Map.get(maj, :position, "")),
status: to_string(decision.outcome),
reasoning: "#{case_text} — #{Map.get(maj, :reasoning, "")}",
torah_references: Enum.uniq([verse_s | Enum.map(Map.get(maj, :torah_refs, []), &sanhedrin_ref_string/1)]),
roots_involved: Enum.map(resolved.roots, &to_string/1),
panel_size: decision.panel_size,
vote_count_for: Map.get(maj, :vote_count, 0),
vote_count_against: Map.get(min, :vote_count, 0),
processing_component: "sanhedrin",
coupling_trace: coupling
}
try do
case VavPipeline.L3.Store.record_interpretation(attrs) do
{:ok, interp} ->
Logger.info("L3: interpretation #{interp.id} recorded for sanhedrin #{case_ref} (#{decision.outcome})")
minority_recorded =
if Map.get(min, :vote_count, 0) > 0 do
case VavPipeline.L3.Store.record_minority(%{
interpretation_id: interp.id,
dissenting_reasoning: "#{Map.get(min, :position, "")} — #{Map.get(min, :reasoning, "")}",
torah_references: Enum.map(Map.get(min, :torah_refs, []), &sanhedrin_ref_string/1),
grounding_type: "achorai"
}) do
{:ok, _} ->
"recorded"
{:error, reason} ->
Logger.error("L3: sanhedrin #{case_ref} minority NOT recorded: #{inspect(reason)}")
"failed"
end
else
"none"
end
%{l3: "recorded", interpretation_id: interp.id, minority: minority_recorded}
{:error, reason} ->
Logger.error("L3: sanhedrin #{case_ref} NOT recorded: #{inspect(reason)}")
%{l3: "failed", reason: inspect(reason)}
end
rescue
e ->
Logger.error("L3: sanhedrin #{case_ref} NOT recorded: #{Exception.message(e)}")
%{l3: "failed", reason: Exception.message(e)}
end
else
%{l3: "absent"}
end
end
defp side_ok?(%{"position" => p} = side) when is_binary(p) do defp side_ok?(%{"position" => p} = side) when is_binary(p) do
case Map.get(side, "evidence", []) do case Map.get(side, "evidence", []) do
@@ -0,0 +1,102 @@
defmodule VavPipeline.SanhedrinConsult do
@moduledoc """
What a judge consults (Architecture §3.2: "consults the specification
(L0/L1)"). Registered in the `sanhedrin` application env as
`{__MODULE__, :consult}` at pipeline start; `judge.erl` calls it once
per vote with the step its seat performs — `:interpret`, `:challenge`
or `:resolve` — and the case's roots and question.
Tablet 55: before this the judge chose among six canned positions by a
hash of the clock. Now the only ground a vote can stand on is the
Talmudic Engine's answer over the case's roots — the same engine, the
same rules, the same verses the bedikah and the Bereishit cycle use.
When the engine's socket is not running, or answers with an error, the
judge is told `{:error, reason}` and abstains. Nothing here is answered
from a stub (tablet 41): `TalmudicEngineClient`'s stub fallbacks are
deliberately NOT used — a court that accepts on a stub is the calf.
"""
require Logger
alias VavPipeline.TalmudicEngineSocket
@books ~w(bereishit shemot vayikra bamidbar devarim)a
@typedoc "What the judge receives: a status the engine gave, the reading, its verses."
@type consultation :: %{
required(:status) => String.t(),
required(:text) => String.t(),
required(:verses) => [{atom(), pos_integer(), pos_integer()}],
optional(:kushya) => String.t(),
optional(:grounding) => [String.t()]
}
@doc """
Called from the judge (Erlang): `consult(Step, CaseRef, Roots, Question, Context)`.
Roots are the case's shoresh names as the cycle produces them; the
Erlang side hands binaries or charlists — both are accepted.
"""
@spec consult(:interpret | :challenge | :resolve, binary() | charlist(), list(), binary() | charlist(), map()) ::
{:ok, consultation()} | {:error, term()}
def consult(step, case_ref, roots, question, _context) when step in [:interpret, :challenge, :resolve] do
if Process.whereis(TalmudicEngineSocket) do
ctx = %{
context_roots: Enum.map(roots, &to_string/1),
question: to_string(question)
}
ref = "#{to_string(case_ref)}-#{step}"
case apply(TalmudicEngineSocket, step, [ref, ctx]) do
{:ok, %{"status" => status} = reply} when is_binary(status) ->
{:ok, normalize(status, reply)}
{:ok, other} ->
{:error, {:unexpected_reply, other}}
{:error, reason} ->
Logger.warning("SanhedrinConsult: engine #{step} failed for #{ref}: #{inspect(reason)}")
{:error, {:engine_unavailable, reason}}
end
else
{:error, :engine_socket_not_started}
end
end
def consult(step, _ref, _roots, _q, _ctx), do: {:error, {:unknown_step, step}}
@doc "Parse the engine's verse strings (`verse_ref(bereishit,1,1,...)`, `verse(shemot,3,14)`) into `{book, ch, vs}`."
@spec verses_from([String.t()]) :: [{atom(), pos_integer(), pos_integer()}]
def verses_from(strings) when is_list(strings) do
strings
|> Enum.flat_map(fn s ->
case Regex.run(~r/verse(?:_ref)?\((bereishit|shemot|vayikra|bamidbar|devarim)\s*,\s*(\d+)\s*,\s*(\d+)/, to_string(s)) do
[_, book, ch, vs] -> [{String.to_existing_atom(book), String.to_integer(ch), String.to_integer(vs)}]
_ -> []
end
end)
|> Enum.uniq()
end
def verses_from(_), do: []
@doc false
def books, do: @books
defp normalize(status, reply) do
interp = Map.get(reply, "interpretation") || %{}
chall = Map.get(reply, "challenge") || %{}
base = %{
status: status,
text: to_string(Map.get(interp, "text", "")),
verses: verses_from(List.wrap(Map.get(interp, "verses", []))),
grounding: List.wrap(Map.get(interp, "grounding", [])) |> Enum.map(&to_string/1)
}
case Map.get(chall, "kushya") do
k when is_binary(k) and k != "" -> Map.put(base, :kushya, k)
_ -> base
end
end
end
+159
View File
@@ -21,6 +21,38 @@ defmodule VavPipeline.BigdeiKodeshTest do
@opts Router.init([]) @opts Router.init([])
# Tablet 55: a consultation the judges can be pointed at (judge.erl reads
# {M, F} from the sanhedrin env). Seat 2 (challenge) finds a kushya; the
# other seats read the verse as resolved.
def fake_consult(:challenge, _ref, _roots, _q, _ctx),
do: {:ok, %{status: "challenged", text: "", kushya: "but the verse says otherwise", verses: [{:devarim, 6, 4}]}}
def fake_consult(_step, _ref, _roots, _q, _ctx),
do: {:ok, %{status: "resolved", text: "the reading", verses: [{:bereishit, 1, 1}]}}
# A day's manna for the chatzer tests: a panel spends one unit of
# sanhedrin_panels_max (tablet 55), so the ledger must hold a provision.
defp write_provision(panels) do
VavPipeline.MannaLedger.reset()
on_exit(fn -> VavPipeline.MannaLedger.reset() end)
today = Date.utc_today() |> Date.to_iso8601()
expires = DateTime.utc_now() |> DateTime.add(6 * 3600) |> DateTime.to_iso8601()
provision = %{
"date" => today,
"provision" => %{"compute_units" => 50, "memory_mb" => 512, "api_calls_external" => 10, "storage_delta_mb" => 2, "sanhedrin_panels_max" => panels},
"type" => "regular",
"expires_at" => expires,
"written_by" => "bigdei_kodesh_test",
"written_at" => DateTime.utc_now() |> DateTime.to_iso8601()
}
path = VavPipeline.Manna.provision_file()
File.mkdir_p!(Path.dirname(path))
File.write!(path, Jason.encode!(provision))
on_exit(fn -> File.rm(path) end)
end
# ============================================================ # ============================================================
# Pe'ah — Public Endpoint (Machaneh Yisrael) # Pe'ah — Public Endpoint (Machaneh Yisrael)
# ============================================================ # ============================================================
@@ -301,6 +333,8 @@ defmodule VavPipeline.BigdeiKodeshTest do
end end
test "POST /chatzer/sanhedrin with valid case deliberates" do test "POST /chatzer/sanhedrin with valid case deliberates" do
write_provision(2)
conn = conn =
conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{ conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{
case: "Is cooking on Shabbat permitted for pikuach nefesh?" case: "Is cooking on Shabbat permitted for pikuach nefesh?"
@@ -321,6 +355,8 @@ defmodule VavPipeline.BigdeiKodeshTest do
# Tablet 55: the Tamid's own example — a case WITH sides — returned # Tablet 55: the Tamid's own example — a case WITH sides — returned
# 500 and took the halt monitor and escalation down with it. # 500 and took the halt monitor and escalation down with it.
test "POST /chatzer/sanhedrin with sides (the Tamid's example) deliberates" do test "POST /chatzer/sanhedrin with sides (the Tamid's example) deliberates" do
write_provision(2)
conn = conn =
conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{ conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{
case: "A borrower repaid before one witness; the lender denies receipt. Who bears the burden?", case: "A borrower repaid before one witness; the lender denies receipt. Who bears the burden?",
@@ -337,11 +373,134 @@ defmodule VavPipeline.BigdeiKodeshTest do
assert conn.status == 200 assert conn.status == 200
body = Jason.decode!(conn.resp_body) body = Jason.decode!(conn.resp_body)
assert body["deliberation"]["outcome"] in ["accepted", "rejected", "teiku"] assert body["deliberation"]["outcome"] in ["accepted", "rejected", "teiku"]
# the hint names the panel: monetary → three judges (sanhedrin.hrl)
assert body["deliberation"]["panel_size"] == 3
# the Tamid's shape — votes and sources, not vote_count and torah_refs
assert Map.keys(body["deliberation"]["majority"]) |> Enum.sort() == ["position", "reasoning", "sources", "votes"]
assert is_list(body["deliberation"]["majority"]["sources"])
# the case was resolved before it was heard
assert is_map(body["grounding"]["scope"])
assert is_list(body["grounding"]["roots_consulted"])
# and the gate is still standing afterwards # and the gate is still standing afterwards
assert Process.whereis(:halt_monitor) != nil assert Process.whereis(:halt_monitor) != nil
assert Process.whereis(:escalation) != nil assert Process.whereis(:escalation) != nil
end end
# Tablet 55: without a Talmudic Engine to consult, the judges abstain
# and the court says teiku — it does not accept on nothing.
test "POST /chatzer/sanhedrin with no engine to consult is teiku, not accepted" do
# The socket process may run in the test env; no engine answers behind it.
Application.put_env(:sanhedrin, :consult, {VavPipeline.SanhedrinConsult, :consult})
write_provision(2)
conn =
conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{case: "bereishit 1:1 — what is created first?"}))
|> put_req_header("content-type", "application/json")
|> put_req_header("authorization", "Bearer chatzer-test-token")
|> Router.call(@opts)
assert conn.status == 200
body = Jason.decode!(conn.resp_body)
assert body["deliberation"]["outcome"] == "teiku"
assert body["deliberation"]["panel_size"] == 1
assert body["deliberation"]["majority"]["votes"] == 0
assert body["deliberation"]["minority"]["reasoning"] =~ "no consultation"
end
# Tablet 55: with a consultation behind the judges, the dialectic
# decides — interpret resolved, challenge finds a kushya, resolve
# resolved → 2 accept, 1 reject; the minority preserved with its verse.
test "POST /chatzer/sanhedrin monetary panel splits 2–1 from the dialectic" do
Application.put_env(:sanhedrin, :consult, {__MODULE__, :fake_consult})
write_provision(2)
try do
conn =
conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{
case: "devarim 6:4 — is the unity declared or commanded?",
complexity_hint: "monetary"
}))
|> put_req_header("content-type", "application/json")
|> put_req_header("authorization", "Bearer chatzer-test-token")
|> Router.call(@opts)
assert conn.status == 200
d = Jason.decode!(conn.resp_body)["deliberation"]
assert d["panel_size"] == 3
assert d["outcome"] == "accepted"
assert d["majority"]["votes"] == 2
assert d["majority"]["position"] == "the reading"
assert d["majority"]["sources"] == ["bereishit 1:1"]
assert d["minority"]["votes"] == 1
assert d["minority"]["position"] == "but the verse says otherwise"
assert d["minority"]["sources"] == ["devarim 6:4"]
after
Application.put_env(:sanhedrin, :consult, {VavPipeline.SanhedrinConsult, :consult})
end
end
# Devarim 17:9 — the decision is engraved in L3: an interpretation row
# with the panel and the votes, and the minority beside it.
@tag :l3
test "POST /chatzer/sanhedrin engraves the decision and the minority in L3" do
# The record is written from the request process (the router), so a
# checkout here covers it; the sandbox is shared for the sake of the
# panel's processes, which do not touch the Repo.
:ok = Ecto.Adapters.SQL.Sandbox.checkout(VavPipeline.Repo)
Ecto.Adapters.SQL.Sandbox.mode(VavPipeline.Repo, {:shared, self()})
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.mode(VavPipeline.Repo, :manual) end)
Application.put_env(:sanhedrin, :consult, {__MODULE__, :fake_consult})
write_provision(2)
try do
before = VavPipeline.L3.Store.count_interpretations()
before_min = VavPipeline.L3.Store.count_minority_opinions()
conn =
conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{case: "devarim 6:4", complexity_hint: "monetary"}))
|> put_req_header("content-type", "application/json")
|> put_req_header("authorization", "Bearer chatzer-test-token")
|> Router.call(@opts)
assert conn.status == 200
body = Jason.decode!(conn.resp_body)
assert body["grounding"]["recorded"]["l3"] == "recorded"
assert VavPipeline.L3.Store.count_interpretations() == before + 1
assert VavPipeline.L3.Store.count_minority_opinions() == before_min + 1
after
Application.put_env(:sanhedrin, :consult, {VavPipeline.SanhedrinConsult, :consult})
end
end
# A panel is manna (Vayakhel §3.7): the day's sanhedrin_panels_max spent → 503.
test "POST /chatzer/sanhedrin with the day's panels spent returns 503 MANNA_DEPLETED" do
write_provision(1)
body = Jason.encode!(%{case: "bereishit 1:1"})
post = fn ->
conn(:post, "/chatzer/sanhedrin", body)
|> put_req_header("content-type", "application/json")
|> put_req_header("authorization", "Bearer chatzer-test-token")
|> Router.call(@opts)
end
assert post.().status == 200
second = post.()
assert second.status == 503
assert Jason.decode!(second.resp_body)["error"]["code"] == "MANNA_DEPLETED"
end
test "POST /chatzer/sanhedrin with an unknown complexity_hint returns 422" do
conn =
conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{case: "x", complexity_hint: "cosmic"}))
|> put_req_header("content-type", "application/json")
|> put_req_header("authorization", "Bearer chatzer-test-token")
|> Router.call(@opts)
assert conn.status == 422
assert Jason.decode!(conn.resp_body)["error"]["guidance"]["issue"] =~ "complexity_hint"
end
test "POST /chatzer/sanhedrin with malformed sides returns 422, not 500" do test "POST /chatzer/sanhedrin with malformed sides returns 422, not 500" do
for bad <- ["not a list", [%{"no_position" => 1}], [%{"position" => "p", "evidence" => "not a list"}]] do for bad <- ["not a list", [%{"no_position" => 1}], [%{"position" => "p", "evidence" => "not a list"}]] do
conn = conn =