29 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
- 13 Oct 07:51 — Pattern pages, and what nucleation said about them
- 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
- 27 Oct 03:40 — A pull request’s repair rebased in a newer push’s unrepaired commits, and the lint read them
- 27 Oct 20:01 — Pattern-backed warning classes, reliance and bounded acknowledgements
- 28 Oct 02:20 — Pattern pages as outlines, and the dialectic at fixed depth
- 28 Oct 08:27 — Lint on a 5,500-file record spent four minutes on one regex, and the thread pool hid where the rest went
- 30 Oct 18:19 — Pattern predicates fired on Nucleation’s worklist
- 30 Oct 18:23 — luria repair trims inactive-ok codes whose documents went Active
- 30 Oct 22:13 — Primary and derived sides of a converse pair, fired on Nucleation
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.
Pattern pages, and what nucleation said about them
2026-10-13 07:51:54
#373 gives a named pattern a page of its matches (ADR-132), as a view
luria supplies on its own template, luria:pattern.md.j2.
The first version was a renderer of its own, and the rework deleted it.
pattern_match/2, a clingo-tuple reader in logic and
pattern_reports.py duplicated views._Record.match, which reached main
while the branch was open. As a view, the page’s tests from the first
version pass with the same output, link targets included.
matchreturned one row per whole match, so two matches differing only in an anonymous node were two identical rows. It now collapses them.- No pair of outputs was checked for collisions; now all are, in one place.
- YAML eats property maps: fold (
>-) or quote a pattern holding one. luria new changelog --name <branch>returns an existing path on a reused branch, and a shell redirect overwrites the previous fragment.
On nucleation the unopposed count moved from 115 of 175 theses to 286 of 361 because the record grew; undercut stayed at 8 rows.
Follow-up: pattern_row/4 (views) and pattern_node/4 (the report: true classes) are two encodings of one match. A test pins that the page and
the class agree; one predicate for both would remove the need.
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).
A pull request’s repair rebased in a newer push’s unrepaired commits, and the lint read them
2026-10-27 03:40:37
Write the entry here: what problem was solved, what the fix was, and what was found along the way — the failed approaches and the traps the next person would otherwise rediscover.
A downstream record kept seeing stale reds from the pull-request shape (ADR-068). Nucleation adopted a working rule of pushing each commit to its pull request as it is made, with CI as the check. Twice in one day, nucleation#334 and #338, a docs-check run went red on a commit that the next run passed. Every violation was a bare code or a missing converse, which is exactly what luria repair fixes.
The cause is in the generate action. The pull-request step commits the repair, then runs git pull --rebase so that the push lands on a branch that may have moved. When it has moved, the rebase brings the newer push’s commits into this run’s tree, unrepaired. The lint then runs in the same job, on that tree, and reports them. The newer push’s own run repairs them a few minutes later, so the red is always stale. It still reads as a failure, and in a large record it costs a full run.
The fix repairs again when the rebase brought commits in. The step remembers the commit it checked out, and if that is no longer the parent of its repair commit after the rebase, it runs luria repair once more and commits the result before pushing. The lint then reads a tree in which every commit is repaired. The push can still race a third push, but that window is the seconds between the rebase and the push, not the length of a run.
A project can also cancel overtaken runs with a concurrency group, as nucleation now does. That saves the run and avoids the race, but only for a project whose workflow has the group, and the action is shared by every project, including one that wrote its own workflow. So both changes are made: the action repairs again after the rebase, and the template workflow luria init scaffolds, and this repository’s own CI, now cancel a pull request’s overtaken runs. The group is scoped to pull requests: every other run gets a group of its own, because a shared group drops a queued run even without cancel-in-progress, and runs on the default branch must serialize. Fired before trusting (DP-006): against a scratch remote with a stub luria, a newer push landing during the run left the old step pushing a tree with one of the two files unrepaired. The new step repaired both and pushed two repair commits.
Pattern-backed warning classes, reliance and bounded acknowledgements
2026-10-27 20:01:34
A downstream backlog luria never surfaced. Nucleation’s forcing-functions decision measured three blind spots behind more than a thousand undecided documents: credence across an edge was no finding (an Active claim resting on a Proposed one), nothing counted what rests on an undecided document, and an inactive-ok-file over a Superseded premise silenced the question forever. ADR-131 makes each a lint finding a record turns on by configuration.
One matcher. A reported pattern’s rows are a fourth shown predicate of the existing lowering, pattern_node(P, Match, I, X), positional so that a match with no named node is still a row. pattern_edge/3 was not enough: a single-node pattern emits none, and an all-anonymous one emits no pattern_row/4. The class list the dials check is now lint.failable() and lint.mutable(), which add the record’s reported patterns to the static FAILABLE and MUTABLE. The tuples stay, because a dozen tests assert built-in classes against them.
The YAML in the request did not parse. match: (c:CLAIM {status: 'Active'})-[...] is a plain scalar containing : , which YAML refuses (“mapping values are not allowed here”). The first fixture failed on load before any luria code ran. A match: with a property map must be quoted, and the configuration reference’s example now is.
Reliance is held/4 closed in clingo. logic/reliance.lp takes reliance(F) facts as program text, so the memo key distinguishes configurations. Reading held/4 rather than ref_value means a dependence stated only by the premise’s converse field (supports) counts. The test fixture states one edge that way, and the count of 3 depends on it. A cycle does not make a document its own dependent.
“Retired” for the expiry check is out of force and not undecided. Luria’s own retired-citations treats every non-Active target as retired, which would demand a date on every acknowledged citation of a draft. A Proposed premise is what load-bearing ranks instead, and a test pins that.
Fired on fixtures (DP-006). Each new class has a test that fires it on the shape it exists for and checks the row’s text, beside a control that changes one thing and sees it go quiet: an until added, the field dropped from reliance, reliance unset, the target Proposed. The dials are fired too: fail_on failing on inverted_premise and load-bearing, mute, a baseline holding at 1 and then reporting 2 over it, and fail_on refusing a pattern the record has not declared report: true.
Pattern pages as outlines, and the dialectic at fixed depth
2026-10-28 02:20:16
A pattern page now nests its matches as an outline, and patterns that share
an output merge into one (ADR-133). This answers the case PR #380 was
written for, a claim, the argument concluding it and the claims undercutting
that argument, without changing chains.
The step renderer moved before anything else changed. chains._link,
_annotation and _step became luria/steps.py. A chain page captured
from main was pinned as a test first, and it passed before and after the
move. The one generalisation is that a facet the document’s scheme cannot
hold is skipped. A chain refuses such a facet at load, so for a chain that
check never fires; for a pattern it is what lets role show on claims and
not on arguments.
The tree is a sort, not a walk. Every prefix of every match goes into one dict keyed by its codes, and sorting the keys gives preorder: a prefix sorts before its extensions, and siblings sort by code. Equal code tuples are the same node, so a short pattern that is a prefix of a longer one adds nothing twice. That is merging by position, because the key is the position’s code, not the pattern’s variable name.
One view per output. _check_rendered keys by path, and _RENDERED
and views.outputs() key by output too, so two pattern views on one path
would have been last-wins. The patterns sharing a page now become one
View, keyed patterns.<first>, whose where names all of them. The
collision check needed one change: it names a pattern page by its first
pattern’s key (luria.yaml: patterns.bare: …), not by the joined where.
Holding a facet is decided in the logic core, beside the chain’s rule:
pat_facet/2, pat_shown/2 (the label of each named node) and
pattern_facet_unheld/2 in consistency.lp. The wording copies the
chain’s, with “neither CLAIM nor ARG declares” for two schemes.
Nucleation (scratch copy, lint.network: never): without a pattern
output, index and lint output are identical to main. With the
bare/undercut pair the outline has 9 conclusions, 11 argument nodes over 10
ARGs (ARG-001 concludes CLAIM-057 and CLAIM-086, so it is drawn under both)
and 9 undercutters. An independent script over the frontmatter, reading
undercuts and undercut_by from both sides, found the same 11 pairs and 9
triples. The four ARGs nothing undercuts are on the page with nothing under
them. The only new lint finding was the docs-index entry for the page.
Traps. A template’s error line moves whenever the template does. The
render-error test now covers both layouts with their own lines (33 and 13).
In the example, a bare code in the README needs luria link --fix run
under LURIA_ROOT=examples/argument-map. Run from the repository root, it
reads the root’s schemes and leaves OBJ-001 alone.
Lint on a 5,500-file record spent four minutes on one regex, and the thread pool hid where the rest went
2026-10-28 08:27:06
luria lint took about ten minutes on dmarx/nucleation. Timed with the network off (lint.network: never, so upstream answers cannot vary the output), it was 614s on 5,519 scanned files. It is now 145s, and the lint’s output is byte-identical to main’s.
The profile to take is a serial one. py-spy record --idle --threads on the pooled run said the chain pages took 86s and that facts.fingerprint took 52s of that. Run alone, the chain pages took 5s and the fingerprint under half a second over 13 calls. The rest was waiting for the interpreter lock. Almost everything in lint is CPU-bound Python, so the render pool overlaps nothing: LURIA_JOBS=1 took 146s and the default width 150s. A pooled profile attributes lock waits to whatever frame was waiting, so it accuses the wrong code. LURIA_JOBS=1 python -m cProfile gives an honest one, as parallel.py says.
The wrong first theory was the logic core. The owner suspected the record’s facts were rebuilt on every ask. They are not. facts() is memoized, and a lint builds them twice (about 36,000 and 42,000 facts, about 2s each). Every derive() call re-stats every document to prove nothing changed, but that costs under 0.1s a call when nothing else is running. The whole logic path, facts and solves together, was about a tenth of the run once the larger costs were gone. Caching facts across runs would save seconds, not minutes.
Most of the time was one regex. bibliography._LEAD_RE reads an author-year lead. Its name pattern and its joiner overlap: A. is both a surname and an initial, and a run of commas and spaces splits several ways. Its run of names is capped at 40. A 26-author entry, LIT-1357’s, has more than 50 name tokens, so the match must fail, and the engine tries every split first. That block alone took 254s, and re holds the interpreter lock while it matches, so every other thread stalled too. That is why the solves showed 23s of wall time against 2s of CPU. Making the run possessive ({0,40}+, Python 3.11+) commits to the longest run of names, and a failing lead fails in linear time. Against 342,529 blocks in nucleation and luria, old and new leads differ on two. Both are F2013 and F2016, which the old pattern read as F2013 and F, published in 2016, by giving back the digits of a name. is_entry gives the same answer for both.
The rest was per-character Python and repeated work:
doc_refs.maskedandfind_refsset flags one character at a time, and_line_indexwalked every character to find newlines. Slices andbisectdo the same in C.find_refs,legacy_spellingsandref_status._scanran two regexes per scheme over every file, about 77 passes on nucleation. A leading\bstopsrefrom searching for the literal prefix first, so each pass cost the same whether or not the prefix was there. A substring test now skips a scheme whose prefix is absent. It is exact only for a prefix with no regex metacharacters, so it is applied only then.ref_status._scanran those passes once per line. Codes never span a line break, so it now matches the whole text and files each code by line, with the line breaksstr.splitlinesuses. A test pins that a form feed and U+2028 count assplitlinescounts them. It passes onmainas well.contract.resolvablerebuilt a scheme’s whole code set for each of 38,000 asks: 26 millionScheme.codecalls. It is now held against the identity of the scheme’s cached listing, so a new listing is a miss.is_generated,is_historicalandlink_basecompared each path against every view, vocabulary directory and journal on every call, through pathlib’s slow__eq__. They now index the config once, incached_propertys onConfig, which is never mutated after it is built.lint.status_sectionscalledref_status.scan(docs=docs), which bypasses the corpus-scan cache. So every lint scanned the corpus twice.reports.reference_statushad the same bug fixed before, and that comment says why.
Not done, and where the remaining time goes. Of the 145s, the reference report’s corpus scan, the bare-reference scan and the annotation survey each read every file and build their own masks. load_scheme constructs about 107,000 Adr objects in a run, each with a deep copy of its frontmatter, because readings are per-object on purpose (#233). Caching a whole scheme’s load across a run would be a design change, not a refactor. If changes stay localized, the next large step is per-file results cached across runs, keyed on a file’s content and the config. A fact cache alone would not be it.
Pattern predicates fired on Nucleation’s worklist
2026-10-30 18:19:10
The gap. dmarx/nucleation keeps record/status-review/worklist.py for
checks a reported pattern could not express. The pattern subset had only
equality, and WHERE NOT (x {status: ...}) says “not equal” only about a
node of the match, never about one inside a negation. ADR-129 had not
rejected predicates. It stopped at equality because nothing yet needed more.
ADR-125 had rejected a general predicate language. That is why the
widening is one comparison inside the node, of the node’s own field, and
not a clause language (ADR-135).
Fired on a scratch copy of nucleation (git archive HEAD at f01a22572,
read-only). Two reported patterns were added to its luria.yaml:
ready_for_ruling:
match: >-
(c:CLAIM {status: 'Proposed'})
WHERE NOT (c)-[:rests_on]->(p:CLAIM WHERE p.status <> 'Active')
WHERE NOT (c)-[:grounds]->(t:THEORY WHERE t.status <> 'Active')
WHERE NOT (c)-[:grounds]->(k:CASE WHERE k.status <> 'Active')
WHERE NOT (c)-[:uses]->(u:TERM WHERE u.status <> 'Active')
WHERE NOT (c)<-[:objects_to]-(o:CLAIM WHERE o.status IN ['Proposed', 'Active'])
WHERE NOT (c)-[:countered_by]->(x:CASE WHERE x.status IN ['Proposed', 'Active'])
report: true
unread_ground:
match: >-
(c:CLAIM WHERE c.status IN ['Active', 'Proposed'])-[:grounds]->(w:LIT)
WHERE NOT (w)<-[:paper]-(n:NOTE WHERE n.status IN ['Read', 'Skimmed'])
report: true- Before, on main: the config does not load:
patterns.ready_for_ruling: the only condition is WHERE NOT followed by a pattern; .... - After: both patterns load, and one solve over the record takes about 14 s.
Comparison with python3 -I record/status-review/worklist.py --top 50
on the same tree.
-
unread_groundgave 0 rows, and the script’s “Live claims that need a work no NOTE reads” also gave 0. Zero equals zero proves nothing (DP-015). So a second copy set NOTE-589, the only NOTE on LIT-776, toSuperseded. Both then listed exactly CLAIM-007 → LIT-776 and CLAIM-123 → LIT-776. -
ready_for_rulinggave 84 claims. The script’sunblocked()(before it drops claims a blocker note holds) gave 101. Every difference has a named cause, and none is a comparison on one node:- 20 claims are in the script and not the pattern. Each has only objections the script counts as written in: the target’s history or a “filed with its concessions” note names the objection on or after the objection’s date. That is date arithmetic over prose, not frontmatter equality.
- 3 claims are in the pattern and not the script: CLAIM-058, CLAIM-286 and CLAIM-362. None has premises of its own. The script then holds a claim back through the ARGs that conclude it, unless one is sound. That rule is “no sound ARG”, where sound is “no unheld premise”: a negation inside a negation, which the subset still refuses.
- 101 − 20 + 3 = 84.
The script’s “Ready for a ruling (1)” also drops claims with a blocker note under 14 days old. That reads the clock and the body text, which a pattern never will.
Traps.
WHEREinside a node and the path-levelWHERE NOTshare one keyword token. The contextual lexer keeps them apart, butnotalso lexes as aNAMEwhere a variable is expected, so_NOTand_INtake priority 2._NOTneeded a\b, or a variable callednoteswould lex asNOT es.<>has to be an anonymous terminal. A named one becomes a tree child and shifts the shape of the parse.INlowers to a clingo body pool (status(V, ("a";"b"))). A pool expands to one rule per value, so a match may be derived twice. That is harmless, because the match predicate is a set.
luria repair trims inactive-ok codes whose documents went Active
2026-10-30 18:23:57 · record · ci
The problem. Nucleation promoted 53 readings in status review 22. It
then cleared the stale acknowledgements with a throwaway script, which
changed 47 directive lines in 46 files (its commit 23de5f786). Taking an
Active code out of an inactive-ok has one correct answer, so it now
belongs to luria repair (ADR-134).
The lint only saw part of it. stale_annotations judged each directive
as a whole: a directive was stale when it excused nothing. Of the hand
trim’s 47 lines, the lint had reported 21. Another 25 were in directives
whose other codes still excused citations, so the dead Active code in them
was never reported. One more was in a workbench journal entry, which the
reference scan treats as historical and never reads. A repair limited to the
reported directives would have missed over half the case. The lint now has a
second row for an Active code inside a used directive, and both the row and
the repair read ref_status.settled_codes.
Fired on the real case. I extracted Nucleation at 0d244a39d, the
parent of the hand trim, with git archive into a scratch directory and ran
this branch’s luria against it.
- Before: 1476 stale-directive rows from the reference scan. 499 were the existing “X is Active now” rows and 977 were the new per-code rows. So Nucleation was carrying about 1,400 such directives beyond pass 22’s.
luria repair: 1476 directive lines in 1336 files, 1001 rewritten and 475 deleted. Every removed code names an Active document.- After: 24 rows, all “nothing in scope cites”. Those need a person, and the repair leaves them.
- A second
luria repairreportedrepaired 0 file(s). - Against the hand trim, the repair made every removal it made, except
THEORY-927inrecord/workbench.d/2026/10/25/043048.md. That file is historical to the scan, so the lint never reported it either. In the other 35 differing files, the repair also removed older Active codes the script was not looking for, such asQUESTION-277, which isOpen, the QUESTION scheme’s in-force status.
Traps.
- The directive parser’s
spanstarts at the comment, not at the directive’s name. That is<!--in markdown and#in Python, and in frontmatter it is the character after#. So the rewrite searches for<name>[-scope]:from that offset rather than trusting the span. - An
until <date>expiry lives among the arguments as two tokens and must survive a comma-separated rewrite as one phrase. - When a directive shares its line with another comment, as in
<!-- inactive-ok-block: X --><!-- unexempt-block: codeblock -->, only that comment is removed, not the line. - Test fixtures put their citations in backticks. Otherwise
luria repairlinks them in the same run, and a whole-file comparison fails for a reason that has nothing to do with the trim.
Primary and derived sides of a converse pair, fired on Nucleation
2026-10-30 22:13:56
A converse pair can now mark one side derived: true. Only the other side,
the primary, is stored, and luria repair moves anything written on the
derived side (ADR-137, superseding ADR-084 for directed pairs). The
owner asked for this on DP-002 and DP-003 grounds. A second ADR,
ADR-136, states which command a write like this belongs in.
Readers needed no change. relations.edges(), held/4 in
luria/logic/relations.lp, already unions a relation with its converse, and
chains, invariants, patterns, reliance and the site all read through it. So
removing a stored derived side loses nothing any of them sees. This was
checked rather than assumed: held/4 over the Nucleation copy had 10,815
tuples before normalization and the same 10,815 after, compared with diff.
The normalization is a logic program, luria/logic/normalize.lp, beside
converse.lp. converse.lp now restricts its pair/4 to pairs with no
derived side, so ADR-084’s HEAD-relative completion runs only for symmetric
and undirected pairs. The two programs never claim the same pair.
ref_derived/2 is a new schema fact, read by consistency.lp too: a pair
with both sides derived, derived without a converse, and derived on a
self-converse relation are refused when the configuration loads.
A move is judged as one unit. It is an add on one document and a removal
on another. Through relations._blocked the contract could refuse the add
and allow the removal, and applying the halves separately would lose the
edge. So each move is checked whole. The survivors are then checked again
as the batch they will be written in, because two moves that are each safe
can together break a field group in one file.
Removal goes by the resolved code, not the spelling. A derived entry
written as a readable alias (LIT-origin) has to be removed in that
spelling. _drop_code reads the field, resolves each raw value through
reference_code, and drops the ones that land on the code being moved. The
add writes readable(code), the spelling a person would choose, as
completion already did.
Fired on a git archive of Nucleation’s default branch (2e046bf6c), in
the scratchpad, with derived: true added to 28 reference fields, chosen as
the dependent→dependency reverses. They match every blurb in Nucleation’s
configuration that reads “written by luria link --fix”.
- Before:
one-sided-relations812 rows,undirected-relations36 pairs. luria repair: 2,626 drops and 62 moves, 1,256 files changed (4,228 lines out, 105 in). Nothing was held back by a contract, no clash, no dangling entry. Every changed file was a normalization; no other repair fired. 45 seconds.- Second
luria repair: “repaired 0 file(s)”. - After:
luria lintclean,one-sided-relations79 rows (all symmetric:compared_against,rivals,complements),undirected-relationsgone. The unverified-identifier count moved from 312 to 398 between the runs. That was arXiv returning HTTP 429, not this change. - The 62 moves: 55
corroborated_bytocorroborates, 4illustrated_bytoillustrates, 2supportstogrounds, 1refined_bytorefines. Five were checked by hand (CASE-018/CLAIM-091, CLAIM-136/THEORY-032, QUESTION-030/QUESTION-284, CLAIM-156/CLAIM-349, THEORY-047/CLAIM-356); each derived entry became the primary entry on the other document.
One data point the hand check turned up. THEORY-032 both supports
(derived from grounds) and corroborates CLAIM-136, which Nucleation’s
own decisions treat as different relations. Normalization moved
both faithfully, since both were already in held/4. Which one is meant is
a question for that record, not a repair.
luria relate had been saying luria repair writes the converse. It
never did: completion was only ever in link --fix, and a test pinned the
false sentence. The note now says which of the three cases applies, through
a new relations.direction().
The repair.py change is one self-contained block, placed after the status
repairs, because the open PR #389 also edits repair.apply.