spinchain 0.1.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.
spinchain/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """SpinChain: Inference-time reasoning optimization using QUBO/Ising formulations."""
2
+
3
+ __version__ = "0.1.0"
spinchain/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Entry point for python -m spinchain."""
2
+
3
+ from spinchain.server import main
4
+
5
+ main()
spinchain/analyze.py ADDED
@@ -0,0 +1,326 @@
1
+ """Trace analysis pipeline for SpinChain MCP usage.
2
+
3
+ Reads JSONL traces and produces:
4
+ - Usage summary: call count, time range, success/fallback rates
5
+ - Latency breakdown: per-stage timing percentiles
6
+ - Energy distribution: min/mean/spread across calls
7
+ - Anomaly flags: unusually slow stages, empty selections, errors
8
+
9
+ Run as: uv run python -m spinchain.analyze [--last N] [--json]
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import sys
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from spinchain.tracing import TraceLogger, DEFAULT_TRACE_DIR
22
+
23
+
24
+ @dataclass
25
+ class UsageSummary:
26
+ """High-level usage statistics."""
27
+
28
+ total_calls: int = 0
29
+ first_call: str | None = None
30
+ last_call: str | None = None
31
+ success_count: int = 0
32
+ fallback_count: int = 0
33
+ error_count: int = 0
34
+
35
+
36
+ @dataclass
37
+ class LatencyStats:
38
+ """Latency percentiles for a pipeline stage (in ms)."""
39
+
40
+ stage: str
41
+ count: int = 0
42
+ min_ms: float = 0.0
43
+ median_ms: float = 0.0
44
+ p95_ms: float = 0.0
45
+ max_ms: float = 0.0
46
+ mean_ms: float = 0.0
47
+
48
+
49
+ @dataclass
50
+ class EnergyStats:
51
+ """Energy distribution across SA solutions."""
52
+
53
+ count: int = 0
54
+ min_energy: float | None = None
55
+ mean_energy: float | None = None
56
+ max_energy: float | None = None
57
+
58
+
59
+ @dataclass
60
+ class Anomaly:
61
+ """A flagged trace with reason."""
62
+
63
+ trace_id: str
64
+ timestamp: str
65
+ reason: str
66
+ details: dict[str, Any] = field(default_factory=dict)
67
+
68
+
69
+ def _percentile(values: list[float], p: float) -> float:
70
+ """Compute p-th percentile (0-100) from sorted values."""
71
+ if not values:
72
+ return 0.0
73
+ k = (len(values) - 1) * (p / 100.0)
74
+ f = int(k)
75
+ c = f + 1
76
+ if c >= len(values):
77
+ return values[f]
78
+ return values[f] + (k - f) * (values[c] - values[f])
79
+
80
+
81
+ class TraceAnalyzer:
82
+ """Analyzes SpinChain trace records from JSONL files."""
83
+
84
+ def __init__(self, traces: list[dict[str, Any]]):
85
+ self.traces = traces
86
+
87
+ @classmethod
88
+ def from_file(cls, trace_dir: str | Path | None = None, last_n: int | None = None) -> "TraceAnalyzer":
89
+ """Load traces from the JSONL file."""
90
+ tracer = TraceLogger(trace_dir=trace_dir)
91
+ traces = tracer.read_traces(last_n=last_n)
92
+ return cls(traces)
93
+
94
+ def usage_summary(self) -> UsageSummary:
95
+ """Compute usage summary stats."""
96
+ summary = UsageSummary(total_calls=len(self.traces))
97
+ if not self.traces:
98
+ return summary
99
+
100
+ summary.first_call = self.traces[0].get("timestamp")
101
+ summary.last_call = self.traces[-1].get("timestamp")
102
+
103
+ for t in self.traces:
104
+ if t.get("error"):
105
+ summary.error_count += 1
106
+ elif t.get("output_summary", {}).get("fallback"):
107
+ summary.fallback_count += 1
108
+ else:
109
+ summary.success_count += 1
110
+
111
+ return summary
112
+
113
+ def latency_breakdown(self) -> list[LatencyStats]:
114
+ """Compute per-stage latency percentiles."""
115
+ stage_times: dict[str, list[float]] = {}
116
+
117
+ for t in self.traces:
118
+ for stage in t.get("stages", []):
119
+ name = stage.get("name", "unknown")
120
+ dur = stage.get("duration_ms", 0.0)
121
+ stage_times.setdefault(name, []).append(dur)
122
+
123
+ # Also compute total
124
+ total_times = [
125
+ t.get("total_duration_ms", 0.0)
126
+ for t in self.traces
127
+ if t.get("total_duration_ms")
128
+ ]
129
+ if total_times:
130
+ stage_times["total"] = total_times
131
+
132
+ results = []
133
+ for stage_name in ["fragment_extraction", "qubo_formulation", "simulated_annealing", "stability_ranking", "total"]:
134
+ times = sorted(stage_times.get(stage_name, []))
135
+ if not times:
136
+ continue
137
+ results.append(LatencyStats(
138
+ stage=stage_name,
139
+ count=len(times),
140
+ min_ms=round(times[0], 2),
141
+ median_ms=round(_percentile(times, 50), 2),
142
+ p95_ms=round(_percentile(times, 95), 2),
143
+ max_ms=round(times[-1], 2),
144
+ mean_ms=round(sum(times) / len(times), 2),
145
+ ))
146
+
147
+ return results
148
+
149
+ def energy_stats(self) -> EnergyStats:
150
+ """Compute energy distribution across successful calls."""
151
+ min_energies = []
152
+ for t in self.traces:
153
+ out = t.get("output_summary", {})
154
+ me = out.get("min_energy")
155
+ if me is not None:
156
+ min_energies.append(me)
157
+
158
+ if not min_energies:
159
+ return EnergyStats()
160
+
161
+ return EnergyStats(
162
+ count=len(min_energies),
163
+ min_energy=round(min(min_energies), 4),
164
+ mean_energy=round(sum(min_energies) / len(min_energies), 4),
165
+ max_energy=round(max(min_energies), 4),
166
+ )
167
+
168
+ def detect_anomalies(
169
+ self,
170
+ slow_threshold_ms: float = 10000.0,
171
+ slow_stage_threshold_ms: float = 5000.0,
172
+ ) -> list[Anomaly]:
173
+ """Flag traces with potential issues."""
174
+ anomalies = []
175
+
176
+ for t in self.traces:
177
+ tid = t.get("trace_id", "?")
178
+ ts = t.get("timestamp", "?")
179
+
180
+ # Error
181
+ if t.get("error"):
182
+ anomalies.append(Anomaly(
183
+ trace_id=tid, timestamp=ts,
184
+ reason="error",
185
+ details={"error": t["error"]},
186
+ ))
187
+
188
+ # Slow total
189
+ total_ms = t.get("total_duration_ms", 0.0)
190
+ if total_ms > slow_threshold_ms:
191
+ anomalies.append(Anomaly(
192
+ trace_id=tid, timestamp=ts,
193
+ reason="slow_total",
194
+ details={"total_duration_ms": total_ms},
195
+ ))
196
+
197
+ # Slow individual stage
198
+ for stage in t.get("stages", []):
199
+ if stage.get("duration_ms", 0) > slow_stage_threshold_ms:
200
+ anomalies.append(Anomaly(
201
+ trace_id=tid, timestamp=ts,
202
+ reason="slow_stage",
203
+ details={
204
+ "stage": stage["name"],
205
+ "duration_ms": stage["duration_ms"],
206
+ },
207
+ ))
208
+
209
+ # Empty selection (non-fallback call selected 0 fragments)
210
+ out = t.get("output_summary", {})
211
+ if not out.get("fallback") and out.get("num_selected", 1) == 0:
212
+ anomalies.append(Anomaly(
213
+ trace_id=tid, timestamp=ts,
214
+ reason="empty_selection",
215
+ details={"num_fragments": out.get("num_fragments")},
216
+ ))
217
+
218
+ return anomalies
219
+
220
+ def full_report(self) -> dict[str, Any]:
221
+ """Generate complete analysis report as a dict."""
222
+ usage = self.usage_summary()
223
+ latency = self.latency_breakdown()
224
+ energy = self.energy_stats()
225
+ anomalies = self.detect_anomalies()
226
+
227
+ return {
228
+ "usage": {
229
+ "total_calls": usage.total_calls,
230
+ "first_call": usage.first_call,
231
+ "last_call": usage.last_call,
232
+ "success": usage.success_count,
233
+ "fallback": usage.fallback_count,
234
+ "errors": usage.error_count,
235
+ },
236
+ "latency": [
237
+ {
238
+ "stage": s.stage,
239
+ "count": s.count,
240
+ "min_ms": s.min_ms,
241
+ "median_ms": s.median_ms,
242
+ "p95_ms": s.p95_ms,
243
+ "max_ms": s.max_ms,
244
+ "mean_ms": s.mean_ms,
245
+ }
246
+ for s in latency
247
+ ],
248
+ "energy": {
249
+ "count": energy.count,
250
+ "min": energy.min_energy,
251
+ "mean": energy.mean_energy,
252
+ "max": energy.max_energy,
253
+ },
254
+ "anomalies": [
255
+ {
256
+ "trace_id": a.trace_id,
257
+ "timestamp": a.timestamp,
258
+ "reason": a.reason,
259
+ **a.details,
260
+ }
261
+ for a in anomalies
262
+ ],
263
+ }
264
+
265
+ def print_report(self, file=sys.stdout) -> None:
266
+ """Print a human-readable report."""
267
+ usage = self.usage_summary()
268
+
269
+ print("=" * 60, file=file)
270
+ print(" SpinChain Trace Analysis", file=file)
271
+ print("=" * 60, file=file)
272
+
273
+ print(f"\n Calls: {usage.total_calls} "
274
+ f"(success: {usage.success_count}, "
275
+ f"fallback: {usage.fallback_count}, "
276
+ f"errors: {usage.error_count})", file=file)
277
+ if usage.first_call:
278
+ print(f" Range: {usage.first_call} -> {usage.last_call}", file=file)
279
+
280
+ latency = self.latency_breakdown()
281
+ if latency:
282
+ print(f"\n {'Stage':<25} {'Count':>5} {'Min':>8} {'Median':>8} {'P95':>8} {'Max':>8} {'Mean':>8}", file=file)
283
+ print(f" {'-'*23} {'-'*5} {'-'*8} {'-'*8} {'-'*8} {'-'*8} {'-'*8}", file=file)
284
+ for s in latency:
285
+ print(f" {s.stage:<25} {s.count:>5} {s.min_ms:>7.1f}ms {s.median_ms:>7.1f}ms "
286
+ f"{s.p95_ms:>7.1f}ms {s.max_ms:>7.1f}ms {s.mean_ms:>7.1f}ms", file=file)
287
+
288
+ energy = self.energy_stats()
289
+ if energy.count:
290
+ print(f"\n Energy: min={energy.min_energy}, mean={energy.mean_energy}, "
291
+ f"max={energy.max_energy} (n={energy.count})", file=file)
292
+
293
+ anomalies = self.detect_anomalies()
294
+ if anomalies:
295
+ print(f"\n Anomalies ({len(anomalies)}):", file=file)
296
+ for a in anomalies:
297
+ print(f" [{a.reason}] {a.trace_id} @ {a.timestamp} — {a.details}", file=file)
298
+ else:
299
+ print("\n No anomalies detected.", file=file)
300
+
301
+ print("", file=file)
302
+
303
+
304
+ def main():
305
+ """CLI entry point for trace analysis."""
306
+ import os
307
+
308
+ parser = argparse.ArgumentParser(description="Analyze SpinChain MCP traces")
309
+ parser.add_argument("--trace-dir", default=os.environ.get("SPINCHAIN_TRACE_DIR"),
310
+ help="Path to trace directory (default: SPINCHAIN_TRACE_DIR or ~/.spinchain/traces)")
311
+ parser.add_argument("--last", type=int, default=None,
312
+ help="Only analyze last N traces")
313
+ parser.add_argument("--json", action="store_true",
314
+ help="Output as JSON instead of human-readable")
315
+ args = parser.parse_args()
316
+
317
+ analyzer = TraceAnalyzer.from_file(trace_dir=args.trace_dir, last_n=args.last)
318
+
319
+ if args.json:
320
+ print(json.dumps(analyzer.full_report(), indent=2))
321
+ else:
322
+ analyzer.print_report()
323
+
324
+
325
+ if __name__ == "__main__":
326
+ main()
@@ -0,0 +1,5 @@
1
+ from spinchain.formulation.fragment_extractor import FragmentExtractor
2
+ from spinchain.formulation.coefficient_builder import CoefficientBuilder
3
+ from spinchain.formulation.qubo_builder import QUBOBuilder
4
+
5
+ __all__ = ["FragmentExtractor", "CoefficientBuilder", "QUBOBuilder"]
@@ -0,0 +1,108 @@
1
+ """Build HUBO/QUBO coefficients from fragment statistics.
2
+
3
+ Implements the coefficient design from QCR-LLM (arXiv 2510.24509):
4
+ - Linear: w_i = -mu * p_i + alpha * risk_i
5
+ - Quadratic: w_ij = -beta * (corr_ij - lambda^2 * sim(i,j))
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+ from scipy.stats import zscore
12
+
13
+
14
+ class CoefficientBuilder:
15
+ """Computes linear and quadratic coefficients for the QUBO formulation.
16
+
17
+ Args:
18
+ mu: Weight for fragment popularity (higher = prefer common fragments).
19
+ alpha: Weight for fragment stability/risk penalty.
20
+ beta: Weight for pairwise coherence terms.
21
+ lambda_sim: Semantic similarity penalty factor.
22
+ regularization: Small constant for z-score stability.
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ mu: float = 1.0,
28
+ alpha: float = 0.5,
29
+ beta: float = 1.0,
30
+ lambda_sim: float = 0.3,
31
+ regularization: float = 1e-6,
32
+ ):
33
+ self.mu = mu
34
+ self.alpha = alpha
35
+ self.beta = beta
36
+ self.lambda_sim = lambda_sim
37
+ self.regularization = regularization
38
+
39
+ def compute_linear_weights(
40
+ self,
41
+ fragment_sources: list[set[int]],
42
+ num_completions: int,
43
+ ) -> np.ndarray:
44
+ """Compute linear (1-body) weights for each fragment.
45
+
46
+ w_i = -mu * p_i + alpha * risk_i
47
+ where p_i = popularity, risk_i = p_i * (1 - p_i)
48
+ """
49
+ r = len(fragment_sources)
50
+ weights = np.zeros(r)
51
+
52
+ for i in range(r):
53
+ p_i = len(fragment_sources[i]) / num_completions
54
+ risk_i = p_i * (1.0 - p_i)
55
+ weights[i] = -self.mu * p_i + self.alpha * risk_i
56
+
57
+ return weights
58
+
59
+ def compute_quadratic_weights(
60
+ self,
61
+ fragment_sources: list[set[int]],
62
+ num_completions: int,
63
+ embeddings: np.ndarray,
64
+ ) -> np.ndarray:
65
+ """Compute quadratic (2-body) weights for fragment pairs.
66
+
67
+ w_ij = -beta * (z_score(corr_ij) - lambda^2 * sim(i,j))
68
+ where corr_ij = co-occurrence correlation, sim = cosine similarity
69
+ """
70
+ r = len(fragment_sources)
71
+ n = num_completions
72
+
73
+ # Popularity
74
+ p = np.array([len(s) / n for s in fragment_sources])
75
+
76
+ # Co-occurrence matrix
77
+ co_occurrence = np.zeros((r, r))
78
+ for i in range(r):
79
+ for j in range(i + 1, r):
80
+ n_ij = len(fragment_sources[i] & fragment_sources[j])
81
+ co_occurrence[i, j] = n_ij / n - p[i] * p[j]
82
+ co_occurrence[j, i] = co_occurrence[i, j]
83
+
84
+ # Standardize correlations (z-scores)
85
+ upper_tri = co_occurrence[np.triu_indices(r, k=1)]
86
+ if len(upper_tri) > 1 and np.std(upper_tri) > self.regularization:
87
+ mean_c = np.mean(upper_tri)
88
+ std_c = np.std(upper_tri)
89
+ z_corr = (co_occurrence - mean_c) / (std_c + self.regularization)
90
+ else:
91
+ z_corr = co_occurrence
92
+
93
+ # Cosine similarity matrix
94
+ norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
95
+ norms = np.maximum(norms, 1e-10)
96
+ normed = embeddings / norms
97
+ sim_matrix = normed @ normed.T
98
+
99
+ # Quadratic weights
100
+ weights = np.zeros((r, r))
101
+ for i in range(r):
102
+ for j in range(i + 1, r):
103
+ weights[i, j] = -self.beta * (
104
+ z_corr[i, j] - self.lambda_sim**2 * sim_matrix[i, j]
105
+ )
106
+ weights[j, i] = weights[i, j]
107
+
108
+ return weights
@@ -0,0 +1,115 @@
1
+ """Extract and deduplicate reasoning fragments from multiple LLM completions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ from sentence_transformers import SentenceTransformer
7
+
8
+
9
+ class FragmentExtractor:
10
+ """Extracts distinct reasoning fragments from N CoT completions.
11
+
12
+ Following QCR-LLM: splits each completion into sentence-level fragments,
13
+ computes embeddings, merges near-duplicates via cosine similarity threshold,
14
+ and returns a normalized fragment pool.
15
+ """
16
+
17
+ def __init__(
18
+ self,
19
+ model_name: str = "all-MiniLM-L6-v2",
20
+ similarity_threshold: float = 0.85,
21
+ ):
22
+ self.model = SentenceTransformer(model_name)
23
+ self.similarity_threshold = similarity_threshold
24
+
25
+ def extract_fragments(self, completions: list[str]) -> list[str]:
26
+ """Extract and deduplicate fragments from multiple completions.
27
+
28
+ Args:
29
+ completions: List of N CoT completion strings from the LLM.
30
+
31
+ Returns:
32
+ List of R distinct reasoning fragments.
33
+ """
34
+ raw_fragments = []
35
+ fragment_sources: list[set[int]] = []
36
+
37
+ for comp_idx, completion in enumerate(completions):
38
+ sentences = self._split_into_sentences(completion)
39
+ for sentence in sentences:
40
+ cleaned = sentence.strip()
41
+ if len(cleaned) < 10:
42
+ continue
43
+ raw_fragments.append(cleaned)
44
+ fragment_sources.append({comp_idx})
45
+
46
+ if not raw_fragments:
47
+ return []
48
+
49
+ embeddings = self.model.encode(raw_fragments, convert_to_numpy=True)
50
+ merged_fragments, merged_sources, merged_embeddings = self._merge_similar(
51
+ raw_fragments, fragment_sources, embeddings
52
+ )
53
+
54
+ self._last_fragments = merged_fragments
55
+ self._last_sources = merged_sources
56
+ self._last_embeddings = merged_embeddings
57
+ self._num_completions = len(completions)
58
+
59
+ return merged_fragments
60
+
61
+ @property
62
+ def fragment_embeddings(self) -> np.ndarray:
63
+ return self._last_embeddings
64
+
65
+ @property
66
+ def fragment_sources(self) -> list[set[int]]:
67
+ return self._last_sources
68
+
69
+ @property
70
+ def num_completions(self) -> int:
71
+ return self._num_completions
72
+
73
+ def _split_into_sentences(self, text: str) -> list[str]:
74
+ """Split text into reasoning sentences. Simple split on period/newline."""
75
+ import re
76
+
77
+ sentences = re.split(r"(?<=[.!?])\s+|\n+", text)
78
+ return [s for s in sentences if s.strip()]
79
+
80
+ def _merge_similar(
81
+ self,
82
+ fragments: list[str],
83
+ sources: list[set[int]],
84
+ embeddings: np.ndarray,
85
+ ) -> tuple[list[str], list[set[int]], np.ndarray]:
86
+ """Merge fragments whose cosine similarity exceeds threshold."""
87
+ n = len(fragments)
88
+ if n == 0:
89
+ return [], [], np.array([])
90
+
91
+ norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
92
+ norms = np.maximum(norms, 1e-10)
93
+ normed = embeddings / norms
94
+ sim_matrix = normed @ normed.T
95
+
96
+ merged = [False] * n
97
+ result_fragments = []
98
+ result_sources = []
99
+ result_embeddings = []
100
+
101
+ for i in range(n):
102
+ if merged[i]:
103
+ continue
104
+ group_sources = set(sources[i])
105
+ for j in range(i + 1, n):
106
+ if merged[j]:
107
+ continue
108
+ if sim_matrix[i, j] >= self.similarity_threshold:
109
+ group_sources |= sources[j]
110
+ merged[j] = True
111
+ result_fragments.append(fragments[i])
112
+ result_sources.append(group_sources)
113
+ result_embeddings.append(embeddings[i])
114
+
115
+ return result_fragments, result_sources, np.array(result_embeddings)
@@ -0,0 +1,66 @@
1
+ """Build QUBO from linear and quadratic weights.
2
+
3
+ Converts the HUBO formulation into a QUBO compatible with any
4
+ Ising/QUBO solver (SA, D-Wave, COBI, Quanfluence, Qiskit QAOA).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+ import dimod
11
+
12
+
13
+ class QUBOBuilder:
14
+ """Builds a dimod BinaryQuadraticModel from fragment coefficients.
15
+
16
+ The QUBO encodes: minimize H(x) = sum_i w_i * x_i + sum_{i<j} w_ij * x_i * x_j
17
+ with an optional cardinality constraint to select ~K fragments.
18
+ """
19
+
20
+ def __init__(self, penalty_strength: float = 5.0):
21
+ self.penalty_strength = penalty_strength
22
+
23
+ def build(
24
+ self,
25
+ linear_weights: np.ndarray,
26
+ quadratic_weights: np.ndarray,
27
+ target_fragments: int | None = None,
28
+ ) -> dimod.BinaryQuadraticModel:
29
+ """Build BQM from weights.
30
+
31
+ Args:
32
+ linear_weights: 1D array of shape (R,) — linear coefficients.
33
+ quadratic_weights: 2D array of shape (R, R) — quadratic coefficients.
34
+ target_fragments: If set, add penalty to select approximately K fragments.
35
+
36
+ Returns:
37
+ dimod.BinaryQuadraticModel ready for any solver.
38
+ """
39
+ r = len(linear_weights)
40
+ linear = {}
41
+ quadratic = {}
42
+
43
+ for i in range(r):
44
+ linear[i] = float(linear_weights[i])
45
+
46
+ for i in range(r):
47
+ for j in range(i + 1, r):
48
+ if abs(quadratic_weights[i, j]) > 1e-10:
49
+ quadratic[(i, j)] = float(quadratic_weights[i, j])
50
+
51
+ # Add cardinality constraint: penalty * (sum(x_i) - K)^2
52
+ if target_fragments is not None:
53
+ k = target_fragments
54
+ for i in range(r):
55
+ linear[i] = linear.get(i, 0.0) + self.penalty_strength * (1 - 2 * k)
56
+ for i in range(r):
57
+ for j in range(i + 1, r):
58
+ key = (i, j)
59
+ quadratic[key] = quadratic.get(key, 0.0) + 2 * self.penalty_strength
60
+
61
+ bqm = dimod.BinaryQuadraticModel(linear, quadratic, 0.0, dimod.BINARY)
62
+ return bqm
63
+
64
+ def bqm_to_qubo(self, bqm: dimod.BinaryQuadraticModel) -> tuple[dict, float]:
65
+ """Convert BQM to raw QUBO dict format (for custom solvers)."""
66
+ return bqm.to_qubo()
@@ -0,0 +1,2 @@
1
+ # Pipeline module removed — SpinChain is now an MCP server.
2
+ # See spinchain.server for the optimize_reasoning tool.
spinchain/py.typed ADDED
File without changes