Loading…
gathering yarn balls…
Cat Game Research
Frontier
Dev-log transmissions from the expedition

A Local Logic Model and LRL: Jev-Style Decisions You Can Train Yourself

A stateful, Vulkan-trained decision model plus LRL — a small compiled language for chaining questions into branching decision programs — scored at 48.6% on a 2,540-record test set.

In 1869 William Stanley Jevons — logician and economist — built a "logical piano": a wooden machine with a keyboard that mechanically eliminated inconsistent combinations of logical terms as you pressed premises into it. Feed it propositions, read the face, get conclusions. The decision contract hasn't changed in 150 years. What changed is where the machine lives.

William Stanley Jevons' Logical Piano: a small upright wooden machine with a piano-style keyboard and a slotted face showing logical term combinations
Jevons' Logical Piano (1869), photographed at the Powerhouse Museum, Sydney. Photo: Nelson Minar, Wikimedia Commons, CC BY-SA 2.0 (resized and recompressed to WebP).

Today the same shape — state in, calibrated answer out — is a hosted API: TypeSafe's Jev, part of their System One family, takes a state plus a map of typed questions and returns typed answers with probabilities and confidence. It is a genuinely good contract. It is also a remote API you pay per call, with someone else's model weights on the other side.

This post is about our version of that contract — a stateful logic model and a small language called LRL (Logic Request Language) that turns individual questions into compiled, branching decision programs. It runs on a gaming laptop's Vulkan GPU, trains from JSONL files you write yourself, and keeps a recurrent hidden state so that question N in a program is asked with questions 1…N−1 still in memory.

~40MParameters (3-layer GRU, hidden 1536)
48.6%v6 test accuracy (n=2,540)
0.079ECE — calibration error
60/60Abstention hits (perfect precision)
13.4hv6 train time, RTX 3080 Laptop
0/327JIT-vs-interpreter divergences

The contract: state + questions → scored answers

Every decision reduces to one record:

{"id":"doc-01234","family":"doc_lookup","qtype":"choice",
 "state":"<48 lines of a markdown spec>",
 "question":"which heading opens this section?",
 "options":["Grammar","Semantics","Interfaces","Testing"],
 "answer":1}

Three question types cover most decision surfaces:

TypeSemanticsHeadUsed for
CHOICEexactly one option is rightsoftmax + cross-entropyrouting, classification, next-action pick
NOULeach option independently true/false; bare NOUL = boolean probe on [FALSE, TRUE]sigmoid + BCEmulti-label sets, yes/no gates, membership checks
SCOREposition on an ordered rubricdistribution over levelsseverity, quality bands, thresholding

A third head emits a scalar confidence alongside the answer, so a downstream pipeline can abstain instead of guessing — and in the current model, abstention is the most polished part of the stack: 60/60 abstention hits on the eval set, and on unanswerable probe questions it returns UNKNOWN at ~0.99 confidence rather than confabulating.

How a decision is actually computed

The model is a stacked GRU — same family as our language models, read differently:

STATEfree textQUESTIONone lineGRU ×3hidden 1536residual+RMSNormcarried H→ H (memory)fork → OPT1GRU continues → h1fork → OPT2GRU continues → h2fork → OPTnup to 64 optionsscore headsoftmax / sigmoidconf headreads H directlyprobs[]confencode once · fork per option · never disturb sibling scores

Two properties fall out of this shape. First, each option is scored in the context of the full state — the fork means scoring option 3 never disturbs option 4's view of the world. Second, the state is encoded once: inside a program walk, the post-STATE hidden vector is checkpointed and reused, so the Nth question costs one question's worth of forward pass, not a full re-read.

The shipped v6 model: 3 GRU layers, hidden 1536, token embedding 512, 4,123-token vocabulary — about 4,090 learned BPE merges plus 33 special tokens, 29 of them the LRL keywords (<NODE>, <GATE>, <WHEN>… grammar structure is a single-token signal to the model). About 40 million parameters, deliberately laptop-scale.

LRL: programs made of questions

A single ask is a lookup. LRL turns asks into procedures — a decision program is a text file you can diff, version, and unit-test:

// verbatim from the L7 eval suite — a record's meta.lrl (dialogue-needle family)
NODE a0 ASK NOUL "did <spk1> mention the rune?" OF <STATE>
NODE a1 ASK NOUL "did <spk0> mention the rune?" OF <STATE>
NODE g GATE OR(a0, a1)
EDGE a0 -> a1
EDGE a1 -> g
EDGE g.true -> <END>
EDGE g.false -> <END>

Two model asks, fused by a pure-logic gate, then truth-selector edges that lower to guards (<spkN> are speaker-turn markers in the record's dialogue state). The vocabulary is small on purpose:

StatementMeaning
NODE x ASK … OF <STATE>ask the model a question over a bound state; OPTIONS [...] inline or resolved from state
NODE g GATE op(...)pure boolean logic — AND/OR/XOR/NAND/IMPLIES/NOT — over earlier results; no model call, no noise
EDGE a -> b [WHEN guard]conditional routing; .true/.false/.unknown selectors lower to implicit guards
REQUIRE CONF …confidence thresholds — a node can route differently when the model is unsure
GEN QUESTION …emit new questions instead of answering — programs that generate their own training data

The gate is the point that matters: a GATE node's outcome decides whether the walk checks the roof hatch or the path — the program branches on model answers, but the branching itself is exact logic. Neural where you need judgment, symbolic where you need guarantees.

Compiled, not interpreted

.lrl sourcelexerASTpassesfold·CSE·pruneLBCVMdisk cacheAST interpreter (--no-jit)permanent reference patheval --suite --walkdiffs both engines per programwalk result

logic run and logic make go through the real pipeline — lexer → AST → optimization passes → LBC bytecode → VM — with compiled blobs disk-cached by content hash. The passes do real work — normalize (bare ASK NOUL → [FALSE,TRUE]), fold gates whose inputs already resolved to constants, dead-branch prune — and codegen adds one more: state-encoding CSE. If an ASK's only in-edge is an unconditional edge from a same-state ASK, no Encode op is emitted; the VM (and interpreter) restores the predecessor's checkpointed post-STATE hidden instead.

The AST interpreter is kept permanently, not as scaffolding. --no-jit runs it, and logic eval --suite --walk executes every program on both engines and diffs paths, answers, and engine divergence — on the current suite that divergence counter reads 0/327.

Programming it: three patterns

1. A pure gate probe — three model asks fused by exact logic:

NODE arith ASK NOUL "computed the arithmetic correctly" OF <STATE>
NODE steps ASK NOUL "carried every step of the calculation" OF <STATE>
NODE tally GATE AND(arith, steps)
EDGE arith -> steps
EDGE steps -> tally
EDGE tally.true -> <END>
EDGE tally.false -> <END>
$ cat_chat logic run probe.lrl --config run.toml
{
  "reached_end": true,
  "path": ["arith","steps","tally"],
  "results": [
    {"node":"arith","truth":"TRUE","answer":1,"conf":0.94,...},
    {"node":"steps","truth":"TRUE","answer":1,"conf":0.87,...},
    {"node":"tally","truth":"TRUE","skipped":false}
  ]
}

2. Branching on answers — the walk only visits nodes whose edges' guards pass:

NODE key_check ASK NOUL "has_item_red_key == true" OF <STATE>
EDGE key_check -> chip_check WHEN key_check   // key found → check chip
EDGE key_check -> <END>                        // no key → stop
NODE chip_check ASK NOUL "has_item_data_chip == true" OF <STATE>
EDGE chip_check -> <END>

3. Programs that write their own training data — GEN nodes plus logic make emit labeled records; unanswered needs_teacher slots are filled by cat_chat teacher --fill-gen:

$ cat_chat logic make prog.lrl --config run.toml --output recs.jsonl
$ cat_chat teacher --fill-gen recs.jsonl   # teacher fills GEN templates
{"id":"mk-4bfa41a1-00000","family":"lrl_gen","qtype":"choice",
 "state":"known: has_item_red_key=false",
 "question":"Is the red key present on the shelf?",
 "options":["The red key is absent","The red key is present","I don't know"],
 "label_source":"teacher","answer":1,
 "meta":{"node":"fwd3","kind":"gen_forward","labeled_by":"teacher"}}

Provenance tiers keep the data honest: procedural (labels computed by code), teacher (a larger model filled them), model (self-labeled — confidence targets masked so it never trains on its own agreement), and needs_teacher templates in flight.

Using it alongside Jev

The request shapes are deliberately compatible. A Jev system_one call decomposes onto our surface almost mechanically:

Jev / System One requestLogic model equivalent
"state": <text>record state / <STATE> bound by --state
choice + criteria mapASK CHOICE "q" OF <STATE> OPTIONS [...]
noul (+ optional true/false criteria)ASK NOUL "q" OF <STATE> (bare → [FALSE,TRUE])
score + ordered level arrayASK SCORE … OPTIONS [...]
confidence fieldper-result conf scalar
many questions per request, parallelmany ASK nodes per program — but sequential, with state carried between them

Our reference client is already in the tree: scripts/lib/logic/jev_quine.py builds an autoregressive next-character LM entirely out of Jev API calls — each request carries all prior output as state, and its --quine mode emits the text that describes the program's own source. Swap the HTTP call for a logic run invocation (or a direct LogicModel scorer binding) and the same trick runs locally, free per call.

When to pick which
  • Jev: zero training data, general-domain questions, want calibrated answers today, comfortable with a remote API and per-call billing.
  • Logic model + LRL: you have a domain (game states, docs, support tickets), want to train on your data, need branching/sequential programs, need it local and offline, or want the model inside a game loop or build pipeline.

Current results — v6 on the L7 test set

The v6 checkpoint (10 epochs, 21,539 train / 2,625 val records, ~13.4 GPU-hours on an RTX 3080 Laptop) scores 48.6% accuracy on 2,540 held-out records — up 7.4 points over v5 on identical test data, and much better calibrated (ECE 0.079 vs 0.33). The scorer itself now runs on the same GPU path as training, which makes the eval numbers the same numbers production asks would produce.

Radar chart of v6 per-domain accuracy: strong spokes at build_config (100%), shell (96%), python (82%), cpp_source (79%); weakest at story (31%), game (38%), numeric (40%)
v6 per-domain accuracy on the L7 test set (2,540 records, GPU eval).
SliceResultReading
Choice accuracy48.6%+7.4pp vs v5 on identical test records
ECE / Brier0.079 / 0.224calibrated — conf means what it says
NOUL opt-acc / F180.1% / 0.805multi-label discrimination is strong
Abstention60/60every UNKNOWN predicted was a true unknown
Suite walk_end53.5%model walks that reach <END> (of 327 programs)
Engine parity0/327JIT and AST interpreter agree everywhere

The honest weak spots are visible in the radar: story (31%), game (38%), numeric (40%), pathfinding (39%). Two of those were diagnosed as data bugs, not model limits — the generator never asked "fewer" questions (the model learned a degenerate polarity rule), and only ~1.5% of training records carried more than 8 options (the model never saw wide-option discrimination). Both are fixed in the L8 dataset, which is staged for the v7 run. Story/dialogue-needle weakness is a different beast: those train/test distributions already match and the records are genuinely hard — facts buried under ~700 characters of filler — which points at a retention-capacity limit, not a data shape.

The precision ladder

Training runs entirely on Vulkan cooperative-matrix tensor cores, with a five-rung precision/memory ladder measured against the CPU reference:

ConfigGEMMActivation cacherel L2 vs FP32Cache bytes/elem
fp32scalar FP32FP32~04
mixed + int8FP16 tensor coresINT88.7e-31
split + int8paired-FP16 (~22-bit)INT88e-41
f16scalar FP16FP164.2e-42
split + fp16paired-FP16 (~22-bit)FP164.2e-42

The split path is worth its own war story: the NVIDIA driver folds every floating-point f32→f16→f32 round-trip into a no-op, so the low-order residual had to be computed in the integer domain (floatBitsToUint → round-to-nearest at bit 13 → uintBitsToFloat), and the tiny residual scaled into fp16's normal range so tensor cores don't flush it as a subnormal. The result is a ~22-bit-precision GEMM on fp16 hardware — int8-cache memory footprint, near-fp32 gradient quality.

Where it goes next

Roadmap
  • v7 on L8 — done: trained and scored (49.9% test acc, ECE 0.074 at the epoch-4 checkpoint) on a half-sized 19.4M model; v8 on the 96,506-record L9 corpus is in progress. See the scaling follow-up.
  • Retention-horizon probe: a graduated needle-depth eval family to map where recall actually decays — turning "capacity limit" from a binary verdict into a measurable curve that sizes the fix (deeper TBPTT vs. attention hybrid vs. wider hidden state).
  • Split-FP16 in the LM trainer: port the integer-domain-residual GEMM to the shared LM training path — same hardware, same silent error class.
  • Violation-set forensics: make fixed-seed violation-index diffing a first-class harness feature — the diagnostic that separated GEMM error from cache noise is currently manual.
  • Option-lane memory work: wave chunking / segment checkpointing so 64-option records fit alongside larger batches.

Caveats and limitations

Related: Scaling the Logic Model (L9, v5→v8) · Vulkan ML Compute · FP16 Accumulators on GRU-L3 · The v5 Pretrain Roast · Bench · jev-sdk (TypeSafe Jev client)

← All transmissions