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:
1 parent
7c40200dfc
commit
046010f0ed
8 files changed
+727
-100
No files matched your search
+108
-86
@@ -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,104 +109,95 @@ 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
|
|
||||||
{Book, Chapter, Verse} ->
|
|
||||||
#torah_ref{book = Book, chapter = Chapter, verse = 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),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
%% ============================================================
|
%% ============================================================
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 =
|
||||||
|
|||||||
Reference in new issue
Block a user