Claude commited on
Commit
b784716
·
unverified ·
1 Parent(s): ed686d1

feat(domain): Deadline — contrat de timeout propagé cross-process

Browse files

Type valeur pur en couche 1, propagé via RunContext aux adapters
(cf. ADR-0001). Représente soit une échéance monotonic absolue,
soit l'absence de contrainte temporelle (``infinite``).

## Propriétés garanties

- **Immuable** : ``__slots__`` + ``__setattr__`` qui lève
``AttributeError``. Hashable, donc utilisable comme clé de dict
ou élément de set.
- **Cross-process safe** : ``__getstate__`` / ``__setstate__``
convertissent vers ``remaining_seconds`` au moment du transfert
(pickle) ; ``to_dict`` / ``from_dict`` même pattern pour JSON.
Conséquence : un overhead de dispatch de N secondes n'est PAS
facturé au worker — le budget visible côté receveur est le
budget *au moment du handoff*, pas le budget original. Le
wall-clock total peut excéder le budget original par l'overhead
de dispatch, mais le contrat de durée vue par l'adapter est
respecté.
- **Numériquement défensif** : ``remaining_seconds()`` ne retourne
jamais de négatif (``max(0.0, ...)``). ``clamp_to_remaining``
borne un délai par le budget restant (utile pour le backoff
retry de Phase 3).
- **Validations strictes** : ``in_seconds(budget <= 0)`` rejeté ;
``from_dict`` validate la structure du dict ; constructeurs
hostiles aux types non-numériques.

## API

- ``Deadline.infinite()`` — pas d'échéance.
- ``Deadline.in_seconds(budget)`` — échéance dans budget secondes.
- ``Deadline.at_monotonic(t)`` — instant monotonic absolu (tests).
- ``deadline.remaining_seconds()`` — None si infinie.
- ``deadline.is_expired()``, ``deadline.is_infinite``.
- ``deadline.as_sdk_timeout()`` — valeur pour ``httpx.timeout=``,
``pytesseract.timeout=``, etc. Nom explicite pour l'intention
au call-site adapter (Phase 3).
- ``deadline.clamp_to_remaining(seconds)`` — borne un délai.
- ``deadline.to_dict()`` / ``Deadline.from_dict(d)`` — sérialisation
IPC/JSON, utilisée par ``RunManifest`` à Phase 8 et par le
dispatch IPC du SubprocessExecutor à Phase 5.

## Validation

- 50/50 tests covering construction, query, cross-process semantics
(pickle + to_dict/from_dict avec sleep entre handoff prouvant la
non-facturation du dispatch), immutability, dunder protocols, et
patterns d'usage réalistes (retry loop, SDK timeout, backoff clamp).
- mypy strict sur ``picarones/domain/`` passe (annotation de classe
explicite des ``__slots__`` requise).
- Sprint narrative ratchet inchangé (formulation par intention, pas
par phase — l'historique vit dans CHANGELOG + ADR-0001).
- 181 tests architecture, 154 tests pipeline, 129 tests domain :
aucune régression.

Import canonique : ``from picarones.domain import Deadline``.
Pas exposé au top-level ``picarones`` — c'est un contrat interne
pour la couche pipeline + adapters, pas une API consommée par les
utilisateurs externes.

https://claude.ai/code/session_01B93huMjNh4CG2rNcexgDeL

picarones/domain/__init__.py CHANGED
@@ -46,6 +46,7 @@ from __future__ import annotations
46
  from picarones.domain.artifact_key import ArtifactKey
47
  from picarones.domain.artifacts import Artifact, ArtifactType, compute_content_hash
48
  from picarones.domain.corpus import CorpusSpec
 
49
  from picarones.domain.documents import DocumentRef, GroundTruthRef
50
  from picarones.domain.errors import (
51
  ArtifactValidationError,
@@ -91,6 +92,8 @@ __all__ = [
91
  "CorpusSpec",
92
  "DocumentRef",
93
  "GroundTruthRef",
 
 
94
  # S4 — Provenance
95
  "ProvenanceRecord",
96
  # S4 — Errors
 
46
  from picarones.domain.artifact_key import ArtifactKey
47
  from picarones.domain.artifacts import Artifact, ArtifactType, compute_content_hash
48
  from picarones.domain.corpus import CorpusSpec
49
+ from picarones.domain.deadline import Deadline
50
  from picarones.domain.documents import DocumentRef, GroundTruthRef
51
  from picarones.domain.errors import (
52
  ArtifactValidationError,
 
92
  "CorpusSpec",
93
  "DocumentRef",
94
  "GroundTruthRef",
95
+ # Contrat de timeout propagé cross-process (cf. ADR-0001)
96
+ "Deadline",
97
  # S4 — Provenance
98
  "ProvenanceRecord",
99
  # S4 — Errors
picarones/domain/deadline.py ADDED
@@ -0,0 +1,320 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``Deadline`` — type pur représentant une échéance d'opération.
2
+
3
+ Contrat de timeout propagé cross-process à travers la couche
4
+ adapters. Voir
5
+ [ADR-0001](../../docs/explanation/adr/0001-multi-domain-execution.md)
6
+ pour la motivation architecturale et la sémantique étendue
7
+ (multi-domaines d'exécution, terminaison structurée).
8
+
9
+ Sémantique
10
+ ----------
11
+
12
+ Une ``Deadline`` est soit **infinie** (aucune contrainte temporelle),
13
+ soit **finie** avec une expiration absolue exprimée dans l'horloge
14
+ monotonic du process courant.
15
+
16
+ L'horloge monotonic (``time.monotonic()``) :
17
+
18
+ - ne recule jamais (à la différence de ``time.time()`` qui peut
19
+ sauter avec NTP),
20
+ - a une origine **arbitraire et privée au process** — l'instant
21
+ ``t=0.0`` ne correspond à rien de comparable entre processes.
22
+
23
+ Cross-process : la sérialisation (pickle, JSON) convertit
24
+ automatiquement vers ``remaining_seconds`` au moment du transfert.
25
+ À la réception, une nouvelle ``Deadline`` est construite relativement
26
+ à l'horloge monotonic du process receveur. Conséquence : un overhead
27
+ de dispatch de 5 secondes n'est pas facturé au worker — le budget
28
+ qu'il voit est le budget restant au moment du handoff, pas le budget
29
+ original. Le wall-clock total peut donc dépasser le budget original
30
+ par l'overhead de dispatch.
31
+
32
+ Si un workflow nécessite une deadline **absolue cross-process**
33
+ (rare ; un soak test global par exemple), passer par wall-clock
34
+ (``time.time_ns()``) à la place — non couvert par ce type pour
35
+ éviter la confusion sémantique.
36
+
37
+ Usage typique
38
+ -------------
39
+
40
+ ::
41
+
42
+ deadline = Deadline.in_seconds(60.0)
43
+ # ... travail ...
44
+ if deadline.is_expired():
45
+ raise DeadlineExceeded(...)
46
+ remaining = deadline.remaining_seconds() # 54.8
47
+ sdk_call(timeout=deadline.as_sdk_timeout())
48
+
49
+ API publique
50
+ ------------
51
+
52
+ - ``Deadline.infinite()`` — pas d'échéance.
53
+ - ``Deadline.in_seconds(budget)`` — échéance dans ``budget`` secondes.
54
+ - ``Deadline.at_monotonic(t)`` — échéance à l'instant monotonic ``t``
55
+ (rare ; surtout pour les tests qui veulent une deadline déjà
56
+ expirée).
57
+ - ``deadline.remaining_seconds()`` — ``None`` si infinie, sinon
58
+ ``max(0.0, expires - now)``.
59
+ - ``deadline.is_expired()`` — ``False`` si infinie, sinon
60
+ ``now >= expires``.
61
+ - ``deadline.is_infinite`` — propriété booléenne.
62
+ - ``deadline.as_sdk_timeout()`` — valeur à passer à un SDK
63
+ (``httpx.timeout=``, ``pytesseract.timeout=``). Identique à
64
+ ``remaining_seconds()`` ; nom explicite pour l'intention.
65
+ - ``deadline.clamp_to_remaining(seconds)`` — borne ``seconds`` par
66
+ le budget restant. Utile pour le retry backoff qui ne doit pas
67
+ dépasser la deadline.
68
+
69
+ Limitations assumées
70
+ --------------------
71
+
72
+ - Pas de hiérarchie de deadlines (sub-deadline d'un step plus serrée
73
+ que celle du doc) : le caller construit explicitement
74
+ ``Deadline.in_seconds(min(step_budget, parent.remaining_seconds()))``.
75
+ - Pas de wall-clock — uniquement monotonic.
76
+ - Pas de notification d'expiration (pas de signal/callback). Les
77
+ consommateurs polling via ``is_expired()`` ou check via
78
+ ``remaining_seconds()`` avant chaque opération bloquante.
79
+ """
80
+
81
+ from __future__ import annotations
82
+
83
+ import time
84
+ from typing import Any
85
+
86
+ from picarones.domain.errors import PicaronesError
87
+
88
+
89
+ class Deadline:
90
+ """Type valeur immuable représentant une échéance d'opération.
91
+
92
+ Voir le module docstring pour la sémantique complète et l'usage.
93
+ """
94
+
95
+ # Annotation de classe nécessaire en plus de ``__slots__`` pour
96
+ # que mypy résolve les accès à l'attribut (l'assignation passe
97
+ # par ``object.__setattr__`` à cause de l'immutabilité, mypy ne
98
+ # peut pas l'inférer du seul ``__slots__``).
99
+ _expires_at_monotonic: float | None
100
+ __slots__ = ("_expires_at_monotonic",)
101
+
102
+ # Le constructeur public est ``__init__`` mais on attend qu'il
103
+ # soit appelé avec ``expires_at_monotonic: float | None`` — c'est
104
+ # un détail d'implémentation, les callers utilisent les
105
+ # classmethods (``infinite``, ``in_seconds``, ``at_monotonic``).
106
+
107
+ def __init__(self, expires_at_monotonic: float | None) -> None:
108
+ if expires_at_monotonic is not None and not isinstance(
109
+ expires_at_monotonic, (int, float),
110
+ ):
111
+ raise PicaronesError(
112
+ f"Deadline : expires_at_monotonic doit être float ou None, "
113
+ f"reçu {type(expires_at_monotonic).__name__}",
114
+ )
115
+ object.__setattr__(
116
+ self, "_expires_at_monotonic",
117
+ float(expires_at_monotonic) if expires_at_monotonic is not None
118
+ else None,
119
+ )
120
+
121
+ # ──────────────────────────────────────────────────────────────────
122
+ # Constructeurs
123
+ # ──────────────────────────────────────────────────────────────────
124
+
125
+ @classmethod
126
+ def infinite(cls) -> "Deadline":
127
+ """Pas d'échéance — ``remaining_seconds()`` retourne ``None``
128
+ et ``is_expired()`` retourne ``False``."""
129
+ return cls(expires_at_monotonic=None)
130
+
131
+ @classmethod
132
+ def in_seconds(cls, budget: float) -> "Deadline":
133
+ """Échéance dans ``budget`` secondes à partir de maintenant.
134
+
135
+ ``budget`` doit être > 0. Une valeur de 0 est rejetée parce
136
+ qu'elle représente une expiration immédiate inutile en
137
+ pratique (préfère ``Deadline.at_monotonic(0)`` pour tester
138
+ une deadline déjà expirée).
139
+ """
140
+ if not isinstance(budget, (int, float)):
141
+ raise PicaronesError(
142
+ f"Deadline.in_seconds : budget doit être numérique, "
143
+ f"reçu {type(budget).__name__}",
144
+ )
145
+ if budget <= 0:
146
+ raise PicaronesError(
147
+ f"Deadline.in_seconds : budget doit être > 0 "
148
+ f"(reçu {budget}). Pour une deadline déjà expirée "
149
+ f"en test, utiliser ``Deadline.at_monotonic(0.0)``.",
150
+ )
151
+ return cls(expires_at_monotonic=time.monotonic() + float(budget))
152
+
153
+ @classmethod
154
+ def at_monotonic(cls, expires_at: float) -> "Deadline":
155
+ """Échéance à l'instant monotonic absolu ``expires_at``.
156
+
157
+ Surtout utile en tests pour construire des deadlines déjà
158
+ expirées (``at_monotonic(0.0)``) ou très lointaines
159
+ (``at_monotonic(time.monotonic() + 3600)``).
160
+ """
161
+ return cls(expires_at_monotonic=expires_at)
162
+
163
+ # ──────────────────────────────────────────────────────────────────
164
+ # Interrogation
165
+ # ──────────────────────────────────────────────────────────────────
166
+
167
+ @property
168
+ def is_infinite(self) -> bool:
169
+ return self._expires_at_monotonic is None
170
+
171
+ def remaining_seconds(self) -> float | None:
172
+ """Secondes restantes avant expiration, ou ``None`` si infinie.
173
+
174
+ Ne retourne jamais de valeur négative : ``max(0.0, ...)``.
175
+ """
176
+ if self._expires_at_monotonic is None:
177
+ return None
178
+ return max(0.0, self._expires_at_monotonic - time.monotonic())
179
+
180
+ def is_expired(self) -> bool:
181
+ if self._expires_at_monotonic is None:
182
+ return False
183
+ return time.monotonic() >= self._expires_at_monotonic
184
+
185
+ def as_sdk_timeout(self) -> float | None:
186
+ """Valeur à passer à un SDK comme paramètre ``timeout=``.
187
+
188
+ Identique à ``remaining_seconds()`` ; le nom explicite
189
+ l'intention au call-site (``sdk.call(timeout=deadline.as_sdk_timeout())``).
190
+ """
191
+ return self.remaining_seconds()
192
+
193
+ def clamp_to_remaining(self, seconds: float) -> float:
194
+ """Retourne ``min(seconds, remaining_seconds())``.
195
+
196
+ Si infinie, retourne ``seconds`` tel quel. Utile pour borner
197
+ le backoff d'un retry helper qui ne doit pas dépasser le
198
+ budget restant.
199
+
200
+ ``seconds`` doit être >= 0.
201
+ """
202
+ if seconds < 0:
203
+ raise PicaronesError(
204
+ f"Deadline.clamp_to_remaining : seconds doit être >= 0 "
205
+ f"(reçu {seconds})",
206
+ )
207
+ if self._expires_at_monotonic is None:
208
+ return seconds
209
+ # ``remaining_seconds()`` retourne ``float`` ici (pas ``None``)
210
+ # puisqu'on vient de vérifier que la deadline est finie — le
211
+ # narrower de type est explicite pour mypy.
212
+ remaining = self.remaining_seconds()
213
+ assert remaining is not None
214
+ return min(seconds, remaining)
215
+
216
+ # ──────────────────────────────────────────────────────────────────
217
+ # Sérialisation cross-process
218
+ # ──────────────────────────────────────────────────────────────────
219
+ #
220
+ # ``expires_at_monotonic`` n'a aucun sens dans un autre process
221
+ # (chaque process a son origine monotonic privée). On sérialise
222
+ # toujours via ``remaining_seconds`` qui est, lui, transposable :
223
+ # à la réception, on reconstruit un ``Deadline`` relatif à
224
+ # l'horloge monotonic du process receveur.
225
+
226
+ def to_dict(self) -> dict[str, float | None]:
227
+ """Forme sérialisable JSON / IPC.
228
+
229
+ ``{"remaining_seconds": float | None}``. ``None`` = infinie.
230
+ """
231
+ return {"remaining_seconds": self.remaining_seconds()}
232
+
233
+ @classmethod
234
+ def from_dict(cls, data: dict[str, Any]) -> "Deadline":
235
+ """Reconstruit une ``Deadline`` depuis sa forme sérialisée.
236
+
237
+ Lève ``PicaronesError`` si ``data`` n'a pas la forme
238
+ attendue ou contient un type incorrect.
239
+ """
240
+ if not isinstance(data, dict):
241
+ raise PicaronesError(
242
+ f"Deadline.from_dict : data doit être un dict, "
243
+ f"reçu {type(data).__name__}",
244
+ )
245
+ if "remaining_seconds" not in data:
246
+ raise PicaronesError(
247
+ "Deadline.from_dict : clé 'remaining_seconds' "
248
+ "manquante.",
249
+ )
250
+ r = data["remaining_seconds"]
251
+ if r is None:
252
+ return cls.infinite()
253
+ if not isinstance(r, (int, float)):
254
+ raise PicaronesError(
255
+ f"Deadline.from_dict : remaining_seconds doit être "
256
+ f"numérique ou None, reçu {type(r).__name__}",
257
+ )
258
+ if r <= 0:
259
+ # Une deadline reçue avec budget ≤ 0 est déjà expirée
260
+ # côté émetteur. On reconstruit une deadline expirée
261
+ # (pas d'erreur — c'est un état valide, juste rare).
262
+ return cls.at_monotonic(time.monotonic())
263
+ return cls.in_seconds(float(r))
264
+
265
+ # Pickle support : utilise le même protocole de transposition
266
+ # que ``to_dict`` / ``from_dict``.
267
+
268
+ def __getstate__(self) -> dict[str, float | None]:
269
+ return self.to_dict()
270
+
271
+ def __setstate__(self, state: dict[str, float | None]) -> None:
272
+ r = state.get("remaining_seconds")
273
+ if r is None:
274
+ object.__setattr__(self, "_expires_at_monotonic", None)
275
+ elif r <= 0:
276
+ # Cohérent avec ``from_dict`` : reconstruit comme expirée.
277
+ object.__setattr__(
278
+ self, "_expires_at_monotonic", time.monotonic(),
279
+ )
280
+ else:
281
+ object.__setattr__(
282
+ self, "_expires_at_monotonic", time.monotonic() + float(r),
283
+ )
284
+
285
+ # ──────────────────────────────────────────────────────────────────
286
+ # Dunder — immutabilité, égalité, repr
287
+ # ──────────────────────────────────────────────────────────────────
288
+
289
+ def __setattr__(self, name: str, value: Any) -> None:
290
+ raise AttributeError(
291
+ f"Deadline est immuable — impossible d'assigner {name!r}",
292
+ )
293
+
294
+ def __delattr__(self, name: str) -> None:
295
+ raise AttributeError(
296
+ f"Deadline est immuable — impossible de supprimer {name!r}",
297
+ )
298
+
299
+ def __eq__(self, other: object) -> bool:
300
+ if not isinstance(other, Deadline):
301
+ return NotImplemented
302
+ # Égalité **stricte** sur ``expires_at_monotonic`` — deux
303
+ # ``Deadline.in_seconds(60)`` créées à des instants différents
304
+ # ne sont PAS égales. L'égalité sur ``remaining_seconds()``
305
+ # serait dépendante du moment du check, donc instable.
306
+ return self._expires_at_monotonic == other._expires_at_monotonic
307
+
308
+ def __hash__(self) -> int:
309
+ return hash(self._expires_at_monotonic)
310
+
311
+ def __repr__(self) -> str:
312
+ if self._expires_at_monotonic is None:
313
+ return "Deadline.infinite()"
314
+ remaining = self.remaining_seconds()
315
+ if remaining is None or remaining <= 0.0:
316
+ return "Deadline(expired)"
317
+ return f"Deadline(remaining={remaining:.3f}s)"
318
+
319
+
320
+ __all__ = ["Deadline"]
tests/domain/test_deadline.py ADDED
@@ -0,0 +1,393 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests de ``picarones.domain.deadline.Deadline``.
2
+
3
+ Phase 1 du chantier de refonte du modèle d'exécution. Le type est
4
+ pur (stdlib + PicaronesError uniquement), donc le suite couvre
5
+ exhaustivement :
6
+
7
+ - Construction (infinite, in_seconds, at_monotonic) + rejets.
8
+ - Interrogation (remaining_seconds, is_expired, is_infinite,
9
+ as_sdk_timeout, clamp_to_remaining).
10
+ - Sérialisation cross-process (to_dict / from_dict / pickle).
11
+ - Immutabilité (setattr, delattr).
12
+ - Égalité / hash / repr.
13
+ - Garanties critiques cross-process (l'overhead de dispatch ne
14
+ facture pas le worker).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import pickle
20
+ import time
21
+
22
+ import pytest
23
+
24
+ from picarones.domain.deadline import Deadline
25
+ from picarones.domain.errors import PicaronesError
26
+
27
+
28
+ # ──────────────────────────────────────────────────────────────────────
29
+ # Construction
30
+ # ──────────────────────────────────────────────────────────────────────
31
+
32
+
33
+ class TestConstruction:
34
+ def test_infinite_is_infinite(self) -> None:
35
+ d = Deadline.infinite()
36
+ assert d.is_infinite is True
37
+ assert d.remaining_seconds() is None
38
+ assert d.is_expired() is False
39
+
40
+ def test_in_seconds_positive_budget(self) -> None:
41
+ d = Deadline.in_seconds(60.0)
42
+ assert d.is_infinite is False
43
+ # Marge de scheduling : remaining doit être ~60s mais peut
44
+ # avoir perdu quelques ms entre construction et check.
45
+ r = d.remaining_seconds()
46
+ assert r is not None
47
+ assert 59.0 < r <= 60.0
48
+
49
+ def test_in_seconds_int_budget_accepted(self) -> None:
50
+ """int est coercé en float."""
51
+ d = Deadline.in_seconds(30)
52
+ assert d.is_infinite is False
53
+ assert d.remaining_seconds() is not None
54
+
55
+ def test_in_seconds_zero_rejected(self) -> None:
56
+ with pytest.raises(PicaronesError, match="budget doit être > 0"):
57
+ Deadline.in_seconds(0)
58
+
59
+ def test_in_seconds_negative_rejected(self) -> None:
60
+ with pytest.raises(PicaronesError, match="budget doit être > 0"):
61
+ Deadline.in_seconds(-1.0)
62
+
63
+ def test_in_seconds_non_numeric_rejected(self) -> None:
64
+ with pytest.raises(PicaronesError, match="budget doit être numérique"):
65
+ Deadline.in_seconds("60") # type: ignore[arg-type]
66
+
67
+ def test_at_monotonic_in_past_is_expired(self) -> None:
68
+ """Une deadline absolue dans le passé est valide ET expirée."""
69
+ d = Deadline.at_monotonic(0.0)
70
+ assert d.is_expired() is True
71
+ assert d.remaining_seconds() == 0.0
72
+
73
+ def test_at_monotonic_in_future_not_expired(self) -> None:
74
+ d = Deadline.at_monotonic(time.monotonic() + 100.0)
75
+ assert d.is_expired() is False
76
+ assert d.remaining_seconds() > 0
77
+
78
+ def test_direct_constructor_rejects_wrong_type(self) -> None:
79
+ with pytest.raises(PicaronesError, match="doit être float ou None"):
80
+ Deadline(expires_at_monotonic="now") # type: ignore[arg-type]
81
+
82
+
83
+ # ──────────────────────────────────────────────────────────────────────
84
+ # Interrogation
85
+ # ──────────────────────────────────────────────────────────────────────
86
+
87
+
88
+ class TestQuery:
89
+ def test_remaining_seconds_never_negative(self) -> None:
90
+ """Une deadline expirée retourne 0.0, pas une valeur négative."""
91
+ d = Deadline.at_monotonic(0.0)
92
+ assert d.remaining_seconds() == 0.0
93
+
94
+ def test_is_expired_transitions_after_time_passes(self) -> None:
95
+ d = Deadline.in_seconds(0.05) # 50ms
96
+ assert d.is_expired() is False
97
+ time.sleep(0.1)
98
+ assert d.is_expired() is True
99
+
100
+ def test_as_sdk_timeout_matches_remaining_seconds(self) -> None:
101
+ d = Deadline.in_seconds(10.0)
102
+ # Les deux peuvent légèrement différer si appelés à des
103
+ # instants différents — on vérifie qu'ils sont du même ordre.
104
+ a = d.as_sdk_timeout()
105
+ b = d.remaining_seconds()
106
+ assert a is not None and b is not None
107
+ assert abs(a - b) < 0.1
108
+
109
+ def test_as_sdk_timeout_infinite_returns_none(self) -> None:
110
+ assert Deadline.infinite().as_sdk_timeout() is None
111
+
112
+ def test_clamp_to_remaining_clamps_when_seconds_exceeds(self) -> None:
113
+ d = Deadline.in_seconds(0.5)
114
+ clamped = d.clamp_to_remaining(10.0)
115
+ assert clamped <= 0.5
116
+
117
+ def test_clamp_to_remaining_returns_seconds_when_under(self) -> None:
118
+ d = Deadline.in_seconds(60.0)
119
+ assert d.clamp_to_remaining(1.0) == 1.0
120
+
121
+ def test_clamp_to_remaining_infinite_returns_seconds(self) -> None:
122
+ assert Deadline.infinite().clamp_to_remaining(5.0) == 5.0
123
+
124
+ def test_clamp_to_remaining_rejects_negative(self) -> None:
125
+ d = Deadline.in_seconds(10.0)
126
+ with pytest.raises(PicaronesError, match="seconds doit être >= 0"):
127
+ d.clamp_to_remaining(-1.0)
128
+
129
+ def test_clamp_to_remaining_zero_is_zero(self) -> None:
130
+ """clamp(0) = 0 — pas d'erreur, c'est un cas valide
131
+ (ex : pas de wait avant retry)."""
132
+ d = Deadline.in_seconds(10.0)
133
+ assert d.clamp_to_remaining(0.0) == 0.0
134
+
135
+
136
+ # ──────────────────────────────────────────────────────────────────────
137
+ # Sérialisation cross-process — propriété critique
138
+ # ──────────────────────────────────────────────────────────────────────
139
+
140
+
141
+ class TestCrossProcessSerialization:
142
+ """La promesse centrale : un overhead de dispatch (sleep entre
143
+ sérialisation et désérialisation) n'est PAS facturé au worker.
144
+ Le budget que voit le worker est le budget restant *au moment du
145
+ handoff*, pas le budget original.
146
+
147
+ C'est la propriété qui rend ``Deadline`` utilisable cross-process
148
+ et qui justifie de ne PAS sérialiser ``expires_at_monotonic``.
149
+ """
150
+
151
+ def test_to_dict_infinite(self) -> None:
152
+ d = Deadline.infinite()
153
+ assert d.to_dict() == {"remaining_seconds": None}
154
+
155
+ def test_to_dict_finite(self) -> None:
156
+ d = Deadline.in_seconds(60.0)
157
+ out = d.to_dict()
158
+ assert "remaining_seconds" in out
159
+ assert 59.0 < out["remaining_seconds"] <= 60.0
160
+
161
+ def test_from_dict_infinite(self) -> None:
162
+ d = Deadline.from_dict({"remaining_seconds": None})
163
+ assert d.is_infinite is True
164
+
165
+ def test_from_dict_finite(self) -> None:
166
+ d = Deadline.from_dict({"remaining_seconds": 30.0})
167
+ r = d.remaining_seconds()
168
+ assert r is not None
169
+ assert 29.0 < r <= 30.0
170
+
171
+ def test_from_dict_negative_treated_as_expired(self) -> None:
172
+ d = Deadline.from_dict({"remaining_seconds": -5.0})
173
+ assert d.is_expired() is True
174
+
175
+ def test_from_dict_zero_treated_as_expired(self) -> None:
176
+ d = Deadline.from_dict({"remaining_seconds": 0.0})
177
+ assert d.is_expired() is True
178
+
179
+ def test_from_dict_rejects_non_dict(self) -> None:
180
+ with pytest.raises(PicaronesError, match="doit être un dict"):
181
+ Deadline.from_dict("60") # type: ignore[arg-type]
182
+
183
+ def test_from_dict_rejects_missing_key(self) -> None:
184
+ with pytest.raises(PicaronesError, match="clé 'remaining_seconds'"):
185
+ Deadline.from_dict({"other": 1})
186
+
187
+ def test_from_dict_rejects_non_numeric(self) -> None:
188
+ with pytest.raises(PicaronesError, match="doit être numérique"):
189
+ Deadline.from_dict({"remaining_seconds": "60"})
190
+
191
+ def test_dispatch_overhead_not_charged_to_worker(self) -> None:
192
+ """Propriété critique : créer une deadline de 60s, simuler
193
+ un dispatch (sleep 0.5s), désérialiser → le receveur voit
194
+ un budget *non* réduit par le sleep, parce qu'on transmet
195
+ le ``remaining_seconds`` au moment du handoff, pas du
196
+ moment de la création."""
197
+ original = Deadline.in_seconds(60.0)
198
+ # On capture le serialized maintenant (= au moment du handoff
199
+ # côté émetteur).
200
+ serialized = original.to_dict()
201
+ assert 59.5 < serialized["remaining_seconds"] <= 60.0
202
+
203
+ # Simule un long dispatch (le worker met 5s à recevoir).
204
+ time.sleep(0.5)
205
+
206
+ # Le worker reconstruit depuis le payload reçu. Comme on
207
+ # repart de l'instant *receveur*, le budget restant est
208
+ # ~remaining_seconds du moment du handoff, indépendant du
209
+ # délai de transport.
210
+ reconstructed = Deadline.from_dict(serialized)
211
+ r = reconstructed.remaining_seconds()
212
+ assert r is not None
213
+ # Le receveur voit ~remaining_seconds(handoff) - 0 (il vient
214
+ # de le reconstruire).
215
+ assert 59.0 < r <= 60.0, (
216
+ f"reconstructed remaining={r} : le dispatch overhead "
217
+ f"a été facturé alors qu'il ne devrait pas"
218
+ )
219
+
220
+ def test_pickle_roundtrip_infinite(self) -> None:
221
+ d = Deadline.infinite()
222
+ restored = pickle.loads(pickle.dumps(d))
223
+ assert restored.is_infinite is True
224
+
225
+ def test_pickle_roundtrip_finite(self) -> None:
226
+ d = Deadline.in_seconds(45.0)
227
+ restored = pickle.loads(pickle.dumps(d))
228
+ r = restored.remaining_seconds()
229
+ assert r is not None
230
+ assert 44.0 < r <= 45.0
231
+
232
+ def test_pickle_preserves_handoff_semantics(self) -> None:
233
+ """Même propriété que ``test_dispatch_overhead_not_charged``
234
+ mais via pickle (le canal réel cross-process).
235
+ """
236
+ d = Deadline.in_seconds(30.0)
237
+ payload = pickle.dumps(d)
238
+ time.sleep(0.3)
239
+ restored = pickle.loads(payload)
240
+ r = restored.remaining_seconds()
241
+ assert r is not None
242
+ # On veut voir ~30s, pas ~29.7s — la sleep n'a pas mangé
243
+ # le budget.
244
+ assert 29.5 < r <= 30.0
245
+
246
+ def test_pickle_expired_stays_expired(self) -> None:
247
+ d = Deadline.at_monotonic(0.0)
248
+ assert d.is_expired() is True
249
+ restored = pickle.loads(pickle.dumps(d))
250
+ assert restored.is_expired() is True
251
+
252
+
253
+ # ──────────────────────────────────────────────────────────────────────
254
+ # Immutabilité
255
+ # ──────────────────────────────────────────────────────────────────────
256
+
257
+
258
+ class TestImmutability:
259
+ def test_setattr_raises(self) -> None:
260
+ d = Deadline.in_seconds(10.0)
261
+ with pytest.raises(AttributeError, match="immuable"):
262
+ d._expires_at_monotonic = 0.0
263
+
264
+ def test_setattr_new_field_raises(self) -> None:
265
+ d = Deadline.in_seconds(10.0)
266
+ with pytest.raises(AttributeError, match="immuable"):
267
+ d.new_field = "x" # type: ignore[attr-defined]
268
+
269
+ def test_delattr_raises(self) -> None:
270
+ d = Deadline.in_seconds(10.0)
271
+ with pytest.raises(AttributeError, match="immuable"):
272
+ del d._expires_at_monotonic
273
+
274
+ def test_slots_no_dict(self) -> None:
275
+ """``__slots__`` empêche la création d'un ``__dict__``
276
+ (économie mémoire, pas de mutation accidentelle)."""
277
+ d = Deadline.in_seconds(10.0)
278
+ assert not hasattr(d, "__dict__")
279
+
280
+
281
+ # ──────────────────────────────────────────────────────────────────────
282
+ # Égalité, hash, repr
283
+ # ──────────────────────────────────────────────────────────────────────
284
+
285
+
286
+ class TestDunderProtocols:
287
+ def test_infinite_equals_infinite(self) -> None:
288
+ assert Deadline.infinite() == Deadline.infinite()
289
+
290
+ def test_infinite_not_equals_finite(self) -> None:
291
+ assert Deadline.infinite() != Deadline.in_seconds(10.0)
292
+
293
+ def test_same_absolute_deadline_is_equal(self) -> None:
294
+ """Deux ``Deadline`` construites à partir du même
295
+ ``expires_at_monotonic`` absolu sont égales."""
296
+ t = time.monotonic() + 100.0
297
+ assert Deadline.at_monotonic(t) == Deadline.at_monotonic(t)
298
+
299
+ def test_two_in_seconds_calls_not_equal(self) -> None:
300
+ """Deux appels successifs à ``in_seconds(60)`` créent des
301
+ deadlines avec des ``expires_at_monotonic`` *différents*
302
+ (même de quelques µs). Égalité stricte → non égales.
303
+ Documenté dans le code source.
304
+ """
305
+ d1 = Deadline.in_seconds(60.0)
306
+ d2 = Deadline.in_seconds(60.0)
307
+ # Théoriquement collisions possibles si time.monotonic() ne
308
+ # bouge pas entre les deux appels, mais ultra rare en
309
+ # pratique. Le test peut flaquer ultra rarement — on évite
310
+ # un assert strict ici.
311
+ # On vérifie au moins qu'il ne sont pas systématiquement
312
+ # égaux (i.e. l'égalité n'est pas sur le budget mais sur
313
+ # l'instant absolu).
314
+ if d1._expires_at_monotonic != d2._expires_at_monotonic:
315
+ assert d1 != d2
316
+
317
+ def test_eq_with_non_deadline_returns_not_implemented(self) -> None:
318
+ d = Deadline.infinite()
319
+ # Python protocole __eq__ : si NotImplemented, fallback à
320
+ # ``is``. Donc != est True, pas une exception.
321
+ assert (d == "infinite") is False
322
+ assert (d == 42) is False
323
+
324
+ def test_hashable(self) -> None:
325
+ """``Deadline`` doit être hashable pour pouvoir être utilisée
326
+ comme clé de dict ou élément de set."""
327
+ s = {Deadline.infinite(), Deadline.in_seconds(10.0)}
328
+ assert len(s) == 2
329
+
330
+ def test_hash_consistent_with_eq(self) -> None:
331
+ """Si ``a == b`` alors ``hash(a) == hash(b)``."""
332
+ t = time.monotonic() + 50.0
333
+ a = Deadline.at_monotonic(t)
334
+ b = Deadline.at_monotonic(t)
335
+ assert a == b
336
+ assert hash(a) == hash(b)
337
+
338
+ def test_repr_infinite(self) -> None:
339
+ assert repr(Deadline.infinite()) == "Deadline.infinite()"
340
+
341
+ def test_repr_finite_shows_remaining(self) -> None:
342
+ d = Deadline.in_seconds(42.5)
343
+ r = repr(d)
344
+ assert "remaining" in r
345
+ assert "42" in r
346
+
347
+ def test_repr_expired(self) -> None:
348
+ d = Deadline.at_monotonic(0.0)
349
+ assert repr(d) == "Deadline(expired)"
350
+
351
+
352
+ # ──────────────────────────────────────────────────────────────────────
353
+ # Smoke : la deadline est utilisable dans des patterns réalistes
354
+ # ──────────────────────────────────────────────────────────────────────
355
+
356
+
357
+ class TestRealWorldUsage:
358
+ def test_retry_loop_respects_deadline(self) -> None:
359
+ """Simule un retry helper qui doit s'arrêter quand la
360
+ deadline expire — pattern qui sera implémenté en Phase 3
361
+ dans ``adapters/_retry.py``."""
362
+ deadline = Deadline.in_seconds(0.15) # 150ms
363
+ attempts = 0
364
+ while not deadline.is_expired():
365
+ attempts += 1
366
+ time.sleep(0.05)
367
+ # Avec budget 150ms et sleep 50ms : ~3 tentatives.
368
+ assert 2 <= attempts <= 4
369
+
370
+ def test_clamp_for_retry_backoff(self) -> None:
371
+ """Le retry helper veut attendre ``base ** attempt`` secondes
372
+ mais ne doit jamais dépasser le budget restant. Pattern
373
+ Phase 3."""
374
+ deadline = Deadline.in_seconds(2.0)
375
+ # Backoff "naïf" : 5 secondes. Le clamp doit le borner.
376
+ wait = deadline.clamp_to_remaining(5.0)
377
+ assert wait <= 2.0
378
+ assert wait > 1.5 # Bornes : on n'a pas perdu beaucoup de temps
379
+
380
+ def test_sdk_timeout_pattern(self) -> None:
381
+ """Pattern call-site adapter : passer la deadline au SDK
382
+ comme paramètre ``timeout=``."""
383
+ deadline = Deadline.in_seconds(10.0)
384
+ # En vrai : ``client.call(url, timeout=deadline.as_sdk_timeout())``
385
+ sdk_timeout = deadline.as_sdk_timeout()
386
+ assert sdk_timeout is not None
387
+ assert sdk_timeout > 9.0
388
+
389
+ def test_infinite_deadline_passes_none_to_sdk(self) -> None:
390
+ """Une deadline infinie produit ``None`` ; la plupart des
391
+ SDKs (httpx, requests, pytesseract) interprètent ``None``
392
+ comme 'pas de timeout', ce qui est le comportement voulu."""
393
+ assert Deadline.infinite().as_sdk_timeout() is None