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 +3 -0
- spinchain/__main__.py +5 -0
- spinchain/analyze.py +326 -0
- spinchain/formulation/__init__.py +5 -0
- spinchain/formulation/coefficient_builder.py +108 -0
- spinchain/formulation/fragment_extractor.py +115 -0
- spinchain/formulation/qubo_builder.py +66 -0
- spinchain/pipeline/__init__.py +2 -0
- spinchain/py.typed +0 -0
- spinchain/server.py +226 -0
- spinchain/solvers/__init__.py +4 -0
- spinchain/solvers/base.py +36 -0
- spinchain/solvers/simulated_annealing.py +42 -0
- spinchain/tracing.py +172 -0
- spinchain-0.1.0.dist-info/METADATA +242 -0
- spinchain-0.1.0.dist-info/RECORD +18 -0
- spinchain-0.1.0.dist-info/WHEEL +4 -0
- spinchain-0.1.0.dist-info/entry_points.txt +3 -0
spinchain/__init__.py
ADDED
spinchain/__main__.py
ADDED
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()
|
spinchain/py.typed
ADDED
|
File without changes
|