Metadata-Version: 2.5
Name: denckring
Version: 0.6.0
Summary: A library of experimental writing procedures, where the validator is the eval
Project-URL: Homepage, https://github.com/senzelden/denckring
Project-URL: Documentation, https://senzelden.github.io/denckring/
Project-URL: Repository, https://github.com/senzelden/denckring
Project-URL: Issues, https://github.com/senzelden/denckring/issues
Project-URL: Changelog, https://github.com/senzelden/denckring/blob/main/CHANGELOG.md
Author-email: Dennis Senzel <dennis.senzel@posteo.com>
License-Expression: Apache-2.0 AND CC-BY-4.0
License-File: LICENSE
License-File: LICENSE-DATA
License-File: NOTICE
Keywords: constrained-writing,lipogram,nlp,oulipo,poetics
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Science/Research
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: Free Threading :: 3 - Stable
Classifier: Topic :: Text Processing :: Linguistic
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: pydantic>=2.7
Requires-Dist: pyyaml>=6.0
Requires-Dist: typer>=0.12
Provides-Extra: de
Requires-Dist: denckring-de-data==0.6.0; extra == 'de'
Provides-Extra: de-frequency
Requires-Dist: denckring-de-data==0.6.0; extra == 'de-frequency'
Requires-Dist: denckring-de-frequency==0.6.0; extra == 'de-frequency'
Provides-Extra: de-wiktionary
Requires-Dist: denckring-de-data==0.6.0; extra == 'de-wiktionary'
Requires-Dist: denckring-de-wiktionary==0.6.0; extra == 'de-wiktionary'
Provides-Extra: en
Requires-Dist: denckring-en-data==0.6.0; extra == 'en'
Provides-Extra: fr
Requires-Dist: denckring-fr-data==0.6.0; extra == 'fr'
Provides-Extra: mcp
Requires-Dist: mcp[cli]>=2; extra == 'mcp'
Provides-Extra: pos
Requires-Dist: denckring-en-data==0.6.0; extra == 'pos'
Requires-Dist: denckring-en-pos==0.6.0; extra == 'pos'
Description-Content-Type: text/markdown

# denckring

[![CI](https://github.com/senzelden/denckring/actions/workflows/ci.yml/badge.svg)](https://github.com/senzelden/denckring/actions/workflows/ci.yml)
[![Coverage ≥ 97%](https://img.shields.io/badge/coverage-%E2%89%A597%25-brightgreen)](https://github.com/senzelden/denckring/actions/workflows/ci.yml)
[![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org/downloads/)
[![Licence: Apache-2.0 AND CC-BY-4.0](https://img.shields.io/badge/licence-Apache--2.0%20AND%20CC--BY--4.0-green)](#licence)

**A library of experimental writing procedures — lipograms, snowballs, acrostics and a
hundred and fifty more — where the validator is the eval.**

Every procedure pairs a generator with a validator, and the acceptance criterion is
intrinsic: a lipogram either contains the forbidden letter or it does not. Nothing has to
be invented to test it, and nothing has to be believed. That is the whole design.

https://github.com/user-attachments/assets/97dd9198-790c-4f82-a9ca-f5f35b155fc2

*A model is asked to write without `e`, says it has, and `check("lipogram", ...)` says
otherwise. The same check then passes the corrected draft: the validator is the eval.*

## Quickstart

```console
pip install denckring
```

```python
from denckring import check, produce

report = check("lipogram", "A small conforming bit of writing", forbidden="z")
report.satisfied, report.score  # True, 1.0

produce("cut_up", "one two three four five six", seed=7).texts  # the generating half
```

```console
$ denckring check lipogram gedicht.txt --lang de   # exits 1 when unsatisfied
$ denckring apply cut_up text.txt --json           # emits a Production
$ denckring status
158 catalogued · 133 implementable · 133 implemented · 133 validated · 25 not mechanically checkable
0 instruments catalogued
68 rows externally evidenced · 65 explained · 0 unexplained
```

Three interfaces over the same core functions, so they cannot disagree about what a
procedure is: the Python API, the CLI, and `--json` on `check`, `apply`, `show` and
`describe` for callers that are neither.

## Letting a model write under a constraint

A model writing to a form can check its own draft instead of guessing, which is the
difference between a haiku it believes is a haiku and one that is.

```console
pip install denckring[mcp]    # an MCP server: denckring-mcp
```

The server exposes four tools — `list_procedures`, `describe_procedure`, `check_text`,
`apply_procedure` — with the procedure as a parameter rather than eighty-six separate
tools, because model performance degrades with tool count. Errors come back as data
(`{"code": "invalid_params", ...}`), never as a traceback a model cannot act on.

`check_text` and `apply_procedure` refuse a text over `DENCKRING_MCP_MAX_CHARS` characters
(default 50000, counting `text` plus any string parameters such as `source`) with a
`text_too_long` error, rather than running against an arbitrarily large remote input. Set
`DENCKRING_MCP_MAX_CHARS` to raise or lower that cap for a known workload.

For Claude Code, the skill at
[`skills/denckring/SKILL.md`](https://github.com/senzelden/denckring/blob/main/skills/denckring/SKILL.md)
shells out to the CLI instead, and needs no extra beyond the package itself.

## What's here

A hundred and thirty-three of the 158 catalogued procedures are implemented, and every
one of them is validated. Of the rest, 25 have no mechanical acceptance criterion and are
catalogued rather than implemented — see [What can be checked](#what-can-be-checked) —
and all 133 implementable procedures are implemented.

Validated means a row agrees with its golden examples, and most of those were written
for the suite, so they show the code does what its author meant, not that the author
read the form right. Only an example quoted from a published text is evidence of that.
Golden cases added from 2026-10-12 quote public-domain text only and record the URL
they were extracted from (ADR 0062); some earlier external cases predate that policy.
Every implemented row either has an external case or records why not: the form
postdates the public-domain cutoff, its exemplars are in copyright or in a language no
pack covers, or none has been found yet, with what was searched or that no search has
been made. `describe(pid).corpus` gives a row's split, as a `CorpusShare` counting its
examples by provenance and recorded verdict beside that reason; `denckring catalogue
export` carries it per row as `corpus`, and `denckring status` totals it on its third
line.

The [**gallery**](https://senzelden.github.io/denckring/gallery/) has a page per
catalogued procedure, generated from the catalogue and the golden fixtures, so every
worked example on it is one the test suite enforces and none of it can drift. Locally,
`denckring list` shows what your install can run and `denckring list --status catalogued`
shows everything.

## Install options

The extras improve or unlock procedures rather than changing the language itself. Each
vendored source keeps its own licence file beside the data it covers, which is why a
distribution exists per licence rather than per language (ADR 0013).

| install | adds | what it unlocks |
| --- | --- | --- |
| `denckring` | no data files | English, German and French; the lexicon-free procedures in all three |
| `denckring[en]` | pronouncing dictionary, noun lexicon, SCOWL graded word list, relations and proverbs | exact syllables instead of estimates; `apply anagram` |
| `denckring[de]` | word lexicon and noun list, from Wikidata Lexemes (CC0) | `charade`, `semordnilap`, `word_square`, `n_plus_7`, `s_plus_7` in German |
| `denckring[de-wiktionary]` | pronunciations, stress, glosses (CC BY-SA) | German phonetic and gloss-based rows; no English-only relations or POS |
| `denckring[de-frequency]` | frequency bands, from the Leipzig Corpora Collection (CC BY) | German `apply anagram` |
| `denckring[fr]` | word list, nouns, frequency bands, glosses, syllables, phonemes (Lexique 3.82, fr.Wiktionary) | French from 72 rows to **108** |
| `denckring[mcp]` | the MCP server | `denckring-mcp` |

Without `[en]`, syllables are estimated from spelling and every report says how many
words were guessed. `apply anagram` needs a word list graded by commonness, not one that
merely answers whether a string is a word (ADR 0028) — which is why it is the one thing
an unlocking extra is required for rather than merely improving. ADR 0030 records the
measurement behind `[de-wiktionary]`: 94.0% token coverage over 341,000 tokens of
Wieland, Goethe, Kafka and Mann.

## Languages

German and French ship in core because the procedures that need no lexicon work for them
unchanged; both are built-in defaults exactly like English (ADR 0022), and their data
distributions override those defaults rather than registering through a separate path.
On core alone, 72 of the 133 implemented rows run in French.

Language packs declare capabilities; procedures declare what they require. Asking for a
language whose pack is not installed, or a procedure whose requirements that pack does
not meet, raises `MissingCapability` naming what is missing rather than quietly returning
an approximate answer.

`describe` answers both halves of "can I run this in French": `languages` is the row's
authored editorial scope — `wechselsatz` is German by nature, not merely by capability —
and `runs_in` is computed from the installed packs. `clerihew` is `languages: [en]`, the
form being English by nature, and `runs_in` all three.

The two move independently, and the editorial half is the one that lags: 174 (row,
language) pairs ran unclaimed when it was last measured. ADR 0047 closed the thirty
French rows whose own `source:` field is the evidence — `lipogram` is Perec's *La
Disparition* — and records why the rest stay open rather than being swept. ADR 0063 adds
one narrower ground for one class of row: the letter rules, whose checkers read letters
and nothing else, all declare German, which there means the rule is checked in German,
not that German literature attests the form.

**18 French rows will never close.** They are accentual metres, French has no lexical
stress, and they are not French forms; declaring the capability to reach a bigger number
would be a promise the data cannot keep. ADR 0032 records why Wikidata Lexemes could not
supply French as it does German — 12,768 French noun lexemes against 188,948 German —
and ADR 0034 the line-level syllable seam French needs and the others do not: a final
mute *e* elides or counts depending on what follows, so French cannot be counted word by
word, and its line count is always `estimated`, never exact.

Whether `ä` counts as `a` is an editorial decision rather than a library constant, so it
is a parameter: `fold_diacritics` defaults to true and can be turned off per call. Glyph
questions never fold — *Masse* satisfies the prisoner's constraint and *Maße* does not,
because a written `ß` carries an ascender.

## Reports and Productions

`check` returns a `Report`: `satisfied`, a continuous `score` in `[0, 1]`, a list of
`violations` with character offsets, free-form `metrics`, and `evidence` — the
measurements the verdict rests on, each saying whether it came from a dictionary, from
a dictionary listing several readings the checker accepted any of (`ambiguous`), or from a
spelling heuristic. A haiku that misses names the words it counted and what it made them;
a line that does not scan names the stress it read each word as. It is empty on the rows
that need no such account: a lipogram's violation already carries the offending character.
`describe()` says in advance which kind a row is — `reading.determinacy` is `exact` for 85
of the 133 and `heuristic` for 48. The score is monotone in
violation count and `satisfied` is exactly `score == 1.0`, so a caller driving a retry
loop can tell whether a text missed by one word or by fifty.

`produce` returns a `Production` — the procedure's id, its `texts` (best first, never
empty), `truncated`, and `metrics` — and `apply` is defined as `produce(...).texts[0]`,
the one-text surface for a caller who wants the best answer and not the search behind it.

Every `check` is deterministic: the same text, language and parameters give the same
`Report`, on every row. A row's catalogue flag `deterministic` is about the form, not the
checker: it is false where the form leaves its output to chance or to the writer, as a
cut-up or a homophonic translation does. A generator that draws takes a `seed`, which
repeats the draw and which `provenance` echoes.

Both carry `provenance`: the package version, a schema version that moves independently
of it, which pack answered and which data distributions it was reading, the
`fold_diacritics` policy in force, and the `seed` a drawing generator used. German names
three distributions there, because `GermanWiktionaryFrequencyPack` layers three. The seed
is the field that makes a draw repeatable — it is an input parameter, so until it was
echoed here `apply --seed 7 --json` printed an object that did not contain 7.

`texts` is derived from `candidates`, which is where a generator that ranks says why:
`produce("anagram", "dormitory")` returns `dirty room` first, carrying the SCOWL size
band of its least common word, ahead of covers built from rarer ones (ADR 0027). A caller
reading `texts` gets the order without the reasons, which is a real cost of keeping that
field; the reasons are there for anyone who asks for `candidates` by name.

## What can be checked

Not every procedure in the catalogue can be graded by a program, and the catalogue says
which. `checkability: self` means decidable from the text alone; `source` means it needs
the text it was made from, supplied as `--source`; `none` means no computable acceptance
criterion exists — `canada_dry` is *defined* as having no operative constraint, and a
calligram's shape is not something code judges. Those rows are catalogued because the
forms belong in an honest survey, and `denckring status` counts them separately rather
than reporting a gap that can never close.

## What is stable

`0.x` means the API can change in a minor release, and the changelog says when it does.
Eight surfaces are treated as contracts regardless, because things outside this repository
are built on them:

- **Procedure ids.** An id that has shipped does not change meaning. When a row is
  replaced, the old id stays findable through `denckring search` as an alias — though
  `get` resolves ids only, and raises `UnknownProcedure` naming the replacement.
  `multiple_constraint`, which replaced `univocalic_lipogram_pair`, is the precedent.
- **The names `denckring` exports**, imported from `denckring` itself. `check`, `apply`,
  `produce`, `describe`, `get`, `list_procedures`, `summaries`, `all_procedures` and
  `rules` keep their signatures and meaning, as do `checker_history` and `CheckerChange`
  (ADR 0061); a new parameter arrives as a keyword whose default keeps today's answer.
  `Report`, `Violation`, `Evidence`, `Production`, `Description`, `Summary`,
  `Scholarly`, `Meta` and `Lang` keep the type and meaning of every field they have,
  except the fields listed below as not yet promised. `DenckringError` and every
  subclass keep their names, their place under `DenckringError` and their `code`. The
  same objects reached through `denckring.core` are not covered: the path is part of the
  promise. The rest of `__all__` is published but not yet promised, as listed below.
  Also promised, because the sibling `denckring-bench` reads them (ADR 0060):
  `golden_cases` and `GoldenCase`, the shipped examples with the verdict each must give;
  `unmet_requirements`, which says, given a case and a pack, why the case cannot run
  (the capabilities it needs that the pack lacks, or `[]`); `pack_provenance` and
  `PackProvenance`, the record `Report.provenance.pack` carries, whose `digests` (from
  `pack_provenance`, empty on a `Report`) hold a lower-case hex SHA-256 per surface,
  changing exactly when that surface's content does, so a snapshot keys on them rather
  than on distribution versions, which move with every release. `graded_words` is
  `sha256(json.dumps(sorted(pack.graded_words().items()), ensure_ascii=False,
  separators=(",", ":")).encode("utf-8"))`; `nouns` is the same over
  `sorted(pack.nouns())`; `files` hashes `name\0path\0bytes\0` for each file under each
  data distribution's import package directory (sorted, paths relative to it, bytecode
  excluded, nothing from `*.dist-info`), so the release version does not move it and a
  wheel and an editable install of the same data agree. A surface the pack lacks omits
  its key; `Description.reading`, including `reading.units`, `reading.word_examples`,
  `reading.vowels`, `reading.rhyme` and `reading.counting` (a `Counting`, or `None` on a
  row that totals no letter values; ADR 0061 D4); `Description.corpus`, a `CorpusShare`
  (ADR 0062) whose counts move as golden cases land and whose `gap` is `None` or one of
  `in_copyright`, `after_cutoff`, `unsupported_language` and `none_found`, each keeping
  its meaning; and `PARAMETER_ROLES`, which names each `x-denckring-role` value a
  parameter schema carries and what it means. Roles may be added; none is removed or
  renamed without a **Breaking:** entry in the changelog.
- **`Report` as JSON** — `procedure`, `satisfied`, `score`, `violations`, `metrics`,
  `evidence`, `provenance`, `estimated` — and the `--json` output of `check`, `show` and
  `describe` that carries it. Fields may be added; the ones already there do not change
  type or meaning. `estimated` says whether the verdict rests on anything estimated or left
  unjudged, and is what to read in place of `metrics["estimated_words"]`. Each `evidence`
  entry is one measurement the verdict rests on: `subject` (the word or line), `scope`
  (`word` or `line`), `offset`, `value` and `basis`, which is one of `dictionary` (looked
  up, read one way), `ambiguous` (looked up, more than one listed reading, and the checker
  accepted any; not an estimate) and `estimated` (no listing, so a heuristic answered). A
  caller deciding to leave a verdict unscored reads `estimated`, or `basis` per entry,
  and needs no metrics key. A new basis would arrive with a new `schema_version`.
  `describe`'s `Description` carries `runs_in` under the same promise, and `rules`, the
  row's `denckring.rules` in the same sorted order, which `describe --json`, `show --json`
  and the MCP server's `describe_procedure` carry too. `provenance`
  carries its own `schema_version`, which moves when the *shape* of these objects does
  and not when the package is released: `1.3` since `provenance.checkers` (1.2 added the
  `ambiguous` value of `Evidence.basis`; 1.1 added `estimated`).
- **`Production` as JSON** — `procedure`, `candidates`, `texts`, `truncated`, `metrics`,
  `provenance` — and the `--json` output of `apply`. Covered by the same promise in the
  same words. Both `candidates` and `texts` are ordered, best first, because `apply`
  returns `texts[0]`.
- **The catalogue export schema** (`denckring catalogue export`), including the `licence`
  and `attribution` keys the CC BY terms are carried by, and each row's `corpus`: the
  row's `CorpusShare`, or `null` on a row with no checker (ADR 0062).
- **The language-pack surface**: `denckring.get_pack` (also `denckring.lang.get_pack`)
  and the `LanguagePack` protocol it returns, with the `PosTag` its `pos_tags` answers,
  all importable from `denckring`. A method on the protocol keeps its signature and
  meaning. A new pack method arrives as an optional member, read with `getattr` and a
  fallback, because the protocol is `runtime_checkable` and a required method added
  later would make every pack written before it fail `isinstance`.
- **The `denckring.lang` entry-point group** and the capability names a pack
  declares, so an installed third-party pack keeps working. This covers
  replacing or extending the data behind English, German or French (ADR
  0044) — `Lang` itself is a closed three-member type, so a pack cannot
  register a new language through this mechanism.
- **Violation `rule` strings** (ADR 0059). A rule published through
  `denckring.rules(procedure_id)` keeps its name and meaning, so a caller can map
  rules to its own classes once. A row may add rules. Removing or renaming one is a
  breaking change, and the changelog names it.
- **Checker versions** (ADR 0061). Each row has a `checker_version`, an integer in
  `describe().checker_version`, bumped whenever a change can move `satisfied`, `score`
  or the set of violation rules on some text; evidence and message wording do not bump
  it. `report.provenance.checkers` maps the row, and every row it delegates to (each
  entry of a `multiple_constraint` call, and whatever those entries delegate to in
  turn), to the version that decided the verdict. `denckring.checker_history`
  (returning `CheckerChange` entries `{version, since, reason}`) says what each version
  changed and in which release. A caller compares results across releases per row by
  comparing these. Versions only rise, and every row's history begins at 1 in 0.5.0.
- **Promised metrics** (ADR 0060). A row may name `metrics` keys in
  `describe().metrics`, key to meaning; each is a float in every report of that row,
  and removing or renaming one is a breaking change.
- **Parameter defaults.** A parameter's default, as the schema `default` in
  `describe().params`, is part of the promise. Changing one moves verdicts for callers
  who left it unset, so it is **Breaking:**.

Not stable, and expected to move: `metrics` keys a row does not promise, message
wording, the wording of prompt hints, and everything under `denckring.core`. A check's
*verdict* and the rule it names for a failure are contracts; the wording that explains
the failure is not.

**Published, not yet promised.** These are exported and documented, so nothing has to
reach into `denckring.core` for them, but they may still change in a minor release, with
a changelog line when they do. All are new in 0.3.2:

- `rule_categories` and `failure_categories`: the kind of failure each rule names;
- the catalogue flags `Meta.unique_answer` and `Meta.hidden_material`, which
  `Description` carries too, and `Meta.hint_omits`;
- `prompt_hint` and `render_hint`, which render the catalogue's hint templates;
- `scope`, `scopes`, `admits` and `witness`;
- `words` and `nouns`.

## The catalogue as data

The catalogue is 158 sourced procedures and is a contribution in its own right — useful
to someone who will never install the package.

```console
denckring search rhopalic            # finds snowball by its alias
denckring list --family form         # the prosodic and stanzaic entries
denckring catalogue export --format json --output catalogue.json
```

Every entry records **how** its source was established, which is the field that makes the
dataset trustworthy: `primary` names an author, work and year the catalogue stands behind;
`reference` means the form is attested in a standard work — the *Oulipo Compendium*,
Borgmann's *Language on Vacation*, *Word Ways* — rather than traced to an origin;
`traditional` means no single origin exists to name. A `reference` row is not a weaker
`primary`; it is an honest statement that the origin is not established.

Entries are filed under one of eight families — `letter`, `word`, `syntax`, `form`,
`permutation`, `procedural`, `translation`, `visual` — and carry the other names each form
travels under, so a search for `isogram` finds `heterogram`.

Each entry also declares a `layer`. A `verfahren` is accepted the way everything in this
catalogue has always been accepted: run it, get text, have a checker score it. An
`instrument` is a combinatorial device — temurah, the zāʾirja — admitted for a mechanism
that is faithfully formalised and sourced, whether or not anyone would read its output.
Every row is a `verfahren` today, and `denckring status` counts the two on separate lines
so that the headline number keeps meaning what it has always meant (ADR 0033).

## Devices, corpora and your own data

Three kinds of thing a procedure can need, handled differently. A **device** —
Harsdörffer's five rings — is the procedure, so it ships with it. A **language pack**
describes a language and ships separately when it carries weight, as above. A **corpus**
is somebody's collection, so the package carries the loader and you supply the reading:

```console
denckring check ideenwuerfeln throw.txt --source my-excerpts.json
```

A device's word-lists do not have to be the ones that shipped. Set `DENCKRING_DEVICE_PATH`
to a colon-separated list of directories and `denckring.core.device.load` searches them, in
order, before the packaged directory — so an earlier entry can add a device under a new id
or shadow a packaged one:

```console
DENCKRING_DEVICE_PATH=/path/to/my/devices denckring check denckring wort.txt --param device=my_rings
```

Each directory holds one YAML file per device, named `<id>.yaml`, in the shape the packaged
devices use (see `src/denckring/data/devices/`). A directory that does not exist, or cannot
be read, is skipped rather than raised on.

What this does not give you: no schema versioning beyond ordinary YAML/Pydantic validation,
no record of where a loaded device came from, and if a device on the path shadows a packaged
id, the packaged device is simply not what ran — `load` has no way to say so. The id itself
is untrusted input and is validated down to a bare name — no path separators, no `..`, not
absolute — so a device must live *in* a directory on the path rather than be addressed by a
path of its own. A relative entry resolves against the process's working directory at call
time, not against wherever the variable was set.

## The explorer

A local browser for the catalogue and a bench for trying procedures on your own text, kept
outside the distribution in [`apps/explorer`](apps/explorer):

```console
uv run --project apps/explorer explorer
```

It lays the catalogue out as a compositor's type case — one compartment per procedure,
filled where a checker exists — builds each parameter form from that procedure's own
`params_schema()`, and marks the offending characters inline using the offsets every
`Violation` carries.

## Contributing

One module per procedure, `check` mandatory. Start with
`uv run python scripts/new_procedure.py <id>`, which scaffolds the module, test, strategy,
golden fixture and catalogue row. See [CONTRIBUTING.md](CONTRIBUTING.md).

Build the documentation site locally with:

```console
uv run python scripts/build_docs.py && uv run mkdocs serve
```

## Scope of the name

The *Fünffacher Denckring der Teutschen Sprache* (Harsdörffer, 1651) is one device among
the hundred and fifty catalogued here, not the whole subject.

## Using the name

The name is reserved — Apache-2.0 grants no trade mark rights (section 6), which is what
keeps a fork from publishing as this project. What that reservation permits, stated so it
can be complied with rather than guessed at: redistribute the package unmodified under the
name freely, say that your work uses or is compatible with denckring freely, and rename
before publishing a modified version. "denckring" in the name of a package you publish, or
on a site presenting your fork as the original, is the line.

## Licence

Code is Apache-2.0 — see [LICENSE](LICENSE) and [NOTICE](NOTICE), which every derivative
distribution has to carry. The catalogue is CC BY 4.0 — see [LICENSE-DATA](LICENSE-DATA)
for the attribution string and what the licence does and does not cover. The code was MIT
before 0.1.0; ADR 0024 records why it moved.
