From 5305e757980efa5f35faa9258753ef98530ec03c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 00:05:56 +0000 Subject: [PATCH 1/6] Draft ADR: a chain may walk a composed relation (#371) Proposed. A scheme declares a composed relation as the reference fields it follows in order; a chain names it where it names a field. Read-only, single-scheme legs, steps name their intermediate, partial paths shown. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xS6nL8ZZpTh1jMSvaTRks --- record/decisions.d/ADR-tmpfdvjo.md | 186 +++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 record/decisions.d/ADR-tmpfdvjo.md diff --git a/record/decisions.d/ADR-tmpfdvjo.md b/record/decisions.d/ADR-tmpfdvjo.md new file mode 100644 index 00000000..d40e8625 --- /dev/null +++ b/record/decisions.d/ADR-tmpfdvjo.md @@ -0,0 +1,186 @@ +--- +status: Proposed +title: 'A chain may walk a composed relation' +version: 1 +tags: +- record +- mechanism +date: '2026-10-13' +issue: '#371' +summary: >- + A scheme may declare a composed relation: a name and the reference fields + it follows in order, `leads_to: [premise_of, concludes]`, from the scheme + through one or more other schemes and back. A chain names it in + `relation` or `sibling` as it names a field, so a chain stays a sequence + within one scheme while a step passes through another. It is read-only: + never written, completed or converse-paired, and every leg must land in + exactly one scheme. The page names the document each step passed through + and shows where a path stops partway. Rejected: a hop key on the chain, a + derived reference field (`derive`/`from:`), writing the relation into + frontmatter, walking intermediates as steps, legs over a union of + schemes, and a query language in `luria.yaml`. +influenced_by: +- ADR-106 +- ADR-125 +- ADR-126 +- ADR-127 +--- + +# ADR-tmpfdvjo: A chain may walk a composed relation + +## Context + +A chain walks reference fields: `relation` names one field or several +([#211](https://github.com/dmarx/luria/issues/211)), `sibling` one more, all declared on the chain's own scheme. A chain +is a sequence within one scheme ([ADR-106](ADR-106.md)), and [ADR-126](ADR-126.md) kept it so when +references gained unions: a chain walks a relation only if its own scheme +is the sole target, because skipping the edges that leave would drop steps +without a finding. + +That leaves a line the record states and no chain can draw. [ADR-127](ADR-127.md) names +it: in a downstream record, an argument runs claim → inference → claim. The +inference is its own document, citing the claims it starts from and the +claim it concludes, so the step from one claim to the next is real but is +two edges through another scheme. The chain stops at the inference, and +the line of argument the record was built to show is never rendered. +Illustratively: + +```yaml +schemes: + CLAIM: + fields: + premise_of: {scheme: INF, many: true, converse: premises} + INF: + fields: + premises: {scheme: CLAIM, many: true, converse: premise_of} + concludes: {scheme: CLAIM} +``` + +[ADR-127](ADR-127.md) put "chains over a derived relation rather than a field" after the +logic core, outside its decision. The core is in ([#360](https://github.com/dmarx/luria/issues/360)): `held/4` in +`luria/logic/relations.lp` reads every declared relation from either side, +and the joins this needs are one rule over it. + +Two words are already taken. A *derived field* is `derive` ([#216](https://github.com/dmarx/luria/issues/216)): a value +computed from a template, read everywhere as an ordinary field. `paths:` is +the settings table for directories. So this decision says *composed*. + +## Decision + +**A scheme may declare composed relations.** Each is a name and the +reference fields it follows, in order: + +```yaml +schemes: + CLAIM: + composed: + leads_to: [premise_of, concludes] +chains: + argument: + scheme: CLAIM + relation: leads_to + output: docs/arguments.md +``` + +`A leads_to B` holds when A stands in `premise_of` to some INF document I, +and I stands in `concludes` to B. Each leg is read through `held/4`, so a +leg stated only on its converse side counts, as it does for every chain +today. + +- **Declared on the scheme, named once.** A chain names a composed relation + in `relation` or `sibling` wherever it names a field, and may mix it with + fields in one spine as [#211](https://github.com/dmarx/luria/issues/211) mixes fields. The chain gains no key, and + the name is a fact of the scheme that `luria facts` prints: + `composed(S, N)` and `composed_leg(S, N, I, F)`. +- **Every leg lands in exactly one scheme, and the last in the declaring + one.** The first field is a reference declared on the scheme. Each later + field is declared on the sole target of the field before it. The last + field's sole target is the declaring scheme. A leg whose field names a + union is refused at load, for [ADR-126](ADR-126.md)'s reason: following only the + codes that land in the next leg's scheme would drop edges with no + finding. These are new refusals in `consistency.lp`, beside + `walk_leaves`. +- **Read-only.** A composed relation is never written into frontmatter, + has no converse, is never completed by `luria link --fix`, and cannot be + written by `luria relate`. It restates facts two fields already hold, so + it has no source of its own to drift from. +- **The step says what it passed through.** A spine step from a composed + relation renders under its parent as a field step does, and names the + intermediate documents that connect the pair: "— through INF-004". With + several, all are named, in code order. The page shows the line; the + inference is still where the argument is read. +- **A path that stops partway is shown, not dropped.** An intermediate the + first leg reaches and the last leg never leaves (an inference with + premises and no conclusion yet) is listed on the chain's page under the + step it hangs from, as "open at INF-007". It is not a lint finding, + because an inference in progress is a legitimate state, and nothing + mechanical can supply its conclusion. Not drawing it would read exactly + like a line that ends there ([DP-15](../principles.d/DP-015.md)). +- **A chain's `invariant` covers composed steps.** It is checked between + the two endpoints, as for any edge the chain walks. A composed relation + takes no `invariant` of its own here: [ADR-106](ADR-106.md)'s relation-level + invariant belongs to a field, and its legs already carry one if they + need it. +- **Derived in the logic core.** A rule in `luria/logic/` derives the + composed edges and the intermediates, recursing over leg index so + length is not fixed at two. Python reads the derived atoms into the same + spine the walk already consumes, so cycles, depth, ordering and facets + behave the same whichever kind of step filled them. + +## Alternatives considered + +- **A hop key on the chain** (`through: INF`, or a leg list under the + chain). Smaller, but the composition would be restated in every chain + that walks it, and a chain's config would carry a second way to name a + relation beside the fields. On the scheme it is one declaration that + `luria facts`, and later a report or [ADR-127](ADR-127.md)'s queries, can read without + a chain. +- **A derived reference field**, by extending `derive` and `from:`. That + machinery renders a template against one hop to one document + (`source[0]`), and its result is an ordinary field everywhere, which a + reference would then expose to the contract, converse pairing and the + fixer. A composed relation fans out over every intermediate, and the + page needs to know which intermediate connected each pair, which a field + value loses. +- **Writing the relation into frontmatter**, kept by the fixer as a + materialized `leads_to:`. The same fact stored twice, free to disagree + with the two fields it restates, and a fixer writing a claim nobody + stated. +- **Walking intermediates as steps** (rendering the INF documents in the + spine). [ADR-106](ADR-106.md) refused far-scheme nodes in a chain. Facets, status + vocabularies and invariants are the chain scheme's, and a line that + alternates schemes reads as neither. +- **Legs over a union of schemes**, following only the codes that land in + the next leg's scheme. This is the union skip [ADR-126](ADR-126.md) refused, one level + down. A record that needs it can declare one composed relation per + route; widening this later is additive. +- **A query language in `luria.yaml`** (a pattern, or ASP rules). [ADR-125](ADR-125.md) + keeps configuration readable as the rule in the line. A list of field + names is that. A rule body is not, and the `.lp` programs stay luria's + implementation. +- **Status quo.** Records that argue through intermediate documents keep + writing their lines in prose, one paragraph per participant, which is + the duplication chains were built to end ([#171](https://github.com/dmarx/luria/issues/171)). + +## Consequences + +- Records that declare no composed relation are unchanged. Under + [ADR-127](ADR-127.md)'s rule, the change merges only if `luria lint` and `luria index` + output is byte-identical on luria's own record, every example, + dmarx/nucleation and dmarx/anthology-of-the-sota. +- Anything that reads a chain's `relation` as field names must accept a + composed name. Today that is the chain loader, `consistency.lp`'s + `walk_undeclared`, the chain facts and the page's intro sentence, which + should say `` `leads_to:` (premise_of → concludes) ``. +- A composed relation and a field may not share a name on one scheme. A + refusal at load keeps every name a chain uses meaning one thing. +- The chain page grows two renderings: "through" on a step and "open at" + under one. Both need tests against a real record before they are + trusted, and an example that declares the shape. `examples/` has none + today. +- The derivation is joins only: no choice rules, one answer set, so the + page stays deterministic, as [ADR-127](ADR-127.md) requires. +- Reports and invariants over composed relations, and a composed relation + used outside chains, are not decided here. They become possible because + the declaration lives on the scheme, and are left to whichever record + needs them first. From a7a7bb8bf54811e8512836410b1d71e273c0edec Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 02:59:42 +0000 Subject: [PATCH 2/6] Rewrite draft ADR around named path patterns (#371) Chains walk a named Cypher-syntax path pattern, parsed by GraphGlot and lowered to clingo over held/4, replacing the composed field list. Devlog records the design path and the Cypher-to-Datalog tool survey. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xS6nL8ZZpTh1jMSvaTRks --- record/decisions.d/ADR-tmpfdvjo.md | 338 ++++++++++++++++----------- record/devlog.d/2026/10/13/025600.md | 58 +++++ 2 files changed, 255 insertions(+), 141 deletions(-) create mode 100644 record/devlog.d/2026/10/13/025600.md diff --git a/record/decisions.d/ADR-tmpfdvjo.md b/record/decisions.d/ADR-tmpfdvjo.md index d40e8625..82c532bb 100644 --- a/record/decisions.d/ADR-tmpfdvjo.md +++ b/record/decisions.d/ADR-tmpfdvjo.md @@ -1,6 +1,6 @@ --- status: Proposed -title: 'A chain may walk a composed relation' +title: 'A chain may walk a named path pattern' version: 1 tags: - record @@ -8,17 +8,17 @@ tags: date: '2026-10-13' issue: '#371' summary: >- - A scheme may declare a composed relation: a name and the reference fields - it follows in order, `leads_to: [premise_of, concludes]`, from the scheme - through one or more other schemes and back. A chain names it in - `relation` or `sibling` as it names a field, so a chain stays a sequence - within one scheme while a step passes through another. It is read-only: - never written, completed or converse-paired, and every leg must land in - exactly one scheme. The page names the document each step passed through - and shows where a path stops partway. Rejected: a hop key on the chain, a - derived reference field (`derive`/`from:`), writing the relation into - frontmatter, walking intermediates as steps, legs over a union of - schemes, and a query language in `luria.yaml`. + A record may declare named path patterns in Cypher's pattern syntax, such + as `(a:CLAIM)-[:premise_of]->(i:INF)-[:concludes]->(b:CLAIM)`. Node labels + are schemes, edge types are reference fields, and an edge is a relation as + `held/4` reads it. Patterns are parsed by GraphGlot and lowered by luria + to clingo rules over the record's facts, so the logic core stays the one + engine. A chain names a pattern in `relation` or `sibling` as it names a + field. The pattern's end nodes are the step and its named intermediates + render as "through". The subset is checked against the schema at load and + is stratified by construction. Rejected: a field list on the scheme, + clingo rule bodies as queries, a graph engine, grand-cypher over + networkx, Raqlet, and a hand-written grammar as the first choice. influenced_by: - ADR-106 - ADR-125 @@ -26,55 +26,50 @@ influenced_by: - ADR-127 --- -# ADR-tmpfdvjo: A chain may walk a composed relation +# ADR-tmpfdvjo: A chain may walk a named path pattern ## Context A chain walks reference fields: `relation` names one field or several ([#211](https://github.com/dmarx/luria/issues/211)), `sibling` one more, all declared on the chain's own scheme. A chain -is a sequence within one scheme ([ADR-106](ADR-106.md)), and [ADR-126](ADR-126.md) kept it so when -references gained unions: a chain walks a relation only if its own scheme -is the sole target, because skipping the edges that leave would drop steps -without a finding. - -That leaves a line the record states and no chain can draw. [ADR-127](ADR-127.md) names -it: in a downstream record, an argument runs claim → inference → claim. The -inference is its own document, citing the claims it starts from and the -claim it concludes, so the step from one claim to the next is real but is -two edges through another scheme. The chain stops at the inference, and -the line of argument the record was built to show is never rendered. -Illustratively: - -```yaml -schemes: - CLAIM: - fields: - premise_of: {scheme: INF, many: true, converse: premises} - INF: - fields: - premises: {scheme: CLAIM, many: true, converse: premise_of} - concludes: {scheme: CLAIM} -``` - -[ADR-127](ADR-127.md) put "chains over a derived relation rather than a field" after the -logic core, outside its decision. The core is in ([#360](https://github.com/dmarx/luria/issues/360)): `held/4` in -`luria/logic/relations.lp` reads every declared relation from either side, -and the joins this needs are one rule over it. - -Two words are already taken. A *derived field* is `derive` ([#216](https://github.com/dmarx/luria/issues/216)): a value -computed from a template, read everywhere as an ordinary field. `paths:` is -the settings table for directories. So this decision says *composed*. +is a sequence within one scheme ([ADR-106](ADR-106.md)), and [ADR-126](ADR-126.md) kept it so: a chain +walks a relation only if its own scheme is the sole target, because +skipping the edges that leave would drop steps without a finding. + +That leaves lines the record states and no chain can draw. [ADR-127](ADR-127.md) names +one: in a downstream record an argument runs claim → inference → claim, and +the chain stops at the inference. A larger one is on the way. A record +that develops arguments wants a view that walks from a question, through +its candidate answers, the objections to them and the replies, to what was +concluded. Such views are the units a manuscript could later be composed +from. + +[ADR-127](ADR-127.md) left "user-facing queries … and chains over a derived relation" for +after the logic core. It rejected Cypher as an *engine* ("it needs an +engine luria would have to ship"), but said its path patterns read well. +[ADR-125](ADR-125.md) sets the bar for anything in `luria.yaml`: a reader sees the rule in +the line. A path pattern meets that bar. A clingo rule body does not. + +The questions these views ask are path questions: which documents connect +by this sequence of typed hops, through what. Path patterns are the +notation built for that, and the translation from a pattern to Datalog is +mechanical: + +- a hop is a join; +- alternation is a union; +- variable length is transitive closure; +- `WHERE NOT` over a pattern is stratified negation. ## Decision -**A scheme may declare composed relations.** Each is a name and the -reference fields it follows, in order: +**A record may declare named path patterns**, and a chain may walk one. ```yaml -schemes: - CLAIM: - composed: - leads_to: [premise_of, concludes] +patterns: + leads_to: (a:CLAIM)-[:premise_of]->(i:INF)-[:concludes]->(b:CLAIM) + answered: (q:Q)<-[:answers]-(c:CLAIM) + unchallenged: (c:CLAIM) WHERE NOT (c)<-[:challenges]-(:OBJ) + chains: argument: scheme: CLAIM @@ -82,105 +77,166 @@ chains: output: docs/arguments.md ``` -`A leads_to B` holds when A stands in `premise_of` to some INF document I, -and I stands in `concludes` to B. Each leg is read through `held/4`, so a -leg stated only on its converse side counts, as it does for every chain -today. - -- **Declared on the scheme, named once.** A chain names a composed relation - in `relation` or `sibling` wherever it names a field, and may mix it with - fields in one spine as [#211](https://github.com/dmarx/luria/issues/211) mixes fields. The chain gains no key, and - the name is a fact of the scheme that `luria facts` prints: - `composed(S, N)` and `composed_leg(S, N, I, F)`. -- **Every leg lands in exactly one scheme, and the last in the declaring - one.** The first field is a reference declared on the scheme. Each later - field is declared on the sole target of the field before it. The last - field's sole target is the declaring scheme. A leg whose field names a - union is refused at load, for [ADR-126](ADR-126.md)'s reason: following only the - codes that land in the next leg's scheme would drop edges with no - finding. These are new refusals in `consistency.lp`, beside - `walk_leaves`. -- **Read-only.** A composed relation is never written into frontmatter, - has no converse, is never completed by `luria link --fix`, and cannot be - written by `luria relate`. It restates facts two fields already hold, so - it has no source of its own to drift from. -- **The step says what it passed through.** A spine step from a composed - relation renders under its parent as a field step does, and names the - intermediate documents that connect the pair: "— through INF-004". With - several, all are named, in code order. The page shows the line; the - inference is still where the argument is read. -- **A path that stops partway is shown, not dropped.** An intermediate the - first leg reaches and the last leg never leaves (an inference with - premises and no conclusion yet) is listed on the chain's page under the - step it hangs from, as "open at INF-007". It is not a lint finding, - because an inference in progress is a legitimate state, and nothing - mechanical can supply its conclusion. Not drawing it would read exactly - like a line that ends there ([DP-15](../principles.d/DP-015.md)). -- **A chain's `invariant` covers composed steps.** It is checked between - the two endpoints, as for any edge the chain walks. A composed relation - takes no `invariant` of its own here: [ADR-106](ADR-106.md)'s relation-level - invariant belongs to a field, and its legs already carry one if they - need it. -- **Derived in the logic core.** A rule in `luria/logic/` derives the - composed edges and the intermediates, recursing over leg index so - length is not fixed at two. Python reads the derived atoms into the same - spine the walk already consumes, so cycles, depth, ordering and facets - behave the same whichever kind of step filled them. +### What a pattern means + +- **A node label is a scheme.** Exactly one label per node, and every node + carries one, so each document's scheme is known at every position. +- **An edge type is a reference field** declared on the scheme of the node + the arrow leaves. `(a:S)-[:f]->(b:T)` holds when `held(S, f, a, b)` + does. That is the reading every chain and invariant already uses, so a + hop stated only on its converse side counts. +- **`<-[:f]-` reads the same field from the other end.** `(q:Q)<-[:answers]-(c:CLAIM)` + holds when `c`'s `answers` names `q`. +- **The target label filters.** A field that may name several schemes + ([ADR-126](ADR-126.md)) is a valid hop if the next node's label is one of its targets. + Codes landing in the other targets are not matches, and that is visible + in the line. This is not the silent drop [ADR-126](ADR-126.md) refused for chains: the + pattern names the scheme it follows. + +### The subset + +**In:** + +- one label per node; +- edge types with direction, and `|` between field names; +- variable length on a single edge (`-[:extends*1..]->`, a lower bound of + at least 1); +- literal equality on a node's frontmatter (`{status: 'Active'}`); +- `WHERE NOT` followed by a pattern that is not itself variable-length. + +**Out**, refused at load with the construct named: + +- `RETURN`, aggregation and ordering; +- `OPTIONAL MATCH`; +- several `MATCH` clauses; +- undirected edges; +- shortest path; +- any write clause. + +A pattern is written without `MATCH`. luria adds it. + +### How patterns are parsed and evaluated + +- **Parsing:** [GraphGlot](https://github.com/averdeny/graphglot), a + pure-Python parser for GQL and Cypher modelled on sqlglot, produces the + AST. luria uses no other part of it. +- **Lowering:** luria lowers the AST to a clingo program over `held/4`, + `doc/2` and `value/4`, one derived predicate per pattern. The logic + core stays the one engine ([ADR-127](ADR-127.md)). +- **Dependency:** GraphGlot is an optional extra, imported only when a + config declares `patterns:`. Without it, a config that declares patterns + is refused with the install line. + +### Checked at load + +- each label is a declared scheme; +- each edge type is a reference declared on the node it leaves, and the + next label is among that field's targets; +- each property is a field the node's scheme can hold; +- the construct is in the subset. + +These join the refusals in `consistency.lp`. + +### Stratified by construction + +The subset admits no recursion through negation. `WHERE NOT` takes only +patterns that are not variable-length, and variable length is positive. +So every lowered program has one answer set, and the views it feeds are +deterministic, as [ADR-127](ADR-127.md) requires. + +### How a chain uses a pattern + +- **The ends are the step.** A chain may name a pattern in `relation` or + `sibling`, alone or beside fields in one spine ([#211](https://github.com/dmarx/luria/issues/211)). The pattern's + first and last nodes must both be labelled with the chain's scheme. They + are the edge the chain walks. +- **Named intermediates are "through".** A variable bound on an inner node + (`i` above) is what the step renders as "— through INF-004". An + anonymous inner node (`(:INF)`) is matched and not shown. +- **A match that stops partway is shown.** A document of the chain's + scheme from which at least one hop of the pattern matches, and the whole + pattern does not, is listed on the chain's page as "open at" the + furthest node reached. An inference with premises and no conclusion yet + is a legitimate state, so this is not a lint finding. Not drawing it + would read exactly like a line that ends there ([DP-15](../principles.d/DP-015.md)). +- **A chain's `invariant` is checked between the two ends**, as for any + edge it walks. + +Read-only throughout: a pattern is never written into frontmatter, has no +converse, and is never completed by `luria link --fix`. ## Alternatives considered -- **A hop key on the chain** (`through: INF`, or a leg list under the - chain). Smaller, but the composition would be restated in every chain - that walks it, and a chain's config would carry a second way to name a - relation beside the fields. On the scheme it is one declaration that - `luria facts`, and later a report or [ADR-127](ADR-127.md)'s queries, can read without - a chain. -- **A derived reference field**, by extending `derive` and `from:`. That - machinery renders a template against one hop to one document - (`source[0]`), and its result is an ordinary field everywhere, which a - reference would then expose to the contract, converse pairing and the - fixer. A composed relation fans out over every intermediate, and the - page needs to know which intermediate connected each pair, which a field - value loses. -- **Writing the relation into frontmatter**, kept by the fixer as a - materialized `leads_to:`. The same fact stored twice, free to disagree - with the two fields it restates, and a fixer writing a claim nobody - stated. -- **Walking intermediates as steps** (rendering the INF documents in the - spine). [ADR-106](ADR-106.md) refused far-scheme nodes in a chain. Facets, status - vocabularies and invariants are the chain scheme's, and a line that - alternates schemes reads as neither. -- **Legs over a union of schemes**, following only the codes that land in - the next leg's scheme. This is the union skip [ADR-126](ADR-126.md) refused, one level - down. A record that needs it can declare one composed relation per - route; widening this later is additive. -- **A query language in `luria.yaml`** (a pattern, or ASP rules). [ADR-125](ADR-125.md) - keeps configuration readable as the rule in the line. A list of field - names is that. A rule body is not, and the `.lp` programs stay luria's - implementation. +- **A field list on the scheme** (`composed: {leads_to: [premise_of, + concludes]}`), the first draft of this decision. It is a path pattern + with fixed length, no alternation, no variable length and no negation, in + a syntax of luria's own that the first need beyond it would have to + replace. +- **clingo rule bodies as queries.** These are the most general and need + no parser. They fail [ADR-125](ADR-125.md)'s test of a rule read in the line. They can + recurse through negation and lose determinism. And luria cannot check + their endpoints at load, or name an intermediate, because it does not + know what the rule means. +- **A graph engine** (an embedded graph database executing Cypher). + [ADR-127](ADR-127.md) rejected this for the engine it ships. The decision here takes + the syntax and leaves the engine. +- **[grand-cypher](https://pypi.org/project/grand-cypher/)**, which executes + a Cypher subset over networkx graphs. It is a second evaluation engine + beside clingo, with results outside the logic core. Negated patterns and + `OPTIONAL MATCH` are listed there as unimplemented, and the first is + needed now, the second for the outline view. +- **[Raqlet](https://arxiv.org/abs/2508.03978)**, a research compiler from + Cypher and SQL/PGQ to Datalog (CIDR 2026). It is the closest thing to a + ready-made translation. No public release was found, it targets + Soufflé-style Datalog over a schema-generated EDB rather than clingo over + `held/4`, and it covers full Cypher, aggregation included, which is more + than this needs. The lowering here is small, because the subset is. +- **A hand-written grammar** for the subset, in a library such as lark. + This is viable, and it is the fallback if GraphGlot's API moves faster + than luria can follow. As the first choice it means owning a grammar and + its error messages for syntax a maintained parser already handles, + including the 3,897-scenario openCypher conformance suite it reports + passing. +- **GQL syntax instead of Cypher.** GQL is the ISO standard (39075:2024), + and GraphGlot parses both into the same AST. Cypher is the one people + know, and its variable-length and negation forms are shorter. Admitting + GQL spellings later is a parser switch, not a design change. - **Status quo.** Records that argue through intermediate documents keep writing their lines in prose, one paragraph per participant, which is the duplication chains were built to end ([#171](https://github.com/dmarx/luria/issues/171)). ## Consequences -- Records that declare no composed relation are unchanged. Under +- **A new optional dependency**, `graphglot`, pinned to a minor version + because it is pre-1.0 and releases often. It needs Python ≥ 3.11, as + luria does, and depends on click, PyYAML and rich. Tests pin the AST + shapes the lowering reads, so an upgrade that moves them fails a test + rather than a page. +- **Fired before writing this.** GraphGlot 0.14.3, Cypher dialect, parsed + every pattern in this decision, plus the variable-length, property-map + and `OPTIONAL MATCH` forms. In its GQL dialect the Cypher spellings of + variable length and negation fail, and the GQL spellings (`->{1,}`, + `NOT EXISTS { MATCH … }`) parse. The lowering is not written yet. +- **The facts become an interface.** Patterns lower to `held/4`, `doc/2` + and `value/4`, so changing those predicates changes what patterns mean. +- **Parity.** Records that declare no pattern are unchanged. Under [ADR-127](ADR-127.md)'s rule, the change merges only if `luria lint` and `luria index` output is byte-identical on luria's own record, every example, dmarx/nucleation and dmarx/anthology-of-the-sota. -- Anything that reads a chain's `relation` as field names must accept a - composed name. Today that is the chain loader, `consistency.lp`'s - `walk_undeclared`, the chain facts and the page's intro sentence, which - should say `` `leads_to:` (premise_of → concludes) ``. -- A composed relation and a field may not share a name on one scheme. A - refusal at load keeps every name a chain uses meaning one thing. -- The chain page grows two renderings: "through" on a step and "open at" - under one. Both need tests against a real record before they are - trusted, and an example that declares the shape. `examples/` has none - today. -- The derivation is joins only: no choice rules, one answer set, so the - page stays deterministic, as [ADR-127](ADR-127.md) requires. -- Reports and invariants over composed relations, and a composed relation - used outside chains, are not decided here. They become possible because - the declaration lives on the scheme, and are left to whichever record - needs them first. +- **Chain code** must accept a pattern name in `relation`. Today that + touches the chain loader, `consistency.lp`'s `walk_undeclared` and + `walk_leaves`, the chain facts, and the page's intro sentence, which + should print the pattern. +- **One name, one meaning:** a pattern and a reference field may not share + a name on a scheme a chain walks. +- **The chain page grows "through" and "open at".** Both need tests against + a real record, and an example that declares the shape. `examples/` has + none today. +- **Not decided here, and made possible by it:** + - an outline view that walks a pattern from a root across schemes, which + is where `OPTIONAL MATCH` would earn its place; + - transcluding document bodies, so views can compose into a manuscript; + - patterns rendered as reports, such as `unchallenged` above. + + Each is its own decision. This one keeps named intermediates and partial + matches available to them. diff --git a/record/devlog.d/2026/10/13/025600.md b/record/devlog.d/2026/10/13/025600.md new file mode 100644 index 00000000..0999ba90 --- /dev/null +++ b/record/devlog.d/2026/10/13/025600.md @@ -0,0 +1,58 @@ +--- +title: 'Chains over path patterns: how the design got there, and what parses Cypher' +created: '2026-10-13T02:56:00' +tags: [] +--- + +[#371](https://github.com/dmarx/luria/issues/371) asks for chains over a derived relation, the follow-on [ADR-127](../../record/decisions.d/ADR-127.md) +left open. The draft decision, [ADR-tmpfdvjo](../../record/decisions.d/ADR-tmpfdvjo.md), 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](../../record/decisions.d/ADR-127.md)'s "user-facing queries" +read as user-written Datalog. That fails [ADR-125](../../record/decisions.d/ADR-125.md)'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](../../record/decisions.d/ADR-127.md) 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 the translation luria writes is AST → clingo for a small subset, not a +parser. If GraphGlot's API churns faster than a pin can absorb, the +fallback is a lark grammar for the subset. The lowering is the same either +way. From a74f2c9d0abb57679a388d81ad919c6cb47beacd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 03:08:11 +0000 Subject: [PATCH 3/6] Draft ADR: parse path patterns with luria's own lark grammar (#371) GraphGlot moves to the rejected alternatives: luria writes the lowering either way, and a 15-line grammar accepts only the subset. Firing a scratch grammar corrected the node-label rule (a bare back reference is allowed); the devlog carries the grammar and its known gaps. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xS6nL8ZZpTh1jMSvaTRks --- record/decisions.d/ADR-tmpfdvjo.md | 92 ++++++++++++++++------------ record/devlog.d/2026/10/13/025600.md | 48 +++++++++++++-- 2 files changed, 98 insertions(+), 42 deletions(-) diff --git a/record/decisions.d/ADR-tmpfdvjo.md b/record/decisions.d/ADR-tmpfdvjo.md index 82c532bb..bd162e9d 100644 --- a/record/decisions.d/ADR-tmpfdvjo.md +++ b/record/decisions.d/ADR-tmpfdvjo.md @@ -11,14 +11,14 @@ summary: >- A record may declare named path patterns in Cypher's pattern syntax, such as `(a:CLAIM)-[:premise_of]->(i:INF)-[:concludes]->(b:CLAIM)`. Node labels are schemes, edge types are reference fields, and an edge is a relation as - `held/4` reads it. Patterns are parsed by GraphGlot and lowered by luria - to clingo rules over the record's facts, so the logic core stays the one - engine. A chain names a pattern in `relation` or `sibling` as it names a + `held/4` reads it. luria parses patterns with its own lark grammar for + the subset and lowers them to clingo rules over the record's facts, so + the logic core stays the one engine. A chain names a pattern in `relation` or `sibling` as it names a field. The pattern's end nodes are the step and its named intermediates render as "through". The subset is checked against the schema at load and is stratified by construction. Rejected: a field list on the scheme, clingo rule bodies as queries, a graph engine, grand-cypher over - networkx, Raqlet, and a hand-written grammar as the first choice. + networkx, Raqlet, and GraphGlot as the parser. influenced_by: - ADR-106 - ADR-125 @@ -79,8 +79,10 @@ chains: ### What a pattern means -- **A node label is a scheme.** Exactly one label per node, and every node - carries one, so each document's scheme is known at every position. +- **A node label is a scheme.** A node carries exactly one label, or none + when it refers back to a variable bound earlier in the pattern (`(c)` in + `WHERE NOT (c)<-[:challenges]-(:OBJ)`) and inherits that node's scheme. + So each document's scheme is known at every position. - **An edge type is a reference field** declared on the scheme of the node the arrow leaves. `(a:S)-[:f]->(b:T)` holds when `held(S, f, a, b)` does. That is the reading every chain and invariant already uses, so a @@ -97,14 +99,14 @@ chains: **In:** -- one label per node; +- one label per node, or a bare reference to a node already bound; - edge types with direction, and `|` between field names; - variable length on a single edge (`-[:extends*1..]->`, a lower bound of at least 1); - literal equality on a node's frontmatter (`{status: 'Active'}`); - `WHERE NOT` followed by a pattern that is not itself variable-length. -**Out**, refused at load with the construct named: +**Out**, refused at load: - `RETURN`, aggregation and ordering; - `OPTIONAL MATCH`; @@ -113,19 +115,24 @@ chains: - shortest path; - any write clause. -A pattern is written without `MATCH`. luria adds it. +A pattern is written without `MATCH`. The grammar has no production for +anything outside the subset, so an out-of-subset pattern fails to parse. +The refusal names the construct for the keywords and shapes people will +reach for (`MATCH`, `RETURN`, `OPTIONAL`, an undirected edge). Anything +else gets the parse position. ### How patterns are parsed and evaluated -- **Parsing:** [GraphGlot](https://github.com/averdeny/graphglot), a - pure-Python parser for GQL and Cypher modelled on sqlglot, produces the - AST. luria uses no other part of it. -- **Lowering:** luria lowers the AST to a clingo program over `held/4`, - `doc/2` and `value/4`, one derived predicate per pattern. The logic - core stays the one engine ([ADR-127](ADR-127.md)). -- **Dependency:** GraphGlot is an optional extra, imported only when a - config declares `patterns:`. Without it, a config that declares patterns - is refused with the install line. +- **Parsing:** a [lark](https://github.com/lark-parser/lark) grammar for + the subset, in `luria/`, parsed LALR. The grammar is the subset's + definition: what it accepts is what this decision admits, and widening + the subset is a reviewed change to one file. +- **Lowering:** luria lowers the parse tree to a clingo program over + `held/4`, `doc/2` and `value/4`, one derived predicate per pattern. The + logic core stays the one engine ([ADR-127](ADR-127.md)). +- **Dependency:** `lark`, a core dependency. It is pure Python with no + dependencies of its own, stable at 1.x, and small enough that an extra + with an install-line refusal would cost more than it saves. ### Checked at load @@ -191,32 +198,41 @@ converse, and is never completed by `luria link --fix`. Soufflé-style Datalog over a schema-generated EDB rather than clingo over `held/4`, and it covers full Cypher, aggregation included, which is more than this needs. The lowering here is small, because the subset is. -- **A hand-written grammar** for the subset, in a library such as lark. - This is viable, and it is the fallback if GraphGlot's API moves faster - than luria can follow. As the first choice it means owning a grammar and - its error messages for syntax a maintained parser already handles, - including the 3,897-scenario openCypher conformance suite it reports - passing. -- **GQL syntax instead of Cypher.** GQL is the ISO standard (39075:2024), - and GraphGlot parses both into the same AST. Cypher is the one people - know, and its variable-length and negation forms are shorter. Admitting - GQL spellings later is a parser switch, not a design change. +- **[GraphGlot](https://github.com/averdeny/graphglot) as the parser.** + It is a pure-Python, sqlglot-style parser for GQL and Cypher, and it + parsed every pattern here. But it does no translation to Datalog or SQL, + so luria would write the lowering either way. What it would add is a + parse tree shaped like a full GQL program, fifteen levels of wrapper + above the path, for a lowering that reads only the path. It is pre-1.0 + with releases days apart, so its shapes would need pinning. And it + accepts all of Cypher, so the subset would be enforced by a second pass + over a tree that had already said yes. A grammar of luria's own is about + fifteen lines, accepts only the subset, and hands the lowering the tree + it reads. +- **GQL syntax instead of Cypher.** GQL is the ISO standard (39075:2024). + Cypher is the one people know, and its variable-length (`*1..` against + `->{1,}`) and negation forms are shorter. Admitting GQL spellings later + is a grammar change, not a design change. - **Status quo.** Records that argue through intermediate documents keep writing their lines in prose, one paragraph per participant, which is the duplication chains were built to end ([#171](https://github.com/dmarx/luria/issues/171)). ## Consequences -- **A new optional dependency**, `graphglot`, pinned to a minor version - because it is pre-1.0 and releases often. It needs Python ≥ 3.11, as - luria does, and depends on click, PyYAML and rich. Tests pin the AST - shapes the lowering reads, so an upgrade that moves them fails a test - rather than a page. -- **Fired before writing this.** GraphGlot 0.14.3, Cypher dialect, parsed - every pattern in this decision, plus the variable-length, property-map - and `OPTIONAL MATCH` forms. In its GQL dialect the Cypher spellings of - variable length and negation fail, and the GQL spellings (`->{1,}`, - `NOT EXISTS { MATCH … }`) parse. The lowering is not written yet. +- **A new dependency**, `lark`, pure Python with none of its own. +- **luria owns the grammar and its error messages.** The subset is small + on purpose, and the grammar is where that is enforced. A request to + widen it is a change to the grammar, the lowering and this decision + together. +- **Fired before writing this.** A scratch grammar of 15 rule lines (lark + 1.3.1, LALR) parsed every pattern in this decision, plus the + variable-length and property-map forms. It refused `OPTIONAL MATCH`, an + undirected edge, `MATCH … RETURN` and a trailing `RETURN`. The first try + caught a rule this decision had wrong: `WHERE NOT (c)…` refers to a bound + node without a label, so "every node carries a label" became "a label, + or a reference to a node already bound". Its refusals are lark's + "unexpected token at column N", which is why naming the common + constructs is a separate step. The lowering is not written yet. - **The facts become an interface.** Patterns lower to `held/4`, `doc/2` and `value/4`, so changing those predicates changes what patterns mean. - **Parity.** Records that declare no pattern are unchanged. Under diff --git a/record/devlog.d/2026/10/13/025600.md b/record/devlog.d/2026/10/13/025600.md index 0999ba90..cb8ead99 100644 --- a/record/devlog.d/2026/10/13/025600.md +++ b/record/devlog.d/2026/10/13/025600.md @@ -52,7 +52,47 @@ things came out of sketching it, both outside this ADR: - **microsoft/openCypherTranspiler** targets T-SQL, and **cypher-for-gremlin** targets Gremlin. Neither fits. -So the translation luria writes is AST → clingo for a small subset, not a -parser. If GraphGlot's API churns faster than a pin can absorb, the -fallback is a lark grammar for the subset. The lowering is the same either -way. +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: + +```lark +// A path pattern: the subset ADR-tmpfdvjo 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: it +takes `*..` with no lower bound, which the ADR forbids (at least 1), and +it rejects a bare `*`, which Cypher 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. From e286fd22aa7e97e4aef88d499be2e9dc298a5d16 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 03:33:49 +0000 Subject: [PATCH 4/6] Chains may walk a named path pattern (#371) A `patterns:` family declares paths in Cypher's pattern syntax. luria's own lark grammar accepts only the subset and names the constructs it refuses; pattern_rules lowers a parse to clingo over held/4, so the logic core stays the one engine. Labels, hops, properties and chain endpoints are checked at load in consistency.lp. A chain names a pattern in `relation` or `sibling`; its page shows what each step went through and lists paths that stop partway, and a chain invariant covers pattern steps. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xS6nL8ZZpTh1jMSvaTRks --- luria/chains.py | 108 ++++++++++++-- luria/config.py | 88 +++++++++++- luria/config_doc.py | 27 ++-- luria/consistency.py | 167 ++++++++++++++++++++-- luria/facts.py | 4 + luria/invariants.py | 5 +- luria/logic/__init__.py | 26 ++-- luria/logic/consistency.lp | 44 +++++- luria/logic/invariants.lp | 4 + luria/pattern_rules.py | 256 +++++++++++++++++++++++++++++++++ luria/patterns.lark | 29 ++++ luria/patterns.py | 270 +++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- tests/test_pattern_chains.py | 151 ++++++++++++++++++++ tests/test_pattern_config.py | 161 +++++++++++++++++++++ tests/test_pattern_rules.py | 169 ++++++++++++++++++++++ tests/test_patterns.py | 97 +++++++++++++ tools/wheel_smoke.py | 9 +- 18 files changed, 1569 insertions(+), 48 deletions(-) create mode 100644 luria/pattern_rules.py create mode 100644 luria/patterns.lark create mode 100644 luria/patterns.py create mode 100644 tests/test_pattern_chains.py create mode 100644 tests/test_pattern_config.py create mode 100644 tests/test_pattern_rules.py create mode 100644 tests/test_patterns.py diff --git a/luria/chains.py b/luria/chains.py index ea04497f..76cca37a 100644 --- a/luria/chains.py +++ b/luria/chains.py @@ -43,6 +43,15 @@ no earliest step, which is a statement about the sequence and nothing else. It is also the one finding no fixer can repair, because which of two steps came first is not recoverable from the data. + +**A pattern stands in for a field (#371).** `relation` and `sibling` may +name a declared path pattern, which relates two documents of this scheme +through documents of another — a claim to the claims it follows from, +through the inference that concludes it. Its two ends are the edge, and the +walk below cannot tell it from a field. Two things only a pattern has reach +the page: the named inner nodes a step went *through*, and the paths that +stop partway, listed as *open* — an absent step reads exactly like a line +that ends there (DP-15). """ from __future__ import annotations @@ -53,8 +62,8 @@ from .adr_index import Adr, load_scheme, prefix_for from . import statuses from .config import current -from .contract import codes_of, for_scheme -from . import relations +from .contract import codes_of, for_scheme, local_scheme +from . import pattern_rules, relations MARKER = "" @@ -108,15 +117,44 @@ def _load(chain) -> tuple[dict[str, Adr], dict[str, list[str]], # reporting identical whether a record signs its succession or not. held: dict[str, set[str]] = {} for field in chain.relation: - for code, targets in relations.edges(chain.scheme, field).items(): + for code, targets in _edges(chain, field).items(): held.setdefault(code, set()).update(targets) - apart = (relations.edges(chain.scheme, chain.sibling) - if chain.sibling else {}) + apart = _edges(chain, chain.sibling) if chain.sibling else {} spine = {code: sorted(held.get(code, ())) for code in docs} cross = {code: sorted(apart.get(code, ())) for code in docs} return docs, spine, cross +def _edges(chain, name: str) -> dict[str, set[str]]: + """One relation the chain walks: a reference field, or a pattern by its + two ends (#371). The loader refuses a name that is both.""" + if name in current().patterns: + return pattern_rules.edges(name) + return relations.edges(chain.scheme, name) + + +def _patterns(chain) -> list[str]: + """The patterns this chain walks, spine then sibling.""" + names = [*chain.relation, *([chain.sibling] if chain.sibling else [])] + return [n for n in names if n in current().patterns] + + +def _through(chain) -> dict[tuple[str, str], list[str]]: + """Each pattern edge's named inner nodes, by (step, parent).""" + out: dict[tuple[str, str], set[str]] = {} + for name in _patterns(chain): + for pair, codes in pattern_rules.through(name).items(): + out.setdefault(pair, set()).update(codes) + return {pair: sorted(codes) for pair, codes in out.items()} + + +def _open(chain) -> list[tuple[str, str]]: + """(end, where it stops) for every path a walked pattern starts and + cannot finish.""" + return sorted({hit for name in _patterns(chain) + for hit in pattern_rules.open_ends(name)}) + + def _components(codes: list[str], neighbours: dict[str, set[str]] ) -> list[list[str]]: """Maximal weakly-connected groups, in the order their earliest member @@ -263,7 +301,9 @@ def _spine(chain) -> str: common case and `` `extends:` and `corrects:` `` for a signed one. One implementation, because the cycle finding and the page header name the same thing and drifting apart would be a small lie in two voices.""" - fields = [f"`{f}:`" for f in chain.relation] + patterns = current().patterns + fields = [f"`{f}` (`{patterns[f].match}`)" if f in patterns + else f"`{f}:`" for f in chain.relation] if len(fields) < 3: return " and ".join(fields) return ", ".join(fields[:-1]) + f" and {fields[-1]}" @@ -283,6 +323,23 @@ def _reaches(start: str, spine: dict[str, list[str]]) -> set[str]: return seen +def _elsewhere(codes) -> dict[str, Adr]: + """Documents of any scheme, by code — what a step went through, or where + a path stops, which need not be in the chain's own scheme.""" + out: dict[str, Adr] = {} + for prefix in {local_scheme(c) for c in codes} - {None}: + out.update((d.code, d) for d in load_scheme(current().schemes[prefix])) + return out + + +def _cite(code: str, docs: dict[str, Adr], chain) -> str: + doc, prefix = docs.get(code), local_scheme(code) + if doc is None or prefix is None: + return code + scheme = current().schemes[prefix] + return f"[{code}]({prefix_for(scheme, chain.output.parent)}{doc.path.name})" + + def _link(doc: Adr, chain) -> str: """A target that resolves from where the page renders. @@ -370,15 +427,21 @@ def _sections(chain, lines: list[Line]) -> list[tuple[str, list[Line]]]: return out -def _line_block(chain, line: Line, heading: str) -> list[str]: +def _line_block(chain, line: Line, heading: str, + through: dict | None = None, + elsewhere: dict | None = None) -> list[str]: head = line.spine[0] if line.spine else line.alongside[0] out = [f"{heading} From {head.title}", ""] + through, elsewhere = through or {}, elsewhere or {} for doc in line.spine: # A step with several parents nests under one of them; the rest # are named here so the page holds every declared edge, not the # subset a tree layout can draw. extra = line.also.get(doc.code, []) - tail = (f" — also extends {', '.join(extra)}" if extra else "") + via = through.get((doc.code, line.under.get(doc.code, "")), []) + tail = (f" — through {', '.join(_cite(c, elsewhere, chain) for c in via)}" + if via else "") + tail += (f" — also extends {', '.join(extra)}" if extra else "") out.append(" " * line.depth[doc.code] + "- " + _step(doc, chain) + tail) for doc in line.alongside: @@ -387,7 +450,28 @@ def _line_block(chain, line: Line, heading: str) -> list[str]: return out +def _open_block(chain, opens: list[tuple[str, str]], + elsewhere: dict[str, Adr]) -> list[str]: + """Paths a walked pattern starts and cannot finish, last on the page.""" + if not opens: + return [] + out = ["## Open", "", + "Paths that stop partway: each starts at a step and reaches a " + "document the rest of the pattern does not yet leave.", ""] + for end, at in opens: + doc = elsewhere.get(end) + title = f" — {doc.title}" if doc else "" + out.append(f"- {_cite(end, elsewhere, chain)}{title} — open at " + f"{_cite(at, elsewhere, chain)}") + return out + [""] + + def _render(chain, lines: list[Line]) -> str: + through, opens = _through(chain), _open(chain) + elsewhere = (_elsewhere({c for v in through.values() for c in v} + | {c for pair in opens for c in pair}) + if through or opens else {}) + tail = _open_block(chain, opens, elsewhere) count = f"{len(lines)} line" + ("" if len(lines) == 1 else "s") out = [MARKER, "", f"# {chain.title}", "", f"{count}, walked from {_spine(chain)} on {chain.scheme} " @@ -395,8 +479,8 @@ def _render(chain, lines: list[Line]) -> str: f"they came in.", ""] if not chain.invariant: for line in lines: - out += _line_block(chain, line, "##") - return "\n".join(out).rstrip() + "\n" + out += _line_block(chain, line, "##", through, elsewhere) + return "\n".join(out + tail).rstrip() + "\n" # Grouping is opt-in through the same key that opts into the check, so a # record that declares no invariant gets exactly the page it had. @@ -406,8 +490,8 @@ def _render(chain, lines: list[Line]) -> str: out.append(f"## {value}") out.append("") for line in group: - out += _line_block(chain, line, "###") - return "\n".join(out).rstrip() + "\n" + out += _line_block(chain, line, "###", through, elsewhere) + return "\n".join(out + tail).rstrip() + "\n" def outputs() -> dict: diff --git a/luria/config.py b/luria/config.py index 6490d537..87938970 100644 --- a/luria/config.py +++ b/luria/config.py @@ -136,6 +136,9 @@ def _own_version() -> str: # (ADR-011 in the consumer record, #171). Empty for a project that # declares no relation worth walking, which is every project today. "chains": {}, + # Named path patterns a chain may walk in place of a field (#371). Empty + # for a project whose lines all follow one reference field. + "patterns": {}, # Other projects whose records this one cites, keyed by a short prefix. A # reference then composes: `LU-ADR-013` is that remote's decision 13 # (ADR-016). @@ -1960,6 +1963,52 @@ def _fragment(spec) -> Fragment: return Fragment(Path(spec)) +@dataclass(frozen=True) +class NamedPattern: + """A named path pattern: a relation between documents that no single + field holds, written in Cypher's pattern syntax (#371). + + patterns: + follows_from: (b:CLAIM)<-[:concludes]-(i:INF)<-[:premise_of]-(a:CLAIM) + + A node's label is a scheme and an edge's type is a reference field + declared on the node the arrow leaves; the hop holds exactly when the + relation does, read from either side as a chain reads a field. The + pattern above relates a claim to the claims it follows from, through the + inference that concludes it — a step two fields state between them and + neither states alone. + + A chain names a pattern in `relation` or `sibling` wherever it names a + field. It reads like the field it stands in for: the first node is the + step, the last node the one it builds on, so `follows_from` nests a + conclusion under its premises. A named inner node (`i`) is shown as what + the step went *through*; an anonymous one (`(:INF)`) is matched and not + shown. + + The subset: one label per node (or a bare `(c)` naming a node bound + earlier), directed typed hops with `|` between fields, variable length on + one hop (`-[:extends*]->`, `*2..`, `*..3`), literal equality on a node's + frontmatter (`{status: 'Active'}`), and `WHERE NOT` followed by a fixed + path. `MATCH`, `RETURN`, `OPTIONAL MATCH`, undirected and named edges, + shortest paths and writes are refused at load, by name. Every label, + hop and property is checked against the schemes then too — a pattern + over a field nothing declares matches nothing, and nothing looks exactly + like a record with nothing to say (DP-15). + + The value is the pattern itself, or a table with `match:` and a `blurb:` + when the pattern wants a sentence of explanation.""" + name: str + match: str + blurb: str = "" + # The parse, done once at load so a malformed pattern is a config error + # rather than a page that fails to render. + _parsed: object = dcfield(default=None, compare=False, repr=False) + + @property + def parsed(self): + return self._parsed + + @dataclass(frozen=True) class Chain: """A relation walked transitively and rendered as sequences (#171). @@ -2145,6 +2194,7 @@ class Config: remotes: dict[str, Remote] journals: dict[str, Journal] chains: dict[str, Chain] + patterns: dict[str, NamedPattern] stale_days: int user_agent: str # what every request announces fail_on: tuple[str, ...] # warning classes promoted to failures @@ -2398,7 +2448,8 @@ def rel(self, path: Path) -> str: # The tables whose entries a project names, as opposed to the settings tables # whose keys Luria names. The distinction decides the merge rule (ADR-047). -FAMILIES = ("schemes", "fragments", "journals", "remotes", "chains") +FAMILIES = ("schemes", "fragments", "journals", "remotes", "chains", + "patterns") def load(root: Path | None = None, text: str | None = None, @@ -2489,7 +2540,9 @@ def load(root: Path | None = None, text: str | None = None, ) for name, spec in raw.get("journals", {}).items() }, - chains=_chains(raw.get("chains", {}), schemes, root), + patterns=(patterns := _patterns(raw.get("patterns") or {}, + schemes)), + chains=_chains(raw.get("chains", {}), schemes, root, patterns), stale_days=int(raw.get("stale_days", 90)), user_agent=str(raw.get("user_agent") or DEFAULTS["user_agent"]), @@ -2518,7 +2571,34 @@ def nameable(scheme) -> set[str]: | set(scheme.requires) | {"status"}) -def _chains(raw: dict, schemes: dict, root: Path) -> dict[str, Chain]: +def _patterns(raw: dict, schemes: dict) -> dict[str, NamedPattern]: + """Every declared pattern, parsed and checked against the schemes. + + Parsing refuses what is outside the subset (`patterns.parse`); the + checks against the schemes — labels, hops, properties — are + `consistency.check_patterns`, beside the other cross-scheme refusals.""" + from .patterns import parse + out: dict[str, NamedPattern] = {} + for name, spec in raw.items(): + name = str(name) + table = spec if isinstance(spec, dict) else {"match": spec} + if unknown := set(table) - {"match", "blurb"}: + raise ValueError( + f"luria.yaml: patterns.{name}: {', '.join(sorted(unknown))} " + f"is not a key — a pattern is its `match:` and an optional " + f"`blurb:`") + source = str(table.get("match") or "") + out[name] = NamedPattern(name=name, match=source.strip(), + blurb=str(table.get("blurb", "")).strip(), + _parsed=parse(name, source)) + if out: + from . import consistency + consistency.check_patterns(out, schemes) + return out + + +def _chains(raw: dict, schemes: dict, root: Path, + patterns: dict | None = None) -> dict[str, Chain]: """Every declared chain, checked against the schemes it walks. Validated here for the reason every other declaration is: a chain over a @@ -2537,7 +2617,7 @@ def listed(value) -> tuple[str, ...]: "invariant": str(spec.get("invariant", ""))} for name, spec in raw.items()} from . import consistency - consistency.check_chains(specs, schemes) + consistency.check_chains(specs, schemes, patterns or {}) return {name: Chain(name=name, scheme=s["scheme"], relation=s["relation"], sibling=s["sibling"], diff --git a/luria/config_doc.py b/luria/config_doc.py index 444ee4a3..49fa5a26 100644 --- a/luria/config_doc.py +++ b/luria/config_doc.py @@ -69,7 +69,8 @@ from . import config as config_mod from .adr_index import escape_cell from .config import (FAMILIES, Chain, FieldGroup, Fragment, Journal, - PlainField, Reference, Remote, RemoteScheme, RequiredWhen, + NamedPattern, PlainField, Reference, Remote, + RemoteScheme, RequiredWhen, Scheme, Site, TagGroup, Vocabulary, VocabularyTable, current) from . import writes @@ -103,11 +104,11 @@ ## The shape of the file -The tables below are the whole schema. Five of them — `schemes`, `fragments`, -`journals`, `remotes` and `chains` — are *families*: you name the entries, and -the name you choose becomes part of the vocabulary. `schemes.RFC` is how a -project gets RFCs; `journals.incidents` is how it gets a second -journal. No code path spells `ADR`. +The tables below are the whole schema. Six of them — `schemes`, `fragments`, +`journals`, `remotes`, `chains` and `patterns` — are *families*: you name the +entries, and the name you choose becomes part of the vocabulary. +`schemes.RFC` is how a project gets RFCs; `journals.incidents` is how it gets +a second journal. No code path spells `ADR`. That is worth stating plainly, because this package ships one instantiation of its own machinery — decisions, principles, a changelog and a devlog — and it @@ -134,6 +135,7 @@ | `fragments.` | many small files assembled into one document | yes, keyed by directory | | `journals.X` | dated entries that persist, rendered into books | yes, keyed by name | | `chains.X` | a relation walked transitively, rendered as sequences | yes, keyed by name | +| `patterns.X` | a path between documents no single field holds | yes, keyed by name | | `remotes.X` | another project's codes, cited from this one | yes, keyed by prefix | | `code` | which source files are scanned for stale references | — | | `lint` | which warning classes fail, and which are not reported | — | @@ -249,6 +251,12 @@ "Where a reference field gives a document its neighbours, a chain " "answers the question no single document holds: what line of work is " "this a step in?"), + NamedPattern: ("Patterns — `patterns.X`", + "One entry per path between documents that no single reference holds, " + "in Cypher's pattern syntax. A chain walks one wherever it would walk a " + "field, which is how a line passes through a document of another " + "kind — a claim to the claims it follows from, through the inference " + "that concludes it."), Remote: ("Remotes — `remotes.X`", "One entry per foreign project whose codes this record cites. The `uid` " "key is the general case: with a regex and a URL template, a remote need " @@ -277,7 +285,8 @@ #: here is appended rather than dropped, so a new nested table lands at the end #: of the page instead of nowhere. ORDER = [Scheme, Vocabulary, PlainField, RequiredWhen, Reference, TagGroup, - FieldGroup, VocabularyTable, Fragment, Journal, Chain, Remote, + FieldGroup, VocabularyTable, Fragment, Journal, Chain, + NamedPattern, Remote, RemoteScheme, Site] @@ -317,7 +326,8 @@ def sections() -> list[tuple[str, type, str]]: # whose default is a dict is a free-form mapping and belongs in the table above # it, as a row like any other key. SECTION_SUBTREES: frozenset[str] = frozenset( - {"paths", "lint", "site", "code", "schemes", "chains", "remotes", + {"paths", "lint", "site", "code", "schemes", "chains", "patterns", + "remotes", "journals", "fragments", "vocabularies"}) # Prose for the tables that have no dataclass behind them: scalars at the top @@ -456,6 +466,7 @@ def swap(m: re.Match) -> str: FROM_TABLE_NAME: dict[type, set[str]] = { Scheme: {"prefix"}, # `schemes.ADR` → prefix "ADR" Journal: {"name"}, # `journals.devlog` → name "devlog" + NamedPattern: {"name"}, # `patterns.follows_from` → its name Remote: {"prefix"}, # …but `Remote.name` IS a key, deliberately RemoteScheme: {"prefix"}, } diff --git a/luria/consistency.py b/luria/consistency.py index ce00c120..f544da12 100644 --- a/luria/consistency.py +++ b/luria/consistency.py @@ -28,7 +28,13 @@ **A chain is a sequence within one scheme.** Walking a relation that leaves it once reached a document the walker had never loaded (#272); the refusal -says where the assertion belongs instead. +says where the assertion belongs instead. A chain walking a pattern (#371) +holds the same line at the pattern's two ends, wherever its middle goes. + +**A pattern names schemes and fields.** Every label a declared scheme, every +hop a reference held by the node the arrow leaves and able to land on the +node it reaches, every property a field the node can hold. A pattern that +misses any of these matches nothing, silently. """ from __future__ import annotations @@ -80,6 +86,28 @@ def _chain_facts(chains: dict[str, dict]) -> list[Fact]: return out +def _pattern_facts(patterns: dict) -> list[Fact]: + out: list[Fact] = [] + for name, spec in patterns.items(): + pattern = spec.parsed + out.append(Fact("pattern", (name,))) + out.append(Fact("pat_hops", (name, len(pattern.path.hops)))) + out.append(Fact("pat_ends", (name, pattern.first.label, + pattern.last.label))) + for w, path in enumerate((pattern.path, *pattern.negations)): + for i, node in enumerate(path.nodes): + out.append(Fact("pat_node", (name, w, i, node.label))) + out += [Fact("pat_prop", (name, w, i, key)) + for key, _ in node.props] + for h, hop in enumerate(path.hops): + near, far = (h, h + 1) if hop.right else (h + 1, h) + out += [Fact("pat_hop", (name, w, h, near, far, f)) + for f in hop.fields] + if hop.variable: + out.append(Fact("pat_closure", (name, w, h))) + return out + + # The scheme checks run in two passes of one load (`config._schemes` raises # an undeclared target between other per-scheme checks, the rest after), and # both read one answer. Kept against the schemes dict it was derived from, @@ -88,12 +116,14 @@ def _chain_facts(chains: dict[str, dict]) -> list[Fact]: def _derived(schemes: dict, chains: dict[str, dict], - remotes=()) -> dict[str, set[tuple]]: + remotes=(), patterns: dict | None = None + ) -> dict[str, set[tuple]]: from . import logic - if chains: + if chains or patterns: return logic.derive("consistency", found=_scheme_facts(schemes, remotes) - + _chain_facts(chains)) + + _chain_facts(chains) + + _pattern_facts(patterns or {})) if _last["schemes"] is not schemes: _last.update(schemes=schemes, derived=logic.derive( "consistency", found=_scheme_facts(schemes, remotes))) @@ -219,7 +249,8 @@ def where(prefix, field, part): # ── the chains ──────────────────────────────────────────────────────────── -def check_chains(chains: dict[str, dict], schemes: dict) -> None: +def check_chains(chains: dict[str, dict], schemes: dict, + patterns: dict | None = None) -> None: """Refuse the first chain that would render an empty or meaningless page: per chain in order — its scheme, each `facet_by`, each field it walks (spine, then sibling), its `output`, its `invariant`. @@ -227,7 +258,8 @@ def check_chains(chains: dict[str, dict], schemes: dict) -> None: `chains` is each chain's raw declaration, normalised: `scheme` upper- cased, `relation` and `facet_by` as tuples.""" from .config import nameable, spelled - derived = _derived(schemes, chains) + patterns = patterns or {} + derived = _derived(schemes, chains, patterns=patterns) order = list(chains) found: list[tuple[tuple, str]] = [] @@ -253,12 +285,31 @@ def listing(prefix): for name, key, field in derived.get("walk_undeclared", ()): prefix = chains[name]["scheme"] declared = ", ".join(sorted(_refs(schemes, prefix))) or "none" + what = (f"neither a reference {prefix} declares nor a declared " + f"pattern" if patterns + else f"not a reference {prefix} declares") + also = (f"; patterns: {', '.join(patterns)}" if patterns else "") found.append(((order.index(name), 2, walked(name).index((key, field)), 0), ( f"luria.yaml: chains.{name}: `{key}` names {field!r}, which is " - f"not a reference {prefix} declares — a chain over a field " - f"nothing types walks no edges and renders an empty page " - f"(declared: {declared})"))) + f"{what} — a chain over a field nothing types walks no edges " + f"and renders an empty page (declared: {declared}{also})"))) + for name, key, field in derived.get("walk_pattern_ends", ()): + prefix = chains[name]["scheme"] + pattern = patterns[field].parsed + found.append(((order.index(name), 2, + walked(name).index((key, field)), 1), ( + f"luria.yaml: chains.{name}: `{key}` names pattern {field!r}, " + f"which runs from {pattern.first.label} to {pattern.last.label} " + f"— a chain is a sequence of {prefix} documents, so the pattern " + f"it walks starts and ends on {prefix}, wherever its middle " + f"goes"))) + for name, key, field in derived.get("walk_pattern_still", ()): + found.append(((order.index(name), 2, + walked(name).index((key, field)), 1), ( + f"luria.yaml: chains.{name}: `{key}` names pattern {field!r}, " + f"which has no hop — a chain walks from one document to " + f"another, and a single node relates nothing"))) for name, key, field in derived.get("walk_leaves", ()): prefix = chains[name]["scheme"] far = _ref(schemes, prefix, field).scheme @@ -284,3 +335,101 @@ def listing(prefix): f"nothing (nameable: {listing(prefix)})"))) if found: raise ValueError(min(found)[1]) + + +# ── the patterns ────────────────────────────────────────────────────────── + +def _node_text(node) -> str: + if node.bound: + return f"({node.var})" + return f"({node.var}:{node.label})" + + +def _hop_text(hop) -> str: + length = "" + if hop.variable: + high = "" if hop.high is None else str(hop.high) + length = f"*{hop.low}..{high}" + inner = f"[:{'|'.join(hop.fields)}{length}]" + return f"-{inner}->" if hop.right else f"<-{inner}-" + + +def check_patterns(patterns: dict, schemes: dict) -> None: + """Refuse the first pattern that cannot match what it says: per pattern + in order, a name a reference already uses, then each path (the match, + then each `WHERE NOT`) node by node and hop by hop.""" + from .config import nameable, spelled + derived = _derived(schemes, {}, patterns=patterns) + order = list(patterns) + found: list[tuple[tuple, str]] = [] + + def path(name, w): + p = patterns[name].parsed + return (p.path, *p.negations)[w] + + def where(name): + return f"luria.yaml: patterns.{name}: " + + for name, prefix in derived.get("pattern_shadows", ()): + found.append(((order.index(name), -1, 0, 0, 0), where(name) + ( + f"is also a reference {prefix} declares — a chain names either " + f"by the same key, so one name has to mean one thing"))) + for name, w, i in derived.get("pattern_label_unknown", ()): + node = path(name, w).nodes[i] + found.append(((order.index(name), w, i, 0, 0), where(name) + ( + f"`{_node_text(node)}`: {node.label} is not a declared scheme — " + f"a node's label is the scheme its documents belong to " + f"(have: {', '.join(sorted(schemes))})"))) + + def hop_field(name, w, h, field): + hop = path(name, w).hops[h] + text = _hop_text(hop) + return text if len(hop.fields) == 1 else f"{text}`: `{field}" + + def fields_at(name, w, h, kind): + # A derived atom names the hop, not the field: recover which of an + # alternation's fields is at fault, in the order they were written. + p = path(name, w) + hop = p.hops[h] + near, far = p.near(h), p.far(h) + for field in hop.fields: + ref = next((r for r in schemes[near.label].references + if r.field == field), None) + if kind == "undeclared" and ref is None: + yield field, near, far, ref + if (kind == "misses" and ref is not None + and far.label not in ref.scheme and "*" not in ref.scheme): + yield field, near, far, ref + + for name, w, h in derived.get("pattern_hop_undeclared", ()): + for field, near, far, _ in fields_at(name, w, h, "undeclared"): + refs = ", ".join(_refs(schemes, near.label)) or "none" + found.append(((order.index(name), w, h, 1, 0), where(name) + ( + f"`{hop_field(name, w, h, field)}` is not a reference " + f"{near.label} declares — a hop follows a field held by the " + f"node the arrow leaves, `{_node_text(near)}` " + f"(declared: {refs})"))) + break + for name, w, h in derived.get("pattern_hop_misses", ()): + for field, near, far, ref in fields_at(name, w, h, "misses"): + found.append(((order.index(name), w, h, 2, 0), where(name) + ( + f"`{hop_field(name, w, h, field)}` holds " + f"{spelled(ref.scheme)} codes, so it never lands on " + f"`{_node_text(far)}`"))) + break + for name, w, h in derived.get("pattern_closure_leaves", ()): + p = path(name, w) + found.append(((order.index(name), w, h, 3, 0), where(name) + ( + f"`{_hop_text(p.hops[h])}` runs from {p.near(h).label} to " + f"{p.far(h).label} — a variable-length hop stays within one " + f"scheme, since every step between its ends is held by a " + f"document of the same kind"))) + for name, w, i, key in derived.get("pattern_prop_unheld", ()): + node = path(name, w).nodes[i] + known = sorted(nameable(schemes[node.label])) + found.append(((order.index(name), w, i, 4, 0), where(name) + ( + f"`{_node_text(node)}`: `{key}` is not a field {node.label} " + f"declares, so the property never holds " + f"(nameable: {', '.join(known)})"))) + if found: + raise ValueError(min(found)[1]) diff --git a/luria/facts.py b/luria/facts.py index 352e5f40..34b3778a 100644 --- a/luria/facts.py +++ b/luria/facts.py @@ -23,6 +23,8 @@ ref_label(S, F, L). …what a view calls it chain(N, S). chain_relation(N, F, I). chain_sibling(N, F). chain_invariant(N, I). + pattern(P). a named path pattern (`patterns:`); what it + matches is derived, not a fact doc(C, S). every document, numbered or temporary status(C, W). its status word, without the note @@ -164,6 +166,8 @@ def schema_facts(cfg=None) -> Iterator[Fact]: yield Fact("chain_sibling", (name, chain.sibling)) if chain.invariant: yield Fact("chain_invariant", (name, chain.invariant)) + for name in cfg.patterns: + yield Fact("pattern", (name,)) def _invariant_fields(cfg) -> set[str]: diff --git a/luria/invariants.py b/luria/invariants.py index bee59752..5b2da0da 100644 --- a/luria/invariants.py +++ b/luria/invariants.py @@ -127,7 +127,10 @@ def shared(docs, field: str) -> set[str]: # atoms in the order the report has always printed them. def _derived() -> logic.Derived: - return logic.derive("relations", "invariants") + from .pattern_rules import record_program + text, families = record_program() + return logic.derive("relations", "invariants", extra=text, + families=families) def edges(chain: Chain) -> list[Unbound]: diff --git a/luria/logic/__init__.py b/luria/logic/__init__.py index 5ab7b021..5cb1629e 100644 --- a/luria/logic/__init__.py +++ b/luria/logic/__init__.py @@ -94,27 +94,35 @@ def solve(sources: Iterable[str], found: Iterable[Fact]) -> Derived: _lock = threading.Lock() -def derive(*names: str, found: Iterable[Fact] | None = None) -> Derived: +def derive(*names: str, found: Iterable[Fact] | None = None, + extra: str = "", families: frozenset[str] = frozenset() + ) -> Derived: """Run the named programs over the record's facts (or the facts given). + `extra` is program text generated from the configuration rather than + shipped as a `.lp` file — the lowered `patterns:` — and `families` the + fact families it reads beyond the named programs'. It is part of the + memo key, so two configurations never share an answer. + Over the record's own facts, only the families the programs read are built, and the result is kept until those facts change: `facts()` returns the same list object while the documents are unchanged, so that identity is the key. Locked, because the render pool asks from several threads and one solve serves them all.""" - label = "solve " + "+".join(names) + label = "solve " + "+".join(names) + ("+patterns" if extra else "") + sources = [rules(n) for n in names] + ([extra] if extra else []) if found is not None: with timed(label): - return solve([rules(n) for n in names], found) - families = set().union(*(needs(n) for n in names)) + return solve(sources, found) + wanted = set(families).union(*(needs(n) for n in names)) + key = (names, extra) with _lock: - current_facts = record_facts(families=families) + current_facts = record_facts(families=wanted) # Each entry holds the facts it was solved against, so the identity # test cannot be fooled by a list reused at the same address. - held = _memo["results"].get(names) + held = _memo["results"].get(key) if held is None or held[0] is not current_facts: with timed(label): - held = (current_facts, - solve([rules(n) for n in names], current_facts)) - _memo["results"][names] = held + held = (current_facts, solve(sources, current_facts)) + _memo["results"][key] = held return held[1] diff --git a/luria/logic/consistency.lp b/luria/logic/consistency.lp index 2940e317..73031bd7 100644 --- a/luria/logic/consistency.lp +++ b/luria/logic/consistency.lp @@ -8,7 +8,11 @@ % role_successor/2, role_retires_on/2, role_influence/2, status_word/2, and % per raw % chain: chain_scheme/2, chain_facet/2, chain_walks/3 (relation or sibling), -% chain_output/1, chain_invariant/2. +% chain_output/1, chain_invariant/2; and per pattern: pattern/1, +% pat_node/4 (pattern, path, position, label — path 0 is the match, 1.. each +% WHERE NOT), pat_hop/6 (pattern, path, hop, near position, far position, +% field), pat_closure/3, pat_prop/4, pat_ends/3 (first and last label), +% pat_hops/2. % ── references ────────────────────────────────────────────────────────── % A reference may only name a scheme or a remote that is declared. @@ -44,15 +48,41 @@ influence_undeclared(S, F) :- role_influence(S, F), not ref(S, F). chain_unknown(N) :- chain_scheme(N, S), not scheme(S). facet_unheld(N, X) :- chain_facet(N, X), chain_scheme(N, S), scheme(S), not nameable(S, X). -% A chain walks references, and only within its own scheme. +% A chain walks references or patterns, and only within its own scheme. walk_undeclared(N, K, F) :- chain_walks(N, K, F), chain_scheme(N, S), - scheme(S), not ref(S, F). + scheme(S), not ref(S, F), not pattern(F). walk_leaves(N, K, F) :- chain_walks(N, K, F), chain_scheme(N, S), ref_target(S, F, T), T != S. +walk_pattern_ends(N, K, P) :- chain_walks(N, K, P), pattern(P), + chain_scheme(N, S), scheme(S), + not pat_ends(P, S, S). +walk_pattern_still(N, K, P) :- chain_walks(N, K, P), pat_hops(P, 0). output_missing(N) :- chain_scheme(N, S), scheme(S), not chain_output(N). chain_invariant_unheld(N, I) :- chain_invariant(N, I), chain_scheme(N, S), scheme(S), not nameable(S, I). +% ── patterns ──────────────────────────────────────────────────────────── +% Every label a scheme; every hop a reference declared on the node the arrow +% leaves, able to land on the node it reaches; a closure within one scheme; +% every property a field the node's scheme can hold; and no pattern named +% like a reference, so a name a chain walks means one thing. +pattern_label_unknown(P, W, I) :- pat_node(P, W, I, L), not scheme(L). +pattern_hop_undeclared(P, W, H) :- pat_hop(P, W, H, Near, _, F), + pat_node(P, W, Near, S), scheme(S), + not ref(S, F). +pattern_hop_misses(P, W, H) :- pat_hop(P, W, H, Near, Far, F), + pat_node(P, W, Near, S), ref(S, F), + pat_node(P, W, Far, T), scheme(T), + not ref_target(S, F, T), + not ref_target(S, F, "*"). +pattern_closure_leaves(P, W, H) :- pat_closure(P, W, H), + pat_hop(P, W, H, Near, Far, _), + pat_node(P, W, Near, S), + pat_node(P, W, Far, T), S != T. +pattern_prop_unheld(P, W, I, F) :- pat_prop(P, W, I, F), pat_node(P, W, I, S), + scheme(S), not nameable(S, F). +pattern_shadows(P, S) :- pattern(P), ref(S, P). + #show unknown_target/3. #show converse_missing/3. #show converse_elsewhere/3. @@ -66,5 +96,13 @@ chain_invariant_unheld(N, I) :- chain_invariant(N, I), chain_scheme(N, S), #show facet_unheld/2. #show walk_undeclared/3. #show walk_leaves/3. +#show walk_pattern_ends/3. +#show walk_pattern_still/3. #show output_missing/1. +#show pattern_label_unknown/3. +#show pattern_hop_undeclared/3. +#show pattern_hop_misses/3. +#show pattern_closure_leaves/3. +#show pattern_prop_unheld/4. +#show pattern_shadows/2. #show chain_invariant_unheld/2. diff --git a/luria/logic/invariants.lp b/luria/logic/invariants.lp index 2c59ad94..9080ff36 100644 --- a/luria/logic/invariants.lp +++ b/luria/logic/invariants.lp @@ -16,6 +16,10 @@ walks(N, F) :- chain_relation(N, F, _). walks(N, F) :- chain_sibling(N, F). near(N, A, B) :- chain(N, S), chain_invariant(N, _), walks(N, F), held(S, F, A, B), doc(A, S), doc(B, S). +% A walked pattern (#371) is an edge by its two ends; `pattern_edge/3` +% comes from the lowered `patterns:` solved alongside, absent otherwise. +near(N, A, B) :- chain(N, S), chain_invariant(N, _), walks(N, P), + pattern_edge(P, A, B), doc(A, S), doc(B, S). near(N, A, B) :- near(N, B, A). chain_pair(N, A, B) :- near(N, A, B), A <= B. diff --git a/luria/pattern_rules.py b/luria/pattern_rules.py new file mode 100644 index 00000000..a4a1223a --- /dev/null +++ b/luria/pattern_rules.py @@ -0,0 +1,256 @@ +# luria/pattern_rules.py +"""A parsed path pattern, lowered to clingo rules over the record's facts. + +The translation is mechanical, which is why a pattern can be the notation +and the logic core can stay the engine (ADR-127): + +- a node is `doc(V, "LABEL")`, and a property map adds `status/2` or + `value/4` literals; +- a hop is `held/4` from `relations.lp` — the reading every chain and + invariant already uses, so a hop stated only on its converse side counts — + oriented by the arrow: `-[:f]->` is held by the node before it, + `<-[:f]-` by the node after; +- `|` between fields is one rule per field; +- variable length is a closure that counts steps up to the bound it needs, + so it terminates on a cycle; +- `WHERE NOT` is a helper predicate over the shared nodes, negated in the + match — non-recursive, so the program stays stratified and has one answer + set. + +**What is shown.** Three predicates, each keyed by the pattern's name: + + pattern_edge(P, First, Last) a whole match, by its two ends + pattern_via(P, First, Last, Var, X) a named inner node of that match + pattern_open(P, End, X) a partial match from either end + that cannot be completed, by the + end it starts from and the + furthest node it reached + +Open ends are read from both ends because a path can stop on either side of +its middle — an inference with premises and no conclusion is only visible +walking from the premise, one with a conclusion and no premises only from the +conclusion — and `WHERE NOT` is left out of them: a match a negation removes +is excluded, not unfinished. + +Every pattern's internal predicates are prefixed `pat_` by position, and +its name only ever appears as a quoted string, so no name a record chooses +can collide with a rule or inject one. +""" +from __future__ import annotations + +from .facts import _term +from .patterns import Path, Pattern + + +def program(patterns: dict[str, Pattern]) -> str: + """The clingo text for every pattern, in declaration order.""" + out = ["% Generated from luria.yaml `patterns:` — see pattern_rules.py."] + for k, pattern in enumerate(patterns.values()): + out += _Lowering(f"pat{k}", pattern).rules() + out += ["#show pattern_edge/3.", "#show pattern_via/5.", + "#show pattern_open/3."] + return "\n".join(out) + "\n" + + +def needs_values(patterns: dict[str, Pattern]) -> bool: + """Whether any property map reads a field other than `status`, which is + the only one the `documents` facts carry.""" + return any(key != "status" + for p in patterns.values() + for path in (p.path, *p.negations) + for node in path.nodes for key, _ in node.props) + + +class _Lowering: + def __init__(self, prefix: str, pattern: Pattern): + self.p = prefix + self.pattern = pattern + self.name = _term(pattern.name) + self.lines: list[str] = [] + # Main-path variable per node position; a bare reference shares the + # variable of the node it names. + self.main = self._vars(pattern.path, "N", {}) + self.bound = {n.var: v for n, v in zip(pattern.path.nodes, self.main) + if n.var} + + # ── naming ───────────────────────────────────────────────────────────── + + @staticmethod + def _vars(path: Path, stem: str, shared: dict[str, str]) -> list[str]: + seen = dict(shared) + out = [] + for i, node in enumerate(path.nodes): + if node.var and node.var in seen: + out.append(seen[node.var]) + continue + var = f"{stem}{i}" + if node.var: + seen[node.var] = var + out.append(var) + return out + + # ── bodies ───────────────────────────────────────────────────────────── + + def _node(self, node, var: str, first: bool) -> list[str]: + out = [f"doc({var}, {_term(node.label)})"] if first else [] + for key, value in node.props: + out.append(f"status({var}, {_term(value)})" if key == "status" + else f"value({var}, {_term(key)}, _, {_term(value)})") + return out + + def _hop(self, tag: str, path: Path, i: int) -> str: + """Define `tag(X, Y)`: node i to node i+1 by hop i, in path order. + Returns the predicate name.""" + hop = path.hops[i] + near = _term(path.near(i).label) + step = f"{tag}_s" if hop.variable else tag + for field in hop.fields: + held = (f"held({near}, {_term(field)}, X, Y)" if hop.right + else f"held({near}, {_term(field)}, Y, X)") + self.lines.append(f"{step}(X, Y) :- {held}.") + if hop.variable: + r = f"{tag}_r" + cap = hop.high if hop.high is not None else hop.low + self.lines.append(f"{r}(X, Y, 1) :- {step}(X, Y).") + self.lines.append( + f"{r}(X, Z, L + 1) :- {r}(X, Y, L), {step}(Y, Z), L < {cap}.") + if hop.high is None: + # Saturate at the lower bound: every longer walk is as good + # as one of exactly `low` steps, and the count stays finite. + self.lines.append( + f"{r}(X, Z, {cap}) :- {r}(X, Y, {cap}), {step}(Y, Z).") + self.lines.append(f"{tag}(X, Y) :- {r}(X, Y, {cap}).") + else: + self.lines.append( + f"{tag}(X, Y) :- {r}(X, Y, L), L >= {hop.low}.") + return tag + + def _body(self, tag: str, path: Path, vars_: list[str], + upto: int | None = None, start: int = 0) -> list[str]: + """Literals for nodes `start..upto` and the hops between them.""" + end = len(path.nodes) - 1 if upto is None else upto + out: list[str] = [] + declared: set[str] = set() + for i in range(start, end + 1): + node, var = path.nodes[i], vars_[i] + # Each variable stands on its label once per rule. In a negation + # that includes the shared nodes: redundant beside the match, + # and what keeps the helper safe when no hop mentions them. + out += self._node(node, var, first=var not in declared) + declared.add(var) + if i < end: + out.append(f"{tag}_h{i}({var}, {vars_[i + 1]})") + return out + + # ── rules ────────────────────────────────────────────────────────────── + + def rules(self) -> list[str]: + path = self.pattern.path + n = len(path.nodes) - 1 + for i in range(n): + self._hop(f"{self.p}_h{i}", path, i) + body = self._body(self.p, path, self.main) + for j, neg in enumerate(self.pattern.negations): + body.append(f"not {self._negation(j, neg)}") + args = ", ".join(self.main) + self.lines.append(f"{self.p}_m({args}) :- {', '.join(body)}.") + if n == 0: + return self.lines + first, last = self.main[0], self.main[n] + self.lines.append( + f"pattern_edge({self.name}, {first}, {last}) :- {self.p}_m({args}).") + shown = {path.nodes[0].var, path.nodes[n].var} + for i in range(1, n): + node = path.nodes[i] + if node.var and node.var not in shown: + shown.add(node.var) + self.lines.append( + f"pattern_via({self.name}, {first}, {last}, " + f"{_term(node.var)}, {self.main[i]}) :- {self.p}_m({args}).") + self._open(path, n) + return self.lines + + def _negation(self, j: int, neg: Path) -> str: + tag = f"{self.p}_n{j}" + vars_ = self._vars(neg, f"M{j}_", self.bound) + for i in range(len(neg.hops)): + self._hop(f"{tag}_h{i}", neg, i) + shared = list(dict.fromkeys( + v for v in vars_ if v in self.bound.values())) + head = f"{tag}({', '.join(shared)})" if shared else tag + body = self._body(tag, neg, vars_) + self.lines.append(f"{head} :- {', '.join(body)}.") + return head + + def _open(self, path: Path, n: int) -> None: + """Dead ends from the first node forward and the last node back.""" + v = self.main + for j in range(1, n + 1): + self.lines.append( + f"{self.p}_x{j}({', '.join(v[:j + 1])}) :- " + f"{', '.join(self._body(self.p, path, v, upto=j))}.") + self.lines.append( + f"{self.p}_y{j}({', '.join(v[n - j:])}) :- " + f"{', '.join(self._body(self.p, path, v, start=n - j))}.") + for j in range(1, n): + ahead, behind = ", ".join(v[:j + 1]), ", ".join(v[:j + 2]) + self.lines.append( + f"{self.p}_xe{j}({ahead}) :- {self.p}_x{j + 1}({behind}).") + self.lines.append( + f"pattern_open({self.name}, {v[0]}, {v[j]}) :- " + f"{self.p}_x{j}({ahead}), not {self.p}_xe{j}({ahead}).") + back, further = ", ".join(v[n - j:]), ", ".join(v[n - j - 1:]) + self.lines.append( + f"{self.p}_ye{j}({back}) :- {self.p}_y{j + 1}({further}).") + self.lines.append( + f"pattern_open({self.name}, {v[n]}, {v[n - j]}) :- " + f"{self.p}_y{j}({back}), not {self.p}_ye{j}({back}).") + + +# ── over the record ──────────────────────────────────────────────────────── + +def record_program(cfg=None) -> tuple[str, frozenset[str]]: + """The lowered `patterns:` of a config, and the fact families it reads + beyond `relations.lp`'s — ("", ∅) for a record that declares none.""" + from .config import current + cfg = cfg or current() + if not cfg.patterns: + return "", frozenset() + parsed = {name: p.parsed for name, p in cfg.patterns.items()} + return program(parsed), (frozenset({"values"}) if needs_values(parsed) + else frozenset()) + + +def derived() -> dict[str, set[tuple]]: + """Every pattern's matches over the record, solved once per change.""" + from . import logic + text, families = record_program() + if not text: + return {} + return logic.derive("relations", extra=text, families=families) + + +def edges(name: str) -> dict[str, set[str]]: + """A pattern as a chain reads a field: each first node, mapped to the + last nodes it matches.""" + out: dict[str, set[str]] = {} + for p, first, last in derived().get("pattern_edge", ()): + if p == name: + out.setdefault(first, set()).add(last) + return out + + +def through(name: str) -> dict[tuple[str, str], list[str]]: + """The named inner nodes of each match, by its two ends, in code order.""" + out: dict[tuple[str, str], set[str]] = {} + for p, first, last, _, code in derived().get("pattern_via", ()): + if p == name: + out.setdefault((first, last), set()).add(code) + return {k: sorted(v) for k, v in out.items()} + + +def open_ends(name: str) -> list[tuple[str, str]]: + """(end, furthest node reached) for every partial match that cannot be + completed, sorted.""" + return sorted((end, at) for p, end, at in derived().get("pattern_open", ()) + if p == name) diff --git a/luria/patterns.lark b/luria/patterns.lark new file mode 100644 index 00000000..3c77a92a --- /dev/null +++ b/luria/patterns.lark @@ -0,0 +1,29 @@ +// luria/patterns.lark +// A path pattern: the subset of Cypher's pattern syntax a record may +// declare (the ADR on chains walking a named path pattern). This grammar +// IS the subset — widening it is a change to this file, the lowering in +// `pattern_rules.py` and that decision together. `patterns.py` names the +// constructs it refuses; a parse failure reaching it unnamed gets a column. + +start: path (_WHERE _NOT path)* + +path: node (edge node)* + +node: "(" [NAME] [":" NAME] [props] ")" +props: "{" prop ("," prop)* "}" +prop: NAME ":" (STRING | INT) + +// An edge variable is accepted here only so it can be refused by name. +edge: "-[" [NAME] types [length] "]->" -> right + | "<-[" [NAME] types [length] "]-" -> left +types: ":" NAME ("|" NAME)* +length: "*" [INT] [RANGE [INT]] + +_WHERE: /where/i +_NOT: /not/i +RANGE: ".." +NAME: /[A-Za-z_][A-Za-z0-9_]*/ +STRING: /'[^']*'/ | /"[^"]*"/ +%import common.INT +%import common.WS +%ignore WS diff --git a/luria/patterns.py b/luria/patterns.py new file mode 100644 index 00000000..0d219bac --- /dev/null +++ b/luria/patterns.py @@ -0,0 +1,270 @@ +# luria/patterns.py +"""A named path pattern, parsed into the shape the lowering reads (#371). + + patterns: + follows_from: (b:CLAIM)<-[:concludes]-(i:INF)<-[:premise_of]-(a:CLAIM) + +The notation is Cypher's, restricted to what a path question needs: node +labels (schemes), typed directed hops (reference fields, `|` between +several), variable length on one hop, literal equality on a node's +frontmatter, and `WHERE NOT` over a pattern. Everything else — `MATCH`, +`RETURN`, `OPTIONAL MATCH`, undirected edges, named edges, shortest path, +any write — is refused here, by name, at load. ADR-125 asks that a reader +see the rule in the line; a pattern is that, and a clause language around it +would not be. + +**Why a grammar of luria's own** (the ADR's alternatives): no tool lowers +Cypher to clingo, so luria writes the lowering whatever parses, and a +general Cypher parser would hand it a whole query tree to walk down for the +path and then accept constructs this subset has to refuse in a second pass. +`patterns.lark` accepts only the subset. + +**Refusals by name.** lark's own message is "unexpected token at column N", +which tells a person what the parser saw and nothing about what they meant. +So a failed parse is diagnosed first against the constructs people reach +for, and only an unrecognised mistake falls back to the column. + +This module checks shape only: what a pattern *says*. Whether its labels +are schemes and its hops are declared references is the schema's question, +asked in `consistency.py` with every scheme in view. +""" +from __future__ import annotations + +import functools +import re +from dataclasses import dataclass +from importlib import resources + +import lark + + +class PatternError(ValueError): + """A pattern outside the subset, or one that cannot mean anything.""" + + +@dataclass(frozen=True) +class Node: + """One position in a path. `var` is "" for an anonymous node; `bound` + marks a bare `(c)` referring back to a node named earlier, whose label it + carries.""" + var: str + label: str + props: tuple[tuple[str, str], ...] + bound: bool = False + + +@dataclass(frozen=True) +class Hop: + """One typed edge between consecutive nodes. `right` is the arrow's + direction: `-[:f]->` leaves the node before it, `<-[:f]-` the node after. + `high` is None for an unbounded variable-length hop.""" + fields: tuple[str, ...] + right: bool + low: int = 1 + high: int | None = 1 + + @property + def variable(self) -> bool: + return (self.low, self.high) != (1, 1) + + +@dataclass(frozen=True) +class Path: + nodes: tuple[Node, ...] + hops: tuple[Hop, ...] + + def near(self, i: int) -> Node: + """The node hop `i` leaves: the one whose field holds the code.""" + return self.nodes[i] if self.hops[i].right else self.nodes[i + 1] + + def far(self, i: int) -> Node: + """The node hop `i` lands on.""" + return self.nodes[i + 1] if self.hops[i].right else self.nodes[i] + + +@dataclass(frozen=True) +class Pattern: + name: str + source: str + path: Path + negations: tuple[Path, ...] + + @property + def first(self) -> Node: + return self.path.nodes[0] + + @property + def last(self) -> Node: + return self.path.nodes[-1] + + +@functools.cache +def _parser() -> lark.Lark: + grammar = resources.files(__package__).joinpath( + "patterns.lark").read_text(encoding="utf-8") + return lark.Lark(grammar, parser="lalr", maybe_placeholders=True) + + +def parse(name: str, source: str) -> Pattern: + """`source` as a pattern, or a `PatternError` saying what is outside the + subset.""" + where = f"luria.yaml: patterns.{name}: " + text = str(source or "") + if not text.strip(): + raise PatternError(where + "is empty — a pattern is a path, such as " + "`(a:LIT)-[:extends]->(b:LIT)`") + try: + tree = _parser().parse(text) + except lark.UnexpectedInput as exc: + raise PatternError(where + _diagnose(text, exc)) from None + paths = [_path(where, p) for p in tree.children] + return _bind(where, name, text.strip(), paths[0], paths[1:]) + + +# ── shape ────────────────────────────────────────────────────────────────── + +def _path(where: str, tree: lark.Tree) -> tuple[list, list[Hop]]: + nodes, hops = [], [] + for child in tree.children: + if child.data == "node": + var, label, props = child.children + nodes.append((str(var or ""), str(label or ""), _props(props))) + else: + hops.append(_hop(where, child)) + return nodes, hops + + +def _props(tree) -> tuple[tuple[str, str], ...]: + if tree is None: + return () + out = [] + for prop in tree.children: + key, value = prop.children + text = str(value) + out.append((str(key), text[1:-1] if value.type == "STRING" else text)) + return tuple(out) + + +def _hop(where: str, tree: lark.Tree) -> Hop: + var, types, length = tree.children + fields = tuple(str(t) for t in types.children) + if var is not None: + raise PatternError( + where + f"`[{var}:{'|'.join(fields)}]` is a named edge, which is " + f"not in the subset — a hop is identified by its field, so write " + f"`[:{'|'.join(fields)}]`") + low, high = 1, 1 + if length is not None: + first, dots, last = length.children + if dots is None: + low = high = int(first) if first is not None else 1 + if first is None: + high = None + else: + low = int(first) if first is not None else 1 + high = int(last) if last is not None else None + if low < 1: + raise PatternError( + where + f"`*{low}..` — a variable-length hop takes at least 1 " + f"step; a pattern whose two ends may be one document says " + f"nothing about how they connect") + if high is not None and high < low: + raise PatternError(where + f"`*{low}..{high}` — the upper bound " + f"is below the lower, so nothing can match") + return Hop(fields, right=tree.data == "right", low=low, high=high) + + +def _bind(where: str, name: str, source: str, main, negated) -> Pattern: + """Resolve each bare `(c)` to the label bound earlier, and refuse what + cannot mean anything: a node with no label to stand on, a name bound + twice, a variable-length hop under `WHERE NOT`.""" + labels: dict[str, str] = {} + + def resolve(raw_nodes, shared: bool) -> tuple[Node, ...]: + local = dict(labels) + out = [] + for var, label, props in raw_nodes: + if not label: + if not var: + raise PatternError(where + "`()` needs a label — a node's " + "label is the scheme it stands on") + if var not in local: + raise PatternError( + where + f"`({var})` refers to `{var}`, which is not " + f"bound earlier in the pattern — a node needs a label " + f"(its scheme) the first time it appears") + out.append(Node(var, local[var], props, bound=True)) + continue + if var and var in local: + raise PatternError( + where + f"`({var}:{label})` — `{var}` is already bound; " + f"refer back to it as `({var})`") + if var: + local[var] = label + out.append(Node(var, label, props)) + if shared: + labels.update(local) + return tuple(out) + + nodes, hops = main + path = Path(resolve(nodes, shared=True), tuple(hops)) + negations = [] + for nodes, hops in negated: + if any(h.variable for h in hops): + raise PatternError( + where + "`WHERE NOT` takes a fixed path, not a " + "variable-length one — negation over a closure is the " + "recursion through negation the logic core cannot evaluate " + "deterministically") + negations.append(Path(resolve(nodes, shared=False), tuple(hops))) + return Pattern(name, source, path, tuple(negations)) + + +# ── refusals by name ─────────────────────────────────────────────────────── + +_CLAUSES = ("WITH", "UNWIND", "ORDER BY", "LIMIT", "SKIP", "UNION", "CALL") +_WRITES = ("CREATE", "MERGE", "SET", "DELETE", "DETACH", "REMOVE") + + +def _diagnose(text: str, exc: lark.UnexpectedInput) -> str: + """The construct a failed parse most likely reached for, in words.""" + bare = re.sub(r"'[^']*'|\"[^\"]*\"", "''", text) + + def has(pattern: str) -> bool: + return re.search(pattern, bare, re.IGNORECASE) is not None + + if has(r"^\s*match\b"): + return ("write the pattern without `MATCH` — luria adds it, and the " + "pattern is the whole query") + if has(r"\boptional\s+match\b"): + return ("`OPTIONAL MATCH` is not in the subset — a pattern matches " + "whole or not at all") + if has(r"\breturn\b"): + return ("`RETURN` is not in the subset — a pattern's nodes are its " + "result, and what reads it decides which it shows") + if has(r"\b(all)?shortestpath\b"): + return "`shortestPath` is not in the subset" + for word in _WRITES: + if has(rf"\b{word}\b"): + return f"`{word}` writes, and a pattern only reads" + for word in _CLAUSES: + if has(r"\b" + word.replace(" ", r"\s+") + r"\b"): + return f"`{word}` is not in the subset — a pattern is one path" + if has(r"\bwhere\b(?!\s+not\b)"): + return ("the only condition is `WHERE NOT` followed by a pattern; " + "equality on a " + "node is a property map, such as `(c:CLAIM {status: " + "'Active'})`") + if has(r"<-\[[^\]]*\]->") or has(r"(?)"): + return ("an undirected edge is not in the subset — a reference is " + "held by one end, so give the hop a direction: `-[:f]->` or " + "`<-[:f]-`") + if has(r"-->|<--|\)\s*--\s*\(") or has(r"\[\s*\]"): + return ("an edge needs a type — the reference field it follows, " + "such as `-[:extends]->`") + if has(r"\(\s*\w*\s*:\s*\w+\s*[:|]"): + return ("a node takes one label — the one scheme its documents " + "belong to") + column = getattr(exc, "column", "?") + return (f"cannot read this at column {column} (`{text.strip()}`) — " + f"see the configuration reference for the pattern subset") diff --git a/pyproject.toml b/pyproject.toml index 85dd0e52..b5f46992 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ readme = "README.md" requires-python = ">=3.11" license = { text = "MIT" } dependencies = ["PyYAML>=6", "fire>=0.7", "omegaconf>=2.3", "ruamel.yaml>=0.18", - "clingo>=5.6"] + "clingo>=5.6", "lark>=1.2,<2"] keywords = ["adr", "architecture-decision-records", "documentation", "lint", "project-memory", "changelog", "devlog"] classifiers = [ diff --git a/tests/test_pattern_chains.py b/tests/test_pattern_chains.py new file mode 100644 index 00000000..7133ddd3 --- /dev/null +++ b/tests/test_pattern_chains.py @@ -0,0 +1,151 @@ +# tests/test_pattern_chains.py +"""A chain walking a named path pattern (#371). + +The case that motivated it: an argument runs claim → inference → claim, the +inference is its own document, and a chain over fields stops at it. A +pattern relates the two claims through the inference, and the chain draws +the line the record states. +""" +from __future__ import annotations + +from pathlib import Path + +from _config import merged +from luria import chains, config, invariants + +BASE = """ +issue_url: https://example.test/issues/{n} +vocabularies: + topics: {logic: about logic, ethics: about ethics} +schemes: + CLAIM: + dir: record/claims.d + output: docs/claims + fields: + topic: {vocabulary: topics} + references: + premise_of: {scheme: INF, many: true, required: false, converse: premises} + INF: + dir: record/inferences.d + output: docs/inferences + references: + premises: {scheme: CLAIM, many: true, required: false, converse: premise_of} + concludes: {scheme: CLAIM, required: false} +patterns: + follows_from: (b:CLAIM)<-[:concludes]-(i:INF)<-[:premise_of]-(a:CLAIM) +chains: + argument: + scheme: CLAIM + relation: follows_from + output: docs/arguments.md + title: Lines of argument +""" + + +def project(tmp_path: Path, monkeypatch, *extra) -> Path: + (tmp_path / "luria.yaml").write_text(merged(BASE, *extra)) + monkeypatch.setenv("LURIA_ROOT", str(tmp_path)) + config.reset() + return tmp_path + + +def doc(root: Path, scheme: str, number: int, title: str, **fields) -> None: + code = f"{scheme}-{number:03d}" + folder = {"CLAIM": "claims.d", "INF": "inferences.d"}[scheme] + front = ["---", "status: Active", f"title: {title!r}", "date: '2026-01-01'"] + for key, value in fields.items(): + if isinstance(value, (list, tuple)): + front.append(f"{key}:") + front += [f"- {v}" for v in value] + else: + front.append(f"{key}: {value}") + front += ["---", "", f"# {code}: {title}", "", "Body."] + path = root / "record" / folder / f"{code}.md" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(front) + "\n") + + +def argument(root: Path) -> None: + """C1 and C2 are premises of I1, which concludes C3; C3 is a premise of + I2, which concludes nothing yet.""" + doc(root, "CLAIM", 1, "First premise", premise_of=["INF-001"]) + doc(root, "CLAIM", 2, "Second premise", premise_of=["INF-001"]) + doc(root, "CLAIM", 3, "The conclusion", premise_of=["INF-002"]) + doc(root, "INF", 1, "Modus ponens", concludes="CLAIM-003") + doc(root, "INF", 2, "An unfinished step") + + +def test_the_line_runs_through_the_inference(tmp_path, monkeypatch): + root = project(tmp_path, monkeypatch) + argument(root) + (line,) = chains.walk() + assert [d.code for d in line.spine] == ["CLAIM-001", "CLAIM-003", + "CLAIM-002"] + assert line.under == {"CLAIM-003": "CLAIM-001"} + assert line.also == {"CLAIM-003": ["CLAIM-002"]} + + +def test_the_page_names_what_each_step_went_through(tmp_path, monkeypatch): + root = project(tmp_path, monkeypatch) + argument(root) + page = chains.outputs()[root / "docs/arguments.md"] + assert (" - [CLAIM-003](../record/claims.d/CLAIM-003.md) — The conclusion " + "*(Active)* — through [INF-001](../record/inferences.d/INF-001.md)" + in page) + assert ("walked from `follows_from` " + "(`(b:CLAIM)<-[:concludes]-(i:INF)<-[:premise_of]-(a:CLAIM)`)" + in page) + + +def test_a_path_that_stops_partway_is_listed_as_open(tmp_path, monkeypatch): + root = project(tmp_path, monkeypatch) + argument(root) + page = chains.outputs()[root / "docs/arguments.md"] + assert "## Open" in page + assert ("- [CLAIM-003](../record/claims.d/CLAIM-003.md) — The conclusion — " + "open at [INF-002](../record/inferences.d/INF-002.md)" in page) + + +def test_a_converse_side_statement_draws_the_same_line(tmp_path, monkeypatch): + root = project(tmp_path, monkeypatch) + doc(root, "CLAIM", 1, "Premise") + doc(root, "CLAIM", 2, "Conclusion") + doc(root, "INF", 1, "Step", premises=["CLAIM-001"], concludes="CLAIM-002") + (line,) = chains.walk() + assert line.under == {"CLAIM-002": "CLAIM-001"} + + +def test_a_pattern_cycle_is_a_finding(tmp_path, monkeypatch): + root = project(tmp_path, monkeypatch) + doc(root, "CLAIM", 1, "A", premise_of=["INF-001"]) + doc(root, "CLAIM", 2, "B", premise_of=["INF-002"]) + doc(root, "INF", 1, "A so B", concludes="CLAIM-002") + doc(root, "INF", 2, "B so A", concludes="CLAIM-001") + (row,) = chains.rows() + assert "CLAIM-001 and CLAIM-002 each come before the other" in row + assert "`follows_from`" in row + + +def test_a_chain_invariant_covers_pattern_steps(tmp_path, monkeypatch): + root = project(tmp_path, monkeypatch, {"chains": {"argument": { + "invariant": "topic"}}}) + doc(root, "CLAIM", 1, "Premise", topic="logic", premise_of=["INF-001"]) + doc(root, "CLAIM", 2, "Conclusion", topic="ethics") + doc(root, "INF", 1, "Step", concludes="CLAIM-002") + (hit,) = invariants.edges(config.current().chains["argument"]) + assert hit.codes == ("CLAIM-001", "CLAIM-002") + + +def test_fields_and_patterns_share_one_spine(tmp_path, monkeypatch): + root = project(tmp_path, monkeypatch, { + "schemes": {"CLAIM": {"references": { + "refines": {"scheme": "CLAIM", "many": True, "required": False}}}}, + "chains": {"argument": {"relation": ["follows_from", "refines"]}}}) + doc(root, "CLAIM", 1, "Premise", premise_of=["INF-001"]) + doc(root, "CLAIM", 2, "Conclusion") + doc(root, "CLAIM", 3, "Refined conclusion", refines=["CLAIM-002"]) + doc(root, "INF", 1, "Step", concludes="CLAIM-002") + (line,) = chains.walk() + assert line.under == {"CLAIM-002": "CLAIM-001", "CLAIM-003": "CLAIM-002"} + page = chains.outputs()[root / "docs/arguments.md"] + assert "Refined conclusion *(Active)*\n" in page diff --git a/tests/test_pattern_config.py b/tests/test_pattern_config.py new file mode 100644 index 00000000..b95b00d8 --- /dev/null +++ b/tests/test_pattern_config.py @@ -0,0 +1,161 @@ +# tests/test_pattern_config.py +"""`patterns:` in luria.yaml, and the refusals that keep a pattern meaning +what it says (#371). + +A pattern over a label nothing declares, or a hop nothing types, matches +nothing — and a chain walking it renders an empty page, which reads exactly +like a record with nothing to say (DP-15). So each is refused at load, in +the words of the key to change. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from _config import merged +from luria import config + +SCHEMES = """ +vocabularies: + kinds: {x: an x} +schemes: + CLAIM: + dir: record/claims.d + output: docs/claims + fields: + kind: {vocabulary: kinds} + references: + premise_of: {scheme: INF, many: true, required: false, converse: premises} + INF: + dir: record/inferences.d + output: docs/inferences + references: + premises: {scheme: CLAIM, many: true, required: false, converse: premise_of} + concludes: {scheme: CLAIM, required: false} + OBJ: + dir: record/objections.d + output: docs/objections + references: + challenges: {scheme: [CLAIM, INF], required: false} + LIT: + dir: record/literature.d + output: docs/literature + references: + extends: {scheme: LIT, many: true, required: false} + cites: {scheme: CLAIM, many: true, required: false} +""" + +FOLLOWS = "(b:CLAIM)<-[:concludes]-(i:INF)<-[:premise_of]-(a:CLAIM)" + + +def load(tmp_path: Path, monkeypatch, *parts: str | dict) -> config.Config: + (tmp_path / "luria.yaml").write_text(merged(SCHEMES, *parts)) + monkeypatch.setenv("LURIA_ROOT", str(tmp_path)) + config.reset() + return config.load(tmp_path) + + +def refused(tmp_path, monkeypatch, *parts) -> str: + with pytest.raises(ValueError) as caught: + load(tmp_path, monkeypatch, *parts) + return str(caught.value) + + +def test_a_pattern_loads_as_a_string_or_a_table(tmp_path, monkeypatch): + cfg = load(tmp_path, monkeypatch, {"patterns": { + "follows_from": FOLLOWS, + "lineage": {"match": "(a:LIT)-[:extends*]->(b:LIT)", + "blurb": "Every ancestor."}}}) + assert cfg.patterns["follows_from"].match == FOLLOWS + assert cfg.patterns["lineage"].blurb == "Every ancestor." + assert cfg.patterns["lineage"].parsed.path.hops[0].high is None + + +def test_no_patterns_is_the_default(tmp_path, monkeypatch): + assert load(tmp_path, monkeypatch).patterns == {} + + +def test_an_unknown_key_is_refused(tmp_path, monkeypatch): + msg = refused(tmp_path, monkeypatch, + {"patterns": {"p": {"match": FOLLOWS, "returns": "a"}}}) + assert "patterns.p: returns is not a key" in msg + + +def test_a_syntax_error_is_a_config_error(tmp_path, monkeypatch): + msg = refused(tmp_path, monkeypatch, + {"patterns": {"p": "MATCH (a:LIT) RETURN a"}}) + assert msg.startswith("luria.yaml: patterns.p: write the pattern without") + + +@pytest.mark.parametrize("pattern, says", [ + ("(a:THEORY)-[:extends]->(b:LIT)", + "`(a:THEORY)`: THEORY is not a declared scheme"), + ("(a:LIT)-[:supersedes]->(b:LIT)", + "`-[:supersedes]->` is not a reference LIT declares"), + ("(a:LIT)<-[:extends]-(b:CLAIM)", + "`<-[:extends]-` is not a reference CLAIM declares"), + ("(a:LIT)-[:cites]->(b:INF)", + "`-[:cites]->` holds CLAIM codes, so it never lands on `(b:INF)`"), + ("(a:LIT)-[:extends|cites]->(b:LIT)", + "`-[:extends|cites]->`: `cites` holds CLAIM codes"), + ("(o:OBJ)-[:challenges*]->(c:CLAIM)", + "a variable-length hop stays within one scheme"), + ("(c:CLAIM {colour: 'red'})-[:premise_of]->(i:INF)", + "`colour` is not a field CLAIM declares"), + ("(c:CLAIM) WHERE NOT (c)<-[:challenges]-(:THEORY)", + "THEORY is not a declared scheme"), +]) +def test_a_pattern_is_checked_against_the_schemes(tmp_path, monkeypatch, + pattern, says): + msg = refused(tmp_path, monkeypatch, {"patterns": {"p": pattern}}) + assert msg.startswith("luria.yaml: patterns.p: ") + assert says in msg + + +def test_a_hop_into_a_union_is_valid_when_the_label_is_a_target( + tmp_path, monkeypatch): + cfg = load(tmp_path, monkeypatch, + {"patterns": {"p": "(o:OBJ)-[:challenges]->(i:INF)"}}) + assert "p" in cfg.patterns + + +def test_a_property_may_be_status_undeclared(tmp_path, monkeypatch): + load(tmp_path, monkeypatch, + {"patterns": {"p": "(c:CLAIM {status: 'Active', kind: 'x'})"}}) + + +def test_a_pattern_may_not_share_a_name_with_a_reference(tmp_path, monkeypatch): + msg = refused(tmp_path, monkeypatch, {"patterns": {"extends": FOLLOWS}}) + assert "patterns.extends: is also a reference LIT declares" in msg + + +CHAIN = {"chains": {"argument": {"scheme": "CLAIM", "relation": "follows_from", + "output": "docs/arguments.md"}}} + + +def test_a_chain_may_walk_a_pattern(tmp_path, monkeypatch): + cfg = load(tmp_path, monkeypatch, + {"patterns": {"follows_from": FOLLOWS}}, CHAIN) + assert cfg.chains["argument"].relation == ("follows_from",) + + +def test_a_chain_pattern_must_start_and_end_on_the_chain_scheme( + tmp_path, monkeypatch): + msg = refused(tmp_path, monkeypatch, {"patterns": { + "follows_from": "(i:INF)<-[:premise_of]-(a:CLAIM)"}}, CHAIN) + assert msg.startswith("luria.yaml: chains.argument: `relation` names " + "pattern 'follows_from', which runs from INF to CLAIM") + + +def test_a_chain_pattern_needs_a_hop(tmp_path, monkeypatch): + msg = refused(tmp_path, monkeypatch, + {"patterns": {"follows_from": "(c:CLAIM)"}}, CHAIN) + assert "pattern 'follows_from', which has no hop" in msg + + +def test_a_chain_naming_neither_says_patterns_count(tmp_path, monkeypatch): + msg = refused(tmp_path, monkeypatch, {"patterns": {"other": FOLLOWS}}, + CHAIN) + assert "'follows_from', which is neither a reference CLAIM declares " \ + "nor a declared pattern" in msg diff --git a/tests/test_pattern_rules.py b/tests/test_pattern_rules.py new file mode 100644 index 00000000..c616bf51 --- /dev/null +++ b/tests/test_pattern_rules.py @@ -0,0 +1,169 @@ +# tests/test_pattern_rules.py +"""Lowering a path pattern to clingo, checked against hand-written facts. + +The facts are the ones `facts.py` emits (`doc/2`, `status/2`, `value/4`, +`ref_value/3`, `ref_target/3`, `ref_converse/3`), and `relations.lp` reads +them into `held/4` exactly as every chain does — so a pattern's hop means +what a chain's field means, converse side included. +""" +from __future__ import annotations + +from luria import logic +from luria.facts import Fact +from luria.pattern_rules import program +from luria.patterns import parse + + +def F(pred, *args): + return Fact(pred, args) + + +SCHEMA = [ + F("ref_target", "CLAIM", "premise_of", "INF"), + F("ref_converse", "CLAIM", "premise_of", "premises"), + F("ref_target", "INF", "premises", "CLAIM"), + F("ref_converse", "INF", "premises", "premise_of"), + F("ref_target", "INF", "concludes", "CLAIM"), + F("ref_target", "OBJ", "challenges", "CLAIM"), + F("ref_target", "LIT", "extends", "LIT"), +] + +FOLLOWS = "(b:CLAIM)<-[:concludes]-(i:INF)<-[:premise_of]-(a:CLAIM)" + + +def solve(patterns: dict[str, str], facts: list[Fact]): + parsed = {n: parse(n, s) for n, s in patterns.items()} + return logic.solve([logic.rules("relations"), program(parsed)], + SCHEMA + facts) + + +def docs(scheme, *codes): + return [F("doc", c, scheme) for c in codes] + + +def test_a_two_hop_pattern_joins_through_the_intermediate(): + facts = (docs("CLAIM", "C1", "C2") + docs("INF", "I1") + + [F("ref_value", "C1", "premise_of", "I1"), + F("ref_value", "I1", "concludes", "C2")]) + got = solve({"follows_from": FOLLOWS}, facts) + assert got["pattern_edge"] == {("follows_from", "C2", "C1")} + assert got["pattern_via"] == {("follows_from", "C2", "C1", "i", "I1")} + + +def test_a_hop_stated_only_on_the_converse_side_counts(): + facts = (docs("CLAIM", "C1", "C2") + docs("INF", "I1") + + [F("ref_value", "I1", "premises", "C1"), + F("ref_value", "I1", "concludes", "C2")]) + got = solve({"follows_from": FOLLOWS}, facts) + assert got["pattern_edge"] == {("follows_from", "C2", "C1")} + + +def test_the_label_filters_where_a_code_lands(): + # C1's premise_of names a CLAIM, not an INF: not a match, and not an + # error here either — the contract owns codes in the wrong scheme. + facts = (docs("CLAIM", "C1", "C2", "C3") + + [F("ref_value", "C1", "premise_of", "C3")]) + assert "pattern_edge" not in solve({"f": FOLLOWS}, facts) + + +def test_a_path_stopping_partway_is_open_from_either_end(): + facts = (docs("CLAIM", "C1", "C2", "C3") + docs("INF", "I1", "I2", "I3") + + [F("ref_value", "C1", "premise_of", "I1"), # full: C2 <- I1 <- C1 + F("ref_value", "I1", "concludes", "C2"), + F("ref_value", "C1", "premise_of", "I2"), # premise, no conclusion + F("ref_value", "I3", "concludes", "C3")]) # conclusion, no premise + got = solve({"f": FOLLOWS}, facts) + assert got["pattern_edge"] == {("f", "C2", "C1")} + assert got["pattern_open"] == {("f", "C1", "I2"), ("f", "C3", "I3")} + + +def test_alternation_is_a_union_of_fields(): + schema = [F("ref_target", "REPLY", "rebuts", "OBJ"), + F("ref_target", "REPLY", "concedes", "OBJ")] + facts = (schema + docs("OBJ", "O1", "O2") + docs("REPLY", "R1", "R2") + + [F("ref_value", "R1", "rebuts", "O1"), + F("ref_value", "R2", "concedes", "O2")]) + got = solve({"met": "(o:OBJ)<-[:rebuts|concedes]-(r:REPLY)"}, facts) + assert got["pattern_edge"] == {("met", "O1", "R1"), ("met", "O2", "R2")} + + +def test_variable_length_is_the_closure_within_bounds(): + facts = (docs("LIT", "L1", "L2", "L3", "L4") + + [F("ref_value", "L2", "extends", "L1"), + F("ref_value", "L3", "extends", "L2"), + F("ref_value", "L4", "extends", "L3")]) + def edges(text): + return {(a, b) for _, a, b in + solve({"x": text}, facts).get("pattern_edge", ())} + assert edges("(a:LIT)-[:extends*]->(b:LIT)") == { + ("L2", "L1"), ("L3", "L2"), ("L4", "L3"), + ("L3", "L1"), ("L4", "L2"), ("L4", "L1")} + assert edges("(a:LIT)-[:extends*2]->(b:LIT)") == {("L3", "L1"), ("L4", "L2")} + assert edges("(a:LIT)-[:extends*2..]->(b:LIT)") == { + ("L3", "L1"), ("L4", "L2"), ("L4", "L1")} + assert edges("(a:LIT)<-[:extends*..2]-(b:LIT)") == { + ("L1", "L2"), ("L2", "L3"), ("L3", "L4"), ("L1", "L3"), ("L2", "L4")} + + +def test_variable_length_terminates_on_a_cycle(): + facts = (docs("LIT", "L1", "L2") + + [F("ref_value", "L1", "extends", "L2"), + F("ref_value", "L2", "extends", "L1")]) + got = solve({"x": "(a:LIT)-[:extends*]->(b:LIT)"}, facts) + assert len(got["pattern_edge"]) == 4 + + +def test_a_property_map_filters_on_status_and_on_any_field(): + facts = (docs("CLAIM", "C1", "C2") + docs("INF", "I1", "I2") + + [F("status", "C1", "Active"), F("status", "C2", "Withdrawn"), + F("value", "I1", "kind", 0, "deductive"), + F("value", "I2", "kind", 0, "abductive")] + + [F("ref_value", "C1", "premise_of", "I1"), + F("ref_value", "I1", "concludes", "C2"), + F("ref_value", "C2", "premise_of", "I2"), + F("ref_value", "I2", "concludes", "C1")]) + def edges(text): + return {(a, b) for _, a, b in + solve({"x": text}, facts).get("pattern_edge", ())} + assert edges("(b:CLAIM {status: 'Active'})<-[:concludes]-(i:INF)" + "<-[:premise_of]-(a:CLAIM)") == {("C1", "C2")} + assert edges("(b:CLAIM)<-[:concludes]-(i:INF {kind: 'deductive'})" + "<-[:premise_of]-(a:CLAIM)") == {("C2", "C1")} + + +def test_where_not_removes_matches_with_the_negated_path(): + facts = (docs("CLAIM", "C1", "C2", "C3") + docs("INF", "I1", "I2") + + docs("OBJ", "O1") + + [F("ref_value", "C1", "premise_of", "I1"), + F("ref_value", "I1", "concludes", "C2"), + F("ref_value", "C1", "premise_of", "I2"), + F("ref_value", "I2", "concludes", "C3"), + F("ref_value", "O1", "challenges", "C3")]) + got = solve({"x": FOLLOWS + " WHERE NOT (b)<-[:challenges]-(:OBJ)"}, + facts) + assert got["pattern_edge"] == {("x", "C2", "C1")} + + +def test_a_bare_reference_closes_a_cycle(): + facts = (docs("LIT", "L1", "L2", "L3") + + [F("ref_value", "L1", "extends", "L2"), + F("ref_value", "L2", "extends", "L1"), + F("ref_value", "L3", "extends", "L1")]) + got = solve({"loop": "(a:LIT)-[:extends]->(b:LIT)-[:extends]->(a)"}, + facts) + assert got["pattern_edge"] == {("loop", "L1", "L1"), ("loop", "L2", "L2")} + + +def test_two_patterns_lower_side_by_side(): + facts = (docs("CLAIM", "C1", "C2") + docs("INF", "I1") + + [F("ref_value", "C1", "premise_of", "I1"), + F("ref_value", "I1", "concludes", "C2")]) + got = solve({"f": FOLLOWS, + "g": "(a:CLAIM)-[:premise_of]->(i:INF)-[:concludes]->(b:CLAIM)"}, + facts) + assert got["pattern_edge"] == {("f", "C2", "C1"), ("g", "C1", "C2")} + + +def test_a_name_is_quoted_not_interpolated_into_the_program(): + text = program({'we"ird': parse('we"ird', FOLLOWS)}) + assert '"we\\"ird"' in text diff --git a/tests/test_patterns.py b/tests/test_patterns.py new file mode 100644 index 00000000..c7ff69ce --- /dev/null +++ b/tests/test_patterns.py @@ -0,0 +1,97 @@ +# tests/test_patterns.py +"""Path patterns: the subset a named pattern may be written in (ADR on +chains walking a named path pattern, #371). + +The grammar is the subset's definition, so these tests are the subset's +specification: what parses here is what a record may declare, and every +construct a Cypher user will reach for that is outside it is refused by name +rather than by column number. +""" +from __future__ import annotations + +import pytest + +from luria.patterns import Hop, Node, PatternError, parse + + +def test_a_fixed_path_reads_nodes_and_hops_in_order(): + p = parse("leads_to", + "(b:CLAIM)<-[:concludes]-(i:INF)<-[:premise_of]-(a:CLAIM)") + assert [(n.var, n.label) for n in p.path.nodes] == [ + ("b", "CLAIM"), ("i", "INF"), ("a", "CLAIM")] + assert p.path.hops == (Hop(("concludes",), right=False), + Hop(("premise_of",), right=False)) + assert p.negations == () + + +def test_alternation_is_a_set_of_fields_on_one_hop(): + p = parse("met", "(o:OBJ)<-[:rebuts|concedes]-(r:REPLY)") + assert p.path.hops[0].fields == ("rebuts", "concedes") + + +def test_variable_length_defaults_follow_cypher(): + def bounds(text): + return [(h.low, h.high) for h in parse("x", text).path.hops] + assert bounds("(a:LIT)-[:extends*]->(b:LIT)") == [(1, None)] + assert bounds("(a:LIT)-[:extends*2]->(b:LIT)") == [(2, 2)] + assert bounds("(a:LIT)-[:extends*2..]->(b:LIT)") == [(2, None)] + assert bounds("(a:LIT)-[:extends*..3]->(b:LIT)") == [(1, 3)] + assert bounds("(a:LIT)-[:extends*1..3]->(b:LIT)") == [(1, 3)] + assert bounds("(a:LIT)-[:extends]->(b:LIT)") == [(1, 1)] + + +def test_a_property_map_is_literal_equality(): + p = parse("x", "(c:CLAIM {status: 'Active', number: 3})-[:answers]->(q:Q)") + assert p.path.nodes[0].props == (("status", "Active"), ("number", "3")) + + +def test_where_not_takes_a_pattern_over_bound_nodes(): + p = parse("unchallenged", "(c:CLAIM) WHERE NOT (c)<-[:challenges]-(:OBJ)") + (neg,) = p.negations + assert neg.nodes[0] == Node("c", "CLAIM", (), bound=True) + assert neg.nodes[1] == Node("", "OBJ", ()) + + +def test_a_bare_reference_inherits_its_label(): + p = parse("loop", "(a:LIT)-[:extends]->(b:LIT)-[:extends]->(a)") + assert p.path.nodes[2] == Node("a", "LIT", (), bound=True) + + +def test_keywords_are_case_insensitive_and_whitespace_is_free(): + p = parse("x", " ( c:CLAIM )\n where not ( c ) <-[ :challenges ]- ( :OBJ ) ") + assert len(p.negations) == 1 + + +@pytest.mark.parametrize("text, says", [ + ("MATCH (a:LIT) RETURN a", "without `MATCH`"), + ("(a:LIT)-[:x]->(b:LIT) RETURN a", "`RETURN`"), + ("(a:LIT) OPTIONAL MATCH (a)-[:x]->(b:LIT)", "`OPTIONAL MATCH`"), + ("(a:LIT)-[:extends]-(b:LIT)", "undirected"), + ("(a:LIT)<-[:extends]->(b:LIT)", "undirected"), + ("(a:LIT)-->(b:LIT)", "needs a type"), + ("(a:LIT)-[]->(b:LIT)", "needs a type"), + ("(a:LIT:SOTA)", "one label"), + ("(a:LIT|SOTA)", "one label"), + ("(a:LIT)-[e:extends]->(b:LIT)", "named edge"), + ("(a:LIT)-[:extends*0..]->(b:LIT)", "at least 1"), + ("(a:LIT)-[:extends*3..2]->(b:LIT)", "3..2"), + ("(a:LIT) WHERE a.status = 'Active'", "`WHERE NOT`"), + ("(a:LIT) WHERE NOT (a)-[:extends*]->(:LIT)", "variable-length"), + ("(a:LIT)-[:extends]->(b)", "not bound"), + ("(a:LIT)-[:extends]->(a:SOTA)", "already bound"), + ("(a)", "label"), + ("shortestPath((a:LIT)-[:extends*]->(b:LIT))", "shortestPath"), + ("(a:LIT) SET a.x = 1", "`SET`"), + ("", "empty"), +]) +def test_out_of_subset_is_refused_by_name(text, says): + with pytest.raises(PatternError) as caught: + parse("p", text) + message = str(caught.value) + assert message.startswith("luria.yaml: patterns.p: ") + assert says in message + + +def test_any_other_mistake_says_where(): + with pytest.raises(PatternError, match=r"column \d+"): + parse("p", "(a:LIT)-[:extends->(b:LIT)") diff --git a/tools/wheel_smoke.py b/tools/wheel_smoke.py index 542b495d..7cd2aa12 100644 --- a/tools/wheel_smoke.py +++ b/tools/wheel_smoke.py @@ -11,6 +11,8 @@ - `luria` is imported from that environment, not from the source tree; - every `luria/logic/*.lp` in the source tree is in the wheel, readable through `importlib.resources` by `logic.rules`, and not empty; +- `luria/patterns.lark` is in the wheel: the installed parser reads a path + pattern (#371); - the installed CLI lints and prints the facts of a copy of an example, once with the usual `PATH` and once with git out of reach — 0.36.0 ran without git, and a record outside any repository must still read as one. @@ -40,7 +42,10 @@ names = sorted(p.name[:-3] for p in resources.files("luria.logic").iterdir() if p.name.endswith(".lp")) read = {n: len(logic.rules(n)) for n in names} -print(json.dumps({"file": luria.__file__, "programs": names, "read": read})) +from luria.patterns import parse +hops = len(parse("probe", "(a:LIT)-[:extends*]->(b:LIT)").path.hops) +print(json.dumps({"file": luria.__file__, "programs": names, "read": read, + "pattern_hops": hops})) """ @@ -67,6 +72,8 @@ def main(argv: list[str] | None = None) -> int: problems.append(f"the wheel lacks {', '.join(missing)}.lp") if empty := sorted(n for n, size in found["read"].items() if not size): problems.append(f"empty in the wheel: {', '.join(empty)}") + if found["pattern_hops"] != 1: + problems.append("the installed parser misread a path pattern") record = Path(tmp) / "record" shutil.copytree(EXAMPLE, record) bin_dir = Path(args.python).parent From bc2fec5e05c39fb65747b208fdccde34d74d3b1d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 03:41:29 +0000 Subject: [PATCH 5/6] Path patterns: argument-map example, ADR and record entries (#371) examples/argument-map fires the feature on a real shape: two chains over patterns through inference documents, one dropping challenged steps with WHERE NOT, both listing an unfinished inference as open. The ADR now matches the implementation (reading direction, open ends from both ends, closures within one scheme, the name-clash rule); the devlog records what building it corrected; the changelog fragment and project-memory describe the feature. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xS6nL8ZZpTh1jMSvaTRks --- docs/project-memory.md | 15 ++ examples/README.md | 1 + examples/argument-map/README.md | 39 ++++ examples/argument-map/luria.yaml | 81 ++++++++ .../argument-map/record/claims.d/CLAIM-001.md | 12 ++ .../argument-map/record/claims.d/CLAIM-002.md | 12 ++ .../argument-map/record/claims.d/CLAIM-003.md | 12 ++ .../argument-map/record/claims.d/CLAIM-004.md | 12 ++ .../argument-map/record/claims.d/CLAIM-005.md | 14 ++ .../argument-map/record/claims.d/CLAIM-006.md | 10 + .../argument-map/record/claims.d/CLAIM-007.md | 12 ++ .../record/inferences.d/INF-001.md | 14 ++ .../record/inferences.d/INF-002.md | 15 ++ .../record/inferences.d/INF-003.md | 13 ++ .../record/inferences.d/INF-004.md | 14 ++ .../record/objections.d/OBJ-001.md | 14 ++ .../claude-beautiful-cray-sr1fx2.md | 30 +++ record/decisions.d/ADR-tmpfdvjo.md | 181 ++++++++++-------- record/devlog.d/2026/10/13/025600.md | 45 ++++- tests/test_examples.py | 18 +- 20 files changed, 485 insertions(+), 79 deletions(-) create mode 100644 examples/argument-map/README.md create mode 100644 examples/argument-map/luria.yaml create mode 100644 examples/argument-map/record/claims.d/CLAIM-001.md create mode 100644 examples/argument-map/record/claims.d/CLAIM-002.md create mode 100644 examples/argument-map/record/claims.d/CLAIM-003.md create mode 100644 examples/argument-map/record/claims.d/CLAIM-004.md create mode 100644 examples/argument-map/record/claims.d/CLAIM-005.md create mode 100644 examples/argument-map/record/claims.d/CLAIM-006.md create mode 100644 examples/argument-map/record/claims.d/CLAIM-007.md create mode 100644 examples/argument-map/record/inferences.d/INF-001.md create mode 100644 examples/argument-map/record/inferences.d/INF-002.md create mode 100644 examples/argument-map/record/inferences.d/INF-003.md create mode 100644 examples/argument-map/record/inferences.d/INF-004.md create mode 100644 examples/argument-map/record/objections.d/OBJ-001.md create mode 100644 record/changelog.d/claude-beautiful-cray-sr1fx2.md diff --git a/docs/project-memory.md b/docs/project-memory.md index b6a02bbb..1da7480f 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -340,6 +340,21 @@ question no single document holds — *what line of work is this a step in* — and renders the sequences on one page. A cycle, or a comparison held on one side only, becomes a finding rather than a page that quietly omits it. +Some lines run through documents of another kind: an argument goes claim → +inference → claim, and no single field says which claim follows from which. +A **path pattern** names that relation, in Cypher's pattern syntax, and a +chain walks it wherever it would walk a field: + +```yaml +patterns: + follows_from: (b:CLAIM)<-[:concludes]-(i:INF)<-[:premise_of]-(a:CLAIM) +``` + +Labels are schemes and edge types are reference fields, checked when the +config loads. The page names what each step went *through*, and lists as +*open* the paths that stop partway — an inference with premises and no +conclusion yet ([ADR-tmpfdvjo](../record/decisions.d/ADR-tmpfdvjo.md), `examples/argument-map`). + **Tag rules.** A vocabulary says what a value *means*; a group says which of them may appear together, because some fields are an axis rather than a pile. The group is declared under the field it constrains: diff --git a/examples/README.md b/examples/README.md index edd6e22b..e0c82af4 100644 --- a/examples/README.md +++ b/examples/README.md @@ -25,6 +25,7 @@ that turned out to conflate *committed* with *hand-maintained*. | [`knowledge-base/`](knowledge-base/) | a record of *domain* content rather than project meta-documentation: two schemes that cite each other and carry separate statuses, with required fields and a one-primary-category rule | | [`world-bible/`](world-bible/) | a story bible: scenes that `follow` several scenes (a plural reference) and belong to world trajectories drawn from a closed vocabulary with a default — a field that is neither a reference, a tag nor a status | | [`constitution/`](constitution/) | a record with no code in it at all: an AI assistant's operating instructions decomposed into values, practices and boundaries, where **precedence is a checked reference** (`BOUNDARY.overrides → PRACTICE`) rather than escalating emphasis, and a practice is retired by a boundary from another scheme | +| [`argument-map/`](argument-map/) | an argument as a record: claims, the inferences drawn between them, and objections — lines of argument walked as **named path patterns** through the inference documents, with an uncontested view that drops challenged steps and an *Open* list of steps not yet finished | To run one by hand: diff --git a/examples/argument-map/README.md b/examples/argument-map/README.md new file mode 100644 index 00000000..a7f8746f --- /dev/null +++ b/examples/argument-map/README.md @@ -0,0 +1,39 @@ +# Argument map + +An argument kept as a record, and the shape that needed path patterns. + +**Three kinds of document.** A *claim* states something. An *inference* +draws one claim from others: its `premises` name the claims it starts from, +and `concludes` names the claim it reaches. An *objection* `challenges` a +claim or an inference. + +**No field says which claim follows from which.** That fact takes two +fields on two documents: `premise_of` on the premise and `concludes` on the +inference. A chain over either field alone stops at the inference. So the +line is declared as a named path pattern, and the chain walks that: + +```yaml +patterns: + follows_from: (b:CLAIM)<-[:concludes]-(i:INF)<-[:premise_of]-(a:CLAIM) +``` + +It reads like the field it stands in for: the first node is the step and +the last is the claim it builds on, so a conclusion nests under its +premises. `i` is named, so each step on the page says which inference it +went through. + +**Two views of one argument.** [Lines of argument](docs/argument.md) walks +every step. [Uncontested lines](docs/uncontested.md) walks `stands_on`, the +same pattern with `WHERE NOT (i)<-[:challenges]-(:OBJ)`. It drops the step +from the 2025 inspection to "the repair can wait", because [OBJ-001](record/objections.d/OBJ-001.md) +challenges it. + +**An unfinished step is shown, not hidden.** [INF-004](record/inferences.d/INF-004.md) has a premise and no +conclusion yet. Both pages list it under *Open*, at the claim it starts +from, because a missing step would otherwise read exactly like a line that +ends there. + +``` +LURIA_ROOT=$PWD luria index +LURIA_ROOT=$PWD luria lint +``` diff --git a/examples/argument-map/luria.yaml b/examples/argument-map/luria.yaml new file mode 100644 index 00000000..5d198203 --- /dev/null +++ b/examples/argument-map/luria.yaml @@ -0,0 +1,81 @@ +# examples/argument-map/luria.yaml +# +# An argument kept as a record: claims, the inferences that draw one claim +# from others, and objections to either. No single field says which claim +# follows from which — that takes two, `premise_of` on a claim and +# `concludes` on an inference — so the lines of argument are walked as +# named path patterns (#371). +issue_url: https://github.com/acme/bridge/issues/{n} +vocabularies: + statuses: + Active: + blurb: held — the record stands behind it + Proposed: + blurb: put forward and not yet settled + Withdrawn: + blurb: no longer held; kept so what cited it still resolves +schemes: + CLAIM: + dir: record/claims.d + output: docs/claims + render: index + fields: + status: {vocabulary: statuses} + references: + premise_of: + scheme: INF + many: true + required: false + converse: premises + INF: + dir: record/inferences.d + output: docs/inferences + render: index + fields: + status: {vocabulary: statuses} + references: + premises: + scheme: CLAIM + many: true + required: false + converse: premise_of + concludes: + scheme: CLAIM + required: false + OBJ: + dir: record/objections.d + output: docs/objections + render: index + fields: + status: {vocabulary: statuses} + references: + # An objection may challenge a claim or the step drawn from it. + challenges: + scheme: [CLAIM, INF] + many: true + required: true +patterns: + # Read like the field it stands in for: the first node is the step, the + # last the claim it builds on — so a conclusion nests under its premises. + # `i` is named, so the page says which inference each step went through. + follows_from: (b:CLAIM)<-[:concludes]-(i:INF)<-[:premise_of]-(a:CLAIM) + stands_on: + match: >- + (b:CLAIM)<-[:concludes]-(i:INF)<-[:premise_of]-(a:CLAIM) + WHERE NOT (i)<-[:challenges]-(:OBJ) + blurb: A step no objection challenges. +chains: + argument: + scheme: CLAIM + relation: follows_from + output: docs/argument.md + title: Lines of argument + blurb: Every step, challenged or not. + uncontested: + scheme: CLAIM + relation: stands_on + output: docs/uncontested.md + title: Uncontested lines + blurb: Only the steps no objection challenges. +site: + title: Argument map diff --git a/examples/argument-map/record/claims.d/CLAIM-001.md b/examples/argument-map/record/claims.d/CLAIM-001.md new file mode 100644 index 00000000..e69239fd --- /dev/null +++ b/examples/argument-map/record/claims.d/CLAIM-001.md @@ -0,0 +1,12 @@ +--- +status: Active +title: 'Traffic on the bridge has doubled since 2020' +version: 1 +date: '2026-09-01' +premise_of: +- INF-001 +--- + +# CLAIM-001: Traffic on the bridge has doubled since 2020 + +The council's counts: 9,000 crossings a day in 2020, 18,500 in 2026. diff --git a/examples/argument-map/record/claims.d/CLAIM-002.md b/examples/argument-map/record/claims.d/CLAIM-002.md new file mode 100644 index 00000000..3f4a82bc --- /dev/null +++ b/examples/argument-map/record/claims.d/CLAIM-002.md @@ -0,0 +1,12 @@ +--- +status: Active +title: 'The load rating has not changed' +version: 1 +date: '2026-09-01' +premise_of: +- INF-001 +--- + +# CLAIM-002: The load rating has not changed + +The deck was rated in 1998 and has not been re-rated since. diff --git a/examples/argument-map/record/claims.d/CLAIM-003.md b/examples/argument-map/record/claims.d/CLAIM-003.md new file mode 100644 index 00000000..8171627d --- /dev/null +++ b/examples/argument-map/record/claims.d/CLAIM-003.md @@ -0,0 +1,12 @@ +--- +status: Active +title: 'The bridge now carries loads near its rating' +version: 1 +date: '2026-09-02' +premise_of: +- INF-002 +--- + +# CLAIM-003: The bridge now carries loads near its rating + +Drawn from the first two claims by [INF-001](../inferences.d/INF-001.md). diff --git a/examples/argument-map/record/claims.d/CLAIM-004.md b/examples/argument-map/record/claims.d/CLAIM-004.md new file mode 100644 index 00000000..c618fb02 --- /dev/null +++ b/examples/argument-map/record/claims.d/CLAIM-004.md @@ -0,0 +1,12 @@ +--- +status: Active +title: 'Freeze–thaw accelerates damage to a heavily loaded deck' +version: 1 +date: '2026-09-02' +premise_of: +- INF-002 +--- + +# CLAIM-004: Freeze–thaw accelerates damage to a heavily loaded deck + +Water in existing cracks expands, and a deck near its rating has more of them. diff --git a/examples/argument-map/record/claims.d/CLAIM-005.md b/examples/argument-map/record/claims.d/CLAIM-005.md new file mode 100644 index 00000000..e660e684 --- /dev/null +++ b/examples/argument-map/record/claims.d/CLAIM-005.md @@ -0,0 +1,14 @@ +--- +status: Active +title: 'The bridge should be repaired before winter' +version: 1 +date: '2026-09-03' +premise_of: +- INF-004 +--- + +# CLAIM-005: The bridge should be repaired before winter + +The conclusion this record exists to support. [INF-004](../inferences.d/INF-004.md), which would price +the repair, has premises and no conclusion yet, so the argument page lists +it as open. diff --git a/examples/argument-map/record/claims.d/CLAIM-006.md b/examples/argument-map/record/claims.d/CLAIM-006.md new file mode 100644 index 00000000..92027a06 --- /dev/null +++ b/examples/argument-map/record/claims.d/CLAIM-006.md @@ -0,0 +1,10 @@ +--- +status: Proposed +title: 'The repair can wait until spring' +version: 1 +date: '2026-09-04' +--- + +# CLAIM-006: The repair can wait until spring + +The rival conclusion, drawn by [INF-003](../inferences.d/INF-003.md) and challenged by [OBJ-001](../objections.d/OBJ-001.md). diff --git a/examples/argument-map/record/claims.d/CLAIM-007.md b/examples/argument-map/record/claims.d/CLAIM-007.md new file mode 100644 index 00000000..18ce62b0 --- /dev/null +++ b/examples/argument-map/record/claims.d/CLAIM-007.md @@ -0,0 +1,12 @@ +--- +status: Active +title: 'The last inspection found no cracking' +version: 1 +date: '2026-09-04' +premise_of: +- INF-003 +--- + +# CLAIM-007: The last inspection found no cracking + +The 2025 inspection report, section 4. diff --git a/examples/argument-map/record/inferences.d/INF-001.md b/examples/argument-map/record/inferences.d/INF-001.md new file mode 100644 index 00000000..e199e90c --- /dev/null +++ b/examples/argument-map/record/inferences.d/INF-001.md @@ -0,0 +1,14 @@ +--- +status: Active +title: 'Twice the traffic on the same rating' +version: 1 +date: '2026-09-02' +premises: +- CLAIM-001 +- CLAIM-002 +concludes: CLAIM-003 +--- + +# INF-001: Twice the traffic on the same rating + +If the load has doubled and the rating has not moved, the margin has shrunk. diff --git a/examples/argument-map/record/inferences.d/INF-002.md b/examples/argument-map/record/inferences.d/INF-002.md new file mode 100644 index 00000000..7fbb3993 --- /dev/null +++ b/examples/argument-map/record/inferences.d/INF-002.md @@ -0,0 +1,15 @@ +--- +status: Active +title: 'A loaded deck will not survive a winter unrepaired' +version: 1 +date: '2026-09-03' +premises: +- CLAIM-003 +- CLAIM-004 +concludes: CLAIM-005 +--- + +# INF-002: A loaded deck will not survive a winter unrepaired + +Together the premises say the damage a winter does is damage this deck +cannot absorb. diff --git a/examples/argument-map/record/inferences.d/INF-003.md b/examples/argument-map/record/inferences.d/INF-003.md new file mode 100644 index 00000000..c49ee23f --- /dev/null +++ b/examples/argument-map/record/inferences.d/INF-003.md @@ -0,0 +1,13 @@ +--- +status: Proposed +title: 'No cracking, so no urgency' +version: 1 +date: '2026-09-04' +premises: +- CLAIM-007 +concludes: CLAIM-006 +--- + +# INF-003: No cracking, so no urgency + +Damage needs somewhere to start, and an uncracked deck gives it nowhere. diff --git a/examples/argument-map/record/inferences.d/INF-004.md b/examples/argument-map/record/inferences.d/INF-004.md new file mode 100644 index 00000000..b7911d05 --- /dev/null +++ b/examples/argument-map/record/inferences.d/INF-004.md @@ -0,0 +1,14 @@ +--- +status: Proposed +title: 'What the repair should cost' +version: 1 +date: '2026-09-05' +premises: +- CLAIM-005 +--- + +# INF-004: What the repair should cost + +Unfinished: the premises are in and the conclusion is not. Until it names +one, the argument page lists this step as open rather than letting the line +look complete. diff --git a/examples/argument-map/record/objections.d/OBJ-001.md b/examples/argument-map/record/objections.d/OBJ-001.md new file mode 100644 index 00000000..c11231fd --- /dev/null +++ b/examples/argument-map/record/objections.d/OBJ-001.md @@ -0,0 +1,14 @@ +--- +status: Active +title: 'The inspection predates the traffic increase' +version: 1 +date: '2026-09-05' +challenges: +- INF-003 +--- + +# OBJ-001: The inspection predates the traffic increase + +The 2025 inspection measured a deck carrying 2023 traffic. An absence of +cracks then says little about the deck now, so the step from [CLAIM-007](../claims.d/CLAIM-007.md) to +[CLAIM-006](../claims.d/CLAIM-006.md) does not hold, and the uncontested page leaves it out. diff --git a/record/changelog.d/claude-beautiful-cray-sr1fx2.md b/record/changelog.d/claude-beautiful-cray-sr1fx2.md new file mode 100644 index 00000000..84ba72a6 --- /dev/null +++ b/record/changelog.d/claude-beautiful-cray-sr1fx2.md @@ -0,0 +1,30 @@ +### Added + +- **Path patterns** (`patterns:`): a named relation between documents that + no single field holds, in Cypher's pattern syntax. For example, + `(b:CLAIM)<-[:concludes]-(i:INF)<-[:premise_of]-(a:CLAIM)` relates a + claim to the claims it follows from, through the inference that + concludes it. + - Node labels are schemes and edge types are reference fields. A hop + holds when the relation does, read from either side. + - The subset: one label per node, `|` between fields, variable length + (`*`, `*2..`, `*..3`), property maps such as `{status: 'Active'}`, and + `WHERE NOT` followed by a fixed path. `MATCH`, `RETURN`, + `OPTIONAL MATCH`, undirected and named edges, shortest paths and writes + are refused at load, by name. + - Labels, hops and properties are checked against the schemes when + `luria.yaml` loads. +- A chain's `relation` or `sibling` may name a pattern in place of a field. + - The step reads like a field: the first node nests under the last. + - Its page names the document each step went *through*. + - It lists paths that stop partway under *Open*, such as an inference + with premises and no conclusion yet. + - A chain `invariant` covers pattern steps. +- `examples/argument-map`: claims, inferences and objections, with one + chain over every step and one that drops steps an objection challenges. +- `luria facts` prints `pattern(P)` for each declared pattern. + +### Changed + +- `lark` is a new dependency; luria parses patterns with its own grammar + for the subset. diff --git a/record/decisions.d/ADR-tmpfdvjo.md b/record/decisions.d/ADR-tmpfdvjo.md index bd162e9d..4ad9fd12 100644 --- a/record/decisions.d/ADR-tmpfdvjo.md +++ b/record/decisions.d/ADR-tmpfdvjo.md @@ -9,16 +9,17 @@ date: '2026-10-13' issue: '#371' summary: >- A record may declare named path patterns in Cypher's pattern syntax, such - as `(a:CLAIM)-[:premise_of]->(i:INF)-[:concludes]->(b:CLAIM)`. Node labels - are schemes, edge types are reference fields, and an edge is a relation as - `held/4` reads it. luria parses patterns with its own lark grammar for - the subset and lowers them to clingo rules over the record's facts, so - the logic core stays the one engine. A chain names a pattern in `relation` or `sibling` as it names a - field. The pattern's end nodes are the step and its named intermediates - render as "through". The subset is checked against the schema at load and - is stratified by construction. Rejected: a field list on the scheme, - clingo rule bodies as queries, a graph engine, grand-cypher over - networkx, Raqlet, and GraphGlot as the parser. + as `(b:CLAIM)<-[:concludes]-(i:INF)<-[:premise_of]-(a:CLAIM)`. Node labels + are schemes, edge types are reference fields, and a hop holds when the + relation does as `held/4` reads it. luria parses patterns with its own + lark grammar for the subset and lowers them to clingo rules over the + record's facts, so the logic core stays the one engine. A chain names a + pattern in `relation` or `sibling` as it names a field: the pattern's two + ends are the step, its named inner nodes render as "through", and paths + that stop partway are listed as open. The subset is checked against the + schemes at load and is stratified by construction. Rejected: a field list + on the scheme, clingo rule bodies as queries, a graph engine, grand-cypher + over networkx, Raqlet, and GraphGlot as the parser. influenced_by: - ADR-106 - ADR-125 @@ -66,29 +67,35 @@ mechanical: ```yaml patterns: - leads_to: (a:CLAIM)-[:premise_of]->(i:INF)-[:concludes]->(b:CLAIM) - answered: (q:Q)<-[:answers]-(c:CLAIM) - unchallenged: (c:CLAIM) WHERE NOT (c)<-[:challenges]-(:OBJ) + follows_from: (b:CLAIM)<-[:concludes]-(i:INF)<-[:premise_of]-(a:CLAIM) + stands_on: + match: >- + (b:CLAIM)<-[:concludes]-(i:INF)<-[:premise_of]-(a:CLAIM) + WHERE NOT (i)<-[:challenges]-(:OBJ) + blurb: A step no objection challenges. chains: argument: scheme: CLAIM - relation: leads_to - output: docs/arguments.md + relation: follows_from + output: docs/argument.md ``` +A pattern is the string itself, or a table with `match:` and an optional +`blurb:`. + ### What a pattern means - **A node label is a scheme.** A node carries exactly one label, or none - when it refers back to a variable bound earlier in the pattern (`(c)` in - `WHERE NOT (c)<-[:challenges]-(:OBJ)`) and inherits that node's scheme. + when it refers back to a variable bound earlier in the pattern (`(i)` in + `WHERE NOT (i)<-[:challenges]-(:OBJ)`) and inherits that node's scheme. So each document's scheme is known at every position. - **An edge type is a reference field** declared on the scheme of the node the arrow leaves. `(a:S)-[:f]->(b:T)` holds when `held(S, f, a, b)` does. That is the reading every chain and invariant already uses, so a hop stated only on its converse side counts. -- **`<-[:f]-` reads the same field from the other end.** `(q:Q)<-[:answers]-(c:CLAIM)` - holds when `c`'s `answers` names `q`. +- **`<-[:f]-` reads the same field from the other end.** + `(b:CLAIM)<-[:concludes]-(i:INF)` holds when `i`'s `concludes` names `b`. - **The target label filters.** A field that may name several schemes ([ADR-126](ADR-126.md)) is a valid hop if the next node's label is one of its targets. Codes landing in the other targets are not matches, and that is visible @@ -101,10 +108,11 @@ chains: - one label per node, or a bare reference to a node already bound; - edge types with direction, and `|` between field names; -- variable length on a single edge (`-[:extends*1..]->`, a lower bound of - at least 1); +- variable length on a single hop, with Cypher's defaults: `*` is `*1..`, + `*2` is exactly two, `*..3` is one to three, and the lower bound is at + least 1; - literal equality on a node's frontmatter (`{status: 'Active'}`); -- `WHERE NOT` followed by a pattern that is not itself variable-length. +- `WHERE NOT` followed by a fixed path, as often as needed. **Out**, refused at load: @@ -112,24 +120,32 @@ chains: - `OPTIONAL MATCH`; - several `MATCH` clauses; - undirected edges; +- named edges (`[e:extends]`), since a hop is identified by its field; - shortest path; - any write clause. A pattern is written without `MATCH`. The grammar has no production for anything outside the subset, so an out-of-subset pattern fails to parse. The refusal names the construct for the keywords and shapes people will -reach for (`MATCH`, `RETURN`, `OPTIONAL`, an undirected edge). Anything -else gets the parse position. +reach for (`MATCH`, `RETURN`, `OPTIONAL MATCH`, an undirected or untyped +edge, a second label, a write). Anything else gets the parse position. ### How patterns are parsed and evaluated - **Parsing:** a [lark](https://github.com/lark-parser/lark) grammar for - the subset, in `luria/`, parsed LALR. The grammar is the subset's - definition: what it accepts is what this decision admits, and widening - the subset is a reviewed change to one file. -- **Lowering:** luria lowers the parse tree to a clingo program over - `held/4`, `doc/2` and `value/4`, one derived predicate per pattern. The - logic core stays the one engine ([ADR-127](ADR-127.md)). + the subset, `luria/patterns.lark`, parsed LALR. The grammar is the + subset's definition: what it accepts is what this decision admits, and + widening the subset is a reviewed change to one file. +- **Lowering:** `luria/pattern_rules.py` lowers each parse to clingo rules + over `held/4`, `doc/2`, `status/2` and `value/4`. The logic core stays the + one engine ([ADR-127](ADR-127.md)). The lowered program is generated from the + configuration rather than shipped as a `.lp` file, and `logic.derive` + takes it as extra program text, memoized like the rest. +- **What it derives:** + - `pattern_edge(P, First, Last)`: a whole match, by its two ends; + - `pattern_via(P, First, Last, Var, X)`: a named inner node of that match; + - `pattern_open(P, End, X)`: a partial match that cannot be completed, by + the end it starts from and the furthest node it reached. - **Dependency:** `lark`, a core dependency. It is pure Python with no dependencies of its own, stable at 1.x, and small enough that an extra with an install-line refusal would cost more than it saves. @@ -139,35 +155,46 @@ else gets the parse position. - each label is a declared scheme; - each edge type is a reference declared on the node it leaves, and the next label is among that field's targets; +- a variable-length hop starts and ends on the same scheme, since every + step between its ends is held by a document of that kind; - each property is a field the node's scheme can hold; -- the construct is in the subset. +- no pattern shares its name with a reference field on any scheme, so a + name a chain walks means one thing. These join the refusals in `consistency.lp`. ### Stratified by construction The subset admits no recursion through negation. `WHERE NOT` takes only -patterns that are not variable-length, and variable length is positive. -So every lowered program has one answer set, and the views it feeds are -deterministic, as [ADR-127](ADR-127.md) requires. +fixed paths, and variable length is positive. A closure counts its steps up +to the bound it needs, saturating at the lower bound when there is no upper +one, so it terminates on a cycle. So every lowered program has one answer +set, and the views it feeds are deterministic, as [ADR-127](ADR-127.md) requires. ### How a chain uses a pattern -- **The ends are the step.** A chain may name a pattern in `relation` or - `sibling`, alone or beside fields in one spine ([#211](https://github.com/dmarx/luria/issues/211)). The pattern's - first and last nodes must both be labelled with the chain's scheme. They - are the edge the chain walks. +- **The ends are the step, read like a field.** A chain may name a pattern + in `relation` or `sibling`, alone or beside fields in one spine ([#211](https://github.com/dmarx/luria/issues/211)). + The pattern's first and last nodes must both be labelled with the + chain's scheme, and the pattern needs at least one hop. The first node + is the step and the last is the one it builds on, as with a field: `A + extends B` nests A under B. So `follows_from` above nests a conclusion + under its premises. - **Named intermediates are "through".** A variable bound on an inner node (`i` above) is what the step renders as "— through INF-004". An anonymous inner node (`(:INF)`) is matched and not shown. -- **A match that stops partway is shown.** A document of the chain's - scheme from which at least one hop of the pattern matches, and the whole - pattern does not, is listed on the chain's page as "open at" the - furthest node reached. An inference with premises and no conclusion yet - is a legitimate state, so this is not a lint finding. Not drawing it - would read exactly like a line that ends there ([DP-15](../principles.d/DP-015.md)). -- **A chain's `invariant` is checked between the two ends**, as for any - edge it walks. +- **A path that stops partway is listed as open.** A partial match that + cannot be completed is listed in an *Open* section at the end of the + chain's page, by the step it starts from and where it stops. Partial + matches are read from both ends. An inference with premises and no + conclusion is visible only walking from the premise, and one with a + conclusion and no premises only from the conclusion. A match that `WHERE + NOT` removes is excluded, not unfinished, so it is not open. This is not + a lint finding: an inference in progress is a legitimate state. Not + drawing it would read exactly like a line that ends there ([DP-15](../principles.d/DP-015.md)). +- **A chain's `invariant` covers pattern steps.** It is checked between the + two ends, as for any edge the chain walks. +- **Cycles through a pattern are the chain's existing cycle finding.** Read-only throughout: a pattern is never written into frontmatter, has no converse, and is never completed by `luria link --fix`. @@ -206,53 +233,57 @@ converse, and is never completed by `luria link --fix`. above the path, for a lowering that reads only the path. It is pre-1.0 with releases days apart, so its shapes would need pinning. And it accepts all of Cypher, so the subset would be enforced by a second pass - over a tree that had already said yes. A grammar of luria's own is about - fifteen lines, accepts only the subset, and hands the lowering the tree - it reads. + over a tree that had already said yes. A grammar of luria's own accepts + only the subset and hands the lowering the tree it reads. - **GQL syntax instead of Cypher.** GQL is the ISO standard (39075:2024). Cypher is the one people know, and its variable-length (`*1..` against `->{1,}`) and negation forms are shorter. Admitting GQL spellings later is a grammar change, not a design change. +- **Open ends as a lint finding.** Rejected: an unfinished inference is a + legitimate state of a record being written, and nothing mechanical can + supply its conclusion. CLAUDE.md admits a check to the lint only when the + violation is always wrong and mechanically fixable. - **Status quo.** Records that argue through intermediate documents keep writing their lines in prose, one paragraph per participant, which is the duplication chains were built to end ([#171](https://github.com/dmarx/luria/issues/171)). ## Consequences -- **A new dependency**, `lark`, pure Python with none of its own. +- **A new dependency**, `lark`, pure Python with none of its own. The + wheel carries `luria/patterns.lark`, and `tools/wheel_smoke.py` now + parses a pattern through the installed package to prove it. - **luria owns the grammar and its error messages.** The subset is small on purpose, and the grammar is where that is enforced. A request to widen it is a change to the grammar, the lowering and this decision together. -- **Fired before writing this.** A scratch grammar of 15 rule lines (lark - 1.3.1, LALR) parsed every pattern in this decision, plus the - variable-length and property-map forms. It refused `OPTIONAL MATCH`, an - undirected edge, `MATCH … RETURN` and a trailing `RETURN`. The first try - caught a rule this decision had wrong: `WHERE NOT (c)…` refers to a bound - node without a label, so "every node carries a label" became "a label, - or a reference to a node already bound". Its refusals are lark's - "unexpected token at column N", which is why naming the common - constructs is a separate step. The lowering is not written yet. -- **The facts become an interface.** Patterns lower to `held/4`, `doc/2` - and `value/4`, so changing those predicates changes what patterns mean. -- **Parity.** Records that declare no pattern are unchanged. Under - [ADR-127](ADR-127.md)'s rule, the change merges only if `luria lint` and `luria index` - output is byte-identical on luria's own record, every example, - dmarx/nucleation and dmarx/anthology-of-the-sota. -- **Chain code** must accept a pattern name in `relation`. Today that - touches the chain loader, `consistency.lp`'s `walk_undeclared` and - `walk_leaves`, the chain facts, and the page's intro sentence, which - should print the pattern. -- **One name, one meaning:** a pattern and a reference field may not share - a name on a scheme a chain walks. -- **The chain page grows "through" and "open at".** Both need tests against - a real record, and an example that declares the shape. `examples/` has - none today. +- **`patterns` is a sixth config family**, replaced whole when declared + ([ADR-047](ADR-047.md)), with its own section in the configuration reference. + `luria facts` prints `pattern(P)` for each; what a pattern matches is + derived, not a fact. +- **The facts become an interface.** Patterns lower to `held/4`, `doc/2`, + `status/2` and `value/4`, so changing those predicates changes what + patterns mean. `value/4` facts are built only when some pattern filters + on a field other than `status`. +- **Parity.** Records that declare no pattern render the same pages. The + chain page changes only where a chain walks a pattern, and the chain + code paths that read a field are unchanged. +- **The "also extends" note on a step with several parents is still + hard-coded.** On a pattern chain it reads wrongly ("also extends" a + premise). [#359](https://github.com/dmarx/luria/issues/359) names each extra parent by its relation, and once it + merges a pattern's name reads in that place ("also follows from"). +- **Fired before trusting it.** `examples/argument-map` declares claims, + inferences and objections, and two chains over patterns. One walks every + step through its inference. The other uses `WHERE NOT` to drop a step an + objection challenges. Both list an inference with a premise and no + conclusion as open. The example lints clean, and `tests/test_examples.py` + runs it. Writing the scratch grammar first also corrected a rule this + decision had wrong: `WHERE NOT (i)…` refers to a bound node without a + label. - **Not decided here, and made possible by it:** - an outline view that walks a pattern from a root across schemes, which is where `OPTIONAL MATCH` would earn its place; - transcluding document bodies, so views can compose into a manuscript; - - patterns rendered as reports, such as `unchallenged` above. + - patterns rendered as reports, such as claims no objection challenges. Each is its own decision. This one keeps named intermediates and partial matches available to them. diff --git a/record/devlog.d/2026/10/13/025600.md b/record/devlog.d/2026/10/13/025600.md index cb8ead99..1a91d27f 100644 --- a/record/devlog.d/2026/10/13/025600.md +++ b/record/devlog.d/2026/10/13/025600.md @@ -86,9 +86,9 @@ STRING: /'[^']*'/ | /"[^"]*"/ ``` It parsed every pattern and refused -`OPTIONAL MATCH`, undirected edges and `RETURN`. Two gaps for the real one: it -takes `*..` with no lower bound, which the ADR forbids (at least 1), and -it rejects a bare `*`, which Cypher reads as `*1..`. +`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 @@ -96,3 +96,42 @@ 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 + +Four 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. +- **Open ends need both directions.** Partial matches walked from the first + node find an inference with a conclusion and no premises. The case the + ADR named, premises and no conclusion, is only visible walking from the + last node. `pattern_rules._open` builds prefixes and suffixes both. A + match that `WHERE NOT` removes is left out of open ends: it is excluded, + not unfinished. +- **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](https://github.com/dmarx/luria/issues/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](https://github.com/dmarx/luria/issues/359) is an open PR that names each extra parent by + its relation, so this PR leaves the line alone rather than conflict with + it. + +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. `docs/uncontested.md` drops the challenged step, +and both list INF-004 as open. The example lints clean. + diff --git a/tests/test_examples.py b/tests/test_examples.py index 12848b93..9ab97e2f 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -26,7 +26,7 @@ EXAMPLES = Path(__file__).resolve().parents[1] / "examples" NAMES = ["rfcs-and-specs", "collocated", "many-journals", "external-citations", - "knowledge-base", "world-bible", "constitution"] + "knowledge-base", "world-bible", "constitution", "argument-map"] @pytest.fixture @@ -643,3 +643,19 @@ def test_every_section_of_the_source_says_what_accounts_for_it(example): + ". A `> **Decomposed as** — nothing yet` block is a legitimate " "answer and a different one from silence." ) + + +def test_a_line_of_argument_runs_through_its_inferences(example): + """A chain over a path pattern (#371): each step names the inference it + went through, a challenged step is gone from the uncontested view, and + an inference with no conclusion yet is listed as open on both.""" + root = example("argument-map") + argument = (root / "docs/argument.md").read_text() + uncontested = (root / "docs/uncontested.md").read_text() + assert ("The bridge should be repaired before winter *(Active)* — through " + "[INF-002](../record/inferences.d/INF-002.md)" in argument) + assert "through [INF-003]" in argument + assert "INF-003" not in uncontested + for page in (argument, uncontested): + assert ("— open at [INF-004](../record/inferences.d/INF-004.md)" + in page) From a6db88bb53183608723791e76e94e46aefa46748 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 04:52:15 +0000 Subject: [PATCH 6/6] Remove built-in open ends from pattern chains (#373) Tried on dmarx/nucleation, the Open list named six complete arguments: they rest only on `grounds`, which the record's field group allows. Whether a partial match is unfinished depends on the schema, so a record states it as a pattern of its own; rendering one is #373. pattern_open, its rules, the page section and their tests go. The ADR records the trial as the reason, and the devlog records parity on nucleation. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xS6nL8ZZpTh1jMSvaTRks --- docs/project-memory.md | 5 +- examples/README.md | 2 +- examples/argument-map/README.md | 15 ++-- .../argument-map/record/claims.d/CLAIM-005.md | 4 +- .../record/inferences.d/INF-004.md | 5 +- luria/chains.py | 45 +++--------- luria/pattern_rules.py | 50 ++----------- .../claude-beautiful-cray-sr1fx2.md | 2 - record/decisions.d/ADR-tmpfdvjo.md | 71 +++++++++++-------- record/devlog.d/2026/10/13/025600.md | 42 ++++++++--- tests/test_examples.py | 6 +- tests/test_pattern_chains.py | 10 +-- tests/test_pattern_rules.py | 14 ++-- 13 files changed, 123 insertions(+), 148 deletions(-) diff --git a/docs/project-memory.md b/docs/project-memory.md index 1da7480f..ef1b671e 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -351,9 +351,8 @@ patterns: ``` Labels are schemes and edge types are reference fields, checked when the -config loads. The page names what each step went *through*, and lists as -*open* the paths that stop partway — an inference with premises and no -conclusion yet ([ADR-tmpfdvjo](../record/decisions.d/ADR-tmpfdvjo.md), `examples/argument-map`). +config loads, and the page names what each step went *through* +([ADR-tmpfdvjo](../record/decisions.d/ADR-tmpfdvjo.md), `examples/argument-map`). **Tag rules.** A vocabulary says what a value *means*; a group says which of them may appear together, because some fields are an axis rather than a pile. diff --git a/examples/README.md b/examples/README.md index e0c82af4..6280754a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -25,7 +25,7 @@ that turned out to conflate *committed* with *hand-maintained*. | [`knowledge-base/`](knowledge-base/) | a record of *domain* content rather than project meta-documentation: two schemes that cite each other and carry separate statuses, with required fields and a one-primary-category rule | | [`world-bible/`](world-bible/) | a story bible: scenes that `follow` several scenes (a plural reference) and belong to world trajectories drawn from a closed vocabulary with a default — a field that is neither a reference, a tag nor a status | | [`constitution/`](constitution/) | a record with no code in it at all: an AI assistant's operating instructions decomposed into values, practices and boundaries, where **precedence is a checked reference** (`BOUNDARY.overrides → PRACTICE`) rather than escalating emphasis, and a practice is retired by a boundary from another scheme | -| [`argument-map/`](argument-map/) | an argument as a record: claims, the inferences drawn between them, and objections — lines of argument walked as **named path patterns** through the inference documents, with an uncontested view that drops challenged steps and an *Open* list of steps not yet finished | +| [`argument-map/`](argument-map/) | an argument as a record: claims, the inferences drawn between them, and objections — lines of argument walked as **named path patterns** through the inference documents, with an uncontested view that drops challenged steps | To run one by hand: diff --git a/examples/argument-map/README.md b/examples/argument-map/README.md index a7f8746f..d9e177cc 100644 --- a/examples/argument-map/README.md +++ b/examples/argument-map/README.md @@ -28,10 +28,17 @@ same pattern with `WHERE NOT (i)<-[:challenges]-(:OBJ)`. It drops the step from the 2025 inspection to "the repair can wait", because [OBJ-001](record/objections.d/OBJ-001.md) challenges it. -**An unfinished step is shown, not hidden.** [INF-004](record/inferences.d/INF-004.md) has a premise and no -conclusion yet. Both pages list it under *Open*, at the claim it starts -from, because a missing step would otherwise read exactly like a line that -ends there. +**An unfinished step draws nothing.** [INF-004](record/inferences.d/INF-004.md) has a premise and no +conclusion yet, so it is not a step on either page. What counts as +unfinished depends on what this record requires, so luria does not guess. +The record can say it as a pattern of its own: + +```yaml +unfinished: (i:INF)<-[:premise_of]-(:CLAIM) WHERE NOT (i)-[:concludes]->(:CLAIM) +``` + +Rendering such a pattern as a list of its matches is luria's +[issue 373](https://github.com/dmarx/luria/issues/373). ``` LURIA_ROOT=$PWD luria index diff --git a/examples/argument-map/record/claims.d/CLAIM-005.md b/examples/argument-map/record/claims.d/CLAIM-005.md index e660e684..c2c6f9e2 100644 --- a/examples/argument-map/record/claims.d/CLAIM-005.md +++ b/examples/argument-map/record/claims.d/CLAIM-005.md @@ -10,5 +10,5 @@ premise_of: # CLAIM-005: The bridge should be repaired before winter The conclusion this record exists to support. [INF-004](../inferences.d/INF-004.md), which would price -the repair, has premises and no conclusion yet, so the argument page lists -it as open. +the repair, has premises and no conclusion yet, so it draws no step on +either page. diff --git a/examples/argument-map/record/inferences.d/INF-004.md b/examples/argument-map/record/inferences.d/INF-004.md index b7911d05..dde43574 100644 --- a/examples/argument-map/record/inferences.d/INF-004.md +++ b/examples/argument-map/record/inferences.d/INF-004.md @@ -9,6 +9,5 @@ premises: # INF-004: What the repair should cost -Unfinished: the premises are in and the conclusion is not. Until it names -one, the argument page lists this step as open rather than letting the line -look complete. +Unfinished: the premises are in and the conclusion is not. A partial match +is no step, so neither page draws one until it names its conclusion. diff --git a/luria/chains.py b/luria/chains.py index 76cca37a..6fe9fe37 100644 --- a/luria/chains.py +++ b/luria/chains.py @@ -48,10 +48,8 @@ name a declared path pattern, which relates two documents of this scheme through documents of another — a claim to the claims it follows from, through the inference that concludes it. Its two ends are the edge, and the -walk below cannot tell it from a field. Two things only a pattern has reach -the page: the named inner nodes a step went *through*, and the paths that -stop partway, listed as *open* — an absent step reads exactly like a line -that ends there (DP-15). +walk below cannot tell it from a field. One thing only a pattern has reaches +the page: the named inner nodes a step went *through*. """ from __future__ import annotations @@ -148,13 +146,6 @@ def _through(chain) -> dict[tuple[str, str], list[str]]: return {pair: sorted(codes) for pair, codes in out.items()} -def _open(chain) -> list[tuple[str, str]]: - """(end, where it stops) for every path a walked pattern starts and - cannot finish.""" - return sorted({hit for name in _patterns(chain) - for hit in pattern_rules.open_ends(name)}) - - def _components(codes: list[str], neighbours: dict[str, set[str]] ) -> list[list[str]]: """Maximal weakly-connected groups, in the order their earliest member @@ -324,8 +315,8 @@ def _reaches(start: str, spine: dict[str, list[str]]) -> set[str]: def _elsewhere(codes) -> dict[str, Adr]: - """Documents of any scheme, by code — what a step went through, or where - a path stops, which need not be in the chain's own scheme.""" + """Documents of any scheme, by code — what a step went through, which + need not be in the chain's own scheme.""" out: dict[str, Adr] = {} for prefix in {local_scheme(c) for c in codes} - {None}: out.update((d.code, d) for d in load_scheme(current().schemes[prefix])) @@ -450,28 +441,10 @@ def _line_block(chain, line: Line, heading: str, return out -def _open_block(chain, opens: list[tuple[str, str]], - elsewhere: dict[str, Adr]) -> list[str]: - """Paths a walked pattern starts and cannot finish, last on the page.""" - if not opens: - return [] - out = ["## Open", "", - "Paths that stop partway: each starts at a step and reaches a " - "document the rest of the pattern does not yet leave.", ""] - for end, at in opens: - doc = elsewhere.get(end) - title = f" — {doc.title}" if doc else "" - out.append(f"- {_cite(end, elsewhere, chain)}{title} — open at " - f"{_cite(at, elsewhere, chain)}") - return out + [""] - - def _render(chain, lines: list[Line]) -> str: - through, opens = _through(chain), _open(chain) - elsewhere = (_elsewhere({c for v in through.values() for c in v} - | {c for pair in opens for c in pair}) - if through or opens else {}) - tail = _open_block(chain, opens, elsewhere) + through = _through(chain) + elsewhere = (_elsewhere({c for v in through.values() for c in v}) + if through else {}) count = f"{len(lines)} line" + ("" if len(lines) == 1 else "s") out = [MARKER, "", f"# {chain.title}", "", f"{count}, walked from {_spine(chain)} on {chain.scheme} " @@ -480,7 +453,7 @@ def _render(chain, lines: list[Line]) -> str: if not chain.invariant: for line in lines: out += _line_block(chain, line, "##", through, elsewhere) - return "\n".join(out + tail).rstrip() + "\n" + return "\n".join(out).rstrip() + "\n" # Grouping is opt-in through the same key that opts into the check, so a # record that declares no invariant gets exactly the page it had. @@ -491,7 +464,7 @@ def _render(chain, lines: list[Line]) -> str: out.append("") for line in group: out += _line_block(chain, line, "###", through, elsewhere) - return "\n".join(out + tail).rstrip() + "\n" + return "\n".join(out).rstrip() + "\n" def outputs() -> dict: diff --git a/luria/pattern_rules.py b/luria/pattern_rules.py index a4a1223a..78e0dac8 100644 --- a/luria/pattern_rules.py +++ b/luria/pattern_rules.py @@ -17,20 +17,15 @@ match — non-recursive, so the program stays stratified and has one answer set. -**What is shown.** Three predicates, each keyed by the pattern's name: +**What is shown.** Two predicates, each keyed by the pattern's name: pattern_edge(P, First, Last) a whole match, by its two ends pattern_via(P, First, Last, Var, X) a named inner node of that match - pattern_open(P, End, X) a partial match from either end - that cannot be completed, by the - end it starts from and the - furthest node it reached -Open ends are read from both ends because a path can stop on either side of -its middle — an inference with premises and no conclusion is only visible -walking from the premise, one with a conclusion and no premises only from the -conclusion — and `WHERE NOT` is left out of them: a match a negation removes -is excluded, not unfinished. +Nothing about partial matches. Whether a path that stops partway is +unfinished depends on what the record's schema requires, which a pattern +cannot know; a record that wants that list writes it as a pattern of its own, +`WHERE NOT` and all (#373). Every pattern's internal predicates are prefixed `pat_` by position, and its name only ever appears as a quoted string, so no name a record chooses @@ -47,8 +42,7 @@ def program(patterns: dict[str, Pattern]) -> str: out = ["% Generated from luria.yaml `patterns:` — see pattern_rules.py."] for k, pattern in enumerate(patterns.values()): out += _Lowering(f"pat{k}", pattern).rules() - out += ["#show pattern_edge/3.", "#show pattern_via/5.", - "#show pattern_open/3."] + out += ["#show pattern_edge/3.", "#show pattern_via/5."] return "\n".join(out) + "\n" @@ -167,7 +161,6 @@ def rules(self) -> list[str]: self.lines.append( f"pattern_via({self.name}, {first}, {last}, " f"{_term(node.var)}, {self.main[i]}) :- {self.p}_m({args}).") - self._open(path, n) return self.lines def _negation(self, j: int, neg: Path) -> str: @@ -182,30 +175,6 @@ def _negation(self, j: int, neg: Path) -> str: self.lines.append(f"{head} :- {', '.join(body)}.") return head - def _open(self, path: Path, n: int) -> None: - """Dead ends from the first node forward and the last node back.""" - v = self.main - for j in range(1, n + 1): - self.lines.append( - f"{self.p}_x{j}({', '.join(v[:j + 1])}) :- " - f"{', '.join(self._body(self.p, path, v, upto=j))}.") - self.lines.append( - f"{self.p}_y{j}({', '.join(v[n - j:])}) :- " - f"{', '.join(self._body(self.p, path, v, start=n - j))}.") - for j in range(1, n): - ahead, behind = ", ".join(v[:j + 1]), ", ".join(v[:j + 2]) - self.lines.append( - f"{self.p}_xe{j}({ahead}) :- {self.p}_x{j + 1}({behind}).") - self.lines.append( - f"pattern_open({self.name}, {v[0]}, {v[j]}) :- " - f"{self.p}_x{j}({ahead}), not {self.p}_xe{j}({ahead}).") - back, further = ", ".join(v[n - j:]), ", ".join(v[n - j - 1:]) - self.lines.append( - f"{self.p}_ye{j}({back}) :- {self.p}_y{j + 1}({further}).") - self.lines.append( - f"pattern_open({self.name}, {v[n]}, {v[n - j]}) :- " - f"{self.p}_y{j}({back}), not {self.p}_ye{j}({back}).") - # ── over the record ──────────────────────────────────────────────────────── @@ -247,10 +216,3 @@ def through(name: str) -> dict[tuple[str, str], list[str]]: if p == name: out.setdefault((first, last), set()).add(code) return {k: sorted(v) for k, v in out.items()} - - -def open_ends(name: str) -> list[tuple[str, str]]: - """(end, furthest node reached) for every partial match that cannot be - completed, sorted.""" - return sorted((end, at) for p, end, at in derived().get("pattern_open", ()) - if p == name) diff --git a/record/changelog.d/claude-beautiful-cray-sr1fx2.md b/record/changelog.d/claude-beautiful-cray-sr1fx2.md index 84ba72a6..540a5a11 100644 --- a/record/changelog.d/claude-beautiful-cray-sr1fx2.md +++ b/record/changelog.d/claude-beautiful-cray-sr1fx2.md @@ -17,8 +17,6 @@ - A chain's `relation` or `sibling` may name a pattern in place of a field. - The step reads like a field: the first node nests under the last. - Its page names the document each step went *through*. - - It lists paths that stop partway under *Open*, such as an inference - with premises and no conclusion yet. - A chain `invariant` covers pattern steps. - `examples/argument-map`: claims, inferences and objections, with one chain over every step and one that drops steps an objection challenges. diff --git a/record/decisions.d/ADR-tmpfdvjo.md b/record/decisions.d/ADR-tmpfdvjo.md index 4ad9fd12..b3748f73 100644 --- a/record/decisions.d/ADR-tmpfdvjo.md +++ b/record/decisions.d/ADR-tmpfdvjo.md @@ -15,11 +15,12 @@ summary: >- lark grammar for the subset and lowers them to clingo rules over the record's facts, so the logic core stays the one engine. A chain names a pattern in `relation` or `sibling` as it names a field: the pattern's two - ends are the step, its named inner nodes render as "through", and paths - that stop partway are listed as open. The subset is checked against the - schemes at load and is stratified by construction. Rejected: a field list - on the scheme, clingo rule bodies as queries, a graph engine, grand-cypher - over networkx, Raqlet, and GraphGlot as the parser. + ends are the step, and its named inner nodes render as "through". A + partial match is no step and luria derives nothing from it. The subset is + checked against the schemes at load and is stratified by construction. + Rejected: a field list on the scheme, clingo rule bodies as queries, a + graph engine, grand-cypher over networkx, Raqlet, GraphGlot as the parser, + and built-in open ends. influenced_by: - ADR-106 - ADR-125 @@ -143,9 +144,8 @@ edge, a second label, a write). Anything else gets the parse position. takes it as extra program text, memoized like the rest. - **What it derives:** - `pattern_edge(P, First, Last)`: a whole match, by its two ends; - - `pattern_via(P, First, Last, Var, X)`: a named inner node of that match; - - `pattern_open(P, End, X)`: a partial match that cannot be completed, by - the end it starts from and the furthest node it reached. + - `pattern_via(P, First, Last, Var, X)`: a named inner node of that + match. - **Dependency:** `lark`, a core dependency. It is pure Python with no dependencies of its own, stable at 1.x, and small enough that an extra with an install-line refusal would cost more than it saves. @@ -183,15 +183,12 @@ set, and the views it feeds are deterministic, as [ADR-127](ADR-127.md) requires - **Named intermediates are "through".** A variable bound on an inner node (`i` above) is what the step renders as "— through INF-004". An anonymous inner node (`(:INF)`) is matched and not shown. -- **A path that stops partway is listed as open.** A partial match that - cannot be completed is listed in an *Open* section at the end of the - chain's page, by the step it starts from and where it stops. Partial - matches are read from both ends. An inference with premises and no - conclusion is visible only walking from the premise, and one with a - conclusion and no premises only from the conclusion. A match that `WHERE - NOT` removes is excluded, not unfinished, so it is not open. This is not - a lint finding: an inference in progress is a legitimate state. Not - drawing it would read exactly like a line that ends there ([DP-15](../principles.d/DP-015.md)). +- **A path that stops partway is no step.** luria derives nothing from a + partial match. Whether one is unfinished depends on what the record's + schema requires, and a pattern cannot know that. A record that wants the + list writes it as a pattern of its own, such as + `(i:INF)<-[:premise_of]-(:CLAIM) WHERE NOT (i)-[:concludes]->(:CLAIM)`, + and rendering such a pattern is [#373](https://github.com/dmarx/luria/issues/373). - **A chain's `invariant` covers pattern steps.** It is checked between the two ends, as for any edge the chain walks. - **Cycles through a pattern are the chain's existing cycle finding.** @@ -239,10 +236,19 @@ converse, and is never completed by `luria link --fix`. Cypher is the one people know, and its variable-length (`*1..` against `->{1,}`) and negation forms are shorter. Admitting GQL spellings later is a grammar change, not a design change. -- **Open ends as a lint finding.** Rejected: an unfinished inference is a - legitimate state of a record being written, and nothing mechanical can - supply its conclusion. CLAUDE.md admits a check to the lint only when the - violation is always wrong and mechanically fixable. +- **Built-in open ends.** The first implementation listed, on each chain + page, every partial match that could not be completed, read from both + ends. Tried on dmarx/nucleation, it listed six arguments as open, and all + six were complete. They rest only on `grounds`, which nucleation's + `premises` field group allows. It also 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 says so. Whether a partial match is unfinished is a + question about the schema, and the record answers it better as a pattern + than luria does as a heuristic. Removed before merge. +- **Open ends as a lint finding.** Rejected for the same reason, and + because CLAUDE.md admits a check to the lint only when the violation is + always wrong and mechanically fixable. - **Status quo.** Records that argue through intermediate documents keep writing their lines in prose, one paragraph per participant, which is the duplication chains were built to end ([#171](https://github.com/dmarx/luria/issues/171)). @@ -274,16 +280,23 @@ converse, and is never completed by `luria link --fix`. - **Fired before trusting it.** `examples/argument-map` declares claims, inferences and objections, and two chains over patterns. One walks every step through its inference. The other uses `WHERE NOT` to drop a step an - objection challenges. Both list an inference with a premise and no - conclusion as open. The example lints clean, and `tests/test_examples.py` - runs it. Writing the scratch grammar first also corrected a rule this - decision had wrong: `WHERE NOT (i)…` refers to a bound node without a - label. + objection challenges. The example lints clean, and + `tests/test_examples.py` runs it. Writing the scratch grammar first also + corrected a rule this decision had wrong: `WHERE NOT (i)…` refers to a + bound node without a label. +- **Tried on dmarx/nucleation.** Unchanged, every file `luria index` writes + is byte-identical between `main` and this change, and so is `luria lint` + with the network off. With `argued_from: (c:CLAIM)<-[:concludes]-(g:ARG) + -[:rests_on]->(p:CLAIM) WHERE NOT (g {status: 'Rejected'})` added to its + `argument` chain, the steps drawn through ARG-006, ARG-007 and ARG-008 + appear, each "through" its argument. They were invisible to a chain over + `rests_on`. The trial is also what removed built-in open ends (above). - **Not decided here, and made possible by it:** - an outline view that walks a pattern from a root across schemes, which is where `OPTIONAL MATCH` would earn its place; - transcluding document bodies, so views can compose into a manuscript; - - patterns rendered as reports, such as claims no objection challenges. + - patterns rendered as reports, such as claims no objection challenges + or steps a record calls unfinished ([#373](https://github.com/dmarx/luria/issues/373)). - Each is its own decision. This one keeps named intermediates and partial - matches available to them. + Each is its own decision. This one keeps named intermediates available + to them. diff --git a/record/devlog.d/2026/10/13/025600.md b/record/devlog.d/2026/10/13/025600.md index 1a91d27f..907c12d9 100644 --- a/record/devlog.d/2026/10/13/025600.md +++ b/record/devlog.d/2026/10/13/025600.md @@ -99,7 +99,7 @@ the parse. ## Building it -Four things the ADR got wrong or left out, found while implementing 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 @@ -108,12 +108,6 @@ Four things the ADR got wrong or left out, found while implementing it: 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. -- **Open ends need both directions.** Partial matches walked from the first - node find an inference with a conclusion and no premises. The case the - ADR named, premises and no conclusion, is only visible walking from the - last node. `pattern_rules._open` builds prefixes and suffixes both. A - match that `WHERE NOT` removes is left out of open ends: it is excluded, - not unfinished. - **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 @@ -132,6 +126,36 @@ 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. `docs/uncontested.md` drops the challenged step, -and both list INF-004 as open. The example lints clean. +"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](https://github.com/dmarx/luria/issues/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. diff --git a/tests/test_examples.py b/tests/test_examples.py index 9ab97e2f..98f5a69b 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -647,8 +647,7 @@ def test_every_section_of_the_source_says_what_accounts_for_it(example): def test_a_line_of_argument_runs_through_its_inferences(example): """A chain over a path pattern (#371): each step names the inference it - went through, a challenged step is gone from the uncontested view, and - an inference with no conclusion yet is listed as open on both.""" + went through, and a challenged step is gone from the uncontested view.""" root = example("argument-map") argument = (root / "docs/argument.md").read_text() uncontested = (root / "docs/uncontested.md").read_text() @@ -656,6 +655,3 @@ def test_a_line_of_argument_runs_through_its_inferences(example): "[INF-002](../record/inferences.d/INF-002.md)" in argument) assert "through [INF-003]" in argument assert "INF-003" not in uncontested - for page in (argument, uncontested): - assert ("— open at [INF-004](../record/inferences.d/INF-004.md)" - in page) diff --git a/tests/test_pattern_chains.py b/tests/test_pattern_chains.py index 7133ddd3..cf119b6c 100644 --- a/tests/test_pattern_chains.py +++ b/tests/test_pattern_chains.py @@ -67,7 +67,7 @@ def doc(root: Path, scheme: str, number: int, title: str, **fields) -> None: def argument(root: Path) -> None: """C1 and C2 are premises of I1, which concludes C3; C3 is a premise of - I2, which concludes nothing yet.""" + I2, which concludes nothing yet — a partial match, which is no step.""" doc(root, "CLAIM", 1, "First premise", premise_of=["INF-001"]) doc(root, "CLAIM", 2, "Second premise", premise_of=["INF-001"]) doc(root, "CLAIM", 3, "The conclusion", premise_of=["INF-002"]) @@ -97,13 +97,13 @@ def test_the_page_names_what_each_step_went_through(tmp_path, monkeypatch): in page) -def test_a_path_that_stops_partway_is_listed_as_open(tmp_path, monkeypatch): +def test_a_path_that_stops_partway_draws_no_step_and_no_section( + tmp_path, monkeypatch): root = project(tmp_path, monkeypatch) argument(root) page = chains.outputs()[root / "docs/arguments.md"] - assert "## Open" in page - assert ("- [CLAIM-003](../record/claims.d/CLAIM-003.md) — The conclusion — " - "open at [INF-002](../record/inferences.d/INF-002.md)" in page) + assert "INF-002" not in page + assert "## Open" not in page def test_a_converse_side_statement_draws_the_same_line(tmp_path, monkeypatch): diff --git a/tests/test_pattern_rules.py b/tests/test_pattern_rules.py index c616bf51..f9c69052 100644 --- a/tests/test_pattern_rules.py +++ b/tests/test_pattern_rules.py @@ -66,15 +66,19 @@ def test_the_label_filters_where_a_code_lands(): assert "pattern_edge" not in solve({"f": FOLLOWS}, facts) -def test_a_path_stopping_partway_is_open_from_either_end(): +def test_unfinished_is_a_pattern_the_record_writes(): + """What counts as an unfinished step depends on what the schema + requires, so luria derives no partial matches of its own (#373): a + record states it, and only the step it names is reported.""" facts = (docs("CLAIM", "C1", "C2", "C3") + docs("INF", "I1", "I2", "I3") - + [F("ref_value", "C1", "premise_of", "I1"), # full: C2 <- I1 <- C1 + + [F("ref_value", "C1", "premise_of", "I1"), # complete F("ref_value", "I1", "concludes", "C2"), F("ref_value", "C1", "premise_of", "I2"), # premise, no conclusion F("ref_value", "I3", "concludes", "C3")]) # conclusion, no premise - got = solve({"f": FOLLOWS}, facts) - assert got["pattern_edge"] == {("f", "C2", "C1")} - assert got["pattern_open"] == {("f", "C1", "I2"), ("f", "C3", "I3")} + got = solve({"unfinished": "(i:INF)<-[:premise_of]-(a:CLAIM) " + "WHERE NOT (i)-[:concludes]->(:CLAIM)"}, facts) + assert got["pattern_edge"] == {("unfinished", "I2", "C1")} + assert "pattern_open" not in solve({"f": FOLLOWS}, facts) def test_alternation_is_a_union_of_fields():