21 entries. All books.

Contents


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 of and at (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.source 267;
  • SOTA.introduced_by 253;
  • THEORY.source 70.

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, and axis refused 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.md was left behind. The directory stopped being one the generator owns once the field stopped being grouped, so luria index neither 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 migration wrote title = "…" into a .yaml file that luria migrate reads with yaml.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.md still pointed at statuses.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_doc falls back to a form that seeds tags: [record] and “Why this needed deciding” prose. Promotion now writes the scheme’s template and stub the way luria init does, drops the seeded tags, 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 _codes in 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.toml the record once had. Both this checkout and CI’s are shallow, so the answer would depend on clone depth.
  • Have init write a mute line. init never overwrites a luria.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.

  1. Formatting commands (\textbf, \mathcal, …) go first and keep their argument.
  2. Letter accents (\v{c}) go next. They count as accents only when followed by {, so \cite and \boldsymbol aren’t eaten.
  3. Then symbol accents and escaped punctuation (\"o, \&).
  4. 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 reported overrides: 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 reported is 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_SCHEME against an unconverted string sentinel would have stayed False for every field.
  • converse_scheme_of would 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-1 does not resolve to LIT-001: codes_of resolves derived aliases, not numbering. The facts report what luria reads, so ref_value holds LIT-1.
  • edge/3 is not “edges that land”. edges.graph keeps any code of a declared scheme, resolved or not. Whether a target exists is doc/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=1 it 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 .lp file 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.run had a local list named writes, which shadowed the module.
  • wrote() loaded the config to ask whether a path was a document, and luria upgrade must 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 NOT off variable-length patterns.

What exists for Cypher → Datalog, surveyed 2026-10-13:

  • GraphGlot (graphglot on 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: the neo4j dialect parsed all of them, plus *1.., {status: 'Active'} and OPTIONAL MATCH. The fullgql dialect rejects the Cypher spellings of variable length and negation, and accepts ->{1,} and NOT EXISTS { MATCH … }. The AST is the full GQL program tree. The path sits under GqlProgram → … → SimpleMatchStatement → GraphPattern → PathPatternList → PathTerm.factors, which alternates NodePattern and FullEdgePointingLeft/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 MATCH are “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 WS

It 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 B puts 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*]-> is held/4 on 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 _load and _line_block resolved 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 index writes are byte-identical between the two, and luria lint is too once lint.network: never is 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 the argument chain draws the steps through ARG-006, ARG-007 and ARG-008, which a chain over rests_on never saw. ARG-004 is Rejected and drew a step too, until WHERE 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’s premises field group allows. It ignored WHERE NOT, so Rejected arguments appeared too. The case it was built for, premises and no conclusion, cannot occur there, because ARG.concludes is 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_blocks eats the newline after {% endfor %}. That is Jinja’s documented behaviour and the right default for markdown built from loops, but a one-line loop needs join or 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_is runs luria in a subprocess with HOME replaced. A dependency installed only in the user site (pip install --user) is invisible there, so the test fails with ModuleNotFoundError: jinja2 on 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).