Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/project-memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
16 changes: 9 additions & 7 deletions examples/argument-map/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions examples/argument-map/luria.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion examples/argument-map/record/claims.d/CLAIM-005.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 2 additions & 1 deletion examples/argument-map/record/inferences.d/INF-004.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
94 changes: 82 additions & 12 deletions luria/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)."""
Expand All @@ -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


Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"]),
Expand All @@ -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]:
Expand All @@ -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(
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion luria/pattern_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<k>_` by position, and
its name only ever appears as a quoted string, so no name a record chooses
Expand Down
7 changes: 7 additions & 0 deletions luria/patterns.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 9 additions & 5 deletions luria/reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
3 changes: 2 additions & 1 deletion luria/view_reads.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions luria/view_templates/pattern.md.j2
Original file line number Diff line number Diff line change
@@ -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 %}
Loading
Loading