intelloptim 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.
Files changed (69) hide show
  1. intelloptim/__init__.py +51 -0
  2. intelloptim/_internal/__init__.py +19 -0
  3. intelloptim/_internal/audit.py +349 -0
  4. intelloptim/_internal/hashing.py +255 -0
  5. intelloptim/_internal/logging.py +352 -0
  6. intelloptim/_internal/schemas.py +300 -0
  7. intelloptim/algorithms/__init__.py +22 -0
  8. intelloptim/algorithms/backend/__init__.py +19 -0
  9. intelloptim/algorithms/backend/builtin/__init__.py +25 -0
  10. intelloptim/algorithms/backend/builtin/_common.py +41 -0
  11. intelloptim/algorithms/backend/builtin/owg.py +454 -0
  12. intelloptim/algorithms/backend/builtin/sa.py +317 -0
  13. intelloptim/algorithms/backend/quantum/__init__.py +31 -0
  14. intelloptim/algorithms/backend/quantum/commons/__init__.py +0 -0
  15. intelloptim/algorithms/backend/quantum/commons/base.py +348 -0
  16. intelloptim/algorithms/backend/quantum/commons/config.py +115 -0
  17. intelloptim/algorithms/backend/quantum/commons/distributions.py +319 -0
  18. intelloptim/algorithms/backend/quantum/commons/executor.py +149 -0
  19. intelloptim/algorithms/backend/quantum/commons/learner.py +225 -0
  20. intelloptim/algorithms/backend/quantum/commons/measurement.py +86 -0
  21. intelloptim/algorithms/backend/quantum/commons/policy.py +248 -0
  22. intelloptim/algorithms/backend/quantum/commons/ppo.py +135 -0
  23. intelloptim/algorithms/backend/quantum/commons/resources.py +60 -0
  24. intelloptim/algorithms/backend/quantum/commons/serialization.py +25 -0
  25. intelloptim/algorithms/backend/quantum/commons/snapshot.py +104 -0
  26. intelloptim/algorithms/backend/quantum/commons/space.py +230 -0
  27. intelloptim/algorithms/backend/quantum/gaus.py +47 -0
  28. intelloptim/algorithms/backend/quantum/mixture.py +49 -0
  29. intelloptim/algorithms/backend/quantum/qiskit/__init__.py +15 -0
  30. intelloptim/algorithms/backend/quantum/qiskit/circuit.py +33 -0
  31. intelloptim/algorithms/backend/quantum/qiskit/ibm_runtime.py +88 -0
  32. intelloptim/algorithms/backend/quantum/qiskit/simulator.py +62 -0
  33. intelloptim/algorithms/errors.py +33 -0
  34. intelloptim/algorithms/protocols.py +71 -0
  35. intelloptim/algorithms/pruners.py +88 -0
  36. intelloptim/algorithms/samplers.py +235 -0
  37. intelloptim/algorithms/space.py +53 -0
  38. intelloptim/client/__init__.py +30 -0
  39. intelloptim/client/api.py +364 -0
  40. intelloptim/client/auth.py +88 -0
  41. intelloptim/client/errors.py +170 -0
  42. intelloptim/client/study.py +388 -0
  43. intelloptim/client/trial.py +250 -0
  44. intelloptim/core/__init__.py +27 -0
  45. intelloptim/core/objective.py +146 -0
  46. intelloptim/core/parameters.py +301 -0
  47. intelloptim/core/pruners/__init__.py +63 -0
  48. intelloptim/core/pruners/base.py +56 -0
  49. intelloptim/core/pruners/optuna.py +176 -0
  50. intelloptim/core/samplers/__init__.py +97 -0
  51. intelloptim/core/samplers/base.py +141 -0
  52. intelloptim/core/samplers/builtin.py +138 -0
  53. intelloptim/core/samplers/optuna.py +215 -0
  54. intelloptim/core/samplers/quantum.py +332 -0
  55. intelloptim/params.py +24 -0
  56. intelloptim/py.typed +0 -0
  57. intelloptim/version.py +10 -0
  58. intelloptim/worker/__init__.py +14 -0
  59. intelloptim/worker/cli.py +134 -0
  60. intelloptim/worker/evaluator_cli.py +223 -0
  61. intelloptim/worker/registry.py +130 -0
  62. intelloptim/worker/slurm.py +72 -0
  63. intelloptim-0.1.0.dist-info/METADATA +188 -0
  64. intelloptim-0.1.0.dist-info/RECORD +69 -0
  65. intelloptim-0.1.0.dist-info/WHEEL +5 -0
  66. intelloptim-0.1.0.dist-info/entry_points.txt +2 -0
  67. intelloptim-0.1.0.dist-info/licenses/LICENSE +201 -0
  68. intelloptim-0.1.0.dist-info/licenses/NOTICE +8 -0
  69. intelloptim-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,51 @@
1
+ """intelloptim — black-box optimization client, algorithms and wire protocol.
2
+
3
+ The recommended import alias is::
4
+
5
+ import intelloptim as intopt
6
+
7
+ Ready to use after the import:
8
+
9
+ - ``intopt.params``: the parameters that define a search space.
10
+ - ``intopt.samplers`` and ``intopt.pruners``: the specs that configure how a
11
+ study samples and prunes.
12
+ - ``intopt.objective``: decorator that registers an objective, optionally with
13
+ its study spec, so that ``intelloptim-worker`` can run it.
14
+
15
+ Loaded on first access:
16
+
17
+ - ``intopt.client``: the SDK that talks to a server (``Client``, ``Trial``,
18
+ ``TrialState``, the ``errors``).
19
+ - ``intopt.algorithms``: resolves specs to Optuna samplers and pruners. It is
20
+ meant for servers and sampler plugins, and running a study does not need it.
21
+ """
22
+
23
+ import importlib
24
+ from typing import TYPE_CHECKING, Any
25
+
26
+ from intelloptim.core import pruners, samplers
27
+ from intelloptim.core.objective import objective
28
+ from intelloptim.version import __version__
29
+
30
+ from . import params
31
+
32
+ if TYPE_CHECKING:
33
+ from intelloptim import client
34
+
35
+ _LAZY_SUBMODULES = frozenset({"algorithms", "client"})
36
+
37
+
38
+ def __getattr__(name: str) -> Any:
39
+ if name in _LAZY_SUBMODULES:
40
+ return importlib.import_module(f"{__name__}.{name}")
41
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
42
+
43
+
44
+ __all__ = [
45
+ "__version__",
46
+ "client",
47
+ "objective",
48
+ "params",
49
+ "pruners",
50
+ "samplers",
51
+ ]
@@ -0,0 +1,19 @@
1
+ """Internal modules shared by the client, the worker and the server.
2
+
3
+ Nothing here is a public API: names and behaviour can change between releases
4
+ without notice. Import these modules from their own path, for example
5
+ ``from intelloptim._internal.schemas import AskRequest``.
6
+
7
+ Submodules:
8
+
9
+ * :mod:`~intelloptim._internal.schemas` — request and response models of the
10
+ REST API, and the protocol version.
11
+ * :mod:`~intelloptim._internal.hashing` — canonical serialisation and the
12
+ hashes that give studies their identity.
13
+ * :mod:`~intelloptim._internal.logging` — logging configuration under the
14
+ ``intelloptim`` logger.
15
+ * :mod:`~intelloptim._internal.audit` — audit records of security-relevant
16
+ events.
17
+ """
18
+
19
+ __all__ = ["audit", "hashing", "logging", "schemas"]
@@ -0,0 +1,349 @@
1
+ """Audit events: a small, stable record of security-relevant actions.
2
+
3
+ Each function below records one kind of event (a login, a token change, a study
4
+ stopped or rebuilt) as a structured log record on the ``intelloptim.audit``
5
+ logger. Event and field names do not change between releases, so operators can
6
+ filter and alert on them.
7
+
8
+ Every record carries:
9
+
10
+ * ``event_category="audit"`` — tells audit records apart from operational ones.
11
+ * ``event`` — short, stable name such as ``"login.success"``,
12
+ ``"token.issued"`` or ``"study.stopped"``.
13
+ * ``outcome`` — ``"success"`` or ``"failure"``.
14
+ * ``actor`` — who acted: a user id, a token prefix, ``"system"`` for background
15
+ work or ``"anonymous"``.
16
+ * extra fields specific to the event, such as ``study_id`` or ``reason``.
17
+
18
+ Records are written to the main log stream, marked with ``event_category=audit``,
19
+ unless ``INTELLOPTIM_AUDIT_FILE`` sends them to a file of their own (see
20
+ :mod:`intelloptim._internal.logging`). Tokens are identified by their prefix
21
+ only, never in full.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ from typing import Any, Dict, Mapping, Optional
28
+
29
+ from intelloptim._internal.logging import ROOT_LOGGER_NAME, get_logger
30
+
31
+ _STANDARD_FIELDS = frozenset({"event_category", "event", "outcome", "actor"})
32
+
33
+ # Names ``extra=`` cannot use: the standard fields above and the attributes of a
34
+ # LogRecord (``logging`` raises ``KeyError`` on those, and ``message`` and
35
+ # ``asctime`` are set by the formatters).
36
+ _RESERVED_FIELDS = (
37
+ _STANDARD_FIELDS
38
+ | frozenset(logging.makeLogRecord({}).__dict__)
39
+ | {"message", "asctime"}
40
+ )
41
+
42
+
43
+ def get_audit_logger() -> logging.Logger:
44
+ """Return the ``intelloptim.audit`` logger, configuring logging if needed.
45
+
46
+ Returns:
47
+ logging.Logger: The logger all audit records are written to.
48
+ """
49
+ get_logger("audit")
50
+ return logging.getLogger(f"{ROOT_LOGGER_NAME}.audit")
51
+
52
+
53
+ def emit(
54
+ event: str,
55
+ *,
56
+ outcome: str,
57
+ actor: Optional[str] = None,
58
+ extra: Optional[Mapping[str, Any]] = None,
59
+ message: Optional[str] = None,
60
+ level: int = logging.INFO,
61
+ ) -> None:
62
+ """Write one audit record.
63
+
64
+ The functions below call this with the right event name and fields; call it
65
+ directly only for an event they do not cover.
66
+
67
+ Args:
68
+ event (str): Short stable name, for example ``"login.success"``. Dots
69
+ group related events.
70
+ outcome (str): ``"success"`` or ``"failure"``.
71
+ actor (Optional[str]): Who acted: a user id or a token prefix.
72
+ ``None`` means ``"anonymous"``.
73
+ extra (Optional[Mapping[str, Any]]): Additional structured fields, for
74
+ example ``{"study_id": "..."}``. They are merged into the record
75
+ and must not reuse the names of the standard fields or of
76
+ :class:`logging.LogRecord` attributes.
77
+ message (Optional[str]): Human-readable summary; defaults to *event*.
78
+ level (int): Log level. Use :data:`logging.WARNING` for failures and
79
+ suspicious activity.
80
+
81
+ Raises:
82
+ ValueError: If *extra* uses a reserved field name. Nothing is logged.
83
+ """
84
+ if extra:
85
+ clashes = sorted(_RESERVED_FIELDS & set(extra))
86
+ if clashes:
87
+ raise ValueError(
88
+ f"audit extra fields use reserved names: {', '.join(clashes)}"
89
+ )
90
+ payload: Dict[str, Any] = {
91
+ "event_category": "audit",
92
+ "event": event,
93
+ "outcome": outcome,
94
+ "actor": actor or "anonymous",
95
+ }
96
+ if extra:
97
+ payload.update(extra)
98
+ get_audit_logger().log(level, message or event, extra=payload)
99
+
100
+
101
+ def login_success(*, user_id: str, email: str) -> None:
102
+ """Record a successful login (event ``login.success``).
103
+
104
+ Args:
105
+ user_id (str): Id of the user who logged in; becomes the actor.
106
+ email (str): Email the user logged in with.
107
+ """
108
+ emit("login.success", outcome="success", actor=user_id, extra={"email": email})
109
+
110
+
111
+ def login_failure(*, email: str, reason: str) -> None:
112
+ """Record a failed login (event ``login.failure``, level ``WARNING``).
113
+
114
+ Args:
115
+ email (str): Email the attempt used. The actor is ``"anonymous"``, since
116
+ the user is not authenticated.
117
+ reason (str): Why the login failed, for example ``"invalid-credentials"``.
118
+ """
119
+ emit(
120
+ "login.failure",
121
+ outcome="failure",
122
+ actor="anonymous",
123
+ extra={"email": email, "reason": reason},
124
+ level=logging.WARNING,
125
+ )
126
+
127
+
128
+ def logout(*, user_id: str) -> None:
129
+ """Record a logout (event ``logout``).
130
+
131
+ Args:
132
+ user_id (str): Id of the user who logged out; becomes the actor.
133
+ """
134
+ emit("logout", outcome="success", actor=user_id)
135
+
136
+
137
+ def token_issued(*, user_id: str, token_prefix: str, name: str) -> None:
138
+ """Record the creation of an API token (event ``token.issued``).
139
+
140
+ Args:
141
+ user_id (str): Id of the user who owns the token; becomes the actor.
142
+ token_prefix (str): First characters of the token, enough to identify it
143
+ in a log. Never pass the full token.
144
+ name (str): Label the user gave the token.
145
+ """
146
+ emit(
147
+ "token.issued",
148
+ outcome="success",
149
+ actor=user_id,
150
+ extra={"token_prefix": token_prefix, "token_name": name},
151
+ )
152
+
153
+
154
+ def token_revoked(*, user_id: str, token_prefix: str) -> None:
155
+ """Record that an API token was revoked (event ``token.revoked``).
156
+
157
+ Args:
158
+ user_id (str): Id of the user who owns the token; becomes the actor.
159
+ token_prefix (str): Prefix identifying the token. Never pass the full
160
+ token.
161
+ """
162
+ emit(
163
+ "token.revoked",
164
+ outcome="success",
165
+ actor=user_id,
166
+ extra={"token_prefix": token_prefix},
167
+ )
168
+
169
+
170
+ def token_deleted(*, user_id: str, token_prefix: str) -> None:
171
+ """Record that an API token was deleted (event ``token.deleted``).
172
+
173
+ Args:
174
+ user_id (str): Id of the user who owns the token; becomes the actor.
175
+ token_prefix (str): Prefix identifying the token. Never pass the full
176
+ token.
177
+ """
178
+ emit(
179
+ "token.deleted",
180
+ outcome="success",
181
+ actor=user_id,
182
+ extra={"token_prefix": token_prefix},
183
+ )
184
+
185
+
186
+ def token_authentication_failure(*, prefix: str, reason: str) -> None:
187
+ """Record a request rejected for its token (event ``token.auth_failed``).
188
+
189
+ The record has level ``WARNING``.
190
+
191
+ Args:
192
+ prefix (str): Prefix of the token that was presented. The actor is
193
+ ``"prefix:<prefix>"``, or ``"anonymous"`` if the prefix is empty.
194
+ reason (str): Why authentication failed, for example ``"invalid-or-revoked"``.
195
+ """
196
+ emit(
197
+ "token.auth_failed",
198
+ outcome="failure",
199
+ actor=f"prefix:{prefix}" if prefix else "anonymous",
200
+ extra={"reason": reason},
201
+ level=logging.WARNING,
202
+ )
203
+
204
+
205
+ def study_created(*, study_id: str, user_id: str, name: str) -> None:
206
+ """Record the creation of a study (event ``study.created``).
207
+
208
+ Args:
209
+ study_id (str): Id of the new study.
210
+ user_id (str): Id of the user who created it; becomes the actor.
211
+ name (str): Study name, stored in the record as ``study_name``.
212
+ """
213
+ emit(
214
+ "study.created",
215
+ outcome="success",
216
+ actor=user_id,
217
+ extra={"study_id": study_id, "study_name": name},
218
+ )
219
+
220
+
221
+ def study_stopped(*, study_id: str, user_id: str) -> None:
222
+ """Record that a study was stopped (event ``study.stopped``).
223
+
224
+ Args:
225
+ study_id (str): Id of the stopped study.
226
+ user_id (str): Id of the user who stopped it; becomes the actor.
227
+ """
228
+ emit(
229
+ "study.stopped",
230
+ outcome="success",
231
+ actor=user_id,
232
+ extra={"study_id": study_id},
233
+ )
234
+
235
+
236
+ def trial_enqueued(*, study_id: str, user_id: str, trial_number: int) -> None:
237
+ """Record a trial injected with a known result (event ``trial.enqueued``).
238
+
239
+ Only trials added together with an objective value are recorded: they enter
240
+ the study as completed and shape what the sampler learns. The value and the
241
+ parameters are not part of the record.
242
+
243
+ Args:
244
+ study_id (str): Id of the study that received the trial.
245
+ user_id (str): Id of the user who injected it; becomes the actor.
246
+ trial_number (int): Zero-based position of the new trial in the study.
247
+ """
248
+ emit(
249
+ "trial.enqueued",
250
+ outcome="success",
251
+ actor=user_id,
252
+ extra={"study_id": study_id, "trial_number": trial_number},
253
+ )
254
+
255
+
256
+ def quota_exhausted(*, user_id: str, calls_per_month: int, refused: int = 1) -> None:
257
+ """Record calls refused because the monthly quota is used up.
258
+
259
+ The event is ``quota.exhausted``, with outcome ``failure`` and level
260
+ ``WARNING``. A client that keeps retrying makes many refusals, so the caller
261
+ may log one record for several of them and report their number in *refused*.
262
+
263
+ Args:
264
+ user_id (str): Id of the user whose quota is exhausted; becomes the
265
+ actor.
266
+ calls_per_month (int): The user's effective monthly limit.
267
+ refused (int): Number of refused calls this record stands for.
268
+ """
269
+ emit(
270
+ "quota.exhausted",
271
+ outcome="failure",
272
+ actor=user_id,
273
+ extra={"calls_per_month": calls_per_month, "refused": refused},
274
+ level=logging.WARNING,
275
+ )
276
+
277
+
278
+ def study_access_denied(*, study_id: str, user_id: str, action: str) -> None:
279
+ """Record a request for a study that belongs to someone else.
280
+
281
+ The event is ``study.access_denied``, with outcome ``failure`` and level
282
+ ``WARNING``. The caller answers 404 as if the study did not exist, so this
283
+ record is the only trace that the study does exist.
284
+
285
+ Args:
286
+ study_id (str): Id of the study that was requested.
287
+ user_id (str): Id of the user who asked; becomes the actor.
288
+ action (str): What was attempted: ``"read"``, ``"stop"``, ``"delete"``
289
+ or ``"rebuild"``.
290
+ """
291
+ emit(
292
+ "study.access_denied",
293
+ outcome="failure",
294
+ actor=user_id,
295
+ extra={"study_id": study_id, "action": action},
296
+ level=logging.WARNING,
297
+ )
298
+
299
+
300
+ def study_rebuild_started(*, study_id: str, user_id: Optional[str] = None) -> None:
301
+ """Record the start of a sampler rebuild (event ``study.rebuild.started``).
302
+
303
+ Args:
304
+ study_id (str): Id of the study being rebuilt.
305
+ user_id (Optional[str]): Id of the user who triggered the rebuild, or
306
+ ``None`` if the server started it, in which case the actor is
307
+ ``"system"``.
308
+ """
309
+ emit(
310
+ "study.rebuild.started",
311
+ outcome="success",
312
+ actor=user_id or "system",
313
+ extra={"study_id": study_id},
314
+ )
315
+
316
+
317
+ def study_rebuild_finished(*, study_id: str, user_id: Optional[str] = None) -> None:
318
+ """Record the end of a sampler rebuild (event ``study.rebuild.finished``).
319
+
320
+ Args:
321
+ study_id (str): Id of the rebuilt study.
322
+ user_id (Optional[str]): Id of the user who triggered the rebuild, or
323
+ ``None`` if the server started it, in which case the actor is
324
+ ``"system"``.
325
+ """
326
+ emit(
327
+ "study.rebuild.finished",
328
+ outcome="success",
329
+ actor=user_id or "system",
330
+ extra={"study_id": study_id},
331
+ )
332
+
333
+
334
+ __all__ = [
335
+ "login_failure",
336
+ "login_success",
337
+ "logout",
338
+ "quota_exhausted",
339
+ "study_access_denied",
340
+ "study_created",
341
+ "study_rebuild_finished",
342
+ "study_rebuild_started",
343
+ "study_stopped",
344
+ "token_authentication_failure",
345
+ "token_deleted",
346
+ "token_issued",
347
+ "token_revoked",
348
+ "trial_enqueued",
349
+ ]
@@ -0,0 +1,255 @@
1
+ """Canonical serialisation and hashing that give studies their identity.
2
+
3
+ A study is content-addressed: its id is the SHA-256 of a canonical string built
4
+ from the owner's key, the study name and the full study configuration. Equal
5
+ inputs give equal ids in every process, so independent clients that submit the
6
+ same configuration land on the same study.
7
+
8
+ The owner key (``study_key``) is an opaque, stable identifier of a user or a
9
+ group, never the API token itself; the server resolves it from the token. The
10
+ SDK only computes :func:`config_digest`, the same hash without key and name,
11
+ which it sends with every request so the server can detect a configuration that
12
+ differs between clients.
13
+
14
+ The output is identical across Python versions, operating systems and CPU
15
+ architectures.
16
+
17
+ Canonical form:
18
+
19
+ * Dict keys are sorted lexicographically at every depth.
20
+ * Sequences keep their declared order (parameter order is meaningful).
21
+ * Floats are written with :func:`repr` (shortest round-trippable form), so
22
+ ``1`` and ``1.0`` hash differently.
23
+ * No whitespace and no trailing newline; the output is ASCII only.
24
+ * ``NaN`` and infinities are rejected: they have no useful meaning in a study.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import hashlib
30
+ import math
31
+ from typing import TYPE_CHECKING, Any, Mapping, Sequence, Tuple
32
+
33
+ if TYPE_CHECKING:
34
+ from intelloptim._internal.schemas import StudyResolveRequest
35
+
36
+ _HASH_PREFIX = "suid:"
37
+
38
+
39
+ def canonicalize(value: Any) -> str:
40
+ """Serialise *value* to its canonical string.
41
+
42
+ The result is valid JSON: dict keys are sorted, non-ASCII characters are
43
+ escaped as ``\\uXXXX`` and lists and tuples give the same output.
44
+
45
+ Args:
46
+ value (Any): A composition of ``None``, ``bool``, ``int``, ``float``,
47
+ ``str``, ``list``/``tuple`` and mappings with ``str`` keys. Convert
48
+ pydantic models with ``model_dump(mode="json")`` first.
49
+
50
+ Returns:
51
+ str: The canonical string: ASCII only, no whitespace.
52
+
53
+ Raises:
54
+ TypeError: If *value* contains an unsupported type, for example a
55
+ ``set``, ``bytes`` or a custom object.
56
+ ValueError: If a ``float`` is ``NaN`` or infinite, or a mapping has a
57
+ non-``str`` key.
58
+ """
59
+ return _emit(value)
60
+
61
+
62
+ def study_id(
63
+ study_key: str,
64
+ study_name: str,
65
+ direction: str,
66
+ parameters: Sequence[Mapping[str, Any]],
67
+ sampler_spec: Mapping[str, Any],
68
+ pruner_spec: Mapping[str, Any],
69
+ ) -> str:
70
+ """Compute the content-addressed id of a study.
71
+
72
+ Callers that submit equivalent specifications with the same ``study_key``
73
+ get the same id. Any difference in the name, direction, parameters, sampler
74
+ or pruner, or a different key, gives a different id. The key keeps the
75
+ studies of different users and groups apart without putting the API token in
76
+ the hash. :func:`study_identity` computes this id, and the digest, directly
77
+ from a resolve request.
78
+
79
+ Args:
80
+ study_key (str): Opaque key (UUID4 hex) of the calling user or group,
81
+ resolved by the server; never the raw API token.
82
+ study_name (str): Human-readable study name.
83
+ direction (str): ``"minimize"`` or ``"maximize"``.
84
+ parameters (Sequence[Mapping[str, Any]]): Declared search space as
85
+ parameter dicts in declared order, each with a ``kind`` field.
86
+ sampler_spec (Mapping[str, Any]): Sampler spec dict (``kind`` plus its
87
+ settings).
88
+ pruner_spec (Mapping[str, Any]): Pruner spec dict (``kind`` plus its
89
+ settings).
90
+
91
+ Returns:
92
+ str: ``"suid:"`` followed by 64 hex characters (69 in total).
93
+ """
94
+ # Key names and values are hashed: renaming one changes every study id.
95
+ payload = {
96
+ "study_key": study_key,
97
+ "study_name": study_name,
98
+ "direction": direction,
99
+ "parameters": list(parameters),
100
+ "sampler_spec": dict(sampler_spec),
101
+ "pruner_spec": dict(pruner_spec),
102
+ }
103
+ return _digest(canonicalize(payload))
104
+
105
+
106
+ def config_digest(
107
+ direction: str,
108
+ parameters: Sequence[Mapping[str, Any]],
109
+ sampler_spec: Mapping[str, Any],
110
+ pruner_spec: Mapping[str, Any],
111
+ ) -> str:
112
+ """Compute the digest of a study configuration, without owner or name.
113
+
114
+ Clients send it with every study request. The server compares it with the
115
+ digest of the stored configuration and answers ``409 Conflict`` on a
116
+ mismatch, which catches two clients that think they share a study but hold
117
+ different configurations. It hashes the same fields as :func:`study_id`
118
+ minus ``study_key`` and ``study_name``, so it is the same for every owner.
119
+
120
+ Args:
121
+ direction (str): ``"minimize"`` or ``"maximize"``.
122
+ parameters (Sequence[Mapping[str, Any]]): Declared search space as
123
+ parameter dicts in declared order.
124
+ sampler_spec (Mapping[str, Any]): Sampler spec dict (``kind`` plus its
125
+ settings).
126
+ pruner_spec (Mapping[str, Any]): Pruner spec dict (``kind`` plus its
127
+ settings).
128
+
129
+ Returns:
130
+ str: ``"suid:"`` followed by 64 hex characters.
131
+ """
132
+ # Key names and values are hashed: renaming one changes every digest.
133
+ payload = {
134
+ "direction": direction,
135
+ "parameters": list(parameters),
136
+ "sampler_spec": dict(sampler_spec),
137
+ "pruner_spec": dict(pruner_spec),
138
+ }
139
+ return _digest(canonicalize(payload))
140
+
141
+
142
+ def study_identity(study_key: str, request: "StudyResolveRequest") -> Tuple[str, str]:
143
+ """Compute ``(study_id, config_digest)`` from a resolve request.
144
+
145
+ The specs are serialised with ``model_dump(mode="json")``, so default values
146
+ such as ``log=False`` are part of the hash and a spec that relies on a
147
+ default gets the same id as one that sets it explicitly.
148
+
149
+ Args:
150
+ study_key (str): Opaque key of the calling user or group, resolved by
151
+ the server from the bearer token; never the raw token.
152
+ request (StudyResolveRequest): The resolve request, or any object with
153
+ the same fields.
154
+
155
+ Returns:
156
+ Tuple[str, str]: ``(study_id, config_digest)``. Only the id depends on
157
+ ``study_key`` and the study name.
158
+ """
159
+ parameters = [p.model_dump(mode="json") for p in request.parameters]
160
+ direction = request.direction
161
+ sampler_spec = request.sampler_spec.model_dump(mode="json")
162
+ pruner_spec = request.pruner_spec.model_dump(mode="json")
163
+ suid = study_id(
164
+ study_key=study_key,
165
+ study_name=request.study_name,
166
+ direction=direction,
167
+ parameters=parameters,
168
+ sampler_spec=sampler_spec,
169
+ pruner_spec=pruner_spec,
170
+ )
171
+ digest = config_digest(
172
+ direction=direction,
173
+ parameters=parameters,
174
+ sampler_spec=sampler_spec,
175
+ pruner_spec=pruner_spec,
176
+ )
177
+ return suid, digest
178
+
179
+
180
+ def _digest(canonical: str) -> str:
181
+ """Return ``"suid:"`` plus the SHA-256 hex digest of a canonical string."""
182
+ h = hashlib.sha256(canonical.encode("ascii")).hexdigest()
183
+ return f"{_HASH_PREFIX}{h}"
184
+
185
+
186
+ def _emit(value: Any) -> str:
187
+ """Serialise one value recursively; see :func:`canonicalize`."""
188
+ if value is None:
189
+ return "null"
190
+ if isinstance(value, bool):
191
+ return "true" if value else "false"
192
+ if isinstance(value, int):
193
+ return str(value)
194
+ if isinstance(value, float):
195
+ if math.isnan(value) or math.isinf(value):
196
+ raise ValueError(
197
+ "NaN and infinite floats are not allowed in canonical form"
198
+ )
199
+ return repr(value)
200
+ if isinstance(value, str):
201
+ return _emit_string(value)
202
+ if isinstance(value, (list, tuple)):
203
+ return "[" + ",".join(_emit(v) for v in value) + "]"
204
+ if isinstance(value, Mapping):
205
+ items: list[tuple[str, Any]] = []
206
+ for k, v in value.items():
207
+ if not isinstance(k, str):
208
+ raise ValueError(
209
+ f"dict keys must be str, got {type(k).__name__}: {k!r}"
210
+ )
211
+ items.append((k, v))
212
+ items.sort(key=lambda kv: kv[0])
213
+ body = ",".join(f"{_emit_string(k)}:{_emit(v)}" for k, v in items)
214
+ return "{" + body + "}"
215
+ raise TypeError(f"unsupported type for canonicalization: {type(value).__name__}")
216
+
217
+
218
+ def _emit_string(value: str) -> str:
219
+ """Quote *value* and escape it to printable ASCII.
220
+
221
+ Quote, backslash and the usual control characters use their short escapes;
222
+ every other character outside ``0x20``-``0x7E`` becomes ``\\uXXXX``, and
223
+ characters above ``U+FFFF`` a surrogate pair, as in JSON.
224
+ """
225
+ out = ['"']
226
+ for ch in value:
227
+ code = ord(ch)
228
+ if ch == '"':
229
+ out.append('\\"')
230
+ elif ch == "\\":
231
+ out.append("\\\\")
232
+ elif ch == "\b":
233
+ out.append("\\b")
234
+ elif ch == "\f":
235
+ out.append("\\f")
236
+ elif ch == "\n":
237
+ out.append("\\n")
238
+ elif ch == "\r":
239
+ out.append("\\r")
240
+ elif ch == "\t":
241
+ out.append("\\t")
242
+ elif code > 0xFFFF:
243
+ offset = code - 0x10000
244
+ high = 0xD800 + (offset >> 10)
245
+ low = 0xDC00 + (offset & 0x3FF)
246
+ out.append(f"\\u{high:04x}\\u{low:04x}")
247
+ elif code < 0x20 or code > 0x7E:
248
+ out.append(f"\\u{code:04x}")
249
+ else:
250
+ out.append(ch)
251
+ out.append('"')
252
+ return "".join(out)
253
+
254
+
255
+ __all__ = ["config_digest", "study_identity"]