eigenmind 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Merlin Intelligence
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.
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.4
2
+ Name: eigenmind
3
+ Version: 0.1.0
4
+ Summary: Eigenvalue-based graph computations for semantic chunk retrieval (similarity graph, spectral analysis, Lovász theta diversity).
5
+ Author-email: Marion Schaeffer <marion.schaeffer@merlin-intelligence.eu>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/merlin-intelligence/eigenmind
8
+ Project-URL: Repository, https://github.com/merlin-intelligence/eigenmind
9
+ Project-URL: Issues, https://github.com/merlin-intelligence/eigenmind/issues
10
+ Keywords: graph,eigenvalue,spectral-clustering,embeddings,retrieval,diversity
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Scientific/Engineering
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: numpy
24
+ Requires-Dist: scipy
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest; extra == "dev"
27
+ Requires-Dist: ruff; extra == "dev"
28
+ Provides-Extra: viz
29
+ Requires-Dist: matplotlib; extra == "viz"
30
+ Dynamic: license-file
31
+
32
+ # eigenmind
33
+ Eigenmind is a decision-intelligence engine that turns tacit expert knowledge into reusable decision frameworks, ideal for data-scarce settings. Its core uses eigenvalue-based optimisation on semantic graphs.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install git+https://github.com/merlin-intelligence/eigenmind.git
39
+ ```
40
+
41
+ ## Usage
42
+
43
+ ```python
44
+ from eigenmind import SimilarityGraph
45
+
46
+ # retrieved_points: objects with .id, .vector, .payload (e.g. Qdrant points)
47
+ graph = SimilarityGraph(retrieved_points)
48
+ tags = graph.selection_tags(top_k=10) # {chunk_id: ["Singular", "Hinge", "Theta"]}
49
+ ```
50
+
51
+ ### Expected input data
52
+
53
+ `retrieved_points` is a plain list of objects — any class works (duck-typing,
54
+ modeled after Qdrant's point objects), each exposing:
55
+
56
+ | Attribute | Type | Constraint |
57
+ |---|---|---|
58
+ | `.id` | hashable (`int`, `str`, `UUID`, ...) | unique across the list — used as a dict key |
59
+ | `.vector` | sequence of floats (`list` or `np.ndarray`) | **same length for every point**, and **unit-normalized (L2 norm ≈ 1)** |
60
+ | `.payload` | `dict` | only read by `eigenvalue_analysis`, via `.get("text")`, `.get("chunk_number")`, `.get("filename")` |
61
+
62
+ The library computes cosine similarity as a plain dot product between
63
+ vectors (`embedding_matrix @ embedding_matrix.T`) — it does **not** normalize
64
+ your vectors for you. Passing unnormalized vectors raises a `ValueError`
65
+ (from `build_similarity_matrix`, used internally by `SimilarityGraph` and
66
+ `find_singular_chunks`); normalize each vector yourself first, e.g.
67
+ `v / np.linalg.norm(v)`.
68
+
69
+ A `types.SimpleNamespace(id=..., vector=..., payload={...})` is enough — no
70
+ dependency on Qdrant is required, as shown in the test suite. Because the
71
+ interface is duck-typed on these three attributes, results from any
72
+ vector store (Pinecone, Weaviate, Milvus, pgvector, Chroma, ...) work as-is,
73
+ either directly or after a thin adapter mapping their result objects to
74
+ `.id` / `.vector` / `.payload`.
75
+
76
+ `SimilarityGraph` memoizes the similarity matrix and exposes four ranking
77
+ strategies over a set of embedded chunks:
78
+
79
+ - **Singular chunks** — points at the +/- poles of the normalized Laplacian's
80
+ first eigenvectors (`singular.py`).
81
+ - **Hinge ranking** — "relevant but not obvious" chunks via an ℓ∞-connectivity
82
+ optimizer (`connectivity.py`).
83
+ - **Theta diversity** — a diverse subset picked via a Lovász θ approximation on
84
+ the conflict graph (`theta.py`).
85
+ - **Eigenvalue analysis** — spectrum plot + most expressive chunks per
86
+ eigenvector, for exploratory/debugging use. Requires the `viz` extra
87
+ (`pip install eigenmind[viz]`).
88
+
89
+
90
+ ## Configuration
91
+
92
+ Every tunable is a plain keyword argument with a default from
93
+ [`defaults.py`](src/eigenmind/defaults.py) — override it per call/instance,
94
+ there is no global state to mutate.
95
+
96
+ | Constant | Default | Meaning | Override via |
97
+ |---|---|---|---|
98
+ | `SIMILARITY_THRESHOLD` | `0.65` | Minimum cosine similarity kept as an edge weight; pairs below it are zeroed out of `W`. | `SimilarityGraph(points, threshold=...)`, `build_similarity_matrix(..., threshold=...)` |
99
+ | `THETA_CONFLICT_THRESHOLD` | `= SIMILARITY_THRESHOLD` | Similarity level above which two chunks are considered "conflicting" (same info) for the θ diversity graph. | `SimilarityGraph.conflict_mask(threshold=...)`, `build_conflict_mask(..., threshold=...)` |
100
+ | `THETA_RANK` | `24` | Rank of the low-rank factor `Y` (`X ≈ Y Yᵀ`) used by the diversity picker — caps how many effective directions the θ approximation keeps. | `SimilarityGraph.theta_factor(rank=...)`, `theta_subgradient_approximation(..., rank=...)` |
101
+ | `THETA_MAX_ITERS` | `400` | Number of subgradient-descent iterations for the Lovász θ approximation. | `SimilarityGraph.theta_factor(max_iters=...)`, `theta_subgradient_approximation(..., max_iters=...)` |
102
+ | `THETA_STEP0` | `0.25` | Initial subgradient step size, decayed as `step0 / sqrt(k)` over iterations. | `SimilarityGraph.theta_factor(step0=...)`, `theta_subgradient_approximation(..., step0=...)` |
103
+ | `THETA_DIAG_SHIFT` | `1e-2` | Diagonal regularization added when the dual matrix isn't Cholesky-decomposable (numerical stabilizer). | `SimilarityGraph.theta_factor(diag_shift=...)`, `theta_subgradient_approximation(..., diag_shift=...)` |
104
+
105
+ These defaults are the values the algorithms were originally tuned against;
106
+ adjust them if your embeddings' similarity distribution differs
107
+ significantly (e.g. a different embedding model or normalization scheme).
108
+
109
+ ## Tests
110
+
111
+ ```bash
112
+ pip install -e ".[dev]"
113
+ pytest
114
+ ```
@@ -0,0 +1,83 @@
1
+ # eigenmind
2
+ Eigenmind is a decision-intelligence engine that turns tacit expert knowledge into reusable decision frameworks, ideal for data-scarce settings. Its core uses eigenvalue-based optimisation on semantic graphs.
3
+
4
+ ## Install
5
+
6
+ ```bash
7
+ pip install git+https://github.com/merlin-intelligence/eigenmind.git
8
+ ```
9
+
10
+ ## Usage
11
+
12
+ ```python
13
+ from eigenmind import SimilarityGraph
14
+
15
+ # retrieved_points: objects with .id, .vector, .payload (e.g. Qdrant points)
16
+ graph = SimilarityGraph(retrieved_points)
17
+ tags = graph.selection_tags(top_k=10) # {chunk_id: ["Singular", "Hinge", "Theta"]}
18
+ ```
19
+
20
+ ### Expected input data
21
+
22
+ `retrieved_points` is a plain list of objects — any class works (duck-typing,
23
+ modeled after Qdrant's point objects), each exposing:
24
+
25
+ | Attribute | Type | Constraint |
26
+ |---|---|---|
27
+ | `.id` | hashable (`int`, `str`, `UUID`, ...) | unique across the list — used as a dict key |
28
+ | `.vector` | sequence of floats (`list` or `np.ndarray`) | **same length for every point**, and **unit-normalized (L2 norm ≈ 1)** |
29
+ | `.payload` | `dict` | only read by `eigenvalue_analysis`, via `.get("text")`, `.get("chunk_number")`, `.get("filename")` |
30
+
31
+ The library computes cosine similarity as a plain dot product between
32
+ vectors (`embedding_matrix @ embedding_matrix.T`) — it does **not** normalize
33
+ your vectors for you. Passing unnormalized vectors raises a `ValueError`
34
+ (from `build_similarity_matrix`, used internally by `SimilarityGraph` and
35
+ `find_singular_chunks`); normalize each vector yourself first, e.g.
36
+ `v / np.linalg.norm(v)`.
37
+
38
+ A `types.SimpleNamespace(id=..., vector=..., payload={...})` is enough — no
39
+ dependency on Qdrant is required, as shown in the test suite. Because the
40
+ interface is duck-typed on these three attributes, results from any
41
+ vector store (Pinecone, Weaviate, Milvus, pgvector, Chroma, ...) work as-is,
42
+ either directly or after a thin adapter mapping their result objects to
43
+ `.id` / `.vector` / `.payload`.
44
+
45
+ `SimilarityGraph` memoizes the similarity matrix and exposes four ranking
46
+ strategies over a set of embedded chunks:
47
+
48
+ - **Singular chunks** — points at the +/- poles of the normalized Laplacian's
49
+ first eigenvectors (`singular.py`).
50
+ - **Hinge ranking** — "relevant but not obvious" chunks via an ℓ∞-connectivity
51
+ optimizer (`connectivity.py`).
52
+ - **Theta diversity** — a diverse subset picked via a Lovász θ approximation on
53
+ the conflict graph (`theta.py`).
54
+ - **Eigenvalue analysis** — spectrum plot + most expressive chunks per
55
+ eigenvector, for exploratory/debugging use. Requires the `viz` extra
56
+ (`pip install eigenmind[viz]`).
57
+
58
+
59
+ ## Configuration
60
+
61
+ Every tunable is a plain keyword argument with a default from
62
+ [`defaults.py`](src/eigenmind/defaults.py) — override it per call/instance,
63
+ there is no global state to mutate.
64
+
65
+ | Constant | Default | Meaning | Override via |
66
+ |---|---|---|---|
67
+ | `SIMILARITY_THRESHOLD` | `0.65` | Minimum cosine similarity kept as an edge weight; pairs below it are zeroed out of `W`. | `SimilarityGraph(points, threshold=...)`, `build_similarity_matrix(..., threshold=...)` |
68
+ | `THETA_CONFLICT_THRESHOLD` | `= SIMILARITY_THRESHOLD` | Similarity level above which two chunks are considered "conflicting" (same info) for the θ diversity graph. | `SimilarityGraph.conflict_mask(threshold=...)`, `build_conflict_mask(..., threshold=...)` |
69
+ | `THETA_RANK` | `24` | Rank of the low-rank factor `Y` (`X ≈ Y Yᵀ`) used by the diversity picker — caps how many effective directions the θ approximation keeps. | `SimilarityGraph.theta_factor(rank=...)`, `theta_subgradient_approximation(..., rank=...)` |
70
+ | `THETA_MAX_ITERS` | `400` | Number of subgradient-descent iterations for the Lovász θ approximation. | `SimilarityGraph.theta_factor(max_iters=...)`, `theta_subgradient_approximation(..., max_iters=...)` |
71
+ | `THETA_STEP0` | `0.25` | Initial subgradient step size, decayed as `step0 / sqrt(k)` over iterations. | `SimilarityGraph.theta_factor(step0=...)`, `theta_subgradient_approximation(..., step0=...)` |
72
+ | `THETA_DIAG_SHIFT` | `1e-2` | Diagonal regularization added when the dual matrix isn't Cholesky-decomposable (numerical stabilizer). | `SimilarityGraph.theta_factor(diag_shift=...)`, `theta_subgradient_approximation(..., diag_shift=...)` |
73
+
74
+ These defaults are the values the algorithms were originally tuned against;
75
+ adjust them if your embeddings' similarity distribution differs
76
+ significantly (e.g. a different embedding model or normalization scheme).
77
+
78
+ ## Tests
79
+
80
+ ```bash
81
+ pip install -e ".[dev]"
82
+ pytest
83
+ ```
@@ -0,0 +1,75 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "eigenmind"
7
+ version = "0.1.0"
8
+ description = "Eigenvalue-based graph computations for semantic chunk retrieval (similarity graph, spectral analysis, Lovász theta diversity)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "Marion Schaeffer", email = "marion.schaeffer@merlin-intelligence.eu" },
14
+ ]
15
+ keywords = ["graph", "eigenvalue", "spectral-clustering", "embeddings", "retrieval", "diversity"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Topic :: Scientific/Engineering",
25
+ "Typing :: Typed",
26
+ ]
27
+ dependencies = [
28
+ "numpy",
29
+ "scipy",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ dev = ["pytest", "ruff"]
34
+ viz = ["matplotlib"]
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/merlin-intelligence/eigenmind"
38
+ Repository = "https://github.com/merlin-intelligence/eigenmind"
39
+ Issues = "https://github.com/merlin-intelligence/eigenmind/issues"
40
+
41
+ [tool.setuptools.packages.find]
42
+ where = ["src"]
43
+
44
+ [tool.setuptools.package-data]
45
+ eigenmind = ["py.typed"]
46
+
47
+ [tool.ruff]
48
+ line-length = 100
49
+ target-version = "py310"
50
+ extend-exclude = [".venv", "venv", "build", "dist"]
51
+
52
+ [tool.ruff.lint]
53
+ select = [
54
+ "E", # pycodestyle errors
55
+ "W", # pycodestyle warnings
56
+ "F", # pyflakes
57
+ "I", # isort
58
+ "B", # flake8-bugbear
59
+ "C4", # flake8-comprehensions
60
+ "UP", # pyupgrade
61
+ "SIM", # flake8-simplify
62
+ ]
63
+ ignore = [
64
+ "E501", # line too long (handled by formatter)
65
+ "B008", # do not perform function calls in argument defaults
66
+ ]
67
+
68
+ [tool.ruff.lint.per-file-ignores]
69
+ "tests/*" = ["B"]
70
+ "__init__.py" = ["F401"]
71
+
72
+ [tool.ruff.format]
73
+ quote-style = "double"
74
+ indent-style = "space"
75
+ line-ending = "auto"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,37 @@
1
+ """eigenmind — eigenvalue-based graph computations for semantic chunk retrieval.
2
+
3
+ Public API re-exported here for convenience::
4
+
5
+ from eigenmind import SimilarityGraph
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from eigenmind.connectivity import (
10
+ compute_linf_connectivity_optimizer,
11
+ rank_relevant_but_not_obvious_chunks,
12
+ )
13
+ from eigenmind.similarity_graph import SimilarityGraph
14
+ from eigenmind.singular import (
15
+ analyze_laplacian_eigenvectors,
16
+ build_similarity_matrix,
17
+ find_singular_chunks,
18
+ normalized_laplacian,
19
+ )
20
+ from eigenmind.theta import (
21
+ build_conflict_mask,
22
+ theta_diversity_picker,
23
+ theta_subgradient_approximation,
24
+ )
25
+
26
+ __all__ = [
27
+ "SimilarityGraph",
28
+ "analyze_laplacian_eigenvectors",
29
+ "build_conflict_mask",
30
+ "build_similarity_matrix",
31
+ "compute_linf_connectivity_optimizer",
32
+ "find_singular_chunks",
33
+ "normalized_laplacian",
34
+ "rank_relevant_but_not_obvious_chunks",
35
+ "theta_diversity_picker",
36
+ "theta_subgradient_approximation",
37
+ ]
@@ -0,0 +1,118 @@
1
+ """ℓ∞-connectivity optimizer + hinge ranking ("relevant but not obvious" chunks)."""
2
+ from __future__ import annotations
3
+
4
+ import heapq
5
+
6
+ import numpy as np
7
+
8
+
9
+ def _build_weighted_lengths(W: np.ndarray, eps: float = 1e-12) -> np.ndarray:
10
+ """Convert similarity weights W_ij in [0,1] into positive edge lengths via -log."""
11
+ L = np.full_like(W, np.inf, dtype=float)
12
+ mask = W > 0
13
+ L[mask] = -np.log(np.clip(W[mask], eps, 1.0))
14
+ np.fill_diagonal(L, 0.0)
15
+ return L
16
+
17
+
18
+ def _dijkstra_all_sources(lengths: np.ndarray) -> np.ndarray:
19
+ """All-pairs shortest paths via Dijkstra from each node (dense, OK for n<=~500)."""
20
+ n = lengths.shape[0]
21
+ dist = np.full((n, n), np.inf, dtype=float)
22
+
23
+ for src in range(n):
24
+ d = dist[src]
25
+ d[src] = 0.0
26
+ pq = [(0.0, src)]
27
+ visited = np.zeros(n, dtype=bool)
28
+
29
+ while pq:
30
+ du, u = heapq.heappop(pq)
31
+ if visited[u]:
32
+ continue
33
+ visited[u] = True
34
+
35
+ for v in range(n):
36
+ w = lengths[u, v]
37
+ if w == np.inf or v == u:
38
+ continue
39
+ nd = du + w
40
+ if nd < d[v]:
41
+ d[v] = nd
42
+ heapq.heappush(pq, (nd, v))
43
+
44
+ return dist
45
+
46
+
47
+ def compute_linf_connectivity_optimizer(W: np.ndarray) -> np.ndarray:
48
+ """Practical ℓ∞-connectivity optimizer x* in chunk space."""
49
+ n = W.shape[0]
50
+ degrees = np.sum(W, axis=1)
51
+ if np.all(degrees == 0):
52
+ return np.zeros(n, dtype=float)
53
+
54
+ lengths = _build_weighted_lengths(W)
55
+ dist = _dijkstra_all_sources(lengths)
56
+
57
+ finite = np.isfinite(dist)
58
+ if not np.all(finite):
59
+ max_finite = np.max(dist[finite]) if np.any(finite) else 1.0
60
+ dist = np.where(finite, dist, 10.0 * max_finite)
61
+
62
+ transmission = np.sum(dist, axis=1)
63
+ v_star = int(np.argmax(transmission))
64
+
65
+ x_raw = dist[v_star, :].copy()
66
+
67
+ deg_sum = float(np.sum(degrees))
68
+ if deg_sum > 0:
69
+ weighted_mean = float(np.sum(degrees * x_raw)) / deg_sum
70
+ x_centered = x_raw - weighted_mean
71
+ else:
72
+ x_centered = x_raw - np.mean(x_raw)
73
+
74
+ denom = float(np.max(np.abs(x_centered)))
75
+ if denom < 1e-12:
76
+ return np.zeros(n, dtype=float)
77
+
78
+ return x_centered / denom
79
+
80
+
81
+ def rank_relevant_but_not_obvious_chunks(
82
+ W: np.ndarray,
83
+ x_star: np.ndarray,
84
+ pole_quantile: float = 0.90,
85
+ ):
86
+ """Chunk-only retrieval of relevant-but-not-obvious items.
87
+
88
+ Returns (ranking, P_plus, P_minus, q_hi, q_lo). Each ranking entry is
89
+ ``(idx, hinge_score, bridge, S_plus, S_minus, x_i)``.
90
+ """
91
+ x = x_star
92
+ q_hi = float(np.quantile(x, pole_quantile))
93
+ q_lo = float(np.quantile(x, 1.0 - pole_quantile))
94
+
95
+ Pp = np.where(x >= q_hi)[0]
96
+ Pm = np.where(x <= q_lo)[0]
97
+
98
+ if len(Pp) == 0 or len(Pm) == 0:
99
+ q_hi = float(np.quantile(x, 0.80))
100
+ q_lo = float(np.quantile(x, 0.20))
101
+ Pp = np.where(x >= q_hi)[0]
102
+ Pm = np.where(x <= q_lo)[0]
103
+
104
+ S_plus = np.sum(W[:, Pp], axis=1) if len(Pp) > 0 else np.zeros(W.shape[0])
105
+ S_minus = np.sum(W[:, Pm], axis=1) if len(Pm) > 0 else np.zeros(W.shape[0])
106
+
107
+ bridge = np.minimum(S_plus, S_minus)
108
+ hinge = bridge * (1.0 - np.abs(x))
109
+
110
+ ranking = sorted(
111
+ [
112
+ (i, float(hinge[i]), float(bridge[i]), float(S_plus[i]), float(S_minus[i]), float(x[i]))
113
+ for i in range(W.shape[0])
114
+ ],
115
+ key=lambda t: t[1],
116
+ reverse=True,
117
+ )
118
+ return ranking, Pp, Pm, q_hi, q_lo
@@ -0,0 +1,14 @@
1
+ """Default parameter values for the graph algorithms.
2
+
3
+ Every public function accepts these as overridable keyword arguments; the
4
+ values below are the ones the algorithms were originally tuned against.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ SIMILARITY_THRESHOLD = 0.65
9
+
10
+ THETA_CONFLICT_THRESHOLD = SIMILARITY_THRESHOLD
11
+ THETA_RANK = 24
12
+ THETA_MAX_ITERS = 400
13
+ THETA_STEP0 = 0.25
14
+ THETA_DIAG_SHIFT = 1e-2
File without changes
@@ -0,0 +1,142 @@
1
+ """``SimilarityGraph`` — high-level API around the graph math primitives.
2
+
3
+ Holds a similarity matrix W and exposes the four ranking strategies (singular,
4
+ hinge, theta, eigenvalue analysis) without recomputing W or the conflict mask
5
+ every time. The lower-level pure functions in
6
+ :mod:`eigenmind.singular`, :mod:`eigenmind.connectivity` and
7
+ :mod:`eigenmind.theta` remain available and are still directly tested.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import numpy as np
12
+
13
+ from eigenmind.connectivity import (
14
+ compute_linf_connectivity_optimizer,
15
+ rank_relevant_but_not_obvious_chunks,
16
+ )
17
+ from eigenmind.defaults import (
18
+ SIMILARITY_THRESHOLD,
19
+ THETA_CONFLICT_THRESHOLD,
20
+ THETA_DIAG_SHIFT,
21
+ THETA_MAX_ITERS,
22
+ THETA_RANK,
23
+ THETA_STEP0,
24
+ )
25
+ from eigenmind.singular import (
26
+ analyze_laplacian_eigenvectors,
27
+ build_similarity_matrix,
28
+ find_singular_chunks,
29
+ normalized_laplacian,
30
+ )
31
+ from eigenmind.theta import (
32
+ build_conflict_mask,
33
+ theta_diversity_picker,
34
+ theta_subgradient_approximation,
35
+ )
36
+
37
+
38
+ class SimilarityGraph:
39
+ """A weighted graph over chunks, built from their embedding matrix.
40
+
41
+ Memoizes the similarity matrix, the ℓ∞ optimizer, the conflict mask and the
42
+ θ low-rank factor so clients that ask for several rankings only pay the cost
43
+ once.
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ retrieved_points: list,
49
+ threshold: float = SIMILARITY_THRESHOLD,
50
+ ):
51
+ self.points = retrieved_points
52
+ self.threshold = threshold
53
+ self.id_to_point = {p.id: p for p in retrieved_points}
54
+ self.ordered_ids = [p.id for p in retrieved_points]
55
+ self.embedding_matrix = (
56
+ np.array([p.vector for p in retrieved_points])
57
+ if retrieved_points
58
+ else np.empty((0, 0))
59
+ )
60
+ self.W = build_similarity_matrix(self.embedding_matrix, threshold) if len(retrieved_points) else np.empty((0, 0))
61
+ self._x_star: np.ndarray | None = None
62
+ self._conflict_mask: np.ndarray | None = None
63
+ self._theta_factor: np.ndarray | None = None
64
+
65
+ # ─── basic linear algebra ──────────────────────────────────────
66
+
67
+ @property
68
+ def n(self) -> int:
69
+ return self.W.shape[0]
70
+
71
+ def laplacian(self) -> np.ndarray:
72
+ return normalized_laplacian(self.W)
73
+
74
+ # ─── strategy 1: singular chunks (eigenvector poles) ───────────
75
+
76
+ def singular_chunks(self) -> list:
77
+ """Points lying at the +/- poles of the first k Laplacian eigenvectors."""
78
+ return find_singular_chunks(self.points, W=self.W)
79
+
80
+ def eigenvalue_analysis(self, output_filename_base: str):
81
+ """Persist the eigenvalue plot and return the per-chunk EV pole tags."""
82
+ return analyze_laplacian_eigenvectors(self.W, self.id_to_point, self.ordered_ids, output_filename_base)
83
+
84
+ # ─── strategy 2: ℓ∞ connectivity + hinge ranking ───────────────
85
+
86
+ def linf_optimizer(self) -> np.ndarray:
87
+ """Memoized ``x*`` — the ℓ∞-connectivity optimizer."""
88
+ if self._x_star is None:
89
+ self._x_star = compute_linf_connectivity_optimizer(self.W)
90
+ return self._x_star
91
+
92
+ def hinge_ranking(self, pole_quantile: float = 0.90):
93
+ """Ranking of relevant-but-not-obvious chunks. See :func:`rank_relevant_but_not_obvious_chunks`."""
94
+ return rank_relevant_but_not_obvious_chunks(self.W, self.linf_optimizer(), pole_quantile)
95
+
96
+ # ─── strategy 3: Lovász θ + diversity ──────────────────────────
97
+
98
+ def conflict_mask(self, threshold: float = THETA_CONFLICT_THRESHOLD) -> np.ndarray:
99
+ if self._conflict_mask is None:
100
+ self._conflict_mask = build_conflict_mask(self.W, threshold)
101
+ return self._conflict_mask
102
+
103
+ def theta_factor(
104
+ self,
105
+ max_iters: int = THETA_MAX_ITERS,
106
+ step0: float = THETA_STEP0,
107
+ diag_shift: float = THETA_DIAG_SHIFT,
108
+ rank: int = THETA_RANK,
109
+ ) -> np.ndarray:
110
+ """Memoized low-rank factor Y from the θ approximation."""
111
+ if self._theta_factor is None:
112
+ _, _, Y, _, _, _ = theta_subgradient_approximation(
113
+ self.conflict_mask(),
114
+ max_iters=max_iters, step0=step0, diag_shift=diag_shift, rank=rank,
115
+ )
116
+ self._theta_factor = Y
117
+ return self._theta_factor
118
+
119
+ def theta_diversity(self, k: int = 10, seed: str = "leverage") -> list[int]:
120
+ """Indices of ``k`` diverse chunks in the conflict graph."""
121
+ selected, _ = theta_diversity_picker(self.theta_factor(), k=k, seed=seed)
122
+ return selected
123
+
124
+ # ─── consensus across strategies ───────────────────────────────
125
+
126
+ def selection_tags(self, top_k: int = 10) -> dict:
127
+ """Return ``{chunk_id: [tags...]}`` aggregating singular/hinge/theta selections."""
128
+ tags: dict = {}
129
+ if self.n == 0:
130
+ return tags
131
+
132
+ for p in self.singular_chunks():
133
+ tags.setdefault(p.id, []).append("Singular")
134
+
135
+ if self.n > 2:
136
+ ranking, _, _, _, _ = self.hinge_ranking()
137
+ for r in ranking[:top_k]:
138
+ tags.setdefault(self.ordered_ids[r[0]], []).append("Hinge")
139
+ for idx in self.theta_diversity(k=top_k):
140
+ tags.setdefault(self.ordered_ids[idx], []).append("Theta")
141
+
142
+ return tags
@@ -0,0 +1,165 @@
1
+ """Singular chunks via eigenvector poles of the symmetric normalized Laplacian."""
2
+ from __future__ import annotations
3
+
4
+ import textwrap
5
+
6
+ import numpy as np
7
+ from scipy.linalg import eigh
8
+
9
+ from eigenmind.defaults import SIMILARITY_THRESHOLD
10
+
11
+
12
+ def build_similarity_matrix(embedding_matrix: np.ndarray, threshold: float = SIMILARITY_THRESHOLD) -> np.ndarray:
13
+ """Cosine-similarity matrix with sub-threshold values zeroed and zero diagonal.
14
+
15
+ ``embedding_matrix`` rows must already be unit-normalized (L2 norm ≈ 1):
16
+ the similarity is a plain row-wise dot product, not a normalized cosine,
17
+ so unnormalized vectors would silently produce a meaningless matrix.
18
+ """
19
+ if embedding_matrix.size:
20
+ norms = np.linalg.norm(embedding_matrix, axis=1)
21
+ bad = np.where(np.abs(norms - 1.0) > 1e-3)[0]
22
+ if len(bad):
23
+ raise ValueError(
24
+ "embedding_matrix rows must be unit-normalized (L2 norm ≈ 1); "
25
+ f"{len(bad)} row(s) are not, e.g. row {int(bad[0])} has norm "
26
+ f"{norms[bad[0]]:.4f}. Normalize each vector (v / np.linalg.norm(v)) "
27
+ "before building a SimilarityGraph."
28
+ )
29
+ W = embedding_matrix @ embedding_matrix.T
30
+ W[W < threshold] = 0
31
+ np.fill_diagonal(W, 0)
32
+ return W
33
+
34
+
35
+ def normalized_laplacian(W: np.ndarray) -> np.ndarray:
36
+ degrees = np.sum(W, axis=1)
37
+ d_inv_sqrt = np.zeros_like(degrees)
38
+ nz = degrees > 0
39
+ d_inv_sqrt[nz] = 1.0 / np.sqrt(degrees[nz])
40
+ D_inv_sqrt = np.diag(d_inv_sqrt)
41
+ return np.identity(W.shape[0]) - D_inv_sqrt @ W @ D_inv_sqrt
42
+
43
+
44
+ def find_singular_chunks(
45
+ retrieved_points: list,
46
+ W: np.ndarray | None = None,
47
+ threshold: float = SIMILARITY_THRESHOLD,
48
+ ) -> list:
49
+ """Return points lying at the +/- poles of the first k Laplacian eigenvectors.
50
+
51
+ Pass ``W`` (e.g. ``SimilarityGraph.W``) to reuse an already-built similarity
52
+ matrix instead of recomputing one from ``threshold``.
53
+ """
54
+ if len(retrieved_points) < 2:
55
+ return []
56
+
57
+ id_to_point = {p.id: p for p in retrieved_points}
58
+ ordered_ids = [p.id for p in retrieved_points]
59
+
60
+ if W is None:
61
+ embedding_matrix = np.array([p.vector for p in retrieved_points])
62
+ W = build_similarity_matrix(embedding_matrix, threshold)
63
+
64
+ L_sym = normalized_laplacian(W)
65
+ eigenvalues, eigenvectors = eigh(L_sym)
66
+
67
+ zero_threshold = 1e-3
68
+ k = int(np.sum(eigenvalues < zero_threshold))
69
+
70
+ out: list = []
71
+ seen: set = set()
72
+ for i in range(1, min(k + 1, len(eigenvalues))):
73
+ ev = eigenvectors[:, i]
74
+ for idx in (int(np.argmax(ev)), int(np.argmin(ev))):
75
+ pid = ordered_ids[idx]
76
+ if pid not in seen:
77
+ seen.add(pid)
78
+ out.append(id_to_point[pid])
79
+ return out
80
+
81
+
82
+ def analyze_laplacian_eigenvectors(
83
+ similarity_matrix: np.ndarray,
84
+ id_to_point: dict,
85
+ ordered_ids: list,
86
+ output_filename_base: str,
87
+ ):
88
+ """Compute the Laplacian spectrum and identify the most expressive chunks per eigenvector.
89
+
90
+ Returns (plot_path, summary_text, singular_info) where singular_info maps
91
+ chunk-index → list of tags like "EV3(+)".
92
+
93
+ Requires the ``matplotlib`` extra (``pip install eigenmind[viz]``).
94
+ """
95
+ try:
96
+ import matplotlib.pyplot as plt
97
+ except ImportError as e:
98
+ raise ImportError(
99
+ "analyze_laplacian_eigenvectors requires matplotlib. "
100
+ "Install it with: pip install eigenmind[viz]"
101
+ ) from e
102
+
103
+ summary: list[str] = []
104
+ singular_info: dict[int, list[str]] = {}
105
+
106
+ L_sym = normalized_laplacian(similarity_matrix)
107
+ eigenvalues, eigenvectors = eigh(L_sym)
108
+
109
+ zero_threshold = 1e-3
110
+ zero_multiplicity = int(np.sum(eigenvalues < zero_threshold))
111
+ summary.append(
112
+ f"Found {zero_multiplicity} connected components (eigenvalues < {zero_threshold})."
113
+ )
114
+
115
+ n_to_plot = min(20, len(eigenvalues))
116
+ plt.figure(figsize=(10, 6))
117
+ plt.plot(range(1, n_to_plot + 1), eigenvalues[:n_to_plot], "o-")
118
+ plt.title("Eigenvalues of the Graph Laplacian (Spectrum)")
119
+ plt.xlabel(f"Eigenvalue Index (Sorted) - First {zero_multiplicity} are near-zero")
120
+ plt.ylabel("Eigenvalue")
121
+ plt.xticks(range(1, n_to_plot + 1))
122
+ plt.grid(True)
123
+ plot_filename = f"{output_filename_base}_eigenvalues.png"
124
+ try:
125
+ plt.savefig(plot_filename)
126
+ summary.append(f"Eigenvalue plot saved to '{plot_filename}'")
127
+ except Exception as e: # noqa: BLE001
128
+ summary.append(f"Could not save eigenvalue plot. Error: {e}")
129
+ plt.close()
130
+
131
+ summary.append("\nMost expressive chunks along the primary semantic axes (eigenvectors):")
132
+ for i in range(1, min(zero_multiplicity + 5, len(eigenvalues))):
133
+ ev = eigenvectors[:, i]
134
+ pos_idx = np.argsort(ev)[-3:]
135
+ neg_idx = np.argsort(ev)[:3]
136
+
137
+ for idx in pos_idx:
138
+ singular_info.setdefault(int(idx), []).append(f"EV{i + 1}(+)")
139
+ for idx in neg_idx:
140
+ singular_info.setdefault(int(idx), []).append(f"EV{i + 1}(-)")
141
+
142
+ summary.append(f"\n--- EIGENVECTOR {i + 1} (Eigenvalue: {eigenvalues[i]:.4f}) ---")
143
+ summary.append(" (+) Positive Pole Chunks:")
144
+ for idx in reversed(pos_idx):
145
+ point = id_to_point[ordered_ids[idx]]
146
+ text = " ".join(point.payload.get("text", "N/A").split())
147
+ wrapped = textwrap.fill(text, width=120, initial_indent=" " * 6, subsequent_indent=" " * 6)
148
+ summary.append(
149
+ f" - (Score: {ev[idx]:.3f}) "
150
+ f"[{point.payload.get('chunk_number', 'N/A')} | {point.payload.get('filename', 'N/A')}]"
151
+ )
152
+ summary.append(f' "{wrapped.strip()}"')
153
+
154
+ summary.append("\n (-) Negative Pole Chunks:")
155
+ for idx in neg_idx:
156
+ point = id_to_point[ordered_ids[idx]]
157
+ text = " ".join(point.payload.get("text", "N/A").split())
158
+ wrapped = textwrap.fill(text, width=120, initial_indent=" " * 6, subsequent_indent=" " * 6)
159
+ summary.append(
160
+ f" - (Score: {ev[idx]:.3f}) "
161
+ f"[{point.payload.get('chunk_number', 'N/A')} | {point.payload.get('filename', 'N/A')}]"
162
+ )
163
+ summary.append(f' "{wrapped.strip()}"')
164
+
165
+ return plot_filename, "\n".join(summary), singular_info
@@ -0,0 +1,144 @@
1
+ """Lovász θ approximation (subgradient) + diversity picker on the conflict graph."""
2
+ from __future__ import annotations
3
+
4
+ import numpy as np
5
+
6
+ from eigenmind.defaults import (
7
+ THETA_DIAG_SHIFT,
8
+ THETA_MAX_ITERS,
9
+ THETA_RANK,
10
+ THETA_STEP0,
11
+ )
12
+
13
+
14
+ def build_conflict_mask(similarity_matrix: np.ndarray, threshold: float) -> np.ndarray:
15
+ """Conflict graph H: H_ij = 1 iff similarity ≥ threshold (no self-loops, symmetric)."""
16
+ H = (similarity_matrix >= threshold).astype(np.int8)
17
+ np.fill_diagonal(H, 0)
18
+ return np.maximum(H, H.T)
19
+
20
+
21
+ def _chol_ok(A: np.ndarray) -> bool:
22
+ try:
23
+ np.linalg.cholesky(A)
24
+ return True
25
+ except np.linalg.LinAlgError:
26
+ return False
27
+
28
+
29
+ def theta_subgradient_approximation(
30
+ conflict_mask: np.ndarray,
31
+ max_iters: int = THETA_MAX_ITERS,
32
+ step0: float = THETA_STEP0,
33
+ diag_shift: float = THETA_DIAG_SHIFT,
34
+ rank: int = THETA_RANK,
35
+ verbose: bool = False,
36
+ ):
37
+ """Subgradient approximation of Lovász θ(H) via the Lagrangian dual.
38
+
39
+ Returns (best_theta, X, Y, lam, mu, best_x):
40
+ - X is a PSD primal proxy (n×n);
41
+ - Y is a low-rank factor with X ≈ Y Y^T (used by the diversity picker).
42
+ """
43
+ n = conflict_mask.shape[0]
44
+ lam = np.ones(n, dtype=float)
45
+ mu = np.zeros((n, n), dtype=float)
46
+ ones = np.ones(n, dtype=float)
47
+
48
+ def build_A(lam_vec: np.ndarray, mu_mat: np.ndarray) -> np.ndarray:
49
+ A = np.diag(lam_vec).astype(float)
50
+ A = A + 0.5 * (mu_mat * conflict_mask)
51
+ return 0.5 * (A + A.T)
52
+
53
+ best_theta = np.inf
54
+ best_A = None
55
+ best_x = None
56
+
57
+ for k in range(1, max_iters + 1):
58
+ A = build_A(lam, mu)
59
+ if not _chol_ok(A):
60
+ A = A + diag_shift * np.eye(n)
61
+ lam = lam + diag_shift
62
+ if not _chol_ok(A):
63
+ A = A + (10.0 * diag_shift) * np.eye(n)
64
+ lam = lam + 10.0 * diag_shift
65
+
66
+ b = ones + lam
67
+ try:
68
+ x = 0.5 * np.linalg.solve(A, b)
69
+ except np.linalg.LinAlgError:
70
+ x = 0.5 * np.linalg.solve(A + (10.0 * diag_shift) * np.eye(n), b)
71
+
72
+ theta_k = float(b @ x - x @ (A @ x))
73
+ if theta_k < best_theta:
74
+ best_theta = theta_k
75
+ best_A = A.copy()
76
+ best_x = x.copy()
77
+
78
+ g = x - x * x
79
+ h = np.outer(x, x) * conflict_mask
80
+ t = step0 / np.sqrt(k)
81
+ lam = np.clip(lam - t * g, -0.9, 1e3)
82
+ mu = np.clip(mu + t * h, 0.0, 1e3)
83
+
84
+ A = best_A
85
+ try:
86
+ Ainv = np.linalg.inv(A)
87
+ except np.linalg.LinAlgError:
88
+ Ainv = np.linalg.pinv(A)
89
+ Ainv = 0.5 * (Ainv + Ainv.T)
90
+
91
+ tr = float(np.trace(Ainv))
92
+ X = (np.eye(n) / n) if tr <= 1e-12 else (Ainv / tr)
93
+
94
+ w, Q = np.linalg.eigh(X)
95
+ w = np.maximum(w, 0.0)
96
+ r = min(rank, n)
97
+ idx = np.argsort(w)[-r:]
98
+ Y = Q[:, idx] * np.sqrt(np.maximum(w[idx], 0.0))[None, :]
99
+
100
+ return best_theta, X, Y, lam, mu, best_x
101
+
102
+
103
+ def theta_diversity_picker(
104
+ Y: np.ndarray,
105
+ k: int = 12,
106
+ seed: str = "leverage",
107
+ seed_index: int = 0,
108
+ eps: float = 1e-12,
109
+ ):
110
+ """Greedy farthest-point selection on the low-rank factor Y.
111
+
112
+ Returns (selected_indices, min_distances).
113
+ """
114
+ n, _ = Y.shape
115
+ k = min(k, n)
116
+ norms = np.linalg.norm(Y, axis=1)
117
+ Yn = Y / np.maximum(norms[:, None], eps)
118
+
119
+ if seed == "leverage":
120
+ U, _, _ = np.linalg.svd(Y, full_matrices=False)
121
+ leverage = np.sum(U * U, axis=1)
122
+ first = int(np.argmax(leverage))
123
+ elif seed == "norm":
124
+ first = int(np.argmax(norms))
125
+ elif seed == "index":
126
+ first = int(np.clip(seed_index, 0, n - 1))
127
+ else:
128
+ raise ValueError("seed must be 'leverage', 'norm', or 'index'")
129
+
130
+ selected = [first]
131
+ min_dist: list = [np.nan]
132
+
133
+ d_to_S = 1.0 - (Yn @ Yn[first])
134
+ d_to_S[first] = -np.inf
135
+
136
+ for _ in range(1, k):
137
+ nxt = int(np.argmax(d_to_S))
138
+ selected.append(nxt)
139
+ min_dist.append(float(d_to_S[nxt]))
140
+ d_to_new = 1.0 - (Yn @ Yn[nxt])
141
+ d_to_S = np.minimum(d_to_S, d_to_new)
142
+ d_to_S[nxt] = -np.inf
143
+
144
+ return selected, min_dist
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.4
2
+ Name: eigenmind
3
+ Version: 0.1.0
4
+ Summary: Eigenvalue-based graph computations for semantic chunk retrieval (similarity graph, spectral analysis, Lovász theta diversity).
5
+ Author-email: Marion Schaeffer <marion.schaeffer@merlin-intelligence.eu>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/merlin-intelligence/eigenmind
8
+ Project-URL: Repository, https://github.com/merlin-intelligence/eigenmind
9
+ Project-URL: Issues, https://github.com/merlin-intelligence/eigenmind/issues
10
+ Keywords: graph,eigenvalue,spectral-clustering,embeddings,retrieval,diversity
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Scientific/Engineering
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: numpy
24
+ Requires-Dist: scipy
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest; extra == "dev"
27
+ Requires-Dist: ruff; extra == "dev"
28
+ Provides-Extra: viz
29
+ Requires-Dist: matplotlib; extra == "viz"
30
+ Dynamic: license-file
31
+
32
+ # eigenmind
33
+ Eigenmind is a decision-intelligence engine that turns tacit expert knowledge into reusable decision frameworks, ideal for data-scarce settings. Its core uses eigenvalue-based optimisation on semantic graphs.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install git+https://github.com/merlin-intelligence/eigenmind.git
39
+ ```
40
+
41
+ ## Usage
42
+
43
+ ```python
44
+ from eigenmind import SimilarityGraph
45
+
46
+ # retrieved_points: objects with .id, .vector, .payload (e.g. Qdrant points)
47
+ graph = SimilarityGraph(retrieved_points)
48
+ tags = graph.selection_tags(top_k=10) # {chunk_id: ["Singular", "Hinge", "Theta"]}
49
+ ```
50
+
51
+ ### Expected input data
52
+
53
+ `retrieved_points` is a plain list of objects — any class works (duck-typing,
54
+ modeled after Qdrant's point objects), each exposing:
55
+
56
+ | Attribute | Type | Constraint |
57
+ |---|---|---|
58
+ | `.id` | hashable (`int`, `str`, `UUID`, ...) | unique across the list — used as a dict key |
59
+ | `.vector` | sequence of floats (`list` or `np.ndarray`) | **same length for every point**, and **unit-normalized (L2 norm ≈ 1)** |
60
+ | `.payload` | `dict` | only read by `eigenvalue_analysis`, via `.get("text")`, `.get("chunk_number")`, `.get("filename")` |
61
+
62
+ The library computes cosine similarity as a plain dot product between
63
+ vectors (`embedding_matrix @ embedding_matrix.T`) — it does **not** normalize
64
+ your vectors for you. Passing unnormalized vectors raises a `ValueError`
65
+ (from `build_similarity_matrix`, used internally by `SimilarityGraph` and
66
+ `find_singular_chunks`); normalize each vector yourself first, e.g.
67
+ `v / np.linalg.norm(v)`.
68
+
69
+ A `types.SimpleNamespace(id=..., vector=..., payload={...})` is enough — no
70
+ dependency on Qdrant is required, as shown in the test suite. Because the
71
+ interface is duck-typed on these three attributes, results from any
72
+ vector store (Pinecone, Weaviate, Milvus, pgvector, Chroma, ...) work as-is,
73
+ either directly or after a thin adapter mapping their result objects to
74
+ `.id` / `.vector` / `.payload`.
75
+
76
+ `SimilarityGraph` memoizes the similarity matrix and exposes four ranking
77
+ strategies over a set of embedded chunks:
78
+
79
+ - **Singular chunks** — points at the +/- poles of the normalized Laplacian's
80
+ first eigenvectors (`singular.py`).
81
+ - **Hinge ranking** — "relevant but not obvious" chunks via an ℓ∞-connectivity
82
+ optimizer (`connectivity.py`).
83
+ - **Theta diversity** — a diverse subset picked via a Lovász θ approximation on
84
+ the conflict graph (`theta.py`).
85
+ - **Eigenvalue analysis** — spectrum plot + most expressive chunks per
86
+ eigenvector, for exploratory/debugging use. Requires the `viz` extra
87
+ (`pip install eigenmind[viz]`).
88
+
89
+
90
+ ## Configuration
91
+
92
+ Every tunable is a plain keyword argument with a default from
93
+ [`defaults.py`](src/eigenmind/defaults.py) — override it per call/instance,
94
+ there is no global state to mutate.
95
+
96
+ | Constant | Default | Meaning | Override via |
97
+ |---|---|---|---|
98
+ | `SIMILARITY_THRESHOLD` | `0.65` | Minimum cosine similarity kept as an edge weight; pairs below it are zeroed out of `W`. | `SimilarityGraph(points, threshold=...)`, `build_similarity_matrix(..., threshold=...)` |
99
+ | `THETA_CONFLICT_THRESHOLD` | `= SIMILARITY_THRESHOLD` | Similarity level above which two chunks are considered "conflicting" (same info) for the θ diversity graph. | `SimilarityGraph.conflict_mask(threshold=...)`, `build_conflict_mask(..., threshold=...)` |
100
+ | `THETA_RANK` | `24` | Rank of the low-rank factor `Y` (`X ≈ Y Yᵀ`) used by the diversity picker — caps how many effective directions the θ approximation keeps. | `SimilarityGraph.theta_factor(rank=...)`, `theta_subgradient_approximation(..., rank=...)` |
101
+ | `THETA_MAX_ITERS` | `400` | Number of subgradient-descent iterations for the Lovász θ approximation. | `SimilarityGraph.theta_factor(max_iters=...)`, `theta_subgradient_approximation(..., max_iters=...)` |
102
+ | `THETA_STEP0` | `0.25` | Initial subgradient step size, decayed as `step0 / sqrt(k)` over iterations. | `SimilarityGraph.theta_factor(step0=...)`, `theta_subgradient_approximation(..., step0=...)` |
103
+ | `THETA_DIAG_SHIFT` | `1e-2` | Diagonal regularization added when the dual matrix isn't Cholesky-decomposable (numerical stabilizer). | `SimilarityGraph.theta_factor(diag_shift=...)`, `theta_subgradient_approximation(..., diag_shift=...)` |
104
+
105
+ These defaults are the values the algorithms were originally tuned against;
106
+ adjust them if your embeddings' similarity distribution differs
107
+ significantly (e.g. a different embedding model or normalization scheme).
108
+
109
+ ## Tests
110
+
111
+ ```bash
112
+ pip install -e ".[dev]"
113
+ pytest
114
+ ```
@@ -0,0 +1,16 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/eigenmind/__init__.py
5
+ src/eigenmind/connectivity.py
6
+ src/eigenmind/defaults.py
7
+ src/eigenmind/py.typed
8
+ src/eigenmind/similarity_graph.py
9
+ src/eigenmind/singular.py
10
+ src/eigenmind/theta.py
11
+ src/eigenmind.egg-info/PKG-INFO
12
+ src/eigenmind.egg-info/SOURCES.txt
13
+ src/eigenmind.egg-info/dependency_links.txt
14
+ src/eigenmind.egg-info/requires.txt
15
+ src/eigenmind.egg-info/top_level.txt
16
+ tests/test_graph_math.py
@@ -0,0 +1,9 @@
1
+ numpy
2
+ scipy
3
+
4
+ [dev]
5
+ pytest
6
+ ruff
7
+
8
+ [viz]
9
+ matplotlib
@@ -0,0 +1 @@
1
+ eigenmind
@@ -0,0 +1,129 @@
1
+ """Smoke tests on the graph math modules — pure numpy, no external services needed.
2
+
3
+ Run with: pytest
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from types import SimpleNamespace
8
+
9
+ import numpy as np
10
+
11
+ from eigenmind.connectivity import (
12
+ compute_linf_connectivity_optimizer,
13
+ rank_relevant_but_not_obvious_chunks,
14
+ )
15
+ from eigenmind.similarity_graph import SimilarityGraph
16
+ from eigenmind.singular import build_similarity_matrix, find_singular_chunks, normalized_laplacian
17
+ from eigenmind.theta import (
18
+ build_conflict_mask,
19
+ theta_diversity_picker,
20
+ theta_subgradient_approximation,
21
+ )
22
+
23
+
24
+ def _toy_embeddings(seed: int = 0, n: int = 8, d: int = 16) -> np.ndarray:
25
+ rng = np.random.default_rng(seed)
26
+ M = rng.standard_normal((n, d))
27
+ M = M / np.linalg.norm(M, axis=1, keepdims=True)
28
+ return M
29
+
30
+
31
+ def test_similarity_matrix_is_symmetric_with_zero_diagonal():
32
+ W = build_similarity_matrix(_toy_embeddings(), threshold=0.0)
33
+ assert W.shape == (8, 8)
34
+ assert np.allclose(np.diag(W), 0.0)
35
+ assert np.allclose(W, W.T)
36
+
37
+
38
+ def test_build_similarity_matrix_rejects_unnormalized_vectors():
39
+ M = _toy_embeddings() * 3.0 # norm 3, not 1
40
+ try:
41
+ build_similarity_matrix(M, threshold=0.0)
42
+ raise AssertionError("expected ValueError for unnormalized vectors")
43
+ except ValueError as e:
44
+ assert "unit-normalized" in str(e)
45
+
46
+
47
+ def test_normalized_laplacian_is_symmetric():
48
+ W = build_similarity_matrix(_toy_embeddings(), threshold=0.0) + 0.1
49
+ np.fill_diagonal(W, 0)
50
+ L = normalized_laplacian(W)
51
+ assert np.allclose(L, L.T, atol=1e-10)
52
+
53
+
54
+ def test_linf_optimizer_in_unit_interval_or_zero():
55
+ W = build_similarity_matrix(_toy_embeddings(seed=1), threshold=0.0) + 0.1
56
+ np.fill_diagonal(W, 0)
57
+ x = compute_linf_connectivity_optimizer(W)
58
+ assert x.shape == (8,)
59
+ assert np.max(np.abs(x)) <= 1.0 + 1e-9
60
+
61
+
62
+ def test_rank_returns_one_entry_per_node():
63
+ W = build_similarity_matrix(_toy_embeddings(seed=2), threshold=0.0) + 0.1
64
+ np.fill_diagonal(W, 0)
65
+ x = compute_linf_connectivity_optimizer(W)
66
+ ranking, _, _, _, _ = rank_relevant_but_not_obvious_chunks(W, x)
67
+ assert len(ranking) == W.shape[0]
68
+ assert ranking[0][1] >= ranking[-1][1] # sorted descending by hinge
69
+
70
+
71
+ def test_theta_pipeline_runs_end_to_end():
72
+ W = build_similarity_matrix(_toy_embeddings(seed=3), threshold=0.0) + 0.1
73
+ np.fill_diagonal(W, 0)
74
+ H = build_conflict_mask(W, threshold=0.5)
75
+ assert np.all(np.diag(H) == 0)
76
+ assert np.array_equal(H, H.T)
77
+
78
+ _, _, Y, _, _, _ = theta_subgradient_approximation(H, max_iters=20)
79
+ selected, _ = theta_diversity_picker(Y, k=4)
80
+ assert len(set(selected)) == 4 # picker returns distinct indices
81
+
82
+
83
+ def _toy_points(seed: int = 0, n: int = 8):
84
+ """Build mock points compatible with what Qdrant returns: id, vector, payload."""
85
+ M = _toy_embeddings(seed=seed, n=n)
86
+ return [
87
+ SimpleNamespace(
88
+ id=i,
89
+ vector=M[i].tolist(),
90
+ payload={"filename": f"f{i}.pdf", "chunk_number": i, "text": f"chunk {i} text"},
91
+ )
92
+ for i in range(n)
93
+ ]
94
+
95
+
96
+ def test_similarity_graph_memoizes_x_star_and_theta_factor():
97
+ points = _toy_points(seed=4)
98
+ g = SimilarityGraph(points, threshold=0.0)
99
+ assert g.n == 8
100
+ # First call computes, second returns memoized array
101
+ x1 = g.linf_optimizer()
102
+ x2 = g.linf_optimizer()
103
+ assert x1 is x2 # same object reference proves memoization
104
+
105
+ Y1 = g.theta_factor(max_iters=10)
106
+ Y2 = g.theta_factor(max_iters=10)
107
+ assert Y1 is Y2
108
+
109
+
110
+ def test_singular_chunks_respects_graph_threshold_not_the_default():
111
+ # threshold=0.0 keeps every pairwise edge, unlike the library default (0.65).
112
+ # singular_chunks() must reuse SimilarityGraph.W rather than recomputing it
113
+ # from the module-level default threshold.
114
+ points = _toy_points(seed=6)
115
+ g = SimilarityGraph(points, threshold=0.0)
116
+ assert find_singular_chunks(g.points, W=g.W) == g.singular_chunks()
117
+ assert len(g.singular_chunks()) > 0
118
+
119
+
120
+ def test_similarity_graph_selection_tags_aggregates_methods():
121
+ points = _toy_points(seed=5)
122
+ g = SimilarityGraph(points, threshold=0.0)
123
+ tags = g.selection_tags(top_k=4)
124
+ # Some chunks should have at least one tag
125
+ assert any(len(v) > 0 for v in tags.values())
126
+ # Tags must be a subset of the three known methods
127
+ allowed = {"Singular", "Hinge", "Theta"}
128
+ for vs in tags.values():
129
+ assert set(vs) <= allowed