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:
1 parent
adc3e41679
commit
d34e2799ac
14 files changed
+722
-3
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user