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>
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/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
|