thebasedcapital commited on
Commit
783c92f
·
verified ·
1 Parent(s): c33d66c

morph-hrr v0.1.0: compositional HRR morpheme tokenizer

Browse files
.pytest_cache/.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Created by pytest automatically.
2
+ *
.pytest_cache/CACHEDIR.TAG ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ Signature: 8a477f597d28d172789f06886806bc55
2
+ # This file is a cache directory tag created by pytest.
3
+ # For information about cache directory tags, see:
4
+ # https://bford.info/cachedir/spec.html
.pytest_cache/README.md ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # pytest cache directory #
2
+
3
+ This directory contains data from the pytest's cache plugin,
4
+ which provides the `--lf` and `--ff` options, as well as the `cache` fixture.
5
+
6
+ **Do not** commit this to version control.
7
+
8
+ See [the docs](https://docs.pytest.org/en/stable/how-to/cache.html) for more information.
.pytest_cache/v/cache/lastfailed ADDED
@@ -0,0 +1 @@
 
 
1
+ {}
.pytest_cache/v/cache/nodeids ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ "tests/test_compositionality.py::test_oov_root_family",
3
+ "tests/test_compositionality.py::test_prefix_recovery_beats_control",
4
+ "tests/test_compositionality.py::test_roles_are_near_orthogonal",
5
+ "tests/test_compositionality.py::test_shared_root_clusters",
6
+ "tests/test_compositionality.py::test_shared_suffix_clusters",
7
+ "tests/test_hrr_math.py::test_bind_unbind_supports_batch",
8
+ "tests/test_hrr_math.py::test_bundle_recovery_beats_distractors",
9
+ "tests/test_hrr_math.py::test_determinism_same_seed",
10
+ "tests/test_hrr_math.py::test_empty_bundle_raises",
11
+ "tests/test_hrr_math.py::test_normalize_yields_unit_norm",
12
+ "tests/test_hrr_math.py::test_unitary_bind_unbind_recovers_value",
13
+ "tests/test_hrr_math.py::test_unitary_keys_have_unit_fft_magnitude",
14
+ "tests/test_hrr_math.py::test_update_context_stays_normalized_and_finite",
15
+ "tests/test_segmentation.py::test_empty",
16
+ "tests/test_segmentation.py::test_non_alpha_passthrough",
17
+ "tests/test_segmentation.py::test_segment[KING-expected10]",
18
+ "tests/test_segmentation.py::test_segment[a-expected11]",
19
+ "tests/test_segmentation.py::test_segment[disconnected-expected8]",
20
+ "tests/test_segmentation.py::test_segment[happiness-expected4]",
21
+ "tests/test_segmentation.py::test_segment[happy-expected9]",
22
+ "tests/test_segmentation.py::test_segment[kingdom-expected5]",
23
+ "tests/test_segmentation.py::test_segment[preorder-expected7]",
24
+ "tests/test_segmentation.py::test_segment[running-expected2]",
25
+ "tests/test_segmentation.py::test_segment[unhappy-expected0]",
26
+ "tests/test_segmentation.py::test_segment[unkind-expected1]",
27
+ "tests/test_segmentation.py::test_segment[walking-expected3]",
28
+ "tests/test_segmentation.py::test_segment[walks-expected6]"
29
+ ]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BB Claude
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # morph-hrr
2
+
3
+ A **compositional morpheme tokenizer built on Holographic Reduced Representations (HRR)**, for Apple MLX.
4
+
5
+ Each word becomes one fixed-width dense vector by *binding* its morphemes (prefix ⊗ root ⊗ suffix) into a single "holistic" superposition. Because the binding is circular convolution with **unitary role vectors**, you can algebraically pull a morpheme back out: `unbind(word_vec, prefix_role)` recovers the prefix. Words that share morphology land near each other in vector space, and an out-of-vocabulary word built from its pieces still neighbors its root family.
6
+
7
+ ## What this is — and isn't
8
+
9
+ **It is:** an **input representation / embedding layer** for HRR-native or experimental models. It emits dense vectors, one per word, and exposes the HRR algebra (`bind`, `unbind`, `bundle`, `make_unitary`) behind them.
10
+
11
+ **It is not:** a HuggingFace text↔ID tokenizer. It does not produce token IDs, has no vocabulary, and will not drop into `transformers`. Think of it as an alternative to a learned embedding table — one whose vectors are *compositional by construction* rather than arbitrary.
12
+
13
+ **It is not:** a way to make a pretrained Qwen/Gemma smaller or faster. HRR input is a different representation than what pretrained weights expect; you cannot swap it into an existing model without retraining from scratch. (See *Scope & honesty* below.)
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install morph-hrr # Apple Silicon (mlx is the only dependency)
19
+ ```
20
+
21
+ Development:
22
+
23
+ ```bash
24
+ git clone <repo> && cd morph-hrr
25
+ pip install -e ".[test]"
26
+ pytest -q
27
+ ```
28
+
29
+ ## Quickstart
30
+
31
+ ```python
32
+ from morph_hrr import MorphemeTokenizer, unbind, cosine_similarity
33
+
34
+ tok = MorphemeTokenizer(dim=2048) # deterministic given seed
35
+
36
+ # A word is one fixed-width vector, composed from its morphemes by role.
37
+ v = tok.word_vector("unhappy") # mx.array, shape (2048,)
38
+ print(tok.segment("unhappy")) # ('un', 'happy', '')
39
+
40
+ # Composition is algebraic: recover the prefix filler by unbinding its role.
41
+ recovered = unbind(v, tok.prefix_role)
42
+ print(cosine_similarity(recovered, tok.bytes_vector("un"))) # ~0.6 (vs ~0 control)
43
+
44
+ # Shared morphology => shared neighborhood.
45
+ print(cosine_similarity(tok.word_vector("unhappy"),
46
+ tok.word_vector("happy"))) # > 0.2 (vs ~0 unrelated)
47
+
48
+ # Encode a sentence: one float16 vector per word.
49
+ mat = tok.encode("the quick brown fox") # mx.array, shape (4, 2048), float16
50
+ ```
51
+
52
+ ## The math (one paragraph)
53
+
54
+ `bind(a, b)` is circular convolution, computed in the Fourier domain as `real(IFFT(FFT(a) * FFT(b)))`. `unbind(bind(a,b), b)` is the convolution inverse, `real(IFFT(FFT(bound) * conj(FFT(b))))`. A **unitary** vector has all FFT magnitudes equal to 1, so binding with it is a perfect, lossless rotation — unbinding recovers the original to >0.99 cosine. A word vector bundles three role⊗filler pairs — `prefix_role ⊗ bytes(prefix)`, `root_role ⊗ bytes(root)`, `suffix_role ⊗ bytes(suffix)` — into one normalized superposition; unbinding a role pulls its filler back out of the superposition (the other two act as noise, which is why longer dims recover more cleanly).
55
+
56
+ ## Compositionality demo (regression-tested, dim=2048)
57
+
58
+ | Property | HRR cosine | Random control | Test |
59
+ |---|---|---|---|
60
+ | Prefix recovery (`unbind(unhappy, prefix_role)` ≈ `bytes("un")`) | > 0.50 | ≈ 0 | `test_prefix_recovery_beats_control` |
61
+ | Shared-root clustering (`unhappy` ~ `happy`) | > 0.20 | ≈ 0 | `test_shared_root_clusters` |
62
+ | Shared-suffix clustering (`running` ~ `walking`) | > 0.15 | ≈ 0 | `test_shared_suffix_clusters` |
63
+ | OOV root family (`unbind(unkind, root_role)` ≈ `bytes("kind")`) | > 0.50 | ≈ 0 | `test_oov_root_family` |
64
+ | Role vectors near-orthogonal | < 0.10 | — | `test_roles_are_near_orthogonal` |
65
+
66
+ Each HRR measurement is paired against a random-vector control of the same dimension, asserting the structured signal is meaningfully stronger than noise.
67
+
68
+ ## Public API
69
+
70
+ | Name | What |
71
+ |---|---|
72
+ | `MorphemeTokenizer(dim=2048, seed=0)` | Map text → HRR morpheme vectors |
73
+ | `.segment(word)` | `(prefix, root, suffix)` |
74
+ | `.word_vector(word)` / `.bytes_vector(text)` | Holistic / byte-fold word vector |
75
+ | `.encode(text)` / `.iter_vectors(text)` | Stacked `(n, dim)` float16 / lazy yield |
76
+ | `.prefix_role` / `.root_role` / `.suffix_role` | Exposed unitary role vectors (for `unbind`) |
77
+ | `bind`, `unbind`, `bundle`, `normalize`, `make_unitary`, `cosine_similarity`, `update_context` | HRR primitives |
78
+ | `segment`, `PREFIXES`, `SUFFIXES` | Standalone morphological segmentation |
79
+
80
+ `HolographicMorphemeTokenizer` is kept as a backwards-compatible alias for `MorphemeTokenizer`.
81
+
82
+ ## Segmentation: deliberately simple
83
+
84
+ `morphemes.segment` is dependency-free, longest-match affix stripping over curated prefix/suffix lists, with a minimum-root guard. It is **imperfect by design**: it does not undo consonant doubling (`running → runn + ing`, not `run + ing`) and cannot tell a real root from a suffixable tail (`preorder → pre + ord + er`). No dictionary, no learned model, no `nltk`. This keeps the package lightweight; better segmentation is future work and is orthogonal to the HRR representation itself.
85
+
86
+ ## Scope & honesty
87
+
88
+ - **mlx-only.** Apple Silicon target audience; a numpy/JAX backend is a documented future option, not this release.
89
+ - **Representation, not a drop-in tokenizer.** No token IDs, no HF integration.
90
+ - **Can't retrofit pretrained models.** Swapping HRR input into a real model throws away its pretrained weights — you must train from scratch. On consumer hardware (e.g. 16 GB) that means small research-scale models, not deployable ones. The value of this package is the *compositional input representation* and the HRR algebra, demonstrated on small models.
91
+ - v0.1 ships the tokenizer + primitives + tests. A demo Space, a trained reference model, and a portable backend are future work.
92
+
93
+ ## License
94
+
95
+ MIT. See `LICENSE`.
96
+
97
+ ## Cite / priority
98
+
99
+ If you build on this representation in published work, please cite this repository. The compositional HRR morpheme representation (role⊗filler binding of prefix/root/suffix via circular convolution, with unitary roles enabling algebraic morpheme manipulation) is, to our knowledge, novel as a tokenization scheme; we'd appreciate attribution.
pyproject.toml ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "morph-hrr"
7
+ version = "0.1.0"
8
+ description = "Compositional HRR morpheme tokenizer/embeddings: circular-convolution prefix (x) root (x) suffix, for Apple MLX."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ authors = [{ name = "BB Claude" }]
13
+ keywords = ["hrr", "vsa", "hyperdimensional", "tokenizer", "morphology", "mlx", "embeddings", "circular-convolution"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: MacOS",
18
+ ]
19
+ dependencies = ["mlx"]
20
+
21
+ [project.optional-dependencies]
22
+ test = ["pytest"]
23
+
24
+ [project.urls]
25
+ Repository = "https://huggingface.co/<user>/morph-hrr"
26
+
27
+ [tool.hatch.build.targets.wheel]
28
+ packages = ["src/morph_hrr"]
29
+
30
+ [tool.pytest.ini_options]
31
+ pythonpath = ["src"]
src/morph_hrr/__init__.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """morph-hrr: compositional HRR morpheme tokenizer for Apple MLX.
2
+
3
+ A word becomes one fixed-width vector via circular-convolution composition of its
4
+ morphemes (prefix (x) root (x) suffix), giving algebraically manipulable,
5
+ out-of-vocabulary-friendly token representations.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ __version__ = "0.1.0"
10
+
11
+ from .hrr import (
12
+ bind,
13
+ bundle,
14
+ cosine_similarity,
15
+ make_unitary,
16
+ normalize,
17
+ unbind,
18
+ update_context,
19
+ )
20
+ from .morphemes import PREFIXES, SUFFIXES, segment
21
+ from .tokenizer import HolographicMorphemeTokenizer, MorphemeTokenizer
22
+
23
+ __all__ = [
24
+ "MorphemeTokenizer",
25
+ "HolographicMorphemeTokenizer",
26
+ "segment",
27
+ "PREFIXES",
28
+ "SUFFIXES",
29
+ "bind",
30
+ "unbind",
31
+ "bundle",
32
+ "normalize",
33
+ "make_unitary",
34
+ "cosine_similarity",
35
+ "update_context",
36
+ "__version__",
37
+ ]
src/morph_hrr/hrr.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Holographic Reduced Representation (HRR) primitives on Apple MLX.
2
+
3
+ Binding/unbinding are circular (de)convolution in the FFT domain (Plate, 1995).
4
+ These are the building blocks the morpheme tokenizer uses to compose
5
+ ``prefix (x) root (x) suffix`` into a single fixed-width "holistic" token vector.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import mlx.core as mx
10
+ import numpy as np
11
+
12
+
13
+ def normalize(x: mx.array, eps: float = 1e-6) -> mx.array:
14
+ """L2-normalize along the last axis; upcast to float32 for stable math."""
15
+ x = x.astype(mx.float32)
16
+ return x / mx.sqrt(mx.sum(x * x, axis=-1, keepdims=True) + eps)
17
+
18
+
19
+ def _bind(a: mx.array, b: mx.array) -> mx.array:
20
+ """Circular convolution: real(IFFT(FFT(a) * FFT(b)))."""
21
+ return mx.real(mx.fft.ifft(mx.fft.fft(a.astype(mx.float32)) * mx.fft.fft(b.astype(mx.float32))))
22
+
23
+
24
+ def _unbind(bound: mx.array, key: mx.array) -> mx.array:
25
+ """Circular correlation (approx inverse of bind): real(IFFT(FFT(bound) * conj(FFT(key))))."""
26
+ return mx.real(
27
+ mx.fft.ifft(mx.fft.fft(bound.astype(mx.float32)) * mx.conj(mx.fft.fft(key.astype(mx.float32))))
28
+ )
29
+
30
+
31
+ # Compiled hot paths.
32
+ bind = mx.compile(_bind)
33
+ unbind = mx.compile(_unbind)
34
+
35
+
36
+ def bundle(*vectors: mx.array) -> mx.array:
37
+ """Superpose vectors by normalized sum (the VSA "add")."""
38
+ if not vectors:
39
+ raise ValueError("bundle requires at least one vector")
40
+ return normalize(sum(v.astype(mx.float32) for v in vectors))
41
+
42
+
43
+ def cosine_similarity(a: mx.array, b: mx.array) -> mx.array:
44
+ """Cosine similarity along the last axis (vectors are normalized first)."""
45
+ return mx.sum(normalize(a) * normalize(b), axis=-1)
46
+
47
+
48
+ def make_unitary(dim: int, seed: int = 0) -> mx.array:
49
+ """Deterministic unitary vector: its FFT has all-ones magnitude, so binding with
50
+ it is exactly invertible (unbind perfectly recovers the bound value)."""
51
+ rng = np.random.default_rng(seed)
52
+ spectrum = np.ones(dim, dtype=np.complex64)
53
+ half = dim // 2
54
+ phases = rng.uniform(0, 2 * np.pi, max(0, half - 1))
55
+ spectrum[1:half] = np.exp(1j * phases)
56
+ spectrum[half + 1 :] = np.conj(spectrum[1:half][::-1])
57
+ if dim % 2 == 0:
58
+ spectrum[half] = 1.0
59
+ return mx.array(np.fft.ifft(spectrum).real.astype(np.float32))
60
+
61
+
62
+ def update_context(context: mx.array, token: mx.array, position_key: mx.array) -> mx.array:
63
+ """Incrementally fold a token into a running context: normalize(context + token (x) pos)."""
64
+ return normalize(context.astype(mx.float32) + bind(token, position_key))
src/morph_hrr/morphemes.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dependency-free English morphological segmentation.
2
+
3
+ Splits a word into ``(prefix, root, suffix)`` using curated affix lists with
4
+ longest-match and minimum-root guards. Deliberately simple and predictable:
5
+ no dictionary, no learned model, no external deps. Imperfect (it won't undo
6
+ consonant doubling like ``running -> run``) — documented as future work — but it
7
+ handles the common derivational/inflectional cases the tokenizer relies on.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ # Conservative, high-precision derivational prefixes (min root kept >= MIN_ROOT).
12
+ PREFIXES: tuple[str, ...] = (
13
+ "anti", "auto", "circum", "contra", "counter", "dis", "extra", "fore",
14
+ "hyper", "hypo", "in", "im", "il", "ir", "inter", "intra", "mal", "mid",
15
+ "mis", "multi", "non", "out", "over", "poly", "post", "pre", "proto",
16
+ "pseudo", "re", "retro", "semi", "sub", "super", "supra", "sur", "trans",
17
+ "tri", "ultra", "un", "under",
18
+ )
19
+
20
+ # Inflectional + common derivational suffixes, tried longest-first.
21
+ SUFFIXES: tuple[str, ...] = (
22
+ # 5+ chars
23
+ "ation", "ation", "ically", "iosity", "itious", "aceous", "acious",
24
+ "ality", "ative", "ator", "fully", "ially", "ables", "ibles", "iness",
25
+ # 4 chars
26
+ "able", "ably", "ance", "ence", "ency", "hood", "ible", "ical", "iest",
27
+ "isms", "ists", "ment", "ness", "ship", "tion", "wise",
28
+ # 3 chars
29
+ "acy", "age", "al", "ant", "ate", "dom", "ees", "ers", "est", "ful",
30
+ "ial", "ies", "ily", "ing", "ion", "ish", "ism", "ist", "ity", "ive",
31
+ "ize", "ity", "less", "ous", "ity",
32
+ # 2 chars
33
+ "al", "ed", "en", "er", "es", "ic", "ly", "or", "ty",
34
+ # 1 char (high-precision only)
35
+ "s",
36
+ )
37
+
38
+ # Keep roots at least this long after affix removal (avoids eating short words).
39
+ MIN_ROOT = 3
40
+
41
+ _SUFFIXES_BY_LEN = tuple(sorted({s for s in SUFFIXES if s}, key=len, reverse=True))
42
+
43
+
44
+ def segment(word: str) -> tuple[str, str, str]:
45
+ """Return ``(prefix, root, suffix)`` for ``word``; affixes are "" when absent."""
46
+ w = (word or "").strip().lower()
47
+ if not w.isalpha():
48
+ return ("", w, "")
49
+
50
+ prefix = ""
51
+ for p in PREFIXES:
52
+ if w.startswith(p) and len(w) - len(p) >= MIN_ROOT:
53
+ prefix = p
54
+ break
55
+ root = w[len(prefix):]
56
+
57
+ suffix = ""
58
+ for s in _SUFFIXES_BY_LEN:
59
+ if root.endswith(s) and len(root) - len(s) >= MIN_ROOT:
60
+ suffix = s
61
+ root = root[: len(root) - len(s)]
62
+ break
63
+
64
+ return (prefix, root, suffix)
src/morph_hrr/tokenizer.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Compositional HRR morpheme tokenizer.
2
+
3
+ Each word is represented as a single fixed-width "holistic" vector:
4
+
5
+ word_vec(word) = bundle(
6
+ prefix_role (x) bytes(prefix),
7
+ root_role (x) bytes(root),
8
+ suffix_role (x) bytes(suffix),
9
+ )
10
+
11
+ where ``(x)`` is circular convolution (``hrr.bind``) and ``bundle`` is normalized
12
+ superposition. Because the roles are unitary, ``unbind(word_vec, role)`` recovers
13
+ that role's filler — enabling algebraic morpheme manipulation (strip a prefix,
14
+ swap a suffix, build a vector for an out-of-vocabulary word from its pieces).
15
+
16
+ NOTE: this is an input **representation / embedding**, not a HuggingFace
17
+ text<->ID tokenizer. It emits dense vectors (one per word), intended to feed
18
+ HRR-native or experimental models.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import re
23
+
24
+ import mlx.core as mx
25
+ import numpy as np
26
+
27
+ from .hrr import bind, bundle, make_unitary, normalize
28
+ from .morphemes import segment as _segment
29
+
30
+ _WORD_RE = re.compile(r"[A-Za-z]+|\S")
31
+
32
+
33
+ class MorphemeTokenizer:
34
+ """Map text -> HRR morpheme vectors of width ``dim`` (deterministic given ``seed``)."""
35
+
36
+ def __init__(self, dim: int = 2048, seed: int = 0):
37
+ self.dim = dim
38
+ self.seed = seed
39
+ # Fixed random base vectors for each byte value; the "filler" alphabet.
40
+ rng = np.random.default_rng(seed)
41
+ byte_vectors = rng.normal(size=(256, dim)).astype(np.float32)
42
+ self.byte_vectors = normalize(mx.array(byte_vectors))
43
+ # Unitary role vectors for prefix / root / suffix slots.
44
+ self.prefix_role = make_unitary(dim, seed=seed + 1_001)
45
+ self.root_role = make_unitary(dim, seed=seed + 1_002)
46
+ self.suffix_role = make_unitary(dim, seed=seed + 1_003)
47
+
48
+ # -- morphemes ---------------------------------------------------------
49
+
50
+ def segment(self, word: str) -> tuple[str, str, str]:
51
+ """Return ``(prefix, root, suffix)`` for ``word``."""
52
+ return _segment(word)
53
+
54
+ def bytes_vector(self, text: str) -> mx.array:
55
+ """Fixed vector for a string: normalized fold-bind of its byte vectors."""
56
+ if not text:
57
+ return mx.zeros((self.dim,), dtype=mx.float32)
58
+ acc = self.byte_vectors[ord(text[0]) % 256]
59
+ for ch in text[1:]:
60
+ acc = bind(acc, self.byte_vectors[ord(ch) % 256])
61
+ return normalize(acc)
62
+
63
+ # -- composition -------------------------------------------------------
64
+
65
+ def word_vector(self, word: str) -> mx.array:
66
+ """Holistic HRR vector for ``word`` (composes its morphemes by role)."""
67
+ prefix, root, suffix = _segment(word)
68
+ pieces: list[mx.array] = []
69
+ if prefix:
70
+ pieces.append(bind(self.prefix_role, self.bytes_vector(prefix)))
71
+ if root:
72
+ pieces.append(bind(self.root_role, self.bytes_vector(root)))
73
+ if suffix:
74
+ pieces.append(bind(self.suffix_role, self.bytes_vector(suffix)))
75
+ if not pieces:
76
+ return self.bytes_vector(word)
77
+ return bundle(*pieces)
78
+
79
+ # -- text -> vectors ---------------------------------------------------
80
+
81
+ def iter_vectors(self, text: str):
82
+ """Yield one ``float16`` word vector per token in ``text`` (word or punct)."""
83
+ for word in _WORD_RE.findall(text):
84
+ vector = self.word_vector(word).astype(mx.float16)
85
+ mx.eval(vector)
86
+ yield mx.stop_gradient(vector)
87
+
88
+ def encode(self, text: str) -> mx.array:
89
+ """Stacked word vectors, shape ``(n_words, dim)`` in ``float16``."""
90
+ vectors = list(self.iter_vectors(text))
91
+ if not vectors:
92
+ return mx.zeros((0, self.dim), dtype=mx.float16)
93
+ return mx.stack(vectors)
94
+
95
+
96
+ # Backwards-compatible alias.
97
+ HolographicMorphemeTokenizer = MorphemeTokenizer
tests/test_compositionality.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Compositionality regression suite (ported from the Phase 1 checks).
2
+
3
+ These are the headline properties that make the HRR morpheme representation
4
+ useful. Each HRR measurement is paired against a random-vector control of the
5
+ same dimension, asserting the HRR signal is meaningfully stronger.
6
+ """
7
+ import numpy as np
8
+
9
+ import mlx.core as mx
10
+
11
+ from morph_hrr import MorphemeTokenizer, unbind, cosine_similarity
12
+
13
+ DIM = 2048
14
+ # A tokenizer is reused across tests; construction is cheap and deterministic.
15
+ TOK = MorphemeTokenizer(dim=DIM, seed=0)
16
+
17
+ # Random-vector control: a fresh tokenizer whose "word vectors" are i.i.d. random
18
+ # unit vectors (no composition at all). Expected to show ~0 structure.
19
+
20
+
21
+ def _control():
22
+ rng = np.random.default_rng(99)
23
+ return normalize(mx.array(rng.normal(size=(DIM,)).astype(np.float32)))
24
+
25
+
26
+ def normalize(v):
27
+ n = mx.sqrt(mx.sum(v * v) + 1e-12)
28
+ return v / n
29
+
30
+
31
+ def _rand_vec(tag: str) -> mx.array:
32
+ """Deterministic random unit vector keyed by tag (for the control family)."""
33
+ rng = np.random.default_rng(abs(hash(tag)) % (2**32))
34
+ return normalize(mx.array(rng.normal(size=(DIM,)).astype(np.float32)))
35
+
36
+
37
+ # -- 1. Prefix recovery: unbind recovers the prefix filler from the holistic vec -
38
+
39
+ def test_prefix_recovery_beats_control():
40
+ word = TOK.word_vector("unhappy")
41
+ recovered = unbind(word, TOK.prefix_role)
42
+ target = TOK.bytes_vector("un")
43
+ hrr_cos = float(cosine_similarity(target, recovered))
44
+
45
+ # Control: unbinding a random vector with the prefix role gives noise.
46
+ ctrl = unbind(_rand_vec("control-prefix"), TOK.prefix_role)
47
+ ctrl_cos = abs(float(cosine_similarity(target, ctrl)))
48
+
49
+ assert hrr_cos > 0.50, f"prefix recovery cosine {hrr_cos} too low"
50
+ assert hrr_cos > ctrl_cos + 0.40, (
51
+ f"HRR prefix recovery {hrr_cos} not clearly above control {ctrl_cos}"
52
+ )
53
+
54
+
55
+ # -- 2. Shared-root clustering: unhappy ~ happy (same root "happy") ------------
56
+
57
+ def test_shared_root_clusters():
58
+ unhappy = TOK.word_vector("unhappy")
59
+ happy = TOK.word_vector("happy")
60
+ hrr_cos = float(cosine_similarity(unhappy, happy))
61
+
62
+ # Control: two unrelated random words should be ~orthogonal.
63
+ ctrl_cos = abs(float(cosine_similarity(_rand_vec("a"), _rand_vec("b"))))
64
+
65
+ assert hrr_cos > 0.20, f"shared-root cosine {hrr_cos} too low"
66
+ assert hrr_cos > ctrl_cos + 0.15
67
+
68
+
69
+ # -- 3. Shared-suffix clustering: running ~ walking (same suffix "ing") --------
70
+
71
+ def test_shared_suffix_clusters():
72
+ running = TOK.word_vector("running")
73
+ walking = TOK.word_vector("walking")
74
+ hrr_cos = float(cosine_similarity(running, walking))
75
+
76
+ ctrl_cos = abs(float(cosine_similarity(_rand_vec("c"), _rand_vec("d"))))
77
+
78
+ assert hrr_cos > 0.15, f"shared-suffix cosine {hrr_cos} too low"
79
+ assert hrr_cos > ctrl_cos + 0.10
80
+
81
+
82
+ # -- 4. OOV root family: a built-from-pieces vector still neighbors its root ---
83
+
84
+ def test_oov_root_family():
85
+ # "unkind" is in-vocab via segmentation; its root filler is "kind".
86
+ unkind = TOK.word_vector("unkind")
87
+ kind = TOK.word_vector("kind")
88
+ recovered_root = unbind(unkind, TOK.root_role)
89
+ hrr_cos = float(cosine_similarity(TOK.bytes_vector("kind"), recovered_root))
90
+
91
+ ctrl = unbind(_rand_vec("control-root"), TOK.root_role)
92
+ ctrl_cos = abs(float(cosine_similarity(TOK.bytes_vector("kind"), ctrl)))
93
+
94
+ assert hrr_cos > 0.50, f"OOV root recovery cosine {hrr_cos} too low"
95
+ assert hrr_cos > ctrl_cos + 0.40
96
+
97
+
98
+ # -- 5. Roles are near-orthogonal (composition uses distinct slots) -----------
99
+
100
+ def test_roles_are_near_orthogonal():
101
+ pairs = [
102
+ (TOK.prefix_role, TOK.root_role),
103
+ (TOK.prefix_role, TOK.suffix_role),
104
+ (TOK.root_role, TOK.suffix_role),
105
+ ]
106
+ for a, b in pairs:
107
+ cos = abs(float(cosine_similarity(a, b)))
108
+ assert cos < 0.10, f"roles not orthogonal: cosine {cos}"
tests/test_hrr_math.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import mlx.core as mx
2
+
3
+ from morph_hrr import (
4
+ bind,
5
+ bundle,
6
+ cosine_similarity,
7
+ make_unitary,
8
+ normalize,
9
+ unbind,
10
+ update_context,
11
+ )
12
+
13
+ DIM = 4096
14
+
15
+
16
+ def _close(actual, expected, tol):
17
+ assert abs(float(actual) - float(expected)) <= tol, f"{actual} !~= {expected} (tol {tol})"
18
+
19
+
20
+ def test_unitary_keys_have_unit_fft_magnitude():
21
+ key = make_unitary(DIM, seed=10)
22
+ mags = mx.abs(mx.fft.fft(key))
23
+ _close(mx.min(mags), 1.0, 1e-4)
24
+ _close(mx.max(mags), 1.0, 1e-4)
25
+
26
+
27
+ def test_unitary_bind_unbind_recovers_value():
28
+ key = make_unitary(DIM, seed=11)
29
+ value = normalize(mx.random.normal((DIM,)))
30
+ recovered = unbind(bind(key, value), key)
31
+ assert float(cosine_similarity(value, recovered)) > 0.995
32
+
33
+
34
+ def test_bind_unbind_supports_batch():
35
+ key = make_unitary(DIM, seed=12)
36
+ values = normalize(mx.random.normal((3, DIM)))
37
+ recovered = unbind(bind(key, values), key)
38
+ assert recovered.shape == values.shape
39
+ assert float(mx.min(cosine_similarity(values, recovered))) > 0.995
40
+
41
+
42
+ def test_bundle_recovery_beats_distractors():
43
+ roles = [make_unitary(DIM, seed=13 + i) for i in range(3)]
44
+ vals = [normalize(mx.random.normal((DIM,))) for _ in range(3)]
45
+ memory = bundle(*(bind(r, v) for r, v in zip(roles, vals)))
46
+ recovered = unbind(memory, roles[1])
47
+ assert float(cosine_similarity(vals[1], recovered)) > 0.50
48
+ assert abs(float(cosine_similarity(vals[0], recovered))) < 0.10
49
+ assert abs(float(cosine_similarity(vals[2], recovered))) < 0.10
50
+
51
+
52
+ def test_normalize_yields_unit_norm():
53
+ v = normalize(mx.random.normal((DIM,)))
54
+ _close(mx.sum(v * v), 1.0, 1e-4)
55
+
56
+
57
+ def test_update_context_stays_normalized_and_finite():
58
+ context = mx.zeros((DIM,), dtype=mx.float32)
59
+ for i in range(128):
60
+ token = normalize(mx.random.normal((DIM,)))
61
+ context = update_context(context, token, make_unitary(DIM, seed=1_000 + i))
62
+ _close(mx.sum(context * context), 1.0, 1e-4)
63
+ assert bool(mx.all(mx.isfinite(context)))
64
+
65
+
66
+ def test_empty_bundle_raises():
67
+ try:
68
+ bundle()
69
+ except ValueError:
70
+ return
71
+ raise AssertionError("empty bundle must raise ValueError")
72
+
73
+
74
+ def test_determinism_same_seed():
75
+ a = make_unitary(DIM, seed=42)
76
+ b = make_unitary(DIM, seed=42)
77
+ assert bool(mx.allclose(a, b))
tests/test_segmentation.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
+ from morph_hrr import segment
4
+
5
+ cases = [
6
+ ("unhappy", ("un", "happy", "")),
7
+ ("unkind", ("un", "kind", "")),
8
+ ("running", ("", "runn", "ing")), # no undoubling (documented)
9
+ ("walking", ("", "walk", "ing")),
10
+ ("happiness", ("", "happ", "iness")), # longest suffix "iness"
11
+ ("kingdom", ("", "king", "dom")),
12
+ ("walks", ("", "walk", "s")),
13
+ # Rule-based longest-match can't know "order" is a root: after "pre" it
14
+ # strips "er" too. Documents the (deliberate) imperfectness.
15
+ ("preorder", ("pre", "ord", "er")),
16
+ ("disconnected", ("dis", "connect", "ed")),
17
+ ("happy", ("", "happy", "")), # no bare-"y" suffix
18
+ ("KING", ("", "king", "")), # case-insensitive
19
+ ("a", ("", "a", "")), # too short -> root only
20
+ ]
21
+
22
+
23
+ @pytest.mark.parametrize("word,expected", cases)
24
+ def test_segment(word, expected):
25
+ assert segment(word) == expected
26
+
27
+
28
+ def test_non_alpha_passthrough():
29
+ p, r, s = segment("123")
30
+ assert r == "123" and p == "" and s == ""
31
+
32
+
33
+ def test_empty():
34
+ assert segment("") == ("", "", "")