How a read client reaches a KB — directly in-process, or over the daemon HTTP API
(vaelii.host.serve). It re-exports the slice of the vaelii.core read surface the
browser uses, so the browser is written once against these names and runs unchanged
against a local KB or a remote daemon.
A target is either a KB, read in-process, or an access value from remote. A
KB-read op dispatches on it:
:remote → the client (vaelii.host.client/call) — one HTTP round-trip
a KB → vaelii.core, via serve/ops (the very allowlist the daemon serves, so
local and remote answer through the same table and cannot drift)
The pure display fns (term-role, reified-term?, readable-sentence,
indexable-terms, negative?, rests-on, query-contexts, assertable-strengths,
sort-by-content, levels, calculi), the bootstrap fns (open-kb, clear!), the
in-process write-hazards and the process-wide switch-value take no target and
delegate to vaelii.core. They are here so a caller can require this one namespace
and reach the whole surface it needs.
Reads — including check / check-edit, which answer what assert would refuse
and write nothing — plus the four writes the browser performs: edit (an
assert/retract batch in one settle), edit-with-consequences (the same batch, plus
the belief it moved), forward-chain, and preview (which stores nothing but applies
a batch and rolls it back, so it holds the single writer). A remote result is already
EDN-clean (the daemon projects sentex records to maps); a local result is the raw
record, and both answer to the same keys, so a caller handles them identically.
How a *read* client reaches a KB — directly in-process, or over the daemon HTTP API
(`vaelii.host.serve`). It re-exports the slice of the `vaelii.core` read surface the
browser uses, so the browser is written once against these names and runs unchanged
against a local KB or a remote daemon.
A **target** is either a KB, read in-process, or an access value from `remote`. A
KB-read op dispatches on it:
:remote → the client (`vaelii.host.client/call`) — one HTTP round-trip
a KB → `vaelii.core`, via `serve/ops` (the very allowlist the daemon serves, so
local and remote answer through the same table and cannot drift)
The pure display fns (`term-role`, `reified-term?`, `readable-sentence`,
`indexable-terms`, `negative?`, `rests-on`, `query-contexts`, `assertable-strengths`,
`sort-by-content`, `levels`, `calculi`), the bootstrap fns (`open-kb`, `clear!`), the
in-process `write-hazards` and the process-wide `switch-value` take no target and
delegate to `vaelii.core`. They are here so a caller can require this one namespace
and reach the whole surface it needs.
Reads — including `check` / `check-edit`, which answer what `assert` would refuse
and write nothing — plus the four writes the browser performs: `edit` (an
assert/retract batch in one settle), `edit-with-consequences` (the same batch, plus
the belief it moved), `forward-chain`, and `preview` (which stores nothing but applies
a batch and rolls it back, so it holds the single writer). A remote result is already
EDN-clean (the daemon projects sentex records to maps); a local result is the raw
record, and both answer to the same keys, so a caller handles them identically.A backup of a :pg-snapshot store, taken from the browser before a large edit.
Such a store is in two places: its directory holds the index and reasoning images and
the operation log, and a Postgres schema holds the records (index/records.edn names
the schema). A backup is both, as of one state, in a new directory beside the store:
| file | |
|---|---|
store/ | an APFS clone (cp -c) of the store directory, its lock file left out |
records.dump | pg_dump --format=custom of the schema |
backup.edn | where it came from, the seal, the time, and how to restore it |
One state. The job seals the KB first, so the images name the records as they
stand (v/seal), and holds the process's writer claim and the browser's write monitor
(:run-in) from before the seal to after the dump, so nothing writes between the
three. pg_dump reads its schema in one snapshot transaction.
Restore is by hand and takes the KB offline: unload it, rename the schema out of
the way, pg_restore the dump (which recreates the schema under its own name), put
store/ back in the store directory's place, and load the store. backup.edn
carries the commands. The images then open against the restored records, since
pg_dump keeps the fingerprint row they were sealed against.
A backup of a `:pg-snapshot` store, taken from the browser before a large edit. Such a store is in two places: its directory holds the index and reasoning images and the operation log, and a Postgres schema holds the records (`index/records.edn` names the schema). A backup is both, as of one state, in a new directory beside the store: | file | | |---|---| | `store/` | an APFS clone (`cp -c`) of the store directory, its lock file left out | | `records.dump` | `pg_dump --format=custom` of the schema | | `backup.edn` | where it came from, the seal, the time, and how to restore it | **One state.** The job seals the KB first, so the images name the records as they stand (`v/seal`), and holds the process's writer claim and the browser's write monitor (`:run-in`) from before the seal to after the dump, so nothing writes between the three. `pg_dump` reads its schema in one snapshot transaction. **Restore** is by hand and takes the KB offline: unload it, rename the schema out of the way, `pg_restore` the dump (which recreates the schema under its own name), put `store/` back in the store directory's place, and load the store. `backup.edn` carries the commands. The images then open against the restored records, since `pg_dump` keeps the fingerprint row they were sealed against.
What knowledge bases this process can load, and the lifecycle of loading one.
Everything above the engine assumes it is holding the KB. A browser that lists the KBs available, loads one while you watch, and switches to it needs two things the engine does not have: a description of a KB that has not been loaded yet, and somewhere for a load that takes minutes to run while the pages keep answering. Those are the two halves here.
A source is a KB you could load, as data — a kind, a name, and wherever the content comes from. Six kinds:
| kind | content | loader |
|---|---|---|
:core | the upper ontology alone, in CxUniverse | vaelii.host.core-context |
:starter | the shipped schema-only ontology | vaelii.host.starter |
:generated | synthesized from numbers — types, rules, a fwd mix | vaelii.host.io.generate |
:corpus | a translated sentence corpus (OpenCyc) | a foreign reader, :cyc-corpus |
:dump | a vaelii export dump | vaelii.impl.io.import |
:store | an on-disk KB already in vaelii's own format | opened in place |
The first three ship in this repo and are always offered. The last three are found:
each directory on the search path (VAELII_KB_PATH, else ./kbs and ~/.vaelii/kbs)
is probed, and what marks it — a corpus meta.edn, a dump meta.edn, a records/ +
index/ pair — decides its kind. A catalog.edn (VAELII_KB_CATALOG, else
~/.vaelii/catalog.edn) names sources outside the search path. Nothing about a
machine's paths is baked into the repo.
An entry is a source that has been loaded, or is loading: a KB, a status, and a
progress reading the loaders report into (:on-progress, reported by every loader —
the corpus reader, io.import/import-dump and io.generate/load-into). The running
half of that is not here: a load is a job (vaelii.browser.jobs), which is what gives
it a thread of its own, the progress reading, the cancel flag and the report — so an
entry carries the job's id and reads its status rather than keeping one. One load runs
at a time, since a load claims this process's writer, and cancelling one is cooperative:
the loaders have no other safe interruption point, and an import is not a transaction,
so a cancelled load leaves the KB holding what had already landed.
A KB is readable before it is finished. activate asks only that an entry hold a
KB, so the one arriving can be the one every page reads — a corpus is browsable from
its first thousand sentexes, and a store that opens in seconds is browsable while
belief is still being rebuilt behind it. What that costs a reader is completeness, not
correctness, and active-caveat is what says so.
And a KB can go back out. export-entry! writes a loaded one as an export dump as
a job like any other, which closes the loop: a dump written under the search path is a
:dump source the moment its meta.edn lands, so exporting and reloading needs nothing
outside this namespace.
Unloading never deletes an on-disk KB. A memory-backed entry has its stores cleared (they would otherwise hold the corpus for the life of the JVM); every other backend is closed — the file lock released, the directory left exactly as it was, an adapter's connection closed. The same directory can then be loaded again, or opened by another process.
What knowledge bases this process can load, and the lifecycle of loading one. Everything above the engine assumes it is holding *the* KB. A browser that lists the KBs available, loads one while you watch, and switches to it needs two things the engine does not have: a description of a KB that has not been loaded yet, and somewhere for a load that takes minutes to run while the pages keep answering. Those are the two halves here. **A source** is a KB you could load, as data — a kind, a name, and wherever the content comes from. Six kinds: | kind | content | loader | |--------------|-----------------------------------------------------|--------| | `:core` | the upper ontology alone, in CxUniverse | `vaelii.host.core-context` | | `:starter` | the shipped schema-only ontology | `vaelii.host.starter` | | `:generated` | synthesized from numbers — types, rules, a fwd mix | `vaelii.host.io.generate` | | `:corpus` | a translated sentence corpus (OpenCyc) | a foreign reader, `:cyc-corpus` | | `:dump` | a vaelii export dump | `vaelii.impl.io.import` | | `:store` | an on-disk KB already in vaelii's own format | opened in place | The first three ship in this repo and are always offered. The last three are **found**: each directory on the search path (`VAELII_KB_PATH`, else `./kbs` and `~/.vaelii/kbs`) is probed, and what marks it — a corpus `meta.edn`, a dump `meta.edn`, a `records/` + `index/` pair — decides its kind. A `catalog.edn` (`VAELII_KB_CATALOG`, else `~/.vaelii/catalog.edn`) names sources outside the search path. Nothing about a machine's paths is baked into the repo. **An entry** is a source that has been loaded, or is loading: a KB, a status, and a progress reading the loaders report into (`:on-progress`, reported by every loader — the corpus reader, `io.import/import-dump` and `io.generate/load-into`). The running half of that is not here: a load is a **job** (`vaelii.browser.jobs`), which is what gives it a thread of its own, the progress reading, the cancel flag and the report — so an entry carries the job's id and reads its status rather than keeping one. One load runs at a time, since a load claims this process's writer, and cancelling one is cooperative: the loaders have no other safe interruption point, and an import is not a transaction, so a cancelled load leaves the KB holding what had already landed. **A KB is readable before it is finished.** `activate` asks only that an entry hold a KB, so the one arriving can be the one every page reads — a corpus is browsable from its first thousand sentexes, and a store that opens in seconds is browsable while belief is still being rebuilt behind it. What that costs a reader is completeness, not correctness, and `active-caveat` is what says so. **And a KB can go back out.** `export-entry!` writes a loaded one as an export dump as a job like any other, which closes the loop: a dump written under the search path is a `:dump` source the moment its `meta.edn` lands, so exporting and reloading needs nothing outside this namespace. **Unloading never deletes an on-disk KB.** A memory-backed entry has its stores cleared (they would otherwise hold the corpus for the life of the JVM); every other backend is *closed* — the file lock released, the directory left exactly as it was, an adapter's connection closed. The same directory can then be loaded again, or opened by another process.
Worked examples of the reasoning the shipped ontology actually does — the data, and the one function that runs one.
Nothing here is a story about the engine. Each example names the sentexes it
rests on, and those are looked up in the live KB before anything is claimed: an
example whose rests-on sentences are not stored is reported unavailable rather
than answered, so switching to another corpus greys the examples out instead of
silently showing a verdict computed from vocabulary that is not there. The verdict
itself is an ordinary ask / escalate / check, and the proof is why.
Two kinds, and the split is about what the KB ships rather than about presentation:
read-only — no premises. The shipped schema is types, taxonomy, metadata and
rules, so everything asked of kinds is answerable with no write at all, and the
page computes it on render. This is where the taxonomy, transitiveInArg,
disjointness and the predicate meta-ontology live.
sandboxed — premises naming individuals. The starter ships no cast (the fables and their casts live in the test-world), so an example about defaults, joins or refusals has to bring its own, and it writes them into the reader's own sandbox context. Nothing shipped can see in, and the sandbox reset takes the whole thing away.
:expect is what the ontology is supposed to answer, and examples_test asserts
every one of them — so the page cannot drift away from the KB it describes.
Worked examples of the reasoning the shipped ontology actually does — the data, and the one function that runs one. **Nothing here is a story about the engine.** Each example names the sentexes it rests on, and those are looked up in the live KB before anything is claimed: an example whose `rests-on` sentences are not stored is reported *unavailable* rather than answered, so switching to another corpus greys the examples out instead of silently showing a verdict computed from vocabulary that is not there. The verdict itself is an ordinary `ask` / `escalate` / `check`, and the proof is `why`. Two kinds, and the split is about what the KB ships rather than about presentation: **read-only** — no premises. The shipped schema is types, taxonomy, metadata and rules, so everything asked *of kinds* is answerable with no write at all, and the page computes it on render. This is where the taxonomy, `transitiveInArg`, disjointness and the predicate meta-ontology live. **sandboxed** — premises naming individuals. The starter ships no cast (the fables and their casts live in the test-world), so an example about defaults, joins or refusals has to bring its own, and it writes them into the reader's own sandbox context. Nothing shipped can see in, and the sandbox reset takes the whole thing away. `:expect` is what the ontology is supposed to answer, and `examples_test` asserts every one of them — so the page cannot drift away from the KB it describes.
Page loads that run long: released from the request, joined rather than repeated, and listed while they run.
A page is a read, and over a KB of millions of sentexes some reads run for minutes or more. Without this, the request holds its server thread for the whole read and the reader gets a spinner. A reader who clicks again, or reloads, starts a second read of the same page beside the first. Neither read can be stopped from outside: interrupting a thread inside a disk store's read closes the store's file channel. So the protection is to never start a second copy of a read, and to show what is running.
detach-ms (vaelii.browser.detach-ms, 5 s). A page done by then is answered as
usual. One that is not is answered with a page saying what is being computed and for
how long. That page polls /inflight/poll and, once the read is done, loads the
page's own URL again, which is then answered from the finished result.#main out of a whole document).keep-ms for that next
load, then dropped.running is what /jobs lists: each read's URL, its age, and the
engine frame its thread is in.Only a page load is guarded: a GET or HEAD that is a plain navigation or an htmx
boosted one (HX-Boosted). Every other htmx request targets part of a page that has
its own place to land, and a waiting page swapped into it would land in the wrong
place. Static files (a last path segment holding a dot) and /health pass straight
through.
Page loads that run long: released from the request, joined rather than repeated, and listed while they run. A page is a read, and over a KB of millions of sentexes some reads run for minutes or more. Without this, the request holds its server thread for the whole read and the reader gets a spinner. A reader who clicks again, or reloads, starts a second read of the same page beside the first. Neither read can be stopped from outside: interrupting a thread inside a disk store's read closes the store's file channel. So the protection is to never start a second copy of a read, and to show what is running. - **Released.** A page load runs on a thread of its own, and the request waits for it `detach-ms` (`vaelii.browser.detach-ms`, 5 s). A page done by then is answered as usual. One that is not is answered with a page saying what is being computed and for how long. That page polls `/inflight/poll` and, once the read is done, loads the page's own URL again, which is then answered from the finished result. - **Joined.** A second load of the same URL and query in the same session while the first runs waits on the first instead of starting another. The read always computes the whole document, never the boosted fragment, so a boosted load and a plain reload of the same URL share one read (htmx selects `#main` out of a whole document). - **Kept.** A finished result nobody was waiting for is kept `keep-ms` for that next load, then dropped. - **Listed.** `running` is what `/jobs` lists: each read's URL, its age, and the engine frame its thread is in. Only a **page load** is guarded: a GET or HEAD that is a plain navigation or an htmx boosted one (`HX-Boosted`). Every other htmx request targets part of a page that has its own place to land, and a waiting page swapped into it would land in the wrong place. Static files (a last path segment holding a dot) and `/health` pass straight through.
Long work, as jobs: one registry, one progress reading, one cancel.
Three things this process does take minutes rather than milliseconds — filling a KB from a corpus, writing one back out, and joining every rule over everything stored. Each of them wants the same four capabilities, and they are the only four: run on a thread of its own so the pages keep answering, say where it has got to, stop when asked, and leave a report somebody can read afterwards. That shape is here once.
A job is {:id :label :kind :status :progress :started :finished :error :summary :result-url}, plus a cancel flag and the future, which no public job carries. submit
returns the id; job reads one; jobs lists them, newest first. The caller's work
is handed a progress! fn and nothing else: what it records shows up under
:progress, and what it throws is how cancellation lands, because a tight assert
loop has no other point at which stopping is safe.
One status vocabulary, whatever the job is doing:
:running → :cancelling → :done | :cancelled | :failed
:cancelling is the state between the request and the stop: cancel! sets the flag and
returns, and the work keeps running until it reaches its next progress report — which,
for a phase that reports none (opening a large store scans its whole record log before
it says anything), can be a while.
The single writer stays single. :writes names the KB a job writes, or true for
one it has not opened yet, and one writing job runs at a time: a second is refused
with a message naming the job that holds the writer. Two interleaved writers are not
serializable (docs/storage.md, the single-writer contract), and a registry that let two
through would be a way around the contract rather than a place to watch it from.
writes-kb? is the other half of the same question, asked by identity, so a job filling
one KB is never a reason to refuse a write to another. The browser's write monitor is
a separate thing: it is process-wide, and a chaining job holds it for its whole run, so
a write to any KB waits for the chain (docs/web.md).
Cancellation is cooperative, and for a KB-writing job that is not negotiable. A
thread interrupt landing mid-cascade on a durable store surfaces as
ClosedByInterruptException and can leave a torn removal, so a job with :writes is
flagged and never interrupted however long it takes to notice. A job that writes
nothing may say :interruptible? true and be cancelled the hard way as well; the
registry checks both, so the two can never be confused for one another. A job's thread
is the pool's once its body has unwound, so the hard tier is fenced: the body
publishes :released under the job's :monitor and cancel! re-reads it there, which
is what stops an interrupt aimed at a job that has already finished from landing on
whatever the pool runs next.
A finished job's report outlives the job, for an hour — long enough to read what it
did, since the page that would have shown it is usually the page you navigated away
from. The registry holds the newest max-settled-jobs settled reports at most, so a
caller submitting no-op jobs holds a bounded number of them. Nothing unsettled is ever dropped, at any age: forgetting a job is releasing
its writer claim, and a thread that is still running is still writing. So a wedged job
keeps its place and keeps counting towards the running badge, which is the truth about
the process — better than a store two writers took turns on.
Long work, as jobs: one registry, one progress reading, one cancel.
Three things this process does take minutes rather than milliseconds — filling a KB
from a corpus, writing one back out, and joining every rule over everything stored.
Each of them wants the same four capabilities, and they are the only four: run on a
thread of its own so the pages keep answering, say where it has got to, stop when
asked, and leave a report somebody can read afterwards. That shape is here once.
**A job** is `{:id :label :kind :status :progress :started :finished :error :summary
:result-url}`, plus a cancel flag and the future, which no public job carries. `submit`
returns the id; `job` reads one; `jobs` lists them, newest first. The caller's `work`
is handed a `progress!` fn and nothing else: what it records shows up under
`:progress`, and what it *throws* is how cancellation lands, because a tight assert
loop has no other point at which stopping is safe.
**One status vocabulary**, whatever the job is doing:
:running → :cancelling → :done | :cancelled | :failed
`:cancelling` is the state between the request and the stop: `cancel!` sets the flag and
returns, and the work keeps running until it reaches its next progress report — which,
for a phase that reports none (opening a large store scans its whole record log before
it says anything), can be a while.
**The single writer stays single.** `:writes` names the KB a job writes, or `true` for
one it has not opened yet, and **one writing job runs at a time**: a second is refused
with a message naming the job that holds the writer. Two interleaved writers are not
serializable (docs/storage.md, the single-writer contract), and a registry that let two
through would be a way around the contract rather than a place to watch it from.
`writes-kb?` is the other half of the same question, asked by identity, so a job filling
one KB is never a reason to refuse a write to another. The browser's write monitor is
a separate thing: it is process-wide, and a chaining job holds it for its whole run, so
a write to any KB waits for the chain (docs/web.md).
**Cancellation is cooperative, and for a KB-writing job that is not negotiable.** A
thread interrupt landing mid-cascade on a durable store surfaces as
`ClosedByInterruptException` and can leave a torn removal, so a job with `:writes` is
flagged and never interrupted however long it takes to notice. A job that writes
nothing may say `:interruptible? true` and be cancelled the hard way as well; the
registry checks both, so the two can never be confused for one another. A job's thread
is the *pool's* once its body has unwound, so the hard tier is fenced: the body
publishes `:released` under the job's `:monitor` and `cancel!` re-reads it there, which
is what stops an interrupt aimed at a job that has already finished from landing on
whatever the pool runs next.
**A finished job's report outlives the job**, for an hour — long enough to read what it
did, since the page that would have shown it is usually the page you navigated away
from. The registry holds the newest `max-settled-jobs` settled reports at most, so a
caller submitting no-op jobs holds a bounded number of them. Nothing *unsettled* is ever dropped, at any age: forgetting a job is releasing
its writer claim, and a thread that is still running is still writing. So a wedged job
keeps its place and keeps counting towards the running badge, which is the truth about
the process — better than a store two writers took turns on.The development browser's source reloader, which only scripts/start-vaelii-dev.sh
turns on (vaelii.browser.web/dev-repl). Before each request it reloads every watched
source file that changed since the last request, together with every loaded namespace
that requires one of them, transitively, in dependency order.
A protocol, record, type or interface is never redefined. Re-evaluating a
defprotocol defines a new interface and empties the protocol's extension map, and
re-evaluating a defrecord, deftype or definterface defines a new class, so a KB
loaded before the reload would hold values the reloaded code no longer recognizes. A
reload therefore loads a file form by form and leaves out each of those forms whose
protocol or class already exists; every other form is evaluated. A record keeps its
methods inline, where protocol dispatch is a direct interface call, and a method that
calls a function reaches the reloaded function through its var. An edit inside such a
form is not loaded: restart-owed names it until the process restarts, and the page
shows that list. A form for a class that does not exist yet is evaluated.
A held namespace is never re-evaluated at all. A namespace whose ns symbol carries
:clojure.tools.namespace.repl/load false holds protocols and method-less records
(vaelii.impl.types.*, vaelii.impl.protocols, …). An edit to one reloads nothing on
its account, and restart-owed names it.
Form by form, not tools.namespace's refresh. refresh removes each namespace with
remove-ns before it loads the file again, which re-evaluates every defonce in it and
empties the state a loaded KB keeps there: the cache registry, the change feed's
listeners, the thaw guard's installed readers. Loading into the existing namespace
keeps a defonce's value, as require :reload does.
Only loaded namespaces reload. A dependent this process never loaded stays unloaded: loading it would run code the running server does not use.
tools.namespace ships in the :dev profile only, so its dependency graph and ns-form
reader are resolved when wrap-reload builds the reloader, never at this namespace's
load. The standalone jar compiles this namespace and has no tools.namespace.
The development browser's source reloader, which only `scripts/start-vaelii-dev.sh` turns on (`vaelii.browser.web/dev-repl`). Before each request it reloads every watched source file that changed since the last request, together with every loaded namespace that requires one of them, transitively, in dependency order. **A protocol, record, type or interface is never redefined.** Re-evaluating a `defprotocol` defines a new interface and empties the protocol's extension map, and re-evaluating a `defrecord`, `deftype` or `definterface` defines a new class, so a KB loaded before the reload would hold values the reloaded code no longer recognizes. A reload therefore loads a file form by form and leaves out each of those forms whose protocol or class already exists; every other form is evaluated. A record keeps its methods inline, where protocol dispatch is a direct interface call, and a method that calls a function reaches the reloaded function through its var. An edit inside such a form is not loaded: `restart-owed` names it until the process restarts, and the page shows that list. A form for a class that does not exist yet is evaluated. **A held namespace is never re-evaluated at all.** A namespace whose ns symbol carries `:clojure.tools.namespace.repl/load false` holds protocols and method-less records (`vaelii.impl.types.*`, `vaelii.impl.protocols`, …). An edit to one reloads nothing on its account, and `restart-owed` names it. **Form by form, not tools.namespace's `refresh`.** `refresh` removes each namespace with `remove-ns` before it loads the file again, which re-evaluates every `defonce` in it and empties the state a loaded KB keeps there: the cache registry, the change feed's listeners, the thaw guard's installed readers. Loading into the existing namespace keeps a `defonce`'s value, as `require :reload` does. **Only loaded namespaces reload.** A dependent this process never loaded stays unloaded: loading it would run code the running server does not use. tools.namespace ships in the `:dev` profile only, so its dependency graph and ns-form reader are resolved when `wrap-reload` builds the reloader, never at this namespace's load. The standalone jar compiles this namespace and has no tools.namespace.
The sentexes the browser last removed from each KB, so a removal can be put back.
A retraction is the browser's one write that loses content: an assert can be retracted
and an edit edited back, but a retracted sentence exists afterwards only in the reader's
memory. So every removal the browser makes is noted here first — the sentence as the
editor prints it, its context and its strength — and the newest max-kept per KB are
kept. /removed lists them, and putting one back is an assert of what was noted.
Per KB, by name, on disk. The list is about a KB rather than a session, it has to
outlive the process that made the removal, and it must not be written into the KB it
describes, so it is an EDN file per KB under ~/.vaelii/removed/ (the vaelii.removed.dir
property moves it, and the test profile sets it). A file is
replaced whole through a rename, so a reader never sees half of one.
Only the sentexes a removal took out are noted. A selected sentex that stays, being derived another way, was not lost, and the derived sentexes the sweep took with a removal come back with what they rest on.
The sentexes the browser last removed from each KB, so a removal can be put back. A retraction is the browser's one write that loses content: an assert can be retracted and an edit edited back, but a retracted sentence exists afterwards only in the reader's memory. So every removal the browser makes is noted here first — the sentence as the editor prints it, its context and its strength — and the newest `max-kept` per KB are kept. `/removed` lists them, and putting one back is an assert of what was noted. **Per KB, by name, on disk.** The list is about a KB rather than a session, it has to outlive the process that made the removal, and it must not be written into the KB it describes, so it is an EDN file per KB under `~/.vaelii/removed/` (the `vaelii.removed.dir` property moves it, and the test profile sets it). A file is replaced whole through a rename, so a reader never sees half of one. Only the sentexes a removal **took out** are noted. A selected sentex that stays, being derived another way, was not lost, and the derived sentexes the sweep took with a removal come back with what they rest on.
Somewhere safe to be wrong.
A sandbox is a scratch context of one browser session's own, hung below
CxWell so it sees what CxWell sees (every shipped context but the opt-in theories,
the kb/middle/ files that state no genlCx edge from CxWell) and nothing shipped
sees it. A
reader can therefore use every type, every relation and every rule the KB ships, and
cannot damage any of them: their content is visible only from inside, and one control
takes all of it away again.
Why that shape and not a permission system: visibility here is logical, not
administrative. genlCx already decides what a context can see, and hanging the
sandbox at the bottom of the context hierarchy gives exactly the asymmetry wanted — everything
flows in, nothing flows out — with no new concept and nothing to enforce. A shipped
rule firing over sandbox facts places its conclusion in the sandbox, because
placement is the maximal common descendant of the rule's context and the antecedents'
(docs/contexts.md), and the sandbox is the only context below both. So the derived
content is inside the thing that gets discarded, without anything arranging for that.
Four facts about the lifecycle:
genlCx edge per idle visitor./find, CxWell's term page, /op :contexts).max-sandboxes per KB. Opening one more resets the
lowest-priority sandbox on the eviction table below (docs/web.md).edit's :remove, and the genlCx edge with them. The edge is not in the
extent — genlCx is a forced-decontextualized predicate, so it is stored in
CxUniverse — which is why it is fetched by hand rather than swept up with the
rest.Promotion — moving something out of a sandbox into a context that outlives it — is deliberately not here. A sandbox is a dead end, and a dead end that cannot be half-escaped is easier to reason about than one with an entry point in it.
Somewhere safe to be wrong. A **sandbox** is a scratch context of one browser session's own, hung below `CxWell` so it sees what CxWell sees (every shipped context but the opt-in theories, the `kb/middle/` files that state no `genlCx` edge from CxWell) and nothing shipped sees it. A reader can therefore use every type, every relation and every rule the KB ships, and cannot damage any of them: their content is visible only from inside, and one control takes all of it away again. Why that shape and not a permission system: visibility here is *logical*, not administrative. `genlCx` already decides what a context can see, and hanging the sandbox at the bottom of the context hierarchy gives exactly the asymmetry wanted — everything flows in, nothing flows out — with no new concept and nothing to enforce. A shipped rule firing over sandbox facts places its conclusion **in the sandbox**, because placement is the maximal common descendant of the rule's context and the antecedents' (docs/contexts.md), and the sandbox is the only context below both. So the derived content is inside the thing that gets discarded, without anything arranging for that. Four facts about the lifecycle: - **The context is created on the first write, not on the first page.** A reader who only looks costs the KB nothing, and a KB full of empty sandboxes would be a KB with a `genlCx` edge per idle visitor. - **The server mints the session token and tags it.** The cookie carries the token and an HMAC of it under a key fixed at start, and a cookie whose tag does not check is replaced with a fresh one, so a client cannot choose a sandbox. The context name carries a digest of the token rather than the token, because every reader sees every context's name (`/find`, `CxWell`'s term page, `/op :contexts`). - **A process keeps at most `max-sandboxes` per KB.** Opening one more resets the lowest-priority sandbox on the eviction table below (docs/web.md). - **Reset is a real teardown**, not a flag: every sentex in the extent goes through `edit`'s `:remove`, and the `genlCx` edge with them. The edge is not in the extent — `genlCx` is a forced-decontextualized predicate, so it is stored in `CxUniverse` — which is why it is fetched by hand rather than swept up with the rest. Promotion — moving something out of a sandbox into a context that outlives it — is deliberately not here. A sandbox is a dead end, and a dead end that cannot be half-escaped is easier to reason about than one with an entry point in it.
The inline-SVG primitives the term page's concept graph is drawn with: a node, an edge, an arrowhead, and the arithmetic that lays out a row, a column or a ring.
No graph library. The browser ships two JavaScript files and this adds none — a layout that is a fold over a row of boxes is a dozen lines, and a dependency that drew it would be the largest thing the client loads. Nor a shell-out: a page that renders by starting a process is a page that cannot be served.
Everything here is pure — no KB, no access facade, no belief — so it is tested on hand-built maps. What a node means is the caller's: it supplies the term, the colour class, the link and the tooltip, and this decides only where the box goes and what shape it is.
Coordinates live in one flat user space and may be negative; scene crops the
viewBox to the union of what was actually drawn, so a sparse graph is centred rather
than adrift in a fixed canvas and a long snake_case label is never clipped. Every
number reaching an attribute is a long: Clojure's / yields a ratio, and 1/2 in
an SVG attribute is not a coordinate.
The inline-SVG primitives the term page's concept graph is drawn with: a node, an edge, an arrowhead, and the arithmetic that lays out a row, a column or a ring. **No graph library.** The browser ships two JavaScript files and this adds none — a layout that is a fold over a row of boxes is a dozen lines, and a dependency that drew it would be the largest thing the client loads. Nor a shell-out: a page that renders by starting a process is a page that cannot be served. Everything here is **pure** — no KB, no access facade, no belief — so it is tested on hand-built maps. What a node *means* is the caller's: it supplies the term, the colour class, the link and the tooltip, and this decides only where the box goes and what shape it is. Coordinates live in one flat user space and may be negative; `scene` crops the `viewBox` to the union of what was actually drawn, so a sparse graph is centred rather than adrift in a fixed canvas and a long snake_case label is never clipped. Every number reaching an attribute is a **long**: Clojure's `/` yields a ratio, and `1/2` in an SVG attribute is not a coordinate.
A small reitit-ring web browser over a KB:
/ the upper ontology (contexts, types, core predicates, disjointness) /stats KB-wide counts, contexts by size, and the reasoning-health ledgers /find?q=<pattern> the terms whose name matches, from the index's term-name set /term?q=<term> every sentex containing the term, grouped by the index root that reaches it (functor / argument-position / context / term-index) /sentex/:id a sentex (literal or rule): its belief state (IN, or the why-not reason — superseded / defeated / unsupported), supports, dependents /justification/:id a justification: its supports (arguments) and dependent sentex /levels?q=<goal> the lookup-to-query stack: what each of the 8 levels answers /edit the multi-sentex editor (GET seeds it, POST saves) — a fragment /{term,find,levels}/rows one more page of a capped list, as bare rows
Run it with lein run -m vaelii.browser.web (serves a starter-loaded KB on :3000).
Handlers are pure request -> response, so they are testable without a server.
Every page is answered twice over: as a whole document, and — when htmx asks, which
is every navigation and search — as the #main fragment that actually lands. What a
page costs in KB reads is part of what this demonstrates, since the browser reads the
public surface alone and each read is a round-trip under --attach; see the render-env
section below and docs/web.md.
A small reitit-ring web browser over a KB:
/ the upper ontology (contexts, types, core predicates, disjointness)
/stats KB-wide counts, contexts by size, and the reasoning-health ledgers
/find?q=<pattern> the terms whose name matches, from the index's term-name set
/term?q=<term> every sentex containing the term, grouped by the index root that
reaches it (functor / argument-position / context / term-index)
/sentex/:id a sentex (literal or rule): its belief state (IN, or the why-not
reason — superseded / defeated / unsupported), supports, dependents
/justification/:id a justification: its supports (arguments) and dependent sentex
/levels?q=<goal> the lookup-to-query stack: what each of the 8 levels answers
/edit the multi-sentex editor (GET seeds it, POST saves) — a fragment
/{term,find,levels}/rows one more page of a capped list, as bare rows
Run it with `lein run -m vaelii.browser.web` (serves a starter-loaded KB on :3000).
Handlers are pure `request -> response`, so they are testable without a server.
Every page is answered twice over: as a whole document, and — when htmx asks, which
is every navigation and search — as the `#main` fragment that actually lands. What a
page costs in KB reads is part of what this demonstrates, since the browser reads the
public surface alone and each read is a round-trip under `--attach`; see the `render-env`
section below and docs/web.md.cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |