File size: 29,718 Bytes
2a87ede
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
"""

Tests for mcp_server/server.py -- the dense_evolution_mcp MCP adapter.



Routes every request through httpx.ASGITransport straight into the real,

in-process local_site.app.server.app (client._TEST_TRANSPORT, swapped via

monkeypatch.setattr in the autouse fixture below -- not a raw manual

global mutation, for automatic restoration even if a test fails partway

through) instead of a live subprocess kernel bound to a real port. That

means every test below still exercises the real DenseSVSimulator / real PennyLane

Hartree-Fock Hamiltonians -- no mocked physics, only the network hop is

swapped for an in-process one, for the same reason test_local_site_server.py

uses FastAPI's TestClient rather than a live server.

"""
import asyncio
import base64
import json
import os

import pytest

# fastapi/uvicorn/pydantic (composer) and mcp/httpx (mcp) are both optional
# extras -- skip this whole module cleanly wherever they aren't installed,
# same pattern test_local_site_server.py uses for the composer extra alone.
pytest.importorskip("fastapi")
pytest.importorskip("mcp")
import httpx  # noqa: E402

from local_site.app import server as kernel  # noqa: E402
from mcp_server import server as mcp_adapter  # noqa: E402
from mcp_server import client as mcp_client  # noqa: E402
from mcp_server.utils import images as mcp_images  # noqa: E402
from mcp_server import models as mcp_models  # noqa: E402
from mcp_server import molecules as mcp_molecules  # noqa: E402

H2 = "H2 (Idrogeno) - R = 0.7414 A [equilibrio reale]"
EXACT_H2_ENERGY_HARTREE = -1.1372701748786913
BELL_QASM = """OPENQASM 2.0;

include "qelib1.inc";

qreg q[2];

creg c[2];

h q[0];

cx q[0],q[1];

measure q[0] -> c[0];

measure q[1] -> c[1];

"""


def run(coro):
    return asyncio.run(coro)


@pytest.fixture(autouse=True)
def route_through_real_kernel_in_process(monkeypatch):
    monkeypatch.setattr(mcp_client, "_TEST_TRANSPORT", httpx.ASGITransport(app=kernel.app))
    # A fresh molecule-alias cache per test: several tests below rely on
    # the real /api/hamiltonians catalog being (re)fetched, not whatever a
    # previous test happened to populate.
    mcp_molecules._molecule_catalog_cache.invalidate()
    yield
    mcp_molecules._molecule_catalog_cache.invalidate()


def test_health_reports_real_kernel_info():
    data = json.loads(run(mcp_adapter.dense_evolution_health()))
    assert data["status"] == "ok"
    assert data["dense_evolution_version"] == kernel.dense_evolution.__version__
    assert data["total_ram_gb"] > 0


def test_system_limits_reports_positive_qubit_ceiling():
    data = json.loads(run(mcp_adapter.dense_evolution_system_limits()))
    assert data["max_qubits_dense"] > 0


def test_list_presets_includes_a_real_bell_state():
    data = json.loads(run(mcp_adapter.dense_evolution_list_presets()))
    assert any("Bell" in name for name in data)


def test_list_gates_is_non_empty():
    data = json.loads(run(mcp_adapter.dense_evolution_list_gates()))
    assert len(data) > 0


def test_list_noise_models_includes_depolarizing():
    data = json.loads(run(mcp_adapter.dense_evolution_list_noise_models()))
    assert "depolarizing" in data


def test_build_circuit_from_ops_round_trips_to_valid_qasm():
    ops = [{"gate": "h", "qubits": [0]}, {"gate": "cx", "qubits": [0, 1]}]
    result = run(mcp_adapter.dense_evolution_build_circuit(mcp_models.BuildCircuitInput(n_qubits=2, ops=ops)))
    data = json.loads(result)
    assert "OPENQASM" in data["qasm"]
    assert "h q[0]" in data["qasm"]


def test_custom_molecule_energy_matches_catalog_h2():
    result = json.loads(run(mcp_adapter.dense_evolution_custom_molecule_energy(mcp_models.CustomMoleculeInput(
        symbols=["H", "H"], geometry=[[0, 0, 0], [0, 0, 0.7414]],
    ))))
    assert result["ground_state_energy_hartree"] == pytest.approx(EXACT_H2_ENERGY_HARTREE, abs=1e-6)


def test_qmmm_forces_returns_real_force_vectors():
    result = json.loads(run(mcp_adapter.dense_evolution_qmmm_forces(mcp_models.QmmmForcesInput(name="H2"))))
    assert "forces" in result or "force_norm" in result or "energy_hartree" in result


def test_md_trajectory_runs_a_few_fixed_electronic_state_steps():
    result = json.loads(run(mcp_adapter.dense_evolution_md_trajectory(mcp_models.MdTrajectoryInput(
        name="H2", n_steps=2, recompute_electronic_state=False,
    ))))
    assert "error" not in result


def test_mitigate_zne_richardson_extrapolates_toward_ideal():
    # Single-seed, single-comparison assertions here are genuinely
    # statistical, not deterministic: NoiseModel.apply_to_sv's depolarizing
    # channel is a real stochastic single-qubit-per-shot Kraus draw (fixed
    # 2026-08-11, see registry.py's changelog entry -- the channel used to
    # decide fire/no-fire independently per computational-basis amplitude
    # pair instead of once per qubit per shot, which understated true
    # per-shot variance on entangled states). At the default n_trials=200
    # per noise scale, a single fixed seed can land on the unlucky side of
    # the distribution (verified directly: 42's own default seed passes
    # only ~70% of nearby seeds at n_trials=200). Average the comparison
    # over several seeds instead of trusting one arbitrary seed's outcome
    # -- this is the statistically honest form of the same physics check.
    ideal_ok = True
    zne_gaps = []
    noisiest_gaps = []
    for seed in range(5):
        result = json.loads(run(mcp_adapter.dense_evolution_mitigate_zne(mcp_models.MitigateZneInput(
            qasm=BELL_QASM, pauli_string="ZZ", noise_model="depolarizing", noise_p=0.05, seed=seed,
        ))))
        ideal_ok = ideal_ok and (result["ideal_expectation"] == pytest.approx(1.0, abs=1e-6))
        zne_gaps.append(abs(result["zne_extrapolated"] - 1.0))
        noisiest_gaps.append(abs(result["noisy_expectations"][-1] - 1.0))
    assert ideal_ok
    # Real physics check, not just "did it return something": averaged
    # over several seeds, ZNE should land closer to ideal than the
    # noisiest single measurement.
    assert sum(zne_gaps) / len(zne_gaps) < sum(noisiest_gaps) / len(noisiest_gaps)


def test_mitigate_density_matrix_reports_fidelity_improvement():
    result = json.loads(run(mcp_adapter.dense_evolution_mitigate_density_matrix(
        mcp_models.MitigateDensityMatrixInput(qasm=BELL_QASM, noise_model="depolarizing", noise_p=0.05)
    )))
    assert 0.0 <= result["fidelity_raw"] <= 1.0
    assert 0.0 <= result["fidelity_corrected"] <= 1.0


def test_vector_healing_replaces_an_outlier_with_the_local_median():
    vectors = [[1.0, 2.0], [1.1, 2.1], [1.05, 2.05], [50.0, -30.0], [1.08, 2.08], [1.02, 2.02]]
    result = json.loads(run(mcp_adapter.dense_evolution_vector_healing(
        mcp_models.VectorHealingInput(vectors=vectors)
    )))
    assert len(result["healed_vectors"]) == 6
    assert result["healed_vectors"][3] != vectors[3]
    assert result["reconstruction_error"] > 0.0


def test_list_molecules_includes_short_ids():
    data = json.loads(run(mcp_adapter.dense_evolution_list_molecules(mcp_models.ListMoleculesInput())))
    by_id = {m["id"]: m for m in data}
    assert "H2" in by_id
    assert by_id["H2"]["full_name"] == H2
    assert by_id["H2"]["n_qubits"] == 4


def test_molecule_energy_accepts_short_id():
    result = run(mcp_adapter.dense_evolution_molecule_energy(mcp_models.MoleculeEnergyInput(name="H2")))
    data = json.loads(result)
    assert data["ground_state_energy_hartree"] == pytest.approx(EXACT_H2_ENERGY_HARTREE, abs=1e-6)


def test_molecule_energy_accepts_full_catalog_name():
    result = run(mcp_adapter.dense_evolution_molecule_energy(mcp_models.MoleculeEnergyInput(name=H2)))
    data = json.loads(result)
    assert data["ground_state_energy_hartree"] == pytest.approx(EXACT_H2_ENERGY_HARTREE, abs=1e-6)


def test_molecule_energy_unknown_name_is_a_clear_error_not_a_crash():
    result = run(mcp_adapter.dense_evolution_molecule_energy(mcp_models.MoleculeEnergyInput(name="Unobtainium")))
    assert result.startswith("Error:")
    assert "Unobtainium" in result


def test_mix_molecules_accepts_short_ids():
    result = run(mcp_adapter.dense_evolution_mix_molecules(
        mcp_models.MixMoleculesInput(name_a="H2", name_b="HeH+", weight_a=0.5, weight_b=0.5)
    ))
    # H2 (4 qubits) and HeH+ (4 qubits) share a qubit count -- a real,
    # meaningful mix; the tool should resolve both short ids and succeed.
    data = json.loads(result)
    assert "energy_mixed" in data


def test_run_circuit_bell_state_gives_real_50_50_split():
    result = run(mcp_adapter.dense_evolution_run_circuit(
        mcp_models.RunCircuitInput(qasm=BELL_QASM, shots=500, seed=42)
    ))
    data = json.loads(result)
    assert data["n_qubits"] == 2
    assert set(data["counts"]) == {"00", "11"}
    assert data["counts"]["00"] + data["counts"]["11"] == 500
    # Both truncation summaries should be present and self-consistent.
    assert data["statevector"]["total_nonzero_amplitudes"] == 2
    assert data["probabilities"]["total_basis_states"] == 4


def test_run_circuit_top_k_is_configurable():
    # BUG FIX: top_k used to be hardcoded at 25 inside the truncation
    # helpers with no caller-facing way to change it. A 3-qubit GHZ-ish
    # circuit only has 2 nonzero amplitudes/basis states, so top_k=1 is
    # the meaningful case to check truncation actually engages.
    ghz_qasm = """OPENQASM 2.0;

include "qelib1.inc";

qreg q[3];

creg c[3];

h q[0];

cx q[0],q[1];

cx q[1],q[2];

"""
    result = run(mcp_adapter.dense_evolution_run_circuit(
        mcp_models.RunCircuitInput(qasm=ghz_qasm, shots=50, top_k=1)
    ))
    data = json.loads(result)
    assert data["statevector"]["total_nonzero_amplitudes"] == 2
    assert data["statevector"]["shown"] == 1
    assert len(data["statevector"]["top_amplitudes_by_magnitude"]) == 1
    assert data["probabilities"]["shown"] == 1
    assert len(data["probabilities"]["top_states_by_probability"]) == 1


def test_run_circuit_visualizations_are_saved_to_disk_not_inlined(tmp_path):
    original_dir = mcp_images.IMAGE_OUTPUT_DIR
    mcp_images.IMAGE_OUTPUT_DIR = tmp_path
    try:
        result = run(mcp_adapter.dense_evolution_run_circuit(
            mcp_models.RunCircuitInput(qasm=BELL_QASM, shots=50, include_visualizations=True)
        ))
        data = json.loads(result)
        for key in ("circuit_png_path", "histogram_png_path", "qsphere_png_path", "bloch_png_path"):
            assert key in data
            path = data[key]
            assert os.path.exists(path)
            assert os.path.getsize(path) > 0
            assert str(tmp_path) in path
        # The point of saving to disk: no raw base64 anywhere in the response
        # -- checked against the parsed dict's own keys, since "circuit_png"
        # is (correctly) a substring of "circuit_png_path" in the raw text.
        for raw_key in ("circuit_png", "histogram_png", "qsphere_png", "bloch_png"):
            assert raw_key not in data
    finally:
        mcp_images.IMAGE_OUTPUT_DIR = original_dir


def test_run_circuit_image_metadata_sidecar_is_written(tmp_path):
    # BUG FIX: a saved image's filename (circuit_<timestamp>.png) used to
    # carry no way to trace it back to the circuit/tool call that produced
    # it. Every PNG dense_evolution_run_circuit saves should now have a
    # same-stem .json sidecar identifying the source qasm/seed/etc.
    original_dir = mcp_images.IMAGE_OUTPUT_DIR
    mcp_images.IMAGE_OUTPUT_DIR = tmp_path
    try:
        result = run(mcp_adapter.dense_evolution_run_circuit(
            mcp_models.RunCircuitInput(qasm=BELL_QASM, shots=50, seed=7, include_visualizations=True)
        ))
        data = json.loads(result)
        import pathlib
        png_path = pathlib.Path(data["circuit_png_path"])
        json_path = png_path.with_suffix(".json")
        assert json_path.exists()
        meta = json.loads(json_path.read_text())
        assert meta["tool"] == "dense_evolution_run_circuit"
        assert meta["qasm"] == BELL_QASM
        assert meta["seed"] == 7
    finally:
        mcp_images.IMAGE_OUTPUT_DIR = original_dir


def test_prune_old_images_keeps_only_the_most_recent_max_files(tmp_path):
    # BUG FIX: nothing ever cleaned up IMAGE_OUTPUT_DIR -- a long-running
    # MCP session grows it without bound. Uses synthetic files with
    # explicit mtimes (not _save_png's real-clock filenames) so the
    # "oldest" ordering is deterministic instead of racing real time.
    original_dir = mcp_images.IMAGE_OUTPUT_DIR
    original_max = mcp_images.IMAGE_MAX_FILES
    mcp_images.IMAGE_OUTPUT_DIR = tmp_path
    mcp_images.IMAGE_MAX_FILES = 3
    try:
        for i in range(5):
            p = tmp_path / f"img{i}.png"
            p.write_bytes(b"x")
            os.utime(p, (i, i))

        mcp_images._prune_old_images()

        remaining = {p.name for p in tmp_path.glob("*.png")}
        assert remaining == {"img2.png", "img3.png", "img4.png"}
    finally:
        mcp_images.IMAGE_OUTPUT_DIR = original_dir
        mcp_images.IMAGE_MAX_FILES = original_max


def test_prune_old_images_disabled_when_max_files_non_positive(tmp_path):
    original_dir = mcp_images.IMAGE_OUTPUT_DIR
    original_max = mcp_images.IMAGE_MAX_FILES
    mcp_images.IMAGE_OUTPUT_DIR = tmp_path
    mcp_images.IMAGE_MAX_FILES = 0
    try:
        for i in range(5):
            (tmp_path / f"img{i}.png").write_bytes(b"x")

        mcp_images._prune_old_images()

        assert len(list(tmp_path.glob("*.png"))) == 5
    finally:
        mcp_images.IMAGE_OUTPUT_DIR = original_dir
        mcp_images.IMAGE_MAX_FILES = original_max


def test_save_png_calls_prune_so_the_directory_stays_bounded(tmp_path):
    # End-to-end wiring check: _save_png itself must trigger pruning, not
    # just the helper in isolation. Real-clock filenames make exact
    # oldest-file identity unreliable at test speed, so this only checks
    # the invariant that actually matters: the directory never exceeds
    # the configured cap.
    original_dir = mcp_images.IMAGE_OUTPUT_DIR
    original_max = mcp_images.IMAGE_MAX_FILES
    mcp_images.IMAGE_OUTPUT_DIR = tmp_path
    mcp_images.IMAGE_MAX_FILES = 3
    try:
        tiny_png_b64 = base64.b64encode(b"not a real png but bytes are bytes").decode()
        for i in range(6):
            mcp_images._save_png(tiny_png_b64, f"shot{i}")
        assert len(list(tmp_path.glob("*.png"))) <= 3
    finally:
        mcp_images.IMAGE_OUTPUT_DIR = original_dir
        mcp_images.IMAGE_MAX_FILES = original_max


def test_molecule_catalog_second_call_hits_the_cache_not_the_network():
    # BUG FIX target: the pre-Phase-2 code never cached across calls in
    # production (only test code ever reset the old plain-dict cache) --
    # this checks the actual cache-HIT code path fires: a second call for
    # the same mapping must NOT reach _request again.
    first = run(mcp_adapter.dense_evolution_list_molecules(mcp_models.ListMoleculesInput()))

    async def _fail_if_called(*args, **kwargs):
        raise AssertionError("second call should have hit the cache, not _request")

    original_request = mcp_molecules._request
    mcp_molecules._request = _fail_if_called
    try:
        second = run(mcp_adapter.dense_evolution_list_molecules(mcp_models.ListMoleculesInput()))
    finally:
        mcp_molecules._request = original_request
    assert first == second


def test_molecule_catalog_fetch_failure_is_cached_and_reraised(monkeypatch):
    mcp_molecules._molecule_catalog_cache.invalidate()

    class _BrokenClient:
        async def request(self, method, path, **kwargs):
            raise httpx.ConnectError("simulated kernel down")

    monkeypatch.setattr(mcp_client, "_get_client", lambda: _BrokenClient())
    result = run(mcp_adapter.dense_evolution_list_molecules(mcp_models.ListMoleculesInput()))
    assert result.startswith("Error:")
    # Second call within the failure TTL must reuse the cached failure
    # (not attempt a fresh connection) -- proven by NOT patching _get_client
    # back yet and getting the same error shape again, fast.
    result_again = run(mcp_adapter.dense_evolution_list_molecules(mcp_models.ListMoleculesInput()))
    assert result_again.startswith("Error:")
    mcp_molecules._molecule_catalog_cache.invalidate()


def test_run_circuit_large_scale_response_includes_image_metadata(monkeypatch, tmp_path):
    # Mocks the kernel's own large_scale response shape (same technique
    # test_kernel_timeout_gives_actionable_error_not_a_traceback uses)
    # rather than driving a real >24-qubit MPS run, which would be slow
    # and depend on exact MPS internals unrelated to what this test checks:
    # that the large_scale branch's saved image also gets a metadata
    # sidecar, same as the normal branch already does.
    original_dir = mcp_images.IMAGE_OUTPUT_DIR
    mcp_images.IMAGE_OUTPUT_DIR = tmp_path
    tiny_png_b64 = base64.b64encode(b"not a real png but bytes are bytes").decode()

    class _FakeResponse:
        status_code = 200

        def json(self):
            return {
                "large_scale": True, "n_qubits": 30, "k_requested": 32,
                "top_k_states": [], "circuit_png": tiny_png_b64,
            }

    class _FakeClient:
        async def request(self, method, path, **kwargs):
            return _FakeResponse()

    monkeypatch.setattr(mcp_client, "_get_client", lambda: _FakeClient())
    try:
        result = run(mcp_adapter.dense_evolution_run_circuit(
            mcp_models.RunCircuitInput(qasm=BELL_QASM, include_visualizations=True)
        ))
        data = json.loads(result)
        assert data["large_scale"] is True
        assert "circuit_png" not in data
        import pathlib
        png_path = pathlib.Path(data["circuit_png_path"])
        assert png_path.with_suffix(".json").exists()
    finally:
        mcp_images.IMAGE_OUTPUT_DIR = original_dir


def test_save_png_returns_none_for_falsy_input():
    assert mcp_images._save_png(None, "whatever") is None
    assert mcp_images._save_png("", "whatever") is None


def test_energy_scan_matches_individual_molecule_energy_call():
    single = json.loads(run(mcp_adapter.dense_evolution_molecule_energy(mcp_models.MoleculeEnergyInput(name="H2"))))
    scan_result = json.loads(run(mcp_adapter.dense_evolution_energy_scan(mcp_models.EnergyScanInput(
        symbols=["H", "H"],
        geometries=[[[0, 0, 0], [0, 0, 0.7414]]],
        labels=["equilibrium"],
    ))))
    assert scan_result["n_points"] == 1
    assert scan_result["results"][0]["ground_state_energy_hartree"] == pytest.approx(
        single["ground_state_energy_hartree"], abs=1e-9
    )
    assert scan_result["minimum"]["label"] == "equilibrium"


def test_energy_scan_finds_h2_minimum_near_known_equilibrium():
    result = json.loads(run(mcp_adapter.dense_evolution_energy_scan(mcp_models.EnergyScanInput(
        symbols=["H", "H"],
        geometries=[[[0, 0, 0], [0, 0, r]] for r in (0.5, 0.7414, 1.2)],
        labels=[0.5, 0.7414, 1.2],
    ))))
    assert result["minimum"]["label"] == 0.7414


def test_energy_scan_isolates_a_failing_point_from_the_rest():
    # A 13-atom linear hydrogen chain needs far more than 12 qubits --
    # dense_evolution_custom_molecule_energy's own real, documented cap --
    # so this point must fail while the small, valid point still succeeds.
    big_symbols = ["H"] * 13
    big_geometry = [[0, 0, float(i)] for i in range(13)]
    result = json.loads(run(mcp_adapter.dense_evolution_energy_scan(mcp_models.EnergyScanInput(
        symbols=["H", "H"],
        geometries=[[[0, 0, 0], [0, 0, 0.7414]]],
        labels=["ok"],
    ))))
    assert "error" not in result["results"][0]

    # Mismatched symbols/geometry length within one scan point is caught
    # before ever calling the kernel, and reported per-point.
    mismatched = json.loads(run(mcp_adapter.dense_evolution_energy_scan(mcp_models.EnergyScanInput(
        symbols=["H", "H", "H"],
        geometries=[[[0, 0, 0], [0, 0, 0.7414]]],  # only 2 rows for 3 symbols
        labels=["bad"],
    ))))
    assert mismatched["results"][0]["error"]
    assert mismatched["minimum"] is None


def test_energy_scan_isolates_a_real_kernel_error_from_the_rest():
    # Distinct from test_energy_scan_isolates_a_failing_point_from_the_rest,
    # which only exercises the LOCAL symbols/geometry length check (never
    # reaches the kernel at all). This one needs a point where that local
    # check passes but the kernel itself rejects the request (13 H atoms
    # needs far more than the real, documented 12-qubit exact-diagonalization
    # cap) -- the try/except around the real _request call inside _one_point.
    big_symbols = ["H"] * 13
    big_geometry = [[0, 0, float(i)] for i in range(13)]
    result = json.loads(run(mcp_adapter.dense_evolution_energy_scan(mcp_models.EnergyScanInput(
        symbols=big_symbols,
        geometries=[big_geometry],
        labels=["too_big"],
    ))))
    assert "error" in result["results"][0]
    assert result["minimum"] is None


def test_energy_scan_rejects_mismatched_labels_length():
    result = run(mcp_adapter.dense_evolution_energy_scan(mcp_models.EnergyScanInput(
        symbols=["H", "H"],
        geometries=[[[0, 0, 0], [0, 0, 0.7]], [[0, 0, 0], [0, 0, 0.8]]],
        labels=["only-one-label"],
    )))
    assert result.startswith("Error:")


def test_run_vqe_hardware_efficient_converges_close_to_exact_for_h2():
    result = json.loads(run(mcp_adapter.dense_evolution_run_vqe(mcp_models.RunVqeInput(
        name="H2", ansatz_type="hardware_efficient", n_layers=4, maxiter=150, seed=0,
    ))))
    assert abs(result["vqe_energy_hartree"] - result["exact_energy_hartree"]) < 0.01


def test_kernel_unreachable_gives_actionable_error_not_a_traceback(monkeypatch):
    monkeypatch.setattr(mcp_client, "_TEST_TRANSPORT", None)  # force a real (failing) TCP attempt
    monkeypatch.setattr(mcp_client, "KERNEL_URL", "http://127.0.0.1:1")  # nothing listens here
    result = run(mcp_adapter.dense_evolution_health())
    assert result.startswith("Error:")
    assert "dense-evolution serve" in result


def test_kernel_timeout_gives_actionable_error_not_a_traceback(monkeypatch):
    class _TimeoutClient:
        async def request(self, method, path, **kwargs):
            raise httpx.TimeoutException("simulated timeout")

    monkeypatch.setattr(mcp_client, "_get_client", lambda: _TimeoutClient())
    result = run(mcp_adapter.dense_evolution_health())
    assert result.startswith("Error:")
    assert "timed out" in result


def test_kernel_error_response_with_non_json_body_falls_back_to_raw_text(monkeypatch):
    # _request's status_code>=400 branch tries resp.json().get("detail", ...)
    # first and falls back to resp.text if the body isn't valid JSON (e.g. a
    # raw HTML 502 from a proxy in front of the kernel, not the kernel's own
    # structured FastAPI error response).
    class _FakeResponse:
        status_code = 502
        text = "<html>Bad Gateway</html>"

        def json(self):
            raise ValueError("not valid json")

    class _ErrorClient:
        async def request(self, method, path, **kwargs):
            return _FakeResponse()

    monkeypatch.setattr(mcp_client, "_get_client", lambda: _ErrorClient())
    result = run(mcp_adapter.dense_evolution_health())
    assert result.startswith("Error:")
    assert "<html>Bad Gateway</html>" in result


def test_wormhole_select_instance_finds_the_known_seed_61():
    data = json.loads(run(mcp_adapter.dense_evolution_wormhole_select_instance(
        mcp_models.WormholeSelectInstanceInput(
            n_majorana=8, k_terms=10, J=2 ** 0.5, n_candidates=200, target_commuting=34,
        )
    )))
    assert data["seed"] == 61
    assert data["commuting"] == 34
    assert data["anticommuting"] == 11


def test_wormhole_teleportation_exact_backend_matches_known_reference_values():
    """seed=61, t0=0.3, t1=0.60 known peak: I(mu=+12)=0.01326, I(mu=-12)=0.01793

    (exact backend) -- same values pinned in tests/test_local_site_server.py,

    checked again here through the MCP adapter's own request path."""
    pos = json.loads(run(mcp_adapter.dense_evolution_wormhole_teleportation(
        mcp_models.WormholeTeleportationInput(mu=12.0, t0=0.3, t1=0.6, seed=61, backend="exact")
    )))
    neg = json.loads(run(mcp_adapter.dense_evolution_wormhole_teleportation(
        mcp_models.WormholeTeleportationInput(mu=-12.0, t0=0.3, t1=0.6, seed=61, backend="exact")
    )))
    assert pos["mutual_information_pt"] == pytest.approx(0.01326, abs=1e-5)
    assert neg["mutual_information_pt"] == pytest.approx(0.01793, abs=1e-5)


def test_wormhole_teleportation_trotter_backend_matches_known_reference_values():
    pos = json.loads(run(mcp_adapter.dense_evolution_wormhole_teleportation(
        mcp_models.WormholeTeleportationInput(mu=12.0, t0=0.3, t1=0.6, seed=61, backend="trotter")
    )))
    neg = json.loads(run(mcp_adapter.dense_evolution_wormhole_teleportation(
        mcp_models.WormholeTeleportationInput(mu=-12.0, t0=0.3, t1=0.6, seed=61, backend="trotter")
    )))
    assert pos["mutual_information_pt"] == pytest.approx(0.01301, abs=1e-5)
    assert neg["mutual_information_pt"] == pytest.approx(0.01821, abs=1e-5)


def test_wormhole_teleportation_invalid_backend_gives_error_not_a_traceback():
    result = run(mcp_adapter.dense_evolution_wormhole_teleportation(
        mcp_models.WormholeTeleportationInput(backend="not-a-backend")
    ))
    assert result.startswith("Error:")


def test_wormhole_scan_sweeps_t1_and_finds_the_known_peak():
    """One batched call replacing the two-call-per-point pattern above --

    the peak (largest mu<0 minus mu>0 delta) for this exact configuration

    is known to land at t1=0.60 (delta=+0.00468, exact backend) from the

    original research reproduction's 11-point sweep."""
    result = json.loads(run(mcp_adapter.dense_evolution_wormhole_scan(
        mcp_models.WormholeScanInput(
            mu_magnitude=12.0, t0=0.3, t1_values=[0.10, 0.30, 0.60, 0.85, 1.20],
            seed=61, backend="exact",
        )
    )))
    assert result["n_points"] == 5
    assert all("delta" in r for r in result["results"])
    assert result["peak"]["t1"] == pytest.approx(0.60)
    assert result["peak"]["delta"] == pytest.approx(0.00468, abs=1e-4)


def test_wormhole_scan_reports_per_point_errors_without_aborting():
    result = json.loads(run(mcp_adapter.dense_evolution_wormhole_scan(
        mcp_models.WormholeScanInput(
            n_majorana=200, t0=0.3, t1_values=[0.5], seed=61, backend="exact",
        )
    )))
    assert result["n_points"] == 1
    assert "error" in result["results"][0]
    assert result["peak"] is None


def test_request_reuses_the_same_client_across_calls():
    # BUG FIX (perf): _request used to open a fresh httpx.AsyncClient
    # (TCP handshake) per call -- _get_client now caches and reuses one
    # as long as (_TEST_TRANSPORT, KERNEL_URL) haven't changed, which
    # they don't within a single test (the autouse fixture only swaps
    # _TEST_TRANSPORT between tests, not within one).
    run(mcp_adapter.dense_evolution_health())
    client_after_first_call = mcp_client._shared_client
    run(mcp_adapter.dense_evolution_health())
    assert mcp_client._shared_client is client_after_first_call


def test_request_rebuilds_client_when_transport_or_url_changes(monkeypatch):
    run(mcp_adapter.dense_evolution_health())
    stale_client = mcp_client._shared_client

    monkeypatch.setattr(mcp_client, "_TEST_TRANSPORT", None)
    monkeypatch.setattr(mcp_client, "KERNEL_URL", "http://127.0.0.1:1")
    result = run(mcp_adapter.dense_evolution_health())
    assert result.startswith("Error:")  # confirms it actually hit the new (broken) target
    assert mcp_client._shared_client is not stale_client


def test_per_tool_timeouts_reach_the_real_http_call(monkeypatch):
    """Each tool used to share one flat 180s timeout regardless of real

    cost -- a fast health check waited as long as a slow VQE run would

    need, and a genuinely long VQE/MD run could get cut off by the same

    180s ceiling. Verifies the actual timeout= kwarg httpx.AsyncClient.request

    receives differs per tool and matches each endpoint's real expected

    cost, not that the call merely succeeds (which wouldn't catch a timeout

    value silently being ignored or defaulted)."""
    seen_timeouts = {}
    real_request = httpx.AsyncClient.request

    async def _spy_request(self, method, url, *args, **kwargs):
        seen_timeouts[url] = kwargs.get("timeout")
        return await real_request(self, method, url, *args, **kwargs)

    monkeypatch.setattr(httpx.AsyncClient, "request", _spy_request)

    run(mcp_adapter.dense_evolution_health())
    run(mcp_adapter.dense_evolution_run_vqe(mcp_models.RunVqeInput(
        name="H2", ansatz_type="hardware_efficient", n_layers=1, maxiter=1,
    )))

    assert seen_timeouts["/api/health"] == 5.0
    assert seen_timeouts["/api/vqe"] == 600.0
    assert seen_timeouts["/api/health"] != seen_timeouts["/api/vqe"]