responsible-request 0.4.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.
- responsible_request/__init__.py +94 -0
- responsible_request/_http.py +31 -0
- responsible_request/analysis.py +81 -0
- responsible_request/cache.py +59 -0
- responsible_request/client.py +169 -0
- responsible_request/config.py +266 -0
- responsible_request/controller.py +79 -0
- responsible_request/cost.py +96 -0
- responsible_request/estimator.py +113 -0
- responsible_request/helpers/__init__.py +21 -0
- responsible_request/helpers/batch.py +127 -0
- responsible_request/helpers/messages.py +18 -0
- responsible_request/helpers/params.py +18 -0
- responsible_request/helpers/structured.py +150 -0
- responsible_request/limiter.py +165 -0
- responsible_request/logfiles.py +120 -0
- responsible_request/logging.py +183 -0
- responsible_request/providers.py +105 -0
- responsible_request/py.typed +0 -0
- responsible_request/records.py +265 -0
- responsible_request/runs.py +116 -0
- responsible_request/sources.py +207 -0
- responsible_request/throttle.py +229 -0
- responsible_request/transport.py +376 -0
- responsible_request-0.4.0.dist-info/METADATA +319 -0
- responsible_request-0.4.0.dist-info/RECORD +28 -0
- responsible_request-0.4.0.dist-info/WHEEL +4 -0
- responsible_request-0.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Read request records back from the log while the client runs (cache lookups, spent cost)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sqlite3
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import IO, Any, Protocol
|
|
11
|
+
|
|
12
|
+
from .logfiles import compression_of, open_binary, rotated_segments
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _Target(Protocol):
|
|
16
|
+
@property
|
|
17
|
+
def sqlite(self) -> str | Path | None: ...
|
|
18
|
+
@property
|
|
19
|
+
def jsonl(self) -> str | Path | None: ...
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _cacheable(row: dict[str, Any]) -> bool:
|
|
23
|
+
return (
|
|
24
|
+
bool(row.get("cache_key"))
|
|
25
|
+
and row.get("status_code") == 200
|
|
26
|
+
and not row.get("error")
|
|
27
|
+
and not row.get("stream")
|
|
28
|
+
and isinstance(row.get("response_body"), dict)
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SQLiteSource:
|
|
33
|
+
"""Looks up responses in the ``requests`` table written by the SQLite log sink."""
|
|
34
|
+
|
|
35
|
+
_QUERY = (
|
|
36
|
+
"SELECT response_body FROM requests WHERE cache_key = ? AND status_code = 200 "
|
|
37
|
+
"AND error IS NULL AND NOT stream AND response_body IS NOT NULL ORDER BY rowid LIMIT 1"
|
|
38
|
+
)
|
|
39
|
+
_COST = "SELECT SUM(cost_usd) FROM requests WHERE NOT cache_hit"
|
|
40
|
+
|
|
41
|
+
def __init__(self, path: str | Path) -> None:
|
|
42
|
+
self.path = Path(path)
|
|
43
|
+
self._conn: sqlite3.Connection | None = None
|
|
44
|
+
|
|
45
|
+
def _query(self, sql: str, *args: Any) -> Any:
|
|
46
|
+
if self._conn is None:
|
|
47
|
+
if not self.path.exists(): # the log sink creates it with the first record
|
|
48
|
+
return None
|
|
49
|
+
self._conn = sqlite3.connect(self.path, check_same_thread=False)
|
|
50
|
+
try:
|
|
51
|
+
return self._conn.execute(sql, args).fetchone()
|
|
52
|
+
except sqlite3.OperationalError: # table or column not created yet
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
def get(self, key: str) -> dict[str, Any] | None:
|
|
56
|
+
row = self._query(self._QUERY, key)
|
|
57
|
+
if row is None:
|
|
58
|
+
return None
|
|
59
|
+
with contextlib.suppress(ValueError):
|
|
60
|
+
body = json.loads(row[0])
|
|
61
|
+
if isinstance(body, dict):
|
|
62
|
+
return body
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
def total_cost(self) -> float:
|
|
66
|
+
"""Sum of ``cost_usd`` over all records that were not cache hits."""
|
|
67
|
+
row = self._query(self._COST)
|
|
68
|
+
return float(row[0]) if row is not None and row[0] is not None else 0.0
|
|
69
|
+
|
|
70
|
+
def close(self) -> None:
|
|
71
|
+
if self._conn is not None:
|
|
72
|
+
self._conn.close()
|
|
73
|
+
self._conn = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class _Rotated(Exception):
|
|
77
|
+
"""The active file was replaced since it was last indexed."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class JSONLSource:
|
|
81
|
+
"""Indexes a JSONL log by cache key and reads matching lines on demand.
|
|
82
|
+
|
|
83
|
+
The segments loguru rotated away from the log are indexed too. Responses from compressed
|
|
84
|
+
segments are kept in memory (only the first one per cache key); everything else is read back
|
|
85
|
+
by byte offset. New lines in the active file are indexed incrementally on each lookup, and a
|
|
86
|
+
rotation while the client runs triggers a full re-index.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(self, path: str | Path) -> None:
|
|
90
|
+
self.path = Path(path)
|
|
91
|
+
self._live = compression_of(self.path) is None # a compressed file does not grow
|
|
92
|
+
self._reset()
|
|
93
|
+
|
|
94
|
+
def _reset(self) -> None:
|
|
95
|
+
# cache key -> (plain file, byte offset of the first matching line) or response JSON
|
|
96
|
+
self._index: dict[str, tuple[Path, int] | str] = {}
|
|
97
|
+
self._cost = 0.0 # cost_usd of the indexed records that were not cache hits
|
|
98
|
+
self._loaded = False # rotated or compressed segments indexed
|
|
99
|
+
self._file_id: tuple[int, int] | None = None # (device, inode) of the active file
|
|
100
|
+
self._offset = 0 # bytes of the active file indexed so far
|
|
101
|
+
|
|
102
|
+
def get(self, key: str) -> dict[str, Any] | None:
|
|
103
|
+
if key not in self._index:
|
|
104
|
+
self._scan()
|
|
105
|
+
for _ in range(2):
|
|
106
|
+
entry = self._index.get(key)
|
|
107
|
+
if entry is None:
|
|
108
|
+
return None
|
|
109
|
+
body = self._read(key, entry)
|
|
110
|
+
if body is not None:
|
|
111
|
+
return body
|
|
112
|
+
# a rotation renamed, compressed or replaced the file since it was indexed
|
|
113
|
+
self._reset()
|
|
114
|
+
self._scan()
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def _read(key: str, entry: tuple[Path, int] | str) -> dict[str, Any] | None:
|
|
119
|
+
if isinstance(entry, str):
|
|
120
|
+
body: dict[str, Any] = json.loads(entry)
|
|
121
|
+
return body
|
|
122
|
+
path, pos = entry
|
|
123
|
+
try:
|
|
124
|
+
with path.open("rb") as fh:
|
|
125
|
+
fh.seek(pos)
|
|
126
|
+
row = json.loads(fh.readline())
|
|
127
|
+
except (FileNotFoundError, ValueError):
|
|
128
|
+
return None
|
|
129
|
+
if not isinstance(row, dict) or row.get("cache_key") != key:
|
|
130
|
+
return None
|
|
131
|
+
body = row["response_body"]
|
|
132
|
+
return body
|
|
133
|
+
|
|
134
|
+
def _scan(self) -> None:
|
|
135
|
+
for _ in range(5):
|
|
136
|
+
try:
|
|
137
|
+
if not self._loaded:
|
|
138
|
+
self._load_segments()
|
|
139
|
+
if self._live:
|
|
140
|
+
self._scan_active()
|
|
141
|
+
return
|
|
142
|
+
except (FileNotFoundError, _Rotated): # loguru renamed or compressed a file under us
|
|
143
|
+
self._reset()
|
|
144
|
+
|
|
145
|
+
def _load_segments(self) -> None:
|
|
146
|
+
segments = rotated_segments(self.path) if self._live else [self.path]
|
|
147
|
+
for segment in segments:
|
|
148
|
+
with open_binary(segment) as fh:
|
|
149
|
+
self._index_lines(fh, segment, 0, in_memory=compression_of(segment) is not None)
|
|
150
|
+
self._loaded = True
|
|
151
|
+
if self._live:
|
|
152
|
+
self._scan_active()
|
|
153
|
+
if rotated_segments(self.path) != segments: # rotated while we were reading
|
|
154
|
+
raise _Rotated
|
|
155
|
+
|
|
156
|
+
def _scan_active(self) -> None:
|
|
157
|
+
try:
|
|
158
|
+
fh = self.path.open("rb")
|
|
159
|
+
except FileNotFoundError: # nothing written yet, or in the middle of a rotation
|
|
160
|
+
return
|
|
161
|
+
with fh:
|
|
162
|
+
stat = os.fstat(fh.fileno())
|
|
163
|
+
file_id = (stat.st_dev, stat.st_ino)
|
|
164
|
+
if self._file_id is None:
|
|
165
|
+
self._file_id = file_id
|
|
166
|
+
elif file_id != self._file_id or stat.st_size < self._offset:
|
|
167
|
+
raise _Rotated
|
|
168
|
+
if stat.st_size > self._offset:
|
|
169
|
+
fh.seek(self._offset)
|
|
170
|
+
self._offset = self._index_lines(fh, self.path, self._offset, in_memory=False)
|
|
171
|
+
|
|
172
|
+
def _index_lines(self, fh: IO[bytes], path: Path, pos: int, *, in_memory: bool) -> int:
|
|
173
|
+
for line in fh:
|
|
174
|
+
if not line.endswith(b"\n"): # still being written
|
|
175
|
+
break
|
|
176
|
+
with contextlib.suppress(ValueError):
|
|
177
|
+
row = json.loads(line)
|
|
178
|
+
if not isinstance(row, dict):
|
|
179
|
+
continue
|
|
180
|
+
if _cacheable(row) and row["cache_key"] not in self._index:
|
|
181
|
+
self._index[row["cache_key"]] = (
|
|
182
|
+
json.dumps(row["response_body"]) if in_memory else (path, pos)
|
|
183
|
+
)
|
|
184
|
+
if not row.get("cache_hit"):
|
|
185
|
+
self._cost += float(row.get("cost_usd") or 0.0)
|
|
186
|
+
pos += len(line)
|
|
187
|
+
return pos
|
|
188
|
+
|
|
189
|
+
def total_cost(self) -> float:
|
|
190
|
+
"""Sum of ``cost_usd`` over all records that were not cache hits."""
|
|
191
|
+
self._scan()
|
|
192
|
+
return self._cost
|
|
193
|
+
|
|
194
|
+
def close(self) -> None:
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
RecordSource = SQLiteSource | JSONLSource
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def open_source(target: _Target | None) -> RecordSource | None:
|
|
202
|
+
"""A reader for the SQLite database of ``target``, else its JSONL file, else None."""
|
|
203
|
+
if target is not None and target.sqlite is not None:
|
|
204
|
+
return SQLiteSource(target.sqlite)
|
|
205
|
+
if target is not None and target.jsonl is not None:
|
|
206
|
+
return JSONLSource(target.jsonl)
|
|
207
|
+
return None
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Per-model throttling state: limiter + load estimator + controller."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from loguru import logger
|
|
13
|
+
|
|
14
|
+
from .config import ThrottleConfig
|
|
15
|
+
from .controller import State, ThrottleController, Transition
|
|
16
|
+
from .estimator import LatencyEstimator, LoadEstimator
|
|
17
|
+
from .limiter import AdaptiveLimiter
|
|
18
|
+
from .records import RequestRecord
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class LaneStats:
|
|
23
|
+
requests: int = 0
|
|
24
|
+
errors: int = 0
|
|
25
|
+
prompt_tokens: int = 0
|
|
26
|
+
completion_tokens: int = 0
|
|
27
|
+
cost_usd: float = 0.0
|
|
28
|
+
|
|
29
|
+
def add(self, record: RequestRecord) -> None:
|
|
30
|
+
self.requests += 1
|
|
31
|
+
if record.error or (record.status_code or 0) >= 400:
|
|
32
|
+
self.errors += 1
|
|
33
|
+
self.prompt_tokens += record.prompt_tokens or 0
|
|
34
|
+
self.completion_tokens += record.completion_tokens or 0
|
|
35
|
+
self.cost_usd += record.cost_usd or 0.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Lane:
|
|
39
|
+
"""Throttling state for one (endpoint, model) pair.
|
|
40
|
+
|
|
41
|
+
Endpoints usually serve every model from its own backend, so load is tracked per model.
|
|
42
|
+
Lanes for endpoints that are not in ``observe_paths`` are paced and react to server errors,
|
|
43
|
+
but they do not adapt to latency.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
name: str,
|
|
49
|
+
config: ThrottleConfig,
|
|
50
|
+
*,
|
|
51
|
+
observed: bool,
|
|
52
|
+
clock: Callable[[], float] = time.monotonic,
|
|
53
|
+
rng: random.Random | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
self.name = name
|
|
56
|
+
self.config = config
|
|
57
|
+
self.observed = observed
|
|
58
|
+
self.limiter = AdaptiveLimiter(config.initial_rpm, config.max_concurrency)
|
|
59
|
+
self.estimator: LoadEstimator = (
|
|
60
|
+
config.estimator_factory(config)
|
|
61
|
+
if config.estimator_factory is not None
|
|
62
|
+
else LatencyEstimator(config, clock=clock)
|
|
63
|
+
)
|
|
64
|
+
self.controller = ThrottleController(config, clock=clock)
|
|
65
|
+
self.totals = LaneStats()
|
|
66
|
+
self.since_summary = LaneStats()
|
|
67
|
+
self.last_record: RequestRecord | None = None
|
|
68
|
+
self._rng = rng or random.Random()
|
|
69
|
+
self._backoff_level = 0 # consecutive 429s without Retry-After (fixed mode)
|
|
70
|
+
self._lock = threading.Lock()
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def rpm(self) -> float:
|
|
74
|
+
return self.controller.rpm
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def state(self) -> State:
|
|
78
|
+
return self.controller.state
|
|
79
|
+
|
|
80
|
+
def observe(
|
|
81
|
+
self, record: RequestRecord, *, eligible: bool, retry_after: float | None = None
|
|
82
|
+
) -> Transition | None:
|
|
83
|
+
"""Update load estimate and rate after a request finished.
|
|
84
|
+
|
|
85
|
+
``retry_after`` is the server's ``Retry-After`` in seconds, if it sent one.
|
|
86
|
+
"""
|
|
87
|
+
with self._lock:
|
|
88
|
+
if not self.config.adaptive:
|
|
89
|
+
self._handle_fixed(record, retry_after)
|
|
90
|
+
return self._observe(record, eligible=eligible)
|
|
91
|
+
|
|
92
|
+
def _handle_fixed(self, record: RequestRecord, retry_after: float | None) -> None:
|
|
93
|
+
"""Fixed mode: pause this lane after a 429 instead of changing the rate."""
|
|
94
|
+
status = record.status_code
|
|
95
|
+
if status is not None and status < 400:
|
|
96
|
+
self._backoff_level = 0
|
|
97
|
+
if status != 429:
|
|
98
|
+
return
|
|
99
|
+
cfg = self.config
|
|
100
|
+
if retry_after is not None:
|
|
101
|
+
delay, reason = retry_after, "Retry-After"
|
|
102
|
+
else:
|
|
103
|
+
# 429s of requests sent before the current pause began do not raise the level.
|
|
104
|
+
if self.limiter.paused_s == 0 or self._backoff_level == 0:
|
|
105
|
+
self._backoff_level += 1
|
|
106
|
+
exponent = min(self._backoff_level - 1, 30)
|
|
107
|
+
delay = min(cfg.backoff_max_s, cfg.backoff_initial_s * 2**exponent)
|
|
108
|
+
reason = f"backoff #{self._backoff_level}"
|
|
109
|
+
delay += self._rng.uniform(0, cfg.backoff_jitter_s)
|
|
110
|
+
self.limiter.pause(delay)
|
|
111
|
+
logger.warning("{}: HTTP 429, pausing {:.1f} s ({})", self.name, delay, reason)
|
|
112
|
+
|
|
113
|
+
def _observe(self, record: RequestRecord, *, eligible: bool) -> Transition | None:
|
|
114
|
+
self.last_record = record
|
|
115
|
+
self.totals.add(record)
|
|
116
|
+
self.since_summary.add(record)
|
|
117
|
+
congested = is_congestion(record)
|
|
118
|
+
if not self.observed:
|
|
119
|
+
ratio: float | None = 1.0 # non-latency lanes only react to server errors
|
|
120
|
+
else:
|
|
121
|
+
if eligible and not congested and record.error is None:
|
|
122
|
+
self.estimator.observe(record, throttled=self.state is State.THROTTLED)
|
|
123
|
+
ratio = self.estimator.load_ratio()
|
|
124
|
+
transition = self.controller.update(ratio, congested=congested)
|
|
125
|
+
if transition is not None:
|
|
126
|
+
self.limiter.set_rpm(self.controller.rpm)
|
|
127
|
+
_log_transition(self.name, transition)
|
|
128
|
+
return transition
|
|
129
|
+
|
|
130
|
+
def snapshot(self) -> dict[str, Any]:
|
|
131
|
+
with self._lock:
|
|
132
|
+
return self._snapshot()
|
|
133
|
+
|
|
134
|
+
def _snapshot(self) -> dict[str, Any]:
|
|
135
|
+
return {
|
|
136
|
+
"state": self.state.value,
|
|
137
|
+
"rpm": round(self.rpm, 2),
|
|
138
|
+
"baseline": self.estimator.baseline,
|
|
139
|
+
"load_ratio": self.estimator.load_ratio() if self.observed else None,
|
|
140
|
+
"in_flight": self.limiter.in_flight,
|
|
141
|
+
"paused_s": round(self.limiter.paused_s, 2),
|
|
142
|
+
"requests": self.totals.requests,
|
|
143
|
+
"errors": self.totals.errors,
|
|
144
|
+
"prompt_tokens": self.totals.prompt_tokens,
|
|
145
|
+
"completion_tokens": self.totals.completion_tokens,
|
|
146
|
+
"cost_usd": round(self.totals.cost_usd, 6),
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def is_congestion(record: RequestRecord) -> bool:
|
|
151
|
+
"""429, 5xx and timeouts mean the server is struggling."""
|
|
152
|
+
status = record.status_code
|
|
153
|
+
if status is not None and (status == 429 or status >= 500):
|
|
154
|
+
return True
|
|
155
|
+
return record.error is not None and "Timeout" in record.error
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _log_transition(name: str, t: Transition) -> None:
|
|
159
|
+
if t.new_state is State.THROTTLED and t.old_state is not State.THROTTLED:
|
|
160
|
+
logger.warning(
|
|
161
|
+
"{}: {} -> throttling {:.0f} -> {:.0f} RPM", name, t.reason, t.old_rpm, t.new_rpm
|
|
162
|
+
)
|
|
163
|
+
elif t.old_rpm != t.new_rpm:
|
|
164
|
+
logger.info(
|
|
165
|
+
"{}: {} ({:.0f} -> {:.0f} RPM, {})",
|
|
166
|
+
name,
|
|
167
|
+
t.reason,
|
|
168
|
+
t.old_rpm,
|
|
169
|
+
t.new_rpm,
|
|
170
|
+
t.new_state.value,
|
|
171
|
+
)
|
|
172
|
+
else:
|
|
173
|
+
logger.info("{}: {} ({})", name, t.reason, t.new_state.value)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class Throttle:
|
|
177
|
+
"""All lanes of one client. Available as ``client.throttle`` on :class:`rr.AsyncOpenAI`.
|
|
178
|
+
|
|
179
|
+
To enforce one rate across several clients, e.g. one per thread with its own
|
|
180
|
+
``asyncio.run``, create one ``Throttle`` and pass it as ``throttle=`` to each client.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
def __init__(
|
|
184
|
+
self, config: ThrottleConfig | None = None, clock: Callable[[], float] = time.monotonic
|
|
185
|
+
) -> None:
|
|
186
|
+
self.config = config or ThrottleConfig()
|
|
187
|
+
self._clock = clock
|
|
188
|
+
self._rng = random.Random() # seeded from OS entropy: jitter differs between clients
|
|
189
|
+
self._lanes: dict[tuple[str, str | None], Lane] = {}
|
|
190
|
+
self._lock = threading.Lock()
|
|
191
|
+
|
|
192
|
+
def lane(self, path: str, model: str | None) -> Lane:
|
|
193
|
+
key = (path, model)
|
|
194
|
+
with self._lock:
|
|
195
|
+
lane = self._lanes.get(key)
|
|
196
|
+
if lane is None:
|
|
197
|
+
observed = self.config.adaptive and any(
|
|
198
|
+
path.endswith(p) for p in self.config.observe_paths
|
|
199
|
+
)
|
|
200
|
+
name = model or path
|
|
201
|
+
lane = Lane(name, self.config, observed=observed, clock=self._clock, rng=self._rng)
|
|
202
|
+
self._lanes[key] = lane
|
|
203
|
+
return lane
|
|
204
|
+
|
|
205
|
+
def lanes(self) -> list[Lane]:
|
|
206
|
+
with self._lock:
|
|
207
|
+
return list(self._lanes.values())
|
|
208
|
+
|
|
209
|
+
def find(self, model: str) -> list[Lane]:
|
|
210
|
+
with self._lock:
|
|
211
|
+
return [lane for (_, m), lane in self._lanes.items() if m == model]
|
|
212
|
+
|
|
213
|
+
def stats(self) -> dict[str, dict[str, Any]]:
|
|
214
|
+
"""Current state, rate, load and totals per lane (keyed by model, or path)."""
|
|
215
|
+
with self._lock:
|
|
216
|
+
items = list(self._lanes.items())
|
|
217
|
+
out: dict[str, dict[str, Any]] = {}
|
|
218
|
+
for (path, model), lane in items:
|
|
219
|
+
key = model if model is not None else path
|
|
220
|
+
if key in out:
|
|
221
|
+
key = f"{model} {path}"
|
|
222
|
+
out[key] = lane.snapshot()
|
|
223
|
+
return out
|
|
224
|
+
|
|
225
|
+
def reset_baseline(self, model: str | None = None) -> None:
|
|
226
|
+
"""Forget learned baselines (of one model, or all)."""
|
|
227
|
+
for lane in self.lanes():
|
|
228
|
+
if model is None or lane.name == model:
|
|
229
|
+
lane.estimator.reset()
|