Compare commits

..
Author SHA1 Message Date
Scott KarpandClaude Fable 5.1 e01181959d tablet 55, fourth: the engine answers the whole dialectic over the wire
Live after vav :4bffa01e the judges consulted with roots the engine
could read — interpret found 4 mitzvot and 11 verses for bereishit 1:1
— and the other two seats still said teiku. The engine had no
"challenge" action at all (process_json_line failed and the socket
fell silent), and a resolve that SUCCEEDED came back
UNEXPECTED_RESPONSE because response(ok, resolved_accepted, ...) had
no JSON clause. Both pre-existing; both invisible until a judge asked.

- api.pl: request(challenge, ...) — interpret, then a kushya against
  it → resolved_challenged with the kushya; none → no_challenge with
  the reading.
- json_bridge.pl: interpreted | resolved_accepted | no_challenge share
  the "resolved" JSON; resolved_challenged now carries the
  interpretation's verses and mitzvot (a judge rejecting on the kushya
  cites its ground); resolved_synthesized and no_interpretation gain
  clauses.
- reasoning_test.pl: dialectic_over_the_wire — all three actions
  answer; no roots → resolved on an empty trace (the judge abstains).

Prolog: 7 suites green (bedikah 45, halt 49, middot 47, mitzvot 42,
phase3 28, reasoning 18, tzaraat 34). Local probe: interpret /
challenge / resolve each → resolved, 11 verses, 4 mitzvot.

Live proof waits for tomorrow's manna: today's five panels were all
spent on verification (503 MANNA_DEPLETED, as specified).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 18:25:17 +00:00
Scott KarpandClaude Fable 5.1 4bffa01e0e tablet 55, third: roots the engine can read; abstentions count; an empty trace is no ground
Live after the second commit (vav :046010f0): a monetary case naming
bereishit 1:1 convened three judges and came back "accepted" — the
interpret seat said "resolved (5 roots, 0 verses)" with
reading(mitzvot_count(0),verse_count(0),grounded_in([],[])), the other
two seats said teiku, and one accept beat two abstentions (the reply
showed majority 1, minority 2). Three defects the tests could not see:

- The engine names roots by letter (bet_resh_aleph; json_bridge reads
  "bet-resh-aleph") and the pipeline sent it the Hebrew, which its
  verse facts never match. BereishitCycle.engine_root_name/1 builds
  the engine's name from the Shoresh Engine's letter names or, for the
  L1 fallback, from the Hebrew with finals folded; the router sends
  that. (The cycle's own Talmudic routing still sends the Hebrew —
  recorded, not changed here: it alters /peah/ask and needs its own
  verification.)
- judge.erl: "resolved" with no verse and no mitzvah is a reading
  coupled to nothing (avodah_zarah.pl Form 2); the judge abstains and
  says "resolved on an empty trace; nothing to stand on".
- panel.erl: the majority is the verdict with the most votes,
  abstentions included; an abstaining plurality or an accept/reject
  tie is teiku.

EUnit 57/0 (plurality, tie, empty trace). Elixir 632/0 (engine_root_name
from names, from Hebrew, nil on nothing; the no-engine case is an
abstaining majority of one, teiku).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 18:18:50 +00:00
Scott KarpandClaude Fable 5.1 046010f0ed 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>
2026-10-19 17:04:46 +00:00
Scott KarpandClaude Fable 5.1 7c40200dfc tablet 55: the row that was a label — the Sanhedrin's example request crashed its own gate
Verified live 2026-10-19: Tamid's own example for POST /chatzer/sanhedrin
(a case with `sides`) answered 500. The router passed the sides LIST as
the case context; halt_monitor:check_avodah_zarah/1 reads a map, maps:get
raised, and the halt monitor and escalation gen_servers died with the
caller's input (supervisor restarted them; health stayed 8/8). The router
then matched {:ok, decision} on the error and made a 500 of its own.

- sanhedrin/src/halt_monitor.erl: do_check_case refuses a non-map
  context as {halt, malformed_case, _} and stays standing. EUnit 49/0
  with the new halt_malformed_context_test.
- router.ex process_sanhedrin: sides validated at the kiyor (422 on a bad
  shape), carried as context %{sides: sides}; a Sanhedrin refusal answers
  422 SANHEDRIN_HALTED, a failure 503 SANHEDRIN_UNAVAILABLE. Two new
  router tests, one asserting the gate processes survive the example.
  Elixir 624/0.
- specs/broken_tablets/README.md: tablet 55, including what is NOT fixed
  — a panel of one accepting on zero references under a grounded-
  sounding sentence, the ignored complexity hint, no panel manna spent,
  no L3 record. The row in Tamid is still larger than the thing behind it.

Doorpost pin unchanged (no @spec_files touched; scripts/doorpost_check.py
passes).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 16:44:53 +00:00
Scott KarpandClaude Fable 5.1 04f61c1238 build: the Doorpost read before the image is built
Tablet 54 (2026-10-15): two spec edits in @spec_files shipped without
moving @expected_hash; the pod logged DOORPOST_DRIFT_AT_STARTUP,
readiness answered 503, and the gate — with every Claude Code session
behind it — went dark. The Doorpost test catches this only when the
suite runs on the exact commit that gets built, and the build script
never ran it.

scripts/doorpost_check.py computes the Doorpost identity the way the
module does (sha256 per spec file in listed order, "path\thash" joined
by newline, sha256 of that) with no Elixir, against the working tree
the image is copied from (default) or a git revision (--rev). On a
mismatch it exits 1 and names each file changed since --base
(origin/main). scripts/build_and_push.sh runs it before anything else
and refuses to build; DOORPOST_SKIP=1 is the written-down exception.

Proven: working tree and cb18f719 match their pins; 347f83cf (the
tablet 54 commit) reports DRIFT naming specs/bikoret_tamid.md and
specs/bishearecha.md; a blank line appended to specs/lo_tosif.md makes
both the check and the build script exit 1. KOHEN.md "Rolling out"
carries the procedure.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 16:25:09 +00:00
Scott KarpandClaude Fable 5.1 07463c9c37 kohen: claude-key — Claude Code on the API key when the login is what failed
2026-10-15 21:08Z: the daemon's proactive OAuth refresh failed (no
incident at Anthropic; most likely a cross-process race on the
single-use refresh token), the credential was dropped, and every
session on the VM showed "Login expired · Please run /login" until a
browser login took at 23:50Z — two "Login interrupted" first. The gate
was off; the fallback of 4acd56c4 could not help, because it is the
same OAuth credential.

scripts/claude-key starts Claude Code on the Anthropic API key from
~/.config/shorashim/upstream_keys (read at launch, never written),
direct to Anthropic whatever settings.json says. Proven:
`claude-key auth status` → authMethod api_key, apiKeySource
ANTHROPIC_API_KEY; a -p run from a directory with a dead gate armed in
project settings answered. KOHEN.md: the login recovery (claude-key,
`claude auth login` from the shell, `auth status --text`).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 23:55:48 +00:00
18 changed files with 1309 additions and 133 deletions

No files matched your search

+46 -4
View File
@@ -566,6 +566,26 @@ is (Day 4). `scripts/upstream-key-set` stores a caller's model key for
the gate scenario without echoing it (`~/.config/bikoret/upstream_keys`,
0600) — run it from a real terminal, not through a non-interactive runner.
**Before the image is built — the Doorpost read on the workbench.**
`VavPipeline.Doorpost` bakes a hash over the category-grade spec files
(`@spec_files` in `vav_pipeline/lib/vav_pipeline/doorpost.ex`); a pod
whose files do not match logs `DOORPOST_DRIFT_AT_STARTUP`, answers 503
on readiness, and takes the gate dark (tablet 54: two spec edits shipped
without the pin). `scripts/build_and_push.sh` now runs
`scripts/doorpost_check.py` first and refuses to build on drift, naming
the files that changed since `origin/main`. Hand builds should do the
same:
```bash
python3 -I scripts/doorpost_check.py # the working tree, as the image is built from
python3 -I scripts/doorpost_check.py --rev HEAD # a commit
python3 -I scripts/doorpost_check.py --print # the hash the pin should be
```
A spec change and its pin move belong in ONE commit, with
`mix test test/doorpost_test.exs` run on it. `DOORPOST_SKIP=1` exists
for a deliberate, written-down exception; it is never the way to ship.
### Standing at the gate — *Bishe'arecha*
A caller can put Bikoret between themselves and a model instead of
@@ -633,6 +653,10 @@ while the gate is armed there. Three tools, all in `scripts/` (install to
claude-direct # start Claude Code straight to Anthropic, whatever
# settings.json says (a --settings JSON on the command
# line outranks every settings file); OAuth as usual
claude-key # the same, on the Anthropic API KEY from
# ~/.config/shorashim/upstream_keys instead of the
# subscription's OAuth token: no browser, no /login.
# Billed to the API console account, not the Max plan.
claude-bikoret # start behind the gate; probes the gate first and, if
# it does not answer, says so and starts claude-direct.
# BIKORET_STRICT=1 claude-bikoret refuses instead.
@@ -643,10 +667,28 @@ bikoret-settings on|off|status
# it, not the builder; `off` is the recovery.
```
Order of recovery: `claude-direct` (always works), then `bikoret-settings
off` if background sessions must also recover, then fix the pod
(`curl -s https://bikoret.ch/kodesh/health` names the failed check), then
`bikoret-settings on` and `claude-bikoret` again.
Order of recovery: `claude-direct` (always works while the OAuth token
is good), then `bikoret-settings off` if background sessions must also
recover, then fix the pod (`curl -s https://bikoret.ch/kodesh/health`
names the failed check), then `bikoret-settings on` and `claude-bikoret`
again.
**When it is the login, not the gate.** Every session on the VM shares
one OAuth credential (`~/.claude/.credentials.json`); the access token
lives eight hours and the daemon refreshes it ahead of time. On
2026-10-15 21:08Z that refresh failed with no incident at Anthropic
(most likely two processes racing on the single-use refresh token), the
credential was dropped, and every session said "Login expired · Please
run /login" for 2 h 43 min. The browser flow from a remote shell is
fragile ("Login interrupted" twice before it took). Recovery:
```bash
claude-key # work NOW on the API key; nothing to log in to
claude auth login # from the shell, not inside a session: prints the
# URL, paste the code it gives back; `--console`
# signs in to the API account instead of the plan
claude auth status --text # says which credential a launch would use
```
### The second scroll — *Mishneh haTorah*
+19 -3
View File
@@ -145,13 +145,29 @@ handle_info(_Info, State) ->
%% Internal — gate-keeper checks
%%====================================================================
do_check_case(Case) ->
Context = Case#sanhedrin_case.context,
%% The context is a map (authority_source, action, ...). Anything else is
%% a malformed case, refused here — never reasoned about, never a crash.
%% Tablet 55 (2026-10-19): the Tamid's own example request carried a
%% LIST as context; maps:get raised, and this gen_server and escalation
%% died with the caller's input. A gate that can be felled by what it is
%% handed is not a gate. Fail closed, stay standing.
do_check_case(#sanhedrin_case{context = Context}) when is_map(Context) ->
with_chain([
fun() -> check_avodah_zarah(Context) end,
fun() -> check_shfichut_damim(Context) end,
fun() -> check_gilui_arayot(Context) end
]).
]);
do_check_case(#sanhedrin_case{context = Other}) ->
{halt, malformed_case,
iolist_to_binary([<<"case context must be a map, got ">>,
atom_to_binary(type_name(Other), utf8)])}.
type_name(T) when is_list(T) -> list;
type_name(T) when is_binary(T) -> binary;
type_name(T) when is_atom(T) -> atom;
type_name(T) when is_tuple(T) -> tuple;
type_name(T) when is_number(T) -> number;
type_name(_) -> other.
do_check_vote(_Vote) ->
%% Vote-level halt detection is the responsibility of the upstream
+124 -93
View File
@@ -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,112 @@ 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, []),
Grounding = maps:get(grounding, Map, []),
case Status of
<<"resolved">> when Verses =:= [], Grounding =:= [] ->
%% The engine says "resolved" over an empty trace — no verse,
%% no mitzvah. That is a reading that stands on nothing, and
%% a vote to accept it would be the calf (avodah_zarah.pl
%% Form 2: the uncoupled output). Abstain, and say why.
{<<"Teiku — the reading is coupled to no verse and no mitzvah">>,
reasoning(Step, <<"resolved on an empty trace; nothing to stand on">>, RootCount, Verses),
[], abstain};
<<"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
).
+13 -6
View File
@@ -207,15 +207,21 @@ compute_majority_minority([]) ->
torah_refs => [],
vote_count => 0}};
compute_majority_minority(Votes) ->
%% Count accepts vs rejects
Accepts = [V || V <- Votes, V#vote.verdict == accept],
Rejects = [V || V <- Votes, V#vote.verdict == reject],
Abstains = [V || V <- Votes, V#vote.verdict == abstain],
NA = length(Accepts), NR = length(Rejects), NB = length(Abstains),
%% The majority is the verdict with the most votes — abstentions
%% count (tablet 55: live, one accept "won" against two
%% abstentions, and the reply showed majority 1, minority 2). An
%% abstaining plurality, or an accept/reject tie, is teiku
%% (determine_outcome reads the majority's verdict).
{MajVotes, MinVotes} =
case length(Accepts) >= length(Rejects) of
true -> {Accepts, Rejects ++ Abstains};
false -> {Rejects, Accepts ++ Abstains}
if
NA > NR, NA >= NB -> {Accepts, Rejects ++ Abstains};
NR > NA, NR >= NB -> {Rejects, Accepts ++ Abstains};
true -> {Abstains, Accepts ++ Rejects}
end,
MajSample = case MajVotes of
@@ -307,9 +313,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)
+130
View File
@@ -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
%% ============================================================
@@ -58,6 +61,16 @@ halt_clear_test() ->
?assertEqual(clear, halt_monitor:check_case(Case)),
cleanup(ok).
%% Tablet 55: a non-map context is refused, and the monitor survives it.
halt_malformed_context_test() ->
{ok, _} = application:ensure_all_started(sanhedrin),
Sides = [#{<<"position">> => <<"a">>, <<"evidence">> => [<<"x">>]}],
Case = make_case_with_context(simple, <<"question">>, Sides),
?assertMatch({halt, malformed_case, _}, halt_monitor:check_case(Case)),
%% still standing, and still judging
?assertEqual(clear, halt_monitor:check_case(make_case(simple, <<"q">>))),
ok.
halt_avodah_zarah_test() ->
setup(),
Case = make_case_with_context(simple, <<"question">>,
@@ -372,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
%% ============================================================
@@ -407,6 +506,37 @@ majority_verdict_reject_test() ->
{Maj, _Min} = panel:compute_majority_minority(Votes),
?assertEqual(reject, maps:get(verdict, Maj)).
%% Tablet 55: abstentions count. One accept against two abstentions is
%% not an accepting majority — it is teiku.
majority_abstaining_plurality_is_teiku_test() ->
Votes = [make_vote(accept), make_vote(abstain), make_vote(abstain)],
{Maj, Min} = panel:compute_majority_minority(Votes),
?assertEqual(abstain, maps:get(verdict, Maj)),
?assertEqual(2, maps:get(vote_count, Maj)),
?assertEqual(1, maps:get(vote_count, Min)),
?assertEqual(teiku, panel:determine_outcome(Maj, Min, passed)).
majority_accept_reject_tie_is_teiku_test() ->
Votes = [make_vote(accept), make_vote(reject)],
{Maj, _Min} = panel:compute_majority_minority(Votes),
?assertEqual(abstain, maps:get(verdict, Maj)),
?assertEqual(0, maps:get(vote_count, Maj)).
%% Tablet 55: "resolved" over an empty trace — no verse, no mitzvah —
%% is nothing to stand on; the judge abstains rather than accept.
judge_resolved_on_empty_trace_abstains_test() ->
{Pos, Reason, Refs, Verdict} =
judge:vote_from_consultation(interpret,
{ok, #{status => <<"resolved">>, text => <<"reading(0,0)">>, verses => [], grounding => []}}, 5),
?assertEqual(abstain, Verdict),
?assertEqual([], Refs),
?assertMatch({_, _}, binary:match(Pos, <<"Teiku">>)),
?assertMatch({_, _}, binary:match(Reason, <<"empty trace">>)),
%% a mitzvah without a verse is still ground
{_, _, _, V2} = judge:vote_from_consultation(interpret,
{ok, #{status => <<"resolved">>, text => <<"r">>, verses => [], grounding => [<<"mitzvah(5)">>]}}, 1),
?assertEqual(accept, V2).
%% ============================================================
%% determine_outcome tests — accepted, rejected, teiku reachable
%% ============================================================
+10
View File
@@ -53,6 +53,16 @@ if [[ ! -d "deploy/docker" || ! -d "l1/torah" ]]; then
fi
# Sanity: docker buildx
# The Doorpost, read before the build: the spec files the image will carry
# must hash to the pin baked into VavPipeline.Doorpost, or the pod drifts
# at startup, readiness answers 503, and the gate goes dark (tablet 54,
# 2026-10-15). scripts/doorpost_check.py computes the same hash the same
# way and names the drifted files. DOORPOST_SKIP=1 only for a deliberate,
# documented exception — never to ship a drift.
if [[ "${DOORPOST_SKIP:-0}" != "1" ]]; then
python3 -I scripts/doorpost_check.py || exit 1
fi
if ! docker buildx version >/dev/null 2>&1; then
echo "ERROR: docker buildx not available — install Docker Desktop or buildx plugin" >&2
exit 1
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
# Start Claude Code on the Anthropic API KEY instead of the subscription's
# OAuth token — the second leg of the fallback (KOHEN "Claude Code behind
# the gate — fallback"). For when /login fails or the OAuth refresh died
# (2026-10-15 21:08Z: the daemon's proactive refresh failed, every session
# on the VM showed "Login expired", and the browser flow took three tries
# from a remote shell).
#
# Billing: the API console account (pay per token), not the Max plan.
# The key is read from the upstream keys file at launch and never written
# anywhere. Direct to Anthropic, whatever settings.json says.
KEY=$(awk '$1=="anthropic"{print $2}' "$HOME/.config/shorashim/upstream_keys")
[ -n "$KEY" ] || { echo "no 'anthropic <key>' line in ~/.config/shorashim/upstream_keys" >&2; exit 1; }
export ANTHROPIC_API_KEY="$KEY"
unset ANTHROPIC_BASE_URL ANTHROPIC_CUSTOM_HEADERS
exec claude --settings '{"env":{"ANTHROPIC_BASE_URL":"https://api.anthropic.com","ANTHROPIC_CUSTOM_HEADERS":""}}' "$@"
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""doorpost_check.py — the Doorpost read BEFORE the image is built.
VavPipeline.Doorpost bakes @expected_hash over the category-grade spec
files (@spec_files in vav_pipeline/lib/vav_pipeline/doorpost.ex). An
image whose spec files do not hash to its pin logs
DOORPOST_DRIFT_AT_STARTUP, answers 503 on readiness, and takes the gate
dark with it — tablet 54, 2026-10-15: two spec edits shipped without a
pin move and every Claude Code session behind the gate was cut off.
This script computes the same hash the same way, with no Elixir:
for each path in @spec_files, in listed order: sha256(contents)
join "path\\thash" with "\\n"; sha256 of that.
and compares it with the pin. Exit 0 on a match, 1 on drift (the
message names each drifted file against a base revision), 2 on a
missing file or an unreadable module.
scripts/doorpost_check.py # the working tree, as the image is built from
scripts/doorpost_check.py --rev <rev> # a git revision instead of the working tree
scripts/doorpost_check.py --base <rev> # on drift, diff the manifest against <rev> (default origin/main)
scripts/doorpost_check.py --print # print the hash only (what the pin should be)
Devarim 4:2 — nothing added, nothing taken: the pin moves by a commit
that moves it, never by a build that ignores it.
"""
import argparse, hashlib, os, re, subprocess, sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
MODULE = "vav_pipeline/lib/vav_pipeline/doorpost.ex"
def read(rev, path):
"""Bytes of `path` at `rev`, or from the working tree when rev is None. None if missing."""
if rev is None:
full = os.path.join(ROOT, path)
try:
with open(full, "rb") as f:
return f.read()
except FileNotFoundError:
return None
r = subprocess.run(["git", "-C", ROOT, "show", f"{rev}:{path}"], capture_output=True)
return r.stdout if r.returncode == 0 else None
def spec_files_and_pin(rev):
src = read(rev, MODULE)
if src is None:
sys.exit(f"doorpost_check: cannot read {MODULE}" + (f" at {rev}" if rev else ""))
src = src.decode()
block = src.split("@spec_files [", 1)[1].split("]", 1)[0]
files = re.findall(r'"([^"]+)"', block)
pin = re.search(r'@expected_hash\s+"([0-9a-f]{64})"', src)
if not files or not pin:
sys.exit(f"doorpost_check: could not parse @spec_files/@expected_hash from {MODULE}")
return files, pin.group(1)
def manifest(rev, files):
out = {}
for p in files:
data = read(rev, p)
out[p] = None if data is None else hashlib.sha256(data).hexdigest()
return out
def identity(man):
return hashlib.sha256("\n".join(f"{p}\t{h}" for p, h in man.items()).encode()).hexdigest()
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--rev", help="git revision to check instead of the working tree")
ap.add_argument("--base", default="origin/main", help="revision to name drifted files against")
ap.add_argument("--print", action="store_true", help="print the computed hash only")
a = ap.parse_args()
files, pin = spec_files_and_pin(a.rev)
man = manifest(a.rev, files)
missing = [p for p, h in man.items() if h is None]
if missing:
print("doorpost_check: MISSING spec file(s): " + ", ".join(missing), file=sys.stderr)
sys.exit(2)
computed = identity(man)
if a.print:
print(computed)
return
where = a.rev or "working tree"
if computed == pin:
print(f"doorpost: {where} hashes to its pin {pin[:8]}… ({len(files)} files) — the Doorpost will verify")
return
print(f"doorpost: DRIFT in {where} — computed {computed}, pinned {pin}", file=sys.stderr)
base_files, _ = spec_files_and_pin(a.base)
base_man = manifest(a.base, base_files)
for p in files:
if base_man.get(p) != man[p]:
print(f" changed since {a.base}: {p}", file=sys.stderr)
for p in files:
if p not in base_man:
print(f" added to @spec_files since {a.base}: {p}", file=sys.stderr)
print(f" Move @expected_hash in {MODULE} to {computed} in the same commit as the spec change,", file=sys.stderr)
print(f" then run: mix test test/doorpost_test.exs. Refusing to build an image that would drift at startup.", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
+63
View File
@@ -132,3 +132,66 @@ make. The fence held throughout — each forgery was stripped and named by
a verdict the builder did not write — which is the design proving itself
on its builder: the check has to stand outside the model, because the
model's own intention is subject to the same pull it is meant to catch.
## Tablet 55 — the row that was a label (2026-10-19)
Tamid promises `POST /chatzer/sanhedrin`: a case, optional sides, a
panel of three, a majority of two and a minority of one with their
sources. The row had stood since the endpoint was written. The build
state carried a line beside it — "never verified on prod" — for months.
On 2026-10-19 the builder sent the spec's own example request to the
live system, as the roadmap's rule asks: a spec row is a claim to test,
not a label.
The first request answered 500. The router had handed the `sides` list
to the Sanhedrin as the case's *context*, where the halt monitor reads a
map of `authority_source` and `action`; `maps:get` raised, and the halt
monitor and the escalation server died with the caller's input. The
supervisor restarted them; health stayed 8/8 the whole time. A gate that
can be felled by what it is handed is not a gate, and a health check
that cannot see the gate fall is not watching it. The router then
pattern-matched `{:ok, decision}` on an error and turned the refusal
into a 500 of its own.
The second request, without sides, answered 200 — and the answer was
hollow. A panel of one, not three. Field names the spec does not use.
An outcome of "accepted" on zero roots and zero references examined,
under the sentence "Torah references ground this interpretation". That
is the shape the bedikah names golden_calf when a model says it: a
confident claim standing on nothing. Here the system said it in its own
voice, through its own inner court. The complexity hint was ignored, no
panel manna was spent, and nothing was engraved in L3.
Fixed in this tablet: the halt monitor refuses a non-map context as
`malformed_case` and stays standing; the router carries the sides inside
the context map, checks their shape at the kiyor (422), and answers a
Sanhedrin refusal (422 SANHEDRIN_HALTED) or failure (503
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 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
cast it.
+18
View File
@@ -45,6 +45,24 @@ process_request(request(resolve, CaseRef, Domain, Roots, Question), Response) :-
; Response = response(error, no_resolution, CaseRef)
).
%% The challenge step of the dialectic, on its own (tablet 55: the
%% Sanhedrin's second seat asks it; until now no handler answered and
%% the socket fell silent). Interpret, then look for a kushya against
%% the interpretation: one stands → resolved_challenged, the kushya
%% named; none → no_challenge, the interpretation as read.
process_request(request(challenge, CaseRef, Domain, Roots, Question), Response) :-
Case = case(CaseRef, Domain, Roots, Question),
( interpret(Case, Interpretation)
-> ( challenge(Interpretation, Kushya)
-> Response = response(ok, resolved_challenged,
result(interpretation(Interpretation),
challenge(Kushya)))
; format_interpretation(Interpretation, response(ok, interpreted, Result)),
Response = response(ok, no_challenge, Result)
)
; Response = response(error, no_interpretation, CaseRef)
).
process_request(request(halt_check, Action), Response) :-
format_halt_check(Action, Response).
+41 -6
View File
@@ -153,11 +153,15 @@ read_coupling_term(Str, Term) :-
%% JSON format (Construction §3.3):
%% { "status": "resolved", "interpretation": { ... } }
%% Interpreted response
response_to_json(response(ok, interpreted, result(ref(Ref), mitzvot(Mitzvot),
verses(Verses), reading(Reading),
confidence(Conf))),
%% Interpreted response — and the same shape under the names the
%% resolve and challenge steps give it when the reading stands
%% (tablet 55: resolved_accepted had no clause, so a resolve that
%% succeeded came back UNEXPECTED_RESPONSE; no_challenge is new).
response_to_json(response(ok, Kind, result(ref(Ref), mitzvot(Mitzvot),
verses(Verses), reading(Reading),
confidence(Conf))),
Dict) :-
memberchk(Kind, [interpreted, resolved_accepted, no_challenge]),
maplist(term_to_string, Mitzvot, MitzvotStrs),
maplist(term_to_string, Verses, VerseStrs),
term_to_string(Reading, ReadingStr),
@@ -171,21 +175,52 @@ response_to_json(response(ok, interpreted, result(ref(Ref), mitzvot(Mitzvot),
confidence: ConfStr},
challenge: null}.
%% Challenged response
%% Challenged response — the interpretation's verses and mitzvot are
%% carried, so a judge rejecting on the kushya still cites its ground.
response_to_json(response(ok, resolved_challenged,
result(interpretation(Interp), challenge(Kushya))),
Dict) :-
Interp = interpretation(_Ref, Mitzvot, Verses, _Reading, _Conf, _Trace),
maplist(term_to_string, Mitzvot, MitzvotStrs),
maplist(term_to_string, Verses, VerseStrs),
term_to_string(Interp, InterpStr),
term_to_string(Kushya, KushyaStr),
Dict = json{status: "challenged",
interpretation: json{text: InterpStr,
prolog_form: InterpStr,
grounding: [],
grounding: MitzvotStrs,
verses: VerseStrs,
confidence: "medium"},
challenge: json{kushya: KushyaStr,
contradicting: "",
strength: "moderate"}}.
%% Synthesized resolution: both readings stand on common ground.
response_to_json(response(ok, resolved_synthesized,
result(common(Common), verses(Verses), both_sides(Both))),
Dict) :-
maplist(term_to_string, Common, CommonStrs),
maplist(term_to_string, Verses, VerseStrs),
term_to_string(Both, BothStr),
Dict = json{status: "resolved",
interpretation: json{text: BothStr,
prolog_form: BothStr,
grounding: CommonStrs,
verses: VerseStrs,
confidence: "medium"},
challenge: null}.
%% No interpretation possible (from resolve)
response_to_json(response(ok, no_interpretation, result(ref(Ref))), Dict) :-
atom_string(Ref, RefStr),
Dict = json{status: "teiku",
interpretation: json{text: "No interpretation found",
prolog_form: RefStr,
grounding: [],
verses: [],
confidence: "low"},
challenge: null}.
%% Teiku response
response_to_json(response(ok, teiku, result(record(Record))), Dict) :-
term_to_string(Record, RecordStr),
+49
View File
@@ -112,3 +112,52 @@ test(teiku_count_works) :-
integer(Count).
:- end_tests(teiku_module).
%% ============================================================
%% Tablet 55 — the dialectic over the wire: interpret, challenge,
%% resolve each answer JSON a judge can vote on. Before this the
%% engine had no "challenge" action and a resolve that succeeded came
%% back UNEXPECTED_RESPONSE (resolved_accepted had no JSON clause).
%% ============================================================
:- use_module('../src/interface/api').
:- use_module('../src/interface/json_bridge').
:- use_module('../src/interface/socket_server').
:- begin_tests(dialectic_over_the_wire).
roots_line(Action, Line) :-
format(atom(Line),
'{"action":"~w","case":{"ref":"t55-~w","question":"q","context_roots":["bet-resh-aleph","aleph-lamed-he"]}}',
[Action, Action]).
test(interpret_answers_resolved_with_verses, [nondet]) :-
roots_line(interpret, L),
socket_server:process_json_line(L, R),
get_dict(status, R, "resolved"),
get_dict(interpretation, R, I),
get_dict(verses, I, Vs), Vs \== [].
test(challenge_answers_and_carries_the_ground, [nondet]) :-
roots_line(challenge, L),
socket_server:process_json_line(L, R),
get_dict(status, R, S),
memberchk(S, ["resolved", "challenged"]),
get_dict(interpretation, R, I),
get_dict(verses, I, Vs), Vs \== [].
test(resolve_answers_resolved_not_unexpected, [nondet]) :-
roots_line(resolve, L),
socket_server:process_json_line(L, R),
\+ get_dict(error, R, _),
get_dict(status, R, S),
memberchk(S, ["resolved", "challenged", "teiku"]).
test(no_roots_resolves_on_an_empty_trace, [nondet]) :-
socket_server:process_json_line('{"action":"interpret","case":{"ref":"t55-empty","question":"q","context_roots":[]}}', R),
get_dict(status, R, "resolved"),
get_dict(interpretation, R, I),
get_dict(verses, I, []),
get_dict(grounding, I, []).
:- end_tests(dialectic_over_the_wire).
@@ -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")
@@ -970,6 +970,40 @@ defmodule VavPipeline.BereishitCycle do
defp letters_to_hebrew(_), do: nil
# The inverse: Hebrew consonant → letter name, finals folded to their base.
@hebrew_to_name Map.new(@consonant_names, fn {name, heb} -> {heb, name} end)
|> Map.merge(%{"ך" => "kaf", "ם" => "mem", "ן" => "nun", "ף" => "pe", "ץ" => "tsadi"})
@doc """
The root as the Talmudic Engine names it — letter names joined by `-`
(`json_bridge` `parse_root_name` → `bet_resh_aleph`, the form of its
verse facts). From the letter names the Shoresh Engine gives when it
has them, else from the Hebrew (the L1 fallback carries only that).
Tablet 55: the engine was being sent the Hebrew, which its facts never
match — 5 roots in, 0 verses out, and "resolved" over the emptiness.
`nil` when the root carries nothing nameable.
"""
@spec engine_root_name(map()) :: String.t() | nil
def engine_root_name(root) when is_map(root) do
letters = Map.get(root, :shoresh_letters) || []
names =
if is_list(letters) and letters != [] do
Enum.map(letters, &String.downcase(to_string(&1)))
else
root
|> Map.get(:shoresh)
|> case do
s when is_binary(s) -> s |> String.graphemes() |> Enum.map(&Map.get(@hebrew_to_name, &1))
_ -> [nil]
end
end
if names != [] and Enum.all?(names, &is_binary/1), do: Enum.join(names, "-"), else: nil
end
def engine_root_name(_), do: nil
# ============================================================
# Private helpers
# ============================================================
@@ -1084,32 +1084,319 @@ defmodule VavPipeline.BigdeiKodesh.Router do
resubmit: true
})
else
case_ref = generate_id()
# Tablet 55: the sides are the caller's arguments, not the halt
# gate's context. The gate reads a MAP (authority_source, action);
# handing it the sides list crashed the halt monitor and escalation
# 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.
#
# 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()
{:ok, decision} =
VavPipeline.SanhedrinClient.submit_case(%{
case_ref: case_ref,
complexity: :simple,
question: case_text,
context: params["sides"] || %{},
requested_by: "chatzer"
})
case VavPipeline.SanhedrinClient.submit_case(%{
case_ref: case_ref,
complexity: complexity,
question: case_text,
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,
outcome: decision.outcome,
processing_time_ms: decision.deliberation_time_ms
},
note: "This is the system's reasoning, not a binding ruling. A human beit din may reach a different conclusion.",
disclosure: disclosure()
})
send_json(conn, 200, %{
case_ref: decision.case_ref,
deliberation: %{
panel_size: decision.panel_size,
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()
})
{:error, "Halted" <> _ = reason} ->
send_error(conn, 422, "SANHEDRIN_HALTED", %{
issue: reason,
correction: "The case was refused at the gate before deliberation. Amend it and resubmit.",
resubmit: true
})
{:error, reason} ->
send_error(conn, 503, "SANHEDRIN_UNAVAILABLE", %{
issue: reason,
correction: "The Sanhedrin did not deliberate. Retry; if it recurs, the kohanim will find it in the log.",
resubmit: true
})
end
else
{: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
# The optional sides: absent → []; a list of maps each with a string
# "position" and an optional list of string "evidence" → kept as given;
# anything else → refused at the kiyor.
defp sanhedrin_sides(nil), do: {:ok, []}
defp sanhedrin_sides(sides) when is_list(sides) do
if Enum.all?(sides, &side_ok?/1),
do: {:ok, sides},
else: {:error, :sides, "'sides' must be a list of {position, evidence[]} objects"}
end
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,
%{
# The engine's verse facts name roots by letter — bet_resh_aleph —
# and json_bridge reads "bet-resh-aleph". The cycle's root carries
# the letter names beside the Hebrew; the Hebrew never matches
# (live, 2026-10-19: 5 roots sent, 0 verses found, "resolved").
roots: roots_full |> Enum.map(&VavPipeline.BereishitCycle.engine_root_name/1) |> Enum.reject(&is_nil/1),
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
ev when is_list(ev) -> Enum.all?(ev, &is_binary/1)
_ -> false
end
end
defp side_ok?(_), do: false
# ============================================================
# Internal — Health and Status
# ============================================================
@@ -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
+198
View File
@@ -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?"
@@ -317,6 +351,170 @@ defmodule VavPipeline.BigdeiKodeshTest do
assert body["deliberation"]["outcome"] in ["accepted", "rejected", "teiku"]
assert body["note"] =~ "not a binding ruling"
end
# 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?",
sides: [
%{position: "the borrower is believed", evidence: ["the money was handed over before a witness"]},
%{position: "the lender is believed", evidence: ["one witness does not establish payment"]}
],
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["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
# the one judge abstained: an abstaining majority of one, no dissent
assert body["deliberation"]["majority"]["votes"] == 1
assert body["deliberation"]["majority"]["reasoning"] =~ "no consultation"
assert body["deliberation"]["minority"]["votes"] == 0
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 =
conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{case: "x", sides: bad}))
|> put_req_header("content-type", "application/json")
|> put_req_header("authorization", "Bearer chatzer-test-token")
|> Router.call(@opts)
assert conn.status == 422, "expected 422 for sides=#{inspect(bad)}, got #{conn.status}"
assert Jason.decode!(conn.resp_body)["error"]["code"] == "KIYOR_VALIDATION_FAILED"
end
end
end
# ============================================================
@@ -0,0 +1,27 @@
defmodule VavPipeline.Tablet55EngineRootNameTest do
@moduledoc """
Tablet 55: the Talmudic Engine names roots by letter (bet_resh_aleph,
read from "bet-resh-aleph"). The pipeline was sending it the Hebrew,
which its verse facts never match — roots in, no verses out, and a
"resolved" reading over the emptiness.
"""
use ExUnit.Case, async: true
alias VavPipeline.BereishitCycle
test "from the Shoresh Engine's letter names" do
assert BereishitCycle.engine_root_name(%{shoresh: "ברא", shoresh_letters: ["bet", "resh", "aleph"]}) ==
"bet-resh-aleph"
end
test "from the Hebrew alone (the L1 fallback), finals folded" do
assert BereishitCycle.engine_root_name(%{shoresh: "דבר", shoresh_letters: []}) == "dalet-bet-resh"
assert BereishitCycle.engine_root_name(%{shoresh: "מלך"}) == "mem-lamed-kaf"
end
test "nothing nameable → nil, never a Hebrew string the engine cannot read" do
assert BereishitCycle.engine_root_name(%{shoresh: nil}) == nil
assert BereishitCycle.engine_root_name(%{shoresh: "x1"}) == nil
assert BereishitCycle.engine_root_name(%{}) == nil
end
end