Files
bikoret/vav_pipeline/lib/vav_pipeline/talmudic_engine_socket.ex
T
Scott KarpandClaude Fable 5.1 b8522ea098 Tablet 50: a socket reply is one line, however long — and a failed retry closes its socket
Both Unix-socket clients (Korban Processor, Talmudic Engine) read a
reply with a single :gen_tcp.recv/3, which returns at most one receive
buffer — 1460 bytes, measured — whatever the packet mode. A bedikah of
forty claims answered in 18 KB: the client decoded the first 1460
bytes, failed, reconnected, failed again, and returned an error with
the retry's socket still open. The single-threaded Korban Processor
then sat on that dead conversation and every later bedikah, however
small, timed out at 15 s. Live, for every caller, from the first long
output.

Clients now read until the newline (recv_line/2, 16 MiB ceiling) in
raw mode, and close the retry's socket on failure. The Korban
Processor drops a connection that says nothing for 120 s, so one
stuck client can no longer hold the door. tablet50_socket_line_test
stands up a 60 KB-line server and asks both clients. Elixir 552/0
(L3 on and off), korban 280.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 22:46:54 +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/shorashim/talmudic.sock (dev)
/var/run/shorashim/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/shorashim/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