From f00862e388946aac1e8d0ac88c195d44ebc0ee77 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 6 Oct 2026 22:51:27 +0000
Subject: [PATCH 1/5] Signals: add the resonance theory and read each
transmission through it
Adds a 'Speech that needs two' section to /signals/ (CN and EN) and a
short resonance-theory reading under each of the five transmissions.
Framed as a working theory held by listeners in the story, not a
decoding. Encodings and audio are unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01QgskKyin9t8k9F6KcUj5o7
---
app/signals.css | 4 ++++
signals/en/index.html | 17 ++++++++++++++++-
signals/index.html | 17 ++++++++++++++++-
3 files changed, 36 insertions(+), 2 deletions(-)
diff --git a/app/signals.css b/app/signals.css
index 59c1d4a..f3ffced 100644
--- a/app/signals.css
+++ b/app/signals.css
@@ -15,6 +15,10 @@
.signals-split .scope canvas { height: clamp(16rem, 34vw, 24rem); }
#cv-picture { image-rendering: pixelated; }
+/* Readings under the resonance theory: a working theory in the story, not a decoding. */
+.signals-split .theory { padding-left: 0.9rem; border-left: 4px solid var(--them); }
+.signals-split .theory strong { color: var(--them); }
+
.tick-us, .tick-them { font-style: normal; }
.tick-us { color: var(--us); }
.tick-them { color: var(--them); }
diff --git a/signals/en/index.html b/signals/en/index.html
index 4719d59..b0e9030 100644
--- a/signals/en/index.html
+++ b/signals/en/index.html
@@ -40,6 +40,16 @@ One beacon, five transmissions.
+
+
+
A working theory
+
Speech that needs two
+
Nobody on Earth knows what they look like. But the transmissions have led many listeners to one theory: they speak by resonance, in air. Alone, a body can only ring at its own tones, coloured by the place it stands. That is enough to say "I am here", and little more. When a second voice is near (a person, a machine, even the wind), new tones appear that neither could make alone. Tones split, sum and difference tones sound, and the two either fall into step or beat against each other. In this theory, that is where language lives.
+
If it is right, the beacon is only half of a conversation. It holds its note, with gaps where a partner's tones would go. To understand it fully, someone has to answer.
+
Below, each transmission is read in two ways: what it encodes, and what it might mean if the theory is right.
+
+
+
@@ -47,6 +57,7 @@
One beacon, five transmissions.
667 bits, folded once
Clicks spaced 2, 3, 5, 7 … 47 apart say "this is deliberate". Then come 667 bits, high tone for 1 and low tone for 0. 667 = 23 × 29, so there is only one way to lay them out as a picture: counting, their star and its one planet, a dish, and the hydrogen spin flip.
This is the naive one: it assumes we see the way they do. Fold it yourself .
+
Resonance theory. A picture is the most foreign thing a resonant species could make, and they made it because we see. Its two tones may stand for the two states of a pair: in step, and beating.
Listen · 40 s
23 × 29 · 1 = high tone, 0 = low tone –
@@ -60,6 +71,7 @@
667 bits, folded once
Their first word is "you"
The tone dims and recovers, again and again. Each dip is Earth crossing the Sun, exactly as it looked from Ross 128: 84 parts per million deep, rounded by the Sun's darker edge. They send one dip per century of the years they watched, from 1100 BCE to 1100 CE. The dips start short and grazing, grow to 13 hours, then shrink again, because Ross 128 was drifting across the band from which Earth's transits can be seen. After that comes silence: two centuries with no dip. Then three short, deep dips: their own planet crossing their star, every 9.87 days.
Every dip comes from the real motion of the two stars (Drift ). The pattern says "we watched you for 2,200 years, and then we lost you".
+
Resonance theory. Earth crossing the Sun once a year is a natural rhythm, our planet's own tone. They name us by our rhythm, and themselves by theirs.
Listen · 30 s
brightness · dips ×7,000 so you can see them –
@@ -73,6 +85,7 @@ Their first word is "you"
An address written in clocks
Twelve pulse trains, one after another, each at a real pulsar's period. A millisecond pulsar spins fast enough to be a note: B1937+21 hums at 642 Hz. A slow one ticks. The periods name the pulsars, so there is no need to draw where they are; any astronomer already knows.
What matters is when each train starts. They begin it at the phase of the pulsar as it reaches Ross 128. A pulse reaches them earlier or later than it reaches us, depending on how far they sit along that pulsar's direction. Twelve phase shifts with only three unknowns pin down where they are and, read against the pulsar clocks, when they sent it. This is how the MC ledger keeps time, so the ledger can read it directly.
+ Resonance theory. The pulsars are not only the address but the partner. Heard together with the real pulsars, the trains would make tones that neither makes alone. That may be why the signal turned up in pulsar-timing data at all: it was never meant to be heard by itself.
Listen · 20 s
one period per row · ▮ us · ▮ them –
@@ -86,6 +99,7 @@ An address written in clocks
Words made of molecules
Short bursts at several frequencies at once. Each frequency is a real radio line, the note a molecule rings at: hydrogen at 1420 MHz, water at 22 GHz, oxygen at 119 GHz. A chord names a substance and its loudness says how much. Every line is shifted up by the same 0.01%, the same Doppler shift as the hydrogen tone, so they all come from the same place.
Five chords so far: hydrogen as the unit; water; much water; their air, with oxygen and ozone; and methanol beside three lines no catalogue lists.
+ Resonance theory. Molecules are resonators too, and air is what carries their speech. The chord of their air may be its tuning, and the three unknown lines may be gases they added to set it.
Listen · 11 s
frequency, 1–130 GHz (log) · bar height = loudness –
@@ -99,6 +113,7 @@ Words made of molecules
137, with no digits
A fast buzz of clicks and a slow, bright pip. Count the clicks between pips: 137. But the pip doesn't land on a click. Each round it slips a little further, 0.036 of a click, and after about 28 rounds it lines up again. The ratio of the two rhythms is 137.036.
That is the inverse of the fine-structure constant, the strength of light's grip on electrons. It has no units, so it is the same number in any system of measurement on any world. Sending it as a ratio says three things at once: we know physics, our physics is yours, and here is how precisely we measured it.
+ Resonance theory. Two rhythms that never quite agree, with the meaning in the slip between them. If they speak by resonance, this is the simplest complete sentence they could send, and the natural first lesson.
Listen · 5 s
one row per round · zoomed in on the gap between two clicks –
@@ -115,7 +130,7 @@ How the sound is made
For the chords, the line frequencies are real (CDMS/JPL). Every factor of four in radio frequency becomes one octave, with 1420 MHz on the G below middle C. The three unknown lines are invented.
For the number, 1/α = 137.035999084 (CODATA 2018).
- The rotation of the five transmissions, their content and their order are story proposals.
+ The rotation of the five transmissions, their content and their order are story proposals. The resonance readings are a theory held by listeners in the story, not a decoding.
diff --git a/signals/index.html b/signals/index.html
index e939bbc..84bcd41 100644
--- a/signals/index.html
+++ b/signals/index.html
@@ -40,6 +40,16 @@ 一个信标,五段发射。
+
+
+
一个推测
+
需要两个声音的语言
+
地球上没有人知道他们长什么样。但这几段发射让许多收听者想到同一个推测:他们靠共振说话,借助空气。一个身体独自只能以自己的音调振动,再带上所处地方的音色。这足以说“我在这里”,也仅此而已。当另一个声音在旁边,无论是另一个人、一台机器,还是风,就会出现两者单独都发不出的音:音调分裂,和音与差音响起,两者要么合拍,要么互相拍频。按这个推测,语言就在这里。
+
如果推测是对的,信标只是一场对话的一半。它持续发着自己的音,在伙伴的音该在的地方留出空隙。要完全听懂它,就得有人回答。
+
下面每一段都有两种读法:它编码了什么,以及如果推测成立,它可能是什么意思。
+
+
+
@@ -47,6 +57,7 @@
一个信标,五段发射。
667 位,折一次
间隔 2、3、5、7……47 的咔嗒声在说“这是故意的”。接着是 667 位,1 是高音,0 是低音。667 = 23 × 29,所以只有一种排法能成图:计数、他们的星和唯一的行星、一面天线、氢的自旋翻转。
这是最朴素的一段:它假设我们和他们看东西的方式一样。自己折一次 。
+
共振推测: 对一个靠共振说话的物种来说,图是最陌生的东西。他们画了,是因为我们靠眼睛。两种音调也许代表一对声音的两种状态:合拍,和拍频。
收听 · 40 秒
23 × 29 · 1 = 高音,0 = 低音 –
@@ -60,6 +71,7 @@
667 位,折一次
他们的第一个词是“你们”
音调一次次变暗又恢复。每一次变暗,都是地球从太阳前经过,正是从罗斯128看到的样子:深百万分之八十四,被太阳较暗的边缘磨圆。他们观察我们的那些年里,每个世纪发一次,从公元前约 1100 年到公元 1100 年。起初很短、擦边而过,后来长到 13 小时,再慢慢变短,因为罗斯128正在漂过那条能看到地球凌日的带子。然后是两个世纪的静默。最后是三次又短又深的变暗:他们自己的行星每 9.87 天从他们的恒星前经过一次。
每一次变暗都来自两颗星的真实运动(两星漂移 )。整段意思是:“我们看了你们两千二百年,然后看不见了。”
+
共振推测: 地球每年从太阳前经过一次,是一种天然的节奏,是我们星球自己的音。他们用我们的节奏称呼我们,用他们的节奏称呼自己。
收听 · 30 秒
亮度 · 变暗放大 7,000 倍 –
@@ -73,6 +85,7 @@ 他们的第一个词是“你们”
用时钟写成的地址
十二串脉冲,一串接一串,每一串都按一颗真实脉冲星的周期。毫秒脉冲星转得够快,听起来是一个音:B1937+21 以 642 Hz 嗡鸣。慢的只是滴答声。周期就是名字,所以不必画出它们在哪里,天文学家都知道。
关键是每一串从哪里开始。他们按脉冲星传到罗斯128时的相位起头。同一个脉冲到达他们那里比到达我们早一些或晚一些,取决于他们在这颗脉冲星的方向上离我们多远。十二个相位差,只有三个未知数,足以定出他们在哪里;再对照脉冲星时钟,还能定出发送的时刻。脉币账本正是这样计时的,所以账本可以直接读它。
+ 共振推测: 脉冲星不只是地址,也是伙伴。这些脉冲串和真实的脉冲星一起听,会产生两者单独都没有的音。这也许正是信号会出现在脉冲星计时数据里的原因:它本来就不是给人单独听的。
收听 · 20 秒
每行一个周期 · ▮ 我们 · ▮ 他们 –
@@ -86,6 +99,7 @@ 用时钟写成的地址
用分子组成的词
几个频率同时响起的短促爆发。每个频率都是一条真实的射电谱线,是某种分子特有的音:氢在 1420 MHz,水在 22 GHz,氧在 119 GHz。一个和弦说出一种物质,响度说明有多少。每条谱线都同样偏高 0.01%,和氢音调的多普勒频移一样,说明它们来自同一个地方。
目前有五个和弦:氢,作为单位;水;很多水;他们的空气,有氧和臭氧;还有甲醇,旁边是三条任何目录里都没有的谱线。
+ 共振推测: 分子也是共振体,而空气承载着他们的语言。他们空气的和弦也许是一种调音,那三条未知谱线也许是他们为了定调而加入的气体。
收听 · 11 秒
频率 1–130 GHz(对数)· 柱高 = 响度 –
@@ -99,6 +113,7 @@ 用分子组成的词
137,不用数字
一串急促的咔嗒声,和一个缓慢、明亮的提示音。数一数两次提示音之间的咔嗒声:137。但提示音并不正好落在咔嗒声上。每一轮它都往后滑一点,是一个间隔的 0.036,大约 28 轮之后又对齐。两种节奏之比是 137.036。
这是精细结构常数的倒数,代表光与电子相互作用的强弱。它没有单位,所以在任何世界、任何度量制里都是同一个数。用比例发送它,同时说了三件事:我们懂物理;我们的物理就是你们的物理;这是我们测量它的精度。
+ 共振推测: 两种节奏永远对不齐,意义就在两者的滑移里。如果他们靠共振说话,这是他们能发出的最简单的完整句子,也是最自然的第一课。
收听 · 5 秒
每行一轮 · 放大到两下咔嗒声之间 –
@@ -115,7 +130,7 @@ 声音是怎么做的
和弦:谱线频率是真实的(CDMS/JPL)。射电频率每乘 4 升一个八度,1420 MHz 落在中央 C 下面的 G 上。三条未知谱线是虚构的。
数字:1/α = 137.035999084(CODATA 2018)。
- 五段发射的轮换方式、内容和顺序都是故事提案。
+ 五段发射的轮换方式、内容和顺序都是故事提案。共振的读法是故事中收听者的推测,不是破译结果。
From 7f63d7ad414f3e86c2191d7ec2fb2a2db942e98c Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 7 Oct 2026 08:37:23 +0000
Subject: [PATCH 2/5] Netlify: publish the static site as-is so deploy previews
stop failing
The old Netlify site still builds previews with its Hugo settings, which
fail since the Hugo config was removed in #3.
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01QgskKyin9t8k9F6KcUj5o7
---
netlify.toml | 6 ++++++
1 file changed, 6 insertions(+)
create mode 100644 netlify.toml
diff --git a/netlify.toml b/netlify.toml
new file mode 100644
index 0000000..ae08e7f
--- /dev/null
+++ b/netlify.toml
@@ -0,0 +1,6 @@
+# The live site is served by GitHub Pages from main. Netlify still builds
+# deploy previews for pull requests with the old Hugo settings, which fail
+# now that the site is plain static files. Publish the repo root as-is.
+[build]
+ publish = "."
+ command = "echo 'static site, no build step'"
From c376fcf72289b81b4a4649c6fca5366b756f1969 Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 8 Oct 2026 04:30:02 +0000
Subject: [PATCH 3/5] Remove netlify.toml: the site is on GitHub Pages and the
Netlify setup is being retired
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01RH7A8tqLXxxGiks9mUWtyE
---
netlify.toml | 6 ------
1 file changed, 6 deletions(-)
delete mode 100644 netlify.toml
diff --git a/netlify.toml b/netlify.toml
deleted file mode 100644
index ae08e7f..0000000
--- a/netlify.toml
+++ /dev/null
@@ -1,6 +0,0 @@
-# The live site is served by GitHub Pages from main. Netlify still builds
-# deploy previews for pull requests with the old Hugo settings, which fail
-# now that the site is plain static files. Publish the repo root as-is.
-[build]
- publish = "."
- command = "echo 'static site, no build step'"
From 15a263e828e43791c04bcbbec77d5d535351185b Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 8 Oct 2026 05:27:05 +0000
Subject: [PATCH 4/5] Grid script pages: grammar v8 (triplet spacing, base-512
numbers)
lib/glyph.js now ports glyph-cli 0.3.0: words are spaced 1/2/4 cells,
triplets run left to right, numbers are COUNT plus base-512 digits, and
decode reads the structure from the gaps. The explainer, converter and
dictionary pages describe and use the new rules; the converter gets a
triplets-per-line choice, a free-width drawing grid and a warning for
numbers whose trailing 0 digit would vanish at a line end. Vocabulary
and fixtures regenerated from glyph-cli 0.3.0 (InterImm/glyph-cli#5).
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01QgskKyin9t8k9F6KcUj5o7
---
app/script.js | 104 +++---
lib/glyph-vocab.json | 10 +-
lib/glyph-zh.json | 2 +-
lib/glyph.js | 401 ++++++++++++++++------
script/convert/en/index.html | 11 +-
script/convert/index.html | 13 +-
script/en/index.html | 26 +-
script/index.html | 26 +-
scripts/glyph_fixtures.py | 12 +-
tests/glyph-cli-fixtures.json | 477 ++++++++++++++++++--------
tests/glyph-examples/conversation.txt | 2 +-
tests/glyph-examples/how-many.txt | 2 +-
tests/glyph-examples/numbers.txt | 5 +
tests/glyph-examples/pulsars.txt | 2 +-
tests/glyph-examples/saw-that.txt | 2 +-
tests/glyph.test.mjs | 53 ++-
16 files changed, 786 insertions(+), 362 deletions(-)
create mode 100644 tests/glyph-examples/numbers.txt
diff --git a/app/script.js b/app/script.js
index 8021a1b..2a0f439 100644
--- a/app/script.js
+++ b/app/script.js
@@ -3,7 +3,8 @@
// and wires up the controls. The page says which parts it has with data-gs-* attributes.
import {
Vocabulary, GlyphError, parsePage, formatPage, render, renderSvg, rowsSvg, decode, readGraph, bandRows,
- pageSymbols, wordStr, isEmpty, structure, ink, numberShape, WIDTH, POSITIONS, EMPTY_CELL, BACKGROUND, GRID,
+ pageSymbols, wordStr, isEmpty, structure, ink, numberShape, positions, toDigits, hiddenZeros,
+ EMPTY_CELL, BACKGROUND, GRID, BASE,
} from '../lib/glyph.js';
const zh = document.documentElement.lang.toLowerCase().startsWith('zh');
@@ -21,37 +22,37 @@ const pageUrl = (name, source) => {
};
const T = zh ? {
- slots: { subject: '前节点', relation: '关系', object: '后节点' }, halves: { kind: '类', which: '指' },
+ slots: { subject: '前节点', relation: '关系', object: '后节点' }, halves: { kind: '类', which: '指' }, digit: (n) => `第 ${n} 位`,
meaning: { yes: '是', no: '不', instead: '换成', 'and also': '还有' },
to: (s) => `(回应 ${s})`, quote: (s) => `“${s}”`, noComment: '在场,未表态',
- statement: (n) => `[第 ${n} 句]`, thatNothing: '[“那”:上面没有句子]', notInVocabulary: '(不在词典中)',
+ statement: (n) => `[第 ${n} 个三元组]`, thatNothing: '[“那”:前面没有三元组]', notInVocabulary: '(不在词典中)',
part: '部件', asThing: '作为事物', asRelation: '作为关系', none: '—',
results: (n) => `${n} 个词`, noResults: '没有找到。试试别的词,或者直接写出一个词,比如 BODY.STAR。',
- composed: '拼出来的词', inVocab: '词典里的词', notIn: '还不在词典里:这是按部件拼出的读法。', number: '数', numberNote: '数是内置的:COUNT 后面的九个比特。',
+ composed: '拼出来的词', inVocab: '词典里的词', notIn: '还不在词典里:这是按部件拼出的读法。', number: '数', numberNote: '数是内置的:COUNT 后面跟着 512 进制的数位,每位九个比特。',
+ hiddenZero: (n) => `第 ${n} 个三元组在行尾以 0 数位结尾。0 位画出来是空的,读的人看不出这个数在哪里结束:把它放到前节点,或者在后面再写一个三元组。`,
all: '全部', draw: '在转换器里画', copied: '已复制', copy: '复制', linkCopied: '链接已复制',
empty: '空', voice: (s) => `声部 ${s}`, edges: '读出的图', nodes: '节点',
errLine: (n) => `第 ${n} 行:`,
err: {
- unknownPart: (a) => `未知的部件:${a.part}`, range: (a) => `数超出 0–511:${a.n}`,
+ unknownPart: (a) => `未知的部件:${a.part}`, digit: (a) => `每个数位是 0–511(512 进制):${a.s}`,
+ leadingZero: (a) => `数不以 0 数位开头:${a.s}`, tooLong: (a) => `只有数才会多于两个位置:${a.s}`, whole: (a) => `只能是零或正整数:${a.n}`,
numberKind: (a) => `数要以 COUNT 为类,比如 COUNT.${a.n}(写的是 ${a.s})`,
countWhich: (a) => `COUNT 的“指”只能是数,比如 COUNT.12(写的是 ${a.s})`,
symbol: (a) => `符号是一个字符,不能是空格或 # . : _ |(写的是 “${a.symbol}”)`,
format: () => '应写成“符号: 节点 | 关系 | 节点”', slots: (a) => `要正好三个位置,用 | 分开,现在是 ${a.n} 个`,
- width: (a) => `第 ${a.row} 行有 ${a.len} 格宽;文字总是 23 格(不在格点上:是一幅图吗?)`,
- between: (a) => `第 ${a.row} 行第 ${a.col} 列:标记落在两个位置之间(是一幅图吗?)`,
- structure: () => '这些行排不成句子和声部;这是一幅图,而不是文字吗?',
- mixed: (a) => `第 ${a.from}–${a.to} 行:一个声部里混用了几种符号`,
- notPart: (a) => `第 ${a.from}–${a.to} 行,第 ${a.pos} 个位置:不是部件`,
- notWhich: (a) => `第 ${a.from}–${a.to} 行,第 ${a.pos} 个位置:这里既不是部件,也不是数`,
+ structure: () => '这些排分不成行和声部;这是一幅图,而不是文字吗?',
+ gaps: (a) => `第 ${a.from}–${a.to} 排:这些记号按 1、2、4 格的空隙切不成 3 × 3 的部件(是一幅图吗?)`,
+ mixed: (a) => `第 ${a.from}–${a.to} 排:一个声部里混用了几种符号`,
},
} : {
- slots: { subject: 'subject', relation: 'relation', object: 'object' }, halves: { kind: 'kind', which: 'which' },
+ slots: { subject: 'subject', relation: 'relation', object: 'object' }, halves: { kind: 'kind', which: 'which' }, digit: (n) => `digit ${n}`,
meaning: { yes: 'yes', no: 'no', instead: 'instead', 'and also': 'and also' },
to: (s) => ` (to ${s})`, quote: (s) => `"${s}"`, noComment: 'present, no comment',
- statement: (n) => `[statement ${n}]`, thatNothing: '[that: nothing above]', notInVocabulary: '(not in vocabulary)',
+ statement: (n) => `[triplet ${n}]`, thatNothing: '[that: nothing before]', notInVocabulary: '(not in vocabulary)',
part: 'part', asThing: 'as a thing', asRelation: 'as a relation', none: '—',
results: (n) => `${n} word${n === 1 ? '' : 's'}`, noResults: 'Nothing found. Try another word, or write one out, like BODY.STAR.',
- composed: 'A word you built', inVocab: 'In the dictionary', notIn: 'Not in the dictionary yet: this is how its parts read.', number: 'A number', numberNote: 'Numbers are built in: nine bits after COUNT.',
+ composed: 'A word you built', inVocab: 'In the dictionary', notIn: 'Not in the dictionary yet: this is how its parts read.', number: 'A number', numberNote: 'Numbers are built in: COUNT and base-512 digits, nine bits each.',
+ hiddenZero: (n) => `Triplet ${n} ends its line with a number ending in a 0 digit. A 0 digit is drawn blank, so a reader can't see where the number ends: move it to the subject, or put another triplet after it.`,
all: 'All', draw: 'Draw in the converter', copied: 'Copied', copy: 'Copy', linkCopied: 'Link copied',
empty: 'empty', voice: (s) => `voice ${s}`, edges: 'The graph it reads', nodes: 'Nodes',
errLine: () => '', err: {},
@@ -84,8 +85,8 @@ async function loadVocab() {
// ------------------------------------------------------------------ drawing helpers
-const svgOf = (rows, symbols, cell = 12, width = WIDTH) => rowsSvg(rows, symbols, { cell, width });
-const wordSvg = (vocab, w, symbol = '+') => svgOf(bandRows(vocab, [w.kind, w.which], symbol), [symbol], 12, 7);
+const svgOf = (rows, symbols, cell = 12, width) => rowsSvg(rows, symbols, { cell, width });
+const wordSvg = (vocab, w, symbol = '+') => svgOf(bandRows(vocab, positions(w), symbol), [symbol], 12);
const partSvg = (shape, symbol = '+') => svgOf(shape.map((r) => r.replaceAll('#', symbol)), [symbol], 12, 3);
function graphHtml(voc, g, symbols) {
@@ -96,7 +97,7 @@ function graphHtml(voc, g, symbols) {
${esc(e.relation || T.none)}
${esc(label(e.object))}
${e.replies.map((r) => `${sym(r.symbol)} ${r.responses.length ? r.responses.map((x) =>
- `${T.slots[x.slot]}.${T.halves[x.half]} ${T.meaning[x.meaning]}${x.meaning === 'yes' || x.meaning === 'no' ? '' : ' ' + esc(T.quote(x.part))}${x.to ? esc(T.to(x.to)) : ''} `).join('') : `${T.noComment} `}
`).join('')}
+ `${T.slots[x.slot]}.${T.halves[x.half] ?? T.digit(x.half.split(' ')[1])} ${T.meaning[x.meaning]}${x.meaning === 'yes' || x.meaning === 'no' ? '' : ' ' + esc(T.quote(x.part))}${x.to ? esc(T.to(x.to)) : ''} `).join('') : `${T.noComment} `}`).join('')}
`).join('')}`;
}
@@ -129,14 +130,19 @@ function explainer({ en, voc }) {
for (const el of $$('[data-gs-number]')) {
const input = $('input[type=range]', el), num = $('input[type=number]', el), out = $('.gs-draw', el), bits = $('.gs-bits', el);
const show = (n) => {
- n = Math.max(0, Math.min(511, Math.round(+n || 0)));
- input.value = num.value = n;
+ n = Math.max(0, Math.min(+num.max || 511, Math.round(+n || 0)));
+ num.value = n;
+ input.value = Math.min(n, 511);
const w = en.parse(`COUNT.${n}`);
out.innerHTML = wordSvg(en, w);
- const sh = numberShape(n);
+ // the bits of the last digit; a bigger number shows how its digits add up
+ const ds = toDigits(n);
+ const sh = numberShape(ds[ds.length - 1]);
const vals = [256, 128, 64, 32, 16, 8, 4, 2, 1];
bits.innerHTML = sh.join('').split('').map((c, i) => `${vals[i]} `).join('');
- $('.gs-number-word', el).textContent = wordStr(w);
+ const term = (d, p) => (p === 0 ? `${d}` : p === 1 ? `${d} × ${BASE}` : `${d} × ${BASE}^${p}`);
+ const sum = ds.length > 1 ? ` = ${ds.map((d, i) => term(d, ds.length - 1 - i)).join(' + ')}` : '';
+ $('.gs-number-word', el).textContent = wordStr(w) + sum;
};
input.addEventListener('input', () => show(input.value));
num.addEventListener('input', () => show(num.value));
@@ -179,7 +185,7 @@ function dictionary({ en, voc }) {
const t = q.value.trim().toLowerCase();
// a word written out (BODY.STAR, count.42): show how it draws and reads, in the dictionary or not
built.hidden = true;
- if (t && /^[a-z_]+(\.[a-z0-9_]*)?$/i.test(t)) {
+ if (t && /^[a-z_]+(\.[a-z0-9_]*)*$/i.test(t)) {
try {
const w = en.parse(t);
if (!isEmpty(w)) {
@@ -213,18 +219,21 @@ const SYMBOLS = ['+', '×', '*'];
function converter({ en, voc }) {
const src = $('#gs-src'), out = $('#gs-out'), rowsOut = $('#gs-rows'), graphOut = $('#gs-graph'), errOut = $('#gs-src-err');
- const examples = $('#gs-examples');
+ const examples = $('#gs-examples'), perLineIn = $('#gs-per-line');
let lastRows = [];
+ const perLine = () => +perLineIn.value || 2;
// ---- source -> drawing
const fromSource = () => {
try {
const page = parsePage(src.value, en);
- lastRows = render(en, page);
- out.innerHTML = page.lines.length ? renderSvg(en, page, { cell: 16 }) : '';
+ lastRows = render(en, page, perLine());
+ out.innerHTML = page.triplets.length ? renderSvg(en, page, { cell: 16, perLine: perLine() }) : '';
rowsOut.textContent = lastRows.join('\n');
- graphOut.innerHTML = page.lines.length ? graphHtml(voc, readGraph(voc, page), pageSymbols(page)) : '';
- errOut.hidden = true;
+ graphOut.innerHTML = page.triplets.length ? graphHtml(voc, readGraph(voc, page), pageSymbols(page)) : '';
+ const hidden = hiddenZeros(page, perLine());
+ errOut.textContent = hidden.map(T.hiddenZero).join(' ');
+ errOut.hidden = !hidden.length;
src.removeAttribute('aria-invalid');
} catch (err) {
errOut.textContent = message(err); errOut.hidden = false;
@@ -232,6 +241,7 @@ function converter({ en, voc }) {
}
};
src.addEventListener('input', fromSource);
+ perLineIn.addEventListener('change', fromSource);
examples.addEventListener('change', () => {
const opt = examples.selectedOptions[0];
if (!opt.value) return;
@@ -240,7 +250,7 @@ function converter({ en, voc }) {
$('#gs-copy-rows').addEventListener('click', (ev) => copyText(lastRows.join('\n') + '\n', ev.currentTarget));
$('#gs-svg').addEventListener('click', () => {
try {
- const blob = new Blob([renderSvg(en, parsePage(src.value, en))], { type: 'image/svg+xml' });
+ const blob = new Blob([renderSvg(en, parsePage(src.value, en), { perLine: perLine() })], { type: 'image/svg+xml' });
const a = Object.assign(document.createElement('a'), { href: URL.createObjectURL(blob), download: 'grid-script.svg' });
a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 1000);
} catch { /* the error is already on screen */ }
@@ -258,7 +268,7 @@ function converter({ en, voc }) {
// ---- drawing -> source: an editor grid, mirrored in a text area
const drawing = $('#gs-drawing'), editor = $('#gs-editor'), back = $('#gs-back'), backGraph = $('#gs-back-graph'), backErr = $('#gs-back-err');
- const linesIn = $('#gs-lines'), voicesIn = $('#gs-voices'), palette = $('#gs-palette'), numIn = $('#gs-num');
+ const linesIn = $('#gs-lines'), voicesIn = $('#gs-voices'), palette = $('#gs-palette'), numIn = $('#gs-num'), widthIn = $('#gs-width');
let grid = []; // rows of characters
let symbol = '+';
let tool = 'BODY'; // a part name, 'NUMBER', 'CELL' or 'ERASE'
@@ -266,21 +276,26 @@ function converter({ en, voc }) {
const heightFor = (lines, voices) => lines * (4 * voices - 1) + LINE_GAP_ROWS * (lines - 1);
const LINE_GAP_ROWS = 3;
+ const cols = () => Math.max(7, Math.min(120, Math.round(+widthIn.value || 45)));
const resize = (h) => {
- const blank = () => Array(WIDTH).fill(EMPTY_CELL);
- grid = Array.from({ length: h }, (_, r) => grid[r] || blank());
+ const w = cols();
+ grid = Array.from({ length: h }, (_, r) => {
+ const row = (grid[r] || []).slice(0, w);
+ while (row.length < w) row.push(EMPTY_CELL);
+ return row;
+ });
};
const drawEditor = () => {
const symbols = [...new Set(SYMBOLS.concat(grid.flat().filter((c) => c !== EMPTY_CELL)))];
- // lattice positions are a shade lighter, so the grid shows where parts go
+ // the rows where bands go are a shade lighter, so the grid shows where parts can sit
const voices = +voicesIn.value, period = 4 * voices - 1 + LINE_GAP_ROWS;
- const w = (WIDTH + 2) * CELL, h = (grid.length + 2) * CELL, size = CELL - 2;
+ const w = (cols() + 2) * CELL, h = (grid.length + 2) * CELL, size = CELL - 2;
const out = [` `];
grid.forEach((row, r) => {
const at = r % period;
const onRow = at < 4 * voices - 1 && at % 4 !== 3;
row.forEach((ch, c) => {
- const fill = ch !== EMPTY_CELL ? ink(ch, symbols) : onRow && c % 4 !== 3 ? '#26339a' : GRID;
+ const fill = ch !== EMPTY_CELL ? ink(ch, symbols) : onRow ? '#26339a' : GRID;
out.push(` `);
});
});
@@ -290,8 +305,10 @@ function converter({ en, voc }) {
const fromDrawing = (load = false) => {
if (load) {
const rows = drawing.value.split(/\r?\n/).map((r) => r.trim()).filter(Boolean);
- if (rows.length && rows.every((r) => [...r].length === WIDTH)) {
- grid = rows.map((r) => [...r]);
+ if (rows.length) {
+ const w = Math.max(...rows.map((r) => [...r].length));
+ grid = rows.map((r) => { const row = [...r]; while (row.length < w) row.push(EMPTY_CELL); return row; });
+ widthIn.value = w;
// the voices per line as the machine reads them, so the editor shades the right positions
try {
const v = structure(rows);
@@ -303,7 +320,7 @@ function converter({ en, voc }) {
try {
const page = decode(en, drawing.value);
back.textContent = formatPage(page);
- backGraph.innerHTML = page.lines.length ? graphHtml(voc, readGraph(voc, page), pageSymbols(page)) : '';
+ backGraph.innerHTML = page.triplets.length ? graphHtml(voc, readGraph(voc, page), pageSymbols(page)) : '';
backErr.hidden = true;
} catch (err) {
back.textContent = ''; backGraph.innerHTML = '';
@@ -313,12 +330,13 @@ function converter({ en, voc }) {
const relayout = () => { resize(heightFor(+linesIn.value, +voicesIn.value)); drawEditor(); fromDrawing(); };
linesIn.addEventListener('change', () => { linesIn.value = Math.max(1, Math.min(12, Math.round(+linesIn.value || 1))); relayout(); });
voicesIn.addEventListener('change', relayout);
+ widthIn.addEventListener('change', () => { widthIn.value = cols(); relayout(); });
drawing.addEventListener('input', () => fromDrawing(true));
$('#gs-clear').addEventListener('click', () => { grid = []; relayout(); });
// the palette: one button per part, a number, a single cell, the eraser
const tools = [...Object.values(en.parts).map((p) => ({ id: p.name, label: p.name, shape: p.shape })),
- { id: 'NUMBER', label: zh ? '数' : 'number', shape: numberShape(5) },
+ { id: 'NUMBER', label: zh ? '数位' : 'digit', shape: numberShape(5) },
{ id: 'CELL', label: zh ? '单格' : 'one cell', shape: ['...', '.#.', '...'] },
{ id: 'ERASE', label: zh ? '擦除' : 'erase', shape: ['...', '...', '...'] }];
palette.innerHTML = tools.map((t) => `
@@ -340,17 +358,17 @@ function converter({ en, voc }) {
const paint = (ev, dragging) => {
const box = editor.firstElementChild.getBoundingClientRect();
- const scale = box.width / ((WIDTH + 2) * CELL);
+ const scale = box.width / ((cols() + 2) * CELL);
const c = Math.floor((ev.clientX - box.left) / scale / CELL) - 1;
const r = Math.floor((ev.clientY - box.top) / scale / CELL) - 1;
- if (r < 0 || c < 0 || r >= grid.length || c >= WIDTH) return;
+ if (r < 0 || c < 0 || r >= grid.length || c >= cols()) return;
if (tool === 'CELL') {
if (dragging) grid[r][c] = paint.mode;
else { paint.mode = grid[r][c] === EMPTY_CELL ? symbol : EMPTY_CELL; grid[r][c] = paint.mode; }
} else {
if (dragging) return;
- // stamp a whole part into the lattice position under the pointer
- const p = Math.floor(c / 4);
+ // stamp a whole part with its left edge on the cell under the pointer (kept inside the grid)
+ const left = Math.min(c, cols() - 3);
const voices = +voicesIn.value, period = 4 * voices - 1 + LINE_GAP_ROWS;
const at = r % period;
if (at >= 4 * voices - 1) return;
@@ -359,7 +377,7 @@ function converter({ en, voc }) {
? numberShape(Math.max(0, Math.min(511, Math.round(+numIn.value || 0)))) : en.parts[tool].shape;
const ch = tool === 'ERASE' ? EMPTY_CELL : symbol;
for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++) {
- if (top + i < grid.length) grid[top + i][p * 4 + j] = shape[i][j] === '#' ? ch : EMPTY_CELL;
+ if (top + i < grid.length) grid[top + i][left + j] = shape[i][j] === '#' ? ch : EMPTY_CELL;
}
}
drawEditor(); fromDrawing();
diff --git a/lib/glyph-vocab.json b/lib/glyph-vocab.json
index fa3d907..4cc67bf 100644
--- a/lib/glyph-vocab.json
+++ b/lib/glyph-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/lib/glyph-zh.json b/lib/glyph-zh.json
index f1b54e7..3c030db 100644
--- a/lib/glyph-zh.json
+++ b/lib/glyph-zh.json
@@ -81,7 +81,7 @@
"VOICE.LIGHT": {"gloss": "无线电", "note": "由光承载的声音"},
"VOICE.BODY": {"gloss": "共鸣体、乐器", "note": "会发声的物体"},
"VOICE.OPEN": {"gloss": "一个问题", "note": ""},
- "ONE": {"gloss": "那", "note": "单独放在节点位置:指上一行"},
+ "ONE": {"gloss": "那", "note": "单独放在节点位置:指前一个三元组"},
"ONE.ONE": {"gloss": "相同", "note": ""},
"ONE.NOT": {"gloss": "不同", "note": ""},
"OPEN.COUNT": {"gloss": "多少?", "note": "“什么数?”,用 COUNT.n 回答"},
diff --git a/lib/glyph.js b/lib/glyph.js
index 50f6440..64584ee 100644
--- a/lib/glyph.js
+++ b/lib/glyph.js
@@ -1,19 +1,23 @@
-// The grid script, in the browser: a port of glyph-cli 0.2.0 (https://github.com/InterImm/glyph-cli).
+// The grid script, in the browser: a port of glyph-cli 0.3.0 (https://github.com/InterImm/glyph-cli).
// Parts, words, pages, drawings and the knowledge-graph reading behave exactly as the `glyph` command does,
// and lib/glyph-vocab.json is a copy of its bundled vocabulary; tests/glyph.test.mjs checks the two agree.
// No DOM here, so the same file runs under node for the tests.
-export const POSITIONS = 6;
-export const WIDTH = POSITIONS * 4 - 1; // 23 cells
+// Spacing is structure (grammar v8): 1 empty cell between the parts of a word, 2 between the words
+// of a triplet, 4 between triplets. Bands of one line are 1 empty row apart, lines 3.
+export const GAP_PART = 1;
+export const GAP_WORD = 2;
+export const GAP_TRIPLET = 4;
export const BAND_GAP = 1;
export const LINE_GAP = 3;
+export const PER_LINE = 2; // triplets per drawn line, unless asked otherwise
export const EMPTY = '_';
export const EMPTY_CELL = '.';
export const MARK = '#';
export const NUMBER = 'COUNT'; // the kind whose which is a number
-export const MAX_NUMBER = 511;
+export const BASE = 512;
+export const MAX_DIGIT = BASE - 1;
export const SLOTS = ['subject', 'relation', 'object'];
-export const HALVES = ['kind', 'which'];
export const RESERVED_SYMBOLS = new Set([...'.#|:_ \t']);
// Colours of the phase 2 ("137") design: signal blue, white-cyan for them, Sol yellow for us.
@@ -26,8 +30,8 @@ export const OTHER_INKS = ['#c9b3ff', '#7dffa8', '#ffa45c', '#ffffff'];
export const LABELS = {
none: '—',
notInVocabulary: '(not in vocabulary)',
- statement: (n) => `[statement ${n}]`,
- thatNothing: '[that: nothing above]',
+ statement: (n) => `[triplet ${n}]`,
+ thatNothing: '[that: nothing before]',
yes: 'yes', no: 'no', instead: 'instead', 'and also': 'and also',
to: (s) => ` (to ${s})`,
quote: (s) => ` "${s}"`,
@@ -46,18 +50,42 @@ export class GlyphError extends Error {
}
// ---------- words ----------
-// A word is { kind, which }: kind is a part name or null, which is a part name, a number or null.
+// A word is { kind, which }: kind is a part name or null; which is a part name, one base-512 digit,
+// an array of digits (a number above 511) or null.
export const word = (kind = null, which = null) => ({ kind, which });
export const isEmpty = (w) => w.kind === null && w.which === null;
-export const isNumber = (w) => typeof w.which === 'number';
-export const halves = (w) => [w.kind, w.which];
-export const sameWord = (a, b) => a.kind === b.kind && a.which === b.which;
+export const isNumber = (w) => typeof w.which === 'number' || Array.isArray(w.which);
+export const digits = (w) => (Array.isArray(w.which) ? w.which : typeof w.which === 'number' ? [w.which] : []);
+export const sameWord = (a, b) => wordStr(a) === wordStr(b);
export const THAT = word('ONE', null);
+/** 2219 -> [4, 171]: base-512 digits, most significant first. */
+export function toDigits(n) {
+ if (!Number.isSafeInteger(n) || n < 0) throw new GlyphError(`only whole numbers of zero or more: ${n}`, 'whole', { n });
+ const out = [];
+ do { out.unshift(n % BASE); n = Math.floor(n / BASE); } while (n);
+ return out;
+}
+
+export const fromDigits = (ds) => ds.reduce((n, d) => n * BASE + d, 0);
+
+/** The number a COUNT word stands for (COUNT alone is 0); null for other words. */
+export function value(w) {
+ if (w.kind === NUMBER || (w.kind === null && isNumber(w))) return digits(w).length ? fromDigits(digits(w)) : 0;
+ return null;
+}
+
+/** The lattice positions a word takes: a one-part word one, a pair two, a number one per digit after COUNT. */
+export function positions(w) {
+ if (w.which === null) return [w.kind];
+ return isNumber(w) ? [w.kind, ...digits(w)] : [w.kind, w.which];
+}
+
export function wordStr(w) {
if (isEmpty(w)) return EMPTY;
- return (w.kind ?? EMPTY) + (w.which === null ? '' : `.${w.which}`);
+ const which = w.which === null ? '' : `.${isNumber(w) ? digits(w).join('.') : w.which}`;
+ return (w.kind ?? EMPTY) + which;
}
// ---------- the vocabulary ----------
@@ -75,12 +103,16 @@ export class Vocabulary {
}
this.markers = { ...(data.markers || {}) };
this.entries = data.words.map((e) => ({
- word: word(e.kind, e.which), gloss: e.gloss, domain: e.domain ?? 'Unsorted', note: e.note ?? '',
+ word: word(e.kind, Array.isArray(e.which) && e.which.length === 1 ? e.which[0] : e.which), gloss: e.gloss, domain: e.domain ?? 'Unsorted', note: e.note ?? '',
}));
this.labels = { ...LABELS, ...labels };
}
- /** 'BODY.OTHER' -> {kind: 'BODY', which: 'OTHER'}; 'COUNT.137' -> {kind: 'COUNT', which: 137}. */
+ /**
+ * 'BODY.OTHER' -> {kind: 'BODY', which: 'OTHER'}; 'COUNT.137' -> {kind: 'COUNT', which: 137};
+ * 'COUNT.4.171' -> {kind: 'COUNT', which: [4, 171]} (2219). A single number above 511 is split into
+ * digits for you: 'COUNT.2219' is COUNT.4.171.
+ */
parse(text) {
const s = String(text).trim();
if (s === '' || s === EMPTY) return word();
@@ -91,13 +123,18 @@ export class Vocabulary {
let which;
if (w === '' || w === EMPTY) {
which = null;
- } else if (/^[0-9]+$/.test(w)) {
- which = parseInt(w, 10);
- if (which > MAX_NUMBER) throw new GlyphError(`number out of range 0-${MAX_NUMBER}: ${which}`, 'range', { n: which });
+ } else if (/^[0-9]+(\.[0-9]+)*$/.test(w)) {
+ let ds = w.split('.').map((d) => parseInt(d, 10));
+ if (ds.length === 1) ds = toDigits(ds[0]);
+ else if (ds.some((d) => d > MAX_DIGIT)) throw new GlyphError(`each digit of a number is 0-${MAX_DIGIT} (base ${BASE}): ${s}`, 'digit', { s });
+ else if (ds[0] === 0 && kind === NUMBER) throw new GlyphError(`a number does not start with a 0 digit: ${s}`, 'leadingZero', { s });
if (kind !== NUMBER && kind !== null) {
- throw new GlyphError(`a number needs ${NUMBER} as its kind, e.g. ${NUMBER}.${which} (got ${s})`, 'numberKind', { n: which, s });
+ throw new GlyphError(`a number needs ${NUMBER} as its kind, e.g. ${NUMBER}.${w} (got ${s})`, 'numberKind', { n: w, s });
}
+ which = ds.length === 1 ? ds[0] : ds;
if (kind === NUMBER && which === 0) which = null; // COUNT alone is zero: COUNT.0 draws exactly like it
+ } else if (w.includes('.')) {
+ throw new GlyphError(`only numbers take more than two positions: ${s}`, 'tooLong', { s });
} else {
which = w.toUpperCase();
if (kind === NUMBER) throw new GlyphError(`${NUMBER} only takes a number as its which, e.g. ${NUMBER}.12 (got ${s})`, 'countWhich', { s });
@@ -116,6 +153,7 @@ export class Vocabulary {
thing(half) {
if (half === null) return EMPTY;
if (typeof half === 'number') return String(half);
+ if (Array.isArray(half)) return half.join('.');
return this.parts[half].thing;
}
@@ -129,10 +167,10 @@ export class Vocabulary {
base = part.relation || part.thing;
}
if (w.which === null) return base;
- const marker = this.markers[String(w.which)] ?? String(w.which);
+ const marker = this.markers[String(w.which)] || (isNumber(w) ? this.thing(w.which) : String(w.which));
return `${base} [${marker}]`;
}
- if (w.kind === NUMBER && (isNumber(w) || w.which === null)) return String(w.which || 0);
+ if (w.kind === NUMBER) return String(value(w));
const entry = this.lookup(w);
if (entry) return entry.gloss;
if (w.which === null) return this.parts[w.kind].thing;
@@ -157,11 +195,19 @@ export class Vocabulary {
shapes.set(key, p.name);
if (!p.shape.some((r) => r.includes(MARK))) errors.push(`part ${p.name}: shape is empty`);
}
+ for (const p of Object.values(this.parts)) {
+ // 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 (const moved of [p.shape.map((r) => r.slice(1) + '.'), p.shape.map((r) => '.' + r.slice(0, 2))]) {
+ const other = shapes.get(moved.join('/'));
+ if (other && other !== p.name) errors.push(`part ${p.name} moved one cell sideways is ${other}: gaps would be ambiguous`);
+ }
+ }
const seen = new Map();
const glosses = new Map();
for (const e of this.entries) {
const ws = wordStr(e.word);
- for (const half of halves(e.word)) {
+ for (const half of [e.word.kind, e.word.which]) {
if (typeof half === 'string' && !(half in this.parts)) errors.push(`${ws}: unknown part ${half}`);
}
if (e.word.kind === NUMBER) errors.push(`${ws}: ${NUMBER} words are numbers and are built in`);
@@ -182,14 +228,16 @@ export class Vocabulary {
}
// ---------- pages ----------
-// A page is { lines: [{ bands: [{ symbol, words: [w, w, w] }] }] }. The first band of a line is the statement.
+// A page is { triplets: [{ bands: [{ symbol, words: [w, w, w] }] }] }. The first band of a triplet is
+// the statement (one edge of the graph); the bands under it are other voices replying.
-export const bandHalves = (band) => band.words.flatMap(halves);
-export const pageVoices = (page) => page.lines.reduce((n, line) => Math.max(n, line.bands.length), 0);
+export const pageVoices = (page) => page.triplets.reduce((n, t) => Math.max(n, t.bands.length), 0);
+/** Positions per slot of a triplet: as many as the longest word any band puts there. */
+export const tripletWidths = (t) => [0, 1, 2].map((i) => Math.max(...t.bands.map((b) => positions(b.words[i]).length)));
export function pageSymbols(page) {
const out = [];
- for (const line of page.lines) for (const band of line.bands) if (!out.includes(band.symbol)) out.push(band.symbol);
+ for (const t of page.triplets) for (const band of t.bands) if (!out.includes(band.symbol)) out.push(band.symbol);
return out;
}
@@ -200,14 +248,14 @@ export function checkSymbol(symbol) {
return symbol;
}
-/** Read page source ("SYMBOL: node | relation | node", one band per line) into a page. */
+/** Read page source ("SYMBOL: node | relation | node", one band per line, a blank line between triplets). */
export function parsePage(text, vocab) {
- const page = { lines: [] };
+ const page = { triplets: [] };
let current = { bands: [] };
text.split(/\r?\n/).forEach((raw, i) => {
const line = raw.split('#', 1)[0].trimEnd();
if (!line.trim()) {
- if (current.bands.length) { page.lines.push(current); current = { bands: [] }; }
+ if (current.bands.length) { page.triplets.push(current); current = { bands: [] }; }
return;
}
const at = (err) => new GlyphError(`line ${i + 1}: ${err.message}`, err.code, { ...err.args, line: i + 1 });
@@ -225,19 +273,19 @@ export function parsePage(text, vocab) {
try { words = cells.map((c) => vocab.parse(c)); } catch (err) { throw at(err); }
current.bands.push({ symbol, words });
});
- if (current.bands.length) page.lines.push(current);
+ if (current.bands.length) page.triplets.push(current);
return page;
}
/** Write a page back as page source. */
export function formatPage(page) {
- const blocks = page.lines.map((line) => line.bands.map((b) => `${b.symbol}: ${b.words.map(wordStr).join(' | ')}`).join('\n'));
+ const blocks = page.triplets.map((t) => t.bands.map((b) => `${b.symbol}: ${b.words.map(wordStr).join(' | ')}`).join('\n'));
return blocks.join('\n\n') + (blocks.length ? '\n' : '');
}
// ---------- drawing ----------
-/** Nine bits, 256 ... 1, left to right, top to bottom. */
+/** One base-512 digit: nine bits, 256 ... 1, left to right, top to bottom. */
export function numberShape(n) {
const bits = n.toString(2).padStart(9, '0');
return [0, 1, 2].map((r) => [0, 1, 2].map((c) => (bits[r * 3 + c] === '1' ? MARK : EMPTY_CELL)).join(''));
@@ -249,34 +297,78 @@ export function shape(vocab, half) {
return vocab.parts[half].shape;
}
-/** Three rows of text for a run of positions drawn in one symbol. */
+/** Cells taken by a run of positions inside one word. */
+export const width = (n) => (n ? 4 * n - GAP_PART : 0);
+
+/** Three rows of text for the positions of one word drawn in one symbol. */
export function bandRows(vocab, hs, symbol) {
const rows = ['', '', ''];
hs.forEach((half, i) => {
const s = shape(vocab, half);
- for (let r = 0; r < 3; r++) rows[r] += (i ? EMPTY_CELL : '') + s[r].replaceAll(MARK, symbol);
+ for (let r = 0; r < 3; r++) rows[r] += (i ? EMPTY_CELL.repeat(GAP_PART) : '') + s[r].replaceAll(MARK, symbol);
});
return rows;
}
/** Words side by side, as a quick look-up drawing (not a page). */
export function drawWords(vocab, words, symbol = '+', spacing = 3) {
- const blocks = words.map((w) => bandRows(vocab, halves(w), symbol));
+ const blocks = words.map((w) => bandRows(vocab, positions(w), symbol));
return [0, 1, 2].map((r) => blocks.map((b) => b[r]).join(' '.repeat(spacing)));
}
-/** Draw a page as rows of text. Every line gets as many bands as the page has voices. */
-export function render(vocab, page) {
- const blank = EMPTY_CELL.repeat(WIDTH);
- const n = pageVoices(page);
+/** One triplet with `voices` bands. Each slot is as wide as its longest word. */
+export function tripletRows(vocab, t, voices) {
+ const widths = tripletWidths(t);
+ const xs = [];
+ let x = 0;
+ for (const w of widths) { xs.push(x); x += width(w) + GAP_WORD; }
+ const total = x - GAP_WORD;
const out = [];
- page.lines.forEach((line, li) => {
- if (li) for (let i = 0; i < LINE_GAP; i++) out.push(blank);
- for (let k = 0; k < n; k++) {
- if (k) for (let i = 0; i < BAND_GAP; i++) out.push(blank);
- if (k < line.bands.length) out.push(...bandRows(vocab, bandHalves(line.bands[k]), line.bands[k].symbol));
- else out.push(blank, blank, blank);
+ for (let k = 0; k < voices; k++) {
+ if (k) for (let i = 0; i < BAND_GAP; i++) out.push(EMPTY_CELL.repeat(total));
+ const band = [0, 1, 2].map(() => Array(total).fill(EMPTY_CELL));
+ if (k < t.bands.length) {
+ const b = t.bands[k];
+ b.words.forEach((w, slot) => {
+ if (isEmpty(w)) return;
+ bandRows(vocab, positions(w), b.symbol).forEach((row, r) => {
+ [...row].forEach((ch, j) => { if (ch !== EMPTY_CELL) band[r][xs[slot] + j] = ch; });
+ });
+ });
}
+ out.push(...band.map((r) => r.join('')));
+ }
+ return out;
+}
+
+/**
+ * Draw a page as rows of text. Triplets run left to right, `perLine` to a line, 4 empty cells apart.
+ * Every triplet gets as many bands as the page has voices. Rows are padded to one width.
+ */
+export function render(vocab, page, perLine = PER_LINE) {
+ if (perLine < 1) throw new GlyphError('per_line must be at least 1', 'perLine');
+ const n = pageVoices(page);
+ const grids = page.triplets.map((t) => tripletRows(vocab, t, n));
+ const out = [];
+ for (let i = 0; i < grids.length; i += perLine) {
+ if (i) for (let j = 0; j < LINE_GAP; j++) out.push('');
+ const row = grids.slice(i, i + perLine);
+ for (let r = 0; r < row[0].length; r++) out.push(row.map((g) => g[r]).join(EMPTY_CELL.repeat(GAP_TRIPLET)));
+ }
+ const w = Math.max(0, ...out.map((r) => [...r].length));
+ return out.map((r) => r + EMPTY_CELL.repeat(w - [...r].length));
+}
+
+/**
+ * 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.
+ */
+export function hiddenZeros(page, perLine = PER_LINE) {
+ const out = [];
+ page.triplets.forEach((t, idx) => {
+ const i = idx + 1;
+ if (i % perLine && i !== page.triplets.length) return;
+ if (t.bands.some((b) => { const ds = digits(b.words[2]); return isNumber(b.words[2]) && ds.length > 1 && ds[ds.length - 1] === 0; })) out.push(i);
});
return out;
}
@@ -290,8 +382,8 @@ export function ink(symbol, symbols) {
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
/** Draw rows of text as SVG: square pixels on signal blue, no curves. */
-export function rowsSvg(rows, symbols, { cell = 12, pad = 1, grid = true, width = WIDTH } = {}) {
- const w = width + 2 * pad, h = rows.length + 2 * pad;
+export function rowsSvg(rows, symbols, { cell = 12, pad = 1, grid = true, width: cells } = {}) {
+ const w = (cells ?? (rows.length ? [...rows[0]].length : 0)) + 2 * pad, h = rows.length + 2 * pad;
const out = [
``,
` `,
@@ -311,7 +403,7 @@ export function rowsSvg(rows, symbols, { cell = 12, pad = 1, grid = true, width
/** Draw a page as SVG, as `glyph render --svg` does. */
export function renderSvg(vocab, page, opts = {}) {
- return rowsSvg(render(vocab, page), pageSymbols(page), opts);
+ return rowsSvg(render(vocab, page, opts.perLine ?? PER_LINE), pageSymbols(page), opts);
}
// ---------- reading a drawing back ----------
@@ -335,64 +427,155 @@ export function structure(rows) {
throw new GlyphError("the rows don't fall into lines and bands; is this a picture rather than text?", 'structure');
}
+const bits = (pattern) => parseInt(pattern.join('').replaceAll(EMPTY_CELL, '0').replaceAll(MARK, '1'), 2);
+
/**
- * Read a drawing (as made by 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.
+ * 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. Number digits can have empty columns of their own, 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.
*/
-export function decode(vocab, text) {
- const rows = text.split(/\r?\n/).map((r) => r.trim()).filter(Boolean);
- const page = { lines: [] };
- if (!rows.length) return page;
- rows.forEach((row, i) => {
- const len = [...row].length;
- if (len !== WIDTH) {
- throw new GlyphError(`row ${i + 1} is ${len} cells wide; text is always ${WIDTH} (off the lattice: a picture?)`, 'width', { row: i + 1, len });
+class LineReader {
+ constructor(vocab, bands) {
+ this.vocab = vocab;
+ this.bands = bands; // n bands of 3 rows, each row an array of cells
+ this.width = bands[0][0].length;
+ this.byShape = new Map(Object.values(vocab.parts).map((p) => [p.shape.join('/'), p.name]));
+ this.filled = Array.from({ length: this.width }, (_, c) => bands.some((b) => b.some((row) => row[c] !== EMPTY_CELL)));
+ this.memo = new Map();
+ }
+
+ empty(start, stop) {
+ if (stop > this.width) return false;
+ for (let c = start; c < stop; c++) if (this.filled[c]) return false;
+ return true;
+ }
+
+ pattern(k, x) {
+ return this.bands[k].map((row) => row.slice(x, x + 3).map((ch) => (ch !== EMPTY_CELL ? MARK : EMPTY_CELL)).join(''));
+ }
+
+ slot(x, p) {
+ const words = [];
+ let numberAbove = false, lastUsed = false;
+ for (let k = 0; k < this.bands.length; k++) {
+ const pats = Array.from({ length: p }, (_, j) => this.pattern(k, x + 4 * j));
+ const drawn = pats.map((pat) => pat.some((r) => r.includes(MARK)));
+ if (!drawn.some(Boolean)) { words.push(word()); continue; }
+ lastUsed = lastUsed || drawn[p - 1];
+ const kind = drawn[0] ? (this.byShape.get(pats[0].join('/')) ?? undefined) : null;
+ if (kind === undefined) return null;
+ if (kind === NUMBER || (kind === null && numberAbove)) {
+ const ds = pats.slice(1).map(bits);
+ if (kind === NUMBER) {
+ lastUsed = lastUsed || p > 1;
+ while (ds.length && ds[0] === 0) ds.shift();
+ } else {
+ while (ds.length && ds[ds.length - 1] === 0) ds.pop();
+ }
+ words.push(word(kind, !ds.length ? null : ds.length === 1 ? ds[0] : ds));
+ numberAbove = numberAbove || kind === NUMBER;
+ continue;
+ }
+ if (p > 2 && drawn.slice(2).some(Boolean)) return null;
+ let which = null;
+ if (p > 1 && drawn[1]) {
+ which = this.byShape.get(pats[1].join('/')) ?? null;
+ if (which === null) return null;
+ }
+ words.push(word(kind, which));
}
- const cells = [...row];
- for (let c = 3; c < WIDTH; c += 4) {
- if (cells[c] !== EMPTY_CELL) throw new GlyphError(`row ${i + 1}, column ${c + 1}: a mark between lattice positions (a picture?)`, 'between', { row: i + 1, col: c + 1 });
+ if (p > 1 && !lastUsed) return null;
+ return words;
+ }
+
+ read() {
+ const layouts = this.layouts(0, 0);
+ if (!layouts.length) return null;
+ let best = layouts[0], top = this.score(best);
+ for (const l of layouts.slice(1)) { const sc = this.score(l); if (sc > top) { best = l; top = sc; } }
+ return best;
+ }
+
+ score(layout) {
+ let total = 0;
+ for (const slots of layout) {
+ slots.forEach(([, , words], i) => {
+ const w = words.find((x) => !isEmpty(x)) ?? word();
+ if (i === 1) {
+ const part = w.kind ? this.vocab.parts[w.kind] : null;
+ total += Boolean(part && part.relation) && (w.which === null || String(w.which) in this.vocab.markers) ? 1 : 0;
+ } else {
+ total += w.kind === NUMBER || w.which === null || this.vocab.lookup(w) ? 1 : 0;
+ }
+ });
}
- });
+ return total;
+ }
+
+ layouts(x, slot) {
+ const key = `${x}:${slot}`;
+ if (!this.memo.has(key)) this.memo.set(key, this.findLayouts(x, slot).slice(0, 64));
+ return this.memo.get(key);
+ }
+
+ findLayouts(x, slot) {
+ const found = [];
+ for (let p = 1; x + width(p) <= this.width; p++) {
+ if (p > 1 && this.filled[x + width(p - 1)]) break; // a mark where the 1-cell gap inside a word must be
+ const words = this.slot(x, p);
+ const end = x + width(p);
+ if (!words) continue;
+ const here = [x, end, words];
+ if (slot < 2) {
+ if (this.empty(end, end + GAP_WORD)) {
+ for (const rest of this.layouts(end + GAP_WORD, slot + 1)) found.push([[here, ...rest[0]], ...rest.slice(1)]);
+ }
+ } else if (this.empty(end, this.width)) {
+ found.push([[here]]);
+ } else if (this.empty(end, end + GAP_TRIPLET)) {
+ for (const rest of this.layouts(end + GAP_TRIPLET, 0)) found.push([[here], ...rest]);
+ }
+ }
+ return found;
+ }
+}
+
+/**
+ * Read a drawing (as made by render) back into a page. 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.
+ */
+export function decode(vocab, text) {
+ let rows = text.split(/\r?\n/).map((r) => r.trim()).filter(Boolean);
+ const page = { triplets: [] };
+ if (!rows.length) return page;
+ const w = Math.max(...rows.map((r) => [...r].length));
+ rows = rows.map((r) => r + EMPTY_CELL.repeat(w - [...r].length));
const grid = rows.map((r) => [...r]);
const n = structure(rows);
const period = 4 * n - 1 + LINE_GAP;
- const byShape = new Map(Object.values(vocab.parts).map((p) => [p.shape.join('/'), p.name]));
for (let top = 0; top < grid.length; top += period) {
- const line = { bands: [] };
- for (let k = 0; k < n; k++) {
- const r0 = top + 4 * k;
- const band = grid.slice(r0, r0 + 3);
- const marks = new Set(band.flat().filter((ch) => ch !== EMPTY_CELL));
- if (!marks.size) continue;
- const where = `rows ${r0 + 1}-${r0 + 3}`;
- if (marks.size > 1) throw new GlyphError(`${where}: one band mixes symbols ${[...marks].sort().join(' ')}`, 'mixed', { from: r0 + 1, to: r0 + 3 });
- const symbol = checkSymbol([...marks][0]);
- const hs = [];
- for (let pos = 0; pos < POSITIONS; pos++) {
- const pattern = band.map((row) => row.slice(pos * 4, pos * 4 + 3).map((ch) => (ch !== EMPTY_CELL ? MARK : EMPTY_CELL)).join(''));
- if (!pattern.some((p) => p.includes(MARK))) { hs.push(null); continue; }
- const slot = Math.floor(pos / 2);
- if (pos % 2 === 0) {
- const name = byShape.get(pattern.join('/'));
- if (name === undefined) throw new GlyphError(`${where}, position ${pos + 1}: not a part`, 'notPart', { from: r0 + 1, to: r0 + 3, pos: pos + 1 });
- hs.push(name);
- } else {
- const above = line.bands.length ? line.bands[0].words[slot] : null;
- const kind = hs[hs.length - 1];
- let which;
- if (kind === NUMBER || (kind === null && above !== null && (above.kind === NUMBER || above.which === NUMBER))) {
- which = parseInt(pattern.join('').replaceAll(EMPTY_CELL, '0').replaceAll(MARK, '1'), 2);
- } else {
- which = byShape.get(pattern.join('/')) ?? null;
- }
- if (which === null) throw new GlyphError(`${where}, position ${pos + 1}: not a part or a number here`, 'notWhich', { from: r0 + 1, to: r0 + 3, pos: pos + 1 });
- hs.push(which);
+ const bands = Array.from({ length: n }, (_, k) => grid.slice(top + 4 * k, top + 4 * k + 3));
+ const layout = new LineReader(vocab, bands).read();
+ if (!layout) {
+ throw new GlyphError(`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?)`,
+ 'gaps', { from: top + 1, to: top + 4 * n - 1 });
+ }
+ for (const slots of layout) {
+ const start = slots[0][0], stop = slots[slots.length - 1][1];
+ const t = { bands: [] };
+ for (let k = 0; k < n; k++) {
+ const words = slots.map((s) => s[2][k]);
+ if (words.every(isEmpty)) continue;
+ const marks = new Set(bands[k].flatMap((row) => row.slice(start, stop)).filter((ch) => ch !== EMPTY_CELL));
+ if (marks.size > 1) {
+ const r0 = top + 4 * k;
+ throw new GlyphError(`rows ${r0 + 1}-${r0 + 3}: one band mixes symbols ${[...marks].sort().join(' ')}`, 'mixed', { from: r0 + 1, to: r0 + 3 });
}
+ t.bands.push({ symbol: checkSymbol([...marks][0]), words });
}
- line.bands.push({ symbol, words: [0, 2, 4].map((i) => word(hs[i], hs[i + 1])) });
+ if (t.bands.length) page.triplets.push(t);
}
- if (line.bands.length) page.lines.push(line);
}
return page;
}
@@ -418,13 +601,19 @@ export function responseText(r, labels = LABELS) {
return `${r.slot}.${r.half}: ${labels[r.meaning]}${tail}${to}`;
}
+/** kind, which, or "digit n" in a number of several digits. */
+export function positionName(w, index) {
+ if (index === 0) return 'kind';
+ return isNumber(w) && digits(w).length > 1 ? `digit ${index}` : 'which';
+}
+
/**
- * Rule 3: the first band of every line is one edge. Rule 5: every part in a lower band answers the
- * nearest drawn part above it in the same position.
+ * Rule 3: every triplet is one edge, read from its first band. Rule 5: every part in a lower band
+ * answers the nearest drawn part above it in the same position of the same slot.
*/
export function readGraph(vocab, page) {
const g = { edges: [], nodes: {} };
- page.lines.forEach((line, idx) => {
+ page.triplets.forEach((line, idx) => {
const i = idx + 1;
const st = line.bands[0];
const [subjectW, relationW, objectW] = st.words;
@@ -440,14 +629,16 @@ export function readGraph(vocab, page) {
line.bands.slice(1).forEach((band, b0) => {
const b = b0 + 1;
const reply = { symbol: band.symbol, responses: [] };
- bandHalves(band).forEach((below, pos) => {
- let above = null, by = null;
- for (let j = b - 1; j >= 0; j--) {
- const prev = bandHalves(line.bands[j])[pos];
- if (prev !== null) { above = prev; by = line.bands[j].symbol; break; }
- }
- const meaning = respond(above, below);
- if (meaning) reply.responses.push({ slot: SLOTS[Math.floor(pos / 2)], half: HALVES[pos % 2], meaning, part: vocab.thing(below), to: by });
+ band.words.forEach((w, slot) => {
+ positions(w).forEach((below, pos) => {
+ let above = null, by = null;
+ for (let j = b - 1; j >= 0; j--) {
+ const prev = positions(line.bands[j].words[slot]);
+ if (pos < prev.length && prev[pos] !== null) { above = prev[pos]; by = line.bands[j].symbol; break; }
+ }
+ const meaning = respond(above, below);
+ if (meaning) reply.responses.push({ slot: SLOTS[slot], half: positionName(w, pos), meaning, part: vocab.thing(below), to: by });
+ });
});
edge.replies.push(reply);
});
diff --git a/script/convert/en/index.html b/script/convert/en/index.html
index 8dc9dd1..a0090f8 100644
--- a/script/convert/en/index.html
+++ b/script/convert/en/index.html
@@ -41,14 +41,14 @@ Text to grid, grid to text
Text → drawing
-
One band per row: SYMBOL: node | relation | node. Words are KIND.WHICH from the dictionary , numbers are COUNT.n, and _ is empty. Rows under a statement are other voices answering it. A blank row starts the next statement. # starts a comment.
+
One band per row: SYMBOL: node | relation | node. Words are KIND.WHICH from the dictionary , numbers are COUNT and base-512 digits (COUNT.137, COUNT.4.171 for 2219, or just COUNT.2219), and _ is empty. A block of rows is one triplet; rows under its first are other voices answering it. A blank row starts the next triplet. # starts a comment.
Examples
Pick an example …
- A conversation
+ A conversation
We saw your world
We no longer see you
We saw that
@@ -56,10 +56,12 @@ Text → drawing
A question
How many?
A third voice
+ The year 2219
Page source
+
Triplets per line 1 2 3 4
Symbols: + is them, × is us; any other single character is another voice.
@@ -81,7 +83,7 @@
Text → drawing
Drawing → text
-
Pick a part and click a position to stamp it, or pick "one cell" and draw freely. The lighter squares are the lattice: text only ever sits there, so a mark between them makes it a picture. You can also paste a drawing as text, one row per line, with . for empty cells.
+
Pick a part and click to stamp it, or pick "one cell" and draw freely. The lighter rows are where bands go. Leave 1 empty cell between the parts of a word, 2 between words and 4 between triplets: the machine reads the structure from those gaps alone, and anything that won't split into whole parts is a picture. You can also paste a drawing as text, one row per line, with . for empty cells.
@@ -95,8 +97,9 @@
Drawing → text
- Number
+ Digit
Lines
+ Width
Voices 1 2 3
Clear
diff --git a/script/convert/index.html b/script/convert/index.html
index 1da1428..3124a26 100644
--- a/script/convert/index.html
+++ b/script/convert/index.html
@@ -41,14 +41,14 @@
文字变图,图变文字
文字 → 图
-
每行一个声部:符号: 节点 | 关系 | 节点。词写成词典 里的 类.指,数写成 COUNT.n,_ 表示空。一句下面的行是其他声音在回应它。空一行开始下一句。# 后面是注释。
+
每行一个声部:符号: 节点 | 关系 | 节点。词写成词典 里的 类.指,数写成 COUNT 加 512 进制的数位(COUNT.137;2219 写作 COUNT.4.171,也可以直接写 COUNT.2219),_ 表示空。一组行是一个三元组,第一行下面的行是其他声音在回应它。空一行开始下一个三元组。# 后面是注释。
例子
选一个例子 …
- 一场对话
+ 一场对话
我们看见过你们的世界
我们看不见你们了
我们看见过那个
@@ -56,10 +56,12 @@ 文字 → 图
一个问题
多少?
第三个声音
+ 2219 年
页面源文
+
每行几个三元组 1 2 3 4
符号:+ 是他们,× 是我们;其他任何单个字符是另一个声音。
@@ -81,7 +83,7 @@
文字 → 图
图 → 文字
-
选一个部件,点一个位置把它盖上去;或者选“单格”随手画。颜色浅一些的方块是格点:文字只会落在那里,画在它们之间的记号会让它变成一幅图。也可以粘贴用文字表示的图,每行一排,. 表示空格。
+
选一个部件,点一下把它盖上去;或者选“单格”随手画。颜色浅一些的排是声部所在的地方。词里的部件之间空 1 格,词与词空 2 格,三元组之间空 4 格:翻译机只靠这些空隙读出结构,切不成完整部件的就是一幅图。也可以粘贴用文字表示的图,每行一排,. 表示空格。
@@ -95,8 +97,9 @@
图 → 文字
- 数
- 句数
+ 数位
+ 行数
+ 宽
声音数 1 2 3
清空
diff --git a/script/en/index.html b/script/en/index.html
index e842778..f64407d 100644
--- a/script/en/index.html
+++ b/script/en/index.html
@@ -51,7 +51,8 @@
One grid, one mark per voice
Everything is drawn on one shared grid of cells.
Each party draws only with its own mark: + for Ross 128 b, × for us. Marks never cover each other, so you can always see who drew what.
One grid square of 3 × 3 cells is one part, and two parts make a word, the way strokes make a Chinese character. It is not an alphabet.
-
A line is one statement. Under it, other voices draw their answers.
+
Spacing is structure: 1 empty cell between the parts of a word, 2 between the words of a triplet, 4 between triplets.
+
A triplet is one statement. Under it, other voices draw their answers.
So far, everything they have sent is drawn in + alone. The second band in the drawing is a reply humans could send: water, instead of air.
@@ -75,7 +76,7 @@
Sixteen parts
3 · Words
Two parts make a word
-
A word takes two neighbouring positions: the kind on the left and the which on the right, written KIND.WHICH. A world that is yours is your world; a star that keeps time is a pulsar; air going is wind. A one-part word leaves the which empty. The dictionary lists every agreed word.
+
A word is two parts, one empty cell apart: the kind on the left and the which on the right, written KIND.WHICH. A world that is yours is your world; a star that keeps time is a pulsar; air going is wind. A one-part word is just 3 cells wide. The dictionary lists every agreed word.
@@ -85,8 +86,8 @@
Two parts make a word
4 · Lines
-
Every line is one edge of a graph
-
A line is six positions wide (23 cells), so it holds three words: a node, a relation, another node. A text is a stack of lines, so a text is a knowledge graph. 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.
+
Every triplet is one edge of a graph
+
A text is a run of triplets (node, relation, node), read left to right and wrapping onto new lines. Each triplet is one edge of a knowledge graph, so a text is a graph. The gaps say it all: 1 cell inside a word, 2 between words, 4 between triplets, 3 rows between lines. The same word in two triplets is the same node, and a lone ONE in a node slot means "that": the triplet before.
@@ -118,20 +119,21 @@
Every line is one edge of a graph
5 · Numbers
-
Numbers are nine bits
-
A number is COUNT, a counting line, followed by a which read as nine bits: 256 down to 1, left to right, top to bottom. COUNT alone is zero, so 0 to 511 fit. COUNT is the only kind whose which is read as bits, so a number can never be mistaken for a word.
+
Numbers are base-512 digits
+
A number is COUNT, a counting line, followed by base-512 digits, most significant first. Each digit is nine bits: 256 down to 1, left to right, top to bottom. COUNT alone is zero; 137 is COUNT.137; the year 2219 is COUNT.4.171, because 4 × 512 + 171 = 2219. Only the positions after COUNT are read as bits, so a number can never be mistaken for a word.
+
@@ -141,7 +143,7 @@
Numbers are nine bits
6 · Voices
Stacking means another voice
-
Only a new party adds a band under a line, and each party keeps its band, so the number of bands is the number of voices. Every part in a lower band answers the nearest drawn part above it in the same position. That lets anyone agree with, deny or change any node or edge, part by part. A conversation is a graph several voices build together.
+
Only a new party adds a band under a triplet, and each party keeps its band, so the number of bands is the number of voices. Every part in a lower band answers the nearest drawn part above it in the same position of the same word. That lets anyone agree with, deny or change any node or edge, part by part. A conversation is a graph several voices build together.