q-runtime-core 0.9.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.
qruntime/budget.py ADDED
@@ -0,0 +1,295 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from qruntime.spend import (
6
+ SpendSummary,
7
+ _normalize_agent_id,
8
+ _normalize_session_id,
9
+ )
10
+ from qruntime.policy import _normalize_origin
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class BudgetLimit:
15
+ network: str
16
+ asset: str
17
+ max_amount_atomic: int
18
+ origin: str | None = None
19
+ agent_id: str | None = None
20
+ session_id: str | None = None
21
+
22
+ def __post_init__(self) -> None:
23
+ if (
24
+ not isinstance(self.network, str)
25
+ or not self.network.strip()
26
+ ):
27
+ raise ValueError(
28
+ "budget network must be "
29
+ "a non-empty string"
30
+ )
31
+
32
+ if (
33
+ not isinstance(self.asset, str)
34
+ or not self.asset.strip()
35
+ ):
36
+ raise ValueError(
37
+ "budget asset must be "
38
+ "a non-empty string"
39
+ )
40
+
41
+ if (
42
+ isinstance(
43
+ self.max_amount_atomic,
44
+ bool,
45
+ )
46
+ or not isinstance(
47
+ self.max_amount_atomic,
48
+ int,
49
+ )
50
+ or self.max_amount_atomic <= 0
51
+ ):
52
+ raise ValueError(
53
+ "budget maximum must be "
54
+ "a positive integer"
55
+ )
56
+
57
+ if self.origin is not None:
58
+ object.__setattr__(
59
+ self,
60
+ "origin",
61
+ _normalize_origin(
62
+ self.origin
63
+ ),
64
+ )
65
+
66
+ if self.agent_id is not None:
67
+ object.__setattr__(
68
+ self,
69
+ "agent_id",
70
+ _normalize_agent_id(
71
+ self.agent_id
72
+ ),
73
+ )
74
+
75
+ if self.session_id is not None:
76
+ object.__setattr__(
77
+ self,
78
+ "session_id",
79
+ _normalize_session_id(
80
+ self.session_id
81
+ ),
82
+ )
83
+
84
+
85
+ @dataclass(frozen=True, slots=True)
86
+ class BudgetDecision:
87
+ allowed: bool
88
+
89
+ network: str
90
+ asset: str
91
+
92
+ limit_atomic: int
93
+
94
+ confirmed_atomic: int
95
+ pending_atomic: int
96
+ requested_atomic: int
97
+
98
+ used_atomic: int
99
+ projected_atomic: int
100
+ remaining_before_atomic: int
101
+ remaining_after_atomic: int
102
+
103
+ reason: str | None = None
104
+ reserved_atomic: int = 0
105
+ origin: str | None = None
106
+ agent_id: str | None = None
107
+ session_id: str | None = None
108
+
109
+
110
+ def evaluate_budget(
111
+ limit: BudgetLimit,
112
+ *,
113
+ summary: SpendSummary,
114
+ requested_amount_atomic: int,
115
+ ) -> BudgetDecision:
116
+ if summary.origin != limit.origin:
117
+ raise ValueError(
118
+ "spend summary origin scope "
119
+ "does not match budget origin"
120
+ )
121
+
122
+ if summary.agent_id != limit.agent_id:
123
+ raise ValueError(
124
+ "spend summary agent scope "
125
+ "does not match budget agent"
126
+ )
127
+
128
+ if summary.session_id != limit.session_id:
129
+ raise ValueError(
130
+ "spend summary session scope "
131
+ "does not match budget session"
132
+ )
133
+
134
+ if (
135
+ isinstance(
136
+ requested_amount_atomic,
137
+ bool,
138
+ )
139
+ or not isinstance(
140
+ requested_amount_atomic,
141
+ int,
142
+ )
143
+ or requested_amount_atomic <= 0
144
+ ):
145
+ raise ValueError(
146
+ "requested amount must be "
147
+ "a positive integer"
148
+ )
149
+
150
+ confirmed = summary.confirmed_amount(
151
+ network=limit.network,
152
+ asset=limit.asset,
153
+ )
154
+
155
+ pending = (
156
+ summary.pending_exposure_amount(
157
+ network=limit.network,
158
+ asset=limit.asset,
159
+ )
160
+ )
161
+
162
+ reserved = (
163
+ summary.reserved_exposure_amount(
164
+ network=limit.network,
165
+ asset=limit.asset,
166
+ )
167
+ )
168
+
169
+ used = (
170
+ confirmed
171
+ + pending
172
+ + reserved
173
+ )
174
+
175
+ projected = (
176
+ used
177
+ + requested_amount_atomic
178
+ )
179
+
180
+ remaining_before = max(
181
+ limit.max_amount_atomic - used,
182
+ 0,
183
+ )
184
+
185
+ remaining_after = max(
186
+ limit.max_amount_atomic
187
+ - projected,
188
+ 0,
189
+ )
190
+
191
+ allowed = (
192
+ projected
193
+ <= limit.max_amount_atomic
194
+ )
195
+
196
+ reason = None
197
+
198
+ if not allowed:
199
+ reason = "aggregate_budget_exceeded"
200
+
201
+ return BudgetDecision(
202
+ allowed=allowed,
203
+ network=limit.network,
204
+ asset=limit.asset,
205
+ limit_atomic=(
206
+ limit.max_amount_atomic
207
+ ),
208
+ confirmed_atomic=confirmed,
209
+ pending_atomic=pending,
210
+ reserved_atomic=reserved,
211
+ origin=limit.origin,
212
+ agent_id=limit.agent_id,
213
+ session_id=limit.session_id,
214
+ requested_atomic=(
215
+ requested_amount_atomic
216
+ ),
217
+ used_atomic=used,
218
+ projected_atomic=projected,
219
+ remaining_before_atomic=(
220
+ remaining_before
221
+ ),
222
+ remaining_after_atomic=(
223
+ remaining_after
224
+ ),
225
+ reason=reason,
226
+ )
227
+
228
+
229
+ @dataclass(frozen=True, slots=True)
230
+ class BudgetSet:
231
+ """Multiple budget constraints that must all allow."""
232
+
233
+ limits: tuple[BudgetLimit, ...]
234
+
235
+ def __post_init__(self) -> None:
236
+ normalized = tuple(self.limits)
237
+
238
+ if not normalized:
239
+ raise ValueError(
240
+ "budget set must contain "
241
+ "at least one limit"
242
+ )
243
+
244
+ for limit in normalized:
245
+ if not isinstance(
246
+ limit,
247
+ BudgetLimit,
248
+ ):
249
+ raise TypeError(
250
+ "budget set entries must "
251
+ "be BudgetLimit instances"
252
+ )
253
+
254
+ seen = set()
255
+
256
+ for limit in normalized:
257
+ key = (
258
+ limit.network,
259
+ limit.asset,
260
+ limit.origin,
261
+ limit.agent_id,
262
+ limit.session_id,
263
+ )
264
+
265
+ if key in seen:
266
+ raise ValueError(
267
+ "budget set contains duplicate "
268
+ "budget scope"
269
+ )
270
+
271
+ seen.add(key)
272
+
273
+ object.__setattr__(
274
+ self,
275
+ "limits",
276
+ normalized,
277
+ )
278
+
279
+
280
+ @dataclass(frozen=True, slots=True)
281
+ class BudgetSetDecision:
282
+ """Result of evaluating all limits in one budget set."""
283
+
284
+ allowed: bool
285
+ decisions: tuple[BudgetDecision, ...]
286
+
287
+ @property
288
+ def first_denied(
289
+ self,
290
+ ) -> BudgetDecision | None:
291
+ for decision in self.decisions:
292
+ if not decision.allowed:
293
+ return decision
294
+
295
+ return None