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>
238 lines
7.3 KiB
Elixir
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
|