conlang
A partial-spec constructed-language generator.
You describe only what you care about — phonology, syllable shapes, a handful of words — and the generator samples the rest from typologically-plausible defaults. Same spec plus same seed always produces byte-identical output, and sampled entries can be locked so they survive re-sampling.
spec (YAML) ──▶ generate ──▶ model (canonical JSON) ──▶ render ──▶ dictionary + grammar sketch
│
├── lint (machine-checkable invariants)
└── bench (plausibility scorecard vs. Kah)
The model JSON is the single source of truth; the dictionary, grammar sketch, and any future views are rendered from it.
Install
pip install conlanggen
The distribution is conlanggen, but the import package and the command are both
conlang — import conlang, conlang generate.
Quickstart
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install -e ".[dev]"
# Generate from the example spec, and render a Markdown dictionary.
.\.venv\Scripts\python.exe -m conlang.cli generate --spec spec\example.yaml --render
# Happy with it? Freeze it into an explicit spec so the values stick.
conlang accept --model out\veshtari.json
Output lands in out/: a model JSON and a rendered .md.
# Re-lint or re-render an existing model.
conlang lint --model out\veshtari.json
conlang render --model out\veshtari.json --out out\veshtari.md
Translating a fixed story
The toolkit ships a repeatable pipeline for translating the fixed 111-sentence
story The Quiet Morning into a generated language: conlang new → grow →
translate → validate-translation → extras. The runbook is
docs/pipeline.md.
Writing a spec
Everything is optional. Omitted fields are sampled.
name: Veshtari
seed: 42
phonology:
consonants: [p, t, k, m, n, s, l, r, v, sh, ch]
vowels: [a, e, i, o, u]
syllables: [CV, CVC, V] # C = consonant slot, V = vowel slot
word_count: 150
lexicon: # pin specific words; they are locked by default
- gloss: water
root: vela
Accepted keys: name, seed, phonology.{consonants,vowels,ipa,orthography},
syllables, concepts, word_count, lexicon, affixes, onsets, codas,
forbidden.
An explicit, unsized lexicon is treated as the complete word list (that is
what accept writes back); otherwise the built-in concept list is used and
word_count trims or extends it.
If your syllables include CCV/CCVC, the generator samples a legal onset
set unless you supply onsets yourself, and accept records whichever was
used so a frozen spec regenerates identically.
IPA inventories
By default a symbol is its spelling (sh is written sh). Set
phonology.ipa: true to declare the inventory phonemic: each symbol is an IPA
phoneme, and the tool derives a romanised orthography for the dictionary.
name: Shibo
seed: 11
phonology:
ipa: true
consonants: [p, t, k, ʃ, tʃ, m, n, s, l]
vowels: [a, e, i, o, u]
orthography: # optional; corrects or extends the built-in defaults
tʃ: ch
ʃ: sh
/ʃ/ spells sh and /tʃ/ spells ch from a built-in table; anything absent
spells itself, so a plain Latin inventory is unchanged. orthography overrides
the table (set an entry back to its own symbol to cancel a default), and the
resolved map is stored on the inventory. The dictionary and word-form tables
then show a spelling column beside the ipa one, and accept writes the map
back so a frozen spec regenerates identically. Lint warns on a map entry that is
not an inventory symbol, or two symbols that would spell the same thing.
Freezing a language (accept)
A partial spec samples whatever you leave out. Once you like the result,
conlang accept writes it back as a fully-explicit spec: the phonology,
syllable shapes, constraints, affixes, and every root become pinned entries.
Regenerating from that spec reproduces the same language exactly — sampled
values now survive re-sampling, and changing the seed can no longer disturb
them.
conlang generate --spec spec\example.yaml --out out\veshtari.json
conlang accept --model out\veshtari.json --out spec/veshtari.yaml
conlang generate --spec spec/veshtari.yaml --out out/veshtari.json
Two fields legitimately change across the round trip: origin becomes
specified for every accepted entry (it is pinned now, not sampled), and
spec_hash changes because the spec text is different. Everything linguistic
is identical, and tests/test_invariants.py asserts exactly that.
Guarantees and lint rules
Reproducibility is the one hard guarantee: spec + seed ⇒ identical bytes.
Everything else is reported by lint as errors or warnings, and a broken
language is still generated (conlangers want to explore broken states).
| rule | severity | meaning |
|---|---|---|
schema |
error/warning | schema version present and known |
lexeme.root |
error | lexeme has a non-empty root |
phonotactics.lexeme |
error | every root is a legal word |
phonotactics.word |
error | every inflected form is a legal word |
lexicon.homophones |
warning | two roots collide (Kah has polysemy too) |
morphology.paradigm |
warning | a word is missing a form for a category its class realises |
morphology.class |
error | a word names an inflection class the language does not define |
lexicon.nonempty |
warning | the lexicon is empty |
orthography.unknown |
warning | the spelling map names a symbol that is not in the inventory |
orthography.collision |
warning | two symbols would spell the same thing |
Every rule above has a test in tests/test_lint.py, tests/test_lint_invariants.py,
or tests/test_morphology.py.
Phonotactics
A word is legal when its C/V skeleton can be partitioned into the language's
syllable templates and every resulting onset and coda is allowed. Two details
matter more than they look:
- Constraints are checked while syllabifying, not after. A word often has
several valid partitions;
deplangoisde|plan|gowith legal onsets, and alsode|pla|ngowith an illegal one. Verdict must depend on the phonology, not on which partition the search happens to return first. - Onsets and codas are compared by segment, not by string. An onset of
ny+mrenders as"nym"— three characters, two segments. Matching strings would silently reject legal words in any language with a digraph.
When a template set admits onset clusters (CCV, CCVC), the generator samples
a legal onset set and builds roots from it, so generated words are legal by
construction rather than by luck. constraints.onsets in the model records what
was sampled; an explicit onsets in the spec always wins.
Affixes carry allomorphs: conditioned surface forms. A single form cannot
serve both consonant-final and vowel-final roots, because the root's coda plus a
consonant-initial affix resyllabify into an onset cluster. When the primary form
would be illegal, build_words uses the first alternative that is legal — which
is what real languages do. It is a rare fallback (~2 in 8,500 affixed forms), but
without it those words would be illegal and lint would fail the language.
The Kah benchmark
Kah (Kwesho's language) is hand-made, so the benchmark does not try to regenerate it. It does two things:
- Adapter fixture — parses the real Kwesho dictionary cache into the model schema, proving the schema can represent real data.
- Plausibility scorecard — compares a generated language against Kah.
The scorecard deliberately reports three separate numbers, because a word can fail for two unrelated reasons and collapsing them makes the report unreadable:
| metric | question |
|---|---|
| symbol coverage | which Kah sounds does this language have? |
| usable sounds | how many Kah headwords use only sounds this language has? |
| legal shapes | of those, how many fit the phonotactics? |
Scoring the built-in kah preset against Kah is circular, so the report says so
explicitly when it detects it. Treat the scorecard as a lever, not a target: the
presets are deliberately small, and a language that only allows CV syllables is
a legitimate (if monotonous) language type, not a bug to tune away.
conlang bench --spec spec\example.yaml
# or point at a cache explicitly:
conlang bench --spec spec\example.yaml --kah C:\path\to\kah-lexicon.txt
Kah's dictionary is Kwesho's copyrighted work and is never vendored into this
repository. Without --kah, the benchmark looks for the glossary at a per-user
cache path (%LOCALAPPDATA%\conlang\kah-lexicon.txt on Windows,
~/.cache/conlang/kah-lexicon.txt elsewhere, or $XDG_CACHE_HOME/conlang/);
override it with --kah or CONLANG_KAH_LEXICON.
On the reference machine, the adapter parses 8,153 entries with a 97.8% validity rate.
Evolving a language
A generated model can be the parent of a daughter, evolved by ordered sound
changes. A rule is old > new (new empty deletes the segment), optionally
conditioned by an environment after /. The environment names the context on
each side of the target, with _ marking the target slot, # the word boundary,
and V/C the vowel/consonant classes:
conlang generate --spec spec\example.yaml --out out\proto.json
conlang evolve --model out\proto.json --rule "a > e" --rule "p > f / V_V" --out out\daughter.json
conlang evolve --model out\proto.json --rule "t > d / #_" --rule "s > z / V_" --out out\voiced.json
V_V is "between vowels", #_ is word-initial, _# is word-final. Rules are
applied left to right so a later rule sees an earlier one's output (feeding).
The daughter is a full model: its inventory gains any new sounds, its lexicon
and affixes are rewritten, and its word forms are rebuilt. It records where it
came from in lineage:
"lineage": { "parent": "proto", "rules": ["a > e", "p > f / V_V"] }
That is the whole point of the field: the derivation is data, so a daughter can
itself be evolved, and accept keeps the chain across a round trip. Mergers
introduced by a change show up as lexicon.homophones warnings, never errors.
Syntax
Each language samples a constituent order (SOV, SVO, or VSO, weighted
toward the first two) and an alignment (accusative or ergative). Both are
recorded in the model and survive accept. A transitive clause is linearised
from the lexicon and inflected: the subject and object take case by alignment
(accusative marks NOM/ACC; ergative marks ERG/ABS) and the verb agrees
with the subject:
from conlang.syntax import sample_clause
words, gloss = sample_clause(language)
# e.g. (['pyople', 'klavragprekbra', 'blonyto'], 'water.ERG say.3SG fire.ABS')
Case and agreement are applied when the clause is built, not woven into every word's standing paradigm — a deliberate split, so a noun's form count does not double again for case.
Layout
src/conlang/
model/ canonical pydantic schema (the source of truth)
sampling/ deterministic RNG, inventory presets, concepts, defaults
phonology/ tokenization, syllabification, legality checks
lexicon/ seeded root generation, stable per-item seeding
morphology/ affixes, inflection classes, and word-form assembly
soundchange/ ordered sound changes; derive a daughter language
syntax/ word order, alignment, simple clause linearisation
render/ Markdown dictionary + grammar sketch
lint/ machine-checkable invariants
bench/ Kah adapter + scorecard
cli.py thin command-line view over the library
Development
.\.venv\Scripts\python.exe -m pytest
.\.venv\Scripts\python.exe -m ruff check .
.\.venv\Scripts\python.exe -m mypy src
Roadmap
Shipped so far:
- Phonology — inventory, phonotactics, seeded word generation.
- IPA inventories — declare the inventory phonemic (
ipa: true) and the tool derives a romanised orthography alongside the phonemic forms. - Morphology — affix breadth, inflection classes, and stacking (a word can carry several categories at once).
- Diachrony —
conlang evolve: ordered sound changes derive a daughter language, now with environments (p > f / V_V,#,_), and the chain recorded inlineage. - Syntax — sampled word order and alignment, case marking with subject agreement, the predication tier (adpositions, noun phrases, intransitives, copula/existential), clause sequencing (parataxis and conjunctions), and aspect (progressive/perfect, stacking with tense).
- Translation pipeline —
conlang new/grow/translate/validate-translation/extras: build a generated language and translate the fixed story into it, including a grammar-match port from a sibling language. - Writing systems —
conlang script: a language samples a script (alphabet / syllabary / impure abjad) from its phonology, the tool generates deterministic SVG glyphs as a derived view, and renders a script chart plus the lexicon written out (the recall deck shows glyphs too).
Next:
- Optional data packs: PHOIBLE (inventories) and WALS (typology) as CC-BY adapters behind the built-in defaults.
- Semantics/pragmatics (deferred from v1); featural scripts, which need a phonetic feature layer the tool does not yet have.
License
MIT (code). Kah dictionary and grammar content remains Kwesho's and is not part of this repository.
Metadata
Release files for conlanggen 0.5.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| conlanggen-0.5.0.tar.gz | 118.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| conlanggen-0.5.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 195.2 kB
Release files / conlanggen-0.5.0.tar.gz
| Download URL | conlanggen-0.5.0.tar.gz |
|---|---|
| Size | 118.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
6fb10874ccd528da40fd3bb57ddfaa285379af1d1d0aab596283618700fddb1d
|
|
BLAKE2b-256 checksum How to use checksums |
d397f97780dd3416760a3b2538da6a2e5bbb947a2419b86208b5acb30887c25b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 8, 2026.
Transparency logRelease files / conlanggen-0.5.0-py3-none-any.whl
| Download URL | conlanggen-0.5.0-py3-none-any.whl |
|---|---|
| Size | 76.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
8589082f10a96f2be416fdf5f6c1a5020ae8ee89aa010fb58308a95872d4cec8
|
|
BLAKE2b-256 checksum How to use checksums |
e3c907a4040823e41dd43ddd9945dffd09a46d79c7349ffdc44efa40591e4fe1
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 8, 2026.
Transparency log