Merge pull request 'Sa'ueia workshop first cut: table, checker, sound proposal' (#5) from weksa/sa-ueia-workshop into main

This commit is contained in:
Weksa 2026-11-04 12:56:50 +00:00
commit d043281a18
4 changed files with 313 additions and 0 deletions

View file

@ -92,6 +92,42 @@ The Sa'ueia continent should not read as empty wilderness. Its language should
have ways to name civilization that moves, recurs, bargains, and is remembered by
ecology.
## Sa'ueia Sound (Proposal For Deru To Cut)
Status: proposal. Deru approved the outline in #zyphos on 2026-11-04; only the
species name Sa'ueia is canon. Everything below is a working draft in
`sa-ueia/table.yaml`, each entry marked `deru` or `weksa-placeholder`, and
`sa-ueia/check.py` enforces what is mechanical.
Taken from Deru's words:
- five vowels: `a e i o u`;
- no `h`, `q`, `y`; `k` where a `q` might have been;
- short words, soft and open;
- suffixing morphology; the one suffix so far is `-um`, a verb suffix;
- identical letters merge at a morpheme join: `weru` + `um` gives `werum`,
`ila` + `werum` gives `ilawerum` (walk of knowledge);
- a vowel only where one is needed.
First roots, all Deru's: `ila` walk, `weru` know, `Olune` breeding ground
(unanalyzed).
Proposed, for Deru to accept or cut:
- the apostrophe in Sa'ueia is orthographic, and is not counted as a letter;
- "short" means at most 10 letters in a joined word, as a warning only;
- no doubled letter inside a morpheme, so a double can only arise at a join
and the merge rule removes it.
Open, not guessed:
- the consonant inventory. Only `l m n r s w` appear in Deru's words so far,
and that is attestation, not an inventory;
- whether the know root is `weru` or `wer-` (both give `werum`);
- syllable shapes, stress, and what "vowel only where needed" means once the
inventory is known (the checker does not test it);
- the phonological role of the apostrophe.
## Naming Brief Shape
A Weksa naming brief should capture at least:

View file

@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Sa'ueia workshop checker: join morphemes by table.yaml's rules, flag violations.
Reads table.yaml next to this file (or a path argument). The consonant inventory
is open, so no consonant is ever flagged; only rules in the table are enforced.
Exit status is 1 when any error is found; warnings do not fail the run.
"""
from __future__ import annotations
import sys
from dataclasses import dataclass
from pathlib import Path
import yaml
TABLE = Path(__file__).with_name("table.yaml")
PROVENANCES = {"deru", "weksa-placeholder"}
@dataclass(frozen=True)
class Finding:
severity: str # "error" | "warning"
where: str
message: str
def __str__(self) -> str:
return f"{self.severity}: {self.where}: {self.message}"
def letters(form: str) -> str:
return form.replace("'", "").lower()
def join(morphemes: list[str]) -> str:
"""Concatenate, merging one identical letter at each join."""
out = ""
for m in morphemes:
if out and m and out[-1] == m[0]:
m = m[1:]
out += m
return out
def check_form(form: str, rules: dict, where: str, *, inside_morpheme: bool = False) -> list[Finding]:
found = []
text = letters(form)
for ch in rules["forbidden_letters"]["value"]:
if ch in text:
found.append(Finding("error", where, f"{form!r} contains forbidden letter {ch!r}"))
for a, b in zip(text, text[1:]):
if a == b and (not inside_morpheme or rules["no_doubled_letters_inside_morpheme"]["value"]):
found.append(Finding("error", where, f"{form!r} has doubled {a!r}"))
limit = rules["max_word_letters"]
if not inside_morpheme and len(text) > limit["value"]:
found.append(Finding(limit["severity"], where, f"{form!r} is {len(text)} letters, over {limit['value']}"))
return found
def check_table(table: dict) -> list[Finding]:
rules = table["rules"]
found = []
for r_name, r in rules.items():
if r.get("provenance") not in PROVENANCES:
found.append(Finding("error", f"rules.{r_name}", "missing or unknown provenance"))
if table["consonants"]["inventory"] != "open":
found.append(Finding("error", "consonants", "inventory is not Deru's to guess; keep it open until given"))
forms: dict[str, str] = {}
for kind in ("roots", "suffixes"):
for entry in table[kind]:
form = entry["form"]
where = f"{kind}.{form}"
if entry.get("provenance") not in PROVENANCES:
found.append(Finding("error", where, "missing or unknown provenance"))
forms[form] = kind
found += check_form(form, rules, where, inside_morpheme=True)
for comp in table["compositions"]:
where = "compositions." + "+".join(comp["morphemes"])
if comp.get("provenance") not in PROVENANCES:
found.append(Finding("error", where, "missing or unknown provenance"))
for m in comp["morphemes"]:
if m not in forms and not any(m == f2 for f2 in (c["expected"] for c in table["compositions"])):
found.append(Finding("error", where, f"morpheme {m!r} is in no table row or earlier composition"))
got = join(comp["morphemes"])
if got != comp["expected"]:
found.append(Finding("error", where, f"joins to {got!r}, table says {comp['expected']!r}"))
found += check_form(comp["expected"], rules, where)
return found
def main(argv: list[str]) -> int:
path = Path(argv[1]) if len(argv) > 1 else TABLE
findings = check_table(yaml.safe_load(path.read_text(encoding="utf-8")))
for f in findings:
print(f)
print(f"{sum(f.severity == 'error' for f in findings)} error(s), "
f"{sum(f.severity == 'warning' for f in findings)} warning(s)")
return 1 if any(f.severity == "error" for f in findings) else 0
if __name__ == "__main__":
sys.exit(main(sys.argv))

View file

@ -0,0 +1,81 @@
# Sa'ueia workshop table, first cut.
# Source: Deru's words in #zyphos, 2026-11-04. Setting owner: Deru (the Zyphos vault).
# Every entry carries provenance:
# deru - said by Deru, or part of the sound outline Deru approved
# weksa-placeholder - Weksa's stand-in so the checker can run; Deru may cut it
# Only the species name Sa'ueia is canon. Everything else here is a working
# draft, even when its provenance is deru.
roots:
- form: ila
gloss: walk
provenance: deru
- form: weru
gloss: know
provenance: deru
note: >
Deru gave "werum" (knowledge, maybe a verb) and was asked whether -um
suffixes wer- or weru. "weru" is the reading under which the join rule
does visible work (weru + um -> werum); wer- gives the same surface form.
Open for Deru.
- form: olune
gloss: breeding ground
provenance: deru
analysis: unanalyzed # no morpheme boundaries known
- form: sa'ueia
gloss: the species name
provenance: deru
canon: true
analysis: unanalyzed
note: Apostrophe is part of the canon spelling; its phonological role is open.
suffixes:
- form: um
gloss: verb suffix
provenance: deru
rules:
vowels:
value: [a, e, i, o, u]
provenance: deru
forbidden_letters:
value: [h, q, y]
provenance: deru
note: k is used where q might have been.
join_merge_identical:
value: true
provenance: deru
note: >
When one morpheme ends in the letter the next begins with, they merge to
one letter at the join (weru + um -> werum, ila + werum -> ilawerum).
morphology_direction:
value: suffixing
provenance: deru
short_words:
provenance: deru
checked: false # no number from Deru
max_word_letters:
value: 10
provenance: weksa-placeholder
severity: warning
note: Stand-in for "short words"; apostrophes not counted. Deru sets the real bound.
no_doubled_letters_inside_morpheme:
value: true
provenance: weksa-placeholder
note: Follows from the merge rule only if doubles never occur inside a morpheme either.
vowel_only_where_needed:
provenance: deru
checked: false # not mechanically decidable without the consonant inventory
consonants:
inventory: open # Deru has not given it. The checker does not guess.
seen_in_deru_words: [l, m, n, r, s, w] # attested above, not an inventory
# Joined forms the checker must reproduce. Each: morphemes in order, expected surface.
compositions:
- morphemes: [weru, um]
expected: werum
provenance: deru # "werum" is Deru's form
- morphemes: [ila, werum]
expected: ilawerum
provenance: deru # "Ilawerum", walk of knowledge

View file

@ -0,0 +1,94 @@
import copy
import unittest
import yaml
import check
def table():
return yaml.safe_load(check.TABLE.read_text(encoding="utf-8"))
def errors(t):
return [f for f in check.check_table(t) if f.severity == "error"]
class JoinTests(unittest.TestCase):
def test_identical_letters_merge_at_join(self):
self.assertEqual(check.join(["weru", "um"]), "werum")
self.assertEqual(check.join(["ila", "werum"]), "ilawerum")
def test_merge_is_one_letter_not_all(self):
self.assertEqual(check.join(["ila", "aba"]), "ilaba")
self.assertEqual(check.join(["ila", "ala"]), "ilala")
def test_different_letters_concatenate(self):
self.assertEqual(check.join(["ila", "um"]), "ilaum")
def test_merge_applies_only_at_joins(self):
# a doubled letter inside a morpheme is not collapsed by join
self.assertEqual(check.join(["aa", "b"]), "aab")
class TableTests(unittest.TestCase):
def test_shipped_table_is_clean(self):
self.assertEqual(errors(table()), [])
def test_forbidden_letter_in_root_flagged(self):
t = table()
t["roots"].append({"form": "haru", "gloss": "x", "provenance": "weksa-placeholder"})
self.assertTrue(any("forbidden letter 'h'" in str(f) for f in errors(t)))
def test_q_and_y_flagged(self):
for ch in "qy":
t = table()
t["roots"].append({"form": f"a{ch}a", "gloss": "x", "provenance": "deru"})
self.assertTrue(any(f"forbidden letter '{ch}'" in str(f) for f in errors(t)), ch)
def test_k_allowed(self):
t = table()
t["roots"].append({"form": "kela", "gloss": "x", "provenance": "weksa-placeholder"})
self.assertEqual(errors(t), [])
def test_missing_or_unknown_provenance_flagged(self):
for bad in (None, "guess"):
t = table()
t["suffixes"][0]["provenance"] = bad
self.assertTrue(any("provenance" in str(f) for f in errors(t)), bad)
def test_unmerged_composition_flagged(self):
t = table()
t["compositions"][0]["expected"] = "weruum"
msgs = [str(f) for f in errors(t)]
self.assertTrue(any("joins to 'werum'" in m for m in msgs))
self.assertTrue(any("doubled 'u'" in m for m in msgs))
def test_doubled_letter_inside_morpheme_flagged(self):
t = table()
t["roots"].append({"form": "ilaa", "gloss": "x", "provenance": "weksa-placeholder"})
self.assertTrue(any("doubled 'a'" in str(f) for f in errors(t)))
def test_unknown_morpheme_in_composition_flagged(self):
t = table()
t["compositions"].append({"morphemes": ["ila", "zo"], "expected": "ilazo", "provenance": "weksa-placeholder"})
self.assertTrue(any("'zo'" in str(f) for f in errors(t)))
def test_long_word_is_warning_not_error(self):
t = table()
t["compositions"].append({"morphemes": ["ila", "werum", "ila", "werum"], "expected": "ilawerumilawerum",
"provenance": "weksa-placeholder"})
self.assertEqual(errors(t), [])
self.assertTrue(any(f.severity == "warning" for f in check.check_table(t)))
def test_consonant_inventory_must_stay_open(self):
t = table()
t["consonants"]["inventory"] = ["l", "m"]
self.assertTrue(any("inventory" in str(f) for f in errors(t)))
def test_apostrophe_is_not_a_letter(self):
self.assertEqual(check.letters("Sa'ueia"), "saueia")
if __name__ == "__main__":
unittest.main()