From 57e1fe2c2aa1c8a92fb202421c13c3e46734008c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 05:21:05 +0000 Subject: [PATCH 1/3] Grammar v8: triplet spacing and base-512 numbers Spacing is structure: 1 cell inside a word, 2 between the words of a triplet, 4 between triplets. One-part words are 3 cells wide and slots widen for the longest word. Triplets run left to right (render --per-line). Numbers are COUNT plus base-512 digits (COUNT.4.171 = 2219), with a new `glyph number` command. decode finds words from the gaps. BEFORE and AFTER are redrawn as a path with a tick at either end, since the old single dots were ONE moved one cell sideways and made gaps ambiguous; validate now checks for that. Page.lines is Page.triplets. Version 0.3.0. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QgskKyin9t8k9F6KcUj5o7 --- README.md | 4 +- docs/assets/examples/conversation.drawing.txt | 64 +- docs/assets/examples/conversation.svg | 1011 +++++++++++------ docs/assets/examples/how-many.drawing.txt | 14 +- docs/assets/examples/how-many.svg | 46 +- docs/assets/examples/numbers.drawing.txt | 3 + docs/assets/examples/numbers.svg | 153 +++ docs/assets/examples/pulsars.drawing.txt | 12 +- docs/assets/examples/pulsars.svg | 257 ++--- docs/assets/examples/question.drawing.txt | 14 +- docs/assets/examples/question.svg | 56 +- docs/assets/examples/saw-that.drawing.txt | 12 +- docs/assets/examples/saw-that.svg | 233 ++-- .../examples/saw-your-world.drawing.txt | 6 +- docs/assets/examples/saw-your-world.svg | 60 +- docs/assets/examples/shadow.drawing.txt | 12 +- docs/assets/examples/shadow.svg | 243 ++-- docs/assets/examples/third-voice.drawing.txt | 22 +- docs/assets/examples/third-voice.svg | 60 +- docs/assets/parts.txt | 6 +- docs/cli.md | 24 +- docs/getting-started.md | 20 +- docs/index.md | 3 +- docs/pages.md | 41 +- docs/python.md | 11 +- docs/script.md | 51 +- docs/vocabulary.md | 30 +- examples/conversation.txt | 2 +- examples/how-many.txt | 2 +- examples/numbers.txt | 5 + examples/pulsars.txt | 2 +- examples/saw-that.txt | 2 +- src/glyph_cli/__init__.py | 2 +- src/glyph_cli/cli.py | 35 +- src/glyph_cli/data/vocab.json | 10 +- src/glyph_cli/drawing.py | 307 +++-- src/glyph_cli/export.py | 6 +- src/glyph_cli/graph.py | 46 +- src/glyph_cli/page.py | 45 +- src/glyph_cli/script.py | 136 ++- tests/test_cli.py | 17 +- tests/test_pages.py | 138 ++- tests/test_script.py | 27 +- 43 files changed, 1891 insertions(+), 1359 deletions(-) create mode 100644 docs/assets/examples/numbers.drawing.txt create mode 100644 docs/assets/examples/numbers.svg create mode 100644 examples/numbers.txt diff --git a/README.md b/README.md index d92c052..c6d102e 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,9 @@ 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 +glyph number 2219 # COUNT.4.171: base-512 digits glyph render examples/third-voice.txt # draw a page +glyph render examples/conversation.txt --per-line 1 # one triplet per line glyph render examples/third-voice.txt --svg -o page.svg glyph graph examples/conversation.txt # read it as a knowledge graph (--json, --dot) glyph render examples/question.txt | glyph decode - # and back @@ -48,7 +50,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/`](https://github.com/InterImm/glyph-cli/tree/main/examples) and the [docs](https://interimm.github.io/glyph-cli/pages/). +A page file has one band per line, `SYMBOL: node | relation | node`; a block of bands is one triplet, and a blank line starts the next. See [`examples/`](https://github.com/InterImm/glyph-cli/tree/main/examples) and the [docs](https://interimm.github.io/glyph-cli/pages/). ## Develop diff --git a/docs/assets/examples/conversation.drawing.txt b/docs/assets/examples/conversation.drawing.txt index a9deeda..6db235d 100644 --- a/docs/assets/examples/conversation.drawing.txt +++ b/docs/assets/examples/conversation.drawing.txt @@ -1,39 +1,25 @@ -+++.+++.........+++.+.+ -+++.+....+......+++.... -+++.+++.........+++.+.+ -....................... -....................××× -....................... -....................××× -....................... -....................*.* -....................... -....................*.* -....................... -....................... -....................... -+++.....+.+.....+++.+++ -+.+......+..+...+++.+.. -+++.....+.+.....+++.+++ -....................... -....................... -....................... -....................... -....................... -....................... -....................... -....................... -....................... -....................... -....................... -+++.....+.............. -+.+.....++...+...+..... -+++.....+++............ -....................... -....................... -....................... -....................... -....................... -....................... -....................... -....................... ++++.+++.......+++.+.+....+++..+.+.+....+++.+++ ++++.+.....+...+++........+.+...+..+++..+++.+.. ++++.+++.......+++.+.+....+++..+.+.+....+++.+++ +.............................................. +..................×××......................... +.............................................. +..................×××......................... +.............................................. +..................*.*......................... +.............................................. +..................*.*......................... +.............................................. +.............................................. +.............................................. ++++..+........................................ ++.+..++...+....+.............................. ++++..+++...................................... +.............................................. +.............................................. +.............................................. +.............................................. +.............................................. +.............................................. +.............................................. +.............................................. diff --git a/docs/assets/examples/conversation.svg b/docs/assets/examples/conversation.svg index f314e53..8f4ab90 100644 --- a/docs/assets/examples/conversation.svg +++ b/docs/assets/examples/conversation.svg @@ -1,4 +1,4 @@ - + + + @@ -14,15 +14,38 @@ - - ++ ++ + -+ + + + -+ + + + ++ ++ ++ + + ++ + ++ + ++ + + + + ++ ++ ++ + ++ ++ ++ + + + @@ -32,20 +55,43 @@ -+ - + ++ - - ++ ++ + -+ -+ + + + + ++ + ++ + + + ++ + + ++ ++ ++ + + ++ ++ ++ + ++ + + + + + @@ -60,15 +106,38 @@ - - ++ ++ + -+ + + + -+ + + + ++ ++ ++ + + ++ + ++ + ++ + + + + ++ ++ ++ + ++ ++ ++ @@ -92,6 +161,29 @@ + + + + + + + + + + + + + + + + + + + + + + + @@ -110,11 +202,34 @@ - - +× +× × -× -× + + + + + + + + + + + + + + + + + + + + + + + + + @@ -138,6 +253,29 @@ + + + + + + + + + + + + + + + + + + + + + + + @@ -156,11 +294,34 @@ - - +× +× × -× -× + + + + + + + + + + + + + + + + + + + + + + + + + @@ -184,6 +345,29 @@ + + + + + + + + + + + + + + + + + + + + + + + @@ -202,11 +386,34 @@ - +* * -* + + + + + + + + + + + + + + + + + + + + + + + + @@ -230,6 +437,29 @@ + + + + + + + + + + + + + + + + + + + + + + + @@ -248,11 +478,34 @@ - +* * -* + + + + + + + + + + + + + + + + + + + + + + + + @@ -276,6 +529,29 @@ + + + + + + + + + + + + + + + + + + + + + + + @@ -299,6 +575,29 @@ + + + + + + + + + + + + + + + + + + + + + + + @@ -322,75 +621,167 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + - ++ -+ + -+ + -+ -+ -+ + + + -+ -+ -+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - ++ ++ -+ - + ++ -+ + - -+ -+ -+ ++ + + + -+ + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - -+ ++ ++ ++ + -+ + -+ -+ -+ + + + -+ -+ -+ + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -414,6 +805,29 @@ + + + + + + + + + + + + + + + + + + + + + + + @@ -437,6 +851,29 @@ + + + + + + + + + + + + + + + + + + + + + + + @@ -460,6 +897,29 @@ + + + + + + + + + + + + + + + + + + + + + + + @@ -483,6 +943,29 @@ + + + + + + + + + + + + + + + + + + + + + + + @@ -506,6 +989,29 @@ + + + + + + + + + + + + + + + + + + + + + + + @@ -529,6 +1035,29 @@ + + + + + + + + + + + + + + + + + + + + + + + @@ -552,6 +1081,29 @@ + + + + + + + + + + + + + + + + + + + + + + + @@ -575,326 +1127,27 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -+ -+ -+ - - - - - -+ - - - - - - - - - - - - - - -+ - -+ - - - - - -+ -+ - - - -+ - - - -+ - - - - - -+ -+ -+ - - - - - -+ -+ -+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/examples/how-many.drawing.txt b/docs/assets/examples/how-many.drawing.txt index 3453ae2..70d3da1 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 d4a0ba5..6742f18 100644 --- a/docs/assets/examples/how-many.svg +++ b/docs/assets/examples/how-many.svg @@ -1,4 +1,4 @@ - + + @@ -15,14 +15,12 @@ - ++ -+ + - - + + + @@ -32,20 +30,18 @@ + -+ - + ++ - ++ + -+ + - - + @@ -61,14 +57,12 @@ - ++ -+ - - + ++ ++ + -+ -+ @@ -90,8 +84,6 @@ - - @@ -113,8 +105,6 @@ - - @@ -135,9 +125,7 @@ - - -× +× @@ -152,13 +140,11 @@ - - +× +× × -× + × -× - - + diff --git a/docs/assets/examples/numbers.drawing.txt b/docs/assets/examples/numbers.drawing.txt new file mode 100644 index 0000000..fe093a2 --- /dev/null +++ b/docs/assets/examples/numbers.drawing.txt @@ -0,0 +1,3 @@ ++.+....................+......+..+.+.............. ++.+..+....+...........+.+....+++.+.+...+.........+ ++.+...........+++.+....++.....+..+.+.......+++.+.. diff --git a/docs/assets/examples/numbers.svg b/docs/assets/examples/numbers.svg new file mode 100644 index 0000000..ff6e1bc --- /dev/null +++ b/docs/assets/examples/numbers.svg @@ -0,0 +1,153 @@ + + ++ + ++ + + + + + + + + + + + + + + + + + + + + ++ + + + + + + ++ + + ++ + ++ + + + + + + + + + + + + + + ++ + ++ + + ++ + + + + ++ + + + + + + + + + + + ++ + ++ + + + + ++ ++ ++ + ++ + ++ + + + ++ + + + + + + + + + ++ ++ + ++ + + + + + + + + + + + ++ ++ ++ + ++ + + + + ++ ++ + + + + + ++ + + ++ + ++ + + + + + + + ++ ++ ++ + ++ + + + diff --git a/docs/assets/examples/pulsars.drawing.txt b/docs/assets/examples/pulsars.drawing.txt index 6eccdb8..1ebd4f0 100644 --- a/docs/assets/examples/pulsars.drawing.txt +++ b/docs/assets/examples/pulsars.drawing.txt @@ -1,9 +1,3 @@ -+++.....+.+......+..+.+ -+.+......+...+..+++.+.+ -+++.....+.+......+..+.+ -....................... -....................... -....................... -.+..+.+................ -+++.+.+..+............+ -.+..+.+.........+++.+.. ++++..+.+.......+..+.+.....+..+.+.............. ++.+...+...+...+++.+.+....+++.+.+...+.........+ ++++..+.+.......+..+.+.....+..+.+.......+++.+.. diff --git a/docs/assets/examples/pulsars.svg b/docs/assets/examples/pulsars.svg index b609a71..6452acd 100644 --- a/docs/assets/examples/pulsars.svg +++ b/docs/assets/examples/pulsars.svg @@ -1,210 +1,141 @@ - + + + + - ++ - -+ ++ + -+ + - ++ -+ - + ++ + -+ + + + + ++ + + ++ + ++ + + + + + + + + + + + + + + + + - ++ -+ - + ++ -+ - - + ++ ++ + -+ + + + -+ + + + ++ ++ ++ + ++ + ++ + + + ++ + + + + + + + + + ++ + + + - ++ - -+ ++ + -+ + - ++ -+ - + ++ + -+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -+ - - -+ - -+ - - - - - - - - - - - - - - - - -+ -+ -+ - -+ - -+ - - -+ - - - - - - - - - - - - -+ - -+ - - -+ - -+ - - - - - - - - - -+ -+ -+ - -+ - - + + + + ++ + + ++ + ++ + + + + + + + ++ ++ ++ + ++ + + diff --git a/docs/assets/examples/question.drawing.txt b/docs/assets/examples/question.drawing.txt index 1023636..28a01f8 100644 --- a/docs/assets/examples/question.drawing.txt +++ b/docs/assets/examples/question.drawing.txt @@ -1,7 +1,7 @@ -+++.+++..........+..... -+++.+....+......+.+.... -+++.+++..........+..... -....................... -................×××.××× -................×××.... -................×××.××× ++++.+++........+..... ++++.+.....+...+.+.... ++++.+++........+..... +..................... +..............×××.××× +..............×××.... +..............×××.××× diff --git a/docs/assets/examples/question.svg b/docs/assets/examples/question.svg index 8d72c2c..b365052 100644 --- a/docs/assets/examples/question.svg +++ b/docs/assets/examples/question.svg @@ -1,4 +1,4 @@ - + + + @@ -15,14 +15,12 @@ - ++ -+ + - - + + + @@ -32,20 +30,18 @@ -+ - + ++ - ++ + -+ + - - + + + @@ -61,14 +57,12 @@ - ++ -+ + - - @@ -90,8 +84,6 @@ - - @@ -106,15 +98,13 @@ - - +× +× × -× + × - +× × -× -× @@ -129,15 +119,13 @@ - - +× +× × -× -× + + - - @@ -152,13 +140,11 @@ - - +× +× × -× + × - +× × -× -× diff --git a/docs/assets/examples/saw-that.drawing.txt b/docs/assets/examples/saw-that.drawing.txt index 93cff36..55c52eb 100644 --- a/docs/assets/examples/saw-that.drawing.txt +++ b/docs/assets/examples/saw-that.drawing.txt @@ -1,9 +1,3 @@ -+++.+++.........+++.+++ -+++.+....+......+++.... -+++.+++.........+++.+++ -....................... -....................... -....................... -+++.....+.+............ -+.+......+..+....+..... -+++.....+.+............ ++++.+++.......+++.+++....+++..+.+.+....... ++++.+.....+...+++........+.+...+..+++...+. ++++.+++.......+++.+++....+++..+.+.+....... diff --git a/docs/assets/examples/saw-that.svg b/docs/assets/examples/saw-that.svg index 380e7c0..295fdc1 100644 --- a/docs/assets/examples/saw-that.svg +++ b/docs/assets/examples/saw-that.svg @@ -1,4 +1,4 @@ - + + + @@ -14,15 +14,34 @@ - - ++ ++ + -+ + + - ++ + -+ -+ + + + + ++ ++ ++ + + ++ + ++ + ++ + + + + + + + + + + @@ -32,20 +51,39 @@ -+ - + ++ - - ++ ++ + -+ -+ + + + + ++ + ++ + + + ++ + + ++ ++ ++ + + + ++ + + + + @@ -60,151 +98,32 @@ - - ++ ++ + -+ + + - ++ + -+ -+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -+ -+ -+ - - - - - -+ - -+ - - - - - - - - - - - - -+ - -+ - - - - - - -+ - - -+ - - - - -+ - - - - - -+ -+ -+ - - - - - -+ - -+ - - - - - - - - - - - - + + + + ++ ++ ++ + + ++ + ++ + ++ + + + + + + + diff --git a/docs/assets/examples/saw-your-world.drawing.txt b/docs/assets/examples/saw-your-world.drawing.txt index 410d8b3..4792fe8 100644 --- a/docs/assets/examples/saw-your-world.drawing.txt +++ b/docs/assets/examples/saw-your-world.drawing.txt @@ -1,3 +1,3 @@ -+++.....+.+.....+++.+++ -+.+......+..+...+++.+.. -+++.....+.+.....+++.+++ ++++..+.+.+....+++.+++ ++.+...+..+++..+++.+.. ++++..+.+.+....+++.+++ diff --git a/docs/assets/examples/saw-your-world.svg b/docs/assets/examples/saw-your-world.svg index ff15eb7..2e261d1 100644 --- a/docs/assets/examples/saw-your-world.svg +++ b/docs/assets/examples/saw-your-world.svg @@ -1,72 +1,66 @@ - + + + + - ++ - -+ - -+ ++ + ++ + - - ++ ++ + -+ + + - ++ + -+ -+ + + - ++ + - - -+ ++ ++ + - - ++ ++ + -+ + + -+ - - + + + + - ++ - -+ - -+ ++ + ++ + - - ++ ++ + -+ + + - ++ + -+ -+ diff --git a/docs/assets/examples/shadow.drawing.txt b/docs/assets/examples/shadow.drawing.txt index 0d00676..0575bfb 100644 --- a/docs/assets/examples/shadow.drawing.txt +++ b/docs/assets/examples/shadow.drawing.txt @@ -1,9 +1,3 @@ -+++.....+.+.....+++.... -+.+......+..+...+...... -+++.....+.+.....+++.... -....................... -....................... -....................... -+++.....+.+.+...+++.... -+.+......+...+..+...... -+++.....+.+...+.+++.... ++++..+.+.+....+++....+++..+.+.+....+++ ++.+...+..+++..+......+.+...+...+...+.. ++++..+.+.+....+++....+++..+.+...+..+++ diff --git a/docs/assets/examples/shadow.svg b/docs/assets/examples/shadow.svg index f0ad070..c3a59ab 100644 --- a/docs/assets/examples/shadow.svg +++ b/docs/assets/examples/shadow.svg @@ -1,210 +1,117 @@ - + + + + - ++ - -+ - -+ ++ + ++ + - - ++ ++ + -+ -+ + + - - ++ ++ ++ + + ++ + ++ + ++ + + + + ++ ++ ++ + + - ++ + - - -+ ++ ++ + - ++ -+ + - ++ ++ + + + ++ + + + ++ + + + ++ + + + + + - ++ - -+ - -+ ++ + ++ + - - ++ ++ + -+ -+ + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -+ -+ -+ - - - - - -+ - -+ - -+ - - - -+ -+ -+ - - - - -+ - -+ - - - - - - -+ - - - -+ - - -+ - - - - - - -+ -+ -+ - - - - - -+ - -+ - - - -+ - -+ -+ -+ - - - - ++ ++ ++ + + ++ + ++ + + + ++ + + ++ ++ ++ diff --git a/docs/assets/examples/third-voice.drawing.txt b/docs/assets/examples/third-voice.drawing.txt index 613063c..d8133c1 100644 --- a/docs/assets/examples/third-voice.drawing.txt +++ b/docs/assets/examples/third-voice.drawing.txt @@ -1,11 +1,11 @@ -+++.+++.........+++.+.+ -+++.+....+......+++.... -+++.+++.........+++.+.+ -....................... -....................××× -....................... -....................××× -....................... -....................*.* -....................... -....................*.* ++++.+++.......+++.+.+ ++++.+.....+...+++.... ++++.+++.......+++.+.+ +..................... +..................××× +..................... +..................××× +..................... +..................*.* +..................... +..................*.* diff --git a/docs/assets/examples/third-voice.svg b/docs/assets/examples/third-voice.svg index bdb4607..bb73c8b 100644 --- a/docs/assets/examples/third-voice.svg +++ b/docs/assets/examples/third-voice.svg @@ -1,4 +1,4 @@ - + + + @@ -14,15 +14,13 @@ - - ++ ++ + -+ + + + - -+ + + + @@ -32,20 +30,18 @@ -+ - + ++ - - ++ ++ + -+ -+ + + - - + + + @@ -60,15 +56,13 @@ - - ++ ++ + -+ + + + - -+ @@ -90,8 +84,6 @@ - - @@ -110,11 +102,9 @@ - - +× +× × -× -× @@ -136,8 +126,6 @@ - - @@ -156,11 +144,9 @@ - - +× +× × -× -× @@ -182,8 +168,6 @@ - - @@ -202,11 +186,9 @@ - +* * - -* @@ -228,8 +210,6 @@ - - @@ -248,9 +228,7 @@ - +* * - -* diff --git a/docs/assets/parts.txt b/docs/assets/parts.txt index bf22290..d672735 100644 --- a/docs/assets/parts.txt +++ b/docs/assets/parts.txt @@ -14,6 +14,6 @@ VOICE PATH NOT ONE ### ... ..# ... BEFORE AFTER OPEN COUNT -... ... .#. ... -#.. ..# #.# ... -... ... .#. ### +#.. ..# .#. ... +### ### #.# ... +#.. ..# .#. ### diff --git a/docs/cli.md b/docs/cli.md index 631a963..00f603a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -9,8 +9,10 @@ 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 COUNT. (0-511); COUNT alone is zero. "_" is an empty position or an empty word. +A number is COUNT and base-512 digits: COUNT.137, COUNT.4.171 = 2219 (COUNT.2219 also works); +COUNT alone is zero. "_" is an empty position or an empty word. Pages are text files, one band per line: "SYMBOL: node | relation | node". +A block of bands is one triplet; a blank line starts the next. positional arguments: COMMAND @@ -19,6 +21,7 @@ positional arguments: check is a word in the vocabulary? (exit 1 if not) find search meanings and notes show draw words + number write a number in base-512 digits and draw it add add a word to an editable vocabulary remove remove a word from an editable vocabulary validate check the vocabulary (exit 1 on problems) @@ -110,6 +113,22 @@ options: --symbol SYMBOL the party's symbol (default +) ``` +## `glyph number` + +```text +$ glyph number --help +usage: glyph number [-h] [--symbol SYMBOL] n + +write a number in base-512 digits and draw it + +positional arguments: + n a whole number, 0 or more + +options: + -h, --help show this help message and exit + --symbol SYMBOL the party's symbol (default +) +``` + ## `glyph add` ```text @@ -160,7 +179,7 @@ options: ```text $ glyph render --help -usage: glyph render [-h] [--svg] [--cell CELL] [--no-grid] [-o OUTPUT] file +usage: glyph render [-h] [--svg] [--per-line N] [--cell CELL] [--no-grid] [-o OUTPUT] file draw a page from page source @@ -170,6 +189,7 @@ positional arguments: options: -h, --help show this help message and exit --svg draw SVG pixels instead of text + --per-line N triplets per drawn line (default 2) --cell CELL SVG cell size in pixels (default 12) --no-grid SVG: leave empty cells undrawn -o, --output OUTPUT write to a file instead of stdout diff --git a/docs/getting-started.md b/docs/getting-started.md index b25dbaa..b3dfaea 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -64,7 +64,7 @@ Each part means a thing when it is a node (`BODY` is *world, matter*) and an act ## Look words up -A word is two parts side by side: a **kind** narrowed by a **which**, written `KIND.WHICH`. +A word is two parts side by side, one empty cell apart: a **kind** narrowed by a **which**, written `KIND.WHICH`. A single part is a word too. ```sh glyph find world # search meanings @@ -73,11 +73,15 @@ glyph check BODY.STAR # no, and its literal reading glyph show BODY.OTHER BODY.SELF --symbol × ``` -Numbers are built in: `COUNT.137` is 137, its which drawn as nine bits. `COUNT` alone is zero. +Numbers are built in: `COUNT.137` is 137, its digit drawn as nine bits. Bigger numbers take more digits in base 512, most significant first: `COUNT.4.171` is 4 × 512 + 171 = 2219. `COUNT` alone is zero. + +```sh +glyph number 2219 # 2219 = COUNT.4.171, and its drawing +``` ## Write a page -A page is a text file. Each line of the file is one band: the party's symbol, then three slots, `node | relation | node`. Save this as `hello.txt`: +A page is a text file. Each line of the file is one band: the party's symbol, then a triplet, `node | relation | node`. Save this as `hello.txt`: ```text +: SELF | LIGHT.BEFORE | BODY.OTHER @@ -90,9 +94,9 @@ glyph render hello.txt ``` ```text -+++.....+.+.....+++.+++ -+.+......+..+...+++.+.. -+++.....+.+.....+++.+++ ++++..+.+.+....+++.+++ ++.+...+..+++..+++.+.. ++++..+.+.+....+++.+++ ``` And read it: @@ -106,7 +110,7 @@ Edges: 1. (+) we --see [past]--> your world ``` -"We saw your world." +"We saw your world." The gaps carry the structure: 1 empty cell inside a word, 2 between the words of a triplet, and 4 between triplets. A blank line in the file starts the next triplet, and `glyph render` puts two triplets on each drawn line (change it with `--per-line`). ## Reply @@ -135,6 +139,8 @@ The translating machine works the other way too: give it a drawing and it gives glyph render conversation.txt | glyph decode - ``` +It finds the words from the gaps alone. + ## Grow the vocabulary The bundled vocabulary is read-only. Make your own copy, point `glyph` at it, and add words: diff --git a/docs/index.md b/docs/index.md index 5aa0832..c92257e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,7 +2,7 @@ **glyph** is the dictionary and toolkit for the *grid script*: the way humans write down what Ross 128 b sends, in InterImm's [*The Contact Era*](https://interstellar.interimm.org/). -It draws words and pages on the lattice, keeps the vocabulary honest, reads a page as a knowledge graph, and reads a drawing back into text. +It draws words and pages on the grid, keeps the vocabulary honest, reads a page as a knowledge graph, and reads a drawing back into text. ![A three-voice page: their statement in cyan, our reply in yellow, a third party in violet](assets/examples/third-voice.svg) @@ -34,6 +34,7 @@ uvx --from glyph-cli glyph show BODY.OTHER STAR.TIME COUNT.137 | `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 number` | Write a number in base-512 digits | | `glyph render` | Draw a whole page, as text or as SVG pixels | | `glyph graph` | Read a page as a knowledge graph (text, JSON or Graphviz) | | `glyph decode` | Read a drawing back into page source | diff --git a/docs/pages.md b/docs/pages.md index 09c6d0e..e875106 100644 --- a/docs/pages.md +++ b/docs/pages.md @@ -5,10 +5,10 @@ A page is a plain text file that `glyph render`, `glyph graph` and `glyph decode ## Format - One **band** per line: `SYMBOL: node | relation | node`. -- 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. +- A block of bands is one **triplet**. Its first band is the **statement**, one edge of the graph. Bands under it are **other voices** replying, part by part. +- A **blank line** starts the next triplet. - `#` starts a comment. -- 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 word is `KIND.WHICH`, `KIND`, `_.WHICH`, or `_` for nothing. Numbers are `COUNT` and base-512 digits, most significant first: `COUNT.137`, `COUNT.4.171` (2219). `COUNT.2219` is accepted and written as `COUNT.4.171`. `COUNT` alone is zero. In a reply under a number, `_.12` answers just the digits. - A symbol is any single character except a space, `.`, `#`, `|`, `:` and `_`. By convention `+` is Ross 128 b and `×` is Earth. ```text @@ -16,7 +16,12 @@ A page is a plain text file that `glyph render`, `glyph graph` and `glyph decode ×: _ | _ | _.WATER # our reply: water instead of air ``` -Every line of a page is drawn with as many bands as the page has voices, so all lines are the same height. To leave room for an answer, add a band with nothing in it, such as `×: _ | _ | _`. +## Drawing + +- Triplets run left to right, two to a drawn line by default (`glyph render --per-line N`). Lines are three empty rows apart. +- Inside a triplet, each slot is as wide as the longest word any band puts there: a one-part word is 3 cells, a pair 7, a number 4 more per digit. +- Every triplet is drawn with as many bands as the page has voices, so all lines are the same height. To leave room for an answer, add a band with nothing in it, such as `×: _ | _ | _`. +- A 0 digit is a blank position. A number ending in 0 digits as the last word of a drawn line can't be read back, because nothing shows where it ends; `glyph render` warns about it. Put such a number in the subject slot, or another triplet after it. ## Examples @@ -70,7 +75,7 @@ glyph graph examples/shadow.txt ### “Your world is water. We saw that.” -A lone `ONE` in a node slot is *that*, the line above. +A lone `ONE` in a node slot is *that*, the triplet before. === "Pixels" @@ -94,7 +99,7 @@ glyph graph examples/saw-that.txt ### “We see pulsars. The pulsars are twelve.” -*Pulsar* on two lines is one node with two edges. +*Pulsar* in two triplets is one node with two edges. === "Pixels" @@ -164,6 +169,30 @@ We answer 12. glyph graph examples/how-many.txt ``` +### “Now is 2219.” + +A four-digit year takes two base-512 digits: `COUNT.4.171`. + +=== "Pixels" + + ![“Now is 2219.”](assets/examples/numbers.svg) + +=== "Text" + + ```text + --8<-- "docs/assets/examples/numbers.drawing.txt" + ``` + +=== "Source" + + ```text + --8<-- "examples/numbers.txt" + ``` + +```sh +glyph graph examples/numbers.txt +``` + ### A third voice It answers our *water* with *air*: it sides with them, against us, about our own world. diff --git a/docs/python.md b/docs/python.md index d670a5e..1c5a379 100644 --- a/docs/python.md +++ b/docs/python.md @@ -12,13 +12,14 @@ vocab = Vocabulary.load() # the bundled vocabulary; or Vocabulary.loa word = vocab.parse("BODY.OTHER") # Word(kind='BODY', which='OTHER') vocab.gloss(word) # 'your world' +vocab.parse("COUNT.2219") # Word(kind='COUNT', which=(4, 171)), drawn as COUNT.4.171 page = parse_page(""" +: BODY.OTHER | ONE | BODY.AIR ×: _ | _ | _.WATER """, vocab) -print("\n".join(render(vocab, page))) +print("\n".join(render(vocab, page))) # per_line=2 triplets to a drawn line svg = render_svg(vocab, page, cell=16) graph = read_graph(vocab, page) @@ -32,10 +33,12 @@ assert decode(vocab, "\n".join(render(vocab, page))) == page | Module | Holds | |---|---| -| `glyph_cli.script` | `Part`, `Word`, `Entry`, `Vocabulary` (load, save, parse, gloss, add, remove, validate) and `GlyphError` | -| `glyph_cli.page` | `Band`, `Line`, `Page`, `parse_page`, `format_page` | -| `glyph_cli.drawing` | `render`, `render_svg`, `decode`, `draw_words` | +| `glyph_cli.script` | `Part`, `Word`, `Entry`, `Vocabulary` (load, save, parse, gloss, add, remove, validate), `to_digits`, `from_digits` and `GlyphError` | +| `glyph_cli.page` | `Band`, `Triplet`, `Page`, `parse_page`, `format_page` | +| `glyph_cli.drawing` | `render`, `render_svg`, `decode`, `draw_words`, `hidden_zeros` | | `glyph_cli.graph` | `read_graph`, `format_graph`, `to_dot` and the `Graph`, `Edge`, `Reply`, `Response` records | | `glyph_cli.export` | `vocabulary_markdown` | +A `Word` lists its lattice positions in `word.positions` (`["COUNT", 4, 171]`), and a number's value is `word.value`. In 0.3 `Page.lines` became `Page.triplets` and `Line` became `Triplet`. + Errors the user can fix (a bad word, a bad page, a bad vocabulary file) raise `GlyphError` with a message that names the problem and, for pages, the line. diff --git a/docs/script.md b/docs/script.md index ebfd95d..a0ccfe8 100644 --- a/docs/script.md +++ b/docs/script.md @@ -1,14 +1,14 @@ # The script -This is the grammar `glyph` implements: version 7 of the grid script, where **a page is a knowledge graph**. In the story it is our transcription of their data, not a script anyone has seen them use. +This is the grammar `glyph` implements: version 8 of the grid script, where **a text is a knowledge graph** and **spacing is structure**. In the story it is our transcription of their data, not a script anyone has seen them use. ## The five rules -1. **Lattice.** Every mark is a whole 3×3 part in a lattice position. Positions are one empty cell apart, so a line is **6 positions wide** (23 cells). -2. **Words are pairs.** A word takes two neighbouring positions: **kind** on the left, **which** on the right. A one-part word leaves the which empty. -3. **Every line is one edge.** Positions 1–2 hold a **node**, 3–4 a **relation**, 5–6 another **node**. The same word on two lines is the same node. Lines are three empty rows apart. -4. **Stacking means another voice.** Only a new party adds a band to a line: a 3-row strip under the band above, one empty row between. Each party has one symbol. -5. **A part answers the part above it.** Read every part in a lower band against the nearest drawn part above it in the same position: +1. **Spacing is structure.** Every mark is a whole 3×3 part. The gaps between parts say how they group: **1 empty cell** between the parts of a word, **2** between the words of a triplet, **4** between triplets. Lines are **3 empty rows** apart. +2. **Words are pairs.** A word is a **kind** narrowed by a **which**, `KIND.WHICH`. A one-part word is just its kind, 3 cells wide. Only numbers have more parts. +3. **Every triplet is one edge.** A text is a run of triplets, `(node, relation, node)`, read left to right. The same word in two triplets is the same node, and a lone `ONE` in a node slot means **that**: the triplet before. +4. **Stacking means another voice.** Only a new party adds a band to a triplet: a 3-row strip under the band above, one empty row between. Each party has one symbol. +5. **A part answers the part above it.** Read every part in a lower band against the nearest drawn part above it, in the same position of the same slot: | Below | Means | |---|---| @@ -18,6 +18,16 @@ This is the grammar `glyph` implements: version 7 of the grid script, where **a | A part under an empty position | And also | | Nothing | No comment | +Two triplets on one line, "We saw your world. We will say that.": + +```text ++++..+.+.+....+++.+++....+++..+.....+..... ++.+...+..+++..+++.+......+.+..++..+++...+. ++++..+.+.+....+++.+++....+++..+++...+..... +``` + +`SELF`, 2 cells, `LIGHT.BEFORE` (1 cell inside), 2 cells, `BODY.OTHER`; then 4 cells and the next triplet. + ## Parts ```text @@ -59,7 +69,16 @@ A relation is one part (the action) plus a which that marks time or polarity: ## Numbers -`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`: +`COUNT` followed by **base-512 digits**, most significant first. Each digit is a part's worth of nine bits, 256 down to 1, left to right and top to bottom, and the digits are one cell apart like the parts of any word. `COUNT` alone is zero. + +| Number | Written | Because | +|---|---|---| +| 137 | `COUNT.137` | one digit | +| 2219 | `COUNT.4.171` | 4 × 512 + 171 | +| 70,491 | `COUNT.137.347` | 137 × 512 + 347 | +| 3,200,000 | `COUNT.12.106.0` | 12 × 512² + 106 × 512 + 0 | + +`COUNT.137`: ```text .....+. @@ -67,19 +86,27 @@ A relation is one part (the action) plus a which that marks time or polarity: +++...+ ``` -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*.) +`glyph number N` writes any number this way, and `COUNT.2219` in a page is accepted and written as `COUNT.4.171`. + +COUNT is the only kind whose positions are read as bits, and it never takes a part, so a number can never be drawn like a word. (Under v6, numbers were `ONE` plus bits, and 495 was drawn exactly like `ONE.SELF`, *one of us*.) + +A 0 digit is a blank position. Inside a line that is fine, because the next gap shows where the number ends, but a number ending in 0 digits as the last word of a line can't be told from a shorter one. `glyph render` warns when that happens. ## "That" and questions -- `ONE` alone in a node slot means **that**: the statement on the line above. +- `ONE` alone in a node slot means **that**: the triplet before. - `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. +## Why no part is another part moved sideways + +Because gaps carry the structure, a part with empty edge columns could make a 1-cell gap look like a 2-cell gap. `ONE` is a single dot in the middle, so `BEFORE` and `AFTER` are drawn as a path with a tick at its start and its end, not as dots at the edges: otherwise `BODY.AFTER | OTHER` (*will have, you*) and `BODY | ONE.OTHER` (*have, one of you*) would be the same drawing. `glyph validate` checks that no part moved one cell sideways is another part. + ## How the machine reads a page -1. Cut the page into 3×3 positions. Anything off the lattice is a picture, not text. -2. Split it into lines at the three-row gaps, and count the bands: that is the number of voices. -3. Read band 1 of each line as an edge. Same word, same node. A lone `ONE` points at the line above. +1. Split the drawing into lines at the three-row gaps, and count the bands: that is the number of voices. +2. Split each line into triplets, words and parts from the 1, 2 and 4 cell gaps. Anything that won't split into whole parts is a picture, not text. Number digits can have empty edges of their own, so when more than one split fits, the machine keeps the one that reads best against the vocabulary. +3. Read the first band of each triplet as an edge. Same word, same node. A lone `ONE` points at the triplet before. 4. Apply rule 5 to every part in a lower band, and attach the result to that node or edge with the voice that drew it. `glyph decode` does steps 1 and 2; `glyph graph` does 3 and 4. diff --git a/docs/vocabulary.md b/docs/vocabulary.md index 7d0fd1f..14e78fb 100644 --- a/docs/vocabulary.md +++ b/docs/vocabulary.md @@ -1,8 +1,8 @@ # 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 (`COUNT.0` to `COUNT.511`) are built in, so they -are not listed. Status: **preliminary**, 55 words. +on its own. Any part can also be a **relation**, and numbers (`COUNT` and base-512 digits, such as `COUNT.137` +or `COUNT.4.171` for 2219) are built in, so they are not listed. Status: **preliminary**, 55 words. @@ -10,8 +10,8 @@ are not listed. Status: **preliminary**, 55 words. | Word | Parts | Meaning | Note | |---|---|---|---| -| `+++....`
`+.+....`
`+++....` | SELF | we | the one drawing | -| `+++....`
`+......`
`+++....` | OTHER | you | the one reading | +| `+++`
`+.+`
`+++` | SELF | we | the one drawing | +| `+++`
`+..`
`+++` | OTHER | you | the one reading | | `+++.+++`
`+...+..`
`+++.+++` | OTHER.OTHER | a third party | "another you" | | `....+++`
`.+..+.+`
`....+++` | ONE.SELF | one of us | an individual | | `....+++`
`.+..+..`
`....+++` | ONE.OTHER | one of you | a human, to them | @@ -22,7 +22,7 @@ are not listed. Status: **preliminary**, 55 words. | Word | Parts | Meaning | Note | |---|---|---|---| -| `.+.....`
`+++....`
`.+.....` | STAR | star | | +| `.+.`
`+++`
`.+.` | STAR | star | | | `.+..+++`
`+++.+.+`
`.+..+++` | STAR.SELF | our star | Ross 128 | | `.+..+++`
`+++.+..`
`.+..+++` | STAR.OTHER | your star | the Sun | | `.+..+.+`
`+++.+.+`
`.+..+.+` | STAR.TIME | pulsar | a timing star | @@ -35,7 +35,7 @@ are not listed. Status: **preliminary**, 55 words. | Word | Parts | Meaning | Note | |---|---|---|---| -| `+++....`
`+++....`
`+++....` | BODY | world, matter | | +| `+++`
`+++`
`+++` | BODY | world, matter | | | `+++.+++`
`+++.+.+`
`+++.+++` | BODY.SELF | our world | Ross 128 b | | `+++.+++`
`+++.+..`
`+++.+++` | BODY.OTHER | your world | Earth | | `+++.+.+`
`+++....`
`+++.+.+` | BODY.AIR | air-world | | @@ -47,13 +47,13 @@ are not listed. Status: **preliminary**, 55 words. | Word | Parts | Meaning | Note | |---|---|---|---| -| `+.+....`
`.+.....`
`+.+....` | LIGHT | light | | +| `+.+`
`.+.`
`+.+` | LIGHT | light | | | `+.+.+..`
`.+...+.`
`+.+...+` | LIGHT.NOT | dark, the night side | | | `....+.+`
`+++..+.`
`....+.+` | PATH.LIGHT | terminator | the way between light and dark | -| `+.+....`
`.......`
`+.+....` | AIR | air | | +| `+.+`
`...`
`+.+` | AIR | air | | | `+.+....`
`....+++`
`+.+....` | AIR.PATH | wind | air going | | `+.+.+++`
`.......`
`+.+.+++` | AIR.WATER | cloud | watery air | -| `+++....`
`.......`
`+++....` | WATER | water | | +| `+++`
`...`
`+++` | WATER | water | | | `+++....`
`....+++`
`+++....` | WATER.PATH | river | water going | | `+++.+++`
`....+++`
`+++.+++` | WATER.BODY | ice | solid water | | `+++.+..`
`.....+.`
`+++...+` | WATER.NOT | dry land | | @@ -62,10 +62,10 @@ are not listed. Status: **preliminary**, 55 words. | Word | Parts | Meaning | Note | |---|---|---|---| -| `+.+....`
`+.+....`
`+.+....` | TIME | time | | -| `+.+....`
`+.+.+..`
`+.+....` | TIME.BEFORE | the past, before | | +| `+.+`
`+.+`
`+.+` | TIME | time | | +| `+.+.+..`
`+.+.+++`
`+.+.+..` | TIME.BEFORE | the past, before | | | `+.+....`
`+.+..+.`
`+.+....` | TIME.ONE | now | | -| `+.+....`
`+.+...+`
`+.+....` | TIME.AFTER | the future, after | | +| `+.+...+`
`+.+.+++`
`+.+...+` | TIME.AFTER | the future, after | | | `+.+....`
`+.+.+++`
`+.+....` | TIME.PATH | always | time going on | | `+.+.+..`
`+.+..+.`
`+.+...+` | TIME.NOT | never | | | `+.+.+++`
`+.+.+++`
`+.+.+++` | TIME.BODY | year | one orbit | @@ -76,7 +76,7 @@ are not listed. Status: **preliminary**, 55 words. | Word | Parts | Meaning | Note | |---|---|---|---| -| `+......`
`++.....`
`+++....` | VOICE | sound, voice | | +| `+..`
`++.`
`+++` | VOICE | sound, voice | | | `+......`
`++..+++`
`+++....` | VOICE.PATH | message | voice going | | `+...+..`
`++...+.`
`+++...+` | VOICE.NOT | silence | | | `+...+..`
`++..++.`
`+++.+++` | VOICE.VOICE | conversation | voice with voice | @@ -90,8 +90,8 @@ are not listed. Status: **preliminary**, 55 words. | Word | Parts | Meaning | Note | |---|---|---|---| -| `.......`
`.+.....`
`.......` | ONE | that | alone in a node slot: the line above | +| `...`
`.+.`
`...` | ONE | that | alone in a node slot: the triplet before | | `.......`
`.+...+.`
`.......` | ONE.ONE | the same | | | `....+..`
`.+...+.`
`......+` | ONE.NOT | different | | | `.+.....`
`+.+....`
`.+..+++` | OPEN.COUNT | how many? | "what number?"; answer with COUNT.n | -| `.+.....`
`+.+....`
`.+.....` | OPEN | what? | | +| `.+.`
`+.+`
`.+.` | OPEN | what? | | diff --git a/examples/conversation.txt b/examples/conversation.txt index 73871ea..5b4339a 100644 --- a/examples/conversation.txt +++ b/examples/conversation.txt @@ -1,4 +1,4 @@ -# They speak: three statements. A blank line starts the next edge. +# They speak: three triplets. A blank line starts the next one. +: BODY.OTHER | ONE | BODY.AIR ×: _ | _ | _.WATER *: _ | _ | _.AIR diff --git a/examples/how-many.txt b/examples/how-many.txt index 6f8e67b..dd3c4c1 100644 --- a/examples/how-many.txt +++ b/examples/how-many.txt @@ -1,4 +1,4 @@ # "How many pulsars?" Twelve. -# OPEN.COUNT asks "what number?"; the answer COUNT.12 replaces OPEN with COUNT, and COUNT with the bits. +# OPEN.COUNT asks "what number?"; the answer COUNT.12 replaces OPEN with COUNT, and COUNT with the digit. +: STAR.TIME | ONE | OPEN.COUNT ×: _ | _ | COUNT.12 diff --git a/examples/numbers.txt b/examples/numbers.txt new file mode 100644 index 0000000..824636f --- /dev/null +++ b/examples/numbers.txt @@ -0,0 +1,5 @@ +# "Now is 2219. Twelve pulsars." Big numbers are base-512 digits after COUNT. +# COUNT.4.171 = 4 x 512 + 171 = 2219 (you can also write COUNT.2219). ++: TIME.ONE | ONE | COUNT.4.171 + ++: STAR.TIME | ONE | COUNT.12 diff --git a/examples/pulsars.txt b/examples/pulsars.txt index 32e7099..0f0c579 100644 --- a/examples/pulsars.txt +++ b/examples/pulsars.txt @@ -1,5 +1,5 @@ # "We see pulsars. The pulsars are twelve." -# The same word on two lines is one node with two edges. +# The same word in two triplets is one node with two edges. +: SELF | LIGHT.ONE | STAR.TIME +: STAR.TIME | ONE | COUNT.12 diff --git a/examples/saw-that.txt b/examples/saw-that.txt index 3d22412..1aafe0b 100644 --- a/examples/saw-that.txt +++ b/examples/saw-that.txt @@ -1,5 +1,5 @@ # "Your world is water. We saw that." -# A lone ONE in a node slot is "that": the statement on the line above. +# A lone ONE in a node slot is "that": the triplet before. +: BODY.OTHER | ONE | BODY.WATER +: SELF | LIGHT.BEFORE | ONE diff --git a/src/glyph_cli/__init__.py b/src/glyph_cli/__init__.py index c8f49a2..58364b2 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.2.0" +__version__ = "0.3.0" __all__ = ["Entry", "GlyphError", "Part", "Vocabulary", "Word", "__version__"] diff --git a/src/glyph_cli/cli.py b/src/glyph_cli/cli.py index e9c8bf8..69101c2 100644 --- a/src/glyph_cli/cli.py +++ b/src/glyph_cli/cli.py @@ -10,11 +10,11 @@ from pathlib import Path from . import __version__ -from .drawing import decode, draw_words, render, render_svg +from .drawing import PER_LINE, decode, draw_words, hidden_zeros, render, render_svg 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 NUMBER, GlyphError, Vocabulary +from .script import BASE, NUMBER, GlyphError, Vocabulary, Word, to_digits ENV_VOCAB = "GLYPH_VOCAB" @@ -22,8 +22,10 @@ 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 COUNT. (0-511); COUNT alone is zero. "_" is an empty position or an empty word. +A number is COUNT and base-512 digits: COUNT.137, COUNT.4.171 = 2219 (COUNT.2219 also works); +COUNT alone is zero. "_" is an empty position or an empty word. Pages are text files, one band per line: "SYMBOL: node | relation | node". +A block of bands is one triplet; a blank line starts the next. """ EPILOG = f"""\ @@ -99,7 +101,7 @@ def cmd_check(v: Vocabulary, a: argparse.Namespace) -> int: print(f'yes: {word} = "{e.gloss}" ({e.domain})') return 0 if word.kind == NUMBER: - print(f"yes: {word} = the number {word.which or 0} (numbers are built in)") + print(f"yes: {word} = the number {word.value} (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] @@ -128,6 +130,15 @@ def cmd_show(v: Vocabulary, a: argparse.Namespace) -> int: return 0 +def cmd_number(v: Vocabulary, a: argparse.Namespace) -> int: + digits = to_digits(a.n) + word = Word(NUMBER, None if a.n == 0 else digits[0] if len(digits) == 1 else digits) + sums = " + ".join(f"{d}×{BASE}^{len(digits) - 1 - i}" for i, d in enumerate(digits)) + print(f"{a.n} = {word}" + (f" ({sums})" if len(digits) > 1 else "")) + print("\n".join(draw_words(v, [word], check_symbol(a.symbol)))) + return 0 + + def cmd_add(v: Vocabulary, a: argparse.Namespace) -> int: e = v.add(v.parse(a.word), a.gloss, a.domain, a.note, a.force) path = v.save() @@ -152,10 +163,16 @@ def cmd_validate(v: Vocabulary, a: argparse.Namespace) -> int: def cmd_render(v: Vocabulary, a: argparse.Namespace) -> int: page = parse_page(read_input(a.file), v) + for i in hidden_zeros(page, a.per_line): + print( + f"glyph: warning: triplet {i} ends its line with a number ending in a 0 digit, which is drawn blank; " + "a reader can't see where it ends. Move it to the subject slot or put a triplet after it.", + file=sys.stderr, + ) if a.svg: - write_output(render_svg(v, page, cell=a.cell, grid=not a.no_grid), a.output) + write_output(render_svg(v, page, cell=a.cell, grid=not a.no_grid, per_line=a.per_line), a.output) else: - write_output("\n".join(render(v, page)) + "\n", a.output) + write_output("\n".join(render(v, page, a.per_line)) + "\n", a.output) return 0 @@ -218,6 +235,9 @@ def add(name: str, func: Callable, help: str) -> argparse.ArgumentParser: p = add("show", cmd_show, "draw words") p.add_argument("words", nargs="+", metavar="WORD") p.add_argument("--symbol", default="+", help="the party's symbol (default +)") + p = add("number", cmd_number, "write a number in base-512 digits and draw it") + p.add_argument("n", type=int, help="a whole number, 0 or more") + p.add_argument("--symbol", default="+", help="the party's symbol (default +)") p = add("add", cmd_add, "add a word to an editable vocabulary") p.add_argument("word") p.add_argument("gloss", help="its meaning") @@ -230,6 +250,9 @@ def add(name: str, func: Callable, help: str) -> argparse.ArgumentParser: p = add("render", cmd_render, "draw a page from page source") p.add_argument("file", help="page source file, or - for stdin") p.add_argument("--svg", action="store_true", help="draw SVG pixels instead of text") + p.add_argument( + "--per-line", type=int, default=PER_LINE, metavar="N", help=f"triplets per drawn line (default {PER_LINE})" + ) p.add_argument("--cell", type=int, default=12, help="SVG cell size in pixels (default 12)") p.add_argument("--no-grid", action="store_true", help="SVG: leave empty cells undrawn") p.add_argument("-o", "--output", help="write to a file instead of stdout") diff --git a/src/glyph_cli/data/vocab.json b/src/glyph_cli/data/vocab.json index fa3d907..4cc67bf 100644 --- a/src/glyph_cli/data/vocab.json +++ b/src/glyph_cli/data/vocab.json @@ -111,18 +111,18 @@ }, "BEFORE": { "shape": [ - "...", "#..", - "..." + "###", + "#.." ], "thing": "earlier", "relation": null }, "AFTER": { "shape": [ - "...", "..#", - "..." + "###", + "..#" ], "thing": "later", "relation": null @@ -511,7 +511,7 @@ "which": null, "gloss": "that", "domain": "Logic and number", - "note": "alone in a node slot: the line above" + "note": "alone in a node slot: the triplet before" }, { "kind": "ONE", diff --git a/src/glyph_cli/drawing.py b/src/glyph_cli/drawing.py index 61bf1f4..b6f69f7 100644 --- a/src/glyph_cli/drawing.py +++ b/src/glyph_cli/drawing.py @@ -1,21 +1,23 @@ -"""Drawing pages on the lattice, and reading drawings back. +"""Drawing pages on the grid, and reading drawings back. -Rule 1 of the grammar: every mark is a whole 3x3 part in a lattice position, -positions are one empty cell apart, so a line is 6 positions (23 cells) wide. -Bands of one line are one empty row apart; lines are three empty rows apart. +Rule 1 of the grammar: spacing is structure. Every mark is a whole 3x3 part. +Parts of one word are 1 empty cell apart, the words of a triplet 2, triplets 4. +Bands of one line are 1 empty row apart; lines are 3 empty rows apart. """ from __future__ import annotations from html import escape -from .page import Band, Line, Page, check_symbol +from .page import Band, Page, Triplet, check_symbol from .script import NUMBER, GlyphError, Half, Vocabulary, Word -POSITIONS = 6 -WIDTH = POSITIONS * 4 - 1 # 23 cells -BAND_GAP = 1 -LINE_GAP = 3 +GAP_PART = 1 # between the parts of a word +GAP_WORD = 2 # between the words of a triplet +GAP_TRIPLET = 4 # between triplets on a line +BAND_GAP = 1 # rows between the bands (voices) of a line +LINE_GAP = 3 # rows between lines +PER_LINE = 2 # triplets per drawn line, unless asked otherwise EMPTY_CELL = "." MARK = "#" @@ -30,7 +32,7 @@ def number_shape(n: int) -> tuple[str, str, str]: - """Nine bits, 256 ... 1, left to right, top to bottom.""" + """One base-512 digit: nine bits, 256 ... 1, left to right, top to bottom.""" bits = format(n, "09b") return tuple("".join(MARK if bits[r * 3 + c] == "1" else EMPTY_CELL for c in range(3)) for r in range(3)) # type: ignore[return-value] @@ -43,41 +45,90 @@ def shape(vocab: Vocabulary, half: Half) -> tuple[str, str, str]: return vocab.parts[half].shape -def band_rows(vocab: Vocabulary, halves: list[Half], symbol: str) -> list[str]: - """Three rows of text for a run of positions drawn in one symbol.""" +def width(positions: int) -> int: + """Cells taken by a run of positions inside one word.""" + return 4 * positions - GAP_PART if positions else 0 + + +def band_rows(vocab: Vocabulary, positions: list[Half], symbol: str) -> list[str]: + """Three rows of text for the positions of one word drawn in one symbol.""" rows = ["", "", ""] - for i, half in enumerate(halves): + for i, half in enumerate(positions): s = shape(vocab, half) for r in range(3): - rows[r] += (EMPTY_CELL if i else "") + s[r].replace(MARK, symbol) + rows[r] += (EMPTY_CELL * GAP_PART if i else "") + s[r].replace(MARK, symbol) return rows def draw_words(vocab: Vocabulary, words: list[Word], symbol: str = "+", spacing: int = 3) -> list[str]: """Words side by side, as a quick look-up drawing (not a page).""" - blocks = [band_rows(vocab, list(w.halves), symbol) for w in words] + blocks = [band_rows(vocab, w.positions, symbol) for w in words] return [(" " * spacing).join(b[r] for b in blocks) for r in range(3)] # ---------- pages ---------- -def render(vocab: Vocabulary, page: Page) -> list[str]: - """Draw a page as rows of text. Every line gets as many bands as the page has voices.""" - blank = EMPTY_CELL * WIDTH +def triplet_rows(vocab: Vocabulary, triplet: Triplet, voices: int) -> list[str]: + """One triplet with ``voices`` bands. Each slot is as wide as its longest word.""" + widths = triplet.widths() + xs, x = [], 0 + for w in widths: + xs.append(x) + x += width(w) + GAP_WORD + total = x - GAP_WORD + out: list[str] = [] + for k in range(voices): + if k: + out += [EMPTY_CELL * total] * BAND_GAP + band = [[EMPTY_CELL] * total for _ in range(3)] + if k < len(triplet.bands): + b = triplet.bands[k] + for slot, word in enumerate(b.words): + if word.is_empty: + continue + for r, row in enumerate(band_rows(vocab, word.positions, b.symbol)): + for j, ch in enumerate(row): + if ch != EMPTY_CELL: + band[r][xs[slot] + j] = ch + out += ["".join(r) for r in band] + return out + + +def render(vocab: Vocabulary, page: Page, per_line: int = PER_LINE) -> list[str]: + """Draw a page as rows of text. + + Triplets run left to right, ``per_line`` to a line, 4 empty cells apart. Every + triplet gets as many bands as the page has voices. Rows are padded to one width. + """ + if per_line < 1: + raise GlyphError("per_line must be at least 1") n = page.voices + grids = [triplet_rows(vocab, t, n) for t in page.triplets] out: list[str] = [] - for li, line in enumerate(page.lines): - if li: - out += [blank] * LINE_GAP - for k in range(n): - if k: - out += [blank] * BAND_GAP - if k < len(line.bands): - band = line.bands[k] - out += band_rows(vocab, band.halves, band.symbol) - else: - out += [blank] * 3 + for i in range(0, len(grids), per_line): + if i: + out += [""] * LINE_GAP + row = grids[i : i + per_line] + out += [(EMPTY_CELL * GAP_TRIPLET).join(g[r] for g in row) for r in range(len(row[0]))] + w = max((len(r) for r in out), default=0) + return [r.ljust(w, EMPTY_CELL) for r in out] + + +def hidden_zeros(page: Page, per_line: int = PER_LINE) -> list[int]: + """Triplets whose last word is a number ending in a 0 digit at the end of a drawn line. + + A 0 digit is a blank position, so there the reader cannot see where the number ends. + """ + out = [] + for i, t in enumerate(page.triplets, 1): + if i % per_line and i != len(page.triplets): + continue + for b in t.bands: + w = b.words[2] + if w.is_number and len(w.digits) > 1 and w.digits[-1] == 0: + out.append(i) + break return out @@ -88,11 +139,13 @@ def ink(symbol: str, symbols: list[str]) -> str: return OTHER_INKS[others.index(symbol) % len(OTHER_INKS)] -def render_svg(vocab: Vocabulary, page: Page, cell: int = 12, pad: int = 1, grid: bool = True) -> str: +def render_svg( + vocab: Vocabulary, page: Page, cell: int = 12, pad: int = 1, grid: bool = True, per_line: int = PER_LINE +) -> str: """Draw a page as SVG: square pixels on signal blue, no curves.""" - rows = render(vocab, page) + rows = render(vocab, page, per_line) symbols = page.symbols() - w, h = WIDTH + 2 * pad, len(rows) + 2 * pad + w, h = (len(rows[0]) if rows else 0) + 2 * pad, len(rows) + 2 * pad out = [ f'', @@ -136,67 +189,159 @@ 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, 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 _bits(pattern: tuple[str, ...]) -> int: + return int("".join(pattern).replace(EMPTY_CELL, "0").replace(MARK, "1"), 2) + + +class _LineReader: + """Split one drawn line into triplets and words. + + The gaps say where everything is: 1 empty column inside a word, 2 between words, + 4 between triplets. Some parts (ONE, BEFORE, AFTER) and number digits have empty + columns of their own, so a gap can look a cell wider than it is. The parts are + drawn so that no part moved one cell sideways is another part, but number digits + can be anything. So the reader finds every layout in which each position is a + part (or a digit, in a number) and keeps the one whose statements read best + against the vocabulary, like the translating machine of the story leaning on + its lexicon. + """ + + LIMIT = 64 # layouts kept per starting point; real lines have one or two + + def __init__(self, vocab: Vocabulary, bands: list[list[str]]): + self.vocab = vocab + self.bands = bands + self.width = len(bands[0][0]) + self.by_shape = {p.shape: p.name for p in vocab.parts.values()} + self.filled = [any(row[c] != EMPTY_CELL for band in bands for row in band) for c in range(self.width)] + self.memo: dict[tuple[int, int], list[list]] = {} + + def empty(self, start: int, stop: int) -> bool: + return stop <= self.width and not any(self.filled[start:stop]) + + def pattern(self, k: int, x: int) -> tuple[str, ...]: + cells = (row[x : x + 3] for row in self.bands[k]) + return tuple("".join(MARK if ch != EMPTY_CELL else EMPTY_CELL for ch in c) for c in cells) + + def slot(self, x: int, p: int) -> list[Word] | None: + """The words each band draws in a slot of ``p`` positions at column ``x``, or None if they aren't words.""" + words: list[Word] = [] + number_above = False + last_used = False + for k in range(len(self.bands)): + pats = [self.pattern(k, x + 4 * j) for j in range(p)] + drawn = [any(MARK in r for r in pat) for pat in pats] + if not any(drawn): + words.append(Word()) + continue + last_used = last_used or drawn[-1] + kind = self.by_shape.get(pats[0]) if drawn[0] else None + if drawn[0] and kind is None: + return None + if kind == NUMBER or (kind is None and number_above): + digits = [_bits(pat) for pat in pats[1:]] + if kind == NUMBER: + last_used = last_used or p > 1 + while digits and digits[0] == 0: + digits.pop(0) + else: + while digits and digits[-1] == 0: + digits.pop() + which = None if not digits else digits[0] if len(digits) == 1 else tuple(digits) + words.append(Word(kind, which)) + number_above = number_above or kind == NUMBER + continue + if p > 2 and any(drawn[2:]): + return None + which = None + if p > 1 and drawn[1]: + which = self.by_shape.get(pats[1]) + if which is None: + return None + words.append(Word(kind, which)) + if p > 1 and not last_used: + return None + return words + + def read(self) -> list | None: + """The best layout of the line: a list of triplets, each 3 slots of (start, end, words per band).""" + layouts = self.layouts(0, 0) + return max(layouts, key=self.score) if layouts else None + + def score(self, layout: list) -> int: + """How many statement words read as known words (first band of each triplet).""" + total = 0 + for slots in layout: + words = [next((w for w in s[2] if not w.is_empty), Word()) for s in slots] + for i, w in enumerate(words): + if i == 1: + part = self.vocab.parts.get(w.kind) if w.kind else None + total += bool(part and part.relation) and (w.which is None or str(w.which) in self.vocab.markers) + else: + total += w.kind == NUMBER or w.which is None or self.vocab.lookup(w) is not None + return total + + def layouts(self, x: int, slot: int) -> list[list]: + key = (x, slot) + if key not in self.memo: + self.memo[key] = self._layouts(x, slot)[: self.LIMIT] + return self.memo[key] + + def _layouts(self, x: int, slot: int) -> list[list]: + found: list[list] = [] + p = 1 + while x + width(p) <= self.width: + if p > 1 and self.filled[x + width(p - 1)]: + break # a mark where the 1-cell gap inside a word must be + words = self.slot(x, p) + end = x + width(p) + if words is not None: + here = (x, end, words) + if slot < 2: + if self.empty(end, end + GAP_WORD): + found += [[[here, *rest[0]], *rest[1:]] for rest in self.layouts(end + GAP_WORD, slot + 1)] + elif self.empty(end, self.width): + found.append([[here]]) + elif self.empty(end, end + GAP_TRIPLET): + found += [[[here], *rest] for rest in self.layouts(end + GAP_TRIPLET, 0)] + p += 1 + return found def decode(vocab: Vocabulary, text: str) -> Page: """Read a drawing (as made by :func:`render`) back into a page. - 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. + A position after COUNT is read as nine bits, and so is a position with no + kind under a number (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: return Page() - for i, row in enumerate(rows, 1): - if len(row) != WIDTH: - raise GlyphError(f"row {i} is {len(row)} cells wide; text is always {WIDTH} (off the lattice: a picture?)") - for c in range(3, WIDTH, 4): - if row[c] != EMPTY_CELL: - raise GlyphError(f"row {i}, column {c + 1}: a mark between lattice positions (a picture?)") + w = max(len(r) for r in rows) + rows = [r.ljust(w, EMPTY_CELL) for r in rows] n = _structure(rows) period = 4 * n - 1 + LINE_GAP - by_shape = {p.shape: p.name for p in vocab.parts.values()} page = Page() for top in range(0, len(rows), period): - line = Line() - for k in range(n): - r0 = top + 4 * k - band = rows[r0 : r0 + 3] - marks = {ch for row in band for ch in row if ch != EMPTY_CELL} - if not marks: - continue - if len(marks) > 1: - raise GlyphError(f"rows {r0 + 1}-{r0 + 3}: one band mixes symbols {' '.join(sorted(marks))}") - symbol = check_symbol(marks.pop()) - halves: list[Half] = [] - for pos in range(POSITIONS): - pattern = tuple( - "".join(MARK if ch != EMPTY_CELL else EMPTY_CELL for ch in row[pos * 4 : pos * 4 + 3]) - for row in band - ) - if not any(MARK in p for p in pattern): - halves.append(None) + bands = [rows[top + 4 * k : top + 4 * k + 3] for k in range(n)] + layout = _LineReader(vocab, bands).read() + if layout is None: + raise GlyphError( + f"rows {top + 1}-{top + 4 * n - 1}: the marks don't split into 3x3 parts with 1, 2 and 4 cell gaps " + "(a picture rather than text?)" + ) + for slots in layout: + start, stop = slots[0][0], slots[-1][1] + triplet = Triplet() + for k in range(n): + words = tuple(s[2][k] for s in slots) + if all(word.is_empty for word in words): continue - slot = pos // 2 - if pos % 2 == 0: - name = by_shape.get(pattern) - if name is None: - raise GlyphError(f"rows {r0 + 1}-{r0 + 3}, position {pos + 1}: not a part") - halves.append(name) - else: - 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) - words = tuple(Word(halves[i], halves[i + 1]) for i in (0, 2, 4)) - line.bands.append(Band(symbol, words)) # type: ignore[arg-type] - if line.bands: - page.lines.append(line) + marks = {ch for row in bands[k] for ch in row[start:stop] if ch != EMPTY_CELL} + if len(marks) > 1: + r0 = top + 4 * k + raise GlyphError(f"rows {r0 + 1}-{r0 + 3}: one band mixes symbols {' '.join(sorted(marks))}") + triplet.bands.append(Band(check_symbol(marks.pop()), words)) # type: ignore[arg-type] + if triplet.bands: + page.triplets.append(triplet) return page diff --git a/src/glyph_cli/export.py b/src/glyph_cli/export.py index 2e8fb8a..fe3c56c 100644 --- a/src/glyph_cli/export.py +++ b/src/glyph_cli/export.py @@ -9,8 +9,8 @@ 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 (`COUNT.0` to `COUNT.511`) are built in, so they -are not listed. Status: **preliminary**, {count} words.""" +on its own. Any part can also be a **relation**, and numbers (`COUNT` and base-512 digits, such as `COUNT.137` +or `COUNT.4.171` for 2219) are built in, so they are not listed. Status: **preliminary**, {count} words.""" def vocabulary_markdown(vocab: Vocabulary, title: str | None = None) -> str: @@ -23,6 +23,6 @@ def vocabulary_markdown(vocab: Vocabulary, title: str | None = None) -> str: for e in vocab.entries: if e.domain != domain: continue - pic = "
".join(f"`{r}`" for r in band_rows(vocab, list(e.word.halves), "+")) + pic = "
".join(f"`{r}`" for r in band_rows(vocab, e.word.positions, "+")) out.append(f"| {pic} | {e.word} | {e.gloss} | {e.note} |") return "\n".join(out) + "\n" diff --git a/src/glyph_cli/graph.py b/src/glyph_cli/graph.py index 7940744..68acdbe 100644 --- a/src/glyph_cli/graph.py +++ b/src/glyph_cli/graph.py @@ -1,12 +1,12 @@ """Reading a page as a knowledge graph (grammar rules 3 and 5). -Rule 3: the first band of every line is one edge, node | relation | node. The -same word on two lines is the same node, and a lone ONE in a node slot means -"that", the statement on the line above. +Rule 3: every triplet is one edge, node | relation | node, read from its first +band. The same word in two triplets is the same node, and a lone ONE in a node +slot means "that", the triplet before. Rule 5: every part in a lower band answers the nearest drawn part above it in -the same position: the same part is *yes*, NOT is *no*, a different part is -*instead*, a part under an empty position is *and also*. +the same position of the same slot: the same part is *yes*, NOT is *no*, a +different part is *instead*, a part under an empty position is *and also*. """ from __future__ import annotations @@ -16,7 +16,6 @@ from .page import SLOTS, Page from .script import Half, Vocabulary, Word -HALVES = ("kind", "which") THAT = Word("ONE", None) @@ -28,14 +27,14 @@ class Node: def label(self) -> str: if self.statement is not None: - return f"[statement {self.statement}]" if self.statement else "[that: nothing above]" + return f"[triplet {self.statement}]" if self.statement else "[that: nothing before]" return self.gloss @dataclass class Response: slot: str - half: str + half: str # kind, which, or "digit n" in a number meaning: str # yes | no | instead | and also part: str to: str | None # symbol of the voice answered, None when answering an empty position @@ -84,9 +83,15 @@ def respond(above: Half, below: Half) -> str | None: return "instead" +def position_name(word: Word, index: int) -> str: + if index == 0: + return "kind" + return f"digit {index}" if word.is_number and len(word.digits) > 1 else "which" + + def read_graph(vocab: Vocabulary, page: Page) -> Graph: g = Graph() - for i, line in enumerate(page.lines, 1): + for i, line in enumerate(page.triplets, 1): st = line.statement subject_w, relation_w, object_w = st.words @@ -102,15 +107,18 @@ def node(word: Word, i: int = i) -> Node: edge = Edge(i, st.symbol, node(subject_w), relation, str(relation_w), node(object_w)) for b, band in enumerate(line.bands[1:], 1): reply = Reply(band.symbol) - for pos, below in enumerate(band.halves): - above, by = None, None - for prev in reversed(line.bands[:b]): - if prev.halves[pos] is not None: - above, by = prev.halves[pos], prev.symbol - break - meaning = respond(above, below) - if meaning: - reply.responses.append(Response(SLOTS[pos // 2], HALVES[pos % 2], meaning, vocab.thing(below), by)) + for slot, word in enumerate(band.words): + for pos, below in enumerate(word.positions): + above, by = None, None + for prev in reversed(line.bands[:b]): + prev_positions = prev.words[slot].positions + if pos < len(prev_positions) and prev_positions[pos] is not None: + above, by = prev_positions[pos], prev.symbol + break + meaning = respond(above, below) + if meaning: + name = position_name(word, pos) + reply.responses.append(Response(SLOTS[slot], name, meaning, vocab.thing(below), by)) edge.replies.append(reply) g.edges.append(edge) return g @@ -132,7 +140,7 @@ def to_dot(g: Graph) -> str: """Graphviz DOT, for anyone who wants to draw the graph.""" def nid(n: Node) -> str: - return f'"statement {n.statement}"' if n.statement is not None else f'"{n.word or "_"}"' + return f'"triplet {n.statement}"' if n.statement is not None else f'"{n.word or "_"}"' def q(s: str) -> str: return s.replace("\\", "\\\\").replace('"', '\\"') diff --git a/src/glyph_cli/page.py b/src/glyph_cli/page.py index fc0f73c..d5e99d7 100644 --- a/src/glyph_cli/page.py +++ b/src/glyph_cli/page.py @@ -1,9 +1,10 @@ """Page source: the plain-text form of a page. -One band per line, ``SYMBOL: node | relation | node``. The first band of a -block is the statement (one edge of the graph); the bands under it are other -voices replying, part by part. A blank line starts the next statement. ``#`` -starts a comment. +One band per line, ``SYMBOL: node | relation | node``. A block of bands is one +triplet: the first band is the statement (one edge of the graph), the bands +under it are other voices replying, part by part. A blank line starts the next +triplet. ``#`` starts a comment. How many triplets go on one drawn line is a +choice made when drawing, not part of the source. +: BODY.OTHER | ONE | BODY.AIR # their statement: your world is an air-world ×: _ | _ | _.WATER # our reply: water instead of air @@ -29,14 +30,14 @@ class Band: words: tuple[Word, Word, Word] @property - def halves(self) -> list: - """The six lattice positions, left to right.""" - return [h for w in self.words for h in w.halves] + def positions(self) -> list[list]: + """The lattice positions of each slot, left to right.""" + return [w.positions for w in self.words] @dataclass -class Line: - """One line of a page: a statement band and the voices that answer it.""" +class Triplet: + """One triplet (node, relation, node): a statement band and the voices that answer it.""" bands: list[Band] = field(default_factory=list) @@ -48,20 +49,24 @@ def statement(self) -> Band: def replies(self) -> list[Band]: return self.bands[1:] + def widths(self) -> list[int]: + """Positions per slot: as many as the longest word any band puts there.""" + return [max(len(b.words[i].positions) for b in self.bands) for i in range(3)] + @dataclass class Page: - lines: list[Line] = field(default_factory=list) + triplets: list[Triplet] = field(default_factory=list) @property def voices(self) -> int: - """The most bands any line has: every line is drawn this tall.""" - return max((len(line.bands) for line in self.lines), default=0) + """The most bands any triplet has: every line is drawn this tall.""" + return max((len(t.bands) for t in self.triplets), default=0) def symbols(self) -> list[str]: out: list[str] = [] - for line in self.lines: - for band in line.bands: + for t in self.triplets: + for band in t.bands: if band.symbol not in out: out.append(band.symbol) return out @@ -76,13 +81,13 @@ def check_symbol(symbol: str) -> str: def parse_page(text: str, vocab: Vocabulary) -> Page: """Read page source into a :class:`Page`.""" - page, current = Page(), Line() + page, current = Page(), Triplet() for number, raw in enumerate(text.splitlines(), 1): line = raw.split("#", 1)[0].rstrip() if not line.strip(): if current.bands: - page.lines.append(current) - current = Line() + page.triplets.append(current) + current = Triplet() continue symbol, sep, rest = line.partition(":") where = f"line {number}" @@ -101,13 +106,13 @@ def parse_page(text: str, vocab: Vocabulary) -> Page: raise GlyphError(f"{where}: {exc}") from None current.bands.append(Band(symbol.strip(), words)) # type: ignore[arg-type] if current.bands: - page.lines.append(current) + page.triplets.append(current) return page def format_page(page: Page) -> str: """Write a :class:`Page` back as page source.""" blocks = [] - for line in page.lines: - blocks.append("\n".join(f"{b.symbol}: " + " | ".join(str(w) for w in b.words) for b in line.bands)) + for t in page.triplets: + blocks.append("\n".join(f"{b.symbol}: " + " | ".join(str(w) for w in b.words) for b in t.bands)) return "\n\n".join(blocks) + ("\n" if blocks else "") diff --git a/src/glyph_cli/script.py b/src/glyph_cli/script.py index 243a232..e5ae9d7 100644 --- a/src/glyph_cli/script.py +++ b/src/glyph_cli/script.py @@ -1,10 +1,11 @@ """The script itself: parts, words and the vocabulary that names them. -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 (``_``). 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. +A *part* is a 3x3 shape with a meaning. A *word* is a **kind** part narrowed by +a **which** part, written ``KIND.WHICH``, or a kind alone. Either half may be empty +(``_``). Numbers are ``COUNT`` followed by base-512 digits, most significant first +(``COUNT.137``, ``COUNT.4.171`` = 2219): COUNT is the only kind whose positions are +read as nine bits, and it never takes a part, so a number can never be drawn like a +word. ``COUNT`` alone is zero. """ from __future__ import annotations @@ -15,14 +16,37 @@ from importlib import resources from pathlib import Path -#: A half of a word: a part name, a number (only after ``ONE``) or ``None`` for empty. +#: One lattice position: a part name, a base-512 digit (only in a number) or ``None`` for empty. Half = str | int | None +#: The which of a word: a part, one digit, several digits (a number above 511) or ``None``. +Which = str | int | tuple[int, ...] | None -MAX_NUMBER = 511 +BASE = 512 +MAX_DIGIT = BASE - 1 +MAX_NUMBER = MAX_DIGIT # kept for compatibility: the largest single digit EMPTY = "_" NUMBER = "COUNT" # the kind whose which is a number +def to_digits(n: int) -> tuple[int, ...]: + """``2219`` -> ``(4, 171)``: base-512 digits, most significant first.""" + if n < 0: + raise GlyphError(f"only whole numbers of zero or more: {n}") + digits = [] + while True: + n, d = divmod(n, BASE) + digits.append(d) + if not n: + return tuple(reversed(digits)) + + +def from_digits(digits: tuple[int, ...]) -> int: + n = 0 + for d in digits: + n = n * BASE + d + return n + + class GlyphError(Exception): """A problem the user can fix: a bad word, a bad page, a bad vocabulary.""" @@ -37,10 +61,14 @@ class Part: @dataclass(frozen=True) class Word: - """One word: ``kind`` and ``which``. ``Word(None, None)`` is the empty word.""" + """One word: ``kind`` and ``which``. ``Word(None, None)`` is the empty word. + + A number's which is one digit (``Word("COUNT", 137)``) or a tuple of digits + (``Word("COUNT", (4, 171))`` = 2219). + """ kind: str | None = None - which: Half = None + which: Which = None @property def is_empty(self) -> bool: @@ -48,16 +76,47 @@ def is_empty(self) -> bool: @property def is_number(self) -> bool: - return isinstance(self.which, int) + return isinstance(self.which, (int, tuple)) + + @property + def digits(self) -> tuple[int, ...]: + """A number's digits; ``()`` for any other which.""" + if isinstance(self.which, tuple): + return self.which + return (self.which,) if isinstance(self.which, int) else () + + @property + def value(self) -> int | None: + """The number a COUNT word stands for (``COUNT`` alone is 0); ``None`` for other words.""" + if self.kind == NUMBER or (self.kind is None and self.is_number): + return from_digits(self.digits) if self.digits else 0 + return None @property - def halves(self) -> tuple[Half, Half]: - return (self.kind, self.which) + def positions(self) -> list[Half]: + """The lattice positions the word takes, left to right. + + A one-part word takes one position (3 cells), a pair two, a number one per digit + after COUNT. The empty word takes one empty position. + """ + if self.which is None: + return [self.kind] + return [self.kind, *self.digits] if self.is_number else [self.kind, self.which] + + @property + def halves(self) -> tuple[Half, ...]: + """Deprecated name for :attr:`positions`, kept for 0.2 code.""" + return tuple(self.positions) def __str__(self) -> str: if self.is_empty: return EMPTY - which = "" if self.which is None else f".{self.which}" + if self.which is None: + which = "" + elif self.is_number: + which = "." + ".".join(str(d) for d in self.digits) + else: + which = f".{self.which}" return (self.kind or EMPTY) + which @@ -73,7 +132,7 @@ class Entry: def to_json(self) -> dict: return { "kind": self.word.kind, - "which": self.word.which, + "which": list(self.word.which) if isinstance(self.word.which, tuple) else self.word.which, "gloss": self.gloss, "domain": self.domain, "note": self.note, @@ -97,7 +156,12 @@ def from_json(cls, data: dict, path: Path | None = None) -> Vocabulary: name: Part(name, tuple(p["shape"]), p["thing"], p.get("relation")) for name, p in data["parts"].items() } entries = [ - Entry(Word(e["kind"], e["which"]), e["gloss"], e.get("domain", "Unsorted"), e.get("note", "")) + Entry( + Word(e["kind"], _which_from_json(e["which"])), + e["gloss"], + e.get("domain", "Unsorted"), + e.get("note", ""), + ) for e in data["words"] ] return cls(parts, dict(data.get("markers", {})), entries, data.get("version", 1), path) @@ -145,23 +209,34 @@ def save(self, path: str | os.PathLike | None = None) -> Path: # ---- words ---- def parse(self, text: str) -> Word: - """``'BODY.OTHER'`` -> ``Word('BODY', 'OTHER')``; ``'COUNT.137'`` -> ``Word('COUNT', 137)``.""" + """``'BODY.OTHER'`` -> ``Word('BODY', 'OTHER')``; ``'COUNT.137'`` -> ``Word('COUNT', 137)``. + + Numbers are base-512 digits after COUNT: ``'COUNT.4.171'`` -> ``Word('COUNT', (4, 171))``. + A single number above 511 is split into digits for you: ``'COUNT.2219'`` is ``COUNT.4.171``. + """ s = text.strip() if s in ("", EMPTY): return Word() k, _, w = s.partition(".") kind = None if k in ("", EMPTY) else k.upper() - which: Half + which: Which if w in ("", EMPTY): which = None - elif w.isdigit(): - which = int(w) - if which > MAX_NUMBER: - raise GlyphError(f"number out of range 0-{MAX_NUMBER}: {which}") + elif all(d.isdigit() for d in w.split(".")): + digits = tuple(int(d) for d in w.split(".")) + if len(digits) == 1: + digits = to_digits(digits[0]) + elif any(d > MAX_DIGIT for d in digits): + raise GlyphError(f"each digit of a number is 0-{MAX_DIGIT} (base {BASE}): {s}") + elif digits[0] == 0 and kind == NUMBER: + raise GlyphError(f"a number does not start with a 0 digit: {s}") if kind not in (NUMBER, None): - raise GlyphError(f"a number needs {NUMBER} as its kind, e.g. {NUMBER}.{which} (got {s})") + raise GlyphError(f"a number needs {NUMBER} as its kind, e.g. {NUMBER}.{w} (got {s})") + which = digits[0] if len(digits) == 1 else digits if kind == NUMBER and which == 0: which = None # COUNT alone is zero: COUNT.0 draws exactly like it + elif "." in w: + raise GlyphError(f"only numbers take more than two positions: {s}") else: which = w.upper() if kind == NUMBER: @@ -183,6 +258,8 @@ def thing(self, half: Half) -> str: return EMPTY if isinstance(half, int): return str(half) + if isinstance(half, tuple): + return ".".join(str(d) for d in half) return self.parts[half].thing def gloss(self, word: Word, role: str = "node") -> str: @@ -198,8 +275,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 == NUMBER and (word.is_number or word.which is None): - return str(word.which or 0) + if word.kind == NUMBER: + return str(word.value) entry = self.lookup(word) if entry: return entry.gloss @@ -247,6 +324,13 @@ def validate(self) -> list[str]: shapes[p.shape] = p.name if not any("#" in r for r in p.shape): errors.append(f"part {p.name}: shape is empty") + for p in self.parts.values(): + # Spacing is structure: a part moved one cell sideways must not be another part, + # or a 1-cell gap could read as a 2-cell gap. + for moved in ((r[1:] + "." for r in p.shape), ("." + r[:2] for r in p.shape)): + other = shapes.get(tuple(moved)) + if other and other != p.name: + errors.append(f"part {p.name} moved one cell sideways is {other}: gaps would be ambiguous") seen: dict[Word, str] = {} glosses: dict[str, Word] = {} for e in self.entries: @@ -270,3 +354,7 @@ def domains(self) -> list[str]: if e.domain not in out: out.append(e.domain) return out + + +def _which_from_json(which: object) -> Which: + return tuple(which) if isinstance(which, list) else which # type: ignore[return-value] diff --git a/tests/test_cli.py b/tests/test_cli.py index 6442a33..cbd0fc6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -57,7 +57,7 @@ def test_find_and_show(capsys): def test_render_graph_decode(capsys, tmp_path): src = str(EXAMPLES / "third-voice.txt") code, drawing, _ = run(capsys, "render", src) - assert code == 0 and len(drawing.splitlines()) == 11 + assert code == 0 and len(drawing.splitlines()) == 11 and len(drawing.splitlines()[0]) == 21 drawn = tmp_path / "drawing.txt" drawn.write_text(drawing, encoding="utf-8") code, out, _ = run(capsys, "decode", str(drawn)) @@ -71,6 +71,21 @@ def test_render_graph_decode(capsys, tmp_path): assert code == 0 and svg.read_text().startswith(" your world", - "3. (+) we --say, send [now]--> [statement 2]", + "3. (+) we --say, send [now]--> [triplet 2]", ] assert g.nodes == {"BODY.OTHER": "your world", "BODY.AIR": "air-world", "SELF": "we"} @@ -148,7 +196,15 @@ def test_rule_five(vocab, reply, expected): def test_that_on_the_first_line(vocab): g = read_graph(vocab, page(vocab, "+: SELF | LIGHT.BEFORE | ONE")) - assert g.edges[0].object.label() == "[that: nothing above]" + assert g.edges[0].object.label() == "[that: nothing before]" + + +def test_rule_five_by_digit(vocab): + g = read_graph(vocab, page(vocab, "+: SELF | LIGHT | COUNT.4.171\n×: _ | _ | _.4.170")) + assert [r.text(vocab) for r in g.edges[0].replies[0].responses] == [ + "object.digit 1: yes (to +)", + 'object.digit 2: instead "170" (to +)', + ] def test_svg(vocab): diff --git a/tests/test_script.py b/tests/test_script.py index 73520e5..08023b4 100644 --- a/tests/test_script.py +++ b/tests/test_script.py @@ -17,6 +17,9 @@ def test_bundled_vocabulary_is_sound(vocab): ("COUNT.137", Word("COUNT", 137)), ("COUNT.0", Word("COUNT", None)), ("COUNT", Word("COUNT", None)), + ("COUNT.4.171", Word("COUNT", (4, 171))), + ("COUNT.2219", Word("COUNT", (4, 171))), + ("COUNT.512", Word("COUNT", (1, 0))), ("_.12", Word(None, 12)), ("_.WATER", Word(None, "WATER")), ("_", Word()), @@ -27,13 +30,17 @@ def test_parse(vocab, text, word): assert vocab.parse(text) == word -@pytest.mark.parametrize("text", ["BODY.WIND", "COUNT.512", "STAR.12", "ONE.12", "COUNT.SELF", "FOO"]) +@pytest.mark.parametrize( + "text", ["BODY.WIND", "COUNT.4.512", "COUNT.0.5", "STAR.12", "ONE.12", "COUNT.SELF", "FOO", "BODY.SELF.OTHER"] +) def test_parse_rejects(vocab, text): with pytest.raises(GlyphError): vocab.parse(text) -@pytest.mark.parametrize("text", ["BODY.OTHER", "SELF", "COUNT.137", "COUNT", "_.12", "_.WATER", "_"]) +@pytest.mark.parametrize( + "text", ["BODY.OTHER", "SELF", "COUNT.137", "COUNT", "COUNT.4.171", "COUNT.12.106.0", "_.12", "_.WATER", "_"] +) def test_word_str_round_trips(vocab, text): assert str(vocab.parse(text)) == text @@ -42,6 +49,7 @@ def test_gloss(vocab): assert vocab.gloss(Word("BODY", "OTHER")) == "your world" assert vocab.gloss(Word("COUNT", 137)) == "137" assert vocab.gloss(Word("COUNT", None)) == "0" + assert vocab.gloss(Word("COUNT", (4, 171))) == "2219" 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" @@ -92,7 +100,7 @@ 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} + words = {tuple(band_rows(vocab, e.word.positions, "#")) for e in vocab.entries} assert all(tuple(band_rows(vocab, ["COUNT", n], "#")) not in words for n in range(1, 512)) @@ -102,6 +110,19 @@ def test_validate_catches_duplicate_shapes(vocab): assert any("same shape" in e for e in Vocabulary.from_json(data).validate()) +def test_validate_catches_parts_one_cell_apart(vocab): + data = vocab.to_json() + data["parts"]["BEFORE"]["shape"] = ["...", "#..", "..."] # ONE moved one cell left + assert any("sideways is BEFORE" in e for e in Vocabulary.from_json(data).validate()) + + +def test_positions(vocab): + assert vocab.parse("SELF").positions == ["SELF"] + assert vocab.parse("BODY.OTHER").positions == ["BODY", "OTHER"] + assert vocab.parse("COUNT.4.171").positions == ["COUNT", 4, 171] + assert vocab.parse("_").positions == [None] + + def test_load_errors(tmp_path): with pytest.raises(GlyphError, match="not found"): Vocabulary.load(tmp_path / "missing.json") From db8be59e8e9d80b734f54a6a9adad4a1559cb525 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 05:24:10 +0000 Subject: [PATCH 2/3] Read a number used as a relation's which as dotted digits Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QgskKyin9t8k9F6KcUj5o7 --- src/glyph_cli/script.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/glyph_cli/script.py b/src/glyph_cli/script.py index e5ae9d7..27ab27f 100644 --- a/src/glyph_cli/script.py +++ b/src/glyph_cli/script.py @@ -273,7 +273,8 @@ def gloss(self, word: Word, role: str = "node") -> str: base = part.relation or part.thing if word.which is None: return base - marker = self.markers.get(str(word.which), str(word.which)) + plain = self.thing(word.which) if word.is_number else str(word.which) + marker = self.markers.get(str(word.which)) or plain return f"{base} [{marker}]" if word.kind == NUMBER: return str(word.value) From 6339a23dda36a3234538ed50381ecea5370816bc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 05:38:34 +0000 Subject: [PATCH 3/3] Keep BEFORE and AFTER as single dots L chose to keep the original shapes. The reader already picks the layout that reads best against the vocabulary, so ambiguous gaps resolve to known words; the docs now say so, and validate no longer rejects parts that are one-cell shifts of each other. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QgskKyin9t8k9F6KcUj5o7 --- docs/assets/examples/conversation.drawing.txt | 6 +++--- docs/assets/examples/conversation.svg | 8 ++++---- docs/assets/examples/saw-that.drawing.txt | 6 +++--- docs/assets/examples/saw-that.svg | 8 ++++---- .../examples/saw-your-world.drawing.txt | 6 +++--- docs/assets/examples/saw-your-world.svg | 8 ++++---- docs/assets/examples/shadow.drawing.txt | 6 +++--- docs/assets/examples/shadow.svg | 8 ++++---- docs/assets/parts.txt | 6 +++--- docs/getting-started.md | 6 +++--- docs/script.md | 12 +++++------ docs/vocabulary.md | 4 ++-- src/glyph_cli/data/vocab.json | 8 ++++---- src/glyph_cli/drawing.py | 11 +++++----- src/glyph_cli/script.py | 7 ------- tests/test_pages.py | 20 +++++++++++-------- tests/test_script.py | 6 ------ 17 files changed, 63 insertions(+), 73 deletions(-) diff --git a/docs/assets/examples/conversation.drawing.txt b/docs/assets/examples/conversation.drawing.txt index 6db235d..b9137c6 100644 --- a/docs/assets/examples/conversation.drawing.txt +++ b/docs/assets/examples/conversation.drawing.txt @@ -1,6 +1,6 @@ -+++.+++.......+++.+.+....+++..+.+.+....+++.+++ -+++.+.....+...+++........+.+...+..+++..+++.+.. -+++.+++.......+++.+.+....+++..+.+.+....+++.+++ ++++.+++.......+++.+.+....+++..+.+......+++.+++ ++++.+.....+...+++........+.+...+..+....+++.+.. ++++.+++.......+++.+.+....+++..+.+......+++.+++ .............................................. ..................×××......................... .............................................. diff --git a/docs/assets/examples/conversation.svg b/docs/assets/examples/conversation.svg index 8f4ab90..829ed45 100644 --- a/docs/assets/examples/conversation.svg +++ b/docs/assets/examples/conversation.svg @@ -34,7 +34,7 @@ + -+ + @@ -81,8 +81,8 @@ + -+ -+ + + + @@ -126,7 +126,7 @@ + -+ + diff --git a/docs/assets/examples/saw-that.drawing.txt b/docs/assets/examples/saw-that.drawing.txt index 55c52eb..64f0c93 100644 --- a/docs/assets/examples/saw-that.drawing.txt +++ b/docs/assets/examples/saw-that.drawing.txt @@ -1,3 +1,3 @@ -+++.+++.......+++.+++....+++..+.+.+....... -+++.+.....+...+++........+.+...+..+++...+. -+++.+++.......+++.+++....+++..+.+.+....... ++++.+++.......+++.+++....+++..+.+......... ++++.+.....+...+++........+.+...+..+.....+. ++++.+++.......+++.+++....+++..+.+......... diff --git a/docs/assets/examples/saw-that.svg b/docs/assets/examples/saw-that.svg index 295fdc1..f90385a 100644 --- a/docs/assets/examples/saw-that.svg +++ b/docs/assets/examples/saw-that.svg @@ -34,7 +34,7 @@ + -+ + @@ -77,8 +77,8 @@ + -+ -+ + + @@ -118,7 +118,7 @@ + -+ + diff --git a/docs/assets/examples/saw-your-world.drawing.txt b/docs/assets/examples/saw-your-world.drawing.txt index 4792fe8..a2eff84 100644 --- a/docs/assets/examples/saw-your-world.drawing.txt +++ b/docs/assets/examples/saw-your-world.drawing.txt @@ -1,3 +1,3 @@ -+++..+.+.+....+++.+++ -+.+...+..+++..+++.+.. -+++..+.+.+....+++.+++ ++++..+.+......+++.+++ ++.+...+..+....+++.+.. ++++..+.+......+++.+++ diff --git a/docs/assets/examples/saw-your-world.svg b/docs/assets/examples/saw-your-world.svg index 2e261d1..4d93c8e 100644 --- a/docs/assets/examples/saw-your-world.svg +++ b/docs/assets/examples/saw-your-world.svg @@ -9,7 +9,7 @@ + -+ + @@ -31,8 +31,8 @@ + -+ -+ + + + @@ -51,7 +51,7 @@ + -+ + diff --git a/docs/assets/examples/shadow.drawing.txt b/docs/assets/examples/shadow.drawing.txt index 0575bfb..14f9ebe 100644 --- a/docs/assets/examples/shadow.drawing.txt +++ b/docs/assets/examples/shadow.drawing.txt @@ -1,3 +1,3 @@ -+++..+.+.+....+++....+++..+.+.+....+++ -+.+...+..+++..+......+.+...+...+...+.. -+++..+.+.+....+++....+++..+.+...+..+++ ++++..+.+......+++....+++..+.+.+....+++ ++.+...+..+....+......+.+...+...+...+.. ++++..+.+......+++....+++..+.+...+..+++ diff --git a/docs/assets/examples/shadow.svg b/docs/assets/examples/shadow.svg index c3a59ab..7471c0c 100644 --- a/docs/assets/examples/shadow.svg +++ b/docs/assets/examples/shadow.svg @@ -9,7 +9,7 @@ + -+ + @@ -48,8 +48,8 @@ + -+ -+ + + + @@ -85,7 +85,7 @@ + -+ + diff --git a/docs/assets/parts.txt b/docs/assets/parts.txt index d672735..bf22290 100644 --- a/docs/assets/parts.txt +++ b/docs/assets/parts.txt @@ -14,6 +14,6 @@ VOICE PATH NOT ONE ### ... ..# ... BEFORE AFTER OPEN COUNT -#.. ..# .#. ... -### ### #.# ... -#.. ..# .#. ### +... ... .#. ... +#.. ..# #.# ... +... ... .#. ### diff --git a/docs/getting-started.md b/docs/getting-started.md index b3dfaea..2e56de1 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -94,9 +94,9 @@ glyph render hello.txt ``` ```text -+++..+.+.+....+++.+++ -+.+...+..+++..+++.+.. -+++..+.+.+....+++.+++ ++++..+.+......+++.+++ ++.+...+..+....+++.+.. ++++..+.+......+++.+++ ``` And read it: diff --git a/docs/script.md b/docs/script.md index a0ccfe8..637b10c 100644 --- a/docs/script.md +++ b/docs/script.md @@ -21,9 +21,9 @@ This is the grammar `glyph` implements: version 8 of the grid script, where **a Two triplets on one line, "We saw your world. We will say that.": ```text -+++..+.+.+....+++.+++....+++..+.....+..... -+.+...+..+++..+++.+......+.+..++..+++...+. -+++..+.+.+....+++.+++....+++..+++...+..... ++++..+.+......+++.+++....+++..+........... ++.+...+..+....+++.+......+.+..++....+...+. ++++..+.+......+++.+++....+++..+++......... ``` `SELF`, 2 cells, `LIGHT.BEFORE` (1 cell inside), 2 cells, `BODY.OTHER`; then 4 cells and the next triplet. @@ -98,14 +98,14 @@ A 0 digit is a blank position. Inside a line that is fine, because the next gap - `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. -## Why no part is another part moved sideways +## When the gaps fit two readings -Because gaps carry the structure, a part with empty edge columns could make a 1-cell gap look like a 2-cell gap. `ONE` is a single dot in the middle, so `BEFORE` and `AFTER` are drawn as a path with a tick at its start and its end, not as dots at the edges: otherwise `BODY.AFTER | OTHER` (*will have, you*) and `BODY | ONE.OTHER` (*have, one of you*) would be the same drawing. `glyph validate` checks that no part moved one cell sideways is another part. +`ONE`, `BEFORE` and `AFTER` are each one dot, a cell apart, so a dot next to a gap can sometimes be read either way. `BODY.AFTER | OTHER` (*will have, you*) and `BODY | ONE.OTHER` (*have, one of you*) are the same drawing. This is kept on purpose (L, 2026-10-08): like any real decipherment, the machine reads such a line the way that makes most sense against its lexicon, a relation with a tense marker and words it already knows, and it can be wrong. ## How the machine reads a page 1. Split the drawing into lines at the three-row gaps, and count the bands: that is the number of voices. -2. Split each line into triplets, words and parts from the 1, 2 and 4 cell gaps. Anything that won't split into whole parts is a picture, not text. Number digits can have empty edges of their own, so when more than one split fits, the machine keeps the one that reads best against the vocabulary. +2. Split each line into triplets, words and parts from the 1, 2 and 4 cell gaps. Anything that won't split into whole parts is a picture, not text. ONE, BEFORE and AFTER and number digits have empty edges of their own, so when more than one split fits, the machine keeps the one that reads best against the vocabulary. 3. Read the first band of each triplet as an edge. Same word, same node. A lone `ONE` points at the triplet before. 4. Apply rule 5 to every part in a lower band, and attach the result to that node or edge with the voice that drew it. diff --git a/docs/vocabulary.md b/docs/vocabulary.md index 14e78fb..7d20303 100644 --- a/docs/vocabulary.md +++ b/docs/vocabulary.md @@ -63,9 +63,9 @@ or `COUNT.4.171` for 2219) are built in, so they are not listed. Status: **preli | Word | Parts | Meaning | Note | |---|---|---|---| | `+.+`
`+.+`
`+.+` | TIME | time | | -| `+.+.+..`
`+.+.+++`
`+.+.+..` | TIME.BEFORE | the past, before | | +| `+.+....`
`+.+.+..`
`+.+....` | TIME.BEFORE | the past, before | | | `+.+....`
`+.+..+.`
`+.+....` | TIME.ONE | now | | -| `+.+...+`
`+.+.+++`
`+.+...+` | TIME.AFTER | the future, after | | +| `+.+....`
`+.+...+`
`+.+....` | TIME.AFTER | the future, after | | | `+.+....`
`+.+.+++`
`+.+....` | TIME.PATH | always | time going on | | `+.+.+..`
`+.+..+.`
`+.+...+` | TIME.NOT | never | | | `+.+.+++`
`+.+.+++`
`+.+.+++` | TIME.BODY | year | one orbit | diff --git a/src/glyph_cli/data/vocab.json b/src/glyph_cli/data/vocab.json index 4cc67bf..0fcf300 100644 --- a/src/glyph_cli/data/vocab.json +++ b/src/glyph_cli/data/vocab.json @@ -111,18 +111,18 @@ }, "BEFORE": { "shape": [ + "...", "#..", - "###", - "#.." + "..." ], "thing": "earlier", "relation": null }, "AFTER": { "shape": [ + "...", "..#", - "###", - "..#" + "..." ], "thing": "later", "relation": null diff --git a/src/glyph_cli/drawing.py b/src/glyph_cli/drawing.py index b6f69f7..fb2fb7a 100644 --- a/src/glyph_cli/drawing.py +++ b/src/glyph_cli/drawing.py @@ -198,12 +198,11 @@ class _LineReader: The gaps say where everything is: 1 empty column inside a word, 2 between words, 4 between triplets. Some parts (ONE, BEFORE, AFTER) and number digits have empty - columns of their own, so a gap can look a cell wider than it is. The parts are - drawn so that no part moved one cell sideways is another part, but number digits - can be anything. So the reader finds every layout in which each position is a - part (or a digit, in a number) and keeps the one whose statements read best - against the vocabulary, like the translating machine of the story leaning on - its lexicon. + columns of their own, so a gap can look a cell wider than it is: ONE moved one + cell left is BEFORE, one cell right is AFTER. The reader finds every layout in + which each position is a part (or a digit, in a number) and keeps the one whose + statements read best against the vocabulary, like the translating machine of the + story leaning on its lexicon. It can be wrong; that is part of the story. """ LIMIT = 64 # layouts kept per starting point; real lines have one or two diff --git a/src/glyph_cli/script.py b/src/glyph_cli/script.py index 27ab27f..0c74db3 100644 --- a/src/glyph_cli/script.py +++ b/src/glyph_cli/script.py @@ -325,13 +325,6 @@ def validate(self) -> list[str]: shapes[p.shape] = p.name if not any("#" in r for r in p.shape): errors.append(f"part {p.name}: shape is empty") - for p in self.parts.values(): - # Spacing is structure: a part moved one cell sideways must not be another part, - # or a 1-cell gap could read as a 2-cell gap. - for moved in ((r[1:] + "." for r in p.shape), ("." + r[:2] for r in p.shape)): - other = shapes.get(tuple(moved)) - if other and other != p.name: - errors.append(f"part {p.name} moved one cell sideways is {other}: gaps would be ambiguous") seen: dict[Word, str] = {} glosses: dict[str, Word] = {} for e in self.entries: diff --git a/tests/test_pages.py b/tests/test_pages.py index 1448b97..ac73e20 100644 --- a/tests/test_pages.py +++ b/tests/test_pages.py @@ -17,9 +17,9 @@ def page(vocab, text): # 2 between words, 4 between triplets; a one-part word is 3 cells wide. GRAMMAR = { "+: SELF | LIGHT.BEFORE | BODY.OTHER": """ -+++..+.+.+....+++.+++ -+.+...+..+++..+++.+.. -+++..+.+.+....+++.+++ ++++..+.+......+++.+++ ++.+...+..+....+++.+.. ++++..+.+......+++.+++ """, "+: STAR.TIME | ONE | COUNT.12": """ .+..+.+.............. @@ -49,9 +49,9 @@ def page(vocab, text): ..................*.* """, "+: SELF | LIGHT | BODY.OTHER\n\n+: SELF | VOICE.AFTER | ONE": """ -+++..+.+..+++.+++....+++..+.....+..... -+.+...+...+++.+......+.+..++..+++...+. -+++..+.+..+++.+++....+++..+++...+..... ++++..+.+..+++.+++....+++..+........... ++.+...+...+++.+......+.+..++....+...+. ++++..+.+..+++.+++....+++..+++......... """, } @@ -122,10 +122,14 @@ def test_a_zero_digit_at_the_end_of_a_line_is_invisible(vocab): def test_decode_ambiguous_gaps(vocab): - # ONE and the gaps around it: every one of these reads back as written. - for src in ("+: BODY.AFTER | ONE | OTHER", "+: BODY | ONE.OTHER | OTHER", "+: SELF | VOICE.ONE | ONE"): + # ONE, BEFORE and AFTER are one dot a cell apart, so a few drawings fit two readings. + # The reader keeps the one made of known words. + for src in ("+: BODY | ONE.OTHER | OTHER", "+: SELF | VOICE.ONE | ONE", "+: SELF | LIGHT.BEFORE | BODY.OTHER"): p = page(vocab, src) assert decode(vocab, "\n".join(render(vocab, p))) == p + a, b = page(vocab, "+: AIR.NOT | BODY.AFTER | OTHER"), page(vocab, "+: AIR.NOT | BODY | ONE.OTHER") + assert render(vocab, a) == render(vocab, b) # truly the same drawing + assert decode(vocab, "\n".join(render(vocab, a))) == b def test_decode_kindless_number_reply(vocab): diff --git a/tests/test_script.py b/tests/test_script.py index 08023b4..0dd8656 100644 --- a/tests/test_script.py +++ b/tests/test_script.py @@ -110,12 +110,6 @@ def test_validate_catches_duplicate_shapes(vocab): assert any("same shape" in e for e in Vocabulary.from_json(data).validate()) -def test_validate_catches_parts_one_cell_apart(vocab): - data = vocab.to_json() - data["parts"]["BEFORE"]["shape"] = ["...", "#..", "..."] # ONE moved one cell left - assert any("sideways is BEFORE" in e for e in Vocabulary.from_json(data).validate()) - - def test_positions(vocab): assert vocab.parse("SELF").positions == ["SELF"] assert vocab.parse("BODY.OTHER").positions == ["BODY", "OTHER"]