From 53015a5c4d44125012ebe8ea59ce98a949f3df29 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 10:17:59 +0000 Subject: [PATCH 1/2] Write numbers as COUNT.n, not ONE.n The grammar moved numbers to a new 16th part, COUNT, so a number can never be drawn like a word (ONE.495 looked exactly like ONE.SELF). COUNT is the only kind whose which is read as bits, it never takes a part as its which, and COUNT alone is zero. "How many?" is OPEN.COUNT. The bundled vocabulary, parser, decoder, examples, tests and docs follow the canon grammar; the ONE.n twin warning is gone. Version 0.2.0. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01F8Qs752onkG5wP4feFsWjQ --- README.md | 10 +++--- docs/assets/examples/how-many.drawing.txt | 8 ++--- docs/assets/examples/how-many.svg | 26 ++++++++-------- docs/assets/examples/pulsars.drawing.txt | 4 +-- docs/assets/examples/pulsars.svg | 8 ++--- docs/assets/parts.txt | 29 ++++++++++-------- docs/cli.md | 6 ++-- docs/getting-started.md | 4 +-- docs/index.md | 10 +++--- docs/pages.md | 2 +- docs/script.md | 9 +++--- docs/vocabulary.md | 4 +-- examples/how-many.txt | 5 +-- examples/pulsars.txt | 2 +- scripts/gen_docs.py | 6 ++-- src/glyph_cli/__init__.py | 2 +- src/glyph_cli/cli.py | 19 +++--------- src/glyph_cli/data/vocab.json | 15 +++++++-- src/glyph_cli/drawing.py | 27 +++++++---------- src/glyph_cli/export.py | 2 +- src/glyph_cli/script.py | 37 +++++++++++------------ tests/test_cli.py | 4 +-- tests/test_pages.py | 33 +++++++++++--------- tests/test_script.py | 30 ++++++++++++++---- 24 files changed, 161 insertions(+), 141 deletions(-) diff --git a/README.md b/README.md index 6287fe1..d5bfa76 100644 --- a/README.md +++ b/README.md @@ -5,16 +5,16 @@ The dictionary and toolkit for the **grid script**: how humans write down what R **Docs:** https://interimm.github.io/glyph-cli/ ```sh -uvx --from git+https://github.com/InterImm/glyph-cli glyph show BODY.OTHER STAR.TIME ONE.137 +uvx --from git+https://github.com/InterImm/glyph-cli glyph show BODY.OTHER STAR.TIME COUNT.137 ``` ```text +++.+++ .+..+.+ .....+. -+++.+.. +++.+.+ .+....+ -+++.+++ .+..+.+ ......+ ++++.+.. +++.+.+ ......+ ++++.+++ .+..+.+ +++...+ BODY.OTHER: your world STAR.TIME: pulsar - ONE.137: 137 + COUNT.137: 137 ``` ## Install @@ -28,7 +28,7 @@ Python 3.10 or newer, no other dependencies. ## Use ```sh -glyph parts # the 15 parts +glyph parts # the 16 parts glyph find wind # search meanings glyph check BODY.STAR # is it a word? exit 1 if not glyph show BODY.OTHER --symbol × # draw words diff --git a/docs/assets/examples/how-many.drawing.txt b/docs/assets/examples/how-many.drawing.txt index 7cd9cde..3453ae2 100644 --- a/docs/assets/examples/how-many.drawing.txt +++ b/docs/assets/examples/how-many.drawing.txt @@ -1,7 +1,7 @@ -.+..+.+..............+. -+++.+.+..+.......+..+.+ -.+..+.+..............+. +.+..+.+..........+..... ++++.+.+..+......+.+.... +.+..+.+..........+..+++ ....................... ....................... ......................× -....................×.. +................×××.×.. diff --git a/docs/assets/examples/how-many.svg b/docs/assets/examples/how-many.svg index ab8b122..d4a0ba5 100644 --- a/docs/assets/examples/how-many.svg +++ b/docs/assets/examples/how-many.svg @@ -17,11 +17,11 @@ - ++ -+ + + + @@ -39,13 +39,13 @@ - -+ - ++ + ++ -+ + -+ + + @@ -63,12 +63,12 @@ - ++ - ++ + - ++ @@ -154,9 +154,9 @@ - - - +× +× +× × diff --git a/docs/assets/examples/pulsars.drawing.txt b/docs/assets/examples/pulsars.drawing.txt index 56a0557..6eccdb8 100644 --- a/docs/assets/examples/pulsars.drawing.txt +++ b/docs/assets/examples/pulsars.drawing.txt @@ -5,5 +5,5 @@ ....................... ....................... .+..+.+................ -+++.+.+..+.......+....+ -.+..+.+.............+.. ++++.+.+..+............+ +.+..+.+.........+++.+.. diff --git a/docs/assets/examples/pulsars.svg b/docs/assets/examples/pulsars.svg index 71a0941..b609a71 100644 --- a/docs/assets/examples/pulsars.svg +++ b/docs/assets/examples/pulsars.svg @@ -178,7 +178,7 @@ -+ + @@ -200,9 +200,9 @@ - - - ++ ++ ++ + diff --git a/docs/assets/parts.txt b/docs/assets/parts.txt index 75082cd..bf22290 100644 --- a/docs/assets/parts.txt +++ b/docs/assets/parts.txt @@ -1,14 +1,19 @@ -SELF OTHER STAR LIGHT BODY -### ### .#. #.# ### -#.# #.. ### .#. ### -### ### .#. #.# ### +SELF OTHER STAR LIGHT +### ### .#. #.# +#.# #.. ### .#. +### ### .#. #.# -AIR WATER TIME VOICE PATH -#.# ### #.# #.. ... -... ... #.# ##. ### -#.# ### #.# ### ... +BODY AIR WATER TIME +### #.# ### #.# +### ... ... #.# +### #.# ### #.# -NOT ONE BEFORE AFTER OPEN -#.. ... ... ... .#. -.#. .#. #.. ..# #.# -..# ... ... ... .#. +VOICE PATH NOT ONE +#.. ... #.. ... +##. ### .#. .#. +### ... ..# ... + +BEFORE AFTER OPEN COUNT +... ... .#. ... +#.. ..# #.# ... +... ... .#. ### diff --git a/docs/cli.md b/docs/cli.md index 23da074..631a963 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -9,12 +9,12 @@ usage: glyph [-h] [--version] [--vocab PATH] COMMAND ... glyph: the dictionary and toolkit for the grid script of Ross 128 b. Words are written KIND.WHICH (BODY.OTHER = "your world"), or KIND alone. -A number is ONE. (0-511). "_" is an empty position or an empty word. +A number is COUNT. (0-511); COUNT alone is zero. "_" is an empty position or an empty word. Pages are text files, one band per line: "SYMBOL: node | relation | node". positional arguments: COMMAND - parts list the 15 parts and the relation markers + parts list the parts and the relation markers list list the vocabulary check is a word in the vocabulary? (exit 1 if not) find search meanings and notes @@ -43,7 +43,7 @@ Run `glyph init` to make an editable copy. Docs: https://interimm.github.io/glyp $ glyph parts --help usage: glyph parts [-h] [--json] -list the 15 parts and the relation markers +list the parts and the relation markers options: -h, --help show this help message and exit diff --git a/docs/getting-started.md b/docs/getting-started.md index 33d4823..a9a05da 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -30,7 +30,7 @@ glyph --version ## Look at the parts -Everything is built from 15 parts, each a 3×3 shape. +Everything is built from 16 parts, each a 3×3 shape. ```sh glyph parts @@ -49,7 +49,7 @@ glyph check BODY.STAR # no, and its literal reading glyph show BODY.OTHER BODY.SELF --symbol × ``` -Numbers are built in: `ONE.137` is 137, drawn as nine bits. +Numbers are built in: `COUNT.137` is 137, its which drawn as nine bits. `COUNT` alone is zero. ## Write a page diff --git a/docs/index.md b/docs/index.md index 4d8a903..dd18e66 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,16 +13,16 @@ It draws words and pages on the lattice, keeps the vocabulary honest, reads a pa No install needed if you have [uv](https://docs.astral.sh/uv/): ```sh -uvx --from git+https://github.com/InterImm/glyph-cli glyph show BODY.OTHER STAR.TIME ONE.137 +uvx --from git+https://github.com/InterImm/glyph-cli glyph show BODY.OTHER STAR.TIME COUNT.137 ``` ```text +++.+++ .+..+.+ .....+. -+++.+.. +++.+.+ .+....+ -+++.+++ .+..+.+ ......+ ++++.+.. +++.+.+ ......+ ++++.+++ .+..+.+ +++...+ BODY.OTHER: your world STAR.TIME: pulsar - ONE.137: 137 + COUNT.137: 137 ``` Then follow [Getting started](getting-started.md). @@ -31,7 +31,7 @@ Then follow [Getting started](getting-started.md). | Command | What for | |---|---| -| `glyph parts` | The 15 parts and what they mean as a thing and as a relation | +| `glyph parts` | The 16 parts and what they mean as a thing and as a relation | | `glyph list`, `find`, `check` | Look words up | | `glyph show` | Draw words | | `glyph render` | Draw a whole page, as text or as SVG pixels | diff --git a/docs/pages.md b/docs/pages.md index 7580057..09c6d0e 100644 --- a/docs/pages.md +++ b/docs/pages.md @@ -8,7 +8,7 @@ A page is a plain text file that `glyph render`, `glyph graph` and `glyph decode - The first band of a block is the **statement**, one edge of the graph. Bands under it are **other voices** replying, part by part. - A **blank line** starts the next statement. - `#` starts a comment. -- A word is `KIND.WHICH`, `KIND`, `_.WHICH`, or `_` for nothing. Numbers are `ONE.0` to `ONE.511` (or `_.12` in a reply). +- A word is `KIND.WHICH`, `KIND`, `_.WHICH`, or `_` for nothing. Numbers are `COUNT.0` to `COUNT.511`; `COUNT` alone is zero (in a reply under a number, `_.12` changes just the bits). - A symbol is any single character except a space, `.`, `#`, `|`, `:` and `_`. By convention `+` is Ross 128 b and `×` is Earth. ```text diff --git a/docs/script.md b/docs/script.md index 1ebc220..ebfd95d 100644 --- a/docs/script.md +++ b/docs/script.md @@ -41,6 +41,7 @@ This is the grammar `glyph` implements: version 7 of the grid script, where **a | BEFORE | earlier | — | | AFTER | later | — | | OPEN | what goes here? | — | +| COUNT | number | — | ## Relations @@ -58,21 +59,21 @@ A relation is one part (the action) plus a which that marks time or polarity: ## Numbers -`ONE` with a which read as nine bits, 256 down to 1, left to right and top to bottom. `ONE.137`: +`COUNT` plus a which read as nine bits, 256 down to 1, left to right and top to bottom. `COUNT` alone is zero. `COUNT.137`: ```text .....+. -.+....+ ......+ ++++...+ ``` -!!! note "Numbers that look like words" - Some numbers spell a part's shape: 495 is drawn exactly like `SELF`, so `ONE.495` and `ONE.SELF` (*one of us*) look the same on the page. When a drawing is read back, `glyph decode` reads such a which as the vocabulary word, and `glyph check ONE.495` warns about it. The clashes today are 16 (`ONE.ONE`), 170 (`ONE.OPEN`), 273 (`ONE.NOT`), 487 (`ONE.OTHER`) and 495 (`ONE.SELF`). `ONE.0` draws nothing in the which, so it reads back as `ONE` alone. +COUNT is the only kind whose which is read as bits, and it never takes a part as its which, so a number can never be drawn like a word. (Under the old rule, numbers were `ONE` plus bits, and 495 was drawn exactly like `ONE.SELF`, *one of us*.) ## "That" and questions - `ONE` alone in a node slot means **that**: the statement on the line above. - `OPEN` in any position asks **what goes here?** The asker leaves an empty band for the answer, and whatever is drawn under `OPEN` replaces it. `OPEN` as a relation's which asks yes or no. +- **How many?** is `OPEN.COUNT` (*what number?*), answered with a `COUNT` word. ## How the machine reads a page diff --git a/docs/vocabulary.md b/docs/vocabulary.md index be6a3e0..7d0fd1f 100644 --- a/docs/vocabulary.md +++ b/docs/vocabulary.md @@ -1,7 +1,7 @@ # Vocabulary How words are made: a **kind** part narrowed by a **which** part (`KIND.WHICH`). A single part is a word -on its own. Any part can also be a **relation**, and numbers (`ONE.0` to `ONE.511`) are built in, so they +on its own. Any part can also be a **relation**, and numbers (`COUNT.0` to `COUNT.511`) are built in, so they are not listed. Status: **preliminary**, 55 words. @@ -93,5 +93,5 @@ are not listed. Status: **preliminary**, 55 words. | `.......`
`.+.....`
`.......` | ONE | that | alone in a node slot: the line above | | `.......`
`.+...+.`
`.......` | ONE.ONE | the same | | | `....+..`
`.+...+.`
`......+` | ONE.NOT | different | | -| `.....+.`
`.+..+.+`
`.....+.` | ONE.OPEN | how many? | | +| `.+.....`
`+.+....`
`.+..+++` | OPEN.COUNT | how many? | "what number?"; answer with COUNT.n | | `.+.....`
`+.+....`
`.+.....` | OPEN | what? | | diff --git a/examples/how-many.txt b/examples/how-many.txt index f6a47a5..6f8e67b 100644 --- a/examples/how-many.txt +++ b/examples/how-many.txt @@ -1,3 +1,4 @@ # "How many pulsars?" Twelve. -+: STAR.TIME | ONE | ONE.OPEN -×: _ | _ | _.12 +# OPEN.COUNT asks "what number?"; the answer COUNT.12 replaces OPEN with COUNT, and COUNT with the bits. ++: STAR.TIME | ONE | OPEN.COUNT +×: _ | _ | COUNT.12 diff --git a/examples/pulsars.txt b/examples/pulsars.txt index 0c2d6ac..32e7099 100644 --- a/examples/pulsars.txt +++ b/examples/pulsars.txt @@ -2,4 +2,4 @@ # The same word on two lines is one node with two edges. +: SELF | LIGHT.ONE | STAR.TIME -+: STAR.TIME | ONE | ONE.12 ++: STAR.TIME | ONE | COUNT.12 diff --git a/scripts/gen_docs.py b/scripts/gen_docs.py index ab0a70d..5cdd6fa 100644 --- a/scripts/gen_docs.py +++ b/scripts/gen_docs.py @@ -35,11 +35,11 @@ def cli_markdown() -> str: def parts_text(vocab: Vocabulary) -> str: - """The 15 parts drawn in rows of five, with their names above.""" + """The parts drawn in rows of four, with their names above.""" names = list(vocab.parts) blocks = [] - for i in range(0, len(names), 5): - row = names[i : i + 5] + for i in range(0, len(names), 4): + row = names[i : i + 4] lines = ["".join(f"{n:<10}" for n in row).rstrip()] for r in range(3): lines.append("".join(f"{vocab.parts[n].shape[r]:<10}" for n in row).rstrip()) diff --git a/src/glyph_cli/__init__.py b/src/glyph_cli/__init__.py index 621e445..c8f49a2 100644 --- a/src/glyph_cli/__init__.py +++ b/src/glyph_cli/__init__.py @@ -2,6 +2,6 @@ from .script import Entry, GlyphError, Part, Vocabulary, Word -__version__ = "0.1.0" +__version__ = "0.2.0" __all__ = ["Entry", "GlyphError", "Part", "Vocabulary", "Word", "__version__"] diff --git a/src/glyph_cli/cli.py b/src/glyph_cli/cli.py index bb865ed..e9c8bf8 100644 --- a/src/glyph_cli/cli.py +++ b/src/glyph_cli/cli.py @@ -14,7 +14,7 @@ from .export import vocabulary_markdown from .graph import format_graph, read_graph, to_dot from .page import check_symbol, format_page, parse_page -from .script import GlyphError, Vocabulary, Word +from .script import NUMBER, GlyphError, Vocabulary ENV_VOCAB = "GLYPH_VOCAB" @@ -22,7 +22,7 @@ glyph: the dictionary and toolkit for the grid script of Ross 128 b. Words are written KIND.WHICH (BODY.OTHER = "your world"), or KIND alone. -A number is ONE. (0-511). "_" is an empty position or an empty word. +A number is COUNT. (0-511); COUNT alone is zero. "_" is an empty position or an empty word. Pages are text files, one band per line: "SYMBOL: node | relation | node". """ @@ -56,13 +56,6 @@ def emit_json(data: object) -> None: print(json.dumps(data, indent=2, ensure_ascii=False)) -def warn_twin(v: Vocabulary, word: Word) -> None: - if word.is_number: - twin = v.number_twin(word.which) # type: ignore[arg-type] - if twin: - print(f' note: drawn exactly like {twin.word} ("{twin.gloss}"); a reader sees that word', file=sys.stderr) - - # ---------- commands ---------- @@ -105,9 +98,8 @@ def cmd_check(v: Vocabulary, a: argparse.Namespace) -> int: if e: print(f'yes: {word} = "{e.gloss}" ({e.domain})') return 0 - if word.kind == "ONE" and word.is_number: - print(f"yes: {word} = the number {word.which} (numbers are built in)") - warn_twin(v, word) + if word.kind == NUMBER: + print(f"yes: {word} = the number {word.which or 0} (numbers are built in)") return 0 print(f"no: {word} is not in the vocabulary. Literal reading: {v.gloss(word)}") same_kind = [x for x in v.entries if x.word.kind == word.kind] @@ -133,7 +125,6 @@ def cmd_show(v: Vocabulary, a: argparse.Namespace) -> int: print("\n".join(draw_words(v, words, check_symbol(a.symbol)))) for w in words: print(f" {w}: {v.gloss(w)}") - warn_twin(v, w) return 0 @@ -215,7 +206,7 @@ def add(name: str, func: Callable, help: str) -> argparse.ArgumentParser: p.set_defaults(func=func) return p - p = add("parts", cmd_parts, "list the 15 parts and the relation markers") + p = add("parts", cmd_parts, "list the parts and the relation markers") p.add_argument("--json", action="store_true", help="print JSON") p = add("list", cmd_list, "list the vocabulary") p.add_argument("--domain", help="only domains containing this text") diff --git a/src/glyph_cli/data/vocab.json b/src/glyph_cli/data/vocab.json index 26621e9..fa3d907 100644 --- a/src/glyph_cli/data/vocab.json +++ b/src/glyph_cli/data/vocab.json @@ -135,6 +135,15 @@ ], "thing": "what goes here?", "relation": null + }, + "COUNT": { + "shape": [ + "...", + "...", + "###" + ], + "thing": "number", + "relation": null } }, "markers": { @@ -519,11 +528,11 @@ "note": "" }, { - "kind": "ONE", - "which": "OPEN", + "kind": "OPEN", + "which": "COUNT", "gloss": "how many?", "domain": "Logic and number", - "note": "" + "note": "\"what number?\"; answer with COUNT.n" }, { "kind": "OPEN", diff --git a/src/glyph_cli/drawing.py b/src/glyph_cli/drawing.py index c729fad..61bf1f4 100644 --- a/src/glyph_cli/drawing.py +++ b/src/glyph_cli/drawing.py @@ -10,7 +10,7 @@ from html import escape from .page import Band, Line, Page, check_symbol -from .script import GlyphError, Half, Vocabulary, Word +from .script import NUMBER, GlyphError, Half, Vocabulary, Word POSITIONS = 6 WIDTH = POSITIONS * 4 - 1 # 23 cells @@ -136,24 +136,19 @@ def _structure(rows: list[str]) -> int: raise GlyphError("the rows don't fall into lines and bands; is this a picture rather than text?") -def _which(vocab: Vocabulary, pattern: tuple[str, ...], kind: Half, slot: int, statement_kind: Half) -> Half: - by_shape = {p.shape: p.name for p in vocab.parts.values()} - named = by_shape.get(pattern) - numeric = slot != 1 and (kind == "ONE" or (kind is None and statement_kind == "ONE")) - if numeric: - if named and (named == "OPEN" or vocab.lookup(Word("ONE", named))): - return named - bits = "".join(pattern).replace(EMPTY_CELL, "0").replace(MARK, "1") - return int(bits, 2) - return named +def _which(vocab: Vocabulary, pattern: tuple[str, ...], kind: Half, above: Word | None) -> Half: + """Read a which: bits under COUNT (or under nothing, answering a COUNT word above), else a part.""" + if kind == NUMBER or (kind is None and above is not None and NUMBER in (above.kind, above.which)): + return int("".join(pattern).replace(EMPTY_CELL, "0").replace(MARK, "1"), 2) + return {p.shape: p.name for p in vocab.parts.values()}.get(pattern) def decode(vocab: Vocabulary, text: str) -> Page: """Read a drawing (as made by :func:`render`) back into a page. - Ambiguity: in a node slot whose kind is ONE, a which that is both a part - and a number reads as the part only when ONE.PART is a vocabulary word (or - OPEN, "how many?"); otherwise it reads as the number. + A which is read as nine bits only after COUNT, or with no kind under a line + whose word there is a COUNT word (a reply like ``_.12``); everywhere else it + must be a part. """ rows = [r.strip() for r in text.splitlines() if r.strip()] if not rows: @@ -195,8 +190,8 @@ def decode(vocab: Vocabulary, text: str) -> Page: raise GlyphError(f"rows {r0 + 1}-{r0 + 3}, position {pos + 1}: not a part") halves.append(name) else: - statement_kind = line.bands[0].words[slot].kind if line.bands else None - which = _which(vocab, pattern, halves[-1], slot, statement_kind) + above = line.bands[0].words[slot] if line.bands else None + which = _which(vocab, pattern, halves[-1], above) if which is None: raise GlyphError(f"rows {r0 + 1}-{r0 + 3}, position {pos + 1}: not a part or a number here") halves.append(which) diff --git a/src/glyph_cli/export.py b/src/glyph_cli/export.py index a60bf02..2e8fb8a 100644 --- a/src/glyph_cli/export.py +++ b/src/glyph_cli/export.py @@ -9,7 +9,7 @@ INTRO = """\ How words are made: a **kind** part narrowed by a **which** part (`KIND.WHICH`). A single part is a word -on its own. Any part can also be a **relation**, and numbers (`ONE.0` to `ONE.511`) are built in, so they +on its own. Any part can also be a **relation**, and numbers (`COUNT.0` to `COUNT.511`) are built in, so they are not listed. Status: **preliminary**, {count} words.""" diff --git a/src/glyph_cli/script.py b/src/glyph_cli/script.py index fec5496..243a232 100644 --- a/src/glyph_cli/script.py +++ b/src/glyph_cli/script.py @@ -2,8 +2,9 @@ A *part* is a 3x3 shape with a meaning. A *word* is two lattice positions, a **kind** on the left and a **which** on the right, written ``KIND.WHICH``. -Either half may be empty (``_``). When the kind is ``ONE`` the which may be a -number from 0 to 511, drawn as nine bits. +Either half may be empty (``_``). Numbers are ``COUNT.`` (0 to 511): COUNT is the +only kind whose which is read as nine bits, and it never takes a part as its which, +so a number can never be drawn like a word. ``COUNT`` alone is zero. """ from __future__ import annotations @@ -19,6 +20,7 @@ MAX_NUMBER = 511 EMPTY = "_" +NUMBER = "COUNT" # the kind whose which is a number class GlyphError(Exception): @@ -143,7 +145,7 @@ def save(self, path: str | os.PathLike | None = None) -> Path: # ---- words ---- def parse(self, text: str) -> Word: - """``'BODY.OTHER'`` -> ``Word('BODY', 'OTHER')``; ``'ONE.137'`` -> ``Word('ONE', 137)``.""" + """``'BODY.OTHER'`` -> ``Word('BODY', 'OTHER')``; ``'COUNT.137'`` -> ``Word('COUNT', 137)``.""" s = text.strip() if s in ("", EMPTY): return Word() @@ -156,10 +158,14 @@ def parse(self, text: str) -> Word: which = int(w) if which > MAX_NUMBER: raise GlyphError(f"number out of range 0-{MAX_NUMBER}: {which}") - if kind not in ("ONE", None): - raise GlyphError(f"a number needs ONE as its kind, e.g. ONE.{which} (got {s})") + if kind not in (NUMBER, None): + raise GlyphError(f"a number needs {NUMBER} as its kind, e.g. {NUMBER}.{which} (got {s})") + if kind == NUMBER and which == 0: + which = None # COUNT alone is zero: COUNT.0 draws exactly like it else: which = w.upper() + if kind == NUMBER: + raise GlyphError(f"{NUMBER} only takes a number as its which, e.g. {NUMBER}.12 (got {s})") for half in (kind, which): if isinstance(half, str) and half not in self.parts: raise GlyphError(f"unknown part: {half} (see `glyph parts`)") @@ -192,8 +198,8 @@ def gloss(self, word: Word, role: str = "node") -> str: return base marker = self.markers.get(str(word.which), str(word.which)) return f"{base} [{marker}]" - if word.kind == "ONE" and word.is_number: - return str(word.which) + if word.kind == NUMBER and (word.is_number or word.which is None): + return str(word.which or 0) entry = self.lookup(word) if entry: return entry.gloss @@ -204,8 +210,8 @@ def gloss(self, word: Word, role: str = "node") -> str: # ---- editing ---- def add(self, word: Word, gloss: str, domain: str = "Unsorted", note: str = "", force: bool = False) -> Entry: - if word.kind is None or word.is_number: - raise GlyphError("a vocabulary word needs a kind; numbers are built in") + if word.kind is None or word.is_number or word.kind == NUMBER: + raise GlyphError(f"a vocabulary word needs a kind; numbers ({NUMBER}.n) are built in") existing = self.lookup(word) if existing: raise GlyphError(f'already in the vocabulary: {word} = "{existing.gloss}"') @@ -247,6 +253,8 @@ def validate(self) -> list[str]: for half in e.word.halves: if isinstance(half, str) and half not in self.parts: errors.append(f"{e.word}: unknown part {half}") + if e.word.kind == NUMBER: + errors.append(f"{e.word}: {NUMBER} words are numbers and are built in") if e.word in seen: errors.append(f'{e.word} defined twice ("{seen[e.word]}" and "{e.gloss}")') seen[e.word] = e.gloss @@ -256,17 +264,6 @@ def validate(self) -> list[str]: glosses[g] = e.word return errors - def number_twin(self, n: int) -> Entry | None: - """The ONE.PART word that is drawn exactly like the number ONE.n, if there is one. - - A number's nine bits can spell a part's shape (495 is SELF), so ONE.495 and - ONE.SELF ("one of us") look the same on the page. - """ - for p in self.parts.values(): - if int("".join(p.shape).replace("#", "1").replace(".", "0"), 2) == n: - return self.lookup(Word("ONE", p.name)) - return None - def domains(self) -> list[str]: out: list[str] = [] for e in self.entries: diff --git a/tests/test_cli.py b/tests/test_cli.py index 60b81a8..6442a33 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -24,7 +24,7 @@ def test_parts(capsys): code, out, _ = run(capsys, "parts") assert code == 0 and "SELF" in out and "asked: does it?" in out code, out, _ = run(capsys, "parts", "--json") - assert len(json.loads(out)["parts"]) == 15 + assert len(json.loads(out)["parts"]) == 16 def test_list(capsys): @@ -39,7 +39,7 @@ def test_list(capsys): def test_check(capsys): assert run(capsys, "check", "BODY.OTHER")[0] == 0 - assert run(capsys, "check", "ONE.137")[0] == 0 + assert run(capsys, "check", "COUNT.137")[0] == 0 code, out, _ = run(capsys, "check", "LIGHT.VOICE") assert code == 1 and "not in the vocabulary" in out code, _, err = run(capsys, "check", "WIND") diff --git a/tests/test_pages.py b/tests/test_pages.py index 3ae3ce5..e6b5240 100644 --- a/tests/test_pages.py +++ b/tests/test_pages.py @@ -20,14 +20,19 @@ def page(vocab, text): +.+......+..+...+++.+.. +++.....+.+.....+++.+++ """, - "+: STAR.TIME | ONE | ONE.OPEN\n×: _ | _ | _.12": """ -.+..+.+..............+. -+++.+.+..+.......+..+.+ -.+..+.+..............+. + "+: STAR.TIME | ONE | COUNT.12": """ +.+..+.+................ ++++.+.+..+............+ +.+..+.+.........+++.+.. +""", + "+: STAR.TIME | ONE | OPEN.COUNT\n×: _ | _ | COUNT.12": """ +.+..+.+..........+..... ++++.+.+..+......+.+.... +.+..+.+..........+..+++ ....................... ....................... ......................× -....................×.. +................×××.×.. """, "+: BODY.OTHER | ONE | BODY.AIR\n×: _ | _ | _.WATER\n*: _ | _ | _.AIR": """ +++.+++.........+++.+.+ @@ -67,22 +72,20 @@ def test_decode_round_trips_examples(vocab, path): assert decode(vocab, "\n".join(render(vocab, p))) == p -@pytest.mark.parametrize("n", [0, 1, 12, 137, 186, 511]) +@pytest.mark.parametrize("n", [0, 1, 12, 16, 137, 170, 273, 487, 495, 511]) def test_decode_numbers(vocab, n): - p = page(vocab, f"+: STAR.TIME | ONE | ONE.{n}") + # Numbers whose bits spell a part's shape (495 is SELF) read back as numbers: they sit under COUNT. + p = page(vocab, f"+: STAR.TIME | ONE | COUNT.{n}") back = decode(vocab, "\n".join(render(vocab, p))) - # ONE.0 draws nothing in the which, so it reads back as ONE alone ("that"). - assert str(back.lines[0].statement.words[2]) == ("ONE" if n == 0 else f"ONE.{n}") + assert str(back.lines[0].statement.words[2]) == ("COUNT" if n == 0 else f"COUNT.{n}") -@pytest.mark.parametrize("n, twin", [(495, "ONE.SELF"), (487, "ONE.OTHER"), (273, "ONE.NOT"), (16, "ONE.ONE")]) -def test_numbers_drawn_like_words_read_as_the_word(vocab, n, twin): - assert str(vocab.number_twin(n).word) == twin - p = page(vocab, f"+: STAR.TIME | ONE | ONE.{n}") - assert str(decode(vocab, "\n".join(render(vocab, p))).lines[0].statement.words[2]) == twin +def test_decode_kindless_number_reply(vocab): + p = page(vocab, "+: STAR.TIME | ONE | COUNT.12\n×: _ | _ | _.495") + assert decode(vocab, "\n".join(render(vocab, p))) == p -def test_decode_prefers_vocabulary_words(vocab): +def test_decode_words(vocab): p = page(vocab, "+: ONE.SELF | ONE | ONE.OTHER") assert decode(vocab, "\n".join(render(vocab, p))) == p diff --git a/tests/test_script.py b/tests/test_script.py index 20b5acc..73520e5 100644 --- a/tests/test_script.py +++ b/tests/test_script.py @@ -4,7 +4,7 @@ def test_bundled_vocabulary_is_sound(vocab): - assert len(vocab.parts) == 15 + assert len(vocab.parts) == 16 assert vocab.validate() == [] @@ -14,7 +14,9 @@ def test_bundled_vocabulary_is_sound(vocab): ("BODY.OTHER", Word("BODY", "OTHER")), ("body.other", Word("BODY", "OTHER")), ("SELF", Word("SELF", None)), - ("ONE.137", Word("ONE", 137)), + ("COUNT.137", Word("COUNT", 137)), + ("COUNT.0", Word("COUNT", None)), + ("COUNT", Word("COUNT", None)), ("_.12", Word(None, 12)), ("_.WATER", Word(None, "WATER")), ("_", Word()), @@ -25,20 +27,22 @@ def test_parse(vocab, text, word): assert vocab.parse(text) == word -@pytest.mark.parametrize("text", ["BODY.WIND", "ONE.512", "STAR.12", "FOO"]) +@pytest.mark.parametrize("text", ["BODY.WIND", "COUNT.512", "STAR.12", "ONE.12", "COUNT.SELF", "FOO"]) def test_parse_rejects(vocab, text): with pytest.raises(GlyphError): vocab.parse(text) -@pytest.mark.parametrize("text", ["BODY.OTHER", "SELF", "ONE.137", "_.12", "_.WATER", "_"]) +@pytest.mark.parametrize("text", ["BODY.OTHER", "SELF", "COUNT.137", "COUNT", "_.12", "_.WATER", "_"]) def test_word_str_round_trips(vocab, text): assert str(vocab.parse(text)) == text def test_gloss(vocab): assert vocab.gloss(Word("BODY", "OTHER")) == "your world" - assert vocab.gloss(Word("ONE", 137)) == "137" + assert vocab.gloss(Word("COUNT", 137)) == "137" + assert vocab.gloss(Word("COUNT", None)) == "0" + assert vocab.gloss(Word("OPEN", "COUNT")) == "how many?" assert vocab.gloss(Word("LIGHT", "BEFORE"), role="relation") == "see [past]" assert vocab.gloss(Word("ONE", None), role="relation") == "is, equals" assert vocab.gloss(Word("LIGHT", "VOICE")).endswith("(not in vocabulary)") @@ -60,7 +64,7 @@ def test_add_and_remove(vocab): def test_add_rejects_numbers_and_kindless(vocab): - for w in (Word("ONE", 5), Word(None, "WATER")): + for w in (Word("COUNT", 5), Word("COUNT", None), Word(None, "WATER")): with pytest.raises(GlyphError): vocab.add(w, "x") @@ -78,6 +82,20 @@ def test_save_and_load(vocab, tmp_path): assert again.path == path +def test_validate_rejects_count_words(vocab): + data = vocab.to_json() + data["words"].append({"kind": "COUNT", "which": 3, "gloss": "three", "domain": "x", "note": ""}) + assert any("built in" in e for e in Vocabulary.from_json(data).validate()) + + +def test_no_number_draws_like_a_word(vocab): + """The reason for COUNT: no COUNT.n looks like any vocabulary word.""" + from glyph_cli.drawing import band_rows + + words = {tuple(band_rows(vocab, list(e.word.halves), "#")) for e in vocab.entries} + assert all(tuple(band_rows(vocab, ["COUNT", n], "#")) not in words for n in range(1, 512)) + + def test_validate_catches_duplicate_shapes(vocab): data = vocab.to_json() data["parts"]["AFTER"]["shape"] = data["parts"]["BEFORE"]["shape"] From ba656d42aad8bc1b389b873e88df79989b2c3cef Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 16:56:19 +0000 Subject: [PATCH 2/2] Publish to PyPI on GitHub release A release workflow builds the package when a GitHub release tagged vX.Y.Z is published, checks the tag matches __version__, tests the built wheel, and uploads it to PyPI with Trusted Publishing (no token). Install docs now point at PyPI, the README uses absolute links so it renders on PyPI, and the sdist is limited to the project's own files. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01F8Qs752onkG5wP4feFsWjQ --- .github/workflows/release.yml | 51 +++++++++++++++++++++++++++++++++++ .gitignore | 1 + README.md | 15 ++++++++--- docs/getting-started.md | 8 +++--- docs/index.md | 2 +- pyproject.toml | 14 ++++++++++ 6 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a830055 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,51 @@ +name: Release + +# Publishing a GitHub release tagged vX.Y.Z builds the package and uploads it to PyPI +# with Trusted Publishing (OIDC), so no API token is stored anywhere. + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: "3.13" + - run: pip install build twine pytest + - name: Tag matches the package version + run: | + version=$(python -c "import re; print(re.search(r'__version__ = \"(.+)\"', open('src/glyph_cli/__init__.py').read())[1])") + test "v$version" = "$GITHUB_REF_NAME" || { echo "tag $GITHUB_REF_NAME does not match version $version"; exit 1; } + - run: python -m build + - run: twine check --strict dist/* + - name: Test the built wheel + run: | + pip install dist/*.whl + glyph validate + pytest -q # src/ is not on the path, so this tests the installed wheel + - uses: actions/upload-artifact@v7 + with: + name: dist + path: dist/ + + publish: + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/glyph-cli/ + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v8 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index 728cded..1943f79 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ build/ site/ .pytest_cache/ .ruff_cache/ +.cache/ diff --git a/README.md b/README.md index d5bfa76..9a6fd3c 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ The dictionary and toolkit for the **grid script**: how humans write down what R **Docs:** https://interimm.github.io/glyph-cli/ ```sh -uvx --from git+https://github.com/InterImm/glyph-cli glyph show BODY.OTHER STAR.TIME COUNT.137 +uvx --from glyph-cli glyph show BODY.OTHER STAR.TIME COUNT.137 ``` ```text @@ -20,7 +20,7 @@ uvx --from git+https://github.com/InterImm/glyph-cli glyph show BODY.OTHER STAR. ## Install ```sh -uv tool install git+https://github.com/InterImm/glyph-cli # or: pipx install git+https://github.com/InterImm/glyph-cli +uv tool install glyph-cli # or: pipx install glyph-cli, or: pip install glyph-cli ``` Python 3.10 or newer, no other dependencies. @@ -40,7 +40,7 @@ glyph init my-vocab.json && export GLYPH_VOCAB=$PWD/my-vocab.json glyph add BODY.STAR "a star-world" --domain Worlds ``` -A page file has one band per line, `SYMBOL: node | relation | node`; a blank line starts the next statement. See [`examples/`](examples) and the [docs](https://interimm.github.io/glyph-cli/pages/). +A page file has one band per line, `SYMBOL: node | relation | node`; a blank line starts the next statement. See [`examples/`](https://github.com/InterImm/glyph-cli/tree/main/examples) and the [docs](https://interimm.github.io/glyph-cli/pages/). ## Develop @@ -52,10 +52,17 @@ uv run python scripts/gen_docs.py # regenerate docs/vocabulary.md, docs/cli.m uv run zensical serve # docs at http://localhost:8000 ``` -The vocabulary ships in [`src/glyph_cli/data/vocab.json`](src/glyph_cli/data/vocab.json). Change it with the tool (`glyph --vocab src/glyph_cli/data/vocab.json add ...`), then run `scripts/gen_docs.py`; the tests fail if the generated docs are stale. +The vocabulary ships in [`src/glyph_cli/data/vocab.json`](https://github.com/InterImm/glyph-cli/blob/main/src/glyph_cli/data/vocab.json). Change it with the tool (`glyph --vocab src/glyph_cli/data/vocab.json add ...`), then run `scripts/gen_docs.py`; the tests fail if the generated docs are stale. Pushes to `main` publish the docs to GitHub Pages. +## Release + +1. Bump `__version__` in `src/glyph_cli/__init__.py` and merge it to `main`. +2. Publish a GitHub release tagged `vX.Y.Z` (the same version). + +The release workflow checks the tag matches the version, builds the package, tests the built wheel and uploads it to [PyPI](https://pypi.org/project/glyph-cli/) with Trusted Publishing, so no token is stored. + ## License MIT diff --git a/docs/getting-started.md b/docs/getting-started.md index a9a05da..a5b808e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -7,21 +7,23 @@ === "uv" ```sh - uv tool install git+https://github.com/InterImm/glyph-cli + uv tool install glyph-cli ``` === "pipx" ```sh - pipx install git+https://github.com/InterImm/glyph-cli + pipx install glyph-cli ``` === "pip" ```sh - pip install git+https://github.com/InterImm/glyph-cli + pip install glyph-cli ``` +The latest code on `main` installs with `uv tool install git+https://github.com/InterImm/glyph-cli`. + Check it works: ```sh diff --git a/docs/index.md b/docs/index.md index dd18e66..fe6bd1c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,7 +13,7 @@ It draws words and pages on the lattice, keeps the vocabulary honest, reads a pa No install needed if you have [uv](https://docs.astral.sh/uv/): ```sh -uvx --from git+https://github.com/InterImm/glyph-cli glyph show BODY.OTHER STAR.TIME COUNT.137 +uvx --from glyph-cli glyph show BODY.OTHER STAR.TIME COUNT.137 ``` ```text diff --git a/pyproject.toml b/pyproject.toml index 0776218..e4ac9f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,15 +12,26 @@ license-files = ["LICENSE"] authors = [{ name = "InterImm" }] requires-python = ">=3.10" dependencies = [] +keywords = ["conlang", "writing system", "knowledge graph", "science fiction", "interimm"] classifiers = [ + "Development Status :: 3 - Alpha", "Environment :: Console", + "Intended Audience :: Other Audience", + "Operating System :: OS Independent", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Text Processing :: Linguistic", ] [project.urls] Homepage = "https://github.com/InterImm/glyph-cli" Documentation = "https://interimm.github.io/glyph-cli/" +Source = "https://github.com/InterImm/glyph-cli" +Issues = "https://github.com/InterImm/glyph-cli/issues" [project.scripts] glyph = "glyph_cli.cli:main" @@ -45,3 +56,6 @@ extend-exclude = ["docs"] [tool.ruff.lint] select = ["E", "F", "W", "I", "UP", "B"] + +[tool.hatch.build.targets.sdist] +include = ["src/", "tests/", "examples/", "scripts/", "docs/", "zensical.toml", "README.md", "LICENSE"]