From 8da9a05429d208b72073084d7514bccef01a7f7e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 04:44:46 +0000 Subject: [PATCH 1/3] Initial plan From 911eb1814c69f3cc6f7c7f78ae9dba1c79b81cc3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 04:55:58 +0000 Subject: [PATCH 2/3] Implement UCNS library: UCN, EpicycleDecomposition, MobiusTransform, UCNEmbedding, similarity metrics, tests, docs Agent-Logs-Url: https://github.com/The-Interdependency/ucns/sessions/7fc7989f-a74b-41c2-abc5-5e7c722996cd Co-authored-by: wayseer00 <258336486+wayseer00@users.noreply.github.com> --- README.md | 196 +++++++++++- pyproject.toml | 23 ++ tests/__init__.py | 0 tests/__pycache__/test_core.cpython-312.pyc | Bin 0 -> 14822 bytes .../test_embedding.cpython-312.pyc | Bin 0 -> 16373 bytes .../__pycache__/test_epicycle.cpython-312.pyc | Bin 0 -> 12751 bytes tests/__pycache__/test_mobius.cpython-312.pyc | Bin 0 -> 10224 bytes .../test_similarity.cpython-312.pyc | Bin 0 -> 9830 bytes tests/test_core.py | 184 ++++++++++++ tests/test_embedding.py | 202 +++++++++++++ tests/test_epicycle.py | 165 +++++++++++ tests/test_mobius.py | 145 +++++++++ tests/test_similarity.py | 143 +++++++++ ucns/__init__.py | 76 +++++ ucns/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 2246 bytes ucns/__pycache__/core.cpython-312.pyc | Bin 0 -> 7852 bytes ucns/__pycache__/embedding.cpython-312.pyc | Bin 0 -> 8891 bytes ucns/__pycache__/epicycle.cpython-312.pyc | Bin 0 -> 12371 bytes ucns/__pycache__/mobius.cpython-312.pyc | Bin 0 -> 7979 bytes ucns/__pycache__/similarity.cpython-312.pyc | Bin 0 -> 7112 bytes ucns/core.py | 165 +++++++++++ ucns/embedding.py | 212 +++++++++++++ ucns/epicycle.py | 278 ++++++++++++++++++ ucns/mobius.py | 191 ++++++++++++ ucns/similarity.py | 181 ++++++++++++ 25 files changed, 2159 insertions(+), 2 deletions(-) create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/__pycache__/test_core.cpython-312.pyc create mode 100644 tests/__pycache__/test_embedding.cpython-312.pyc create mode 100644 tests/__pycache__/test_epicycle.cpython-312.pyc create mode 100644 tests/__pycache__/test_mobius.cpython-312.pyc create mode 100644 tests/__pycache__/test_similarity.cpython-312.pyc create mode 100644 tests/test_core.py create mode 100644 tests/test_embedding.py create mode 100644 tests/test_epicycle.py create mode 100644 tests/test_mobius.py create mode 100644 tests/test_similarity.py create mode 100644 ucns/__init__.py create mode 100644 ucns/__pycache__/__init__.cpython-312.pyc create mode 100644 ucns/__pycache__/core.cpython-312.pyc create mode 100644 ucns/__pycache__/embedding.cpython-312.pyc create mode 100644 ucns/__pycache__/epicycle.cpython-312.pyc create mode 100644 ucns/__pycache__/mobius.cpython-312.pyc create mode 100644 ucns/__pycache__/similarity.cpython-312.pyc create mode 100644 ucns/core.py create mode 100644 ucns/embedding.py create mode 100644 ucns/epicycle.py create mode 100644 ucns/mobius.py create mode 100644 ucns/similarity.py diff --git a/README.md b/README.md index 48ce88c8..678e6288 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,194 @@ -# ucns -unit circle number system - the unit circle is a Mobius disk with recursive epicycles +# ucns — Unit Circle Number System + +> **The unit circle is a Möbius disk with recursive epicycles.** + +A zero-dependency Python library for creating **compact, efficient embeddings** +using a novel **Unit Circle Number System (UCNS)**. + +--- + +## Why UCNS? + +Traditional dense embeddings (word2vec, BERT, OpenAI Ada, …) store each +dimension as a 32-bit float. UCNS embeddings encode every dimension as an +**angle** θ ∈ \[0, 2π\) on the unit circle: + +| Property | float32 embedding | UCNS embedding | +|---|---|---| +| Bytes per dimension | 4 | 2 (uint16) | +| Similarity computation | dot product + two L2 norms | mean cos(Δθ) – no normalisation | +| Geometric space | Euclidean Rⁿ | Unit torus (S¹)ⁿ | +| Hierarchical structure | no | yes (Möbius / Poincaré disk) | +| External dependencies | numpy / torch / … | **none** | + +The compression and speed gains come from two structural properties: + +1. **All embeddings already live on the unit sphere** – the inner product + `cos(θᵢ − φᵢ)` never needs a length normalisation step. +2. **Angles fit in 16 bits** – 0.0001 rad resolution with half the storage of + float32. + +--- + +## Architecture + +``` +input data + │ + ▼ +real-valued signal (ordinals / floats / bytes …) + │ + ▼ FFT (Cooley–Tukey O(n log n), pure Python) +Epicycle decomposition ──► amplitudes + phases + │ + ▼ + UCNS embedding vector + (list of n angles in [0, τ)) +``` + +The **Möbius disk** (Poincaré disk model of the hyperbolic plane) is available +as a companion geometry for encoding *hierarchical* relationships. Points deep +in the tree live near the boundary of the disk (high hyperbolic radius); root +nodes sit near the centre. + +--- + +## Installation + +```bash +pip install ucns # from PyPI (no dependencies) +# or from source: +pip install . +``` + +Python ≥ 3.8 required. No third-party packages needed. + +--- + +## Quick start + +```python +from ucns import UCNEmbedding + +emb = UCNEmbedding(dim=64) + +# Encode any data to a list of 64 angles +v1 = emb.encode("hello world") +v2 = emb.encode("hello world") +v3 = emb.encode("completely different") + +print(emb.similarity(v1, v2)) # 1.0 (identical) +print(emb.similarity(v1, v3)) # < 1.0 + +# Compact storage: 64 × 2 bytes = 128 bytes (vs 256 bytes for float32) +packed = emb.encode_packed("hello world") +print(len(packed)) # 128 +restored = UCNEmbedding.unpack(packed) + +# Nearest-neighbour search +corpus = [emb.encode(w) for w in ["cat", "dog", "fish", "bird"]] +idx, score = emb.nearest(emb.encode("cat"), corpus) +print(idx, score) # 0 1.0 +``` + +--- + +## API reference + +### `UCN` — core unit-circle number + +```python +from ucns import UCN, TAU + +u = UCN(1.23) # angle in radians, normalised to [0, τ) +v = UCN.from_real(0.5) # map float → UCN +w = u * v # rotation (angle addition) +d = u.arc_distance(v) # geodesic distance on S¹ ∈ [0, π] +s = u.dot(v) # cos(θ_u − θ_v) ∈ [−1, 1] +b = u.to_bytes() # 2-byte compact serialisation +``` + +### `EpicycleDecomposition` — FFT on the unit circle + +```python +from ucns import EpicycleDecomposition + +d = EpicycleDecomposition([1, 2, 3, 4, 5, 6, 7, 8]) +print(d.amplitudes) # per-frequency radii +print(d.phases) # per-frequency UCN angles +print(d.reconstruct()) # lossless signal reconstruction +sim = d.phase_similarity(d2) # amplitude-weighted phase cosine +packed = d.pack() # uint16 serialisation (2 bytes/freq) +``` + +### `MobiusTransform` — Möbius disk automorphisms + +```python +from ucns import MobiusTransform, poincare_distance + +T = MobiusTransform(a=0.3 + 0.1j, phi=0.5) # a ∈ open unit disk +w = T(0.2 + 0j) # apply transform +T_inv = T.inverse() # T_inv(T(z)) == z +d = poincare_distance(0.1 + 0j, 0.5 + 0j) # hyperbolic metric +``` + +### Similarity metrics + +```python +from ucns.similarity import phase_cosine, arc_distance, hyperbolic_cosine, top_k_overlap + +phase_cosine(a, b) # mean cos(θᵢ − φᵢ) ∈ [−1, 1] +arc_distance(a, b) # mean arc distance, ∈ [0, 1] +hyperbolic_cosine(a, b) # Poincaré-disk based ∈ [−1, 1] +top_k_overlap(amps_a, amps_b, k=8) # dominant-frequency Jaccard ∈ [0, 1] +``` + +--- + +## Running the tests + +```bash +python -m pytest tests/ -v +# or without pytest: +python -m unittest discover tests/ +``` + +--- + +## Mathematical background + +### Unit Circle Number System + +Every UCN is a point on the unit circle S¹ ⊂ ℂ: + + z = e^(iθ), θ ∈ [0, 2π) + +S¹ is a compact abelian group under multiplication. Representing data as +angles exploits this group structure: similarity becomes circular correlation, +arithmetic becomes rotation, and conjugation becomes reflection. + +### Möbius disk + +The open unit disk D = {z ∈ ℂ : |z| < 1} with the Poincaré metric is a +model of the hyperbolic plane. Every conformal automorphism has the form + + T_{a,φ}(z) = e^(iφ) · (z − a) / (1 − ā·z) + +These transformations preserve the circular boundary ∂D = S¹ and the +hyperbolic metric d(z,w) = 2 arctanh(|(z−w)/(1−w̄z)|). + +### Epicycles + +Any periodic signal can be written as a sum of circular motions: + + x(t) = Σ_k Aₖ · e^(i(2πkt/N + φₖ)) + +This is the Fourier series interpreted geometrically. The FFT computes the +amplitudes *Aₖ* and phases *φₖ* in O(N log N) time. UCNS stores only the +phases (the angular part), giving a compact multi-scale fingerprint. + +--- + +## License + +Apache 2.0 — see [LICENSE](LICENSE). diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..87dd054e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,23 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.backends.legacy:build" + +[project] +name = "ucns" +version = "0.1.0" +description = "Unit Circle Number System – zero-dependency compact embeddings via epicycle decomposition on the Möbius disk" +readme = "README.md" +license = { file = "LICENSE" } +requires-python = ">=3.8" +# No runtime dependencies – pure Python standard library only +dependencies = [] + +[project.optional-dependencies] +dev = [] + +[tool.setuptools.packages.find] +where = ["."] +include = ["ucns*"] + +[tool.setuptools.package-data] +ucns = ["py.typed"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/__pycache__/test_core.cpython-312.pyc b/tests/__pycache__/test_core.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8e278573856b3ba9ba821ecb53cf9dfea6ef6e06 GIT binary patch literal 14822 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 HisJtP#kP%5 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_embedding.cpython-312.pyc b/tests/__pycache__/test_embedding.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..58db854ea7cb89e79a561e13593b0c48f1f47a90 GIT binary patch literal 16373 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 A)&Kwi literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_mobius.cpython-312.pyc b/tests/__pycache__/test_mobius.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..758f96693b409e5fc6298106aea53ee963001dda GIT binary patch literal 10224 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 literal 0 HcmV?d00001 diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 00000000..6bc5d31c --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,184 @@ +"""Tests for ucns.core – Unit Circle Number arithmetic.""" + +import math +import struct +import unittest + +from ucns.core import UCN, TAU + + +class TestUCNConstruction(unittest.TestCase): + def test_theta_normalised_positive(self): + u = UCN(3 * TAU + 1.0) + self.assertAlmostEqual(u.theta, 1.0, places=10) + + def test_theta_normalised_negative(self): + u = UCN(-0.5) + self.assertAlmostEqual(u.theta, TAU - 0.5, places=10) + + def test_theta_zero(self): + self.assertAlmostEqual(UCN(0.0).theta, 0.0) + + def test_theta_tau_wraps_to_zero(self): + self.assertAlmostEqual(UCN(TAU).theta, 0.0, places=10) + + def test_from_complex_unit_circle(self): + import cmath + z = cmath.exp(1j * 1.23) + u = UCN.from_complex(z) + self.assertAlmostEqual(u.theta, 1.23, places=10) + + def test_from_complex_non_unit(self): + # phase is preserved regardless of magnitude + u = UCN.from_complex(2.0 + 2.0j) + self.assertAlmostEqual(u.theta, math.pi / 4, places=10) + + def test_from_real_midpoint(self): + u = UCN.from_real(0.0, -1.0, 1.0) + self.assertAlmostEqual(u.theta, TAU / 2, places=10) + + def test_from_real_lo(self): + u = UCN.from_real(-1.0, -1.0, 1.0) + self.assertAlmostEqual(u.theta, 0.0, places=10) + + def test_from_real_hi(self): + u = UCN.from_real(1.0, -1.0, 1.0) + # t=1.0 → theta=τ, which normalises to 0 + self.assertAlmostEqual(u.theta, 0.0, places=10) + + def test_from_real_clamping(self): + u_lo = UCN.from_real(-999.0, -1.0, 1.0) + u_hi = UCN.from_real(999.0, -1.0, 1.0) + self.assertAlmostEqual(u_lo.theta, 0.0, places=10) + self.assertAlmostEqual(u_hi.theta, 0.0, places=10) # 1.0 wraps to 0 + + def test_from_real_equal_lo_hi_raises(self): + with self.assertRaises(ValueError): + UCN.from_real(0.0, lo=1.0, hi=1.0) + + +class TestUCNProperties(unittest.TestCase): + def test_real_imag(self): + theta = math.pi / 3 + u = UCN(theta) + self.assertAlmostEqual(u.real, math.cos(theta)) + self.assertAlmostEqual(u.imag, math.sin(theta)) + + def test_complex_on_unit_circle(self): + u = UCN(1.1) + self.assertAlmostEqual(abs(u.complex), 1.0, places=12) + + +class TestUCNArithmetic(unittest.TestCase): + def test_multiplication_adds_angles(self): + u = UCN(1.0) + v = UCN(2.0) + self.assertAlmostEqual((u * v).theta, 3.0, places=10) + + def test_multiplication_wraps(self): + u = UCN(TAU - 0.1) + v = UCN(0.2) + expected = (TAU - 0.1 + 0.2) % TAU + self.assertAlmostEqual((u * v).theta, expected, places=10) + + def test_division_subtracts_angles(self): + u = UCN(3.0) + v = UCN(1.0) + self.assertAlmostEqual((u / v).theta, 2.0, places=10) + + def test_conjugate(self): + u = UCN(1.0) + c = u.conjugate() + self.assertAlmostEqual(c.theta, TAU - 1.0, places=10) + + def test_conjugate_of_zero(self): + u = UCN(0.0) + self.assertAlmostEqual(u.conjugate().theta, 0.0, places=10) + + def test_mul_div_roundtrip(self): + u = UCN(1.5) + v = UCN(0.7) + self.assertAlmostEqual(((u * v) / v).theta, u.theta, places=10) + + def test_identity_element(self): + u = UCN(1.23) + identity = UCN(0.0) + self.assertAlmostEqual((u * identity).theta, u.theta, places=10) + + +class TestUCNMetrics(unittest.TestCase): + def test_dot_identical(self): + u = UCN(1.0) + self.assertAlmostEqual(u.dot(u), 1.0, places=10) + + def test_dot_opposite(self): + u = UCN(0.0) + v = UCN(math.pi) + self.assertAlmostEqual(u.dot(v), -1.0, places=10) + + def test_dot_quarter_turn(self): + u = UCN(0.0) + v = UCN(math.pi / 2) + self.assertAlmostEqual(u.dot(v), 0.0, places=10) + + def test_arc_distance_same(self): + u = UCN(1.0) + self.assertAlmostEqual(u.arc_distance(u), 0.0, places=10) + + def test_arc_distance_half_circle(self): + u = UCN(0.0) + v = UCN(math.pi) + self.assertAlmostEqual(u.arc_distance(v), math.pi, places=10) + + def test_arc_distance_symmetry(self): + u = UCN(0.3) + v = UCN(2.7) + self.assertAlmostEqual(u.arc_distance(v), v.arc_distance(u), places=10) + + def test_arc_distance_short_arc(self): + u = UCN(0.1) + v = UCN(TAU - 0.1) + self.assertAlmostEqual(u.arc_distance(v), 0.2, places=10) + + +class TestUCNSerialisation(unittest.TestCase): + def test_int16_roundtrip(self): + for theta in [0.0, 1.0, math.pi, TAU * 0.75]: + u = UCN(theta) + v = UCN.from_int16(u.to_int16()) + self.assertAlmostEqual(u.theta, v.theta, delta=TAU / 65535 + 1e-9) + + def test_bytes_roundtrip(self): + u = UCN(2.718) + v = UCN.from_bytes(u.to_bytes()) + self.assertAlmostEqual(u.theta, v.theta, delta=TAU / 65535 + 1e-9) + + def test_bytes_length(self): + self.assertEqual(len(UCN(1.0).to_bytes()), 2) + + +class TestUCNDunder(unittest.TestCase): + def test_repr(self): + r = repr(UCN(1.23456)) + self.assertIn("UCN", r) + self.assertIn("1.23456", r) + + def test_equality(self): + self.assertEqual(UCN(1.0), UCN(1.0 + TAU)) + + def test_inequality(self): + self.assertNotEqual(UCN(1.0), UCN(2.0)) + + def test_hash_consistent_with_eq(self): + self.assertEqual(hash(UCN(1.0)), hash(UCN(1.0 + TAU))) + + def test_float_conversion(self): + u = UCN(1.23) + self.assertAlmostEqual(float(u), 1.23, places=10) + + def test_lt(self): + self.assertLess(UCN(0.5), UCN(1.5)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_embedding.py b/tests/test_embedding.py new file mode 100644 index 00000000..2032533d --- /dev/null +++ b/tests/test_embedding.py @@ -0,0 +1,202 @@ +"""Tests for ucns.embedding – UCNEmbedding API.""" + +import math +import struct +import unittest + +from ucns.embedding import UCNEmbedding + +_TAU = 2.0 * math.pi + + +class TestUCNEmbeddingConstruction(unittest.TestCase): + def test_dim_power_of_two(self): + emb = UCNEmbedding(dim=64) + self.assertEqual(emb.dim, 64) + + def test_dim_rounded_up(self): + emb = UCNEmbedding(dim=50) + self.assertEqual(emb.dim, 64) + + def test_dim_one(self): + emb = UCNEmbedding(dim=1) + self.assertEqual(emb.dim, 1) + + def test_dim_zero_raises(self): + with self.assertRaises(ValueError): + UCNEmbedding(dim=0) + + def test_repr_contains_dim(self): + emb = UCNEmbedding(dim=32) + self.assertIn("32", repr(emb)) + + +class TestUCNEmbeddingEncode(unittest.TestCase): + def setUp(self): + self.emb = UCNEmbedding(dim=16) + + def test_encode_returns_list(self): + v = self.emb.encode("hello") + self.assertIsInstance(v, list) + + def test_encode_correct_length(self): + v = self.emb.encode("hello") + self.assertEqual(len(v), self.emb.dim) + + def test_encode_angles_in_range(self): + for data in ["hello", [1.0, 2.0, 3.0], 42.0, b"abc"]: + v = self.emb.encode(data) + for angle in v: + self.assertGreaterEqual(angle, 0.0) + self.assertLess(angle, _TAU + 1e-9) + + def test_encode_deterministic(self): + v1 = self.emb.encode("hello world") + v2 = self.emb.encode("hello world") + self.assertEqual(v1, v2) + + def test_encode_string(self): + v = self.emb.encode("abc") + self.assertEqual(len(v), self.emb.dim) + + def test_encode_float(self): + v = self.emb.encode(3.14) + self.assertEqual(len(v), self.emb.dim) + + def test_encode_int(self): + v = self.emb.encode(42) + self.assertEqual(len(v), self.emb.dim) + + def test_encode_list(self): + v = self.emb.encode([1.0, 2.0, 3.0]) + self.assertEqual(len(v), self.emb.dim) + + def test_encode_bytes(self): + v = self.emb.encode(b"\x00\xff\x80") + self.assertEqual(len(v), self.emb.dim) + + def test_encode_unsupported_type_raises(self): + with self.assertRaises(TypeError): + self.emb.encode({"key": "value"}) + + def test_encode_long_signal_truncated(self): + long_signal = list(range(self.emb.dim * 3)) + v = self.emb.encode(long_signal) + self.assertEqual(len(v), self.emb.dim) + + def test_encode_empty_string(self): + v = self.emb.encode("") + self.assertEqual(len(v), self.emb.dim) + + +class TestUCNEmbeddingPackedSerialization(unittest.TestCase): + def setUp(self): + self.emb = UCNEmbedding(dim=16) + + def test_packed_length(self): + packed = self.emb.encode_packed("hello") + self.assertEqual(len(packed), self.emb.dim * 2) + + def test_pack_unpack_roundtrip(self): + v = self.emb.encode("hello world") + packed = self.emb.encode_packed("hello world") + unpacked = UCNEmbedding.unpack(packed) + for orig, rec in zip(v, unpacked): + self.assertAlmostEqual(orig, rec, delta=_TAU / 65535 + 1e-6) + + def test_packed_is_bytes(self): + self.assertIsInstance(self.emb.encode_packed("test"), bytes) + + +class TestUCNEmbeddingSimilarity(unittest.TestCase): + def setUp(self): + self.emb = UCNEmbedding(dim=32) + + def test_similarity_self(self): + v = self.emb.encode("hello") + self.assertAlmostEqual(self.emb.similarity(v, v), 1.0, places=10) + + def test_similarity_in_range(self): + v1 = self.emb.encode("hello") + v2 = self.emb.encode("world") + s = self.emb.similarity(v1, v2) + self.assertGreaterEqual(s, -1.0) + self.assertLessEqual(s, 1.0) + + def test_similarity_symmetric(self): + v1 = self.emb.encode("foo") + v2 = self.emb.encode("bar") + self.assertAlmostEqual( + self.emb.similarity(v1, v2), + self.emb.similarity(v2, v1), + places=12, + ) + + def test_similarity_different_lengths_raises(self): + with self.assertRaises(ValueError): + self.emb.similarity([1.0, 2.0], [1.0]) + + def test_similar_inputs_higher_score(self): + v1 = self.emb.encode("the cat sat") + v2 = self.emb.encode("the cat sat") # identical + v3 = self.emb.encode("completely different xyz 999") + s_same = self.emb.similarity(v1, v2) + s_diff = self.emb.similarity(v1, v3) + self.assertGreater(s_same, s_diff) + + +class TestUCNEmbeddingNearest(unittest.TestCase): + def setUp(self): + self.emb = UCNEmbedding(dim=32) + + def test_nearest_finds_identical(self): + query = self.emb.encode("apple") + corpus = [ + self.emb.encode("banana"), + self.emb.encode("apple"), + self.emb.encode("cherry"), + ] + idx, score = self.emb.nearest(query, corpus) + self.assertEqual(idx, 1) + self.assertAlmostEqual(score, 1.0, places=10) + + def test_nearest_empty_corpus_raises(self): + query = self.emb.encode("hello") + with self.assertRaises(ValueError): + self.emb.nearest(query, []) + + def test_nearest_returns_valid_index(self): + query = self.emb.encode("test") + corpus = [self.emb.encode(str(i)) for i in range(10)] + idx, score = self.emb.nearest(query, corpus) + self.assertGreaterEqual(idx, 0) + self.assertLess(idx, 10) + + +class TestUCNEmbeddingToSignal(unittest.TestCase): + def test_int_gives_one_element(self): + sig = UCNEmbedding._to_signal(5) + self.assertEqual(sig, [5.0]) + + def test_float_gives_one_element(self): + sig = UCNEmbedding._to_signal(3.14) + self.assertAlmostEqual(sig[0], 3.14) + + def test_str_encodes_ordinals(self): + sig = UCNEmbedding._to_signal("AB") + self.assertAlmostEqual(sig[0], 65.0) + self.assertAlmostEqual(sig[1], 66.0) + + def test_bytes_encodes_byte_values(self): + sig = UCNEmbedding._to_signal(b"\x00\x01\xff") + self.assertAlmostEqual(sig[0], 0.0) + self.assertAlmostEqual(sig[1], 1.0) + self.assertAlmostEqual(sig[2], 255.0) + + def test_list_passthrough(self): + sig = UCNEmbedding._to_signal([1.5, 2.5]) + self.assertEqual(sig, [1.5, 2.5]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_epicycle.py b/tests/test_epicycle.py new file mode 100644 index 00000000..b149a9b7 --- /dev/null +++ b/tests/test_epicycle.py @@ -0,0 +1,165 @@ +"""Tests for ucns.epicycle – FFT and EpicycleDecomposition.""" + +import cmath +import math +import unittest + +from ucns.epicycle import EpicycleDecomposition, fft, ifft, _next_pow2 + +_TAU = 2.0 * math.pi + + +class TestNextPow2(unittest.TestCase): + def test_exact_power_of_two(self): + for p in [1, 2, 4, 8, 16, 32, 64]: + self.assertEqual(_next_pow2(p), p) + + def test_non_power_of_two(self): + self.assertEqual(_next_pow2(3), 4) + self.assertEqual(_next_pow2(5), 8) + self.assertEqual(_next_pow2(100), 128) + + +class TestFFT(unittest.TestCase): + def _slow_dft(self, x): + n = len(x) + return [ + sum(x[j] * cmath.exp(-1j * _TAU * k * j / n) for j in range(n)) + for k in range(n) + ] + + def test_fft_equals_dft_n4(self): + signal = [1.0, 2.0, 3.0, 4.0] + fast = fft(signal) + slow = self._slow_dft(signal) + for a, b in zip(fast, slow): + self.assertAlmostEqual(a, b, places=10) + + def test_fft_equals_dft_n8(self): + import math + signal = [math.sin(_TAU * k / 8) for k in range(8)] + fast = fft(signal) + slow = self._slow_dft(signal) + for a, b in zip(fast, slow): + self.assertAlmostEqual(a, b, places=10) + + def test_fft_zero_pads(self): + # Length 3 → padded to 4 + result = fft([1.0, 2.0, 3.0]) + self.assertEqual(len(result), 4) + + def test_fft_single_element(self): + result = fft([5.0]) + self.assertAlmostEqual(result[0], 5.0, places=12) + + def test_fft_dc_component(self): + """DC component (k=0) should equal sum of the signal.""" + signal = [1.0, 2.0, 3.0, 4.0] + result = fft(signal) + self.assertAlmostEqual(result[0].real, sum(signal), places=10) + + def test_ifft_fft_roundtrip(self): + signal = [1.0, -2.0, 3.5, -0.5, 0.0, 1.0, 2.0, -3.0] + restored = ifft(fft(signal)) + for orig, rec in zip(signal, restored): + self.assertAlmostEqual(orig, rec.real, places=10) + self.assertAlmostEqual(rec.imag, 0.0, places=10) + + def test_ifft_empty(self): + self.assertEqual(ifft([]), []) + + def test_parseval_theorem(self): + """Energy in time domain == energy in frequency domain / N.""" + signal = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0] + spectrum = fft(signal) + n = len(spectrum) + energy_time = sum(x * x for x in signal) + energy_freq = sum(abs(X) ** 2 for X in spectrum) / n + self.assertAlmostEqual(energy_time, energy_freq, places=8) + + +class TestEpicycleDecomposition(unittest.TestCase): + def test_empty_raises(self): + with self.assertRaises(ValueError): + EpicycleDecomposition([]) + + def test_lengths(self): + d = EpicycleDecomposition([1.0, 2.0, 3.0, 4.0]) + self.assertEqual(len(d.amplitudes), d.n) + self.assertEqual(len(d.phases), d.n) + self.assertEqual(len(d.frequencies), d.n) + + def test_n_is_power_of_two(self): + d = EpicycleDecomposition([1.0, 2.0, 3.0]) + self.assertEqual(d.n, 4) + + def test_amplitudes_nonnegative(self): + d = EpicycleDecomposition([1.0, -2.0, 3.0, -4.0]) + for a in d.amplitudes: + self.assertGreaterEqual(a, 0.0) + + def test_phases_in_range(self): + d = EpicycleDecomposition([1.5, 2.5, 0.5, -1.0]) + for p in d.phases: + self.assertGreaterEqual(p, 0.0) + self.assertLess(p, _TAU + 1e-9) + + def test_reconstruction_lossless(self): + signal = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0] + d = EpicycleDecomposition(signal) + rec = d.reconstruct() + for orig, r in zip(signal, rec): + self.assertAlmostEqual(orig, r, places=8) + + def test_reconstruction_padded(self): + """Non-power-of-2 input: first n_orig values should reconstruct.""" + signal = [3.0, 1.0, 4.0, 1.0, 5.0] # length 5 → padded to 8 + d = EpicycleDecomposition(signal) + rec = d.reconstruct() + self.assertEqual(len(rec), len(signal)) + for orig, r in zip(signal, rec): + self.assertAlmostEqual(orig, r, places=8) + + def test_phase_vector_length(self): + d = EpicycleDecomposition([1.0, 2.0, 3.0, 4.0]) + self.assertEqual(len(d.phase_vector), d.n) + + def test_phase_similarity_self(self): + d = EpicycleDecomposition([1.0, 2.0, 3.0, 4.0]) + self.assertAlmostEqual(d.phase_similarity(d), 1.0, places=10) + + def test_phase_similarity_range(self): + d1 = EpicycleDecomposition([1.0, 0.0, -1.0, 0.0]) + d2 = EpicycleDecomposition([0.0, 1.0, 0.0, -1.0]) + sim = d1.phase_similarity(d2) + self.assertGreaterEqual(sim, -1.0) + self.assertLessEqual(sim, 1.0) + + def test_pack_unpack_roundtrip(self): + signal = [1.0, 2.0, -1.0, 0.5, 0.0, -0.5, 1.5, -2.0] + d = EpicycleDecomposition(signal) + packed = d.pack() + self.assertEqual(len(packed), d.n * 2) + unpacked = EpicycleDecomposition.unpack_phases(packed) + for orig, rec in zip(d.phases, unpacked): + self.assertAlmostEqual(orig, rec, delta=_TAU / 65535 + 1e-6) + + def test_dominant_frequency(self): + """Pure sine at frequency k should give dominant_frequency ≈ k.""" + n = 16 + k = 3 + signal = [math.sin(_TAU * k * j / n) for j in range(n)] + d = EpicycleDecomposition(signal) + self.assertIn(d.dominant_frequency, {k, n - k}) + + def test_repr(self): + d = EpicycleDecomposition([1.0, 2.0, 3.0, 4.0]) + self.assertIn("EpicycleDecomposition", repr(d)) + + def test_len(self): + d = EpicycleDecomposition([1.0, 2.0]) + self.assertEqual(len(d), d.n) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mobius.py b/tests/test_mobius.py new file mode 100644 index 00000000..ec3c2c79 --- /dev/null +++ b/tests/test_mobius.py @@ -0,0 +1,145 @@ +"""Tests for ucns.mobius – Möbius disk transformations.""" + +import cmath +import math +import unittest + +from ucns.mobius import ( + MobiusTransform, + circle_to_disk, + disk_to_circle, + poincare_distance, +) + +_TAU = 2.0 * math.pi + + +class TestMobiusConstruction(unittest.TestCase): + def test_valid_construction(self): + t = MobiusTransform(0.5 + 0j) + self.assertEqual(t.a, 0.5 + 0j) + + def test_a_on_boundary_raises(self): + with self.assertRaises(ValueError): + MobiusTransform(1.0 + 0j) + + def test_a_outside_disk_raises(self): + with self.assertRaises(ValueError): + MobiusTransform(1.5 + 0j) + + def test_phi_normalised(self): + t = MobiusTransform(0j, phi=_TAU + 1.0) + self.assertAlmostEqual(t.phi, 1.0, places=10) + + +class TestMobiusEvaluation(unittest.TestCase): + def test_maps_a_to_zero(self): + a = 0.3 + 0.2j + t = MobiusTransform(a) + result = t(a) + self.assertAlmostEqual(abs(result), 0.0, places=12) + + def test_maps_zero_to_minus_a_rotated(self): + a = 0.4 + 0j + phi = 0.5 + t = MobiusTransform(a, phi=phi) + expected = cmath.exp(1j * phi) * (-a) + self.assertAlmostEqual(t(0j), expected, places=12) + + def test_preserves_unit_circle(self): + """Points on ∂D should map to ∂D.""" + a = 0.3 + 0.1j + t = MobiusTransform(a, phi=0.7) + for angle in [0.0, 1.0, 2.0, 3.0, 4.0, 5.0]: + z = cmath.exp(1j * angle) + w = t(z) + self.assertAlmostEqual(abs(w), 1.0, places=10, + msg=f"Unit circle not preserved at angle={angle}") + + def test_maps_disk_to_disk(self): + """Interior points should stay inside D.""" + a = 0.2 - 0.3j + t = MobiusTransform(a, phi=1.2) + for z in [0.1 + 0j, 0.5j, -0.4 - 0.4j]: + self.assertLess(abs(t(z)), 1.0) + + +class TestMobiusInverse(unittest.TestCase): + def test_inverse_roundtrip(self): + a = 0.35 + 0.15j + t = MobiusTransform(a, phi=0.9) + t_inv = t.inverse() + for z in [0j, 0.1 + 0.2j, -0.3 + 0.0j]: + self.assertAlmostEqual(t_inv(t(z)), z, places=10) + + def test_identity_transform(self): + t = MobiusTransform(0j, phi=0.0) + for z in [0.3 + 0j, -0.5j, 0.1 + 0.1j]: + self.assertAlmostEqual(t(z), z, places=12) + + +class TestPoincareDistance(unittest.TestCase): + def test_distance_zero_with_itself(self): + z = 0.3 + 0.2j + self.assertAlmostEqual(poincare_distance(z, z), 0.0, places=12) + + def test_distance_positive(self): + d = poincare_distance(0.1 + 0j, -0.1 + 0j) + self.assertGreater(d, 0.0) + + def test_distance_symmetric(self): + z = 0.2 + 0.1j + w = -0.3 + 0.15j + self.assertAlmostEqual(poincare_distance(z, w), poincare_distance(w, z), places=12) + + def test_distance_grows_near_boundary(self): + """Points near the boundary should be "far" from the origin.""" + d_near = poincare_distance(0j, 0.1 + 0j) + d_far = poincare_distance(0j, 0.99 + 0j) + self.assertGreater(d_far, d_near) + + def test_outside_disk_raises(self): + with self.assertRaises(ValueError): + poincare_distance(1.5 + 0j, 0j) + + def test_on_boundary_raises(self): + with self.assertRaises(ValueError): + poincare_distance(1.0 + 0j, 0j) + + def test_mobius_isometry(self): + """Möbius transform must preserve hyperbolic distance.""" + z = 0.3 + 0.1j + w = -0.2 + 0.4j + t = MobiusTransform(0.1 + 0.05j, phi=0.3) + d_before = poincare_distance(z, w) + d_after = poincare_distance(t(z), t(w)) + self.assertAlmostEqual(d_before, d_after, places=8) + + +class TestDiskCircleProjection(unittest.TestCase): + def test_disk_to_circle_angle(self): + theta = 1.2 + z = 0.7 * cmath.exp(1j * theta) + self.assertAlmostEqual(disk_to_circle(z), theta, places=10) + + def test_disk_to_circle_zero(self): + self.assertAlmostEqual(disk_to_circle(0j), 0.0, places=10) + + def test_circle_to_disk_modulus(self): + z = circle_to_disk(1.0, r=0.5) + self.assertAlmostEqual(abs(z), 0.5, places=12) + + def test_circle_to_disk_angle(self): + theta = 2.5 + z = circle_to_disk(theta, r=0.3) + self.assertAlmostEqual(cmath.phase(z), theta, places=10) + + def test_circle_to_disk_invalid_radius(self): + with self.assertRaises(ValueError): + circle_to_disk(1.0, r=0.0) + with self.assertRaises(ValueError): + circle_to_disk(1.0, r=1.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_similarity.py b/tests/test_similarity.py new file mode 100644 index 00000000..e8b089d5 --- /dev/null +++ b/tests/test_similarity.py @@ -0,0 +1,143 @@ +"""Tests for ucns.similarity – metric functions.""" + +import math +import unittest + +from ucns.similarity import arc_distance, hyperbolic_cosine, phase_cosine, top_k_overlap + +_TAU = 2.0 * math.pi + + +class TestPhaseCosine(unittest.TestCase): + def test_identical_gives_one(self): + a = [0.1, 0.5, 1.2, 2.0] + self.assertAlmostEqual(phase_cosine(a, a), 1.0, places=12) + + def test_opposite_gives_minus_one(self): + a = [0.0] + b = [math.pi] + self.assertAlmostEqual(phase_cosine(a, b), -1.0, places=12) + + def test_quarter_turn_gives_zero(self): + a = [0.0] + b = [math.pi / 2] + self.assertAlmostEqual(phase_cosine(a, b), 0.0, places=12) + + def test_symmetric(self): + a = [0.1, 0.5, 1.0] + b = [0.3, 0.7, 0.2] + self.assertAlmostEqual(phase_cosine(a, b), phase_cosine(b, a), places=12) + + def test_empty_returns_zero(self): + self.assertEqual(phase_cosine([], []), 0.0) + + def test_different_lengths_raises(self): + with self.assertRaises(ValueError): + phase_cosine([1.0], [1.0, 2.0]) + + +class TestArcDistance(unittest.TestCase): + def test_identical_gives_zero(self): + a = [0.5, 1.0, 2.0] + self.assertAlmostEqual(arc_distance(a, a), 0.0, places=12) + + def test_half_circle_gives_one(self): + a = [0.0] + b = [math.pi] + self.assertAlmostEqual(arc_distance(a, b), 1.0, places=12) + + def test_short_arc_used(self): + """Going 0 → 2π-0.1 should use short arc 0.1, not 2π-0.1.""" + a = [0.0] + b = [_TAU - 0.1] + result = arc_distance(a, b) + self.assertAlmostEqual(result, 0.1 / math.pi, places=10) + + def test_symmetric(self): + a = [0.3, 1.1, 2.5] + b = [1.0, 0.5, 0.1] + self.assertAlmostEqual(arc_distance(a, b), arc_distance(b, a), places=12) + + def test_result_in_range(self): + import random + rng = random.Random(42) + a = [rng.uniform(0, _TAU) for _ in range(20)] + b = [rng.uniform(0, _TAU) for _ in range(20)] + result = arc_distance(a, b) + self.assertGreaterEqual(result, 0.0) + self.assertLessEqual(result, 1.0) + + def test_empty_returns_zero(self): + self.assertEqual(arc_distance([], []), 0.0) + + def test_different_lengths_raises(self): + with self.assertRaises(ValueError): + arc_distance([1.0], [1.0, 2.0]) + + +class TestHyperbolicCosine(unittest.TestCase): + def test_identical_gives_one(self): + a = [0.1, 0.5, 1.2] + self.assertAlmostEqual(hyperbolic_cosine(a, a), 1.0, places=10) + + def test_result_in_range(self): + a = [0.0, 1.0, 2.0, 3.0] + b = [math.pi, 0.5, 1.5, 2.5] + result = hyperbolic_cosine(a, b) + self.assertGreaterEqual(result, -1.0) + self.assertLessEqual(result, 1.0) + + def test_symmetric(self): + a = [0.3, 1.1] + b = [1.0, 0.5] + self.assertAlmostEqual( + hyperbolic_cosine(a, b), + hyperbolic_cosine(b, a), + places=12, + ) + + def test_invalid_radius_raises(self): + with self.assertRaises(ValueError): + hyperbolic_cosine([0.0], [0.0], radius=0.0) + with self.assertRaises(ValueError): + hyperbolic_cosine([0.0], [0.0], radius=1.0) + + def test_empty_returns_zero(self): + self.assertEqual(hyperbolic_cosine([], []), 0.0) + + def test_different_lengths_raises(self): + with self.assertRaises(ValueError): + hyperbolic_cosine([1.0], [1.0, 2.0]) + + +class TestTopKOverlap(unittest.TestCase): + def test_identical_amplitudes_gives_one(self): + a = [3.0, 1.0, 2.0, 0.5] + self.assertAlmostEqual(top_k_overlap(a, a, k=2), 1.0, places=12) + + def test_completely_different_gives_zero(self): + a = [1.0, 0.0, 0.0, 0.0] + b = [0.0, 0.0, 0.0, 1.0] + self.assertAlmostEqual(top_k_overlap(a, b, k=1), 0.0, places=12) + + def test_result_in_range(self): + a = [3.0, 1.0, 2.0, 4.0] + b = [1.0, 4.0, 0.5, 2.0] + result = top_k_overlap(a, b, k=2) + self.assertGreaterEqual(result, 0.0) + self.assertLessEqual(result, 1.0) + + def test_k_larger_than_n(self): + """k is clamped to len(amplitudes).""" + a = [1.0, 2.0] + b = [2.0, 1.0] + # with k=100 (clamped to 2), both sets are the same → overlap = 1 + self.assertAlmostEqual(top_k_overlap(a, b, k=100), 1.0, places=12) + + def test_different_lengths_raises(self): + with self.assertRaises(ValueError): + top_k_overlap([1.0], [1.0, 2.0], k=1) + + +if __name__ == "__main__": + unittest.main() diff --git a/ucns/__init__.py b/ucns/__init__.py new file mode 100644 index 00000000..49026c3a --- /dev/null +++ b/ucns/__init__.py @@ -0,0 +1,76 @@ +""" +ucns – Unit Circle Number System +================================= +A zero-dependency Python library for creating compact, efficient embeddings +using a novel **Unit Circle Number System (UCNS)**. + +Overview +-------- +Every number in UCNS is an *angle* θ ∈ [0, 2π) that identifies a point on the +unit circle e^(iθ). A sequence of such angles forms a UCNS embedding: + +* **Compact** – angles stored as ``uint16`` use only 2 bytes per dimension + (2× smaller than float32). +* **Fast similarity** – inner product = mean of cos(θᵢ − φᵢ); no length + normalisation required. +* **Hierarchical** – the recursive epicycle (FFT) structure captures + multi-scale patterns; the Möbius disk geometry supports hyperbolic + (tree-like) relationships. +* **Zero dependencies** – pure Python standard library only. + +Quick start +----------- +>>> from ucns import UCNEmbedding +>>> emb = UCNEmbedding(dim=64) +>>> v1 = emb.encode("hello world") +>>> v2 = emb.encode("hello world") +>>> emb.similarity(v1, v2) +1.0 +>>> packed = emb.encode_packed("hello world") +>>> len(packed) # 64 angles × 2 bytes +128 + +Building blocks +--------------- +``UCN`` + Single unit-circle number (angle + arithmetic). +``EpicycleDecomposition`` + Decompose any signal into weighted unit-circle rotations via FFT. +``MobiusTransform`` + Conformal automorphism of the Poincaré disk. +``UCNEmbedding`` + High-level embedding API. +Similarity functions + ``phase_cosine``, ``arc_distance``, ``hyperbolic_cosine``, + ``top_k_overlap``. +""" + +from .core import UCN, TAU +from .epicycle import EpicycleDecomposition, fft, ifft +from .embedding import UCNEmbedding +from .mobius import MobiusTransform, poincare_distance, disk_to_circle, circle_to_disk +from .similarity import phase_cosine, arc_distance, hyperbolic_cosine, top_k_overlap + +__all__ = [ + # Core number type + "UCN", + "TAU", + # Epicycle / FFT + "EpicycleDecomposition", + "fft", + "ifft", + # Möbius disk + "MobiusTransform", + "poincare_distance", + "disk_to_circle", + "circle_to_disk", + # Embedding + "UCNEmbedding", + # Similarity metrics + "phase_cosine", + "arc_distance", + "hyperbolic_cosine", + "top_k_overlap", +] + +__version__ = "0.1.0" diff --git a/ucns/__pycache__/__init__.cpython-312.pyc b/ucns/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..035eeff4033bd790a4ca99f637f46c92520cd3dc GIT binary patch literal 2246 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 CKJ*O$ literal 0 HcmV?d00001 diff --git a/ucns/__pycache__/embedding.cpython-312.pyc b/ucns/__pycache__/embedding.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..95f78944f28897a96ef5430adf601c70f138ac21 GIT binary patch literal 8891 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 literal 0 HcmV?d00001 diff --git a/ucns/__pycache__/epicycle.cpython-312.pyc b/ucns/__pycache__/epicycle.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0070cc8f530e9d6c970b30768f944e881990cb86 GIT binary patch literal 12371 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 literal 0 HcmV?d00001 diff --git a/ucns/__pycache__/mobius.cpython-312.pyc b/ucns/__pycache__/mobius.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e250de3809860f4a3715b79b56ff983ef830b595 GIT binary patch literal 7979 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 literal 0 HcmV?d00001 diff --git a/ucns/__pycache__/similarity.cpython-312.pyc b/ucns/__pycache__/similarity.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..74079ac7bdaa635603a2562bcb59e1b693272a5b GIT binary patch literal 7112 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 Hf^`20E08%_ literal 0 HcmV?d00001 diff --git a/ucns/core.py b/ucns/core.py new file mode 100644 index 00000000..046eb828 --- /dev/null +++ b/ucns/core.py @@ -0,0 +1,165 @@ +""" +ucns.core +========= +Unit Circle Number (UCN) – the fundamental numeric primitive. + +Every UCN is an angle θ ∈ [0, 2π) that identifies a point on the unit circle +e^(iθ) ∈ ℂ. Because |e^(iθ)| = 1 for all θ, the set of all UCNs forms a +compact abelian group under multiplication (rotation), making them a natural +substrate for periodic / cyclic data and for efficient angular embeddings. + +Key properties +-------------- +* **Closure**: multiplying two UCNs (adding angles) always stays on the unit + circle. +* **Compact storage**: an angle fits in a 16-bit integer (65 536 steps vs 32 + bits for a single-precision float). +* **Fast inner product**: dot(u, v) = cos(θ_u − θ_v) – no square root needed. +* **No external dependencies**: pure Python / math stdlib only. +""" + +from __future__ import annotations + +import math +import cmath +import struct + +__all__ = ["UCN", "TAU"] + +TAU: float = 2.0 * math.pi # full turn = τ + + +class UCN: + """Unit Circle Number – a real number encoded as an angle on the unit circle. + + Parameters + ---------- + theta: + Angle in radians. Automatically reduced modulo τ = 2π so that + ``self.theta`` is always in ``[0, τ)``. + """ + + __slots__ = ("_theta",) + + def __init__(self, theta: float) -> None: + self._theta: float = float(theta) % TAU + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + @property + def theta(self) -> float: + """Angle in radians, normalised to ``[0, τ)``.""" + return self._theta + + @property + def real(self) -> float: + """Real part of the corresponding unit-circle point: cos θ.""" + return math.cos(self._theta) + + @property + def imag(self) -> float: + """Imaginary part of the corresponding unit-circle point: sin θ.""" + return math.sin(self._theta) + + @property + def complex(self) -> complex: + """The unit-circle point as a Python ``complex``: e^(iθ).""" + return cmath.exp(1j * self._theta) + + # ------------------------------------------------------------------ + # Constructors + # ------------------------------------------------------------------ + + @classmethod + def from_complex(cls, z: complex) -> "UCN": + """Project any complex number *z* onto the unit circle (keep phase).""" + return cls(cmath.phase(z)) + + @classmethod + def from_real(cls, x: float, lo: float = -1.0, hi: float = 1.0) -> "UCN": + """Map a real number *x* ∈ [lo, hi] uniformly onto [0, τ). + + Values outside ``[lo, hi]`` are clamped before mapping. + """ + if hi == lo: + raise ValueError("lo and hi must differ") + t = (max(lo, min(hi, x)) - lo) / (hi - lo) # ∈ [0, 1] + return cls(t * TAU) + + # ------------------------------------------------------------------ + # Group arithmetic (unit circle = ℝ/τℤ) + # ------------------------------------------------------------------ + + def __mul__(self, other: "UCN") -> "UCN": + """Rotation: θ₁ ⊗ θ₂ ≡ θ₁ + θ₂ (mod τ).""" + return UCN(self._theta + other._theta) + + def __truediv__(self, other: "UCN") -> "UCN": + """Inverse rotation: θ₁ ⊘ θ₂ ≡ θ₁ − θ₂ (mod τ).""" + return UCN(self._theta - other._theta) + + def conjugate(self) -> "UCN": + """Conjugate (reflection): θ* ≡ −θ (mod τ).""" + return UCN(-self._theta) + + # ------------------------------------------------------------------ + # Metric / similarity + # ------------------------------------------------------------------ + + def dot(self, other: "UCN") -> float: + """Angular inner product: cos(θ_self − θ_other) ∈ [−1, 1].""" + return math.cos(self._theta - other._theta) + + def arc_distance(self, other: "UCN") -> float: + """Geodesic (arc-length) distance on the unit circle ∈ [0, π].""" + diff = abs(self._theta - other._theta) % TAU + return min(diff, TAU - diff) + + # ------------------------------------------------------------------ + # Compact serialisation + # ------------------------------------------------------------------ + + def to_int16(self) -> int: + """Quantise angle to an unsigned 16-bit integer (0 … 65 535). + + Provides ~0.0001 rad (≈ 0.006°) angular resolution with only 2 bytes. + """ + return min(65535, int(self._theta * 65535.0 / TAU)) + + @classmethod + def from_int16(cls, v: int) -> "UCN": + """Restore a UCN from a 16-bit quantised integer.""" + return cls(int(v) * TAU / 65535.0) + + def to_bytes(self) -> bytes: + """Serialise to 2 bytes (little-endian unsigned short).""" + return struct.pack(" "UCN": + """Deserialise from 2 bytes.""" + (v,) = struct.unpack(" str: + return f"UCN({self._theta:.6f})" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, UCN): + return NotImplemented + return abs(self._theta - other._theta) < 1e-9 + + def __hash__(self) -> int: + return hash(round(self._theta, 9)) + + def __float__(self) -> float: + return self._theta + + def __lt__(self, other: "UCN") -> bool: + return self._theta < other._theta diff --git a/ucns/embedding.py b/ucns/embedding.py new file mode 100644 index 00000000..0585956d --- /dev/null +++ b/ucns/embedding.py @@ -0,0 +1,212 @@ +""" +ucns.embedding +============== +High-level embedding API built on the Unit Circle Number System. + +**Why UCNS embeddings?** + +Traditional neural embeddings (e.g. word2vec, BERT, OpenAI Ada) produce +*dense float32 vectors* of 384–3072 dimensions. UCNS embeddings offer three +concrete advantages: + +1. **Compact storage** – angles are quantised to ``uint16`` (2 bytes), giving + a 2× space saving over ``float32`` with negligible information loss + (≈0.0001 rad angular resolution). + +2. **Fast similarity** – the inner product ``cos(θᵢ − φᵢ)`` is computed with + a single subtraction and cosine lookup per dimension. No square root for + normalisation is needed because all embeddings already live on the unit + torus (‖embedding‖ = 1 by construction). + +3. **Zero dependencies** – implemented entirely in the Python standard library + (``math``, ``cmath``, ``struct``). + +Architecture +------------ +``UCNEmbedding`` uses ``EpicycleDecomposition`` under the hood: + + input data → real-valued signal → FFT → phases = embedding + +The embedding dimension is always a power of two (the next power-of-two ≥ +*dim*) because the FFT requires it. Extra dimensions are zeroed out. + +Supported input types +--------------------- +* ``float`` / ``int`` – single-element signal. +* ``str`` – ordinal encoding of Unicode code points. +* ``list[float]`` / ``tuple[float]`` – arbitrary real-valued signal. +* ``bytes`` – unsigned byte values as signal. +""" + +from __future__ import annotations + +import math +import struct +from typing import Union + +from .epicycle import EpicycleDecomposition, _next_pow2 + +__all__ = ["UCNEmbedding"] + +_TAU = 2.0 * math.pi + +# Type accepted by UCNEmbedding.encode +Encodable = Union[int, float, str, bytes, list, tuple] + + +class UCNEmbedding: + """Generate and compare Unit Circle Number System embeddings. + + Parameters + ---------- + dim: + Desired embedding dimension. The actual dimension used is the next + power of two ≥ *dim* (because the FFT requires it). + + Examples + -------- + >>> emb = UCNEmbedding(dim=16) + >>> v1 = emb.encode("hello") + >>> v2 = emb.encode("hello") + >>> emb.similarity(v1, v2) + 1.0 + >>> v3 = emb.encode("world") + >>> -1.0 <= emb.similarity(v1, v3) <= 1.0 + True + """ + + def __init__(self, dim: int = 64) -> None: + if dim < 1: + raise ValueError("dim must be at least 1") + self._dim_requested: int = dim + self._dim: int = _next_pow2(dim) + + @property + def dim(self) -> int: + """Actual embedding dimension (next power of two ≥ the requested dim).""" + return self._dim + + # ------------------------------------------------------------------ + # Encoding + # ------------------------------------------------------------------ + + def encode(self, data: Encodable) -> list[float]: + """Encode *data* as a UCNS embedding vector. + + Returns a list of ``dim`` angles in ``[0, τ)``. Identical data always + produces identical embeddings. + + Parameters + ---------- + data: + Input to encode. See module docstring for supported types. + """ + signal = self._to_signal(data) + # Pad / truncate to self._dim + if len(signal) < self._dim: + signal = signal + [0.0] * (self._dim - len(signal)) + else: + signal = signal[: self._dim] + decomp = EpicycleDecomposition(signal) + return decomp.phase_vector + + def encode_packed(self, data: Encodable) -> bytes: + """Encode and immediately serialise to compact ``uint16`` bytes. + + Each of the ``dim`` angles is stored as a 16-bit unsigned integer, + giving ``2 * dim`` bytes total (vs. ``4 * dim`` for float32). + """ + phases = self.encode(data) + scale = 65535.0 / _TAU + ints = [min(65535, int(p * scale)) for p in phases] + return struct.pack(f"<{len(ints)}H", *ints) + + @staticmethod + def unpack(data: bytes) -> list[float]: + """Unpack ``uint16`` bytes back to a list of float angles.""" + n = len(data) // 2 + ints = struct.unpack(f"<{n}H", data) + scale = _TAU / 65535.0 + return [v * scale for v in ints] + + # ------------------------------------------------------------------ + # Similarity + # ------------------------------------------------------------------ + + def similarity(self, a: list[float], b: list[float]) -> float: + """Mean phase-cosine similarity between two embeddings ∈ [−1, 1]. + + This is the canonical UCNS inner product: + + sim(a, b) = (1/dim) · Σᵢ cos(aᵢ − bᵢ) + + All embedding vectors have unit "norm" under this metric, so the + result is a pure cosine without any length normalisation step. + """ + if len(a) != len(b): + raise ValueError( + f"Embeddings must have equal length; got {len(a)} and {len(b)}" + ) + if not a: + return 0.0 + return sum(math.cos(ai - bi) for ai, bi in zip(a, b)) / len(a) + + def nearest( + self, + query: list[float], + corpus: list[list[float]], + ) -> tuple[int, float]: + """Find the index and score of the most similar embedding in *corpus*. + + Parameters + ---------- + query: + Query embedding (list of angles). + corpus: + List of candidate embeddings to compare against. + + Returns + ------- + (index, score): + Index of the best match and its similarity score in ``[−1, 1]``. + """ + if not corpus: + raise ValueError("corpus is empty") + best_idx = 0 + best_score = self.similarity(query, corpus[0]) + for i, candidate in enumerate(corpus[1:], start=1): + score = self.similarity(query, candidate) + if score > best_score: + best_score = score + best_idx = i + return best_idx, best_score + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + @staticmethod + def _to_signal(data: Encodable) -> list[float]: + """Convert supported input types to a list of floats.""" + if isinstance(data, (int, float)): + return [float(data)] + if isinstance(data, str): + return [float(ord(c)) for c in data] + if isinstance(data, bytes): + return [float(b) for b in data] + if isinstance(data, (list, tuple)): + return [float(x) for x in data] + raise TypeError( + f"Unsupported type {type(data).__name__!r}. " + "Expected int, float, str, bytes, list, or tuple." + ) + + # ------------------------------------------------------------------ + # Dunder + # ------------------------------------------------------------------ + + def __repr__(self) -> str: + return ( + f"UCNEmbedding(dim={self._dim}, " + f"bytes_per_embedding={self._dim * 2})" + ) diff --git a/ucns/epicycle.py b/ucns/epicycle.py new file mode 100644 index 00000000..5ba24ca5 --- /dev/null +++ b/ucns/epicycle.py @@ -0,0 +1,278 @@ +""" +ucns.epicycle +============= +Epicycle decomposition via the Fast Fourier Transform (FFT). + +**What are epicycles?** +In the Ptolemaic model of the solar system, planets move on small circles +(epicycles) whose centres trace larger circles. Mathematically this is +equivalent to a finite Fourier series: any smooth periodic signal can be +written as a superposition of circular motions at integer multiples of a +fundamental frequency. + +**Why epicycles for UCNS?** +The unit circle supports exactly this structure. A signal of length *n* is +fully characterised by *n* complex Fourier coefficients. Each coefficient +describes *one epicycle*: + + amplitude = radius of the epicycle + frequency = how many full turns per signal period + phase = UCN angle at which that epicycle starts + +Storing only the *phases* (and discarding amplitude information) gives a +compact angular "fingerprint" of the signal. Including amplitudes allows +exact reconstruction (the full DFT is invertible). + +**Efficiency** +This module implements a pure-Python Cooley–Tukey radix-2 FFT so that large +signals are handled in O(n log n) time rather than the O(n²) naive DFT. +Inputs whose length is not a power of two are zero-padded automatically. +""" + +from __future__ import annotations + +import cmath +import math +import struct + +__all__ = [ + "fft", + "ifft", + "EpicycleDecomposition", +] + +_TAU = 2.0 * math.pi + + +# ------------------------------------------------------------------ +# Low-level FFT (radix-2 Cooley–Tukey, in-place) +# ------------------------------------------------------------------ + + +def _next_pow2(n: int) -> int: + """Return the smallest power of two ≥ *n*.""" + p = 1 + while p < n: + p <<= 1 + return p + + +def fft(signal: list[float | complex]) -> list[complex]: + """Compute the 1-D Discrete Fourier Transform using the Cooley–Tukey + radix-2 FFT algorithm. + + Parameters + ---------- + signal: + Sequence of real or complex samples. If its length is not a power of + two it is **zero-padded** to the next power of two. + + Returns + ------- + list[complex] + Length-*N* list of complex frequency-domain coefficients where + ``N = next_pow2(len(signal))``. The *k*-th entry equals + + X[k] = Σ_{j=0}^{N-1} x[j] · e^{−2πi·j·k/N}. + """ + n = _next_pow2(len(signal)) + x: list[complex] = [complex(v) for v in signal] + x += [0j] * (n - len(x)) # zero-pad + _fft_inplace(x, inverse=False) + return x + + +def ifft(spectrum: list[complex]) -> list[complex]: + """Compute the inverse DFT (normalised so that ``ifft(fft(x)) ≈ x``). + + Parameters + ---------- + spectrum: + Length-*N* sequence of complex frequency coefficients + (``N`` must be a power of two). + + Returns + ------- + list[complex] + Length-*N* list of complex time-domain samples. + """ + n = len(spectrum) + if n == 0: + return [] + x = [complex(v) for v in spectrum] + _fft_inplace(x, inverse=True) + inv_n = 1.0 / n + return [v * inv_n for v in x] + + +def _fft_inplace(x: list[complex], *, inverse: bool) -> None: + """Cooley–Tukey iterative FFT (bit-reversal permutation + butterfly). + + Operates in-place on list *x* whose length **must** be a power of two. + """ + n = len(x) + if n <= 1: + return + + # Bit-reversal permutation + j = 0 + for i in range(1, n): + bit = n >> 1 + while j & bit: + j ^= bit + bit >>= 1 + j ^= bit + if i < j: + x[i], x[j] = x[j], x[i] + + # Butterfly passes + sign = 1.0 if inverse else -1.0 + length = 2 + while length <= n: + half = length >> 1 + w_n = cmath.exp(1j * sign * _TAU / length) + for i in range(0, n, length): + w = 1.0 + 0j + for k in range(half): + u = x[i + k] + v = x[i + k + half] * w + x[i + k] = u + v + x[i + k + half] = u - v + w *= w_n + length <<= 1 + + +# ------------------------------------------------------------------ +# High-level epicycle decomposition +# ------------------------------------------------------------------ + + +class EpicycleDecomposition: + """Represent a real signal as a set of weighted unit-circle rotations. + + After construction, the signal is fully described by three parallel arrays: + + * ``amplitudes[k]`` – radius of the *k*-th epicycle (≥ 0). + * ``phases[k]`` – UCN angle of the *k*-th epicycle ∈ [0, τ). + * ``frequencies[k]`` – integer frequency index (0, 1, …, N−1). + + Parameters + ---------- + signal: + 1-D sequence of real (or complex) numbers. + """ + + __slots__ = ("_n_orig", "_n", "amplitudes", "phases", "frequencies") + + def __init__(self, signal: list[float | complex]) -> None: + if not signal: + raise ValueError("signal must be non-empty") + self._n_orig: int = len(signal) + spectrum = fft(signal) + n = len(spectrum) + self._n: int = n + self.frequencies: list[int] = list(range(n)) + self.amplitudes: list[float] = [abs(c) / n for c in spectrum] + self.phases: list[float] = [ + cmath.phase(c) % _TAU if abs(c) > 1e-12 else 0.0 + for c in spectrum + ] + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + @property + def n(self) -> int: + """Length of the (possibly padded) transform.""" + return self._n + + @property + def phase_vector(self) -> list[float]: + """All epicycle phases as a flat list of angles in ``[0, τ)``.""" + return list(self.phases) + + @property + def dominant_frequency(self) -> int: + """Index of the epicycle with the largest amplitude.""" + return max(range(self._n), key=lambda k: self.amplitudes[k]) + + # ------------------------------------------------------------------ + # Reconstruction + # ------------------------------------------------------------------ + + def reconstruct(self) -> list[float]: + """Reconstruct the original signal (first ``n_orig`` samples). + + Reconstruction is lossless when the input length was already a power + of two; otherwise only the first ``n_orig`` values are meaningful. + """ + n = self._n + spectrum = [ + self.amplitudes[k] * n * cmath.exp(1j * self.phases[k]) + for k in range(n) + ] + samples = ifft(spectrum) + return [s.real for s in samples[: self._n_orig]] + + # ------------------------------------------------------------------ + # Similarity + # ------------------------------------------------------------------ + + def phase_similarity(self, other: "EpicycleDecomposition") -> float: + """Amplitude-weighted phase-cosine similarity ∈ [−1, 1]. + + Computes + + sim = Σ_k (A_k · B_k · cos(φ_k − ψ_k)) / (‖A‖₂ · ‖B‖₂) + + where *A_k*, *B_k* are the amplitudes and *φ_k*, *ψ_k* are the phases + of the two decompositions. Uses only the shared frequency bands when + the two transforms have different lengths. + """ + n = min(self._n, other._n) + total = sum( + self.amplitudes[k] * other.amplitudes[k] + * math.cos(self.phases[k] - other.phases[k]) + for k in range(n) + ) + norm_a = math.sqrt(sum(a * a for a in self.amplitudes[:n])) or 1.0 + norm_b = math.sqrt(sum(b * b for b in other.amplitudes[:n])) or 1.0 + return total / (norm_a * norm_b) + + # ------------------------------------------------------------------ + # Compact serialisation + # ------------------------------------------------------------------ + + def pack(self) -> bytes: + """Serialise phases as 16-bit unsigned integers (2 bytes each). + + Provides ~0.0001 rad resolution with 2 bytes per dimension, giving a + **2× compression** vs. 32-bit floats for the same angular data. + """ + scale = 65535.0 / _TAU + ints = [min(65535, int(p * scale)) for p in self.phases] + return struct.pack(f"<{len(ints)}H", *ints) + + @classmethod + def unpack_phases(cls, data: bytes) -> list[float]: + """Deserialise 16-bit integer phases back to floats in ``[0, τ)``.""" + n = len(data) // 2 + ints = struct.unpack(f"<{n}H", data) + scale = _TAU / 65535.0 + return [v * scale for v in ints] + + # ------------------------------------------------------------------ + # Dunder + # ------------------------------------------------------------------ + + def __repr__(self) -> str: + dom = self.dominant_frequency + return ( + f"EpicycleDecomposition(n={self._n}, " + f"dominant_freq={dom}, " + f"dominant_amplitude={self.amplitudes[dom]:.4f})" + ) + + def __len__(self) -> int: + return self._n diff --git a/ucns/mobius.py b/ucns/mobius.py new file mode 100644 index 00000000..7bdcf5b0 --- /dev/null +++ b/ucns/mobius.py @@ -0,0 +1,191 @@ +""" +ucns.mobius +=========== +Möbius (bilinear) transformations of the unit disk. + +The **Poincaré disk model** represents the hyperbolic plane as the open unit +disk D = {z ∈ ℂ : |z| < 1}. Its boundary ∂D is the unit circle – the home +of every UCN. Conformal automorphisms of D are Möbius transformations of the +form + + T_{a,φ}(z) = e^(iφ) · (z − a) / (1 − ā·z), a ∈ D, φ ∈ ℝ. + +These transformations: + +* **preserve** the unit circle (boundary maps to boundary), +* **preserve** the hyperbolic (Poincaré) metric, +* compose to form the group Aut(D) ≅ PU(1,1). + +Geometric intuition +------------------- +Think of the disk as a "rubber sheet" that can be stretched or compressed while +keeping the circular boundary fixed. Embedding data on the interior of the +disk naturally encodes *hierarchical* relationships: nearby points are close in +hyperbolic distance; points near the boundary are conceptually "far out" (low +frequency, coarse-grained). Combining this with the recursive epicycle +structure (see ``ucns.epicycle``) yields a multi-scale embedding space that +is simultaneously compact (unit circle) and hierarchical (Möbius disk). + +References +---------- +* Poincaré disk model – Wikipedia +* "Poincaré Embeddings for Learning Hierarchical Representations" – Nickel & Kiela 2017 +""" + +from __future__ import annotations + +import cmath +import math + +__all__ = ["MobiusTransform", "poincare_distance", "disk_to_circle", "circle_to_disk"] + +_TAU = 2.0 * math.pi + + +class MobiusTransform: + """A conformal automorphism of the open unit disk. + + Parameters + ---------- + a: + Translation parameter. Must satisfy ``|a| < 1`` (interior of disk). + The transformation maps ``a ↦ 0``. + phi: + Rotation angle in radians. Applied after the translation. + """ + + __slots__ = ("a", "phi") + + def __init__(self, a: complex, phi: float = 0.0) -> None: + if abs(a) >= 1.0: + raise ValueError( + f"|a| must be strictly less than 1; got |a| = {abs(a):.6f}" + ) + self.a: complex = complex(a) + self.phi: float = float(phi) % _TAU + + # ------------------------------------------------------------------ + # Evaluation + # ------------------------------------------------------------------ + + def __call__(self, z: complex) -> complex: + """Apply the transform: T(z) = e^(iφ) · (z − a) / (1 − ā·z).""" + denom = 1.0 - self.a.conjugate() * z + if abs(denom) < 1e-15: + raise ValueError("z is the image of infinity under this transform") + return cmath.exp(1j * self.phi) * (z - self.a) / denom + + # ------------------------------------------------------------------ + # Group structure + # ------------------------------------------------------------------ + + def inverse(self) -> "MobiusTransform": + """Return T⁻¹ such that T⁻¹(T(z)) = z for all z ∈ D.""" + return MobiusTransform( + -self.a * cmath.exp(1j * self.phi), + -self.phi, + ) + + def compose(self, other: "MobiusTransform") -> "MobiusTransform": + """Return the composed transform self ∘ other (apply *other* first).""" + # Numerically stable composition via a sample point + # We derive the new (a, phi) from where each transform sends 0. + # T_composed sends 0 → self(other(0)) + a_new = self(other(0j)) + # Determine rotation by evaluating at a second point + p = other(0.5 + 0j) + q = self(p) + # q = e^(i*phi_new) * (q_unnorm) → phi_new = arg(q / T_a_new(a_sample)) + t_check = MobiusTransform(a_new) + sample = 0.5 + 0j + out = q + raw = (out - a_new) / (1.0 - a_new.conjugate() * out) + phi_new = cmath.phase(raw) if abs(raw) > 1e-15 else 0.0 + return MobiusTransform(a_new, phi_new) + + # ------------------------------------------------------------------ + # Hyperbolic geometry + # ------------------------------------------------------------------ + + def hyperbolic_distance(self, z: complex, w: complex) -> float: + """Poincaré disk metric d(z, w) = 2·arctanh(|T_z(w)|). + + This is the intrinsic distance in the hyperbolic plane modelled by D. + """ + return poincare_distance(z, w) + + # ------------------------------------------------------------------ + # Dunder + # ------------------------------------------------------------------ + + def __repr__(self) -> str: + return f"MobiusTransform(a={self.a:.4f}, phi={self.phi:.4f})" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, MobiusTransform): + return NotImplemented + return ( + abs(self.a - other.a) < 1e-9 + and abs(self.phi - other.phi) < 1e-9 + ) + + +# ------------------------------------------------------------------ +# Standalone geometric helpers +# ------------------------------------------------------------------ + + +def poincare_distance(z: complex, w: complex) -> float: + """Hyperbolic distance between two points in the Poincaré disk. + + Parameters + ---------- + z, w: + Points in the open unit disk (``|z|, |w| < 1``). + + Returns + ------- + float + ``d(z, w) = 2·arctanh(|(z − w) / (1 − w̄·z)|)`` ≥ 0. + """ + for name, p in (("z", z), ("w", w)): + if abs(p) >= 1.0: + raise ValueError( + f"Point {name} = {p} lies outside or on the unit disk boundary" + ) + denom = 1.0 - w.conjugate() * z + if abs(denom) < 1e-15: + return float("inf") + rho = abs((z - w) / denom) + rho = min(rho, 1.0 - 1e-15) # guard against numerical overshoot + return 2.0 * math.atanh(rho) + + +def disk_to_circle(z: complex) -> float: + """Project a point *z* in the unit disk to its angle on ∂D (the unit circle). + + Uses the Cayley-like radial projection z ↦ z/|z| for z ≠ 0; for z = 0 + returns 0. The returned angle θ ∈ [0, τ) gives the UCN associated with + the boundary limit of the radial ray through *z*. + """ + if abs(z) < 1e-15: + return 0.0 + return cmath.phase(z) % _TAU + + +def circle_to_disk(theta: float, r: float = 0.5) -> complex: + """Embed a unit-circle point (angle) into the interior of the disk at radius *r*. + + This is useful when you want to treat UCN angles as interior hyperbolic + points (r < 1 keeps them strictly inside D). + + Parameters + ---------- + theta: + Angle on the unit circle (radians). + r: + Radial depth in (0, 1). Defaults to 0.5. + """ + if not (0.0 < r < 1.0): + raise ValueError("r must be in (0, 1)") + return r * cmath.exp(1j * theta) diff --git a/ucns/similarity.py b/ucns/similarity.py new file mode 100644 index 00000000..b410af48 --- /dev/null +++ b/ucns/similarity.py @@ -0,0 +1,181 @@ +""" +ucns.similarity +=============== +Similarity and distance metrics for Unit Circle Number embeddings. + +All functions operate on plain Python lists of angles (floats in [0, τ)) as +returned by ``UCNEmbedding.encode``. No external libraries required. + +Metric catalogue +---------------- +``phase_cosine`` + Mean of cos(θᵢ − φᵢ) – the natural "dot product" on the torus. + Range: [−1, 1]. Value 1 means identical embeddings. + +``arc_distance`` + Mean minimum arc length |θᵢ − φᵢ|_circle, normalised to [0, 1]. + Value 0 means identical; value 1 means diametrically opposite. + +``hyperbolic_cosine`` + Uses the Poincaré disk: maps each angle to an interior point of the + unit disk and computes hyperbolic cosine similarity. Sensitive to + hierarchical structure (high-frequency vs low-frequency components). + +``top_k_overlap`` + Jaccard-like overlap of the *k* dominant frequency indices. Good for + sparse or categorical data. +""" + +from __future__ import annotations + +import math +import cmath +from typing import Sequence + +__all__ = [ + "phase_cosine", + "arc_distance", + "hyperbolic_cosine", + "top_k_overlap", +] + +_TAU = 2.0 * math.pi + + +def _check_same_length(a: Sequence[float], b: Sequence[float]) -> int: + if len(a) != len(b): + raise ValueError( + f"Embeddings must have the same length; got {len(a)} and {len(b)}" + ) + return len(a) + + +def phase_cosine(a: Sequence[float], b: Sequence[float]) -> float: + """Mean angular cosine similarity between two UCNS embeddings. + + Parameters + ---------- + a, b: + Lists of angles (radians) of equal length. + + Returns + ------- + float + Value in ``[−1, 1]``. A value of 1 indicates identical phase + patterns; −1 indicates perfectly anti-phase patterns. + """ + n = _check_same_length(a, b) + if n == 0: + return 0.0 + return sum(math.cos(ai - bi) for ai, bi in zip(a, b)) / n + + +def arc_distance(a: Sequence[float], b: Sequence[float]) -> float: + """Mean normalised arc distance between two UCNS embeddings. + + Each per-dimension distance is the shorter arc between the two angles, + normalised by π so the result lies in ``[0, 1]``. + + Parameters + ---------- + a, b: + Lists of angles (radians) of equal length. + + Returns + ------- + float + Value in ``[0, 1]``. 0 means identical; 1 means maximally different. + """ + n = _check_same_length(a, b) + if n == 0: + return 0.0 + total = 0.0 + for ai, bi in zip(a, b): + diff = abs(ai - bi) % _TAU + total += min(diff, _TAU - diff) + return total / (n * math.pi) + + +def hyperbolic_cosine( + a: Sequence[float], + b: Sequence[float], + *, + radius: float = 0.5, +) -> float: + """Similarity via per-dimension hyperbolic cosine in the Poincaré disk. + + Each angle θ is embedded at ``r·e^(iθ)`` inside the unit disk. The + hyperbolic distance between the two disk points is converted to a cosine: + + sim = mean_k( cos( d_hyp(r·e^{iθ_k}, r·e^{iφ_k}) ) ) + + This metric is more sensitive to *low-frequency* (large-amplitude) + components than ``phase_cosine`` and captures hierarchical relationships. + + Parameters + ---------- + a, b: + Lists of angles (radians) of equal length. + radius: + Radial depth in ``(0, 1)`` for the disk embedding. Smaller values + compress the hyperbolic scale; larger values expand it. + + Returns + ------- + float + Value in ``[−1, 1]``. + """ + if not (0.0 < radius < 1.0): + raise ValueError("radius must be in (0, 1)") + n = _check_same_length(a, b) + if n == 0: + return 0.0 + total = 0.0 + for ai, bi in zip(a, b): + za = radius * cmath.exp(1j * ai) + zb = radius * cmath.exp(1j * bi) + denom = 1.0 - zb.conjugate() * za + if abs(denom) < 1e-15: + d = 0.0 + else: + rho = abs((za - zb) / denom) + rho = min(rho, 1.0 - 1e-15) + d = 2.0 * math.atanh(rho) + total += math.cos(d) + return total / n + + +def top_k_overlap( + amplitudes_a: Sequence[float], + amplitudes_b: Sequence[float], + *, + k: int = 8, +) -> float: + """Jaccard-like overlap of the *k* most energetic frequency components. + + Parameters + ---------- + amplitudes_a, amplitudes_b: + Amplitude arrays (as from ``EpicycleDecomposition.amplitudes``). + k: + Number of top components to compare. + + Returns + ------- + float + Value in ``[0, 1]``. 1 means the top-*k* frequency sets are + identical; 0 means completely disjoint. + """ + _check_same_length(amplitudes_a, amplitudes_b) + k = max(1, min(k, len(amplitudes_a))) + + def top_k_indices(amps: Sequence[float]) -> set: + return set( + sorted(range(len(amps)), key=lambda i: amps[i], reverse=True)[:k] + ) + + sa = top_k_indices(amplitudes_a) + sb = top_k_indices(amplitudes_b) + intersection = len(sa & sb) + union = len(sa | sb) + return intersection / union if union else 0.0 From 326eacb0ed6799277ea5450f94ad4e0df1068965 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 04:56:16 +0000 Subject: [PATCH 3/3] Add .gitignore, remove __pycache__ from tracking Agent-Logs-Url: https://github.com/The-Interdependency/ucns/sessions/7fc7989f-a74b-41c2-abc5-5e7c722996cd Co-authored-by: wayseer00 <258336486+wayseer00@users.noreply.github.com> --- .gitignore | 28 ++++++++++++++++++ tests/__pycache__/test_core.cpython-312.pyc | Bin 14822 -> 0 bytes .../test_embedding.cpython-312.pyc | Bin 16373 -> 0 bytes .../__pycache__/test_epicycle.cpython-312.pyc | Bin 12751 -> 0 bytes tests/__pycache__/test_mobius.cpython-312.pyc | Bin 10224 -> 0 bytes .../test_similarity.cpython-312.pyc | Bin 9830 -> 0 bytes ucns/__pycache__/__init__.cpython-312.pyc | Bin 2246 -> 0 bytes ucns/__pycache__/core.cpython-312.pyc | Bin 7852 -> 0 bytes ucns/__pycache__/embedding.cpython-312.pyc | Bin 8891 -> 0 bytes ucns/__pycache__/epicycle.cpython-312.pyc | Bin 12371 -> 0 bytes ucns/__pycache__/mobius.cpython-312.pyc | Bin 7979 -> 0 bytes ucns/__pycache__/similarity.cpython-312.pyc | Bin 7112 -> 0 bytes 12 files changed, 28 insertions(+) create mode 100644 .gitignore delete mode 100644 tests/__pycache__/test_core.cpython-312.pyc delete mode 100644 tests/__pycache__/test_embedding.cpython-312.pyc delete mode 100644 tests/__pycache__/test_epicycle.cpython-312.pyc delete mode 100644 tests/__pycache__/test_mobius.cpython-312.pyc delete mode 100644 tests/__pycache__/test_similarity.cpython-312.pyc delete mode 100644 ucns/__pycache__/__init__.cpython-312.pyc delete mode 100644 ucns/__pycache__/core.cpython-312.pyc delete mode 100644 ucns/__pycache__/embedding.cpython-312.pyc delete mode 100644 ucns/__pycache__/epicycle.cpython-312.pyc delete mode 100644 ucns/__pycache__/mobius.cpython-312.pyc delete mode 100644 ucns/__pycache__/similarity.cpython-312.pyc diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..d4ed3165 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Python bytecode +__pycache__/ +*.py[cod] +*.pyo +*.pyd + +# Distribution / packaging +dist/ +build/ +*.egg-info/ +*.egg +MANIFEST + +# Virtual environments +.venv/ +venv/ +env/ + +# Testing / coverage +.pytest_cache/ +.coverage +htmlcov/ + +# Type checkers / editors +.mypy_cache/ +.ruff_cache/ +.idea/ +.vscode/ diff --git a/tests/__pycache__/test_core.cpython-312.pyc b/tests/__pycache__/test_core.cpython-312.pyc deleted file mode 100644 index 8e278573856b3ba9ba821ecb53cf9dfea6ef6e06..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14822 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 HisJtP#kP%5 diff --git a/tests/__pycache__/test_embedding.cpython-312.pyc b/tests/__pycache__/test_embedding.cpython-312.pyc deleted file mode 100644 index 58db854ea7cb89e79a561e13593b0c48f1f47a90..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16373 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 A)&Kwi diff --git a/tests/__pycache__/test_mobius.cpython-312.pyc b/tests/__pycache__/test_mobius.cpython-312.pyc deleted file mode 100644 index 758f96693b409e5fc6298106aea53ee963001dda..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10224 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 diff --git a/ucns/__pycache__/__init__.cpython-312.pyc b/ucns/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 035eeff4033bd790a4ca99f637f46c92520cd3dc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2246 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 CKJ*O$ diff --git a/ucns/__pycache__/embedding.cpython-312.pyc b/ucns/__pycache__/embedding.cpython-312.pyc deleted file mode 100644 index 95f78944f28897a96ef5430adf601c70f138ac21..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8891 zcma)BYj7M@cJ7|;8I4Ad(bM<^zDUN_$nwat{J>xvj4gu$n1^LZM4VA;`buhhUheLZ zJ%d!yQqGnf$RaP^wOB(TcGX;((=!sHvaQnXJNJ3+Io~<=oYVhUR~M1+Jap(kjel4pNxz{VrKifR$hW_T$^}W6 z1|(h911UKj7zoHzS5nG=BHrqNinp2yrb7cE^ab@$swy2G2&bzDs`0yOL>`FfVXhos zQChzIE(RrOpq7Vt9k1t6-oP7q({N}N1`IR@q=zNF`V3ZhH{i|~XelVMNdE6IDUwfS ztT;~(aa}huBauCep2#D{$mr%2ALA+Jk74_sJj90bM#^T{46{c$JDM?Uw%;(5Db5b( zv6jh>6fB#k>b=d+^)c69&YBjpvW2Rb_=k$zLt4co|Ov=qzmyor~;hs8R0d?e0J zWleqS7*BSwhx(rG?_y8ncxK-rwolh$EN5o*e3C~xb)K;}8%||4yLT(YXgh0Koh&=d zdhgwKbMlqmEjza|-AH2?cC+GaDLEKA44Umxlk-S2n@O77=1kMaw2ZBdaO=KEq$kcg zJNIYPIW1{33mc%lvy)+Ure#J_++v!^*~vU+7#7!=on?cAc_U-@>=+zm9b4H@!RA)1 zi;Wm##An8s#bYOe*In4Vj#z`SQ0$Q+c+VS<{rZW>7dQZ57{QzL$qlb z9V5}fL1y+uKF4xc-unSBxvss(rShQG%PM0z)_k`Fk!?}*lhImrTL&#cc z5lFO@$u+&eQpOlBfiVv-(o&F79x%E&`9mL7sIxt+2eJTh8Ot{Ff?DEFFY#x9n^~rF zU<{B-8rly--~B!VDzwz*F)fwHQ7mJGbkFTLc(C8A5*lX>vgVulTX^qmHp${V=~R)mB=c{Xo@cSrKMT-GF^xW}{$Ic_atqR-DrXGyKV zsczJvnW&Nay6D}^T?uWJtjLfS|*dV zh4xs7@jD12vzgCi>J2!p%VNigBuGpNNN=lCTM4=m_qvcCOnMdZQrsW%?K7xckhnA; z<6;ZwGA^=Oy-p9D2@a?{sMqVt8TDOn=0He~>gpMFph|DhgLsE|wce-~CcO$h zHPur2PjA-4XsgBOC@!(`dUbk>9zjoip(fU9hOte40mpop!;ooYy@dsWy@rMP*88c9 zXoyZI%#)g_rD6R|VOp6Fpv|HexV=wQNVg7f3uuK!DCuY!tbi}+3yfhNpz+NfsW8B5 z2`wd_`)e)I9HTQ0)DB-T6K7E#-}Rm#l-^J*8J{v>Bt> zZkL#)>-EN{r?gzZndf5Nr03EIm=QAZFW-I|l?&3itjj0RZ`RnP0OHXD?|StYH&}C`n|!m1!UtMB zS8&3Kgn^qbktnuSN;h6=K8#hZ4iuBpd`cK^k7Q|g~p-^N%e>9Yers4m#K8WpfpI*#|!=LpPBz7MVYT(J8@R=~cad<~Rt z2f{dk+Gh_9kHz1S%{r`wS3O=9QNYXwlx7q~v0>3sF{7VG(a=e0AtW`lyj(b2m(3^<^=BP&Y*F*XhZI08T|H)6T9iijG> zYcFD{i9jZb`GMtUuSUP^MG~OVr)EMKSB{h6Tk=36N`51b*GHEXhbh7J-$}QHw#4!E zd}Ys&kTH3h!x72QNV>#GSc8LLGj1Wz%Z1l7IQZO_F81p)a0p@N4#8EjjU<^l@-kds zK&U3I=lSTk(*0pRKuRW;#w{_s)U?F(QWNb^w!hSJNccB!wq4r=dX8|;(g+OTkm}ha zZg9G-;a$Q#EqM#VPl)?8{?%Sw;eAzTMxT}(6~+rjYpG7a4@0zpt6k_=&yLM-?ck4lE*l&}KEll| zA8>96h*h?-PBMGYF0mUZv7tvDV+c_P2eEwB3y6?5#nUk~IUoBkR`_B!XbJ6>!R20{vtuH@+_W7BoUbAMmT`bI; zxYRheX471>_mX}k+B+BS{qmL~HSD(NPJUzEzIJud?M1Bu*ymBXAeCK!aUX!XTm+OS zSIpWdPWz+!ndO{ud01tpys{)csz`65ihIsi6+_52Q z1u)BYSOCDY!tu|D4D)U(+NfYCV3A0$<^~i_C@&T^+o>byN?FdBn`l6c2FPQAZ9=;4 z1>240B__&UWE>sV@1dBKz6eQm4b$q2hh`ej9-fj(Koe&tAiBm`WwvML_h;>^(XJ`= zdPB?fDIwzajh{q2X0&N}y6=_hE76X*aL1Q;iM{`-c3)6ULPY*UmzfaRKcTWnWWGmM zAx8fqJh2m}YA*{B7RtZe%U3!?7^rCXXJ3?FS4*~pd_qeuC>8cgJtI|IzA~;~4+00I zQfyP2U9q}PFF^wraxniGNP3)W876dXvzHtxd%18W?Ngj1semB4d`BYxGR2-l5(YkX z&#?-7)IW+i!V4vmS|*EN6t)8zc?8jLMKZ@r1h{I<5HO}Yv@SLjgDc+A(@jnn`^Ee0 zS3jX#4`qF{QqpILaz1W|L6i4YW?o83Ha4mu@j@9F)<&tDHa`mi$`D4GMzV`pS@QIJ zyM+KhkJKRsAYp*42klCEBFZNrt_Q@C&*7O7N_;KK2EmEWmCavqV_zxm!dB#q3tHf9 zgAMNd``HLGOU0mw1p3Dk6oSDW~L^e3<-vO8Ynp&KOgiKzxf6&cgKlufN>1=S$&`yl~G(xnv}OO36{IeA-cpMlPnf&Wx-%^3Wp6oJg61;vL^Y z+7!{Dp@4yCk!qt;Ctuug{>V(@E6-f1S%au!zG>Ho?H_1=v*Cl4AGQ9{`um=b$37aJ zYk1Q(WzNj>{LHRO%=;` zlwtul2B`2s+)WqHe#{FTV3c$N-f>0R5Qz`Z*TRvs5zRmjwt`?U`0y=-7XkRcI|Nr? zP>3b5#k(F745QQ|xHp7=n?dp5u?e@&rbJmO>+Mb_o*|Ga8CHZc{x-#^dn__hJe{)( zCTYKj{6$I)sb15D%8(A3dr>&mJd;n0v{NkXYEn?J)d{&PIbotGVd$rwh^UDZII`hX z`&=UD3&*nP4uoA0j~hNzWjK;pmR)RFq<>!Pk1*bP9>t{e`39+Bg^2RwSDU)$YP;u~ zTc*PEt*b61&Lw7#UOM<*&pU^%w(gjUTwi<7?C_Rn6M+-CK6lM&u)l9JsRcRZ=mtZCpYWB70E? zK9Rw#BjX^Ra$HgN2TFDu&e=-goGsnMk3jrAR*5|V-X4fWa7Xxilmm0D7!@l*vCDVJ zxMbJ*^iaw|JQXK*S63=lukb_0l?r;@)fKGXSXu`@PjjhW=G*a>Qd{|b5ytLyf3+9D zF%d1vr%G{Cz^^HYpp@d){n-q@OTqB?5p+eqeHky+iWeJ?X3C=}-eyI7f$BS*!80!+a~-G`a0Gnk`7%A;f_d{$73~4#DH@^A6Jsxya$JHg2EBT-DR7Og0#F7 z6muPwjFb};6P@aQZ0Tmi<{ny3KD{_tH&>!{$xT;I34Dl7 z>wOf$6|P+|t-p5Ut$Su3yH*ps-m+?D{VR`7@0qJ**K1nQv;SHRo8Q#^N%NLj?R?*B z?K9fN4L^ANO7oVv+AZ#gzH5l;H*|i|ylJ-aeBaF7KX~Ly^QO7lO>S@hTia(IzgE+^ z?1=h1=IhtJrOx*MB=RZiz07*&*6mo3Y%1((~ob=1uf( z5nkS3vp=f-ZLDd3i~5U|P5W1UOW5=fhT@YorL<66`j9uhe2*?-d=Zrg$@4u7FjRcV z66xpS3Qyz-d=^aj-tHbVj(xt8VzN#SvdLMbbF(##V+?h|f>l64)s=KX!M6Drn zH#JaQfYPmr{T_4sNfvJFe$ z{+NG&;vGrw)(tM{J`f*icA;YZF^V(N=YghR_=^rT7(r|J&HMP(`({6j^h-nra7R zF}E%l@#doT#RfI#${in$G>}3Z8P3xeSR9=kL~|PzZXgZwfefNaSCtdy-nUng5Ofpz z;2AzjgO!1tVYbseGE*iA&Lpn8!hVu!Pf;NdA=shQPfAp~F+g~?E0w(_=^I_9)-xyo zRayQ#ut8S;`$<`n+kYd~{EJlguhPojN;_}VteT6go~*i26`AUP`PsA2erIA*1t02~ lrrW3UGs)TQm)5@*oNL;Cxpv27_5XyP3&@dM5|v`#{{y*y7*+rP diff --git a/ucns/__pycache__/epicycle.cpython-312.pyc b/ucns/__pycache__/epicycle.cpython-312.pyc deleted file mode 100644 index 0070cc8f530e9d6c970b30768f944e881990cb86..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12371 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 diff --git a/ucns/__pycache__/mobius.cpython-312.pyc b/ucns/__pycache__/mobius.cpython-312.pyc deleted file mode 100644 index e250de3809860f4a3715b79b56ff983ef830b595..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7979 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 diff --git a/ucns/__pycache__/similarity.cpython-312.pyc b/ucns/__pycache__/similarity.cpython-312.pyc deleted file mode 100644 index 74079ac7bdaa635603a2562bcb59e1b693272a5b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7112 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 Hf^`20E08%_