How the two engines are built
For developers. How the September 2026 rewrite and the engine it replaced differ in design, and what that means for a game.
Both engines are versions of Cyclops Storm: Python libraries that a game calls in-process to answer Prolog-style questions about an authored story world, including what is true at each moment. The earlier engine, BadScienceFiction-CyclopsStorm (package src/kb/, class KBEngine), is compared at its final commit, e62758c. The rewrite, AkiyaBlocks-CyclopsStorm (src/cyclops/, class KnowledgeBase), was begun in September 2026 from a design brief rather than the old code and is compared at 5d598de. Migration predates the rewrite's seven behavior changes; How it works and its repair history cover them. Where the pages differ, this page is current.
Principal findings
- Same job, same answers on ordinary input. On the lab-door story below, all probed answers and game decisions agree.
- Difference 1: one path instead of many. The earlier engine picks machinery per request: a handler chosen by predicate name, then one of two evaluators. The rewrite sends every goal through one solver, so a goal gets the same answer whether the game or a rule asks.
- Difference 2: a request's state belongs to the request. The earlier engine keeps evaluation state and answer caches on the engine, and routing state process-wide. The rewrite discards a request's evaluation state with the request, keeping only structures tied to the current program; its process-wide touches are a variable counter, a never-emptied atom table and Python's recursion limit. The price is a rebuild after every change.
- A third difference, one either design could have made: only the rewrite's results can say "didn't finish". An earlier limit reaches the game as an ordinary "no", at most with a printed warning. The cost: one answerable circular question was left open. Neither flags an undefined predicate by default; a typo just means "no".
- The earlier engine does more. It explains why a rule succeeded, caches answers across queries, prints authoring warnings and ships applications.
In the code: read in source. Observed: small probes on isolated copies. What it means: interpretation. Each section links to its evidence.
Who does each job
Figure 1. Which part of each engine does each job?
holds_at, term model) where the earlier engine has several, chosen per request or entry path; the earlier engine also does jobs the rewrite doesn’t.Text equivalent of Figure 1
The last row is code around the reasoning kernel. Combined: routing, done inside the rewrite's solve(). Elsewhere: the rewrite's timeline, built by its facade class, KnowledgeBase. Jobs only the earlier engine does: rule-level explanation, applications and logging.
| Job | Earlier engine, src/kb/ |
Rewrite, src/cyclops/ |
|---|---|---|
| Host API and game hooks | KBEngine (engine/kb_engine.py); JSON and game hooks (integration/) |
KnowledgeBase (engine.py); JSON and game hooks (api/json_api.py, api/affordance.py), loaded on first use; value conversion (api/values.py) is imported by the facade |
| Getting a goal to its code | Seven-stage pipeline (engine/query/pipeline/); handler picked by name (engine/query/registry.py); router choosing the DFS engine or the Legacy evaluator (routing.py, engine/routing/) |
Combined: inside solve(), control constructs first, then the built-in table, then clauses (solve/machine.py) |
| Solving rule bodies | Legacy evaluator and strategies (engine/derived_evaluation/, engine/evaluation/); DFS engine (backtracking.py); tabling and a depth cap of 50 (engine/tabling/, engine/constants.py); built-ins in several places |
One solver (solve/machine.py); one built-in table (solve/builtins.py); whole-program analysis and tabling (solve/analysis.py, solve/tabling.py) |
| Story time | Temporal package (engine/reasoning/temporal/); several holds_at entry points; timeline filled while loading (engine/facts/processor.py) |
One holds_at, shared by explain (temporal/calculus.py); elsewhere: timeline built per revision by the facade through the solver (engine.py) |
| Terms and clause storage | Several parsers and two term formats (text_parser/, engine/parsing/); two unifiers (unify/, backtracking.py); facts and rules stored apart (engine/storage.py, engine/compilation/rule_compiler.py) |
One reader and term model (syntax/, terms.py); one binding store (bindings.py); one clause list per predicate (program.py) |
| Limits and "didn't finish" | Fixed limits (engine/constants.py); absent: result status, per-request budget, cancellation |
Per-request budget, cancellation and status (solve/context.py, errors.py) |
| Explanations and provenance | why, derivation, evidence_chain (engine/query/provenance_handlers.py); source spans including source/2 (engine/storage.py) |
explain, story time only (temporal/calculus.py); spans_for via fact_id and supports (engine.py), returned without a status; absent: rule-level explanation |
| Around the kernel, inside the package | Command-line and rooms games, gameplay (cli/, gameplay/); the legacy facade they use (core.py); metrics, structured logging, validators (engine/metrics/, engine/structured_logging/) |
Absent: applications, legacy facade, logging; combined: diagnostics on the knowledge base (KnowledgeBase.diagnostics), with per-query reasons as notes on each result |
Runtime calls in both directions: in the earlier engine between the Legacy evaluator and the temporal package; in the rewrite between solve() and holds_at. That is runtime recursion, not a dependency: a rewrite test stops lower modules importing higher ones, so its solver reaches story time only at runtime, through the built-in table.
In the code. The earlier games use its original API class, the legacy facade core.py. Observed. That facade answers from stored facts only. What it means. The earlier scope is real, but its games bypass the main reasoner compared here; a game adopting the rewrite builds those layers itself. Evidence
One question, asked of both
The same story, loaded identically into a fresh copy of each engine:
time(t0). time(t1). time(t2). time(t3).
before(t0, t1). before(t1, t2). before(t2, t3).
initially(door_open(lab)).
happens(lock_door(lab), t1).
terminates(lock_door(lab), door_open(lab)).
initiates(lock_door(lab), locked(lab)).
can_enter(R) :- now(T), holds_at(door_open(R), T).
affords(enter_lab, corridor).
guard(enter_lab, 1, can_enter(lab), 'The laboratory door is locked.').
action_label(enter_lab, 'enter the laboratory').
now(t2). % the game's clock, loaded separately
Story time uses the Event Calculus: events happen at time points and initiate or terminate fluents, facts that change over time; holds_at(F, T) asks whether F is true at T. In both KBEngine and KnowledgeBase, an effect starts at the next time point, so the door is still open at t1. affords, guard (the 1 is its order) and action_label are game hooks: decide(kb, "enter_lab", "corridor") allows, denies with the first unmet guard's message, or abstains when the action isn't offered there; the rewrite adds indeterminate, which leaves the action disabled.
Observed, with each clock in a fresh engine (the earlier one can't retract a fact, only restore a snapshot):
| Question | Earlier engine | Rewrite |
|---|---|---|
Door open at t1? At t2? |
yes; no | yes; no (complete) |
decide, now(t2) |
deny: "The laboratory door is locked." | same, complete |
decide, now(t0) |
allow | allow, complete |
All eight goals matched under three Python hash seeds.
Figure 2. When the game asks, where does the request's state live, and for how long?
now(t2), so both answers are no. Takeaway: the earlier request fills caches owned by the engine and reads routing state owned by the process; in the rewrite, no answers or evaluation state outlive the request; only structures tied to the program’s current revision, and interned atoms, do.Text equivalent of Figure 2
Traced on a fresh engine with Python's sys.monitoring, without changing engine code. The state each step touches is labeled with how long it lives, from shortest to longest. Earlier engine, six lifetimes: this request; until the next query; until the next add or 300 seconds, whichever comes first (the result cache); until the next add (the holds_at cache); the engine object's life; the whole Python process. Rewrite, four: this request; until the program changes; the knowledge base's life; the whole Python process.
Earlier engine, KBEngine.query("can_enter(lab)"):
KBEngine.querycreates the pipeline'sQueryContext: query text, goal, handler, results (this request).- Pipeline stages: cache lookup (the result cache lives until the next add or 300 seconds), validation, parsing (answer tables are cleared here and live until the next query), and routing by predicate name.
Reasonerโ router โ Legacy evaluator runs the rule body. It reads the process-wide routing allowlist and loggers, keeps substitutions and variable names for this request, and fills a derived-result cache that is cleared when the next query starts.FluentEvaluator.holds_atanswers story time and calls back into theReasonerfor effect rules. Itsholds_atcache lives until the next add; facts, rules and time order live as long as the engine.- The result is stored in the result cache and projected. The host receives
[], a list of dictionaries (no; a yes would be[{}]); an identical repeat is answered from the result cache.
Rewrite, KnowledgeBase.ask("can_enter(lab)"):
KnowledgeBase.askcreates a newQueryContext: bindings, tables, story-time memo, budget, status, notes (this request)._refresh: if the program's revision (a counter every change advances) changed, analyze it (find recursion to table, check cuts and negation), which the request's budget doesn't meter, then build the timeline, the time points in order and their events, through the solver, which it does.DerivedState(analysis, timeline, diagnostics) lives until the knowledge changes. A preparation stopped by the budget or a cancel may be incomplete, so it isn't kept, and the next request prepares again.solveresolves the goal against clauses inProgram(clauses and revision number, life of the knowledge base), through a first-argument index per predicate that it builds on first use after each change, and uses the built-in table (whole process, fixed at import). The call also raises Python's recursion limit until it returns and numbers variables from a process-wide counter; any new atom it creates joins a process-wide table that is never emptied.holds_atโ_explain, found in the built-in table, callssolveback for effect rules. It memoizes in theQueryContextand reads the timeline fromDerivedState.- The status is folded into the result and the
QueryContextis discarded. The host receivesSolutions(answers=(), status=complete)(no; a yes has one answer with no bindings), a new frozen object on every call.
Observed: a difference case. With now(t0), add a second guard, reachable(corridor, lab), over a tunnel of 51 to 60 passage/2 segments, searched recursively. The earlier engine denies entry, printing only warnings: its evaluator stops at depth 50. The rewrite allows it, complete; with a 200-step budget or a cancel, it returns indeterminate. The input was built to cross the cap. Evidence
Seven comparisons
The table is in the code; the symbol ending each engine's cell is where to look.
| Comparison | Shared | Earlier engine | Rewrite |
|---|---|---|---|
| Terms and storage | Immutable terms; a trail of bindings to undo on backtracking | Two term formats and unifiers; facts, story facts and rules stored apart: FactStore |
One term model; one clause list per predicate: Program |
| Running a query | Goal-by-goal SLD resolution; automatic tabling, so recursion over a cycle doesn't loop | Handler chosen by predicate name, then one of two evaluators: RoutingFSM |
One solver for every goal, after whole-program analysis: solve |
| Mutable state | Clauses live with the engine; a per-query QueryContext |
Evaluation state and answer caches on the engine; routing process-wide; some thread-local state and locks: QueryCache |
Evaluation state dropped after each request; analysis and timeline kept until the program changes; nothing for threads: DerivedState |
| Story time | Event Calculus vocabulary, delayed effects, exclusive fluents (one value at a time), a tie-break for a start and stop at one moment | Timeline filled from facts while loading; finds earlier events, then their effects: FluentEvaluator |
Timeline rebuilt after each change, through the solver; solves effect rules, then checks events: _build_timeline |
| Can't finish | Runaway work bounded; game hooks fail closed (an error never allows an action); unknown predicates quietly fail | Fixed limits, no status: MAX_TRANSITIVE_DEPTH |
Step and depth limits per query (a decide makes several), cancellation polled every 512 steps, result status: Limits |
| Explanations | Provenance from authored fact_id/2, supports/2 |
Rule-level why, built by separate code: WhyHandler |
explain, story time only, the procedure that answers holds_at: _explain |
| Host and extension | In-process Python, JSON, the same game hooks | Lists of dicts; add-only; handlers registered by name: HandlerRegistry |
ask returns frozen results with a status; assert and retract; fixed built-ins: Solutions |
Terms and storage
Observed. The effect fact terminates(lock_door(R), door_open(R)). closes the door in the rewrite; the earlier engine, which files effects under the event's text, silently ignores it. A rule form works in both. What it means. The rewrite finds story facts by unification, without story indexes, at an unmeasured speed cost. Evidence
Running a query
Observed. Over 12,000 facts, a rule named human/1 got 10 answers; renamed person/1, a name on the router's list, it went to the other evaluator, silently ran out of budget and got none. What it means. The rewrite's answers depend only on the program, its options and the request's limits (generated names of unbound variables aside); the earlier engine's also on names, entry path and process-wide state. Evidence
Mutable state
Observed. Repeats took 0.011 ms on the earlier engine (cached) and 0.07 ms on the rewrite. After a one-fact change, the next query took 0.35 ms on the earlier engine at every size, and 0.25 ms on the rewrite, about 3.4 ms with 10,000 more facts (medians, one machine). In the code. Any change, even to the clock, makes the rewrite reanalyze the program and rebuild its timeline. Neither is built for threads. What it means. The earlier engine's correctness depends on every cache being cleared on time; the rewrite pays at every change, more for larger programs. Evidence
Story time
Observed. Given the extra rule terminates(auto_lock(lab), door_open(lab)) :- holds_at(door_open(lab), t3)., with auto_lock at t1, the earlier engine answered consistently, as stored facts settled each question first; the rewrite stopped, exhausted (decide: indeterminate). Where the earlier engine needs such a rule, its re-entry guard silently treats the inner question as false: a paradoxical variant got yes at every time. The earlier timeline ignored rules for before/2 and happens/2; the rewrite's didn't. What it means. The rewrite doesn't guess at circular questions, but leaves some answerable ones open. Evidence
Can't finish
In the code. Earlier limits return the answers found so far as an ordinary list. Rewrite results are complete, exhausted or cancelled: ask returns partial answers, query raises, decide returns indeterminate; only spans_for drops the status. By default a cut in a recursive predicate, or negation through recursion, makes every later query, related or not, raise SemanticConflict until the program changes (decide: indeterminate). What it means. An earlier game can't tell "no" from "gave up", hence the tunnel's false refusal; a rewrite game must handle indeterminate. Evidence
Explanations
Observed. With the door open, the earlier why(can_enter(lab)) returned the rule and its bindings; the rewrite can't. With it locked, neither explained the failed rule; the rewrite's explain named the lock that closed the door, but on the rule head can_enter(lab) it said "never initiated", complete, even when the rule succeeded. In the code. The earlier why is separate code that could disagree with the answer (none seen); the rewrite's explain is the procedure behind holds_at. What it means. The earlier engine answers "why can I?" better; the rewrite's explain covers only story-time fluents. Evidence
Host and extension
Observed. An earlier handler registered for weight/2 answered a single goal typed at the top level, not a conjunction or rule; the built-in length/2 handler hid a user fact length(rope, 5), which the rewrite refuses at load. Only the earlier engine printed authoring warnings, such as a variable used once. What it means. In both, game-computed values must in practice be asserted as facts, each costing the rewrite a rebuild. Evidence
What this comparison can't tell you
- History and standard Prolog. Two revisions, not the path between them (History, Prolog compatibility).
- Speed and size. Only the timings above are used. The earlier package has seven times the lines (54,387 against 7,904), but line counts describe scope, not quality.
- The engines' own tests. Rerun in isolation: earlier, 6,647 passed (all 20 failures and errors need an uninstalled package); rewrite, 1,790 passed, 5 skipped.
- Review. AI-simulated readers and adversarial reviewers, not real users, checked two longer drafts, then this page before its last corrections (review record).