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
98 changes: 75 additions & 23 deletions luria/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,10 @@
import os
import re
from contextlib import contextmanager
from dataclasses import dataclass, field as dcfield
from dataclasses import dataclass, field as dcfield, fields as dcfields
import yaml
from functools import lru_cache
from typing import cast
from typing import ClassVar, cast

from omegaconf import OmegaConf
from pathlib import Path
Expand Down Expand Up @@ -1296,6 +1296,50 @@ def primary_tags(prefix: str, values: dict) -> frozenset[str]:
return frozenset(found)


@dataclass(frozen=True)
class VocabularyTable:
"""The nested shape of one entry in the central `vocabularies:` table.

This exists so the set of keys a nested table may carry is a consequence
of the declaration rather than a second list beside it. `alert` arrived on
a vocabulary (#273) and the nested form arrived separately (#279); the
first record to use both was refused, because the discriminator spelled
the keys inline and nobody updated them when the dataclass grew a fourth
(#281). A list that has to be edited in step with a dataclass is one that
will not be — so there is no list, and `KEYS` below is read off the
fields.

It is a schema, not a carrier: `_vocabulary_tables` builds one and reads
its attributes, so a field added here reaches both the discriminator and
the metadata in the same edit."""
label: str = ""
blurb: str = ""
# What the vocabulary says when a rule about it FIRES (#273), as against
# `blurb`, which is what it is at rest.
alert: str = ""
# The values. Named `terms` rather than `values` (#282 review): a
# controlled vocabulary has terms, and the old spelling collided with the
# commonest thing a vocabulary's own values are called, which is what made
# the ambiguity below worth a refusal in the first place.
terms: dict[str, dict] = dcfield(default_factory=dict)

KEYS: ClassVar[frozenset[str]]
META: ClassVar[frozenset[str]]

@classmethod
def read(cls, table: dict) -> "VocabularyTable":
"""Build from a table already known to be nested."""
return cls(**{k: table[k] for k in cls.KEYS if k in table})

def meta(self) -> dict[str, str]:
"""What the set says about itself, which is everything but the terms."""
return {k: str(getattr(self, k)).strip() for k in sorted(self.META)}


VocabularyTable.KEYS = frozenset(f.name for f in dcfields(VocabularyTable))
VocabularyTable.META = VocabularyTable.KEYS - {"terms"}


def _vocabulary_tables(raw: dict) -> tuple[dict, dict]:
"""The central `vocabularies:` table, split into values and the set's own
description (#279).
Expand All @@ -1308,42 +1352,44 @@ def _vocabulary_tables(raw: dict) -> tuple[dict, dict]:
topics: # the set describes itself
label: Topics
blurb: the primary axis of both indexes
values:
terms:
alpha: {label: Alpha, blurb: "..."}

The nested form is recognised by a `values:` key holding a mapping, which
The nested form is recognised by a `terms:` key holding a mapping, which
is what makes this additive: every config written before this reads
exactly as it did. A vocabulary whose flat table has a VALUE named
`values` is the one ambiguous case, and it is refused rather than guessed
`terms` is the one ambiguous case, and it is refused rather than guessed
at — silently reading a project's values as metadata would empty the
vocabulary and report it as no violations."""
values_by_name, meta = {}, {}
for name, table in raw.items():
name, table = str(name), dict(table or {})
# Nested when the table says ONLY the three things a nested table
# says. Testing `isinstance(table["values"], dict)` alone is not
# enough: a value named `values` carries `{label, blurb}`, which is
# also a mapping, so a flat table holding one would read as nested and
# every other value would vanish.
# Nested when the table says ONLY the things a nested table says.
# Testing `isinstance(table["terms"], dict)` alone is not enough: a
# value named `terms` carries `{label, blurb}`, which is also a
# mapping, so a flat table holding one would read as nested and every
# other value would vanish.
#
# The single unreachable spelling is a flat vocabulary whose ONLY
# value is named `values`, which reads as an empty nested table.
# value is named `terms`, which reads as an empty nested table.
# Nothing distinguishes those two from shape, and a one-value
# vocabulary named after the key that holds values is not a case
# vocabulary named after the key that holds terms is not a case
# worth a third syntax.
nested = (isinstance(table.get("values"), dict)
and set(table) <= {"label", "blurb", "values"})
if not nested and "values" in table:
nested = (isinstance(table.get("terms"), dict)
and set(table) <= VocabularyTable.KEYS)
if not nested and "terms" in table:
raise ValueError(
f"luria.yaml: vocabularies.{name} has a value named `values`, "
f"luria.yaml: vocabularies.{name} has a value named `terms`, "
f"which is also the key that holds a nested vocabulary's "
f"values — write the whole table in the nested form "
f"(`label`, `blurb`, `values:`) so the two cannot be "
f"confused")
values_by_name[name] = dict(table["values"]) if nested else table
f"terms — write the whole table in the nested form "
f"({', '.join(chr(96) + k + chr(96) for k in sorted(VocabularyTable.KEYS))})"
f" so the two cannot be confused")
if nested:
meta[name] = {"label": str(table.get("label", "")).strip(),
"blurb": str(table.get("blurb", "")).strip()}
declared = VocabularyTable.read(table)
values_by_name[name] = dict(declared.terms)
meta[name] = declared.meta()
else:
values_by_name[name] = table
return values_by_name, meta


Expand Down Expand Up @@ -1681,7 +1727,13 @@ def _fields(prefix: str, raw: dict, scheme_dir: Path, root: Path,
many=many, required=required,
default=defaults,
closed=bool(spec.get("closed", True)),
alert=str(spec.get("alert", "")).strip(),
# From the SET too (#281). The rule an alert
# explains — whether the list is closed
# because it is finished — is a fact about
# the vocabulary, and a record whose three
# schemes name one vocabulary would otherwise
# write the same sentence three times.
alert=meta.get("alert", ""),
# From the SET, not this field: a vocabulary
# two schemes share is described once, which
# is the whole reason it is declared centrally
Expand Down
29 changes: 29 additions & 0 deletions record/changelog.d/20260916-174342.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
### Fixed

- **A vocabulary could not carry an `alert` and a `blurb` at once.** The
nested-table discriminator ([#279](https://github.com/dmarx/luria/issues/279)) tested against an inline set of keys that
did not include `alert` ([#273](https://github.com/dmarx/luria/issues/273)), so the first record to use both features was
refused — with a message about a value named `values`, describing a
different fault entirely. The allowed keys are now read off a
`VocabularyTable` dataclass that mirrors the table, so the discriminator,
the refusal message and the metadata cannot drift apart again.

### Changed

- A vocabulary's `alert` is declared on **the set**, in the central
`vocabularies:` table, beside `label` and `blurb` — not on the field that
names it. The rule an alert explains is a fact about the vocabulary, and a
record whose three schemes name one vocabulary was otherwise writing the
same sentence three times, which is the drift [ADR-098](record/decisions.d/ADR-098.md) centralised
vocabularies to prevent. A tag group's `alert` is unchanged: a group is
declared inline and has no central table to move to.

### Changed

- **A nested vocabulary's values are declared under `terms:`, not `values:`**
— a controlled vocabulary has terms, and the old spelling collided with the
commonest name for a vocabulary's own entries, which is what made a value
named `values` plausible enough to need a refusal. **Breaking for anything
written against 0.25.0**, which is the only release that carried the nested
form; no alias is accepted, and the refusal message names the current
spelling.
126 changes: 126 additions & 0 deletions record/decisions.d/ADR-tmp7gsqp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
---
status: Proposed
title: "A vocabulary's alert belongs to the set, like its blurb"
version: 1
tags:
- config
- contract
date: '2026-09-16'
issue: '#281'
summary: >-
`alert` on a vocabulary moves from the field that names it to the central
table, beside `label` and `blurb`. The rule it explains is a fact about the
set, and a record whose three schemes name one vocabulary was otherwise
writing the same sentence three times. The nested form's key list becomes a
named constant, because the version that was spelled inline did not know
about `alert` and refused the first record to use both features together.
---

# ADR-tmp7gsqp: A vocabulary's alert belongs to the set, like its blurb

## Context

Two features shipped in 0.25.0 and had never been used together.

`alert` ([#273](https://github.com/dmarx/luria/issues/273)) is the sentence a vocabulary prints when a closed-set
violation fires — *"closed so every tag is one somebody chose, not because
the list is finished"*. `label` and `blurb` ([#279](https://github.com/dmarx/luria/issues/279)) are what the vocabulary
is at rest, and [#279](https://github.com/dmarx/luria/issues/279) put them in the **central** `vocabularies:` table, on
the argument [ADR-098](ADR-098.md) had already settled: a set two schemes share is declared
once so the copies cannot drift.

`alert` was left on the **field**. That was not a decision; it is where the
`Vocabulary` object happens to be built, and [#273](https://github.com/dmarx/luria/issues/273)'s own docstring says the
opposite — *"attached to the vocabulary rather than the field so it rides the
one rule it describes"*.

The first record to adopt both found it. Writing the alert where the blurb
goes was refused with a message about a value named `values`, because the
nested form's discriminator was spelled inline:

set(table) <= {"label", "blurb", "values"}

`alert` is not in that set. The message named three keys while the code
allowed three and the dataclass carried four, and the failure it produced
described a completely different fault.

## Decision

**`alert` is read from the central table**, beside `label` and `blurb`. A
vocabulary three schemes name carries one alert.

**The key set is read off a dataclass that mirrors the table.**
`VocabularyTable` declares `label`, `blurb`, `alert` and the key holding the
values; `KEYS` is `{f.name for f in fields(VocabularyTable)}`, and both the
discriminator and the refusal message read it. `_vocabulary_tables` builds one
and reads its attributes, so the schema is load-bearing rather than
decorative and a fifth key reaches all three places in one edit.

**The values live under `terms:`, not `values:`.** A controlled vocabulary has
terms; `values` was the commonest word for the things a vocabulary's own
entries are called, which is exactly why a project naming one of them `values`
was plausible enough to need a refusal. Renaming does not remove the
ambiguity — a vocabulary could name a term `terms` — but it moves the
collision from a word every such table is about to one that is merely
possible. 0.25.0 is the only release carrying the old spelling, it is hours
old, and the sole record using it is on an unmerged branch, so this is a hard
rename with no alias.

A `TagGroup`'s `alert` stays where it is. A group is declared inline, under
the field whose values it constrains, and there is no central table for it to
move to — the two are not inconsistent, they are different shapes of thing.

## Alternatives considered

- **Add `alert` to the inline key set and leave it on the field.** One
character of the bug fixed and none of the cause. A vocabulary three
schemes name would still need the sentence three times, which is the exact
drift [ADR-098](ADR-098.md) centralised vocabularies to prevent and [#279](https://github.com/dmarx/luria/issues/279) cited when it
moved `blurb`.
- **Honour both spellings, field-level overriding the set.** Two homes for
one fact, which is what [ADR-089](ADR-089.md) argues against upstream and what this
project keeps deciding against. Nothing has released a record using the
field-level spelling — 0.25.0 is hours old — so there is nothing to keep
working.
- **A named constant, `VOCABULARY_KEYS = frozenset({...})`.** What this PR
shipped first, and what review rejected as inelegant — correctly. The
argument for it was that `Vocabulary` also carries `many`, `required`,
`closed`, `default` and `required_when`, all declared per field, so the keys
a *table* may carry are not the fields that object has. That is true and it
is an argument against deriving from `Vocabulary`, not against deriving at
all: the answer is a second dataclass shaped like the table. Writing the set
down by hand was the thing the ADR's own opening paragraph says not to do.
- **`OmegaConf.structured` for the table**, which omegaconf being already a
dependency makes plausible. It works — merging a flat table against a nested
schema raises `ConfigKeyError`, so try/except discriminates — and it is
still the wrong trade here. It makes exception handling the control flow for
an expected, common case; it replaces a message this project wrote and
tested with omegaconf's; and `config.py` hand-parses every other object it
builds, so one structured table is a local inconsistency rather than a
direction. Adopting structured configs across the whole loader is a real
question and is filed as its own, not settled by a four-key table.
- **Status quo.** The composition stays broken, and the message sends the
next person looking for a value named `values`.

## Consequences

The example in `test_a_closed_vocabulary_can_print_its_own_advice` declares
its alert on the set now, which is also the first exercise of the nested form
outside the tests written for it.

Fired on the real case, as the working agreement asks. `anthology-of-the-sota`
declares `topics` with all three keys; the config loads, one declaration
reaches SOTA, LIT and THEORY, and a bad tag prints:

record/practices.d/SOTA-001.md: `tags: kv-cache-paging` is not in the
`topics` vocabulary — the values are training-optimization, …
↳ Closed so that every tag is one somebody chose and blurbed, NOT
because the list is finished. If your document wants a word this
vocabulary cannot say, add it — …

That is [#273](https://github.com/dmarx/luria/issues/273)'s whole purpose, reaching a real reader for the first time.

Left open: nothing here checks that a vocabulary's `alert` is only meaningful
where some field declares `closed: true`. An alert on an open vocabulary is
inert rather than wrong, and a finding for it would want to know which of the
naming fields is closed — worth a look if anyone writes one.
19 changes: 13 additions & 6 deletions tests/test_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -386,12 +386,19 @@ def test_a_closed_vocabulary_can_print_its_own_advice(example):
reads as "pick one of these", which is how a vocabulary stops growing."""
root = example("world-bible")
cfg = root / "luria.yaml"
cfg.write_text(cfg.read_text().replace(
" worlds:\n vocabulary: worlds\n",
" worlds:\n vocabulary: worlds\n"
" alert: >-\n"
" Closed so every trajectory is one somebody plotted. A new\n"
" one is an edit to this table, not a workaround.\n"))
# On the SET, in the central table (#281) — the rule an alert explains is
# a fact about the vocabulary, not about one field that names it.
lines = cfg.read_text().split("\n")
top = lines.index(" worlds:")
stop = next(i for i in range(top + 1, len(lines))
if lines[i][:3].strip() and not lines[i].startswith(" "))
lines[top:stop] = [" worlds:",
" alert: >-",
" Closed so every trajectory is one somebody plotted. A new",
" one is an edit to this table, not a workaround.",
" terms:"] + [" " + l if l.strip() else l
for l in lines[top + 1:stop]]
cfg.write_text("\n".join(lines))
config.reset()
doc = root / "record" / "scenes.d" / "SCENE-002.md"
doc.write_text(doc.read_text().replace("worlds:\n- A\n", "worlds:\n- Z\n"))
Expand Down
Loading
Loading