scripts.benchmark_prolog_codeintel
Reference › scripts › benchmark_prolog_codeintel
Benchmarks recursive code-intelligence queries in Prolog against the same queries in plain Python. It is a large script (about 1,400 lines) that a human runs to decide where recursive queries should execute. tests/scripts/test_benchmark_prolog_codeintel.py imports it, and it reuses the shipped codebase tools from codebase_tools.
Invocation: --ast-json (the serialized AST to start from), --runs (default 5), --workdir (default a temporary directory), --report (where the Markdown report goes) and --skip-cli. It exits 2 if the AST file is missing.
The stages are printed as they run. collect_environment records the host, generate_facts turns the AST JSON into a Prolog fact file, and measure_consult_overhead times loading it. dump_edges extracts the edge relations, and build_graph builds an in-memory adjacency structure (Graph) for the Python side, which implements closure, bfs_path, a depth-first path search and cycle enumeration with canonicalize_cycle. select_workloads picks real modules and symbols from the graph. Four workloads follow: run_w1 (call impact), run_w2 (dependencies in a direction), run_w3 (cycles) and run_w4 (dependency path). Each times a Prolog call per query (time_psub, psub_query), the warm in-process Janus path when available (janus_sanity_check), the Python version (time_warm), the shipped wrapper (time_shipped_wrapper) and the CLI (time_cli), and checks that the answers agree. The result is a WorkloadOutcome with summary and detail tables and a verdict line.
At a glance
| Source | scripts/benchmark_prolog_codeintel.py |
| Lines of code | 1401 |
| Classes | 4 |
| Functions | 32 |
| Imports (internal) | orchestrata_ultimate.tools.codebase_tools |
| Imported by | none |
| Tests that import it | tests/scripts/test_benchmark_prolog_codeintel.py |
Class diagram
Dependency diagram
API
Classes
QueryTiming (dataclass)
Decorators: dataclass · Line 76
| Attribute | Type | Default |
|---|---|---|
label |
str |
|
result |
Any |
|
times_s |
list[float] |
|
first_call_s |
float | None |
None |
| Method | Signature | Summary |
|---|---|---|
stats property |
stats() -> dict[str, float] |
WorkloadOutcome (dataclass)
Decorators: dataclass · Line 88
| Attribute | Type | Default |
|---|---|---|
name |
str |
|
parity_ok |
bool |
|
parity_notes |
list[str] |
field(default_factory=list) |
timings |
dict[str, list[QueryTiming]] |
field(default_factory=dict) |
Graph (dataclass)
Decorators: dataclass · Line 248
| Attribute | Type | Default |
|---|---|---|
dep_edge_adj |
dict[str, set[str]] |
|
dep_edge_radj |
dict[str, set[str]] |
|
mod_edge_adj |
dict[str, set[str]] |
|
mod_edge_radj |
dict[str, set[str]] |
|
modules |
set[str] |
|
symbols |
dict[str, dict[str, str]] |
|
calls_in_degree |
Counter[str] |
|
n_calls_resolved |
int |
|
n_inherits_resolved |
int |
|
n_dep_raw |
int |
Workloads (dataclass)
Decorators: dataclass · Line 440
| Attribute | Type | Default |
|---|---|---|
w1_symbols |
list[str] |
|
w2_pairs |
list[tuple[str, str]] |
|
w4_pairs |
list[tuple[str, str, int]] |
|
wrapper_symbol |
str | None |
Functions
| Function | Signature | Summary |
|---|---|---|
quote_atom |
quote_atom(value: str) -> str |
Render value as a single-quoted Prolog atom literal (safe: no goal-string substitution — the value is embedded directly by position, never by search/replace, so it cannot corrupt other… |
stats_of |
stats_of(times_s: list[float]) -> dict[str, float] |
|
collect_environment |
collect_environment() -> dict[str, str] |
|
generate_facts |
generate_facts(ast_json: Path, workdir: Path) -> tuple[Path, list[Any], float] |
Run load_codebase/2 + save_facts/1 via standalone swipl. |
measure_consult_overhead |
measure_consult_overhead(facts_path: Path, runs: int) -> dict[str, float] |
P-sub consult overhead alone: consult rules+facts, goal = true, no query. |
dump_edges |
dump_edges(rules_pl: Path, facts_path: Path, workdir: Path) -> tuple[Path, float] |
Dump calls_resolved/3, inherits_resolved/2, dep_raw/2, module/3, symbol/7 as JSON lines, via one swipl subprocess. |
build_graph |
build_graph(edges_path: Path) -> tuple[Graph, float] |
Parse the JSON-lines edge dump and build the Py-mem adjacency structures. |
closure |
closure(start: str, adj: dict[str, set[str]]) -> set[str] |
Transitive closure of start's direct successors in adj — mirrors affected/2 (when adj=dep_edge_radj) and mod_depends/2 (adj=mod_edge_adj or mod_edge_radj depending on direction). |
bfs_path |
bfs_path(a: str, b: str, adj: dict[str, set[str]]) -> list[str] | None |
Shortest simple path a -> b over adj, or None. |
_paths_to_target |
_paths_to_target(start: str, target: str, adj: dict[str, set[str]]) -> list[list[str]] |
All simple node-sequences start -> ... -> target over adj, mirroring walk_to/4's nondeterministic backtracking exactly (no repeated nodes except the closing return to target). |
enumerate_raw_cycles |
enumerate_raw_cycles(adj: dict[str, set[str]]) -> list[list[str]] |
Mirrors mod_cycle/2: for every module M and every direct successor X, every simple path X -> ... -> M closes a cycle [M, X, ..., M]. |
canonicalize_cycle |
canonicalize_cycle(cycle: list[str]) -> tuple[str, ...] |
Mirrors canonical_cycle/2: drop the closing duplicate, rotate to the (first occurrence of the) minimum element, re-append it as the closer. |
py_mem_cycles |
py_mem_cycles(adj: dict[str, set[str]]) -> frozenset[tuple[str, ...]] |
|
select_workloads |
select_workloads(graph: Graph) -> Workloads |
|
psub_query |
psub_query(rules_pl: Path, facts_path: Path, workdir: Path, goal_template: str, out_var: str = 'R') -> tuple[dict[str, Any], float] |
Run one Prolog goal via a fresh swipl subprocess; return (result_dict, elapsed_seconds) for the WHOLE subprocess (consult + query + write). |
time_psub |
time_psub(rules_pl: Path, facts_path: Path, workdir: Path, goal_template: str, runs: int) -> QueryTiming |
|
janus_sanity_check |
janus_sanity_check(rules_pl: Path, facts_path: Path) -> tuple[bool, str] |
Consult rules+facts and run one tabled query INSIDE A SUBPROCESS, so a segfault (if the patched janus_swi genuinely cannot handle this KB) cannot kill the main benchmark process. |
time_warm |
time_warm(fn: Callable[[], Any], runs: int, warmup: int = 1) -> tuple[Any, list[float], float] |
Generic warm-mode timing harness: discard warmup calls (their result AND elapsed time of the LAST warmup call is kept — for tabled Prolog predicates this warmup call IS the "table build / first… |
cli_name_for_symbol |
cli_name_for_symbol(sym_info: dict[str, str]) -> str |
|
time_cli |
time_cli(args: list[str], runs: int) -> QueryTiming |
|
time_shipped_wrapper |
time_shipped_wrapper(rules_pl: Path, facts_path: Path, workdir: Path, qname: str) -> tuple[dict[str, Any], float] |
|
qnames_of |
qnames_of(dicts: list[dict[str, Any]], key: str) -> frozenset[str] |
|
run_w1 |
run_w1(graph: Graph, workloads: Workloads, rules_pl: Path, facts_path: Path, workdir: Path, runs: int, janus_available: bool) -> WorkloadOutcome |
|
run_w2 |
run_w2(graph: Graph, workloads: Workloads, rules_pl: Path, facts_path: Path, workdir: Path, runs: int, janus_available: bool) -> WorkloadOutcome |
|
run_w3 |
run_w3(graph: Graph, rules_pl: Path, facts_path: Path, workdir: Path, runs: int, janus_available: bool) -> WorkloadOutcome |
|
run_w4 |
run_w4(graph: Graph, workloads: Workloads, rules_pl: Path, facts_path: Path, workdir: Path, runs: int, janus_available: bool) -> WorkloadOutcome |
|
fmt_ms |
fmt_ms(x: float) -> str |
|
workload_summary_table |
workload_summary_table(outcome: WorkloadOutcome) -> str |
|
workload_detail_table |
workload_detail_table(outcome: WorkloadOutcome) -> str |
|
verdict_line |
verdict_line(outcome: WorkloadOutcome, mode: str, baseline: str) -> str | None |
|
build_report |
build_report(env: dict[str, str], graph: Graph, workloads: Workloads, facts_elapsed: float, facts_warnings: list[Any], edge_dump_elapsed: float, graph_build_elapsed: float, consult_overhead: dict[str, float], janus_consult_s: float | None, janus_available: bool, janus_error: str, outcomes: dict[str… |
|
main |
main() -> int |
Source
#!/usr/bin/env python3
"""benchmark_prolog_codeintel.py — Prolog vs Python recursive code-intelligence benchmark.
Answers one question with numbers: is the Prolog codebase-intelligence layer
(``src/orchestrata_ultimate/tools/codebase_db.pl``) better/faster than plain
Python recursion for recursive code-intelligence queries (reverse-dependency
closure, transitive module deps, cycle enumeration, dependency paths)?
Four drivers are benchmarked over the IDENTICAL edge set (dumped once from
Prolog so resolver differences never pollute the recursion comparison):
P-sub — Prolog via subprocess swipl (cold: pays full consult every call).
P-janus — Prolog via in-process patched janus_swi (warm: consult once).
Py-mem — pure-Python recursion (BFS/DFS + memo) over the dumped edges.
Py-cli — end-to-end ``serialize_codebase_ast.py`` timings (context only).
Correctness parity (P-sub == P-janus == Py-mem, as frozensets) is asserted
BEFORE any timing number is trusted — see ``run_parity_and_benchmark``.
Usage:
uv run python scripts/benchmark_prolog_codeintel.py \\
[--ast-json plans/ast-ultimate.json] [--runs 5] [--workdir DIR] \\
[--report reports/prolog-codeintel-benchmark.md] [--skip-cli]
"""
from __future__ import annotations
import argparse
import json
import os
import platform
import statistics
import subprocess
import sys
import tempfile
import time
from collections import Counter, defaultdict, deque
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, cast
REPO_ROOT = Path(__file__).resolve().parent.parent
RULES_PL = REPO_ROOT / "src" / "orchestrata_ultimate" / "tools" / "codebase_db.pl"
DEFAULT_AST_JSON = REPO_ROOT / "plans" / "ast-ultimate.json"
DEFAULT_REPORT = REPO_ROOT / "reports" / "prolog-codeintel-benchmark.md"
NO_LIMIT = 1_000_000 # "limit" passed to tool_* predicates so nothing truncates
W4_MIN_HOPS = 3
W1_W2_COUNT = 10
SWIPL_TIMEOUT_S = 300
# =====================================================================
# Small utilities
# =====================================================================
def quote_atom(value: str) -> str:
"""Render *value* as a single-quoted Prolog atom literal (safe: no
goal-string substitution — the value is embedded directly by position,
never by search/replace, so it cannot corrupt other placeholders)."""
escaped = value.replace("\\", "\\\\").replace("'", "\\'")
return f"'{escaped}'"
def stats_of(times_s: list[float]) -> dict[str, float]:
return {
"median_ms": statistics.median(times_s) * 1000,
"min_ms": min(times_s) * 1000,
"max_ms": max(times_s) * 1000,
"n": len(times_s),
}
@dataclass
class QueryTiming:
label: str
result: Any
times_s: list[float]
first_call_s: float | None = None # warm modes only: the discarded warmup
@property
def stats(self) -> dict[str, float]:
return stats_of(self.times_s)
@dataclass
class WorkloadOutcome:
name: str
parity_ok: bool
parity_notes: list[str] = field(default_factory=list)
timings: dict[str, list[QueryTiming]] = field(default_factory=dict)
# =====================================================================
# Phase 0 — environment
# =====================================================================
def collect_environment() -> dict[str, str]:
swipl_version = subprocess.run(
["swipl", "--version"], capture_output=True, text=True, timeout=30
).stdout.strip()
gil_enabled = getattr(sys, "_is_gil_enabled", None)
cpu_model = ""
try:
with open("/proc/cpuinfo", encoding="utf-8") as f:
for line in f:
if line.startswith("model name"):
cpu_model = line.split(":", 1)[1].strip()
break
except OSError:
pass
return {
"swipl_version": swipl_version,
"python_version": sys.version.replace("\n", " "),
"gil_enabled": str(gil_enabled()) if gil_enabled is not None else "unknown",
"platform": platform.platform(),
"cpu_model": cpu_model or platform.processor() or "unknown",
"cpu_count": str(os.cpu_count()),
}
# =====================================================================
# Phase 1 — facts generation (P-sub, cold, once)
# =====================================================================
def generate_facts(ast_json: Path, workdir: Path) -> tuple[Path, list[Any], float]:
"""Run load_codebase/2 + save_facts/1 via standalone swipl. Returns
(facts_path, warnings, elapsed_seconds)."""
facts_path = workdir / "facts.pl"
query_path = workdir / "gen_facts.pl"
warn_marker = "WARNCOUNT"
query_path.write_text(
f":- consult({quote_atom(str(RULES_PL))}).\n"
"run :-\n"
f" load_codebase({quote_atom(str(ast_json))}, R),\n"
" get_dict(warnings, R, W),\n"
f" save_facts({quote_atom(str(facts_path))}),\n"
f' format("{warn_marker} ~q~n", [W]).\n',
encoding="utf-8",
)
t0 = time.perf_counter()
proc = subprocess.run(
["swipl", "-q", "-g", "run", "-g", "halt", str(query_path)],
capture_output=True,
text=True,
timeout=SWIPL_TIMEOUT_S,
)
elapsed = time.perf_counter() - t0
warnings: list[Any] = []
for line in proc.stdout.splitlines():
if line.startswith(warn_marker):
raw = line[len(warn_marker) :].strip()
warnings.append(raw)
if not facts_path.exists():
raise RuntimeError(
f"facts generation failed: rc={proc.returncode}\n"
f"stdout={proc.stdout[-2000:]}\nstderr={proc.stderr[-2000:]}"
)
return facts_path, warnings, elapsed
def measure_consult_overhead(facts_path: Path, runs: int) -> dict[str, float]:
"""P-sub consult overhead alone: consult rules+facts, goal = true, no query."""
times: list[float] = []
for _ in range(runs):
query_path_fd, query_path_name = tempfile.mkstemp(
suffix=".pl", dir=facts_path.parent
)
os.close(query_path_fd)
query_path = Path(query_path_name)
try:
query_path.write_text(
f":- consult({quote_atom(str(RULES_PL))}).\n"
f":- consult({quote_atom(str(facts_path))}).\n"
"run :- true.\n",
encoding="utf-8",
)
t0 = time.perf_counter()
subprocess.run(
["swipl", "-q", "-g", "run", "-g", "halt", str(query_path)],
capture_output=True,
text=True,
timeout=SWIPL_TIMEOUT_S,
)
times.append(time.perf_counter() - t0)
finally:
query_path.unlink(missing_ok=True)
return stats_of(times)
# =====================================================================
# Phase 2 — edge dump (Prolog -> JSON lines) + Py-mem graph build
# =====================================================================
def dump_edges(rules_pl: Path, facts_path: Path, workdir: Path) -> tuple[Path, float]:
"""Dump calls_resolved/3, inherits_resolved/2, dep_raw/2, module/3, symbol/7
as JSON lines, via one swipl subprocess. Returns (edges_path, elapsed_s)."""
edges_path = workdir / "edges.jsonl"
query_path = workdir / "dump_edges.pl"
query_path.write_text(
":- use_module(library(http/json)).\n"
f":- consult({quote_atom(str(rules_pl))}).\n"
f":- consult({quote_atom(str(facts_path))}).\n"
"dump_line(Out, D) :- json_write(Out, D, [width(0)]), nl(Out).\n"
"run :-\n"
" setup_call_cleanup(\n"
f" open({quote_atom(str(edges_path))}, write, Out, [encoding(utf8)]),\n"
" ( forall(calls_resolved(A,B,L),\n"
" dump_line(Out, json{rel:calls_resolved, a:A, b:B, l:L})),\n"
" forall(inherits_resolved(A,B),\n"
" dump_line(Out, json{rel:inherits_resolved, a:A, b:B})),\n"
" forall(dep_raw(A,B),\n"
" dump_line(Out, json{rel:dep_raw, a:A, b:B})),\n"
" forall(module(M,F,L),\n"
" dump_line(Out, json{rel:module, a:M, f:F, l:L})),\n"
" forall(symbol(Q,M,K,N,P,_,_),\n"
" dump_line(Out, json{rel:symbol, q:Q, m:M, k:K, n:N, p:P}))\n"
" ),\n"
" close(Out)).\n",
encoding="utf-8",
)
t0 = time.perf_counter()
proc = subprocess.run(
["swipl", "-q", "-g", "run", "-g", "halt", str(query_path)],
capture_output=True,
text=True,
timeout=SWIPL_TIMEOUT_S,
)
elapsed = time.perf_counter() - t0
if not edges_path.exists() or not edges_path.stat().st_size:
# A failed consult (e.g. missing facts file) is only a WARNING for
# swipl: rc stays 0 and the forall dump still runs, producing an
# EMPTY edges file. Any real codebase has module/symbol records, so
# an empty dump always means the KB never loaded — treat it as the
# failure it is (mirrors psub_query's empty-result check).
raise RuntimeError(
f"edge dump failed: rc={proc.returncode}\n"
f"stdout={proc.stdout[-2000:]}\nstderr={proc.stderr[-2000:]}"
)
return edges_path, elapsed
@dataclass
class Graph:
dep_edge_adj: dict[str, set[str]]
dep_edge_radj: dict[str, set[str]]
mod_edge_adj: dict[str, set[str]]
mod_edge_radj: dict[str, set[str]]
modules: set[str]
symbols: dict[str, dict[str, str]]
calls_in_degree: Counter[str]
n_calls_resolved: int
n_inherits_resolved: int
n_dep_raw: int
def build_graph(edges_path: Path) -> tuple[Graph, float]:
"""Parse the JSON-lines edge dump and build the Py-mem adjacency
structures. Returns (graph, elapsed_seconds) — the elapsed time IS the
"Py-mem graph build" number reported separately from query time."""
t0 = time.perf_counter()
dep_edge_adj: dict[str, set[str]] = defaultdict(set)
dep_edge_radj: dict[str, set[str]] = defaultdict(set)
dep_raw_pairs: list[tuple[str, str]] = []
modules: set[str] = set()
symbols: dict[str, dict[str, str]] = {}
calls_in_degree: Counter[str] = Counter()
n_calls_resolved = 0
n_inherits_resolved = 0
with open(edges_path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
rec = json.loads(line)
rel = rec["rel"]
if rel == "calls_resolved":
a, b = rec["a"], rec["b"]
dep_edge_adj[a].add(b)
dep_edge_radj[b].add(a)
calls_in_degree[b] += 1
n_calls_resolved += 1
elif rel == "inherits_resolved":
a, b = rec["a"], rec["b"]
dep_edge_adj[a].add(b)
dep_edge_radj[b].add(a)
n_inherits_resolved += 1
elif rel == "dep_raw":
dep_raw_pairs.append((rec["a"], rec["b"]))
elif rel == "module":
modules.add(rec["a"])
elif rel == "symbol":
symbols[rec["q"]] = {
"mod": rec["m"],
"kind": rec["k"],
"name": rec["n"],
"parent": rec["p"],
}
mod_edge_adj: dict[str, set[str]] = defaultdict(set)
mod_edge_radj: dict[str, set[str]] = defaultdict(set)
for a, b in dep_raw_pairs:
if b in modules:
mod_edge_adj[a].add(b)
mod_edge_radj[b].add(a)
elapsed = time.perf_counter() - t0
graph = Graph(
dep_edge_adj=dict(dep_edge_adj),
dep_edge_radj=dict(dep_edge_radj),
mod_edge_adj=dict(mod_edge_adj),
mod_edge_radj=dict(mod_edge_radj),
modules=modules,
symbols=symbols,
calls_in_degree=calls_in_degree,
n_calls_resolved=n_calls_resolved,
n_inherits_resolved=n_inherits_resolved,
n_dep_raw=len(dep_raw_pairs),
)
return graph, elapsed
# =====================================================================
# Py-mem recursive algorithms (mirror the tabled Prolog rules exactly)
# =====================================================================
def closure(start: str, adj: dict[str, set[str]]) -> set[str]:
"""Transitive closure of *start*'s direct successors in *adj* — mirrors
affected/2 (when adj=dep_edge_radj) and mod_depends/2 (adj=mod_edge_adj
or mod_edge_radj depending on direction). Iterative DFS with memo (a
visited set doubling as the memo table)."""
result: set[str] = set()
stack = list(adj.get(start, ()))
while stack:
node = stack.pop()
if node in result:
continue
result.add(node)
stack.extend(adj.get(node, ()))
return result
def bfs_path(a: str, b: str, adj: dict[str, set[str]]) -> list[str] | None:
"""Shortest simple path a -> b over adj, or None. A concrete, valid path
— not necessarily identical to Prolog's DFS-found path (parity is on
found/not-found, per protocol)."""
if a == b:
return [a]
prev: dict[str, str | None] = {a: None}
q: deque[str] = deque([a])
while q:
cur = q.popleft()
if cur == b:
break
for nxt in adj.get(cur, ()):
if nxt not in prev:
prev[nxt] = cur
q.append(nxt)
if b not in prev:
return None
path = [b]
while path[-1] != a:
# Loop invariant: only the entry for `a` maps to None (the BFS seed);
# every node reachable from it maps to its str predecessor, and this
# branch only runs while path[-1] != a.
pred = prev[path[-1]]
assert pred is not None
path.append(pred)
path.reverse()
return path
def _paths_to_target(
start: str, target: str, adj: dict[str, set[str]]
) -> list[list[str]]:
"""All simple node-sequences start -> ... -> target over adj, mirroring
walk_to/4's nondeterministic backtracking exactly (no repeated nodes
except the closing return to target)."""
results: list[list[str]] = []
visited = {start}
path = [start]
def dfs(current: str) -> None:
if current == target:
results.append(list(path))
return
for nxt in adj.get(current, ()):
if nxt == target:
path.append(nxt)
results.append(list(path))
path.pop()
elif nxt not in visited:
visited.add(nxt)
path.append(nxt)
dfs(nxt)
path.pop()
visited.discard(nxt)
dfs(start)
return results
def enumerate_raw_cycles(adj: dict[str, set[str]]) -> list[list[str]]:
"""Mirrors mod_cycle/2: for every module M and every direct successor X,
every simple path X -> ... -> M closes a cycle [M, X, ..., M]."""
return [
[m, *tail]
for m in adj
for x in adj.get(m, ())
for tail in _paths_to_target(x, m, adj)
]
def canonicalize_cycle(cycle: list[str]) -> tuple[str, ...]:
"""Mirrors canonical_cycle/2: drop the closing duplicate, rotate to the
(first occurrence of the) minimum element, re-append it as the closer."""
mid = cycle[:-1]
m = min(mid)
i = mid.index(m)
rot = mid[i:] + mid[:i]
return tuple((*rot, m))
def py_mem_cycles(adj: dict[str, set[str]]) -> frozenset[tuple[str, ...]]:
return frozenset(canonicalize_cycle(c) for c in enumerate_raw_cycles(adj))
# =====================================================================
# Workload selection (computed from the live graph)
# =====================================================================
@dataclass
class Workloads:
w1_symbols: list[str]
w2_pairs: list[tuple[str, str]] # (module, direction)
w4_pairs: list[tuple[str, str, int]] # (from, to, hop_count)
wrapper_symbol: str | None
def select_workloads(graph: Graph) -> Workloads:
ranked_symbols = sorted(
graph.calls_in_degree.items(), key=lambda kv: (-kv[1], kv[0])
)
w1_symbols = [q for q, _ in ranked_symbols[:W1_W2_COUNT]]
fan_out = {m: len(graph.mod_edge_adj.get(m, ())) for m in graph.modules}
fan_in = {m: len(graph.mod_edge_radj.get(m, ())) for m in graph.modules}
top_fan_in = sorted(graph.modules, key=lambda m: (-fan_in[m], m))[:5]
top_fan_out = sorted(graph.modules, key=lambda m: (-fan_out[m], m))[:5]
w2_modules: list[str] = []
for m in [*top_fan_in, *top_fan_out]:
if m not in w2_modules:
w2_modules.append(m)
w2_pairs = [(m, d) for m in w2_modules for d in ("deps", "dependents")]
candidates: list[tuple[str, str, int]] = []
for a in sorted(graph.modules):
dist: dict[str, int] = {a: 0}
bfs_queue: deque[str] = deque([a])
while bfs_queue:
cur = bfs_queue.popleft()
for nxt in graph.mod_edge_adj.get(cur, ()):
if nxt not in dist:
dist[nxt] = dist[cur] + 1
bfs_queue.append(nxt)
for b, d in dist.items():
if d >= W4_MIN_HOPS:
candidates.append((a, b, d))
candidates.sort()
w4_pairs: list[tuple[str, str, int]] = []
seen_a: set[str] = set()
for a, b, d in candidates:
if a in seen_a:
continue
w4_pairs.append((a, b, d))
seen_a.add(a)
if len(w4_pairs) >= W1_W2_COUNT:
break
if len(w4_pairs) < W1_W2_COUNT:
chosen_pairs = {(a, b) for a, b, _ in w4_pairs}
for a, b, d in candidates:
if (a, b) in chosen_pairs:
continue
w4_pairs.append((a, b, d))
chosen_pairs.add((a, b))
if len(w4_pairs) >= W1_W2_COUNT:
break
wrapper_symbol = None
for sym_q, _ in ranked_symbols:
if "L" not in sym_q:
wrapper_symbol = sym_q
break
return Workloads(
w1_symbols=w1_symbols,
w2_pairs=w2_pairs,
w4_pairs=w4_pairs,
wrapper_symbol=wrapper_symbol,
)
# =====================================================================
# P-sub driver (cold subprocess per query, JSON via atom_json_dict)
# =====================================================================
def psub_query(
rules_pl: Path,
facts_path: Path,
workdir: Path,
goal_template: str,
out_var: str = "R",
) -> tuple[dict[str, Any], float]:
"""Run one Prolog goal via a fresh swipl subprocess; return (result_dict,
elapsed_seconds) for the WHOLE subprocess (consult + query + write)."""
fd, query_name = tempfile.mkstemp(suffix=".pl", dir=workdir)
os.close(fd)
fd2, result_name = tempfile.mkstemp(suffix=".json", dir=workdir)
os.close(fd2)
query_path = Path(query_name)
result_path = Path(result_name)
try:
query_path.write_text(
":- use_module(library(http/json)).\n"
f":- consult({quote_atom(str(rules_pl))}).\n"
f":- consult({quote_atom(str(facts_path))}).\n"
"run :-\n"
f" {goal_template},\n"
f" atom_json_dict(JsonAtom, {out_var}, [as(atom)]),\n"
" setup_call_cleanup(\n"
f" open({quote_atom(str(result_path))}, write, Out, [encoding(utf8)]),\n"
' format(Out, "~a", [JsonAtom]),\n'
" close(Out)).\n",
encoding="utf-8",
)
t0 = time.perf_counter()
proc = subprocess.run(
["swipl", "-q", "-g", "run", "-g", "halt", str(query_path)],
capture_output=True,
text=True,
timeout=SWIPL_TIMEOUT_S,
)
elapsed = time.perf_counter() - t0
if not result_path.exists() or not result_path.stat().st_size:
raise RuntimeError(
f"P-sub query produced no result: rc={proc.returncode}\n"
f"goal={goal_template}\nstdout={proc.stdout[-1000:]}\nstderr={proc.stderr[-1000:]}"
)
result = json.loads(result_path.read_text(encoding="utf-8"))
return result, elapsed
finally:
query_path.unlink(missing_ok=True)
result_path.unlink(missing_ok=True)
def time_psub(
rules_pl: Path, facts_path: Path, workdir: Path, goal_template: str, runs: int
) -> QueryTiming:
result: dict[str, Any] | None = None
times: list[float] = []
for _ in range(runs):
r, t = psub_query(rules_pl, facts_path, workdir, goal_template)
if result is None:
result = r
times.append(t)
return QueryTiming(label=goal_template, result=result, times_s=times)
# =====================================================================
# P-janus driver (in-process, warm) — sanity-checked in a SUBPROCESS first
# =====================================================================
def janus_sanity_check(rules_pl: Path, facts_path: Path) -> tuple[bool, str]:
"""Consult rules+facts and run one tabled query INSIDE A SUBPROCESS, so a
segfault (if the patched janus_swi genuinely cannot handle this KB)
cannot kill the main benchmark process."""
script = (
"import janus_swi as janus\n"
f"janus.consult({str(rules_pl)!r})\n"
f"janus.consult({str(facts_path)!r})\n"
"r = janus.query_once('tool_stats(R)', {})\n"
"assert r['truth'] is True, r\n"
"print('JANUS_SANITY_OK')\n"
)
proc = subprocess.run(
[sys.executable, "-c", script],
capture_output=True,
text=True,
timeout=SWIPL_TIMEOUT_S,
)
ok = proc.returncode == 0 and "JANUS_SANITY_OK" in proc.stdout
detail = f"rc={proc.returncode}\nstdout={proc.stdout[-2000:]}\nstderr={proc.stderr[-2000:]}"
return ok, detail
def time_warm(
fn: Callable[[], Any], runs: int, warmup: int = 1
) -> tuple[Any, list[float], float]:
"""Generic warm-mode timing harness: discard `warmup` calls (their
result AND elapsed time of the LAST warmup call is kept — for tabled
Prolog predicates this warmup call IS the "table build / first call"
cost the plan asks to report separately), then time `runs` more calls."""
result = None
first_call_s = 0.0
for _ in range(warmup):
t0 = time.perf_counter()
result = fn()
first_call_s = time.perf_counter() - t0
times: list[float] = []
for _ in range(runs):
t0 = time.perf_counter()
result = fn()
times.append(time.perf_counter() - t0)
return result, times, first_call_s
# =====================================================================
# Py-cli driver (end-to-end serialize_codebase_ast.py, cold)
# =====================================================================
def cli_name_for_symbol(sym_info: dict[str, str]) -> str:
if sym_info["kind"] == "method" and sym_info["parent"] != "none":
class_name = sym_info["parent"].rsplit(".", 1)[-1]
return f"{class_name}.{sym_info['name']}"
return sym_info["name"]
def time_cli(args: list[str], runs: int) -> QueryTiming:
result = None
times: list[float] = []
for _ in range(runs):
t0 = time.perf_counter()
proc = subprocess.run(
[
sys.executable,
str(REPO_ROOT / "scripts" / "serialize_codebase_ast.py"),
*args,
],
capture_output=True,
text=True,
cwd=REPO_ROOT,
timeout=SWIPL_TIMEOUT_S,
)
elapsed = time.perf_counter() - t0
times.append(elapsed)
if result is None:
result = {"returncode": proc.returncode, "stdout": proc.stdout}
return QueryTiming(label=" ".join(args), result=result, times_s=times)
# =====================================================================
# Production-wrapper row (post-remediation CodebaseTools: per-instance
# workdir + attach(); the pre-fix class-level /tmp constants are gone)
# =====================================================================
def time_shipped_wrapper(
rules_pl: Path, facts_path: Path, workdir: Path, qname: str
) -> tuple[dict[str, Any], float]:
sys.path.insert(0, str(REPO_ROOT / "src"))
from orchestrata_ultimate.tools.codebase_tools import CodebaseTools # noqa: PLC0415
tools = CodebaseTools(prolog_file=str(rules_pl), workdir=str(workdir))
tools.attach(str(facts_path)) # facts already generated; no reindex needed
t0 = time.perf_counter()
result = tools.impact(qname, limit=NO_LIMIT)
elapsed = time.perf_counter() - t0
return result, elapsed
# =====================================================================
# Parity + benchmark orchestration per workload
# =====================================================================
def qnames_of(dicts: list[dict[str, Any]], key: str) -> frozenset[str]:
return frozenset(d[key] for d in dicts)
def run_w1(
graph: Graph,
workloads: Workloads,
rules_pl: Path,
facts_path: Path,
workdir: Path,
runs: int,
janus_available: bool,
) -> WorkloadOutcome:
outcome = WorkloadOutcome(name="W1 impact closure", parity_ok=True)
outcome.timings["P-sub"] = []
if janus_available:
outcome.timings["P-janus"] = []
outcome.timings["Py-mem"] = []
for q in workloads.w1_symbols:
goal = f"tool_impact({quote_atom(q)}, {NO_LIMIT}, R)"
psub_t = time_psub(rules_pl, facts_path, workdir, goal, runs)
psub_set = qnames_of(psub_t.result["affected"], "qname")
outcome.timings["P-sub"].append(psub_t)
pymem_set = closure(q, graph.dep_edge_radj)
pymem_times: list[float] = []
for _ in range(runs + 1):
t0 = time.perf_counter()
closure(q, graph.dep_edge_radj)
pymem_times.append(time.perf_counter() - t0)
outcome.timings["Py-mem"].append(
QueryTiming(
label=q,
result=pymem_set,
times_s=pymem_times[1:],
first_call_s=pymem_times[0],
)
)
if pymem_set != psub_set:
outcome.parity_ok = False
outcome.parity_notes.append(
f"W1 {q}: P-sub={len(psub_set)} Py-mem={len(pymem_set)} "
f"delta(P-sub-Py-mem)={sorted(psub_set - pymem_set)[:5]} "
f"delta(Py-mem-P-sub)={sorted(pymem_set - psub_set)[:5]}"
)
if janus_available:
import janus_swi as janus # noqa: PLC0415
def call_impact(q: str = q) -> dict[str, Any]:
r = janus.query_once(
"tool_impact(Q, Limit, R)", {"Q": q, "Limit": NO_LIMIT}
)
assert r["truth"] is True, r
return cast("dict[str, Any]", r["R"])
jresult, jtimes, jfirst = time_warm(call_impact, runs)
janus_set = qnames_of(jresult["affected"], "qname")
outcome.timings["P-janus"].append(
QueryTiming(
label=q, result=janus_set, times_s=jtimes, first_call_s=jfirst
)
)
if janus_set != psub_set:
outcome.parity_ok = False
outcome.parity_notes.append(
f"W1 {q}: P-janus != P-sub "
f"delta={sorted(janus_set.symmetric_difference(psub_set))[:5]}"
)
return outcome
def run_w2(
graph: Graph,
workloads: Workloads,
rules_pl: Path,
facts_path: Path,
workdir: Path,
runs: int,
janus_available: bool,
) -> WorkloadOutcome:
outcome = WorkloadOutcome(name="W2 module deps", parity_ok=True)
outcome.timings["P-sub"] = []
if janus_available:
outcome.timings["P-janus"] = []
outcome.timings["Py-mem"] = []
for mod, direction in workloads.w2_pairs:
goal = f"tool_module_deps({quote_atom(mod)}, {direction}, {NO_LIMIT}, R)"
psub_t = time_psub(rules_pl, facts_path, workdir, goal, runs)
psub_set = qnames_of(psub_t.result["modules"], "module")
outcome.timings["P-sub"].append(psub_t)
adj = graph.mod_edge_adj if direction == "deps" else graph.mod_edge_radj
pymem_times = []
pymem_set = closure(mod, adj)
for _ in range(runs + 1):
t0 = time.perf_counter()
closure(mod, adj)
pymem_times.append(time.perf_counter() - t0)
label = f"{mod}[{direction}]"
outcome.timings["Py-mem"].append(
QueryTiming(
label=label,
result=pymem_set,
times_s=pymem_times[1:],
first_call_s=pymem_times[0],
)
)
if pymem_set != psub_set:
outcome.parity_ok = False
outcome.parity_notes.append(
f"W2 {label}: P-sub={len(psub_set)} Py-mem={len(pymem_set)} "
f"delta={sorted(psub_set.symmetric_difference(pymem_set))[:5]}"
)
if janus_available:
import janus_swi as janus # noqa: PLC0415
def call_deps(mod: str = mod, direction: str = direction) -> dict[str, Any]:
r = janus.query_once(
"tool_module_deps(M, D, Limit, R)",
{"M": mod, "D": direction, "Limit": NO_LIMIT},
)
assert r["truth"] is True, r
return cast("dict[str, Any]", r["R"])
jresult, jtimes, jfirst = time_warm(call_deps, runs)
janus_set = qnames_of(jresult["modules"], "module")
outcome.timings["P-janus"].append(
QueryTiming(
label=label, result=janus_set, times_s=jtimes, first_call_s=jfirst
)
)
if janus_set != psub_set:
outcome.parity_ok = False
outcome.parity_notes.append(
f"W2 {label}: P-janus != P-sub "
f"delta={sorted(janus_set.symmetric_difference(psub_set))[:5]}"
)
return outcome
def run_w3(
graph: Graph,
rules_pl: Path,
facts_path: Path,
workdir: Path,
runs: int,
janus_available: bool,
) -> WorkloadOutcome:
outcome = WorkloadOutcome(name="W3 cycle enumeration", parity_ok=True)
goal = f"tool_cycles({NO_LIMIT}, R)"
psub_t = time_psub(rules_pl, facts_path, workdir, goal, runs)
psub_cycles = frozenset(tuple(c) for c in psub_t.result["cycles"])
outcome.timings["P-sub"] = [psub_t]
pymem_times = []
pymem_cycles = py_mem_cycles(graph.mod_edge_adj)
for _ in range(runs + 1):
t0 = time.perf_counter()
py_mem_cycles(graph.mod_edge_adj)
pymem_times.append(time.perf_counter() - t0)
outcome.timings["Py-mem"] = [
QueryTiming(
label="tool_cycles",
result=pymem_cycles,
times_s=pymem_times[1:],
first_call_s=pymem_times[0],
)
]
if pymem_cycles != psub_cycles:
outcome.parity_ok = False
outcome.parity_notes.append(
f"W3: P-sub total={len(psub_cycles)} Py-mem total={len(pymem_cycles)} "
f"delta(P-sub-Py-mem)={sorted(psub_cycles - pymem_cycles)[:5]} "
f"delta(Py-mem-P-sub)={sorted(pymem_cycles - psub_cycles)[:5]}"
)
if janus_available:
import janus_swi as janus # noqa: PLC0415
def call_cycles() -> dict[str, Any]:
r = janus.query_once("tool_cycles(Limit, R)", {"Limit": NO_LIMIT})
assert r["truth"] is True, r
return cast("dict[str, Any]", r["R"])
jresult, jtimes, jfirst = time_warm(call_cycles, runs)
janus_cycles = frozenset(tuple(c) for c in jresult["cycles"])
outcome.timings["P-janus"] = [
QueryTiming(
label="tool_cycles",
result=janus_cycles,
times_s=jtimes,
first_call_s=jfirst,
)
]
if janus_cycles != psub_cycles:
outcome.parity_ok = False
outcome.parity_notes.append(
f"W3: P-janus != P-sub delta={sorted(janus_cycles.symmetric_difference(psub_cycles))[:5]}"
)
return outcome
def run_w4(
graph: Graph,
workloads: Workloads,
rules_pl: Path,
facts_path: Path,
workdir: Path,
runs: int,
janus_available: bool,
) -> WorkloadOutcome:
outcome = WorkloadOutcome(name="W4 dep_path", parity_ok=True)
outcome.timings["P-sub"] = []
if janus_available:
outcome.timings["P-janus"] = []
outcome.timings["Py-mem"] = []
for a, b, hops in workloads.w4_pairs:
goal = f"tool_dep_path({quote_atom(a)}, {quote_atom(b)}, R)"
psub_t = time_psub(rules_pl, facts_path, workdir, goal, runs)
psub_found = bool(psub_t.result.get("found"))
outcome.timings["P-sub"].append(psub_t)
# Validate the Prolog-returned path is a real edge path.
if psub_found:
path = psub_t.result["path"]
for x, y in zip(path, path[1:]):
if y not in graph.mod_edge_adj.get(x, ()):
outcome.parity_ok = False
outcome.parity_notes.append(
f"W4 {a}->{b}: P-sub path has non-edge step {x}->{y}"
)
pymem_times = []
pymem_path = bfs_path(a, b, graph.mod_edge_adj)
for _ in range(runs + 1):
t0 = time.perf_counter()
bfs_path(a, b, graph.mod_edge_adj)
pymem_times.append(time.perf_counter() - t0)
pymem_found = pymem_path is not None
label = f"{a}->{b}(hops>={hops})"
outcome.timings["Py-mem"].append(
QueryTiming(
label=label,
result=pymem_found,
times_s=pymem_times[1:],
first_call_s=pymem_times[0],
)
)
if pymem_found != psub_found:
outcome.parity_ok = False
outcome.parity_notes.append(
f"W4 {label}: P-sub found={psub_found} Py-mem found={pymem_found}"
)
if janus_available:
import janus_swi as janus # noqa: PLC0415
def call_dep_path(a: str = a, b: str = b) -> dict[str, Any]:
r = janus.query_once("tool_dep_path(A, B, R)", {"A": a, "B": b})
assert r["truth"] is True, r
return cast("dict[str, Any]", r["R"])
jresult, jtimes, jfirst = time_warm(call_dep_path, runs)
janus_found = bool(jresult.get("found"))
outcome.timings["P-janus"].append(
QueryTiming(
label=label, result=janus_found, times_s=jtimes, first_call_s=jfirst
)
)
if janus_found != psub_found:
outcome.parity_ok = False
outcome.parity_notes.append(
f"W4 {label}: P-janus found={janus_found} P-sub found={psub_found}"
)
return outcome
# =====================================================================
# Report generation
# =====================================================================
def fmt_ms(x: float) -> str:
# Py-mem query times are frequently sub-millisecond; 2 decimals would
# round them all to "0.00" and hide real (if tiny) differences.
if 0 < x < 1:
return f"{x:.4f}"
return f"{x:.2f}"
def workload_summary_table(outcome: WorkloadOutcome) -> str:
lines = [
"| mode | median (ms) | min (ms) | max (ms) | n queries | n runs/query |",
"|---|---:|---:|---:|---:|---:|",
]
for mode, timings in outcome.timings.items():
all_times = [t for qt in timings for t in qt.times_s]
if not all_times:
continue
s = stats_of(all_times)
lines.append(
f"| {mode} | {fmt_ms(s['median_ms'])} | {fmt_ms(s['min_ms'])} | "
f"{fmt_ms(s['max_ms'])} | {len(timings)} | {timings[0].stats['n']} |"
)
return "\n".join(lines)
def workload_detail_table(outcome: WorkloadOutcome) -> str:
modes = list(outcome.timings.keys())
lines = [
"| query | "
+ " | ".join(f"{m} median (ms)" for m in modes)
+ " | P-janus first-call (ms) |",
"|---" * (len(modes) + 2) + "|",
]
n_queries = max((len(v) for v in outcome.timings.values()), default=0)
for i in range(n_queries):
row = []
label = None
janus_first = ""
for m in modes:
qts = outcome.timings[m]
if i < len(qts):
qt = qts[i]
label = label or qt.label
row.append(fmt_ms(qt.stats["median_ms"]))
if m == "P-janus" and qt.first_call_s is not None:
janus_first = fmt_ms(qt.first_call_s * 1000)
else:
row.append("n/a")
lines.append(f"| {label} | " + " | ".join(row) + f" | {janus_first or 'n/a'} |")
return "\n".join(lines)
def verdict_line(outcome: WorkloadOutcome, mode: str, baseline: str) -> str | None:
if mode not in outcome.timings or baseline not in outcome.timings:
return None
mode_times = [t for qt in outcome.timings[mode] for t in qt.times_s]
base_times = [t for qt in outcome.timings[baseline] for t in qt.times_s]
if not mode_times or not base_times:
return None
mode_med = statistics.median(mode_times)
base_med = statistics.median(base_times)
ratio = base_med / mode_med if mode_med else float("inf")
verdict = "PASS" if ratio > 1.0 else "FAIL"
return (
f"- **{mode} vs {baseline}** ({outcome.name}): {verdict} — "
f"{mode} median {fmt_ms(mode_med * 1000)} ms vs {baseline} median "
f"{fmt_ms(base_med * 1000)} ms (ratio {ratio:.2f}x)"
)
def build_report(
env: dict[str, str],
graph: Graph,
workloads: Workloads,
facts_elapsed: float,
facts_warnings: list[Any],
edge_dump_elapsed: float,
graph_build_elapsed: float,
consult_overhead: dict[str, float],
janus_consult_s: float | None,
janus_available: bool,
janus_error: str,
outcomes: dict[str, WorkloadOutcome],
wrapper_row: tuple[str, dict[str, Any], float] | None,
cli_outcomes: dict[str, QueryTiming],
runs: int,
) -> str:
lines: list[str] = []
lines.append("# Prolog vs Python codebase-intelligence benchmark\n")
lines.append(
"Generated by `scripts/benchmark_prolog_codeintel.py`. Answers: is the Prolog "
"codebase-intelligence layer (`codebase_db.pl`) better/faster than plain Python "
"recursion for recursive code-intelligence queries?\n"
)
lines.append("## Environment\n")
lines.extend(f"- **{k}**: {v}" for k, v in env.items())
lines.append("")
lines.append("## Graph stats\n")
lines.append(f"- modules: {len(graph.modules)}")
lines.append(f"- symbols: {len(graph.symbols)}")
lines.append(f"- calls_resolved edges: {graph.n_calls_resolved}")
lines.append(f"- inherits_resolved edges: {graph.n_inherits_resolved}")
lines.append(f"- dep_raw edges: {graph.n_dep_raw}")
lines.append(
f"- facts-generation warnings: {facts_warnings if facts_warnings else '[]'}"
)
lines.append("")
lines.append("## Setup timings (not part of any driver's query time)\n")
lines.append("| step | elapsed (ms) |")
lines.append("|---|---:|")
lines.append(
f"| facts generation (load_codebase + save_facts, cold) | {fmt_ms(facts_elapsed * 1000)} |"
)
lines.append(
f"| P-sub consult overhead alone (empty goal, median of {runs}) | {fmt_ms(consult_overhead['median_ms'])} |"
)
lines.append(
f"| P-sub consult overhead alone (empty goal, min) | {fmt_ms(consult_overhead['min_ms'])} |"
)
if janus_available and janus_consult_s is not None:
lines.append(
f"| janus consult (rules+facts, once) | {fmt_ms(janus_consult_s * 1000)} |"
)
lines.append(
f"| edge dump (Prolog -> JSON lines, informational) | {fmt_ms(edge_dump_elapsed * 1000)} |"
)
lines.append(
f"| Py-mem graph build (JSON lines -> adjacency dicts) | {fmt_ms(graph_build_elapsed * 1000)} |"
)
lines.append("")
lines.append("## P-janus availability\n")
if janus_available:
lines.append(
"Available. Sanity-checked in a subprocess before use in the main process.\n"
)
else:
lines.append("**UNAVAILABLE.** Sanity-check subprocess failed. Exact error:\n")
lines.append("```")
lines.append(janus_error)
lines.append("```\n")
lines.append("## Workload selection\n")
lines.append(
f"- W1 symbols (top-{W1_W2_COUNT} by incoming calls_resolved edges): "
f"{', '.join(workloads.w1_symbols)}"
)
lines.append(
f"- W2 modules ({len(workloads.w2_pairs) // 2} modules x 2 directions = "
f"{len(workloads.w2_pairs)} queries): "
f"{', '.join(sorted({m for m, _ in workloads.w2_pairs}))}"
)
lines.append(
f"- W4 pairs ({len(workloads.w4_pairs)}, hop_count >= {W4_MIN_HOPS}): "
+ "; ".join(f"{a}->{b} ({h} hops)" for a, b, h in workloads.w4_pairs)
)
lines.append(
f"- as-shipped wrapper demo symbol (no uppercase 'L', dodges D2): "
f"{workloads.wrapper_symbol}"
)
lines.append("")
lines.append("## Parity results\n")
lines.append("| workload | parity | notes |")
lines.append("|---|---|---|")
for outcome in outcomes.values():
status = "PASS" if outcome.parity_ok else "FAIL"
notes = (
"; ".join(outcome.parity_notes)
if outcome.parity_notes
else "identical result sets"
)
lines.append(f"| {outcome.name} | {status} | {notes} |")
lines.append("")
lines.extend(
item
for outcome in outcomes.values()
for item in (
f"## {outcome.name}\n",
"### Summary (all queries pooled)\n",
workload_summary_table(outcome),
"",
"### Per-query median (ms)\n",
workload_detail_table(outcome),
"",
)
)
if wrapper_row is not None:
qname, result, elapsed = wrapper_row
lines.append("## As-shipped wrapper (CodebaseTools, explicit KB path)\n")
lines.append(
f"`CodebaseTools(prolog_file=codebase_db.pl).impact({qname!r}, limit={NO_LIMIT})` "
f"— D1 (broken default KB path) and D2 (goal-substitution corruption for symbols "
f"containing uppercase 'L') dodged by construction; D4 (hardcoded /tmp paths) "
f"dodged by pointing `FACTS_FILE`/`RESULT_FILE` at this run's workdir.\n"
)
lines.append(f"- elapsed: {fmt_ms(elapsed * 1000)} ms")
lines.append(
f"- total affected: {result.get('total', 'error: ' + str(result))}"
)
lines.append("")
if cli_outcomes:
lines.append("## Py-cli (end-to-end serialize_codebase_ast.py, context only)\n")
lines.append("| command | median (ms) | min (ms) |")
lines.append("|---|---:|---:|")
for label, qt in cli_outcomes.items():
s = qt.stats
lines.append(
f"| {label} | {fmt_ms(s['median_ms'])} | {fmt_ms(s['min_ms'])} |"
)
lines.append("")
lines.append("## Verdict\n")
lines.append(
'Claim under test: "Prolog is better/faster than Python for recursive work." '
"Assessed per deployment mode against the Py-mem (algorithmic) and Py-cli "
"(end-to-end) baselines. Ratio = baseline_median / mode_median; ratio > 1.0 means "
"the Prolog mode is faster (PASS), ratio <= 1.0 means it is not (FAIL).\n"
)
for outcome in outcomes.values():
for mode in ("P-sub", "P-janus"):
v = verdict_line(outcome, mode, "Py-mem")
if v:
lines.append(v)
if not janus_available:
lines.append("- **P-janus**: FAIL (unavailable) — see error above.")
lines.append("")
if cli_outcomes:
w1_cli_times = [
t
for label, qt in cli_outcomes.items()
if "call-graph" in label
for t in qt.times_s
]
w3_cli_label = next(
(label for label in cli_outcomes if "find-cycles" in label), None
)
if w1_cli_times:
cli_med = statistics.median(w1_cli_times)
for mode in ("P-sub", "P-janus"):
if (
"W1 impact closure" in outcomes
and mode in outcomes["W1 impact closure"].timings
):
mode_times = [
t
for qt in outcomes["W1 impact closure"].timings[mode]
for t in qt.times_s
]
if mode_times:
mode_med = statistics.median(mode_times)
ratio = cli_med / mode_med if mode_med else float("inf")
verdict = "PASS" if ratio > 1.0 else "FAIL"
lines.append(
f"- **{mode} vs Py-cli** (W1, end-to-end): {verdict} — "
f"{mode} median {fmt_ms(mode_med * 1000)} ms vs Py-cli median "
f"{fmt_ms(cli_med * 1000)} ms (ratio {ratio:.2f}x)"
)
if w3_cli_label:
cli_med = statistics.median(cli_outcomes[w3_cli_label].times_s)
for mode in ("P-sub", "P-janus"):
if (
"W3 cycle enumeration" in outcomes
and mode in outcomes["W3 cycle enumeration"].timings
):
mode_times = [
t
for qt in outcomes["W3 cycle enumeration"].timings[mode]
for t in qt.times_s
]
if mode_times:
mode_med = statistics.median(mode_times)
ratio = cli_med / mode_med if mode_med else float("inf")
verdict = "PASS" if ratio > 1.0 else "FAIL"
lines.append(
f"- **{mode} vs Py-cli** (W3, end-to-end): {verdict} — "
f"{mode} median {fmt_ms(mode_med * 1000)} ms vs Py-cli median "
f"{fmt_ms(cli_med * 1000)} ms (ratio {ratio:.2f}x)"
)
lines.append("")
return "\n".join(lines)
# =====================================================================
# main
# =====================================================================
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--ast-json", type=Path, default=DEFAULT_AST_JSON)
parser.add_argument("--runs", type=int, default=5)
parser.add_argument("--workdir", type=Path, default=None)
parser.add_argument("--report", type=Path, default=DEFAULT_REPORT)
parser.add_argument("--skip-cli", action="store_true")
args = parser.parse_args()
ast_json = (
args.ast_json if args.ast_json.is_absolute() else REPO_ROOT / args.ast_json
)
if not ast_json.exists():
print(f"error: AST json not found: {ast_json}", file=sys.stderr)
return 2
report_path = args.report if args.report.is_absolute() else REPO_ROOT / args.report
workdir = args.workdir or Path(tempfile.mkdtemp(prefix="prolog_codeintel_bench_"))
workdir.mkdir(parents=True, exist_ok=True)
runs = args.runs
print("[env] collecting environment info")
env = collect_environment()
print(f"[facts] generating facts from {ast_json} -> {workdir}")
facts_path, facts_warnings, facts_elapsed = generate_facts(ast_json, workdir)
print(f"[facts] done in {facts_elapsed:.2f}s, warnings={facts_warnings}")
print(f"[consult] measuring P-sub consult overhead ({runs} runs)")
consult_overhead = measure_consult_overhead(facts_path, runs)
print("[edges] dumping edge relations")
edges_path, edge_dump_elapsed = dump_edges(RULES_PL, facts_path, workdir)
print(f"[edges] done in {edge_dump_elapsed:.2f}s")
print("[graph] building Py-mem adjacency structures")
graph, graph_build_elapsed = build_graph(edges_path)
print(
f"[graph] done in {graph_build_elapsed:.2f}s ({len(graph.modules)} modules, "
f"{len(graph.symbols)} symbols)"
)
print("[workload] selecting workloads from the live graph")
workloads = select_workloads(graph)
print("[janus] sanity-checking janus_swi in a subprocess")
janus_available, janus_error = janus_sanity_check(RULES_PL, facts_path)
janus_consult_s: float | None = None
if janus_available:
print("[janus] sanity OK; consulting rules+facts in-process")
import janus_swi as janus # noqa: PLC0415
t0 = time.perf_counter()
janus.consult(str(RULES_PL))
janus.consult(str(facts_path))
janus_consult_s = time.perf_counter() - t0
print(f"[janus] consult done in {janus_consult_s:.3f}s")
else:
print(f"[janus] UNAVAILABLE: {janus_error}")
outcomes: dict[str, WorkloadOutcome] = {}
print(f"[W1] impact closure ({len(workloads.w1_symbols)} symbols)")
outcomes["W1 impact closure"] = run_w1(
graph, workloads, RULES_PL, facts_path, workdir, runs, janus_available
)
print(f"[W2] module deps ({len(workloads.w2_pairs)} queries)")
outcomes["W2 module deps"] = run_w2(
graph, workloads, RULES_PL, facts_path, workdir, runs, janus_available
)
print("[W3] cycle enumeration")
outcomes["W3 cycle enumeration"] = run_w3(
graph, RULES_PL, facts_path, workdir, runs, janus_available
)
print(f"[W4] dep_path ({len(workloads.w4_pairs)} pairs)")
outcomes["W4 dep_path"] = run_w4(
graph, workloads, RULES_PL, facts_path, workdir, runs, janus_available
)
wrapper_row = None
if workloads.wrapper_symbol:
print(f"[wrapper] as-shipped CodebaseTools demo on {workloads.wrapper_symbol}")
wresult, welapsed = time_shipped_wrapper(
RULES_PL, facts_path, workdir, workloads.wrapper_symbol
)
wrapper_row = (workloads.wrapper_symbol, wresult, welapsed)
cli_outcomes: dict[str, QueryTiming] = {}
if not args.skip_cli:
print("[cli] timing Py-cli call-graph + find-cycles")
for q in workloads.w1_symbols:
sym = graph.symbols.get(q)
if sym is None:
continue
name = cli_name_for_symbol(sym)
qt = time_cli(["call-graph", "--name", name], runs)
cli_outcomes[f"call-graph --name {name}"] = qt
cli_outcomes["find-cycles"] = time_cli(["find-cycles"], runs)
all_parity_ok = all(o.parity_ok for o in outcomes.values())
print(f"[parity] overall: {'PASS' if all_parity_ok else 'FAIL'}")
for o in outcomes.values():
if not o.parity_ok:
for note in o.parity_notes:
print(f"[parity] FAIL: {note}")
report = build_report(
env=env,
graph=graph,
workloads=workloads,
facts_elapsed=facts_elapsed,
facts_warnings=facts_warnings,
edge_dump_elapsed=edge_dump_elapsed,
graph_build_elapsed=graph_build_elapsed,
consult_overhead=consult_overhead,
janus_consult_s=janus_consult_s,
janus_available=janus_available,
janus_error=janus_error,
outcomes=outcomes,
wrapper_row=wrapper_row,
cli_outcomes=cli_outcomes,
runs=runs,
)
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(report, encoding="utf-8")
print(f"[report] written to {report_path}")
print(f"[workdir] {workdir}")
return 0
if __name__ == "__main__":
sys.exit(main())