Files
bikoret/vav_pipeline/test/l3_store_test.exs
T
Scott KarpandClaude Opus 4.7 b1d5ac69e4 l3: validate coupling_trace at the write boundary (Elixir half of finding L)
Companion commit to b254ee9 (Prolog Form 4 self_grounding) and 6b96250
(Prolog regression canary). Closes the Elixir half of Pass 2 finding L.

THE GAP:

The Prolog halt rules validate coupling traces as Prolog terms: a
trace whose anchor is prior_decision/1, system_view/1, consensus/1,
or interpretation/1 fires the self_grounding halt. But L3 stores
traces as a list of strings (schema: {:array, :string}), serialized
from Prolog. Inert string traces in L3 cannot be re-checked by Prolog
halts. So the safety property the Prolog rules enforce stops at the
Erlang/Elixir boundary — once a trace serializes into L3, no rule
re-validates it.

Worse: L3 is APPEND-ONLY (Architecture §3.7). Once a trace is in L3
it is permanent oral tradition; there is no path to retroactive
correction. A future reader who pulls an L3 record and trusts the
trace must be able to trust it was valid at the moment it became
permanent.

THE FIX:

VavPipeline.L3.CouplingTrace (new) — Elixir-side validator that
mirrors is_l1_grounded_coupling/1 in talmudic_engine/src/halt/
avodah_zarah.pl. Parses the string form (with a small balanced-paren
parser so nested subjects like root(בער) don't break splitting) and
recognizes exactly the three valid shapes:

    coupled(_, verse(BOOK, CHAPTER, VERSE))
    coupled(_, mitzvah(ID))
    coupled(_, mitzvah(ID), via_verse(BOOK, CHAPTER, VERSE))

Anything else fails. Empty traces are allowed at this layer (the
empty-trace case is the golden_calf halt's domain in Prolog Form 2;
L3 may legitimately store empty-trace records).

L3.Store.record_interpretation/1 — gated. The new write path:

    case CouplingTrace.validate(coupling_trace) do
      :ok -> %Interpretation{} |> changeset(attrs) |> Repo.insert()
      {:error, :ungrounded, bad} -> {:error, {:ungrounded_coupling, bad}}
    end

Validation fires BEFORE Repo.insert. An ungrounded trace can never
become permanent oral tradition.

CROSS-LANGUAGE INVARIANT:

CouplingTrace.l1_grounded?/1 (Elixir) and is_l1_grounded_coupling/1
(Prolog) MUST stay aligned. If Prolog adds a new recognized shape,
Elixir must add it too. Both module docstrings name this dependency
explicitly. The next time CouplingTraceTest runs after a Prolog
schema change, the Elixir tests catch the drift.

ONE BUG CAUGHT WHILE WRITING:

The first parser cut Hebrew root subjects mid-character because it
used String.slice/3 with byte_size/1 as the codepoint length. Fixed
to use binary_part/3 (since the trailing `)` is always one byte and
the rest is opaque UTF-8). Test for Hebrew-root anchors now passes;
documented inline so the trap is visible to future readers.

TESTS:

  test/coupling_trace_test.exs (new, 22 tests):
    - all 3 valid shapes (verse, mitzvah, via_verse) including
      Hebrew root subject (catches the byte/codepoint bug)
    - all the predictable ungrounded shapes
    - malformed inputs (missing parens, wrong functor, wrong arity,
      empty string, non-string)
    - validate/1 list semantics (empty allowed, first-failure
      reported, mixed-trace caught)
    - parser robustness with deeply-nested subject parens

  test/l3_store_test.exs (extended, 5 new :l3-tagged tests):
    - record_interpretation refuses ungrounded trace
    - record_interpretation refuses mixed (good + bad) trace
    - record_interpretation accepts empty trace
    - record_interpretation accepts mitzvah-anchored trace
    - record_interpretation accepts 3-arg via_verse trace

  Full vav_pipeline suite: 315 tests, 0 failures, 24 excluded
  (24 = 19 pre-existing :l3 + 5 new :l3 store-gate tests; both
  need PostgreSQL). The 22 CouplingTrace unit tests run
  unconditionally and all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:26:49 -04:00

305 lines
11 KiB
Elixir

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
# === COUPLING_TRACE GATE (Pass 2 finding L, Elixir half) ===
# Mirrors the Prolog Form 4 self_grounding halt at the L3 write
# boundary. L3 is append-only — once a trace is written it is
# permanent. Validation MUST fire before Repo.insert so a trace whose
# anchor is not L1-grounded never becomes oral tradition.
test "record_interpretation refuses ungrounded coupling trace (prior_decision)" do
attrs =
Map.put(@valid_interpretation, :coupling_trace, [
"coupled(self, prior_decision(d1))"
])
assert {:error, {:ungrounded_coupling, "coupled(self, prior_decision(d1))"}} =
Store.record_interpretation(attrs)
end
test "record_interpretation refuses mixed trace (one valid + one ungrounded)" do
attrs =
Map.put(@valid_interpretation, :coupling_trace, [
"coupled(root(brk), verse(bereishit, 1, 1))",
"coupled(consensus, system_view(s1))"
])
assert {:error, {:ungrounded_coupling, "coupled(consensus, system_view(s1))"}} =
Store.record_interpretation(attrs)
end
test "record_interpretation accepts empty coupling trace (golden_calf's domain, not Form 4)" do
attrs = Map.put(@valid_interpretation, :coupling_trace, [])
assert {:ok, %Interpretation{coupling_trace: []}} = Store.record_interpretation(attrs)
end
test "record_interpretation accepts mitzvah-anchored trace (Rambam-derived)" do
attrs =
Map.put(@valid_interpretation, :coupling_trace, [
"coupled(domain(creation), mitzvah(5))"
])
assert {:ok, %Interpretation{coupling_trace: ["coupled(domain(creation), mitzvah(5))"]}} =
Store.record_interpretation(attrs)
end
test "record_interpretation accepts via_verse 3-arg trace" do
attrs =
Map.put(@valid_interpretation, :coupling_trace, [
"coupled(root(shm), mitzvah(7), via_verse(devarim, 6, 4))"
])
assert {:ok, %Interpretation{}} = 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