A Prolog interpreter whose running state is data, so it can be suspended into a database and finished by a different process.
cocolog is written in Cicili and keeps its knowledge base in ZiguratIP. It uses both and modifies neither.
$ cocolog --kb demo forget # consult ADDS; it does not replace
forgot 0 clause(s) in 'demo'
$ cocolog --kb demo consult demo/family.pl
consulted 12 clause(s) into 'demo'
$ cocolog --kb demo start job "ancestor(tom,X)"
started 'job'
$ cocolog --kb demo --steps 12 step job # one process
1. ancestor(tom,bob)
2. ancestor(tom,liz)
job: suspended at 13 inference(s), 2 answer(s) this turn
$ cocolog --kb demo --steps 12 step job # a different process
1. ancestor(tom,ann)
2. ancestor(tom,pat)
job: suspended at 26 inference(s), 2 answer(s) this turnBetween those two commands the machine was not in memory anywhere. It was three hundred bytes of ASCII in a table.
A term is an index into a flat array of 64-bit cells, not a pointer into a graph, and the resolution engine never recurses in C: the continuation is a term on the heap and the choice points are an array of integers.
Written the natural way — malloc'd term nodes, a recursive solve() that uses
the C stack as its continuation — an interpreter is half the length. But its
state is then pointers and stack frames, and neither is data. cocolog exists to
stop mid-proof and go into a database, so:
| terms are indices | an array of indices is position independent: write it out, read it back at a different address, and every reference still means what it meant |
| the continuation is a term | '$k'(Goal, Barrier, Rest) on the heap, so a pending goal is a cell like any other |
| a cut barrier travels per goal | a suspended machine has to remember the barrier of every pending goal, not just the current one |
| choice points are integer frames | {kind, goals, trail_mark, heap_mark, call, pred, clause_ix, barrier} and nothing else |
lib/state.cicili is what that buys: freezing a machine is printing six arrays
and thawing one is reading them back. There is no graph walker, no pointer
fixup and no stack to reconstruct — and there could not be one, because a C
stack is not something you can write to a table.
lib/term.cicili cells, the machine, interning, unification with a
trail, copying, and a compile-time term DSL
lib/syntax.cicili the operator table, the reader and the writer
lib/kb.cicili the clause store, and the hook a backend fills in
lib/solve.cicili the engine: continuation, choice stack, cut, negation,
if-then-else, and the builtins
lib/module.cicili the module seam: a bridge between C and Coco
lib/files.cicili SWI's Files library, as a module
lib/lists.cicili SWI's Lists library, as a module
lib/apply.cicili SWI's Apply library -- clauses only, no C at all
lib/builtins.cicili the ISO core builtins cocolog was missing, plus
format/1,2,3, code_type/2 and must_be/2
lib/dcg.cicili definite clause grammars: the --> translation, and
phrase/2,3
lib/swipl/ EIGHT of SWI's own libraries -- assoc, pairs,
ordsets, yall, aggregate, ugraphs, dcg/basics and
dcg/high_order -- copied unmodified under their own
BSD-2 headers and read at start-up, so they are
there with no import
lib/library.cicili use_module, like SWI's: libraries load at run time --
registered modules, Name.so (dlopen'd Cicili modules)
and Name.pl on $COCOLOG_LIBRARY
lib/sdk.cicili what a LOADABLE module is written against: the module
API over opaque engine types, for Cicili modules
compiled outside this build
lib/state.cicili freeze and thaw
lib/zigurat-kb.cicili the knowledge base over Zigurat's binary protocol
lib/zeytun-kb.cicili the same, over Zeytun's HTTP pages (read only)
lib/zigurat.cicili Cicili declarations for the C client, and a front end
lib/zeytun.cicili the same for the page client
library/ THE LIBRARY PATH, and what ships on it: http.pl
(HTTP/1.1 as a grammar), httpd.pl (a server whose
pages are clauses), json.pl, xml.pl and html.pl (a
term as a document, and back), ca.pl (a certificate
authority as rules), and the .so's that
`make modules' builds
modules/ the LOADABLE modules, one directory each -- tcp,
thread, curl, bigint, torch, and ZiguratIP's
cryptography: sha, aes, der, x509, tls. None is part of
`make': a cocolog with no libtorch, no ZiguratIP
headers and no libcurl still builds and still runs
tools/cc/ the toolchain, in four small files: clang, plus the
one flag Ubuntu makes necessary and two shims for
the one build step that names gcc outright
client/ the two protocols, in C. No C++, no ZiguratIP headers:
libc and the sockets API and nothing else -- except
tls.c, the ONE unit that knows OpenSSL exists, which
both reach through six functions behind an opaque
pointer, weakly, so a build without OpenSSL still
links and says so by name
parsi/ the schema and the pages, compiled into a ZiguratIP
home by ZiguratIP's own parsi compiler
cocolog.cicili the program
test/ the suite; groups.sh and ruler.sh are the concurrent
ones, and are crowds of processes rather than .cicili;
tls.sh, httpd-tls.sh and zigurat-tls.sh are the three
that raise real handshakes between real processes
test/files/ Prolog programs run by BOTH swipl and cocolog, with
their output compared line for line
tutorials/ DOCUMENTATION THAT RUNS -- three categories, and
every claim in the first two is a `must/3' that
fails the file when it stops being true:
basics/ (eleven lessons, the language itself),
library/ (thirty-two, ONE PER LIBRARY that
ships), torch/ (twenty-four networks, three
processes each). `sh test/tutorials.sh'
demo/family.pl something to run it on
emacs/ cocolog-mode: a Prolog major mode with colours for
variables and execution graphs drawn under the rules,
its engine held to this interpreter
colab/ train on a Colab GPU, query from anywhere: the
notebook, and COLAB.md for the arrangement it runs
coworker/ coworking: cocolog instances working one problem
together through knowledge bases -- the accumulator
(fan-in) and the balancer (all-gather)
art/ the banners, hand-drawn SVG with PNG renders: the
README strip and the 1280x640 social preview
What the build needs on the machine:
- SBCL — Cicili is Lisp that emits C, and
sbclruns it. Needed only to build. - A C and a C++ compiler and GNU make —
gcc/g++or equivalents. The interpreter is C; the embedded engine and the torch module are C++. - libtorch — either
$LIBTORCHpointing at the directory that HOLDSinclude/andlib/(for headers under/usr/local/include/torch/...and dylibs under/usr/local/lib, that isLIBTORCH=/usr/local— not/usr/local/lib), or the piptorchpackage (pip install torch), which is where everything looks by default. A standalone or installed libtorch also wantsTORCH_LIB=$LIBTORCH/libexported for the Makefile's link line. - SWI-Prolog — optional; only the
filestest case compares against it, and it SKIPs whenswiplis absent.
Three checkouts, side by side:
git clone https://github.com/saman-pasha/cicili
git clone https://github.com/saman-pasha/ziguratip ZiguratIP
git clone https://github.com/saman-pasha/cocologSet these three, and put them in your shell profile (~/.zshrc or
~/.bashrc) — every build and every test shell needs all of them, and a
shell without them fails with set CICILI to a Cicili checkout or, worse,
builds against the wrong tree:
# Coco requisites
export CICILI="$HOME/Projects/GitHub/cicili" # the Cicili checkout, for sbcl
export ZIGURATIP="$HOME/Projects/GitHub/ZiguratIP" # the BUILT ZiguratIP checkout
export ZIGURATIP_HOME="$ZIGURATIP/home" # and its home
# macOS, libtorch via Homebrew: headers land in /usr/local/include and
# dylibs in /usr/local/lib, so the root that holds both is /usr/local
export LIBTORCH="/usr/local"
export TORCH_LIB="/usr/local/lib" # the Makefile's link line(On Linux with the pip torch package, the last two are not needed —
everything asks Python where the package lives.)
Build ZiguratIP first — plain make in its checkout; a C++11 compiler is all
it asks — which fills ZiguratIP/home with its libraries, its parsi
compiler and the server binary. Then:
cd cocolog
make # the C client and the ONE cocolog binary
make schema # compile the Parsi objects into $ZIGURATIP_HOME
make test # the suite; the database tests skip without a serverAnd it runs — the first three need nothing else on the machine at all:
./cocolog # the toplevel: ?- awaits
./cocolog query "X is 2 + 2" # local: memory, the default
./cocolog -s myscript.pl # load it, prove main; the
# EXIT CODE says whether it
# proved -- a script's verdict
./cocolog --embed run tutorials/torch/07-xor.pl train # the store at ./KB
cd $ZIGURATIP && ZIGURATIP_HOME=$PWD/home \
LD_LIBRARY_PATH=$PWD/home/lib ./home/bin/ziguratip & # the server
./cocolog --kb demo consult demo/family.pl # naming a kb chooses itBare cocolog is what bare swipl is — a toplevel. Line editing is built
in — the emacs keys, the arrows, and a history walked with C-p that
survives in ~/.cocolog_history — written into the binary rather than
linked, GNU readline's license not being this project's. ?- reads a goal to
its full stop, over as many lines as it takes; answers come back under the
query's own variable names, in SWI's shapes down to the aliases (X = f(Z), Y = Z. answers X = f(Y), Z = Y., held to a live SWI); ; asks for
another solution, and the punctuation is honest — an answer that left no
choice point ends . with nobody asked. [family]. consults family.pl,
what one goal asserts the next goal sees, halt. leaves. It runs in any of
the four arrangements: against a store or the server, every finished goal is
one committed transaction, so a toplevel session is also the quickest way to
poke at a knowledge base other processes are working.
There is one cocolog binary and it is the full one: the interpreter, the
embedded MVCCS engine and the torch module, all in it. Which knowledge base a
run uses is a runtime choice among four arrangements — --local (memory,
the default when no other arrangement is named),
the server (--tcp/--tls, or --kb/--host),
--http/--https (Zeytun, read only), and
--embed [DIR] (the store inside the process; a bare
--embed opens ./KB) — never a build. Cicili is needed only to build:
sbcl runs cicili.lisp over the .cicili files and out comes C. The
embedded engine links against the built ZiguratIP's Core and StreamIO,
the torch module against libtorch, and a server is needed only when a run
chooses the server arrangement.
Documentation that RUNS. Fifty-nine files, and sh test/tutorials.sh
runs every one of them as a case of the suite.
./cocolog run tutorials/basics/01-facts-and-rules.pl main
COCOLOG_LIBRARY=$PWD/library ./cocolog run tutorials/library/12-json.pl main
./cocolog --embed /tmp/t run tutorials/torch/07-xor.pl trainNothing but the binary: no library path, no database, no build flag. Read them in order — each leans on the one before.
| teaches | |
|---|---|
| 01-facts-and-rules | a fact, a rule, a query, and what a variable is |
| 02-unification | the one operation underneath everything; =, \=, ==, the occurs check |
| 03-lists | [H|T], and why append/3 runs backwards |
| 04-arithmetic | is vs =, and the evaluable functors |
| 05-backtracking-and-cut | choice points, !, and the four shapes it appears in |
| 06-findall-and-friends | findall, bagof, setof, aggregate_all, and the free-variable rule |
| 07-assert-and-retract | a program that edits itself, and retract/1's determinism |
| 08-atoms-text-and-codes | atoms, codes, and the string type this Prolog does not have |
| 09-exceptions | failure is not an error; catch/3, throw/1, ISO error terms |
| 10-grammars | -->, phrase/2,3, and a parser that also generates |
| 11-the-knowledge-base | the one that is not in any other Prolog book: the store outlives the process |
Four of them exist because cocolog differs, and each difference is
checked by a must/3 in the file that teaches it: double_quotes is
codes, so "hi" IS [104,105] (08); every builtin is
deterministic, which retires the retract(X), fail loop (07) and makes
atom_concat(A, B, abc) with both unbound an instantiation_error
rather than three solutions (08); 2 ** 10 is 1024, an integer (04);
and 11 is the claim the whole project exists to make, in four lines of
Prolog.
Tier 1 first — the twelve that answer with no import at all — then the eleven on the library path.
| 00-the-library-path | — | the two tiers, the four search directories, and how to check which tier something is in |
| 01-lists … 11-ugraphs | tier 1 | lists, apply, files, builtins, dcg, assoc, pairs, ordsets, yall, aggregate, ugraphs |
| 12-json 13-xml 14-html | tier 2 | a term as a document, and back |
| 15-http 16-httpd 17-tcp 18-thread | tier 2 | the grammar, the server, the socket seam, the threads |
| 19-zigurat 20-curl 21-bigint 22-torch | tier 2 | the connection, an HTTP client, integers that do not wrap, and Prolog that trains |
The numbering is one per library, so a gap is visible — a library
with no NN-name.pl beside it is one nobody has demonstrated end to
end. A new library therefore gets a tutorial in the same commit.
The deep end, and its own README — described under Prolog that trains below.
A lesson does not print what it computed. It asserts what the answer must be, through one helper repeated at the bottom of all thirty-four files:
must(Label, Got, Want) :-
( Got == Want
-> format(" ~w = ~q~n", [Label, Got])
; format(" ~w = ~q BUT THIS LESSON SAYS ~q~n", [Label, Got, Want]),
fail
).So a run reads as a transcript and fails as a test:
$ ./cocolog run tutorials/basics/05-backtracking-and-cut.pl main
-- backtracking makes combinations out of nothing
every colour with every size = [red-small,red-large,green-small,green-large,blue-small,blue-large]
-- a cut keeps only the first answer
without a cut = [red,green,blue]
with one = [red]
-- once/1 is a cut with a name, and reads better
once(colour(C)) = [red]
That last line did not work when it was written. once/1 and
ignore/1 did not exist, and retractall/1 was written as a
failure-driven loop — retract(H), fail — which retracts exactly ONE
clause in an interpreter where every builtin is deterministic, and then
reports success. Three bugs, found by documentation that runs, in a
language whose own suite had never needed those predicates.
The tutorials are tests because a lesson that prints its answer proves nothing. The same idea, one size up: a whole suite is cocolog too, and both repositories downstream of this one now write theirs that way -- CivV's 29 cases and The Coco's 19, the latter 551 checks in all. Both were shell suites first, and both were converted case by case.
A case is main/0 and the exit code is the verdict. iso/2,
want/2 and refuses/1 below are the suite's own prelude, three
clauses over what this repository ships:
:- use_module('test/prelude.pl').
main :-
iso('a transfer moves exactly what it says, both sides',
( world(A, B), transfer(A, B, 500),
balance(A, X), balance(B, Y), want(X-Y, 500-500) )),
iso('more than you have is refused',
( world(A, B), refuses(transfer(A, B, 2000)) )),
checks_done../cocolog -s test/money.pl # 0 exactly when main provedchecks_done is the reason that exit code means something: it prints
GREEN or RED and fails on red, so the verdict reaches whatever ran
the script without anybody parsing the output. And the check itself does
not stop the run -- a suite that halts at its first failure hides the
second.
What the suite libraries are for. None of them was written for this; each was written because a suite needed it and a shell was doing it worse.
library(process) |
check/3 and checks_done -- the harness every shell suite re-implements, once. sh/1,2, sh_exit/2, sh_atom/2, and shl/1,2 for a command built out of values, because cocolog has no string interpolation and the LIST is the interpolation. proc_spawn/2, proc_wait/3, proc_stop/1 (15, a moment, then 9, and always the wait, so a case leaves no zombie for the next one), proc_until/3 for the wait-for-the-server loop. |
library(thread) |
run_isolated/2: a proof on a fresh machine with a fresh store, in this process. It is what most spawning in a shell suite was actually buying -- isolation -- at none of the price. |
library(text) |
re_match/2, re_first/3, re_replace/4, re_lines/3 -- grep and sed as clauses, over libc's own regex. |
library(os) |
os_is/1, os_has/1, os_which/2, os_lib_path_var/1. One answer on Linux and macOS, where uname -s and command -v gave two. |
library(kbs) |
many knowledge bases from one SCRIPT -- seed one, grind another, read both back. Every kb_* goal is one process-proof over the wire, deliberately: a store half exists to show a SECOND process sees the rows, and a library that proved it in-process would quietly stop making that claim. |
What that buys, concretely. A comparison is Got == Want on TERMS
rather than a string match on a process's output, and the value is the
goal's own binding, so nothing is grepped and nothing is parsed. The
label is a label rather than a printf format. And a case that wants
a clean slate asks for one: run_isolated/2 per check, instead of a
whole interpreter started per check to get a store nobody had written
to yet.
And what it does not replace. A claim about two PROCESSES sharing a
knowledge base -- one writes, a second that consulted nothing reads it
back -- is exactly what run_isolated/2 cannot make, because a fresh
machine in this process is still this process and this process's
connection. Those checks start a real cocolog and always should;
converting them would keep a suite green and delete its proof.
cocolog's own 39 cases are still shell, and that is not an oversight. They test this binary from the outside -- including the arrangements where the point is that the binary is wrong -- and a case written in the language under test cannot report on an interpreter too broken to read it. A suite for something ELSE, written in cocolog, is a different proposition entirely: there the interpreter is the tool and not the subject.
lib/kb.cicili gives the clause store five function pointers. Everything above
them is written against the store and knows nothing about where clauses come
from.
| hook | when | what it is for |
|---|---|---|
fetch |
at most once per predicate, when something calls it | clauses arrive as the proof reaches them |
on_assert |
a clause was added | write it through |
on_retract |
a clause was removed | write that through too |
on_dynamic |
a predicate was declared dynamic |
a declaration is about the knowledge base, so it has to outlive the process |
warm |
listing |
name every predicate the knowledge base holds, without fetching any clauses |
on_assert and on_retract both rewrite the WHOLE predicate. Clauses are
stored by ordinal, and asserta puts one at the front — which changes the
ordinal of every clause after it — so writing back only what changed would
leave two clauses both claiming to be first. O(n) per assert, and always right.
warm exists because the laziness that makes a shared knowledge base
affordable also means a fresh interpreter knows of no predicate it has not
already reached: listing asking what there is would answer "nothing", which
is true of the store and false of the knowledge base. It interns names and
declarations only — what the clauses ARE is still nobody's business until
somebody calls one.
That is four arrangements from one interpreter:
- local — no hooks. Everything in memory. The default: naming
--kb,--hostor--tcpchooses the server instead. - Zigurat —
lib/zigurat-kb.cicili. All five, over the wire. Machines suspend and resume here. - embedded —
--embed [DIR](a bare--embedopens./KB). The same five hooks —lib/zigurat-kb.ciciliagain, its wire swapped forembed/embed.cicili: the same eighteen procedures the server offers, in-process over the Cicili MVCCS engine. No server, no socket, and machines suspend and resume here exactly as they do over the wire. - Zeytun —
lib/zeytun-kb.cicili.fetchandwarm, over HTTP, for an interpreter that cannot open a socket to the binary port.
The HTTP backend deliberately does not write. One HTTP request is one
transaction; a machine is a header row plus a row per chunk, and over HTTP
those would be separate transactions with no way to roll the first back when
the third failed. Reading is another matter, so listing over HTTP shows
exactly what listing over the binary protocol shows.
A module carries predicates written in Cicili and clauses written in Prolog, and
a program cannot tell which half a predicate came from. lib/solve.cicili holds
two null function pointers and consults them; lib/module.cicili fills them in.
A cocolog built without a single module is the cocolog that existed before
modules, because the hooks stay null.
A goal is tried as a control construct, then a core builtin, then a module
predicate, then the knowledge base — so a module can add to the language but
cannot redefine is underneath a program, and is not shadowed by a clause
somebody asserted.
Five ship compiled in, and they are deliberately spread across the range.
Files is seventeen predicates in C and five in Prolog, because a file
system is a syscall away. Lists is thirty-odd in Prolog and seven in C,
because member/2 and permutation/2 must answer many times and a module's
C half cannot — it has no access to the choice stack, so a nondeterministic
predicate belongs in the Coco half where the engine provides the choice points
and a frozen machine can be thawed elsewhere and go on backtracking through it.
Apply has no C half at all. Builtins is the ISO core cocolog was
missing — findall/3 and its family, between/3, the atom and term
predicates, clause/2, current_predicate/1, format/1,2,3 and
code_type/2. DCG is one C predicate and three clauses.
Every one of them is checked by running the same Prolog program under swipl
and under cocolog and comparing the output byte for byte — the only kind of
compatibility claim that cannot be fooled by its author. It has caught five
things that would otherwise have shipped looking right.
MODULES.md is how to write one.
TIER 1 — always present, and no use_module needed. Registered before
the first goal runs: the five above plus library and zigurat, and the
eight of SWI's own that are vendored in lib/swipl and read from disk at
start-up. They are part of Prolog here, not an optional extra — a program
that must say use_module(library(assoc)) before it can use an association
list is doing the interpreter's bookkeeping. So an import for any of the
sixteen is a line that does nothing, and none is written anywhere in this
repository.
TIER 2 — on the library path, loaded when asked. $COCOLOG_LIBRARY
first, then ./library, then library/ and lib/swipl/ beside the
BINARY — found through /proc/self/exe, so an installed cocolog finds its
own libraries and a cocolog run inside somebody else's tree does not prefer
theirs.
$COCOLOG_LIBRARY IS A LIST, colon-separated like PATH, so your own
modules go beside the shipped ones rather than instead of them:
export COCOLOG_LIBRARY=/opt/my/modules:/opt/vendor/prologThe suite appends to it rather than replacing it, and The Coco's
test/config.sh does the same, so COCOLOG_LIBRARY=/opt/my/modules sh test/run.sh works in both. What ships always comes first: a suite that let
somebody else's library(httpd) win would be green about somebody else's
code.
library(tcp) |
.so |
sockets: listen, connect, accept, read, write |
library(thread) |
.so |
threads that share nothing, channels that copy |
library(curl) |
.so |
an HTTP client, over libcurl |
library(bigint) |
.so |
arbitrary-precision integers |
library(torch) |
.so |
libtorch: tensors, nets, training, GPU |
library(http) |
.pl |
HTTP/1.1 as a DCG over the bytes tcp gives back |
library(httpd) |
.pl |
a server whose pages are clauses, with a worker pool |
library(json) |
.pl |
a term as JSON, and JSON as a term |
library(xml) |
.pl |
the same for XML |
library(html) |
.pl |
the same for HTML |
A thing belongs in tier 2 when its dependency should not be everybody's,
and that argument moved three modules out of the binary. tcp was swept
into cocolog.c by the Makefile's wildcard; torch and bigint were
objects in the link reached through weak symbols, so every link needed
libtorch and libCore for two modules most programs never call. The binary
went from 936 KB to 585 KB and ldd shows no torch at all.
--> works, and so does everything built on it. The translation lives in
lib/dcg.cicili and runs inside coco_assert — the one function every clause
passes through — so a grammar rule means the same thing consulted from a file,
asserted by a running program, or arriving from the database.
It is written, not copied. SWI's own boot/dcg.pl is half source-position
terms and module qualification, machinery for a module system cocolog does not
have; what is left once both are removed is short enough to write, and writing
it keeps third-party code out of the core.
Eight of SWI's libraries are copied, byte for byte, under their own
BSD-2 headers, in lib/swipl/: assoc, pairs, ordsets, yall,
aggregate, ugraphs, dcg/basics and dcg/high_order. Nothing in them
is edited, and all eight are read at start-up rather than on demand —
measured free, 469ms bare against 459ms with all of them, inside the noise
of a start-up dominated by the embedded store. Instead the things they needed
were built here — the soft cut *->, code_type/2, must_be/2,
format/1,2,3 with its codes(H,T) sink, with_output_to/2,
ord_intersection/3 and ord_subtract/3, and acceptance of the :- module
and :- use_module lines a library file starts with. test/files/run.sh
consults those very bytes into cocolog and runs the same test file under SWI,
and the two agree exactly.
cocolog --local run lib/swipl/dcg_basics.pl my_grammar.pl maincocolog has one namespace, so :- module/2's export list is ignored and a
vendored file's private predicates are callable. That is a real difference, not
a shim; lib/swipl/README.md records it along with the provenance and
checksums of both copies.
Zeytun serves a knowledge base over HTTP and is C++; it answers with rows.
library(httpd) answers with whatever a clause can compute, in the same
process that holds the store — so a page sees the knowledge base directly,
with no protocol in between, and two cocolog instances can talk to each other
in Prolog rather than in JSON about Prolog.
%% pages.pl -- the pages, as clauses
httpd_page('/hello', _, reply(200, [], 'hello, world')).
httpd_page('/stock', _, reply(200, [], Body)) :-
stock(widget, N), % a clause ANOTHER PROCESS wrote
atomic_list_concat(['widget ', N], Body).%% server.pl
:- use_module(library(httpd)).
:- use_module('pages.pl'). % as a MODULE, not a consult
main :- httpd_serve(8080, [root('./public'), workers(4)]).The pages are a MODULE and that is not style, it is the one thing a
worker pool asks of the program above it. A worker answers each request as
an isolated proof with a fresh store, and a fresh store is filled from the
process-wide module registry that use_module writes — so a page consulted,
asserted, or written straight into the file you hand to cocolog run lives
in the parent's store and no worker ever finds it. The failure is a plain
404. workers(0), the default, serves such a page perfectly well, which is
exactly how this is easy to meet in a demo and lose the moment a pool is
added.
The sockets are library(tcp)'s C, the parse is library(http)'s grammar,
and everything between them — routing, path safety, content types, keep-alive,
the loop — is Prolog, where it can be read and tested a predicate at a time.
workers(N) is a pool over library(thread). One thread accepts and
posts connections down a channel; N workers each take one and hold it for the
whole conversation. That split is the design: accepting is the one thing that
must be serialised, and it is also the one thing that costs nothing.
Measured — one slow page 372 ms; four at once, one connection at a time,
1 365 ms; the same four through four workers, 419 ms. The pool is not
faster at one request; it is what stops one slow request holding every other
client.
Each request is its own turn, which is what makes a pooled server correct rather than merely parallel. A worker's goal runs for the life of the server and a store caches, so two workers answering writes would hold two divergent pictures of the same predicate and the second commit would overwrite the first. Measured, before the fix: three sequential POSTs through a pool of three left two facts in the database. So a request runs as an isolated proof — fresh machine, fresh store, fresh database connection, one commit at the end, a rollback if it broke.
ZiguratIP already carries a hand-written RSA, AES, the SHA family, a DER
encoder and an X.509 implementation with a ca tool over it. cocolog
now speaks all of it, as five tier-2 libraries with prefixes of their
own:
?- use_module(library(sha)),
sha_hash(sha256, abc, H).
H = ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.
?- use_module(library(x509)), use_module(library(der)),
x509_public_key('ca.crt', K),
der_wrap(48, K, Spki),
der_decode(Spki, sequence([Alg, bit_string(Bits)])),
der_decode(Bits, sequence([integer(Modulus), integer(Exponent)])).
Alg = sequence([oid('1.2.840.113549.1.1.1'), null]),
Modulus = '13455941168279...' % 617 decimal digits
Exponent = '65537'.The certificate came out of C++ and the modulus was read in Prolog.
| is | needs | |
|---|---|---|
library(sha) |
SHA-1/224/256/384/512 and HMAC | a built ZiguratIP |
library(aes) |
AES-128/192/256, CBC and ECB, PKCS #7 | a built ZiguratIP |
library(der) |
DER as terms, both directions | no cipher at all |
library(x509) |
the whole ca tool, plus sign/verify/encrypt/decrypt |
a built ZiguratIP |
library(ca) |
clauses only: roots, enrolment, and authorisation | — |
library(tls) |
a secure connection, mutually authenticated | a built ZiguratIP |
THE SPLIT IS ARITHMETIC, GRAMMAR, POLICY. Arithmetic is bound and
never rewritten — a Prolog RSA is not merely slow, it cannot be made
constant-time, so a private-key operation would leak by timing. Grammar
is Prolog: library(der)'s C++ half knows one tag-length-value, and
walking a sequence of them is a two-clause recursion. Policy is clauses,
which is the part that is better here than in any C++ stack:
ca_may(Subject, Action) :-
ca_grants(Subject, Grant),
ca_covers(Grant, Action), !.
ca_covers(G, A) :- G == A, !.
ca_covers(G, A) :- atom_concat(G, '.', P), atom_concat(P, _, A).A CERTIFICATE BECOMES CLAUSES. An issuer may write a list of
permissions into a certificate — ZiguratIP's own OID arc — and they mean
nothing to the certificate: they are matched by whoever cares. So
ca_load/1 turns a signed document into ca_holder/2 and ca_grants/2
facts, which are ROWS, which means another process can ask. A gateway
loads what it trusts at start-up and every later authorisation is a
query against the store rather than a signature check against a file.
KEYS ARE FILES, NOT TERMS, and that is the one decision to preserve. A private key read into an atom would be on the heap, in the trail, in every copy a channel made of the term holding it, and in the knowledge base the moment anything asserted it. This project's whole claim is that a clause is a row somebody else can read; a signing key is the one thing that must never become one.
cocolog --tcp --kb main # the binary protocol, in the clear (2160)
cocolog --tls --kb main --cacert ca.crt # encrypted
cocolog --tls --kb main --cacert ca.crt --cert me.crt --key me.key
cocolog --http --host NAME # Zeytun, plain HTTP (80)
cocolog --https --host NAME # Zeytun over TLS (443)--tls keeps the port and --https changes it, and that asymmetry
is ZiguratIP's: SERVER/TLS_MODE: TRUE changes what is on 2160, while
80 and 443 are two ports.
A client certificate is optional, and mandatory for permissions —
which sounds contradictory and is not. SERVER/TLS_CLIENT_AUTH takes
REQUIRED (the default), OPTIONAL or NONE, so the two --tls lines above
are both real arrangements; the server decides which one it will accept.
What a certificate is required for is SECURITY/PERMISSIONS_MODE.
ZiguratIP identifies every TLS peer, certificate or not — one without a
certificate is identified with an empty subject and an empty permission
set — and Globals::permits allows everything only to a peer that is
not identified, which is to say a plain connection. So:
| connection | PERMISSIONS_MODE: TRUE reaches |
|---|---|
plain (--tcp) |
everything — unidentified |
--tls, no --cert |
nothing — identified, no permissions |
--tls --cert --key |
what the certificate grants |
Turning TLS on is what turns access control on. The permission list is
the one library(ca) reads out of a certificate, on the other side of
the same seam.
When the server wants a certificate and the run has none, the refusal arrives as an alert on the first read rather than as a failed handshake — TLS 1.3 does not look at the client until the client has finished talking — and cocolog reports it in those words:
cocolog: no server at HOST:2160 -- read failed: tlsv13 alert certificate
required -- this server wants a client certificate: --cert and --key
The hostname is checked, not just the chain, on both. --insecure turns
that off and says so on stderr every time.
--port PORT is deprecated, and still works: it is exactly --tcp PORT. It named a number back when there was one transport; there are
four now and each says which as well as where. Nothing warns — the
flag is a spelling, not a mistake, and a line on stderr every run would
land in the output of every script that pipes cocolog.
The strongest thing this client can say was measured somewhere else. The
Coco — which is written in cocolog and treats it as frozen — runs its
three consensus rungs over --tls: proof of authority, proof of history,
proof of stake, three federated nodes sealing and gossiping and
re-verifying, a fork opened and closed by rule, a spine verified in
parallel segments, a stake-weighted vote to finality. All seventy-eight
of its verdicts come back byte for byte identical to the plaintext
run, including the three attacks that are supposed to succeed.
That is the point rather than a footnote. Every law those rungs enforce is about content — a hash recomputed, a signature checked against a published key, a tick count re-run, a quorum weighed against rows — so none of them can be improved by encrypting the link, and none should be. Their suite makes an attacker arrive over a verified TLS connection to the same store the honest nodes use, and checks she is refused exactly as before: an authenticated peer is not a trusted one.
The certificates are for something. library(tls) is library(tcp) with
a handshake in front of it — real OpenSSL underneath, TLS 1.2 at the
lowest, ECDHE and AEAD and !kRSA so static key transport cannot be
negotiated at all:
?- Creds = [ certificate('node.crt'), key('node.key'),
authority('ca.crt') ], % client_auth(required) by default
tls_listen(9443, S),
tls_accept(S, 15000, Creds, Conn, Peer),
tls_peer_subject(Conn, Who),
tls_peer_permissions(Conn, Granted).
Peer = '127.0.0.1:34844',
Who = 'C=IR, O=Coco, CN=alice, emailAddress=alice@example.org',
Granted = [read, 'ledger.write'].THE PERMISSIONS ARRIVE WITH THE HANDSHAKE. They were written into alice's certificate by an issuer, checked against the authority before a byte moved, and handed over as a list. So a server does not authenticate its peer — that already happened — and what is left is authorisation, which is a rule:
serve(Conn) :-
tls_peer_permissions(Conn, Granted),
( member(G, Granted), ca_covers(G, 'ledger.write')
-> apply_the_write(Conn)
; refuse(Conn) )."cocolog has no stream layer" was the wrong objection, and this is
the correction. Zigurat::tlsstream is a C++ iostream and there is
nothing here to hand one to — but nothing has to be. The stream stays
inside the module and what crosses into Prolog is an index into a table,
exactly as library(tcp) does with a descriptor.
A refused handshake FAILS rather than raising. A stranger, a
certificate this authority did not sign, and nobody arriving inside the
timeout are all ordinary answers; tls_why/1 says which. A server that
raised would stop serving everybody else because one impostor knocked.
test/tls.sh raises a server and runs three clients at it as separate
processes — enrolled, impostor, browser — because a handshake is between
two ends that do not share memory.
One option, and only the transport changes:
httpd_serve(9443, [ tls([ certificate('node.crt'),
key('node.key'),
authority('ca.crt') ]),
workers(4) ]).A connection became a tagged term — plain(S) or secure(S) — and five
predicates dispatch on it. Routing, keep-alive, the path rules and
httpd_answer/3 are the same code on both, so HTTPS cannot drift away
from HTTP by being maintained separately.
And a page knows who is on the connection. The handshake settled it against the authority before a byte moved, and it arrives as two synthetic headers a page reads like any other:
httpd_page('/ledger', Request, reply(200, [], 'write applied')) :-
http_header(Request, 'Tls-Peer-Permissions', Granted),
atomic_list_concat(Gs, ',', Granted),
member(G, Gs), ca_covers(G, 'ledger.write').The client's own Tls-Peer-* headers are stripped first, on both
transports. A server that merely added its own would leave two, with
the client's first — the standard reverse-proxy hole. On a plain
connection they are stripped and not replaced, so a page that trusts
them is closed to port 80 by construction.
test/crypto.sh holds them to FIPS 180, RFC 4231, NIST SP 800-38A and
DER's own worked examples, then issues a certificate for real — key,
request, issuance, validation, signature, verification: 74 checks.
library(thread) is threads and channels, and the shape is the one the
swarm command already had: a thread gets its own machine, store and
engine. A cocolog machine is an unguarded heap, a trail and an atom table;
two threads proving goals on one would corrupt it in a millisecond, and
locking at that level would be neither correct nor fast.
?- channel_new(Ch),
thread_pool(4, worker(Ch), Ids),
channel_forall(Ch, [T]>>handle(T)),
thread_join_all(Ids).So a channel copies, in canonical text — the same form the database
already stores clauses in, quoted and with operators ignored, so a term reads
back on a machine that never ran the same op/3. Two machines cannot share a
heap cell, so a term crossing between them is copied whatever the mechanism;
text is the copy this interpreter already trusts.
Measured: four threads doing four times the work of one took 1.7× the time on four cores, and eight senders put 800 terms through one channel with all 800 arriving.
library(json), library(xml) and library(html) go both ways, and all six
halves are DCGs — a grammar that emits is the format written down, readable a
clause at a time. There is no C in any of them.
:- use_module(library(json)).
:- use_module(library(html)).
?- json_atom(json([name-'Ada', ok- @(true), xs-[1,2]]), A).
A = '{"name":"Ada","ok":true,"xs":[1,2]}'
?- json_parse('{"a":[1,true]}', T).
T = json([a-[1, @(true)]])
?- html_atom(element(p, [class=note], ['a < b']), A).
A = '<p class="note">a < b</p>'
?- html_parse('<ul><li>one<li>two</ul>', T).
T = [element(ul, [], [element(li,[],[one]), element(li,[],[two])])]Which makes a page that answers JSON one line longer than a page that answers text — in the pages module, beside the others:
:- use_module(library(json)).
httpd_page('/api/stock', _, reply(200, ['Content-Type'-'application/json'], Body)) :-
findall(json([item-I, n-N]), stock(I, N), Rows),
json_atom(json([stock-Rows]), Body).$ curl localhost:8080/api/stock
{"stock":[{"item":"widget","n":7}]}
They throw rather than guess. An unbound variable is not null; foo(1)
is not "foo(1)"; @(maybe) is not a literal; <br>text</br> is not markup;
an integer past 64 bits is refused rather than wrapped, because
number_codes/2 answers -1 for a twenty-digit literal without complaining and
a silently wrong balance is the worst thing a JSON parser can do. Every
refusal names the term.
A code list is a list, and str/1 is the way out. cocolog has no string
type — double_quotes is codes, so "hello" is [104,101,…] and nothing
in the term says which you meant. Guessing is how a JSON array of byte values
silently becomes a word, and how element(p,[],["hello"]) becomes
<p>104101108108111</p> — which is what an earlier draft of xml.pl did,
and why there is a case for it.
The round trip is the real test. Write a document, read it, write it again, compare the two texts: a reader and a writer that disagree about the same bytes are worse than either alone, and no amount of hand-written expectations on each half finds a disagreement between them.
Where the three differ from each other, the languages differ. XML
self-closes an empty element and HTML's void elements close by being
themselves. XML 1.0 forbids -- in a comment outright, HTML5 ends on -->.
xml.pl indents element-only content and html.pl has no indent option at
all, because whitespace between two inline elements is a rendered space.
An unknown entity is an error in XML and text in HTML — which is what makes
AT&T render as AT&T.
There is no DTD, and that is the XXE answer. xml.pl skips the DOCTYPE,
internal subset and all, and has no code that could open a file or a socket:
the whole external-entity family is structurally impossible rather than
defended against.
html.pl is not an HTML5 tree builder, and says so. It handles void
elements, script/style as raw text, optional end tags, misnested end tags,
case-insensitive names, unquoted and bare attributes, and a < that begins no
tag. It does not do implied <html>/<head>/<body>, foster parenting or the
adoption agency — a half tree builder is worse than none, because it produces a
tree that looks right and quietly is not the one a browser built.
The one security-shaped check in the three is </script, in any case, inside a
script element — the only place where escaping is not the answer, because
a < b must reach the JavaScript parser as a < b.
Clauses are text — the canonical written form, which the reader reads back. The writer and the reader are tested against each other, so the round trip loses nothing, and text buys three things a binary encoding would not: a person can read the table, a Parsi procedure can search it, and the schema is not tied to the cell layout. Change a tag in the interpreter and every clause already in the database still loads.
Machine state is an opaque blob, in chunks of 4000 bytes. A Text is
documented at 65535 and the wire agrees, but a row has to fit in a page: with
the default 8192-byte page a Text of 8000 stores and one of 8192 comes back
allocation overflow. Measured, not guessed.
Model parameters are doubles, in cocolog::tensors — a table of one
Vector<Double> field, each row's id columns saying which tensor it belongs
to and seq which piece, 512 doubles to a piece for the same
row-fits-in-a-page reason. model_save/model_load use it wherever the
arrangement can (the server; the embedded store, whose Cicili engine
carries the same rows through its own vector column; over HTTP the tensor
page serves it back paged, from and limit, the elements travelling
as the IEEE bits of the double so nothing rounds) and fall back to the
older clause chunks where it cannot — --local by nature. The spec stays
a clause either way, so torch_model(Name, _) is still the question a
poller asks.
Thawing is two steps, in order: thaw the machine, which rebuilds the atom table exactly as it was, then load the knowledge base, whose clauses intern against that table. The other order leaves the store's cells referring to atom ids the thawed heap does not agree with.
The interpreter is not C in parentheses. The macro layer does the work a handwritten interpreter would repeat:
- The cell tags are one table.
*cell-tags*inlib/term.ciciliemits the C enum, the constructors and the testers, and because the macros know the numbers at expansion time(coco-tag-is REF c)folds to a comparison against a literal. - The operator table is read by both halves of the grammar. One
*operators*list emits the reader's lookups and the writer's, so they cannot disagree about an operator. - The lexer is written in characters and compiles to numbers. Cicili has no
character literal, so
(coco-ch-between c "a" "z")becomesc >= 97 && c <= 122at expansion time and the reader stays readable. - Builtins are a table.
*builtins*emits the dispatcher grouped by arity, so a goal of arity 3 is never compared against a builtin of arity 1 and the dispatcher cannot fall out of step with the table. - Terms can be written as terms.
(coco-term m (append (splice xs) (cons 1 nil) ?Rest))expands to the heap construction, reservations and fixups — the dance every hand-written term repeats, written once.
A string literal is raw. It reaches C untouched, which is why "\n" is two
characters in the source and a newline only after the C compiler sees it.
A string may not end in a backslash. The reader decides where a literal
stops by asking whether the previous character was one, so the closing quote of
"/\\" is read as escaped, the reader runs on into the rest of the file, and
every string after it is read as code. The failure surfaces hundreds of lines
later as Package +-*/\^<>=~ does not exist.
$ sh test/groups.sh
twelve interpreters, three per machine
starting four machines
ok state-a produced its full answer set ancestor(tom,ann) ... ancestor(tom,zoe)
ok state-a answered nothing twice 0
...
turns: a1=6 a2=4 a3=7
turns: b1=4 b2=4 b3=4
turns: c1=10 c2=10 c3=10
turns: d1=11 d2=7 d3=12
ok all three interpreters of group a took turns
ok no machine left suspended 0
GREEN: 0 failure(s)Twelve cocolog work processes against one server, three per machine. Each
machine produces its full answer set exactly once however many workers produced
it, and every member of every group does some of the work.
test/ruler.sh is the other half of the claim: one interpreter asserting a
program clause by clause while eight others query the same knowledge base, and
none of them may ever answer something the finished program cannot prove.
One decision does most of the work, and it is about isolation.
cocolog::machine_claim_named is a read followed by a write of the row it read
— find an idle machine, then mark it as one worker's — and it is the only thing
in cocolog that runs SERIALIZABLE. At READ COMMITTED two workers arriving
together both see suspended, both take it, and both advance the same state from
the same point; the answers come out twice. ZiguratIP's SERIALIZABLE excludes
only other SERIALIZABLE transactions, so twelve claims queue for microseconds
each while the work that matters — loading a machine, proving, saving it — goes
on at READ COMMITTED all at once. A short critical section at the strongest
level and everything else at the weakest that is still correct.
The rest is in STATUS.md: why an empty claim means two different things, why "the machine is gone" is not proof the first time you see it, and what had to be fixed in ZiguratIP before any of this held.
The knowledge base is a seam, and embed/ is a third thing plugged into it:
the same eighteen cocolog::* procedures the server offers, implemented
in-process over the Cicili MVCCS engine (ZiguratIP/MVCCS-cicili/) and
the very .cicili table definitions the Parsi compiler generated beside its
C++ pair. It is in the one cocolog binary: --embed [DIR] (which
opens ./KB when the directory is
left off) opens the store inside the process — no server, no
socket — and every command works unchanged, because the C client dispatches
each verb to the embedded engine behind the same zg_conn handle. The hooks
are weak symbols the interpreter carries either way; linking the engine in is
all that plugs it in.
An embedded store belongs to one process, so the group test's concurrency
moves inside it: cocolog swarm A1 M1 A2 M2 ... runs each worker as a thread
with its own session (the engine keeps transactions thread-local, and the
SERIALIZABLE claim is the same gate of one the server uses), and
test/groups-embed.sh runs the identical twelve-worker choreography and
checks. What the two arrangements measure, three runs each, same machine:
| run 1 | run 2 | run 3 | run 4 | run 5 | |
|---|---|---|---|---|---|
| wire, fresh server | 11.5s | 19.0s | 24.8s | — | — |
| embedded, fresh store | 14.7s | 14.5s | 14.5s | — | — |
| wire, vacuuming in setup | 15.2s | 15.7s | 16.0s | 16.7s | 15.0s |
All green throughout. The first two rows are the measurement that forced the
vacuum's hand: the embedded times were flat because the store was new each
run, while the wire times grew because the server's store kept the dead rows
of every earlier run. The third row is the same wire test after cocolog vacuum went into its setup — flat for as long as it is run, on the same
worked store the first row was ageing.
Then the same benchmark was pointed at a PERSISTENT embedded store
(GROUPS_EMBED_STORE=DIR test/groups-embed.sh), and what it found was not a
number but three storage engine bugs, shared by the C++ engine and the
Cicili port alike: a record sized at an exact chunk multiple measured one
chunk short everywhere it was read back (so its frees leaked and its holes
fit nothing), a fully-emptied page could never return to the allocator (the
whole-page test counted chunks a page can never surrender), and every
commit paid three syncs of two files even when it had written nothing —
which, for a choreography whose workers poll, was most of the wall clock.
All three are fixed in ZiguratIP, in MVCCS/memory.cpp and
MVCCS-cicili/mvccs-lib.cicili both, and the table after the fixes reads:
| run 1 | run 2 | run 3 | run 4 | run 5 | |
|---|---|---|---|---|---|
| wire, vacuuming in setup | 7.7s | 7.9s | 7.9s | 7.9s | 7.9s |
| embedded, fresh store | 8.7s | — | — | — | — |
| embedded, persistent + vacuum | 9.7s | 16.2s | 26.0s | 44.4s | 55.9s |
The wire halved and went perfectly flat — the server had been fsyncing
twice per client poll. The embedded fresh run dropped the same way. The
last row was, for a while, the honest open item: a persistent embedded
store stayed bounded but each run started slower than the last, walk-length
under the engine's one stream guard growing with the store's history. That
ager was then hunted through four more engine layers — the cursor read the
hexmap a byte at a time through a stream whose buffer every seek discarded;
load_control did nine reads where one block read serves; index storage
was swept when it should be rebuilt at truncate (four indexes held 72
of a vacuumed store's 168 pages, remembering every id they had ever seen);
and truncate reclaimed only settled DELETEs, never the superseded versions
an UPDATE leaves, so a machine claimed and released a thousand times left a
thousand old versions no vacuum would touch. With all of it fixed (the
ZiguratIP branch's "walk-length ager, solved" commit is the account):
| run 1 | run 3 | run 5 | run 8 | run 10 | |
|---|---|---|---|---|---|
| embedded, persistent + vacuum | 5.1s | 4.5s | 4.8s | 4.6s | 4.6s |
Dead flat, and the store byte-identical at 912KB from run 1 to run 10. The persistent embedded arrangement is now the fastest way to run the choreography — faster than the wire, faster than a fresh store every run — because the vacuum in its setup now actually returns the store to the same state every time. The one stream guard still serialises every reader (STATUS.md's one-core section), but nothing behind it accumulates any more: the guard protects work proportional to live data, not to history. And a worker killed mid-write no longer bricks the store: the recovery walk salvages a torn tail — what a kill catches in flight never reached its commit sync, so by shadow paging's own rule it never happened — keeps every parseable record, and refrees the rest.
Coco is a Prolog that trains. Most languages bolt machine learning
on through a foreign library; Coco makes it part of the logic. A
network is a term you assert, training is a goal you call, and the
learned weights are facts — saved, queried, and reloaded through the
same knowledge base that holds your rules. The last seam to be filled:
modules/torch/ puts libtorch behind the module system, so a
Prolog program can load a dataset the Files module vouched for, train a
network on it, and model_save the result — an assert of the model as
terms, which the knowledge base persists like any other fact.
It is a LOADABLE module — library/torch.so, built by
sh modules/torch/build.sh and reached with
use_module(library(torch)) — because a dependency this large should
not be everybody's: a cocolog with no libtorch still builds and still
runs, and ldd on the binary shows no torch at all. test/torch.sh
runs the whole story: train, store in Zigurat, reload in a fresh
process, predict identically.
The classic AI/ML challenges pass, one .pl file at a time.
tutorials/torch/ holds twenty-four such programs,
each a documented file carrying train, test and predict as
separate goals in separate processes — the store carries the model
between them: regression and classification, two-moons and spirals,
autoencoders and denoising, CNNs through a mini-LeNet, batch norm,
dropout, learning-rate schedules, LSTM sequence models with embeddings,
and fitted Q-iteration reinforcement learning. They are the third
tutorial category — sh test/tutorials.sh runs all sixty-seven files,
the seventy-two torch processes included, green and deterministically
in about forty-five seconds. The one to read first is
22-embedding-lstm, the shape of every
text classifier at toy scale — token ids through a learned embedding
into an LSTM, trained to remember whether token 3 ever appeared:
$ ./cocolog --embed /tmp/tut run tutorials/torch/22-embedding-lstm.pl train
trained: final nll 0.0117
saved
$ ./cocolog --embed /tmp/tut run tutorials/torch/22-embedding-lstm.pl predict
[0,1,2,3,4,5] -> contains token 3
[0,1,2,4,5,6] -> no token 3
[3,0,0,0,0,0] -> contains token 3
[7,7,7,7,7,7] -> no token 3That third line is the point: the token sat at the very start and the
LSTM carried the fact across five further steps, in a model that was
trained by one process, stored as terms, and is answering in another.
And 24-q-learning closes the collection
with reinforcement learning — fitted Q-iteration on a gridworld, the
DQN idea built from nothing but model_predict for the Bellman targets
and model_train for the regression, whose greedy policy walks the
optimal six moves around the pit.
Underneath: a full torch surface — layers, losses, optimisers, metrics,
and device selection with honest CUDA refusal rather than silent
fallback — models persisted as Prolog terms via
model_save/model_load, and an MVCC storage engine (ZiguratIP) so
learned knowledge survives the process the same way asserted knowledge
does. Where a neural net stops — explaining, constraining, chaining
conclusions — the Prolog engine picks up, because they were never in
different systems to begin with.
And because a trained model is clauses, it travels the way clauses do.
colab/COLAB.md is that claim on free hardware: one
Colab session trains on its GPU into a knowledge base that Google Drive
keeps between sessions, a Cloudflare tunnel publishes Zeytun's
read-only view of it, and every other cocolog — another Colab, a
laptop's ?- prompt — does model_load(xor, M), model_predict(M, ...)
over --http, the weights arriving as terms and the prediction running
wherever the querier is. One writer, many readers, enforced by which
port is public; test/tunnel.sh rehearses the edge locally, port for
port and Host for Host.
Coco is built for coworking — several cocolog instances sharing one job through knowledge bases, with no coordinator process, no message queue and no protocol invented for the occasion. Everything that moves between coworkers is clauses in a store: a worker announces itself by asserting, waits for a peer by asking the peer's knowledge base, fetches data by reading rows, and hands its result over by saving a model — because a trained model is clauses, a coworker's finished work is queryable the moment its turn commits, by anyone, with plain Prolog.
coworker/ holds two worked arrangements over the same
split dataset, each self-contained and runnable with one sh run.sh:
- accumulator — fan-in. Three trainers in
parallel, each on its own third of the data into its own knowledge
base; a fourth instance polls each part for the model by its
name —
torch_model(rings, _), an answer that flips exactly when a publish turn commits — then averages the three parameter sets (one-shot federated averaging, in Prolog, over the flat float lists), saves the accumulated model, tests it on held-out data and predicts. - balancer — all-gather, no centre. Every worker owns a third, waits for its peers, fetches the two thirds it lacks out of their knowledge bases, trains the full model itself and publishes it back — so any of the three answers, and queries go to whichever node is up or nearest.
Each part's connection is an environment variable, so the same scripts run against three separate servers on three machines. The suspended machines of the twelve-interpreter demos are the same idea one level deeper: there the coworkers share not just the knowledge but the proof in progress.
--trace turns on a four-port tracer: Call, Exit, Redo and Fail
lines in SWI-Prolog's own format, on stderr, for every goal the engine
proves — and trace/0 / notrace/0 switch it from inside a program, as
they do there. It is held to SWI the way everything here is held to
something: test/trace.sh asks both tracers the same queries over the
same program and compares the port lines one for one, down to the
subtleties — a Redo re-entering a clause shows the call with its
bindings undone, taking the other arm of a ; (or the else of an
->, or failing out of a \+) is a Redo of the call it sits in,
a deeper Redo reopens the calls it is nested in so their Fail
prints when the failure finally crosses them, and a call whose
remaining clause heads can never match is discarded in silence, which
is the quiet SWI's clause indexing buys. The engine's design pays for
itself here: the continuation is a term, so the Exit port is just a
marker the body proves its way through, and a frozen machine carries
its pending exits with it. TRACING.md is the whole
story: turning it on, reading the ports, the subtleties, and how the
conformance is kept.
emacs/ holds cocolog-mode, a Prolog major mode
with two ideas of its own: a variable can be a colour instead of a
name — same colour, same variable, which is how Prolog scopes them
anyway — and a test case lives in a comment beside its rule, where
C-c C-t runs it and draws the whole execution graph underneath, every
clause tried, as comments that survive git and any other editor. The
mode carries its own Prolog engine in Emacs Lisp so all of that works
with nothing installed — and that engine is a deliberate shadow of this
interpreter, held to it twice over: offline, make coco in emacs/
asks both the same 234 queries and compares; live, every graph drawn on
a machine with a cocolog binary is certified against it on the spot,
with the four-port trace of the rule refreshing alongside. Pickers
insert the goals, the grammar pieces and the torch rules (C-c C-i,
C-c C-g), and C-c C-e traces a goal under the real
interpreter in any of the four knowledge-base arrangements.
The interpreter, the serialisation, all four transports, the schema and
the concurrent arrangements are done and tested; make test ends
red: 0 over 39 cases, three of them TLS and one of them a window under Xvfb.
See STATUS.md for what is finished, what it cost to get there, and
what is known to be missing.
How cocolog compares to Python and to SWI-Prolog — across the
language aspects, the backend work, and the four separate senses of
"AI-friendly" — is written down once, honestly, in The Coco's
bench/languages.md.
It lives in a bench/ directory because a comparison is a benchmark of
a different kind and obeys the same rule: no sentence claims a number
that has not been printed.
It opens with the everyday row, because that is where most language choices are actually made: ease of entry, syntax weight, how a page reads, how much code a thought costs. There the "less code" claim is a measurement rather than an adjective — an entire proof-of-authority consensus in 39 non-comment lines, outnumbered by its own explanatory prose nearly three to one — and it is granted honestly that Python wins ease of entry outright, that Prolog's steepness lives in semantics rather than syntax (a better language to read RULES in, a worse one to read EXECUTION in), and that misapplied Prolog is MORE code than Python, not less.
It is honest in both directions throughout, which means cocolog loses often — strings, GC, tabling, tooling, ecosystem — and the rows where it genuinely differs compare positions rather than languages: a clause is a row other processes read, a turn is a transaction, and a suspended proof is data any process can finish.
clause indexing used to be on that list of losses and is not any
more, which is the benchmark's one unambiguous success: it named the
gap, the gap was closed, and the same script re-measured it.
Five small programs, the same task in each language, every lane's answer
checked against every other's before a number may print. --local is the
in-memory arrangement, --embed the MVCCS engine linked into the process,
and cpython + sqlite3 is there because a dict is not a database and
timing one against a store measures the guarantees rather than the engine:
| task (one rep) | cocolog --local | cpython | cocolog --embed | cocolog server | cpython + sqlite3 |
|---|---|---|---|---|---|
| naive reverse, 400 elements | 0.034682 s (8.3x) | 0.004186 s | 0.035370 s (8.4x) | 0.034849 s (8.3x) | 0.004852 s (1.2x) |
| 8-queens, all 92 solutions | 0.022249 s (11.3x) | 0.001973 s | 0.021706 s (11.0x) | 0.021999 s (11.2x) | 0.001957 s (1.0x) |
| 100 000 additions, one at a time | 0.080093 s (19.1x) | 0.004188 s | 0.081349 s (19.4x) | 0.079177 s (18.9x) | 0.028561 s (6.8x) |
| 1000 keyed lookups over 200 facts | 0.001773 s (18.5x) | 0.000096 s | 0.001886 s (19.6x) | 0.001820 s (19.0x) | 0.012229 s (127.4x) |
| generate-and-sort 5000 integers | 0.010062 s (5.8x) | 0.001744 s | 0.010205 s (5.9x) | 0.010286 s (5.9x) | 0.002480 s (1.4x) |
(macOS, i9-9880H, Python 3.11.13; three runs on this box with the shared lanes agreeing within a few percent; the sqlite column pairs every task with a durable Python -- the data as committed rows, read back through the database per rep. The earlier Linux box read 6-34x with the same shape -- search best, the tight loop worst -- and the two boxes do not compare across without naming both.)
So cocolog is 6-19x CPython as a language, and the spread is the interesting part: backtracking search is its best showing, which is the thing a Prolog engine is for, and a tight counting loop its worst, which is the per-inference cost of a continuation-passing interpreter with no compilation step. Start-up is not the reason and the guess that it was is dead — every arrangement boots in about a tenth of a second here, the same as Python.
And the server column is new. Over a socket, with a turn committed
against a store the harness empties per run, every task sits within a
few percent of the in-memory lane: the pipelined client, the turn-wide
write batch and the mapped store left the wire's per-rep cost too small
for the two-point method to see. And the sqlite column, written for
every task now, splits by where the work is: computation over durable rows costs Python 1.0-1.4x over its
own dict -- the same near-nothing cocolog's store lanes pay over
--local -- while per-row store traffic trades sides: the cursor's
addends cost sqlite 6.8x on the counting loop, and the thousand keyed
probes cost python + sqlite3 6.5x what they cost --embed.
Two of those readings used to be defects rather than a design, and the benchmark is what found them.
The keyed lookup was a SLOPE, not a factor. A call walked the predicate,
copying each clause onto the heap and unifying its head, so a probe into a
table of facts copied the table — 7x slower than a Python dict at 200
facts, 49x at 2 000, 411x at 20 000. A ratio that grows with N is a
linear scan's signature. coco_pred now carries a first-argument index:
| facts | before | after |
|---|---|---|
| 200 | 7x | 3x |
| 2 000 | 49x | 3x |
| 20 000 | 411x | 4x |
(On the second box, a Mac, it is still flat: 0.20 s at 200 facts, 0.22 s at 20 000.)
Writing was quadratic in the clauses already there. A clause written
through to the database re-sent its WHOLE predicate, and the batching that
made a consult cheap was switched off before the goal ran — so a file of
clauses was cheap and the same clauses asserted BY THE GOAL were not. The
batch spans the turn now, and the turn was already one transaction:
assertz into one predicate, --embed |
before | after |
|---|---|---|
| 50 clauses | 0.59 s | 0.024 s |
| 100 | 2.98 s | 0.031 s |
| 200 | 16.88 s | 0.050 s |
| 400 | 85.38 s | 0.088 s |
Roughly N^2.4 became roughly linear. Downstream, The Coco's settlement lane — blocks sealed onto a chain through the store — went from 18.38 to 194.84 blocks/s at an identical arrangement, and three of its lanes had to have their counts raised because they finished in under a second and the harness refused to print a rate for them.
Neither fix touched the per-inference cost, and the table above is unchanged by them on the four compute tasks. Two defects moved; a design did not.
This is the one piece of operating knowledge cocolog demands, and skipping it costs a factor of five.
The knowledge base grows with use, not with data. A deleted row is kept
under MVCC so that a transaction entitled to an earlier view can still read
it, and nothing reclaims those versions on its own. The workload does not look
like deleting, which is why this is easy to miss: saving a machine rewrites
its row, so a proof of thirty turns leaves twenty-nine dead ones; forget
then consult leaves a dead copy of every clause. The live contents stay
the same size. What grows is the number of dead versions every read walks
past — so nothing breaks, and everything gets slower.
Measured, twelve interpreters over four machines, identical work every time:
| wall clock | |
|---|---|
| empty store | 12s |
| the same store, five runs later | 32s |
| a store a few hundred test runs old | 60s |
that store, after one TRUNCATE pass |
16s, stable thereafter |
Five times the wall clock on identical work and identical live data. Every benchmark number in this README was taken against a fresh store, and a number taken against a worked one measures the store's history, not the change being tested. If an unexplained slowdown appears and the answer to "when was this store last truncated" is "never", that is the cause until proven otherwise.
TRUNCATE is ZiguratIP's vacuum — per table, reclaiming only rows committed
as deleted that no running transaction can still be entitled to, so it is
safe against a knowledge base in use (ZiguratIP/doc/truncate.md is the full
account). cocolog carries the pass in three forms, one per kind of caller:
cocolog vacuum # against the server (make schema ships
# the cocolog::vacuum procedure it calls)
cocolog --embed DIR vacuum # the same pass, embedded — the Cicili
# engine's own truncate over the same
# four tables
cocolog --vacuum query "vacuum_kb" # from inside a program; also
# vacuum_kb(Live) for the live countThe verb prints — and vacuum_kb(Live) answers — the number of live rows
left behind, which is the more useful number than the reclaimed count: run it
twice, and the second answer being the same is what says there was nothing
left to reclaim. It is store-wide, not per --kb, because a dead row no
longer has a knowledge base to belong to.
Run it the way any store with MVCC and no background vacuum wants its
maintenance run: on a schedule, whenever the store has been worked —
between benchmark runs always, between test-suite runs if the numbers are to
mean anything (test/groups.sh and test/ruler.sh run it in setup, which is
why they no longer slow down run over run), and periodically in any
long-lived deployment. The first measured pass took a 35MB store down to its
263 live rows.
Three caveats, so the schedule is chosen with open eyes:
- It spends point-in-time reads. The reclaimed versions are exactly what
SNAPSHOTisolation androllback_transaction_toread from, so after a pass the store cannot be read at a moment before it ran. Giving that up is an operator's decision, which is whyvacuum_kbis gated: without--vacuumon the command line it raisespermission_error(vacuum, knowledge_base, _)— a refusal, never a quiet no-op — and a program never spends the store's history unless the operator said this run may. Thevacuumverb needs no flag; typing it is the decision. - A store written before the schema made every column
NOT NULLcan never be reclaimed —TRUNCATErefuses rows carrying a NULL, and old stores have them (machines.note; STATUS.md has the story). For those the only cure is a fresh data directory. - A vacuumed store is fast, not small. Reclaimed pages go back to the allocator for any table to reuse; the files do not shrink. What recovers is the number of dead versions every read walks past — which is the thing the measurements above show was being paid for.
cocolog is a client and modifies neither of the projects it uses — but running
twelve of it at once turned up four faults in ZiguratIP, from unguarded B-tree
walks over the shared page store to a documented Parsi clause that had never
compiled. Those are fixed in ZiguratIP itself; its doc/concurrency.md is the
account.