cjk-semantic-split 0.1.3__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. {cjk_semantic_split-0.1.3 → cjk_semantic_split-0.2.0}/.gitignore +32 -3
  2. {cjk_semantic_split-0.1.3 → cjk_semantic_split-0.2.0}/PKG-INFO +1 -1
  3. {cjk_semantic_split-0.1.3 → cjk_semantic_split-0.2.0}/docs/superpowers/specs/2026-07-16-cjk-semantic-split-design.md +167 -75
  4. {cjk_semantic_split-0.1.3 → cjk_semantic_split-0.2.0}/pyproject.toml +2 -2
  5. {cjk_semantic_split-0.1.3 → cjk_semantic_split-0.2.0}/scripts/harvest.py +69 -29
  6. {cjk_semantic_split-0.1.3 → cjk_semantic_split-0.2.0}/src/chinese_decompose/__init__.py +1 -1
  7. cjk_semantic_split-0.2.0/src/chinese_decompose/data/decomp_l1.txt +74410 -0
  8. cjk_semantic_split-0.2.0/src/chinese_decompose/data/decomp_l2.txt +6 -0
  9. cjk_semantic_split-0.2.0/src/chinese_decompose/data/decomp_l3.txt +8 -0
  10. cjk_semantic_split-0.2.0/src/chinese_decompose/data/decomp_patches.txt +6 -0
  11. cjk_semantic_split-0.2.0/src/chinese_decompose/data/primitives.txt +1510 -0
  12. {cjk_semantic_split-0.1.3 → cjk_semantic_split-0.2.0}/src/chinese_decompose/dsl.py +34 -3
  13. {cjk_semantic_split-0.1.3 → cjk_semantic_split-0.2.0}/src/chinese_decompose/loader.py +64 -5
  14. {cjk_semantic_split-0.1.3 → cjk_semantic_split-0.2.0}/src/chinese_decompose/primitives.py +25 -4
  15. {cjk_semantic_split-0.1.3 → cjk_semantic_split-0.2.0}/tests/test_decompose.py +7 -7
  16. {cjk_semantic_split-0.1.3 → cjk_semantic_split-0.2.0}/tests/test_harvest.py +13 -11
  17. {cjk_semantic_split-0.1.3 → cjk_semantic_split-0.2.0}/tests/test_primitives.py +13 -10
  18. {cjk_semantic_split-0.1.3 → cjk_semantic_split-0.2.0}/tests/test_smoke.py +1 -1
  19. cjk_semantic_split-0.1.3/.hypothesis/.gitignore +0 -9
  20. cjk_semantic_split-0.1.3/.hypothesis/constants/28b72037d99ba6eb +0 -4
  21. cjk_semantic_split-0.1.3/.hypothesis/constants/2b726390919ba924 +0 -4
  22. cjk_semantic_split-0.1.3/.hypothesis/constants/3f14f0ebb32e0fff +0 -4
  23. cjk_semantic_split-0.1.3/.hypothesis/constants/447154ffb3443956 +0 -4
  24. cjk_semantic_split-0.1.3/.hypothesis/constants/448896e190e1a139 +0 -4
  25. cjk_semantic_split-0.1.3/.hypothesis/constants/44fdbf8fc7dec723 +0 -4
  26. cjk_semantic_split-0.1.3/.hypothesis/constants/487391a4cba1e887 +0 -4
  27. cjk_semantic_split-0.1.3/.hypothesis/constants/6dc13c365eb3deeb +0 -4
  28. cjk_semantic_split-0.1.3/.hypothesis/constants/7d0a29d738e12257 +0 -4
  29. cjk_semantic_split-0.1.3/.hypothesis/constants/a686eee4be3e3873 +0 -4
  30. cjk_semantic_split-0.1.3/.hypothesis/constants/a80ac1b9c26466c7 +0 -4
  31. cjk_semantic_split-0.1.3/.hypothesis/constants/cd01bf70c5c5b577 +0 -4
  32. cjk_semantic_split-0.1.3/.hypothesis/constants/dc1aa6aba7748109 +0 -4
  33. cjk_semantic_split-0.1.3/.hypothesis/constants/e6674edc9b17fd2d +0 -4
  34. cjk_semantic_split-0.1.3/.hypothesis/constants/eb3de9a3db1e637c +0 -4
  35. cjk_semantic_split-0.1.3/.hypothesis/constants/f2e388b96604c388 +0 -4
  36. cjk_semantic_split-0.1.3/.hypothesis/unicode_data/15.1.0/charmap.json.gz +0 -0
  37. cjk_semantic_split-0.1.3/.superpowers/sdd/.gitignore +0 -1
  38. cjk_semantic_split-0.1.3/.superpowers/sdd/progress.md +0 -43
  39. cjk_semantic_split-0.1.3/.superpowers/sdd/review-22d28a8..564c3e6.diff +0 -227
  40. cjk_semantic_split-0.1.3/.superpowers/sdd/review-564c3e6..0b4b3f6.diff +0 -146
  41. cjk_semantic_split-0.1.3/.superpowers/sdd/task-1-brief.md +0 -237
  42. cjk_semantic_split-0.1.3/.superpowers/sdd/task-1-report.md +0 -79
  43. cjk_semantic_split-0.1.3/.superpowers/sdd/task-10-report.md +0 -12
  44. cjk_semantic_split-0.1.3/.superpowers/sdd/task-11-report.md +0 -7
  45. cjk_semantic_split-0.1.3/.superpowers/sdd/task-12-report.md +0 -8
  46. cjk_semantic_split-0.1.3/.superpowers/sdd/task-13-report.md +0 -13
  47. cjk_semantic_split-0.1.3/.superpowers/sdd/task-14-report.md +0 -7
  48. cjk_semantic_split-0.1.3/.superpowers/sdd/task-15-report.md +0 -11
  49. cjk_semantic_split-0.1.3/.superpowers/sdd/task-2-brief.md +0 -161
  50. cjk_semantic_split-0.1.3/.superpowers/sdd/task-2-report.md +0 -76
  51. cjk_semantic_split-0.1.3/.superpowers/sdd/task-3-brief.md +0 -249
  52. cjk_semantic_split-0.1.3/.superpowers/sdd/task-3-report.md +0 -74
  53. cjk_semantic_split-0.1.3/.superpowers/sdd/task-4-report.md +0 -9
  54. cjk_semantic_split-0.1.3/.superpowers/sdd/task-5-report.md +0 -19
  55. cjk_semantic_split-0.1.3/.superpowers/sdd/task-6-report.md +0 -13
  56. cjk_semantic_split-0.1.3/.superpowers/sdd/task-7-report.md +0 -21
  57. cjk_semantic_split-0.1.3/.superpowers/sdd/task-8-report.md +0 -14
  58. cjk_semantic_split-0.1.3/.superpowers/sdd/task-9-report.md +0 -19
  59. cjk_semantic_split-0.1.3/src/chinese_decompose/data/decomp_l1.json +0 -1
  60. cjk_semantic_split-0.1.3/src/chinese_decompose/data/decomp_l2.json +0 -1
  61. cjk_semantic_split-0.1.3/src/chinese_decompose/data/decomp_l3.json +0 -5
  62. cjk_semantic_split-0.1.3/src/chinese_decompose/data/decomp_patches.json +0 -1
  63. cjk_semantic_split-0.1.3/src/chinese_decompose/data/primitives.json +0 -1
  64. {cjk_semantic_split-0.1.3 → cjk_semantic_split-0.2.0}/.github/workflows/ci.yml +0 -0
  65. {cjk_semantic_split-0.1.3 → cjk_semantic_split-0.2.0}/LICENSE +0 -0
  66. {cjk_semantic_split-0.1.3 → cjk_semantic_split-0.2.0}/README.md +0 -0
  67. {cjk_semantic_split-0.1.3 → cjk_semantic_split-0.2.0}/docs/component-position-encoding.md +0 -0
  68. {cjk_semantic_split-0.1.3 → cjk_semantic_split-0.2.0}/docs/decomposition-logic.md +0 -0
  69. {cjk_semantic_split-0.1.3 → cjk_semantic_split-0.2.0}/docs/superpowers/plans/2026-07-16-cjk-semantic-split-implementation.md +0 -0
  70. {cjk_semantic_split-0.1.3 → cjk_semantic_split-0.2.0}/scripts/__init__.py +0 -0
  71. {cjk_semantic_split-0.1.3 → cjk_semantic_split-0.2.0}/scripts/review_conflicts.py +0 -0
  72. {cjk_semantic_split-0.1.3 → cjk_semantic_split-0.2.0}/src/chinese_decompose/__main__.py +0 -0
  73. {cjk_semantic_split-0.1.3 → cjk_semantic_split-0.2.0}/src/chinese_decompose/api.py +0 -0
  74. {cjk_semantic_split-0.1.3 → cjk_semantic_split-0.2.0}/src/chinese_decompose/errors.py +0 -0
  75. {cjk_semantic_split-0.1.3 → cjk_semantic_split-0.2.0}/src/chinese_decompose/split.py +0 -0
  76. {cjk_semantic_split-0.1.3 → cjk_semantic_split-0.2.0}/src/chinese_decompose/structures.py +0 -0
  77. {cjk_semantic_split-0.1.3 → cjk_semantic_split-0.2.0}/tests/__init__.py +0 -0
  78. {cjk_semantic_split-0.1.3 → cjk_semantic_split-0.2.0}/tests/conftest.py +0 -0
  79. {cjk_semantic_split-0.1.3 → cjk_semantic_split-0.2.0}/tests/test_api.py +0 -0
  80. {cjk_semantic_split-0.1.3 → cjk_semantic_split-0.2.0}/tests/test_cli.py +0 -0
  81. {cjk_semantic_split-0.1.3 → cjk_semantic_split-0.2.0}/tests/test_compose.py +0 -0
  82. {cjk_semantic_split-0.1.3 → cjk_semantic_split-0.2.0}/tests/test_dsl.py +0 -0
  83. {cjk_semantic_split-0.1.3 → cjk_semantic_split-0.2.0}/tests/test_loader.py +0 -0
  84. {cjk_semantic_split-0.1.3 → cjk_semantic_split-0.2.0}/tests/test_properties.py +0 -0
  85. {cjk_semantic_split-0.1.3 → cjk_semantic_split-0.2.0}/tests/test_read_structure.py +0 -0
  86. {cjk_semantic_split-0.1.3 → cjk_semantic_split-0.2.0}/tests/test_split.py +0 -0
  87. {cjk_semantic_split-0.1.3 → cjk_semantic_split-0.2.0}/tests/test_structures.py +0 -0
@@ -4,6 +4,8 @@ __pycache__/
4
4
  *$py.class
5
5
  *.so
6
6
  .Python
7
+
8
+ # Build / distribution / packaging
7
9
  build/
8
10
  develop-eggs/
9
11
  dist/
@@ -17,17 +19,20 @@ sdist/
17
19
  var/
18
20
  wheels/
19
21
  *.egg-info/
20
- .installed.cfg
22
+ *.egg-info
21
23
  *.egg
22
24
  MANIFEST
25
+ .installed.cfg
23
26
 
24
27
  # Virtual envs
25
28
  .venv/
26
29
  venv/
27
30
  env/
28
31
  ENV/
32
+ .env
33
+ .env.local
29
34
 
30
- # Test / coverage
35
+ # Test / coverage / type-checker caches
31
36
  .pytest_cache/
32
37
  .coverage
33
38
  .coverage.*
@@ -35,10 +40,34 @@ htmlcov/
35
40
  .tox/
36
41
  .cache
37
42
  coverage.xml
43
+ coverage.txt
38
44
  *.cover
45
+ .hypothesis/
46
+ .mypy_cache/
47
+ .pyre/
48
+ .pytype/
49
+ .ruff_cache/
39
50
 
40
- # IDE
51
+ # IDEs / editors
41
52
  .idea/
42
53
  .vscode/
54
+ .vscode-test/
43
55
  *.swp
56
+ *.swo
57
+ *~
44
58
  .DS_Store
59
+ Thumbs.db
60
+
61
+ # AI tooling session data
62
+ .aider*
63
+ .claude/
64
+ .superpowers/
65
+
66
+ # Project-specific scratch
67
+ /dist/
68
+
69
+ # Logs / runtime
70
+ *.log
71
+ *.pid
72
+ *.seed
73
+ *.pid.lock
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cjk-semantic-split
3
- Version: 0.1.3
3
+ Version: 0.2.0
4
4
  Summary: Recursive atomic decomposition of CJK characters with 9-grid 2-digit spatial encoding. 将汉字递归拆解为原子部件,并在 9 宫格 2 位数字空间编码中标记每个组件的位置。
5
5
  Project-URL: Source, https://github.com/yueguanyu/Semantic-Character-Splitting-and-Structural-Coding-for-Chinese-Script.git
6
6
  Project-URL: Bug Tracker, https://github.com/yueguanyu/Semantic-Character-Splitting-and-Structural-Coding-for-Chinese-Script/issues
@@ -25,10 +25,11 @@ A Python library that, given any CJK Unified Ideograph, returns a flat DSL strin
25
25
 
26
26
  ### 1.3 Reference Standards & Sources
27
27
 
28
- - ISO/IEC 10646 IDC (Ideographic Description Characters): `吅 吕 罒 目 回 冂 凵 匚 厂 勹 匕 .` — repurposed as initial structure inspiration
29
- - Wikimedia Commons:Chinese_characters_decomposition — primary data source for 2nd & 3rd layer harvesting
30
- - Unihan Database (`kIDS` field) — primary source for the 1st layer and base coverage
31
- - CHISE projectreference for canonical primitive set
28
+ - ISO/IEC 10646 IDC (Ideographic Description Characters): `吅 吕 罒 目 回 冂 凵 匚 厂 勹 匕 .` — repurposed as initial structure inspiration; same notation appears as Unicode IDC `⿰ ⿱ ⿲ ⿳ ⿴ ⿵ ⿶ ⿷ ⿸ ⿹ ⿺ ⿻ ⿼ ⿽ ⿾ ⿿`.
29
+ - Wikimedia Commons:Chinese_characters_decomposition — visual decomposition **principle** (primitives are visually irreducible units, not structurally minimal); also the planned source for Tier 2/3 harvesting.
30
+ - **yi-bai/ids** (MIT-licensed) — current **operational** source for Tier 1 (~82k chars); provides the same IDC notation that Unihan `kIDS` historically used.
31
+ - Unihan Database (`kIDS` field) historically the primary Tier 1 source; no longer included as a separate file in current Unicode distributions (the `kIDS` field was removed from Unihan.txt in 9.x+).
32
+ - CHISE project — historical reference for canonical primitive sets.
32
33
 
33
34
  ---
34
35
 
@@ -91,16 +92,18 @@ Full table (`P ⊙ Q → R`). `-` indicates a geometrically invalid combination
91
92
  | P\Q | 00 | 10 | 20 | 30 | 40 | 50 | 60 | 70 | 80 | 9X |
92
93
  |-------|----|----|----|----|----|----|----|----|----|----|
93
94
  | **00**| 00 | 10 | 20 | 30 | 40 | 50 | 60 | 70 | 80 | 9X |
94
- | **10**| 10 | 10 | - | 50 | 60 | 50 | 60 | - | - | - |
95
- | **20**| 20 | - | 20 | 70 | 80 | - | - | 70 | 80 | - |
96
- | **30**| 30 | 50 | 70 | 30 | 30 | 50 | 50 | 70 | 70 | - |
97
- | **40**| 40 | 60 | 80 | 40 | 40 | 60 | 60 | 80 | 80 | - |
98
- | **50**| 50 | 50 | 50 | 50 | 50 | 50 | 50 | 70 | 70 | - |
99
- | **60**| 60 | 60 | 60 | 60 | 60 | 60 | 60 | 80 | 80 | - |
100
- | **70**| 70 | 50 | 70 | 70 | 70 | 50 | 50 | 70 | 70 | - |
101
- | **80**| 80 | 60 | 80 | 80 | 80 | 60 | 60 | 80 | 80 | - |
95
+ | **10**| 10 | 10 | - | 50 | 60 | 50 | 60 | - | - | 9X |
96
+ | **20**| 20 | - | 20 | 70 | 80 | - | - | 70 | 80 | 9X |
97
+ | **30**| 30 | 50 | 70 | 30 | 30 | 50 | 50 | 70 | 70 | 9X |
98
+ | **40**| 40 | 60 | 80 | 40 | 40 | 60 | 60 | 80 | 80 | 9X |
99
+ | **50**| 50 | 50 | 50 | 50 | 50 | 50 | 50 | 70 | 70 | 9X |
100
+ | **60**| 60 | 60 | 60 | 60 | 60 | 60 | 60 | 80 | 80 | 9X |
101
+ | **70**| 70 | 50 | 70 | 70 | 70 | 50 | 50 | 70 | 70 | 9X |
102
+ | **80**| 80 | 60 | 80 | 80 | 80 | 60 | 60 | 80 | 80 | 9X |
102
103
  | **9X**| 00 | 10 | 20 | 30 | 40 | 50 | 60 | 70 | 80 | - |
103
104
 
105
+ **Note on the `9X` columns in rows 10–80**: spec §2.2 only formalized "surround parent passes children through" (the row `9X`). Real CJK data nests surrounds inside directional parents (e.g. `明` contains `日`, which decomposes as `⿴口一` — surround-with-content inside a left-zone). The implementation adds the **symmetric** rule "a surround child of any parent retains its own slot" so the table is closed for the surround column too. This is documented in `src/chinese_decompose/structures.py` near `COMPOSE_TABLE`.
106
+
104
107
  **Worked example** (踩):
105
108
  ```
106
109
  踩 → 足 at 30 + 采 at 40
@@ -109,7 +112,7 @@ Recurse:
109
112
  足_final = 30 (already primitive-leaf)
110
113
  爪_final = 40 ⊙ 10 = 60 (top-right)
111
114
  木_final = 40 ⊙ 20 = 80 (bottom-right)
112
- DSL: 踩[30]:足[60]:爪[80]:木
115
+ DSL: 踩 [30]:足 [60]:爪 [80]:木
113
116
  ```
114
117
 
115
118
  ---
@@ -118,13 +121,16 @@ DSL: 踩[30]:足[60]:爪[80]:木
118
121
 
119
122
  ### 3.1 Definition
120
123
 
121
- A primitive is a non-decomposable semantic component. Examples:
124
+ A primitive is a non-decomposable semantic component — a visual atom that the walker treats as a leaf. Examples:
122
125
 
123
126
  ```
124
127
  木 火 土 金 水 人 口 手 心 日 月 目 耳 鼻 舌 身 爪 戈 矢 石
125
128
  一 乙 二 三 四 五 六 七 八 九 十 丁 卜 厂 寸 干 弓 牛 犬 玉 王
129
+ 宀 冖 冫 氵 扌 忄 讠 纟 亻 刂 艹 阝 彳 凵 匚 廴 疒 攴
126
130
  ```
127
131
 
132
+ `primitives.json` is **authoritative** for atomicity. This means: if the bundled JSON lists `日` as primitive, the walker stops at `日` even if a deeper data tier (e.g. yi-bai/ids) records `日 → ⿴口一`. The rule follows the **wikimedia commons** visual decomposition view — primitives are the smallest irreducible units, not whatever deeper structure the source suggests. See §6.3 for the runtime resolution order.
133
+
128
134
  ### 3.2 Storage & Override
129
135
 
130
136
  - Default set bundled as `chinese_decompose/data/primitives.json` (~250 entries).
@@ -147,12 +153,16 @@ A character is primitive if and only if it appears in the resolved primitive set
147
153
 
148
154
  ## 4. Decomposition Data Sources & Tiers
149
155
 
150
- | Tier | File | Source | Coverage target | Description |
151
- |------|------------------------------|-------------------------------|-----------------|-------------|
152
- | 1 | `data/decomp_l1.json` | Unihan `kIDS` | ≥ 21,000 chars | Normalized to 9-grid; covers CJK Unified base block |
153
- | 2 | `data/decomp_l2.json` | wikimedia Commons + cleanup | ≥ 22,000 chars | Cross-checked against Tier 1; fills gaps |
154
- | 3 | `data/decomp_l3.json` | wikimedia "next-layer" harvest | ≥ 80,000 chars | Recursive harvest; supports 踩 → 足 爪 木 |
155
- | 4 | `data/decomp_patches.json` | Manual curation | dispute cases | Last-wins overrides for ambiguous/dubious entries |
156
+ | Tier | File | Source | Coverage target | Description |
157
+ |------|------------------------------|---------------------------------------|-----------------|-------------|
158
+ | 1 | `data/decomp_l1.json` | **yi-bai/ids** (MIT, via jsDelivr CDN) | ≥ 81,000 chars | Currently ~82k; covers CJK Unified + Ext-A |
159
+ | 2 | `data/decomp_l2.json` | wikimedia Commons + cleanup | ≥ 22,000 chars | Cross-checked against Tier 1; fills gaps |
160
+ | 3 | `data/decomp_l3.json` | wikimedia "next-layer" harvest | ≥ 80,000 chars | Recursive harvest; supports 踩 → 足 爪 木 |
161
+ | 4 | `data/decomp_patches.json` | Manual curation | dispute cases | Last-wins overrides for ambiguous/dubious entries |
162
+
163
+ Note on Unihan `kIDS`: the field that originally seeded Tier 1 was removed from current Unicode distributions (Unihan 9.x+ no longer ships a single combined `Unihan.txt`). The project switched to **yi-bai/ids** which provides the same IDC notation (⿰⿱⿲⿳⿴⿵…) and is actively maintained.
164
+
165
+ Note on IDs that are skipped during harvest: 3-cell horizontal (⿲), 3-cell vertical (⿳), and overlaid (⿻/⿼/⿽) — see §11 (open questions).
156
166
 
157
167
  ### 4.1 Loader Resolution
158
168
 
@@ -196,39 +206,71 @@ The data pipeline is **not** part of the Python package; it is a separate offlin
196
206
 
197
207
  ## 5. DSL Format
198
208
 
199
- ### 5.1 Grammar (informal EBNF)
209
+ ### 5.1 Format Convention
210
+
211
+ The DSL is a **staggered** encoding: each segment's bracket carries the position of the *next* segment's character. The last leaf is bare. A single space separates every character from its following `[pos]` bracket.
200
212
 
201
213
  ```
202
- dsl = char "[" pos "]" ":" component (":" component)*
203
- component = char "[" pos "]"
204
- | char "[" pos "]" ":" component (":" component)* (* primitive expansion optional *)
205
- pos = "00" | "10" | "20" | "30" | "40"
206
- | "50" | "60" | "70" | "80"
207
- | "14" | "16" | "34" | "36"
208
- | "90" | "91" | "92" | "93" | "94" | "95" | "96" | "97" | "98" | "99"
214
+ char_pos_segment = char " " "[" pos "]" (* space is part of the format *)
215
+ last_leaf = char (* no bracket, no preceding space *)
216
+
217
+ dsl_segment = char_pos_segment (":" char_pos_segment)* ":" last_leaf
209
218
  ```
210
219
 
211
- Primitives terminate expansion. Non-primitives expand their one-level definition recursively, with each descendant's position computed by chaining `⊙`.
220
+ Primitives terminate expansion they emit only `char` (no brackets, no space).
212
221
 
213
- ### 5.2 Examples
222
+ The bracket carries the **next** leaf's position (not the current character's). Example walk for `踩`:
214
223
 
215
- | Input | Output DSL |
216
- |-------|-----------------------------------------------------------|
217
- | 好 | `好[30]:女[40]:子` |
218
- | | `踩[30]:足[60]:爪[80]:木` |
219
- || `森[10]:木[20]:木[40]:木` |
220
- | 回 | `回[90]:囗[00]:口` |
221
- | 囚 | `囚[90]:囗[00]:人` |
222
- | 目 | `目[10]: [sub-cells 14, 16] [20]` — a 4-cell vertical layout. Concrete example TBD by data fixtures. |
223
- | 一 | `一` (primitive, no brackets) |
224
- | ? | `?[?]:?` (unknown char marker — see §6.2) |
224
+ ```
225
+ 踩 [30]:足 [60]:爪 [80]:木
226
+ ↑ ↑ ↑ ↑ ↑ ↑ ↑
227
+ root pos | | | | last
228
+ | | |
229
+ ↓ ↓ ↓
230
+ pos pos last (no bracket, no space)
231
+ ```
232
+
233
+ Reading top-to-bottom:
234
+ - `踩 [30]` — root `踩`, next leaf's position is 30 → meaning `足` will appear at composed position 30
235
+ - `足 [60]` — first leaf `足`, next leaf's position is 60 → `爪` at position 60
236
+ - `爪 [80]` — second leaf `爪`, next leaf's position is 80 → `木` at position 80
237
+ - `木` — third (last) leaf, bare, no bracket
238
+
239
+ The bracket offset-by-one encodes the same final positions as the explicit `tree.positions` field (`(30, 60, 80)` here) but inline for human reading.
225
240
 
226
- ### 5.3 Multi-Character Input
241
+ ### 5.2 Grammar (informal EBNF)
242
+
243
+ ```
244
+ dsl_segment = atom (":" atom)* (* the leaf count is determined by data *)
245
+ atom = primitive (* bare char, no brackets, no space *)
246
+ | char " " "[" pos "]"
247
+ primitve = the char itself (no expansion needed)
248
+
249
+ pos = "00" | "10" | "20" | "30" | "40"
250
+ | "50" | "60" | "70" | "80"
251
+ | "14" | "16" | "34" | "36"
252
+ | "90" | "91" | "92" | "93" | "94" | "95" | "96" | "97" | "98" | "99"
253
+ ```
254
+
255
+ The parser tolerates the same character-pos-bracket form on every segment; the final segment is bare by convention (the parser also accepts a space-then-`[` on the final segment for backward compatibility with one-level emit).
256
+
257
+ ### 5.3 Examples
258
+
259
+ | Input | Output DSL | Notes |
260
+ |-------|-----------------------------------------------------|-------|
261
+ | 一 | `一` | primitive — bare |
262
+ | 好 | `好 [30]:女 [40]:子` | 2 leaves, root + 2 atom segments |
263
+ | 踩 | `踩 [30]:足 [60]:爪 [80]:木` | 3 leaves, intermediate 采 absorbed into final composed positions |
264
+ | 森 | `森 [10]:木 [20]:木 [40]:木` | 3 leaves, vertical 3-cell |
265
+ | 国 | `国 [90]:囗 [0]:玉` | surround + content |
266
+ | ? | `?[?]:?` | unknown-char marker (non-strict mode) — fixed sentinel, brackets use literal `?` |
267
+
268
+ ### 5.4 Multi-Character Input
227
269
 
228
270
  For an input string of length > 1, per-character DSL segments are joined by `|`:
229
271
 
230
272
  ```
231
- decompose("踩好") → 踩[30]:足[60]:爪[80]:木|好[30]:女[40]:子
273
+ decompose("踩好") → 踩 [30]:足 [60]:爪 [80]:木|好 [30]:女 [40]:子
232
274
  ```
233
275
 
234
276
  ---
@@ -238,29 +280,36 @@ decompose("踩好") → 踩[30]:足[60]:爪[80]:木|好[30]:女[40]:子
238
280
  ```python
239
281
  from chinese_decompose import (
240
282
  decompose,
283
+ read_structure,
241
284
  DecompositionTree,
242
- DSLParser,
285
+ parse_dsl,
286
+ serialize_tree,
243
287
  set_primitives,
244
- PrimitiveSet,
245
288
  get_primitives,
246
289
  )
247
290
 
248
- # Primary API
249
- decompose("踩") # → str DSL
250
- decompose("踩好") # → str DSL (multi-char)
251
- decompose("踩", as_tree=True) # → DecompositionTree
291
+ # Primary APIs
292
+ decompose("踩") # → str DSL (staggered format with spaces)
293
+ decompose("踩好") # → multi-char DSL joined by '|'
294
+ decompose("踩", as_tree=True) # → list[DecompositionTree] (FLAT: intermediates absorbed)
295
+
296
+ # Structural read (NESTED — preserves intermediate non-leaf nodes)
297
+ read_structure("踩") # → list[DecompositionTree]
298
+ # trees[0].children[1] is 采 (intermediate node preserved)
299
+ # trees[0].children[1].position == 40 (its slot in 踩's frame)
252
300
 
253
301
  # Programmatic access
254
- tree = decompose("踩", as_tree=True)
302
+ tree = read_structure("踩")[0]
255
303
  tree.char # '踩'
256
- tree.positions # [30, 60, 80]
257
- tree.atoms # ['足', '爪', '木']
258
- tree.children # list[DecompositionTree] for subtrees
259
- tree.to_dsl() # round-trip DSL
304
+ tree.atoms # ('足', '爪', '木') — flat list of leaves in DFS order
305
+ tree.positions # (30, 60, 80) — composed final position of each leaf
306
+ tree.children # tuple[DecompositionTree, ...] may include intermediates
307
+ tree.to_dsl() # same as serialize_tree(tree)
260
308
 
261
- # Round-trip
262
- parsed = DSLParser.parse("踩[30]:足[60]:爪[80]:木")
263
- parsed.to_dict() # {'踩': [{'char': '足', 'pos': 30}, {'char': '爪', 'pos': 60}, ...]}
309
+ # Round-trip (parser tolerant of the staggered form)
310
+ parsed = parse_dsl("踩 [30]:足 [60]:爪 [80]:木")
311
+ parsed.char # '踩'
312
+ parsed.atoms # ('足', '爪', '木')
264
313
 
265
314
  # Primitive control
266
315
  set_primitives({"木", "火", "水", ...})
@@ -269,18 +318,30 @@ get_primitives() # → frozenset[str]
269
318
 
270
319
  ### 6.1 Return Types
271
320
 
272
- - `decompose(text: str, as_tree: bool = False) -> str | DecompositionTree`
273
- - `DecompositionTree` is an immutable value object (no setters).
321
+ - `decompose(text: str, as_tree: bool = False, strict: bool = True, loader=None) -> str | list[DecompositionTree]`
322
+ - `read_structure(text: str, strict: bool = True, loader=None) -> list[DecompositionTree]`
323
+ - `DecompositionTree` is an immutable `@dataclass(frozen=True)` with:
324
+ - `char: str`
325
+ - `children: tuple[DecompositionTree, ...]` (empty for primitives and unknown markers)
326
+ - `position: int | None` (composed final position; `None` means root)
327
+ - Derived properties: `atoms: tuple[str, ...]`, `positions: tuple[int, ...]` (flat over DFS)
274
328
 
275
329
  ### 6.2 Exceptions
276
330
 
277
331
  | Exception | Raised when |
278
332
  |------------------------------|-------------|
279
- | `UnknownCharacterError` | A character in `text` has no entry across all tiers. |
280
- | `MalformedDSLParseError` | Parser fails on input string. |
333
+ | `UnknownCharacterError` | A character in `text` has no entry across all tiers (and `strict=True`). |
334
+ | `MalformedDSLParseError` | DSL parser fails on input string (bad position, missing colons, etc.). |
281
335
  | `InvalidPositionCodeError` | Data file contains a code outside the valid 2-digit set (see §2.1). |
336
+ | `CompositionError` | `compose(parent, child)` is called with a geometrically invalid pair (per §2.3 table). |
337
+
338
+ Unknown character handling: with `strict=False`, decompose emits `?[?]:?` marker tokens in place of unknown chars. `read_structure` emits a marker tree `DecompositionTree(char="?", children=(DecompositionTree(char="?", children=(), position=0),))`.
282
339
 
283
- Unknown character handling for non-strict mode: return `?[?]:?` token instead of raising. Strict mode (default for the library; non-strict for CLI) controlled by `decompose(text, strict=True)`.
340
+ ### 6.3 Caller-Atomicity Rule
341
+
342
+ The bundled `primitives.json` is the **authoritative atomicity definition**: any character in it is treated as atomic by the walker, even if some data tier records a further decomposition for it. This matches the wikimedia commons visual decomposition view — primitives are the visual irreducible units, not whatever deeper IDS structure the source data suggests.
343
+
344
+ Priority on lookup: `is_primitive(char)` short-circuits before loader lookup, so prims win over data (last-wins at the loader level is moot for prims). This rule is **load-bearing** for characters that appear both in `primitives.json` and in `decomp_l{1,2,3}.json` (e.g. `日`).
284
345
 
285
346
  ---
286
347
 
@@ -347,28 +408,47 @@ Semantic-Character-Splitting-and-Structural-Coding-for-Chinese-Script/
347
408
 
348
409
  ### 9.1 `python -m chinese_decompose`
349
410
 
411
+ The default output format is **JSON**. Use `--format dsl` for the staggered DSL string.
412
+
350
413
  ```bash
351
414
  $ python -m chinese_decompose 踩
352
- [30]:足[60]:爪[80]:木
415
+ [
416
+ {
417
+ "char": "踩",
418
+ "atoms": ["足", "爪", "木"],
419
+ "positions": [30, 60, 80],
420
+ "dsl": "踩 [30]:足 [60]:爪 [80]:木"
421
+ }
422
+ ]
423
+
424
+ $ python -m chinese_decompose 踩 --format dsl
425
+ 踩 [30]:足 [60]:爪 [80]:木
353
426
 
354
427
  $ python -m chinese_decompose 囚 --format json
355
- {"char": "囚", "atoms": ["囗", "人"], "positions": [90, 00], "dsl": "囚[90]:囗[00]:人"}
428
+ [
429
+ {
430
+ "char": "囚",
431
+ "atoms": ["囗", "人"],
432
+ "positions": [90, 0],
433
+ "dsl": "囚 [90]:囗 [0]:人"
434
+ }
435
+ ]
356
436
 
357
437
  $ python -m chinese_decompose --self-test
358
- Coverage: 32784 chars indexed across 4 tiers
438
+ chinese_decompose self-test
439
+ version: 0.1.3
440
+ characters indexed: 81686
441
+ tiers: 4 (l1, l2, l3, patches)
359
442
  ```
360
443
 
361
- Flags: `--format {dsl,json,tree}`, `--no-strict`, `--primitives <file>`.
444
+ Flags: `--format {json,dsl,tree}`, `--no-strict`, `--primitives <file>`.
362
445
 
363
446
  ### 9.2 Offline Harvest Script (`scripts/harvest.py`)
364
447
 
365
- - Reads Unihan `Unihan_IRGSources.txt` (or its `kIDS` extract).
366
- - Reads wikimedia Commons decomposition XML.
367
- - Normalizes to data format.
368
- - Emits `decomp_l{1,2,3}.json` next to sources.
369
- - Logs tier conflicts to `scripts/conflicts.log`.
448
+ - **Tier 1** (currently implemented): downloads **yi-bai/ids** (MIT-licensed, ~102k entries) via the jsDelivr CDN mirror, parses TSV-formatted IDC notation, and writes `decomp_l1.json`. Per the §6.3 rule, it never strips primitives from `primitives.json` — it only **adds** new leaves it discovered.
449
+ - **Tier 2/3**: planned (wikimedia Commons scrape + next-layer recursive harvest); returns `NotImplementedError` if invoked with `--tier l2` or `--tier l3` today.
370
450
 
371
- This script is **not** part of the installable package; it lives in `scripts/` and is invoked manually when refreshing data.
451
+ This script is **not** part of the installable package; it lives in `scripts/` and is invoked manually when refreshing data. It requires network access on first run.
372
452
 
373
453
  ---
374
454
 
@@ -386,8 +466,20 @@ This script is **not** part of the installable package; it lives in `scripts/` a
386
466
 
387
467
  ## 11. Open Questions & Future Work
388
468
 
469
+ ### Shipped (since the spec was written)
470
+
471
+ - DSL format finalized: **staggered bracket + space between char-and-position** (`踩 [30]:足 [60]:爪 [80]:木`); last leaf bare.
472
+ - CLI default format is **JSON**; `--format dsl` for the string form.
473
+ - **`read_structure()`** API added: returns `list[DecompositionTree]` that preserves **intermediate non-leaf nodes** (e.g. `采` inside `踩`), unlike `decompose()` which collapses them into a flat atom list.
474
+ - **Real-data harvest (v0.1.3)**: `scripts/harvest.py` now pulls ~82k characters from yi-bai/ids; bundled in `decomp_l1.json` at install time.
475
+ - **Caller-atomicity rule** (§6.3): `primitives.json` is the authoritative primitive set; characters in it stay atomic even when layered data says otherwise.
476
+
477
+ ### Still open
478
+
389
479
  - **Position grammar extensions**: future versions may add codes for repeated patterns (e.g. repetitive 吕 罒 structures) as sub-positions.
390
- - **Bidirectional mapping**: `compose(characters_from_atoms(...))` for the inverse direction not in v0.1.
391
- - **Heuristic decomposer**: for characters with no data entry, attempt primitive grouping — out of scope for v0.1.
392
- - **CJK Extensions B–G**: properly indexing beyond CJK Unified base block relies on Tier 3 harvest quality.
393
- - **Performance**: prototype assumed sub-millisecond per character; v0.1 ships without benchmarks, defer to v0.2 if needed.
480
+ - **3-cell IDC handling**: `⿲` (LMR) and `⿳` (TMB) entries are currently **skipped** by the harvester (out of scope for the 2-digit schema). They are needed for full coverage.
481
+ - **Overlaid IDC handling**: `⿻`/`⿼`/`⿽` patterns skipped.
482
+ - **Bidirectional mapping**: `compose(atoms char)` inversenot in v0.x.
483
+ - **Heuristic decomposer**: for characters with no data entry, attempt primitive grouping out of scope.
484
+ - **CJK Extensions B–G yi-bai coverage**: ~82k chars covers most of the Unified Ideographs block + Extension A but doesn't sweep Extensions B–F systematically. Tiers 2/3 (wikimedia scrape + recursive next-layer) remain unimplemented.
485
+ - **Performance**: prototype assumed sub-millisecond per character; v0.1.3 ships without benchmarks, defer to v0.2 if needed.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cjk-semantic-split"
7
- version = "0.1.3"
7
+ version = "0.2.0"
8
8
  description = "Recursive atomic decomposition of CJK characters with 9-grid 2-digit spatial encoding. 将汉字递归拆解为原子部件,并在 9 宫格 2 位数字空间编码中标记每个组件的位置。"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -40,7 +40,7 @@ dev = [
40
40
 
41
41
  [tool.hatch.build.targets.wheel]
42
42
  packages = ["src/chinese_decompose"]
43
- include = ["src/chinese_decompose/data/*.json"]
43
+ include = ["src/chinese_decompose/data/*.txt"]
44
44
 
45
45
  [tool.pytest.ini_options]
46
46
  testpaths = ["tests"]
@@ -7,7 +7,7 @@ notation (⿰⿱⿴⿵⿶⿷⿸⿹⿺⿾⿿, plus 3-cell and overlaid variants w
7
7
 
8
8
  Usage:
9
9
  python -m scripts.harvest --tier l1 \
10
- --output ../src/chinese_decompose/data/decomp_l1.json
10
+ --output ../src/chinese_decompose/data/decomp_l1.txt
11
11
 
12
12
  Source file format (tab-separated):
13
13
 
@@ -153,6 +153,11 @@ def _walk_kids(
153
153
  return False
154
154
  composed = compose_fn(parent_position, local_pos)
155
155
  if len(comp_str) == 1:
156
+ # Reject leaves that are themselves IDC codes (e.g. degenerate
157
+ # yi-bai entries where ⿰ appears as a slot — that's a parse
158
+ # artifact, not a real atom).
159
+ if comp_str in IDC_TABLE_2_CELL:
160
+ return False
156
161
  out.append((comp_str, composed))
157
162
  else:
158
163
  # Recurse into subtree
@@ -221,8 +226,40 @@ def parse_ids_source(text: str, compose_fn) -> tuple[dict[str, list[tuple[str, i
221
226
  return entries, refs
222
227
 
223
228
 
229
+ def _serialize_dsl(char: str, comps: list[tuple[str, int]]) -> str:
230
+ """Build the staggered DSL string for a character + its components.
231
+
232
+ Matches the format used by ``DecompositionTree.to_dsl()``:
233
+ "踩 [30]:足 [60]:爪 [80]:木" — the bracket carries the *next* leaf's
234
+ position; the last leaf is bare. A primitive char with no children
235
+ yields just the bare char.
236
+ """
237
+ if not comps:
238
+ return char # primitive — bare char
239
+ parts = [f"{char} [{comps[0][1]}]"]
240
+ leaves = [c for c, _ in comps]
241
+ leaf_positions = [p for _, p in comps]
242
+ for i in range(len(leaves) - 1):
243
+ parts.append(f"{leaves[i]} [{leaf_positions[i + 1]}]")
244
+ parts.append(leaves[-1])
245
+ return ":".join(parts)
246
+
247
+
248
+ def _load_existing_primitives_txt(path: Path) -> set[str]:
249
+ if not path.exists():
250
+ return set()
251
+ chars: set[str] = set()
252
+ for line in path.read_text(encoding="utf-8").splitlines():
253
+ line = line.strip()
254
+ if not line or line.startswith("#"):
255
+ continue
256
+ if len(line) == 1:
257
+ chars.add(line)
258
+ return chars
259
+
260
+
224
261
  def emit_tier1(output_path: Path, compose_fn) -> dict:
225
- """Download IDS dataset, parse, write decomp_l1.json and extend primitives.json."""
262
+ """Download IDS dataset, parse, write decomp_l1.txt and extend primitives.txt."""
226
263
  print(f"Downloading {IDS_SOURCES['lv0']}...", file=sys.stderr)
227
264
  text = fetch_text(IDS_SOURCES["lv0"])
228
265
  print(f" fetched {len(text.splitlines())} lines", file=sys.stderr)
@@ -232,18 +269,26 @@ def emit_tier1(output_path: Path, compose_fn) -> dict:
232
269
  print(f" entries: {len(entries)}, primitive refs: {len(refs)}", file=sys.stderr)
233
270
 
234
271
  output_path.parent.mkdir(parents=True, exist_ok=True)
235
- payload = {"_meta": {"tier": 1, "source": "github.com/yi-bai/ids", "license": "MIT"}}
236
- payload.update({char: [[c, p] for c, p in v] for char, v in entries.items()})
237
- output_path.write_text(
238
- json.dumps(payload, ensure_ascii=False, separators=(",", ":")), encoding="utf-8"
239
- )
272
+
273
+ # Emit Tier 1 as TXT (DSL format, one line per char). Format:
274
+ # <char>\t<DSL>
275
+ # Blank lines and lines starting with `#` are comments.
276
+ lines = [
277
+ f"# Tier 1 decomposition data harvested from yi-bai/ids (MIT) via jsDelivr",
278
+ "# Format per line: <char>\\t<DSL>",
279
+ "# DSL is the staggered form: each bracket carries the NEXT atom's",
280
+ "# position; the last atom is bare. Lines starting with # are comments.",
281
+ "# Empty DSL after the tab means the character is primitive at this tier.",
282
+ ]
283
+ for char in sorted(entries):
284
+ lines.append(f"{char}\t{_serialize_dsl(char, entries[char])}")
285
+ output_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
240
286
  size_kb = output_path.stat().st_size // 1024
241
287
  print(f" wrote {output_path} ({size_kb} KB)", file=sys.stderr)
242
288
 
243
289
  # Find all characters that get further decomposed in `entries`
244
- # (recursively, since chars may appear nested). These MUST be removed
245
- # from the primitive set so walker behavior is unambiguous — if a char
246
- # is in both primitives and l1, l1 wins (per last-wins loader order).
290
+ # (recursively, since chars may appear nested). These MUST NOT be
291
+ # promoted into the primitive set.
247
292
  def _collect_decomposable(char: str, seen: set[str], into: set[str]) -> None:
248
293
  if char in seen:
249
294
  return
@@ -260,33 +305,28 @@ def emit_tier1(output_path: Path, compose_fn) -> dict:
260
305
  for char in entries:
261
306
  _collect_decomposable(char, set(), decomposable)
262
307
 
263
- # Primitive leaves: characters in `refs` that NEVER get decomposed (i.e.,
264
- # not in the `decomposable` set we just built). These stay in the
265
- # primitive set; the rest get removed.
266
308
  leaves_without_decomp = {
267
309
  c for c in refs if c not in decomposable and _is_data_char(c)
268
310
  }
269
311
 
270
- # Append new primitives to primitives.json. The bundled primitives.json is
271
- # the AUTHORITATIVE atomicity definition (per wikimedia commons' visual
272
- # decomposition view): any character in it is treated as atomic, even if
273
- # the IDS source happens to have a further decomposition. We only ADD
274
- # new leaves — never remove from the curated set.
312
+ # Append new leaves to primitives.txt. Per the wikimedia commons
313
+ # atomicity principle (§6.3 of the spec), primitives.txt is the
314
+ # authoritative atomicity definition we only ADD new leaves; never
315
+ # remove from the curated set.
275
316
  primitives_path = (
276
- Path(__file__).parent.parent / "src" / "chinese_decompose" / "data" / "primitives.json"
317
+ Path(__file__).parent.parent / "src" / "chinese_decompose" / "data" / "primitives.txt"
277
318
  )
278
- if primitives_path.exists():
279
- existing = set(json.loads(primitives_path.read_text(encoding="utf-8"))["primitives"])
280
- else:
281
- existing = set()
319
+ existing = _load_existing_primitives_txt(primitives_path)
282
320
  new_primitives = sorted(existing | leaves_without_decomp)
321
+ primitives_lines = [
322
+ "# chinese-decompose primitives (authoritative atomic components per wikimedia commons view)",
323
+ "# Format: one character per line. Lines starting with # are comments.",
324
+ "# Characters listed here are treated as atomic by the walker regardless",
325
+ "# of any deeper structure recorded in decomp_*.txt.",
326
+ ]
327
+ primitives_lines += new_primitives
283
328
  primitives_path.write_text(
284
- json.dumps(
285
- {"primitives": new_primitives},
286
- ensure_ascii=False,
287
- separators=(",", ":"),
288
- ),
289
- encoding="utf-8",
329
+ "\n".join(primitives_lines) + "\n", encoding="utf-8"
290
330
  )
291
331
  added = len(new_primitives) - len(existing)
292
332
  print(
@@ -1,5 +1,5 @@
1
1
  """chinese_decompose: recursive atomic decomposition of CJK characters."""
2
- __version__ = "0.1.3"
2
+ __version__ = "0.2.0"
3
3
 
4
4
  from chinese_decompose.api import (
5
5
  DecompositionTree,