Orchestrata Ultimate › Plans › swi-tabling-capabilities

SWI-Prolog Tabling & Evaluation Capabilities — Authoritative Catalog

Source: plans/swi-tabling-capabilities.md at 8a2cb42ad · last changed 2026-07-27 · first committed 2026-07-10 · 581 lines.

Prose pending.

Plan text

SWI-Prolog Tabling & Evaluation Capabilities — Authoritative Catalog

Target runtime: SWI-Prolog 10.0.2, driven from Python via janus-swi. Scope: tabling (SLG resolution) and adjacent evaluation features that are "free" correctness/performance wins for a codebase doing transitive closures over dynamic facts, requirement-graph joins, governance/ownership recursion, and per-request assert → query → retract fact lifecycles.

Every claim below cites the SWI-Prolog reference-manual section it comes from. Sections referenced (all under pldoc/man?section=): tabling, tabling-subsumptive, tabling-incremental, tabling-monotonic, tabling-mode-directed, tabling-restraints, tabling-shared, WFS, tabling-preds.

Load-bearing project constraint (memory: Governance Prolog migrated to janus): keep variable-bearing rules in .pl files loaded via consult, and only assertz ground facts from Python. All tabling directives below belong in the .pl source, not in strings asserted at runtime.


0. Mental model (§tabling)

Tabling = SLG resolution = memoization of a predicate's answers in an answer trie. Two distinct wins, per §tabling:

  1. Memoization — "Re-evaluation of a tabled predicate is avoided by memoizing the answers." Repeated identical subgoals are answered from the trie, not recomputed.
  2. Termination on recursion — "Left recursion, a goal calling a variant of itself recursively and thus looping under normal Prolog SLD resolution is avoided by suspending the variant call and resuming it with answers from the table." This makes naive transitive-closure clauses (path(X,Z):-path(X,Y),edge(Y,Z)) terminate and run in polynomial time instead of looping forever.

The universal gotcha (§tabling): do not table predicates with side effects (I/O, assertz, format, sending CXP packets) and do not table infinite-answer predicates — "If you table predicates with infinitely many solutions you simply run out of (table) memory." A tabled predicate is a pure relation; side effects belong in untabled driver predicates.

Base directive:

:- table path/2.                 % single predicate
:- table (path/2, reach/2).      % multiple, comma list in parens

1. Variant tabling — the default (§tabling)

What it does

Creates one table per call variant. Per §tabling-subsumptive: "A call (term) is a variant of another call (term) if there is a renaming of variables that makes the two terms equal." So path(a,X), path(b,X), path(X,Y) each get their own table; path(a,X) and path(a,Y) share one (renaming).

Syntax (works on 10.x)

:- table ancestor/2.

ancestor(X, Y) :- parent(X, Y).
ancestor(X, Y) :- parent(X, Z), ancestor(Z, Y).

When to use

The default for every recursive/derived relation: transitive closures, reachability, requirement-graph joins, ownership chains. It is the single biggest correctness win — it turns non-terminating left-recursive Datalog into terminating, deduplicated, polynomial evaluation.

Gotchas


2. Subsumptive tabling — as subsumptive (§tabling-subsumptive)

What it does

Instead of a table per variant, answers a specific query from a more general table. Per §tabling-subsumptive it "answers a query using answers from a more general table" — e.g. p(42) is resolved by running trie_gen/2 over the existing p(_) table rather than computing a fresh table for p(42).

Syntax

:- table p/1 as subsumptive.

Or globally: :- set_prolog_flag(table_subsumptive, true).

When to use

Workloads that issue many specific queries (reachable(root, leaf_i)) whose answers are all subsumed by one general computation (reachable(root, _)). §tabling-subsumptive reports the general vs. subsumptive contrast on its example: variant tabling took ~150M inferences / 13.256 s (quadratic — one table per specific call), subsumptive took 140,066 inferences / 0.015 s. This is the fix for "one table per bound key" blowup.

Gotchas (§tabling-subsumptive)

  1. Correctness only for pure programs: "only provides correct support if instances (more specific) queries indeed provide answers that are consistent with the more general query. This is true for pure programs, but not guaranteed for arbitrary Prolog programs." If the predicate's answers depend on the binding pattern (e.g. a guard that behaves differently when an arg is bound), subsumptive tabling is wrong.
  2. Lookup overhead: "Finding more generic tables is more complicated and expensive" and extracting the matching subset "can be expensive."
  3. More call-graph dependencies can slow table completion.

Use it when the general call is cheap to fully materialize and specific queries are many; avoid it when the general call is unbounded/huge (materializing all of p(_) to answer one p(42) is a loss).


3. Incremental tabling — as incremental + dynamic-as-incremental (§tabling-incremental)

What it does

Keeps tables consistent with the dynamic facts they were derived from, via an Incremental Dependency Graph (IDG). When you assert/retract an incremental dynamic predicate, all dependent tables are marked invalid and lazily re-evaluated on next call, bottom-up. If a re-evaluated table is unchanged, that fact propagates and cascading re-evaluation is skipped.

Syntax (both sides MUST opt in)

:- table (p/1, q/1) as incremental.
:- dynamic([d/1], [incremental(true)]).

p(X) :- q(X).
q(X) :- d(X), X < 10.
d(1).

After assert(d(100)): §tabling-incremental — both p/1 and q/1 tables are marked invalid; re-running p(X) re-evaluates q/1 first (100 fails X<10, so no change), and the unchanged result prevents re-evaluating p/1.

When to use

This is the central feature for assert → query → retract fact lifecycles over derived relations. It lets you keep a warm transitive-closure/derived-fact table across many edits instead of abolish-ing and recomputing from scratch on every fact change.

Gotchas / constraints


4. Monotonic tabling — as monotonic (§tabling-monotonic)

What it does

For monotonic programs (adding facts only ever adds answers, never removes them), propagates the consequences of an assertion forward through the IDG without invalidating/recomputing the table — strictly cheaper than incremental when the change is purely additive.

Syntax

:- table connected/2 as monotonic.
:- dynamic link/2 as monotonic.

% lazy variant — queue the update, apply on next call:
:- table p/1 as (monotonic, lazy).

Feeding external/streamed edges (from library(increval)), call after the data update:

:- use_module(library(increval)).
on_new_edge(X, Y) :- incr_propagate_calls(link(X, Y)).

Eager vs lazy (§tabling-monotonic)

When to use

Append-only graphs: monotonically growing containment/dependency/ownership edges where you add edges and want derived closures to stay warm with minimal work per add.

Gotchas / constraints (§tabling-monotonic)


5. Mode-directed tabling / answer subsumption / aggregation (§tabling-mode-directed)

What it does

Designates some arguments as outputs that are aggregated rather than tabled keys, so the table keeps a single aggregated value per key group instead of every answer. Per §tabling-mode-directed this is the fix for "there are infinitely many routes if there are cycles in the network and each new route found will be added to the answer table" — aggregation collapses those to one best value and makes cyclic-graph queries terminate.

Syntax & full mode table (§tabling-mode-directed)

:- table predicate(+Mode1, +Mode2, +AggMode).
Mode Behavior
+ / index Argument is a tabled key (indexed normally)
lattice(PI/3) Call PI(+Old, +New, -Kept) on each new answer; keep aggregate
po(PI/2) Partial order: add answer only if call(PI, +Old, +New) succeeds
- / first Keep first answer only
last Keep last answer
min Keep smallest by standard order of terms
max Keep largest by standard order of terms
sum Sum numeric answers

Canonical shortest-path example (§tabling-mode-directed, verbatim shape)

:- table connection(_, _, lattice(shortest/3)).

shortest(P1, P2, P) :-
    length(P1, L1),
    length(P2, L2),
    ( L1 < L2 -> P = P1 ; P = P2 ).

connection(X, Y, [X, Y]) :- connection(X, Y).
connection(X, Y, P) :-
    connection(X, Z, P0),
    connection(Z, Y),
    append(P0, [Y], P).

lattice(shortest/3) runs shortest/3 on every newly found path, retaining only the shortest — so a cyclic network yields a finite table and a min-hop answer.

Min-hop-count / reachability-with-cost variants:

:- table hops(_, _, min).        % keep the minimum hop count per (From,To)
hops(X, Y, 1) :- edge(X, Y).
hops(X, Y, N) :- edge(X, Z), hops(Z, Y, N0), N is N0 + 1.

When to use

Shortest/cheapest-path, min-hop reachability, "does a path exist + what's the best cost", cheapest-owner, highest-priority-requirement-satisfying. Any query of the form "aggregate over all derivations" where you want the aggregate, not the enumeration.

Gotchas


6. Well-founded semantics & tabled negation — tnot/1 (§WFS)

What it does

Gives a three-valued logic — true / false / undefined — so recursive programs with negation have a well-defined model instead of looping or giving wrong answers. §WFS: undefined "is neither true nor false in the well formed model."

Syntax (§WFS, verbatim)

:- table p/0, q/0.
p :- tnot(q).
q :- tnot(p).

% the canonical paradox -> undefined, not a loop:
:- table undefined/0.
undefined :- tnot(undefined).

tnot(Goal) "must be a goal associated with a tabled predicate." For non-ground or non-tabled negation use not_exists/1 (§tabling-preds: "Handles tabled negation for non-ground (floundering) Goal as well as non tabled goals"; its helper is tabled_call/1, defined tabled_call(Goal) :- call(Goal)).

Introspecting undefined answers (§WFS)

When to use

Governance/ownership recursion with negation-as-failure over recursive relations: "a resource is denied unless some ancestor grants it," "conflict = A blocks B and B blocks A." Plain \+/1 uses a cut and, per §tabling, "one may end up with incomplete tables and incorrect results" — tnot/1 is the correct construct whenever negation sits inside a tabled recursive cycle.

Gotchas


7. Restraint / bounded rationality — guaranteed termination (§tabling-restraints)

What it does

Bounds tabling resources so evaluation always terminates even on cyclic or unbounded graphs, marking capped results undefined (under WFS) rather than looping or OOM-ing. Three restraint dimensions (§tabling-restraints):

  1. Answer count — max_answers(N) / flag max_answers_for_subgoal. Cap answers per subgoal; overflow marks the extra derivations answer_count_restraint (undefined).
  2. Subgoal size — attribute subgoal_abstract(Size); oversized compound subterms are replaced by variables. Example: p(s(s(s(s(s(0)))))) abstracted to size 3.
  3. Answer size — attribute answer_abstract(Size); caps answer-substitution size.

Syntax (§tabling-restraints, verbatim)

:- table p/2 as max_answers(3).

Combine with other modes/attributes in the as (...) list, e.g. :- table path/3 as (incremental, max_answers(1000)).

Global flags & actions (§tabling-restraints)

Flag Values / effect
max_answers_for_subgoal integer — global answer-count cap
max_table_subgoal_size_action abstract (replace excess subterms with vars)
max_table_answer_size_action fail, bounded_rationality

Tripwire mechanism (§tabling-restraints): on trigger, calls hook prolog:tripwire/2; if it fails, applies the configured action — warning (print + continue), error (throw resource_error(tripwire(Wire,Context))), or suspend (print + break level).

Undefinedness markers: radial_restraint marks results undefined when subgoal/answer size restraints fire; answer_count_restraint marks them undefined when max_answers fires — enabling bounded_rationality mode to distinguish capped answers from clean ones. Helper: size_abstract_term/3, e.g. size_abstract_term(1, ret(f(x), x(y(Z))), ret(f(x), x(_))).

Example of a capped result carrying its restraint (§tabling-restraints):

?- p(1000000, X).
X = 3 ; X = 2 ; X = 1 ;
p(1000000, X) :- answer_count_restraint.   % remaining answers are undefined

When to use

Any query over untrusted / unbounded / potentially cyclic external graph data where you must guarantee a bounded response time — request-scoped closures over facts a client just asserted. Restraint is the safety valve that turns "might OOM / might loop" into "always returns, possibly with undefined tail."

Gotchas


8. Shared / concurrent tabling — as shared (§tabling-shared)

What it does

Lets multiple threads share one table instead of each maintaining a private copy — directly relevant to the free-threaded (no-GIL) Python 3.14t runtime driving Prolog via janus across worker threads.

Syntax

:- table (p/1, q/2) as shared.

Semantics (§tabling-shared)

A thread hitting a shared variant sees one of three states:

Abolishing (§tabling-shared): "abolishing a shared table has no effect on the semantics of the tabled predicates" — incomplete tables are marked for destruction on completion; waiters then see a fresh table.

When to use

Read-heavy derived relations that are stable across requests (e.g. a static requirement/spec graph, code-intelligence closures) and are queried concurrently by many worker threads. One thread computes, all share.

Gotchas / constraints (§tabling-shared)


9. Lifecycle & introspection predicates (§tabling-preds)

Verbatim signatures/descriptions:

Predicate Signature Description
table/1 :- table(:Specification) "Prepare the predicates specified by Specification for tabled execution."
untable/1 untable(:Specification) "Remove the tables and tabling instrumentation for the specified predicates."
tnot/1 tnot(:Goal) "Implements tabled negation. Realises Well Founded Semantics."
not_exists/1 not_exists(:Goal) "Handles tabled negation for non-ground (floundering) Goal as well as non tabled goals."
tabled_call/1 tabled_call(:Goal) Helper for not_exists/1: tabled_call(Goal) :- call(Goal).
current_table/2 current_table(:Variant, -Trie) "True when Trie is the answer table for Variant."
abolish_all_tables/0 abolish_all_tables "Remove all tables, both private and shared." Tables "may be abolished regardless of the current state of the table."
abolish_private_tables/0 abolish_private_tables "Abolish all tables that are private to this thread."
abolish_shared_tables/0 abolish_shared_tables "Abolish all tables that are shared between threads."
abolish_table_subgoals/1 abolish_table_subgoals(:Subgoal) "Abolish all tables that unify with SubGoal."
abolish_module_tables/1 abolish_module_tables(+Module) "Remove all tables that belong to predicates in Module."

Lifecycle patterns for assert → query → retract (per-request)


10. janus-swi integration notes


11. Ranked opportunities for THIS codebase

Workload shape: (a) transitive containment/dependency closures over dynamic facts, (b) requirement-graph joins, (c) governance/ownership recursion, (d) per-request assert → query → retract fact lifecycles.

Tier 1 — adopt now (biggest correctness + perf win, low risk)

  1. Variant tabling on every recursive/derived relation (§tabling). Containment closures, dependency reachability, requirement joins, ownership chains. Converts non-terminating left recursion into terminating polynomial evaluation and dedups answers. This is the headline free win. Cost: none beyond declaring :- table. Trap avoided: remove any \+/cut inside these predicates first (§6).

  2. Incremental tabling for the per-request assert→query→retract lifecycle (§3). Exactly the "warm derived table that survives fact edits" pattern. Declare the derived predicates as incremental and every dependent dynamic fact dynamic([f/n],[incremental(true)]). Eliminates the recompute-from-scratch cost on each fact change. Trap: one non-incremental dynamic dep silently breaks invalidation → stale wrong answers. Mutually exclusive with shared (§8) and WFS (§6).

  3. tnot/1 for governance/ownership recursion with negation (§6/WFS). Any rule of the form "deny unless an ancestor grants," "mutual-block conflict." Using \+ here is an active correctness bug (§tabling: incomplete tables, incorrect results). Free correctness win; cost = handle the possible undefined result (deny+escalate policy).

Tier 2 — high value, targeted

  1. Mode-directed aggregation for shortest/cheapest/min-hop path & reachability-with-cost (§5). Replaces "enumerate all paths then aggregate in Python" with a single lattice(shortest/3) / min table that terminates on cyclic graphs and returns the best value directly. Big win wherever a closure query really wants an aggregate.

  2. Restraint (max_answers, size abstraction) on any closure over client-supplied / potentially-cyclic graph data (§7). Guarantees bounded, always-returning queries for per-request asserted facts. Ship it as the safety valve on request-scoped tabled predicates. Cost = treat capped/undefined tails correctly (same handling as §6).

  3. Subsumptive tabling where many specific keys are subsumed by one general closure (§2) — e.g. thousands of reachable(root, leaf_i) from one reachable(root,_). Turns per-key quadratic table blowup into one table. Only for pure relations; verify answers don't depend on binding pattern.

Tier 3 — situational

  1. Shared tabling for static shared knowledge under the no-GIL runtime (§8): stable spec/requirement graph, code-intelligence closures queried by many worker threads. One thread computes, all share. Cannot be combined with incremental or WFS — so only for read-only static graphs, never per-request mutable closures.

  2. Monotonic tabling for append-only edge streams (§4): if containment/dependency edges are added and rarely retracted, as monotonic keeps closures warm more cheaply than incremental. Experimental/incomplete — validate against an incremental baseline; retracts fall back to incremental anyway.

Traps to avoid (explicitly)


12. Applied status + next targets in THIS repo (2026-07-10)

DONE (shipped, verified)

NEXT TARGETS (ranked, with concrete repo anchors)

  1. Restraint on knowledge.pl:ownership_chain/3 (Tier 2 #5, §7). It builds an unbounded list ([AgentId|Rest]) over the same cyclic owns/depends_on graph — plain tabling would enumerate infinite lists on a cycle. Add :- table ownership_chain/3 as (max_answers(N)) (or size abstraction) so it terminates bounded. Handle the capped/undefined tail as deny+escalate.
  2. tnot/1 audit before tabling any negation-bearing governance predicate (Tier 1 #3, §6). constitutional.pl:assess_harm/3 has SELF-referential negation (\+ assess_harm(...) inside assess_harm/3); it is safe ONLY while untabled. Do NOT table it (or ethics.pl harm rules) without converting the self-negation to tnot/1 — else incomplete tables/wrong results. Currently cheap ground-fact lookups → leave untabled.
  3. Mode-directed aggregation for min-hop / shortest escalation path (Tier 2 #4, §5). agents.pl:escalation_path/3 and cxp.pl:escalation_path/2 + any future min-containment-depth need — replace enumerate-then-aggregate with a lattice(shortest/3)/min table that terminates on cyclic graphs.
  4. Shared tabling for static KG closures under the no-GIL (3.14t) runtime (Tier 3 #7, §8). Spec/requirement/code-intelligence closures are read-only and queried by many worker threads — as shared lets one thread compute, all share. BLOCKED on the concurrency model: PrologBridge is single-worker-thread today; revisit if/when queries fan out across threads. Cannot combine with incremental/WFS, so only for static graphs.