From 046010f0ed5959e5961c4855214717c64cb6c89b Mon Sep 17 00:00:00 2001 From: Scott Karp Date: Mon, 19 Oct 2026 17:04:46 +0000 Subject: [PATCH] =?UTF-8?q?tablet=2055,=20second:=20the=20judge=20consults?= =?UTF-8?q?=20=E2=80=94=20the=20Sanhedrin=20no=20longer=20rolls=20dice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- sanhedrin/src/judge.erl | 208 ++++++++------- sanhedrin/src/panel.erl | 5 +- sanhedrin/test/sanhedrin_SUITE.erl | 89 +++++++ specs/broken_tablets/README.md | 25 +- vav_pipeline/lib/vav_pipeline/application.ex | 5 + .../lib/vav_pipeline/bigdei_kodesh/router.ex | 248 +++++++++++++++++- .../lib/vav_pipeline/sanhedrin_consult.ex | 102 +++++++ vav_pipeline/test/bigdei_kodesh_test.exs | 159 +++++++++++ 8 files changed, 734 insertions(+), 107 deletions(-) create mode 100644 vav_pipeline/lib/vav_pipeline/sanhedrin_consult.ex diff --git a/sanhedrin/src/judge.erl b/sanhedrin/src/judge.erl index ca749113..3d071c88 100644 --- a/sanhedrin/src/judge.erl +++ b/sanhedrin/src/judge.erl @@ -11,6 +11,20 @@ %%% %%% Judges deliberate independently — they do not see %%% 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 %%%------------------------------------------------------------------- -module(judge). @@ -20,24 +34,34 @@ -include("sanhedrin.hrl"). %% 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 -export([init/1, handle_call/3, handle_cast/2, handle_info/2]). -record(state, { judge_id :: reference(), + seat :: pos_integer(), case_data :: #sanhedrin_case{}, vote :: #vote{} | undefined, panel_pid :: pid() | undefined }). +-define(STEPS, [interpret, challenge, resolve]). + %%==================================================================== %% API %%==================================================================== 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. -spec deliberate(pid(), pid()) -> ok. @@ -49,28 +73,35 @@ deliberate(JudgePid, PanelPid) -> stop(JudgePid) -> 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 %%==================================================================== -init(Case) -> - JudgeId = make_ref(), +init({Case, Seat}) -> {ok, #state{ - judge_id = JudgeId, + judge_id = make_ref(), + seat = Seat, case_data = Case, vote = undefined, panel_pid = undefined - }}. + }}; +init(Case) -> + init({Case, 1}). handle_call(_Request, _From, State) -> {reply, {error, unknown_request}, 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 PanelPid ! {vote, Vote}, {noreply, State#state{vote = Vote, panel_pid = PanelPid}}; - handle_cast(_Msg, State) -> {noreply, State}. @@ -78,112 +109,103 @@ handle_info(_Info, State) -> {noreply, State}. %%==================================================================== -%% Internal — Deliberation logic +%% Internal — Deliberation %%==================================================================== -%% @doc Perform independent deliberation. -%% -%% 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) -> +perform_deliberation(JudgeId, Seat, Case) -> + Step = step_for_seat(Seat), Context = Case#sanhedrin_case.context, - Question = Case#sanhedrin_case.question, - - %% Extract roots and references from context Roots = maps:get(roots, Context, []), - TorahRefs = maps:get(torah_refs, Context, []), - - %% Generate interpretation based on case content - {Position, Reasoning, Verdict} = interpret(Question, Roots, TorahRefs), - - %% Build Torah references for this vote - VoteRefs = build_refs(TorahRefs), - + Question = Case#sanhedrin_case.question, + Consultation = consult(Step, Case#sanhedrin_case.case_ref, Roots, Question, Context), + {Position, Reasoning, Refs, Verdict} = vote_from_consultation(Step, Consultation, length(Roots)), #vote{ judge_id = JudgeId, position = Position, reasoning = Reasoning, - torah_refs = VoteRefs, + torah_refs = build_refs(Refs), verdict = Verdict, cast_at = erlang:system_time(millisecond) }. -%% @doc Generate an interpretation. -%% -%% Phase 2: Uses deterministic logic based on case content. -%% Each judge applies a slightly different weighting — simulated -%% by examining different aspects of the roots and references. -%% -%% Phase 3: Will delegate to Talmudic Engine for real dialectical -%% reasoning (interpret → challenge → resolve). -interpret(Question, Roots, TorahRefs) -> - %% Base interpretation on root analysis - RootCount = length(Roots), - RefCount = length(TorahRefs), - - %% Deterministic but varied deliberation: - %% Judges use their unique ID (via process randomness) to - %% introduce variation, simulating independent thought. - Hash = erlang:phash2({Question, self(), erlang:monotonic_time()}), - - 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} +%% The consult callback: {M, F} in the sanhedrin application env, +%% called as M:F(Step, CaseRef, Roots, Question, Context) and expected +%% to answer {ok, #{status := binary(), text := binary(), +%% verses := [{atom(), integer(), integer()}], kushya => binary()}} +%% or {error, term()}. Anything it raises is caught: a judge that +%% cannot consult abstains; it does not fall. +consult(Step, CaseRef, Roots, Question, Context) -> + case application:get_env(sanhedrin, consult) of + {ok, {M, F}} when is_atom(M), is_atom(F) -> + try M:F(Step, CaseRef, Roots, Question, Context) of + {ok, Map} when is_map(Map) -> {ok, Map}; + {error, Reason} -> {error, Reason}; + Other -> {error, {bad_consultation, Other}} + catch + Class:Reason -> {error, {Class, Reason}} + end; + _ -> + {error, no_consult_registered} 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([ - Base, - <<" (">>, - integer_to_binary(RootCount), - <<" roots, ">>, - integer_to_binary(RefCount), - <<" references examined)">> + step_name(Step), <<" via the Talmudic Engine: ">>, What, + <<" (">>, integer_to_binary(RootCount), <<" roots, ">>, + integer_to_binary(length(Verses)), <<" verses)">> ]). +step_name(interpret) -> <<"interpret">>; +step_name(challenge) -> <<"challenge">>; +step_name(resolve) -> <<"resolve">>. + build_refs(TorahRefs) -> lists:map( - fun(Ref) when is_tuple(Ref) -> - case Ref of - {Book, Chapter, Verse} -> - #torah_ref{book = Book, chapter = Chapter, verse = Verse}; - _ -> - #torah_ref{book = unknown, chapter = 0, verse = 0} - end; + fun({Book, Chapter, Verse}) -> + #torah_ref{book = Book, chapter = Chapter, verse = Verse}; (Ref) when is_map(Ref) -> - #torah_ref{ - book = maps:get(book, Ref, unknown), - chapter = maps:get(chapter, Ref, 0), - verse = maps:get(verse, Ref, 0) - }; + #torah_ref{ + book = maps:get(book, Ref, unknown), + chapter = maps:get(chapter, 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, TorahRefs ). diff --git a/sanhedrin/src/panel.erl b/sanhedrin/src/panel.erl index 3262033c..b357e734 100644 --- a/sanhedrin/src/panel.erl +++ b/sanhedrin/src/panel.erl @@ -307,9 +307,10 @@ store_all_opinions(CaseRef, Votes, Majority) -> %%==================================================================== convene_judges(Case, Size) -> + %% Seat N performs step N of the dialectic (judge:step_for_seat/1). lists:map( - fun(_N) -> - {ok, Pid} = judge:start_link(Case), + fun(Seat) -> + {ok, Pid} = judge:start_link(Case, Seat), Pid end, lists:seq(1, Size) diff --git a/sanhedrin/test/sanhedrin_SUITE.erl b/sanhedrin/test/sanhedrin_SUITE.erl index bd0cb856..f5bdf8a3 100644 --- a/sanhedrin/test/sanhedrin_SUITE.erl +++ b/sanhedrin/test/sanhedrin_SUITE.erl @@ -17,6 +17,9 @@ -include_lib("eunit/include/eunit.hrl"). -include("sanhedrin.hrl"). +%% The consult callback the tablet-55 tests register (judge:consult reads {M,F}). +-export([fake_consult/5]). + %% ============================================================ %% Test fixture — start/stop the application for each test %% ============================================================ @@ -382,6 +385,92 @@ judge_deliberate_sends_vote_test() -> end, judge:stop(JudgePid). +%% ============================================================ +%% Tablet 55 — the judge consults; it does not roll dice +%% ============================================================ + +judge_seats_cycle_the_dialectic_test() -> + ?assertEqual(interpret, judge:step_for_seat(1)), + ?assertEqual(challenge, judge:step_for_seat(2)), + ?assertEqual(resolve, judge:step_for_seat(3)), + ?assertEqual(interpret, judge:step_for_seat(4)), + ?assertEqual(challenge, judge:step_for_seat(23)), + ?assertEqual(challenge, judge:step_for_seat(71)). + +judge_without_consult_abstains_test() -> + application:unset_env(sanhedrin, consult), + Case = make_case_with_context(simple, <<"q">>, #{roots => [<<"br-a">>]}), + {ok, JudgePid} = judge:start_link(Case, 1), + judge:deliberate(JudgePid, self()), + receive + {vote, Vote} -> + ?assertEqual(abstain, Vote#vote.verdict), + ?assertEqual([], Vote#vote.torah_refs), + ?assertMatch({_, _}, binary:match(Vote#vote.reasoning, <<"no consultation">>)) + after 5000 -> ?assert(false) + end, + judge:stop(JudgePid). + +judge_consult_resolved_accepts_with_verses_test() -> + application:set_env(sanhedrin, consult, {?MODULE, fake_consult}), + Case = make_case_with_context(simple, <<"q">>, #{roots => [<<"br-a">>], fake => resolved}), + {ok, JudgePid} = judge:start_link(Case, 1), + judge:deliberate(JudgePid, self()), + receive + {vote, Vote} -> + ?assertEqual(accept, Vote#vote.verdict), + ?assertEqual(<<"the reading">>, Vote#vote.position), + ?assertMatch([#torah_ref{book = bereishit, chapter = 1, verse = 1}], Vote#vote.torah_refs), + ?assertMatch({_, _}, binary:match(Vote#vote.reasoning, <<"interpret via the Talmudic Engine">>)) + after 5000 -> ?assert(false) + end, + judge:stop(JudgePid), + application:unset_env(sanhedrin, consult). + +judge_consult_raises_abstains_and_stands_test() -> + application:set_env(sanhedrin, consult, {?MODULE, fake_consult}), + Case = make_case_with_context(simple, <<"q">>, #{fake => raise}), + {ok, JudgePid} = judge:start_link(Case, 2), + judge:deliberate(JudgePid, self()), + receive + {vote, Vote} -> ?assertEqual(abstain, Vote#vote.verdict) + after 5000 -> ?assert(false) + end, + ?assert(is_process_alive(JudgePid)), + judge:stop(JudgePid), + application:unset_env(sanhedrin, consult). + +%% A panel of three where the dialectic finds a kushya at seat 2: +%% interpret → resolved, challenge → challenged, resolve → resolved. +%% Majority 2 accept, minority 1 reject — the minority preserved. +panel_of_three_dissent_from_the_dialectic_test() -> + setup(), + application:set_env(sanhedrin, consult, {?MODULE, fake_consult}), + Case = make_case_with_context(monetary, <<"q">>, #{roots => [<<"br-a">>], fake => dialectic}), + {ok, CaseRef} = escalation:submit_case(Case), + timer:sleep(600), + {ok, Decision} = clerk:get_decision(CaseRef), + ?assertEqual(3, Decision#decision.panel_size), + ?assertEqual(accepted, Decision#decision.outcome), + ?assertEqual(2, maps:get(vote_count, Decision#decision.majority)), + ?assertEqual(1, maps:get(vote_count, Decision#decision.minority)), + ?assertEqual(<<"but the verse says otherwise">>, maps:get(position, Decision#decision.minority)), + application:unset_env(sanhedrin, consult), + cleanup(ok). + +%% The fake consultation the tests register. Step-aware for `dialectic`. +fake_consult(_Step, _Ref, _Roots, _Q, #{fake := raise}) -> + erlang:error(engine_exploded); +fake_consult(_Step, _Ref, _Roots, _Q, #{fake := resolved}) -> + {ok, #{status => <<"resolved">>, text => <<"the reading">>, verses => [{bereishit, 1, 1}]}}; +fake_consult(challenge, _Ref, _Roots, _Q, #{fake := dialectic}) -> + {ok, #{status => <<"challenged">>, text => <<>>, kushya => <<"but the verse says otherwise">>, + verses => [{devarim, 6, 4}]}}; +fake_consult(_Step, _Ref, _Roots, _Q, #{fake := dialectic}) -> + {ok, #{status => <<"resolved">>, text => <<"the reading">>, verses => [{bereishit, 1, 1}]}}; +fake_consult(_Step, _Ref, _Roots, _Q, _Ctx) -> + {error, no_fake}. + %% ============================================================ %% Panel majority/minority computation tests %% ============================================================ diff --git a/specs/broken_tablets/README.md b/specs/broken_tablets/README.md index 278eaf0f..0eb5906c 100644 --- a/specs/broken_tablets/README.md +++ b/specs/broken_tablets/README.md @@ -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 afterwards. -Not fixed, and recorded here so it is not mistaken for fixed: the -panel of one, the empty references under a grounded-sounding sentence, -the ignored hint, the unspent manna, the unrecorded case. The row in -Tamid is still larger than the thing behind it. The lesson is tablet +Not fixed in the first commit, and recorded here so it was not mistaken +for fixed: the panel of one, the empty references under a +grounded-sounding sentence, the ignored hint, the unspent manna, the +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 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 diff --git a/vav_pipeline/lib/vav_pipeline/application.ex b/vav_pipeline/lib/vav_pipeline/application.ex index 52257c6e..3e3a56f5 100644 --- a/vav_pipeline/lib/vav_pipeline/application.ex +++ b/vav_pipeline/lib/vav_pipeline/application.ex @@ -26,6 +26,11 @@ defmodule VavPipeline.Application do @impl true 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_cwd = Application.get_env(:vav_pipeline, :shoresh_engine_cwd, nil) talmudic_socket = Application.get_env(:vav_pipeline, :talmudic_socket_path, "/tmp/bikoret/talmudic.sock") diff --git a/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex b/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex index b5b5dfbb..bb93f7ba 100644 --- a/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex +++ b/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex @@ -1090,26 +1090,49 @@ defmodule VavPipeline.BigdeiKodesh.Router do # with the Tamid's own example request. The sides ride inside the # map, their shape checked at the door; and a refusal from the # 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 VavPipeline.SanhedrinClient.submit_case(%{ case_ref: case_ref, - complexity: :simple, + complexity: complexity, question: case_text, - context: %{sides: sides}, + context: %{ + sides: sides, + roots: resolved.roots, + torah_refs: [resolved.torah_ref], + authority_source: :torah + }, requested_by: "chatzer" }) do {:ok, decision} -> + recorded = sanhedrin_record_l3(case_ref, case_text, decision, resolved) + send_json(conn, 200, %{ case_ref: decision.case_ref, deliberation: %{ panel_size: decision.panel_size, - majority: decision.majority, - minority: decision.minority, + majority: sanhedrin_side(decision.majority), + minority: sanhedrin_side(decision.minority), outcome: decision.outcome, 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.", disclosure: disclosure() }) @@ -1129,12 +1152,47 @@ defmodule VavPipeline.BigdeiKodesh.Router do }) end else - {:error, issue} -> + {:error, :sides, issue} -> send_error(conn, 422, "KIYOR_VALIDATION_FAILED", %{ issue: issue, correction: "'sides' is an optional list of {\"position\": string, \"evidence\": [string]}", 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 @@ -1147,10 +1205,184 @@ defmodule VavPipeline.BigdeiKodesh.Router do defp sanhedrin_sides(sides) when is_list(sides) do if Enum.all?(sides, &side_ok?/1), 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 - 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 case Map.get(side, "evidence", []) do diff --git a/vav_pipeline/lib/vav_pipeline/sanhedrin_consult.ex b/vav_pipeline/lib/vav_pipeline/sanhedrin_consult.ex new file mode 100644 index 00000000..d6a5e659 --- /dev/null +++ b/vav_pipeline/lib/vav_pipeline/sanhedrin_consult.ex @@ -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 diff --git a/vav_pipeline/test/bigdei_kodesh_test.exs b/vav_pipeline/test/bigdei_kodesh_test.exs index 47bda294..7a5441d0 100644 --- a/vav_pipeline/test/bigdei_kodesh_test.exs +++ b/vav_pipeline/test/bigdei_kodesh_test.exs @@ -21,6 +21,38 @@ defmodule VavPipeline.BigdeiKodeshTest do @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) # ============================================================ @@ -301,6 +333,8 @@ defmodule VavPipeline.BigdeiKodeshTest do end test "POST /chatzer/sanhedrin with valid case deliberates" do + write_provision(2) + conn = conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{ 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 # 500 and took the halt monitor and escalation down with it. test "POST /chatzer/sanhedrin with sides (the Tamid's example) deliberates" do + write_provision(2) + conn = conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{ 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 body = Jason.decode!(conn.resp_body) 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 assert Process.whereis(:halt_monitor) != nil assert Process.whereis(:escalation) != nil 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 for bad <- ["not a list", [%{"no_position" => 1}], [%{"position" => "p", "evidence" => "not a list"}]] do conn =