Skip to content

A vocabulary's alert belongs to the set, like its blurb (#281) - #282

Merged
dmarx merged 2 commits into
mainfrom
claude/alert-on-the-set
Sep 16, 2026
Merged

dmarx merged 2 commits into
mainfrom
claude/alert-on-the-set

Conversation

@dmarx

@dmarx dmarx commented Sep 16, 2026

Copy link
Copy Markdown
Owner

Closes #281.

0.25.0 shipped alert (#273) and the nested vocabulary form (#279). They had never been used together, and they don't compose — the first record to adopt both is refused with a message about a value named values, describing a completely different fault.

The smaller half

nested = (isinstance(table.get("values"), dict)
          and set(table) <= {"label", "blurb", "values"})

alert isn't in that set. The message named three keys, the code allowed three, the dataclass carried four. VOCABULARY_KEYS is a named constant now and both the discriminator and the message read it — a list that has to be edited in step with a dataclass is one that won't be.

The larger half

alert was read from the field, not the set. That wasn't a decision; it's where the Vocabulary object happens to be built, and #273's own docstring says the opposite — "attached to the vocabulary rather than the field so it rides the one rule it describes."

It now comes from the central table beside label and blurb. The rule an alert explains — whether the list is closed because it's finished — is a fact about the set, and a record whose three schemes name one vocabulary was otherwise writing the same sentence three times. That's the drift ADR-098 centralised vocabularies to prevent and that #279 cited when it moved blurb.

A TagGroup's alert is unchanged: a group is declared inline under the field whose values it constrains, and has no central table to move to. Not an inconsistency — a different shape of thing.

Fired on the real case

anthology-of-the-sota is what found this. With topics declaring 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 — with a label, a blurb and a decision saying why,
      exactly as `representation-and-encoding` and `analysis-and-evaluation`
      were added. …

That's #273's whole purpose reaching a real reader for the first time.

Checks

python -m pytest tests -q1318 passed, 33 skipped. Three new tests: the two features composing, one alert reaching every scheme that names the vocabulary, and the refusal message listing every key the constant holds — that last one caught my own first attempt at the message fix, which silently didn't apply.

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

luria lint clean. Views regenerated locally and reverted.

Left open

Nothing checks that a vocabulary's alert is meaningful only where some field declares closed: true. An alert on an open vocabulary is inert rather than wrong, and a finding for it would need to know which of the naming fields is closed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FeX27AYgHU7geZaYkwshBS


Generated by Claude Code

0.25.0 shipped `alert` (#273) and the nested vocabulary form (#279) and the
two had never been used together. They did not compose: the discriminator
was spelled inline as {"label", "blurb", "values"}, so a vocabulary carrying
an alert was refused — with a message about a value named `values`,
describing a different fault entirely.

The inline set is the smaller half. `alert` was on the FIELD, which was not
a decision but where the Vocabulary object happens to be built; #273's own
docstring says the opposite. It now comes from the central table beside
`label` and `blurb`, so a vocabulary three schemes name carries one alert
rather than three copies free to drift — the argument ADR-098 already
settled and #279 already cited.

VOCABULARY_KEYS is a named constant now, read by both the discriminator and
the refusal message, which listed three keys while the dataclass carried
four. A tag group's alert stays put: a group is declared inline and has no
central table to move to.

Fired on anthology-of-the-sota, which is what found it: `topics` declares
label, blurb and alert together, one declaration reaches SOTA, LIT and
THEORY, and a bad tag now prints the sentence #273 exists to print.

1318 tests pass, luria lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FeX27AYgHU7geZaYkwshBS
@dmarx

dmarx commented Sep 16, 2026

Copy link
Copy Markdown
Owner Author

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

This works, but it's pretty inelegant. Can't we use omegaconf's structured config to give us typing here?

Also, I propose that instead of "values", we use "entries" or "members" or "terms" or something like that.

… review)

Two things review asked for, and both are right.

**The key set comes from a dataclass now.** The first pass named a
`VOCABULARY_KEYS` frozenset, which fixed the bug and left the cause: a
hand-written list of the keys a table may carry, which is the same shape
of thing as the inline set it replaced. `VocabularyTable` declares
`label`, `blurb`, `alert` and `terms`; `KEYS` is read off its fields, and
`_vocabulary_tables` builds one and reads its attributes, so a fifth key
reaches the discriminator, the refusal message and the metadata in one
edit. The ADR's own rejected alternative argued against deriving from
`Vocabulary` — true, since that object also carries `many`, `required`,
`closed` and `default`, which are per-field — but that is an argument for
a second dataclass shaped like the table, not against deriving at all.

I also tried the omegaconf route the review suggested, since omegaconf is
already a dependency. It does work: merging a flat table against a nested
structured schema raises ConfigKeyError, so try/except discriminates. It
is still the wrong trade here — exception handling as control flow for an
expected case, this project's tested refusal message replaced by
omegaconf's, and one structured table in a module that hand-parses every
other object it builds. Adopting structured configs across the loader is
a real question and gets its own issue rather than riding this one.

**The values live under `terms:`.** A controlled vocabulary has terms,
and `values` was the commonest word for the entries themselves, which is
precisely why a project naming one of them `values` was plausible enough
to need a refusal. Breaking against 0.25.0, which is the only release
carrying the nested form, hours old, with one unmerged branch using it —
so a hard rename and no alias.

1318 tests pass. The refusal test now reads its expected keys off
`VocabularyTable` rather than spelling them, so a fifth key fails it until
the message names it.

Fired on the real case: anthology-of-the-sota's held branch, respelled to
`terms:`, loads and lints clean, and a bad tag still prints the set's
alert under the violation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FeX27AYgHU7geZaYkwshBS

dmarx commented Sep 16, 2026

Copy link
Copy Markdown
Owner Author

Both taken. Pushed in f0b423b.

The rename

terms:. Of your three I'd argue for it on the grounds that a controlled vocabulary has terms, and that values was the commonest word for the entries themselves — which is exactly why a project naming one of them values was plausible enough to need a refusal in the first place. It doesn't remove the ambiguity (someone can still name a term terms), it moves the collision from a word every such table is about to one that's merely possible.

Hard rename, no alias: 0.25.0 is the only release carrying the nested form, it's hours old, and the only record using it is on an unmerged branch.

The typing

You're right that the frozenset was inelegant, and the inelegance was worse than it looked — it was a hand-written list of the keys a table may carry, which is the same shape of thing as the inline set it replaced. The ADR even argued against deriving from a dataclass, on the grounds that Vocabulary also carries many, required, closed and default, which are per-field. That's true, and it's an argument against deriving from that dataclass, not against deriving at all. The answer is a second one shaped like the table:

@dataclass(frozen=True)
class VocabularyTable:
    label: str = ""
    blurb: str = ""
    alert: str = ""
    terms: dict[str, dict] = dcfield(default_factory=dict)

VocabularyTable.KEYS = frozenset(f.name for f in dcfields(VocabularyTable))

_vocabulary_tables builds one and reads its attributes, so it's load-bearing rather than decorative — a fifth key reaches the discriminator, the refusal message and the metadata in one edit. The refusal test now reads its expected keys off KEYS too, so a fifth key fails that test until the message names it; spelled as a tuple it would have passed the whole time, which is how #281 got in.

On omegaconf specifically

I tried it rather than guessing, and my instinct was wrong — it does discriminate:

flat     -> rejected: ConfigKeyError: Key 'training-optimization' not in 'Nested'
nested   -> validates
tricky   -> rejected: ValidationError: str is not a subclass of ValueMeta

So try: merge(schema, table) except ConfigKeyError: flat works, including the ambiguous case. I still didn't take it here, for three reasons that are local to this PR:

  1. Exception handling as control flow for an expected, common case — a flat table is not an error.
  2. It replaces the refusal message this PR wrote a test for with omegaconf's, and "a config error that surfaces as no violations" is the failure _tag_groups exists to prevent.
  3. It'd be one structured table in a module that hand-parses Scheme, Field, Reference, Chain, TagGroup and the rest out of dicts — a local inconsistency rather than a direction.

The third is the real one, and it makes this too big for a four-key table to settle, so I filed it as #283 with what I'd want to look at first: port Reference or TagGroup and compare the line count and every error message the suite currently asserts on. If the messages survive and the code shrinks, do it everywhere.

Checks

1318 pass. Fired on the real case as well as the suite: anthology-of-the-sota's held branch respelled to terms: loads and lints clean, and a bad tag still prints the set's alert under the violation.


Generated by Claude Code

@dmarx
dmarx merged commit f2ad782 into main Sep 16, 2026
5 checks passed
dmarx added a commit that referenced this pull request Sep 16, 2026
All five `Proposed` ADRs went into force when they merged; the status was
lagging the code. `Active` here means "in force — the current answer, and
what a citation should normally point at", and by that definition
ADR-106, ADR-107, ADR-108 and ADR-109 have been Active since their
releases, ADR-110 since #282 merged an hour ago.

The report said they were cited nowhere and concluded that nothing
depends on the answer. Plenty does — the citations were pointing at the
wrong kind of object. Between them these five decisions account for
`Reference.invariant`, the cross-scheme chain refusal, whole-field
derivation cardinality, `alert` on a vocabulary and a tag group, the
label/blurb/title pairs on nine config objects, and `VocabularyTable`.
The code implementing all of it cites `#272`, `#276`, `#273`, `#279` and
`#281`, forty-two times, and never once the ADR.

An issue is where the argument happened: unversioned, statusless, and
invisible to the reference check, which sees `#272` as text rather than a
code. So any of these could be superseded tomorrow and nothing would flag
the thirty-odd sites implementing it — the exact failure the check exists
to catch, by a route it cannot see.

`adr_index.py` already had the right shape in one place, `(ADR-103,
#249)`. That shape is now at fourteen definition sites, one per
(decision, file) pair — both codes, because the ADR is what is in force
and the issue is where to read the argument.

ADR-110 also goes to v2: its summary still described a VOCABULARY_KEYS
constant and a `values:` key, both of which review replaced. A summary
describing the first draft is worse than none, since the summary is what
the index shows.

Pending decisions: 5 -> 0. 1318 tests pass. Lint unchanged from main —
the two unresolved placeholder codes are pre-existing and identical
there. No behaviour change, so the changelog fragment is a stub saying so.


Claude-Session: https://claude.ai/code/session_01FeX27AYgHU7geZaYkwshBS

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A vocabulary cannot carry an alert and a blurb at once — the two 0.25.0 features do not compose

2 participants