diff --git a/docs/project-memory.md b/docs/project-memory.md index ad6b8fea..de594018 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -354,6 +354,10 @@ Labels are schemes and edge types are reference fields, checked when the config loads, and the page names what each step went *through* ([ADR-129](../record/decisions.d/ADR-129.md), `examples/argument-map`). +A pattern that is not a line — a thesis nothing objects to, an inference +with no conclusion yet — takes an `output:` instead, and renders as a page +of its matches, a view luria supplies ([ADR-tmpgicpu](../record/decisions.d/ADR-tmpgicpu.md)). + **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/argument-map/README.md b/examples/argument-map/README.md index d9e177cc..f4f4f434 100644 --- a/examples/argument-map/README.md +++ b/examples/argument-map/README.md @@ -28,17 +28,19 @@ 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 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: +**An unfinished step is a page of its own.** [INF-004](record/inferences.d/INF-004.md) has a premise and no +conclusion yet, so it is not a step on either chain. What counts as +unfinished depends on what this record requires, so luria does not guess; +the record says it as a pattern, and gives the pattern an `output`: ```yaml -unfinished: (i:INF)<-[:premise_of]-(:CLAIM) WHERE NOT (i)-[:concludes]->(:CLAIM) +unfinished: + match: (i:INF)<-[:premise_of]-(p:CLAIM) WHERE NOT (i)-[:concludes]->(:CLAIM) + output: docs/unfinished.md ``` -Rendering such a pattern as a list of its matches is luria's -[issue 373](https://github.com/dmarx/luria/issues/373). +[Unfinished steps](docs/unfinished.md) lists each match, one column per +named node: the inference and the premise it has. ``` LURIA_ROOT=$PWD luria index diff --git a/examples/argument-map/luria.yaml b/examples/argument-map/luria.yaml index 5d198203..2ed8929d 100644 --- a/examples/argument-map/luria.yaml +++ b/examples/argument-map/luria.yaml @@ -64,6 +64,14 @@ patterns: (b:CLAIM)<-[:concludes]-(i:INF)<-[:premise_of]-(a:CLAIM) WHERE NOT (i)<-[:challenges]-(:OBJ) blurb: A step no objection challenges. + # What this record calls unfinished: an inference with premises and no + # conclusion. It renders as a page of its matches rather than a chain, + # since it relates an inference to a claim, not a claim to a claim. + unfinished: + match: (i:INF)<-[:premise_of]-(p:CLAIM) WHERE NOT (i)-[:concludes]->(:CLAIM) + blurb: Inferences with premises in place and no conclusion yet. + output: docs/unfinished.md + title: Unfinished steps chains: argument: scheme: CLAIM diff --git a/examples/argument-map/record/claims.d/CLAIM-005.md b/examples/argument-map/record/claims.d/CLAIM-005.md index c2c6f9e2..0c8274e5 100644 --- a/examples/argument-map/record/claims.d/CLAIM-005.md +++ b/examples/argument-map/record/claims.d/CLAIM-005.md @@ -11,4 +11,4 @@ premise_of: 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 it draws no step on -either page. +either chain and is listed with the unfinished steps. diff --git a/examples/argument-map/record/inferences.d/INF-004.md b/examples/argument-map/record/inferences.d/INF-004.md index dde43574..8038f318 100644 --- a/examples/argument-map/record/inferences.d/INF-004.md +++ b/examples/argument-map/record/inferences.d/INF-004.md @@ -10,4 +10,5 @@ premises: # INF-004: What the repair should cost 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. +is no step, so neither chain draws one until it names its conclusion; the +`unfinished` pattern lists it on a page of its own. diff --git a/luria/config.py b/luria/config.py index 726bba18..9d87690b 100644 --- a/luria/config.py +++ b/luria/config.py @@ -1992,8 +1992,8 @@ class View: name: str # The Jinja source, relative to the project root. Keep it out of every # directory the link fixer scans: a code inside an expression would be - # rewritten into a link. - template: Path + # rewritten into a link. Or `luria:NAME`, one of luria's own. + template: Path | str # Where the rendered text lands: any path in the repository, a root file # such as `CLAUDE.md` included. A markdown output is stamped as generated. output: Path @@ -2004,9 +2004,18 @@ class View: # read has changed since. For an output that is published: a submitted # paper, a released document. pin: bool = False + # For a view luria supplies: the values its template reads, and the + # config key that asked for it (`patterns.NAME`). + context: dict = dcfield(default_factory=dict, compare=False, hash=False) + origin: str = "" + + @property + def where(self) -> str: + return self.origin or f"views.{self.name}" -def _views(raw: dict, root: Path) -> dict[str, View]: +def _views(raw: dict, root: Path, + patterns: dict | None = None) -> dict[str, View]: """Every declared view. Both paths are required: a view without a template renders nothing, and a view without an output renders it nowhere — either looks exactly like a view that is current (DP-15).""" @@ -2031,6 +2040,16 @@ def _views(raw: dict, root: Path) -> dict[str, View]: raise ValueError(f"{where}: `output` is the template itself, so " "the first render would overwrite its source") out[str(name)] = view + # A pattern's page is a view luria supplies (#373). + for p in (patterns or {}).values(): + if p.output is not None: + key = f"patterns.{p.name}" + out[key] = View(key, "luria:pattern.md.j2", p.output, origin=key, + context={ + "pattern": p.name, "source": p.match, + "blurb": p.blurb, "columns": p.parsed.named, + "title": p.title or p.name.replace( + "_", " ").capitalize()}) return out @@ -2087,11 +2106,18 @@ class NamedPattern: one. A row names the match's first node's document, and that document acknowledges its rows with `pattern-ok: inverted_premise — reason` (any scope; one that answers for no row is reported stale). A pattern - named like a built-in class is refused at load: one name, one class.""" + named like a built-in class is refused at load: one name, one class. + + **A pattern as a page.** `output: docs/unopposed.md` (and an optional + `title:`) renders every match there, acknowledged or not: one row per + match, one column per named node. It is a view luria supplies (ADR-130), + a list and never a finding; a pattern may have a page, a class, or both.""" name: str match: str blurb: str = "" report: bool = False + output: Path | None = None + title: 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) @@ -2583,7 +2609,7 @@ def load(root: Path | None = None, text: str | None = None, paths = raw["paths"] vocabularies, vocabulary_meta = _vocabulary_tables( raw.get("vocabularies") or {}) - return Config( + cfg = Config( root=root, vocabularies=vocabularies, vocabulary_meta=vocabulary_meta, @@ -2644,9 +2670,9 @@ def load(root: Path | None = None, text: str | None = None, for name, spec in raw.get("journals", {}).items() }, patterns=(patterns := _patterns(raw.get("patterns") or {}, - schemes)), + schemes, root)), chains=_chains(raw.get("chains", {}), schemes, root, patterns), - views=_views(raw.get("views") or {}, root), + views=_views(raw.get("views") or {}, root, patterns), stale_days=int(raw.get("stale_days", 90)), user_agent=str(raw.get("user_agent") or DEFAULTS["user_agent"]), @@ -2661,6 +2687,8 @@ def load(root: Path | None = None, text: str | None = None, site=_site(raw, root), _raw=raw, ) + _check_rendered(cfg) + return cfg def nameable(scheme) -> set[str]: @@ -2677,22 +2705,25 @@ def nameable(scheme) -> set[str]: | set(scheme.requires) | {"status"}) -def _patterns(raw: dict, schemes: dict) -> dict[str, NamedPattern]: +def _patterns(raw: dict, schemes: dict, + root: Path | None = None) -> 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 + root = root or Path(".") 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", "report"}: + if unknown := set(table) - {"match", "blurb", "report", "output", + "title"}: raise ValueError( f"luria.yaml: patterns.{name}: {', '.join(sorted(unknown))} " f"is not a key — a pattern is its `match:`, an optional " - f"`blurb:` and an optional `report:`") + f"`blurb:`, `report:`, `output:` and `title:`") report = table.get("report", False) if not isinstance(report, bool): raise ValueError( @@ -2709,10 +2740,21 @@ def _patterns(raw: dict, schemes: dict) -> dict[str, NamedPattern]: f"a warning class named {name!r}, which is already a " f"built-in class — rename the pattern") source = str(table.get("match") or "") + parsed = parse(name, source) + output = root / str(table["output"]) if table.get("output") else None + title = str(table.get("title") or "").strip() + where = f"luria.yaml: patterns.{name}: " + if title and output is None: + raise ValueError(where + "`title` names a page, and there is no " + "`output` for it to head") + if output is not None and not parsed.named: + raise ValueError(where + "names no node, so a page of its matches " + "would have no columns — name one, as in " + "`(c:CLAIM)`") out[name] = NamedPattern(name=name, match=source.strip(), blurb=str(table.get("blurb", "")).strip(), - report=report, - _parsed=parse(name, source)) + report=report, output=output, title=title, + _parsed=parsed) if out: from . import consistency consistency.check_patterns(out, schemes) @@ -2782,6 +2824,34 @@ def listed(value) -> tuple[str, ...]: for name, s in specs.items()} +def _check_rendered(cfg: "Config") -> None: + """Refuse two renderers writing one path, naming both: whichever wins + looks exactly like a correct page (DP-15). Every renderer is gathered + here, so none is checked against only some of the others.""" + from . import reports + claimed = [] # (path, owner, key) + for prefix, s in cfg.schemes.items(): + key = f"schemes.{prefix}" + claimed += [(p, key, key) for p in (s.output, s.render == "index" + and s.index_path) if p] + claimed += [(cfg.reports / n, f"the status report {n}", "paths.reports") + for n in reports.NAMES] + claimed.append((cfg.record_doc, "the record description", "paths.docs")) + if cfg.owns_schema: + claimed.append((cfg.config_doc, "the configuration reference", + "paths.docs")) + claimed += [(c.output, f"chains.{c.name}", f"chains.{c.name}") + for c in cfg.chains.values()] + claimed += [(v.output, v.where, v.where) for v in cfg.views.values()] + taken: dict[Path, str] = {} + for path, owner, key in claimed: + path = Path(os.path.normpath(path)) + if taken.setdefault(path, owner) != owner: + raise ValueError( + f"luria.yaml: {key}: `output` {cfg.rel(path)} is already " + f"where {taken[path]} renders") + + # `status` alone, and only because it has a vocabulary a scheme need not # declare: `statuses.vocabulary` falls back to the default five. Any other # field — `tags` since ADR-098, the successor since the ADR on explicit diff --git a/luria/pattern_rules.py b/luria/pattern_rules.py index 8f411151..d1b7fc4e 100644 --- a/luria/pattern_rules.py +++ b/luria/pattern_rules.py @@ -42,7 +42,8 @@ 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). +`WHERE NOT` and all, and gives it an `output:` for a page of its matches +(#373), which is a view over `pattern_row` like any other. 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 diff --git a/luria/patterns.py b/luria/patterns.py index 0d219bac..0d574e67 100644 --- a/luria/patterns.py +++ b/luria/patterns.py @@ -97,6 +97,13 @@ def first(self) -> Node: def last(self) -> Node: return self.path.nodes[-1] + @property + def named(self) -> tuple[tuple[str, str], ...]: + """(variable, label) of each node the match binds by name, in + pattern order; anonymous and `WHERE NOT` nodes bind none.""" + return tuple(dict.fromkeys( + (n.var, n.label) for n in self.path.nodes if n.var and not n.bound)) + @functools.cache def _parser() -> lark.Lark: diff --git a/luria/reports.py b/luria/reports.py index 5c5c7f35..719b9293 100644 --- a/luria/reports.py +++ b/luria/reports.py @@ -339,16 +339,20 @@ def unbound_lineage(base: Path | None = None) -> str: return "\n".join(out) +# One table, which `outputs` and the config's collision check both read. +NAMES = { + "reference-status.md": reference_status, + "pending-decisions.md": pending_decisions, + "unbound-lineage.md": unbound_lineage, +} + + def outputs(out_dir: Path | None = None) -> dict[Path, str]: """Every report, keyed by where it lands — the unit `luria index` renders, so a stale report fails the same staleness check as every other view (#35).""" out_dir = current().reports if out_dir is None else out_dir - return { - out_dir / "reference-status.md": reference_status(out_dir), - out_dir / "pending-decisions.md": pending_decisions(out_dir), - out_dir / "unbound-lineage.md": unbound_lineage(out_dir), - } + return {out_dir / name: render(out_dir) for name, render in NAMES.items()} def write(out_dir: Path | None = None) -> list[Path]: diff --git a/luria/view_reads.py b/luria/view_reads.py index 7101d471..18d98220 100644 --- a/luria/view_reads.py +++ b/luria/view_reads.py @@ -72,7 +72,8 @@ def retired_rows(rendered: dict | None = None) -> list[str]: if site.kind != "transcluded" or _acknowledged( site.template, site.line, read.code): continue - rows.append(f"views.{name}: {site.template}:{site.line} " + rows.append(f"{current().views[name].where}: " + f"{site.template}:{site.line} " f"transcludes {read.code}, which is " f"{read.status}{after}") return rows diff --git a/luria/view_templates/pattern.md.j2 b/luria/view_templates/pattern.md.j2 new file mode 100644 index 00000000..1f57816f --- /dev/null +++ b/luria/view_templates/pattern.md.j2 @@ -0,0 +1,23 @@ +{#- A pattern's page (#373); values from `config._views`. Codes are bare: + the link fixer links them in the page's frame. -#} +{% set rows = match(pattern) %} +# {{ title }} + +{% if blurb %} +{{ blurb }} + +{% endif %} +`{{ source }}` · {{ rows | length }} match{{ "" if rows | length == 1 else "es" }} + +{% if not rows %} +No matches. +{% else %} +|{% for var, label in columns %} {{ var }} ({{ label }}) |{% endfor %} + +|{% for _ in columns %}---|{% endfor %} + +{% for row in rows %} +|{% for var, _ in columns %} {{ row[var].code }} — {{ row[var].title | cell }} |{% endfor %} + +{% endfor %} +{% endif %} diff --git a/luria/views.py b/luria/views.py index 9ac00571..2e55e454 100644 --- a/luria/views.py +++ b/luria/views.py @@ -32,6 +32,9 @@ Composition is Jinja's own: `include`, `import` and `extends` resolve from the project root, so a mostly hand-written file is a template that is mostly prose. + +A template named `luria:NAME` is one of luria's own (`view_templates/`), +given its values as `context`: a pattern's page is such a view (#373). """ from __future__ import annotations @@ -52,6 +55,10 @@ STAMP = ("\n\n") +# A supplied view has no template to edit; its stamp names the config key. +SUPPLIED_STAMP = ("\n\n") +PACKAGE_TEMPLATES = Path(__file__).parent / "view_templates" class ViewError(Exception): """A view that cannot render, said once, with the template's line.""" @@ -203,8 +210,7 @@ def match(self, pattern: str) -> list[dict[str, Document]]: written here in the same syntax and checked the same way.""" from . import logic, pattern_rules parsed = _pattern(self.cfg, pattern) - named = list(dict.fromkeys( - n.var for n in parsed.path.nodes if n.var)) + named = [var for var, _ in parsed.named] if not named: raise _Refused(f"`{pattern}` names no node, so a match has " "nothing to show — name one, as in `(c:CLAIM)`") @@ -217,10 +223,11 @@ def match(self, pattern: str) -> list[dict[str, Document]]: "relations", extra=text, families=families).get("pattern_row", ()): found.setdefault(key, {})[var] = code - ordered = sorted(found.values(), - key=lambda row: [row.get(v, "") for v in named]) - return [{v: self.resolve(row[v]) for v in named if v in row} - for row in ordered] + # Matches differing only in an anonymous node are one row. + rows = {tuple(row.get(v, "") for v in named): row + for row in found.values()} + return [{v: self.resolve(rows[k][v]) for v in named if v in rows[k]} + for k in sorted(rows)] def vocabulary(self, name: str) -> list[Term]: values = self.cfg.vocabularies.get(name) @@ -288,17 +295,28 @@ def _holds(contract, meta: dict, field: str, wanted) -> bool: def _environment() -> SandboxedEnvironment: """One environment per render: the loader reads the tree as it is now, and nothing survives from one view to the next.""" - return SandboxedEnvironment( - loader=jinja2.FileSystemLoader(str(current().root)), + env = SandboxedEnvironment( + # luria's own `luria:` templates first, so a project cannot shadow one. + loader=jinja2.ChoiceLoader([ + jinja2.PrefixLoader( + {"luria": jinja2.FileSystemLoader(str(PACKAGE_TEMPLATES))}, + delimiter=":"), + jinja2.FileSystemLoader(str(current().root)), + ]), undefined=_Undefined, autoescape=False, keep_trailing_newline=True, trim_blocks=True, lstrip_blocks=True, ) + from .adr_index import escape_cell + env.filters["cell"] = escape_cell + return env def _name(view) -> str: + if isinstance(view.template, str): # `luria:NAME` + return view.template return current().rel(view.template).replace(os.sep, "/") @@ -308,9 +326,16 @@ def _located(view, exc: BaseException) -> ViewError: where = f"{exc.name or _name(view)}:{exc.lineno}" return ViewError(f"{where}: {exc.message}") root = str(current().root) + ours = str(PACKAGE_TEMPLATES) where = _name(view) for frame in reversed(traceback.extract_tb(exc.__traceback__)): - if frame.filename.startswith(root) and not frame.filename.endswith(".py"): + if frame.filename.endswith(".py"): + continue + if frame.filename.startswith(ours): + where = (f"luria:{Path(frame.filename).relative_to(ours).as_posix()}" + f":{frame.lineno}") + break + if frame.filename.startswith(root): where = (f"{Path(frame.filename).relative_to(root).as_posix()}" f":{frame.lineno}") break @@ -347,13 +372,14 @@ def render(view) -> str: journals=record.journals, chains=record.chains, views=record.views, paths=record.paths) try: - text = env.get_template(_name(view)).render() + text = env.get_template(_name(view)).render(view.context) except (jinja2.TemplateError, _Refused, TypeError, ValueError, AttributeError, KeyError, LookupError) as exc: raise _located(view, exc) from None text, _ = doc_refs.linkify(text, view.output) if view.output.suffix == ".md": - text = STAMP.format(template=_name(view)) + text + text = (SUPPLIED_STAMP.format(origin=view.where) if view.origin + else STAMP.format(template=_name(view))) + text _RENDERED[view.output] = Rendering(text, record.reads()) return text @@ -389,7 +415,7 @@ def errors(rendered: dict[Path, str] | None = None) -> list[str]: try: render(view) except ViewError as exc: - found.append(f"views.{view.name}: {exc}") + found.append(f"{view.where}: {exc}") return found diff --git a/record/changelog.d/pattern-pages-373.md b/record/changelog.d/pattern-pages-373.md new file mode 100644 index 00000000..a89239b5 --- /dev/null +++ b/record/changelog.d/pattern-pages-373.md @@ -0,0 +1,13 @@ +### Added + +- **A path pattern can render a page of its matches** ([#373](https://github.com/dmarx/luria/issues/373)). Give it an + `output:`, and optionally a `title:`, and `luria index` writes one row per + match there, one column per named node, including patterns no chain can + walk. The page is a view luria supplies and a list, never a lint finding; + `report: true` still makes a lint class, and a pattern may have both. +- `examples/argument-map` lists its unfinished steps on such a page. + +### Changed + +- Two renderers writing one path are refused at load, naming both. +- A view's `match` lists matches differing only in an anonymous node once. diff --git a/record/decisions.d/ADR-129.md b/record/decisions.d/ADR-129.md index 272b7017..b2791a19 100644 --- a/record/decisions.d/ADR-129.md +++ b/record/decisions.d/ADR-129.md @@ -191,7 +191,8 @@ set, and the views it feeds are deterministic, as [ADR-127](ADR-127.md) requires 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). + and gives it an `output:` to render it as a page of its matches + ([ADR-tmpgicpu](ADR-tmpgicpu.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.** @@ -297,9 +298,7 @@ converse, and is never completed by `luria link --fix`. - **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 - or steps a record calls unfinished ([#373](https://github.com/dmarx/luria/issues/373)). + - transcluding document bodies, so views can compose into a manuscript. Each is its own decision. This one keeps named intermediates available to them. diff --git a/record/decisions.d/ADR-tmpgicpu.md b/record/decisions.d/ADR-tmpgicpu.md new file mode 100644 index 00000000..aa8182c7 --- /dev/null +++ b/record/decisions.d/ADR-tmpgicpu.md @@ -0,0 +1,109 @@ +--- +status: Proposed +title: 'A named path pattern may render a page of its matches' +version: 1 +tags: +- record +- mechanism +date: '2026-10-13' +issue: '#373' +summary: >- + A pattern with an `output:` (and optional `title:`) renders there as a + view luria supplies, `luria:pattern.md.j2`: one row per match, one column + per named node. It is a list, never a finding; `report: true` is the + separate lint consumer, a pattern may have both, and acknowledged matches + stay listed. Every path luria renders is checked against every other at + load. Rejected: a renderer of its own, closing the issue as served by + views, a `reports:` family, fixed placement, and grouping rows. +influenced_by: +- ADR-129 +- ADR-130 +- ADR-131 +- ADR-035 +--- + +# ADR-tmpgicpu: A named path pattern may render a page of its matches + +## Context + +[ADR-129](ADR-129.md) gave patterns one consumer, a chain, which needs a pattern from its +own scheme back to it and shows matches only as steps. Most of what a record +asks has no such shape: a thesis nothing objects to (one node with +`WHERE NOT`), an argument an Active claim undercuts (across schemes), a step +the record calls unfinished. A view template ([ADR-130](ADR-130.md)) can already list +them with `match(name)`, and `report: true` ([ADR-131](ADR-131.md)) makes them findings. The +question is who writes the list's template. + +## Decision + +**A pattern table may take `output:` and `title:`, and the page is a view +luria supplies.** + +```yaml +patterns: + unopposed: + match: >- + (c:CLAIM {role: 'thesis'}) WHERE NOT (c)<-[:objects_to]-(:CLAIM) + blurb: Theses nothing in the record has objected to yet. + output: docs/unopposed.md +``` + +- **One template, shipped.** At load, a pattern with an `output` adds a + view on `luria:pattern.md.j2` (`luria/view_templates/`), with the + pattern's name, source, blurb, title and named nodes as its values. The + view loader tries the `luria:` prefix in the package before the project + root, inside the same sandbox. The template writes codes bare, and the + link fixer links them in the page's frame. +- **The page.** A heading (the title, else the name), the blurb, the + pattern in a code span with its count, and a table: one column per named + node, headed `var (LABEL)` in pattern order; one row per match, sorted by + its codes; each cell the code and " — " title, `|` escaped by a `cell` + filter. "No matches." replaces an empty table. An anonymous node is not a + column, so two matches differing only in one are a single row; `match` + now collapses them for every template. A pattern that names no node, or a + `title` without an `output`, is refused at load. +- **Being a view**, it is written by `luria index`, held fresh by + `--check`, kept out of the reference scan, and located on error as + `patterns.` with the template line, which its stamp names too. +- **A list, never a finding.** No lint class reads the page; whether a + match matters is a judgement ([ADR-035](ADR-035.md)). `report: true` is the consumer that + makes matches findings, and a pattern may have both. A `pattern-ok:` + acknowledgement silences the finding and leaves the row: the page lists + what matches, not what is outstanding. +- **One collision check for every renderer.** At load, every path luria + renders is gathered: scheme outputs and indexes, the status reports + (`reports.NAMES`, which the renderer reads too), the record description, + the configuration reference, chains, views and pattern pages. Two that + coincide are refused, naming both. Before, no pair was checked, and + whichever view wins looks correct. + +## Alternatives considered + +- **A renderer of its own**, with a `pattern_match/2` predicate, a tuple + reader in the logic core and a module writing the table. This was the + first version of this decision. It duplicated `match` and needed its own + freshness, exclusion and error locating, which a view already has. +- **Close [#373](https://github.com/dmarx/luria/issues/373) as served by views**, each record writing its own template. + Nothing new in luria, but every record rewrites the same twenty-line table + and the copies drift: one escapes `|`, another forgets the empty case. +- **A `reports:` family that names patterns**: another family for what one + key does, where `output:` already means "renders a page here" on a chain. +- **Fixed placement under `docs/reports/`**, which holds luria's own + reports; a record's questions belong in its own docs. +- **Grouping rows under their first column.** Deferred until needed. + +## Consequences + +- `View` gains a context and an origin; `luria views` lists a pattern page + as `patterns.`. A page must be listed in `docs/README.md` like any + other page there (`check_docs_index`). +- **Tried on nucleation** (scratch copy of cf318d525, network off). With no + `output`, `luria index` writes the same files and `luria lint` prints the + same lines as luria `main`, its `report: true` and chain patterns + included. With `unopposed` and + `undercut: (g:ARG)<-[:undercuts]-(c:CLAIM {status: 'Active'})` added, 286 + of 361 theses are unopposed and 8 rows cover 6 undercut arguments, the + same sets a script reading the frontmatter found. The status reports did + not change, and the lint added only the docs-index entries for the two + pages. +- `examples/argument-map` declares `unfinished`; its one row is INF-004. diff --git a/record/devlog.d/2026/10/13/075154.md b/record/devlog.d/2026/10/13/075154.md new file mode 100644 index 00000000..c8d8ef40 --- /dev/null +++ b/record/devlog.d/2026/10/13/075154.md @@ -0,0 +1,28 @@ +--- +title: 'Pattern pages, and what nucleation said about them' +created: '2026-10-13T07:51:54' +tags: [] +--- + +[#373](https://github.com/dmarx/luria/issues/373) gives a named pattern a page of its matches ([ADR-tmpgicpu](../../record/decisions.d/ADR-tmpgicpu.md)), as a view +luria supplies on its own template, `luria:pattern.md.j2`. + +**The first version was a renderer of its own, and the rework deleted it.** +`pattern_match/2`, a clingo-tuple reader in `logic` and +`pattern_reports.py` duplicated `views._Record.match`, which reached `main` +while the branch was open. As a view, the page's tests from the first +version pass with the same output, link targets included. + +- **`match` returned one row per whole match**, so two matches differing + only in an anonymous node were two identical rows. It now collapses them. +- **No pair of outputs was checked for collisions**; now all are, in one place. +- **YAML eats property maps**: fold (`>-`) or quote a pattern holding one. +- **`luria new changelog --name ` returns an existing path** on a + reused branch, and a shell redirect overwrites the previous fragment. + +On nucleation the unopposed count moved from 115 of 175 theses to 286 of +361 because the record grew; undercut stayed at 8 rows. + +**Follow-up:** `pattern_row/4` (views) and `pattern_node/4` (the `report: +true` classes) are two encodings of one match. A test pins that the page and +the class agree; one predicate for both would remove the need. diff --git a/tests/test_examples.py b/tests/test_examples.py index 98f5a69b..932a72d8 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -655,3 +655,14 @@ 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 + + +def test_what_the_record_calls_unfinished_is_a_page(example): + """A pattern with an `output` renders its matches (#373): INF-004 has a + premise and no conclusion, and is the one row.""" + root = example("argument-map") + page = (root / "docs/unfinished.md").read_text() + assert "· 1 match\n" in page + assert ("| [INF-004](../record/inferences.d/INF-004.md) — What the repair " + "should cost | [CLAIM-005](../record/claims.d/CLAIM-005.md) — The " + "bridge should be repaired before winter |") in page diff --git a/tests/test_pattern_pages.py b/tests/test_pattern_pages.py new file mode 100644 index 00000000..97ca60e1 --- /dev/null +++ b/tests/test_pattern_pages.py @@ -0,0 +1,268 @@ +# tests/test_pattern_pages.py +"""A named path pattern rendered as a page of its matches (#373): a view +luria supplies, beside the lint class `report: true` makes (#382).""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from _config import merged +from luria import adr_index, config, pattern_report, pattern_rules, views +from luria.adr_index import outputs +from luria.view_document import _Refused + +BASE = """ +issue_url: https://example.test/issues/{n} +schemes: + CLAIM: + dir: record/claims.d + output: docs/claims + references: + premise_of: {scheme: INF, many: true, required: false, converse: premises} + objects_to: {scheme: CLAIM, many: true, required: false} + INF: + dir: record/inferences.d + output: docs/inferences + references: + premises: {scheme: CLAIM, many: true, required: false, converse: premise_of} + concludes: {scheme: CLAIM, required: false} +""" + +UNFINISHED = {"patterns": {"unfinished": { + "match": "(i:INF)<-[:premise_of]-(p:CLAIM) " + "WHERE NOT (i)-[:concludes]->(:CLAIM)", + "blurb": "Inferences with premises and no conclusion yet.", + "output": "docs/unfinished.md", + "title": "Unfinished steps"}}} + + +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'"] + front += [f"{k}: {v}" for k, v in fields.items()] + 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: + doc(root, "CLAIM", 1, "First premise", premise_of=["INF-001", "INF-002"]) + doc(root, "CLAIM", 2, "Second premise", premise_of=["INF-002"]) + doc(root, "CLAIM", 3, "Conclusion") + doc(root, "INF", 1, "Finished", concludes="CLAIM-003") + doc(root, "INF", 2, "Unfinished | with a pipe") + + +def page(root: Path) -> str: + return views.outputs()[root / "docs/unfinished.md"] + + +def test_a_page_lists_each_match_with_a_column_per_named_node( + tmp_path, monkeypatch): + root = project(tmp_path, monkeypatch, UNFINISHED) + argument(root) + text = page(root) + assert text.startswith( + "") + assert "# Unfinished steps" in text + assert "Inferences with premises and no conclusion yet." in text + assert ("`(i:INF)<-[:premise_of]-(p:CLAIM) WHERE NOT " + "(i)-[:concludes]->(:CLAIM)` · 2 matches") in text + assert "| i (INF) | p (CLAIM) |\n|---|---|\n" in text + rows = [line for line in text.splitlines() if line.startswith("| [")] + assert rows == [ + "| [INF-002](../record/inferences.d/INF-002.md) — Unfinished \\| with " + "a pipe | [CLAIM-001](../record/claims.d/CLAIM-001.md) — First premise |", + "| [INF-002](../record/inferences.d/INF-002.md) — Unfinished \\| with " + "a pipe | [CLAIM-002](../record/claims.d/CLAIM-002.md) — Second premise |", + ] + + +def test_an_empty_result_says_so(tmp_path, monkeypatch): + root = project(tmp_path, monkeypatch, UNFINISHED) + doc(root, "CLAIM", 1, "Premise", premise_of=["INF-001"]) + doc(root, "CLAIM", 2, "Conclusion") + doc(root, "INF", 1, "Finished", concludes="CLAIM-002") + text = page(root) + assert "· 0 matches\n\nNo matches.\n" in text and "|---|" not in text + + +def test_a_zero_hop_pattern_renders(tmp_path, monkeypatch): + root = project(tmp_path, monkeypatch, {"patterns": {"unopposed": { + "match": "(c:CLAIM) WHERE NOT (c)<-[:objects_to]-(:CLAIM)", + "output": "docs/unopposed.md"}}}) + doc(root, "CLAIM", 1, "Thesis") + doc(root, "CLAIM", 2, "Objection", objects_to=["CLAIM-001"]) + text = views.outputs()[root / "docs/unopposed.md"] + assert "# Unopposed" in text + assert "· 1 match\n" in text + assert "| [CLAIM-002](../record/claims.d/CLAIM-002.md) — Objection |" in text + assert "CLAIM-001](" not in text + + +def test_anonymous_and_repeated_nodes_are_not_columns(tmp_path, monkeypatch): + root = project(tmp_path, monkeypatch, {"patterns": {"steps": { + "match": "(c:CLAIM)<-[:concludes]-(:INF)<-[:premise_of]-(p:CLAIM)", + "output": "docs/steps.md"}}}) + argument(root) + text = views.outputs()[root / "docs/steps.md"] + assert "| c (CLAIM) | p (CLAIM) |" in text + assert "INF-001" not in text + + +def test_matches_differing_only_in_an_anonymous_node_are_one_row( + tmp_path, monkeypatch): + """INF-002 has two premises; with the premise anonymous, one row.""" + root = project(tmp_path, monkeypatch, {"patterns": {"open": { + "match": "(i:INF)<-[:premise_of]-(:CLAIM) " + "WHERE NOT (i)-[:concludes]->(:CLAIM)", + "output": "docs/open.md"}}}) + argument(root) + text = views.outputs()[root / "docs/open.md"] + assert "· 1 match\n" in text and text.count("| [INF-002](") == 1 + + +def test_a_pattern_without_output_renders_nothing(tmp_path, monkeypatch): + root = project(tmp_path, monkeypatch, {"patterns": { + "quiet": "(c:CLAIM) WHERE NOT (c)<-[:objects_to]-(:CLAIM)"}}) + doc(root, "CLAIM", 1, "Alone") + assert views.outputs() == {} + + +def test_the_page_is_a_view_luria_index_writes(tmp_path, monkeypatch): + root = project(tmp_path, monkeypatch, UNFINISHED) + argument(root) + assert root / "docs/unfinished.md" in outputs() + + +def test_the_page_is_not_scanned_for_citations(tmp_path, monkeypatch): + """It lists retired documents by design, at sites nobody can annotate.""" + root = project(tmp_path, monkeypatch, UNFINISHED) + argument(root) + assert config.current().is_generated(root / "docs/unfinished.md") + + +@pytest.mark.parametrize("extra, says", [ + ({"patterns": {"p": {"match": "(:CLAIM) WHERE NOT (:INF)", + "output": "docs/p.md"}}}, "patterns.p: names no node"), + ({"patterns": {"p": {"match": "(c:CLAIM)", "title": "No page"}}}, + "patterns.p: `title` names a page, and there is no `output`"), +]) +def test_a_page_that_cannot_render_is_refused(tmp_path, monkeypatch, extra, + says): + project(tmp_path, monkeypatch, extra) + with pytest.raises(ValueError, match=r"luria\.yaml: ") as caught: + config.load(tmp_path) + assert says in str(caught.value) + + +SPEC = {"patterns": {"match": "(c:CLAIM)"}, "views": {"template": "t.j2"}, + "chains": {"scheme": "CLAIM", "relation": "objects_to"}} + + +def family(name, **out): + return {name: {n: {**SPEC[name], "output": o} for n, o in out.items()}} + + +def pats(**out): return family("patterns", **out) +def chains(**out): return family("chains", **out) +def views_(**out): return family("views", **out) + + +# One check covers every renderer whose path the config chooses, so each +# pairing that can occur is refused the same way, naming both. +@pytest.mark.parametrize("extra, later, earlier", [ + (pats(p="docs/claims"), "patterns.p", "schemes.CLAIM"), + (pats(p="docs/claims/README.md"), "patterns.p", "schemes.CLAIM"), + (pats(p="docs/./x.md", q="docs/x.md"), "patterns.q", "patterns.p"), + (pats(p="docs/reports/unbound-lineage.md"), "patterns.p", + "the status report unbound-lineage.md"), + (pats(p="docs/record.md"), "patterns.p", "the record description"), + ({**pats(p="docs/x.md"), **chains(c="docs/x.md")}, "patterns.p", + "chains.c"), + ({**pats(p="docs/x.md"), **views_(v="docs/x.md")}, "patterns.p", + "views.v"), + (chains(c="docs/claims/README.md"), "chains.c", "schemes.CLAIM"), + (chains(c="docs/x.md", d="docs/x.md"), "chains.d", "chains.c"), + (chains(c="docs/reports/pending-decisions.md"), "chains.c", + "the status report pending-decisions.md"), + (views_(v="docs/inferences/README.md"), "views.v", "schemes.INF"), + (views_(v="docs/x.md", w="docs/x.md"), "views.w", "views.v"), + ({**chains(c="docs/x.md"), **views_(v="docs/x.md")}, "views.v", + "chains.c"), + (views_(v="docs/reports/reference-status.md"), "views.v", + "the status report reference-status.md"), + ({"schemes": {"NOTE": {"dir": "record/notes.d", "output": "docs/claims"}}}, + "schemes.NOTE", "schemes.CLAIM"), +]) +def test_two_renderers_at_one_path_are_refused(tmp_path, monkeypatch, extra, + later, earlier): + project(tmp_path, monkeypatch, extra) + with pytest.raises(ValueError) as caught: + config.load(tmp_path) + said = str(caught.value) + assert said.startswith(f"luria.yaml: {later}: `output` "), said + assert f"is already where {earlier} renders" in said + + +def test_distinct_outputs_load(tmp_path, monkeypatch): + project(tmp_path, monkeypatch, pats(p="docs/p.md"), chains(c="docs/c.md"), + views_(v="docs/v.md")) + assert len(config.load(tmp_path).views) == 2 + + +def test_index_check_holds_the_page_fresh(tmp_path, monkeypatch): + root = project(tmp_path, monkeypatch, UNFINISHED) + argument(root) + adr_index.run() + assert root / "docs/unfinished.md" not in adr_index.staleness().stale + doc(root, "INF", 3, "Another unfinished step") + doc(root, "CLAIM", 4, "Its premise", premise_of=["INF-003"]) + assert root / "docs/unfinished.md" in adr_index.staleness().stale + + +def test_a_render_error_names_the_pattern_and_the_template_line( + tmp_path, monkeypatch): + argument(project(tmp_path, monkeypatch, UNFINISHED)) + + def refused(self, pattern): + raise _Refused("out to lunch") + monkeypatch.setattr(views._Record, "match", refused) + assert views.errors() == [ + "patterns.unfinished: luria:pattern.md.j2:3: out to lunch"] + + +BOTH = {"patterns": {"unfinished": {**UNFINISHED["patterns"]["unfinished"], + "report": True}}} + + +def test_a_page_and_a_lint_class_list_the_same_matches(tmp_path, monkeypatch): + """A pattern may have both (#382); the page lists what the class finds.""" + root = project(tmp_path, monkeypatch, BOTH) + argument(root) + page_rows = [(r["i"].code, r["p"].code) for r in + views._Record(root / "docs/unfinished.md").match("unfinished")] + assert pattern_rules.reported()["unfinished"] == page_rows == [ + ("INF-002", "CLAIM-001"), ("INF-002", "CLAIM-002")] + + +def test_an_acknowledged_match_is_still_on_the_page(tmp_path, monkeypatch): + """The page is a list; `pattern-ok:` silences the finding, not the row.""" + root = project(tmp_path, monkeypatch, BOTH) + argument(root) + path = root / "record/inferences.d/INF-002.md" + path.write_text(path.read_text() + "\n\n") + assert pattern_report.findings().acknowledged == 2 + assert page(root).count("| [INF-002](") == 2