Architecture comparison: evidence record
For anyone checking the Architecture page. The revisions, source locations, probe inputs and results, and measurements behind it, recorded on September 28, 2026 and extended during revision on September 29, with the detail the page leaves out and a record of how the page was reviewed.
Raw probe results · diagram script · Architecture · Migration
Revisions examined
| Earlier engine | Rewrite | |
|---|---|---|
| Repository | BadScienceFiction-CyclopsStorm |
AkiyaBlocks-CyclopsStorm |
| Commit | e62758c23f66b26fa92c6d2dafaaf6bf3466499f |
5d598de4448749dc0ce2cb81657aa952d748e2f5 |
| Date | Aug 30, 2026 (final commit) | Sep 27, 2026; last source change 828c0fe; repository begun Sep 13 |
| Package | src/kb/ |
src/cyclops/ |
| Entry points probed | kb.engine.kb_engine.KBEngine; kb.integration.decide, affordances |
cyclops.KnowledgeBase; cyclops.api.decide, affordances |
| Working tree | clean before and after | clean before and after |
The other pages pin the rewrite at 9be7804 (Sep 24). Seven source commits since then change behavior discussed here: cecc6d1 (game decisions gain indeterminate; explain reports unknown truth), de62694 (calculated effect times), 6d6a9a9 (circular story-time questions stop as EXHAUSTED), 2e0c386 (candidate fluents scoped to each requested time), 33cd9a9 (strict game checks), c736a1b (circular timeline sources) and 828c0fe (timeline preparation shares request limits). They touch api/affordance.py, engine.py, errors.py, solve/context.py, temporal/calculus.py and temporal/timeline.py; the reader, bindings, program, solver, tabling, built-ins and analysis are unchanged since 9be7804.
Method
- Code. Source was read at the pinned commits with
git show <commit>:<path>. Line numbers below are at those commits. Each engine's own architecture notes and migration map were used as starting points and checked against the code; drift found is listed below. - Isolation. Each engine's
src/was exported withgit archive <commit> srcinto a scratch folder, and probes imported only the export. Runs used CPython 3.12.10 on Windows 11 withPYTHONDONTWRITEBYTECODE=1andpython -B; nothing was installed. PyYAML 6.0.3 came from the interpreter's site-packages. Afterwardsgit status --porcelainwas empty in both repositories and no file in either checkout was newer than the probe inputs. - Determinism. Every matched probe ran under
PYTHONHASHSEED0, 1 and 2. The seed changes the iteration order of sets of strings, so a result that depended on that order would differ between seeds. Answers, decisions, statuses, notes, errors, standard output and trace sizes were identical across the three seeds for both engines. - Call paths. A read-only recorder registered a Python 3.12
sys.monitoringcallback for function entry (not generator resumption) and recorded the first-entry order, call counts and nearest in-package caller. Two read-only hooks read arguments and in-progress sets on entry to the story-time evaluators. No engine code was edited or monkey-patched. - Test suites. Each engine's suite was run on an isolated export (
git archiveofsrc,testsand the files its tests read), never in the repository, because the earlier suite's configuration writestest_logs/and.pytest_cache/where it runs. Results are under measurements. - Timings. The matched-probe timings are single runs and support no claim. The one timing the page uses, the change-cost measurement, was taken separately on September 29 as medians of 25 runs, repeated under three hash seeds. Function-entry counts from traces describe one engine's path and are not compared across engines, because the code bases divide work into functions differently.
- Earlier-stage probes. Some claims come from single-engine probes run the same day with the same isolation; they are listed separately.
- Revision-stage probes. Reviewer counterexamples were re-run on September 29 on fresh exports with the same isolation; they are listed separately. Their scripts and raw output are in the JSON under
revision_stage_2026_09_29. - Review-round-2 probes. The second accuracy review's counterexamples were re-run the same day on new exports, under three hash seeds; they are listed separately, with script and output in the JSON under
review_round_2_2026_09_29. - Review-round-3 probes. Two claims from the third accuracy review were re-run the same day on a new export of the rewrite, under three hash seeds; they are listed separately, with scripts and output in the JSON under
review_round_3_2026_09_29.
Detail behind each page section
The Architecture page was cut twice on September 29, 2026: first to a shorter read, after which the second review round returned some qualifications to it in short form, then, aiming at a five-minute read, to a page of at most 1,300 words of visible prose, with each comparison reduced to a table row (what is shared, what differs, one symbol per engine) and one or two sentences of consequence (review record). Review round 3 then corrected that cut within the same word limits. The fuller detail is kept here, one heading per page section, in page order, each linking back to its section, with pointers to the probe tables and source map below. Nothing here is new evidence: the markings are the page's own (In the code, read in source at the pinned commits; Observed, from the probes in this record; What it means, interpretation).
Scope and layering
Page section: Who does each job.
- Shared. The same core jobs, one per row of Figure 1.
- In the code. Besides the kernel, the earlier
src/kb/holds a command-line game and a rooms game, gameplay modules, an ontology validator, metrics, structured logging and a legacy facade (core.py, the originalCyclopsStormKBAPI). "Legacy" is the engine's own word, for that API and for its original rule evaluator, the Legacy evaluator. The rewrite's package is the kernel plus a host boundary (api/); its tests, including a differential suite that runs the earlier engine for comparison, live outside the package. Its modules import only downward, andtests/architecture/test_boundaries.pyenforces the order: terms at the bottom, then the reader and bindings, clause storage, analysis and the per-request context, the solver and timeline, story time, and the facade on top. The solver never imports story time. It reachesholds_atthrough the built-in table the facade assembles, andholds_atcalls the solver back; that runtime recursion is Figure 1's dashed arrow, and the earlier engine has the same two-way calls between its Legacy evaluator and its temporal package. No layering test was found in the earlier engine. - Observed. The legacy facade answers queries from stored facts only and has its own story-time check (
core.py:323), with immediate rather than delayed effects: a rule-based question the main engine answered yes got no answer there. The probe is in single-engine probes. - What it means, in full. The earlier scope is real, but its applications don't exercise its main reasoner; a game adopting the rewrite builds those layers itself.
- Paths and line numbers: source map, row "Scope". What was kept, changed and omitted: Migration.
The traced request
Page section: One question, asked of both.
- In the code. In both engines
decideis built from ordinary queries, so game hooks sit on the query API: is the action offered at this site (once(affords(...))), what are its guards (guard/4, all answers), thenonce(Condition)for each guard in order until one fails. That is three queries for the lab door and four for the tunnel's two guards. Figure 2 traces the condition asked directly,can_enter(lab), on a fresh engine. Earlier:KBEngine.query→QueryExecutionPipeline→HandlerRegistry→Reasoner.evaluate_derived→RoutingFSM.decide→LegacyDerivedEvaluator→FluentEvaluator.holds_at. Rewrite:KnowledgeBase.ask→_refresh→solve→ built-in table →holds_at→_explain. Every function entered, with line numbers, is under request path. - Observed. Under the delayed-effect reading the door is open at
t1and closed fromt2. Each clock value went into a fresh engine, because the earlierKBEnginehas no retract method, so no mutation happened between requests. All eight goals and both decisions are under ordinary success; they include when the lab is locked (t2andt3in both). - Difference case. The page's one difference case keeps the lab story with
now(t0)and adds a second guard,reachable(corridor, lab), over a tunnel of npassage/2segments (probe inputs). It was built to cross the earlier engine's depth cap of 50, not to be typical. Earlierdecide: allow at 10 segments; allow at 50, with one warning on standard output (a search past the lab hit the cap); deny, "You cannot find a way to the laboratory.", from 51 to 60, with two warnings each. Rewrite: allow, complete, at every length; at 60 segments, a 200-step budget givesindeterminatewith statusexhausted, and a cancel givesindeterminatewith statuscancelled. Tables: when evaluation can't finish; what it shows: limits, statuses and cancellation.
Terms and clause storage
Page section: Terms and storage.
- Shared. Immutable terms, numbered variables, a trail (a log of bindings to undo when backtracking), and facts and rules that answer together.
- In the code. The earlier engine converts between two term formats (reader output in
core_types.py, engine terms inkb_core/model.py) and has two unifiers (unify/unifier.py,backtracking.py). It files facts by predicate name inFactStore, story facts in side indexes keyed by event text, and rules in a separateRuleCompiler. The rewrite has five term types (terms.py), one binding store (bindings.py) and one clause list per predicate (program.py). - Observed. The variable-effect and rule-effect probes, and the code that explains them, are under input-design check; the rule form also works in the rewrite (round-2 probes).
- What it means, in full. In the earlier engine, how an effect is written decides which store handles it; in the rewrite a story fact is an ordinary clause, found by unification (binding variables so two terms match). The rewrite's cost is speed, not measured here: with no story indexes, story-time lookups try each earlier time point.
Routing, built-ins and recursion
Page section: Running a query.
- Shared. SLD resolution with backtracking, cut and negation as failure, similar built-ins, automatic tabling of recursion, and unknown predicates that quietly fail.
- In the code. The earlier pipeline picks a handler by predicate name (
engine/query/registry.py), so a built-in's handler also captures user predicates of that name. The router (RoutingFSM.decideinrouting.py) then sends derived goals to the depth-first (DFS) engine or to the Legacy evaluator, using a process-wide allowlist of names (child,person,sibling). The engine records the route it took inlast_route. The rewrite has one generator,solve(solve/machine.py), for typed queries and rule bodies alike; after any change it analyzes the whole program, tables every recursive group and checks cuts and negation through recursion (solve/analysis.py). - What it means, in full. The rewrite's answers depend only on the program, its
Options(occurs check, tie-break policy, exclusive fluents, unknown predicates, automatic tabling) and the request's limits, apart from the generated names of unbound variables (see state held between requests). - Observed. Under the head
human/1, a rule body over 12,000 facts went to the Legacy evaluator: 10 answers. Renamed to the allowlistedperson/1, it went to the DFS engine, whose iteration budget ran out silently: none.holds_at(in(player, corridor), T)answered when typed, but not inside a one-line rule, which reaches a differentholds_atthat needs a bound time. These name-dependence and entry-path probes, and the name-capture probes, are under single-engine probes. - What it means, for the earlier engine. Its answers can also depend on names, entry path and process-wide state, though it records the route taken (
last_route).
Mutable state and caches
Page section: Mutable state.
- Shared. Clauses live on the engine object for its whole life, and both engines build a per-query object, confusingly both named
QueryContext. - In the code. The earlier
QueryContextholds only pipeline bookkeeping. Evaluation state lives on engine-lifetime objects: answer tables and a derived-result cache, cleared when the next query starts, and caches ofholds_atanswers and whole results, invalidated when facts are added (the result cache also expires after 300 seconds). The routing allowlist and loggers are process-wide. The rewrite'sQueryContext(solve/context.py) holds all evaluation state and is dropped after the call. Between requests the knowledge base keeps its clauses and revision counter (Program), a first-argument index per predicate, and the analysis and timeline for that revision (DerivedState); the last three are rebuilt after a change. A request also touches process-wide state: it raises Python's recursion limit for the call, numbers variables from a global counter, and interns any atom it creates in a module-level table (terms.py:165) that every knowledge base in the process shares and nothing empties. Atom names therefore outlive the request, though they don't change answers (round-3 probes). - Observed. The rewrite left no
QueryContextalive and returned a new frozen result each time. The earlier engine's cache returned the list it had stored, so a caller that appended to it changed the next answer. That is a missing copy rather than a cost of caching: a deep-copying repeat was still fast in an earlier measurement. Object-level detail is under state held between requests; timings under change-cost measurement. - What it means, in full. With its cache, the earlier engine's repeats were about 6 times faster than the rewrite's on the lab story (0.011 against 0.07 ms), and about 100 times faster on an earlier measurement's query. Caching speeds the earlier engine's repeats, but correctness depends on every invalidation agreeing, and engines in one process aren't independent: two instances shared one routing registry. The rewrite carries no answers between requests (Figure 2), but any change, even advancing
now/1, costs a whole-program reanalysis, which the request's budget doesn't meter, and a timeline rebuild; both grow with the program. Neither engine is designed for concurrent use: the earlier one made a partial start, with thread-local query state and some locks; the rewrite has neither, and each call raises Python's process-wide recursion limit until it returns.
Story time and the timeline
Page section: Story time.
- Shared. The Event Calculus vocabulary (
initially,happens,initiates,terminates,holds_at), the delayed-effect reading, time ordered bybefore/2links, effect rules written as ordinary rules, exclusive fluents declared withexclusive_fluent/1, a tie-break option for a fluent started and stopped at the same moment, and runtime calls in both directions between story-time evaluation and the solver. - In the code. The earlier engine files story facts into temporal stores as they load and has several
holds_atimplementations, chosen by entry path. It works event-first: find the events before the queried time, then ask what they affect (FluentEvaluator). Seven fluent names are always exclusive (exclusivity.py); the rewrite makes those seven opt-in. The rewrite has oneholds_at(temporal/calculus.py). Its facade builds the timeline once per revision by runningtime/1,before/2andhappens/2through the solver and closing the order transitively (KnowledgeBase._build_timeline); when effect rules compute their own times,holds_atbuilds an enlarged copy for that request only. It works rule-first: at each earlier time point, solve the effect rules, then check that the event happened. - Observed. In the self-referential probe the earlier engine never needed the rule, because stored effect facts settle each question first; had it needed the rule, its re-entry guard would have answered "false" without reporting a cycle. A paradoxical variant without the ordinary lock, where no answer is consistent, shows the guard at work: the earlier engine answered yes at
t1,t2andt3, printing nothing, and the rewrite reportedexhausted. Givenbefore/2orhappens/2defined by rules, the earlier timeline ignored them (the door stayed open and was never locked) and the rewrite answered correctly. Both probes are under round-2 probes. With the time written in the rule's head,terminates(auto_lock(lab), door_open(lab), t1) :- …, the rewrite answerst1, whilet2andt3stay open. Tables, traces and the rewrite's note are under when evaluation can't finish. - What it means, in full. The rewrite's timeline, read through the solver, honors rules that define
before/2orhappens/2, but is rebuilt after every change, and some answerable circular questions stay open, depending on how an effect is written (the three-argument form above answerst1). The earlier timeline updates cheaply while loading, but only from facts: given such rules, it never locked the door.
Limits, statuses and cancellation
Page section: Can't finish.
- In the code. The earlier limits are module constants (
engine/constants.py), such as recursion depth 50 in the Legacy evaluator. Hitting one returns the answers found so far as an ordinary list; a warning may be printed to standard output, but nothing reaches the caller, andKBEngine.querytakes only the query text. The rewrite appliesLimitsper request (default 2,000,000 steps, depth 1,200) and polls the host'scancelcallback every 512 steps (solve/context.py). Timeline preparation counts against the request's budget; whole-program analysis doesn't. A result is complete, exhausted (a budget ran out, or a circular question was stopped) or cancelled (result status);askreturns the answers found so far with the status;queryraisesIncompleteResult, where the earlierqueryreturns the partial list;decideturns any status but complete into the decisionindeterminate, not enabled (api/affordance.py); andspans_forreturns a plain list without a status, so partial provenance looks complete. If a budget or cancel stops preparation, nothing is kept and the next request prepares again; abefore/2clause that raises is recorded instead, and story-time answers that depend on it are marked exhausted. - In the code: analysis conflicts. A cut in any recursive predicate, or negation through recursion, makes every query on that knowledge base raise
SemanticConflict, unrelated ones included, until the program changes (see How it works, known gaps). Loading succeeds;decidereports the conflict asindeterminate, with the error in its notes. - Observed. The tunnel sweeps (10 segments, and every length from 44 to 60), the warnings printed, the budget and cancel calls, and the preparation and conflict probes are under when evaluation can't finish and revision-stage probes. The rewrite has limits too, and reports hitting them.
- Observed: unknown predicates. In both engines, by default, a guard built on an undefined or unsupported predicate (a typo, or
catch/3, which the rewrite doesn't have) fails and refuses the player; the rewrite marks that decision complete. Withunknown_fails=Falsethe rewrite returnsindeterminate, naming the predicate in its notes (round-2 probes; How it works, known gaps, first row). - What it means, in full. On the earlier engine a game can't tell "no" from "gave up" or bound a request, and in the tunnel (built to cross the cap, not to be typical) the player got a false refusal. On the rewrite a game must handle partial answers and
indeterminate. Budgets apply to each ofdecide's queries rather than to the whole decision, so a 200-step budget allows 200 steps for each of the four queries the tunnel's two guards need, and cancellation is cooperative, with no wall-clock deadline.
Explanations and provenance
Page section: Explanations.
- In the code. The earlier
why/1,derivation/1andevidence_chain/1cover rule-derived facts as well as story time, but separate code builds them: derived explanations re-run the goal on the Legacy evaluator, bypassing the router (provenance_handlers.py). The rewrite'sexplainis the procedure that answersholds_at(_holdsreturns_explain(...).holds is True), so the two can't disagree, but it covers story time only. Both find source text through authoredfact_id/2andsupports/2facts; the earlier engine also hassource/2. - Observed. With
now(t0), the earlierwhy(can_enter(lab))returned the rule and its bindings, which the rewrite can't. Withnow(t2), the earlierwhy(can_enter(lab))found no derivation andwhy(holds_at(door_open(lab), t2))returneddoes_not_holdwith no cause. Every request and answer is under explanations. - What it means, in full. For "why can't I?", neither engine explains a failed rule, and only the rewrite names the event that ended the fluent. A game composes that reason from the decision's condition, its message and
explainon the fluents inside the condition, not on the condition itself:explaintreats any term as a fluent, so on a rule head such ascan_enter(lab)it returns a misleading "never initiated", complete, even when the rule succeeds (explanations). For "why can I?", the earlier engine's answer is richer, but separate code builds it, so explanation and answer could disagree; the rewrite's can't.
Host integration and extension
Page section: Host and extension.
- Shared. In-process Python APIs, JSON, and the same game hooks: allow, deny, and abstain when an action isn't offered at a site.
- Results. Earlier: a list of dictionaries. Rewrite: a frozen
Solutionswith answers, status and notes;spans_forreturns a plain list. Shapes are under state held between requests. - Extension. Earlier: register a handler by name, edit the routing allowlist, or set environment flags; a registered handler answers a single goal typed at the top level, but not a rule body or a conjunction. A user fact such as
length(rope, 5)is hidden by the built-in's handler. Rewrite: write clauses; built-ins are fixed, a clause for a built-in's name is refused at load, and there is no API for host-computed predicates. - Changing facts. Earlier: add-only; snapshots cover the fact store only. Rewrite:
assert_andretractbetween queries; snapshots cover the whole program. - Authoring feedback. Earlier: warnings for variables used only once, and syntax errors with a code and suggestions, on by default. Rewrite: syntax errors with a line and column; its documentation lists an authoring analyzer (singletons, unused predicates, arity) as deferred. It records some warnings on
kb.diagnosticswithout printing them: an effect at a time nothing orders (engine.py:808), a time order derived fromholds_at/2(engine.py:741) and an ignored directive (program.py:447; round-2 probes). - What it means, in full. In both engines, values the game computes must in practice be asserted as facts, because an earlier handler doesn't reach rule bodies and the rewrite has no API for host-computed predicates; in the rewrite each assert is a change that pays the reanalysis and timeline rebuild (mutable state).
- Observed. In the earlier engine, a handler registered for
weight/2answeredweight(crate, W)but notweight(crate, W), W > 10or a rule calling it, so a guard built on such a rule refused the player. The handler, name-capture, snapshot, built-in refusal and authoring probes are under single-engine probes and revision-stage probes.
Limits of the comparison
Page section: What this comparison can't tell you. Apart from the change-cost timing, timings are single runs on one machine and aren't used, and trace call counts aren't compared across engines (method). The earlier suite's 20 failures and errors all need the hypothesis package; the author's log recorded 6,660 passed by August 30 (measurements).
Probe inputs
The shared story, loaded as identical bytes into a fresh engine of each kind. The comment on its second line refers to the probe stage's working notes, which this page summarizes; SHA-256 hashes of every input are in the JSON.
% Shared story: may the player enter the laboratory after the door is locked?
% Identical input for both engines. Effect axioms are ground facts (see RESULTS.md).
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').
Loaded after it, each as a separate text with the engine's ordinary load call (e.add(text) or kb.load(text)):
- Clock:
now(t2).ornow(t0)., each into a fresh engine. No mutation happened between requests, because the earlierKBEnginehas no retract method. - Tunnel of n segments:
guard(enter_lab, 2, reachable(corridor, lab), 'You cannot find a way to the laboratory.')., the rulesreachable(A, B) :- passage(A, B).andreachable(A, B) :- passage(A, C), reachable(C, B)., and n factspassage(corridor, tunnel1), …,passage(tunnelN-1, lab). Probed at n = 10, 60 and every value from 44 to 56 in the matched set, and every value from 50 to 60 in both engines during revision. - Self-referential effect:
happens(auto_lock(lab), t1).andterminates(auto_lock(lab), door_open(lab)) :- holds_at(door_open(lab), t3).During revision, also the three-argument spellingterminates(auto_lock(lab), door_open(lab), t1) :- holds_at(door_open(lab), t3).in the rewrite. - Input-design check: the story with its two effect facts written as
terminates(lock_door(R), door_open(R)).andinitiates(lock_door(R), locked(R)).
Probe results
Ordinary success
Goal (now(t2)) |
Earlier engine | Rewrite |
|---|---|---|
holds_at(door_open(lab), t0) |
[{}] |
one answer, complete |
holds_at(door_open(lab), t1) |
[{}] |
one answer, complete |
holds_at(door_open(lab), t2) |
[] |
no answers, complete |
holds_at(door_open(lab), t3) |
[] |
no answers, complete |
holds_at(locked(lab), T) |
T = t2, t3 |
T = t2, t3, complete |
holds_at(F, t2) |
F = locked(lab) |
F = locked(lab), complete |
now(T) |
T = t2 |
T = t2, complete |
can_enter(lab) |
[] |
no answers, complete |
decide(enter_lab, corridor) |
deny, not enabled, condition can_enter(lab), "The laboratory door is locked." |
same, plus order: 1, status: complete, notes: [] |
same with now(t0) |
allow, enabled |
allow, enabled, complete |
affordances(corridor) |
enter_lab, "enter the laboratory", not enabled, same message |
same, plus kind: deny, status: complete |
Request path for the first can_enter(lab) after loading
Functions entered, in first-entry order, with the line where each is defined (from the traced code objects). Paths are relative to src/.
Earlier engine (route recorded as LEGACY / default):
KBEngine.querykb/engine/kb_engine.py:748→QueryExecutionPipeline.executekb/engine/query/pipeline/pipeline.py:70- Stages under
kb/engine/query/pipeline/stages/:CacheLookupStage.processcache_lookup_stage.py:37(miss inPerformanceManager.get_cached_result,kb/engine/performance.py:153);ValidationStage.processvalidation_stage.py:26;ParsingStage.processparsing_stage.py:38, which callsTableManager.clearkb/engine/tabling/table_manager.py:563;RoutingStage.processrouting_stage.py:24→HandlerRegistry.get_handler_or_defaultkb/engine/query/registry.py:108;ExecutionStage.processexecution_stage.py:26→GeneralPredicateHandler.handlekb/engine/query/query_handlers.py:938 Reasoner.evaluate_derivedkb/engine/reasoning/reasoner.py:768→QueryCache.get_derived_resultkb/engine/query_cache.py:130(miss) →QueryAnalyzer.analyzekb/routing.py:282→RoutingFSM.decidekb/routing.py:453→EngineRouter.eval_with_enginekb/engine/routing/engine_router.py:99→LegacyDerivedEvaluator.evaluate_with_special_caseskb/engine/derived_evaluation/legacy_evaluator.py:416→DerivedEvaluationTemplate.evaluatekb/engine/derived_evaluation/template.py:44→_check_recursionlegacy_evaluator.py:63→BodyEvaluationCoordinator.evaluate_with_strategieskb/engine/evaluation/body_coordinator.py:57→Reasoner._evaluate_literalreasoner.py:1225(twice)Reasoner._evaluate_holds_at_literalreasoner.py:1374→Reasoner.holds_atreasoner.py:705→FluentEvaluator.holds_atkb/engine/reasoning/temporal/fluent_evaluator.py:96→_compute_holds_at:164→PersistenceEvaluator._is_terminated_beforepersistence.py:243→TemporalReasoner.lt_timetemporal_reasoner.py:58(7 calls; reachability built lazily by_compute_reachability:277)QueryCache.cache_derived_resultquery_cache.py:158;ProjectionStage.processprojection_stage.py:25→PerformanceManager.cache_resultperformance.py:147;MetricsStage.processmetrics_stage.py:30. The host receives[].
Rewrite:
KnowledgeBase.askcyclops/engine.py:339→_run:384→_context:646→QueryContext.__init__cyclops/solve/context.py:92KnowledgeBase._refreshengine.py:668(revision changed):analysecyclops/solve/analysis.py:114; a second, bootstrapQueryContextsharing the request's budget;KnowledgeBase._build_timelineengine.py:815→build_timelinecyclops/temporal/timeline.py:120;DerivedStatepublishedsolvecyclops/solve/machine.py:42→resolve_clausesmachine.py:275→ built-in table (machine.py:123; table assembled atengine.py:60) →holds_atcyclops/temporal/calculus.py:671prepare_effect_timestimeline.py:306→_holdscalculus.py:487→_explaincalculus.py:548→_starts_and_stops:404→_effects:204(4 calls, each back intosolve) →_clipped:469→Timeline.precedestimeline.py:92_runmaps exceptions andctx.statusto a status (engine.py:430-462) and returnsSolutions(answers=(), status=COMPLETE). On a fresh knowledge base this first request reports 103 steps, depth 5, because timeline preparation is charged to it; the same query repeated reports 22 (re-measured during revision; the draft of this record paired the cold trace with the warm count).ask("once(can_enter(lab))"), the formdecidesends, reports 104 when cold.
One decide sent three KBEngine.query calls in the earlier engine and three KnowledgeBase.ask calls in the rewrite, which built four QueryContext objects: one per call plus one bootstrap. The three are the action (once(affords(enter_lab, corridor))), its guards (guard/4, all answers) and one once(Condition) per guard until one fails, so the count grows with the guards: the tunnel's two-guard decide sent four (affordance.py:178-199).
State held between requests
| After | Earlier engine (KBEngine object graph) |
Rewrite (KnowledgeBase) |
|---|---|---|
| Loading | epoch 2; all caches empty | attributes _derived, options, program; program.revision 16; _derived.revision −1 |
First can_enter(lab) |
FluentEvaluator._holds_cache 1 entry; QueryCache._derived_cache 1; pipeline result cache 1; answer tables 0 |
_derived.revision 16 (analysis, timeline, diagnostics); first-argument index built on can_enter/1, happens/2 and initially/1 (_index_revision −1 → 16); 0 live QueryContext objects (checked with gc) |
| Same query again | answered from the pipeline cache; 16 functions entered; solver and story-time evaluator not entered; last_route is None |
1 new QueryContext; analysis and timeline reused; solver and story-time evaluation re-run |
First decide on a fresh engine |
pipeline result cache 3 entries; _holds_cache 1; _derived_cache 1 |
_derived.revision 16; 0 live contexts |
Returned objects: in the earlier engine a second identical query("holds_at(locked(lab), T)") returned the same list object as the first, and that object was the value stored in the pipeline cache. That is a missing defensive copy, not a property of caching: the evidence overview measured a deep-copying repeat at 0.008 ms against 0.004 ms. In the rewrite each ask returned a new frozen Solutions whose answers are a tuple. Two separate KBEngine instances shared one routing registry and one structured logger. Process-wide state the rewrite touches per request: ask raises sys.setrecursionlimit for the call and restores it afterwards (engine.py:376-382; 1000 before and after in the probe), and variable numbers come from a module-level counter (terms.py:109), so non-ground answers get different variable names on each identical request (mk(X) gave f(_G22), then f(_G24)). Atoms are interned in a module-level dictionary (terms.py:165) that only grows: an atom first created by a request stays after it (round-3 probes).
Result shapes: the earlier query returns list of dict, with compound values as internal terms (Term('locked', (Term('lab', ()),))); its Decision.as_json has action, kind, enabled, condition, message. The rewrite's ask returns Solutions(goal, answers, status, notes, steps, depth) with values such as Struct('locked', (Atom('lab'),)); query() returns [{'F': locked('lab')}]; its Decision adds order, status and notes.
When evaluation can't finish
Public signatures, read with inspect.signature: earlier KBEngine.query(self, query_str: str) -> List[Dict[str, Any]], decide(engine, action, site=None), affordances(engine, site). Rewrite KnowledgeBase.ask(self, goal, *, limits=None, distinct=True, cancel=None), explain(self, fluent, when, *, limits=None, cancel=None); decide and affordances also take limits and cancel. Limits() defaults: 2,000,000 steps, depth 1,200, no answer limit.
Input (now(t0)) |
Earlier engine | Rewrite |
|---|---|---|
10 segments: reachable(corridor, lab) |
true | true, complete |
10 segments: answers to reachable(corridor, X) |
10 | 10, complete |
10 segments: decide |
allow | allow, complete |
60 segments: reachable(corridor, lab) |
[]; standard output: WARNING: Recursion depth limit (50) reached for query: passage(tunnel49,lab) and ... reachable(tunnel50,lab); no log records |
true, complete (809 steps, depth 122) |
60 segments: answers to reachable(corridor, X) (60 exist) |
50 | 60, complete |
60 segments: decide |
deny, condition reachable(corridor,lab), "You cannot find a way to the laboratory." |
allow, complete |
44 to 56 segments: reachable(corridor, lab) |
true up to 50, false from 51 | true at every length, complete |
50 to 60 segments: decide (revision stage) |
50: allow, one line on standard output (WARNING: Recursion depth limit (50) reached for query: reachable(lab,lab)); 51 to 60: deny, two lines each |
allow, complete, at every length |
Rewrite only, 60 segments, on a knowledge base warmed by one ask("true") (82 steps, nearly all timeline preparation; whole-program analysis is not metered, because analyse(program, *, auto_table) takes no step callback, and cancellation is checked only before and after it). The decide sub-queries took 3, 3, 8 and 729 steps at default limits.
| Call | Result |
|---|---|
ask("reachable(corridor, lab)", limits=Limits(steps=200)) |
no answers, exhausted, 201 steps |
query(...) with the same limit |
raises IncompleteResult: query did not run to completion (status=exhausted, 0 answer(s) found so far) |
decide(..., limits=Limits(steps=200)) |
indeterminate, not enabled, condition reachable(corridor, lab), order: 2, exhausted, message "Action availability could not be determined." |
ask(..., cancel=lambda: True) |
no answers, cancelled, 0 steps, note "query cancelled" |
decide(..., cancel=lambda: True) |
indeterminate, cancelled |
decide(...) afterwards with defaults |
allow, complete |
Cold knowledge base, ask("true", limits=Limits(steps=5)) |
exhausted, note "timeline preparation stopped (ResourceExhausted: …)"; derived state not published; a retry with defaults completes in 82 steps |
ask("reachable(corridor, X)", limits=Limits(steps=300)) (revision stage) |
1 answer, exhausted, 301 steps: ask returns the answers found so far |
Unstratified p :- \+ q. q :- p. added (revision stage) |
ask("r(X)") and query("r(X)") raise SemanticConflict; decide returns indeterminate, not enabled, message "Action availability could not be determined.", status None, the error in its notes |
Self-referential effect rule (now(t2)), each ask order (t3,t2,t1, t2,t3,t1, t1,t2,t3) on a fresh engine:
holds_at(door_open(lab), T) |
Earlier engine | Rewrite |
|---|---|---|
t1 |
true | no answers, exhausted |
t2 |
false | no answers, exhausted |
t3 |
false | no answers, exhausted |
decide |
deny, "The laboratory door is locked." | indeterminate, exhausted |
rewrite explain(door_open(lab), t2) |
holds: null, reason: unexplained, exhausted |
|
rewrite affords(A, corridor), same knowledge base |
A = enter_lab, complete |
With the effect written in three-argument form, terminates(auto_lock(lab), door_open(lab), t1) :- holds_at(door_open(lab), t3). (no happens/2 needed), the rewrite answered t1 complete with one answer in both ask orders, left t2 and t3 exhausted, and decide stayed indeterminate. The difference: the two-argument rule's body runs before it is joined to happens/2, as the rewrite's docs/LIMITATIONS.md says.
The rewrite's note: "holds_at(door_open(lab), t3) is defined in terms of itself at the same time point; circular temporal evaluation stopped without assuming true or false." Traces: the earlier FluentEvaluator.holds_at was entered once per question and its rule-effect lookup (WeakReasonerMixin.evaluate_causal_rules) never, so the self-referential rule never ran. The rewrite's _explain was entered for (door_open, t1), then (door_open, t3), then (door_open, t3) again, which found its key in progress and raised TemporalCycle. The rewrite's docs/LIMITATIONS.md at 5d598de describes the case under "Time-scoped candidates do not reorder rule bodies". Why the earlier engine never ran the rule: PersistenceEvaluator._is_terminated_before (persistence.py:243) checks stored termination facts first and returns as soon as one precedes the queried time, which the lock_door fact does for t2 and t3; for t1 no event precedes it. Its own cycle guard (fluent_evaluator.py:133-137, which returns false) is code-verified but was not reached. The single consistent answer, which the earlier engine gives, is open at t1 and closed at t2 and t3: the ordinary lock closes the door whatever the rule does, so the rule's condition is false.
Explanations
| Request | Earlier engine | Rewrite |
|---|---|---|
Door at t2 (now(t2)) |
why(holds_at(door_open(lab), t2)): type: temporal, reason: does_not_hold, no cause |
explain("door_open(lab)", "t2"): holds: false, reason: clipped, clipped_by: lock_door(lab) @ t1, complete |
Lock at t2 |
why(holds_at(locked(lab), t2)): initiated_by lock_door(lab) at t1, supporting happens and initiates facts |
explain("locked(lab)", "t2"): holds: true, reason: initiated, start lock_door(lab) @ t1 |
The derived can_enter(lab) (now(t2)) |
why: type: derived, no_derivation_found; derivation: []; why(\+ can_enter(lab)): no_derivation_found; evidence_chain(holds_at(door_open(lab), t2)): one child, initially(door_open(lab)), no cause (revision stage) |
no rule-level explanation; ask("why(can_enter(lab))") is [], complete; with unknown_fails=False it raises UnknownPredicateError |
The derived can_enter(lab) (now(t0)) |
why and derivation: rule_derivation, rule text can_enter(R) :- now(T),holds_at(door_open(R),T)., bindings {'R$1': 'lab', 'T$2': 't0'} |
no equivalent |
| A rule head passed to the story-time explainer | explain("can_enter(lab)", "t2"): holds: false, reason: never_initiated, complete; also with now(t0), when ask("can_enter(lab)") has one answer (revision stage): explain treats any term as a fluent |
|
| Method-level story-time explanation | KBEngine.explain_holds_at("door_open(lab)", "t2") returns the string "Error parsing fluent: _parse_term usage detected in production code …" |
KnowledgeBase.explain, above |
At the boundary tick both engines say door_open(lab) holds at t1 because it held initially; for locked(lab) at t1 the earlier engine says does_not_hold and the rewrite never_initiated. Neither says the lock takes effect after t1.
Which code produced what: the earlier answer came from HoldsAtHandler.handle (query_handlers.py:172) → Reasoner.holds_at → FluentEvaluator.holds_at. Its temporal why came from WhyHandler (kb/engine/query/provenance_handlers.py:89) → _explain_temporal (:169), which calls Reasoner.holds_at for the verdict and builds a positive reason from a separate scan of stored facts whose time test (:205) also accepts an initiation at the queried time; no divergence was observed in this story. Its derived why (_explain_derived, :226) called LegacyDerivedEvaluator.evaluate_with_special_cases directly and did not enter the router. In the rewrite, ask and KnowledgeBase.explain (engine.py:505) both reach calculus._explain (:548); _holds (:487) is _explain(...).holds is True.
Input-design check: effect facts with variables
Goal (now(t2)) |
Earlier engine | Rewrite |
|---|---|---|
holds_at(door_open(lab), t2) |
[{}] (true) |
no answers, complete |
holds_at(locked(lab), T) |
[] |
T = t2, t3, complete |
can_enter(lab) |
[{}] (true) |
no answers, complete |
The earlier engine files effects under the event's text (FactStore.add_termination(event: str, fluent), kb/engine/storage.py:168) and finds an effect's time by looking that text up in event_time_map (get_event_time, :223; used in persistence.py:249-253). No happens/2 fact has the text lock_door(R), so the effect is skipped without an error. The rewrite solves effect goals with unification (_effects, calculus.py:204). The shared story therefore writes its effect facts ground.
Written as rules, terminates(lock_door(R), door_open(R)) :- room(R). and initiates(lock_door(R), locked(R)) :- room(R). with room(lab)., the same effects work in the earlier engine (revision stage): holds_at(door_open(lab), t2) → [], holds_at(locked(lab), T) → t2, t3. Its rule-effect path (WeakReasonerMixin.evaluate_causal_rules) unifies; only the fact path is keyed by text.
Single-engine probes cited on the page
| Page claim | Input and result |
|---|---|
| The earlier games run on a facade that ignores rules | Story with can_enter(P, lab, T) :- holds_at(in(P, corridor), T), \+ holds_at(locked(lab_door), T). Legacy CyclopsStormKB.query("can_enter", "player", "lab", "t1") → []; KBEngine.query("can_enter(player, lab, t1)") → [{}]. The facade's holds_at("locked(lab_door)", "t1") → True (immediate effect); KBEngine → []. The CLI and games import CyclopsStormKB (kb/cli/interactive.py:20, kb/cli/query_engine.py:13, kb/rooms_game.py:22, kb/gameplay/__init__.py:20) |
| Answers depend on a predicate's name | 12,000 facts n(1)…n(12000); human(X) :- n(X), X > 11990. → 10 answers, route LEGACY/default; the same body under person(X) → 0 answers, route DFS/allowlist; person(11995) → [], human(11995) → [{}]. Allowlist child, person, sibling (kb/routing.py:96); DFS budget MAX_BACKTRACKING_ITERATIONS = 10000 (kb/engine/constants.py:84) |
| Answers depend on the entry path | holds_at(in(player, corridor), T) at the top level → t1, t2, t3; open_then(T) :- holds_at(in(player, corridor), T). then open_then(T) → []. In a rule body, holds_at with an unbound time and no cutoff returns no answers (Reasoner._evaluate_holds_at_literal, reasoner.py:1374) |
| Engines in one process share state | KBEngine().reasoner.registry is KBEngine().reasoner.registry → True; adding human to one engine's allowlist is visible from the other; loggers and tracker shared |
| Snapshots cover the fact store only | snapshot(), then add time(t9), before(t3, t9), an unlock event, vip(player) and can_enter(P, lab, T) :- vip(P)., then restore(): vip(player) gone, but 2 can_enter rules remain and the TemporalReasoner still knows t9 (FactStore.snapshot, kb/engine/storage.py:376) |
| Name capture | length(rope, 5). then length(rope, N) → [], also through a rule; sort(crate, heavy). then sort(crate, W) → [] |
| A caller's edit changes a later answer | appending {"Who": "intruder"} to the list returned by can_enter(Who, lab, t1) made the next identical query return both rows |
| Rewrite: analysis conflicts stop every query | an unstratified p :- \+ q. q :- p., or a cut in a recursive predicate, makes an unrelated r(X) raise SemanticConflict; load itself succeeds; with auto_table=False the cut example runs |
| Rewrite: mutation rebuilds derived state | retract("now(t0)"), assert_("now(t2)."): revision 16 → 18; the next decide is deny, complete; kb.analysis and kb.timeline are new objects with the same content |
Rewrite: spans_for doesn't report incompleteness |
fact_id(door_open(lab), f1)., supports(span(doc1, 0, 10), f1)., supports(span(doc2, 0, 5), f1) :- deep(5000). with a recursive deep/1 and auto_table=False: kb.spans_for("door_open(lab)") → ['span(doc1, 0, 10)'] with no status, while ask on the same goal reports exhausted (KnowledgeBase.spans_for, engine.py:580-608) |
Revision-stage probes
Run on September 29, 2026 on fresh git archive exports of both pinned commits, with PYTHONDONTWRITEBYTECODE=1, python -B and PYTHONHASHSEED=0; each answers a reviewer's counterexample or a reader's question. Scripts and raw output: the JSON, key revision_stage_2026_09_29.
| Page claim | Input and result |
|---|---|
| Rewrite: a request leaves a revision-keyed clause index behind | _index_revision of can_enter/1, happens/2, initially/1: −1 before the first ask("can_enter(lab)"), 16 after (program.revision 16); 0 live QueryContext objects (Predicate.matching, program.py:133-170) |
| Rewrite: value conversion loads with the package; JSON and game hooks on first use | fresh process, after import cyclops: cyclops.api.values loaded, cyclops.api.json_api and cyclops.api.affordance not (engine.py:47; api/__init__.py:50-70) |
| Rewrite: diagnostics live on the knowledge base | initiates(e, g, t9). with t9 unordered: ask("holds_at(g, t1)") complete with no notes; kb.diagnostics holds the warning "initiates(e, g, t9). acts at t9, which nothing orders …" (engine.py:624-636) |
Rewrite: the timeline is read from before/2 answers, closed transitively |
with before(t0, t1). before(t1, t2).: ask("before(t0, t2)") complete with 0 answers; kb.timeline.precedes(t0, t2) true (timeline.py:92-98, :149-158) |
Rewrite: a before/2 clause that raises is recorded, not fatal |
adding before(t3, X) :- X is foo + 1.: ask("true") complete and derived state published (revision 17); holds_at(door_open(lab), t1) 1 answer, exhausted, note "before(A, B) could not be read while building the time order (TypeCheckError …)" |
| Rewrite: preparation is charged to the request; an interrupted preparation isn't kept | fresh knowledge base, ask("can_enter(lab)", limits=Limits(steps=1)): exhausted, 2 steps, note "timeline preparation stopped …", _derived.revision −1; retry with defaults: complete, 103 steps, _derived.revision 16 |
| Rewrite: a clause for a built-in is refused | KnowledgeBase().load("length(rope, 5).") raises PermissionErrorCS: "length/2 is a builtin and cannot be redefined by the knowledge base" |
| Earlier: a registered handler doesn't reach rules | BaseQueryHandler registered as weight/2 (crate 12, rope 2): weight(crate, W) → [{'W': 12}]; weight(crate, 12) → [{}]; weight(crate, W), W > 10 → []; heavy(crate) with heavy(X) :- item(X), weight(X, W), W > 10. → []; light(rope) with light(X) :- weight(X, W), W < 5. → []; decide for a guard light(rope) → deny. The registry is consulted only by the pipeline's routing stage (engine/query/pipeline/stages/routing_stage.py:49) and one forall/2 special case (engine/evaluation/literal_evaluator.py:1164) |
| Earlier: authoring checks on by default | add("can_enter(R) :- now(T), holds_at(door_open(R), X).") prints "WARNING: Variable 'X' appears only once - may be unintentional" and the same for T; add("p(a) :- q(b") raises KBSyntaxError "[SYNTAX_INVALID_QUERY] Unmatched parentheses …" with a suggestion list. The rewrite loads the first silently (no diagnostics) and reports the second as SyntaxErrorInSource: expected ')' (found 'end of input') at <string>:1:12; its docs/LIMITATIONS.md lists an authoring analyser as deferred |
Review-round-2 probes
Run on September 29, 2026 on new git archive exports of both pinned commits, with PYTHONDONTWRITEBYTECODE=1 and python -B, under PYTHONHASHSEED 0, 1 and 2; each engine's output was identical across the seeds. Each checks a counterexample from the second accuracy review. Script and raw output: the JSON, key review_round_2_2026_09_29.
| Page claim | Input and result |
|---|---|
| Both: an undefined or unsupported predicate fails, and a guard on it refuses | Lab story, now(t0). and guard(enter_lab, 2, catch(can_enter(lab), _, true), 'Guard two failed.').: can_enter(lab) has one answer in both engines; catch(...) has none (rewrite: complete, no notes); decide is deny, "Guard two failed." (rewrite: complete). With the guard can_entre(lab), a typo: deny in both, complete in the rewrite. Rewrite with unknown_fails=False: both are indeterminate, status None, note "UnknownPredicateError: unknown predicate: catch/3" (or can_entre/1) |
| Earlier: the re-entry guard answers "false" silently | Without the ordinary lock: the time points, initially(door_open(lab))., happens(auto_lock(lab), t1). and terminates(auto_lock(lab), door_open(lab)) :- holds_at(door_open(lab), t3). No answer is consistent. Earlier engine, asked t3, t2, t1 and t1, t2, t3: holds_at(door_open(lab), T) is [{}] (yes) at t1, t2 and t3, nothing printed. Rewrite: no answers, exhausted, at all three, note "holds_at(door_open(lab), t3) is defined in terms of itself at the same time point; …" |
Earlier timeline reads facts only; the rewrite honors rules for before/2 and happens/2 |
Times dawn, noon, dusk, night; next/2 facts; before(X, Y) :- next(X, Y).; the lock at noon. Earlier: door open at noon and dusk, locked(lab) never, before(noon, dusk) → []. Rewrite: open at noon, closed at dusk, locked at dusk and night, before(noon, dusk) true, all complete. With lock_time(t1). and happens(lock_door(lab), T) :- lock_time(T).: both answer happens(lock_door(lab), T) with t1; earlier: door open at t2, never locked; rewrite: closed at t2, locked at t2 and t3, complete |
| Rewrite: effects written as rules work | room(lab)., terminates(lock_door(R), door_open(R)) :- room(R)., initiates(lock_door(R), locked(R)) :- room(R)., now(t2).: holds_at(door_open(lab), t2) no answers, locked(lab) at t2 and t3, can_enter(lab) no answers, all complete; the earlier engine gives the same |
| Both: tabling stops recursion over a cycle | edge(a, b). edge(b, c). edge(c, a). with left-recursive and with right-recursive path/2: path(a, X) gives a, b, c and path(a, a) is true in both engines (rewrite: complete) |
| Rewrite: some authoring warnings are recorded, not printed | Lab story plus :- initialization(main).: loads; ask("true") complete with no notes; kb.diagnostics holds "[warning] |
Review-round-3 probes
Run on September 29, 2026 on a new git archive export of the rewrite at 5d598de, with PYTHONDONTWRITEBYTECODE=1 and python -B, under PYTHONHASHSEED 0, 1 and 2; the output was identical across the seeds. Each checks a claim from the third accuracy review. Scripts and raw output: the JSON, key review_round_3_2026_09_29.
| Page claim | Input and result |
|---|---|
| Rewrite: atoms a request creates outlive it | terms._ATOM_CACHE is a module-level dictionary (terms.py:165), so every knowledge base in the process shares it by construction, and nothing in the package removes an entry (its only uses are terms.py:170-173). After loading p(a). and ask("true"): 5 entries. After ask("X = zz_new_atom_1"): 6. After ask("atom_concat(zz_made_, at_runtime, A)") (complete, A = zz_made_at_runtime): 9, still 9 after a second KnowledgeBase is created |
| Rewrite: the difference case at 51, 55 and 60 segments | Lab story, now(t0). and the tunnel (probe inputs), each length on a cold knowledge base and on one warmed by ask("true"): decide with Limits(steps=200) is indeterminate, exhausted; with cancel=lambda: True, indeterminate, cancelled; at default limits, allow, complete |
Change-cost measurement
The lab story plus now(t0) and N unrelated facts item(1) … item(N), loaded into a fresh engine and queried once. Then, 25 times: add one unrelated fact seen(k) (kb.assert_ in the rewrite, KBEngine.add in the earlier engine) and ask can_enter(lab), timed together; then ask again, timed alone. Medians in milliseconds, CPython 3.12.10 on Windows 11, one machine, September 29, 2026; the three rows per cell are hash seeds 0, 1 and 2.
| N | Rewrite: change, then query | Rewrite: repeat | Earlier: change, then query | Earlier: repeat |
|---|---|---|---|---|
| 0 | 0.26, 0.25, 0.25 | 0.071, 0.071, 0.070 | 0.35, 0.36, 0.35 | 0.011, 0.011, 0.011 |
| 1,000 | 0.53, 0.65, 0.54 | 0.071, 0.086, 0.072 | 0.35, 0.35, 0.33 | 0.011, 0.011, 0.011 |
| 10,000 | 3.37, 3.05, 3.45 | 0.087, 0.073, 0.087 | 0.36, 0.36, 0.35 | 0.011, 0.011, 0.011 |
The rewrite's cost after a change grows with the program because _refresh re-analyzes all of it and rebuilds the timeline; the earlier engine clears its caches but rebuilds nothing. These are indicative only: small programs, facts only, one machine.
Source map
Paths are relative to src/kb/ for the earlier engine and src/cyclops/ for the rewrite unless shown in full; line numbers are at the pinned commits.
| Page section | Earlier engine (e62758c) |
Rewrite (5d598de) |
|---|---|---|
| Scope | Applications cli/, gameplay/, rooms_game.py, rooms_loader.py, rooms_cli_integration.py, app.py; ontology_validator.py; legacy facade core.py:41 CyclopsStormKB (query :218, holds_at :323); instrumentation engine/metrics/, engine/structured_logging/, engine/diagnostics/; query_decomposer/ has no importer; src/tools/ is excluded from the package |
Kernel: 17 modules; host boundary api/values.py (imported by the facade, engine.py:47, the one permitted kernel-to-api edge), and api/json_api.py, api/affordance.py, loaded on first use (api/__init__.py:50-70). Import layering: tests/architecture/test_boundaries.py:126-172 (LAYERS, test_dependencies_only_point_downwards); test_nothing_imports_the_predecessors_package (:87); differential runner tests/differential/runner.py |
| Terms and storage | Reader output core_types.py; engine terms kb_core/model.py (Var :24, Term :166, walk :723); conversion engine/kb_engine.py:819; unify/unifier.py:79 Unifier.unify, :468 clean_substitution; backtracking.py:73 Trail, :102 unify_inplace; engine/storage.py:22 FactStore (static_by_functor :38, add_happens :125); engine/facts/processor.py:23 routes facts by functor; engine/compilation/rule_compiler.py:14 rules_by_functor |
terms.py:75-158 (variable identity :112-131); bindings.py:21-93 Bindings, :105 unify, :156 rename; program.py:112-170 Predicate and first-argument index, :185 Program |
| Running a query | engine/query/pipeline/pipeline.py:52-70; engine/query/registry.py:39 register, :108 get_handler_or_default; registrations engine/query/handler_setup.py:47-95; routing.py:216 get_default_registry, :453 RoutingFSM.decide; engine/routing/engine_router.py:99; backtracking.py:262 DFSEngine; tabling engine/tabling/tabling_coordinator.py:30, tables cleared per query engine/query/pipeline/stages/parsing_stage.py:62; recursive predicates registered on rule add engine/reasoning/reasoner.py:538; engine/kb_engine.py:789 last_route |
solve/machine.py:42 solve, :123-126 built-in lookup, :257 _user_predicate, :275 resolve_clauses; engine.py:60 BUILTINS; solve/analysis.py:114 analyse (no step callback: unmetered), conflicts :152-163 and :188-200; solve/tabling.py:99 solve_tabled, :155 _run_fixpoint |
| State | engine/query/pipeline/query_context.py:15 (pipeline QueryContext); engine/kb_engine.py:257 thread-local query slots; engine/caching/epoch_manager.py:27 EpochCacheManager; engine/performance.py:134 PerformanceManager (TTL :144); engine/query_cache.py:52 QueryCache; engine/reasoning/temporal/fluent_evaluator.py:78 _holds_cache; cache cleared on add engine/facts/manager.py:190; cache hit returns the stored list engine/query/pipeline/stages/cache_lookup_stage.py:37; uid counter kb_core/model.py:20; flags engine/flags.py |
solve/context.py:65-136 QueryContext; engine.py:167-185 DerivedState, :668 _refresh, :682 revision check, :694-705 publish only if complete; program.py:133-170 index keyed by revision; process-wide terms.py:109, :165; recursion limit raised per call engine.py:376-382 |
| Story time | engine/reasoning/temporal/fluent_evaluator.py:96 holds_at, :133-137 re-entry returns false, :164 _compute_holds_at, :389-418 event-first rule effects; weakref_mixin.py:50 WeakReasonerMixin, :193 evaluate_causal_rules; persistence.py:243-253; temporal_reasoner.py:58 lt_time, :160 numeric names; exclusivity.py:212 seven exclusive names; exclusive_fluent/1 declarations engine/facts/processor.py:105; tie-break policy engine/kb_engine.py:80; other holds_at paths engine/query/query_handlers.py:158, engine/reasoning/reasoner.py:1181 and :1374, engine/evaluation/literal_evaluator.py:253, core.py:323 |
temporal/calculus.py:671 holds_at, :487 _holds, :548 _explain, :554-564 cycle check, :204 and :246-253 _effects, :469 _clipped, :747 TEMPORAL_BUILTINS; engine.py:815 _build_timeline; temporal/timeline.py:92 precedes, :120 build_timeline, :306 prepare_effect_times (enlarged per-request copy); exclusivity calculus.py:39-58, :85 LEGACY_EXCLUSIVE_FLUENTS (off by default), :167 exclusive_fluent/1; Options engine.py:82-108 (occurs_check, tie_break, exclusive_fluents, unknown_fails, auto_table) |
| Limits | engine/constants.py:53 MAX_TRANSITIVE_DEPTH = 50, :84 MAX_BACKTRACKING_ITERATIONS = 10000; engine/derived_evaluation/legacy_evaluator.py:63-91 _check_recursion; template.py:91-93; engine/tabling/tabling_coordinator.py:27 pass limit; engine/kb_engine.py:748 query, :776-786 errors relabeled as parse errors; integration/affordance.py:153-165 _holds |
solve/context.py:28-45 Limits, :155-179 step, cancel (every 512 steps) and depth checks; errors.py:42 Completion; engine.py:430-462 status mapping, :476-486 query raises; api/affordance.py:125-146 _ask, :200-209 indeterminate |
| Host, explanation, extension | integration/json_api.py:125 query_json; integration/affordance.py:168 decide, :201 affordances; engine/query/provenance_handlers.py:27, :77, :253; engine/debugging/explanation.py:43 explain_holds_at; codec.py:248 guard; engine/storage.py:551 spans_for_term; engine/indexing/canonical.py:141 |
api/json_api.py:156 answers_to_json; api/affordance.py:165 decide, :213 affordances; engine.py:246-278 assert_, retract, :280-335 snapshot, restore, :505 explain, :580-608 spans_for; solve/builtins.py:60-71 @builtin |
Measurements, current and historical
Current values were measured on September 28, 2026 with the method of measure-migration.py (physical lines from pinned Git snapshots). Historical values are as the other pages recorded them.
| Measurement | Current | Historical |
|---|---|---|
| Rewrite package | 25 files, 7,904 lines at 5d598de: kernel 7,096 (17 modules, including the engine.py facade and errors.py), host boundary 592, __init__ files 216 |
25 files, 7,426 lines at 9be7804 (Migration, How it works) |
Earlier package src/kb/ |
187 files, 54,387 lines at e62758c (unchanged) |
same |
Earlier src/ including src/tools/ |
198 files, 61,715 lines | same |
Rewrite tests/ |
52 files, 16,345 lines | 38 files, 13,137 lines at 9be7804 |
| Rewrite suites | on an isolated export: 1,790 passed and 5 skipped (encoder tests that need the earlier engine importable), 45 deselected; with the earlier engine's export available, those 5 pass, 37 of 37 differential tests pass, and 8 benchmarks pass | 1,795 correctness cases, 37 differential, 8 benchmarks recorded at 828c0fe |
| Earlier suite | on an isolated export of src, tests, models, scripts and pyproject.toml, -m "not benchmark": 6,647 passed, 27 skipped, 65 deselected (benchmarks), 4 xfailed, 5 failed, 15 errors; all 20 failures and errors are ModuleNotFoundError: No module named 'hypothesis', which wasn't installed (September 29, 2026) |
6,660 passed (author's log, by August 30, 2026) |
| Repeated-query speed | lab story: 0.011 ms earlier vs about 0.07 ms rewrite (change-cost measurement) | evidence overview: repeated query 0.004 ms earlier vs 0.44 ms rewrite; first query 6.3 ms vs 2.4 ms (September 2026, earlier revisions) |
Line counts measure scope, not quality or simplicity. The earlier src/kb/ includes applications and instrumentation that the rewrite doesn't have.
Documentation checked against code
- Earlier engine. Its
docs/KB_API_AND_DESIGN.mddescribes a clause index inIndexedFactStorethat was removed (engine/indexed_store.py:1-22), saysRuleCompilerchecks stratification at compile time although the load path doesn't call it (p :- \+ q. q :- \+ p.loads and both goals answer[]), gives the DFS allowlist asperson, child(code:person, child, sibling), and listsexplain_holds_atas public API although it returns an error string outside the test suite. Its README names files that don't exist. Its claim of identical answer order on every run holds for top-level handlers but not fortime/1inside a rule body, whose order changed across hash seeds 0 to 5. - Rewrite. Its architecture diagrams show
_refresh()before theQueryContextis built; the code builds the context first and prepares inside the status boundary. Its prose places the analysis and timeline caches on the program; the code keeps them onKnowledgeBase._derived, and the per-predicate first-argument index is a third revision-keyed structure it doesn't list. Its migration map calls candidate-fluent generation eliminated (now redesigned per requested time) and says self-referential fluents answer false with a note (since6d6a9a9they stop asEXHAUSTED).solve/context.pymentions a provenance recorder that doesn't exist.spans_fordoesn't carry a status. - This site. How it works says all long-lived state belongs to the program; at both
9be7804and5d598dethe derived analysis and timeline live on theKnowledgeBasefacade, keyed by the program's revision.
Review record
This is AI-simulated review, not testing with real users. Independent AI agents played readers and adversarial reviewers in three rounds: round 1 on the first, longer draft; round 2 on the page after it was cut to a shorter read; round 3 on the second cut. In every round the readers got the rendered page with no further explanation and answered five questions: what the projects share, the two most important architectural differences, where a request's mutable state lives, what happens when evaluation can't finish, and what would affect their choice. The reviewers were asked to find unsupported conclusions, unfair comparisons, omitted strengths of the earlier engine, misleading diagrams and counterexamples, and they ran their own isolated probes. Approval was not treated as evidence: each finding below was checked, then fixed or its uncertainty stated. Round 3 had one reader and one accuracy reviewer (below).
Second cut: toward a five-minute page
What changed. After round 2 the page was cut again on September 29, 2026, aiming at a five-minute read: at most 1,300 words of visible prose and 1,800 visible words in all (prose, tables, and figure questions and captions; the collapsed text equivalents and the code block aren't counted). Counted by a script over the rendered HTML, prose fell from 2,012 to 1,267 words, tables from 407 to 265, and figure questions and captions from 247 to 176, so the visible total fell from 2,666 to 1,708. The code block has 43 words and the collapsed text equivalents 706. Each comparison is now one table row (what is shared, what differs, and one symbol per engine to look up), followed by one or two sentences of consequence under its own heading. The traced example keeps the ordinary success and one difference case, the tunnel. Below 600 px the comparison table shows each row as a block of labelled lines instead of scrolling sideways.
Where earlier changes went. The Change and Now columns below describe the page after round 2. These left the page's visible text for the detail unless another place is named: the examples of Figure 1's legend (its text equivalent); the Options examples; decide's list of queries and the per-query budgets; the entry-path probe and last_route; the three-argument effect form (detail) and the two-argument cause of the open circular question (probe results, when evaluation can't finish); the tunnel's 50-segment row; explain on a rule head; which engine's snapshots cover what; the warnings the rewrite records on kb.diagnostics; that loading succeeds before a SemanticConflict; the legacy facade's immediate effects; what hash seeds change (method, added in round 3); and the concurrency note. Round 3 put some back on the page in short form: explain on a rule head, the per-query limits (in the table, with "a decide makes several"), the concurrency note, and the earlier engine's snapshot restore. The qualifications behind round 2's two major accuracy findings stay on the page: unknown predicates quietly fail in both engines, and the earlier engine's re-entry guard answers false without saying so.
Reviewed in round 3. One simulated reader and one accuracy reviewer read this cut; their findings and the corrections are below.
Round 3: the second cut
AI-simulated review, not testing with real users.
What was reviewed. The second cut above, before this round's corrections: 1,267 words of visible prose and 1,708 visible words in all. One AI-simulated reader got the rendered page with no further explanation and answered the five questions. A technical-accuracy reviewer compared the cut with the committed page and with the code at both pinned commits, rebuilt a copy of the site, checked links, anchors and the phone-width table, and ran new probes; both engine repositories stayed clean. There was no second or third reader, and no fairness or visual reviewer, in this round. The accuracy report first arrived cut off partway through its eleventh finding. The full report, recovered afterwards, has thirteen findings, two of them (6 and 13) older than the cut.
| Reader | What they read | Minutes |
|---|---|---|
| One reader; its choice is framed for a turn-based game | The page, relying on the text equivalents for both figures | 17 |
Answers. All five in substance as the page intends. Shared: in-process Python libraries for Prolog-style questions with Event Calculus story time, immutable terms and a binding trail, goal-by-goal search with automatic tabling, the same game hooks and JSON, fail-closed hooks, provenance from fact_id/2 and supports/2, and the same answers on the lab-door story under three hash seeds. Two differences: one path instead of many, with the human/1 and person/1 probe; and request-owned state, with its cost, a reanalysis and timeline rebuild after every change, even advancing the clock. Mutable state: each lifetime in both engines, from the rewrite's per-call QueryContext, revision-keyed DerivedState and clause index to the earlier engine's result cache (next add or 300 seconds), answer tables, derived-result and holds_at caches and process-wide routing. Can't finish: fixed limits that return a plain partial list, against budgets, cancellation and three statuses, with what ask, query, decide and spans_for each do; the tunnel's false refusal; SemanticConflict; the open circular question; and a complete "no" for an undefined predicate. Choice: the rewrite, for answers that don't depend on names or process-wide state, an honest "didn't finish", assert and retract, and isolation between instances; weighing indeterminate in game logic, the rebuild on every assert and clock tick (0.25 ms, about 3.4 ms with 10,000 more facts), no rule-level "why", slower repeats (0.07 against 0.011 ms), no authoring warnings and no bundled applications.
Where answers differed from the page's intent. The reader's list of the rewrite's process-wide state had no atom table, because the page left it out (accuracy finding 6). The reader also took SemanticConflict to break the knowledge base for good, because the cut had dropped "until the program changes" (finding 3).
| Confusion | Change |
|---|---|
| The earlier engine is add-only, yet the story sets a clock: how does an earlier game move it? | The page says the earlier engine can't retract a fact, only restore a snapshot (KBEngine.restore replaces the fact store and clears the caches). How its games keep a clock wasn't examined, and moving the clock by restoring a snapshot wasn't probed |
| Why "didn't finish" is "a separate choice" and not a third difference | Now "a third difference, one either design could have made": a result status belongs to the API and doesn't follow from Difference 1 or 2 |
"indeterminate (not enabled)": is the action refused, or is a feature off? |
Defined where decide is introduced: indeterminate "leaves the action disabled" |
SemanticConflict: unrelated queries too, and is there any way back? |
"Every later query, related or not, … until the program changes", with negation through recursion as the other trigger (finding 3) |
| The circular rule and the tunnel can't be pictured without their code | The self-referential rule is quoted on the page, and the tunnel's guard is named, reachable(corridor, lab) over passage/2 facts; the full inputs stay under probe inputs |
| The earlier games bypass the compared reasoner: how much does the comparison matter to them? | "Its games bypass the main reasoner compared here": the page compares KBEngine with KnowledgeBase, and the legacy facade only as scope |
Two things called "legacy", and a QueryContext class in each engine |
No change on the page, for space; the detail names both legacy things, and mutable state both classes |
| "Apart from variable names" | "Generated names of unbound variables aside" (finding 9) |
| Whether the earlier engine's 0.35 ms is for the small or the large program | "0.35 ms on the earlier engine at every size", in the same sentence as the rewrite's figures (finding 10) |
| Threads are never discussed, though the rewrite raises a process-wide recursion limit | "Neither is built for threads"; the table's state row gives the earlier engine's thread-local state and locks and the rewrite's lack of either (finding 5) |
| Which preparation counts against the budget, and why a stopped preparation is simply redone | Figure 2's text equivalent: the request's budget doesn't meter the analysis but does meter the timeline build; a stopped preparation may be incomplete, so it isn't kept |
| Exclusive fluents, the tie-break option and "main APIs" unexplained; tabling, SLD resolution and cut only as glossary links | The table glosses exclusive fluents and the tie-break; "both main APIs" is now "both KBEngine and KnowledgeBase". The glossary links stay, for space |
| The figures' legends rely on visual encodings, and Figure 2's text equivalent doesn't set out its six lifetimes | The text equivalent now lists each engine's lifetimes from shortest to longest |
| The intro says How it works and Migration predate seven changes: which page to trust? | "Where they differ, this page is current" |
| Project context is assumed; which game would adopt the rewrite? | No change: outside this page's scope (History) |
Technical accuracy. The reviewer confirmed that the rebuilt copy matched the working tree, that the link check found no problems over 22 pages, and that all 16 links into this record, its 10 links back and the new glossary anchors resolve. It also confirmed the rewrite's first commit date, that the earlier games, CLI and app.py import only CyclopsStormKB, the layering test and that the solver never imports story time, every symbol in the comparison table, the difference case at 51, 55 and 60 segments, the timing figures, this record's word counts for the cut, and the phone-width table at 360, 390 and 599 px. Its findings, as received:
| Finding | Resolution |
|---|---|
| 1. Explanations kept an unobserved risk for the earlier engine and dropped an observed defect of the rewrite | Both on the page: the earlier why "could disagree with the answer (none seen)"; on the rule head can_enter(lab) the rewrite's explain said "never initiated", complete, even when the rule succeeded |
2. The host row's "Frozen results with a status" also covered query and spans_for, which carry no status |
"ask returns frozen results with a status" |
3. "Every later query raises SemanticConflict" read as permanent and independent of defaults, and dropped negation |
"By default … or negation through recursion … every later query, related or not, … until the program changes" |
| 4. The earlier handler's reach was overstated: a top-level conjunction also failed | "Answered a single goal typed at the top level, not a conjunction or rule" |
| 5. Rewrite costs that round 2 had put on the page were gone: per-query budgets, cooperative cancellation, the concurrency note | The table's can't-finish row gives limits per query ("a decide makes several") and cancellation polled every 512 steps; the state row and section say neither engine is built for threads, with the earlier engine's thread-local state and locks. Difference 2 names the process-wide recursion limit |
| 6. The rewrite's process-wide state left out the interned-atom table (older than the cut) | Re-probed (round-3 probes); added to Difference 2, Figure 2 (drawing, caption and text equivalent) and the detail |
| 7. Observed and code-read facts sat under What it means, and the difference case had no marking | Each moved under Observed or In the code; the difference case is marked Observed. The undefined-predicate result is now carried by the findings and the table's shared column, with its probe in the detail |
| 8. "A re-entry guard silently answers false" lost the example that showed its effect | "Treats the inner question as false: a paradoxical variant got yes at every time" |
| 9. "Apart from variable names" changed the meaning | "Generated names of unbound variables aside" |
| 10. The change-cost comparison lost "same measure" and "at every size" | Both engines' after-change figures in one sentence, the earlier one "at every size" |
| 11. "Where earlier changes went" named content the detail doesn't have: what hash seeds change, and the two-argument cause | Method says what the seeds change; the list now says where each item lives and which ones round 3 put back on the page |
| 12. The record called the second cut a five-minute page and read, though its own figures give 5.5 to 6.5 minutes for the prose alone | This record only; the page makes no reading-time claim. The cut is now described as "aiming at a five-minute read", its headings as "toward a five-minute page" and "the second cut", and remaining uncertainty gives the reading rate five minutes would need |
| 13. "Caching made the earlier engine's repeats about 6 times faster" compared the two engines, not the earlier engine with and without its cache | This record only: the detail now says the earlier engine's cached repeats were about 6 times faster than the rewrite's (0.011 against 0.07 ms), and about 100 times faster in an earlier measurement. No measurement of the earlier engine without its cache exists |
| While 12 and 13 were missing | Older page claims were re-checked in the code: both engines' terms are immutable (the earlier Var and Term are frozen dataclasses, and unify_inplace binds variables in a substitution, never in a term); the rewrite's JSON answers carry the status (answers_to_json); holds() raises when it gives up with nothing found; the rewrite has no thread-local state or locks, and the earlier engine has both. Nothing further was found, which is not the same as a complete review |
What changed since round 2.
- What was read. Round 2 read the page at about 2,700 visible words; round 3 read the second cut at 1,708.
- What came back. One reader's answers and confusions in full; the accuracy review first arrived cut off after its tenth finding and part of its eleventh; findings 12 and 13 were recovered from the full report afterwards.
- What the reader got right. All five questions; the misreadings traced to two omissions of the cut (findings 3 and 6).
- What the accuracy review found. As in round 2, compression had dropped qualifications, mostly ones that favor the earlier engine or name a cost of the rewrite. They are back in short form, some moved into the code-read table, and the page stays within its limits: 1,294 words of visible prose (limit 1,300) and 1,771 visible words in all (limit 1,800), of which tables 298 and figure questions and captions 179; the collapsed text equivalents have 827 words and the code block 43. At 200 to 240 words a minute, the prose takes about 5.5 to 6.5 minutes and everything visible about 7.5 to 9; the round-3 reader took 17 minutes, including the figures and their text equivalents.
- New evidence. Two round-3 probes. Figure 2's drawing gains the atom table, so its three SVG sizes grew by 16 or 17 px in height.
Round 2: the shortened page
What was reviewed. The page as cut on September 29, 2026, about 2,270 visible words (prose, tables and figure captions) against about 3,500 in the round-1 draft, and its evidence record. Received in full: all three readers' answers and confusions, and a technical-accuracy review that compared the cut page with the committed one and ran new probes. There was no fairness or visual reviewer in this round; the rendering checks after the rebuild were the reviser's own.
| Reader | What they read | Minutes |
|---|---|---|
| N: technically curious non-specialist (Python and web; no Prolog or Event Calculus) | The whole page, both figures, text equivalents and tables; one link, to the glossary | 25 |
| G: game developer choosing an engine to embed (basic Prolog) | The page, both figures and text equivalents; no links | 18 |
| E: software engineer maintaining or extending it (Python and Prolog) | The page and both text equivalents; no links | 18 |
Answers. All three answered the five questions in substance as the page intends. Shared: in-process Python libraries with the same term model, goal-by-goal search with automatic tabling, Event Calculus story time with delayed effects, fail-closed game hooks, provenance from fact_id/2 and supports/2, and the same game hooks; the same answers on the lab-door story. Two differences: one path instead of many (with the human/1 and person/1 probe and the entry-path probe), and a request's state belonging to the request. Mutable state: each gave both engines' lifetimes accurately, from the rewrite's discarded QueryContext, revision-keyed DerivedState and clause index to the earlier engine's result cache (next add or 300 seconds), holds_at cache and process-wide routing. Can't finish: fixed limits and a silent partial list against per-request Limits, cancel and three statuses, with what ask, query, decide and spans_for each do, the tunnel's false refusal, SemanticConflict and the circular rule left open. Choice: all three leaned to the rewrite for honest statuses and answers that don't depend on names, weighing the rebuild after every change (including advancing now/1), slower repeats, no rule-level explanation, closed extension and handling indeterminate; the engineer would first measure the rebuild at their real program size and clock rate.
Where answers or readings differed from the page's intent. N read "so only the rewrite can say 'didn't finish'" as a consequence of Difference 2; it is a separate design choice. N and G first took Difference 2 to mean the rewrite has no process-wide state. N and G couldn't tell which engine snapshots. No reader said that a complete "no" can come from an undefined predicate, because the cut page no longer said so (accuracy finding 1 below).
| Confusion (readers) | Change |
|---|---|
9be7804 read as a commit of the earlier engine; whether the linked pages are stale (N, G, E) |
The intro calls it an earlier commit of the rewrite, from before seven behavior changes, and links the list |
| Repositories, packages and classes: which is which (N, G, E) | The intro says both are versions of Cyclops Storm in separate repositories and names each package and class. How to install either isn't covered: the code is private |
| "So only the rewrite can say…" seemed to follow from Difference 2 (N) | The finding now opens "A separate choice", without "So" |
| Difference 2 versus the rewrite's process-wide variable counter and recursion limit; whether raising the limit is safe with threads (N, G, E) | Difference 2 names both; the state section says the limit is process-wide and restores what each engine has for concurrency |
| The earlier engine also clears answer tables per query; the 300-second expiry is never discussed (N) | No change: Figure 2's text equivalent gives every lifetime, including the 300 seconds, which only limits how long an unchanged result is reused |
| Figure 2's empty results: which clock, and do they mean no? What does a yes look like? (N, G) | The caption says the trace ran under now(t2), so both answers are no; the text equivalent shows a yes in each engine |
| "All eight goals", five rows shown (N, G, E) | The page says the table shows three of the eight goals and links them |
The 1 in guard/4; "the last three facts" when now(t2) is last; decide's outcomes looked exhaustive (N, E) |
The 1 is the guard's order; the hooks are named; "The rewrite adds indeterminate" |
Why now(t2) is loaded separately; how an add-only engine changes its clock (N) |
"Each clock value went into a fresh engine, because the earlier engine can't retract facts" |
| Undefined terms: facade, revision, timeline, options, whole-program analysis (N, G, E) | Facade: the original API class, and the rewrite's KnowledgeBase; revision and timeline defined in Figure 2's text equivalent; Options with two examples (full list in the detail); analysis "picks what to table and checks cuts and negation", unmetered by the budget |
| Why "imports pointing downward" matters (N) | "Stops lower modules importing higher ones, so the solver doesn't depend on story time" |
| Event-first versus rule-first; rules that define "the order" of what (N, G, E) | Each glossed in the table; "rules that define before/2 or happens/2", with the earlier engine's side now probed |
| "Depending on how an effect is written" with no example or authoring advice (G, E) | The three-argument form and its result are on the page. General authoring advice isn't established by these probes |
| The story-index caveat: its cost, and whether the rewrite shares the variable-effect pitfall (N, G, E) | "Its cost is speed, not measured here"; the rule form works in the rewrite too (round-2 probes) |
The routing sentence took two reads; why holds_at failed inside a rule; where the route is reported (N, E, G) |
Rewritten one route at a time; a rule body "reaches a different holds_at that needs a bound time"; the route is recorded in last_route |
"Neither engine explained the failed rule" next to explain naming the lock; what "separate code" implies (N, E) |
Split into the rule and the fluent inside it; "so explanation and answer could disagree; the rewrite's can't" |
explain on a rule head: pitfall or intended? (N, G) |
Called misleading: explain treats any term as a fluent and says "never initiated" even when the rule succeeds |
| Which engine snapshots (N, G) | "Its snapshots cover the whole program (the earlier engine's, the fact store only)" |
| "Warns about authoring slips, which the rewrite defers" versus Figure 1's diagnostics (N, G) | The rewrite defers the analyzer and records only timeline and directive warnings on kb.diagnostics (accuracy finding 8) |
SemanticConflict: unrelated queries too? When is it detected? Why under "can't finish"? (N, G, E) |
"Every later query, related or not"; loading succeeds; introduced as the rewrite rejecting some programs; decide returns indeterminate. Whether diagnostics show it before a query wasn't probed |
spans_for without a status: bug or intent? (N, E) |
"So partial provenance looks complete". The rewrite's documentation doesn't mention it (documentation checks), so intent is unknown |
| The tunnel row merging 10 and 50 segments; the warning at 50 (N) | Split; the warning comes from a search past the lab |
| Whether the earlier engine's 0.35 ms is also measured after a change (N, G, E) | "Same measure" |
| Every change, including the clock, pays the rebuild; game-computed values must be asserted (G, E) | Joined: "each assert is a change that pays the rebuild above". A cheaper way to keep a clock isn't established |
| "Tabling … so recursion finishes" versus the depth cap; which limit ended the tunnel (G) | The gloss is narrowed (accuracy finding 6); the page names the Legacy evaluator's depth cap |
decide's worst-case budget; whether indeterminate blocks (G, E) |
"indeterminate, not enabled"; decide's queries are listed under Figure 2 and budgets apply to each |
ask versus query; the same name query behaves differently in each engine (G, E) |
The host row names ask's status; the can't-finish section says the earlier query returns the partial list |
| A small host budget and timeline preparation (E) | Figure 2's text equivalent: an interrupted preparation isn't kept, "so the next request prepares again" |
| The rewrite's breaking point in the tunnel; whether notes are structured (E) | Not on the page. At 60 segments it used 809 steps and depth 122 (when evaluation can't finish); longer tunnels weren't probed. Notes are text (Solutions.notes: tuple[str, ...], engine.py:145) |
| Only the earlier engine's test results are given (N) | Both suites' results |
| The code-size remark without sizes (N, G) | Sizes moved to "What this comparison can't tell you", with the caveat |
| Figure 1's legend words Combined and Elsewhere (G) | The caption gives an example of each; the drawn legend is unchanged |
| Glossary links land at the top of the glossary (N, E) | Every glossary entry has an anchor, and the page links to each term |
| Whose "my interpretation" (N) | "Interpretation" |
Technical accuracy. The reviewer compared the cut page, both figures' captions, alt text, text equivalents and SVG text with the code at both commits, the committed page and this record, and ran new probes on fresh exports; both engine repositories stayed clean. It confirmed the revisions and dates, the seven intervening commits, the Limits defaults and the 512-step cancel poll, the three statuses, query raising and decide returning indeterminate, spans_for as the only result API without a status, both SemanticConflict triggers, what uses the legacy facade, the earlier engine being add-only apart from snapshots, the fail-closed _holds, the depth cap printing through print(), the re-entry guard, the change-cost figures, all eight goals matching under three seeds and a clean link check. Its findings, each re-probed where it made a behavioral claim (round-2 probes):
| Finding | Resolution |
|---|---|
| Major: the cut removed "unknown predicates quietly fail", so a complete "no" from the rewrite read as trustworthy | Restored: the can't-finish row and section say an undefined or unsupported predicate fails in both engines and the rewrite marks the refusal complete unless unknown_fails=False; the findings say neither flags one by default |
| Major: the cut removed the earlier engine's silent "false" cycle guard, tilting "the rewrite's caution has a cost" | Restored in the story-time section, with the paradox probe: the earlier engine said yes at every time, silently; the rewrite reported exhausted |
| Minor: only the rewrite's half of name capture was left | Both halves on the page |
| Minor: inherited exclusivity and tie-break, and the earlier engine's concurrency work, were dropped | The story-time row lists exclusive fluents and a tie-break option as shared; the state section restores the partial start |
| Minor: "only the rewrite can say 'didn't finish'" broadened the claim | "Only the rewrite's results can say"; the finding notes the warning on standard output |
| Minor: "so recursion finishes" clashed with the page's own "finish" | "So recursion over a cycle doesn't loop"; both engines terminate on cyclic left and right recursion |
| Minor: "depending on how an effect is written" had no support on the page | The three-argument form and its result are given |
| Minor: "which the rewrite defers" over-generalized | The rewrite defers the analyzer and records timeline and directive warnings on kb.diagnostics |
Minor: the SemanticConflict sentence implied decide raises |
"decide returns indeterminate with the error in its notes" |
| Minor: round 1's resolution column described page content the cut had moved | A Now column in the round-1 tables below |
| Minor: the legacy facade "matches stored facts only"; "both engines use the delayed-effect reading" | The facade's own story-time check, with immediate effects, is named; "both engines' main APIs" |
| Minor: two page claims had evidence for one side only | Both sides probed and recorded: rules for before/2 and happens/2 in the earlier engine, and effects written as rules in the rewrite |
| Minor: hash seeds don't change dictionary order; one path is relative to the repository, not the package | "Which change set order"; the page marks the test's path as the repository's |
What changed since round 1.
- What was read. Round 1 read the first draft; round 2 read the page after the cut, which had moved many round-1 fixes into the detail.
- What came back. Round 1's reports arrived partly cut off (below); round 2's arrived whole, so this round records all three readers' confusions. Round 2 had no fairness or visual review.
- What the readers got right. Round-1 readers mostly asked for missing mechanisms and definitions. Round-2 readers answered all five questions as intended; their confusions came mostly from wording the cut had compressed.
- What the accuracy review found. The cut had dropped qualifications that round 1 had added. Round 2 put them back on the page in short form. The page grew from about 2,270 to about 2,700 visible words; the readers took 18 to 25 minutes.
- New evidence. Six round-2 probes. The glossary also gained an anchor per term.
Round 1: the first draft
What reached the revision. The reports came through an automated hand-off that cut part of them off. Received in full: the non-specialist reader, the technical review of the rewrite's claims, and the fairness review's summary and first four findings (the fifth in part). Received in part: the game developer's answers, without their list of confusions. Not received: the software-engineer reader and the visual review's written findings. The visual reviewer's scripts and screenshots were available, so its checks were re-run. Missing parts are recorded here as not received, not as clean.
Now says where each change stands after the cut and round 2: page; detail, only in this record; restored, dropped by the cut and back on the page in round 2; or revised, changed again in round 2.
Technically curious non-specialist (a Python programmer with no Prolog or Event Calculus; 18 minutes, including three linked pages). Answered all five questions correctly in substance: the shared jobs and execution model; both differences (one path, and engine-wide caches versus a discarded per-request object); where state lives in each engine; statuses, budgets, cancellation and the false refusal; and a reasoned choice, the rewrite if a false "no" is worse than an honest "don't know", budgeting for explanations and extension. Noted that choosing "the two most important" differences was their own judgment.
| Confusion | Change | Now |
|---|---|---|
| Figure 1's caption called the row order the rewrite's import layering, but the rows don't follow it | Caption says row order is for reading, not dependency; the tested layering and the runtime recursion are described under the figure | page |
What ask() returns when it runs out, versus query() |
Stated: ask returns the answers found so far with the status; query raises |
page |
| Status words didn't match the glossary, which named two and said "in this engine" | Glossary entry rewritten for the rewrite (complete, exhausted, cancelled; indeterminate for decisions); tables say "indeterminate (status exhausted)" |
page and glossary |
Why the rewrite's example used once(...) |
Figure 2 shows the traced ask("can_enter(lab)") |
page |
| "Delayed-effect reading" undefined | Defined under the story | page |
| Whether the earlier engine's correct circular answer was by design; what "answerable" meant | The one consistent answer is spelled out; the earlier engine never needed the rule because stored facts settle each question first, and its guard would have answered "false" | restored, with the paradox probe |
| Two different "legacy" things | Both named as the earlier engine's own words: its original rule evaluator and its original API | page |
| Which "options" answers depend on | Options listed |
revised: two examples on the page, the list in detail |
| How an add-only engine advances the clock | Each clock value went into a fresh engine, because the earlier engine can't retract | restored |
| No timing for the rebuild after every change | New change-cost measurement | page |
| What happens when preparation runs out of budget | Preparation is charged to the request; an interrupted one isn't kept and is redone | page (Figure 2's text equivalent) |
SemanticConflict missing from "can't finish"; what decide does with it |
Added, with decide's indeterminate result |
restored |
| "Looks like no" versus the warnings on standard output | "A warning may be printed to standard output, but nothing reaches the caller" | revised: the findings mention the warning |
| Eight goals probed, five shown | Points to the evidence record | restored: the page links the eight goals |
| Why hash seeds matter | They change set and dictionary order | revised: "set order"; dictionary order doesn't depend on the seed |
| The consequence of handing out the cache's own list | Stated (an appending caller changed the next answer) and labeled a missing copy | detail |
| "Its smaller size": whose? | "The rewrite's smaller code base" | revised: sizes under "What this comparison can't tell you" |
| Which revision the linked pages describe | The intro says they use 9be7804 and links the later commits |
revised: "an earlier commit of the rewrite" |
| Legend swatches gray while the chips are colored; "Elsewhere" versus "somewhere unexpected" | Legend redrawn in both engine colors, with the chips' own words | page (Figure 1) |
| "abstain" unexplained | Defined at first use | page |
Budgets per decide query |
Spelled out with the tunnel's four queries | detail; the page lists decide's queries |
Game developer embedding an engine (Python, basic Prolog; read the page, both figures and five linked pages). Answered correctly: shared ground, both differences, state lifetimes including the aliasing evidence, can't-finish behavior including the indeterminate message. Would pick the rewrite for predictability, weighing slower repeats, the per-change rebuild ("could be the dominant cost, but it isn't measured"), no rule-level explanation, closed extension, a global SemanticConflict, and handling indeterminate in the UI. Their list of confusions wasn't received. From the answers, two things changed: the findings now label Difference 1 and Difference 2, because the reader said several findings could pass for the second difference; and the change cost is now measured. Both are on the page.
Software engineer maintaining or extending it. Not received.
Technical accuracy of the rewrite's claims (checked against 5d598de with the reviewer's own probes; working tree clean). It confirmed the line counts, the six changed source files, the solver's dispatch order, _holds, the default limits and cancel interval, query raising, decide's indeterminate results, no surviving QueryContext, SemanticConflict from analysis, the circular-rule and variable-effect results, and tunnel lengths 57 to 59.
| Finding | Resolution | Now |
|---|---|---|
Major: "labels every result" is false for spans_for, which drops an incomplete status |
spans_for named as the exception in "can't finish", host integration and Figure 1 |
page |
| Major: Figure 1's caption claimed the row order is the tested import layering; it isn't | Claim removed; the real layering and the runtime recursion described under the figure | page |
| Major: Figure 2 and the state section omitted rewrite state that outlives or escapes the request: the lazily built clause index, the recursion limit and the variable counter | Added to Figure 2, its text equivalent and the state section; takeaway now "no answers or evaluation state outlive the request; only structures tied to the current revision do" | restored: Difference 2 and the state section; Figure 2 kept it throughout |
| Minor: "caches across queries" contradicted the reused analysis and timeline | "caches answers across queries" | page |
| Minor: unbound-variable names differ between identical requests | "apart from the generated names of unbound variables"; probe | page |
| Minor: this record paired the cold trace with a warm step count and called analysis budgeted | Corrected (103 cold, 22 warm); the page says analysis runs outside the budget | this record; page |
Minor: Figure 2 said once(can_enter(lab)), but the plain goal was traced |
Label is ask("can_enter(lab)"); the once form's count is recorded |
page |
Minor: not all of api/ loads on demand |
Value conversion is imported by the facade; only JSON and game hooks load on first use | page (Figure 1's text equivalent) |
| Minor: diagnostics aren't notes on each result | "diagnostics on the knowledge base; per-query reasons as notes" | page (Figure 1) |
| Minor: the timeline can be enlarged per request, and a partial read is kept | Both stated | detail |
| Minor: "the timeline can't disagree with its solver" overstated | "read from the solver's before/2 answers", closed transitively |
page ("read through the solver"); detail |
Minor: advice to pass the decision's condition to explain gives a confidently wrong answer |
The page says to explain the fluents inside the condition and warns what a rule head returns | revised: called misleading |
| Minor: the circular cost depends on the two-argument spelling | Cause restated; the three-argument result added | restored |
Minor: "decide sends three queries" holds for one guard only |
"each guard's condition in order until one fails"; four for the tunnel | page |
| Minor: lengths 57 to 59 unrecorded; at 50 the earlier engine printed one line | Sweep of 50 to 60 re-run in both engines; 50-segment row added | page |
| Minor: the rewrite's side of name capture missing | "the rewrite refuses that clause at load" | revised: both sides |
Fairness to the earlier engine (checked against e62758c with the reviewer's own probes and a test run). The reviewer judged the page fair overall: real strengths named, size disclaimed, the tunnel input flagged as atypical.
| Finding | Resolution | Now |
|---|---|---|
| Major: "'Why can't I enter?' gets a richer answer from the earlier engine" contradicted the page's own probes | Rewritten: richer for "why can I?"; neither engine explains a failed rule; only the rewrite names the event that ended the fluent | page |
| Major: extension overstated: a registered handler reaches single top-level goals only | Qualified on the page; probe added | page |
| Major: the cache handing out its own list, a fixable missing copy, was presented as a principal architectural trait | The findings state the architectural contrast (engine-wide caches versus request-owned state); the aliasing moved to the state section as a missing copy and was removed from Figure 2 | detail |
| Major: Figure 1's takeaway ("same jobs", "a single place", "more chips mean … not more capability") framed fewer places as the finding and misdescribed the figure; "13 files" sized one side only | Takeaway rewritten around "one mechanism of each kind", naming the jobs the rewrite lacks; file count and "one component per job" removed | page |
| Minor: the earlier suite was called impossible to rerun | Rerun on an isolated export: 6,647 passed; all 20 failures and errors need hypothesis |
page |
| Minor (summary only): default-on authoring checks omitted | Added to host integration, with a probe | revised: what the rewrite records is stated |
| Minor (summary only): the variable-effect example hid that the rule form works in the earlier engine | Page and evidence show the rule form working in both | page |
| Minor (summary only): inherited exclusivity and tie-break not credited | Added to story time's shared list; the rewrite makes the seven built-in names opt-in | restored in the table; opt-in names in detail |
Minor (summary only): the explain_holds_at error string is bug catalogue |
Removed from the page; kept under documentation checks | this record |
| Minor (summary only): Figure 2's alt text said five lifetimes where six are shown | Alt text says six | page |
Visual presentation. Written findings not received. The reviewer's checks were re-run in Edge at 360, 390, 899, 900, 960 and 1000 px:
| Problem the re-run showed | Resolution | Now |
|---|---|---|
| The Prolog code blocks scroll sideways at 360 and 390 px and weren't marked focusable. Edge 153 focuses a scrolling block by itself; Safari doesn't | The site builder makes code blocks focusable, with the same focus outline as tables, in a separate commit because it changes every page | builder |
| From 600 to 899 px, the 360 px phone figure sat at natural size in a column up to 860 px wide; at 900 px the wide figure scaled to 0.89 | A 560 px medium layout for 600 to 939 px; the wide layout starts at 940 px, where it scales no lower than 0.94 | page |
| Figure 1's legend didn't match its chips (also reader confusion 19) | Redrawn | page |
Remaining uncertainty
Round 3's corrections have not been re-read by a fresh simulated reader or re-reviewed. Round 3 had one reader, not three, and its accuracy report arrived in two parts (13 findings). Neither round 2 nor round 3 had a fairness or visual reviewer. Round 1's maintainer reading and the visual reviewer's written findings were never received. The change-cost timing uses facts only, on one machine; rules and events would add to the rewrite's analysis and timeline work. Figure text widths were calibrated in Edge on Windows; other platforms' fonts were not checked. The second cut, as corrected in round 3, has 1,294 words of visible prose and 1,771 visible words in all: about 5.5 to 6.5 minutes for the prose alone at 200 to 240 words a minute. The one round-3 reader took 17 minutes over the uncorrected cut, including the figures and their text equivalents; five minutes would need about 260 words a minute over the prose alone.