simudyne-pulse 0.7.0.dev6__tar.gz → 0.7.0.dev7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {simudyne_pulse-0.7.0.dev6/src/simudyne_pulse.egg-info → simudyne_pulse-0.7.0.dev7}/PKG-INFO +1 -1
  2. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/pyproject.toml +1 -1
  3. simudyne_pulse-0.7.0.dev7/src/simudyne/resources/validation.py +346 -0
  4. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7/src/simudyne_pulse.egg-info}/PKG-INFO +1 -1
  5. simudyne_pulse-0.7.0.dev7/tests/test_validation.py +230 -0
  6. simudyne_pulse-0.7.0.dev6/src/simudyne/resources/validation.py +0 -822
  7. simudyne_pulse-0.7.0.dev6/tests/test_validation.py +0 -268
  8. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/LICENSE +0 -0
  9. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/README.md +0 -0
  10. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/setup.cfg +0 -0
  11. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/src/simudyne/__init__.py +0 -0
  12. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/src/simudyne/client.py +0 -0
  13. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/src/simudyne/exceptions.py +0 -0
  14. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/src/simudyne/resources/__init__.py +0 -0
  15. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/src/simudyne/resources/api_keys.py +0 -0
  16. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/src/simudyne/resources/data.py +0 -0
  17. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/src/simudyne/resources/fix.py +0 -0
  18. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/src/simudyne/resources/fm.py +0 -0
  19. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/src/simudyne/resources/historical.py +0 -0
  20. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/src/simudyne/resources/profile.py +0 -0
  21. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/src/simudyne/resources/simulation.py +0 -0
  22. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/src/simudyne/resources/simulator_gym.py +0 -0
  23. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/src/simudyne_pulse.egg-info/SOURCES.txt +0 -0
  24. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/src/simudyne_pulse.egg-info/dependency_links.txt +0 -0
  25. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/src/simudyne_pulse.egg-info/requires.txt +0 -0
  26. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/src/simudyne_pulse.egg-info/top_level.txt +0 -0
  27. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/tests/test_new_resources.py +0 -0
  28. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/tests/test_simulator_gym.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simudyne-pulse
3
- Version: 0.7.0.dev6
3
+ Version: 0.7.0.dev7
4
4
  Summary: Python SDK for the Simudyne Pulse synthetic market data API
5
5
  Author-email: Simudyne <support@simudyne.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "simudyne-pulse"
7
- version = "0.7.0-dev.6"
7
+ version = "0.7.0-dev.7"
8
8
  description = "Python SDK for the Simudyne Pulse synthetic market data API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -0,0 +1,346 @@
1
+ """
2
+ Validation Resource for the Pulse SDK.
3
+
4
+ Compares simulated LOB data against a historical day using distributional
5
+ metrics, impact response, Cont's stylised facts, cross-level volume
6
+ correlation, and the MIND/FID inception distances on DeepLOB embeddings.
7
+
8
+ Submit, then poll::
9
+
10
+ job = client.validation.run(
11
+ symbol="BARC", date="2026-06-17", provider="bmll", exchange="lse",
12
+ sim_ids=[...], ticksize=0.05,
13
+ )
14
+ result = client.validation.get_job(job["job_id"])
15
+ result["distances"]["spread"]["l1"] # per simulation
16
+ result["stylised_fact_verdicts"]["heavy_tails"]
17
+
18
+ ``run`` submits and returns; a validation of a real day takes minutes, so
19
+ ``get_job`` is the status endpoint you call until it is done. ``list_jobs``
20
+ reaches past runs.
21
+
22
+ Selecting metrics
23
+ -----------------
24
+ One flag per metric — ``statistical``, ``stylised_facts``, ``impact``,
25
+ ``volume_correlation``, ``fid``, ``mind``. Left as ``None`` each takes the
26
+ default for your tier, resolved server-side, so naming none behaves exactly as
27
+ your account is entitled to. Pass ``False`` to skip an expensive pass or
28
+ ``True`` to force one on.
29
+
30
+ The historical gate
31
+ -------------------
32
+ Whether the historical half of the comparison comes back — the series in the
33
+ JSON *and* the historical trace on any rendered plot — is decided by your tier,
34
+ not by a parameter here. What is never gated: the L1 and Wasserstein
35
+ ``distances``, the FID and MIND scores, and the true/false
36
+ ``stylised_fact_verdicts`` for both the historical day and each simulation.
37
+ Those are derived scalars and booleans, not series, so every tier receives
38
+ them.
39
+ """
40
+
41
+ import json
42
+ from pathlib import Path
43
+
44
+
45
+ RUN_PATH = "/validation/run"
46
+ UPLOAD_PATH = "/validation/run/upload"
47
+ JOBS_PATH = "/validation/jobs"
48
+
49
+ #: The API rejects more per job; checked client-side so a 26-file submission
50
+ #: fails before any bytes are uploaded.
51
+ MAX_SIM_FILES = 25
52
+
53
+ #: One flag per area of checking. Left unset each takes the server's default.
54
+ _AREA_FLAGS = (
55
+ "statistical",
56
+ "stylised_facts",
57
+ "impact",
58
+ "volume_correlation",
59
+ "fid",
60
+ "mind",
61
+ )
62
+
63
+ #: ``lob`` marks the frames as L2 snapshots, which switches off anything
64
+ #: needing the message stream; ``sample_period`` and ``match_generated_sample``
65
+ #: set the grid the book is resampled onto; ``plots`` is False, True, or a list
66
+ #: of plot ids.
67
+ _EXTRA_FIELDS = ("lob", "sample_period", "match_generated_sample", "plots")
68
+
69
+
70
+ def _frame_to_parquet(entry, index: int):
71
+ """Normalise one simulated run to ``(filename, parquet bytes)``.
72
+
73
+ Accepts a path, a ``(filename, bytes)`` pair, or a polars / pandas
74
+ DataFrame, which is written to parquet in memory. The frame must be pulse
75
+ format -- the same shape the engine writes to ``sim_data.parquet`` -- which
76
+ the server validates; sending something else fails there, not here.
77
+ """
78
+ import io
79
+
80
+ if isinstance(entry, tuple):
81
+ return entry
82
+
83
+ # Duck-typed rather than imported: neither polars nor pandas is a hard
84
+ # dependency of the SDK, and importing one to test for the other would
85
+ # make it one.
86
+ writer = getattr(entry, "write_parquet", None) # polars
87
+ if writer is not None:
88
+ buf = io.BytesIO()
89
+ writer(buf)
90
+ return f"sim_{index}.parquet", buf.getvalue()
91
+
92
+ writer = getattr(entry, "to_parquet", None) # pandas
93
+ if writer is not None:
94
+ buf = io.BytesIO()
95
+ writer(buf, index=False)
96
+ return f"sim_{index}.parquet", buf.getvalue()
97
+
98
+ path = Path(entry)
99
+ return path.name, path.read_bytes()
100
+
101
+
102
+ def _build_config(n_levels, **flags) -> dict:
103
+ """The validation config as the API expects it.
104
+
105
+ Flags left as ``None`` are omitted rather than sent as null: the API reads
106
+ absence as "use my tier's default", and an explicit null would not do that.
107
+ """
108
+ config = {"n_levels": n_levels}
109
+ for name in _AREA_FLAGS + _EXTRA_FIELDS:
110
+ value = flags.get(name)
111
+ if value is not None:
112
+ config[name] = value
113
+
114
+ unknown = set(flags) - set(_AREA_FLAGS) - set(_EXTRA_FIELDS)
115
+ if unknown:
116
+ raise ValueError(
117
+ f"Unknown validation option(s): {sorted(unknown)}. "
118
+ f"Valid: {sorted(_AREA_FLAGS + _EXTRA_FIELDS)}"
119
+ )
120
+ return config
121
+
122
+
123
+ def _save_plots(result: dict, plot_dir=None) -> list:
124
+ """Write the returned figures to disk and return the paths written.
125
+
126
+ The API sends rendered plots base64-encoded in the response. Left to
127
+ itself that is bytes you cannot look at, so when plots were asked for they
128
+ are written out: to ``plot_dir`` when given, otherwise the current
129
+ directory. The directory is created if it does not exist.
130
+ """
131
+ import base64
132
+
133
+ target = Path(plot_dir) if plot_dir is not None else Path.cwd()
134
+ target.mkdir(parents=True, exist_ok=True)
135
+
136
+ written = []
137
+ for category, entries in (result.get("plots") or {}).items():
138
+ for entry in entries or []:
139
+ content = entry.get("content_base64")
140
+ if not content:
141
+ continue
142
+ path = target / f"{entry.get('name') or category}.png"
143
+ path.write_bytes(base64.b64decode(content))
144
+ written.append(str(path))
145
+ return written
146
+
147
+
148
+ class ValidationResource:
149
+ """Validate simulated market data against a historical day."""
150
+
151
+ def __init__(self, client):
152
+ self._client = client
153
+
154
+ def run(
155
+ self,
156
+ symbol: str,
157
+ date: str,
158
+ provider: str,
159
+ exchange: str,
160
+ *,
161
+ sim_ids=None,
162
+ sim_files=None,
163
+ ticksize: float = 1.0,
164
+ statistical=None,
165
+ stylised_facts=None,
166
+ impact=None,
167
+ volume_correlation=None,
168
+ fid=None,
169
+ mind=None,
170
+ lob=None,
171
+ sample_period=None,
172
+ match_generated_sample=None,
173
+ n_levels: int = 10,
174
+ plots=None,
175
+ ) -> dict:
176
+ """Submit a validation job.
177
+
178
+ Returns as soon as the job is accepted. Poll :meth:`get_job` for
179
+ status and, once it is complete, the result — a validation of a real
180
+ day takes minutes, which is far too long to hold a request open.
181
+
182
+ Parameters
183
+ ----------
184
+ symbol : str
185
+ Trading symbol, e.g. ``"BARC"`` or ``"700.HK"``.
186
+ date : str
187
+ Calibration date, ``"YYYY-MM-DD"``. The historical day compared
188
+ against.
189
+ provider : str
190
+ Data provider, ``"bmll"`` or ``"omd"``. Required: the same symbol
191
+ exists under both with different dates and tick sizes, so
192
+ ``(symbol, provider, exchange)`` is the identity, not the symbol.
193
+ exchange : str
194
+ Exchange, e.g. ``"lse"`` or ``"hkex_securities"``.
195
+ sim_ids : list of str, optional
196
+ Platform simulation IDs, 1 to 25. Exactly one of ``sim_ids`` or
197
+ ``sim_files`` is required.
198
+ sim_files : list, optional
199
+ Your own simulated runs, up to 25: paths, ``(filename, bytes)``
200
+ pairs, or polars / pandas DataFrames in pulse format. The
201
+ historical side is fetched for you.
202
+ ticksize : float, default 1.0
203
+ Minimum price increment. Should match the instrument.
204
+ statistical, stylised_facts, impact, volume_correlation, fid, mind : bool, optional
205
+ Whether to run each area. ``None`` uses your tier's default.
206
+ lob : bool, optional
207
+ The frames are L2 snapshots. Anything needing the message stream
208
+ is skipped, with the reason reported in
209
+ ``metadata["areas_skipped"]``.
210
+ sample_period : str, optional
211
+ The grid the book is resampled onto in lob mode, e.g. ``"1s"``.
212
+ Omitted, the cadence is read off your generated frames; if those
213
+ are event-level there is no grid to match and nothing is
214
+ resampled.
215
+ match_generated_sample : bool, optional
216
+ Match the generated frames' grid even when ``sample_period`` is
217
+ given.
218
+ n_levels : int, default 10
219
+ Book levels to measure over.
220
+ plots : bool or list of str, optional
221
+ ``None`` or ``False`` for none, ``True`` for every figure, or plot
222
+ ids such as ``["statistical.radar", "stylised_facts.overall"]``.
223
+ Rendered server-side; :meth:`get_job` writes them to disk.
224
+
225
+ Returns
226
+ -------
227
+ dict
228
+ ``{"job_id": str, "status": str, "message": str}``. Pass the
229
+ job_id to :meth:`get_job`.
230
+
231
+ Raises
232
+ ------
233
+ ValueError
234
+ If the parameters are invalid.
235
+ """
236
+ if (sim_ids is None) == (sim_files is None):
237
+ raise ValueError("Pass exactly one of sim_ids or sim_files")
238
+ if sim_ids is not None and not sim_ids:
239
+ raise ValueError("sim_ids must not be empty")
240
+ if sim_files is not None and not sim_files:
241
+ raise ValueError("sim_files must not be empty")
242
+ count = len(sim_ids if sim_ids is not None else sim_files)
243
+ if count > MAX_SIM_FILES:
244
+ raise ValueError(f"Maximum {MAX_SIM_FILES} simulations per validation job")
245
+ if ticksize <= 0:
246
+ raise ValueError("ticksize must be positive")
247
+ if n_levels < 1:
248
+ raise ValueError("n_levels must be at least 1")
249
+
250
+ config = _build_config(
251
+ n_levels,
252
+ statistical=statistical,
253
+ stylised_facts=stylised_facts,
254
+ impact=impact,
255
+ volume_correlation=volume_correlation,
256
+ fid=fid,
257
+ mind=mind,
258
+ lob=lob,
259
+ sample_period=sample_period,
260
+ match_generated_sample=match_generated_sample,
261
+ plots=plots,
262
+ )
263
+
264
+ if sim_ids is not None:
265
+ submitted = self._client._request(
266
+ "POST",
267
+ RUN_PATH,
268
+ json={
269
+ "symbol": symbol,
270
+ "date": date,
271
+ "provider": provider,
272
+ "exchange": exchange,
273
+ "sim_ids": list(sim_ids),
274
+ "ticksize": ticksize,
275
+ "config": config,
276
+ },
277
+ )
278
+ else:
279
+ files = [
280
+ ("sim_files", _frame_to_parquet(entry, i))
281
+ for i, entry in enumerate(sim_files)
282
+ ]
283
+ submitted = self._client._request(
284
+ "POST",
285
+ UPLOAD_PATH,
286
+ files=files,
287
+ data={
288
+ "symbol": symbol,
289
+ "date": date,
290
+ "provider": provider,
291
+ "exchange": exchange,
292
+ "ticksize": str(ticksize),
293
+ "config": json.dumps(config),
294
+ },
295
+ )
296
+
297
+ if not (submitted or {}).get("job_id"):
298
+ raise ValueError(f"Validation API returned no job_id: {submitted}")
299
+ return submitted
300
+
301
+
302
+ def get_job(self, job_id: str, plot_dir=None) -> dict:
303
+ """Fetch a validation job's status, and its result once it is done.
304
+
305
+ This is the status endpoint: call it until ``status`` is
306
+ ``"completed"`` or ``"failed"``.
307
+
308
+ Parameters
309
+ ----------
310
+ job_id : str
311
+ From :meth:`run`.
312
+ plot_dir : str or Path, optional
313
+ Where to write any figures the job rendered. Defaults to the
314
+ current directory; created if it does not exist. Only has an
315
+ effect once the job is complete and only if ``plots`` was set on
316
+ the run. The paths written come back in ``plot_paths``.
317
+
318
+ Returns
319
+ -------
320
+ dict
321
+ ``job_id``, ``status``, ``symbol``, ``date`` and, once complete:
322
+ ``metadata``, ``distances``, ``distributions``,
323
+ ``impact_response``, ``stylised_facts``,
324
+ ``stylised_fact_verdicts``, ``volume_correlation``,
325
+ ``fid_scores``, ``mind_scores`` — enough to rebuild every figure —
326
+ plus ``plot_paths`` when figures were written.
327
+ """
328
+ job = self._client._request("GET", f"{JOBS_PATH}/{job_id}")
329
+ if (job or {}).get("plots"):
330
+ job["plot_paths"] = _save_plots(job, plot_dir)
331
+ return job
332
+
333
+ def list_jobs(self, limit: int = 50) -> dict:
334
+ """List your validation jobs, newest first.
335
+
336
+ Parameters
337
+ ----------
338
+ limit : int, default 50
339
+ Maximum number of jobs to return.
340
+
341
+ Returns
342
+ -------
343
+ dict
344
+ ``{"jobs": list, "total": int}``.
345
+ """
346
+ return self._client._request("GET", JOBS_PATH, params={"limit": limit})
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simudyne-pulse
3
- Version: 0.7.0.dev6
3
+ Version: 0.7.0.dev7
4
4
  Summary: Python SDK for the Simudyne Pulse synthetic market data API
5
5
  Author-email: Simudyne <support@simudyne.com>
6
6
  License-Expression: MIT
@@ -0,0 +1,230 @@
1
+ """Tests for the validation resource.
2
+
3
+ No network: the client's ``_request`` is replaced with a recorder, so these
4
+ assert the exact payload the API receives and the exact result handed back.
5
+
6
+ The surface is one call — ``run`` submits, waits and returns the finished
7
+ result — so most of what is worth pinning is what it sends, what it refuses to
8
+ send, and what it does with the response.
9
+ """
10
+
11
+ import base64
12
+ import json
13
+
14
+ import pytest
15
+
16
+ from simudyne.resources.validation import MAX_SIM_FILES, ValidationResource
17
+
18
+
19
+ class FakeClient:
20
+ """Records calls and replays queued responses."""
21
+
22
+ def __init__(self, responses=None):
23
+ self.calls = []
24
+ self._responses = list(responses or [])
25
+
26
+ def _request(self, method, path, **kwargs):
27
+ self.calls.append((method, path, kwargs))
28
+ return self._responses.pop(0) if self._responses else {}
29
+
30
+
31
+ SIM_IDS = ["omd:hkex_securities:700.HK:2025-12-22:abc:normal:baseline:0000"]
32
+ IDENTITY = dict(symbol="700.HK", date="2025-12-22", provider="omd",
33
+ exchange="hkex_securities")
34
+
35
+ PNG = base64.b64encode(b"\x89PNG\r\n\x1a\nfake").decode()
36
+
37
+
38
+ def _run(responses, **kwargs):
39
+ client = FakeClient(responses)
40
+ result = ValidationResource(client).run(
41
+ sim_ids=SIM_IDS, **{**IDENTITY, **kwargs}
42
+ )
43
+ return client, result
44
+
45
+
46
+ def _done(**extra):
47
+ return {"job_id": "v1", "status": "completed", **extra}
48
+
49
+
50
+ class TestIdentityIsRequired:
51
+ """The same symbol exists under two providers with different tick sizes,
52
+ so the symbol alone never identified an instrument."""
53
+
54
+ @pytest.mark.parametrize("missing", ["symbol", "date", "provider", "exchange"])
55
+ def test_every_identity_field_is_required(self, missing):
56
+ kwargs = {k: v for k, v in IDENTITY.items() if k != missing}
57
+ with pytest.raises(TypeError):
58
+ ValidationResource(FakeClient()).run(sim_ids=SIM_IDS, **kwargs)
59
+
60
+
61
+ class TestWhatToValidate:
62
+ def test_sim_ids_go_as_json(self):
63
+ client, _ = _run([{"job_id": "v1"}])
64
+ method, path, kwargs = client.calls[0]
65
+ assert (method, path) == ("POST", "/validation/run")
66
+ assert kwargs["json"]["sim_ids"] == SIM_IDS
67
+ assert kwargs["json"]["provider"] == "omd"
68
+
69
+ def test_sim_files_go_as_multipart_to_the_upload_route(self):
70
+ client = FakeClient([{"job_id": "v1"}])
71
+ ValidationResource(client).run(
72
+ sim_files=[("a.parquet", b"x"), ("b.parquet", b"y")], **IDENTITY
73
+ )
74
+ method, path, kwargs = client.calls[0]
75
+ assert (method, path) == ("POST", "/validation/run/upload")
76
+ assert len(kwargs["files"]) == 2
77
+ assert json.loads(kwargs["data"]["config"])["n_levels"] == 10
78
+
79
+ @pytest.mark.parametrize(
80
+ "kwargs,match",
81
+ [
82
+ ({}, "exactly one"),
83
+ ({"sim_ids": SIM_IDS, "sim_files": [("a", b"x")]}, "exactly one"),
84
+ ({"sim_ids": []}, "must not be empty"),
85
+ ({"sim_ids": SIM_IDS * (MAX_SIM_FILES + 1)}, "Maximum 25"),
86
+ ({"sim_ids": SIM_IDS, "ticksize": 0}, "ticksize must be positive"),
87
+ ({"sim_ids": SIM_IDS, "n_levels": 0}, "at least 1"),
88
+ ],
89
+ )
90
+ def test_bad_input_is_refused_before_any_request(self, kwargs, match):
91
+ client = FakeClient()
92
+ with pytest.raises(ValueError, match=match):
93
+ ValidationResource(client).run(**{**IDENTITY, **kwargs})
94
+ assert client.calls == []
95
+
96
+
97
+ class TestMetricFlags:
98
+ def test_unset_flags_are_omitted_so_the_tier_default_applies(self):
99
+ client, _ = _run([{"job_id": "v1"}])
100
+ config = client.calls[0][2]["json"]["config"]
101
+ # Sending these as null would override the server-side tier default.
102
+ for flag in ("statistical", "stylised_facts", "impact",
103
+ "volume_correlation", "fid", "mind"):
104
+ assert flag not in config, f"{flag} must be omitted when unset"
105
+ assert config == {"n_levels": 10}
106
+
107
+ @pytest.mark.parametrize(
108
+ "flag,value",
109
+ [("statistical", False), ("stylised_facts", True), ("impact", False),
110
+ ("volume_correlation", True), ("fid", False), ("mind", True),
111
+ ("lob", True), ("sample_period", "100ms"),
112
+ ("match_generated_sample", True),
113
+ ("plots", ["statistical.radar"])],
114
+ )
115
+ def test_explicit_options_are_forwarded(self, flag, value):
116
+ client, _ = _run([{"job_id": "v1"}], **{flag: value})
117
+ assert client.calls[0][2]["json"]["config"][flag] == value
118
+
119
+ def test_the_legacy_aliases_are_gone(self):
120
+ """run_* and l2_only/plot_data were two names for one thing."""
121
+ for dead in ("run_metrics", "run_impact", "run_stylised_facts",
122
+ "run_inception_distances", "l2_only", "plot_data",
123
+ "historical_output"):
124
+ with pytest.raises((TypeError, ValueError)):
125
+ ValidationResource(FakeClient()).run(
126
+ sim_ids=SIM_IDS, **IDENTITY, **{dead: True}
127
+ )
128
+
129
+
130
+ class TestSubmitOnly:
131
+ """run submits and returns. A real day takes minutes, which is far too
132
+ long to hold a request open, so status is get_job's job."""
133
+
134
+ def test_it_returns_the_submission_without_waiting(self):
135
+ client, result = _run([{"job_id": "v1", "status": "pending"}])
136
+ assert result == {"job_id": "v1", "status": "pending"}
137
+ assert [c[0] for c in client.calls] == ["POST"]
138
+
139
+ def test_a_missing_job_id_is_an_error(self):
140
+ with pytest.raises(ValueError, match="no job_id"):
141
+ _run([{"detail": "nope"}])
142
+
143
+ def test_it_does_not_poll(self):
144
+ assert not hasattr(ValidationResource, "_await")
145
+ import inspect
146
+
147
+ params = inspect.signature(ValidationResource.run).parameters
148
+ assert "poll_interval" not in params
149
+ assert "timeout" not in params
150
+
151
+
152
+ class TestPlots:
153
+ """Figures arrive with the result, so get_job is what writes them."""
154
+
155
+ @staticmethod
156
+ def _fetch(responses, **kwargs):
157
+ client = FakeClient(responses)
158
+ return ValidationResource(client).get_job("v1", **kwargs)
159
+
160
+ def test_plots_requested_on_the_run_go_in_the_config(self):
161
+ client, _ = _run([{"job_id": "v1"}], plots=["statistical.radar"])
162
+ assert client.calls[0][2]["json"]["config"]["plots"] == ["statistical.radar"]
163
+
164
+ def test_nothing_is_written_when_the_job_has_no_plots(self, tmp_path):
165
+ job = self._fetch([_done()], plot_dir=str(tmp_path))
166
+ assert "plot_paths" not in job
167
+ assert list(tmp_path.iterdir()) == []
168
+
169
+ def test_plots_are_written_to_plot_dir(self, tmp_path):
170
+ target = tmp_path / "figs"
171
+ job = self._fetch(
172
+ [_done(plots={"distances": [{"name": "l1", "content_base64": PNG}]})],
173
+ plot_dir=str(target),
174
+ )
175
+ assert job["plot_paths"] == [str(target / "l1.png")]
176
+ assert (target / "l1.png").read_bytes().startswith(b"\x89PNG")
177
+
178
+ def test_plot_dir_is_created_if_absent(self, tmp_path):
179
+ target = tmp_path / "a" / "b"
180
+ self._fetch(
181
+ [_done(plots={"distances": [{"name": "l1", "content_base64": PNG}]})],
182
+ plot_dir=str(target),
183
+ )
184
+ assert (target / "l1.png").is_file()
185
+
186
+ def test_without_plot_dir_they_land_in_the_current_directory(self, tmp_path, monkeypatch):
187
+ monkeypatch.chdir(tmp_path)
188
+ job = self._fetch(
189
+ [_done(plots={"distances": [{"name": "radar", "content_base64": PNG}]})]
190
+ )
191
+ assert job["plot_paths"] == [str(tmp_path / "radar.png")]
192
+ assert (tmp_path / "radar.png").is_file()
193
+
194
+
195
+ class TestOneEntryPoint:
196
+ def test_the_extra_run_methods_are_gone(self):
197
+ for dead in ("run_pipeline", "run_upload", "display_plots",
198
+ "inception_distances"):
199
+ assert not hasattr(ValidationResource, dead)
200
+
201
+ def test_retrieval_still_works(self):
202
+ client = FakeClient([{"job_id": "v1"}, {"jobs": [], "total": 0}])
203
+ res = ValidationResource(client)
204
+ res.get_job("v1")
205
+ res.list_jobs(limit=5)
206
+ assert client.calls[0][:2] == ("GET", "/validation/jobs/v1")
207
+ assert client.calls[1][2]["params"] == {"limit": 5}
208
+
209
+
210
+ class TestSimulatedFrameShapes:
211
+ """sim_files takes a path, a (name, bytes) pair, or a DataFrame."""
212
+
213
+ def _files_sent(self, entries):
214
+ client = FakeClient([{"job_id": "v1"}])
215
+ ValidationResource(client).run(sim_files=entries, **IDENTITY)
216
+ return client.calls[0][2]["files"]
217
+
218
+ def test_pair(self):
219
+ assert self._files_sent([("a.parquet", b"x")])[0][1] == ("a.parquet", b"x")
220
+
221
+ def test_path(self, tmp_path):
222
+ p = tmp_path / "sim.parquet"
223
+ p.write_bytes(b"data")
224
+ assert self._files_sent([str(p)])[0][1] == ("sim.parquet", b"data")
225
+
226
+ def test_each_frame_gets_its_own_name(self):
227
+ pd = pytest.importorskip("pandas")
228
+ frames = [pd.DataFrame({"a": [1]}), pd.DataFrame({"a": [2]})]
229
+ names = [f[1][0] for f in self._files_sent(frames)]
230
+ assert names == ["sim_0.parquet", "sim_1.parquet"]