Feature Extraction
MLX
English
hrr
vsa
holographic-reduced-representations
tokenizer
morphemes
compositional
Instructions to use thebasedcapital/morph-hrr with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- MLX
How to use thebasedcapital/morph-hrr with MLX:
# Download the model from the Hub pip install huggingface_hub[hf_xet] huggingface-cli download --local-dir morph-hrr thebasedcapital/morph-hrr
- Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- LM Studio
- Atomic Chat
morph-hrr v0.1.0: compositional HRR morpheme tokenizer
Browse files- .pytest_cache/.gitignore +2 -0
- .pytest_cache/CACHEDIR.TAG +4 -0
- .pytest_cache/README.md +8 -0
- .pytest_cache/v/cache/lastfailed +1 -0
- .pytest_cache/v/cache/nodeids +29 -0
- LICENSE +21 -0
- README.md +99 -0
- pyproject.toml +31 -0
- src/morph_hrr/__init__.py +37 -0
- src/morph_hrr/hrr.py +64 -0
- src/morph_hrr/morphemes.py +64 -0
- src/morph_hrr/tokenizer.py +97 -0
- tests/test_compositionality.py +108 -0
- tests/test_hrr_math.py +77 -0
- tests/test_segmentation.py +34 -0
.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("") == ("", "", "")
|