Skip to main content

Chapter 7: Tabling — Memory-Safe Infinite Reasoning

Overview

In Chapter 6, we passed --table-space=128m to the engine initialisation and moved on without explanation. That flag allocates a dedicated memory region for a capability that does not exist in classical Prolog: the ability to reason correctly over cyclic data without infinite recursion. This chapter explains what that memory region is for, why it is necessary in any production system that touches real infrastructure data, and how to use it.

The failure mode we are solving for is not exotic. It is the predictable consequence of applying depth-first search — which is what SLD resolution is — to data structures that contain cycles. A Linux package dependency graph contains cycles. A network routing table contains cycles. A virtual machine migration dependency graph contains cycles. Any Prolog program that attempts naive recursive reasoning over these structures will exhaust the call stack and crash. In a standalone REPL session, this is inconvenient. In the Go process we built in Chapter 6, it is a process-killing event that takes the entire orchestrator offline.

Tabling, also known as SLG resolution, is the mechanism SWI-Prolog uses to solve this problem. It replaces depth-first search with a memoised evaluation strategy that suspends recursive calls when a cycle is detected, accumulates answers as they are found, and resumes suspended computations when new answers become available. The result is a reasoning engine that handles cyclic data correctly, terminates when classical Prolog would loop forever, and caches intermediate results for reuse — all with a single directive added to the predicate definition.

7.1 The Infinite Loop Problem

We begin with the failure. Understanding exactly how classical Prolog breaks on cyclic data is not academic ceremony — it is the prerequisite for appreciating why tabling's design is the way it is.

Build a small network topology knowledge base representing the Proxmox cluster's internal network. The nodes are VMs and physical hosts; the edges represent direct network connectivity. Create ~/logic-lab/prolog/network_topology.pl:

% network_topology.pl
% Proxmox homelab network topology.
% Part III, Chapter 7 - Modern SWI-Prolog (2026 Edition)

:- module(network_topology, [
    link/2,
    can_reach/2,
    can_reach_safe/2
]).

% link(+From, +To)
% Direct network links between nodes in the cluster.
% The topology is intentionally cyclic — real networks always are.
link(pve_node1,      mint_logic_lab).
link(pve_node1,      debian_core).
link(pve_node1,      pfsense_fw).
link(pfsense_fw,     pve_node1).       % ← cycle: fw routes back to node1
link(pfsense_fw,     internet_gw).
link(mint_logic_lab, backup_target).
link(backup_target,  pve_node2).
link(pve_node2,      pve_node1).       % ← cycle: node2 routes back to node1
link(pve_node2,      debian_core).
link(debian_core,    mint_logic_lab).  % ← cycle: orchestrator talks back to dev VM

Now the naive reachability predicate. This is textbook Prolog — a recursive transitive closure written exactly as a competent Prolog programmer would write it before knowing about tabling:

% can_reach(+From, +To)
% True if there is a path from From to To through any sequence of links.
% WARNING: This predicate will loop infinitely on cyclic graphs.
can_reach(From, To) :- link(From, To).
can_reach(From, To) :- link(From, Mid), can_reach(Mid, To).

Load the file and ask an innocent question:

?- can_reach(pve_node1, internet_gw).

Watch the tracer with trace enabled. The engine begins:

Call: can_reach(pve_node1, internet_gw)
  Call: link(pve_node1, mint_logic_lab)  % first clause, try link
  Exit: link(pve_node1, mint_logic_lab)
  Call: can_reach(mint_logic_lab, internet_gw)
    Call: link(mint_logic_lab, backup_target)
    Exit: link(mint_logic_lab, backup_target)
    Call: can_reach(backup_target, internet_gw)
      Call: link(backup_target, pve_node2)
      Call: can_reach(pve_node2, internet_gw)
        Call: link(pve_node2, pve_node1)
        Call: can_reach(pve_node1, internet_gw)  ← SEEN THIS BEFORE
          Call: link(pve_node1, mint_logic_lab)
          Call: can_reach(mint_logic_lab, internet_gw)  ← SEEN THIS BEFORE
            ...

The engine has no memory of goals it has already attempted. can_reach(pve_node1, internet_gw) appears in the call stack a second time, and the engine begins evaluating it from scratch — which leads back to the same cycle, which calls it a third time, which leads to the same cycle again. This is not a bug in the predicate. It is the correct behaviour of SLD resolution applied to a problem it was not designed for.

The ASCII diagram below shows the search tree the engine attempts to build. The tree is infinite:

SLD RESOLUTION: THE INFINITE SEARCH TREE
─────────────────────────────────────────────────────────────────
  Goal: can_reach(pve_node1, internet_gw)
  │
  ├─ via link(pve_node1, mint_logic_lab)
  │    └─ can_reach(mint_logic_lab, internet_gw)
  │         └─ via link(mint_logic_lab, backup_target)
  │              └─ can_reach(backup_target, internet_gw)
  │                   └─ via link(backup_target, pve_node2)
  │                        └─ can_reach(pve_node2, internet_gw)
  │                             └─ via link(pve_node2, pve_node1)
  │                                  └─ can_reach(pve_node1, internet_gw)
  │                                       └─ *** SAME GOAL *** depth+1
  │                                            └─ ... ∞
  │
  ├─ via link(pve_node1, debian_core)
  │    └─ can_reach(debian_core, internet_gw)
  │         └─ via link(debian_core, mint_logic_lab)
  │              └─ can_reach(mint_logic_lab, internet_gw)
  │                   └─ *** SEEN ABOVE *** depth+1
  │                        └─ ... ∞
  │
  └─ via link(pve_node1, pfsense_fw)
       └─ can_reach(pfsense_fw, internet_gw)
            └─ via link(pfsense_fw, internet_gw)
                 └─ SUCCESS ← but the engine never reaches this
                              because it diverges on earlier branches
─────────────────────────────────────────────────────────────────
  The correct answer EXISTS in the graph (pve_node1 → pfsense_fw
  → internet_gw), but SLD resolution finds the infinite branch
  first and never terminates to discover the finite answer.
─────────────────────────────────────────────────────────────────

In SWI-Prolog's default configuration, this terminates with ERROR: Stack limit (1.0Gb) exceeded — the global stack overflows as the engine accumulates an unbounded number of stack frames for the recursive can_reach calls. In the Go orchestrator from Chapter 6, this exception would be caught by PL_Q_CATCH_EXCEPTION and returned as a resource_error(stack) Go error — the process survives, but the query fails entirely. The correct answer — that pve_node1 can reach internet_gw via pfsense_fw — is never discovered.

The traditional workaround, taught in many Prolog textbooks, is to maintain an explicit visited set and pass it as an extra argument:

% can_reach_safe(+From, +To)
% Cycle-safe reachability using an explicit visited accumulator.
can_reach_safe(From, To) :-
    can_reach_safe(From, To, [From]).

can_reach_safe(From, To, _Visited) :-
    link(From, To).
can_reach_safe(From, To, Visited) :-
    link(From, Mid),
    \+ member(Mid, Visited),
    can_reach_safe(Mid, To, [Mid|Visited]).

This works. It prevents infinite loops by refusing to visit a node that has already been visited in the current path. But it has three significant problems. First, it adds an extra argument to every predicate in the transitive closure chain, which contaminates the interface of any predicate that needs to call it. Second, the member/2 check is O(N) in the length of the visited list, which makes the overall complexity O(N²) in the number of nodes for a dense graph. Third, it does not cache intermediate results — if you ask can_reach_safe(pve_node1, X) to find all reachable nodes, every sub-goal is re-evaluated from scratch even if it was already proven during an earlier part of the same query. In a knowledge base with thousands of package dependencies or routing entries, this overhead is prohibitive.

Tabling solves all three problems simultaneously with a single directive.

7.2 SLG Resolution: The :- table Directive

Add one line to the network_topology.pl module:

:- table can_reach/2.

That is the entirety of the change. The predicate definition of can_reach/2 is unchanged. Load the file and rerun the query:

?- can_reach(pve_node1, internet_gw).
true.

?- can_reach(pve_node1, X).
X = mint_logic_lab ;
X = debian_core ;
X = pfsense_fw ;
X = backup_target ;
X = internet_gw ;
X = pve_node2 ;
false.

No stack overflow. No infinite loop. All reachable nodes are found. The answer is correct and complete.

What changed? The engine's execution strategy for can_reach/2 switched from SLD resolution (depth-first, no memory) to SLG resolution (goal-directed, with memoisation). Understanding this switch at the mechanism level is essential for using tabling correctly, so we will work through it in detail.

When the engine evaluates a tabled predicate, it maintains two structures in the table space (the --table-space=128m region allocated in Chapter 6):

SLG RESOLUTION: THE TABLE SPACE STRUCTURE
─────────────────────────────────────────────────────────────────
  Table Space (128 MB, allocated at engine init)
  ┌─────────────────────────────────────────────────────────────┐
  │                                                             │
  │  Call Table (goals currently being evaluated)               │
  │  ┌──────────────────────────────────────────────────────┐   │
  │  │ can_reach(pve_node1, internet_gw)  → status: active  │   │
  │  │ can_reach(mint_logic_lab, internet_gw) → status: active│  │
  │  │ can_reach(backup_target, internet_gw) → status: active │  │
  │  │ can_reach(pve_node2, internet_gw)  → status: active   │  │
  │  └──────────────────────────────────────────────────────┘   │
  │                                                             │
  │  Answer Table (proven answers, keyed by goal)               │
  │  ┌──────────────────────────────────────────────────────┐   │
  │  │ can_reach(pfsense_fw, internet_gw)  → [true]         │   │
  │  │ can_reach(pve_node1, pfsense_fw)    → [true]         │   │
  │  │ can_reach(pve_node1, internet_gw)   → [true]  ←──────┼───┤
  │  │ can_reach(pve_node1, mint_logic_lab)→ [true]         │   │
  │  └──────────────────────────────────────────────────────┘   │
  │                                                             │
  └─────────────────────────────────────────────────────────────┘

  When the engine encounters can_reach(pve_node1, internet_gw)
  a second time during evaluation:
    → It finds the goal in the Call Table with status 'active'
    → Instead of recursing, it SUSPENDS the current computation
    → It becomes a "consumer" of can_reach(pve_node1, internet_gw)
    → When new answers are added to the Answer Table, all
       consumers of that goal are RESUMED automatically
─────────────────────────────────────────────────────────────────

The key insight is the word suspend. When the engine encounters a tabled goal that is already in the Call Table with status active, it does not fail and it does not recurse. It pauses the current evaluation path and waits. The engine then backtracks and tries other branches. When those branches produce answers, those answers are added to the Answer Table and broadcast to all suspended computations that were waiting on that goal. The suspended computations resume with the new answer and continue from where they left off.

This evaluate-suspend-resume cycle is called SLG resolution (for Selected Literal with Generator — the terminology comes from Earley parsing, which the algorithm resembles structurally). The practical consequences are:

Termination: Any query that would produce a finite set of answers will terminate, even if the underlying data graph is cyclic. The engine detects cycles via the Call Table and suspends rather than recursing, so the call stack depth is bounded by the number of distinct goals, not the depth of the graph.

Completeness: All answers are found. Classical SLD resolution with the visited-set hack is incomplete — it finds the answers reachable from an initial state but may miss answers reachable via different paths depending on the order of clause evaluation. SLG resolution is provably complete for well-moded tabled predicates: every answer that follows from the program's clauses will be discovered.

Memoisation: Once a goal is proven, its answers sit in the Answer Table. If the same goal is called again — within the same query, in a later query, or from a different predicate — the engine retrieves the cached answers rather than re-evaluating the predicate from scratch. This is the automatic caching of Chapter 4's vm_summary/2 logic without writing a single line of caching code.

The performance difference between can_reach_safe/2 (visited-set approach) and tabled can_reach/2 on a network with one hundred nodes is roughly one order of magnitude — the tabled version is faster because each node is visited exactly once and its result is memoised, while the visited-set version re-evaluates paths it has already explored whenever it approaches a visited node from a different direction.

7.3 Tutorial: The Linux Package Dependency Mapper

The Proxmox homelab runs a mix of Debian and Mint-based systems. Every package manager operation — apt install, apt upgrade, apt-get dist-upgrade — resolves a dependency graph that is, in practice, cyclic. Not in the mathematical sense of a package depending on itself directly, but in the sense that shared library dependencies create diamond shapes and mutual runtime dependencies create loops. libssl depends on libcrypto. libcrypto depends on zlib1g. zlib1g recommends packages that in turn require libssl. A naive recursive all_dependencies/2 predicate exhausts the call stack when trying to resolve a complete package tree.

Create ~/logic-lab/prolog/package_deps.pl:

% package_deps.pl
% Linux package dependency mapper with tabling for cycle safety.
% Represents the apt dependency graph of the Mint 22.x development VM.
% Part III, Chapter 7 - Modern SWI-Prolog (2026 Edition)

:- module(package_deps, [
    depends/2,
    all_deps/2,
    install_set/2,
    dependency_cycle/2,
    shared_dep/3,
    dep_count/2
]).

:- use_module(library(aggregate)).

% --- The dependency graph ---
% depends(+Package, +Dependency)
% Direct apt dependencies. A representative subset of the real graph.

depends(swi_prolog,     libgmp10).
depends(swi_prolog,     libssl3).
depends(swi_prolog,     zlib1g).
depends(swi_prolog,     libedit2).
depends(swi_prolog,     libgcc_s1).
depends(swi_prolog,     libarchive13).

depends(libssl3,        libgcc_s1).
depends(libssl3,        libc6).
depends(libssl3,        libcrypto3).         % ← ssl needs crypto

depends(libcrypto3,     zlib1g).
depends(libcrypto3,     libc6).

depends(zlib1g,         libc6).

depends(libgmp10,       libc6).
depends(libgmp10,       libgcc_s1).

depends(libc6,          libgcc_s1).          % ← creates diamond with libgmp10

depends(libgcc_s1,      libc6).              % ← mutual dependency (runtime ABI)

depends(libarchive13,   libssl3).            % ← archive needs ssl (for https)
depends(libarchive13,   zlib1g).
depends(libarchive13,   libc6).
depends(libarchive13,   liblzma5).

depends(liblzma5,       libc6).

depends(golang,         libc6).
depends(golang,         libgcc_s1).

depends(docker_ce,      golang).             % docker runtime deps
depends(docker_ce,      libseccomp2).
depends(docker_ce,      libdevmapper1_02_1).
depends(libseccomp2,    libc6).
depends(libdevmapper1_02_1, libc6).
depends(libdevmapper1_02_1, libgcc_s1).

% Note the mutual dependency between libssl3 and libgcc_s1 through
% libssl3→libcrypto3→zlib1g→libc6→libgcc_s1→libc6 forms a cycle.
% Without tabling, all_deps/2 will loop on this graph.

Now the tabled predicates:

% :- table all_deps/2.
% CRITICAL: Without this directive, all_deps/2 loops on cyclic deps.
:- table all_deps/2.

% all_deps(+Package, -Dependency)
% True if Dependency is a direct or transitive dependency of Package.
% Tabling ensures each (Package, Dependency) pair is proven at most once.
all_deps(Package, Dep) :- depends(Package, Dep).
all_deps(Package, Dep) :- depends(Package, Mid), all_deps(Mid, Dep).

% install_set(+Package, -SortedDeps)
% Returns the complete sorted list of packages needed to install Package.
install_set(Package, SortedDeps) :-
    findall(D, all_deps(Package, D), Deps),
    sort(Deps, SortedDeps).

% dependency_cycle(+Package, -CyclePath)
% Detects direct mutual dependencies (A depends on B, B depends on A).
dependency_cycle(A, cycle(A, B)) :-
    depends(A, B),
    depends(B, A).

% shared_dep(+PkgA, +PkgB, -SharedDep)
% Finds dependencies that two packages have in common.
% Useful for understanding why removing one package might affect another.
shared_dep(PkgA, PkgB, SharedDep) :-
    all_deps(PkgA, SharedDep),
    all_deps(PkgB, SharedDep).

% dep_count(+Package, -Count)
% Returns the total number of transitive dependencies for a package.
dep_count(Package, Count) :-
    aggregate_all(count, all_deps(Package, _), Count).

Load and query:

?- install_set(swi_prolog, Deps).
Deps = [libc6, libarchive13, libcrypto3, libedit2, libgcc_s1,
        libgmp10, liblzma5, libssl3, zlib1g].

?- dep_count(swi_prolog, N).
N = 9.

?- dep_count(docker_ce, N).
N = 7.

?- dependency_cycle(A, Cycle).
Cycle = cycle(libgcc_s1, libc6) ;
Cycle = cycle(libc6, libgcc_s1) ;
false.

?- shared_dep(swi_prolog, docker_ce, Shared).
Shared = libc6 ;
Shared = libgcc_s1 ;
false.

The install_set/2 query completes correctly and instantly despite the libgcc_s1 ↔ libc6 mutual dependency detected by dependency_cycle/2. Without the :- table all_deps/2. directive, install_set(swi_prolog, Deps) would loop. With it, the engine visits each (Package, Dep) pair exactly once, caches the result, and terminates.

The dependency_cycle/2 predicate finds the mutual dependency between libgcc_s1 and libc6 — a real feature of Debian's packaging, where these two packages form a co-installation unit and each lists the other as a runtime dependency. In the apt resolver, this is handled by a special case. In our Prolog knowledge base, it is handled by tabling.

The shared dependencies query is where the memoisation benefit becomes tangible. When shared_dep(swi_prolog, docker_ce, Shared) is called, the engine evaluates all_deps(swi_prolog, SharedDep) and all_deps(docker_ce, SharedDep). Both of these were computed in previous queries and their answers are cached in the Answer Table. The engine retrieves them from the cache rather than recomputing, making the shared dependency calculation essentially free once the individual dependency sets are known.

Let us make the performance benefit concrete with a predicate that would be genuinely expensive without caching — finding all pairs of packages that share at least one common dependency:

% packages_with_shared_deps(-PkgA, -PkgB, -Count)
% Lists pairs of packages that share transitive dependencies,
% with the count of shared deps. Sorted by count descending.
:- table all_deps/2.  % already declared above; shown here for clarity

packages_with_shared_deps(PkgA, PkgB, Count) :-
    findall(PkgA-PkgB,
        (   depends(PkgA, _),    % iterate over all known packages
            depends(PkgB, _),
            PkgA @< PkgB,        % avoid duplicates (A,B) and (B,A)
            shared_dep(PkgA, PkgB, _)
        ),
        Pairs),
    msort(Pairs, _),
    member(PkgA-PkgB, Pairs),
    aggregate_all(count, shared_dep(PkgA, PkgB, _), Count).

Without tabling, this query would recompute all_deps/2 for every pair of packages from scratch — O(P² × D) calls where P is the number of packages and D is the average dependency depth. With tabling, each all_deps/2 result is computed once and cached. The pairs query becomes O(P²) lookups into the Answer Table — a dramatic improvement on any graph of realistic size.

7.4 Answer Subsumption: Tabling as Optimisation

Standard tabling collects all answers to a tabled goal. For can_reach/2, there is a fixed set of reachable nodes and we want all of them. But for some predicates, we do not want all answers — we want the best answer according to some criterion. The canonical example in infrastructure is shortest path: given the network topology, what is the minimum number of hops between two nodes?

Without answer subsumption, a tabled shortest-path predicate would collect every possible path length, including longer redundant ones. With answer subsumption, we can instruct the engine to retain only the minimum value encountered so far, discarding longer paths as they are discovered.

Add to network_topology.pl:

% hop_count(+From, +To, -Hops)
% Counts the minimum number of hops to reach To from From.
% The 'min' mode retains only the minimum Hops value
% for each (From, To) pair, discarding longer paths automatically.
:- table hop_count(_, _, min).

hop_count(From, To, 1) :-
    link(From, To).
hop_count(From, To, Hops) :-
    link(From, Mid),
    hop_count(Mid, To, MidHops),
    Hops is MidHops + 1.

The directive :- table hop_count(_, _, min) tells the engine: this predicate is tabled, and for the third argument (the Hops count), use the built-in min mode to subsume answers. Subsumption means: if the table already contains hop_count(A, B, 3) and a new answer hop_count(A, B, 5) is derived, the new answer is subsumed by the existing one (because 3 < 5) and discarded. If a new answer hop_count(A, B, 2) arrives, it subsumes the existing answer and replaces it.

?- hop_count(pve_node1, internet_gw, Hops).
Hops = 2.

?- hop_count(mint_logic_lab, internet_gw, Hops).
Hops = 3.

?- hop_count(pve_node2, internet_gw, Hops).
Hops = 3.

The engine correctly finds that pve_node1 → pfsense_fw → internet_gw is two hops, and that pve_node2 → pve_node1 → pfsense_fw → internet_gw is three hops. Longer paths through other routes exist — pve_node1 → debian_core → mint_logic_lab → backup_target → pve_node2 → pve_node1 → pfsense_fw → internet_gw — but they are automatically discarded by the subsumption mechanism without the engine needing to explicitly compare them.

Extend this to a full route analyser:

% network_distance_report/0
% Prints a table of minimum hop counts between all node pairs.
network_distance_report :-
    findall(node(N), (link(N, _) ; link(_, N)), Nodes0),
    sort(Nodes0, Nodes),
    format("~n┌─────────────────────────────────────────────────┐~n"),
    format("│         NETWORK DISTANCE REPORT (min hops)      │~n"),
    format("├─────────────────────────────────────────────────┤~n"),
    forall(
        member(node(From), Nodes),
        report_distances_from(From, Nodes)
    ),
    format("└─────────────────────────────────────────────────┘~n~n").

report_distances_from(From, Nodes) :-
    forall(
        member(node(To), Nodes),
        (   From \= To,
            (   hop_count(From, To, H)
            ->  format("│  ~w → ~w : ~w hop(s)~n", [From, To, H])
            ;   format("│  ~w → ~w : unreachable~n", [From, To])
            )
        ;   true
        )
    ).
?- network_distance_report.

┌─────────────────────────────────────────────────┐
│         NETWORK DISTANCE REPORT (min hops)      │
├─────────────────────────────────────────────────┤
│  backup_target → debian_core : 3 hop(s)
│  backup_target → internet_gw : 4 hop(s)
│  backup_target → mint_logic_lab : 3 hop(s)
│  backup_target → pfsense_fw : 4 hop(s)
│  backup_target → pve_node1 : 2 hop(s)
│  backup_target → pve_node2 : 1 hop(s)
│  debian_core → backup_target : 2 hop(s)
│  debian_core → internet_gw : 3 hop(s)
│  ...
└─────────────────────────────────────────────────┘

Answer subsumption in the min/2 form is what Dijkstra's algorithm achieves through priority queue management, but expressed declaratively — the programmer states the optimisation criterion and the tabling engine handles the bookkeeping. This is a concrete example of the "logic as specification, engine as implementation" principle that makes Prolog a genuinely different tool from procedural languages.

For infrastructure use cases beyond shortest path, the subsumption mode can be customised:

ANSWER SUBSUMPTION MODES (SWI-PROLOG 10.x)
─────────────────────────────────────────────────────────────────
  :- table pred(_, _, min).
  Retains the minimum numeric value. Shortest path, lowest cost,
  earliest timestamp.

  :- table pred(_, _, max).
  Retains the maximum numeric value. Highest bandwidth, latest
  version, maximum reliability score.

  :- table pred(_, _, sum).
  Accumulates a running total across all answers. Total cost of
  a dependency tree, cumulative packet count across a route.

  :- table pred(_, _, lattice(join/3)).
  Custom lattice join operation for domain-specific optimisation.
  Implement join(+Old, +New, -Result) to define your partial order.
  Result is retained; dominated answers are discarded.
─────────────────────────────────────────────────────────────────
  All modes prevent answer accumulation for dominated values,
  keeping the Answer Table bounded even on dense graphs.
─────────────────────────────────────────────────────────────────

7.5 The Production Lifecycle: Tabling in Go

Tabling introduces a state management concern that does not exist with classical Prolog: the Answer Table persists between queries. When the Go orchestrator from Chapter 6 calls hop_count(pve_node1, internet_gw, Hops), the result is cached in the table space. The next call with the same arguments retrieves the cached answer immediately. This is exactly what we want for repeated queries.

But consider the lifecycle of the infrastructure knowledge base. The Go orchestrator loads network_topology.pl once at startup. New VMs are provisioned, new links are established, existing nodes are decommissioned. If the Go process calls assertz/1 to add a new link/2 fact to represent a newly provisioned VM, the Answer Table for hop_count and can_reach is now stale — it was computed from the old topology and may return incorrect results for queries involving the new node.

The tabled predicate's cache must be invalidated when the underlying data changes. SWI-Prolog provides three levels of invalidation:

% Level 1: Abolish tables matching a specific subgoal pattern.
% Use when specific data (like network topology) has changed.
% Pass a concrete subgoal pattern — the engine clears all cached
% answers whose head unifies with the pattern.
abolish_table_subgoals(can_reach(_, _)).
abolish_table_subgoals(hop_count(_, _, _)).

% Level 2: Abolish all tables in the engine.
% Use as a catch-all before processing a stateless request that
% may have been preceded by any knowledge base modification.
abolish_all_tables.

In the Go bridge, these are called through CallBool in plbridge/query.go. The production pattern for handling knowledge base updates is:

// UpdateTopology adds a new network link and invalidates the
// tabling cache for all topology-dependent predicates.
func (e *Engine) UpdateTopology(from, to string) error {
    // Step 1: Assert the new link fact into the knowledge base.
    // go_assert_link/2 is a thin Prolog wrapper around assertz/1
    // that validates the arguments before asserting.
    linkTerm := C.PL_new_term_ref()
    if err := plbridge.PutDict(linkTerm, "link_args", map[string]any{
        "from": from,
        "to":   to,
    }); err != nil {
        return fmt.Errorf("UpdateTopology: build term: %w", err)
    }

    _, err := plbridge.CallWithTerm("network_topology", "go_assert_link", linkTerm)
    if err != nil {
        return fmt.Errorf("UpdateTopology: assert: %w", err)
    }

    // Step 2: Invalidate the tabling cache for the topology predicates.
    // Without this, hop_count and can_reach return stale answers.
    ok, err := plbridge.CallBool("network_topology", "invalidate_topology_cache")
    if err != nil || !ok {
        return fmt.Errorf("UpdateTopology: cache invalidation failed: %w", err)
    }

    return nil
}

Add the Prolog side of this to network_topology.pl:

% go_assert_link(+ArgsDict, -Result)
% Entry point from Go for asserting a new network link.
% Validates that both nodes are non-empty strings before asserting.
% The Result argument is required to satisfy the Go CallWithTerm arity-2 interface.
go_assert_link(Args, success) :-
    get_dict(from, Args, FromStr),
    get_dict(to,   Args, ToStr),
    FromStr \= "",
    ToStr   \= "",
    atom_string(From, FromStr),
    atom_string(To,   ToStr),
    assertz(link(From, To)).

% invalidate_topology_cache/0
% Clears tabling caches for all topology predicates.
% Must be called after any assertz/retract on link/2.
invalidate_topology_cache :-
    abolish_table_subgoals(can_reach(_, _)),
    abolish_table_subgoals(hop_count(_, _, _)).

The memory implications of this pattern are worth stating explicitly, because they connect directly to the --table-space=128m flag from Chapter 6:

TABLE SPACE LIFECYCLE IN THE GO ORCHESTRATOR
─────────────────────────────────────────────────────────────────
  Engine startup (NewEngine)
  └── Table Space: 128 MB allocated, empty

  First query: can_reach(pve_node1, X)
  └── Table Space: filling with (From,To) answer pairs
      ∼ 10 KB for a 10-node topology

  Repeated queries (same topology)
  └── Table Space: stable, answers retrieved from cache
      No new allocations. Near-zero latency per query.

  Go calls UpdateTopology (new VM provisioned)
  ├── assertz(link(new_vm, pve_node1)) — knowledge base updated
  └── abolish_table_subgoals(can_reach(_, _))  — cache freed
      abolish_table_subgoals(hop_count(_, _, _))

  Next query after update
  └── Table Space: recomputed from scratch with new topology
      Cache rebuilt correctly, including new_vm as a reachable node

  If abolish_all_tables is skipped:
  └── can_reach(new_vm, internet_gw) → false (WRONG — stale cache)
      hop_count(new_vm, X, H)        → exception (new_vm unknown in table)
─────────────────────────────────────────────────────────────────
  Rule: If assertz/retract touches a predicate that feeds a tabled
  computation, invalidate that table before the next query.
  No exceptions. Stale tables produce wrong answers silently.
─────────────────────────────────────────────────────────────────

The final line of that diagram is the one that matters most in production: stale tabling caches do not throw exceptions in the general case. They return cached answers that were correct when computed but are now wrong because the underlying data changed. This is silent incorrect reasoning — the hardest class of bug to detect in a production system, because the system continues to function and appears healthy while giving wrong answers. The discipline of calling abolish_table_subgoals/1 (or abolish_all_tables/0 for a full flush) after every knowledge base modification is not optional hygiene. It is the correctness invariant of the tabling system.

For the specific case of the Go orchestrator handling stateless web requests — where each request may involve arbitrary knowledge base modifications — the safest policy is to call abolish_all_tables at the end of each request handler before returning the response. This ensures the next request starts with a clean cache at the cost of recomputing tabled answers for each request. Whether this cost is acceptable depends on the query mix: if most requests read a stable topology and tabled answers are reused across many reads, clearing the cache after every request throws away most of the memoisation benefit. If requests frequently modify the topology, the cache hit rate is low anyway and clearing it after each request costs little. The right policy must be measured, not assumed. Chapter 9's engine pool architecture gives us the tools to measure it.

7.6 Chapter Summary and What Comes Next

Tabling transforms SWI-Prolog from a system that can reason correctly only over acyclic data into one that handles the cyclic, mutual-dependency structures that characterise real infrastructure. The change from SLD resolution to SLG resolution is invisible at the level of the predicate's logic — can_reach/2 is the same two-clause definition either way — but the execution model changes fundamentally: the engine maintains memory of what it is trying to prove and suspends rather than recurses when it encounters a cycle.

The three capabilities introduced in this chapter compound on each other. Standard tabling provides termination and completeness on cyclic graphs. Answer subsumption extends tabling into an optimisation engine that computes shortest paths, minimum costs, and maximum values while discarding dominated answers automatically. The lifecycle management discipline — explicit cache invalidation after knowledge base modifications — ensures that tabled results remain correct in a stateful, long-running Go process that updates its knowledge base in response to infrastructure events.

The --table-space=128m flag passed in Chapter 6 now has a concrete meaning: 128 megabytes of heap dedicated to the Answer Table and Call Table structures that make SLG resolution possible. For the homelab orchestrator's current knowledge base — a few dozen nodes, a few hundred dependency edges — 128 megabytes is generous. For a production deployment managing a data centre with thousands of hosts and tens of thousands of dependencies, the appropriate table space size must be benchmarked. The flag exists so that this benchmark is possible without rebuilding the binary.

Chapter 8 takes the logic engine in a different direction: out of the Go binary and into the web browser via WebAssembly. SWI-Prolog 10.x ships with a WASM compilation target that embeds a complete Prolog runtime in a JavaScript context. This opens the possibility of running the infrastructure knowledge base queries client-side — a network topology explorer in the browser that queries the same can_reach/2 and hop_count/3 predicates we wrote in this chapter, without a round-trip to the server.


Appendix 7A: Tabling Directive Reference

TABLING DIRECTIVES — SWI-PROLOG 10.x
─────────────────────────────────────────────────────────────────
  :- table pred/arity.
  Standard tabling. Memoises all answers. Prevents infinite loops
  on cyclic data. Correct for any predicate with a finite answer set.

  :- table pred(_, _, min).
  Answer subsumption with minimum. Retains only the smallest value
  for the annotated argument position. Shortest path, minimum cost.

  :- table pred(_, _, max).
  Answer subsumption with maximum. Retains the largest value.
  Highest bandwidth, latest package version.

  :- table pred(_, _, sum).
  Aggregate sum across all answers. Running totals, cumulative costs.

  :- table pred(_, _, lattice(join/3)).
  Custom partial order. join(+Old, +New, -Result) defines how to
  combine an existing answer with a new one. Result is retained;
  dominated answers are discarded.

  abolish_table_subgoals(:SubgoalPattern).
  Clears Answer Table entries matching the pattern. Pass a concrete
  subgoal with the same arity as the tabled predicate, using
  anonymous variables for positions to clear broadly:
    abolish_table_subgoals(can_reach(_, _))
    abolish_table_subgoals(hop_count(pve_node1, _, _))  % targeted
  Must be called after assertz/retract on any predicate that feeds
  the tabled computation. Stale tables return wrong answers silently.

  abolish_all_tables.
  Clears all Answer Tables engine-wide. Use for stateless request
  boundaries in the Go orchestrator, or as a safe catch-all after
  bulk knowledge base modifications.

  predicate_property(Head, tabled).
  True if the predicate matching Head is currently tabled. Useful
  for defensive checks before calling abolish_table_subgoals/1.
─────────────────────────────────────────────────────────────────

Appendix 7B: Snapshot Checkpoint

Snapshot name: 08-chapter-7-complete
Description:   Tabling operational on cyclic network topology
               and package dependency graphs. Answer subsumption
               for minimum-hop routing. Go lifecycle management
               with abolish_table_subgoals/1 wired into UpdateTopology.
               Files added:   prolog/network_topology.pl
                              prolog/package_deps.pl
               Files modified: go/orchestrator/plbridge/engine.go
                               (UpdateTopology, invalidate_topology_cache)