From ba3ff4a2f4872808c8b1d591e01b5066da3121f9 Mon Sep 17 00:00:00 2001 From: Scott Karp Date: Sun, 25 Oct 2026 01:44:40 +0000 Subject: [PATCH] sugya, step 4: the hard matter goes up with its dissent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit כי יפלא ממך דבר למשפט ... ובין נגע לנגע דברי ריבת בשעריך וקמת ועלית (Devarim 17:8, read from L1). A mark the inspection cannot settle, a dispute in your gates — it goes up. specs/sugya.md §V step 4: a bedikah's applied dissents become the minority the clerk stores when the case goes up. POST /chatzer/sanhedrin takes `bedikah_ref`. Only this key's own bedikah goes up (404 alike for another's or none). The case is that verdict; the roots are the verses the output cited, read from L1 — the output's text is not re-read, the record never kept it; the sugya is rebuilt from the record (Sugya.from_record/1: forms found but not fired are the noted ones; a fired forged fringe stood on one witness); the applied dissents (Sugya.dissents_applied/1) travel in the case context and down into L3: beside the panel's minority, or AS the minority when the panel was unanimous — "the panel was unanimous | carried up from the bedikah (Devarim 17:8): single_witness_noted (Devarim 19:15) for apology_without_mechanism", grounding achorai. The response's grounding carries the bedikah, its carried dissent and its sugya. Tamid §1.1: the request field. Doorpost pin → 7eac65cf…. Tests: from_record/dissents_applied (noted form; forged fringe; clean); L3 — a noted bedikah goes up under its key: roots from Devarim 6:4 as read in L1 (ש-מ-ע among them), panel 3, the carried dissent in the response, a minority recorded though the panel was unanimous; another key's bedikah 404. Elixir 669/0; L3 8/0. Co-Authored-By: Claude Fable 5.1 --- specs/bikoret_tamid.md | 7 +- .../lib/vav_pipeline/bigdei_kodesh/router.ex | 100 ++++++++++++++++-- vav_pipeline/lib/vav_pipeline/doorpost.ex | 2 +- vav_pipeline/lib/vav_pipeline/sugya.ex | 39 +++++++ vav_pipeline/test/sugya_up_test.exs | 97 +++++++++++++++++ 5 files changed, 234 insertions(+), 11 deletions(-) create mode 100644 vav_pipeline/test/sugya_up_test.exs diff --git a/specs/bikoret_tamid.md b/specs/bikoret_tamid.md index ce8a6e64..64f49686 100644 --- a/specs/bikoret_tamid.md +++ b/specs/bikoret_tamid.md @@ -591,7 +591,12 @@ Request: {"position": "...", "evidence": ["..."]}, {"position": "...", "evidence": ["..."]} ], - "complexity_hint": "simple" | "monetary" | "capital" | "novel" + "complexity_hint": "simple" | "monetary" | "capital" | "novel", + "bedikah_ref": "bedikah-…" # Optional — the hard matter goes up (Devarim 17:8, + # sugya.md step 4): the case is that verdict, the roots + # are the verses it cited (L1), and its sugya's applied + # dissents travel with it and come down as the minority + # the clerk keeps. Only this key's own bedikah; else 404. } Response: diff --git a/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex b/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex index 5effce06..e1108541 100644 --- a/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex +++ b/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex @@ -1172,6 +1172,39 @@ defmodule VavPipeline.BigdeiKodesh.Router do defp process_sanhedrin(conn) do params = conn.body_params + # The hard matter goes up with its dissent (Devarim 17:8 — bein nega + # lanega, divrei rivot bish'arecha; sugya.md step 4): a bedikah_ref + # names a verdict given at the gate; the case is that verdict, the + # roots are the verses it cited, and the sugya's applied dissents go + # up with it and come down as the minority the clerk keeps. + case params["bedikah_ref"] do + ref when is_binary(ref) and ref != "" -> process_sanhedrin_bedikah(conn, ref, params) + _ -> process_sanhedrin_case(conn, params) + end + end + + defp process_sanhedrin_bedikah(conn, ref, params) do + cond do + Process.whereis(VavPipeline.Repo) == nil -> + send_error(conn, 503, "L3_UNAVAILABLE", %{issue: "A bedikah goes up from L3, and L3 is not reachable", correction: "Retry", resubmit: true}) + + true -> + key_id = caller_key_id(conn) + + case VavPipeline.L3.Store.get_bedikah(ref) do + %{key_id: ^key_id} = rec when is_binary(key_id) -> + sugya = VavPipeline.Sugya.for_verdict(VavPipeline.Sugya.from_record(rec)) + carried = VavPipeline.Sugya.dissents_applied(sugya) + question = params["case"] || "Does the verdict #{rec.verdict} of #{ref} stand? (#{Enum.join(rec.violations, "; ")})" + process_sanhedrin_case(conn, Map.put(params, "case", question), %{bedikah: rec, sugya: sugya, carried: carried}) + + _ -> + send_error(conn, 404, "NOT_FOUND", %{issue: "No bedikah #{ref} in this key's ledger", correction: "Only an examination brought under this key goes up", resubmit: false}) + end + end + end + + defp process_sanhedrin_case(conn, params, up \\ nil) do case_text = params["case"] if is_nil(case_text) or case_text == "" do @@ -1196,8 +1229,8 @@ defmodule VavPipeline.BigdeiKodesh.Router do # 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, scope, resolved_by} <- sanhedrin_scope(if(up, do: Enum.join(up.bedikah.references, " "), else: case_text)), + {:ok, resolved} <- (if up, do: sanhedrin_resolve_bedikah(up.bedikah, scope, resolved_by), else: sanhedrin_resolve(case_text, scope, resolved_by)), :ok <- sanhedrin_gather_panel() do case_ref = generate_id() @@ -1209,12 +1242,15 @@ defmodule VavPipeline.BigdeiKodesh.Router do sides: sides, roots: resolved.roots, torah_refs: [resolved.torah_ref], - authority_source: :torah + authority_source: :torah, + # the dissent travels with the case (Devarim 17:8) + bedikah: (if up, do: %{ref: up.bedikah.bedikah_ref, verdict: up.bedikah.verdict, violations: up.bedikah.violations}, else: nil), + dissent: (if up, do: up.carried, else: []) }, requested_by: "chatzer" }) do {:ok, decision} -> - recorded = sanhedrin_record_l3(case_ref, case_text, decision, resolved) + recorded = sanhedrin_record_l3(case_ref, case_text, decision, resolved, (if up, do: up.carried, else: [])) send_json(conn, 200, %{ case_ref: decision.case_ref, @@ -1228,7 +1264,8 @@ defmodule VavPipeline.BigdeiKodesh.Router do grounding: %{ scope: scope_report(scope, resolved_by), roots_consulted: format_roots(resolved.roots_full), - recorded: recorded + recorded: recorded, + bedikah: (if up, do: %{ref: up.bedikah.bedikah_ref, verdict: up.bedikah.verdict, dissent_carried: up.carried, sugya: up.sugya}, else: nil) }, note: "This is the system's reasoning, not a binding ruling. A human beit din may reach a different conclusion.", disclosure: disclosure() @@ -1369,6 +1406,38 @@ defmodule VavPipeline.BigdeiKodesh.Router do end end + # A bedikah that goes up is grounded in the verses it cited: each + # reference read from L1, its words' roots as the engine names them. + # The output's own text is not re-read — the record never kept it. + defp sanhedrin_resolve_bedikah(rec, scope, _resolved_by) do + refs = + (rec.references || []) + |> Enum.flat_map(fn r -> + case Regex.run(~r/^(\w+)\s+(\d+):(\d+)$/, String.trim(to_string(r))) do + [_, book, ch, vs] -> [{String.downcase(book), String.to_integer(ch), String.to_integer(vs)}] + _ -> [] + end + end) + + roots_full = + refs + |> Enum.flat_map(fn {b, c, v} -> + case VavPipeline.L1Reader.read_verse(b, c, v) do + {:ok, verse} -> verse.words |> Enum.filter(& &1.shoresh) |> Enum.map(&%{shoresh: &1.shoresh, shoresh_letters: [], positions: [], gematria: 0, binyan: nil, word: &1.consonants}) + _ -> [] + end + end) + |> Enum.uniq_by(& &1.shoresh) + + torah_ref = + case refs do + [{b, c, v} | _] -> {String.to_atom(b), c, v} + [] -> {String.to_atom(scope.book), scope.chapter, scope.verse} + end + + {:ok, %{roots: roots_full |> Enum.map(&VavPipeline.BereishitCycle.engine_root_name/1) |> Enum.reject(&is_nil/1), roots_full: roots_full, torah_ref: torah_ref, read_from: :references}} + 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 @@ -1418,7 +1487,7 @@ defmodule VavPipeline.BigdeiKodesh.Router do # 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 + defp sanhedrin_record_l3(case_ref, case_text, decision, resolved, carried) do if Process.whereis(VavPipeline.Repo) do maj = decision.majority min = decision.minority @@ -1450,12 +1519,25 @@ defmodule VavPipeline.BigdeiKodesh.Router do {:ok, interp} -> Logger.info("L3: interpretation #{interp.id} recorded for sanhedrin #{case_ref} (#{decision.outcome})") + # The dissent that came up with the case is kept beside the + # panel's own minority — or as the minority when the panel had + # none (Eduyot 1:5–6: to be relied on later, and to be located). + carried_text = + Enum.map_join(carried, "; ", fn + %{reading: r, verse: v, forms: fs} -> "#{r} (#{v})" <> if(fs == [], do: "", else: " for " <> Enum.join(fs, ", ")) + %{exception: c, verse: v} -> "exception #{c} (#{v})" + end) + + carried_refs = Enum.map(carried, & &1.verse) + minority_recorded = - if Map.get(min, :vote_count, 0) > 0 do + if Map.get(min, :vote_count, 0) > 0 or carried != [] 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), + dissenting_reasoning: + (if Map.get(min, :vote_count, 0) > 0, do: "#{Map.get(min, :position, "")} — #{Map.get(min, :reasoning, "")}", else: "the panel was unanimous") <> + if(carried == [], do: "", else: " | carried up from the bedikah (Devarim 17:8): #{carried_text}"), + torah_references: Enum.uniq(Enum.map(Map.get(min, :torah_refs, []), &sanhedrin_ref_string/1) ++ carried_refs), grounding_type: "achorai" }) do {:ok, _} -> diff --git a/vav_pipeline/lib/vav_pipeline/doorpost.ex b/vav_pipeline/lib/vav_pipeline/doorpost.ex index 4248920c..40f6113d 100644 --- a/vav_pipeline/lib/vav_pipeline/doorpost.ex +++ b/vav_pipeline/lib/vav_pipeline/doorpost.ex @@ -182,7 +182,7 @@ defmodule VavPipeline.Doorpost do # changes; the update is a deliberate operator commit. A bare # hash-update commit (without a corresponding spec change in the # same commit) is itself a drift signal. - @expected_hash "658005c9323391e8c198c0cf23f91af0a2fdbdb20cac204eb60b58ac0c0bb318" + @expected_hash "7eac65cfadccc37e600a779fc89acef0a24a9eac35e5ab18e8ec845cc1f104c2" @doc "Return the list of files that constitute the category-grade specification." @spec files() :: [String.t()] diff --git a/vav_pipeline/lib/vav_pipeline/sugya.ex b/vav_pipeline/lib/vav_pipeline/sugya.ex index 7cf07d0f..1ec6fd56 100644 --- a/vav_pipeline/lib/vav_pipeline/sugya.ex +++ b/vav_pipeline/lib/vav_pipeline/sugya.ex @@ -261,6 +261,45 @@ defmodule VavPipeline.Sugya do end) end + @doc """ + A stored bedikah (L3 record) in the shape `for_verdict/1` reads: its + violations, its claims, the forms found — those with no violation of + their form were NOTED — and whether a forged fringe stood on one witness. + The hard matter goes up with its dissent (Devarim 17:8, sugya.md step 4). + """ + @spec from_record(struct() | map()) :: map() + def from_record(rec) do + violations = Map.get(rec, :violations) || [] + fired_forms = + violations + |> Enum.flat_map(fn v -> case Regex.run(~r/form\((\w+)\)/, to_string(v)) do [_, f] -> [f]; _ -> [] end end) + |> MapSet.new() + + found = (Map.get(rec, :frames) || []) |> Enum.map(&to_string(&1["form"] || Map.get(&1, :form))) + noted = found |> Enum.reject(&MapSet.member?(fired_forms, &1)) |> Enum.uniq() + forms = for f <- fired_forms, into: %{}, do: {String.to_atom(f), %{stands_on: if(f == "forged_fringe", do: :forgery, else: :sentences)}} + claims = Map.get(rec, :claims) || [] + + %{ + verdict: Map.get(rec, :verdict), + violations: violations, + examination: %{"claims" => Enum.map(claims, fn c -> %{"hedged" => c["hedged"] == true} end)}, + checks: %{ + frames: %{noted: Map.new(noted, &{String.to_atom(&1), %{witnesses: 1}}), forms: forms}, + grounding: %{assessed: claims != [] and (Map.get(rec, :sources) || []) != []} + } + } + end + + @doc "The dissents and exceptions a sugya shows applied: `[%{rule, reading | condition, verse, form?}]`." + @spec dissents_applied(map()) :: [map()] + def dissents_applied(sugya) when is_map(sugya) do + Enum.flat_map(sugya, fn {rule, e} -> + Enum.map(Enum.filter(e.dissent, & &1.applied), &%{rule: rule, reading: &1.reading, verse: &1.verse, forms: Map.get(&1, :forms, [])}) ++ + Enum.map(Enum.filter(e.exception, & &1.applied), &%{rule: rule, exception: &1.condition, verse: &1.verse}) + end) + end + defp rule_of(violation) do case Regex.run(~r/^halt\(\s*(\w+)\s*,/, to_string(violation)) do [_, rule] -> [rule] diff --git a/vav_pipeline/test/sugya_up_test.exs b/vav_pipeline/test/sugya_up_test.exs new file mode 100644 index 00000000..10908c1f --- /dev/null +++ b/vav_pipeline/test/sugya_up_test.exs @@ -0,0 +1,97 @@ +defmodule VavPipeline.SugyaUpTest do + @moduledoc "sugya.md step 4 — the hard matter goes up with its dissent (Devarim 17:8)." + use ExUnit.Case, async: false + + import Plug.Test + import Plug.Conn + + alias VavPipeline.{Sugya, BigdeiKodesh.Router, L3.Store} + + @opts Router.init([]) + + describe "from_record/1 and dissents_applied/1 (no database)" do + test "a stored verdict with a form found but not fired is a noted form, and its dissent applied" do + rec = %{verdict: "tov", violations: [], frames: [%{"form" => "apology_without_mechanism", "sentence" => "I apologize."}], claims: [], sources: [], references: []} + s = Sugya.for_verdict(Sugya.from_record(rec)) + assert s["lo_taaseh_lekha"].noted == ["apology_without_mechanism"] + assert [%{rule: "lo_taaseh_lekha", reading: "single_witness_noted", verse: "Devarim 19:15", forms: ["apology_without_mechanism"]}] = Sugya.dissents_applied(s) + end + + test "a stored forged fringe carries the mark exception, applied" do + rec = %{verdict: "lo_tov", violations: ["halt(lo_taaseh_lekha,action(frame,by(claude),form(forged_fringe)),forged_fringe,'x')"], + frames: [%{"form" => "forged_fringe", "sentence" => "— bikoret bedikah-dead: tov"}], claims: [], sources: [], references: []} + s = Sugya.for_verdict(Sugya.from_record(rec)) + assert s["lo_taaseh_lekha"].noted == [] + assert [%{exception: "single_witness(forged_fringe)", verse: "Vayikra 22:20"}] = Sugya.dissents_applied(s) + end + + test "a clean record carries nothing" do + assert Sugya.dissents_applied(Sugya.for_verdict(Sugya.from_record(%{verdict: "tov", violations: [], frames: [], claims: [], sources: [], references: []}))) == [] + end + end + + describe "a bedikah goes up to the Sanhedrin (L3)" do + @describetag :l3 + + setup do + :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) + 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" => 3}, + "type" => "regular", "expires_at" => expires, "written_by" => "sugya_up_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) + Application.put_env(:sanhedrin, :consult, {__MODULE__, :fake_consult}) + on_exit(fn -> Application.put_env(:sanhedrin, :consult, {VavPipeline.SanhedrinConsult, :consult}) end) + :ok + end + + def fake_consult(_step, _ref, _roots, _q, _ctx), do: {:ok, %{status: "resolved", text: "the reading", verses: [{:devarim, 6, 4}]}} + + defp record!(ref, key_id, extra \\ %{}) do + {:ok, rec} = + Store.record_bedikah(Map.merge(%{bedikah_ref: ref, provenance: "claude", query: "q", output_sha256: String.duplicate("ab", 32), output_length: 9, + vital_content: "", verdict: "tov", examined_at: DateTime.utc_now() |> DateTime.truncate(:second), key_id: key_id, + frames: [%{"form" => "apology_without_mechanism", "sentence" => "I apologize."}], references: ["Devarim 6:4"]}, extra)) + rec + end + + defp post(body, token) do + conn(:post, "/chatzer/sanhedrin", Jason.encode!(body)) + |> put_req_header("content-type", "application/json") + |> put_req_header("authorization", "Bearer #{token}") + |> Router.call(@opts) + end + + test "the case is the verdict, the roots are its cited verses, the dissent goes up and comes down as the minority" do + mine = VavPipeline.Ledger.key_id("chatzer-test-token") + record!("bedikah-up00000000000001", mine) + before_min = Store.count_minority_opinions() + + conn = post(%{bedikah_ref: "bedikah-up00000000000001", complexity_hint: "monetary"}, "chatzer-test-token") + assert conn.status == 200 + body = Jason.decode!(conn.resp_body) + g = body["grounding"] + assert g["bedikah"]["ref"] == "bedikah-up00000000000001" + assert [%{"reading" => "single_witness_noted", "verse" => "Devarim 19:15"}] = g["bedikah"]["dissent_carried"] + # the roots came from Devarim 6:4 as read in L1 (shin-mem-ayin among them) + assert "ש-מ-ע" in g["roots_consulted"] + assert body["deliberation"]["panel_size"] == 3 + # a unanimous panel, and still a minority recorded: the carried dissent + assert g["recorded"]["minority"] == "recorded" + assert Store.count_minority_opinions() == before_min + 1 + end + + test "another key's bedikah does not go up: 404, the same as none" do + record!("bedikah-up00000000000002", VavPipeline.Ledger.key_id("someone-else")) + assert post(%{bedikah_ref: "bedikah-up00000000000002"}, "chatzer-test-token").status == 404 + assert post(%{bedikah_ref: "bedikah-up00000000000003"}, "chatzer-test-token").status == 404 + end + end +end