From 53015a5c4d44125012ebe8ea59ce98a949f3df29 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 10:17:59 +0000 Subject: [PATCH] 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"]