Cyclops Storm / Lab notes

The engine, precisely

For readers who know Prolog, or want to. This page documents the September 2026 rewrite: what it does, where it departs from ISO Prolog and SWI-Prolog, and where it's known to fall short. Most behavior was checked by running programs against commit 9be7804, with SWI-Prolog 10.0.2 as the reference. Later repairs, through engine commit 828c0fe, are described here as current behavior; Recent changes gives each one's reproduction and verification. Measurements on this page still describe 9be7804. Terms are in the glossary.

In brief

A small, deterministic Python kernel (about 7,400 lines, no runtime dependencies) for a Prolog dialect aimed at story worlds:

It deliberately isn't a general-purpose Prolog. By default, undefined and unsupported predicates fail quietly, with status COMPLETE. Game models should use strict checking. Known gaps lists the places where it can be wrong without saying so.

Facts, rules and story events

Facts record relationships; rules infer new ones:

parent(alice, bob).    parent(alice, carol).    parent(bob, dave).
grandparent(X, Z) :- parent(X, Y), parent(Y, Z).

Ask grandparent(alice, Who). The engine finds bob among alice's children, then dave among bob's children. It backs up to try carol, who has no recorded children. The answer is dave, a relationship nobody explicitly wrote down. Backtracking explores those alternatives; tabling remembers answers for recursive rules, subject to the finiteness and budget limits below.

The Event Calculus adds changes over time. An event such as Harriet's hack switches a property such as the vending machine being offline on or off. The engine follows that property's persistence through the named story moments. Its explanation identifies the initiating event and, when recorded in the model, a supporting sentence. The timed-rule example below explains the limits of this interpretation.

Quick reference

For a feature-by-feature table with SWI references and recorded executable probes, see Prolog compatibility. The summary below describes the broader design; that page identifies exactly which examples were verified.

Area Behavior Versus ISO / SWI
Syntax ISO operator table, fixed No op/3. "abc" reads as the atom abc; no strings, no 0'c
Unification Trail-based; occurs check on Off by default in SWI. X = f(X) fails
Numbers 1 = 1.0 fails; 1, '1', 1.0 are distinct As ISO
Resolution SLD, depth-first, clause order; one ordered clause list per predicate; first-argument indexing Standard, for non-recursive predicates
Answers Deduplicated at the top level by default; ask(goal, distinct=False) reports SLD multiplicity for untabled goals SWI reports every derivation. findall/3 keeps duplicates, so top level and rule body can disagree on multiplicity
Control , ; -> *-> \+/not ! call/1..8 once ignore forall No catch/throw
Cut Local to the clause; local inside if-then-else conditions and call/N Rejected in any recursive (tabled) predicate, which is more conservative than XSB. If-then-else and once/1 are allowed
Negation ISO \+: no reordering, no floundering check Programs must be stratified per predicate; see below
Arithmetic // truncates, div floors, typed errors X is a + 1 raises a type error, as in SWI
Built-ins 74 built-in predicates (counting once/1, forall/2, ignore/1 and holds_at/2): lists, findall/bagof/setof/aggregate_all, sort/msort, =.., functor/arg/copy_term, atom and number conversion No assert/retract as goals, exceptions, I/O, DCG, modules, constraints, dif/freeze, threads
Unknown predicates Fail with status COMPLETE by default ISO raises an existence error; KnowledgeBase(unknown_fails=False) does too, and is recommended for game models
Recursion Tabled automatically; left and mutual recursion terminate See Tabling for the conditions
Result status Query results carry COMPLETE, EXHAUSTED, or CANCELLED SWI can do similar things per call (call_with_inference_limit/3, tabling restraints); here it's part of the query result
Time Native holds_at/2 over happens/initiates/terminates Not part of Prolog; see Story time
Explanation explain(fluent, time), spans_for(fact) No rule-level proof trees

Negation and stratification

\+ G succeeds if G has no solution. As in ISO, it binds nothing and doesn't check for floundering. The engine also doesn't reorder goals around negation. The earlier engine did, which sometimes gave the declaratively right answer, but not Prolog's.

Before any query runs, the program is checked for stratification: no predicate may depend on its own negation through any non-monotone construct. Those constructs are \+, forall, aggregates, if-then-else and soft-cut with an else branch, and ignore/1. The check looks through ^/2 and statically known call/N goals.

The check works at the level of predicates, not ground atoms. So programs that are safe only because of their data are rejected (in the literature, programs that are modularly but not predicate-stratified):

move(a, b).  move(b, c).
win(X) :- move(X, Y), \+ win(Y).
% SemanticConflict: negation is not stratified: {win/1} depends on its own negation

SWI answers this program (win(b)), and with tabling it would use the well-founded semantics. This engine refuses it rather than guess. A dependency hidden behind a runtime call/1 can slip past the static check. It's caught at run time and reported as EXHAUSTED, which overloads a status that usually means "a budget ran out."

Tabling

Recursive strongly connected components are tabled automatically (auto_table=True); :- table also works. Tables are keyed by call variant and filled by naive iteration. All the tables in a stratum iterate together until nothing changes, which makes left and mutual recursion complete:

road(a, b). road(b, c). road(c, a). road(c, d).
reach(X, Y) :- reach(X, Z), road(Z, Y).
reach(X, Y) :- road(X, Y).
% ?- reach(a, W).   W = b ; c ; a ; d      (terminates despite the cycle)

The conditions and costs:

Query Auto-tabled auto_table=False
Splitting a 100-element list with a user-defined append/3 4.3 s 0.05 s
Same, 50 elements 0.4 s 0.01 s

A left-recursive all-pairs "reaches" rule over a 160-link chain (12,880 answers) exhausts the default budget at 11,169 answers, correctly marked EXHAUSTED. The same rule written right-recursively finishes in 0.3 seconds, and the left-recursive form over 80 links completes in 1.6.

Result status

Every query result carries a status (the code calls it Completion; it has nothing to do with Clark's completion):

kb.query() raises IncompleteResult rather than return an EXHAUSTED result, so an empty list never means "gave up." It can still mean "no such predicate," because of the quiet default for unknown predicates. kb.ask() returns partial answers with the status attached.

What a request's budget covers. Besides solving, a request's Limits and cancellation callback cover preparing the story timeline (source enumeration, ground effect-time collection, graph construction and ordering diagnostics) and discovering calculated effect times. The first request after a load or change pays for preparation, even a non-temporal one such as ask("true"). ask and explain include preparation's work and depth in their accounting. Cancellation is checked at request entry, after uncached static analysis, and every 512 charged steps. These are cooperative work controls, not wall-clock deadlines. A game menu runs several internal queries, and each has its own budget. Outer answer limits never truncate the timeline's source search.

Weak spots:

Story time: the Event Calculus

time(t1). time(t2). time(t3). time(t4).
before(t1, t2). before(t2, t3). before(t3, t4).
happens(switch_on, t1).    initiates(switch_on, light_on).
happens(switch_off, t3).   terminates(switch_off, light_on).
% ?- holds_at(light_on, T).    T = t2 ; t3

The engine implements a simplified Event Calculus in the style of the 1990s formulations (Shanahan's among them), evaluated natively rather than by Prolog axioms.

Where it departs from the classical Event Calculus. In the textbook axioms, the third argument of initiates/3 and terminates/3 is the occurrence time of an event that must also appear in happens/2. Here it's the time the effect lands, and happens/2 isn't required. That lets a model be written entirely in the /3 form. But conditional effect rules written the classical way mean something different:

happens(flip, 1).  happens(flip, 3).  time(1). time(2). time(3). time(4). time(5).
initiates(flip, on, T)  :- \+ holds_at(on, T).     % a toggle
terminates(flip, on, T) :- holds_at(on, T).
% Classical reading (SWI with the textbook axioms):  on holds at 2, 3
% This engine:                                       on holds at 2, 4, explained by a
%                                                    termination at 2, when flip didn't happen

The answer comes back COMPLETE, with no warning. The same applies to any conditional timed rule. terminates(check, on, T) :- holds_at(g, T). fires at every moment g holds, not only when check happens. Even terminates(check, on, _) misfires.

Here, a timed effect rule has to say when its event happens:

terminates(check, on, T) :- happens(check, T), holds_at(g, T).

The rewrite's own docs describe the 3-argument forms but never flag this departure from the classical reading.

Calculated effect times

An initiates/3 or terminates/3 rule may calculate when its effect lands, for example terminates(timer, unlocked, T2) :- happens(timer, T), T2 is T + 1. The engine finds that time without a time/1 declaration. If a badge scan unlocks a door and the timer happens at minute 3, the door is still unlocked at minute 4 itself (the usual delayed effect) and locked afterwards; at minute 9, explain names the timer's termination at minute 4. Discovery runs inside the temporal query, using the ordinary solver and the request's budget and cancellation, with isolated bindings and tables. It doesn't change the authored time/1 facts or the cached base timeline.

The supported case is a finite rule that generates its time without reading temporal state. A generator that calls holds_at, directly or indirectly, is reported as uncertain; so are discovery errors and clause-level cuts that could make per-clause enumeration misleading. The discovered times are shared by every story-time question in a query, so one such generator can make otherwise unrelated story-time questions uncertain; non-temporal queries are unaffected. Rules constrained to named output times keep the ordinary state-dependent evaluation. A rule that yields an additional time only when that time is already supplied (for example, one that tests nonvar(T)) must have that time named, for example with time/1; otherwise its effect is missed without warning. Discovery is bounded; it doesn't resolve circular temporal rules.

Reproduction and verification: repair history.

Circular temporal rules

When a story-time question depends on its own answer, the engine stops rather than guess. Take a door that starts unlocked, with terminates(auto_lock, unlocked, 1) :- holds_at(unlocked, 9). and an entry guard on holds_at(unlocked, 9): the state at minute 9 depends on whether the lock fired at minute 1, which depends on the state at minute 9. The query stops as EXHAUSTED with no assumed answer and a note naming the circular call. Its explanation has holds=None and no asserted cause, and entry is disabled and indeterminate. Under the default tie_break="init_wins", changing the lock rule's condition to holds_at(unlocked, 1) gives the ordinary completed denial, and explain names the locking effect.

The stop crosses negation, control flow, aggregates and tables. A tabled helper can't bypass it: story-time calculations entered from an active table session use their own table store, and the caller's store is restored afterwards. Ordinary recursive helpers complete their own fixed point. This is a conservative runtime boundary, not a three-valued well-founded solver. It keeps independent proofs already obtained and leaves unrelated queries usable, but it doesn't explore later alternatives after reaching a cycle, so some answerable circular questions stay open. Current-tick checks under term_wins can reach cycles that init_wins avoids.

Listing what holds at a time. For holds_at(F, T) with F unbound and T named, candidates come from initial fluents and from initiations at strictly earlier ticks, and each candidate is checked with the ordinary evaluator. Future and incomparable /3 heads that can't match those ticks are skipped, so a listing of the present completes even when a /3 rule tied to a later time is circular, while a question that actually reaches the cycle stays uncertain. In this museum, an alarm check at minute 10 depends on its own outcome at minute 11:

time(1). time(2). time(10). time(11).
initially(light_on).
initiates(alarm_check, alarm_on, 10) :- \+ holds_at(alarm_on, 11).
% ?- holds_at(F, 2).            F = light_on, COMPLETE
% ?- holds_at(alarm_on, 11).    no answers, EXHAUSTED

Queries over all times keep time order and may return sound earlier answers before reaching a later incomplete point. Because discovery supplies named ticks to /3 rules, universal-time and nonvar(T) rules can appear in listings where the ground evaluator proves them; no new times are invented. Rule bodies keep their authored order, so a /2 effect body, which runs before its happens/2 join, can reach future reasoning; so can a variable-time /3 body whose time filter follows its temporal call. A state-reading time generator can still make a listing uncertain (see Calculated effect times). Current-tick clipping and both tie policies behave normally.

Effect on the engine's testworld model. It has an alarm/secret post-state cycle, so affected alarm and secret queries report uncertainty. A broad listing at its first point, t0, completes (empty); broad listings at t1 through t6 stay uncertain through its /2 alarm cycle. Normal trust progression and the ordinary story and affordance models are usable.

Reproduction and verification: circular temporal reasoning and time-scoped listings in the repair history.

Building the timeline

The timeline (its points, their order and the events) comes from time/1, before/2 and happens/2, solved as ordinary goals, plus the ground times in /3 effect clauses. Those three predicates must not depend on story state. If they do, as in before(lock_check, arrival) :- holds_at(locked, arrival)., temporal queries and explanations return no answers, with status EXHAUSTED and notes explaining the dependency. Here that status means the reasoning couldn't be established, not that a budget ran out. Explanations have holds=None, and dependent actions are disabled and indeterminate. Ordinary non-temporal queries, unrelated actions and independent earlier proofs remain usable. Replacing the rule with the fact before(lock_check, arrival). restores normal answers.

The check combines known dependencies from before/2, time/1 and happens/2 to holds_at/2 with a runtime stop for temporal calls reached through computed meta-calls during construction. Both temporal queries and explanations reject an affected timeline before calculating effects. The static policy is conservative: a known dependency blocks temporal evaluation even in an unreachable branch, and opaque calls in branches that construction never reaches can't be validated. Each timeline source discards its bindings and tables before the next one runs, so an interrupted table can't leak provisional facts into the timeline. The engine doesn't compute a joint fixed point of time and state; ordinary state-dependent effect rules are unaffected.

Preparing the timeline counts against the initiating request's budget and cancellation (see Result status). Completed preparation is reused until the program changes; a mutation or restore forces preparation for the new revision. Interrupted preparation is discarded, a tabled source's buffered partial answers can't become a completed timeline, and a later request retries normally.

Reproduction and verification: circular timeline construction and timeline preparation limits in the repair history.

Explanations and provenance

Known gaps

Gap Example Fails loudly?
Unknown or unsupported predicates fail catch(p, _, true), assertz(q(1)) → no answers No under the default: status COMPLETE. Use unknown_fails=False for game models; optional guards and labels may then be absent (strict checking)
Timed effect rules use the effect-time reading The toggle above No
Calculated effect times are found only for finite rules that don't read story state terminates(off, f, T2) :- happens(off, T), T2 is T + 1. is found without naming T2; a time generator that calls holds_at is not Partly. State-reading generators, discovery errors and clause-level cuts are reported as uncertain (EXHAUSTED). No for a rule that yields a time only when that time is already supplied (for example a nonvar(T) test): unless the time is named, its effect is missed and the answer is still COMPLETE (details)
Circular story-time rules stop without an answer A rule for holds_at(unlocked, 9) that depends on that same state Yes (EXHAUSTED), but conservatively: alternatives after the cycle aren't explored, so some answerable questions stay open (details)
Timeline rules that read story state before(lock_check, arrival) :- holds_at(locked, arrival). Yes (EXHAUSTED) for story-time queries; a known dependency blocks them even in an unreachable branch (details)
Strict mode checks only the calls it reaches gaurd/4 for guard/4; a misspelled fluent inside a valid holds_at/2 call No: strict mode isn't a static authoring validator (details)
explain() on a non-fluent explain("anc(tom, bob)", …) → never_initiated No
Predicate-level stratification win/move above Yes, but globally: every query on the knowledge base raises SemanticConflict
Cut in a recursive predicate r(X,Y) :- e(X,Y), !. plus a recursive clause Yes, but globally, as above
Auto-tabling slows recursive list code User-defined append/3 on 100 elements: 4.3 s No error; turn off auto_table for such code
Left-recursive closures over long chains exceed the default budget All-pairs "reaches" over a 160-link chain Yes (EXHAUSTED); the right-recursive form is fine
Infinite answer sets nat/1 Yes (EXHAUSTED), but only after the budget, which can take a long time
No rule-level explanations why/1 Yes: the predicate doesn't exist (and fails quietly, see row 1)
No concurrency support Querying while another thread adds facts Not designed for it; deferred

The host API

from cyclops import KnowledgeBase
from cyclops.api import decide, affordances

kb = KnowledgeBase()                     # options: occurs_check, tie_break, unknown_fails, auto_table, ...
kb.load(text) / kb.consult(path)         # add clauses
kb.query(goal)                           # answers as Python values; raises if the search didn't finish
kb.ask(goal, limits=..., distinct=...)   # Solutions: answers + status + notes
kb.holds(goal)                           # bool; raises if it gave up before finding an answer
kb.explain(fluent, time)                 # see above
kb.assert_(clause) / kb.retract(clause); kb.snapshot() / kb.restore(s)
decide(kb, action, site)                 # Decision: kind, allowed, message, status, notes
affordances(kb, site)                    # a site's menu: Affordance entries with label, enabled, kind

Decisions when reasoning can't finish

A game decision is only as definite as the reasoning behind it:

Situation Host behavior
Guard discovery or a necessary condition is incomplete Disabled indeterminate decision, with status and diagnostic notes; no authored refusal presented as fact
A temporal explanation is incomplete holds=None in Python (null in JSON), reason="unexplained", status and notes, and no asserted initiating or terminating cause
Reasoning completes Ordinary allow/deny/abstain decisions and guard priority; completed false explanations have holds=False

An indeterminate decision has kind == "indeterminate", allowed=False, the neutral message "Action availability could not be determined." and a status: EXHAUSTED, CANCELLED, or JSON status "error" when a typed engine error occurred during decide. For example, with KnowledgeBase(auto_table=False), a guard list whose second row loops (guard(open_gate, 2, true, 'Unused') :- loop.) makes decide(kb, "open_gate", "gate") indeterminate with EXHAUSTED, although the first guard was found. Decision and menu JSON (as_json()) include status and notes, and menu entries include their decision kind. affordances raises IncompleteResult if action discovery or label lookup is incomplete; an action whose decision can't finish is listed as a disabled, indeterminate entry. decide and affordances take the same limits and cancel arguments as ask. Limits apply to each internal query, not as one budget for a whole menu. A cancelled decision is indeterminate too, never a permission or an authored refusal; retrying with enough budget and cancellation cleared gives the ordinary decision.

Reproduction and verification: repair history.

Strict checking for game models

KnowledgeBase(unknown_fails=False) turns a reached undefined predicate into an error instead of a quiet failure. It's recommended for game models; the engine's default is permissive. In a decision, that error disables only the affected action: a misspelled guard such as guard(enter, 1, \+ lokced(laboratory), 'The door is locked') makes enter indeterminate with lokced/1 named in its notes, while other actions at the site stay available. The host's optional lookups tolerate absence: if guard/4 or action_label/2 isn't defined at all, actions are unguarded and get a fallback label (read_notice is labelled read notice). To declare a relation that is intentionally empty, write :- dynamic locked/1.

The exception covers only wholly absent guard/4 and action_label/2. A definition with only the wrong arity is still an error, and defined rules, actual conditions and the required affords/2 inventory are checked normally. The fallback runs an ordinary checked query, keeping analysis, resource handling and diagnostics. Strict mode checks the calls that are actually reached. It can't recognize gaurd/4 as a misspelled optional head, detect a misspelled fluent argument inside a valid holds_at/2 call, or inspect alternatives skipped by a first proof. It is not a static authoring validator.

Reproduction and verification: repair history.

Performance

Revised full-binding timings on one Windows machine, September 25, 2026. Both engines materialize every requested variable; the rewrite uses native answer terms, with Python value conversion measured separately. See the method, exact-answer checks, batch ranges and raw timings.

Rewrite, automatic tabling Rewrite, auto_table=False SWI-Prolog 10.0.2
Naive reverse of a 30-element list 107.88 ms 9.20 ms 0.00807 ms
All solutions of 6-queens 507.74 ms 205.68 ms 0.55199 ms
All splits of a 100-element list 4,201.41 ms 18.60 ms 0.22084 ms

These measurements include query setup and result construction, and do not isolate interpreter overhead. SWI executes compiled goals; the rewrite parses each query. The list-split SWI and Python-conversion measurements varied substantially. The original placeholder-answer SWI ratios are superseded.

The earlier story-model audit measured about 20 ms to load 748 facts and 1–16 ms for example queries/explanations; those particular observations were not retimed in this benchmark run. There is no cross-query answer cache. The old engine's cache was fast and had a separate result-aliasing bug: fixing that bug would not eliminate the caching advantage.

Design in one paragraph

There is one term representation, one reader, and one clause list per predicate holding facts and rules together. One solver serves top-level queries and rule bodies, and there is one holds_at. All long-lived state belongs to the program and is versioned by a single revision counter. Everything query-local lives in a context created per query and thrown away. Derived data (static analysis, the timeline, diagnostics) is tagged with the revision it came from, so staleness is detected by comparing one integer. Line and test counts below are at 9be7804.

Part Lines
Kernel: 17 modules for terms, syntax, bindings, program, solver, built-ins, arithmetic, tabling, analysis, temporal 6,691
Host boundary: values, JSON, affordances 519
Package __init__ files 216
Total 7,426
Tests: 38 files; 1,144 correctness cases, plus 253 for the comparison harness 13,137

What changed from the earlier engine

The migration drill-down supplies the measured comparison, test dispositions and capability gaps; the revised 21-case run separates observed correctness from timeout uncertainty. Architecture compares how the two engines are organized at their latest revisions: request routing, where state lives, and what a host sees when a query can't finish.

Semantics the rewrite chose differently:

Removed:

Left out: the text parser, the command-line game, the narrative tools and the analysis tools.

Deferred: semi-naive tabling, cross-query caching, rule-level explanation and concurrency.

HISTORY.md runs the same programs on both.

Compared with SWI-Prolog

SWI-Prolog is a complete, fast, largely ISO-conformant system with nearly 40 years of engineering behind it. For almost anything, use SWI; for Python, its janus bridge works well.

This engine is for one job: reasoning about authored story worlds, with answers you can explain. For that job it offers: