Step 14: Connect components — chamishim karsay zahav

Three connections built and tested:
- Haskell Shoresh Engine via Erlang Port (JSON lines over stdin/stdout)
- Prolog Talmudic Engine via Unix domain socket (JSON over /tmp/shorashim/talmudic.sock)
- Erlang Sanhedrin via native BEAM calls

New: Server.hs (JSON-line server loop), json_bridge.pl + socket_server.pl
(Prolog socket interface), ShoreshEnginePort + TalmudicEngineSocket +
SanhedrinClient (Elixir GenServer wrappers). BereishitCycle now routes
unresolved cases through Talmudic Engine and Sanhedrin. All connections
gracefully degrade — pipeline falls back to L1 data when engines unavailable.

46 Elixir tests, 33 Haskell tests, 57 Prolog tests — 0 failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott KarpandClaude Opus 4.6 committed 2026-03-12 17:39:21 -04:00
1 parent f8f70c77e5
commit c67672777b
18 files changed
+1427 -113

No files matched your search

+1
View File
@@ -32,6 +32,7 @@ library
Shoresh.Havdalah
Shoresh.Kriah
Shoresh.KiTov
Shoresh.Server
Luchot.Firmware
hs-source-dirs: src
default-language: Haskell2010
+3
View File
@@ -27,6 +27,7 @@ import Shoresh.Teamim
import Shoresh.KetivQere
import Shoresh.Lexicon
import Shoresh.Graph
import Shoresh.Server (runServer)
import Luchot.Firmware
main :: IO ()
@@ -34,6 +35,7 @@ main = do
args <- getArgs
case args of
["verify"] -> runVerification
["server"] -> runServer
["verse", book, ch, vs] -> runVerse book ch vs
["root", rootText] -> runRoot rootText
["gematria", word] -> runGematria word
@@ -45,6 +47,7 @@ printUsage = do
putStrLn ""
putStrLn "Usage:"
putStrLn " shoresh-engine verify Run Step 7 verification"
putStrLn " shoresh-engine server JSON-line server (Erlang Port mode)"
putStrLn " shoresh-engine verse <book> <ch> <vs> Read and parse a verse from L1"
putStrLn " shoresh-engine root <root> Activate a root through all 7 binyanim"
putStrLn " shoresh-engine gematria <word> Compute gematria of a Hebrew word"
+2
View File
@@ -79,6 +79,7 @@ data L1Word = L1Word
, l1wShoresh :: !(Maybe Text)
} deriving (Show, Generic)
instance ToJSON L1Word
instance FromJSON L1Word where
parseJSON = withObject "L1Word" $ \v -> L1Word
<$> v .: "position"
@@ -100,6 +101,7 @@ data L1Verse = L1Verse
, l1vWords :: ![L1Word]
} deriving (Show, Generic)
instance ToJSON L1Verse
instance FromJSON L1Verse where
parseJSON = withObject "L1Verse" $ \v -> L1Verse
<$> v .: "ref"
+301
View File
@@ -0,0 +1,301 @@
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Server — JSON-line port interface for pipeline integration.
--
-- Step 14 (Architecture §4): "Connect components: Elixir pipeline
-- calls Haskell engine."
--
-- Construction spec §3.1: Protocol is Erlang Port (JSON).
-- The engine reads one JSON line from stdin, processes it,
-- writes one JSON line to stdout, and repeats.
--
-- Buffering is set to LineBuffering — critical for Erlang Port
-- communication. Haskell defaults to block-buffering on non-TTY
-- stdout, which would deadlock the port.
module Shoresh.Server
( runServer
, handleRequest
) where
import qualified Data.ByteString.Char8 as BS8
import qualified Data.ByteString.Lazy as BL
import qualified Data.ByteString.Lazy.Char8 as BLC
import qualified Data.Map.Strict as Map
import Data.Text (Text)
import qualified Data.Text as T
import Data.Aeson
import Data.Maybe (mapMaybe)
import System.IO (hSetBuffering, stdin, stdout, stderr,
BufferMode(..), hFlush, hIsEOF)
import System.Directory (doesFileExist)
import Control.Exception (catch, SomeException)
import Shoresh.Types
import Shoresh.Gematria
import Shoresh.Binyanim
import Shoresh.Teamim
import Shoresh.Lexicon
-- | Run the JSON-line server loop.
-- Called by Main.hs when invoked with "server" argument.
runServer :: IO ()
runServer = do
-- Critical: set line buffering on stdin/stdout.
-- Haskell defaults to block-buffering on non-TTY, which would
-- deadlock the Erlang Port.
hSetBuffering stdin LineBuffering
hSetBuffering stdout LineBuffering
hSetBuffering stderr LineBuffering
serverLoop
-- | Main server loop: read line, process, write line, repeat.
serverLoop :: IO ()
serverLoop = do
eof <- hIsEOF stdin
if eof
then return ()
else do
line <- BL.fromStrict <$> BS8.getLine
response <- processLine line
BLC.putStrLn (encode response)
hFlush stdout
serverLoop
-- | Process a single JSON line and return a response.
processLine :: BL.ByteString -> IO Value
processLine line = case eitherDecode line of
Left err -> return $ errorResponse "PARSE_ERROR" (T.pack err)
Right req -> handleRequest req
`catch` \e -> return $ errorResponse "INTERNAL_ERROR"
(T.pack $ show (e :: SomeException))
-- | Handle a parsed request and produce a response.
handleRequest :: Value -> IO Value
handleRequest val = case parseOperation val of
Nothing -> return $ errorResponse "INVALID_OPERATION" "Missing or invalid 'operation' field"
Just "process" -> handleProcess val
Just "separate" -> handleSeparate val
Just "name" -> handleName val
Just "evaluate" -> handleEvaluate val
Just op -> return $ errorResponse "UNKNOWN_OPERATION" ("Unknown operation: " <> op)
-- | Extract the operation string from a request.
parseOperation :: Value -> Maybe Text
parseOperation (Object obj) = case obj .: "operation" of
Success t -> Just t
_ -> Nothing
where
(.:) o k = case Map.lookup k (toMap o) of
Just (String s) -> Success s
_ -> Error "not found"
toMap = \o -> case fromJSON (Object o) :: Result (Map.Map Text Value) of
Success m -> m
Error _ -> Map.empty
parseOperation _ = Nothing
-- | Handle "process" operation — root activation, te'amim parse, gematria.
-- Construction spec §3.1: Takes text + scope, returns roots_activated,
-- parse_tree, dual_readings.
handleProcess :: Value -> IO Value
handleProcess val = do
let reqText = extractText val
mscope = extractScope val
-- Parse roots from input text
let roots = extractRoots reqText
-- Build activated roots with gematria and binyanim
let activatedRoots = map activateRoot roots
-- If scope is provided, load verse from L1 and parse te'amim
(parseTree, dualReadings) <- case mscope of
Nothing -> return (Null, toJSON ([] :: [Value]))
Just (book, ch, vs) -> do
let path = "l1/torah/" ++ T.unpack (bookToText book)
++ "/" ++ show ch ++ "/" ++ show vs ++ ".json"
exists <- doesFileExist path
if not exists
then return (Null, toJSON ([] :: [Value]))
else do
mverse <- loadL1Verse path
case mverse of
Nothing -> return (Null, toJSON ([] :: [Value]))
Just verse -> do
let annotated = mapMaybe annotateL1Word (l1vWords verse)
tree = readTeamim annotated
ptJson = treeToJson tree
drJson = toJSON ([] :: [Value]) -- Phase 3: extract ketiv/qere
return (ptJson, drJson)
return $ object
[ "status" .= ("ok" :: Text)
, "result" .= object
[ "roots_activated" .= activatedRoots
, "parse_tree" .= parseTree
, "dual_readings" .= dualReadings
]
]
-- | Handle "separate" operation — te'amim-based separation.
-- Construction spec §3.1 operation "separate".
handleSeparate :: Value -> IO Value
handleSeparate _val = do
-- Phase 2: returns stub separation
-- Full implementation requires the annotated words from context
return $ object
[ "status" .= ("ok" :: Text)
, "result" .= object
[ "groups" .= ([] :: [Value])
]
]
-- | Handle "name" operation — assign names to categories.
handleName :: Value -> IO Value
handleName _val = do
return $ object
[ "status" .= ("ok" :: Text)
, "result" .= object
[ "name" .= ("unnamed" :: Text)
]
]
-- | Handle "evaluate" operation — ki tov check.
handleEvaluate :: Value -> IO Value
handleEvaluate _val = do
return $ object
[ "status" .= ("ok" :: Text)
, "result" .= object
[ "evaluation" .= ("teiku" :: Text)
]
]
-- ============================================================
-- Helpers
-- ============================================================
-- | Extract text from the input field of a request.
extractText :: Value -> Text
extractText (Object obj) = case Map.lookup "input" (toMap obj) of
Just (Object inp) -> case Map.lookup "text" (toMap inp) of
Just (String t) -> t
_ -> ""
_ -> ""
where
toMap o = case fromJSON (Object o) :: Result (Map.Map Text Value) of
Success m -> m
Error _ -> Map.empty
extractText _ = ""
-- | Extract scope (book, chapter, verse) from the input field.
extractScope :: Value -> Maybe (Book, Int, Int)
extractScope (Object obj) = do
inp <- case Map.lookup "input" (toMap obj) of
Just (Object i) -> Just (toMap i)
_ -> Nothing
scopeObj <- case Map.lookup "scope" inp of
Just (Object s) -> Just (toMap s)
_ -> Nothing
bookVal <- Map.lookup "book" scopeObj
bookText <- case bookVal of
String s -> Just s
_ -> Nothing
book <- textToBook bookText
ch <- case Map.lookup "chapter" scopeObj of
Just (Number n) -> Just (floor n)
_ -> Nothing
vs <- case Map.lookup "verse" scopeObj of
Just (Number n) -> Just (floor n)
_ -> Nothing
return (book, ch, vs)
where
toMap o = case fromJSON (Object o) :: Result (Map.Map Text Value) of
Success m -> m
Error _ -> Map.empty
extractScope _ = Nothing
-- | Extract Hebrew roots from input text.
extractRoots :: Text -> [Shoresh]
extractRoots t =
let words' = T.words t
-- Try each word as a potential root
candidates = mapMaybe textToShoresh words'
-- Also try the whole text as a single root
wholeRoot = textToShoresh (T.filter (\c -> c >= '\x05D0' && c <= '\x05EA') t)
in case candidates of
[] -> case wholeRoot of
Just r -> [r]
Nothing -> []
rs -> rs
-- | Activate a root: expand through binyanim, compute gematria, find neighbors.
activateRoot :: Shoresh -> Value
activateRoot sh =
let expanded = expand sh
gv = gematriaRoot sh
consonantNames = map consonantName [c1 sh, c2 sh, c3 sh]
defaultBinyan = case Map.lookup Qal expanded of
Just p -> T.pack (show (Shoresh.Types.binyan p))
Nothing -> "Qal"
in object
[ "shoresh" .= consonantNames
, "shoresh_hebrew" .= shoreshToText sh
, "binyan" .= defaultBinyan
, "domain" .= ("unknown" :: Text) -- Phase 3: domain lookup
, "torah_refs" .= ([] :: [Value]) -- Phase 3: lexicon lookup
, "gematria_value" .= gv
, "gematria_neighbors" .= ([] :: [Value]) -- Phase 3: lexicon needed
, "neighbors" .= ([] :: [Value]) -- Phase 3: lexicon needed
]
-- | Convert an L1 word to an AnnotatedWord for te'amim parsing.
annotateL1Word :: L1Word -> Maybe AnnotatedWord
annotateL1Word w = case l1wTeamim w of
[] -> Nothing
(t:_) -> case taamFromName t of
Nothing -> Nothing
Just taam -> Just AnnotatedWord
{ awWord = l1wText w
, awTaam = taam
, awRole = taamRole taam
}
-- | Convert a ParsedVerse tree to JSON.
treeToJson :: ParsedVerse -> Value
treeToJson (Leaf w) = object
[ "type" .= ("leaf" :: Text)
, "word" .= awWord w
, "taam" .= T.pack (show (awTaam w))
]
treeToJson (Branch t l r) = object
[ "type" .= ("branch" :: Text)
, "taam" .= T.pack (show t)
, "left" .= treeToJson l
, "right" .= treeToJson r
]
-- | Build an error response.
errorResponse :: Text -> Text -> Value
errorResponse code msg = object
[ "status" .= ("error" :: Text)
, "error" .= object
[ "code" .= code
, "message" .= msg
]
]
+18 -1
View File
@@ -49,7 +49,7 @@ import Data.Text (Text)
import qualified Data.Text as T
import Data.Map.Strict (Map)
import GHC.Generics (Generic)
import Data.Aeson (FromJSON(..), ToJSON(..), withText)
import Data.Aeson (FromJSON(..), ToJSON(..), FromJSONKey(..), ToJSONKey(..), withText)
-- | The 22 Hebrew consonants.
-- These are the building blocks. No more, no less.
@@ -145,6 +145,8 @@ data Binyan
instance ToJSON Binyan
instance FromJSON Binyan
instance ToJSONKey Binyan
instance FromJSONKey Binyan
-- | All seven binyanim as a list.
allBinyanim :: [Binyan]
@@ -298,6 +300,9 @@ data ShoreshEntry = ShoreshEntry
, seGlosses :: !(Map Binyan Text)
} deriving (Eq, Show, Generic)
instance ToJSON ShoreshEntry
instance FromJSON ShoreshEntry
-- | Raw input to the processing pipeline.
data Input = Input
@@ -305,18 +310,27 @@ data Input = Input
, inputSource :: !Text
} deriving (Show, Generic)
instance ToJSON Input
instance FromJSON Input
-- | A distinguished category after havdalah.
data Category = Category
{ catName :: !Text
, catContent :: !Text
} deriving (Show, Generic)
instance ToJSON Category
instance FromJSON Category
-- | A processed result ready for evaluation.
data ProcessedResult = ProcessedResult
{ prCategories :: ![Category]
, prRoots :: ![ShoreshEntry]
} deriving (Show, Generic)
instance ToJSON ProcessedResult
instance FromJSON ProcessedResult
-- | The evaluation outcome.
-- Tov: fits the specification.
-- LoTov: does not fit, with reason.
@@ -326,3 +340,6 @@ data Evaluation
| LoTov !Text
| Teiku
deriving (Eq, Show, Generic)
instance ToJSON Evaluation
instance FromJSON Evaluation
@@ -0,0 +1,194 @@
%% json_bridge.pl — JSON ↔ Prolog term conversion for Talmudic Engine.
%%
%% Construction §3.3: Bidirectional conversion between the JSON schema
%% defined in the spec and internal Prolog terms used by api.pl.
%%
%% Inbound: JSON dict → Prolog request term
%% Outbound: Prolog response term → JSON dict
:- module(json_bridge, [
json_to_request/2,
response_to_json/2,
parse_root_name/2
]).
:- use_module(library(json)).
:- use_module(library(lists)).
%% ============================================================
%% INBOUND: JSON → Prolog request
%% ============================================================
%% Convert a JSON dict (as parsed by SWI-Prolog's json library) to a
%% Prolog request term compatible with api:process_request/2.
%%
%% JSON format (Construction §3.3):
%% { "action": "interpret", "case": { "ref": "case-uuid",
%% "facts": [...], "question": "...", "context_roots": [...] } }
%%
%% Prolog format:
%% request(interpret, CaseRef, Domain, Roots, Question)
json_to_request(Dict, request(Action, CaseRef, Domain, Roots, Question)) :-
get_dict(action, Dict, ActionStr),
atom_string(Action, ActionStr),
get_dict(case, Dict, CaseDict),
get_dict(ref, CaseDict, RefStr),
atom_string(CaseRef, RefStr),
%% Extract question
( get_dict(question, CaseDict, QuestionStr)
-> atom_string(Question, QuestionStr)
; Question = none
),
%% Extract context_roots and derive domain
( get_dict(context_roots, CaseDict, RootNames)
-> maplist(parse_root_name, RootNames, Roots)
; Roots = []
),
%% Derive domain from roots (first root's known domain, or 'general')
( Roots = [FirstRoot|_],
root_to_domain(FirstRoot, D)
-> Domain = D
; Domain = general
).
%% Handle halt_check action (different format — no case, just action)
json_to_request(Dict, request(halt_check, Action)) :-
get_dict(action, Dict, "halt_check"),
get_dict(check, Dict, CheckDict),
get_dict(type, CheckDict, TypeStr),
atom_string(Type, TypeStr),
( get_dict(target, CheckDict, TargetStr)
-> atom_string(Target, TargetStr)
; Target = unknown
),
( get_dict(source, CheckDict, SourceStr)
-> atom_string(Source, SourceStr)
; Source = unknown
),
Action = action(Type, source(Source), target(Target)).
%% ============================================================
%% OUTBOUND: Prolog response → JSON
%% ============================================================
%% Convert a Prolog response from api.pl to a JSON-serializable dict.
%%
%% Prolog format: response(ok, interpreted, result(...))
%% JSON format (Construction §3.3):
%% { "status": "resolved", "interpretation": { ... } }
%% Interpreted response
response_to_json(response(ok, interpreted, result(ref(Ref), mitzvot(Mitzvot),
verses(Verses), reading(Reading),
confidence(Conf))),
Dict) :-
maplist(term_to_string, Mitzvot, MitzvotStrs),
maplist(term_to_string, Verses, VerseStrs),
term_to_string(Reading, ReadingStr),
atom_string(Conf, ConfStr),
atom_string(Ref, RefStr),
Dict = json{status: "resolved",
interpretation: json{text: ReadingStr,
prolog_form: RefStr,
grounding: MitzvotStrs,
verses: VerseStrs,
confidence: ConfStr},
challenge: null}.
%% Challenged response
response_to_json(response(ok, resolved_challenged,
result(interpretation(Interp), challenge(Kushya))),
Dict) :-
term_to_string(Interp, InterpStr),
term_to_string(Kushya, KushyaStr),
Dict = json{status: "challenged",
interpretation: json{text: InterpStr,
prolog_form: InterpStr,
grounding: [],
confidence: "medium"},
challenge: json{kushya: KushyaStr,
contradicting: "",
strength: "moderate"}}.
%% Teiku response
response_to_json(response(ok, teiku, result(record(Record))), Dict) :-
term_to_string(Record, RecordStr),
Dict = json{status: "teiku",
interpretation: json{text: RecordStr,
prolog_form: RecordStr,
grounding: [],
confidence: "low"},
challenge: null}.
%% No interpretation
response_to_json(response(error, no_interpretation, CaseRef), Dict) :-
atom_string(CaseRef, RefStr),
Dict = json{status: "teiku",
interpretation: json{text: "No interpretation found",
prolog_form: RefStr,
grounding: [],
confidence: "low"},
challenge: null}.
%% No resolution
response_to_json(response(error, no_resolution, CaseRef), Dict) :-
atom_string(CaseRef, RefStr),
Dict = json{status: "teiku",
interpretation: json{text: "No resolution found",
prolog_form: RefStr,
grounding: [],
confidence: "low"},
challenge: null}.
%% Halt response
response_to_json(response(halt, violations(Halts), action(Action)), Dict) :-
maplist(term_to_string, Halts, HaltStrs),
term_to_string(Action, ActionStr),
Dict = json{status: "halt",
violations: HaltStrs,
action: ActionStr}.
%% No halt
response_to_json(response(ok, no_halt, action(Action)), Dict) :-
term_to_string(Action, ActionStr),
Dict = json{status: "ok",
violations: [],
action: ActionStr}.
%% Catch-all for unexpected responses
response_to_json(Response, Dict) :-
term_to_string(Response, Str),
Dict = json{status: "error",
error: json{code: "UNEXPECTED_RESPONSE",
message: Str}}.
%% ============================================================
%% HELPERS
%% ============================================================
%% Parse a root name like "bet-resh-aleph" into an atom bet_resh_aleph
parse_root_name(Name, Atom) :-
( string(Name) -> S = Name
; atom_string(Name, S)
),
split_string(S, "-", "", Parts),
atomic_list_concat(Parts, '_', Atom).
%% Convert any Prolog term to a string
term_to_string(Term, Str) :-
term_string(Term, Str).
%% Map known roots to domains (basic mapping; will be extended)
root_to_domain(bet_resh_aleph, creation).
root_to_domain(yod_tsade_resh, creation).
root_to_domain(aleph_lamed_he, holiness).
root_to_domain(qof_dalet_shin, holiness).
root_to_domain(shin_pe_tet, judgment).
root_to_domain(dalet_yod_nun, judgment).
root_to_domain(chet_samekh_dalet, mercy).
root_to_domain(resh_chet_mem, mercy).
root_to_domain(aleph_vav_resh, light).
root_to_domain(chet_shin_kaf, darkness).
@@ -0,0 +1,145 @@
%% socket_server.pl — Unix domain socket listener for Talmudic Engine.
%%
%% Construction §3.3: "JSON over Unix domain socket"
%% Step 14 (Architecture §4): "Connect components"
%%
%% Listens on a Unix domain socket, accepts JSON requests,
%% dispatches to api:process_request/2 via json_bridge,
%% returns JSON responses.
%%
%% Protocol: One JSON object per line (same as Haskell server).
%% Connection model: accept, handle one or more requests per
%% connection, close when client disconnects.
:- module(socket_server, [
serve/0,
serve/1,
stop_server/0
]).
:- use_module(library(socket)).
:- use_module(library(json)).
:- use_module(library(readutil)).
:- use_module('../interface/api').
:- use_module('../interface/json_bridge').
%% Default socket path — dev path; production uses /var/run/shorashim/talmudic.sock
default_socket_path('/tmp/shorashim/talmudic.sock').
%% Dynamic flag to control server loop
:- dynamic server_running/1.
%% ============================================================
%% SERVER ENTRY POINTS
%% ============================================================
%% serve/0 — Start server on default socket path
serve :-
default_socket_path(Path),
serve(Path).
%% serve/1 — Start server on given socket path
serve(Path) :-
%% Clean up any stale socket file
( exists_file(Path)
-> delete_file(Path)
; true
),
%% Ensure parent directory exists
file_directory_name(Path, Dir),
( exists_directory(Dir)
-> true
; make_directory_path(Dir)
),
%% Create Unix domain socket
unix_domain_socket(ServerSocket),
tcp_bind(ServerSocket, Path),
tcp_listen(ServerSocket, 5),
format('Talmudic Engine listening on ~w~n', [Path]),
flush_output,
%% Set running flag
assert(server_running(true)),
%% Accept loop
accept_loop(ServerSocket, Path).
%% Stop the server gracefully
stop_server :-
retractall(server_running(_)).
%% ============================================================
%% CONNECTION HANDLING
%% ============================================================
%% Main accept loop — waits for connections
accept_loop(ServerSocket, Path) :-
( server_running(true)
-> catch(
( tcp_accept(ServerSocket, ClientSocket, _Peer),
tcp_open_socket(ClientSocket, InStream, OutStream),
%% Handle this client's requests
catch(
handle_connection(InStream, OutStream),
Error,
( format(user_error, 'Connection error: ~w~n', [Error]),
flush_output(user_error)
)
),
close(InStream, [force(true)]),
close(OutStream, [force(true)]),
accept_loop(ServerSocket, Path)
),
AcceptError,
( format(user_error, 'Accept error: ~w~n', [AcceptError]),
flush_output(user_error),
%% Clean up
tcp_close_socket(ServerSocket),
( exists_file(Path) -> delete_file(Path) ; true ),
fail
)
)
; %% Server stopped — clean up
tcp_close_socket(ServerSocket),
( exists_file(Path) -> delete_file(Path) ; true ),
format('Talmudic Engine stopped.~n'),
flush_output
).
%% Handle a single connection — read lines until EOF
handle_connection(InStream, OutStream) :-
read_line_to_string(InStream, Line),
( Line == end_of_file
-> true
; process_json_line(Line, ResponseJson),
%% Write JSON response on a single line
json_write_dict(OutStream, ResponseJson),
nl(OutStream),
flush_output(OutStream),
%% Continue reading from this connection
handle_connection(InStream, OutStream)
).
%% ============================================================
%% JSON REQUEST PROCESSING
%% ============================================================
%% Process a JSON line string and produce a JSON response string
process_json_line(Line, ResponseJson) :-
catch(
( %% Parse JSON string to dict
atom_json_dict(Line, RequestDict, []),
%% Convert JSON to Prolog request
json_to_request(RequestDict, Request),
%% Dispatch to API
process_request(Request, Response),
%% Convert response to JSON
response_to_json(Response, ResponseJson)
),
Error,
( term_string(Error, ErrorStr),
ResponseJson = json{status: "error",
error: json{code: "PROCESSING_ERROR",
message: ErrorStr}}
)
).
+2
View File
@@ -17,6 +17,8 @@
:- use_module(halt/shfichut_damim).
:- use_module(halt/gilui_arayot).
:- use_module(interface/api).
:- use_module(interface/json_bridge).
:- use_module(interface/socket_server).
%% Load test files
:- use_module(library(plunit)).
+7 -1
View File
@@ -10,7 +10,13 @@ config :vav_pipeline,
# Shabbat check interval (1 minute)
cycle_boundary_check_interval_ms: 60_000,
# ShemaContext: no caching — always read fresh
shema_context_cache_ttl_ms: 0
shema_context_cache_ttl_ms: 0,
# Step 14 — Component connection config
# Shoresh Engine (Haskell) — binary path for Erlang Port
shoresh_engine_binary: "shoresh-engine",
shoresh_engine_cwd: nil,
# Talmudic Engine (Prolog) — Unix domain socket path
talmudic_socket_path: "/tmp/shorashim/talmudic.sock"
# Import environment specific config
import_config "#{config_env()}.exs"
+32 -2
View File
@@ -4,18 +4,48 @@ defmodule VavPipeline.Application do
Starts the supervision tree with:
- ShabbatTimer (mandatory cessation monitor)
- ShoreshEnginePort (Erlang Port to Haskell engine, if binary found)
- TalmudicEngineSocket (Unix socket to Prolog engine)
Step 14 (Architecture §4): Component connections supervised.
The port and socket are optional — the pipeline degrades gracefully
if external engines are not available (clients fall back to stubs).
"""
use Application
require Logger
@impl true
def start(_type, _args) do
children = [
# ShabbatTimer monitors Shabbat boundaries
shoresh_binary = Application.get_env(:vav_pipeline, :shoresh_engine_binary, "shoresh-engine")
shoresh_cwd = Application.get_env(:vav_pipeline, :shoresh_engine_cwd, nil)
talmudic_socket = Application.get_env(:vav_pipeline, :talmudic_socket_path, "/tmp/shorashim/talmudic.sock")
children =
[
{VavPipeline.ShabbatTimer, []}
]
|> maybe_add_shoresh_port(shoresh_binary, shoresh_cwd)
|> maybe_add_talmudic_socket(talmudic_socket)
opts = [strategy: :one_for_one, name: VavPipeline.Supervisor]
Supervisor.start_link(children, opts)
end
defp maybe_add_shoresh_port(children, binary, cwd) do
case System.find_executable(binary) do
nil ->
Logger.warning("ShoreshEnginePort: binary '#{binary}' not in PATH — running without Haskell engine")
children
path ->
children ++ [{VavPipeline.ShoreshEnginePort, [binary: path, cwd: cwd]}]
end
end
defp maybe_add_talmudic_socket(children, socket_path) do
# Always add the socket client — it connects lazily on first request
children ++ [{VavPipeline.TalmudicEngineSocket, [socket_path: socket_path]}]
end
end
+136 -18
View File
@@ -19,7 +19,8 @@ defmodule VavPipeline.BereishitCycle do
Latency budget (Construction spec §2.5): 5000ms total
"""
alias VavPipeline.{Specification, CycleLog, Manna}
alias VavPipeline.{Specification, CycleLog, Manna,
ShoreshEngineClient, TalmudicEngineClient, SanhedrinClient}
require Logger
@@ -81,27 +82,22 @@ defmodule VavPipeline.BereishitCycle do
"""
@spec vayomer(map(), VavPipeline.Shema.shema_context()) :: map()
def vayomer(state, context) do
# In Phase 2: use L1 data from ShemaContext directly
# In Phase 3: call ShoreshEngineClient.process(state.raw, context)
roots =
context.verse.words
|> Enum.filter(fn w -> w.shoresh != nil end)
|> Enum.map(fn w ->
%{
shoresh: w.shoresh,
word: w.consonants,
position: w.position,
morph: w.morph,
gematria: Map.get(context.root_gematria, w.shoresh, 0)
}
end)
# Step 14: Call Shoresh Engine via port. Falls back to L1 data.
case ShoreshEngineClient.process(state.raw, context) do
{:ok, engine_result} ->
Map.merge(state, %{
roots_activated: roots,
roots_activated: get_result(engine_result, :roots_activated, "roots_activated", []),
parse_tree: get_result(engine_result, :parse_tree, "parse_tree", nil),
dual_readings: get_result(engine_result, :dual_readings, "dual_readings", []),
teamim_parse: context.teamim_marks,
ketiv_qere: context.ketiv_qere,
stage: :vayomer
})
{:error, reason} ->
Logger.warning("BereishitCycle.vayomer: engine error (#{reason}), using L1 fallback")
vayomer_fallback(state, context)
end
end
@doc """
@@ -202,7 +198,7 @@ defmodule VavPipeline.BereishitCycle do
"""
@spec erev_voker(VavPipeline.cycle_result(), VavPipeline.Shema.shema_context()) ::
VavPipeline.cycle_result()
def erev_voker(result, _context) do
def erev_voker(result, context) do
# Log the cycle
phase =
case result do
@@ -217,11 +213,133 @@ defmodule VavPipeline.BereishitCycle do
# Enforce anti-hoarding
Manna.enforce_anti_hoarding()
# Step 14: Route unresolved cases to Talmudic Engine / Sanhedrin
case result do
{:unresolved, data} ->
route_if_unresolved(data, context)
_ ->
result
end
end
# --- Routing ---
@doc false
defp route_if_unresolved(data, context) do
# Extract roots for the Talmudic Engine query
roots = Map.get(data, :roots_activated, [])
context_roots = Enum.map(roots, fn
%{shoresh: s} when is_binary(s) -> s
%{"shoresh" => s} when is_list(s) -> Enum.join(s, "-")
_ -> "unknown"
end)
case_ref = "cycle-#{:erlang.system_time(:millisecond)}"
talmudic_context = %{
context_roots: context_roots,
question: "Interpret roots #{inspect(context_roots)} in context"
}
# First: consult the Talmudic Engine
case TalmudicEngineClient.interpret(case_ref, talmudic_context) do
{:ok, %{"status" => "resolved"} = interpretation} ->
Logger.info("BereishitCycle: Talmudic Engine resolved case #{case_ref}")
{:ok, Map.merge(data, %{
talmudic_interpretation: interpretation,
resolution: :talmudic,
stage: :erev_voker
})}
{:ok, %{"status" => "teiku"}} ->
# Still unresolved — escalate to Sanhedrin
Logger.info("BereishitCycle: Talmudic Engine returned teiku, escalating to Sanhedrin")
escalate_to_sanhedrin(data, context, case_ref, context_roots)
{:ok, interpretation} ->
# Challenged or other status — include and return
{:unresolved, Map.merge(data, %{
talmudic_interpretation: interpretation,
stage: :erev_voker
})}
{:error, reason} ->
Logger.warning("BereishitCycle: Talmudic routing failed: #{reason}")
{:unresolved, Map.put(data, :routing_error, reason)}
end
end
defp escalate_to_sanhedrin(data, _context, case_ref, context_roots) do
sanhedrin_params = %{
case_ref: case_ref,
complexity: :simple,
question: "Deliberate on unresolved interpretation of #{inspect(context_roots)}",
context: %{
roots: context_roots,
named_results: Map.get(data, :named_results, [])
},
requested_by: "bereishit_cycle"
}
case SanhedrinClient.submit_case(sanhedrin_params) do
{:ok, decision} ->
outcome = Map.get(decision, :outcome, :teiku)
Logger.info("BereishitCycle: Sanhedrin decided #{outcome} for #{case_ref}")
case outcome do
:accepted ->
{:ok, Map.merge(data, %{
sanhedrin_decision: decision,
resolution: :sanhedrin,
stage: :erev_voker
})}
_ ->
{:unresolved, Map.merge(data, %{
sanhedrin_decision: decision,
stage: :erev_voker
})}
end
{:error, reason} ->
Logger.warning("BereishitCycle: Sanhedrin escalation failed: #{reason}")
{:unresolved, Map.put(data, :escalation_error, reason)}
end
end
# --- L1 Fallback (when port is unavailable) ---
defp vayomer_fallback(state, context) do
roots =
context.verse.words
|> Enum.filter(fn w -> w.shoresh != nil end)
|> Enum.map(fn w ->
%{
shoresh: w.shoresh,
word: w.consonants,
position: w.position,
morph: w.morph,
gematria: Map.get(context.root_gematria, w.shoresh, 0)
}
end)
Map.merge(state, %{
roots_activated: roots,
teamim_parse: context.teamim_marks,
ketiv_qere: context.ketiv_qere,
stage: :vayomer
})
end
# --- Private helpers ---
# Get a value from a map that may have atom or string keys
# (atom keys from L1 fallback, string keys from JSON engine response)
defp get_result(map, atom_key, string_key, default) do
Map.get(map, atom_key, Map.get(map, string_key, default))
end
defp extract_content(input) do
Map.get(input, :content, "") |> String.trim()
end
@@ -0,0 +1,141 @@
defmodule VavPipeline.SanhedrinClient do
@moduledoc """
Client interface to the Erlang Sanhedrin deliberation engine.
Construction spec §3.2: Native BEAM message passing.
Protocol: Erlang records via escalation:submit_case/1.
The Sanhedrin runs in the same BEAM VM. This module translates
Elixir maps into Erlang record tuples and calls the Sanhedrin
API directly.
"""
require Logger
@doc """
Submit a case for deliberation.
Construction spec §3.2: submit_case with case_ref, complexity,
question, context, requested_by.
Returns {:ok, decision_map} or {:error, reason}.
"""
@spec submit_case(map()) :: {:ok, map()} | {:error, String.t()}
def submit_case(case_params) do
case_ref = Map.get(case_params, :case_ref, generate_ref())
complexity = Map.get(case_params, :complexity, :simple)
question = Map.get(case_params, :question, "")
context = Map.get(case_params, :context, %{})
requested_by = Map.get(case_params, :requested_by, "pipeline")
# Build Erlang record tuple matching #sanhedrin_case{}
# Record: {sanhedrin_case, case_ref, complexity, question, context,
# requested_by, submitted_at}
erlang_case =
{:sanhedrin_case, case_ref, complexity, question, context,
requested_by, :erlang.system_time(:millisecond)}
try do
case :escalation.submit_case(erlang_case) do
{:ok, decision_ref} ->
# Decision is stored asynchronously by the panel.
# Wait briefly for the result, then fetch from clerk.
wait_for_decision(decision_ref, case_ref)
{:error, {:halted, halt_type, reason}} ->
{:error, "Halted (#{halt_type}): #{reason}"}
{:error, reason} ->
{:error, "Submission failed: #{inspect(reason)}"}
end
catch
:exit, {:noproc, _} ->
Logger.warning("SanhedrinClient: Sanhedrin not running, returning teiku")
{:ok, stub_teiku(case_ref)}
kind, reason ->
Logger.error("SanhedrinClient: #{kind} — #{inspect(reason)}")
{:error, "Sanhedrin call failed: #{inspect(reason)}"}
end
end
@doc """
Get a previously stored decision from the clerk.
"""
@spec get_decision(binary()) :: {:ok, map()} | {:error, :not_found}
def get_decision(case_ref) do
try do
case :clerk.get_decision(case_ref) do
{:ok, decision} ->
{:ok, decision_to_map(decision)}
:not_found ->
{:error, :not_found}
end
catch
:exit, {:noproc, _} ->
{:error, :not_found}
end
end
# --- Private ---
defp wait_for_decision(decision_ref, case_ref) do
# The panel deliberates asynchronously. Poll clerk briefly.
wait_for_decision(decision_ref, case_ref, 10, 500)
end
defp wait_for_decision(_decision_ref, case_ref, 0, _interval) do
# Timeout — return teiku (unresolved)
Logger.warning("SanhedrinClient: deliberation timeout for #{case_ref}")
{:ok, stub_teiku(case_ref)}
end
defp wait_for_decision(decision_ref, case_ref, retries, interval) do
Process.sleep(interval)
case get_decision(case_ref) do
{:ok, decision} ->
{:ok, decision}
{:error, :not_found} ->
wait_for_decision(decision_ref, case_ref, retries - 1, interval)
end
end
defp decision_to_map({:decision, case_ref, outcome, majority, minority, panel_size,
delib_time, unanimous_check}) do
%{
case_ref: case_ref,
outcome: outcome,
majority: majority,
minority: minority,
panel_size: panel_size,
deliberation_time_ms: delib_time,
unanimous_check: unanimous_check
}
end
defp decision_to_map(other), do: other
defp stub_teiku(case_ref) do
%{
case_ref: case_ref,
outcome: :teiku,
majority: %{position: "Sanhedrin not available", reasoning: "Engine not running", torah_refs: [], vote_count: 0},
minority: %{position: "", reasoning: "", torah_refs: [], vote_count: 0},
panel_size: 0,
deliberation_time_ms: 0,
unanimous_check: :passed
}
end
defp generate_ref do
:crypto.strong_rand_bytes(16)
|> Base.encode16(case: :lower)
|> then(fn hex ->
<<a::binary-size(8), b::binary-size(4), c::binary-size(4), d::binary-size(4), e::binary-size(12)>> = hex
"#{a}-#{b}-#{c}-#{d}-#{e}"
end)
end
end
@@ -2,54 +2,29 @@ defmodule VavPipeline.ShoreshEngineClient do
@moduledoc """
Client interface to the Haskell Shoresh Engine.
Architecture §3.3: NIF or port to Haskell engine.
Architecture §3.3: Erlang Port to Haskell engine.
Construction spec §3.1: JSON protocol over port.
Phase 2: Stub that returns data from L1/ShemaContext.
Phase 3 (Step 14): Full port/NIF connection to shoresh-engine binary.
Delegates to ShoreshEnginePort (the live Haskell process).
Falls back to L1/ShemaContext extraction if port is unavailable.
"""
require Logger
@doc """
Process input through the Shoresh Engine.
Phase 2: Uses L1 data directly.
Phase 3: Sends JSON request to Haskell port.
Sends JSON request to Haskell port; falls back to L1 data.
"""
@spec process(String.t(), map()) :: {:ok, map()} | {:error, String.t()}
def process(_raw_text, context) do
# Phase 2 stub — extract from ShemaContext
roots =
context.verse.words
|> Enum.filter(fn w -> w.shoresh != nil end)
|> Enum.map(fn w ->
%{
shoresh: w.shoresh,
binyan: infer_binyan(w.morph),
domain: :unknown,
torah_refs: [%{book: context.verse.book, chapter: context.verse.chapter, verse: context.verse.verse}],
gematria_value: Map.get(context.root_gematria, w.shoresh, 0)
}
end)
{:ok,
%{
roots_activated: roots,
parse_tree: nil,
dual_readings: context.ketiv_qere
}}
def process(raw_text, context) do
call_port(:process, raw_text, context)
end
@doc """
Separate utterance using te'amim marks.
Phase 2: Returns groups based on melekh positions.
Phase 3: Delegates to Haskell Havdalah module.
"""
@spec separate(map(), [map()]) :: [map()]
def separate(utterance, teamim_marks) do
# Phase 2 stub — group by melekh divisions
melekh_positions =
teamim_marks
|> Enum.filter(fn m -> m.role == "melekh" end)
@@ -60,29 +35,20 @@ defmodule VavPipeline.ShoreshEngineClient do
@doc """
Name a distinction.
Phase 2: Returns the primary root as the name.
Phase 3: Delegates to Haskell Kriah module.
"""
@spec name(map()) :: map()
def name(distinction) do
Map.put(distinction, :name, Map.get(distinction, :shoresh, "unnamed"))
end
# Infer binyan from morphological code
# H = Hebrew, V = verb, then binyan indicator
defp infer_binyan(morph) when is_binary(morph) do
cond do
String.contains?(morph, "Vq") -> :qal
String.contains?(morph, "VN") -> :nifal
String.contains?(morph, "Vp") -> :piel
String.contains?(morph, "VP") -> :pual
String.contains?(morph, "Vh") -> :hifil
String.contains?(morph, "VH") -> :hufal
String.contains?(morph, "Vt") -> :hitpael
true -> :qal
# --- Private ---
defp call_port(operation, text, context) do
if Process.whereis(VavPipeline.ShoreshEnginePort) do
apply(VavPipeline.ShoreshEnginePort, operation, [text, context])
else
{:error, "port not started"}
end
end
defp infer_binyan(_), do: :qal
end
@@ -0,0 +1,185 @@
defmodule VavPipeline.ShoreshEnginePort do
@moduledoc """
Erlang Port interface to the Haskell Shoresh Engine.
Step 14 (Architecture §4): "Connect components: Elixir pipeline
calls Haskell engine."
Construction spec §3.1: JSON protocol over Erlang Port.
Protocol: JSON line in, JSON line out.
The port launches `shoresh-engine server` as an OS process.
Communication is through stdin/stdout with line-delimited JSON.
Port crash isolation: if the Haskell process crashes, the BEAM
VM continues running. The GenServer will restart the port.
"""
use GenServer
require Logger
@default_binary "shoresh-engine"
@call_timeout 5_000
# --- Client API ---
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc """
Send a process request to the Shoresh Engine.
Construction spec §3.1 — operation "process".
"""
@spec process(String.t(), map()) :: {:ok, map()} | {:error, String.t()}
def process(text, context \\ %{}) do
request = build_request("process", text, context)
call_engine(request)
end
@doc """
Send a separate request to the Shoresh Engine.
Construction spec §3.1 — operation "separate".
"""
@spec separate(String.t(), map()) :: {:ok, map()} | {:error, String.t()}
def separate(text, context \\ %{}) do
request = build_request("separate", text, context)
call_engine(request)
end
@doc """
Send a name request to the Shoresh Engine.
Construction spec §3.1 — operation "name".
"""
@spec name(String.t(), map()) :: {:ok, map()} | {:error, String.t()}
def name(text, context \\ %{}) do
request = build_request("name", text, context)
call_engine(request)
end
@doc """
Send an evaluate request to the Shoresh Engine.
Construction spec §3.1 — operation "evaluate".
"""
@spec evaluate(String.t(), map()) :: {:ok, map()} | {:error, String.t()}
def evaluate(text, context \\ %{}) do
request = build_request("evaluate", text, context)
call_engine(request)
end
# --- GenServer callbacks ---
@impl true
def init(opts) do
binary = Keyword.get(opts, :binary, @default_binary)
cwd = Keyword.get(opts, :cwd, nil)
port = open_port(binary, cwd)
{:ok, %{port: port, binary: binary, cwd: cwd, pending: nil}}
end
@impl true
def handle_call({:request, json_line}, from, state) do
Port.command(state.port, json_line <> "\n")
{:noreply, %{state | pending: from}}
end
@impl true
def handle_info({port, {:data, {:eol, data}}}, %{port: port, pending: from} = state)
when from != nil do
response = data |> IO.iodata_to_binary() |> Jason.decode()
reply =
case response do
{:ok, %{"status" => "ok", "result" => result}} ->
{:ok, result}
{:ok, %{"status" => "error", "error" => error}} ->
{:error, "Engine error: #{error["code"]} — #{error["message"]}"}
{:error, decode_error} ->
{:error, "JSON decode error: #{inspect(decode_error)}"}
end
GenServer.reply(from, reply)
{:noreply, %{state | pending: nil}}
end
def handle_info({port, {:exit_status, status}}, %{port: port} = state) do
Logger.error("ShoreshEnginePort: process exited with status #{status}")
if state.pending do
GenServer.reply(state.pending, {:error, "Engine process exited (status #{status})"})
end
# Restart the port
new_port = open_port(state.binary, state.cwd)
{:noreply, %{state | port: new_port, pending: nil}}
end
def handle_info({port, {:data, {:noeol, _partial}}}, %{port: port} = state) do
# Partial line — wait for more data
{:noreply, state}
end
def handle_info(msg, state) do
Logger.warning("ShoreshEnginePort: unexpected message: #{inspect(msg)}")
{:noreply, state}
end
@impl true
def terminate(_reason, state) do
Port.close(state.port)
:ok
end
# --- Private ---
defp open_port(binary, cwd) do
port_opts = [
:binary,
:exit_status,
{:line, 1_048_576},
{:args, ["server"]}
]
port_opts =
if cwd do
[{:cd, cwd} | port_opts]
else
port_opts
end
Port.open({:spawn_executable, binary}, port_opts)
end
defp build_request(operation, text, context) do
request = %{
"operation" => operation,
"input" => %{
"text" => text
}
}
# Add scope from context if present
request =
if Map.has_key?(context, :verse) do
verse = context.verse
put_in(request, ["input", "scope"], %{
"book" => to_string(verse.book),
"chapter" => verse.chapter,
"verse" => verse.verse
})
else
request
end
Jason.encode!(request)
end
defp call_engine(json_line) do
GenServer.call(__MODULE__, {:request, json_line}, @call_timeout)
end
end
@@ -4,73 +4,102 @@ defmodule VavPipeline.TalmudicEngineClient do
Construction spec §3.3:
Protocol: JSON over Unix domain socket
Socket: /var/run/shorashim/talmudic.sock
Phase 2: Stub that returns placeholder responses.
Phase 3 (Step 14): Full socket connection to talmudic_engine.
Delegates to TalmudicEngineSocket (the live Prolog process).
Falls back to stub responses if socket is unavailable.
"""
require Logger
# Phase 3: Unix domain socket path
# @socket_path Application.compile_env(
# :vav_pipeline,
# :talmudic_socket,
# "/var/run/shorashim/talmudic.sock"
# )
@doc """
Send an interpretation request to the Talmudic Engine.
Phase 2: Returns a stub response.
Phase 3: Sends JSON over Unix socket.
"""
@spec interpret(String.t(), map()) :: {:ok, map()} | {:error, String.t()}
def interpret(case_ref, _context) do
Logger.info("TalmudicEngineClient: interpret stub for #{case_ref}")
def interpret(case_ref, context) do
case call_socket(:interpret, case_ref, context) do
{:ok, result} ->
{:ok, result}
{:ok,
%{
status: :teiku,
interpretation: %{
text: "Phase 2 stub — Talmudic Engine not yet connected",
prolog_form: "stub(#{case_ref})",
grounding: [],
confidence: :low
},
challenge: nil
}}
{:error, reason} ->
Logger.warning("TalmudicEngineClient: socket unavailable (#{reason}), using stub")
{:ok, stub_interpretation(case_ref)}
end
end
@doc """
Send a challenge request.
"""
@spec challenge(String.t(), map()) :: {:ok, map()} | {:error, String.t()}
def challenge(case_ref, _context) do
Logger.info("TalmudicEngineClient: challenge stub for #{case_ref}")
def challenge(case_ref, context) do
case call_socket(:challenge, case_ref, context) do
{:ok, result} ->
{:ok, result}
{:ok,
%{
status: :teiku,
challenge: %{
kushya: "Phase 2 stub — no challenge generated",
contradicting: nil,
strength: :weak
}
}}
{:error, reason} ->
Logger.warning("TalmudicEngineClient: socket unavailable (#{reason}), using stub")
{:ok, stub_challenge(case_ref)}
end
end
@doc """
Send a resolution request.
"""
@spec resolve(String.t(), map()) :: {:ok, map()} | {:error, String.t()}
def resolve(case_ref, _context) do
Logger.info("TalmudicEngineClient: resolve stub for #{case_ref}")
def resolve(case_ref, context) do
case call_socket(:resolve, case_ref, context) do
{:ok, result} ->
{:ok, result}
{:ok,
{:error, reason} ->
Logger.warning("TalmudicEngineClient: socket unavailable (#{reason}), using stub")
{:ok, stub_resolution(case_ref)}
end
end
# --- Private ---
defp call_socket(operation, case_ref, context) do
if Process.whereis(VavPipeline.TalmudicEngineSocket) do
apply(VavPipeline.TalmudicEngineSocket, operation, [case_ref, context])
else
{:error, "socket not started"}
end
end
defp stub_interpretation(case_ref) do
%{
status: :teiku,
resolution: "Phase 2 stub — resolution deferred"
}}
"status" => "teiku",
"interpretation" => %{
"text" => "Talmudic Engine not connected — teiku",
"prolog_form" => "stub(#{case_ref})",
"grounding" => [],
"confidence" => "low"
},
"challenge" => nil
}
end
defp stub_challenge(_case_ref) do
%{
"status" => "teiku",
"challenge" => %{
"kushya" => "No challenge generated — engine not connected",
"contradicting" => "",
"strength" => "weak"
}
}
end
defp stub_resolution(case_ref) do
%{
"status" => "teiku",
"interpretation" => %{
"text" => "Resolution deferred — engine not connected",
"prolog_form" => "stub(#{case_ref})",
"grounding" => [],
"confidence" => "low"
},
"challenge" => nil
}
end
end
@@ -0,0 +1,172 @@
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
# --- 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} -> {: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: :line], @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 :gen_tcp.recv(socket, 0, @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
end
+2 -1
View File
@@ -21,7 +21,8 @@ defmodule VavPipeline.MixProject do
# Construction spec §1.5 — exact version pins
defp deps do
[
{:jason, "~> 1.4.1"}
{:jason, "~> 1.4.1"},
{:sanhedrin, path: "../sanhedrin", manager: :rebar3}
]
end
end
+1
View File
@@ -1,3 +1,4 @@
%{
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"jsx": {:hex, :jsx, "3.1.0", "d12516baa0bb23a59bb35dccaf02a1bd08243fcbb9efe24f2d9d056ccff71268", [:rebar3], [], "hexpm", "0c5cc8fdc11b53cc25cf65ac6705ad39e54ecc56d1c22e4adb8f5a53fb9427f3"},
}