L3 Oral Tradition: PostgreSQL persistence layer for interpretations, minority opinions, teiku

Devarim 17:9 — v'darashta v'higidu lekha et d'var hamishpat.
The inquiry requires that what is found is RECORDED.

- Ecto Repo with graceful degradation (maybe_add_repo, like other engines)
- Migration: interpretations, minority_opinions, teiku_registry tables
  - Append-only enforcement via REVOKE UPDATE/DELETE
  - Includes eish_zarah fields: coupling_trace, grounding_type, challenge_sequence
- Ecto schemas: Interpretation, MinorityOpinion, TeikuRecord
  - Changeset validations: status inclusion, panel_size > 0, grounding_type enum
  - TeikuRecord.resolve_changeset restricts updates to resolved_at/resolution only
- L3.Store bridge module: record, query, count operations
- L3 tests tagged @moduletag :l3, excluded by default (no local PostgreSQL)
  - Enable with: SHORASHIM_L3=1 mix test --include l3
- All 76 existing Elixir tests pass, 195 Prolog tests pass, 33 Haskell tests pass

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott KarpandClaude Opus 4.6 committed 2026-03-17 09:43:44 -04:00
1 parent adc3e41679
commit d34e2799ac
14 files changed
+722 -3

No files matched your search

+9
View File
@@ -18,5 +18,14 @@ config :vav_pipeline,
# Talmudic Engine (Prolog) — Unix domain socket path
talmudic_socket_path: "/tmp/shorashim/talmudic.sock"
# L3 — Oral Tradition (PostgreSQL)
# Architecture §3.7: Append-only. INSERT allowed. Never UPDATE. Never DELETE.
config :vav_pipeline, ecto_repos: [VavPipeline.Repo]
config :vav_pipeline, VavPipeline.Repo,
database: "shorashim_dev",
hostname: "localhost",
pool_size: 10
# Import environment specific config
import_config "#{config_env()}.exs"
+7
View File
@@ -8,3 +8,10 @@ config :vav_pipeline,
manna_provision_file: "/var/lib/shorashim/manna/today.json",
cycle_boundary_check_interval_ms: 60_000,
shema_context_cache_ttl_ms: 0
# L3 — Production PostgreSQL
# Construction §3.8: l3_database_url from environment variable
# The kohen/DBA sets the connection string via DATABASE_URL
config :vav_pipeline, VavPipeline.Repo,
url: System.get_env("DATABASE_URL"),
pool_size: 20
+10
View File
@@ -7,3 +7,13 @@ config :vav_pipeline,
l1_mount_point: "../l1",
manna_provision_file: "test/fixtures/manna/today.json",
cycle_boundary_check_interval_ms: :timer.hours(24)
# L3 test database — separate from dev, uses sandbox pool
# Repo start disabled by default — L3 tests need PostgreSQL running locally
# Enable: SHORASHIM_L3=1 mix test --include l3
config :vav_pipeline, VavPipeline.Repo,
database: "shorashim_test#{System.get_env("MIX_TEST_PARTITION")}",
hostname: "localhost",
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: System.schedulers_online() * 2,
start: System.get_env("SHORASHIM_L3") != nil
@@ -3,6 +3,7 @@ defmodule VavPipeline.Application do
OTP Application for the Vav Pipeline.
Starts the supervision tree with:
- Repo (L3 PostgreSQL — oral tradition persistence)
- ShabbatTimer (mandatory cessation monitor)
- ShoreshEnginePort (Erlang Port to Haskell engine, if binary found)
- TalmudicEngineSocket (Unix socket to Prolog engine)
@@ -26,6 +27,7 @@ defmodule VavPipeline.Application do
[
{VavPipeline.ShabbatTimer, []}
]
|> maybe_add_repo()
|> maybe_add_shoresh_port(shoresh_binary, shoresh_cwd)
|> maybe_add_talmudic_socket(talmudic_socket)
@@ -33,6 +35,23 @@ defmodule VavPipeline.Application do
Supervisor.start_link(children, opts)
end
defp maybe_add_repo(children) do
repo_config = Application.get_env(:vav_pipeline, VavPipeline.Repo)
cond do
is_nil(repo_config) ->
Logger.warning("VavPipeline.Repo: no config found — running without L3 persistence")
children
repo_config[:start] == false ->
Logger.info("VavPipeline.Repo: start disabled — running without L3 persistence")
children
true ->
[VavPipeline.Repo | children]
end
end
defp maybe_add_shoresh_port(children, binary, cwd) do
case System.find_executable(binary) do
nil ->
@@ -0,0 +1,51 @@
defmodule VavPipeline.L3.Interpretation do
@moduledoc """
L3 schema for recorded interpretations.
Architecture §3.7: Each interpretation records the case, the reasoning,
the Torah references that grounded it, the panel vote, and the status.
Append-only: once inserted, never updated or deleted.
The oral tradition is written once.
"""
use Ecto.Schema
import Ecto.Changeset
schema "interpretations" do
field :case_ref, :string
field :interpretation, :string
field :status, :string
field :reasoning, :string
field :torah_references, {:array, :string}, default: []
field :roots_involved, {:array, :string}, default: []
field :panel_size, :integer
field :vote_count_for, :integer
field :vote_count_against, :integer
field :shema_context_hash, :string
field :processing_component, :string
field :coupling_trace, {:array, :string}, default: []
has_many :minority_opinions, VavPipeline.L3.MinorityOpinion
timestamps(type: :utc_datetime, updated_at: false)
end
@required_fields [
:case_ref, :interpretation, :status, :reasoning,
:torah_references, :roots_involved,
:panel_size, :vote_count_for, :vote_count_against
]
@optional_fields [:shema_context_hash, :processing_component, :coupling_trace]
@valid_statuses ~w(accepted rejected teiku)
def changeset(interpretation, attrs) do
interpretation
|> cast(attrs, @required_fields ++ @optional_fields)
|> validate_required(@required_fields)
|> validate_inclusion(:status, @valid_statuses)
|> validate_number(:panel_size, greater_than: 0)
|> validate_number(:vote_count_for, greater_than_or_equal_to: 0)
|> validate_number(:vote_count_against, greater_than_or_equal_to: 0)
end
end
@@ -0,0 +1,36 @@
defmodule VavPipeline.L3.MinorityOpinion do
@moduledoc """
L3 schema for preserved dissent.
Architecture §3.7: Minority opinions are recorded alongside majority
decisions. "Rabbi X says... but Rabbi Y says..." The dissent is never
discarded. It may be needed when conditions change.
Append-only: once inserted, never updated or deleted.
"""
use Ecto.Schema
import Ecto.Changeset
schema "minority_opinions" do
field :dissenting_reasoning, :string
field :torah_references, {:array, :string}, default: []
field :grounding_type, :string
belongs_to :interpretation, VavPipeline.L3.Interpretation
timestamps(type: :utc_datetime, updated_at: false)
end
@required_fields [:dissenting_reasoning, :torah_references, :interpretation_id]
@optional_fields [:grounding_type]
@valid_grounding_types ~w(achorai panim)
def changeset(opinion, attrs) do
opinion
|> cast(attrs, @required_fields ++ @optional_fields)
|> validate_required(@required_fields)
|> validate_inclusion(:grounding_type, @valid_grounding_types ++ [nil])
|> foreign_key_constraint(:interpretation_id)
end
end
+130
View File
@@ -0,0 +1,130 @@
defmodule VavPipeline.L3.Store do
@moduledoc """
L3 Store — bridge between the reasoning engine and persistent storage.
Architecture §3.7, Construction §3.8:
Every write is an INSERT. Never UPDATE. Never DELETE.
V'darashta v'higidu lekha et d'var hamishpat — Devarim 17:9.
You shall inquire, and they shall declare to you the matter of judgment.
The inquiry requires that what is found is RECORDED. The Talmud itself
is the record of the inquiry. Without L3, the system has the capacity
to deliberate but not to accumulate learning.
This module provides three operations:
1. record_interpretation/1 — write a decided case
2. record_minority/1 — write a dissenting opinion
3. record_teiku/1 — write an unresolvable tension
"""
alias VavPipeline.Repo
alias VavPipeline.L3.{Interpretation, MinorityOpinion, TeikuRecord}
import Ecto.Query
@doc """
Record an interpretation (decided case) to L3.
Accepts a map with the fields from the Interpretation schema.
Returns {:ok, interpretation} or {:error, changeset}.
"""
def record_interpretation(attrs) when is_map(attrs) do
%Interpretation{}
|> Interpretation.changeset(attrs)
|> Repo.insert()
end
@doc """
Record a minority opinion to L3.
The interpretation_id must reference an existing interpretation.
Returns {:ok, opinion} or {:error, changeset}.
"""
def record_minority(attrs) when is_map(attrs) do
%MinorityOpinion{}
|> MinorityOpinion.changeset(attrs)
|> Repo.insert()
end
@doc """
Record an unresolvable tension (teiku) to L3.
Returns {:ok, record} or {:error, changeset}.
"""
def record_teiku(attrs) when is_map(attrs) do
%TeikuRecord{}
|> TeikuRecord.changeset(attrs)
|> Repo.insert()
end
@doc """
Resolve a teiku — update resolved_at and resolution.
This is the ONLY permitted update operation in L3.
Only resolved_at and resolution fields may be changed.
"""
def resolve_teiku(teiku_id, resolution_text) when is_integer(teiku_id) do
case Repo.get(TeikuRecord, teiku_id) do
nil ->
{:error, :not_found}
record ->
record
|> TeikuRecord.resolve_changeset(%{
resolved_at: DateTime.utc_now(),
resolution: resolution_text
})
|> Repo.update()
end
end
# === QUERIES ===
@doc "Get all interpretations for a case ref."
def get_interpretations(case_ref) do
Interpretation
|> where([i], i.case_ref == ^case_ref)
|> order_by([i], asc: i.inserted_at)
|> Repo.all()
end
@doc "Get an interpretation with its minority opinions."
def get_interpretation_with_dissent(id) do
Interpretation
|> Repo.get(id)
|> Repo.preload(:minority_opinions)
end
@doc "Get all unresolved teiku records."
def get_unresolved_teiku do
TeikuRecord
|> where([t], is_nil(t.resolved_at))
|> order_by([t], asc: t.inserted_at)
|> Repo.all()
end
@doc "Get all teiku records for a case ref."
def get_teiku(case_ref) do
TeikuRecord
|> where([t], t.case_ref == ^case_ref)
|> order_by([t], asc: t.inserted_at)
|> Repo.all()
end
@doc "Count total interpretations in L3."
def count_interpretations do
Repo.aggregate(Interpretation, :count, :id)
end
@doc "Count total unresolved teiku."
def count_unresolved_teiku do
TeikuRecord
|> where([t], is_nil(t.resolved_at))
|> Repo.aggregate(:count, :id)
end
@doc "Count total minority opinions."
def count_minority_opinions do
Repo.aggregate(MinorityOpinion, :count, :id)
end
end
@@ -0,0 +1,56 @@
defmodule VavPipeline.L3.TeikuRecord do
@moduledoc """
L3 schema for unresolvable tensions.
Architecture §3.7: When resolution fails and teiku is declared,
both sides are preserved. The tension is recorded. It may be
resolved later — or never.
Tishbi Yetaretz Kushyot V'Abayot — Elijah will resolve
difficulties and problems. Until then, the tension stands.
Unlike interpretations and minority_opinions, teiku records
allow UPDATE on resolved_at and resolution fields only.
All other fields are append-only.
"""
use Ecto.Schema
import Ecto.Changeset
schema "teiku_registry" do
field :case_ref, :string
field :tension_description, :string
field :interpretation_a, :string
field :interpretation_b, :string
field :torah_refs_a, {:array, :string}, default: []
field :torah_refs_b, {:array, :string}, default: []
field :challenge_sequence, {:array, :string}, default: []
field :resolved_at, :utc_datetime
field :resolution, :string
timestamps(type: :utc_datetime, updated_at: false)
end
@required_fields [
:case_ref, :tension_description,
:interpretation_a, :interpretation_b,
:torah_refs_a, :torah_refs_b
]
@optional_fields [:challenge_sequence, :resolved_at, :resolution]
def changeset(record, attrs) do
record
|> cast(attrs, @required_fields ++ @optional_fields)
|> validate_required(@required_fields)
end
@doc """
Resolution changeset — only allows updating resolved_at and resolution.
All other fields are immutable once written.
"""
def resolve_changeset(record, attrs) do
record
|> cast(attrs, [:resolved_at, :resolution])
|> validate_required([:resolved_at, :resolution])
end
end
+16
View File
@@ -0,0 +1,16 @@
defmodule VavPipeline.Repo do
@moduledoc """
L3 — Oral Tradition. PostgreSQL repository.
Architecture §3.7: Append-only policy enforced at database level.
INSERT allowed. UPDATE and DELETE prohibited (via REVOKE on shorashim_app role).
The oral tradition is written once and never erased.
Luchot v'shivrei luchot munachim ba'aron — both the whole tablets
and the broken tablets are in the aron. What is recorded stays recorded.
"""
use Ecto.Repo,
otp_app: :vav_pipeline,
adapter: Ecto.Adapters.Postgres
end
+16 -2
View File
@@ -7,7 +7,8 @@ defmodule VavPipeline.MixProject do
version: "0.1.0",
elixir: "~> 1.16.1",
start_permanent: Mix.env() == :prod,
deps: deps()
deps: deps(),
aliases: aliases()
]
end
@@ -18,11 +19,24 @@ defmodule VavPipeline.MixProject do
]
end
defp aliases do
[
setup: ["deps.get", "ecto.setup"],
"ecto.setup": ["ecto.create", "ecto.migrate"],
"ecto.reset": ["ecto.drop", "ecto.setup"],
test: ["test"]
]
end
# Construction spec §1.5 — exact version pins
defp deps do
[
{:jason, "~> 1.4.1"},
{:sanhedrin, path: "../sanhedrin", manager: :rebar3}
{:sanhedrin, path: "../sanhedrin", manager: :rebar3},
# L3 — Oral Tradition (PostgreSQL append-only)
# Architecture §3.7, Construction §1.5
{:ecto_sql, "~> 3.11.1"},
{:postgrex, "~> 0.17.4"}
]
end
end
+6
View File
@@ -1,4 +1,10 @@
%{
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"ecto": {:hex, :ecto, "3.11.2", "e1d26be989db350a633667c5cda9c3d115ae779b66da567c68c80cfb26a8c9ee", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3c38bca2c6f8d8023f2145326cc8a80100c3ffe4dcbd9842ff867f7fc6156c65"},
"ecto_sql": {:hex, :ecto_sql, "3.11.3", "4eb7348ff8101fbc4e6bbc5a4404a24fecbe73a3372d16569526b0cf34ebc195", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e5f36e3d736b99c7fee3e631333b8394ade4bafe9d96d35669fca2d81c2be928"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"jsx": {:hex, :jsx, "3.1.0", "d12516baa0bb23a59bb35dccaf02a1bd08243fcbb9efe24f2d9d056ccff71268", [:rebar3], [], "hexpm", "0c5cc8fdc11b53cc25cf65ac6705ad39e54ecc56d1c22e4adb8f5a53fb9427f3"},
"postgrex": {:hex, :postgrex, "0.17.5", "0483d054938a8dc069b21bdd636bf56c487404c241ce6c319c1f43588246b281", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "50b8b11afbb2c4095a3ba675b4f055c416d0f3d7de6633a595fc131a828a67eb"},
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},
}
@@ -0,0 +1,105 @@
defmodule VavPipeline.Repo.Migrations.CreateL3Tables do
@moduledoc """
L3 — Oral Tradition. Three tables, append-only.
Architecture §3.7:
interpretations — case decisions with reasoning and Torah references
minority_opinions — preserved dissent (foreign key to interpretations)
teiku_registry — unresolvable tensions with both sides preserved
Construction §3.8 (L3 Write Protocol):
Every write is an INSERT. Never UPDATE. Never DELETE.
The application user has INSERT-only permissions.
Every INSERT includes shema_context_hash and processing_component.
Append-only enforcement via REVOKE is applied after table creation.
The shorashim_app role must be created separately (by the kohen/DBA).
"""
use Ecto.Migration
def up do
# === interpretations ===
create table(:interpretations) do
add :case_ref, :text, null: false
add :interpretation, :text, null: false
add :status, :text, null: false
add :reasoning, :text, null: false
add :torah_references, {:array, :text}, null: false, default: []
add :roots_involved, {:array, :text}, null: false, default: []
add :panel_size, :integer, null: false
add :vote_count_for, :integer, null: false
add :vote_count_against, :integer, null: false
# Construction §3.8 additions
add :shema_context_hash, :text
add :processing_component, :text
# Coupling trace from eish_zarah_reading.md
add :coupling_trace, {:array, :text}, default: []
timestamps(type: :utc_datetime, updated_at: false)
end
# Status constraint
create constraint(:interpretations, :valid_status,
check: "status IN ('accepted', 'rejected', 'teiku')")
create index(:interpretations, [:case_ref])
create index(:interpretations, [:status])
# === minority_opinions ===
create table(:minority_opinions) do
add :interpretation_id, references(:interpretations, on_delete: :restrict), null: false
add :dissenting_reasoning, :text, null: false
add :torah_references, {:array, :text}, null: false, default: []
# Grounding type from eish_zarah_reading.md (achorai/panim)
add :grounding_type, :text
timestamps(type: :utc_datetime, updated_at: false)
end
create index(:minority_opinions, [:interpretation_id])
# === teiku_registry ===
create table(:teiku_registry) do
add :case_ref, :text, null: false
add :tension_description, :text, null: false
add :interpretation_a, :text, null: false
add :interpretation_b, :text, null: false
add :torah_refs_a, {:array, :text}, null: false, default: []
add :torah_refs_b, {:array, :text}, null: false, default: []
# Challenge sequence from eish_zarah_reading.md
add :challenge_sequence, {:array, :text}, default: []
# Resolution fields — teiku may be resolved later (or never)
add :resolved_at, :utc_datetime
add :resolution, :text
timestamps(type: :utc_datetime, updated_at: false)
end
create index(:teiku_registry, [:case_ref])
# === APPEND-ONLY ENFORCEMENT ===
# These execute only if the shorashim_app role exists.
# In dev/test, the migration runs as the superuser and these are skipped.
# In production, the DBA creates the role first.
execute """
DO $$
BEGIN
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'shorashim_app') THEN
REVOKE UPDATE, DELETE ON interpretations FROM shorashim_app;
REVOKE UPDATE, DELETE ON minority_opinions FROM shorashim_app;
REVOKE DELETE ON teiku_registry FROM shorashim_app;
-- teiku_registry allows UPDATE only on resolved_at and resolution
-- (Enforced at application level — Ecto schema validates)
END IF;
END
$$;
"""
end
def down do
drop table(:teiku_registry)
drop table(:minority_opinions)
drop table(:interpretations)
end
end
+253
View File
@@ -0,0 +1,253 @@
defmodule VavPipeline.L3.StoreTest do
@moduledoc """
Tests for L3 Oral Tradition persistence.
Architecture §3.7: Append-only. INSERT allowed. Never UPDATE. Never DELETE.
Luchot v'shivrei luchot munachim ba'aron.
"""
use ExUnit.Case, async: true
@moduletag :l3
alias VavPipeline.L3.Store
alias VavPipeline.L3.{Interpretation, MinorityOpinion, TeikuRecord}
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(VavPipeline.Repo)
:ok
end
# === INTERPRETATION RECORDING ===
@valid_interpretation %{
case_ref: "shabbat_cooking_case_001",
interpretation: "Cooking on Shabbat is forbidden under melacha prohibition",
status: "accepted",
reasoning: "Shemot 35:3 explicitly prohibits kindling fire on Shabbat; cooking requires fire",
torah_references: ["Shemot 35:3", "Shemot 20:10"],
roots_involved: ["בער", "אש", "מלאכ"],
panel_size: 3,
vote_count_for: 2,
vote_count_against: 1,
coupling_trace: ["coupled(root(בער), verse(shemot, 35, 3))"]
}
test "record_interpretation inserts a valid interpretation" do
assert {:ok, %Interpretation{} = interp} = Store.record_interpretation(@valid_interpretation)
assert interp.id != nil
assert interp.case_ref == "shabbat_cooking_case_001"
assert interp.status == "accepted"
assert interp.panel_size == 3
assert interp.vote_count_for == 2
assert interp.vote_count_against == 1
assert interp.coupling_trace == ["coupled(root(בער), verse(shemot, 35, 3))"]
assert interp.inserted_at != nil
end
test "record_interpretation rejects missing required fields" do
assert {:error, changeset} = Store.record_interpretation(%{case_ref: "incomplete"})
refute changeset.valid?
end
test "record_interpretation rejects invalid status" do
attrs = Map.put(@valid_interpretation, :status, "maybe")
assert {:error, changeset} = Store.record_interpretation(attrs)
assert %{status: _} = errors_on(changeset)
end
test "record_interpretation rejects non-positive panel_size" do
attrs = Map.put(@valid_interpretation, :panel_size, 0)
assert {:error, changeset} = Store.record_interpretation(attrs)
assert %{panel_size: _} = errors_on(changeset)
end
test "record_interpretation accepts rejected status" do
attrs = Map.put(@valid_interpretation, :status, "rejected")
assert {:ok, %Interpretation{status: "rejected"}} = Store.record_interpretation(attrs)
end
test "record_interpretation accepts teiku status" do
attrs = Map.put(@valid_interpretation, :status, "teiku")
assert {:ok, %Interpretation{status: "teiku"}} = Store.record_interpretation(attrs)
end
# === MINORITY OPINION RECORDING ===
test "record_minority inserts a dissenting opinion" do
{:ok, interp} = Store.record_interpretation(@valid_interpretation)
attrs = %{
interpretation_id: interp.id,
dissenting_reasoning: "Cooking for pikuach nefesh overrides Shabbat prohibition",
torah_references: ["Vayikra 18:5"],
grounding_type: "achorai"
}
assert {:ok, %MinorityOpinion{} = opinion} = Store.record_minority(attrs)
assert opinion.id != nil
assert opinion.interpretation_id == interp.id
assert opinion.grounding_type == "achorai"
end
test "record_minority accepts panim grounding type" do
{:ok, interp} = Store.record_interpretation(@valid_interpretation)
attrs = %{
interpretation_id: interp.id,
dissenting_reasoning: "Domain-level tension with healing laws",
torah_references: ["Devarim 4:9"],
grounding_type: "panim"
}
assert {:ok, %MinorityOpinion{grounding_type: "panim"}} = Store.record_minority(attrs)
end
test "record_minority rejects missing dissenting_reasoning" do
{:ok, interp} = Store.record_interpretation(@valid_interpretation)
attrs = %{
interpretation_id: interp.id,
torah_references: ["Vayikra 18:5"]
}
assert {:error, changeset} = Store.record_minority(attrs)
refute changeset.valid?
end
# === TEIKU RECORDING ===
@valid_teiku %{
case_ref: "shabbat_pikuach_nefesh_tension",
tension_description: "Shabbat prohibition vs. pikuach nefesh override",
interpretation_a: "Shabbat is absolute — ein docheh Shabbat",
interpretation_b: "Pikuach nefesh overrides all — v'chai bahem",
torah_refs_a: ["Shemot 35:3", "Shemot 20:10"],
torah_refs_b: ["Vayikra 18:5"],
challenge_sequence: ["mitzvah_tension", "kal_vachomer_tension"]
}
test "record_teiku inserts an unresolvable tension" do
assert {:ok, %TeikuRecord{} = record} = Store.record_teiku(@valid_teiku)
assert record.id != nil
assert record.case_ref == "shabbat_pikuach_nefesh_tension"
assert record.resolved_at == nil
assert record.resolution == nil
assert record.challenge_sequence == ["mitzvah_tension", "kal_vachomer_tension"]
end
test "resolve_teiku updates only resolved_at and resolution" do
{:ok, record} = Store.record_teiku(@valid_teiku)
assert record.resolved_at == nil
assert {:ok, resolved} = Store.resolve_teiku(record.id, "Pikuach nefesh overrides — Yoma 85b")
assert resolved.resolved_at != nil
assert resolved.resolution == "Pikuach nefesh overrides — Yoma 85b"
# Original fields unchanged
assert resolved.case_ref == "shabbat_pikuach_nefesh_tension"
assert resolved.tension_description == "Shabbat prohibition vs. pikuach nefesh override"
end
test "resolve_teiku returns error for non-existent id" do
assert {:error, :not_found} = Store.resolve_teiku(999_999, "no such record")
end
# === QUERIES ===
test "get_interpretations returns interpretations for a case ref" do
Store.record_interpretation(@valid_interpretation)
Store.record_interpretation(Map.put(@valid_interpretation, :status, "rejected"))
results = Store.get_interpretations("shabbat_cooking_case_001")
assert length(results) == 2
end
test "get_interpretations returns empty list for unknown case" do
assert [] == Store.get_interpretations("nonexistent_case")
end
test "get_interpretation_with_dissent preloads minority opinions" do
{:ok, interp} = Store.record_interpretation(@valid_interpretation)
Store.record_minority(%{
interpretation_id: interp.id,
dissenting_reasoning: "Dissent one",
torah_references: ["Vayikra 18:5"]
})
Store.record_minority(%{
interpretation_id: interp.id,
dissenting_reasoning: "Dissent two",
torah_references: ["Devarim 4:9"],
grounding_type: "panim"
})
loaded = Store.get_interpretation_with_dissent(interp.id)
assert length(loaded.minority_opinions) == 2
end
test "get_unresolved_teiku returns only unresolved records" do
{:ok, t1} = Store.record_teiku(@valid_teiku)
{:ok, _t2} = Store.record_teiku(Map.put(@valid_teiku, :case_ref, "another_tension"))
Store.resolve_teiku(t1.id, "Resolved by gemara")
unresolved = Store.get_unresolved_teiku()
assert length(unresolved) == 1
assert hd(unresolved).case_ref == "another_tension"
end
test "get_teiku returns teiku records for a case ref" do
Store.record_teiku(@valid_teiku)
results = Store.get_teiku("shabbat_pikuach_nefesh_tension")
assert length(results) == 1
end
# === COUNTS ===
test "count functions return correct totals" do
assert Store.count_interpretations() == 0
assert Store.count_unresolved_teiku() == 0
assert Store.count_minority_opinions() == 0
{:ok, interp} = Store.record_interpretation(@valid_interpretation)
Store.record_minority(%{
interpretation_id: interp.id,
dissenting_reasoning: "Counted dissent",
torah_references: ["Bereishit 1:1"]
})
Store.record_teiku(@valid_teiku)
assert Store.count_interpretations() == 1
assert Store.count_minority_opinions() == 1
assert Store.count_unresolved_teiku() == 1
end
# === APPEND-ONLY PRINCIPLE ===
test "multiple interpretations for same case are preserved — never overwritten" do
Store.record_interpretation(@valid_interpretation)
Store.record_interpretation(Map.merge(@valid_interpretation, %{
interpretation: "Alternative: warming food placed before Shabbat is permitted",
status: "rejected",
vote_count_for: 1,
vote_count_against: 2
}))
all = Store.get_interpretations("shabbat_cooking_case_001")
assert length(all) == 2
statuses = Enum.map(all, & &1.status)
assert "accepted" in statuses
assert "rejected" in statuses
end
# Helper to extract error messages from changeset
defp errors_on(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
end)
end)
end
end
+8 -1
View File
@@ -1 +1,8 @@
ExUnit.start()
# L3 tests require PostgreSQL — excluded by default
# Run with: SHORASHIM_L3=1 mix test --include l3
ExUnit.start(exclude: [:l3])
if System.get_env("SHORASHIM_L3") do
{:ok, _} = VavPipeline.Repo.start_link()
Ecto.Adapters.SQL.Sandbox.mode(VavPipeline.Repo, :manual)
end