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.
- eigenmind-0.1.0/LICENSE +21 -0
- eigenmind-0.1.0/PKG-INFO +114 -0
- eigenmind-0.1.0/README.md +83 -0
- eigenmind-0.1.0/pyproject.toml +75 -0
- eigenmind-0.1.0/setup.cfg +4 -0
- eigenmind-0.1.0/src/eigenmind/__init__.py +37 -0
- eigenmind-0.1.0/src/eigenmind/connectivity.py +118 -0
- eigenmind-0.1.0/src/eigenmind/defaults.py +14 -0
- eigenmind-0.1.0/src/eigenmind/py.typed +0 -0
- eigenmind-0.1.0/src/eigenmind/similarity_graph.py +142 -0
- eigenmind-0.1.0/src/eigenmind/singular.py +165 -0
- eigenmind-0.1.0/src/eigenmind/theta.py +144 -0
- eigenmind-0.1.0/src/eigenmind.egg-info/PKG-INFO +114 -0
- eigenmind-0.1.0/src/eigenmind.egg-info/SOURCES.txt +16 -0
- eigenmind-0.1.0/src/eigenmind.egg-info/dependency_links.txt +1 -0
- eigenmind-0.1.0/src/eigenmind.egg-info/requires.txt +9 -0
- eigenmind-0.1.0/src/eigenmind.egg-info/top_level.txt +1 -0
- eigenmind-0.1.0/tests/test_graph_math.py +129 -0
eigenmind-0.1.0/LICENSE
ADDED
|
@@ -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.
|
eigenmind-0.1.0/PKG-INFO
ADDED
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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
|