Liking cljdoc? Tell your friends :D

vaelii.host.access

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 real KB (treated as local) or an access value from local / remote. A KB-read op dispatches on it:

:remote → the client (vaelii.host.client/call) — one HTTP round-trip :local → vaelii.core, via serve/ops (the very allowlist the daemon serves, so local and remote answer through the same table and cannot drift) a raw KB → the same local path (so a caller holding a plain KB needs no wrapper)

The pure display fns (term-role, reified-term?, readable-sentence, indexable-terms, levels, calculi) and the bootstrap fns (open-kb, clear!) take no target and just delegate to vaelii.core — they are here only 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 real KB (treated as local) or an access value from `local` /
`remote`.  A KB-read op dispatches on it:

  :remote  → the client (`vaelii.host.client/call`) — one HTTP round-trip
  :local   → `vaelii.core`, via `serve/ops` (the very allowlist the daemon serves, so
             local and remote answer through the same table and cannot drift)
  a raw KB → the same local path (so a caller holding a plain KB needs no wrapper)

The pure display fns (`term-role`, `reified-term?`, `readable-sentence`,
`indexable-terms`, `levels`, `calculi`)
and the bootstrap fns (`open-kb`, `clear!`) take no target and just delegate to
`vaelii.core` — they are here only 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.
raw docstring

vaelii.host.catalog

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:

kindcontentloader
:corethe CxCore vocabulary head alonevaelii.host.core-context
:starterthe shipped schema-only ontologyvaelii.host.starter
:generatedsynthesized from numbers — types, rules, a fwd mixvaelii.host.io.generate
:corpusa translated sentence corpus (OpenCyc)a foreign reader, :cyc-corpus
:dumpa vaelii export dumpvaelii.impl.io.import
:storean on-disk KB already in vaelii's own formatopened 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.host.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); a disk-backed one is closed — the file lock released, the directory left exactly as it was. 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 CxCore vocabulary head alone               | `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.host.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); a disk-backed
one is *closed* — the file lock released, the directory left exactly as it was.  The
same directory can then be loaded again, or opened by another process.
raw docstring

vaelii.host.cli

A command-line driver for a KB — the shell dual of the in-process API, launched with lein run -m vaelii.host.cli <cmd> <args…>. It runs the engine in-process (no daemon); to talk to a running daemon use vaelii.host.client instead.

lein run -m vaelii.host.cli assert '(dog Muffet)' CxNaturalWorld --dir /tmp/kb lein run -m vaelii.host.cli query '(dog ?x)' CxNaturalWorld --dir /tmp/kb lein run -m vaelii.host.cli why 3 --dir /tmp/kb lein run -m vaelii.host.cli export /tmp/dump --dir /tmp/kb lein run -m vaelii.host.cli repl --starter # interactive, starter schema lein cli help # every command and what it takes

help is a word rather than only a flag because Leiningen answers lein cli --help itself, printing the alias expansion — the flag never reaches this namespace through the alias, though it does through the full lein run -m vaelii.host.cli --help.

Backend. --dir <path> uses the durable :disk-log backend (recovered on open, so a fact asserted in one invocation is there in the next); with no --dir the KB is in-memory and lives only for the process — useful for repl or a single compound session, pointless across one-shot commands. --starter loads the shipped schema (types, contexts, relation rules) so you can explore the ontology. --strength monotonic marks an assert or assert-rule known-true. export takes --variant records|records+index and --compression gzip|xz|none.

A flag belongs to the commands that read it (command-flags), and one carried by a command that does not is refused rather than dropped — those three are the driver's and go anywhere, the rest do not.

One writer. A --dir KB takes the single-writer file lock (docs/storage.md), so the CLI and a daemon cannot own the same directory at once — by design.

A command-line driver for a KB — the shell dual of the in-process API, launched with
`lein run -m vaelii.host.cli <cmd> <args…>`.  It runs the engine in-process (no
daemon); to talk to a running daemon use `vaelii.host.client` instead.

  lein run -m vaelii.host.cli assert  '(dog Muffet)'  CxNaturalWorld --dir /tmp/kb
  lein run -m vaelii.host.cli query   '(dog ?x)'    CxNaturalWorld --dir /tmp/kb
  lein run -m vaelii.host.cli why     3                                 --dir /tmp/kb
  lein run -m vaelii.host.cli export  /tmp/dump                         --dir /tmp/kb
  lein run -m vaelii.host.cli repl --starter          # interactive, starter schema
  lein cli help                                      # every command and what it takes

`help` is a word rather than only a flag because Leiningen answers `lein cli --help`
itself, printing the alias expansion — the flag never reaches this namespace through
the alias, though it does through the full `lein run -m vaelii.host.cli --help`.

**Backend.**  `--dir <path>` uses the durable `:disk-log` backend (recovered on open, so
a fact asserted in one invocation is there in the next); with no `--dir` the KB is
in-memory and lives only for the process — useful for `repl` or a single compound
session, pointless across one-shot commands.  `--starter` loads the shipped schema
(types, contexts, relation rules) so you can explore the ontology.  `--strength
monotonic` marks an `assert` or `assert-rule` known-true.  `export` takes `--variant
records|records+index` and `--compression gzip|xz|none`.

**A flag belongs to the commands that read it** (`command-flags`), and one carried by
a command that does not is refused rather than dropped — those three are the driver's
and go anywhere, the rest do not.

**One writer.**  A `--dir` KB takes the single-writer file lock (docs/storage.md), so
the CLI and a daemon cannot own the same directory at once — by design.
raw docstring

vaelii.host.client

A thin EDN-over-HTTP client for the vaelii daemon (vaelii.host.serve). Runs no engine: it POSTs {:op :args} and reads the result back, over JDK java.net.http (no dependency — JDK 21 ships it).

Every call threads an explicit connection handle as its first argument — (query conn '(dog ?x) 'Ctx) — the network mirror of vaelii.core's explicit-kb API. A conn from client holds a reusable HttpClient; no socket opens until a call. A daemon reply of {:ok false} becomes an ex-info carrying the daemon's :error and :type, so a remote naming/disjointness refusal surfaces like a local one.

The bearer token rides on the request the daemon requires it on: the conn carries it (VAELII_API_TOKEN unless :token says otherwise) and every call sets one more header on the builder it was already using. No dependency, no client state, and the conn is still a map you can read.

One wrapper per op, and they are generated (vaelii.regen-client, lein regen-client). The daemon's op table is the single source — an op is a vaelii.core fn with the KB supplied — so a wrapper here is that fn's own spelling, bare or !-marked exactly as vaelii.core spells it, at its own arities with kb replaced by conn. It is generated at build time rather than macroexpanded from serve/ops, because requiring the table would pull the engine, jetty and reitit onto the classpath of a namespace whose whole point is not needing them. client_surface_test compares this file against what the generator would write now, so an op added to the daemon fails the suite until the wrapper is written.

A thin EDN-over-HTTP client for the vaelii daemon (`vaelii.host.serve`).  Runs no
engine: it POSTs `{:op :args}` and reads the result back, over JDK `java.net.http`
(no dependency — JDK 21 ships it).

Every call threads an **explicit connection handle** as its first argument —
`(query conn '(dog ?x) 'Ctx)` — the network mirror of `vaelii.core`'s explicit-`kb`
API.  A `conn` from `client` holds a reusable `HttpClient`; no socket opens until a
call.  A daemon reply of `{:ok false}` becomes an `ex-info` carrying the daemon's
`:error` and `:type`, so a remote naming/disjointness refusal surfaces like a local
one.

**The bearer token rides on the request the daemon requires it on**: the `conn`
carries it (`VAELII_API_TOKEN` unless `:token` says otherwise) and every call sets one
more header on the builder it was already using.  No dependency, no client state, and
the `conn` is still a map you can read.

**One wrapper per op, and they are generated** (`vaelii.regen-client`, `lein
regen-client`).  The daemon's op table is the single source — an op is a `vaelii.core`
fn with the KB supplied — so a wrapper here is that fn's own spelling, bare or
`!`-marked exactly as `vaelii.core` spells it, at its own arities with `kb` replaced by
`conn`.  It is generated at *build* time rather than macroexpanded from `serve/ops`,
because requiring the table would pull the engine, jetty and reitit onto the classpath
of a namespace whose whole point is not needing them.  `client_surface_test` compares
this file against what the generator would write now, so an op added to the daemon
fails the suite until the wrapper is written.
raw docstring

vaelii.host.core-context

The CxCore ontology — Vaelii's vocabulary context. It defines and documents the core predicates the engine interprets, as sentexes in CxCore: the special-predicate surface (types/contexts, arg, disjoint, the set/*Rule wrappers, the predicate metadata, negation, ist, the evaluables) and the predicate meta-ontology. Documentation rides on comment sentexes, (comment <term> "...") — ordinary sentexes (stored, indexed, queryable) — so the KB documents itself in its own representation.

The content is a KB file, resources/kb/CxCore.txt (read by vaelii.host.seed); this namespace loads it and reads the docs back.

CxCore is the spindle head: the root every context sees, and the only layer a core-only KB has. The layers below it — the definitional upper contexts (between Core and Universe) and the theory middle contexts (between Universe and Well) — are the starter's, not the core KB's, and each wires itself into the spindle in its own KB file (see vaelii.host.starter).

The CxCore ontology — Vaelii's vocabulary context.  It defines and
documents the core predicates the engine interprets, as sentexes in CxCore:
the special-predicate surface (types/contexts, arg, disjoint, the `set/*Rule`
wrappers, the predicate metadata, negation, `ist`, the evaluables) and the
predicate meta-ontology.  Documentation rides on `comment` sentexes,
`(comment <term> "...")` — ordinary sentexes (stored, indexed, queryable) — so the
KB documents itself in its own representation.

The content is a KB file, `resources/kb/CxCore.txt` (read by
vaelii.host.seed); this namespace loads it and reads the docs back.

CxCore is the spindle **head**: the root every context sees, and the only
layer a core-only KB has.  The layers below it — the definitional `upper`
contexts (between Core and Universe) and the theory `middle` contexts (between
Universe and Well) — are the starter's, not the core KB's, and each wires itself
into the spindle in its own KB file (see vaelii.host.starter).
raw docstring

vaelii.host.examples

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.
raw docstring

vaelii.host.gloss

A sentence in English, composed from what the KB already says about its own vocabulary rather than generated.

The read path is the one with no verifier. Nothing in the engine can say that an English sentence describing (genl penguin bird) is wrong, so a fluent gloss is a way to teach a reader something false through their only window onto the formal content — which makes reading the more dangerous direction here, not the safer one. The defence is to not write prose at all where the KB has already written it.

The comment is the template

The vocabulary documents itself: every shipped predicate carries a comment sentex, and those comments are written in a shape that is already a template —

(comment eats "(eats ?animal ?food) means that ?animal takes ?food as nourishment. …")
(comment genl "(genl ?subtype ?supertype) means that every ?subtype is a ?supertype. …")

a signature naming the argument positions with variables, then a clause saying what the predicate means in those names. So glossing (eats Muffet kibble) is not a generation problem: read eats's comment, take its first clause, substitute the actual arguments for the signature's variables.

The variables are why it reads: a parameter spelled ?animal cannot be mistaken for an individual the way Animal can, and because the name carries the sort, the clause after it needs no sortal noun to lean on — so what substitutes is the sentence a reader wants rather than one with place Paris in it. Everything past that first clause is documentation for a reader, not template: how the predicate is used, what it is not, and what the KB does with it.

A signature written with plain capitalized words and a colon — (eats Animal Food): Animal eats Food — is read the same way, since an imported vocabulary spells its own comments and they are not ours to rewrite.

This is why the composer is a lookup and a substitution rather than a table of hand-written patterns: adding a predicate with a documented signature gives it a gloss for free, and a comment edited to say something else changes the gloss with it. Of the 328 shipped comments, 210 carry a signature; the 118 that do not are nouns — 100 types and 18 individuals (the units, the dimensions, the three signs) — which need none, because a type gloss is "X is a dog" and the comment is the apposition after it.

What the composition rate does not measure is whether a gloss is worth reading. It earns its place where the predicate name is opaque — genl glossed as "Every dog is an animal" teaches a reader what genl means — and adds nothing where the predicate is already an English verb.

What it will not do

A term with no comment degrades to naming the term. It does not invent a description, because an invented description is exactly the failure this exists to prevent, and a reader who sees the bare name has lost nothing they were entitled to. Every result carries :source saying which it got:

:composed every literal came from a comment :partial some did; the rest are named :named nothing to compose from — the terms, in a frame :generated a model wrote it (gloss never does this; see with-model)

The formal sentence is never replaced by the gloss — that is the caller's contract, and docs/web.md states it for the browser.

A sentence in English, **composed** from what the KB already says about its own
vocabulary rather than generated.

The read path is the one with no verifier.  Nothing in the engine can say that an
English sentence describing `(genl penguin bird)` is wrong, so a fluent gloss is a way
to teach a reader something false through their only window onto the formal content —
which makes reading the more dangerous direction here, not the safer one.  The defence
is to not write prose at all where the KB has already written it.

## The comment is the template

The vocabulary documents itself: every shipped predicate carries a `comment` sentex,
and those comments are written in a shape that is already a template —

    (comment eats "(eats ?animal ?food) means that ?animal takes ?food as nourishment. …")
    (comment genl "(genl ?subtype ?supertype) means that every ?subtype is a ?supertype. …")

a **signature** naming the argument positions with variables, then a clause saying what
the predicate means *in those names*.  So glossing `(eats Muffet kibble)` is not a
generation problem: read `eats`'s comment, take its first clause, substitute the actual
arguments for the signature's variables.

The variables are why it reads: a parameter spelled `?animal` cannot be mistaken for an
individual the way `Animal` can, and because the *name* carries the sort, the clause
after it needs no sortal noun to lean on — so what substitutes is the sentence a reader
wants rather than one with `place Paris` in it.  Everything past that first clause is
documentation for a reader, not template: how the predicate is used, what it is not,
and what the KB does with it.

A signature written with plain capitalized words and a colon — `(eats Animal Food):
Animal eats Food` — is read the same way, since an imported vocabulary spells its own
comments and they are not ours to rewrite.

This is why the composer is a lookup and a substitution rather than a table of
hand-written patterns: adding a predicate with a documented signature gives it a gloss
for free, and a comment edited to say something else changes the gloss with it.  Of the
328 shipped comments, 210 carry a signature; the 118 that do not are nouns — 100 types
and 18 individuals (the units, the dimensions, the three signs) — which need none,
because a type gloss is "X is a dog" and the comment is the apposition after it.

What the composition rate does **not** measure is whether a gloss is worth reading.  It
earns its place where the predicate name is opaque — `genl` glossed as "Every dog is an
animal" teaches a reader what `genl` means — and adds nothing where the predicate is
already an English verb.

## What it will not do

A term with no comment **degrades to naming the term**.  It does not invent a
description, because an invented description is exactly the failure this exists to
prevent, and a reader who sees the bare name has lost nothing they were entitled to.
Every result carries `:source` saying which it got:

  :composed   every literal came from a comment
  :partial    some did; the rest are named
  :named      nothing to compose from — the terms, in a frame
  :generated  a model wrote it (`gloss` never does this; see `with-model`)

The formal sentence is never replaced by the gloss — that is the caller's contract, and
`docs/web.md` states it for the browser.
raw docstring

vaelii.host.guard

The HTTP guards both servers hold to — vaelii.host.web (the browser) and vaelii.host.serve (the daemon).

The browser authenticates nobody and the daemon only when a token is set (api-token), and both bind loopback for that reason. Loopback is what makes the checks here necessary rather than sufficient: a browser running on the same machine is a local client, so "only this machine may reach it" does not mean "only this machine's owner may drive it". Two attacks follow from that, and each guard below closes one.

Cross-site request forgery. Any page the operator visits can fetch a loopback URL. same-origin? rejects the write whenever the browser stamps Origin. edn-body? closes the case where it does not: application/edn is not a CORS-simple content type, so a browser must preflight it, and a server answering no CORS headers fails that preflight before the request is ever sent.

DNS rebinding. same-origin? compares Origin against the request's own Host, so an attacker controlling both — a domain that re-resolves to 127.0.0.1 once the page is loaded — satisfies it. host-allowed? is the check that does not fold, because the Host header must then name the interface the server was actually started on.

The HTTP guards both servers hold to — `vaelii.host.web` (the browser) and
`vaelii.host.serve` (the daemon).

The browser authenticates nobody and the daemon only when a token is set
(`api-token`), and both bind loopback for that reason.  Loopback is what makes the
checks here necessary rather than sufficient: a browser running on the same machine
*is* a local client, so "only this machine may reach it" does not mean "only this
machine's owner may drive it".  Two attacks follow from that, and each guard below
closes one.

**Cross-site request forgery.**  Any page the operator visits can `fetch` a loopback
URL.  `same-origin?` rejects the write whenever the browser stamps `Origin`.
`edn-body?` closes the case where it does not: `application/edn` is not a
CORS-*simple* content type, so a browser must preflight it, and a server answering
no CORS headers fails that preflight before the request is ever sent.

**DNS rebinding.**  `same-origin?` compares `Origin` against the request's own
`Host`, so an attacker controlling both — a domain that re-resolves to 127.0.0.1
once the page is loaded — satisfies it.  `host-allowed?` is the check that does not
fold, because the `Host` header must then name the interface the server was actually
started on.
raw docstring

vaelii.host.io.generate

Synthesize a knowledge base of a chosen shape.

The two other kinds of KB are given: a shipped ontology (vaelii.host.starter) is fixed content, and an imported corpus (vaelii.impl.io.import, or a translated one) is whatever the source says. Neither lets you ask what happens at ten times the rules, and that is the question a scale or behaviour measurement is made of. So this namespace generates a KB from a handful of numbers — how many types, individuals, predicates, facts and rules, how the rules split forward/backward, how many of them are defeasible — and each number is a knob the browser renders as a slider (knobs).

Two properties make a generated KB usable as a measurement rather than as noise:

  • Deterministic. Each of plan's three draw streams owns a java.util.Random seeded from the plan seed and its own constant (stream-seeds), so the same parameters give the same KB whichever order a reader realizes the streams in — a shape can be reproduced from the numbers alone, and a run compared against a rerun.
  • Stratified. Predicates are split into layers: facts populate layer 0, and a rule concluding a layer-k predicate draws its antecedents only from layers below k. The rule set is therefore acyclic, so forward chaining cascades base → derived → further-derived and terminates, instead of the runaway recursion a rule set wired at random produces. Individuals and predicates are Zipf-sampled, so the corpus has hot terms and a long tail like a real one rather than a uniform smear.

plan is pure — the whole KB as data, nothing asserted. load-into asserts it, reporting progress through an optional :on-progress callback (which may throw to cancel the load, the flag vaelii.host.catalog cancels on).

Synthesize a knowledge base of a chosen **shape**.

The two other kinds of KB are given: a shipped ontology (`vaelii.host.starter`) is
fixed content, and an imported corpus (`vaelii.impl.io.import`, or a translated one)
is whatever the source says.  Neither lets you ask *what happens at ten times the
rules*, and that is the question a scale or behaviour measurement is made of.  So this
namespace generates a KB from a handful of numbers — how many types, individuals,
predicates, facts and rules, how the rules split forward/backward, how many of them are
defeasible — and each number is a knob the browser renders as a slider (`knobs`).

Two properties make a generated KB usable as a measurement rather than as noise:

* **Deterministic.**  Each of `plan`'s three draw streams owns a `java.util.Random`
  seeded from the plan seed and its own constant (`stream-seeds`), so the same
  parameters give the same KB whichever order a reader realizes the streams in — a
  shape can be reproduced from the numbers alone, and a run compared against a rerun.
* **Stratified.**  Predicates are split into layers: facts populate layer 0, and a rule
  concluding a layer-k predicate draws its antecedents only from layers below k.  The
  rule set is therefore acyclic, so forward chaining cascades base → derived →
  further-derived and terminates, instead of the runaway recursion a rule set wired at
  random produces.  Individuals and predicates are Zipf-sampled, so the corpus has hot
  terms and a long tail like a real one rather than a uniform smear.

`plan` is pure — the whole KB as data, nothing asserted.  `load-into` asserts it,
reporting progress through an optional `:on-progress` callback (which may throw to
cancel the load, the flag `vaelii.host.catalog` cancels on).
raw docstring

vaelii.host.jobs

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 view 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 honest middle: 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 never blocks a write to another.

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. 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 view 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 honest middle: `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 never blocks a write to another.

**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.  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.
raw docstring

vaelii.host.llm.anthropic

The real backend: the Anthropic Messages API over raw HTTP.

There is no official Anthropic SDK for Clojure, so raw HTTP is the supported path — and it adds no work here, because the repo already carries both halves: cheshire for JSON and JDK java.net.http, which vaelii.host.client already speaks to the vaelii daemon. This namespace mirrors that one: an explicit connection handle, no global state, no new dependency.

Reached only when a caller installs it (vaelii.host.llm.stub is the default), so a build with no credential and no network never loads a socket.

Request shape notes, because several of them are required:

  • temperature / top_p / top_k are rejected on this model family — they are never sent, and steering happens in the prompt instead.
  • Thinking is on by default and takes no token budget; depth is set with output_config.effort. :thinking-display "summarized" opts into a readable summary (the default omits the text).
  • A refusal is HTTP 200 with stop_reason: "refusal" and empty or partial content, so parse-response never fabricates a text block and the session loop branches on :stop-reason before touching :content.
  • The generated system prompt is a large stable prefix, so its last block carries a cache_control breakpoint; the user's turn sits after it. The minimum cacheable prefix on claude-opus-5 is 512 tokens — a short prompt simply will not cache, with no error.
  • fallbacks is sent by default so a policy decline is re-served rather than returned as a dead turn. It rides a beta header; pass {:fallbacks nil} to drop both if the org has not enabled it.

Credentials are resolved from the environment, never hardcoded and never logged — see credentials.

The real backend: the Anthropic Messages API over raw HTTP.

There is no official Anthropic SDK for Clojure, so raw HTTP is the supported path —
and it adds no work here, because the repo already carries both halves: `cheshire`
for JSON and JDK `java.net.http`, which `vaelii.host.client` already speaks to the
vaelii daemon.  This namespace mirrors that one: an explicit connection handle, no
global state, **no new dependency**.

Reached only when a caller installs it (`vaelii.host.llm.stub` is the default), so a
build with no credential and no network never loads a socket.

Request shape notes, because several of them are required:

* **`temperature` / `top_p` / `top_k` are rejected** on this model family — they are
  never sent, and steering happens in the prompt instead.
* **Thinking is on by default** and takes no token budget; depth is set with
  `output_config.effort`.  `:thinking-display "summarized"` opts into a readable
  summary (the default omits the text).
* A **refusal is HTTP 200** with `stop_reason: "refusal"` and empty or partial
  content, so `parse-response` never fabricates a text block and the session loop
  branches on `:stop-reason` before touching `:content`.
* The generated system prompt is a large stable prefix, so its last block carries a
  `cache_control` breakpoint; the user's turn sits after it.  The minimum cacheable
  prefix on `claude-opus-5` is 512 tokens — a short prompt simply will not cache,
  with no error.
* `fallbacks` is sent by default so a policy decline is re-served rather than
  returned as a dead turn.  It rides a beta header; pass `{:fallbacks nil}` to drop
  both if the org has not enabled it.

**Credentials are resolved from the environment, never hardcoded and never logged**
— see `credentials`.
raw docstring

vaelii.host.llm.correct

Type-level corrections: a proposal that says the right thing in the wrong shape.

Every model asked about a type writes facts about the type symbol(eats penguin fish), (mortal penguin) — where the KB's idiom quantifies over the type's instances. Measured across eight models on the shipped schema this is the dominant remaining error class, and nothing else catches it: naming/problems passes it (the names are all well-formed), wff passes it, the arg constraints pass it (open-world — a type symbol carries no type membership, and an untyped argument cannot violate), and it stores. The claim is usually right; only its shape is wrong.

So this rewrites rather than rejects. Each correction carries the sentence it came from, the one to store instead, the alternatives where more than one shape is defensible, and why — because for the most common case the choice between shapes is a semantic judgement no engine can make for the author:

claimshapeinferenceexception possible?
definitional — what a penguin is(genl penguin mortal)free, off the cached closureno
defeasible — what a penguin usually does(set/defaultRule (implies (penguin ?x) (mortal ?x)))forward chainingyes, via exceptWhen

The defeasible shape is the default, on asymmetric risk. A defeasible claim that should have been definitional costs only the chaining it did not need. A definitional claim that should have been defeasible cannot take an exceptWhen at all — a genl edge admits no exception — so the only repair is retracting it and rebuilding the closure. Guessing wrong in that direction is the expensive one, so it is not guessed.

Nothing here mutates: a correction is a proposal about a proposal. What to do with it — show both shapes, show only the rewrite, let the author edit it — is the caller's.

Type-level corrections: a proposal that says the right thing in the wrong shape.

Every model asked about a *type* writes facts **about the type symbol** —
`(eats penguin fish)`, `(mortal penguin)` — where the KB's idiom quantifies over the
type's instances.  Measured across eight models on the shipped schema this is the
dominant remaining error class, and nothing else catches it: `naming/problems` passes
it (the names are all well-formed), `wff` passes it, the arg constraints pass it
(open-world — a type symbol carries no type membership, and an untyped argument cannot
violate), and it stores.  The claim is *usually right*; only its shape is wrong.

So this rewrites rather than rejects.  Each correction carries the sentence it came
from, the one to store instead, the **alternatives** where more than one shape is
defensible, and why — because for the most common case the choice between shapes is a
semantic judgement no engine can make for the author:

| claim | shape | inference | exception possible? |
|---|---|---|---|
| definitional — what a penguin *is* | `(genl penguin mortal)` | free, off the cached closure | no |
| defeasible — what a penguin *usually does* | `(set/defaultRule (implies (penguin ?x) (mortal ?x)))` | forward chaining | yes, via `exceptWhen` |

**The defeasible shape is the default, on asymmetric risk.**  A defeasible claim that
should have been definitional costs only the chaining it did not need.  A definitional
claim that should have been defeasible cannot take an `exceptWhen` at all — a `genl`
edge admits no exception — so the only repair is retracting it and rebuilding the
closure.  Guessing wrong in that direction is the expensive one, so it is not guessed.

Nothing here mutates: a correction is a proposal about a proposal.  What to do with it
— show both shapes, show only the rewrite, let the author edit it — is the caller's.
raw docstring

vaelii.host.llm.http

What every HTTP provider does that is not its wire format — the read deadline, the JSON-body refusal, and the excerpt both of them carry.

A provider namespace (vaelii.host.llm.ollama, vaelii.host.llm.anthropic) is two things at once: an encoding of one vendor's messages, tools and stop reasons, and a transport that posts bytes and reads a body under a deadline. The first is the whole reason each exists separately and is not shared. The second was written out twice, identically bar the vendor's name inside a string — and a deadline policy that lives in two places is one that gets fixed in one.

Each provider passes an endpoint descriptor, {:label :slug}: :label is how the far end is named in a message a reader sees ("the Ollama host", "the Anthropic API"), :slug how it is named in a thread title. That pair is the entire difference between the two copies this replaces.

What every HTTP provider does that is not its wire format — the read deadline, the
JSON-body refusal, and the excerpt both of them carry.

A provider namespace (`vaelii.host.llm.ollama`, `vaelii.host.llm.anthropic`) is two
things at once: an *encoding* of one vendor's messages, tools and stop reasons, and a
*transport* that posts bytes and reads a body under a deadline.  The first is the whole
reason each exists separately and is not shared.  The second was written out twice,
identically bar the vendor's name inside a string — and a deadline policy that lives in
two places is one that gets fixed in one.

Each provider passes an **endpoint** descriptor, `{:label :slug}`: `:label` is how the
far end is named in a message a reader sees ("the Ollama host", "the Anthropic
API"), `:slug` how it is named in a thread title.  That pair is the entire difference
between the two copies this replaces.
raw docstring

vaelii.host.llm.inventory

The KB's own vocabulary, put in front of the model — and the flag for when the model invents vocabulary anyway.

Measured failure mode. Asked to write type-level common sense about a term, every local model that writes usable s-expressions at all encodes structure into the predicate name rather than into arguments:

(implies (penguin ?x) (lives_in_antarctica ?x))
(implies (penguin ?x) (capable_of_swimming ?x))
(implies (penguin ?x) (thermoregulates_via_blubber_and_feathers ?x))

Each is admissible, novel, and useless: a one-off unary predicate joins no rule and matches no other sentence. (livesIn ?x Antarctica) is the same claim in vocabulary the KB can reason with. Swapping models does not fix it — the strongest model produces the most of it, because it produces the most output.

The check chain cannot catch it. A unary snake_case functor is a well-formed type name by the naming invariants, so (has_black_and_white_feathers ?x) passes naming, groundness, well-formedness, arg, disjointness and functionality alike — and it should, since coining a type is a legitimate thing to do. So there are exactly two guards, and they are both here:

  1. Preventioninventory / render put the relevant available vocabulary in the prompt with each predicate's arity and argument types, and the prompt tells the model to reuse it. A model that can see livesIn with args 1:animal 2:place writes (livesIn ?x Antarctica).
  2. Detectioncoined reports every functor in a proposal the KB has never seen, with its arity and naming role, plus a reuse-versus-coin count over the proposal's literals. It reports, never rejects: a reviewer decides, because a genuinely new type is how an ontology grows.

Selection is by relevance, bounded by tokens. Nothing enumerates the KB: the inventory is seeded from the page's term, walks its genl neighbourhood (supertypes nearest first, subtypes, siblings), and takes the predicates that neighbourhood licenses — the arg-declared ones whose argument types the term satisfies, plus the ones already used in facts with those terms. Every read is pinned by a term (a cached closure lookup, an arg query on a fixed argument, a bounded argument-root walk), so the cost tracks the neighbourhood rather than the knowledge base.

The KB's own vocabulary, put in front of the model — and the flag for when the model
invents vocabulary anyway.

**Measured failure mode.**  Asked to write type-level common sense about a term, every
local model that writes usable s-expressions at all encodes structure into the
*predicate name* rather than into arguments:

    (implies (penguin ?x) (lives_in_antarctica ?x))
    (implies (penguin ?x) (capable_of_swimming ?x))
    (implies (penguin ?x) (thermoregulates_via_blubber_and_feathers ?x))

Each is admissible, novel, and useless: a one-off unary predicate joins no rule and
matches no other sentence.  `(livesIn ?x Antarctica)` is the same claim in vocabulary
the KB can reason with.  Swapping models does not fix it — the strongest model
produces the *most* of it, because it produces the most output.

**The check chain cannot catch it.**  A unary snake_case functor is a well-formed type
name by the naming invariants, so `(has_black_and_white_feathers ?x)` passes naming,
groundness, well-formedness, arg, disjointness and functionality alike — and it
should, since coining a type is a legitimate thing to do.  So there are exactly two
guards, and they are both here:

1. **Prevention** — `inventory` / `render` put the *relevant available* vocabulary in
   the prompt with each predicate's arity and argument types, and the prompt tells the
   model to reuse it.  A model that can see `livesIn` with `args 1:animal 2:place`
   writes `(livesIn ?x Antarctica)`.
2. **Detection** — `coined` reports every functor in a proposal the KB has never seen,
   with its arity and naming role, plus a reuse-versus-coin count over the proposal's
   literals.  It **reports, never rejects**: a reviewer decides, because a genuinely
   new type is how an ontology grows.

**Selection is by relevance, bounded by tokens.**  Nothing enumerates the KB: the
inventory is seeded from the page's term, walks its `genl` neighbourhood (supertypes
nearest first, subtypes, siblings), and takes the predicates that neighbourhood
*licenses* — the `arg`-declared ones whose argument types the term satisfies, plus
the ones already used in facts with those terms.  Every read is pinned by a term
(a cached closure lookup, an `arg` query on a fixed argument, a bounded argument-root
walk), so the cost tracks the neighbourhood rather than the knowledge base.
raw docstring

vaelii.host.llm.ollama

A local backend: Ollama's chat API over raw HTTP.

Same shape as vaelii.host.llm.anthropic — the neutral request/response maps of vaelii.host.llm.protocol, an explicit connection handle, no global state, no new dependency (cheshire for JSON, JDK java.net.http). What differs is everything the transport does:

  • No credential. Ollama serves on a host, not behind an API key, so available? is a reachability probe rather than a credential lookup. That is what makes this backend testable end to end.
  • The context window is the caller's to setoptions.num_ctx, per request. Ollama silently truncates a prompt longer than it, which would quietly drop the user's selection, so the caller sizes the prompt against num-ctx before sending (vaelii.host.llm.selection/budget-problem); nothing here truncates.
  • Constrained decoding instead of tool calls. :format carries a JSON schema that the sampler is restricted to, so a model with no tools capability still answers in an exact shape. capabilities reads what a model can actually do, and supports-tools? is the gate — sending 88 tool schemas to a completion-only model spends the whole window on something it will never emit.
  • Streaming is newline-delimited JSON, not SSE: one object per token-ish chunk, the last carrying done: true and the run's counts.

Counts come back on every response — prompt_eval_count is the measured prompt size, which is what a budget is checked against after the fact.

A local backend: Ollama's chat API over raw HTTP.

Same shape as `vaelii.host.llm.anthropic` — the neutral request/response maps of
`vaelii.host.llm.protocol`, an explicit connection handle, no global state, **no new
dependency** (`cheshire` for JSON, JDK `java.net.http`).  What differs is everything
the transport does:

* **No credential.**  Ollama serves on a host, not behind an API key, so
  `available?` is a reachability probe rather than a credential lookup.  That is what
  makes this backend testable end to end.
* **The context window is the caller's to set** — `options.num_ctx`, per request.
  Ollama **silently truncates** a prompt longer than it, which would quietly drop the
  user's selection, so the caller sizes the prompt against `num-ctx` *before* sending
  (`vaelii.host.llm.selection/budget-problem`); nothing here truncates.
* **Constrained decoding instead of tool calls.**  `:format` carries a JSON schema
  that the sampler is restricted to, so a model with no `tools` capability still
  answers in an exact shape.  `capabilities` reads what a model can actually do, and
  `supports-tools?` is the gate — sending 88 tool schemas to a completion-only model
  spends the whole window on something it will never emit.
* **Streaming is newline-delimited JSON**, not SSE: one object per token-ish chunk,
  the last carrying `done: true` and the run's counts.

Counts come back on every response — `prompt_eval_count` is the **measured** prompt
size, which is what a budget is checked against after the fact.
raw docstring

vaelii.host.llm.oracle

An outside judge over what the knowledge base concluded: every claim glossed into one English line, handed to a model, and answered agree / disagree / unsure.

This is the other direction, and the trust runs the other way

vaelii.host.llm.text reads English into the KB, where the danger is a model writing something false into the store and the defence is a reviewer between the two. Here the KB is the one making claims and the model is the one being asked, so nothing a model says can reach the store: this namespace calls no writer, and a verdict is a line in a report. A disagreement is a finding for a person to read, never a retraction, never a defeat class, and never a test failure that edits the KB to make the number go up.

That is the whole of why the judge is worth having. The engine can check that a conclusion follows and that a sentence is well formed; nothing in it can check that the knowledge is true, and a KB full of well-formed nonsense passes every gate this repo has. An outside reader is the only instrument for that, and a model is a reader who will do it for two hundred claims without getting bored.

The claim is glossed, and a claim the KB cannot gloss is not sent

A model handed (genl penguin bird) is judging our notation. vaelii.host.gloss composes the English from the KB's own comments — the vocabulary documents itself, and the first clause of each comment is already a template — so what the judge sees is the knowledge base's sentence rather than a paraphrase somebody wrote for the prompt. A sentence the KB documents nothing about glosses to :named, which is barely more than the s-expression, so it is left out and counted as skipped: an unanswerable question dressed up as a low score measures the prompt and not the KB.

A derived claim is shown its situation, and never its rule

Muffet is awake is not judgeable on its own — nobody knows Muffet. Given that Muffet is a dog, Muffet is awake is: it is the everyday question of whether that is a reasonable thing to say about a dog you have just been told about. So a derived claim carries the facts its justification rests on, glossed the same way.

It does not carry the rule, and that omission is the design. Show the rule and the question becomes does this follow, which is validity — the one thing the engine already guarantees and the one thing an outside judge is not needed for.

Three verdicts, because two would make the number meaningless

Most of this KB is defaults, and a default is not a universal. A judge forced to answer yes or no about an animal is awake will pick one and the disagreement rate will measure the coin. unsure is where a claim that depends on particulars nobody supplied belongs, and the counts are reported apart so a reader can see how much of the answer was a shrug.

What the rate is not is an accuracy: a careful judge marking a default false is telling you the default has exceptions, which the KB already knows and stores as a default for exactly that reason. So each disagreement carries the claim's strength, and the disagreements — not the rate — are the output.

An outside judge over what the knowledge base concluded: every claim glossed into one
English line, handed to a model, and answered *agree / disagree / unsure*.

## This is the other direction, and the trust runs the other way

`vaelii.host.llm.text` reads English **into** the KB, where the danger is a model
writing something false into the store and the defence is a reviewer between the two.
Here the KB is the one making claims and the model is the one being asked, so nothing
a model says can reach the store: this namespace calls no writer, and a verdict is a
line in a report.  A disagreement is a **finding for a person to read**, never a
retraction, never a defeat class, and never a test failure that edits the KB to make
the number go up.

That is the whole of why the judge is worth having.  The engine can check that a
conclusion follows and that a sentence is well formed; nothing in it can check that
the knowledge is *true*, and a KB full of well-formed nonsense passes every gate this
repo has.  An outside reader is the only instrument for that, and a model is a reader
who will do it for two hundred claims without getting bored.

## The claim is glossed, and a claim the KB cannot gloss is not sent

A model handed `(genl penguin bird)` is judging our notation.  `vaelii.host.gloss`
composes the English from the KB's **own** comments — the vocabulary documents itself,
and the first clause of each comment is already a template — so what the judge sees is
the knowledge base's sentence rather than a paraphrase somebody wrote for the prompt.
A sentence the KB documents nothing about glosses to `:named`, which is barely more
than the s-expression, so it is left out and counted as skipped: an unanswerable
question dressed up as a low score measures the prompt and not the KB.

## A derived claim is shown its situation, and never its rule

*Muffet is awake* is not judgeable on its own — nobody knows Muffet.  *Given that Muffet is
a dog, Muffet is awake* is: it is the everyday question of whether that is a reasonable
thing to say about a dog you have just been told about.  So a derived claim carries
the facts its justification rests on, glossed the same way.

It does **not** carry the rule, and that omission is the design.  Show the rule and
the question becomes *does this follow*, which is validity — the one thing the engine
already guarantees and the one thing an outside judge is not needed for.

## Three verdicts, because two would make the number meaningless

Most of this KB is defaults, and a default is not a universal.  A judge forced to
answer yes or no about *an animal is awake* will pick one and the disagreement rate
will measure the coin.  `unsure` is where a claim that depends on particulars nobody
supplied belongs, and the counts are reported apart so a reader can see how much of
the answer was a shrug.

What the rate is **not** is an accuracy: a careful judge marking a default false is
telling you the default has exceptions, which the KB already knows and stores as a
default for exactly that reason.  So each disagreement carries the claim's strength,
and the disagreements — not the rate — are the output.
raw docstring

vaelii.host.llm.page

The page-scoped prompt: the unit of work is the term the reader is looking at.

vaelii.host.llm.selection prompts about lines the reader picked and asks for them back edited. This namespace prompts about a term page/term?q=penguin — and asks for knowledge the KB does not have yet:

  1. what the page already says about the term, as bare sentences,
  2. the vocabulary that term's genl neighbourhood licenses (vaelii.host.llm.inventory) — arity and argument types included,
  3. the reader's free-text instruction ("flesh out the capabilities of this").

Three things differ from the edit path, each because it was measured:

  • The context is dropped. A page is already about one context, so the caller supplies it and the model writes bare sentences. That removes a whole class of answer the model gets wrong for no gain — and it shortens every line it writes.
  • Decoding is constrained (output-schema, Ollama's format). On generation this rescues models that otherwise answer in markdown prose; on the edit path the same parameter silently drops lines, so the two contracts are deliberately different and are not unified.
  • The content is type-level. Common sense about a kind is a genl edge or a rule, not a fact about an individual, so the prompt asks for those shapes and shows them.

The prompt's one job beyond the shape is to stop the model coining vocabulary — see vaelii.host.llm.inventory, which is where both guards against that live.

The page-scoped prompt: **the unit of work is the term the reader is looking at.**

`vaelii.host.llm.selection` prompts about lines the reader picked and asks for them
back edited.  This namespace prompts about a *term page* — `/term?q=penguin` — and asks
for knowledge the KB does not have yet:

1. what the page already says about the term, as bare sentences,
2. the vocabulary that term's `genl` neighbourhood licenses
   (`vaelii.host.llm.inventory`) — arity and argument types included,
3. the reader's free-text instruction ("flesh out the capabilities of this").

Three things differ from the edit path, each because it was measured:

* **The context is dropped.**  A page is already about one context, so the caller
  supplies it and the model writes bare sentences.  That removes a whole class of
  answer the model gets wrong for no gain — and it shortens every line it writes.
* **Decoding is constrained** (`output-schema`, Ollama's `format`).  On generation this
  *rescues* models that otherwise answer in markdown prose; on the edit path the same
  parameter silently drops lines, so the two contracts are deliberately different and
  are not unified.
* **The content is type-level.**  Common sense about a *kind* is a `genl` edge or a
  rule, not a fact about an individual, so the prompt asks for those shapes and shows
  them.

The prompt's one job beyond the shape is to stop the model coining vocabulary — see
`vaelii.host.llm.inventory`, which is where both guards against that live.
raw docstring

vaelii.host.llm.prompt

The system prompt, generated from the live KB.

A hand-written copy of the ontology in a prompt string rots the moment someone drops a new Cx<Name>.txt into resources/kb/. So every section here is read back out of the KB it is about: the context topology from contexts / context-up, the type hierarchy from types / genls, the predicate documentation from the (comment <term> "…") sentexes the vocabulary documents itself with (vaelii.host.core-context/comment-of), the argument types from the stored arg sentexes, the disjointness from disjoint / disjoint_metatype, and the algebraic metadata from props. The naming invariants are the one static section, because they are mechanical rules rather than content.

The result is a large stable prefix: byte-identical across turns for an unchanged KB (every section is sorted, nothing carries a clock or an id), which is what prompt caching needs. The volatile part — the user's request — lives in the message turn after the cache breakpoint, never in here.

The system prompt, **generated from the live KB**.

A hand-written copy of the ontology in a prompt string rots the moment someone
drops a new `Cx<Name>.txt` into `resources/kb/`.  So every section here is read
back out of the KB it is about: the context topology from `contexts` /
`context-up`, the type hierarchy from `types` / `genls`, the predicate
documentation from the `(comment <term> "…")` sentexes the vocabulary documents
itself with (`vaelii.host.core-context/comment-of`), the argument types from the stored
`arg` sentexes, the disjointness from `disjoint` / `disjoint_metatype`, and the
algebraic metadata from `props`.  The naming invariants are the one static section,
because they are mechanical rules rather than content.

The result is a **large stable prefix**: byte-identical across turns for an
unchanged KB (every section is sorted, nothing carries a clock or an id), which is
what prompt caching needs.  The volatile part — the user's request — lives in the
message turn after the cache breakpoint, never in here.
raw docstring

vaelii.host.llm.protocol

The pluggable-model extension point: one protocol, two methods, and a provider-neutral request/response shape.

Mirrors vaelii.impl.solve/Solver — a protocol plus a deterministic stub as the default, with the real backend reached only when a caller installs it. So the suite, and a build with no API key and no network, run the whole pipeline against vaelii.host.llm.stub and never open a socket.

The shapes are the contract. A provider takes a request map and answers a response map; neither mentions HTTP, JSON, or any vendor field, so the session loop (vaelii.host.llm.session) is written once and runs against either provider.

Request:

{:model      "claude-opus-5"     ; provider-resolved when absent
 :system     [{:text "…" :cache? true} …]   ; blocks, in order
 :messages   [{:role "user"|"assistant" :content <string | [block …]}]
 :tools      [<tool schema> …]      ; vaelii.host.llm.tools/schemas
 :max-tokens 8192
 :effort     "low"|"medium"|"high"|"xhigh"|"max"}

:cache? on a system block asks the provider to mark it as a cache breakpoint; the generated system prompt is a large stable prefix and the user's turn is the volatile part after it, which is exactly the shape prompt caching wants.

Response:

{:stop-reason  "end_turn"|"tool_use"|"refusal"|"max_tokens"|…
 :stop-details {…}                ; populated only on a refusal
 :content      [{:type :text     :text "…"}
                {:type :tool-use :id "…" :name "…" :input {…}}
                {:type :thinking :text "…"}]
 :model        "…"
 :usage        {…}}

A refusal is a successful answer with no content — the caller must read :stop-reason before reading :content, so a provider never fabricates a text block to keep indexing code happy. Assistant content is echoed back verbatim in the next request's :messages, so a provider may carry vendor-specific blocks through :content untouched as long as it can read its own back.

The pluggable-model extension point: one protocol, two methods, and a provider-neutral
request/response shape.

Mirrors `vaelii.impl.solve/Solver` — a protocol plus a deterministic stub as the
default, with the real backend reached only when a caller installs it.  So the
suite, and a build with no API key and no network, run the whole pipeline against
`vaelii.host.llm.stub` and never open a socket.

**The shapes are the contract.**  A provider takes a `request` map and answers a
`response` map; neither mentions HTTP, JSON, or any vendor field, so the session
loop (`vaelii.host.llm.session`) is written once and runs against either provider.

Request:

    {:model      "claude-opus-5"     ; provider-resolved when absent
     :system     [{:text "…" :cache? true} …]   ; blocks, in order
     :messages   [{:role "user"|"assistant" :content <string | [block …]}]
     :tools      [<tool schema> …]      ; vaelii.host.llm.tools/schemas
     :max-tokens 8192
     :effort     "low"|"medium"|"high"|"xhigh"|"max"}

`:cache?` on a system block asks the provider to mark it as a cache breakpoint;
the generated system prompt is a large stable prefix and the user's turn is the
volatile part after it, which is exactly the shape prompt caching wants.

Response:

    {:stop-reason  "end_turn"|"tool_use"|"refusal"|"max_tokens"|…
     :stop-details {…}                ; populated only on a refusal
     :content      [{:type :text     :text "…"}
                    {:type :tool-use :id "…" :name "…" :input {…}}
                    {:type :thinking :text "…"}]
     :model        "…"
     :usage        {…}}

A **refusal is a successful answer with no content** — the caller must read
`:stop-reason` before reading `:content`, so a provider never fabricates a text
block to keep indexing code happy.  Assistant content is echoed back verbatim in
the next request's `:messages`, so a provider may carry vendor-specific blocks
through `:content` untouched as long as it can read its own back.
raw docstring

vaelii.host.llm.provider

Which backend a turn runs against — the selection extension point.

Stands where vaelii.impl.asp.solver stands for the ASP backends: a keyword names a backend, the backend is lazily resolved so choosing one is what loads it, and an unreachable backend falls back to the deterministic default rather than throwing. vaelii.host.llm.stub is that default, exactly as vaelii.impl.solve/local-solver is for contradictions — so a build with no credential, no Ollama and no network runs the whole pipeline and opens no socket.

Three kinds:

:stub deterministic, offline, scriptable — the default :ollama a local Ollama (vaelii.host.llm.ollama), no credential :anthropic the Messages API (vaelii.host.llm.anthropic), credential required

Select with VAELII_LLM_PROVIDER or -Dvaelii.llm.provider; a caller that already knows what it wants passes the kind (or a built provider) directly.

Resolution is lazy for a reason beyond load time: anthropic/available? may shell out to the ant CLI, and ollama/available? opens a socket. Neither should happen because a namespace was required.

Which backend a turn runs against — the selection extension point.

Stands where `vaelii.impl.asp.solver` stands for the ASP backends: a keyword names a
backend, the backend is **lazily resolved** so choosing one is what loads it, and an
unreachable backend falls back to the deterministic default rather than throwing.
`vaelii.host.llm.stub` is that default, exactly as `vaelii.impl.solve/local-solver`
is for contradictions — so a build with no credential, no Ollama and no network runs
the whole pipeline and opens no socket.

Three kinds:

  :stub       deterministic, offline, scriptable — the default
  :ollama     a local Ollama (`vaelii.host.llm.ollama`), no credential
  :anthropic  the Messages API (`vaelii.host.llm.anthropic`), credential required

Select with `VAELII_LLM_PROVIDER` or `-Dvaelii.llm.provider`; a caller that already
knows what it wants passes the kind (or a built provider) directly.

Resolution is lazy for a reason beyond load time: `anthropic/available?` may shell
out to the `ant` CLI, and `ollama/available?` opens a socket.  Neither should happen
because a namespace was required.
raw docstring

vaelii.host.llm.score

Scoring a set of candidate entries against a hand-written one.

vaelii.host.llm.text produces candidates and vaelii.core/check-edit says whether each is admissible. Neither says whether it is right, and nothing in the engine can: the whole reason the reading direction needs a reviewer is that every check passes on a well-formed translation of a claim the text did not make. So the only defensible measure is against knowledge somebody wrote by hand, and this is the arithmetic for that.

The gold set is read out of a KB, never transcribed

A second copy of the fables' sentexes in a scoring fixture would drift from the ones the suite actually loads, and a score against a stale gold set is worse than no score. So the gold is a set of handles in a loaded KB, and a candidate matches when vaelii.core/handle-of finds it under one of them.

That also means the comparison uses the engine's own canonical form rather than a reimplementation of it: a rule whose variables are named differently, whose antecedents arrive in another order, or whose symmetric arguments are the other way round is the same sentence to handle-of and is therefore the same sentence here (docs/canonicalization.md). Nothing about matching is this namespace's opinion.

Two scores, because the constants are unrecoverable

A fable introduces its characters by kind — a lion, a mouse — so the names in the formal version (LionA, MouseA) are the modeller's, and no reader of the text could produce them. A strict score therefore reads zero on stories whose structure was recovered perfectly, which measures the naming convention rather than the reading.

So score reports both, and the pair is the finding:

  • strict — the candidate matched a gold handle as written;
  • aligned — the same comparison after renaming the candidate's introduced individuals onto the gold's, one-for-one, by the types each is asserted to have (alignment). A renaming is a bijection or it is not applied, so alignment can never merge two characters into one to score better.

Nothing here writes: handle-of is find-only, and the alignment is arithmetic over sentences.

Scoring a set of candidate entries against a hand-written one.

`vaelii.host.llm.text` produces candidates and `vaelii.core/check-edit` says whether each
is *admissible*.  Neither says whether it is **right**, and nothing in the engine can:
the whole reason the reading direction needs a reviewer is that every check passes on a
well-formed translation of a claim the text did not make.  So the only defensible measure is
against knowledge somebody wrote by hand, and this is the arithmetic for that.

## The gold set is read out of a KB, never transcribed

A second copy of the fables' sentexes in a scoring fixture would drift from the ones the
suite actually loads, and a score against a stale gold set is worse than no score.  So
the gold is a set of **handles** in a loaded KB, and a candidate matches when
`vaelii.core/handle-of` finds it under one of them.

That also means the comparison uses the engine's **own** canonical form rather than a
reimplementation of it: a rule whose variables are named differently, whose antecedents
arrive in another order, or whose symmetric arguments are the other way round is the same
sentence to `handle-of` and is therefore the same sentence here (docs/canonicalization.md).
Nothing about matching is this namespace's opinion.

## Two scores, because the constants are unrecoverable

A fable introduces its characters by kind — *a lion*, *a mouse* — so the names in the
formal version (`LionA`, `MouseA`) are the modeller's, and no reader of the text could
produce them.  A strict score therefore reads zero on stories whose structure was
recovered perfectly, which measures the naming convention rather than the reading.

So `score` reports both, and the pair is the finding:

* **strict** — the candidate matched a gold handle as written;
* **aligned** — the same comparison after renaming the candidate's *introduced*
  individuals onto the gold's, one-for-one, by the types each is asserted to have
  (`alignment`).  A renaming is a bijection or it is not applied, so alignment can never
  merge two characters into one to score better.

Nothing here writes: `handle-of` is find-only, and the alignment is arithmetic over
sentences.
raw docstring

vaelii.host.llm.selection

The selection-scoped prompt: the unit of work is a set of handles, not the KB.

vaelii.host.llm.prompt renders the whole vocabulary — every context, type and predicate — and vaelii.host.llm.tools renders every read as a tool schema. Both are fixed costs that grow with the KB, and against the schema-only starter (no individuals, no facts) they already come to ~24,000 tokens before the user has said anything — 31,818 characters of system prompt and 53,862 of tool schema as sent, at chars-per-token. A KB heading for 100M sentexes cannot pay that per request, and a model with no tools capability cannot spend half of it at all.

So this namespace prompts about what the reader selected:

  1. the selected sentexes as the editor's own [sentence context] lines,
  2. a vocabulary card computed only from the terms those lines mention — each term's comment, its arg constraints, its place in the genl hierarchy, and its metadata,
  3. the reader's instruction.

Every read is pinned by a term the selection actually contains (comment-of, an arg query on a fixed predicate, a genl closure lookup), so the prompt's size is O(selection): ten sentexes yield the same card in a KB of ten as in a KB of a hundred million. Its cost is not flat in KB size, and the difference is relatives — the card shows the first max-relatives neighbours by name, which means sorting the term's whole genls / specs closure to find them. A selection naming a type near the root of an imported ontology pays for that ontology.

The model rewrites lines; it does not write. Its answer is the edited line set, which vaelii.host.llm.session/propose-edit diffs against the selection by content to produce the {:add … :remove …} batch — the same diff the browser's editor does on Save, so an unchanged line touches nothing.

Nothing here truncates. A selection too big for the context window is a clean refusal (budget-problem), because the alternative is Ollama silently dropping the front of the reader's own selection.

The selection-scoped prompt: **the unit of work is a set of handles, not the KB.**

`vaelii.host.llm.prompt` renders the whole vocabulary — every context, type and
predicate — and `vaelii.host.llm.tools` renders every read as a tool schema.  Both
are fixed costs that grow with the KB, and against the schema-only starter (no
individuals, no facts) they already come to ~24,000 tokens before the user has said
anything — 31,818 characters of system prompt and 53,862 of tool schema as sent, at
`chars-per-token`.  A KB heading for 100M sentexes cannot pay that per request, and a model
with no `tools` capability cannot spend half of it at all.

So this namespace prompts about **what the reader selected**:

1. the selected sentexes as the editor's own `[sentence context]` lines,
2. a vocabulary card computed *only* from the terms those lines mention — each
   term's `comment`, its `arg` constraints, its place in the genl hierarchy, and
   its metadata,
3. the reader's instruction.

Every read is pinned by a term the selection actually contains (`comment-of`, an
`arg` query on a fixed predicate, a genl closure lookup), so the prompt's **size**
is O(selection): ten sentexes yield the same card in a KB of ten as in a KB of a
hundred million.  Its **cost** is not flat in KB size, and the difference is
`relatives` — the card shows the first `max-relatives` neighbours by name, which means
sorting the term's whole `genls` / `specs` closure to find them.  A selection naming a
type near the root of an imported ontology pays for that ontology.

**The model rewrites lines; it does not write.**  Its answer is the edited line set,
which `vaelii.host.llm.session/propose-edit` diffs against the selection by content
to produce the `{:add … :remove …}` batch — the same diff the browser's editor does
on Save, so an unchanged line touches nothing.

**Nothing here truncates.**  A selection too big for the context window is a clean
refusal (`budget-problem`), because the alternative is Ollama silently dropping the
front of the reader's own selection.
raw docstring

vaelii.host.llm.session

The turn loop: propose → validate → repair → an edit batch.

The model never writes. Its output is {:add [[sentence context opts?] …] :remove [handle …]} — the exact shape vaelii.core/edit! takes, and the exact shape the browser's textarea editor already produces. So a proposal lands in the existing editor as a reviewable diff: no new write path, no new trust boundary, and no way for a model turn to reach storage. Applying is a separate, explicit call (apply-proposal!), which is where the ! lives.

The well-formedness checker is the critic. check-batch is vaelii.core/check-editassert's own check chain run over each proposed entry for its answer rather than its effect, storing nothing, reporting each failure with the same :type keyword assert would have thrown (:naming, :not-ground, :not-range-restricted, :not-well-formed, :not-stratified, :arg-type, :disjoint, :functional). That is a deterministic grader rather than a model-judged one, which is what makes the repair loop terminate on a fact rather than on an opinion — and sharing the writer's own chain is what keeps the two from drifting, so the model is never graded more leniently than it will be applied.

The loop is bounded twice. :max-repairs caps how many times a rejected batch is fed back, and :max-turns caps total provider turns including tool calls, so neither a stubborn model nor a tool-calling one can spin. Running out of repairs is a reported outcome (:status :invalid with the rejections), not an exception.

Two :stop-reasons are read before the content, not one. A refusal (proto/refused?) is the well-known one; the other is max_tokens (truncated?), where the host cut the turn and what arrived is a prefix of the answer. A prefix parses, so nothing downstream can tell it from a whole answer — and where the answer is diffed against what was sent (propose-edit), absence is read as intent, so the rows the model never reached would come back as proposed retractions. Hence :status :truncated on the two paths that diff or carry a :remove, and an :answer-truncated? flag on the two additive ones, where a short answer costs assertions and proposes nothing.

The turn loop: propose → validate → repair → an edit batch.

**The model never writes.**  Its output is `{:add [[sentence context opts?] …]
:remove [handle …]}` — the exact shape `vaelii.core/edit!` takes, and the exact shape
the browser's textarea editor already produces.  So a proposal lands in the existing
editor as a reviewable diff: no new write path, no new trust boundary, and no way
for a model turn to reach storage.  Applying is a separate, explicit call
(`apply-proposal!`), which is where the `!` lives.

**The well-formedness checker is the critic.**  `check-batch` is
`vaelii.core/check-edit` — `assert`'s own check chain run over each proposed entry
for its answer rather than its effect, storing nothing, reporting each failure with
the same `:type` keyword `assert` would have thrown (`:naming`, `:not-ground`,
`:not-range-restricted`, `:not-well-formed`, `:not-stratified`, `:arg-type`,
`:disjoint`, `:functional`).  That is a deterministic grader rather than a
model-judged one, which is what makes the repair loop terminate on a fact rather
than on an opinion — and sharing the writer's own chain is what keeps the two from
drifting, so the model is never graded more leniently than it will be applied.

**The loop is bounded twice.**  `:max-repairs` caps how many times a rejected batch
is fed back, and `:max-turns` caps total provider turns including tool calls, so
neither a stubborn model nor a tool-calling one can spin.  Running out of repairs is
a reported outcome (`:status :invalid` with the rejections), not an exception.

**Two `:stop-reason`s are read before the content, not one.**  A refusal
(`proto/refused?`) is the well-known one; the other is `max_tokens` (`truncated?`),
where the host cut the turn and what arrived is a *prefix* of the answer.  A prefix
parses, so nothing downstream can tell it from a whole answer — and where the answer is
**diffed against what was sent** (`propose-edit`), absence is read as intent, so the
rows the model never reached would come back as proposed retractions.  Hence
`:status :truncated` on the two paths that diff or carry a `:remove`, and an
`:answer-truncated?` flag on the two additive ones, where a short answer costs
assertions and proposes nothing.
raw docstring

vaelii.host.llm.stub

The default provider: deterministic, offline, no credential.

Standing in the same place vaelii.impl.solve/local-solver stands — the stub that makes the LLM provider usable before (and without) a real backend. lein test runs the whole pipeline against it, so the suite needs no API key and opens no socket, and a deployment with no credential degrades to a provider that proposes nothing rather than to an exception.

Behaviour is scripted, so a test drives the session loop exactly: :script is the sequence of turns to hand back, one per complete/stream call, and :default answers every call past the end of it. Each entry is a full response map, or one of three shorthands:

"some text" a plain text answer {:batch {:add […] :remove […]}} text holding that batch in a fenced edn block {:lines [[sentence context] …]} the selection path's line set (:json? true for the JSON envelope shape instead) {:assertions [sentence …]} the page path's bare sentences, in the JSON envelope it decodes under (:lines? true for the bare-line shape a model ignoring format writes) {:candidates [[sentence seg] …]} the reading path's candidates, each naming the document sentence it came from (:untranslated, :notes) {:verdicts [[item verdict] …]} the judging path's answer, one verdict per numbered claim (true / false / unsure, optional note) {:tool "kb_sentexes_matching" :input {…}} a tool-use turn (:id optional)

With no :script the provider answers every turn with an empty batch — valid, applies to nothing, and never varies. That default is the whole-KB path's answer; on the selection path (session/propose-edit) it reads as unparseable, which is the safe outcome — the only line set meaning "change nothing" is the reader's selection itself, and a provider that never saw it cannot write one.

The default provider: deterministic, offline, no credential.

Standing in the same place `vaelii.impl.solve/local-solver` stands — the stub that
makes the LLM provider usable before (and without) a real backend.  `lein test` runs the
whole pipeline against it, so the suite needs no API key and opens no socket, and a
deployment with no credential degrades to a provider that proposes nothing rather
than to an exception.

Behaviour is **scripted**, so a test drives the session loop exactly: `:script` is
the sequence of turns to hand back, one per `complete`/`stream` call, and `:default`
answers every call past the end of it.  Each entry is a full response map, or one of
three shorthands:

  "some text"                    a plain text answer
  {:batch {:add […] :remove […]}}  text holding that batch in a fenced `edn` block
  {:lines [[sentence context] …]}  the selection path's line set (`:json? true` for
                                   the JSON envelope shape instead)
  {:assertions [sentence …]}       the page path's bare sentences, in the JSON envelope
                                   it decodes under (`:lines? true` for the bare-line
                                   shape a model ignoring `format` writes)
  {:candidates [[sentence seg] …]} the reading path's candidates, each naming the
                                   document sentence it came from (`:untranslated`,
                                   `:notes`)
  {:verdicts [[item verdict] …]}   the judging path's answer, one verdict per numbered
                                   claim (`true` / `false` / `unsure`, optional note)
  {:tool "kb_sentexes_matching" :input {…}}  a tool-use turn (`:id` optional)

With no `:script` the provider answers every turn with an empty batch — valid,
applies to nothing, and never varies.  That default is the *whole-KB* path's answer;
on the selection path (`session/propose-edit`) it reads as unparseable, which is the
safe outcome — the only line set meaning "change nothing" is the reader's selection
itself, and a provider that never saw it cannot write one.
raw docstring

vaelii.host.llm.text

The document-scoped prompt: text in, candidates out — never knowledge in.

vaelii.host.gloss composes English out of the KB and says why that direction is the dangerous one: nothing in the engine can check that a sentence means what a text said. This namespace is the other direction, and it has to answer that argument rather than ignore it, because every check the engine has passes on a correct-looking translation of the wrong claim — naming, well-formedness, argument types and disjointness all read the sentence. So what is built here is a candidate generator with a reviewer between it and the store, and every decision below follows from that:

  • a candidate is a [sentence context opts] entry, which is the shape vaelii.core/edit! already takes and the browser's editor already parses — so a proposal lands as a reviewable diff and there is no second write path;
  • a candidate carries the span of the text it came from, so an accepted sentence is auditable back to the sentence that produced it;
  • a candidate is :default, never :monotonic unless the reader says so — a translated guess asserted as known-true would defeat hand-written defaults;
  • what the pipeline could not translate is part of the answer (coverage), because a reader who is shown only the two-thirds that worked reads it as a reader that understood the document.

Where the boundary is. Nothing here is in vaelii.core, and nothing here writes. Like web / serve / llm, this is an application over the engine: the engine's contribution is the critic (check-edit), the vocabulary (the taxonomy and the declarations), the provenance side map, and the equality partition — all of them public reads that existed already. See docs/reading.md.

Resolution is the problem; parsing is not

A pipeline that coins has_black_and_white_feathers for every sentence produces fragmentation rather than knowledge, and no naming check refuses it (docs/naming.md says so in as many words). So the document's own words are resolved against the vocabulary the KB already has before a model is asked anything:

  • spellings turns each run of the document's words into the symbols a KB term could be spelled as — prepared for winter into preparedForWinter and prepared_for_winter, Muffet into Muffet — and known asks the KB which of them it has. Generating and asking runs the opposite way from inverting the KB's vocabulary into the words each term is written with, which is the one read here that would grow with the KB;
  • resolve-in walks the document longest-run-first and non-overlapping, so a compound predicate is not shredded into the words its name is made of;
  • every resolved term is the equality partition's representative, so a word spelled at a retired name resolves to the term that name was merged into.

What resolves becomes the vocabulary card (document-inventory), which is the prevention half of the fragmentation guard; the detection half is vaelii.host.llm.inventory/coined, unchanged and shared with the other three paths.

The document-scoped prompt: **text in, candidates out — never knowledge in.**

`vaelii.host.gloss` composes English *out of* the KB and says why that direction is the
dangerous one: nothing in the engine can check that a sentence means what a text said.
This namespace is the other direction, and it has to answer that argument rather than
ignore it, because every check the engine has passes on a correct-looking translation of
the wrong claim — naming, well-formedness, argument types and disjointness all read the
*sentence*.  So what is built here is a **candidate generator with a reviewer between it
and the store**, and every decision below follows from that:

* a candidate is a `[sentence context opts]` entry, which is the shape
  `vaelii.core/edit!` already takes and the browser's editor already parses — so a
  proposal lands as a reviewable diff and there is no second write path;
* a candidate carries the **span** of the text it came from, so an accepted sentence is
  auditable back to the sentence that produced it;
* a candidate is `:default`, never `:monotonic` unless the reader says so — a translated
  guess asserted as known-true would defeat hand-written defaults;
* what the pipeline **could not** translate is part of the answer (`coverage`), because a
  reader who is shown only the two-thirds that worked reads it as a reader that
  understood the document.

**Where the boundary is.**  Nothing here is in `vaelii.core`, and nothing here writes.
Like `web` / `serve` / `llm`, this is an application over the engine: the engine's
contribution is the critic (`check-edit`), the vocabulary (the taxonomy and the
declarations), the provenance side map, and the equality partition — all of them public
reads that existed already.  See docs/reading.md.

## Resolution is the problem; parsing is not

A pipeline that coins `has_black_and_white_feathers` for every sentence produces
fragmentation rather than knowledge, and no naming check refuses it (docs/naming.md says
so in as many words).  So the document's own words are resolved against the vocabulary
the KB already has **before** a model is asked anything:

* `spellings` turns each run of the document's words into the symbols a KB term could be
  spelled as — *prepared for winter* into `preparedForWinter` and `prepared_for_winter`,
  *Muffet* into `Muffet` — and `known` asks the KB which of them it has.  Generating and
  asking runs the *opposite* way from inverting the KB's vocabulary into the words each
  term is written with, which is the one read here that would grow with the KB;
* `resolve-in` walks the document longest-run-first and non-overlapping, so a compound
  predicate is not shredded into the words its name is made of;
* every resolved term is the equality partition's `representative`, so a word spelled at
  a retired name resolves to the term that name was merged into.

What resolves becomes the vocabulary card (`document-inventory`), which is the
prevention half of the fragmentation guard; the detection half is
`vaelii.host.llm.inventory/coined`, unchanged and shared with the other three paths.
raw docstring

vaelii.host.llm.tools

The model's read surface over a KB — generated, not hand-written.

vaelii.host.serve/ops is already an allowlisted, EDN-typed map of vaelii.core calls: the exact surface the browser and the daemon reach a KB through. So the tool schemas are derived from its read subset rather than transcribed, and the tool calls are dispatched back through the same table. A read added to serve/ops becomes a tool with no edit here; a read renamed there cannot rot a copy here, because there is no copy.

The model never writes. write-ops names every mutating op, and anything resolving to a ! var is treated as one whatever the table says, so the exposed set is reads only. The model's output is a proposed batch, reviewed and applied by a human (vaelii.host.llm.session) — there is no write tool and no write path.

Argument shapes come from the vaelii.core var's own :arglists (minus the leading kb) and its docstring, so a signature change is picked up on the next build. JSON carries no symbols, so a sentence / context / term argument is a string holding an EDN s-expression"(dog ?x)", "CxWell" — read back with clojure.edn/read-string (never read-string: EDN has no reader-eval, so a model's output cannot evaluate code).

The model's read surface over a KB — **generated**, not hand-written.

`vaelii.host.serve/ops` is already an allowlisted, EDN-typed map of `vaelii.core`
calls: the exact surface the browser and the daemon reach a KB through.  So the
tool schemas are derived from its **read subset** rather than transcribed, and the
tool calls are dispatched back through the same table.  A read added to `serve/ops`
becomes a tool with no edit here; a read renamed there cannot rot a copy here,
because there is no copy.

**The model never writes.**  `write-ops` names every mutating op, and anything
resolving to a `!` var is treated as one whatever the table says, so the exposed set
is reads only.  The model's *output* is a proposed batch, reviewed and applied by a
human (`vaelii.host.llm.session`) — there is no write tool and no write path.

Argument shapes come from the `vaelii.core` var's own `:arglists` (minus the leading
`kb`) and its docstring, so a signature change is picked up on the next build.  JSON
carries no symbols, so a sentence / context / term argument is a **string holding an
EDN s-expression** — `"(dog ?x)"`, `"CxWell"` — read back with
`clojure.edn/read-string` (never `read-string`: EDN has no reader-eval, so a model's
output cannot evaluate code).
raw docstring

vaelii.host.llm.verdict

What a reviewer needs to know about one proposed line, gathered in one place.

A proposal is judged on four independent axes, and they are independent in the strong sense: a line can be admissible and still wrong-shaped, refused and still worth keeping once rewritten, admissible and shaped right and still quietly fragmenting the vocabulary. Prose about a batch buries all four; a reviewer reads a gutter.

:problems what the KB itself says — vaelii.core/check-edit, typed :correction what shape it should have been in — vaelii.host.llm.correct :coined what vocabulary it invents — vaelii.host.llm.inventory/coined :confidence how sure the correction is, which is a fifth thing only in the sense that a rewrite the engine cannot decide is a decision handed back

Each already exists; what did not is one call that runs all of them over one batch and lines the answers up by entry, so a caller renders a row rather than joining three reports by index. Nothing here stores, checks a rewrite, or applies one: this is a reading of a proposal, and the proposal is still a proposal afterwards.

What a reviewer needs to know about one proposed line, gathered in one place.

A proposal is judged on **four independent axes**, and they are independent in the
strong sense: a line can be admissible and still wrong-shaped, refused and still worth
keeping once rewritten, admissible and shaped right and still quietly fragmenting the
vocabulary.  Prose about a batch buries all four; a reviewer reads a gutter.

  :problems    what the KB itself says — `vaelii.core/check-edit`, typed
  :correction  what shape it should have been in — `vaelii.host.llm.correct`
  :coined      what vocabulary it invents — `vaelii.host.llm.inventory/coined`
  :confidence  how sure the correction is, which is a fifth thing only in the sense
               that a rewrite the engine cannot decide is a decision handed back

Each already exists; what did not is one call that runs all of them over one batch and
lines the answers up **by entry**, so a caller renders a row rather than joining three
reports by index.  Nothing here stores, checks a rewrite, or applies one: this is a
reading of a proposal, and the proposal is still a proposal afterwards.
raw docstring

vaelii.host.sandbox

Somewhere safe to be wrong.

A sandbox is a scratch context of one browser session's own, hung below CxWell so it sees the whole shipped ontology 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 spindle 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.

Three 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 session id is in the context name, so two readers of one process never share one. It is minted into a cookie by wrap-session and validated on the way back in — a name is being built from it, and a name built from unvalidated client input is an injection.
  • 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.

Somewhere safe to be wrong.

A **sandbox** is a scratch context of one browser session's own, hung below
`CxWell` so it sees the whole shipped ontology 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 spindle 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.

Three 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 session id is in the context name**, so two readers of one process never share
  one.  It is minted into a cookie by `wrap-session` and validated on the way back in —
  a name is being built from it, and a name built from unvalidated client input is an
  injection.
- **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.
raw docstring

vaelii.host.seed

Ontology KB files: declarative content held as plain text on the classpath rather than as code.

A KB file is a list of ordinary vaelii sentences — one s-expression each, with ;; line comments and blank lines allowed — named for the context its sentences assert into, and grouped term-centrically: every sentence about a vocabulary term sits together, and the terms run in natural sort order. A rule is just a sentence carrying an implies / set/*Rule / exceptWhen wrapper.

The format itself — reader and writer both — is vaelii.impl.io.text, which is where its one non-sentence spelling lives ((set/monotonic S), the known-true class) and what vaelii.core/export-text! writes. What is here is the classpath side: the shallow tree under resources/kb/ and how a layer's files are discovered in it.

The files live under resources/kb/, in a shallow tree that mirrors the context spindle:

kb/CxCore.txt        the vocabulary head (see vaelii.host.core-context)
kb/upper/<C>.txt          definitional layers, between Core and Universe
kb/middle/<C>.txt         theory layers, between Universe and Well

The file name is the context; the sub-directory is the layer. Only the layer a caller names is discovered, so a sibling directory under kb/ that names no layer here is not loaded: kb/koinii/ is one, an application's own context files, which that application loads for itself. What stays in code (vaelii.host.starter) is the order the files load in and the handful of genuinely computed assertions. Sentences read with clojure.edn, so a KB file is data and can never run code.

Ontology KB files: declarative content held as **plain text on the classpath**
rather than as code.

A KB file is a list of ordinary vaelii sentences — one s-expression each, with
`;;` line comments and blank lines allowed — named for the context its sentences
assert into, and grouped **term-centrically**: every sentence about a vocabulary
term sits together, and the terms run in natural sort order.  A rule is just a
sentence carrying an `implies` / `set/*Rule` / `exceptWhen` wrapper.

**The format itself — reader and writer both — is `vaelii.impl.io.text`**, which is
where its one non-sentence spelling lives (`(set/monotonic S)`, the known-true class)
and what `vaelii.core/export-text!` writes.  What is here is the *classpath* side: the
shallow tree under `resources/kb/` and how a layer's files are discovered in it.

The files live under `resources/kb/`, in a shallow tree that mirrors the context
spindle:

    kb/CxCore.txt        the vocabulary head (see vaelii.host.core-context)
    kb/upper/<C>.txt          definitional layers, between Core and Universe
    kb/middle/<C>.txt         theory layers, between Universe and Well

The file *name* is the context; the sub-directory is the layer.  Only the layer a
caller names is discovered, so a sibling directory under `kb/` that names no layer here
is not loaded: `kb/koinii/` is one, an application's own context files, which that
application loads for itself.  What stays in
**code** (vaelii.host.starter) is the *order* the files load in and the handful of
genuinely computed assertions.  Sentences read with `clojure.edn`, so a KB file is
data and can never run code.
raw docstring

vaelii.host.serve

Headless EDN-over-HTTP daemon: one JVM owns one KB and serves it to remote clients (vaelii.host.client). A thin reitit-ring + jetty layer over vaelii.core, the network dual of the in-process API.

Wire format is EDN. A sentence is a symbol s-expression — (dog Muffet), ?x, (genl dog animal) — which EDN round-trips losslessly; JSON would mangle the symbols. The body of every call is {:op <keyword> :args [...]}, and the reply is {:ok true :result …} or {:ok false :error "…"}. EDN is read with clojure.edn/read-string (never clojure.core/read-string), so an untrusted body cannot evaluate code — EDN has no reader-eval.

A refusal's :type is a plain keyword:body-too-large, :not-edn, :cross-origin, :bad-host — and so is the :type an engine ex-info carries through. The protocol is what a client written against another build discriminates on, so it cannot be qualified by the namespace that happens to serve it: a ::-qualified keyword names this namespace, and a client matching on it would be matching on where the daemon's code lives.

The daemon is the single writer (docs/storage.md, the single-writer contract): it owns the one process allowed to mutate the store, so it serializes every op through one monitor. Concurrent client writes therefore apply one at a time and cannot interleave; reads pay the same lock, which is conservative but keeps the contract simple.

Only the allowlisted ops are reachable (ops). Each is a vaelii.core fn with the KB supplied by the daemon — the client sends only the op and the remaining args — so no client can reach an arbitrary var. Sentex records in a result are projected to plain maps before they hit the wire (the sentex-map contract), so the client reads them back without the impl record class.

The change feed is the one thing that is not a vaelii.core fn (feed-ops), and it is a table of its own for that reason: core/watch takes a callback, so what a remote caller holds open instead is a subscription with a cursor:watch, :poll, :unwatch, :watchers, over the per-handler registry app builds (vaelii.host.subscribe, docs/feed.md). A :poll that waits runs outside the monitor; everything else about them is an ordinary EDN op.

One shared bearer token authenticates the caller. With VAELII_API_TOKEN set (guard/api-token), every request presents Authorization: Bearer <token> or is answered 401 with a WWW-Authenticate: Bearer challenge; GET /health is the one route that answers without it. One token for the process, not a session and not an identity — per-caller identity is a reverse proxy's job, and this is the check that has to exist below it. Binding anything but loopback requires a token (-main refuses to start otherwise); on the loopback default it is optional, and a daemon without one is drivable by every process on the machine.

vaelii.host.guard covers what a token does not, and matters most on the open loopback daemon: POST /op requires Content-Type: application/edn, refuses a cross-origin Origin, and answers only to a Host naming the interface it was started on. Together those stop a page the operator happens to visit from driving the KB over loopback — which binding to loopback alone does not.

Headless EDN-over-HTTP daemon: one JVM owns one KB and serves it to remote clients
(`vaelii.host.client`).  A thin reitit-ring + jetty layer over `vaelii.core`, the
network dual of the in-process API.

**Wire format is EDN.**  A sentence is a symbol s-expression — `(dog Muffet)`, `?x`,
`(genl dog animal)` — which EDN round-trips losslessly; JSON would mangle the symbols.
The body of every call is `{:op <keyword> :args [...]}`, and the reply is
`{:ok true :result …}` or `{:ok false :error "…"}`.  EDN is read with
`clojure.edn/read-string` (never `clojure.core/read-string`), so an untrusted body
cannot evaluate code — EDN has no reader-eval.

**A refusal's `:type` is a plain keyword** — `:body-too-large`, `:not-edn`,
`:cross-origin`, `:bad-host` — and so is the `:type` an engine `ex-info` carries
through.  The protocol is what a client written against another build discriminates
on, so it cannot be qualified by the namespace that happens to serve it: a
`::`-qualified keyword names *this* namespace, and a client matching on it would be
matching on where the daemon's code lives.

**The daemon is the single writer** (docs/storage.md, the single-writer contract): it
owns the one process allowed to mutate the store, so it serializes every op through
one monitor.  Concurrent client writes therefore apply one at a time and cannot
interleave; reads pay the same lock, which is conservative but keeps the contract
simple.

**Only the allowlisted ops are reachable** (`ops`).  Each is a `vaelii.core` fn with
the KB supplied by the daemon — the client sends only the op and the remaining args —
so no client can reach an arbitrary var.  Sentex records in a result are projected to
plain maps before they hit the wire (the `sentex`-map contract), so the client reads
them back without the `impl` record class.

**The change feed is the one thing that is not a `vaelii.core` fn** (`feed-ops`), and
it is a table of its own for that reason: `core/watch` takes a callback, so what a
remote caller holds open instead is a subscription with a **cursor** — `:watch`,
`:poll`, `:unwatch`, `:watchers`, over the per-handler registry `app` builds
(`vaelii.host.subscribe`, docs/feed.md).  A `:poll` that waits runs **outside** the
monitor; everything else about them is an ordinary EDN op.

**One shared bearer token authenticates the caller.**  With `VAELII_API_TOKEN` set
(`guard/api-token`), every request presents `Authorization: Bearer <token>` or is
answered 401 with a `WWW-Authenticate: Bearer` challenge; `GET /health` is the one
route that answers without it.  One token for the process, not a session and not an
identity — per-caller identity is a reverse proxy's job, and this is the check that
has to exist below it.  Binding anything but loopback **requires** a token (`-main`
refuses to start otherwise); on the loopback default it is optional, and a daemon
without one is drivable by every process on the machine.

`vaelii.host.guard` covers what a token does not, and matters most on the open
loopback daemon: `POST /op` requires `Content-Type: application/edn`, refuses a
cross-origin `Origin`, and answers only to a `Host` naming the interface it was
started on.  Together those stop a page the operator happens to visit from driving
the KB over loopback — which binding to loopback alone does not.
raw docstring

vaelii.host.starter

A starter common-sense KB: a documented, schema-only upper + middle ontology. It loads the CxCore vocabulary (vaelii.host.core-context), then the starter's own contexts, each a KB file on the classpath under resources/kb/:

  • upper (definitional — between Core and Universe): what things are, always true, like genl. Split by domain, one context each:
    • CxAbstract.txt — the abstract type skeleton (physical/intangible and their kinds) + the structural relations partOf/locatedIn.
    • CxOrganism.txt — the biological taxonomy + its disjointness.
    • CxLife.txt — the organism relations (parentOf, siblingOf, flies, mortal, birthYearOf, olderThan, …) with arg + metadata.
    • CxSociety.txt — the social relations (marriedTo, likes, owns).
    • CxMeasure.txt — the theory of measurement: the two measure terms, the dimensionOf/conversionFactor table with the units that fill it, the comparisons, weightOf / heightOf, and the sign vocabulary for the quantities nobody has a figure for (signOf / trendOf / the qualitative* arithmetic).
    • CxSpace.txt — qualitative space, four independent calculi: RCC-8 topology (eight base + six derived), cardinal direction (nine + four), relative direction over a frame's own axes (nine + four, the frame being the context), and qualitative distance (seven + three).
    • CxTime.txt — qualitative time: Allen's interval relations (thirteen base + seven derived), the point algebra over instants, the three calendar constructors and the InstantFn moment a calendar term's startOf and endOf are computed as, plus the length / totalDuration / overlapDuration vocabulary the arithmetic computes over.
  • middle (theory — between Universe and Well): how the definitional things interrelate, where several overlapping theories can coexist.
    • CxAnatomy.txt — what kinds of thing have what kinds of part.
    • CxBiology.txt — birds fly by default except penguins; living things are mortal; flight enables travel; sleep is what the theory is willing to assume.
    • CxChange.txt — a simple event calculus: a state persists until an event ends it, so holdsAt is inertia over what initiates and terminates say.
    • CxKinship.txt — grandparentOf, ancestorOf, olderThan, and parenthood from maternity and paternity.
    • CxMereology.txt — a part is located where its whole is; owning a whole entails owning its parts.
    • CxSize.txt — comparative size: stated between kinds, computed between objects from their measures.
    • CxSocial.txt — what acquaintance follows from; employment as one way of belonging.

A spindle is three layers — a head every member sees, members that see the head and not each other, and a collector that sees every member — and the topology is two of them stacked, most general (top) to most specific (bottom): CxCore heads the upper spindle, whose members are kb/upper/ and whose collector is CxUniverse, and CxUniverse heads the middle spindle, whose members are kb/middle/ and whose collector is CxWell. Each member file wires itself to its own head and collector, so the topology is data. No cast and no contingent facts ship: the starter is a schema, and contingent data (a cast, worked examples, the Aesop fables) belongs below CxWell and lives in the tests that need it.

The unit table is the one place individuals ship, and it applies that rule rather than excepting itself from it: a minute is sixty seconds by stipulation, so the factor is vocabulary and not a measurement anybody took. CxMeasure.txt states the test it holds a unit to.

What stays in code here is the order the layers load in — loading order is logic, the definitional layer must precede the theories that reason over it — and the one computed batch (every type is also a unary_predicate). Within a layer, every context file present is loaded (discovered from the classpath), so adding a KB is dropping a file in kb/upper/ or kb/middle/, no code change.

A starter common-sense KB: a documented, **schema-only** upper + middle ontology.
It loads the CxCore vocabulary (vaelii.host.core-context), then the starter's own
contexts, each a KB file on the classpath under resources/kb/:

  * upper (definitional — between Core and Universe): what things *are*, always
    true, like `genl`.  Split by domain, one context each:
      - CxAbstract.txt — the abstract type skeleton (physical/intangible and
                              their kinds) + the structural relations partOf/locatedIn.
      - CxOrganism.txt — the biological taxonomy + its disjointness.
      - CxLife.txt     — the organism relations (parentOf, siblingOf, flies,
                              mortal, birthYearOf, olderThan, …) with arg + metadata.
      - CxSociety.txt  — the social relations (marriedTo, likes, owns).
      - CxMeasure.txt  — the theory of measurement: the two measure terms, the
                              dimensionOf/conversionFactor table with the units that
                              fill it, the comparisons, weightOf / heightOf, and the
                              sign vocabulary for the quantities nobody has a figure
                              for (signOf / trendOf / the qualitative* arithmetic).
      - CxSpace.txt    — qualitative space, four independent calculi: RCC-8
                              topology (eight base + six derived), cardinal direction
                              (nine + four), relative direction over a frame's own axes
                              (nine + four, the frame being the context), and
                              qualitative distance (seven + three).
      - CxTime.txt     — qualitative time: Allen's interval relations (thirteen
                              base + seven derived), the point algebra over instants,
                              the three calendar constructors and the InstantFn moment
                              a calendar term's startOf and endOf are computed as, plus
                              the length / totalDuration / overlapDuration vocabulary
                              the arithmetic computes over.
  * middle (theory — between Universe and Well): how the definitional things
    *interrelate*, where several overlapping theories can coexist.
      - CxAnatomy.txt   — what kinds of thing have what kinds of part.
      - CxBiology.txt   — birds fly by default except penguins; living things
                               are mortal; flight enables travel; sleep is what the
                               theory is willing to assume.
      - CxChange.txt    — a simple event calculus: a state persists until an
                               event ends it, so holdsAt is inertia over what
                               initiates and terminates say.
      - CxKinship.txt   — grandparentOf, ancestorOf, olderThan, and parenthood
                               from maternity and paternity.
      - CxMereology.txt — a part is located where its whole is; owning a whole
                               entails owning its parts.
      - CxSize.txt      — comparative size: stated between kinds, computed
                               between objects from their measures.
      - CxSocial.txt    — what acquaintance follows from; employment as one way
                               of belonging.

A spindle is three layers — a head every member sees, members that see the head and
not each other, and a collector that sees every member — and the topology is two of
them stacked, most general (top) to most specific (bottom): CxCore heads the
upper spindle, whose members are `kb/upper/` and whose collector is CxUniverse, and
CxUniverse heads the middle spindle, whose members are `kb/middle/` and whose
collector is CxWell.  Each member file wires itself to its own head and collector, so
the topology is data.  **No cast and no contingent facts ship**: the starter is a schema, and
contingent data (a cast, worked examples, the Aesop fables) belongs below CxWell
and lives in the tests that need it.

The unit table is the one place individuals ship, and it applies that rule rather
than excepting itself from it: a minute is sixty seconds by stipulation, so the
factor is vocabulary and not a measurement anybody took.  CxMeasure.txt states
the test it holds a unit to.

What stays in code here is the *order the layers* load in — loading order is logic,
the definitional layer must precede the theories that reason over it — and the one
computed batch (every type is also a unary_predicate).  Within a layer, every
context file present is loaded (discovered from the classpath), so adding a KB is
dropping a file in kb/upper/ or kb/middle/, no code change.
raw docstring

vaelii.host.subscribe

The change feed with a cursor where the in-process one has a callback — the daemon-side state a remote caller holds a feed open against.

core/watch takes a function, and a function does not cross an EDN wire (the same wall :export's :on-progress hits). So the wire's half of the feed is not the callback marshalled somehow; it is the one thing a request/response protocol can carry, which is state with a cursor: the daemon registers an ordinary listener of its own, that listener files each event into a bounded ring, and a caller reads the ring forward from where it left off. Three ops — register, read, drop — every one of them EDN in and EDN out, so the guards, the client and the error taxonomy that already exist carry it unchanged (docs/operations.md).

A cursor counts events, not handles. It starts at 0 when the subscription is registered and advances by one per delivered event, so a caller compares nothing and stores one integer. poll answers the events past the cursor it was handed and the cursor to send next time.

The ring is bounded, and falling off it is said out loud. A subscriber that stops reading must not grow the daemon's heap, so the ring keeps max-events and drops the oldest past it — and the count of what it dropped is reported as :lagged on the next poll. That number is the whole reason this is usable: a feed with a silent gap is strictly worse than polling, because the caller believes it is current and is not. :lagged is present on every reply, zero and all, so a client that forgets to read it is a client that cannot have one.

A token that names no subscription is refused, never answered empty. The same argument: a reaped, dropped or invented token answering {:events []} is a feed that has silently stopped. :unknown-subscription says so.

What a subscription costs the daemon, and what bounds it. One listener on the KB's feed and one ring of at most max-events events; max-subscriptions of those at once, and one that nobody has polled inside idle-ms is reaped at the next call. Nothing here authenticates the caller — that is the bearer token's job, one layer out (vaelii.host.serve) — but heap a stranger can allocate wants a ceiling whether or not it is authenticated, and the reap is what keeps an abandoned subscription from holding a slot against a live one.

The wait happens here, outside the daemon's monitor. A long poll parks on the subscription's own signal object, so a writer serialized behind serve's one monitor runs to completion while a poll is parked — the feature is about liveness, and a parked poll that blocked every writer would be a global stall wearing its name. The writing thread's only cost is the swap that files the event and a notifyAll on a monitor no poller holds for longer than a compare.

The three entry points are spelled without ! for core/watch's reason: nothing here destroys stored knowledge (docs/api.md). See docs/feed.md, "Across the wire".

The change feed with a **cursor** where the in-process one has a callback — the
daemon-side state a remote caller holds a feed open against.

`core/watch` takes a function, and a function does not cross an EDN wire (the same
wall `:export`'s `:on-progress` hits).  So the wire's half of the feed is not the
callback marshalled somehow; it is the one thing a request/response protocol can
carry, which is **state with a cursor**: the daemon registers an ordinary listener of
its own, that listener files each event into a bounded ring, and a caller reads the
ring forward from where it left off.  Three ops — register, read, drop — every one of
them EDN in and EDN out, so the guards, the client and the error taxonomy that already
exist carry it unchanged (docs/operations.md).

**A cursor counts events, not handles.**  It starts at 0 when the subscription is
registered and advances by one per delivered event, so a caller compares nothing and
stores one integer.  `poll` answers the events past the cursor it was handed and the
cursor to send next time.

**The ring is bounded, and falling off it is said out loud.**  A subscriber that stops
reading must not grow the daemon's heap, so the ring keeps `max-events` and drops the
oldest past it — and the *count* of what it dropped is reported as `:lagged` on the
next poll.  That number is the whole reason this is usable: a feed with a silent gap
is strictly worse than polling, because the caller believes it is current and is not.
`:lagged` is present on every reply, zero and all, so a client that forgets to read it
is a client that cannot have one.

**A token that names no subscription is refused, never answered empty.**  The same
argument: a reaped, dropped or invented token answering `{:events []}` is a feed that
has silently stopped.  `:unknown-subscription` says so.

**What a subscription costs the daemon, and what bounds it.**  One listener on the
KB's feed and one ring of at most `max-events` events; `max-subscriptions` of those at
once, and one that nobody has polled inside `idle-ms` is reaped at the next call.
Nothing here authenticates the caller — that is the bearer token's job, one layer out
(`vaelii.host.serve`) — but heap a stranger can allocate wants a ceiling whether or
not it is authenticated, and the reap is what keeps an abandoned subscription from
holding a slot against a live one.

**The wait happens here, outside the daemon's monitor.**  A long poll parks on the
subscription's own signal object, so a writer serialized behind `serve`'s one monitor
runs to completion while a poll is parked — the feature is about liveness, and a
parked poll that blocked every writer would be a global stall wearing its name.  The
writing thread's only cost is the swap that files the event and a `notifyAll` on a
monitor no poller holds for longer than a compare.

The three entry points are spelled without `!` for `core/watch`'s reason: nothing here
destroys stored knowledge (docs/api.md).  See docs/feed.md, "Across the wire".
raw docstring

vaelii.host.svg

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.
raw docstring

vaelii.host.web

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 roster /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.host.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 view 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 roster
  /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.host.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 `view`
section below and docs/web.md.
raw docstring

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close