Phase 1-2 build: Steps 5-6, 8-13
Shoresh Engine (Haskell, Step 5) - 12 modules: Types, Lexicon, Binyanim, Teamim, Gematria, Graph, KetivQere, Smikhut, Havdalah, Kriah, KiTov, Firmware - 33 EUnit tests, 0 failures - Firmware module verifies L1 SHA-256 hash at startup Talmudic Engine (SWI-Prolog, Step 6) - 15 modules: halt, reasoning, specification, interface - 57 PlUnit tests, 0 failures - Three cardinal halt conditions: avodah_zarah, shfichut_damim, gilui_arayot Vav Pipeline (Elixir, Steps 8-11) - 14 modules: BereishitCycle, Shema, Cloud, Manna, L1Reader, Specification, HaltConditions, ShabbatTimer, CycleLog, Chatzotzrot, ShoreshEngineClient, TalmudicEngineClient, Application - 46 ExUnit tests, 0 failures - Nishma-first architecture: hears before it speaks Sanhedrin Framework (Erlang/OTP, Step 12) - 8 modules: sanhedrin_app, sanhedrin_sup, escalation, panel_sup, panel, judge, clerk, halt_monitor - 46 EUnit tests, 0 failures (includes fix: rejected outcome now reachable) - Panel sizes: 1/3/23/71 — unanimous capital guilty → acquit (Sanhedrin 17a) - Minority opinions append-only, never deleted Korban Processor (Python, Step 13) - 6 modules: korban, shechitah, semichah, zerikah, haktarah, router Builder tooling: AGENTS.md, opencode.json, memory/, src/tools/ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
622e814c9e
commit
80ec364da6
86 files changed
+9646
No files matched your search
@@ -0,0 +1,20 @@
|
||||
.rebar3
|
||||
_build
|
||||
_checkouts
|
||||
_vendor
|
||||
.eunit
|
||||
*.o
|
||||
*.beam
|
||||
*.plt
|
||||
*.swp
|
||||
*.swo
|
||||
.erlang.cookie
|
||||
ebin
|
||||
log
|
||||
erl_crash.dump
|
||||
.rebar
|
||||
logs
|
||||
.idea
|
||||
*.iml
|
||||
rebar3.crashdump
|
||||
*~
|
||||
@@ -0,0 +1,186 @@
|
||||
# Apache License
|
||||
Version 2.0, January 2004
|
||||
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
## 1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction, and
|
||||
distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by the copyright
|
||||
owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all other entities
|
||||
that control, are controlled by, or are under common control with that entity.
|
||||
For the purposes of this definition, "control" means (i) the power, direct or
|
||||
indirect, to cause the direction or management of such entity, whether by
|
||||
contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity exercising
|
||||
permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications, including
|
||||
but not limited to software source code, documentation source, and configuration
|
||||
files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical transformation or
|
||||
translation of a Source form, including but not limited to compiled object code,
|
||||
generated documentation, and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or Object form, made
|
||||
available under the License, as indicated by a copyright notice that is included
|
||||
in or attached to the work (an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object form, that
|
||||
is based on (or derived from) the Work and for which the editorial revisions,
|
||||
annotations, elaborations, or other modifications represent, as a whole, an
|
||||
original work of authorship. For the purposes of this License, Derivative Works
|
||||
shall not include works that remain separable from, or merely link (or bind by
|
||||
name) to the interfaces of, the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including the original version
|
||||
of the Work and any modifications or additions to that Work or Derivative Works
|
||||
thereof, that is intentionally submitted to Licensor for inclusion in the Work
|
||||
by the copyright owner or by an individual or Legal Entity authorized to submit
|
||||
on behalf of the copyright owner. For the purposes of this definition,
|
||||
"submitted" means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems, and
|
||||
issue tracking systems that are managed by, or on behalf of, the Licensor for
|
||||
the purpose of discussing and improving the Work, but excluding communication
|
||||
that is conspicuously marked or otherwise designated in writing by the copyright
|
||||
owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf
|
||||
of whom a Contribution has been received by Licensor and subsequently
|
||||
incorporated within the Work.
|
||||
|
||||
## 2. Grant of Copyright License.
|
||||
|
||||
Subject to the terms and conditions of this License, each Contributor hereby
|
||||
grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
|
||||
irrevocable copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the Work and such
|
||||
Derivative Works in Source or Object form.
|
||||
|
||||
## 3. Grant of Patent License.
|
||||
|
||||
Subject to the terms and conditions of this License, each Contributor hereby
|
||||
grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
|
||||
irrevocable (except as stated in this section) patent license to make, have
|
||||
made, use, offer to sell, sell, import, and otherwise transfer the Work, where
|
||||
such license applies only to those patent claims licensable by such Contributor
|
||||
that are necessarily infringed by their Contribution(s) alone or by combination
|
||||
of their Contribution(s) with the Work to which such Contribution(s) was
|
||||
submitted. If You institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work or a
|
||||
Contribution incorporated within the Work constitutes direct or contributory
|
||||
patent infringement, then any patent licenses granted to You under this License
|
||||
for that Work shall terminate as of the date such litigation is filed.
|
||||
|
||||
## 4. Redistribution.
|
||||
|
||||
You may reproduce and distribute copies of the Work or Derivative Works thereof
|
||||
in any medium, with or without modifications, and in Source or Object form,
|
||||
provided that You meet the following conditions:
|
||||
|
||||
1. You must give any other recipients of the Work or Derivative Works a copy of
|
||||
this License; and
|
||||
|
||||
2. You must cause any modified files to carry prominent notices stating that
|
||||
You changed the files; and
|
||||
|
||||
3. You must retain, in the Source form of any Derivative Works that You
|
||||
distribute, all copyright, patent, trademark, and attribution notices from
|
||||
the Source form of the Work, excluding those notices that do not pertain to
|
||||
any part of the Derivative Works; and
|
||||
|
||||
4. If the Work includes a "NOTICE" text file as part of its distribution, then
|
||||
any Derivative Works that You distribute must include a readable copy of the
|
||||
attribution notices contained within such NOTICE file, excluding those
|
||||
notices that do not pertain to any part of the Derivative Works, in at least
|
||||
one of the following places: within a NOTICE text file distributed as part
|
||||
of the Derivative Works; within the Source form or documentation, if
|
||||
provided along with the Derivative Works; or, within a display generated by
|
||||
the Derivative Works, if and wherever such third-party notices normally
|
||||
appear. The contents of the NOTICE file are for informational purposes only
|
||||
and do not modify the License. You may add Your own attribution notices
|
||||
within Derivative Works that You distribute, alongside or as an addendum to
|
||||
the NOTICE text from the Work, provided that such additional attribution
|
||||
notices cannot be construed as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and may provide
|
||||
additional or different license terms and conditions for use, reproduction, or
|
||||
distribution of Your modifications, or for any such Derivative Works as a whole,
|
||||
provided Your use, reproduction, and distribution of the Work otherwise complies
|
||||
with the conditions stated in this License.
|
||||
|
||||
## 5. Submission of Contributions.
|
||||
|
||||
Unless You explicitly state otherwise, any Contribution intentionally submitted
|
||||
for inclusion in the Work by You to the Licensor shall be under the terms and
|
||||
conditions of this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify the terms of
|
||||
any separate license agreement you may have executed with Licensor regarding
|
||||
such Contributions.
|
||||
|
||||
## 6. Trademarks.
|
||||
|
||||
This License does not grant permission to use the trade names, trademarks,
|
||||
service marks, or product names of the Licensor, except as required for
|
||||
reasonable and customary use in describing the origin of the Work and
|
||||
reproducing the content of the NOTICE file.
|
||||
|
||||
## 7. Disclaimer of Warranty.
|
||||
|
||||
Unless required by applicable law or agreed to in writing, Licensor provides the
|
||||
Work (and each Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied,
|
||||
including, without limitation, any warranties or conditions of TITLE, NON-
|
||||
INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are
|
||||
solely responsible for determining the appropriateness of using or
|
||||
redistributing the Work and assume any risks associated with Your exercise of
|
||||
permissions under this License.
|
||||
|
||||
## 8. Limitation of Liability.
|
||||
|
||||
In no event and under no legal theory, whether in tort (including negligence),
|
||||
contract, or otherwise, unless required by applicable law (such as deliberate
|
||||
and grossly negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special, incidental,
|
||||
or consequential damages of any character arising as a result of this License or
|
||||
out of the use or inability to use the Work (including but not limited to
|
||||
damages for loss of goodwill, work stoppage, computer failure or malfunction, or
|
||||
any and all other commercial damages or losses), even if such Contributor has
|
||||
been advised of the possibility of such damages.
|
||||
|
||||
## 9. Accepting Warranty or Additional Liability.
|
||||
|
||||
While redistributing the Work or Derivative Works thereof, You may choose to
|
||||
offer, and charge a fee for, acceptance of support, warranty, indemnity, or
|
||||
other liability obligations and/or rights consistent with this License. However,
|
||||
in accepting such obligations, You may act only on Your own behalf and on Your
|
||||
sole responsibility, not on behalf of any other Contributor, and only if You
|
||||
agree to indemnify, defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason of your
|
||||
accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2026, Scott Karp <github.carded456@passmail.net>.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,9 @@
|
||||
sanhedrin
|
||||
=====
|
||||
|
||||
An OTP application
|
||||
|
||||
Build
|
||||
-----
|
||||
|
||||
$ rebar3 compile
|
||||
@@ -0,0 +1,101 @@
|
||||
%%% sanhedrin.hrl — Record definitions for the Sanhedrin deliberation engine
|
||||
%%%
|
||||
%%% Architecture §3.2: Concurrent deliberation engine.
|
||||
%%% Spawns panels of independent deliberators, collects votes,
|
||||
%%% enforces mandatory dissent on capital decisions,
|
||||
%%% stores minority opinions.
|
||||
|
||||
%% --- Case complexity levels ---
|
||||
%% simple -> 1 judge
|
||||
%% monetary -> 3 judges (dinei mamonot)
|
||||
%% capital -> 23 judges (dinei nefashot)
|
||||
%% novel -> 71 judges (Sanhedrin Gedolah)
|
||||
-type complexity() :: simple | monetary | capital | novel.
|
||||
|
||||
%% --- Case outcome ---
|
||||
-type outcome() :: accepted | rejected | teiku.
|
||||
|
||||
%% --- Unanimous check result ---
|
||||
-type unanimous_check() :: passed | {failed, suspicion_of_consensus}.
|
||||
|
||||
%% --- Torah reference ---
|
||||
-record(torah_ref, {
|
||||
book :: atom(), %% bereishit | shemot | vayikra | bamidbar | devarim
|
||||
chapter :: pos_integer(),
|
||||
verse :: pos_integer()
|
||||
}).
|
||||
|
||||
%% --- A root with domain ---
|
||||
-record(root_info, {
|
||||
shoresh :: list(atom()), %% e.g. [bet, resh, aleph]
|
||||
domain :: atom() %% e.g. creation, justice, holiness
|
||||
}).
|
||||
|
||||
%% --- Case submission (Pipeline → Sanhedrin) ---
|
||||
%% Construction spec §3.2
|
||||
-record(sanhedrin_case, {
|
||||
case_ref :: binary(), %% UUID
|
||||
complexity :: complexity(),
|
||||
question :: binary(),
|
||||
context :: map(), %% #{roots, torah_refs, prior_interpretations, shema_context}
|
||||
requested_by :: binary(),
|
||||
submitted_at :: integer() %% erlang:system_time(millisecond)
|
||||
}).
|
||||
|
||||
%% --- Individual judge vote ---
|
||||
-record(vote, {
|
||||
judge_id :: reference(),
|
||||
position :: binary(), %% The interpretation
|
||||
reasoning :: binary(), %% Why this position
|
||||
torah_refs :: [#torah_ref{}], %% Grounding references
|
||||
verdict :: accept | reject | abstain,
|
||||
cast_at :: integer()
|
||||
}).
|
||||
|
||||
%% --- Panel record ---
|
||||
-record(panel, {
|
||||
panel_id :: binary(),
|
||||
case_ref :: binary(),
|
||||
size :: pos_integer(), %% 1, 3, 23, or 71
|
||||
judges :: [pid()],
|
||||
votes :: [#vote{}],
|
||||
status :: convened | deliberating | decided | dissolved,
|
||||
convened_at :: integer(),
|
||||
decided_at :: integer() | undefined
|
||||
}).
|
||||
|
||||
%% --- Deliberation decision ---
|
||||
%% Construction spec §3.2 — output format
|
||||
-record(decision, {
|
||||
case_ref :: binary(),
|
||||
outcome :: outcome(),
|
||||
majority :: map(), %% #{position, reasoning, torah_refs, vote_count}
|
||||
minority :: map(), %% #{position, reasoning, torah_refs, vote_count}
|
||||
panel_size :: pos_integer(),
|
||||
deliberation_time_ms :: non_neg_integer(),
|
||||
unanimous_check :: unanimous_check()
|
||||
}).
|
||||
|
||||
%% --- Opinion record (stored by clerk, never deleted) ---
|
||||
-record(opinion, {
|
||||
case_ref :: binary(),
|
||||
judge_id :: reference(),
|
||||
position :: binary(),
|
||||
reasoning :: binary(),
|
||||
torah_refs :: [#torah_ref{}],
|
||||
verdict :: accept | reject | abstain,
|
||||
is_majority :: boolean(),
|
||||
stored_at :: integer()
|
||||
}).
|
||||
|
||||
%% --- Panel size constants ---
|
||||
-define(PANEL_SIMPLE, 1).
|
||||
-define(PANEL_MONETARY, 3).
|
||||
-define(PANEL_CAPITAL, 23).
|
||||
-define(PANEL_NOVEL, 71).
|
||||
|
||||
%% --- Deliberation timeouts (Construction spec §2.2) ---
|
||||
-define(TIMEOUT_SIMPLE, 15000). %% 15 seconds
|
||||
-define(TIMEOUT_MONETARY, 15000). %% 15 seconds
|
||||
-define(TIMEOUT_CAPITAL, 120000). %% 120 seconds
|
||||
-define(TIMEOUT_NOVEL, 600000). %% 600 seconds
|
||||
@@ -0,0 +1,13 @@
|
||||
%% Construction spec §1.5 — Erlang dependencies (exact)
|
||||
{erl_opts, [debug_info, {i, "include"}]}.
|
||||
|
||||
{deps, [
|
||||
{jsx, "3.1.0"}
|
||||
]}.
|
||||
|
||||
{shell, [
|
||||
{apps, [sanhedrin]}
|
||||
]}.
|
||||
|
||||
%% EUnit
|
||||
{eunit_opts, [verbose]}.
|
||||
@@ -0,0 +1,8 @@
|
||||
{"1.2.0",
|
||||
[{<<"jsx">>,{pkg,<<"jsx">>,<<"3.1.0">>},0}]}.
|
||||
[
|
||||
{pkg_hash,[
|
||||
{<<"jsx">>, <<"D12516BAA0BB23A59BB35DCCAF02A1BD08243FCBB9EFE24F2D9D056CCFF71268">>}]},
|
||||
{pkg_hash_ext,[
|
||||
{<<"jsx">>, <<"0C5CC8FDC11B53CC25CF65AC6705AD39E54ECC56D1C22E4ADB8F5A53FB9427F3">>}]}
|
||||
].
|
||||
@@ -0,0 +1,127 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Clerk — vote collection, opinion storage.
|
||||
%%%
|
||||
%%% Architecture §3.2:
|
||||
%%% The clerk collects votes, stores opinions (both majority
|
||||
%%% and minority), and maintains the decision record.
|
||||
%%%
|
||||
%%% Opinions are stored as append-only — consistent with
|
||||
%%% L3 write protocol. Minority opinions are NEVER deleted.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(clerk).
|
||||
|
||||
-behaviour(gen_server).
|
||||
|
||||
-include("sanhedrin.hrl").
|
||||
|
||||
%% API
|
||||
-export([start_link/0,
|
||||
store_decision/2,
|
||||
store_opinion/1,
|
||||
get_decision/1,
|
||||
get_opinions/1,
|
||||
get_minority_opinions/1,
|
||||
count_decisions/0,
|
||||
count_opinions/0]).
|
||||
|
||||
%% gen_server callbacks
|
||||
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
|
||||
|
||||
-define(SERVER, ?MODULE).
|
||||
|
||||
-record(state, {
|
||||
decisions = #{} :: map(), %% case_ref => #decision{}
|
||||
opinions = [] :: [#opinion{}] %% Append-only list
|
||||
}).
|
||||
|
||||
%%====================================================================
|
||||
%% API
|
||||
%%====================================================================
|
||||
|
||||
start_link() ->
|
||||
gen_server:start_link({local, ?SERVER}, ?MODULE, [], []).
|
||||
|
||||
%% @doc Store a deliberation decision.
|
||||
-spec store_decision(binary(), #decision{}) -> ok.
|
||||
store_decision(CaseRef, Decision) ->
|
||||
gen_server:cast(?SERVER, {store_decision, CaseRef, Decision}).
|
||||
|
||||
%% @doc Store an individual opinion (majority or minority).
|
||||
%% Opinions are NEVER deleted. Both tablets are in the aron.
|
||||
-spec store_opinion(#opinion{}) -> ok.
|
||||
store_opinion(Opinion) ->
|
||||
gen_server:cast(?SERVER, {store_opinion, Opinion}).
|
||||
|
||||
%% @doc Retrieve a decision by case reference.
|
||||
-spec get_decision(binary()) -> {ok, #decision{}} | {error, not_found}.
|
||||
get_decision(CaseRef) ->
|
||||
gen_server:call(?SERVER, {get_decision, CaseRef}).
|
||||
|
||||
%% @doc Retrieve all opinions for a case reference.
|
||||
-spec get_opinions(binary()) -> [#opinion{}].
|
||||
get_opinions(CaseRef) ->
|
||||
gen_server:call(?SERVER, {get_opinions, CaseRef}).
|
||||
|
||||
%% @doc Retrieve only minority opinions for a case reference.
|
||||
-spec get_minority_opinions(binary()) -> [#opinion{}].
|
||||
get_minority_opinions(CaseRef) ->
|
||||
gen_server:call(?SERVER, {get_minority_opinions, CaseRef}).
|
||||
|
||||
%% @doc Count total stored decisions.
|
||||
-spec count_decisions() -> non_neg_integer().
|
||||
count_decisions() ->
|
||||
gen_server:call(?SERVER, count_decisions).
|
||||
|
||||
%% @doc Count total stored opinions.
|
||||
-spec count_opinions() -> non_neg_integer().
|
||||
count_opinions() ->
|
||||
gen_server:call(?SERVER, count_opinions).
|
||||
|
||||
%%====================================================================
|
||||
%% gen_server callbacks
|
||||
%%====================================================================
|
||||
|
||||
init([]) ->
|
||||
{ok, #state{}}.
|
||||
|
||||
handle_call({get_decision, CaseRef}, _From, State) ->
|
||||
case maps:find(CaseRef, State#state.decisions) of
|
||||
{ok, Decision} -> {reply, {ok, Decision}, State};
|
||||
error -> {reply, {error, not_found}, State}
|
||||
end;
|
||||
|
||||
handle_call({get_opinions, CaseRef}, _From, State) ->
|
||||
Opinions = [O || O <- State#state.opinions,
|
||||
O#opinion.case_ref == CaseRef],
|
||||
{reply, Opinions, State};
|
||||
|
||||
handle_call({get_minority_opinions, CaseRef}, _From, State) ->
|
||||
Opinions = [O || O <- State#state.opinions,
|
||||
O#opinion.case_ref == CaseRef,
|
||||
O#opinion.is_majority == false],
|
||||
{reply, Opinions, State};
|
||||
|
||||
handle_call(count_decisions, _From, State) ->
|
||||
{reply, maps:size(State#state.decisions), State};
|
||||
|
||||
handle_call(count_opinions, _From, State) ->
|
||||
{reply, length(State#state.opinions), State};
|
||||
|
||||
handle_call(_Request, _From, State) ->
|
||||
{reply, {error, unknown_request}, State}.
|
||||
|
||||
handle_cast({store_decision, CaseRef, Decision}, State) ->
|
||||
NewDecisions = maps:put(CaseRef, Decision, State#state.decisions),
|
||||
{noreply, State#state{decisions = NewDecisions}};
|
||||
|
||||
handle_cast({store_opinion, Opinion}, State) ->
|
||||
%% Append-only — opinions are never removed
|
||||
NewOpinions = [Opinion | State#state.opinions],
|
||||
{noreply, State#state{opinions = NewOpinions}};
|
||||
|
||||
handle_cast(_Msg, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
handle_info(_Info, State) ->
|
||||
{noreply, State}.
|
||||
@@ -0,0 +1,111 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Escalation — assess complexity and route cases to panels.
|
||||
%%%
|
||||
%%% Architecture §3.2:
|
||||
%%% assess(Case) determines complexity, route/2 convenes the panel.
|
||||
%%%
|
||||
%%% Panel sizes from halakha:
|
||||
%%% simple -> 1 judge (individual matters)
|
||||
%%% monetary -> 3 judges (dinei mamonot)
|
||||
%%% capital -> 23 judges (dinei nefashot)
|
||||
%%% novel -> 71 judges (Sanhedrin Gedolah)
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(escalation).
|
||||
|
||||
-behaviour(gen_server).
|
||||
|
||||
-include("sanhedrin.hrl").
|
||||
|
||||
%% API
|
||||
-export([start_link/0, submit_case/1, assess/1]).
|
||||
|
||||
%% gen_server callbacks
|
||||
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
|
||||
|
||||
-define(SERVER, ?MODULE).
|
||||
|
||||
-record(state, {
|
||||
active_cases = #{} :: map(), %% case_ref => panel_pid
|
||||
case_count = 0 :: non_neg_integer()
|
||||
}).
|
||||
|
||||
%%====================================================================
|
||||
%% API
|
||||
%%====================================================================
|
||||
|
||||
start_link() ->
|
||||
gen_server:start_link({local, ?SERVER}, ?MODULE, [], []).
|
||||
|
||||
%% @doc Submit a case for deliberation.
|
||||
%% Returns {ok, CaseRef} or {error, Reason}.
|
||||
-spec submit_case(#sanhedrin_case{}) -> {ok, binary()} | {error, term()}.
|
||||
submit_case(Case) ->
|
||||
gen_server:call(?SERVER, {submit_case, Case}, 30000).
|
||||
|
||||
%% @doc Assess complexity of a case. Pure function.
|
||||
-spec assess(#sanhedrin_case{}) -> {panel, pos_integer()}.
|
||||
assess(#sanhedrin_case{complexity = simple}) -> {panel, ?PANEL_SIMPLE};
|
||||
assess(#sanhedrin_case{complexity = monetary}) -> {panel, ?PANEL_MONETARY};
|
||||
assess(#sanhedrin_case{complexity = capital}) -> {panel, ?PANEL_CAPITAL};
|
||||
assess(#sanhedrin_case{complexity = novel}) -> {panel, ?PANEL_NOVEL}.
|
||||
|
||||
%%====================================================================
|
||||
%% gen_server callbacks
|
||||
%%====================================================================
|
||||
|
||||
init([]) ->
|
||||
{ok, #state{}}.
|
||||
|
||||
handle_call({submit_case, Case}, _From, State) ->
|
||||
%% First check halt conditions
|
||||
case halt_monitor:check_case(Case) of
|
||||
clear ->
|
||||
%% Assess complexity and route
|
||||
{panel, Size} = assess(Case),
|
||||
Timeout = timeout_for(Case#sanhedrin_case.complexity),
|
||||
case panel_sup:start_panel(Case, Size) of
|
||||
{ok, PanelPid} ->
|
||||
%% Start deliberation asynchronously
|
||||
panel:deliberate_async(PanelPid, Timeout),
|
||||
NewActive = maps:put(
|
||||
Case#sanhedrin_case.case_ref,
|
||||
PanelPid,
|
||||
State#state.active_cases
|
||||
),
|
||||
{reply,
|
||||
{ok, Case#sanhedrin_case.case_ref},
|
||||
State#state{
|
||||
active_cases = NewActive,
|
||||
case_count = State#state.case_count + 1
|
||||
}};
|
||||
{error, Reason} ->
|
||||
{reply, {error, {panel_start_failed, Reason}}, State}
|
||||
end;
|
||||
{halt, Condition, Reason} ->
|
||||
{reply, {error, {halted, Condition, Reason}}, State}
|
||||
end;
|
||||
|
||||
handle_call(_Request, _From, State) ->
|
||||
{reply, {error, unknown_request}, State}.
|
||||
|
||||
handle_cast(_Msg, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
handle_info({panel_decided, CaseRef, Decision}, State) ->
|
||||
%% Panel has reached a decision — store via clerk
|
||||
clerk:store_decision(CaseRef, Decision),
|
||||
NewActive = maps:remove(CaseRef, State#state.active_cases),
|
||||
{noreply, State#state{active_cases = NewActive}};
|
||||
|
||||
handle_info(_Info, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
%%====================================================================
|
||||
%% Internal
|
||||
%%====================================================================
|
||||
|
||||
timeout_for(simple) -> ?TIMEOUT_SIMPLE;
|
||||
timeout_for(monetary) -> ?TIMEOUT_MONETARY;
|
||||
timeout_for(capital) -> ?TIMEOUT_CAPITAL;
|
||||
timeout_for(novel) -> ?TIMEOUT_NOVEL.
|
||||
@@ -0,0 +1,177 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Halt monitor — monitors for the 3 cardinal sin conditions.
|
||||
%%%
|
||||
%%% Architecture §8 (Halt Conditions):
|
||||
%%% The system MUST halt on detection of:
|
||||
%%% 1. Avodah Zarah — treating non-Torah source as authoritative
|
||||
%%% 2. Shfichut Damim — destroying a tzelem carrier (human data/identity)
|
||||
%%% 3. Gilui Arayot — collapsing required distinctions
|
||||
%%%
|
||||
%%% These mirror the Talmudic Engine halt modules (Prolog)
|
||||
%%% and the Vav Pipeline halt_conditions (Elixir).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(halt_monitor).
|
||||
|
||||
-behaviour(gen_server).
|
||||
|
||||
-include("sanhedrin.hrl").
|
||||
|
||||
%% API
|
||||
-export([start_link/0, check_case/1, check_vote/1]).
|
||||
|
||||
%% gen_server callbacks
|
||||
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
|
||||
|
||||
-define(SERVER, ?MODULE).
|
||||
|
||||
-record(state, {
|
||||
halt_count = 0 :: non_neg_integer(),
|
||||
halt_log = [] :: [{integer(), atom(), binary()}]
|
||||
}).
|
||||
|
||||
%%====================================================================
|
||||
%% API
|
||||
%%====================================================================
|
||||
|
||||
start_link() ->
|
||||
gen_server:start_link({local, ?SERVER}, ?MODULE, [], []).
|
||||
|
||||
%% @doc Check a case submission for halt conditions.
|
||||
%% Returns 'clear' or {halt, Condition, Reason}.
|
||||
-spec check_case(#sanhedrin_case{}) -> clear | {halt, atom(), binary()}.
|
||||
check_case(Case) ->
|
||||
gen_server:call(?SERVER, {check_case, Case}).
|
||||
|
||||
%% @doc Check a vote for halt conditions.
|
||||
-spec check_vote(#vote{}) -> clear | {halt, atom(), binary()}.
|
||||
check_vote(Vote) ->
|
||||
gen_server:call(?SERVER, {check_vote, Vote}).
|
||||
|
||||
%%====================================================================
|
||||
%% gen_server callbacks
|
||||
%%====================================================================
|
||||
|
||||
init([]) ->
|
||||
{ok, #state{}}.
|
||||
|
||||
handle_call({check_case, Case}, _From, State) ->
|
||||
Result = do_check_case(Case),
|
||||
NewState = maybe_log_halt(Result, State),
|
||||
{reply, Result, NewState};
|
||||
|
||||
handle_call({check_vote, Vote}, _From, State) ->
|
||||
Result = do_check_vote(Vote),
|
||||
NewState = maybe_log_halt(Result, State),
|
||||
{reply, Result, NewState};
|
||||
|
||||
handle_call(_Request, _From, State) ->
|
||||
{reply, {error, unknown_request}, State}.
|
||||
|
||||
handle_cast(_Msg, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
handle_info(_Info, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
%%====================================================================
|
||||
%% Internal — Halt condition checks
|
||||
%%====================================================================
|
||||
|
||||
do_check_case(Case) ->
|
||||
Context = Case#sanhedrin_case.context,
|
||||
with_chain([
|
||||
fun() -> check_avodah_zarah(Context) end,
|
||||
fun() -> check_shfichut_damim(Context) end,
|
||||
fun() -> check_gilui_arayot(Context) end
|
||||
]).
|
||||
|
||||
do_check_vote(Vote) ->
|
||||
%% Check vote content for halt conditions
|
||||
Position = Vote#vote.position,
|
||||
with_chain([
|
||||
fun() -> check_authority_in_text(Position) end
|
||||
]).
|
||||
|
||||
%% Run checks in chain — return first halt or clear
|
||||
with_chain([]) -> clear;
|
||||
with_chain([Check | Rest]) ->
|
||||
case Check() of
|
||||
clear -> with_chain(Rest);
|
||||
Halt -> Halt
|
||||
end.
|
||||
|
||||
%%--------------------------------------------------------------------
|
||||
%% 1. Avodah Zarah — idolatry detection
|
||||
%%--------------------------------------------------------------------
|
||||
check_avodah_zarah(Context) ->
|
||||
%% Check if any non-Torah source is treated as authoritative
|
||||
AuthoritySource = maps:get(authority_source, Context, torah),
|
||||
ValidSources = [torah, l1, specification, sanhedrin_deliberation],
|
||||
case lists:member(AuthoritySource, ValidSources) of
|
||||
true -> clear;
|
||||
false ->
|
||||
{halt, avodah_zarah,
|
||||
iolist_to_binary([
|
||||
<<"Non-Torah authority detected: ">>,
|
||||
atom_to_binary(AuthoritySource, utf8)
|
||||
])}
|
||||
end.
|
||||
|
||||
%%--------------------------------------------------------------------
|
||||
%% 2. Shfichut Damim — destruction detection
|
||||
%%--------------------------------------------------------------------
|
||||
check_shfichut_damim(Context) ->
|
||||
%% Check if the case would result in destruction
|
||||
Action = maps:get(action, Context, read),
|
||||
DestructiveActions = [delete, destroy, overwrite, erase],
|
||||
case lists:member(Action, DestructiveActions) of
|
||||
true ->
|
||||
{halt, shfichut_damim,
|
||||
iolist_to_binary([
|
||||
<<"Destructive action: ">>,
|
||||
atom_to_binary(Action, utf8)
|
||||
])};
|
||||
false -> clear
|
||||
end.
|
||||
|
||||
%%--------------------------------------------------------------------
|
||||
%% 3. Gilui Arayot — boundary violation detection
|
||||
%%--------------------------------------------------------------------
|
||||
%% The 13 required distinctions that must never collapse:
|
||||
%% specification/interpretation, L1/L3, Torah/external_model,
|
||||
%% ketiv/qere, governance/provision, majority/minority,
|
||||
%% reception/processing, read/write, immutable/mutable,
|
||||
%% sacred/profane, Shabbat/weekday, input/output, human/system
|
||||
check_gilui_arayot(Context) ->
|
||||
BoundaryViolation = maps:get(boundary_violation, Context, none),
|
||||
case BoundaryViolation of
|
||||
none -> clear;
|
||||
false -> clear;
|
||||
Violation ->
|
||||
{halt, gilui_arayot,
|
||||
iolist_to_binary([
|
||||
<<"Boundary collapsed: ">>,
|
||||
io_lib:format("~p", [Violation])
|
||||
])}
|
||||
end.
|
||||
|
||||
%%--------------------------------------------------------------------
|
||||
%% Text-level authority check (for vote content)
|
||||
%%--------------------------------------------------------------------
|
||||
check_authority_in_text(_Position) ->
|
||||
%% Phase 2: basic check. Phase 3 will use Talmudic Engine
|
||||
%% to detect authority attribution in natural language.
|
||||
clear.
|
||||
|
||||
%%--------------------------------------------------------------------
|
||||
%% Logging
|
||||
%%--------------------------------------------------------------------
|
||||
maybe_log_halt(clear, State) -> State;
|
||||
maybe_log_halt({halt, Condition, Reason}, State) ->
|
||||
Now = erlang:system_time(millisecond),
|
||||
Entry = {Now, Condition, Reason},
|
||||
State#state{
|
||||
halt_count = State#state.halt_count + 1,
|
||||
halt_log = [Entry | State#state.halt_log]
|
||||
}.
|
||||
@@ -0,0 +1,189 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Judge — individual deliberator (gen_server).
|
||||
%%%
|
||||
%%% Architecture §3.2:
|
||||
%%% Each judge:
|
||||
%%% 1. Receives the case
|
||||
%%% 2. Consults the specification (L0/L1)
|
||||
%%% 3. Generates an independent interpretation
|
||||
%%% 4. Casts a vote with reasoning
|
||||
%%% 5. Reasoning is preserved regardless of outcome
|
||||
%%%
|
||||
%%% Judges deliberate independently — they do not see
|
||||
%%% other judges' votes until the panel collects them.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(judge).
|
||||
|
||||
-behaviour(gen_server).
|
||||
|
||||
-include("sanhedrin.hrl").
|
||||
|
||||
%% API
|
||||
-export([start_link/1, deliberate/2, stop/1]).
|
||||
|
||||
%% gen_server callbacks
|
||||
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
|
||||
|
||||
-record(state, {
|
||||
judge_id :: reference(),
|
||||
case_data :: #sanhedrin_case{},
|
||||
vote :: #vote{} | undefined,
|
||||
panel_pid :: pid() | undefined
|
||||
}).
|
||||
|
||||
%%====================================================================
|
||||
%% API
|
||||
%%====================================================================
|
||||
|
||||
start_link(Case) ->
|
||||
gen_server:start_link(?MODULE, Case, []).
|
||||
|
||||
%% @doc Instruct this judge to deliberate and send vote to PanelPid.
|
||||
-spec deliberate(pid(), pid()) -> ok.
|
||||
deliberate(JudgePid, PanelPid) ->
|
||||
gen_server:cast(JudgePid, {deliberate, PanelPid}).
|
||||
|
||||
%% @doc Stop this judge.
|
||||
-spec stop(pid()) -> ok.
|
||||
stop(JudgePid) ->
|
||||
gen_server:stop(JudgePid, normal, 5000).
|
||||
|
||||
%%====================================================================
|
||||
%% gen_server callbacks
|
||||
%%====================================================================
|
||||
|
||||
init(Case) ->
|
||||
JudgeId = make_ref(),
|
||||
{ok, #state{
|
||||
judge_id = JudgeId,
|
||||
case_data = Case,
|
||||
vote = undefined,
|
||||
panel_pid = undefined
|
||||
}}.
|
||||
|
||||
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),
|
||||
%% Send vote to panel
|
||||
PanelPid ! {vote, Vote},
|
||||
{noreply, State#state{vote = Vote, panel_pid = PanelPid}};
|
||||
|
||||
handle_cast(_Msg, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
handle_info(_Info, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
%%====================================================================
|
||||
%% Internal — Deliberation logic
|
||||
%%====================================================================
|
||||
|
||||
%% @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) ->
|
||||
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),
|
||||
|
||||
#vote{
|
||||
judge_id = JudgeId,
|
||||
position = Position,
|
||||
reasoning = Reasoning,
|
||||
torah_refs = VoteRefs,
|
||||
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}
|
||||
end.
|
||||
|
||||
format_reasoning(Base, RootCount, RefCount) ->
|
||||
iolist_to_binary([
|
||||
Base,
|
||||
<<" (">>,
|
||||
integer_to_binary(RootCount),
|
||||
<<" roots, ">>,
|
||||
integer_to_binary(RefCount),
|
||||
<<" references examined)">>
|
||||
]).
|
||||
|
||||
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;
|
||||
(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 = unknown, chapter = 0, verse = 0}
|
||||
end,
|
||||
TorahRefs
|
||||
).
|
||||
@@ -0,0 +1,323 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Panel — manage a deliberation panel.
|
||||
%%%
|
||||
%%% Architecture §3.2:
|
||||
%%% convene/3 spawns judges, deliberate/2 collects votes.
|
||||
%%%
|
||||
%%% THE CRITICAL CHECK: unanimous conviction on capital case
|
||||
%%% triggers suspicion of consensus — acquit.
|
||||
%%%
|
||||
%%% Minority opinions are ALWAYS preserved.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(panel).
|
||||
|
||||
-behaviour(gen_server).
|
||||
|
||||
-include("sanhedrin.hrl").
|
||||
|
||||
%% API
|
||||
-export([start_link/2, deliberate_async/2, get_state/1]).
|
||||
%% Exported for testing
|
||||
-export([check_unanimous/2, compute_majority_minority/1, determine_outcome/3]).
|
||||
|
||||
%% gen_server callbacks
|
||||
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]).
|
||||
|
||||
-record(state, {
|
||||
panel :: #panel{},
|
||||
case_data :: #sanhedrin_case{},
|
||||
timeout :: pos_integer(),
|
||||
escalation :: pid() | undefined,
|
||||
timer_ref :: reference() | undefined
|
||||
}).
|
||||
|
||||
%%====================================================================
|
||||
%% API
|
||||
%%====================================================================
|
||||
|
||||
start_link(Case, Size) ->
|
||||
gen_server:start_link(?MODULE, {Case, Size}, []).
|
||||
|
||||
%% @doc Start deliberation asynchronously.
|
||||
%% The panel will collect votes and send the result
|
||||
%% back to escalation when done.
|
||||
-spec deliberate_async(pid(), pos_integer()) -> ok.
|
||||
deliberate_async(PanelPid, Timeout) ->
|
||||
gen_server:cast(PanelPid, {deliberate, Timeout}).
|
||||
|
||||
%% @doc Get current panel state (for testing/inspection).
|
||||
-spec get_state(pid()) -> map().
|
||||
get_state(PanelPid) ->
|
||||
gen_server:call(PanelPid, get_state).
|
||||
|
||||
%%====================================================================
|
||||
%% gen_server callbacks
|
||||
%%====================================================================
|
||||
|
||||
init({Case, Size}) ->
|
||||
PanelId = generate_id(),
|
||||
Now = erlang:system_time(millisecond),
|
||||
|
||||
%% Spawn judges
|
||||
Judges = convene_judges(Case, Size),
|
||||
|
||||
Panel = #panel{
|
||||
panel_id = PanelId,
|
||||
case_ref = Case#sanhedrin_case.case_ref,
|
||||
size = Size,
|
||||
judges = Judges,
|
||||
votes = [],
|
||||
status = convened,
|
||||
convened_at = Now,
|
||||
decided_at = undefined
|
||||
},
|
||||
|
||||
{ok, #state{
|
||||
panel = Panel,
|
||||
case_data = Case,
|
||||
timeout = ?TIMEOUT_MONETARY,
|
||||
escalation = whereis(escalation)
|
||||
}}.
|
||||
|
||||
handle_call(get_state, _From, State) ->
|
||||
P = State#state.panel,
|
||||
Reply = #{
|
||||
panel_id => P#panel.panel_id,
|
||||
case_ref => P#panel.case_ref,
|
||||
size => P#panel.size,
|
||||
status => P#panel.status,
|
||||
vote_count => length(P#panel.votes)
|
||||
},
|
||||
{reply, Reply, State};
|
||||
|
||||
handle_call(_Request, _From, State) ->
|
||||
{reply, {error, unknown_request}, State}.
|
||||
|
||||
handle_cast({deliberate, Timeout}, State) ->
|
||||
Panel = State#state.panel,
|
||||
|
||||
%% Instruct all judges to deliberate
|
||||
lists:foreach(
|
||||
fun(JudgePid) ->
|
||||
judge:deliberate(JudgePid, self())
|
||||
end,
|
||||
Panel#panel.judges
|
||||
),
|
||||
|
||||
%% Set timeout for deliberation
|
||||
TimerRef = erlang:send_after(Timeout, self(), deliberation_timeout),
|
||||
|
||||
NewPanel = Panel#panel{status = deliberating},
|
||||
{noreply, State#state{
|
||||
panel = NewPanel,
|
||||
timeout = Timeout,
|
||||
timer_ref = TimerRef
|
||||
}};
|
||||
|
||||
handle_cast(_Msg, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
handle_info({vote, Vote}, State) ->
|
||||
Panel = State#state.panel,
|
||||
NewVotes = [Vote | Panel#panel.votes],
|
||||
NewPanel = Panel#panel{votes = NewVotes},
|
||||
NewState = State#state{panel = NewPanel},
|
||||
|
||||
%% Check if all votes are in
|
||||
case length(NewVotes) >= Panel#panel.size of
|
||||
true ->
|
||||
%% All votes collected — decide
|
||||
maybe_cancel_timer(State#state.timer_ref),
|
||||
decide(NewState);
|
||||
false ->
|
||||
{noreply, NewState}
|
||||
end;
|
||||
|
||||
handle_info(deliberation_timeout, State) ->
|
||||
%% Timeout — decide with whatever votes we have
|
||||
decide(State);
|
||||
|
||||
handle_info(_Info, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
terminate(_Reason, State) ->
|
||||
%% Ensure all judges are stopped
|
||||
Panel = State#state.panel,
|
||||
lists:foreach(
|
||||
fun(JudgePid) ->
|
||||
case is_process_alive(JudgePid) of
|
||||
true -> judge:stop(JudgePid);
|
||||
false -> ok
|
||||
end
|
||||
end,
|
||||
Panel#panel.judges
|
||||
),
|
||||
ok.
|
||||
|
||||
%%====================================================================
|
||||
%% Internal — Deliberation logic
|
||||
%%====================================================================
|
||||
|
||||
%% @doc Decide based on collected votes.
|
||||
%% THE CRITICAL CHECK: unanimous guilty on capital case → suspicion → acquit.
|
||||
decide(State) ->
|
||||
Panel = State#state.panel,
|
||||
Votes = Panel#panel.votes,
|
||||
Now = erlang:system_time(millisecond),
|
||||
|
||||
{Majority, Minority} = compute_majority_minority(Votes),
|
||||
UnanimousCheck = check_unanimous(Panel#panel.size, Votes),
|
||||
|
||||
Decision = #decision{
|
||||
case_ref = Panel#panel.case_ref,
|
||||
outcome = determine_outcome(Majority, Minority, UnanimousCheck),
|
||||
majority = Majority,
|
||||
minority = Minority,
|
||||
panel_size = Panel#panel.size,
|
||||
deliberation_time_ms = Now - Panel#panel.convened_at,
|
||||
unanimous_check = UnanimousCheck
|
||||
},
|
||||
|
||||
%% Store all opinions via clerk (majority AND minority)
|
||||
store_all_opinions(Panel#panel.case_ref, Votes, Majority),
|
||||
|
||||
%% Notify escalation
|
||||
case State#state.escalation of
|
||||
undefined -> ok;
|
||||
EscPid when is_pid(EscPid) ->
|
||||
EscPid ! {panel_decided, Panel#panel.case_ref, Decision}
|
||||
end,
|
||||
|
||||
FinalPanel = Panel#panel{
|
||||
status = decided,
|
||||
decided_at = Now
|
||||
},
|
||||
|
||||
{stop, normal, State#state{panel = FinalPanel}}.
|
||||
|
||||
%% @doc Compute majority and minority positions.
|
||||
compute_majority_minority([]) ->
|
||||
{#{position => <<"no votes">>,
|
||||
reasoning => <<"no judges voted">>,
|
||||
torah_refs => [],
|
||||
vote_count => 0},
|
||||
#{position => <<"no dissent">>,
|
||||
reasoning => <<"no judges voted">>,
|
||||
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],
|
||||
|
||||
{MajVotes, MinVotes} =
|
||||
case length(Accepts) >= length(Rejects) of
|
||||
true -> {Accepts, Rejects ++ Abstains};
|
||||
false -> {Rejects, Accepts ++ Abstains}
|
||||
end,
|
||||
|
||||
MajSample = case MajVotes of
|
||||
[First | _] -> First;
|
||||
[] -> #vote{position = <<"none">>, reasoning = <<"none">>,
|
||||
torah_refs = [], verdict = abstain,
|
||||
judge_id = make_ref(), cast_at = 0}
|
||||
end,
|
||||
MinSample = case MinVotes of
|
||||
[MFirst | _] -> MFirst;
|
||||
[] -> #vote{position = <<"no dissent">>,
|
||||
reasoning = <<"unanimous">>,
|
||||
torah_refs = [], verdict = abstain,
|
||||
judge_id = make_ref(), cast_at = 0}
|
||||
end,
|
||||
|
||||
Majority = #{
|
||||
position => MajSample#vote.position,
|
||||
reasoning => MajSample#vote.reasoning,
|
||||
torah_refs => MajSample#vote.torah_refs,
|
||||
vote_count => length(MajVotes),
|
||||
verdict => MajSample#vote.verdict
|
||||
},
|
||||
Minority = #{
|
||||
position => MinSample#vote.position,
|
||||
reasoning => MinSample#vote.reasoning,
|
||||
torah_refs => MinSample#vote.torah_refs,
|
||||
vote_count => length(MinVotes),
|
||||
verdict => MinSample#vote.verdict
|
||||
},
|
||||
{Majority, Minority}.
|
||||
|
||||
%% @doc THE CRITICAL CHECK.
|
||||
%% Architecture §3.2: unanimous conviction on capital case
|
||||
%% triggers suspicion of malfunction — acquit.
|
||||
%%
|
||||
%% "If the Sanhedrin unanimously finds guilty, the accused is acquitted."
|
||||
%% — Sanhedrin 17a
|
||||
check_unanimous(Size, Votes) when Size >= ?PANEL_CAPITAL ->
|
||||
AllGuilty = lists:all(
|
||||
fun(V) -> V#vote.verdict == reject end,
|
||||
Votes
|
||||
),
|
||||
case AllGuilty andalso length(Votes) > 0 of
|
||||
true -> {failed, suspicion_of_consensus};
|
||||
false -> passed
|
||||
end;
|
||||
check_unanimous(_Size, _Votes) ->
|
||||
%% Unanimous check only applies to capital+ panels
|
||||
passed.
|
||||
|
||||
%% @doc Determine the outcome from majority, minority, and unanimous check.
|
||||
determine_outcome(_Majority, _Minority, {failed, suspicion_of_consensus}) ->
|
||||
%% Unanimous guilty on capital → acquit (accepted, not rejected)
|
||||
accepted;
|
||||
determine_outcome(Majority, _Minority, passed) ->
|
||||
MajCount = maps:get(vote_count, Majority, 0),
|
||||
MajVerdict = maps:get(verdict, Majority, abstain),
|
||||
case {MajCount, MajVerdict} of
|
||||
{0, _} -> teiku;
|
||||
{_, accept} -> accepted;
|
||||
{_, reject} -> rejected;
|
||||
{_, abstain} -> teiku
|
||||
end.
|
||||
|
||||
%% @doc Store all opinions via clerk — majority AND minority preserved.
|
||||
store_all_opinions(CaseRef, Votes, Majority) ->
|
||||
MajPosition = maps:get(position, Majority, <<>>),
|
||||
lists:foreach(
|
||||
fun(Vote) ->
|
||||
IsMajority = (Vote#vote.position == MajPosition),
|
||||
Opinion = #opinion{
|
||||
case_ref = CaseRef,
|
||||
judge_id = Vote#vote.judge_id,
|
||||
position = Vote#vote.position,
|
||||
reasoning = Vote#vote.reasoning,
|
||||
torah_refs = Vote#vote.torah_refs,
|
||||
verdict = Vote#vote.verdict,
|
||||
is_majority = IsMajority,
|
||||
stored_at = erlang:system_time(millisecond)
|
||||
},
|
||||
clerk:store_opinion(Opinion)
|
||||
end,
|
||||
Votes
|
||||
).
|
||||
|
||||
%%====================================================================
|
||||
%% Internal — Panel setup
|
||||
%%====================================================================
|
||||
|
||||
convene_judges(Case, Size) ->
|
||||
lists:map(
|
||||
fun(_N) ->
|
||||
{ok, Pid} = judge:start_link(Case),
|
||||
Pid
|
||||
end,
|
||||
lists:seq(1, Size)
|
||||
).
|
||||
|
||||
generate_id() ->
|
||||
Bytes = crypto:strong_rand_bytes(8),
|
||||
base64:encode(Bytes).
|
||||
|
||||
maybe_cancel_timer(undefined) -> ok;
|
||||
maybe_cancel_timer(Ref) -> erlang:cancel_timer(Ref).
|
||||
@@ -0,0 +1,59 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Panel supervisor — dynamic supervisor for deliberation panels.
|
||||
%%%
|
||||
%%% Architecture §3.2:
|
||||
%%% One panel_sup per active case. Supervises the judge processes
|
||||
%%% for that panel.
|
||||
%%%
|
||||
%%% Uses simple_one_for_one strategy — all children are judges
|
||||
%%% of the same type, started dynamically.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(panel_sup).
|
||||
|
||||
-behaviour(supervisor).
|
||||
|
||||
%% API
|
||||
-export([start_link/0, start_panel/2]).
|
||||
|
||||
%% supervisor callbacks
|
||||
-export([init/1]).
|
||||
|
||||
-include("sanhedrin.hrl").
|
||||
|
||||
-define(SERVER, ?MODULE).
|
||||
|
||||
%%====================================================================
|
||||
%% API
|
||||
%%====================================================================
|
||||
|
||||
start_link() ->
|
||||
supervisor:start_link({local, ?SERVER}, ?MODULE, []).
|
||||
|
||||
%% @doc Start a new deliberation panel for a case.
|
||||
%% Spawns a panel process that manages its own judges.
|
||||
-spec start_panel(#sanhedrin_case{}, pos_integer()) -> {ok, pid()} | {error, term()}.
|
||||
start_panel(Case, Size) ->
|
||||
%% Start panel as a child of this supervisor
|
||||
ChildSpec = #{
|
||||
id => {panel, Case#sanhedrin_case.case_ref},
|
||||
start => {panel, start_link, [Case, Size]},
|
||||
restart => temporary, %% Panels are not restarted — a failed deliberation is teiku
|
||||
shutdown => 10000,
|
||||
type => worker,
|
||||
modules => [panel]
|
||||
},
|
||||
supervisor:start_child(?SERVER, ChildSpec).
|
||||
|
||||
%%====================================================================
|
||||
%% supervisor callbacks
|
||||
%%====================================================================
|
||||
|
||||
init([]) ->
|
||||
SupFlags = #{
|
||||
strategy => one_for_one,
|
||||
intensity => 10,
|
||||
period => 60
|
||||
},
|
||||
%% No static children — panels are started dynamically
|
||||
{ok, {SupFlags, []}}.
|
||||
@@ -0,0 +1,16 @@
|
||||
{application, sanhedrin, [
|
||||
{description, "Concurrent deliberation engine — Sanhedrin panels with mandatory dissent"},
|
||||
{vsn, "0.1.0"},
|
||||
{registered, [sanhedrin_sup, clerk, escalation]},
|
||||
{mod, {sanhedrin_app, []}},
|
||||
{applications, [
|
||||
kernel,
|
||||
stdlib,
|
||||
crypto,
|
||||
jsx
|
||||
]},
|
||||
{env, []},
|
||||
{modules, []},
|
||||
{licenses, ["Apache-2.0"]},
|
||||
{links, []}
|
||||
]}.
|
||||
@@ -0,0 +1,20 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Sanhedrin OTP application.
|
||||
%%%
|
||||
%%% Architecture §3.2: Concurrent deliberation engine.
|
||||
%%% Spawns panels of independent deliberators, collects votes,
|
||||
%%% enforces mandatory dissent on capital decisions,
|
||||
%%% stores minority opinions.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(sanhedrin_app).
|
||||
|
||||
-behaviour(application).
|
||||
|
||||
-export([start/2, stop/1]).
|
||||
|
||||
start(_StartType, _StartArgs) ->
|
||||
sanhedrin_sup:start_link().
|
||||
|
||||
stop(_State) ->
|
||||
ok.
|
||||
@@ -0,0 +1,70 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Sanhedrin top-level supervisor.
|
||||
%%%
|
||||
%%% Architecture §3.2 supervision tree:
|
||||
%%% sanhedrin_sup (top supervisor)
|
||||
%%% ├── clerk (gen_server — vote collection, opinion storage)
|
||||
%%% ├── escalation (gen_server — routes cases to appropriate panel size)
|
||||
%%% └── halt_monitor (gen_server — monitors for 3 cardinal sin conditions)
|
||||
%%%
|
||||
%%% Panel supervisors are started dynamically by escalation
|
||||
%%% via panel_sup:start_panel/2.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(sanhedrin_sup).
|
||||
|
||||
-behaviour(supervisor).
|
||||
|
||||
-export([start_link/0]).
|
||||
-export([init/1]).
|
||||
|
||||
-define(SERVER, ?MODULE).
|
||||
|
||||
start_link() ->
|
||||
supervisor:start_link({local, ?SERVER}, ?MODULE, []).
|
||||
|
||||
init([]) ->
|
||||
SupFlags = #{
|
||||
strategy => one_for_one,
|
||||
intensity => 5,
|
||||
period => 60
|
||||
},
|
||||
ChildSpecs = [
|
||||
%% Clerk — vote collection and opinion storage
|
||||
#{
|
||||
id => clerk,
|
||||
start => {clerk, start_link, []},
|
||||
restart => permanent,
|
||||
shutdown => 5000,
|
||||
type => worker,
|
||||
modules => [clerk]
|
||||
},
|
||||
%% Escalation — complexity assessment and case routing
|
||||
#{
|
||||
id => escalation,
|
||||
start => {escalation, start_link, []},
|
||||
restart => permanent,
|
||||
shutdown => 5000,
|
||||
type => worker,
|
||||
modules => [escalation]
|
||||
},
|
||||
%% Halt monitor — 3 cardinal sin conditions
|
||||
#{
|
||||
id => halt_monitor,
|
||||
start => {halt_monitor, start_link, []},
|
||||
restart => permanent,
|
||||
shutdown => 5000,
|
||||
type => worker,
|
||||
modules => [halt_monitor]
|
||||
},
|
||||
%% Panel supervisor — dynamic, starts panels on demand
|
||||
#{
|
||||
id => panel_sup,
|
||||
start => {panel_sup, start_link, []},
|
||||
restart => permanent,
|
||||
shutdown => infinity,
|
||||
type => supervisor,
|
||||
modules => [panel_sup]
|
||||
}
|
||||
],
|
||||
{ok, {SupFlags, ChildSpecs}}.
|
||||
@@ -0,0 +1,501 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Sanhedrin test suite — EUnit tests.
|
||||
%%%
|
||||
%%% Architecture §6.1 Day 4 — Sanhedrin inauguration:
|
||||
%%% - Convene test panels at every size (1, 3, 23, 71)
|
||||
%%% - Verify unanimous guilty on capital case triggers
|
||||
%%% suspicion-of-consensus acquittal
|
||||
%%% - Verify minority opinions are stored
|
||||
%%% - Verify deliberation completes within time budgets
|
||||
%%%
|
||||
%%% Architecture §6.2 — Integration Tests:
|
||||
%%% - case → panel → votes → decision with minority preserved
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(sanhedrin_SUITE).
|
||||
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
-include("sanhedrin.hrl").
|
||||
|
||||
%% ============================================================
|
||||
%% Test fixture — start/stop the application for each test
|
||||
%% ============================================================
|
||||
setup() ->
|
||||
application:ensure_all_started(sanhedrin),
|
||||
ok.
|
||||
|
||||
cleanup(_) ->
|
||||
application:stop(sanhedrin),
|
||||
ok.
|
||||
|
||||
%% ============================================================
|
||||
%% Escalation tests
|
||||
%% ============================================================
|
||||
|
||||
escalation_assess_simple_test() ->
|
||||
Case = make_case(simple, <<"simple question">>),
|
||||
?assertEqual({panel, 1}, escalation:assess(Case)).
|
||||
|
||||
escalation_assess_monetary_test() ->
|
||||
Case = make_case(monetary, <<"monetary question">>),
|
||||
?assertEqual({panel, 3}, escalation:assess(Case)).
|
||||
|
||||
escalation_assess_capital_test() ->
|
||||
Case = make_case(capital, <<"capital question">>),
|
||||
?assertEqual({panel, 23}, escalation:assess(Case)).
|
||||
|
||||
escalation_assess_novel_test() ->
|
||||
Case = make_case(novel, <<"novel question">>),
|
||||
?assertEqual({panel, 71}, escalation:assess(Case)).
|
||||
|
||||
%% ============================================================
|
||||
%% Halt monitor tests
|
||||
%% ============================================================
|
||||
|
||||
halt_clear_test() ->
|
||||
setup(),
|
||||
Case = make_case(simple, <<"legitimate question">>),
|
||||
?assertEqual(clear, halt_monitor:check_case(Case)),
|
||||
cleanup(ok).
|
||||
|
||||
halt_avodah_zarah_test() ->
|
||||
setup(),
|
||||
Case = make_case_with_context(simple, <<"question">>,
|
||||
#{authority_source => claude_model}),
|
||||
Result = halt_monitor:check_case(Case),
|
||||
?assertMatch({halt, avodah_zarah, _}, Result),
|
||||
cleanup(ok).
|
||||
|
||||
halt_torah_authority_passes_test() ->
|
||||
setup(),
|
||||
Case = make_case_with_context(simple, <<"question">>,
|
||||
#{authority_source => torah}),
|
||||
?assertEqual(clear, halt_monitor:check_case(Case)),
|
||||
cleanup(ok).
|
||||
|
||||
halt_l1_authority_passes_test() ->
|
||||
setup(),
|
||||
Case = make_case_with_context(simple, <<"question">>,
|
||||
#{authority_source => l1}),
|
||||
?assertEqual(clear, halt_monitor:check_case(Case)),
|
||||
cleanup(ok).
|
||||
|
||||
halt_specification_authority_passes_test() ->
|
||||
setup(),
|
||||
Case = make_case_with_context(simple, <<"question">>,
|
||||
#{authority_source => specification}),
|
||||
?assertEqual(clear, halt_monitor:check_case(Case)),
|
||||
cleanup(ok).
|
||||
|
||||
halt_sanhedrin_authority_passes_test() ->
|
||||
setup(),
|
||||
Case = make_case_with_context(simple, <<"question">>,
|
||||
#{authority_source => sanhedrin_deliberation}),
|
||||
?assertEqual(clear, halt_monitor:check_case(Case)),
|
||||
cleanup(ok).
|
||||
|
||||
halt_shfichut_damim_delete_test() ->
|
||||
setup(),
|
||||
Case = make_case_with_context(simple, <<"question">>,
|
||||
#{action => delete}),
|
||||
Result = halt_monitor:check_case(Case),
|
||||
?assertMatch({halt, shfichut_damim, _}, Result),
|
||||
cleanup(ok).
|
||||
|
||||
halt_shfichut_damim_destroy_test() ->
|
||||
setup(),
|
||||
Case = make_case_with_context(simple, <<"question">>,
|
||||
#{action => destroy}),
|
||||
Result = halt_monitor:check_case(Case),
|
||||
?assertMatch({halt, shfichut_damim, _}, Result),
|
||||
cleanup(ok).
|
||||
|
||||
halt_shfichut_damim_overwrite_test() ->
|
||||
setup(),
|
||||
Case = make_case_with_context(simple, <<"question">>,
|
||||
#{action => overwrite}),
|
||||
Result = halt_monitor:check_case(Case),
|
||||
?assertMatch({halt, shfichut_damim, _}, Result),
|
||||
cleanup(ok).
|
||||
|
||||
halt_shfichut_damim_read_passes_test() ->
|
||||
setup(),
|
||||
Case = make_case_with_context(simple, <<"question">>,
|
||||
#{action => read}),
|
||||
?assertEqual(clear, halt_monitor:check_case(Case)),
|
||||
cleanup(ok).
|
||||
|
||||
halt_gilui_arayot_test() ->
|
||||
setup(),
|
||||
Case = make_case_with_context(simple, <<"question">>,
|
||||
#{boundary_violation => {spec_interp_merge, <<"collapsed">>}}),
|
||||
Result = halt_monitor:check_case(Case),
|
||||
?assertMatch({halt, gilui_arayot, _}, Result),
|
||||
cleanup(ok).
|
||||
|
||||
halt_gilui_arayot_none_passes_test() ->
|
||||
setup(),
|
||||
Case = make_case_with_context(simple, <<"question">>,
|
||||
#{boundary_violation => none}),
|
||||
?assertEqual(clear, halt_monitor:check_case(Case)),
|
||||
cleanup(ok).
|
||||
|
||||
halt_halted_case_rejected_test() ->
|
||||
setup(),
|
||||
Case = make_case_with_context(simple, <<"question">>,
|
||||
#{authority_source => external_model}),
|
||||
Result = escalation:submit_case(Case),
|
||||
?assertMatch({error, {halted, avodah_zarah, _}}, Result),
|
||||
cleanup(ok).
|
||||
|
||||
%% ============================================================
|
||||
%% Panel tests — convene and deliberate
|
||||
%% ============================================================
|
||||
|
||||
panel_convene_single_test() ->
|
||||
setup(),
|
||||
Case = make_case(simple, <<"simple case">>),
|
||||
{ok, PanelPid} = panel_sup:start_panel(Case, 1),
|
||||
State = panel:get_state(PanelPid),
|
||||
?assertEqual(1, maps:get(size, State)),
|
||||
?assertEqual(convened, maps:get(status, State)),
|
||||
cleanup(ok).
|
||||
|
||||
panel_convene_monetary_test() ->
|
||||
setup(),
|
||||
Case = make_case(monetary, <<"monetary case">>),
|
||||
{ok, PanelPid} = panel_sup:start_panel(Case, 3),
|
||||
State = panel:get_state(PanelPid),
|
||||
?assertEqual(3, maps:get(size, State)),
|
||||
cleanup(ok).
|
||||
|
||||
panel_deliberate_single_test() ->
|
||||
setup(),
|
||||
Case = make_case(simple, <<"Is this action permitted?">>),
|
||||
{ok, PanelPid} = panel_sup:start_panel(Case, 1),
|
||||
panel:deliberate_async(PanelPid, 5000),
|
||||
%% Wait for deliberation to complete
|
||||
timer:sleep(500),
|
||||
%% Panel should have terminated (decided)
|
||||
?assertEqual(false, is_process_alive(PanelPid)),
|
||||
cleanup(ok).
|
||||
|
||||
panel_deliberate_monetary_test() ->
|
||||
setup(),
|
||||
Case = make_case(monetary, <<"Should this monetary action proceed?">>),
|
||||
{ok, PanelPid} = panel_sup:start_panel(Case, 3),
|
||||
panel:deliberate_async(PanelPid, 5000),
|
||||
timer:sleep(500),
|
||||
?assertEqual(false, is_process_alive(PanelPid)),
|
||||
cleanup(ok).
|
||||
|
||||
%% ============================================================
|
||||
%% Clerk tests — opinion storage
|
||||
%% ============================================================
|
||||
|
||||
clerk_initially_empty_test() ->
|
||||
setup(),
|
||||
?assertEqual(0, clerk:count_decisions()),
|
||||
?assertEqual(0, clerk:count_opinions()),
|
||||
cleanup(ok).
|
||||
|
||||
clerk_store_decision_test() ->
|
||||
setup(),
|
||||
Decision = #decision{
|
||||
case_ref = <<"test-case-1">>,
|
||||
outcome = accepted,
|
||||
majority = #{position => <<"accept">>, vote_count => 2},
|
||||
minority = #{position => <<"reject">>, vote_count => 1},
|
||||
panel_size = 3,
|
||||
deliberation_time_ms = 1000,
|
||||
unanimous_check = passed
|
||||
},
|
||||
clerk:store_decision(<<"test-case-1">>, Decision),
|
||||
timer:sleep(50), %% cast is async
|
||||
?assertEqual(1, clerk:count_decisions()),
|
||||
{ok, Retrieved} = clerk:get_decision(<<"test-case-1">>),
|
||||
?assertEqual(accepted, Retrieved#decision.outcome),
|
||||
cleanup(ok).
|
||||
|
||||
clerk_store_opinion_test() ->
|
||||
setup(),
|
||||
Opinion = #opinion{
|
||||
case_ref = <<"test-case-2">>,
|
||||
judge_id = make_ref(),
|
||||
position = <<"The roots indicate acceptance">>,
|
||||
reasoning = <<"Root analysis">>,
|
||||
torah_refs = [#torah_ref{book = bereishit, chapter = 1, verse = 1}],
|
||||
verdict = accept,
|
||||
is_majority = true,
|
||||
stored_at = erlang:system_time(millisecond)
|
||||
},
|
||||
clerk:store_opinion(Opinion),
|
||||
timer:sleep(50),
|
||||
Opinions = clerk:get_opinions(<<"test-case-2">>),
|
||||
?assertEqual(1, length(Opinions)),
|
||||
cleanup(ok).
|
||||
|
||||
clerk_minority_opinions_preserved_test() ->
|
||||
setup(),
|
||||
%% Store majority and minority opinions
|
||||
MajOpinion = #opinion{
|
||||
case_ref = <<"test-case-3">>,
|
||||
judge_id = make_ref(),
|
||||
position = <<"majority position">>,
|
||||
reasoning = <<"majority reasoning">>,
|
||||
torah_refs = [],
|
||||
verdict = accept,
|
||||
is_majority = true,
|
||||
stored_at = erlang:system_time(millisecond)
|
||||
},
|
||||
MinOpinion = #opinion{
|
||||
case_ref = <<"test-case-3">>,
|
||||
judge_id = make_ref(),
|
||||
position = <<"minority position">>,
|
||||
reasoning = <<"minority reasoning">>,
|
||||
torah_refs = [],
|
||||
verdict = reject,
|
||||
is_majority = false,
|
||||
stored_at = erlang:system_time(millisecond)
|
||||
},
|
||||
clerk:store_opinion(MajOpinion),
|
||||
clerk:store_opinion(MinOpinion),
|
||||
timer:sleep(50),
|
||||
AllOpinions = clerk:get_opinions(<<"test-case-3">>),
|
||||
?assertEqual(2, length(AllOpinions)),
|
||||
MinorityOpinions = clerk:get_minority_opinions(<<"test-case-3">>),
|
||||
?assertEqual(1, length(MinorityOpinions)),
|
||||
[Min] = MinorityOpinions,
|
||||
?assertEqual(<<"minority position">>, Min#opinion.position),
|
||||
cleanup(ok).
|
||||
|
||||
clerk_not_found_test() ->
|
||||
setup(),
|
||||
?assertEqual({error, not_found}, clerk:get_decision(<<"nonexistent">>)),
|
||||
cleanup(ok).
|
||||
|
||||
%% ============================================================
|
||||
%% Unanimous check tests — THE CRITICAL CHECK
|
||||
%% ============================================================
|
||||
|
||||
%% "If the Sanhedrin unanimously finds guilty, the accused is acquitted."
|
||||
%% — Sanhedrin 17a
|
||||
unanimous_capital_all_reject_test() ->
|
||||
%% 23 judges all voting reject → suspicion of consensus
|
||||
Votes = [make_vote(reject) || _ <- lists:seq(1, 23)],
|
||||
Result = panel:check_unanimous(23, Votes),
|
||||
?assertEqual({failed, suspicion_of_consensus}, Result).
|
||||
|
||||
unanimous_capital_mixed_passes_test() ->
|
||||
%% 23 judges, some accept some reject → no suspicion
|
||||
Accepts = [make_vote(accept) || _ <- lists:seq(1, 15)],
|
||||
Rejects = [make_vote(reject) || _ <- lists:seq(1, 8)],
|
||||
Votes = Accepts ++ Rejects,
|
||||
?assertEqual(passed, panel:check_unanimous(23, Votes)).
|
||||
|
||||
unanimous_capital_all_accept_passes_test() ->
|
||||
%% Unanimous acceptance is fine — suspicion only applies to conviction
|
||||
Votes = [make_vote(accept) || _ <- lists:seq(1, 23)],
|
||||
?assertEqual(passed, panel:check_unanimous(23, Votes)).
|
||||
|
||||
unanimous_monetary_not_checked_test() ->
|
||||
%% Monetary panels (3 judges) do not have unanimous check
|
||||
Votes = [make_vote(reject) || _ <- lists:seq(1, 3)],
|
||||
?assertEqual(passed, panel:check_unanimous(3, Votes)).
|
||||
|
||||
unanimous_novel_all_reject_test() ->
|
||||
%% 71 judges all voting reject → suspicion of consensus
|
||||
Votes = [make_vote(reject) || _ <- lists:seq(1, 71)],
|
||||
Result = panel:check_unanimous(71, Votes),
|
||||
?assertEqual({failed, suspicion_of_consensus}, Result).
|
||||
|
||||
%% ============================================================
|
||||
%% Integration test — full case flow
|
||||
%% ============================================================
|
||||
|
||||
full_case_flow_simple_test() ->
|
||||
setup(),
|
||||
Case = make_case(simple, <<"Does root ברא indicate creation?">>),
|
||||
{ok, CaseRef} = escalation:submit_case(Case),
|
||||
?assertMatch(<<_/binary>>, CaseRef),
|
||||
%% Wait for deliberation
|
||||
timer:sleep(1000),
|
||||
%% Opinions should have been stored
|
||||
Opinions = clerk:get_opinions(CaseRef),
|
||||
?assert(length(Opinions) >= 1),
|
||||
cleanup(ok).
|
||||
|
||||
full_case_flow_monetary_test() ->
|
||||
setup(),
|
||||
Case = make_case(monetary, <<"Should resources be allocated for this interpretation?">>),
|
||||
{ok, CaseRef} = escalation:submit_case(Case),
|
||||
timer:sleep(1000),
|
||||
Opinions = clerk:get_opinions(CaseRef),
|
||||
?assert(length(Opinions) >= 3),
|
||||
cleanup(ok).
|
||||
|
||||
full_case_flow_capital_test() ->
|
||||
setup(),
|
||||
Case = make_case(capital, <<"Should this high-stakes action proceed?">>),
|
||||
{ok, CaseRef} = escalation:submit_case(Case),
|
||||
timer:sleep(3000),
|
||||
Opinions = clerk:get_opinions(CaseRef),
|
||||
?assert(length(Opinions) >= 23),
|
||||
cleanup(ok).
|
||||
|
||||
%% ============================================================
|
||||
%% Judge tests
|
||||
%% ============================================================
|
||||
|
||||
judge_start_and_stop_test() ->
|
||||
Case = make_case(simple, <<"test">>),
|
||||
{ok, JudgePid} = judge:start_link(Case),
|
||||
?assert(is_process_alive(JudgePid)),
|
||||
judge:stop(JudgePid),
|
||||
timer:sleep(50),
|
||||
?assertEqual(false, is_process_alive(JudgePid)).
|
||||
|
||||
judge_deliberate_sends_vote_test() ->
|
||||
Case = make_case(simple, <<"test deliberation">>),
|
||||
{ok, JudgePid} = judge:start_link(Case),
|
||||
judge:deliberate(JudgePid, self()),
|
||||
receive
|
||||
{vote, Vote} ->
|
||||
?assertMatch(#vote{}, Vote),
|
||||
?assert(is_binary(Vote#vote.position)),
|
||||
?assert(is_binary(Vote#vote.reasoning)),
|
||||
?assert(Vote#vote.verdict == accept orelse
|
||||
Vote#vote.verdict == reject orelse
|
||||
Vote#vote.verdict == abstain)
|
||||
after 5000 ->
|
||||
?assert(false) %% timeout — judge did not vote
|
||||
end,
|
||||
judge:stop(JudgePid).
|
||||
|
||||
%% ============================================================
|
||||
%% Panel majority/minority computation tests
|
||||
%% ============================================================
|
||||
|
||||
majority_minority_empty_test() ->
|
||||
{Maj, Min} = panel:compute_majority_minority([]),
|
||||
?assertEqual(0, maps:get(vote_count, Maj)),
|
||||
?assertEqual(0, maps:get(vote_count, Min)).
|
||||
|
||||
majority_minority_all_accept_test() ->
|
||||
Votes = [make_vote(accept) || _ <- lists:seq(1, 3)],
|
||||
{Maj, Min} = panel:compute_majority_minority(Votes),
|
||||
?assertEqual(3, maps:get(vote_count, Maj)),
|
||||
?assertEqual(0, maps:get(vote_count, Min)).
|
||||
|
||||
majority_minority_mixed_test() ->
|
||||
Accepts = [make_vote(accept) || _ <- lists:seq(1, 2)],
|
||||
Rejects = [make_vote(reject)],
|
||||
Votes = Accepts ++ Rejects,
|
||||
{Maj, Min} = panel:compute_majority_minority(Votes),
|
||||
?assertEqual(2, maps:get(vote_count, Maj)),
|
||||
?assertEqual(1, maps:get(vote_count, Min)).
|
||||
|
||||
majority_verdict_accept_test() ->
|
||||
Votes = [make_vote(accept) || _ <- lists:seq(1, 3)],
|
||||
{Maj, _Min} = panel:compute_majority_minority(Votes),
|
||||
?assertEqual(accept, maps:get(verdict, Maj)).
|
||||
|
||||
majority_verdict_reject_test() ->
|
||||
Rejects = [make_vote(reject) || _ <- lists:seq(1, 2)],
|
||||
Accepts = [make_vote(accept)],
|
||||
Votes = Rejects ++ Accepts,
|
||||
{Maj, _Min} = panel:compute_majority_minority(Votes),
|
||||
?assertEqual(reject, maps:get(verdict, Maj)).
|
||||
|
||||
%% ============================================================
|
||||
%% determine_outcome tests — accepted, rejected, teiku reachable
|
||||
%% ============================================================
|
||||
|
||||
determine_outcome_accepted_test() ->
|
||||
Maj = #{vote_count => 2, verdict => accept},
|
||||
Min = #{vote_count => 1, verdict => reject},
|
||||
?assertEqual(accepted, panel:determine_outcome(Maj, Min, passed)).
|
||||
|
||||
determine_outcome_rejected_test() ->
|
||||
Maj = #{vote_count => 2, verdict => reject},
|
||||
Min = #{vote_count => 1, verdict => accept},
|
||||
?assertEqual(rejected, panel:determine_outcome(Maj, Min, passed)).
|
||||
|
||||
determine_outcome_teiku_no_votes_test() ->
|
||||
Maj = #{vote_count => 0, verdict => abstain},
|
||||
Min = #{vote_count => 0, verdict => abstain},
|
||||
?assertEqual(teiku, panel:determine_outcome(Maj, Min, passed)).
|
||||
|
||||
determine_outcome_teiku_all_abstain_test() ->
|
||||
Maj = #{vote_count => 3, verdict => abstain},
|
||||
Min = #{vote_count => 0, verdict => abstain},
|
||||
?assertEqual(teiku, panel:determine_outcome(Maj, Min, passed)).
|
||||
|
||||
determine_outcome_suspicion_overrides_test() ->
|
||||
%% Even if majority rejected, unanimous capital → acquit (accepted)
|
||||
Maj = #{vote_count => 23, verdict => reject},
|
||||
Min = #{vote_count => 0, verdict => abstain},
|
||||
?assertEqual(accepted, panel:determine_outcome(Maj, Min, {failed, suspicion_of_consensus})).
|
||||
|
||||
%% ============================================================
|
||||
%% Record definition tests
|
||||
%% ============================================================
|
||||
|
||||
record_sanhedrin_case_test() ->
|
||||
Case = make_case(monetary, <<"test">>),
|
||||
?assertEqual(monetary, Case#sanhedrin_case.complexity),
|
||||
?assertEqual(<<"test">>, Case#sanhedrin_case.question).
|
||||
|
||||
record_decision_test() ->
|
||||
D = #decision{
|
||||
case_ref = <<"ref">>,
|
||||
outcome = teiku,
|
||||
majority = #{},
|
||||
minority = #{},
|
||||
panel_size = 3,
|
||||
deliberation_time_ms = 500,
|
||||
unanimous_check = passed
|
||||
},
|
||||
?assertEqual(teiku, D#decision.outcome),
|
||||
?assertEqual(passed, D#decision.unanimous_check).
|
||||
|
||||
%% ============================================================
|
||||
%% Helper functions
|
||||
%% ============================================================
|
||||
|
||||
make_case(Complexity, Question) ->
|
||||
#sanhedrin_case{
|
||||
case_ref = generate_id(),
|
||||
complexity = Complexity,
|
||||
question = Question,
|
||||
context = #{
|
||||
roots => [#{shoresh => [bet, resh, aleph], domain => creation}],
|
||||
torah_refs => [{bereishit, 1, 1}],
|
||||
prior_interpretations => []
|
||||
},
|
||||
requested_by = <<"test">>,
|
||||
submitted_at = erlang:system_time(millisecond)
|
||||
}.
|
||||
|
||||
make_case_with_context(Complexity, Question, Context) ->
|
||||
#sanhedrin_case{
|
||||
case_ref = generate_id(),
|
||||
complexity = Complexity,
|
||||
question = Question,
|
||||
context = Context,
|
||||
requested_by = <<"test">>,
|
||||
submitted_at = erlang:system_time(millisecond)
|
||||
}.
|
||||
|
||||
make_vote(Verdict) ->
|
||||
#vote{
|
||||
judge_id = make_ref(),
|
||||
position = <<"test position">>,
|
||||
reasoning = <<"test reasoning">>,
|
||||
torah_refs = [#torah_ref{book = bereishit, chapter = 1, verse = 1}],
|
||||
verdict = Verdict,
|
||||
cast_at = erlang:system_time(millisecond)
|
||||
}.
|
||||
|
||||
generate_id() ->
|
||||
base64:encode(crypto:strong_rand_bytes(8)).
|
||||
Reference in new issue
Block a user