Files
bikoret/vav_pipeline/lib/vav_pipeline/talmudic_engine_socket.ex
T
Scott KarpandClaude Fable 5.1 c0d0555aed the name: Bikoret — code, contracts, images
Headers X-Bikoret-Key / -Rebuke / -Verdict / -Bedikah; the `bikoret`
object and the "— bikoret …" fringe on a gate reply; environment
variables BIKORET_*; the image user and paths (/home/bikoret,
/tmp/bikoret, /var/lib/bikoret/manna, /etc/bikoret/secrets,
/opt/bikoret/spec); the tmp-bikoret pod volume; local databases
bikoret_dev/_test. Kubernetes resource names, the shorashim_app role and
the domain are unchanged (Tamid §1.1, "the names that still carry the
old word"). Doorpost c963c4fe…. Elixir 604/0, L3 10/0, korban 295,
Prolog bedikah 44/44. Gitea repo renamed heaviness510/bikoret (old URL
redirects).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-13 22:13:36 +00:00

238 lines
7.3 KiB
Elixir

defmodule VavPipeline.TalmudicEngineSocket do
@moduledoc """
Unix domain socket client to the SWI-Prolog Talmudic Engine.
Step 14 (Architecture §4): "Connect components: routes to
Prolog reasoning."
Construction spec §3.3: JSON over Unix domain socket.
Socket: /tmp/bikoret/talmudic.sock (dev)
/var/run/bikoret/talmudic.sock (production)
Connection model: connect, send one JSON line, read one JSON
line response, keep connection for subsequent requests.
Reconnects on connection loss.
"""
use GenServer
require Logger
@default_socket_path "/tmp/bikoret/talmudic.sock"
@call_timeout 10_000
@connect_timeout 5_000
# --- Client API ---
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc """
Send an interpret request to the Talmudic Engine.
Construction spec §3.3 — action "interpret".
"""
@spec interpret(String.t(), map()) :: {:ok, map()} | {:error, String.t()}
def interpret(case_ref, context) do
request = build_request("interpret", case_ref, context)
call_engine(request)
end
@doc """
Send a challenge request to the Talmudic Engine.
Construction spec §3.3 — action "challenge".
"""
@spec challenge(String.t(), map()) :: {:ok, map()} | {:error, String.t()}
def challenge(case_ref, context) do
request = build_request("challenge", case_ref, context)
call_engine(request)
end
@doc """
Send a resolve request to the Talmudic Engine.
Construction spec §3.3 — action "resolve".
"""
@spec resolve(String.t(), map()) :: {:ok, map()} | {:error, String.t()}
def resolve(case_ref, context) do
request = build_request("resolve", case_ref, context)
call_engine(request)
end
@doc """
Send a halt_check request to the Talmudic Engine.
"""
@spec halt_check(map()) :: {:ok, map()} | {:error, String.t()}
def halt_check(action) do
request = Jason.encode!(%{
"action" => "halt_check",
"check" => action
})
call_engine(request)
end
@doc """
Halt-check a whole action TERM (the bedikah, Construction §3.9) — e.g.
`action(delete, target(user(u)), from(korban))`. The engine reads the
term and runs all three action-shaped halt rules on it.
"""
@spec halt_check_term(String.t()) :: {:ok, map()} | {:error, String.t()}
def halt_check_term(term) when is_binary(term) do
call_engine(Jason.encode!(%{"action" => "halt_check", "term" => term}))
end
@doc """
Halt-check an interpretation by its coupling trace (golden calf and
self-grounding): `ref`, `confidence` ("high" | "low"), and the trace as
a list of coupling terms, e.g. `coupled(root(אהב), verse(devarim, 6, 5))`.
`ground` is `"l1"` (the engine's own rules: only L1 primitives ground)
or `"given"` (the bedikah: the sources the output was given ground it
too — avodah_zarah.pl, forms 2 and 4 for an external output).
"""
@spec halt_check_interpretation(String.t(), String.t(), [String.t()], String.t()) ::
{:ok, map()} | {:error, String.t()}
def halt_check_interpretation(ref, confidence, coupling_trace, ground \\ "l1") do
call_engine(
Jason.encode!(%{
"action" => "halt_check_interpretation",
"interpretation" => %{
"ref" => ref,
"confidence" => confidence,
"coupling_trace" => coupling_trace,
"ground" => ground
}
})
)
end
# --- GenServer callbacks ---
@impl true
def init(opts) do
socket_path = Keyword.get(opts, :socket_path, @default_socket_path)
{:ok, %{socket_path: socket_path, socket: nil}}
end
@impl true
def handle_call({:request, json_line}, _from, state) do
case ensure_connected(state) do
{:ok, state} ->
case send_and_receive(state.socket, json_line) do
{:ok, response} ->
{:reply, {:ok, response}, state}
{:error, reason} ->
Logger.warning("TalmudicEngineSocket: send/receive failed: #{inspect(reason)}")
# Connection broken — close and retry once
:gen_tcp.close(state.socket)
state = %{state | socket: nil}
case ensure_connected(state) do
{:ok, state} ->
case send_and_receive(state.socket, json_line) do
{:ok, response} -> {:reply, {:ok, response}, state}
{:error, reason2} ->
# Close the retry's socket too (tablet 50): left open it
# holds the engine on a dead conversation.
:gen_tcp.close(state.socket)
{:reply, {:error, "Retry failed: #{inspect(reason2)}"}, %{state | socket: nil}}
end
{:error, reason2} ->
{:reply, {:error, "Reconnect failed: #{inspect(reason2)}"}, state}
end
end
{:error, reason} ->
{:reply, {:error, "Cannot connect: #{inspect(reason)}"}, state}
end
end
@impl true
def terminate(_reason, state) do
if state.socket do
:gen_tcp.close(state.socket)
end
:ok
end
# --- Private ---
defp ensure_connected(%{socket: nil, socket_path: path} = state) do
case :gen_tcp.connect({:local, path}, 0, [:binary, active: false, packet: :raw], @connect_timeout) do
{:ok, socket} ->
{:ok, %{state | socket: socket}}
{:error, reason} ->
{:error, reason}
end
end
defp ensure_connected(state), do: {:ok, state}
defp send_and_receive(socket, json_line) do
case :gen_tcp.send(socket, json_line <> "\n") do
:ok ->
case recv_line(socket, @call_timeout) do
{:ok, data} ->
line = String.trim_trailing(data, "\n")
case Jason.decode(line) do
{:ok, parsed} -> {:ok, parsed}
{:error, err} -> {:error, "JSON decode: #{inspect(err)}"}
end
{:error, reason} ->
{:error, reason}
end
{:error, reason} ->
{:error, reason}
end
end
defp build_request(action, case_ref, context) do
roots = Map.get(context, :context_roots, [])
question = Map.get(context, :question, "")
Jason.encode!(%{
"action" => action,
"case" => %{
"ref" => case_ref,
"question" => question,
"context_roots" => roots
}
})
end
defp call_engine(json_line) do
GenServer.call(__MODULE__, {:request, json_line}, @call_timeout)
end
# One reply is one line. `:gen_tcp.recv/3` hands back at most one
# receive buffer (1460 bytes by default — measured, 2026-10-06, tablet
# 50) whatever the packet mode, so a reply longer than that arrived in
# pieces, the first piece failed to decode, and the client reconnected
# mid-conversation; every later call timed out. Read until the newline.
@max_line_bytes 16_777_216
defp recv_line(socket, timeout), do: recv_line(socket, timeout, [], 0)
defp recv_line(_socket, _timeout, _acc, size) when size > @max_line_bytes,
do: {:error, :reply_too_long}
defp recv_line(socket, timeout, acc, size) do
case :gen_tcp.recv(socket, 0, timeout) do
{:ok, chunk} ->
if String.ends_with?(chunk, "\n") do
{:ok, IO.iodata_to_binary(Enum.reverse([chunk | acc]))}
else
recv_line(socket, timeout, [chunk | acc], size + byte_size(chunk))
end
{:error, reason} ->
{:error, reason}
end
end
end