# Chapter 8: Prolog at the Edge — The WASM Revolution
Overview
Every chapter in Part III so far has been about the server side of the architecture. The Go orchestrator embeds libswipl and calls it at microsecond latency. The tabling engine resolves cyclic infrastructure graphs without stack overflow. These are real production capabilities, and they live in the Go binary running on the Mint VM.
This chapter goes somewhere different. We are going to take the exact same .pl files built in Parts I and II — firewall.pl from Chapter 3, infrastructure.pl from Chapter 4, log_parser.pl from Chapter 5 — and run them in a web browser. Not by sending data to the server and getting an answer back. Not by translating the Prolog rules into JavaScript. By compiling the SWI-Prolog engine itself to WebAssembly and loading it alongside the original, unmodified knowledge base files in the browser's JavaScript runtime.
The capability that makes this possible is swipl-wasm: the official SWI-Prolog WebAssembly distribution, which ships as a pair of files (swipl-web.js and swipl-web.wasm) that can be loaded in any modern browser. The WASM binary is a complete SWI-Prolog 10.x runtime — the same WAM, the same standard library predicates, the same tabling engine — compiled to the WASM instruction set and sandboxed by the browser's security model.
The engineering implication is significant. A validation rule written once in Prolog can run in three places without modification: in the REPL during development, in the Go orchestrator on the server, and in the browser at the edge. The rule is the source of truth in all three contexts. There is no translation, no re-implementation, no version drift.
8.1 The Edge Logic Paradigm
The traditional web architecture for complex validation looks like this: the user fills in a form, the browser sends the form data to the server, the server evaluates it against the validation rules, and the server sends back a response. For simple validation — "is this field non-empty?", "is this a valid email address?" — JavaScript has handled this client-side for decades. But for complex, rule-based validation — "does this proposed firewall rule conflict with an existing policy?", "does this package dependency list satisfy all version constraints?", "is this network configuration reachable given the current topology?" — the rules have traditionally lived on the server, because that is where the logic engine lives.
This creates a latency tax on every complex validation interaction. The user types a firewall rule, clicks "Validate", waits for a round-trip to the server, and gets feedback. If they made three mistakes, that is three round-trips before the configuration is correct. On a local network this is tolerable. On a mobile connection to a remote datacenter management interface, it degrades the user experience meaningfully.
WASM edge logic eliminates this tax for the validation step:
TRADITIONAL SERVER VALIDATION vs WASM EDGE VALIDATION
─────────────────────────────────────────────────────────────────
TRADITIONAL: every validation is a network round-trip
Browser Network Go Server
┌─────────────────┐ ┌────────────────┐
│ User fills form │ │ │
│ [Submit] │──── HTTP POST ──────────▶│ firewall.pl │
│ │ │ evaluate/5 │
│ Wait... │◀─── HTTP Response ───────│ → allow/deny │
│ Show feedback │ │ │
│ │ │ │
│ Fix mistake │──── HTTP POST ──────────▶│ evaluate/5 │
│ [Submit again] │◀─── HTTP Response ───────│ → allow/deny │
└─────────────────┘ └────────────────┘
Latency per validation: 5–200ms (network dependent)
Server load: every keystroke that triggers validation
──────────────────────────────────────────────────────────────
WASM EDGE: validation runs in the browser, zero network cost
Browser (WASM sandbox)
┌──────────────────────────────────────────────────────────┐
│ │
│ ┌─────────────────┐ ┌──────────────────────────┐ │
│ │ UI / Form input │─────▶│ swipl-web.wasm │ │
│ │ │ │ firewall.pl loaded │ │
│ │ Instant feedback│◀─────│ evaluate_packet/5 │ │
│ │ (no network) │ │ → allow / deny │ │
│ └─────────────────┘ └──────────────────────────┘ │
│ │
│ When validated: send only the CONFIRMED VALID config │
└────────────────────────┬─────────────────────────────────┘
│ HTTP POST (once, valid data only)
▼
Go Server
┌────────────────┐
│ Persists config│
│ Applies to FW │
└────────────────┘
Latency per validation: <5ms (same process, WASM call)
Server load: only receives pre-validated, correct configs
─────────────────────────────────────────────────────────────────
The Prolog source files are identical in both paths.
The logic runs on the client. The server trusts but verifies.
─────────────────────────────────────────────────────────────────
The "trusts but verifies" note at the bottom is not incidental. WASM edge validation is a user experience improvement, not a security boundary. The Go server must still re-validate any configuration it receives, because a determined attacker can bypass the browser and send raw HTTP requests with invalid data. The value of WASM edge logic is that legitimate users get instant feedback and the server's validation load is reduced — not that server validation can be removed.
The architectural phrase for this pattern is "offline-first logic": the complex reasoning capability exists in the client and functions correctly without network connectivity. A datacenter management interface that can validate firewall configurations, check dependency constraints, and verify network reachability entirely in the browser — without a server connection — is a qualitatively better tool than one that requires a live connection for every interaction. This is particularly valuable for infrastructure tools used in disaster recovery scenarios, where network connectivity to the management plane may be degraded at exactly the moment the tool is most needed.
8.2 Loading the WASM Engine
The swipl-wasm distribution is maintained as part of the official SWI-Prolog project. In 2026, it ships with every SWI-Prolog 10.x release and is available via the project's CDN. For production deployment, serving the WASM binary from the same origin as the application is strongly recommended — CDN WASM binaries are subject to CDN availability and introduce a third-party dependency into a security-sensitive component.
For development on the Mint VM, the simplest setup is to copy the WASM distribution files into the logic-lab web directory and serve them locally. Create the web assets directory:
mkdir -p ~/logic-lab/web/assets
cd ~/logic-lab/web/assets
# Download the swipl-wasm distribution for SWI-Prolog 10.x
# The canonical source is the SWI-Prolog GitHub releases page.
# The two required files are swipl-web.js and swipl-web.wasm.
wget https://www.swi-prolog.org/download/swi-prolog-wasm/swipl-web.js
wget https://www.swi-prolog.org/download/swi-prolog-wasm/swipl-web.wasm
# Copy the Prolog knowledge base files into the web directory
# so the WASM engine can load them via the virtual filesystem.
cp ~/logic-lab/prolog/firewall.pl ~/logic-lab/web/assets/
cp ~/logic-lab/prolog/infrastructure.pl ~/logic-lab/web/assets/
The WASM engine uses an in-memory virtual filesystem (provided by Emscripten, the compiler toolchain used to build the WASM binary). Prolog files loaded by the engine are read from this virtual filesystem, not from the host machine's disk. When loading the engine in a browser context, knowledge base files must either be bundled into the page via a virtual filesystem mount, fetched over HTTP and written into the virtual FS, or inlined as strings and loaded via swipl.prolog.load_string/2.
The JavaScript initialisation block that loads the engine:
// wasm-loader.js
// Initializes the SWI-Prolog WASM engine and loads the knowledge base.
// Part III, Chapter 8 - Modern SWI-Prolog (2026 Edition)
// The SWIPL() function is exported by swipl-web.js.
// It returns a Promise that resolves when the WASM binary is loaded,
// compiled, and the engine is initialised.
async function initPrologEngine() {
const swipl = await SWIPL({
arguments: [
"-q", // quiet mode: suppress banner and prompts
"--nosignals", // disable Unix signal handling (N/A in browser)
],
// locateFile tells the loader where to find the .wasm binary
// relative to the HTML page loading swipl-web.js.
locateFile: (file) => `/assets/${file}`,
});
// Load the knowledge base files from the server into the WASM
// virtual filesystem, then consult them into the Prolog engine.
const kbFiles = ['firewall.pl', 'infrastructure.pl'];
for (const filename of kbFiles) {
// Fetch the Prolog file from the web server.
const response = await fetch(`/assets/${filename}`);
const source = await response.text();
// Write the source into the WASM virtual filesystem.
// The path '/kb/' is a convention — the WASM engine treats
// it as a normal Unix path in its sandboxed environment.
swipl.FS.writeFile(`/kb/${filename}`, source);
// Consult the file into the running Prolog engine.
// This is equivalent to running :- consult('/kb/firewall.pl').
// at the Prolog prompt.
const consultQuery = swipl.prolog.query(
`consult('/kb/${filename}')`
);
const result = consultQuery.once();
consultQuery.close();
if (!result) {
throw new Error(`Failed to consult ${filename} into Prolog engine`);
}
}
console.log('Prolog engine online. Knowledge base loaded.');
return swipl;
}
The SWIPL() call is asynchronous because loading and compiling a WASM binary is not instantaneous — on a typical development machine, it takes 200–800 milliseconds on first load, after which the browser caches the compiled binary and subsequent loads are near-instantaneous. The await pattern ensures the application does not attempt Prolog queries before the engine is ready.
The swipl.FS object is the Emscripten virtual filesystem API. writeFile creates a file in the WASM sandbox's in-memory filesystem. Once written, the file is accessible to Prolog's consult/1 using the normal Unix path syntax. The WASM sandbox has no access to the host machine's filesystem — it can only see what has been explicitly written into the virtual FS.
A minimal HTML page that wires this together:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Homelab Logic — Firewall Configurator</title>
</head>
<body>
<h1>Firewall Rule Validator</h1>
<div id="validator-ui">
<!-- Form elements managed by application JS, not detailed here -->
</div>
<!-- Load the SWI-Prolog WASM engine -->
<script src="/assets/swipl-web.js"></script>
<!-- Application logic -->
<script type="module" src="/assets/wasm-loader.js"></script>
<script type="module" src="/assets/firewall-ui.js"></script>
</body>
</html>
Start a simple HTTP server to serve the files during development:
cd ~/logic-lab/web
python3 -m http.server 8080
# Open http://localhost:8080 in a browser on the Mint VM
The browser's WASM sandbox enforces the same-origin policy and Content Security Policy headers. For local development over localhost, these policies are permissive. For production deployment, the server must set Content-Type: application/wasm for the .wasm file — browsers refuse to execute WASM loaded with an incorrect MIME type — and the Content Security Policy must include 'wasm-unsafe-eval' in the script-src directive.
8.3 The JavaScript-Prolog Bridge
The interface between JavaScript and the WASM Prolog engine is the swipl.prolog.query() function. It accepts a Prolog goal as a string and returns a query iterator object. The iterator has two methods: next() for retrieving one solution at a time, and once() for retrieving the first solution and closing the query. Bindings for query variables are returned as a JavaScript object.
// Simple boolean query — is the engine running?
const q = swipl.prolog.query("true");
const result = q.once(); // Returns {} (empty bindings object) on success,
// or false if the goal fails
q.close();
console.log(result !== false); // true
Variable bindings are returned as JavaScript values that have been automatically converted from Prolog terms:
// Query with variable binding extraction
const q = swipl.prolog.query("X is 2 + 3");
const bindings = q.once();
q.close();
console.log(bindings.X); // 5 (JavaScript number)
The type conversion that the WASM bridge performs is the central concern of this section, and it mirrors the Go bridge discussion in Chapter 6 — the same questions about which Prolog type corresponds to which host-language type, and the same security concern about atoms versus strings for external data.
JS → PROLOG TYPE CONVERSION AT THE WASM BOUNDARY
─────────────────────────────────────────────────────────────────
JavaScript Type Prolog Term Notes
───────────────── ──────────────── ─────────────────────
number (integer) integer 42 → 42
number (float) float 3.14 → 3.14
string string ← ALWAYS "hello" → "hello"
(NEVER atom) User input must be
string, not atom.
boolean true atom: true JS true → Prolog true
boolean false atom: false JS false → Prolog false
null / undefined atom: null No Prolog equivalent
Array list [1,"a",3] → [1,"a",3]
Object {k:v, ...} Dict {a:1} → _{a:1}
Object tagged tagged Dict {tag:"vm",a:1} → vm{a:1}
─────────────────────────────────────────────────────────────────
The string → string rule is NON-NEGOTIABLE in the browser.
The WASM engine's atom table is permanently allocated inside
the WASM linear memory (default 256 MB). User input converted
to atoms leaks memory for the session lifetime. See §8.5.
─────────────────────────────────────────────────────────────────
The Object → Dict conversion deserves close attention because it is the mechanism by which form data crosses from the UI into the Prolog knowledge base. The swipl.prolog.query() function accepts a template string, and JavaScript objects passed via template interpolation are automatically converted to Prolog terms using the table above.
However, the safe pattern for passing user-supplied data is not string interpolation — that is a Prolog injection risk if the user's input contains special characters. The correct pattern is to use the query's variable binding interface, which handles escaping and type conversion:
// UNSAFE: string interpolation with user data
// If userInput contains ") , foo(" this becomes a valid Prolog injection
const q = swipl.prolog.query(`my_pred("${userInput}")`); // DO NOT DO THIS
// SAFE: pass data as a bound variable through the query object
const q = swipl.prolog.query(
"my_pred(Input, Output)",
{ Input: userInput } // userInput is a JS string → Prolog string
);
const result = q.once();
q.close();
The second form of query() accepts a bindings object as its second argument. The keys correspond to Prolog variable names in the query string, and the values are converted to Prolog terms using the type table above. This is the safe way to pass user data into a Prolog query — the WASM bridge handles the conversion from JS string to Prolog string correctly, and there is no opportunity for injection because the data never appears in a position where the Prolog parser would interpret it as syntax.
The diagram below shows a JavaScript object carrying a proposed firewall rule crossing the WASM boundary to become a Prolog Dict:
JS OBJECT → PROLOG DICT TRANSLATION AT THE WASM BOUNDARY
─────────────────────────────────────────────────────────────────
JavaScript side (V8 heap) Prolog side (WASM linear memory)
const packetSpec = {
src_ip: "192.168.10.5", ──▶ string("192.168.10.5") ← NOT atom
dst_ip: "10.0.0.1", ──▶ string("10.0.0.1") ← NOT atom
src_port: 52341, ──▶ integer(52341)
dst_port: 443, ──▶ integer(443)
protocol: "tcp" ──▶ string("tcp") ← NOT atom
};
swipl.prolog.query(
"evaluate_packet(SrcIP, DstIP, DstPort, Proto, Action)",
{
SrcIP: packetSpec.src_ip, ← JS string → Prolog string
DstIP: packetSpec.dst_ip,
DstPort: packetSpec.dst_port,
Proto: packetSpec.protocol
}
)
│
▼ WASM bridge type conversion
│
evaluate_packet(
"192.168.10.5", ← Prolog string (garbage-collected)
"10.0.0.1", ← Prolog string
443, ← Prolog integer
"tcp", ← Prolog string
Action ← unbound variable — receives result
)
─────────────────────────────────────────────────────────────────
Strings from user input remain strings on the Prolog side.
The firewall.pl rules use atom_string/2 to convert to atoms
when matching against atom-typed rule facts, safely isolating
the conversion inside the trusted rule layer.
─────────────────────────────────────────────────────────────────
The last note in the diagram — that firewall.pl uses atom_string/2 internally when it needs to match a string against an atom-typed fact — is the correct architecture. The boundary receives strings. The knowledge base converts to atoms where necessary for matching against statically declared facts. The conversion happens once, inside the rule, not at the boundary where it would apply to unbounded external input.
8.4 Tutorial: The Client-Side Firewall Configurator
The firewall rule evaluator from Chapter 3 is the ideal demonstration for WASM edge logic. The rules in firewall.pl are purely declarative — they check a packet's source IP, destination port, and protocol against an ordered list of rules and return allow or deny. This is exactly the kind of complex, rule-based evaluation that has traditionally required a server round-trip.
Recall the core predicate from Chapter 3:
% evaluate_packet(+SrcIP, +DstIP, +DstPort, +Protocol, -Action)
% Evaluates a packet against the firewall rule set.
% Returns allow or deny based on the first matching rule.
The JavaScript side creates a function that accepts a packet specification from the UI, calls this predicate in the WASM engine, and returns the result — all within a single JavaScript event loop tick after the first call (the engine is pre-loaded and the knowledge base is already consulted):
// firewall-ui.js
// Client-side firewall rule evaluation using the WASM Prolog engine.
// Part III, Chapter 8 - Modern SWI-Prolog (2026 Edition)
// prologEngine is set by the initialisation in wasm-loader.js
// and shared via module scope or a simple module pattern.
let prologEngine = null;
// Initialise the engine on page load.
document.addEventListener('DOMContentLoaded', async () => {
try {
prologEngine = await initPrologEngine();
setEngineStatus('online');
} catch (err) {
setEngineStatus('error', err.message);
}
});
// evaluateFirewallRule(packetSpec) → Promise<{action, rule, latencyMs}>
//
// packetSpec: {
// src_ip: string — source IP address
// dst_ip: string — destination IP address
// dst_port: number — destination port
// protocol: string — "tcp" | "udp" | "icmp"
// }
//
// Returns an object with:
// action: "allow" | "deny"
// latencyMs: query execution time in milliseconds
async function evaluateFirewallRule(packetSpec) {
if (!prologEngine) {
throw new Error('Prolog engine not initialised');
}
const t0 = performance.now();
// SECURITY: all string fields from the UI are passed as bound
// variables, not interpolated into the query string. The WASM
// bridge converts them to Prolog strings automatically.
// This prevents both Prolog injection and Atom Table Exhaustion.
let query;
try {
query = prologEngine.prolog.query(
"evaluate_packet(SrcIP, DstIP, DstPort, Proto, Action)",
{
SrcIP: packetSpec.src_ip, // JS string → Prolog string
DstIP: packetSpec.dst_ip, // JS string → Prolog string
DstPort: packetSpec.dst_port, // JS number → Prolog integer
Proto: packetSpec.protocol, // JS string → Prolog string
}
);
const bindings = query.once();
const latencyMs = performance.now() - t0;
if (bindings === false) {
// No matching rule found — default deny.
return { action: 'deny', rule: 'default', latencyMs };
}
// bindings.Action is a Prolog atom — the WASM bridge converts
// Prolog atoms to JavaScript strings automatically on extraction.
return {
action: bindings.Action, // "allow" or "deny"
latencyMs: latencyMs.toFixed(2),
};
} finally {
// Always close the query iterator, even if an exception occurs.
// Unclosed queries hold references to Prolog stack frames and
// will leak memory in the WASM engine across the session.
if (query) query.close();
}
}
// UI integration — called when the user clicks "Validate Rule"
async function onValidateClicked() {
const packetSpec = {
src_ip: document.getElementById('src-ip').value.trim(),
dst_ip: document.getElementById('dst-ip').value.trim(),
dst_port: parseInt(document.getElementById('dst-port').value, 10),
protocol: document.getElementById('protocol').value,
};
const resultEl = document.getElementById('validation-result');
resultEl.textContent = 'Evaluating...';
try {
const result = await evaluateFirewallRule(packetSpec);
resultEl.className = result.action === 'allow'
? 'result-allow'
: 'result-deny';
resultEl.textContent = [
`Decision: ${result.action.toUpperCase()}`,
`Latency: ${result.latencyMs}ms`,
].join('\n');
} catch (err) {
resultEl.className = 'result-error';
resultEl.textContent = `Engine error: ${err.message}`;
}
}
function setEngineStatus(status, detail = '') {
const el = document.getElementById('engine-status');
if (!el) return;
el.textContent = status === 'online'
? '● Engine online'
: `✗ Engine error: ${detail}`;
}
When the user enters 192.168.10.5, 10.0.0.1, 443, tcp and clicks "Validate", the following occurs:
CLIENT-SIDE EVALUATION TIMELINE
─────────────────────────────────────────────────────────────────
t=0ms User clicks "Validate Rule"
t=0ms onValidateClicked() executes
t=0ms prologEngine.prolog.query(...) called
JS strings → Prolog string terms (WASM bridge)
t=0ms evaluate_packet/5 begins (WAM execution in WASM)
findall + sort across rule base
first-match semantics applied
t=1-3ms evaluate_packet/5 returns Action = allow
Prolog atom → JS string (WASM bridge)
t=1-3ms result displayed: "Decision: ALLOW Latency: 1.4ms"
t=∞ Network: never contacted
─────────────────────────────────────────────────────────────────
The exact same evaluate_packet/5 logic that runs in the Go
server's embedded engine runs here. Not a re-implementation.
Not a translation. The same Prolog source file, loaded into
a different runtime (WASM instead of native libswipl).
─────────────────────────────────────────────────────────────────
The 1–3ms latency figure reflects the overhead of the WASM bridge type conversions and the WAM's execution of the evaluate_packet/5 predicate on a typical 2026 development machine. The first query after page load may take slightly longer as the JIT compiler in the browser warms up the frequently-executed WASM code paths. Subsequent queries stabilise at sub-millisecond to low-millisecond latency.
It is worth comparing this to what the same validation would require server-side: the JavaScript event, a fetch() call, TCP connection (or connection reuse), HTTP framing, Go HTTP handler dispatch, CGO boundary crossing, Prolog evaluation, CGO boundary return, HTTP response framing, network transit back to the browser, and JavaScript promise resolution. On a local network that is still 5–20ms. On a remote management interface it could be 50–200ms. The WASM engine eliminates all of that for the validation step.
Now extend the evaluator to handle multiple rules at once — useful for "what does my current rule set do with this packet?" analysis:
// analyseAllRules(packetSpec) → Promise<Array<{rule, action}>>
// Returns the evaluation result for every rule in the rule base,
// showing which rule matched and what decision would be made.
async function analyseAllRules(packetSpec) {
if (!prologEngine) throw new Error('Engine not initialised');
const results = [];
let query;
try {
// audit_policy/0 from Chapter 3's firewall.pl returns
// all policy violations. We use policy_violations/1 here
// for the full list of rule evaluations.
query = prologEngine.prolog.query(
"evaluate_packet(SrcIP, DstIP, DstPort, Proto, Action)",
{
SrcIP: packetSpec.src_ip,
DstIP: packetSpec.dst_ip,
DstPort: packetSpec.dst_port,
Proto: packetSpec.protocol,
}
);
// Collect all solutions — evaluate_packet/5 as written in
// Chapter 3 returns only the first match (using findall+sort).
// This call returns exactly one result.
for (const bindings of query) {
results.push({ action: bindings.Action });
}
} finally {
if (query) query.close();
}
return results;
}
The for...of loop over a query object iterates over all solutions using the query iterator's Symbol.iterator implementation, calling next() repeatedly until the predicate fails or exhausts its solutions. This is the clean JavaScript idiom for multi-solution queries — it handles query closing correctly via the iterator protocol and is exception-safe when used with try/finally.
8.5 Memory Limits and Engine Sandboxing
The WASM engine runs inside the browser's WASM sandbox, which imposes resource constraints that differ significantly from the Go server environment. Understanding these constraints prevents a class of failures that are easy to trigger accidentally.
Linear memory. The WASM engine's heap is a contiguous block of linear memory, allocated at engine initialisation. The default size is 256 MB, which is sufficient for the knowledge bases built in this book. Unlike native libswipl, the WASM engine cannot request additional memory from the OS at runtime — the heap is fixed at initialisation time. If a query causes a stack overflow or heap exhaustion, it throws a Prolog exception (resource_error(stack) or resource_error(memory)) that must be caught in JavaScript.
Atom table. In a long-running browser session, the atom table grows monotonically. Every call to the engine that interns a new atom — whether from a Prolog source file loaded via consult/1 or from user data incorrectly converted to atoms at the boundary — permanently consumes linear memory. A session running for several hours with high query throughput can exhaust the atom table if user-derived strings are converted to atoms. This is the browser-specific manifestation of the Atom Table Exhaustion vulnerability established in Chapter 5. The mitigation is identical: all user-derived strings stay as strings.
Stack limits. The Prolog stacks (global, local, trail) are carved from the linear memory allocation. For deeply recursive queries against large knowledge bases, the stack can overflow. In the Go orchestrator, a stack overflow throws an exception that the CGO bridge catches as a Go error. In the browser, the same exception surfaces as a JavaScript exception in the query.next() or query.once() call.
The defensive pattern for every query call in browser Prolog:
// safeQuery(swipl, goalString, bindings) → result or null
// Wraps a Prolog query with full exception handling.
// Never throws to the caller — returns null on any failure.
function safeQuery(swipl, goalString, inputBindings = {}) {
let query = null;
try {
query = swipl.prolog.query(goalString, inputBindings);
const result = query.once();
return result; // {} for success with no bindings,
// {Key: value, ...} for success with bindings,
// false for failure (predicate failed)
} catch (err) {
// Prolog exceptions surface here as JavaScript Error objects.
// The message property contains the Prolog exception term
// converted to a string (e.g., "resource_error(stack)").
if (err.message && err.message.includes('resource_error')) {
console.error('Prolog resource limit exceeded:', err.message);
// The engine may still be usable after a resource_error.
// Tabling caches should be cleared before retrying.
try {
swipl.prolog.query('abolish_all_tables').once();
} catch (_) { /* ignore secondary errors */ }
} else if (err.message && err.message.includes('existence_error')) {
console.error('Prolog predicate not found:', err.message);
// This indicates a knowledge base loading failure —
// the queried predicate was not consulted successfully.
} else {
console.error('Prolog exception:', err.message);
}
return null;
} finally {
// Always close the query. A query that is not closed holds
// stack frames in the Prolog engine, leaking memory.
if (query) {
try { query.close(); } catch (_) { /* ignore */ }
}
}
}
The finally block that calls query.close() is not optional. In the native Go bridge, forgetting to close a query causes a resource leak in the Go process's Prolog engine. In the browser, it causes a more immediate problem: each unclosed query holds a stack frame in the WASM engine's fixed linear memory, and after enough unclosed queries the stack overflows. Applications that call Prolog queries on user input events — keypress handlers, slider interactions, real-time validation — must be scrupulous about closing queries in finally blocks.
The abolish_all_tables call in the resource error handler deserves explanation. When the engine runs out of table space (which is separated from but carved from the same linear memory pool), it throws resource_error(table_space). Clearing all tabling caches frees the table space allocation and makes the engine usable again for subsequent queries. This is the browser equivalent of the --table-space=128m recovery pattern discussed in Chapter 7's production lifecycle section.
The practical memory budget for a browser-deployed Prolog knowledge base:
WASM MEMORY BUDGET (default 256 MB linear memory)
─────────────────────────────────────────────────────────────────
Component Typical Usage Hard Limit
───────────────────── ───────────────── ──────────────────
Engine runtime ~15 MB fixed
Standard library ~8 MB fixed at load
Knowledge base files 1–50 MB depends on KB size
(firewall.pl) ~0.1 MB
(infrastructure.pl) ~0.2 MB
(package_deps.pl) ~0.5 MB
Atom table grows with load ~30–50 MB budget
Global stack grows per query ~50 MB recommended
Local stack grows per query ~30 MB recommended
Trail grows per query ~20 MB recommended
Table space grows with tables ~50 MB recommended
───────────────────── ───────────────── ──────────────────
Total headroom for ~70–100 MB before OOM
complex queries
─────────────────────────────────────────────────────────────────
For knowledge bases larger than ~50 MB, increase the WASM
initial memory in the SWIPL() initialisation options:
SWIPL({ arguments: [...], wasmMemory: new WebAssembly.Memory(
{ initial: 512, maximum: 1024 } // pages × 64KB
)})
─────────────────────────────────────────────────────────────────
For the knowledge bases built in this book, the default 256 MB allocation is ample. The firewall rule evaluator, the infrastructure Dict queries, and the network topology reachability checks together consume well under 50 MB of the WASM heap during normal operation. The memory budget table is provided as a reference for readers building larger knowledge bases for production deployment.
8.6 Chapter Summary: Write Once, Reason Anywhere
The journey from Chapter 1's first swipl session to this chapter describes a complete arc. The same Prolog knowledge base files — the firewall rules, the infrastructure Dicts, the network topology, the DCG log parser — now run in three distinct execution contexts without modification:
In the REPL, during development on the Mint VM, where the knowledge engineer writes and tests rules interactively, traces query execution, and builds the logic incrementally.
In the Go orchestrator, where the same files are loaded by an embedded libswipl engine via CGO, evaluated at microsecond latency against live log data and infrastructure events, and integrated with the Go service's lifecycle, error handling, and concurrency model.
In the browser, where the same files are loaded by the WASM engine, evaluated entirely client-side for instant validation feedback, with no network latency for the validation step and no server load for UI-driven queries.
WRITE ONCE, REASON ANYWHERE
─────────────────────────────────────────────────────────────────
firewall.pl ─────────────────────────────────────────
infrastructure.pl │ │ │
log_parser.pl │ │ │
network_topology.pl │ │ │
▼ ▼ ▼
SWI-Prolog libswipl.so swipl-web.wasm
REPL (CGO) (WebAssembly)
│ │ │
▼ ▼ ▼
Interactive Go service Browser tab
development (Chapter 6) (this chapter)
& testing Microsecond Millisecond
server logic edge validation
─────────────────────────────────────────────────────────────────
The .pl files are the single source of truth.
The runtime adapts to the deployment context.
The logic never changes.
─────────────────────────────────────────────────────────────────
This is not a theoretical property of Prolog. It is a consequence of the design decisions made throughout this book: writing knowledge bases as self-contained modules with clean interfaces, using strings (not atoms) for all external data, keeping I/O out of the logic layer, and structuring rules so they can be called by name from any host language.
Chapter 9 returns to the server to address the concurrency limitation noted at the end of Chapter 6. The Go orchestrator's embedded engine is single-threaded at the Prolog level. A Go web server handling fifty concurrent HTTP requests, each requiring a Prolog query, must queue those requests and evaluate them serially. For low-to-moderate query rates, this is acceptable. For high-throughput scenarios — a CI/CD system validating every commit's dependency graph, or a network management system checking route changes in real time — it becomes the bottleneck.
Chapter 9 builds the "Go-Log" concurrency model: a pool of independent SWI-Prolog engine instances, each running in its own OS thread, managed by a Go pool that dispatches incoming queries to idle engines and collects their results. Each engine in the pool loads the same knowledge base independently. Each engine has its own WAM stacks, its own atom table, and its own tabling cache. There is no shared state between engines, which means there is no locking. The throughput ceiling scales linearly with the number of engines in the pool, bounded only by available CPU cores and memory.
Appendix 8A: WASM Deployment Checklist
SWIPL-WASM PRODUCTION DEPLOYMENT CHECKLIST
─────────────────────────────────────────────────────────────────
Build & Serve
□ Serve swipl-web.wasm with Content-Type: application/wasm
□ Set Content-Security-Policy: script-src 'wasm-unsafe-eval'
□ Serve WASM files from same origin as application (no CDN)
□ Enable gzip/brotli compression for swipl-web.wasm (~3× smaller)
□ Set Cache-Control: immutable for versioned WASM binary
Initialisation
□ Use SWIPL({ arguments: ["-q", "--nosignals"] })
□ Await SWIPL() before any query calls
□ Consult knowledge base files after engine init, before UI ready
□ Handle consult failure explicitly (throw / show error state)
Query Safety
□ Never interpolate user input into query strings (injection risk)
□ Always pass user data via the bindings object (second argument)
□ Always close query iterators in finally blocks
□ Wrap all query calls in safeQuery() or equivalent try/catch
□ Handle false return (predicate failure) distinctly from null (error)
Memory
□ All user-derived string data stays as Prolog strings (never atoms)
□ Call abolish_all_tables after bulk KB modifications
□ Monitor WASM linear memory usage in production (window.performance)
□ Increase wasmMemory if knowledge base exceeds ~50 MB loaded size
─────────────────────────────────────────────────────────────────
Appendix 8B: Snapshot Checkpoint
Snapshot name: 09-chapter-8-complete
Description: WASM Prolog engine operational in browser.
firewall.pl and infrastructure.pl loaded via
virtual FS. Client-side evaluate_packet/5
evaluation with <3ms latency demonstrated.
safeQuery() wrapper with resource_error handling.
Files added: web/assets/wasm-loader.js
web/assets/firewall-ui.js
web/index.html