lp2graph 0.3.0__py3-none-any.whl

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.
Files changed (80) hide show
  1. lp2graph/__init__.py +54 -0
  2. lp2graph/cli.py +238 -0
  3. lp2graph/codec/__init__.py +41 -0
  4. lp2graph/codec/latex.py +884 -0
  5. lp2graph/codec/normalize.py +82 -0
  6. lp2graph/core/__init__.py +35 -0
  7. lp2graph/core/graph.py +183 -0
  8. lp2graph/core/loader.py +63 -0
  9. lp2graph/core/model.py +437 -0
  10. lp2graph/core/validate.py +237 -0
  11. lp2graph/export/__init__.py +13 -0
  12. lp2graph/export/dgl.py +51 -0
  13. lp2graph/export/latex.py +126 -0
  14. lp2graph/export/networkx_adapter.py +50 -0
  15. lp2graph/export/pyg.py +79 -0
  16. lp2graph/export/pyomo_stub.py +81 -0
  17. lp2graph/metrics/__init__.py +58 -0
  18. lp2graph/metrics/classification.py +113 -0
  19. lp2graph/metrics/flags.py +122 -0
  20. lp2graph/metrics/result.py +26 -0
  21. lp2graph/metrics/structural.py +236 -0
  22. lp2graph/mining/__init__.py +47 -0
  23. lp2graph/mining/cluster/__init__.py +65 -0
  24. lp2graph/mining/cluster/agglomerative.py +82 -0
  25. lp2graph/mining/cluster/distance.py +65 -0
  26. lp2graph/mining/cluster/operator.py +218 -0
  27. lp2graph/mining/cluster/silhouette.py +88 -0
  28. lp2graph/mining/cluster/stability.py +178 -0
  29. lp2graph/mining/cluster/taxonomy.py +268 -0
  30. lp2graph/mining/corpusmgr/__init__.py +70 -0
  31. lp2graph/mining/corpusmgr/dedup.py +183 -0
  32. lp2graph/mining/corpusmgr/manager.py +79 -0
  33. lp2graph/mining/corpusmgr/manifest.py +82 -0
  34. lp2graph/mining/corpusmgr/record.py +101 -0
  35. lp2graph/mining/corpusmgr/select.py +128 -0
  36. lp2graph/mining/homologize/__init__.py +82 -0
  37. lp2graph/mining/homologize/concept.py +134 -0
  38. lp2graph/mining/homologize/entity.py +217 -0
  39. lp2graph/mining/homologize/lemmatize.py +80 -0
  40. lp2graph/mining/homologize/signature.py +166 -0
  41. lp2graph/mining/homologize/thesaurus.py +70 -0
  42. lp2graph/mining/homologize/tokenize.py +255 -0
  43. lp2graph/mining/homologize/vectorize.py +141 -0
  44. lp2graph/mining/ingest/__init__.py +59 -0
  45. lp2graph/mining/ingest/code_importers.py +104 -0
  46. lp2graph/mining/ingest/dispatch.py +148 -0
  47. lp2graph/mining/ingest/latex_normalizer.py +243 -0
  48. lp2graph/mining/ingest/pyomo_importer.py +297 -0
  49. lp2graph/mining/ingest/result.py +124 -0
  50. lp2graph/mining/isomorphism/__init__.py +26 -0
  51. lp2graph/mining/isomorphism/report.py +178 -0
  52. lp2graph/mining/label/__init__.py +70 -0
  53. lp2graph/mining/label/classifier.py +161 -0
  54. lp2graph/mining/label/features.py +35 -0
  55. lp2graph/mining/label/guardrails.py +176 -0
  56. lp2graph/mining/label/loop.py +314 -0
  57. lp2graph/mining/label/rules.py +92 -0
  58. lp2graph/mining/label/store.py +164 -0
  59. lp2graph/mining/label/vocab.py +64 -0
  60. lp2graph/mining/provenance.py +90 -0
  61. lp2graph/mining/versions.py +51 -0
  62. lp2graph/nl/__init__.py +15 -0
  63. lp2graph/nl/describe.py +301 -0
  64. lp2graph/render/__init__.py +11 -0
  65. lp2graph/render/palette.py +80 -0
  66. lp2graph/render/svg.py +220 -0
  67. lp2graph/solve/__init__.py +50 -0
  68. lp2graph/solve/grounder.py +405 -0
  69. lp2graph/solve/instance.py +76 -0
  70. lp2graph/transform/__init__.py +30 -0
  71. lp2graph/transform/bigm.py +173 -0
  72. lp2graph/views/__init__.py +17 -0
  73. lp2graph/views/ground.py +477 -0
  74. lp2graph/views/hybrid.py +202 -0
  75. lp2graph/views/schema.py +208 -0
  76. lp2graph-0.3.0.dist-info/METADATA +206 -0
  77. lp2graph-0.3.0.dist-info/RECORD +80 -0
  78. lp2graph-0.3.0.dist-info/WHEEL +4 -0
  79. lp2graph-0.3.0.dist-info/entry_points.txt +2 -0
  80. lp2graph-0.3.0.dist-info/licenses/LICENSE +205 -0
@@ -0,0 +1,218 @@
1
+ """The cluster-and-name operator ``CN`` (M3 core).
2
+
3
+ ``CN(E)`` partitions a set of entities ``E`` (each carrying a TF-IDF concept
4
+ vector) into named parts. It is the single primitive the multi-level taxonomy
5
+ passes are built from:
6
+
7
+ 1. Route entities with no concept signal (zero vector) to an explicit
8
+ ``unassigned`` part (label ``-1``) — every entity ends up in exactly one
9
+ part.
10
+ 2. Cluster the rest in cosine-distance space. Default backend is the
11
+ deterministic average-linkage core; ``fixed_k`` selects K by silhouette;
12
+ ``hdbscan`` is used when the optional dependency is installed.
13
+ 3. Name each part by aggregated TF-IDF weight: the concept carrying the most
14
+ summed mass across the part's members becomes its name (collisions get a
15
+ deterministic numeric suffix).
16
+
17
+ The whole operator is deterministic given the versioned
18
+ :class:`ClusterConfig`.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections.abc import Sequence
24
+ from dataclasses import dataclass, field
25
+ from typing import Literal
26
+
27
+ from lp2graph.mining.cluster.agglomerative import agglomerative
28
+ from lp2graph.mining.cluster.distance import distance_matrix, is_zero
29
+ from lp2graph.mining.cluster.silhouette import select_k, silhouette_score
30
+ from lp2graph.mining.homologize.vectorize import Vocabulary
31
+ from lp2graph.mining.versions import CLUSTERING_VERSION
32
+
33
+ #: Cluster id reserved for entities with no concept signal.
34
+ UNASSIGNED = -1
35
+
36
+ Algorithm = Literal["agglomerative", "fixed_k", "hdbscan"]
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class ClusterConfig:
41
+ """Versioned, deterministic configuration for :func:`CN`."""
42
+
43
+ algorithm: Algorithm = "agglomerative"
44
+ distance_threshold: float = 0.7
45
+ k: int | None = None
46
+ k_range: tuple[int, int] = (2, 8)
47
+ seed: int = 0
48
+ version: str = CLUSTERING_VERSION
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class NamedClustering:
53
+ """The result of :func:`CN`: a named partition over the input entities.
54
+
55
+ ``labels`` is aligned to the input order (``-1`` == unassigned).
56
+ ``names`` maps each cluster id to its concept name. ``members`` maps each
57
+ id to the (sorted) input indices it contains. ``silhouette`` is the mean
58
+ silhouette over the assigned points.
59
+ """
60
+
61
+ labels: tuple[int, ...]
62
+ names: dict[int, str]
63
+ members: dict[int, tuple[int, ...]]
64
+ config: ClusterConfig
65
+ silhouette: float
66
+ top_concepts: dict[int, tuple[tuple[str, float], ...]] = field(default_factory=dict)
67
+
68
+ def name_of(self, index: int) -> str:
69
+ """Name of the cluster the entity at ``index`` belongs to."""
70
+ return self.names[self.labels[index]]
71
+
72
+ @property
73
+ def n_clusters(self) -> int:
74
+ """Number of real (non-unassigned) clusters."""
75
+ return len([cid for cid in self.members if cid != UNASSIGNED])
76
+
77
+
78
+ def _aggregate_weights(
79
+ members: Sequence[int], vectors: Sequence[Sequence[float]], vocab: Vocabulary
80
+ ) -> list[tuple[str, float]]:
81
+ totals = [0.0] * len(vocab)
82
+ for idx in members:
83
+ v = vectors[idx]
84
+ for j, val in enumerate(v):
85
+ if val != 0.0:
86
+ totals[j] += val
87
+ weighted = [(vocab.concepts[j], totals[j]) for j in range(len(vocab)) if totals[j] > 0.0]
88
+ # Sort by descending weight, then concept name for deterministic ties.
89
+ weighted.sort(key=lambda kv: (-kv[1], kv[0]))
90
+ return weighted
91
+
92
+
93
+ def _name_clusters(
94
+ members: dict[int, tuple[int, ...]],
95
+ vectors: Sequence[Sequence[float]],
96
+ vocab: Vocabulary,
97
+ ) -> tuple[dict[int, str], dict[int, tuple[tuple[str, float], ...]]]:
98
+ names: dict[int, str] = {}
99
+ tops: dict[int, tuple[tuple[str, float], ...]] = {}
100
+ used: dict[str, int] = {}
101
+ # Deterministic order: by cluster id.
102
+ for cid in sorted(members):
103
+ if cid == UNASSIGNED:
104
+ names[cid] = "unassigned"
105
+ tops[cid] = ()
106
+ continue
107
+ weighted = _aggregate_weights(members[cid], vectors, vocab)
108
+ tops[cid] = tuple(weighted[:5])
109
+ base = weighted[0][0] if weighted else f"cluster_{cid}"
110
+ if base in used:
111
+ used[base] += 1
112
+ name = f"{base}_{used[base]}"
113
+ else:
114
+ used[base] = 1
115
+ name = base
116
+ names[cid] = name
117
+ return names, tops
118
+
119
+
120
+ def _hdbscan_labels(dist: Sequence[Sequence[float]], min_cluster_size: int) -> list[int]:
121
+ import hdbscan # local import; optional dependency
122
+
123
+ clusterer = hdbscan.HDBSCAN(metric="precomputed", min_cluster_size=max(2, min_cluster_size))
124
+ rows = [[float(x) for x in row] for row in dist]
125
+ labels = clusterer.fit_predict(rows)
126
+ return [int(x) for x in labels]
127
+
128
+
129
+ def CN(
130
+ entities: Sequence[object],
131
+ vectors: Sequence[Sequence[float]],
132
+ vocab: Vocabulary,
133
+ config: ClusterConfig | None = None,
134
+ ) -> NamedClustering:
135
+ """Cluster ``entities`` (by ``vectors``) and name the parts.
136
+
137
+ ``entities`` is only used for its length and ordering; the partition is
138
+ computed from ``vectors`` and named from ``vocab``. Returns a
139
+ deterministic :class:`NamedClustering`.
140
+ """
141
+ cfg = config or ClusterConfig()
142
+ n = len(vectors)
143
+ if len(entities) != n:
144
+ raise ValueError("entities and vectors must have the same length")
145
+
146
+ labels = [UNASSIGNED] * n
147
+ assigned = [i for i in range(n) if not is_zero(vectors[i])]
148
+
149
+ if assigned:
150
+ sub_vectors = [vectors[i] for i in assigned]
151
+ sub_dist = distance_matrix(sub_vectors)
152
+ sub_labels = _cluster(sub_dist, cfg)
153
+ # Re-pack sub-cluster ids into a dense 0..K-1 space, ordered by first
154
+ # appearance, then map back to original indices.
155
+ remap: dict[int, int] = {}
156
+ for sub_idx, lab in enumerate(sub_labels):
157
+ if lab < 0: # hdbscan noise → unassigned
158
+ continue
159
+ if lab not in remap:
160
+ remap[lab] = len(remap)
161
+ labels[assigned[sub_idx]] = remap[lab]
162
+
163
+ grouped: dict[int, list[int]] = {}
164
+ for i, lab in enumerate(labels):
165
+ grouped.setdefault(lab, []).append(i)
166
+ members: dict[int, tuple[int, ...]] = {
167
+ cid: tuple(sorted(idxs)) for cid, idxs in grouped.items()
168
+ }
169
+
170
+ names, tops = _name_clusters(members, vectors, vocab)
171
+
172
+ real_labels = [labels[i] for i in assigned]
173
+ if assigned and len({lab for lab in real_labels if lab >= 0}) >= 2:
174
+ sub_dist = distance_matrix([vectors[i] for i in assigned])
175
+ sil = silhouette_score(sub_dist, real_labels)
176
+ else:
177
+ sil = 0.0
178
+
179
+ return NamedClustering(
180
+ labels=tuple(labels),
181
+ names=names,
182
+ members=members,
183
+ config=cfg,
184
+ silhouette=sil,
185
+ top_concepts=tops,
186
+ )
187
+
188
+
189
+ def _cluster(dist: Sequence[Sequence[float]], cfg: ClusterConfig) -> list[int]:
190
+ n = len(dist)
191
+ if n == 0:
192
+ return []
193
+ if n == 1:
194
+ return [0]
195
+ if cfg.algorithm == "hdbscan":
196
+ return _hdbscan_labels(dist, min_cluster_size=2)
197
+ if cfg.algorithm == "fixed_k":
198
+ k_target = cfg.k
199
+ if k_target is None:
200
+ k_target, _ = select_k(
201
+ dist,
202
+ lambda k: agglomerative(dist, distance_threshold=None, k=k),
203
+ k_min=cfg.k_range[0],
204
+ k_max=cfg.k_range[1],
205
+ )
206
+ k_target = max(1, min(k_target, n))
207
+ return list(agglomerative(dist, distance_threshold=None, k=k_target))
208
+ # default: threshold-based agglomerative
209
+ return list(agglomerative(dist, distance_threshold=cfg.distance_threshold, k=None))
210
+
211
+
212
+ __all__ = [
213
+ "CN",
214
+ "UNASSIGNED",
215
+ "Algorithm",
216
+ "ClusterConfig",
217
+ "NamedClustering",
218
+ ]
@@ -0,0 +1,88 @@
1
+ """Silhouette score and K selection (M3).
2
+
3
+ The silhouette coefficient measures how well each point sits in its assigned
4
+ cluster versus the nearest other cluster, in the same cosine-distance space
5
+ the clustering runs in. It serves two roles in the method: it is the
6
+ selection criterion for the fixed-K fallback, and it is reported (alongside
7
+ bootstrap ARI) in the stability report.
8
+
9
+ Pure Python and deterministic.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections import defaultdict
15
+ from collections.abc import Callable, Sequence
16
+
17
+
18
+ def silhouette_samples(dist: Sequence[Sequence[float]], labels: Sequence[int]) -> list[float]:
19
+ """Per-point silhouette values given a distance matrix and labels.
20
+
21
+ Points whose cluster has size 1, and all points when there is only one
22
+ cluster, get a silhouette of 0 (the conventional definition).
23
+ """
24
+ n = len(labels)
25
+ by_cluster: dict[int, list[int]] = defaultdict(list)
26
+ for i, lab in enumerate(labels):
27
+ by_cluster[lab].append(i)
28
+ clusters = sorted(by_cluster)
29
+ out = [0.0] * n
30
+ if len(clusters) < 2:
31
+ return out
32
+
33
+ for i in range(n):
34
+ own = labels[i]
35
+ own_members = by_cluster[own]
36
+ if len(own_members) <= 1:
37
+ out[i] = 0.0
38
+ continue
39
+ a = sum(dist[i][j] for j in own_members if j != i) / (len(own_members) - 1)
40
+ b = float("inf")
41
+ for lab in clusters:
42
+ if lab == own:
43
+ continue
44
+ members = by_cluster[lab]
45
+ mean_d = sum(dist[i][j] for j in members) / len(members)
46
+ if mean_d < b:
47
+ b = mean_d
48
+ denom = max(a, b)
49
+ out[i] = 0.0 if denom == 0.0 else (b - a) / denom
50
+ return out
51
+
52
+
53
+ def silhouette_score(dist: Sequence[Sequence[float]], labels: Sequence[int]) -> float:
54
+ """Mean silhouette over all points (0.0 for a degenerate partition)."""
55
+ samples = silhouette_samples(dist, labels)
56
+ if not samples:
57
+ return 0.0
58
+ return sum(samples) / len(samples)
59
+
60
+
61
+ def select_k(
62
+ dist: Sequence[Sequence[float]],
63
+ cluster_fn: Callable[[int], Sequence[int]],
64
+ *,
65
+ k_min: int,
66
+ k_max: int,
67
+ ) -> tuple[int, float]:
68
+ """Pick the K in ``[k_min, k_max]`` with the highest mean silhouette.
69
+
70
+ ``cluster_fn(k)`` must return labels for ``k`` clusters. Ties are broken
71
+ toward the smaller K (simpler partition). Returns ``(best_k, best_score)``.
72
+ """
73
+ n = len(dist)
74
+ hi = min(k_max, n - 1)
75
+ lo = max(2, k_min)
76
+ if hi < lo:
77
+ return (1, 0.0)
78
+ best_k = lo
79
+ best_score = float("-inf")
80
+ for k in range(lo, hi + 1):
81
+ score = silhouette_score(dist, cluster_fn(k))
82
+ if score > best_score:
83
+ best_score = score
84
+ best_k = k
85
+ return (best_k, best_score)
86
+
87
+
88
+ __all__ = ["select_k", "silhouette_samples", "silhouette_score"]
@@ -0,0 +1,178 @@
1
+ """Clustering stability report: silhouette, bootstrap ARI, sensitivity (M3).
2
+
3
+ A partition is only trustworthy if it survives resampling and small changes
4
+ to the knobs. This module quantifies that:
5
+
6
+ - **silhouette** — cohesion/separation of the reported partition (re-exported
7
+ from :mod:`lp2graph.mining.cluster.silhouette`).
8
+ - **bootstrap ARI** — resample the entities with replacement, re-cluster, and
9
+ measure Adjusted Rand Index against the reference partition restricted to
10
+ the shared points; report mean and spread.
11
+ - **sensitivity** — re-cluster under alternative configs (a different
12
+ algorithm, a coarsened vocabulary ``|C|``) and report the ARI to the
13
+ reference partition.
14
+
15
+ Determinism: bootstrap resampling uses a seeded :class:`random.Random`, so the
16
+ report is reproducible from ``(seed, versions)``.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import random
22
+ from collections import defaultdict
23
+ from collections.abc import Sequence
24
+ from dataclasses import dataclass
25
+
26
+ from lp2graph.mining.cluster.operator import CN, ClusterConfig, NamedClustering
27
+ from lp2graph.mining.homologize.vectorize import Vocabulary, build_vocabulary
28
+
29
+
30
+ def adjusted_rand_index(a: Sequence[int], b: Sequence[int]) -> float:
31
+ """Adjusted Rand Index between two labelings of the same points.
32
+
33
+ Returns 1.0 for identical partitions (up to relabeling), ~0.0 for random
34
+ agreement. Defined as 1.0 for the degenerate all-equal case.
35
+ """
36
+ if len(a) != len(b):
37
+ raise ValueError("labelings must have equal length")
38
+ n = len(a)
39
+ if n == 0:
40
+ return 1.0
41
+ contingency: dict[tuple[int, int], int] = defaultdict(int)
42
+ rows: dict[int, int] = defaultdict(int)
43
+ cols: dict[int, int] = defaultdict(int)
44
+ for x, y in zip(a, b, strict=True):
45
+ contingency[(x, y)] += 1
46
+ rows[x] += 1
47
+ cols[y] += 1
48
+
49
+ def comb2(x: int) -> int:
50
+ return x * (x - 1) // 2
51
+
52
+ sum_comb = sum(comb2(v) for v in contingency.values())
53
+ sum_rows = sum(comb2(v) for v in rows.values())
54
+ sum_cols = sum(comb2(v) for v in cols.values())
55
+ total = comb2(n)
56
+ expected = (sum_rows * sum_cols) / total if total else 0.0
57
+ max_index = (sum_rows + sum_cols) / 2.0
58
+ if max_index == expected:
59
+ return 1.0
60
+ return (sum_comb - expected) / (max_index - expected)
61
+
62
+
63
+ @dataclass(frozen=True, slots=True)
64
+ class StabilityReport:
65
+ """Silhouette + bootstrap ARI + sensitivity for one clustering."""
66
+
67
+ silhouette: float
68
+ bootstrap_ari_mean: float
69
+ bootstrap_ari_min: float
70
+ bootstrap_ari_max: float
71
+ n_bootstrap: int
72
+ sensitivity: dict[str, float]
73
+ config_version: str
74
+ seed: int
75
+
76
+
77
+ def bootstrap_ari(
78
+ vectors: Sequence[Sequence[float]],
79
+ vocab: Vocabulary,
80
+ reference: NamedClustering,
81
+ config: ClusterConfig,
82
+ *,
83
+ n_bootstrap: int = 25,
84
+ ) -> tuple[float, float, float]:
85
+ """Mean/min/max ARI of bootstrap re-clusterings vs the reference partition.
86
+
87
+ Each bootstrap draws ``n`` indices with replacement, re-clusters the
88
+ unique drawn points, and compares to the reference labels on those same
89
+ points. Deterministic via ``config.seed``.
90
+ """
91
+ n = len(vectors)
92
+ if n < 3:
93
+ return (1.0, 1.0, 1.0)
94
+ rng = random.Random(config.seed)
95
+ scores: list[float] = []
96
+ for _ in range(n_bootstrap):
97
+ drawn = sorted({rng.randrange(n) for _ in range(n)})
98
+ if len(drawn) < 2:
99
+ continue
100
+ sub_vectors = [vectors[i] for i in drawn]
101
+ sub = CN(drawn, sub_vectors, vocab, config)
102
+ ref_labels = [reference.labels[i] for i in drawn]
103
+ scores.append(adjusted_rand_index(ref_labels, list(sub.labels)))
104
+ if not scores:
105
+ return (1.0, 1.0, 1.0)
106
+ return (sum(scores) / len(scores), min(scores), max(scores))
107
+
108
+
109
+ def _coarsen_vocabulary(vocab: Vocabulary, keep_every: int) -> Vocabulary:
110
+ """Drop concepts to shrink ``|C|`` (deterministic stride over sorted axis)."""
111
+ kept = tuple(c for i, c in enumerate(vocab.concepts) if i % keep_every == 0)
112
+ return build_vocabulary([{c: 1 for c in kept}], version=vocab.version)
113
+
114
+
115
+ def sensitivity(
116
+ vectors: Sequence[Sequence[float]],
117
+ vocab: Vocabulary,
118
+ reference: NamedClustering,
119
+ config: ClusterConfig,
120
+ ) -> dict[str, float]:
121
+ """ARI of the reference partition against alternative configurations.
122
+
123
+ Probes robustness to the clustering algorithm (``fixed_k``) and to a
124
+ coarsened vocabulary ``|C|`` (every-other concept). Each entry is the ARI
125
+ between the reference labels and the labels under that variation.
126
+ """
127
+ out: dict[str, float] = {}
128
+
129
+ alt_algo = ClusterConfig(
130
+ algorithm="fixed_k",
131
+ k_range=config.k_range,
132
+ seed=config.seed,
133
+ version=config.version,
134
+ )
135
+ alt = CN(list(range(len(vectors))), vectors, vocab, alt_algo)
136
+ out["algorithm=fixed_k"] = adjusted_rand_index(list(reference.labels), list(alt.labels))
137
+
138
+ if len(vocab) >= 4:
139
+ coarse_vocab = _coarsen_vocabulary(vocab, keep_every=2)
140
+ pos = {c: i for i, c in enumerate(vocab.concepts)}
141
+ # Re-project vectors onto the coarsened axis (keep matching coordinates).
142
+ coarse_vectors = [tuple(v[pos[c]] for c in coarse_vocab.concepts) for v in vectors]
143
+ coarse = CN(list(range(len(vectors))), coarse_vectors, coarse_vocab, config)
144
+ out["vocabulary=halved"] = adjusted_rand_index(list(reference.labels), list(coarse.labels))
145
+
146
+ return out
147
+
148
+
149
+ def stability_report(
150
+ vectors: Sequence[Sequence[float]],
151
+ vocab: Vocabulary,
152
+ clustering: NamedClustering,
153
+ config: ClusterConfig,
154
+ *,
155
+ n_bootstrap: int = 25,
156
+ ) -> StabilityReport:
157
+ """Assemble the full stability report for one clustering."""
158
+ mean, lo, hi = bootstrap_ari(vectors, vocab, clustering, config, n_bootstrap=n_bootstrap)
159
+ sens = sensitivity(vectors, vocab, clustering, config)
160
+ return StabilityReport(
161
+ silhouette=clustering.silhouette,
162
+ bootstrap_ari_mean=mean,
163
+ bootstrap_ari_min=lo,
164
+ bootstrap_ari_max=hi,
165
+ n_bootstrap=n_bootstrap,
166
+ sensitivity=sens,
167
+ config_version=config.version,
168
+ seed=config.seed,
169
+ )
170
+
171
+
172
+ __all__ = [
173
+ "StabilityReport",
174
+ "adjusted_rand_index",
175
+ "bootstrap_ari",
176
+ "sensitivity",
177
+ "stability_report",
178
+ ]