How Perslis Symbols works.
Spec, not code. A gate. A person. A runtime that only verifies.
Everything below is stated from the 1.1.2 release and from recorded runs of it. Where a number has a date, that is the date it was measured; where something is not done yet, it says so.
Abstract. Most questions software asks a model about data are arithmetic over rows the software already holds. Answering them with a model costs per call and returns numbers that cannot be checked. Perslis Symbols inverts the arrangement: a model is asked once to compose a specification — a pipeline of named steps from a closed vocabulary, never code — an admission gate proves the specification over the real data (ten checks, three proofs), a person reads what it computes and signs an approval, and a standard-library runtime on the user's machine executes it thereafter over MCP with model_calls: 0. Arithmetic is exact decimal; data that cannot support an exact answer is refused by name; tools are Ed25519-signed and the runtime holds only public keys. We give the vocabulary, the gate, the trust model, the cost model, dated measurements and the limits, and pin the site's demonstrations to recorded transcripts of the real runtime and gate.
The problem
What did we pay each region? Who are our top three vendors? How many invoices have no PO number? None of these is an inference problem. They are counting, filtering and adding over data the software already holds. Sent to a model, each is paid for per token, every time it is asked, and comes back as a number with no way to check it — a confidence score cannot be inspected, and a wrong total looks exactly like a right one.
The failure is not that models are bad at arithmetic. It is that the arrangement puts the model in the loop at answer time, where it is expensive, slow and unverifiable, instead of at authoring time, where reading a question once and composing a procedure is what it is good at.
Definitions
symbol — One question class over a dataset (demo:paid_by_region) bound to one admitted pipeline, signed by the floor that admitted it and by the person who approved it, and served as one MCP tool (derive_demo_paid_by_region).
pipeline — A list of steps from the closed vocabulary of §4, in the grammar rows (filter | join)* [group_by] reducer [top]. Validated before anything runs; it executes whole or not at all.
specification (spec) — What the model returns: {question_class, pipeline, do_not}. Data, not code.
admission — The gate's verdict over the real data: ADMITTED after ten checks and three proofs, or REFUSED with the first failing check named.
attestation — A reviewer's signed record — what the tool computes in words, its answer on the reviewed data, digests of the spec, table and data — bound to the reviewer's key and enrolled name. The runtime refuses a tool without a valid one.
refusal — An answer that is not a number: REFUSED (the data cannot support a checked answer), NO_EVIDENCE (a table the tool reads is absent), NO_VALUE (no rows matched). Each says why.
gap — A question no tool answers exactly, recorded on the user's machine by report_gap so a symbol can be built for it.
The loop
Step 2 is where the model is useful and the only place it appears. The prompt carries the user's column names and the closed vocabulary; the model can only compose from that vocabulary. Step 3 is mechanical and adversarial (§6). Step 4 exists because step 3 has a limit it cannot cross (§7). Step 5 is a 1,600-line standard-library program that a person can read before running (§9).
The vocabulary
rows (filter | join)* [group_by] reducer [top]. Validated before execution; a top needs a group_by; a group_by must sit directly before the reducer.| Step | Fields | What it does |
|---|---|---|
rows | — | Every row of the tool's base table. |
filter | column + exactly one of equals, not_equals, in (1–1000 values), contains (case-insensitive), nonempty, empty, gt, lt, gte, lte (numbers), after, before, on_or_after, on_or_before (ISO dates) | Keeps rows. Text compares canonically; numeric and date bounds parse every value first and refuse the run if one is not a number or not an ISO date. |
join | table, on, to, how ∈ {strict (default), left, inner} | A many-to-one lookup. A duplicate key on the lookup side is refused (it would multiply rows). An unmatched row is refused unless the signed spec says left (keep) or inner (drop). |
group_by | column, optional bucket ∈ {day, week, month, year} | Groups by canonical text, or by an ISO-date bucket (ISO weeks). Rows with no value form the (missing) group; a real value spelled (missing) is refused. |
| reducer | count (no column), sum, mean, max, min, distinct, count_missing, earliest, latest | One per pipeline. Sums, max and min are exact; mean is rounded half-even to 10 places and says so in its readback. |
top | n (1–1000), order ∈ {desc, asc} | Ranks groups with ties: every group tied at the cutoff is returned, so “which vendor has the most?” never silently picks one of two. |
Limits that are policy, not accident: numbers carry at most 38 significant digits and an exponent within ±60 (SQL DECIMAL(38)); an ungrouped answer of more than 1,000 groups needs a top; a mean is the only rounding anywhere. The readback of every tool — “Take every row of invoices; keep rows where status is 'paid'; look up each row's vendor_id in vendors.id (every row must match, or the tool refuses); group by vendors.region; add up amount in each group (exactly).” — is generated from the pipeline itself, so it cannot drift from what executes.
Exactness and refusals
Numbers are parsed to decimals, never binary floats: 9,007,199,254,740,992 + 1 stays 9,007,199,254,740,993. US currency spellings ($1,200.50, -$5) are read; anything a reader could take two ways (1.200,50) is not a number. A value that is not a number, in a numeric position, does not become zero and does not get skipped — the run is refused, and the refusal names the column and the value.
Each of these is refused rather than answered:
- a value in a number position that is not a number (
N/A,1.200,50), or beyond the exact-number limit; - a value in a date position that is not an ISO date (
03/04/2026); - a join key that is not unique on the lookup side; a row with no match, unless the signed spec chose
leftorinner; - a sum whose magnitudes cannot be held exactly; more than 1,000 groups without a
top; atopmade ambiguous by more than 1,000 ties; - a table entry that is not a row (corrupt JSON), duplicate JSON keys (
{"amount": 10, "amount": 999}used to read as 999), a CSV cut off inside a quoted field, a row with the wrong number of cells, a file that changes while it is being read.
The principle is that a wrong answer that looks right is the expensive failure. A refusal costs one re-read of the data; a silently low total costs whatever was decided on it.
The admission gate
A specification is admitted over the real data, not over a sample of it, and only if every check below passes. The transcript of the demo spec going through the gate is replayed in §10; here is what each check is for.
| # | Check | The failure it prevents |
|---|---|---|
| 1 | structure | A pipeline outside the grammar — refused before anything runs. |
| 2 | not_already_registered | Silently replacing an existing symbol under the same question class. |
| 3 | columns_exist | A hallucinated field. The pipeline names a table or column the evidence does not have (“no column 'amt'”). |
| 4 | data_supports_answer | Data that cannot support an exact answer: an N/A in a number column, a non-ISO date, an unmatched or duplicate lookup key. |
| 5 | determinism | Run twice, a different answer. |
| 6 | produces_a_value | A procedure that derives nothing over the evidence. |
| 7 | verifier_accepts_truth | A verifier that rejects the true answer. |
| 8 | verifier_rejects_wrong | A verifier that accepts everything — it would pass every other check. |
| 9 | abstains_without_evidence | Inventing a number when the table is absent; the tool must say NO_EVIDENCE. |
| 10 | grounded_in_evidence | A constant baked in from the sample: perturbing the rows that actually reach the reducer must change the answer. |
The verifier is never written by the model. A model-written verifier that is wrong would promote a wrong tool that then answers confidently, offline, forever, with no model left to catch it. Instead the verifier is derived: verify == run(pipeline) == proposed. After the ten checks the procedure is proved three times and promoted; a join step also leaves an informational coverage note. Then it goes to a person.
Two findings from building the gate belong in the record. The first grounding check perturbed only the first 25 rows and refused every correct spec on 10,000 realistic rows, because the matching rows were further down; it now perturbs the rows that reach the reducer and is tested at that scale for every kind of question. And a gate that admits “correct as written” admitted a spec labelled “total of paid invoices” that filtered status = open — which is the next section.
Why a person
The gate proves a procedure is correct as written. It cannot prove the procedure means what the question means. The recorded transcript gate_wrong in §10 shows it: the spec keeps status = open under the label “total of all paid invoices”, and every mechanical check passes, because every mechanical check is about the procedure and the data, not the sentence.
So nothing ships until a person reads the readback and the answer on the real data and says yes, and the approval is signed into the tool with the reviewer's own key: the record covers the spec, the table, the readback and the answer, and the reviewer's key id maps to one enrolled name in a pinned registry. Editing the tool, its table or the review afterwards voids it; an approval signed by one person's key in another person's name is refused; a registry with two ids under one name is refused as ambiguous.
Signing and trust
- Tools are Ed25519-signed by the floor that admitted them. The runtime can only verify: it holds public keys and there is deliberately no command-line flag to add one. An unsigned or edited spec is refused by name and the other tools are still served (transcript tamper in §10).
- Reviews are authenticated (§7). The public release trusts only the demo reviewer; a pilot kit carries its own reviewers.
- Releases:
SHA256SUMSis signed with a separate release key, verifiable withssh-keygen -Y verify, whose public half is pinned in two places — on the download page and in the GitHub README — so that replacing a zip and its checksum together is not enough. Builds are reproducible byte for byte. The installer is a release asset covered by the signed checksums; there is nocurl … | bashline, because that runs mutable content before anything is verified. - Why the gate is not in the download. If the gate shipped, a wrong specification could be hand-written, loaded, and would then answer confidently, offline, forever. So the gate stays where it can be maintained and audited, and what runs on your machine is an executor — complete for execution and honest about being only that. The runtime contains no network code; you can confirm that by reading it.
The runtime
Standard-library Python 3.9+, no dependencies, no network code, about 1,600 lines. It reads CSV, TSV, JSON (numbers as exact decimals), JSONL, SQLite (read-only) or a folder of them, re-reads when the data changes on disk and caches answers until then; if a changed file cannot be read, calls are refused until it can. It speaks MCP over stdio (protocol versions 2025-06-18, 2025-03-26 and 2024-11-05):
→ {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}
← the admitted tools, each with its question, its readback and "Takes no arguments", plus report_gap
→ {"jsonrpc": "2.0", "id": 10, "method": "tools/call", "params": {"name": "derive_demo_paid_by_region", "arguments": {}}}
← {"jsonrpc": "2.0", "id": 10, "result": {"isError": false, "content": [{"type": "text",
"text": "{\"status\": \"DERIVED\", \"tool\": \"derive_demo_paid_by_region\", \"table\": \"invoices\",
\"question_class\": \"demo:paid_by_region\", \"value\": {\"Central\": 11215.45, \"East\": 21576.59,
\"South\": 4800.25, \"West\": 32996.02}, \"model_calls\": 0,
\"derivation\": \"rows -> filter(status equals 'paid') -> join(vendors on vendor_id=id) -> group_by(vendors.region) -> sum(amount)\"}"}]}}
A tool takes no arguments: the question is fixed at admission, which is what makes the answer checkable. The tool description tells the assistant, in words, exactly what the tool computes and to call report_gap instead if that is not what the user asked. Calls are logged locally (calls.jsonl, gaps.jsonl); the text of an unanswered question stays in a private folder unless the operator chooses to share it. --check runs every tool once against the data and exits non-zero if any is not DERIVED.
Demos — recorded, and run here
Every transcript below is a recording of the real gate (symbolic_floor.floor_ask add) or the real 1.1.2 runtime (floor-serve.py over MCP stdio) on the fictional demo data; the only edits are the replacement of temporary paths by ./data and ./floor. The “compose your own” pane executes the JavaScript port of the runtime's executor, pinned equal to the Python on every shipped example by the site's test.
The gate, check by check
How the runtime refuses
Compose your own pipeline (executor only — nothing here is admitted)
The cost model
Let one model answer cost c, one symbol cost A to author (the model calls that compose the spec, plus the person's review), and one local execution cost ε. After N questions:
t tokens per call and p price per million tokens; k authoring attempts at tauthor tokens and the mean token price; h review hours at rate r; L latency per call. The model bill is linear in questions; the symbol bill is flat after A. With the example figures below — 1,500 in / 150 out tokens at $3 / $15 per million, three authoring attempts of 4,000 tokens, 15 minutes of review at $120 an hour — c ≈ $0.00675, A ≈ $30.11 and N* ≈ 4,460 calls: about two days at 2,000 questions a day, after which a year costs about $30 instead of about $4,900. These are placeholders to edit, not a quotation of any provider's price list.
Two costs are outside the formula. The cost of a wrong answer, which a model's number cannot bound and a symbol's refusal is designed to avoid. And the cost of the questions a symbol cannot answer yet — those are reported as gaps, and they still need a model; the honest count of them is part of what the beta measures.
Measurements
| What | Measured | When · how |
|---|---|---|
| Load, memory, first query | 500,000 rows (18 MB CSV): about 1.3 s to load, about 400 MB, about 2.3 s for the first query; repeat answers cached; default cap 1,000,000 rows | 26 Sep 2026 · pilot build, macOS |
| Admission | About 27 s per specification over the same 500,000 rows (ten checks, three proofs) | 26 Sep 2026 · pilot build |
| The gate on the demo | Correct spec: 10/10 checks, proved 3/3, promoted. Ghost column: refused at check 3. N/A in amount: refused at check 4. Wrong question, right procedure: 10/10 — caught only by review. | 27 Sep 2026 · recorded transcripts (§10) |
| Tests | Over 300 automated tests pass on Python 3.9 and 3.13; four adversarial review rounds, each finding pinned by a regression test | 26 Sep 2026 |
| A real client | A Claude Code session called a built kit over MCP, reported exact answers, and recorded an unanswerable question as a gap | 26 Sep 2026 |
| This site's port | Equal to the Python runtime's recorded answers on 6 tools, 2 refusals, descriptions and derivations | 27 Sep 2026 · scripts/check_symbols.js, run on every build |
Limits, and what is not claimed
- Not yet: relative dates (“last 30 days”), parameterised tools, direct database connections, a self-serve gate. Tools for your data come through a pilot.
- A question outside the vocabulary is reported unmappable; it still needs a model. The vocabulary is small on purpose, and grows one proved primitive at a time.
- The gate proves procedures, not meaning; the person is not optional (§7).
- Signing stops tampering and casual reuse of unsigned tools; it is not a defence against an attacker who controls the machine the runtime runs on.
- The measurements are from one build on one machine, dated; the gate transcripts are one run per case. The JavaScript port on this site is a port — the download is the runtime.
- Status: pilot. Nothing here is a benchmark against another product.
Related work
None of the parts is new on its own, and this page does not claim otherwise. Compiling a decision into a rule that runs without the reasoner is the expert-systems tradition; composing a program from a restricted language and checking it against examples is program synthesis under a DSL; the executable, inspectable derivation is what symbolic flows names across Perslis. What is specific here is the arrangement: the model may only propose a specification, the verifier is derived rather than written, admission is proved over the user's real data, a person's approval is signed into the artefact, and the thing that ships can verify but cannot admit. The same fail-closed posture — nothing enters the trusted state without passing a check that can refuse — is the fail-safe model the rest of the site is built on, applied to tools instead of to answers.
References and sources
- Perslis Floor 1.1.2 — download, installer, signed checksums and build status: perslis.com/perslis-floor; source and releases: github.com/AgewellEPM/perslis-floor (CHANGELOG 1.1.0–1.1.2).
- The symbolic floor, research note and the earlier runtime: perslis.com/research/floor.
- Recorded transcripts behind this page: assets/symbols/recordings.json (runtime 1.1.2, recorded 2026-09-27); the gate transcripts verbatim in
assets/symbols/gate/. - Model Context Protocol specification (tools/list, tools/call, stdio transport): modelcontextprotocol.io/specification.
- Decimal arithmetic as used by the runtime: Python's decimal module (IBM's General Decimal Arithmetic Specification); ISO 8601 week dates for the
weekbucket; Ed25519 per RFC 8032. - Perslis guides this page builds on: symbolic AI, symbolic flows, the fail-safe model.
Questions
- Can the model write code?
- No. It returns one JSON object: a question class and a pipeline of named steps from a closed vocabulary. There is no
eval, noexecand no imports in the executor; an op outside the vocabulary is refused rather than interpreted. - What happens when my data changes?
- The runtime re-reads the data when it changes on disk and caches answers until then. If a changed file cannot be read, calls are refused until it can; stale data is never served silently.
- Can I add my own trusted key to the runtime?
- No — deliberately. Trust comes only from the public keys compiled into the runtime and the reviewer registry; there is no command-line flag to add a key. A tool signed by an unknown key, or edited after signing, is refused by name and the other tools are still served.
- What is a gap?
- A question none of the tools answers exactly. The assistant calls
report_gap; the question is recorded on your machine only, so a symbol can be built for it. Nothing leaves the machine.