orchestrata_ultimate.core.prolog_adapter
Reference › orchestrata_ultimate › core › prolog_adapter
The Prolog engine adapter over janus-swi, in core. It imports janus through janus_bootstrap and sets JANUS_AVAILABLE. If janus is not importable, constructing a PrologEngine raises RuntimeError; there is no in-memory or alternative backend.
PrologEngine pins a reusable SWI engine to the calling thread (_ensure_thread_engine), releases it in close (also used by the context-manager protocol), and exposes consult, assertz, retract, retractall, query and query_once. Janus returns bindings with a truth key, which _strip_truth removes so callers see plain binding dictionaries. query_explained runs a goal through the explain.pl meta-interpreter of ADR-0031, loading it once per process, and returns a ProofResult with the success flag, the proof tree and a list of why-not failures.
One limitation shapes usage: assertz goes through a goal string, and janus treats capitalised names there as Python-bound inputs, so asserting a clause with Prolog variables fails with an instantiation error. Variable-bearing rules therefore belong in .pl files loaded with consult, and only ground facts are asserted at runtime; the governance gate follows this pattern with its completion rules.
At a glance
| Source | src/orchestrata_ultimate/core/prolog_adapter.py |
| Lines of code | 277 |
| Classes | 2 |
| Functions | 5 |
| Imports (internal) | orchestrata_ultimate.core.janus_bootstrap, orchestrata_ultimate.core.paths |
| Imported by | orchestrata_ultimate.core.threading.test_scheduler, orchestrata_ultimate.cxp.prolog_bridge, orchestrata_ultimate.governance.prolog_client, orchestrata_ultimate.governance.prolog_gate, orchestrata_ultimate.orchestrator.prolog_worker, orchestrata_ultimate.postman.governance, orchestrata_ultimate.refactor.codemod.review, orchestrata_ultimate.verification.constraints.evaluator, orchestrata_ultimate.verification.rtm_constraints, orchestrata_ultimate.verification.spec_completeness |
| Tests that import it | tests/conftest.py, tests/core/test_janus_bootstrap.py, tests/core/test_prolog_adapter.py, tests/cxp/test_prolog_bridge.py, tests/cxp/test_prolog_bridge_extended.py, tests/factories/real.py, tests/generated_core/test_prolog_adapter_generated.py, tests/governance/test_prolog_client.py, tests/governance/test_prolog_gate.py, tests/postman/test_governance.py, tests/refactor/codemod/test_review_coverage.py, tests/refactor/codemod/test_review_passes.py, tests/session/test_prolog_interface.py, tests/session/test_prolog_interface_arity_regression.py |
Class diagram
Dependency diagram
API
Constants
| Name | Type | Value |
|---|---|---|
_janus |
Any |
None |
Classes
ProofResult (dataclass)
Decorators: dataclass(frozen=True) · Line 62
The outcome of an explained Prolog query (ADR-0031 / SPEC-033).
| Attribute | Type | Default |
|---|---|---|
succeeded |
bool |
|
tree |
dict[str, Any] | None |
|
whynot |
list[dict[str, Any]] |
field(default_factory=list) |
goal |
str |
'' |
PrologEngine
Line 77
Prolog engine over janus-swi.
| Method | Signature | Summary |
|---|---|---|
__init__ |
__init__(persistent: bool = True) -> None |
Initialize the Prolog engine. |
_ensure_thread_engine |
_ensure_thread_engine() -> None |
Pin a reusable SWI engine on the calling thread (once per thread). |
close |
close() -> None |
Release the pinned engine on the CALLING thread, if this instance pinned one. |
__del__ |
__del__() -> None |
|
__enter__ |
__enter__() -> PrologEngine |
|
__exit__ |
__exit__(**args: object) -> None |
|
backend property |
backend() -> str |
|
consult |
consult(path: str) -> None |
|
assertz |
assertz(term: str) -> None |
|
retract |
retract(term: str) -> None |
|
retractall |
retractall(term: str) -> None |
|
query |
query(goal: str) -> list[dict[str, Any]] |
|
query_once |
query_once(goal: str) -> dict[str, Any] | None |
|
query_explained |
query_explained(goal: str, bound: int = 50, ****bindings: str) -> ProofResult |
Execute goal through the ADR-0031 explain.pl meta-interpreter. |
Functions
| Function | Signature | Summary |
|---|---|---|
_strip_truth |
_strip_truth(result: dict[str, Any]) -> dict[str, Any] |
Strip janus's 'truth' key, leaving a plain binding dict. |
_ensure_explain_loaded |
_ensure_explain_loaded() -> None |
Consult explain.pl into the (process-global) janus engine, once. |
query_explained |
query_explained(goal: str, bound: int = 50, ****bindings: str) -> ProofResult |
Execute goal through the ADR-0031 explain.pl meta-interpreter. |
_try_explain_call |
_try_explain_call(resolved_goal: str) -> dict[str, Any] | None |
Run explain_call/2; return its Tree dict, or None if Goal failed. |
_try_explain_whynot |
_try_explain_whynot(resolved_goal: str, bound: int) -> list[dict[str, Any]] |
Run explain_whynot/3; return its Failures list (possibly empty). |
Source
# mypy: ignore-errors
"""Prolog engine adapter -- janus-swi.
Provides a unified PrologEngine class over janus-swi (thread-safe). janus
returns query results as ``{'truth': True, 'X': val}``; the adapter strips the
``'truth'`` key so callers get plain ``{'X': val}`` binding dicts.
If janus-swi is not available, constructing a PrologEngine raises RuntimeError --
there is no in-memory fallback and no other backend.
LIMITATION + the pattern that works around it:
``assertz`` goes through ``query_once("assertz(<term>)")``, and janus treats
capitalised names in a goal string as Python-bound *input* variables -- so
asserting a clause that contains Prolog **variables** (e.g. ``foo(X) :- bar(X)``)
raises "Arguments are not sufficiently instantiated". Ground facts (no variables)
assert fine.
So the rule is: keep variable-bearing *rules* in a ``.pl`` file and load them
with ``consult`` (which parses Prolog source natively, variables and all), and
only ``assertz`` **ground** facts at runtime. ``governance.PrologGate`` follows
exactly this pattern -- its ``may_complete`` rules live in ``prolog/completion.pl``
(consulted) while task facts are asserted ground.
"""
import logging
import threading
from dataclasses import dataclass, field
from typing import Any
logger = logging.getLogger(__name__)
JANUS_AVAILABLE = False
# Typed Any: janus_swi ships no stubs, and the None initializer would otherwise
# make pyright reject every `_janus.<attr>` access as Optional-member / unknown.
_janus: Any = None
try:
from orchestrata_ultimate.core.janus_bootstrap import (
janus as _janus, # type: ignore[no-redef]
)
JANUS_AVAILABLE = True
logger.info("Prolog backend: janus-swi (thread-safe)")
except ImportError as exc:
logger.debug("janus-swi not available: %s", exc)
PROLOG_AVAILABLE = JANUS_AVAILABLE
# ADR-0031 / SPEC-033: explain.pl is consulted at most once per process (the
# janus engine is process-global -- see tests/conftest.py's real_prolog note)
# the first time query_explained is used, guarded by _EXPLAIN_LOCK.
_EXPLAIN_LOADED = False
_EXPLAIN_LOCK = threading.Lock()
def _strip_truth(result: dict[str, Any]) -> dict[str, Any]:
"""Strip janus's 'truth' key, leaving a plain binding dict."""
return {k: v for k, v in result.items() if k != "truth"}
@dataclass(frozen=True)
class ProofResult:
"""The outcome of an explained Prolog query (ADR-0031 / SPEC-033).
``tree`` (success) and ``whynot`` (failure) are always JSON-safe -- plain
dicts/lists/atoms/numbers -- because that is the only shape janus can
marshal out of a query (see explain.pl's MARSHALING note); there is no
separate conversion step on the Python side.
"""
succeeded: bool
tree: dict[str, Any] | None
whynot: list[dict[str, Any]] = field(default_factory=list)
goal: str = ""
class PrologEngine:
"""Prolog engine over janus-swi.
Concurrency & performance model (measured, not assumed):
janus-swi already runs every op on the calling thread's OWN SWI engine --
``swipl_call`` / ``swipl_open_query`` do ``PL_thread_attach_engine`` /
``PL_thread_destroy_engine`` internally -- and it releases the GIL around
SWI's solution search. So concurrent governance queries are correct AND
genuinely parallel on stock janus with no help from this class; that is not
what the per-thread attach below buys.
What it buys is engine REUSE. Left to itself janus creates and destroys a
fresh SWI engine on EVERY call (``janus.engine()`` reads -1 between ops).
Attaching a persistent engine once per thread pins it (a stable id), so
subsequent ops reuse it instead of paying the create/destroy churn --
measured ~3.4x higher throughput on a hot concurrent governance-query path.
This is a latency optimization, not a correctness requirement.
Use ``close()`` / the context manager to release the calling thread's
pinned engine.
"""
def __init__(self, persistent: bool = True) -> None:
"""Initialize the Prolog engine.
Args:
persistent: If True (default), pin a reusable Prolog engine on the
constructing thread (and lazily on any other thread that later
calls an op), avoiding per-call engine create/destroy churn.
Raises:
RuntimeError: If janus-swi is not available.
"""
if not JANUS_AVAILABLE:
raise RuntimeError(
"No Prolog backend available. Install janus-swi (uv add janus-swi)."
)
self._backend = "janus"
self._persistent = persistent
self._engine = None
# Per-(instance, thread) flag: has THIS instance pinned an engine on the
# calling thread yet? Keeps the (idempotent, ref-counted) attach off the
# hot path after first use, and -- critically -- lets close() release
# ONLY threads this instance actually pinned (see close()).
self._thread_state = threading.local()
if persistent:
self._ensure_thread_engine()
def _ensure_thread_engine(self) -> None:
"""Pin a reusable SWI engine on the calling thread (once per thread)."""
if not self._persistent or getattr(self._thread_state, "attached", False):
return
_janus.attach_engine()
self._thread_state.attached = True
def close(self) -> None:
"""Release the pinned engine on the CALLING thread, if this instance pinned one.
``detach_engine()`` acts on whichever thread runs it, so it must only be
called on a thread this instance actually attached (tracked by the
thread-local flag). Running it on an unrelated thread -- e.g. a
finalizer thread during ``__del__`` -- would decrement/destroy an engine
this instance never created. Engines pinned on other threads are
reclaimed by SWI when those threads exit (bounded, long-lived pools).
"""
if getattr(self._thread_state, "attached", False):
try:
_janus.detach_engine()
except Exception:
pass
self._thread_state.attached = False
def __del__(self) -> None:
self.close()
def __enter__(self) -> PrologEngine:
return self
def __exit__(self, *args: object) -> None:
self.close()
@property
def backend(self) -> str:
return self._backend
def consult(self, path: str) -> None:
self._ensure_thread_engine()
_janus.consult(str(path))
def assertz(self, term: str) -> None:
# janus binds capitalised goal-string names as Python inputs, so this
# works for GROUND facts only; variable-bearing clauses must be loaded
# via consult of a .pl file (see module docstring).
self._ensure_thread_engine()
_janus.query_once(f"assertz({term})")
def retract(self, term: str) -> None:
self._ensure_thread_engine()
_janus.query_once(f"retract({term})")
def retractall(self, term: str) -> None:
self._ensure_thread_engine()
_janus.query_once(f"retractall({term})")
def query(self, goal: str) -> list[dict[str, Any]]:
self._ensure_thread_engine()
return [_strip_truth(r) for r in _janus.query(goal)]
def query_once(self, goal: str) -> dict[str, Any] | None:
self._ensure_thread_engine()
result = _janus.query_once(goal)
return _strip_truth(result) if result is not None else None
def query_explained(
self, goal: str, bound: int = 50, **bindings: str
) -> ProofResult:
"""Execute ``goal`` through the ADR-0031 explain.pl meta-interpreter.
Opt-in explaining query mode: this is a peer of ``query``/``query_once``,
not a replacement -- the fast, non-explaining path those two methods
serve is unaffected. Thin wrapper around the module-level
``query_explained`` (below) that additionally pins this instance's
thread engine first, matching every other method on this class.
"""
self._ensure_thread_engine()
return query_explained(goal, bound, **bindings)
def _ensure_explain_loaded() -> None:
"""Consult explain.pl into the (process-global) janus engine, once."""
global _EXPLAIN_LOADED
if _EXPLAIN_LOADED:
return
with _EXPLAIN_LOCK:
if _EXPLAIN_LOADED:
return
from orchestrata_ultimate.core.paths import get_prolog_dir
_janus.consult(str(get_prolog_dir() / "explain.pl"))
_EXPLAIN_LOADED = True
def query_explained(goal: str, bound: int = 50, **bindings: str) -> ProofResult:
"""Execute ``goal`` through the ADR-0031 explain.pl meta-interpreter.
Module-level so both ``PrologEngine`` (above) and the separate
``PrologWorker``/``PrologProxy`` janus seam (orchestrator/prolog_worker.py
-- the one ``PrologBridge``/``GovernancePrologClient`` actually use in
production) can call the SAME logic against the SAME process-global janus
module, instead of a duplicated (and driftable) second implementation.
``goal`` may contain ``{name}`` placeholders, resolved via
``str.format(**bindings)`` before it is sent to Prolog (bindings must
already be Prolog-atom-safe strings, same as every other query-string
builder in this codebase).
Returns a ``ProofResult``: on success, ``tree`` names the rules fired and
facts consumed (in order); on failure, ``whynot`` enumerates each
candidate clause's first failing precondition, bounded by ``bound`` so a
recursive/cyclic precondition cannot hang the call.
"""
_ensure_explain_loaded()
resolved_goal = goal.format(**bindings) if bindings else goal
tree = _try_explain_call(resolved_goal)
if tree is not None:
return ProofResult(succeeded=True, tree=tree, whynot=[], goal=resolved_goal)
whynot = _try_explain_whynot(resolved_goal, bound)
return ProofResult(succeeded=False, tree=None, whynot=whynot, goal=resolved_goal)
def _try_explain_call(resolved_goal: str) -> dict[str, Any] | None:
"""Run explain_call/2; return its Tree dict, or None if Goal failed.
NOTE: janus_swi's query_once/1 ALWAYS returns a dict carrying a `truth`
key (True/False) -- it never returns Python None on failure (verified
directly against this repo's janus_swi build) -- so success must be read
from `truth`, not from an `is not None` check on the raw result.
"""
try:
solved = _janus.query_once(f"explain:explain_call(({resolved_goal}), Tree)")
except Exception as exc:
logger.debug("explain_call failed for %r: %s", resolved_goal, exc)
return None
if solved is not None and solved.get("truth"):
return solved["Tree"]
return None
def _try_explain_whynot(resolved_goal: str, bound: int) -> list[dict[str, Any]]:
"""Run explain_whynot/3; return its Failures list (possibly empty)."""
try:
explained = _janus.query_once(
f"explain:explain_whynot(({resolved_goal}), {bound}, Failures)"
)
except Exception as exc:
logger.debug("explain_whynot failed for %r: %s", resolved_goal, exc)
return []
if explained is not None and explained.get("truth"):
return list(explained["Failures"])
return []