l3: validate coupling_trace at the write boundary (Elixir half of finding L)
Companion commit tob254ee9(Prolog Form 4 self_grounding) and6b96250(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>
This commit is contained in:
1 parent
5c76a6f42c
commit
b1d5ac69e4
4 files changed
+388
-4
No files matched your search
@@ -0,0 +1,162 @@
|
||||
defmodule VavPipeline.L3.CouplingTrace do
|
||||
@moduledoc """
|
||||
Elixir-side validator for coupling_trace strings written to L3.
|
||||
|
||||
L3 stores coupling traces as a list of strings (schema:
|
||||
`{:array, :string}`), serialized from the Prolog term form. The
|
||||
Prolog halt rules — avodah_zarah.pl Form 4 self_grounding, added
|
||||
in commit b254ee9 — validate Prolog terms; they cannot re-validate
|
||||
inert string traces sitting in L3.
|
||||
|
||||
This module closes that gap. It is the Elixir-side guard that fires
|
||||
at the L3 write boundary. Any trace persisted to oral tradition is
|
||||
validated against the same L1-anchored schema that Prolog enforces,
|
||||
at write time. A trace string whose anchor is `prior_decision(_)`,
|
||||
`system_view(_)`, `consensus(_)`, or `interpretation(_)` will be
|
||||
refused by `L3.Store.record_interpretation/1` — preventing the
|
||||
fake-tzivah pattern (eish_zarah Form 4) from contaminating L3.
|
||||
|
||||
Recognized coupling shapes (must mirror is_l1_grounded_coupling/1
|
||||
in talmudic_engine/src/halt/avodah_zarah.pl):
|
||||
|
||||
coupled(_, verse(BOOK, CHAPTER, VERSE))
|
||||
coupled(_, mitzvah(ID))
|
||||
coupled(_, mitzvah(ID), via_verse(BOOK, CHAPTER, VERSE))
|
||||
|
||||
Adding a new legitimate coupling shape requires adding a clause to
|
||||
`l1_grounded?/1` here AND to is_l1_grounded_coupling/1 in Prolog.
|
||||
Both must move together; CouplingTrace tests will catch drift the
|
||||
next time they run.
|
||||
|
||||
WHY THIS LIVES AT THE WRITE BOUNDARY (not at read time):
|
||||
|
||||
L3 is append-only. Once a trace is written, it is permanent oral
|
||||
tradition — there is no path to retroactive correction. A reader
|
||||
who pulls an L3 record and trusts the trace must be able to trust
|
||||
that the trace was valid at the moment it became permanent. So the
|
||||
validation must fire BEFORE Repo.insert, not after Repo.get.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Validate a list of coupling-trace strings against the L1-anchored
|
||||
schema. Returns :ok if every entry matches, or
|
||||
{:error, :ungrounded, offending_string} on the first failure.
|
||||
|
||||
Empty traces are allowed at this layer. The empty-trace case is
|
||||
the golden_calf halt's domain (Prolog Form 2 in avodah_zarah.pl);
|
||||
L3 may legitimately store interpretations with empty traces (e.g.
|
||||
case-resolution records that don't carry a Talmudic-engine-derived
|
||||
trace).
|
||||
"""
|
||||
@spec validate([String.t()]) :: :ok | {:error, :ungrounded, String.t()}
|
||||
def validate(traces) when is_list(traces) do
|
||||
case Enum.find(traces, fn t -> not l1_grounded?(t) end) do
|
||||
nil -> :ok
|
||||
bad -> {:error, :ungrounded, bad}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
True iff a single coupling-trace string matches one of the
|
||||
recognized L1-anchored shapes.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> VavPipeline.L3.CouplingTrace.l1_grounded?("coupled(root(בער), verse(shemot, 35, 3))")
|
||||
true
|
||||
|
||||
iex> VavPipeline.L3.CouplingTrace.l1_grounded?("coupled(domain(creation), mitzvah(5))")
|
||||
true
|
||||
|
||||
iex> VavPipeline.L3.CouplingTrace.l1_grounded?("coupled(root(shm), mitzvah(7), via_verse(devarim, 6, 4))")
|
||||
true
|
||||
|
||||
iex> VavPipeline.L3.CouplingTrace.l1_grounded?("coupled(self, prior_decision(d1))")
|
||||
false
|
||||
|
||||
iex> VavPipeline.L3.CouplingTrace.l1_grounded?("coupled(consensus, system_view(s1))")
|
||||
false
|
||||
"""
|
||||
@spec l1_grounded?(String.t()) :: boolean
|
||||
def l1_grounded?(coupling_str) when is_binary(coupling_str) do
|
||||
case parse_coupled(String.trim(coupling_str)) do
|
||||
{:ok, [_subject, anchor]} ->
|
||||
starts_with_token?(anchor, "verse") or starts_with_token?(anchor, "mitzvah")
|
||||
|
||||
{:ok, [_subject, mitzvah, via]} ->
|
||||
starts_with_token?(mitzvah, "mitzvah") and starts_with_token?(via, "via_verse")
|
||||
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def l1_grounded?(_), do: false
|
||||
|
||||
# --- Parser ---
|
||||
#
|
||||
# The string form is `coupled(arg1, arg2[, arg3])`. The SUBJECT
|
||||
# (arg1) can carry nested parens (e.g. root(בער), domain(creation));
|
||||
# the anchor positions also do (verse(b,c,v), mitzvah(id),
|
||||
# via_verse(b,c,v)). So a naive comma-split breaks. We need
|
||||
# balanced-paren splitting at depth 0.
|
||||
|
||||
@spec parse_coupled(String.t()) :: {:ok, [String.t()]} | :error
|
||||
defp parse_coupled(str) do
|
||||
# NOTE: must use binary_part/3 (not String.slice/3) when chopping the
|
||||
# trailing `)`. String.slice takes a codepoint length, but `)` is one
|
||||
# byte — passing byte_size as a codepoint length cuts mid-character on
|
||||
# multi-byte UTF-8 (Hebrew root subjects produce exactly that bug).
|
||||
with "coupled(" <> rest <- str,
|
||||
true <- String.ends_with?(rest, ")"),
|
||||
inner <- binary_part(rest, 0, byte_size(rest) - 1),
|
||||
{:ok, args} <- split_top_commas(inner) do
|
||||
args = Enum.map(args, &String.trim/1)
|
||||
cond do
|
||||
length(args) in [2, 3] -> {:ok, args}
|
||||
true -> :error
|
||||
end
|
||||
else
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
@spec split_top_commas(String.t()) :: {:ok, [String.t()]} | :error
|
||||
defp split_top_commas(str) do
|
||||
do_split(String.to_charlist(str), 0, [], [])
|
||||
end
|
||||
|
||||
defp do_split([], 0, acc, parts) do
|
||||
{:ok, Enum.reverse([flush(acc) | parts])}
|
||||
end
|
||||
|
||||
defp do_split([], _depth, _acc, _parts), do: :error
|
||||
|
||||
defp do_split([?, | rest], 0, acc, parts) do
|
||||
do_split(rest, 0, [], [flush(acc) | parts])
|
||||
end
|
||||
|
||||
defp do_split([?( | rest], depth, acc, parts) do
|
||||
do_split(rest, depth + 1, [?( | acc], parts)
|
||||
end
|
||||
|
||||
defp do_split([?) | _rest], 0, _acc, _parts), do: :error
|
||||
|
||||
defp do_split([?) | rest], depth, acc, parts) do
|
||||
do_split(rest, depth - 1, [?) | acc], parts)
|
||||
end
|
||||
|
||||
defp do_split([c | rest], depth, acc, parts) do
|
||||
do_split(rest, depth, [c | acc], parts)
|
||||
end
|
||||
|
||||
defp flush(acc), do: acc |> Enum.reverse() |> List.to_string()
|
||||
|
||||
# True iff `str` is `<token>(...)` — i.e. starts with the named
|
||||
# functor followed by an open paren. Tolerates leading whitespace
|
||||
# because args may have been split with surrounding spaces.
|
||||
@spec starts_with_token?(String.t(), String.t()) :: boolean
|
||||
defp starts_with_token?(str, token) do
|
||||
String.starts_with?(String.trim_leading(str), token <> "(")
|
||||
end
|
||||
end
|
||||
@@ -18,7 +18,7 @@ defmodule VavPipeline.L3.Store do
|
||||
"""
|
||||
|
||||
alias VavPipeline.Repo
|
||||
alias VavPipeline.L3.{Interpretation, MinorityOpinion, TeikuRecord}
|
||||
alias VavPipeline.L3.{CouplingTrace, Interpretation, MinorityOpinion, TeikuRecord}
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
@@ -27,11 +27,33 @@ defmodule VavPipeline.L3.Store do
|
||||
|
||||
Accepts a map with the fields from the Interpretation schema.
|
||||
Returns {:ok, interpretation} or {:error, changeset}.
|
||||
|
||||
Coupling-trace gate: if attrs[:coupling_trace] (or
|
||||
attrs["coupling_trace"]) is present, every entry is validated
|
||||
against the L1-anchored schema (see VavPipeline.L3.CouplingTrace).
|
||||
An ungrounded entry returns
|
||||
{:error, {:ungrounded_coupling, offending_string}} BEFORE any
|
||||
database write — L3 is append-only, so a trace whose anchor does
|
||||
not terminate in L1 must never become permanent oral tradition.
|
||||
This is the Elixir-side mirror of the Prolog Form 4 self_grounding
|
||||
halt (see talmudic_engine/src/halt/avodah_zarah.pl).
|
||||
"""
|
||||
def record_interpretation(attrs) when is_map(attrs) do
|
||||
%Interpretation{}
|
||||
|> Interpretation.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
case CouplingTrace.validate(extract_coupling_trace(attrs)) do
|
||||
:ok ->
|
||||
%Interpretation{}
|
||||
|> Interpretation.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
|
||||
{:error, :ungrounded, bad} ->
|
||||
{:error, {:ungrounded_coupling, bad}}
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_coupling_trace(attrs) do
|
||||
Map.get(attrs, :coupling_trace) ||
|
||||
Map.get(attrs, "coupling_trace") ||
|
||||
[]
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
||||
Reference in new issue
Block a user