21 entries. All books.
Contents
- 1 Oct 02:00 — Reference-entry detection, fired on the anthology
- 1 Oct 18:30 — A vocabulary is a scheme in shorthand
- 2 Oct 05:52 — A fresh record should have nothing to say
- 2 Oct 12:48 — A catch-all keyword swallows —help
- 2 Oct 15:26 — Duplicate keys: ask the parser
- 2 Oct 16:25 — Forbidding a field: the mirror of required_when
- 2 Oct 19:38 — Title comparison folds spelling, not meaning
- 3 Oct 20:51 — A typed number: on a temporary document became a duplicate key on the trunk
- 7 Oct 00:37 — Multi-scheme references: fired on the constitution example
- 7 Oct 04:29 — The facts layer, and three assumptions it corrected
- 7 Oct 04:56 — Logic core step 2: invariants, and what parity had to cover
- 7 Oct 05:44 — Logic core step 3: the converse fixer, and a harness that gives it work
- 7 Oct 06:24 — Logic core step 4: config consistency, and timing markers
- 7 Oct 07:34 — Why index and reports got slower, and the fix
- 7 Oct 14:17 — Logic core step 5: acknowledgements, and why contract stays in Python
- 7 Oct 15:09 — The two timing follow-ups: a fixed cost, and wall time that was waiting
- 7 Oct 18:06 — Review fixes: one door for writes, a wheel smoke test, and git as optional
- 8 Oct 04:48 — Explicit relations: what the migration found, and the remote target it needed
- 13 Oct 02:56 — Chains over path patterns: how the design got there, and what parses Cypher
- 15 Oct 22:55 — Views as templates: fired on nucleation’s CLAUDE.md
- 16 Oct 04:56 — CLAUDE.md derives its list of generated files
Reference-entry detection, fired on the anthology
2026-10-01 02:00:04
The forcing function had been answered by the bibliography. The anthology
opted ten fields into explain: cited. Asked whether that was making its
prose richer, the measurement said no. About half of the 1427 explained
relations were satisfied by a document’s own reference line, Author (Year), LIT-nnn — ARXIV-…, which names the paper and says nothing about it. The
check only bit on the 81 relations whose code appeared nowhere at all.
The detector reads shape, because the heading is one record’s habit. The
first idea was to skip ## Source. That fails on every record that names the
section differently, and it would skip annotated entries filed under it.
luria/bibliography.py judges each paragraph or list item on its own, by
four signals:
- an author lead that ends at a year, or a classic list of initials and surnames;
- identifiers;
- a title, dropped only when a venue or an identifier follows it;
- how much prose is left over.
What separated entries from prose was case, not connectives. The first version asked for two function words among six leftover words. On the anthology it hid “Two groups, three months apart, the same bug and two different fixes.” and “Two groups, two corpora, two instruments, no citation in either direction.” Both are annotations, terse enough to have almost no connectives. Counting lower-case words fixed both. Names, initials and acronyms are capitalised, so authors surviving from a second entry on the line no longer count as prose either. Now eight lower-case words, or five with two connectives, read as prose.
Missed shapes found on the real corpus, each now a test:
- an affiliation after
et al.(Shi et al., Meta (2023)); - a lower-case corporate author (
The Movie Gen team (2024)); - affiliations containing
ofandat(University of Texas at Austin); and colleagues.
The classic formats (APA, numbered, markdown link lists) were never seen on the anthology, and are in the tests on their own merit.
Fired on the anthology with the ten fields as configured: 590 findings, all of them “cites X only in a reference entry (line n)”:
SOTA.source267;SOTA.introduced_by253;THEORY.source70.
Every other explained field was already discussed in prose. Two checks of the result:
- The line-based count disagreed on 133. Sampled, they are explanations that wrap onto the next line, or annotated entries. The block-level judgement is right about them.
- 38 of the 590 discuss the paper by its first author’s name without the code (”…Hoffmann et al.”). That is still a finding worth having: the fix is to write the code in that sentence, which the anthology’s own linking rule asks for anyway.
A vocabulary is a scheme in shorthand
2026-10-01 18:30:16
The question was hierarchy. #298 proposed union vocabularies, and behind
it sat a bigger ask: can a record express a hierarchical ontology? A sweep of
the code, the decisions, the docs and the issues found nothing. groups: is
a single level, a group is not a value anyone can tag with, and nothing rolls
up. A union would have added composition (one field accepting a cat or a
dog) but not subsumption: “cat” would still not be a term.
The experiment that settled it used records, not config. A TERM
scheme with a broader/narrower converse, a chain over broader, and
notes citing terms through a reference rendered Pet → Cat → {Tabby, Calico}
as a tree. It refused a citation of a term that did not exist, and gave
every term a status. So the hierarchy was already expressible; what the
record lacked was the move from vocabulary to scheme. The owner’s reading:
a vocabulary is a tiny constrained scheme, and a record that wants more from
one is showing a type error, so the mechanism to build is promotion, not a
richer vocabulary (ADR-124).
The trap was the views. Hand-converting a vocabulary in a scratch record linted clean and silently lost two things:
- Index grouping. The index could group only by a
fields:entry, andaxisrefused a reference. Nothing listed which RFCs were in an area any more, except the published site’s backlinks. - The old pages.
docs/rfcs/area/runtime.mdwas left behind. The directory stopped being one the generator owns once the field stopped being grouped, soluria indexneither rewrote nor removed it, and nothing reported it.
The first is now group: true on a reference (and axis may name one), so
promotion keeps its views. Promotion keeps the field grouped, which makes
the second a non-issue for promotion. It remains a gap for any field that
stops being grouped by hand.
A converse listing each term’s members was the cheaper alternative, and was rejected. It needs no index change, but it makes every tagged document edit its term’s file, which turns popular terms into merge hot spots (DP-2).
Things found on the way:
- The migration scaffold was still TOML.
luria new migrationwrotetitle = "…"into a.yamlfile thatluria migratereads withyaml.safe_load, where it parsed as one string. A test pinned the TOML spelling, so the suite had been enforcing the bug. The scaffold is YAML now, and a test parses its commented examples into operations the planner knows. - A dry run swept for nothing. It walked every tracked file even when the plan had no code renames, which also made it need git. It now skips the walk unless there is something to sweep.
modeling.mdstill pointed atstatuses.yaml. Its “smells” section named the file — another stale mention of it, found after the second review. Grepping for the vocabulary of a stale claim is what finds them, and reading the flagged line is not.- The decision-shaped template leaked into promoted terms. With no
_template.md,new_scheme_docfalls back to a form that seedstags: [record]and “Why this needed deciding” prose. Promotion now writes the scheme’s template and stub the wayluria initdoes, drops the seededtags, and uses the value’s blurb (or a provenance line) as the body.
Then the numbers. Promotion first rewrote area: [runtime] to
area: [AREA-001]: typed, checked, and unreadable. Derived aliases
already existed (ADR-088). A scheme’s alias: "AREA-{slug}" makes
AREA-runtime resolve to AREA-001, and the fixer keeps it. But only prose
honoured them: a reference field rejected AREA-runtime as “not a code”,
and so did luria relate. Every reader of a reference value had its own
resolution:
- the contract;
- the typed edges;
- two copies of
_codesin chains and relations; - invariants’
held; - the grouping;
relate.
They now share one, aliases.derived_code. The converse fixer writes the
alias spelling too, and drops either spelling, because a removal by code
alone would have silently missed an alias.
The alias is derived from the slug, not the title. A title is a label: it
has spaces and capitals, and can differ from the value entirely. The slug
is the old value, so documents keep reading as they did. An alias tail
allows only letters, digits, dots and hyphens, so long_term slugs to
long-term. Two values slugging alike, or one that would read as a number
(AREA-7 is a code, and a code outranks an alias), are refused.
Linting this repository took the same time with alias resolution on the
reference path (5.6s against 5.4s on main). The alias map is cached per
config, and the lookup runs only when some scheme declares an alias.
A fresh record should have nothing to say
2026-10-02 05:52:56
#331, and why the existing guard missed it.
test_a_fresh_default_init_lints_clean runs init, index and lint. But
lint.run() only raises on violations, so warnings passed it, and three of
them printed on every new record. The new test (tests/test_fresh_init.py)
captures what lint prints and requires nothing but the all-clear. It covers
the default config and a custom one with no principles scheme, which is the
shape the RFC tutorial uses.
CLAUDE.md was a copy, not a plan. docs/README.md has been planned from
the config since ADR-048. CLAUDE.md was copied verbatim, and its first
paragraph links docs/design-principles.md. In a record with no principles
scheme that link points at a page nothing will ever generate, and
broken-targets said so on day one. The paragraph now follows the config.
It points where a document-rendered scheme writes paths.design_principles,
and is left out when no scheme does. The broken links seen before the
first luria index are a different matter: those pages are generated, and
init already says to index before linting, so that state is expected.
spent-upgrades was talking to the wrong reader. Its own devlog entry
(2026-09-06) describes it firing on luria’s record “the moment its own
upgrade ran, which is the guard working”. The remedy it names, “remove it at
1.0.0”, is a change to luria. A downstream record can’t make that change. A
record born on YAML never needed the upgrade at all, so for its owner the
row could only teach them to skim past a finding.
Two narrower fixes didn’t hold up:
- Look at git history for a
luria.tomlthe record once had. Both this checkout and CI’s are shallow, so the answer would depend on clone depth. - Have
initwrite amuteline.initnever overwrites aluria.yaml, so the tutorial path, where the config is written first, wouldn’t get it.
What does hold is scoping by who can act. The finding is reported only when
the running luria/upgrade.py lives inside the record’s root, which is true
for luria’s own checkout because CI installs it editable (pip-spec: -e .).
Along the way: spent_upgrades() runs one plan (the statuses one) for every
entry in SUNSET, so the yaml row has never been checked on its own. That
is unchanged here.
collect crashed on the directory init had just made. The default
record/changelog.d has a target, CHANGELOG.md, that nothing wrote.
init now writes each fragment directory’s target with a heading and the
insert marker. collect still refuses to create a missing target itself:
what goes above the marker is a choice about the file. But the refusal is
now a message that names the file and says luria init writes it, instead
of a FileNotFoundError.
A catch-all keyword swallows —help
2026-10-02 12:48:14
#328. luria new --help answered “no such field ‘help’”. Fire shows a
function’s help when it sees --help only if the function has no
**kwargs to hand it to. new.run has one: it accepts a scheme’s declared
fields as flags (--source LIT-134 where SOTA declares source). So
--help arrived as declared={'help': True} and was refused like any
undeclared field. Any other command that grows a catch-all will do the same.
The fix pops help/h before field parsing, and prints the one thing the
generic Fire help never could: the kinds this record scaffolds and the flags
each takes, read from luria.yaml like the dispatch (ADR-036). Those are
what a first-time reader gets wrong. The first docs draft wrote
luria new RFC four times, when kinds are the lowercased prefix. One
per-kind flag list (accepted_flags) now feeds both help and the refusal
message. The refusal had been listing a scheme’s declared status and
tags twice, once as universal flags and once as declared fields.
Not done here: a fragment or journal still accepts flags it ignores
(luria new changelog --title, #330). accepted_flags is the list a
refusal would read.
Duplicate keys: ask the parser
2026-10-02 15:26:26
#240. The duplicate-key half of frontmatter_shape compared the raw text before the first colon, on unindented lines only. That approximates YAML without being YAML: "title": after title:, and any repeated key in a nested mapping, both collapsed silently. That is the #164 defect again in other spellings. The check now loads the block with a SafeLoader subclass that refuses a repeated key in any mapping, so the parser that does the collapsing is the one asked. The <!-- half stays a text check, because no loader raises on a key that happens to read <!--.
Two details the issue’s sketch did not cover:
<<merge keys are skipped. An explicit key after a merge is how YAML spells an override.- The line number comes free from the key node’s mark, so the finding names it. The issue had expected to lose it.
Journals were never checked. check_journals parsed frontmatter but never called the shape check, so a devlog entry could pass the lint and still fail the Pages build. It now runs the same check, and still reads its metadata through the cached read_document.
Fired once on a real case. The issue’s repro ("title": dup above title: in ADR-124) and a nested duplicate in a real devlog entry were both reported with the right lines, then reverted. This record lints clean under the stricter check. Lint time: 5.8s against 5.4s on main, using the C loader where it is built.
Forbidding a field: the mirror of required_when
2026-10-02 16:25:50
#191 was still open after a month of features. Before building, I checked whether anything since could express it. excluded_by (now on any field since ADR-098) compares values within one field. field_groups counts present fields, and status is always present. Derived fields have no conditionals. The issue’s exact case still linted clean on main. So forbidden_when takes required_when’s condition in the opposite sense (ADR-125). The built-in successor field forbids itself while status is the scheme’s active word.
One condition, two keys. _when parses the shape for both keys, and _conditions yields both to the same load-time check, so a misspelled field or value fails for either. Contradictions are refused at load: required and forbidden, a value in both lists, and forbidden on a field with a default. The last is easy to miss. A default is never absent, so the rule would fire on documents that never wrote the field.
Three existing tests were standing in the forbidden state. Two contract tests and an annotation test put superseded_by on an Active fixture, because nothing had ever said that was wrong. They were about remote resolution and the annotation fixer, not status, so their fixtures now retire the document first.
The annotation fixer refused on its own. annotations already declines a write that would leave a document in breach of its contract. A ref::superseded_by: annotation on an Active note is now reported as a bad annotation instead of being written. No new code was needed for that, and a test now pins it.
Fired once on a real case. In a scratch record from luria init, ADR-001 at Active with superseded_by: [ADR-002] printed luria: docs lint clean on main. On this branch it is a violation naming status: Active and the ADR. This record and every example lint clean under the rule, so none of them held the contradiction.
Not mine: ADR-122.md:123: ref::extended_by names no reference field prints on main too.
Title comparison folds spelling, not meaning
2026-10-02 19:38:00
#340. arXiv began returning a title in TeX ($O(n^2)$), and normalize() read it as a different title from the recorded O(n²). The ² was dropped outright, while ^2 kept its digit behind a separator. Accented letters were also dropped outright. normalize() now folds spelling before comparing: it unwraps TeX, applies Unicode NFKD and drops combining marks, names Greek letters (µ → μ → mu, meeting \mu), and compares with every separator removed.
The order of the TeX passes matters.
- Formatting commands (
\textbf,\mathcal, …) go first and keep their argument. - Letter accents (
\v{c}) go next. They count as accents only when followed by{, so\citeand\boldsymbolaren’t eaten. - Then symbol accents and escaped punctuation (
\"o,\&). - Only then do remaining control words become their names.
\varepsilon, ε and ϵ all reach epsilon, since the variant glyph is the same letter.
What it must not fold is pinned in tests: \mu vs \nu, n^2 vs n^3, and the AdamW nickname. The accepted cost is that data set equals dataset. For “is this the same paper” that is wanted.
Not checked on the real case. LIT-655 lives in anthology-of-the-sota, which this session can’t reach. The issue’s own acceptance step covers it: after release, its source-ok: should be reported as excusing nothing. No source-ok: in this repository or its examples flips.
A typed number: on a temporary document became a duplicate key on the trunk
2026-10-03 20:51:18
The anthology’s lint failed on its own main, in
record/decisions.d/ADR-066.md: a duplicate frontmatter key, number. The
bot’s concretize commit (anthology 743847cd) had written number: 66 above
an existing number: tmptzokf, and nothing in the PR that filed
ADR-tmptzokf had complained.
Why it happened
luria new leaves number: out of a merge-allocated document, so concretize
normally has nothing to replace. This document had been written by hand, and
its author typed number: tmptzokf. write_number was meant to replace an
existing line; its docstring says so, and
test_write_number_replaces_rather_than_duplicates tests it. But
_NUMBER_LINE matched only \d*, the shape the writers themselves produce.
A non-numeric value fell through to the insert branch.
The first theory was that concretize had a separate insertion path for
renamed files. It does not. concretize.run calls the same
new.write_number that luria repair uses, so repair had the same gap: a
numbered document carrying a malformed number: would also have gained a
second key. One pattern fix covers both callers.
Why the lint had not caught it earlier
check_numbers skips temporary documents outright, reasonably, since they
have no number to compare with the filename. So a typed number: on a temp
document passed every check on the branch. It became a violation only once
concretize ran on the trunk, which nobody reviews. That is the wrong place for
a defect to surface. The check now reports it on the branch, and luria repair removes the line.
Fired on a real case
With the fix installed, luria lint on the anthology at 987ae08f, the commit
that filed ADR-tmptzokf, reports
ADR-tmptzokf.md: a temporary document declares number:. luria repair then
removes the line, leaving status: first in the frontmatter.
Multi-scheme references: fired on the constitution example
2026-10-07 00:37:39
#160 asked for scheme: to take a list. The change is the decision in
ADR-126; this entry records how it was checked and what turned up along
the way.
Fired before trusting. On a scratch copy of examples/constitution,
BOUNDARY-003 was given overrides: [PRACTICE-010, BOUNDARY-001]:
- With the shipped
scheme: PRACTICE, the lint reportedoverrides: BOUNDARY-001 is not a PRACTICE code. That is the failure #160 described, reproduced. - With
scheme: [PRACTICE, BOUNDARY], the lint exited 0. - With the edge changed to
VALUE-001, the lint reportedis not a PRACTICE or BOUNDARY code — a BOUNDARY document's overrides names a PRACTICE or BOUNDARY document. The union is checked, not just widened.
None of the example’s three boundaries actually overrides another, so the committed example widens the declaration and leaves the documents alone. Inventing an edge to show the feature off would have put a false claim in a worked record.
Where single-scheme assumptions lived. The sites that read a reference’s
target were found by grepping .scheme, .reference and ANY_SCHEME,
not by following failures, because several would have kept passing while
quietly wrong:
code.startswith(f"{target}-")with a tuple target formats as('LIT',)-and silently matches nothing. It raises no error.field.reference == ANY_SCHEMEagainst an unconverted string sentinel would have stayed False for every field.converse_scheme_ofwould have answered with the first target only.
ANY_SCHEME became ("*",) alongside the change, so every comparison is
tuple against tuple. Every consumer goes through contract.targets,
target_of and spelled.
The converse decomposed cleanly. relations.pairs() now yields one pair
per target scheme. The crossing-pair machinery from ADR-097 already filtered
each side’s reading to the far scheme’s documents, so a multi-scheme field
split into independent per-target pairs and the fixer needed no other change.
edges() was the one reader that took a single far scheme. It now unions
the far documents and reads the converse back from each target.
Two test mistakes worth not repeating. _config.merged deep-merges, so
removing a key from the extra fragment leaves the base’s copy in place. A
test that wants a key gone has to write the whole config. And a converse
that names a third scheme makes that scheme owe the far field. That is the
rule working, not a bug, though it read like one at first.
The facts layer, and three assumptions it corrected
2026-10-07 04:29:47
The first step of the logic-core migration: luria/facts.py reads the
configuration and every document into ground atoms, luria/logic/ runs
clingo programs over them, and tools/parity.py holds every later step to
byte-identical luria lint and luria index output against the last
release.
The harness was fired twice before being trusted. Run against itself,
0.36.0 was byte-identical on all ten records: luria’s own, nucleation, the
anthology and the seven examples, about seven minutes in all. That shows
the comparison is deterministic. A one-word sabotage of the chain renderer
then produced three reported differences on the anthology, each with a
unified diff. Two things make it hermetic: lint.network: never in each
copy, so arXiv being slow or throttling cannot move the output, and copying
the .git with each record, because the converse fixer reads HEAD.
Three assumptions the tests corrected. Each is worth knowing before writing rules against these facts:
- An unpadded code is not an alias.
LIT-1does not resolve toLIT-001:codes_ofresolves derived aliases, not numbering. The facts report what luria reads, soref_valueholdsLIT-1. edge/3is not “edges that land”.edges.graphkeeps any code of a declared scheme, resolved or not. Whether a target exists isdoc/2’s business, and a rule that needs landing must join on it.- clingo stops at the first model by default. The wrapper refuses a
program with more than one answer set, because a generated view must be a
function of its sources. With
--models=1it could never see a second answer set, so the guard passed a program with four. It now asks for two.
Scale. The anthology is 39,385 facts, extracted in 1.6 seconds: 29,439
value, 3,144 edge, 3,126 ref_value, 1,769 documents.
Logic core step 2: invariants, and what parity had to cover
2026-10-07 04:56:24
The second step of the logic-core migration moves the invariant check and
the reading every chain walk starts from (relations.edges) to clingo.
relations.lp derives held/4, a relation read from both sides;
invariants.lp derives unbound relation pairs, unbound chain edges, and
components with no common value, taking each component’s least code as its
name with #min. Python keeps the wording and the order.
The order was the part to get right, not the logic. The report prints
findings in the order the old walks produced them, and findings()
deduplicates by first appearance, relations before chains. Chain pairs and
lines come out sorted, so sorted() reproduces them. A relation pair is met
first at whichever of its two directed readings sorts lower among those the
near side actually holds, so that is the sort key. Without it, a cross-scheme
pair whose near document sorts after its far one would move.
Parity now runs luria reports too. The invariant findings reach a
user through docs/reports/unbound-lineage.md, which luria index does not
write, so the step-1 harness could not have seen a regression here. 0.36.0
against itself is still byte-identical with the step added.
Fired on real cases. The anthology declares a cross-scheme relation
invariant (SOTA.source) and three chain invariants, and has two unbound
lines, one of 140 members. All ten records are byte-identical to 0.36.0
across lint, index and reports. Anthology lint went from about 71 s to
about 68 s. The facts are memoised against the config and a stat
fingerprint of the documents, so a fixer that writes between questions is
read again.
holds/3 comes from facts.members, and invariants.held now calls it too,
so the rules and the shell compare the same sets by construction.
Logic core step 3: the converse fixer, and a harness that gives it work
2026-10-07 05:44:01
Step 3 of the logic-core migration moves the converse decisions to
luria/logic/converse.lp. Each pair is (S, F, B, T). From the working
tree and from HEAD it derives the two sides of every edge. It then
classifies each edge by what changed: written to both sides, taken from
both, or a clash. A clash is either a directed relation asserted both ways
or a withdrawal on one side against an assertion on the other. The HEAD
baseline is a new fact, head_value/4, read by one git grep per scheme
instead of one per pair and side. The commit HEAD names joins the facts’
fingerprint, so a commit between two questions in one process is seen.
#358 falls out. The Python kept a pair’s two sides in a dict keyed by
field name, so a symmetric relation crossing schemes collided with itself.
The rules name the sides now_f and now_b, so there is nothing to collide.
#358’s three regression tests are carried into this branch. They fail
against the old Python and pass against the rules, with none of #358’s
patch applied.
Parity had to be given something to check. On a record whose relations
already agree, the fixer has no work, so byte parity on lint, index and
reports says almost nothing about it. The harness now also runs
luria link and luria link --fix. With --perturb SEED it first drops
one paired code from a seeded third of the eligible documents and commits
that, then drops one from another third and leaves it uncommitted. The
first third is untouched one-sided edges, which the fixer completes. The
second is withdrawals, which it propagates. It was fired first with 0.36.0
against itself. On nucleation, 206 documents were perturbed and luria link proposed 67 writes and 54 removals in 111 files, byte-identical
between two runs.
Logic core step 4: config consistency, and timing markers
2026-10-07 06:24:18
Step 4 moves the cross-scheme checks that luria.yaml must pass to
luria/logic/consistency.lp. They had been spread over _checked_converse,
_check_invariants, a loop in _schemes and the body of _chains. Each
refusal is now one derived atom. luria/consistency.py words it and raises
the least, using a key that reproduces the loader’s old order: per scheme,
converses before invariants, and per chain the scheme, its facets, each
walked field, then output and invariant. So a config with several mistakes
still reports the same one first.
Record parity cannot see this step. A real record’s config is valid
and never reaches a refusal. tools/config_parity.py therefore breaks one
in seeded ways and compares what each build raised, word for word. On
nucleation and the anthology, 700 mutants (300 mixed and 400 chain-only)
gave 583 refusals, all identical, and every refusal kind is represented.
The tool was wrong the first time it ran. python -c puts the working
directory on sys.path, so the “baseline” imported the checkout instead of
0.36.0, and every mutant “differed” by a missing clingo import. It runs
isolated (-I) now. After the fix, changing one word in one message was
caught: 13 of 60 chain mutants differed.
Timing markers. With LURIA_TIMINGS=1, luria prints the facts read, each
solve and the whole command to stderr. The parity harness prints per-step
seconds for both builds. The first use found something. On nucleation, inside
luria lint, the facts took 17.9 s of wall-clock time but 2.3 s when timed
alone (documents 1.3 s, HEAD baseline 1.1 s). The likely cause is the
render thread pool contending for the GIL while they are read, not the
reading itself. Each consistency solve takes 2 to 4 ms, and config load is
unchanged within noise.
Why index and reports got slower, and the fix
2026-10-07 07:34:03
Step 4’s timing table had the anthology’s luria index at x1.20 and luria reports at x1.40 against 0.36.0, from single runs. Three alternating runs
each confirmed it was real. index went from 20.0-20.8 s to 24.5-25.8 s and
reports from 15.6-16.2 s to 17.2-18.3 s.
LURIA_TIMINGS showed two separate causes.
The facts were built twice. Under the render pool, index built all
70,238 facts twice, taking 16.4 s and then 4.2 s. Serially (LURIA_JOBS=1) it
built them once in 3.0 s. Two threads asked at the same moment, both missed
the memo, and each built a copy, slowed by contending with the other. The
memo checked identity but nothing serialised the build. The facts and each
solve are now built under a lock, and a test has eight threads ask at once
and requires one build.
Every command paid for every fact. Timed alone, the HEAD baseline took
2.06 s, from one git show per file. value and edge (32k facts) are read
by no rule, and holds covered every field when the invariant rules only
ever read the fields an invariant names. Each .lp program now names the
families it reads in a % facts: line, and derive builds only those. For
index and reports that is 10,325 facts instead of 70,238. The baseline
reads through one git cat-file --batch. luria facts still prints every
family.
After the fix, over three alternating runs on the anthology:
index: 0.36.0 took 19.7-20.8 s, the candidate 18.8-19.3 s.reports: 0.36.0 took 15.6-16.2 s, the candidate 13.7-14.9 s.lint: 0.36.0 took 74.8-83.4 s, the candidate 60.5-67.2 s.
A caution about the markers. LURIA_TIMINGS reports wall-clock time. A
solve that runs while the render pool holds the GIL can show 8 s for 0.2 s
of work. Per-phase lines say where time went in one run; whether a command
got slower is a question for the command totals over repeated runs.
Logic core step 5: acknowledgements, and why contract stays in Python
2026-10-07 14:17:54
Step 5 was going to be the reference checks in contract. Reading them
first changed the plan. For each value they ask whether the code’s prefix
is one the field names and whether the code is among that scheme’s
documents. Those are two lookups, interleaved with parsing the value. And
relations._blocked asks the same checker about a document as it would read
after the fixer’s edits, which exists only in memory. Record facts cannot
describe that without a solve per file. Rules there would read worse and run
slower, which are two of the ADR’s kill criteria. The owner chose to drop
contract and port ref_status instead, and the ADR’s list now says why.
What ref_status decides is a join: citation sites, annotations and
document states, matched by file, code, line and kind. The scan still finds
the sites in prose (blanking URLs, directives, formerly: entries, chain
fields and composed remote codes). It then hands
luria/logic/acknowledgements.lp the sites, the annotations and which codes
resolve and are in force. The program decides:
- excused/2: the first well-formed annotation in the file that names the
code, governs the line and claims the site’s state, and failing that the
first
mention-ok; - unused/1: a well-formed annotation that answers for nothing;
- lost/3: what a malformed annotation left unacknowledged.
This replaces mention_for, the three next(...) excuse searches,
Scan.used’s identity walk over every site, and _unexcused_under.
On the anthology the scan finds 15,893 sites and 1,212 annotations. The solve takes 0.3 s and the whole scan is unchanged: 0.36.0 took 7.4 s and the candidate 6.9 s.
The two timing follow-ups: a fixed cost, and wall time that was waiting
2026-10-07 15:09:55
Two questions were left open after the logic-core ports.
The small examples were slower by a fixed amount. Parity had them
0.1-0.3 s slower in total over five commands. Fifteen alternating runs per
command on examples/knowledge-base put the gap at +11 ms (index),
+23 ms (reports) and +32 ms (lint), as medians. The pieces:
- importing clingo, about 8 ms, which every command pays because config load checks consistency through it;
- about 1 ms per solve, mostly clingo parsing the rule text, with three at config load;
rules()reading each.lpfile on every call;- one
git rev-parse(about 1.5 ms) per read of the facts, to stamp HEAD.
Now rule files are cached, config load shares one answer between its two
scheme passes, and the HEAD stamp is the HEAD file plus stats of the ref
it names and of packed-refs. git is asked for the git directory once per
root, and asked again if a .git appears. Re-measured, the gap is +12 to
+21 ms, within run-to-run noise of about 10 ms. The import is the floor, and
it is the price of the dependency.
The facts took 17.9 s of wall time inside nucleation’s lint. That figure predates the race fix; afterwards the facts took 0.28 s pooled against 0.13 s serially. One phase still swelled: the first relations solve, at 3.46 s pooled against 0.075 s serially. Taken apart serially, it is 9 ms building program text, 30 ms of clingo parsing, 7 ms grounding, 1 ms solving and 12 ms converting. With the markers now reporting thread CPU time, the pooled run says it directly: 3.918 s of wall time, 0.188 s of CPU. The solve thread waited for the GIL behind the render pool, and the command total is the same pooled or serial (about 55 s against 53 s). Nothing to fix in the solve. The markers were what needed fixing, because a wall-time-only line had invited exactly the misreading this follow-up was opened to check.
Review fixes: one door for writes, a wheel smoke test, and git as optional
2026-10-07 18:06:41
A review of the logic-core stack raised three concerns. Fixing them turned up a fourth problem.
Stale reads after an in-process write. The facts cache keyed on
(path, mtime_ns, size) and HEAD, and each solve keyed on the facts list’s
identity. A same-size rewrite inside one tick of a coarse clock would leave
both stale. The review suggested a facts.forget() called from each
writer. The exposure turned out wider than the facts: they are built
through adr_index.read_document, whose parse cache has the same key, so
a rebuilt facts list would still read the old parse. The fix is one door.
luria/writes.py has write_text and unlink, and both call wrote().
That drops the path’s entries in the parse, number and listing caches. For
a scheme document it also bumps a generation that the facts are keyed on
(and through them every solve) and drops the reference scan. Generated
views do not bump it, so luria index does not rebuild the facts between
pages. An AST rewrite moved all 55 write_text/unlink calls in luria/
through it. tests/test_writes.py fails on any that comes back.
Two things the rewrite broke, which the suite caught:
upgrade.runhad a local list namedwrites, which shadowed the module.wrote()loaded the config to ask whether a path was a document, andluria upgrademust never load the config it repairs. It now asks only a config that is already loaded. With none loaded, nothing is cached against one, so it counts the write.
The parity harness cannot see this, because each command runs in a
fresh process. test_a_rewrite_the_clock_misses_still_reaches_the_rules
does: it derives, makes a same-size edit through the door, puts the old
mtime back with os.utime, and derives again. I sabotaged the generation
key and the parse-cache drop, and it failed as it should.
Wheel coverage. The tests run editable, so they read the .lp files
out of the checkout. tools/wheel_smoke.py takes the interpreter of a
clean environment the wheel was installed into. It checks that luria
imports from there, that every program in the source tree ships and loads
through importlib.resources, and that the installed CLI lints an example.
With relations.lp deleted from an installed copy, it reported
the wheel lacks relations.lp.
The fourth problem: git had become a hard dependency. The first smoke
run restricted PATH to the venv, and luria lint crashed with
FileNotFoundError: git in the new HEAD baseline code. 0.36.0 runs all of
lint, index, link —fix and reports without git. Every git call in
facts.py now goes through _git, which returns None when git cannot run,
and that reads as no repository. The smoke test runs the CLI with git and
without.
Explicit relations: what the migration found, and the remote target it needed
2026-10-08 04:48:39
The request was to make configuration the only source of relation
semantics, so that the facts layer stops omitting relation types and
relations.lp stops inferring them from an absence. Three things came out
of doing it that were not in the request.
Remote successors are real, so references can target remotes. A scan of
every successor value in the ten records found one LIT in nucleation
superseded by an ANTH- code and one LIT in the anthology superseded by an
ARXIV- code. The implicit any-scheme reference had allowed these. A
concrete local target set could not, and an explicit * would have brought
back the thing being removed. So scheme: may now name declared remotes:
scheme: [LIT, ARXIV]. contract.remotes_of reads them, a remote code
passes where its remote is named, and the graph still draws no edge to a
remote.
The index needed a role, not a rename. The “shaped by” line read
influenced_by by name. Removing it would change the index pages of luria’s
own record and the anthology. Keeping the name keeps the special case. So
influence: names the declared reference the index renders, as successor
does for retirement, and config load refuses one that names nothing
declared.
The test helper was leaking. merged() merged dict fragments by
reference, so one test’s extra keys mutated a module-level fragment that
the next test reused. It only surfaced when the new tests passed role dicts
through it. It deep-copies now.
The migration reads the old config with its roles stripped in memory,
because an old config may name a successor it never declared. It does
this through config.rooted(root, text=...), a new optional argument. It
loads the result before writing anything. On copies of the ten records it
inferred every target set by hand-checkable rule. Parity runs 0.36.0 on
the old record against the candidate on the migrated one, with
tools/parity.py --upgrade explicit-relations. One output differs by
design: docs/record.md, which now states each scheme’s successor
obligation under that scheme instead of as a standard rule.
Chains over path patterns: how the design got there, and what parses Cypher
2026-10-13 02:56:00
#371 asks for chains over a derived relation, the follow-on ADR-127 left open. The draft decision, ADR-129, went through three shapes in one session, and the two that lost are worth not re-deriving.
First: a field list on the scheme. composed: {leads_to: [premise_of, concludes]} covers claim → inference → claim and nothing else. It is a
path pattern with every feature removed, in a syntax of luria’s own.
Second: queries as clingo rule bodies. ADR-127’s “user-facing queries” read as user-written Datalog. That fails ADR-125’s “rule in the line”. It also lets a user recurse through negation, and takes away luria’s ability to check endpoints or name intermediates. The owner meant path queries, and had suggested Cypher earlier for exactly this. ADR-127 rejected Cypher as an engine and praised its patterns, and those two are separable.
The motivating case that made path patterns the obvious notation: a view walking a question through its answers, objections and replies to its conclusion, as a unit a manuscript could later be composed from. Two things came out of sketching it, both outside this ADR:
- That view is a tree across schemes, not a chain, so it needs an outline view kind.
- Argument acceptance with attack cycles is not stratified: a cycle gives
several answer sets. It stays out of the pattern subset by keeping
WHERE NOToff variable-length patterns.
What exists for Cypher → Datalog, surveyed 2026-10-13:
- GraphGlot (
graphgloton PyPI, 0.14.3, Apache-2.0, pure Python, modelled on sqlglot). It parses GQL and Cypher to one AST and transpiles only between those, with no Datalog or SQL output. Fired on this ADR’s patterns: theneo4jdialect parsed all of them, plus*1..,{status: 'Active'}andOPTIONAL MATCH. Thefullgqldialect rejects the Cypher spellings of variable length and negation, and accepts->{1,}andNOT EXISTS { MATCH … }. The AST is the full GQL program tree. The path sits underGqlProgram → … → SimpleMatchStatement → GraphPattern → PathPatternList → PathTerm.factors, which alternatesNodePatternandFullEdgePointingLeft/Right. Pre-1.0, with releases days apart, so pin it. - Raqlet (Shaikhha et al., CIDR 2026) compiles Cypher and SQL/PGQ to Datalog through two IRs. It is the real thing, but no public release turned up, and it targets Soufflé-style Datalog.
- grand-cypher executes a Cypher subset over networkx. Negated
patterns and
OPTIONAL MATCHare “help welcome”, and it would be a second engine. - microsoft/openCypherTranspiler targets T-SQL, and cypher-for-gremlin targets Gremlin. Neither fits.
So no tool translates Cypher to clingo for us, and luria writes the lowering whatever parses. Given that, the owner chose a lark grammar of luria’s own over GraphGlot:
- GraphGlot’s tree buries the path fifteen wrapper levels deep.
- It accepts all of Cypher, so the subset would need a second pass.
- It is a pre-1.0 dependency.
A scratch grammar was 15 rule lines:
// A path pattern: the subset ADR-129 admits.
start: path ("WHERE"i "NOT"i path)*
path: node (edge node)*
node: "(" VAR? (":" NAME)? props? ")" // a bare (c) refers to a node bound earlier
props: "{" prop ("," prop)* "}"
prop: NAME ":" STRING
edge: "-[" VAR? types length? "]->" -> right
| "<-[" VAR? types length? "]-" -> left
types: ":" NAME ("|" NAME)*
length: "*" INT? ".." INT?
VAR: /[a-z_][A-Za-z0-9_]*/
NAME: /[A-Za-z_][A-Za-z0-9_]*/
STRING: /'[^']*'/ | /"[^"]*"/
%import common.INT
%import common.WS
%ignore WSIt parsed every pattern and refused
OPTIONAL MATCH, undirected edges and RETURN. Two gaps for the real one,
both closed in luria/patterns.lark: *..3 now takes Cypher’s default
lower bound of 1, and a bare * reads as *1...
Its first run caught a rule the ADR had wrong. WHERE NOT (c)<-… names a
bound node without a label, so a node needs either a label or a back
reference. The trap for whoever builds it: lark’s refusals are “unexpected
token at column N”. So naming the out-of-subset construct (RETURN,
OPTIONAL, an undirected edge) is a check of its own, before or around
the parse.
Building it
Three things the ADR got wrong or left out, found while implementing it:
- Direction. A chain nests the holder of a field under its target:
A extends Bputs A under B. A pattern stands in for a field, so its first node nests under its last. The ADR’s first example,leads_to: (a:CLAIM)-[:premise_of]->…->(b:CLAIM), would have hung premises under their conclusions. The example now reads backwards on purpose (follows_from: (b)<-[:concludes]-(i)<-[:premise_of]-(a)), and the config docstring says why. - A closure stays within one scheme. Each step of
-[:f*]->isheld/4on the near node’s scheme, so the documents between the ends have to be of that scheme too. That became a load-time refusal rather than a closure that silently stops after one step. - “also extends” (#359). A step with two premises renders under one
and names the other with the hard-coded “also extends”. On a pattern
chain that is wrong. #359 names each extra parent by its relation and
merged first. The conflict in
_loadand_line_blockresolved by keeping both sides, and a pattern’s name now reads in that place: “also follows from”.
One trap from writing examples/argument-map: a document whose frontmatter
does not parse is all prose to luria link --fix, so the fixer wrote
markdown links into the frontmatter lines. The cause was a stray first line
from a shell printf --. The lint reported “no YAML frontmatter” first, but
by then the fixer had already run. Check that a new document parses before
running the fixer on it.
Fired on the example before trusting it. docs/argument.md shows each step
“through” its inference, and docs/uncontested.md drops the challenged
step. The example lints clean.
Trying it on nucleation
Run on a scratch copy of dmarx/nucleation, with a worktree of main beside
the branch:
- Parity holds. Unchanged, all 625 files
luria indexwrites are byte-identical between the two, andluria lintis too oncelint.network: neveris set in both copies. Run with the network on, the outputs differed only in which arXiv identifiers each run got throttled on, so compare with the network off. - The pattern earns its place.
argued_from: (c:CLAIM)<-[:concludes]- (g:ARG)-[:rests_on]->(p:CLAIM)on theargumentchain draws the steps through ARG-006, ARG-007 and ARG-008, which a chain overrests_onnever saw. ARG-004 is Rejected and drew a step too, untilWHERE NOT (g {status: 'Rejected'})took it out. A bare back-reference with a property map does that, with no new syntax. - Built-in open ends failed, and were removed. The first version listed
every partial match that could not be completed under Open on the chain
page. On nucleation it listed six arguments, all of them complete: they
rest only on
grounds, which nucleation’spremisesfield group allows. It ignoredWHERE NOT, so Rejected arguments appeared too. The case it was built for, premises and no conclusion, cannot occur there, becauseARG.concludesis required and the lint already catches it. The owner’s point settled it: what a record calls unfinished is a pattern it can write, so luria should not guess. Rendering such a pattern is #373. - A step held both by a field and by a pattern shows “through” only when it renders under that parent. CLAIM-027 rests on CLAIM-080 directly and through ARG-009, renders under another parent, and so shows no “through”. Minor, and left alone.
Views as templates: fired on nucleation’s CLAUDE.md
2026-10-15 22:55:38
Phase 1 of ADR-130 is the views family: a Jinja template rendered
by luria index. Its context is doc, docs, vocabulary, schemes and
journals. Before trusting it (DP-006), I fired it on two real cases, each
in a scratch copy.
nucleation’s CLAUDE.md, as a template. I stripped every hand-written
link back to its bare code, ANTH-LIT-285 and the other remote codes
included, and replaced “Where things are” with a loop over schemes(). The
render matched the committed file byte for byte outside the templated
section: the fixer re-linked every code, local and remote, exactly as it had
been written by hand. Rendering took 0.4 s on nucleation’s 3,100 documents.
The firing found two gaps, both now closed. schemes() lacked the scheme’s
title and blurb, and the journal directories (workbench.d,
curation.d) could not be listed. That is what journals() is for.
A broken template in luria’s own record. A view citing ADR-999 failed
luria lint with exit 1 and the line
views.x: record/views.d/x.md.j2:5: ADR-999 names no document in this record.
With the line removed, the view rendered, luria index --check reported it
current, and the existing docs-index rule asked for its entry in
docs/README.md. A view under docs/ is held to what any page there is.
Traps found along the way:
trim_blockseats the newline after{% endfor %}. That is Jinja’s documented behaviour and the right default for markdown built from loops, but a one-line loop needsjoinor an explicit newline.- The lint renders every view to find the broken ones, and at first it
rendered each working one twice, once for the view tree and once for the
check.
views.errors(rendered)now re-renders only the views missing from the tree the lint already built. test_a_generated_view_is_utf_8_whatever_the_console_isruns luria in a subprocess withHOMEreplaced. A dependency installed only in the user site (pip install --user) is invisible there, so the test fails withModuleNotFoundError: jinja2on a machine where everything else passes. CI installs the package properly and never hits this.
match(), fired on nucleation’s argument graph. The inline pattern
(c:CLAIM)<-[:concludes]-(g:ARG)-[:rests_on]->(p:CLAIM) WHERE NOT (g {status: 'Rejected'}) and the declared argued_from, which is the same
pattern, return the same five rows. Among them is CLAIM-027 through ARG-009
from CLAIM-080, which is the claim’s own rests_on in its frontmatter.
Without the WHERE NOT there are six. The sixth row goes through ARG-004,
which is Rejected, so the negation does its job. The first match in
a process took 7 s, nearly all of it building the record’s facts. That is
the cost luria index already pays on nucleation for its pattern chains.
Each further pattern took about 0.3 s, one solve of relations.lp with the
pattern added.
The existing lowering could not serve a view as it stood. pattern_edge
gives a match’s two ends and pattern_via its named inner nodes, each
keyed by the ends alone. Two matches sharing their ends but passing
through different inner nodes therefore cannot be told apart, and a row
cannot be rebuilt from them. pattern_row keys each named node by the
whole match instead, and is generated only when a view asks for it.
Phase 3, fired on nucleation. The template transcluded every claim on
the will-organization line. It read 28 claims: 14 Proposed, 11 Active,
2 Superseded and 1 Rejected. retired-transclusions reported the two
Superseded ones at line 6, the loop body: CLAIM-012 (by CLAIM-027) and
CLAIM-062 (by CLAIM-080). A separate read of CLAIM-012 at line 10, with
inactive-ok: on the line above, was excused. The site’s “Rendered into”
for CLAIM-012 named docs/probe.md. The count is why retirement here is
retires_on and not the citation scan’s “not Active”: the scan’s rule
would have reported 17 of the 28.
Siting a read needed no tracking of my own. Jinja compiles a template into
Python whose module globals hold __jinja_template__, and
Template.get_corresponding_lineno maps a compiled line back to the source
line. Walking up from the read to the first frame that carries one finds
the innermost template, an included file included.
Phase 1 had a gap that this phase found. A view’s output was not
is_generated, so the citation scan read the rendered CLAUDE.md and
would have reported a retired code there, in a file the next build
overwrites. Chain pages are excluded for the same reason, and view outputs
now are too.
Phase 4, fired on nucleation through the CLI. A pinned view quoted
ARG-003’s “The inference” and named the claim it concludes.
luria views --pin paper endorsed two documents, ARG-003 and CLAIM-086.
I then reworded the quoted inference. luria views showed the view as
drifted, the lint row named ARG-003, and luria index left the published
sentence in docs/paper.md untouched. After re-endorsing, I reworded a
line under “Critical questions”, which the paper does not quote, and
there was no drift. That second edit is the reason the lock hashes the
values each document was read for and not the document’s file: hashing
the file would have reported it.
Naming trap: the CLI’s flag is --pin, and Fire maps a flag to a
parameter of the same name. So the module function could not also be
called pin without the parameter shadowing it inside run. It is
endorse.
CLAUDE.md derives its list of generated files
2026-10-16 04:56:51
CLAUDE.md named the generated files by hand, in a bullet saying never to
edit them. The list is now derived in its template, from loops over
schemes(), journals(), chains() and views(), plus paths() for
the pages luria renders at fixed places. A first version put the list in
Config.generated() and had is_generated ask it, so the check and the
page would share one list. It was dropped in review: the labels and the
loops are presentation and belong in the template, and is_generated was
right as it stood.
The cost is a second derivation of “what luria writes”, in the template,
so the guard is a property and not a list (DP-003). Every file
luria index renders for this record has to fall under a path the
rendered bootloader names. I fired it once (DP-006): with the journals
loop deleted from the template, it failed and named
docs/devlog/2026-10.md. A new kind of generated output fails it until
the template learns to say it.
Trap: adr_index.outputs() includes nested records’ views (ADR-078),
which this record’s bootloader does not name. The test asks
outputs(nested=False).