stacktrace-cli 0.8.0__py3-none-any.whl → 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.
- stacktrace_cli/__init__.py +1 -1
- stacktrace_cli/analysis.py +13 -1
- stacktrace_cli/cli.py +3 -1
- stacktrace_cli/cost/__init__.py +6 -0
- stacktrace_cli/cost/baseline.py +493 -0
- stacktrace_cli/cost/clock.py +40 -0
- stacktrace_cli/cost/components.py +143 -0
- stacktrace_cli/cost/evidence.py +69 -0
- stacktrace_cli/cost/ledger.py +58 -0
- stacktrace_cli/cost/measures.py +351 -0
- stacktrace_cli/cost/models.py +38 -0
- stacktrace_cli/cost/rules.py +1138 -0
- stacktrace_cli/cost/runs.py +126 -0
- stacktrace_cli/cost/tokens.py +69 -0
- stacktrace_cli/cost/totals.py +62 -0
- stacktrace_cli/daemon/cli.py +46 -0
- stacktrace_cli/daemon/config.py +42 -0
- stacktrace_cli/daemon/costs.py +882 -0
- stacktrace_cli/daemon/db.py +65 -0
- stacktrace_cli/daemon/fleet.py +3 -1
- stacktrace_cli/daemon/jobs.py +90 -0
- stacktrace_cli/daemon/observer.py +17 -2
- stacktrace_cli/daemon/presentation.py +33 -0
- stacktrace_cli/daemon/runtime.py +107 -8
- stacktrace_cli/daemon/scan.py +31 -0
- stacktrace_cli/daemon/store.py +243 -2
- stacktrace_cli/detector/finding.py +1 -1
- stacktrace_cli/detector/render.py +14 -12
- stacktrace_cli/detector/rules.py +58 -4
- stacktrace_cli/monitor/costs.py +375 -0
- stacktrace_cli/monitor/offline.py +266 -0
- stacktrace_cli/monitor/render.py +79 -21
- stacktrace_cli/monitor/server.py +5 -0
- stacktrace_cli/monitor/site/app.js +64 -1
- stacktrace_cli/monitor/site/cost.js +436 -0
- stacktrace_cli/monitor/site/index.html +8 -0
- stacktrace_cli/monitor/site/inventory.js +1 -1
- stacktrace_cli/monitor/site/styles.css +23 -0
- stacktrace_cli/monitor/state.py +5 -0
- stacktrace_cli/monitor/watch.py +16 -0
- stacktrace_cli/sessions/lineage.py +24 -0
- stacktrace_cli/sessions/protocols.py +81 -8
- {stacktrace_cli-0.8.0.dist-info → stacktrace_cli-0.9.0.dist-info}/METADATA +2 -2
- {stacktrace_cli-0.8.0.dist-info → stacktrace_cli-0.9.0.dist-info}/RECORD +46 -29
- {stacktrace_cli-0.8.0.dist-info → stacktrace_cli-0.9.0.dist-info}/WHEEL +0 -0
- {stacktrace_cli-0.8.0.dist-info → stacktrace_cli-0.9.0.dist-info}/entry_points.txt +0 -0
stacktrace_cli/__init__.py
CHANGED
stacktrace_cli/analysis.py
CHANGED
|
@@ -62,6 +62,11 @@ class Analysis:
|
|
|
62
62
|
#: is not yet countable and that only calls needing no composition are
|
|
63
63
|
#: listed. `analyse` is always placed.
|
|
64
64
|
placed: bool = True
|
|
65
|
+
#: `newest_hour` on `analyse_progressively`'s first preview, which reads
|
|
66
|
+
#: only the newest hour of the range; None for the whole range. A page
|
|
67
|
+
#: summing totals needs to tell the two apart, and `placed` cannot: both
|
|
68
|
+
#: previews are unplaced.
|
|
69
|
+
slice: str | None = None
|
|
65
70
|
|
|
66
71
|
|
|
67
72
|
#: A count and an optional unit, anchored. Anchored rather than searched
|
|
@@ -451,6 +456,7 @@ def analyse_progressively(
|
|
|
451
456
|
preview: timedelta | None = PREVIEW_WINDOW,
|
|
452
457
|
cache: VerdictCache | None = None,
|
|
453
458
|
attach_advisories: Any = None,
|
|
459
|
+
build_all: Any = None,
|
|
454
460
|
analyzer: str = NO_ANALYZER,
|
|
455
461
|
jev_key: str | None = None,
|
|
456
462
|
history: DriftHistory | None = None,
|
|
@@ -463,6 +469,8 @@ def analyse_progressively(
|
|
|
463
469
|
`history` is accepted and unused: this path never requests reasoning, so
|
|
464
470
|
it never adds a point. It is here so the watcher can forward one options
|
|
465
471
|
dict to both entry points (the test in `tests/monitor/test_watch.py`).
|
|
472
|
+
`build_all` is `analyse`'s own seam, forwarded for the same reason: the
|
|
473
|
+
offline monitor (`monitor/offline.py`) passes one that builds nothing.
|
|
466
474
|
|
|
467
475
|
`analyse` answers once, which is right for a command that prints and exits
|
|
468
476
|
and wrong for a page meant to look alive. Measured over 339 real sessions
|
|
@@ -510,7 +518,10 @@ def analyse_progressively(
|
|
|
510
518
|
cutoff = window_end - preview
|
|
511
519
|
# A window already narrower than the preview is read once, not twice.
|
|
512
520
|
if cutoff > window_start:
|
|
513
|
-
yield
|
|
521
|
+
yield replace(
|
|
522
|
+
_unplaced(read(cutoff), window_start=window_start, window_end=window_end),
|
|
523
|
+
slice="newest_hour",
|
|
524
|
+
)
|
|
514
525
|
|
|
515
526
|
sessions = read(window_start)
|
|
516
527
|
if preview is not None:
|
|
@@ -521,6 +532,7 @@ def analyse_progressively(
|
|
|
521
532
|
bom_paths=bom_paths,
|
|
522
533
|
project_map=project_map,
|
|
523
534
|
attach_advisories=attach_advisories,
|
|
535
|
+
build_all=build_all,
|
|
524
536
|
).view
|
|
525
537
|
|
|
526
538
|
def _stage(run: DetectorRun, sessions: tuple[CorrelatedSession, ...]) -> Analysis:
|
stacktrace_cli/cli.py
CHANGED
|
@@ -411,7 +411,9 @@ def detect(
|
|
|
411
411
|
infrastructure change beyond what the user asked for are security; why
|
|
412
412
|
the agent stopped, and who can unblock it, is reliability.
|
|
413
413
|
The two families carry their own severity ladders, because a blocked agent
|
|
414
|
-
and a leaked credential cannot share one (ADR-0010).
|
|
414
|
+
and a leaked credential cannot share one (ADR-0010). Cost findings are the
|
|
415
|
+
daemon's, which judges a whole day and a 30-day baseline (ADR-0097);
|
|
416
|
+
`stacktrace findings` shows them.
|
|
415
417
|
|
|
416
418
|
Both stages read the record on this machine. Neither needs a model, a
|
|
417
419
|
credential or an account, and on a default run nothing derived from a
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""What agent work uses, in tokens, and how far above the usual (ADR-0099).
|
|
2
|
+
|
|
3
|
+
Pure functions over the session shape (`sessions/protocols.py`). Nothing here
|
|
4
|
+
reads a file, a clock or the store; hosts pass those in. Nothing here prices:
|
|
5
|
+
every figure is tokens, or a ratio to the usual.
|
|
6
|
+
"""
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
"""The environment's baseline: what this developer's work usually looks like.
|
|
2
|
+
|
|
3
|
+
Two halves (ADR-0093):
|
|
4
|
+
|
|
5
|
+
- **`summarize(run)`** turns one finished run into one row of numbers: its
|
|
6
|
+
tokens by active minute, its 5-minute burn, its behaviour measures, and its
|
|
7
|
+
tokens by model and quarter hour of the day. Numbers only, never content. The
|
|
8
|
+
daemon stores one row per finished main session (`cost_baselines`).
|
|
9
|
+
- **`snapshot(rows, ...)`** turns 30 days of rows into the percentiles the
|
|
10
|
+
rules compare against, by cohort `(repo, entrypoint, model)`, widening to
|
|
11
|
+
`(entrypoint, model)` and then `(model)` below 20 sessions. Frozen for each
|
|
12
|
+
evaluation, so a rule's output never depends on when it was read.
|
|
13
|
+
|
|
14
|
+
Tokens, never dollars, and recorded tokens only: nothing else ever
|
|
15
|
+
enters a baseline.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import math
|
|
21
|
+
from collections.abc import Iterable, Iterator, Mapping, Sequence
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from datetime import UTC, date, datetime, time, timedelta, tzinfo
|
|
24
|
+
from statistics import median
|
|
25
|
+
from typing import Any, TypeVar
|
|
26
|
+
|
|
27
|
+
from stacktrace_cli.cost.measures import (
|
|
28
|
+
active_minutes,
|
|
29
|
+
cache_rewrites,
|
|
30
|
+
calls_in_order,
|
|
31
|
+
context_tokens,
|
|
32
|
+
cumulative_by_active_minute,
|
|
33
|
+
fresh_by_design,
|
|
34
|
+
minute_burn,
|
|
35
|
+
most_started_within,
|
|
36
|
+
recorded_tokens,
|
|
37
|
+
repeats,
|
|
38
|
+
tokens_by_slot,
|
|
39
|
+
)
|
|
40
|
+
from stacktrace_cli.cost.models import canonical_model
|
|
41
|
+
from stacktrace_cli.cost.runs import Run
|
|
42
|
+
from stacktrace_cli.cost.tokens import Tokens
|
|
43
|
+
from stacktrace_cli.sessions.protocols import ModelResponseLike, SessionLike
|
|
44
|
+
|
|
45
|
+
#: A run's curves are kept to this many active minutes.
|
|
46
|
+
MAX_MINUTES = 240
|
|
47
|
+
#: The fewest sessions a cohort level needs before a rule may use it.
|
|
48
|
+
MIN_SESSIONS = 20
|
|
49
|
+
#: The fewest working days the day curve needs.
|
|
50
|
+
MIN_WORKING_DAYS = 10
|
|
51
|
+
#: A context counts as large above this many tokens (cache-collapse).
|
|
52
|
+
LARGE_CONTEXT = 50_000
|
|
53
|
+
#: How long since a run's last response before it counts as finished.
|
|
54
|
+
FINISHED_AFTER = timedelta(hours=1)
|
|
55
|
+
#: How far back the baseline reaches.
|
|
56
|
+
WINDOW = timedelta(days=30)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def baseline_since(now: datetime, zone: tzinfo | None) -> datetime:
|
|
60
|
+
"""Where the baseline's window begins: the start of the local day 30 days
|
|
61
|
+
before today. A run is in the window while its last response's local date
|
|
62
|
+
is, so the window, and every baseline built from it, moves only at
|
|
63
|
+
midnight (cost-phase-1 §1, "Finished run"). In UTC, as the store keeps
|
|
64
|
+
times."""
|
|
65
|
+
zone = zone or UTC
|
|
66
|
+
first = now.astimezone(zone).date() - timedelta(days=WINDOW.days)
|
|
67
|
+
return datetime.combine(first, time.min, tzinfo=zone).astimezone(UTC)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
Cohort = tuple[str | None, str | None, str | None]
|
|
71
|
+
#: Sorts an undated response first; only ever a sort key.
|
|
72
|
+
_NEVER = datetime.min.replace(tzinfo=UTC)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class Summary:
|
|
77
|
+
"""One finished run, as numbers."""
|
|
78
|
+
|
|
79
|
+
run_id: str
|
|
80
|
+
agent_kind: str | None
|
|
81
|
+
cohort: Cohort
|
|
82
|
+
day: date
|
|
83
|
+
last_activity_at: datetime
|
|
84
|
+
#: Recorded tokens by the end of each active minute.
|
|
85
|
+
curve: tuple[int, ...]
|
|
86
|
+
#: Recorded tokens in the 5 minutes ending at each active minute.
|
|
87
|
+
burn: tuple[int, ...]
|
|
88
|
+
tokens: int
|
|
89
|
+
subagents_in_10m: int
|
|
90
|
+
repeats: int
|
|
91
|
+
input_per_response: int
|
|
92
|
+
#: The lowest cache-read share over 5 responses running on a large
|
|
93
|
+
#: context; None when the run never had 5 in a row, so it says nothing
|
|
94
|
+
#: about the cache and stays out of the cohort's percentiles.
|
|
95
|
+
lowest_cache_share: float | None
|
|
96
|
+
cache_rewrites: int
|
|
97
|
+
#: Recorded tokens by `date|model|quarter-hour`, each response on its own
|
|
98
|
+
#: local date; read through `dated_slots`.
|
|
99
|
+
slots: Mapping[str, int] = field(default_factory=dict)
|
|
100
|
+
|
|
101
|
+
def to_json(self) -> dict[str, Any]:
|
|
102
|
+
return {
|
|
103
|
+
"curve": list(self.curve),
|
|
104
|
+
"burn": list(self.burn),
|
|
105
|
+
"tokens": self.tokens,
|
|
106
|
+
"subagents_in_10m": self.subagents_in_10m,
|
|
107
|
+
"repeats": self.repeats,
|
|
108
|
+
"input_per_response": self.input_per_response,
|
|
109
|
+
"lowest_cache_share": self.lowest_cache_share,
|
|
110
|
+
"cache_rewrites": self.cache_rewrites,
|
|
111
|
+
"slots": dict(self.slots),
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def from_row(
|
|
116
|
+
cls,
|
|
117
|
+
*,
|
|
118
|
+
run_id: str,
|
|
119
|
+
agent_kind: str | None,
|
|
120
|
+
cohort: Cohort,
|
|
121
|
+
day: date,
|
|
122
|
+
last_activity_at: datetime,
|
|
123
|
+
summary: Mapping[str, Any],
|
|
124
|
+
) -> Summary:
|
|
125
|
+
return cls(
|
|
126
|
+
run_id=run_id,
|
|
127
|
+
agent_kind=agent_kind,
|
|
128
|
+
cohort=cohort,
|
|
129
|
+
day=day,
|
|
130
|
+
last_activity_at=last_activity_at,
|
|
131
|
+
curve=tuple(int(x) for x in summary.get("curve", ())),
|
|
132
|
+
burn=tuple(int(x) for x in summary.get("burn", ())),
|
|
133
|
+
tokens=int(summary.get("tokens", 0)),
|
|
134
|
+
subagents_in_10m=int(summary.get("subagents_in_10m", 0)),
|
|
135
|
+
repeats=int(summary.get("repeats", 0)),
|
|
136
|
+
input_per_response=int(summary.get("input_per_response", 0)),
|
|
137
|
+
lowest_cache_share=(
|
|
138
|
+
None
|
|
139
|
+
if summary.get("lowest_cache_share") is None
|
|
140
|
+
else float(summary["lowest_cache_share"])
|
|
141
|
+
),
|
|
142
|
+
cache_rewrites=int(summary.get("cache_rewrites", 0)),
|
|
143
|
+
slots={str(k): int(v) for k, v in dict(summary.get("slots", {})).items()},
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def is_finished(run: Run[Any], now: datetime) -> bool:
|
|
148
|
+
"""Whether nothing has been recorded in the run for an hour: no response
|
|
149
|
+
and no turn. A new prompt resumes a run before any reply to it, so a run
|
|
150
|
+
the baseline holds leaves it from that prompt on (cost-phase-1 §1,
|
|
151
|
+
"Finished run")."""
|
|
152
|
+
moments = [
|
|
153
|
+
moment
|
|
154
|
+
for session in run.sessions
|
|
155
|
+
for moment in (
|
|
156
|
+
*(r.occurred_at for r in session.responses),
|
|
157
|
+
*(t.occurred_at for t in getattr(session, "turns", ())),
|
|
158
|
+
)
|
|
159
|
+
if moment is not None
|
|
160
|
+
]
|
|
161
|
+
return bool(moments) and now - max(moments) >= FINISHED_AFTER
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
L = TypeVar("L", bound=SessionLike)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def summarize(run: Run[L], zone: tzinfo) -> Summary | None:
|
|
168
|
+
"""A run as one row of numbers, or None for a run with no main session or
|
|
169
|
+
no dated response."""
|
|
170
|
+
main = run.main
|
|
171
|
+
responses = _responses(run)
|
|
172
|
+
last = _last_activity(run)
|
|
173
|
+
if main is None or not responses or last is None:
|
|
174
|
+
return None
|
|
175
|
+
# A session is judged together with its sub-agents, so its curve counts
|
|
176
|
+
# theirs: a fan-out of small sub-agents is how a session spends fast.
|
|
177
|
+
curve = cumulative_by_active_minute(responses)[:MAX_MINUTES]
|
|
178
|
+
minutes = active_minutes(responses)[:MAX_MINUTES]
|
|
179
|
+
burn = tuple(minute_burn(responses, minute) for minute in minutes)
|
|
180
|
+
found = repeats(calls_in_order(run.sessions))
|
|
181
|
+
slots = {
|
|
182
|
+
f"{day.isoformat()}|{model}|{slot}": tokens
|
|
183
|
+
for (day, model, slot), tokens in tokens_by_slot(responses, zone).items()
|
|
184
|
+
}
|
|
185
|
+
return Summary(
|
|
186
|
+
run_id=run.run_id,
|
|
187
|
+
agent_kind=run.agent_kind,
|
|
188
|
+
cohort=cohort_of(run),
|
|
189
|
+
day=last.astimezone(zone).date(),
|
|
190
|
+
last_activity_at=last,
|
|
191
|
+
curve=curve,
|
|
192
|
+
burn=burn,
|
|
193
|
+
tokens=sum(recorded_tokens(r) for r in responses),
|
|
194
|
+
subagents_in_10m=most_started_within(
|
|
195
|
+
(s.started_at for s in run.subagents), timedelta(minutes=10)
|
|
196
|
+
),
|
|
197
|
+
repeats=found[0].repeats if found else 0,
|
|
198
|
+
input_per_response=_p90([context_tokens(r) for r in responses]),
|
|
199
|
+
lowest_cache_share=lowest_cache_share(main),
|
|
200
|
+
cache_rewrites=len(rewrites_on_a_large_context(run.sessions)),
|
|
201
|
+
slots=slots,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def cohort_of(run: Run[Any]) -> Cohort:
|
|
206
|
+
"""`(repo, entrypoint, model)`: the main session's working directory and
|
|
207
|
+
how it was driven, and the model that carried most of the run's tokens,
|
|
208
|
+
its sub-agents' included. A run's figures are the whole run's, so its
|
|
209
|
+
model is too: one whose sub-agents ran mostly on another model is judged
|
|
210
|
+
against that model's history, not its main session's."""
|
|
211
|
+
by_model: dict[str | None, int] = {}
|
|
212
|
+
for session in run.sessions:
|
|
213
|
+
for response in session.responses:
|
|
214
|
+
# A response without a time counts nowhere, the cohort included
|
|
215
|
+
# (§1). One without a model counts toward an unknown model, so its
|
|
216
|
+
# tokens are not handed to whichever model was named.
|
|
217
|
+
if response.occurred_at is not None:
|
|
218
|
+
key = canonical_model(response.model) if response.model else None
|
|
219
|
+
by_model[key] = by_model.get(key, 0) + recorded_tokens(response)
|
|
220
|
+
model = max(by_model, key=lambda m: by_model[m]) if by_model else None
|
|
221
|
+
main = run.main or run.sessions[0]
|
|
222
|
+
return (main.working_directory, main.entrypoint, model)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def rewrites_on_a_large_context(
|
|
226
|
+
sessions: Iterable[SessionLike],
|
|
227
|
+
) -> list[ModelResponseLike]:
|
|
228
|
+
"""Cache re-writes on a context over `LARGE_CONTEXT`, in every session:
|
|
229
|
+
what `cache-collapse` counts, and so what its baseline counts."""
|
|
230
|
+
found = [r for s in sessions for r in cache_rewrites(s) if context_tokens(r) > LARGE_CONTEXT]
|
|
231
|
+
# In time order across the sessions, so "the third re-write" is the third
|
|
232
|
+
# made, not the third listed (cost-phase-1 §1).
|
|
233
|
+
return sorted(found, key=lambda r: r.occurred_at or _NEVER)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _streaks(session: SessionLike, run_of: int) -> Iterator[tuple[float, ModelResponseLike]]:
|
|
237
|
+
"""Each `run_of` responses in a row on a large context: the highest
|
|
238
|
+
cache-read share among them, and the response that ends them. A response
|
|
239
|
+
that starts its cache afresh by design ends a streak rather than joining
|
|
240
|
+
it: it had no cache to read, which is no evidence of one going unused."""
|
|
241
|
+
shares: list[float] = []
|
|
242
|
+
for response, fresh in fresh_by_design(session):
|
|
243
|
+
context = context_tokens(response)
|
|
244
|
+
if context <= LARGE_CONTEXT or fresh:
|
|
245
|
+
shares = []
|
|
246
|
+
continue
|
|
247
|
+
shares.append(Tokens.of(response.usage).cache_read / context)
|
|
248
|
+
if len(shares) >= run_of:
|
|
249
|
+
yield max(shares[-run_of:]), response
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def lowest_cache_share(session: SessionLike, run_of: int = 5) -> float | None:
|
|
253
|
+
"""The lowest cache-read share over `run_of` responses in a row, each on a
|
|
254
|
+
large context; None when there were never that many in a row."""
|
|
255
|
+
return min((share for share, _end in _streaks(session, run_of)), default=None)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def lowest_streak_end(session: SessionLike, run_of: int = 5) -> ModelResponseLike | None:
|
|
259
|
+
"""The response that ended the run of `run_of` with the lowest cache-read
|
|
260
|
+
share: what a cache-collapse finding states, and where it happened."""
|
|
261
|
+
found = list(_streaks(session, run_of))
|
|
262
|
+
return min(found, key=lambda pair: pair[0])[1] if found else None
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# The snapshot.
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@dataclass(frozen=True)
|
|
269
|
+
class Bars:
|
|
270
|
+
"""One cohort's percentiles, with how many runs they came from."""
|
|
271
|
+
|
|
272
|
+
n: int
|
|
273
|
+
level: str
|
|
274
|
+
p5: Mapping[str, float]
|
|
275
|
+
p50: Mapping[str, float]
|
|
276
|
+
p95: Mapping[str, float]
|
|
277
|
+
p99: Mapping[str, float]
|
|
278
|
+
#: Per active minute: (p50, p95, p99) of cumulative tokens and of burn.
|
|
279
|
+
curve: tuple[tuple[float, float, float], ...]
|
|
280
|
+
burn: tuple[tuple[float, float, float], ...]
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@dataclass(frozen=True)
|
|
284
|
+
class DayCurve:
|
|
285
|
+
"""One model's usual tokens by each quarter hour, over its working days."""
|
|
286
|
+
|
|
287
|
+
days: int
|
|
288
|
+
#: Cumulative recorded tokens by slot on each working day.
|
|
289
|
+
by_day: Mapping[date, tuple[int, ...]]
|
|
290
|
+
#: Cumulative recorded tokens by slot, on each working day.
|
|
291
|
+
usual: tuple[float, ...]
|
|
292
|
+
#: The spread of log(day ÷ usual) at each slot: 1.48 × MAD, at least 0.25.
|
|
293
|
+
spread: tuple[float, ...]
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@dataclass(frozen=True)
|
|
297
|
+
class Snapshot:
|
|
298
|
+
built_at: datetime
|
|
299
|
+
runs: int
|
|
300
|
+
by_cohort: Mapping[Cohort, Bars]
|
|
301
|
+
by_entrypoint_model: Mapping[tuple[str | None, str | None], Bars]
|
|
302
|
+
by_model: Mapping[str | None, Bars]
|
|
303
|
+
days: Mapping[str, DayCurve]
|
|
304
|
+
|
|
305
|
+
def bars(self, cohort: Cohort) -> Bars | None:
|
|
306
|
+
"""The narrowest level with enough runs, or None: the rule is silent."""
|
|
307
|
+
repo, entrypoint, model = cohort
|
|
308
|
+
for found in (
|
|
309
|
+
self.by_cohort.get((repo, entrypoint, model)),
|
|
310
|
+
self.by_entrypoint_model.get((entrypoint, model)),
|
|
311
|
+
self.by_model.get(model),
|
|
312
|
+
):
|
|
313
|
+
if found is not None and found.n >= MIN_SESSIONS:
|
|
314
|
+
return found
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
_MEASURES = (
|
|
319
|
+
"tokens",
|
|
320
|
+
"subagents_in_10m",
|
|
321
|
+
"repeats",
|
|
322
|
+
"input_per_response",
|
|
323
|
+
"lowest_cache_share",
|
|
324
|
+
"cache_rewrites",
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def snapshot(
|
|
329
|
+
summaries: Iterable[Summary],
|
|
330
|
+
*,
|
|
331
|
+
flagged: frozenset[tuple[str, str]] = frozenset(),
|
|
332
|
+
now: datetime,
|
|
333
|
+
zone: tzinfo | None = None,
|
|
334
|
+
) -> Snapshot:
|
|
335
|
+
"""The baseline as of `now`: the last 30 days of runs, without the runs a
|
|
336
|
+
cost finding was raised on (ADR-0093). The day curve leaves out today,
|
|
337
|
+
which is what it is compared with.
|
|
338
|
+
|
|
339
|
+
`flagged` holds `(agent_kind, run_id)` pairs: a run id is unique only
|
|
340
|
+
within the reader that emitted it."""
|
|
341
|
+
recent = [s for s in summaries if s.last_activity_at >= baseline_since(now, zone)]
|
|
342
|
+
kept = [s for s in recent if (s.agent_kind, s.run_id) not in flagged]
|
|
343
|
+
today = now.astimezone(zone).date() if zone is not None else now.date()
|
|
344
|
+
by_cohort: dict[Cohort, list[Summary]] = {}
|
|
345
|
+
by_entrypoint_model: dict[tuple[str | None, str | None], list[Summary]] = {}
|
|
346
|
+
by_model: dict[str | None, list[Summary]] = {}
|
|
347
|
+
for summary in kept:
|
|
348
|
+
_repo, entrypoint, model = summary.cohort
|
|
349
|
+
by_cohort.setdefault(summary.cohort, []).append(summary)
|
|
350
|
+
by_entrypoint_model.setdefault((entrypoint, model), []).append(summary)
|
|
351
|
+
by_model.setdefault(model, []).append(summary)
|
|
352
|
+
return Snapshot(
|
|
353
|
+
built_at=now,
|
|
354
|
+
runs=len(kept),
|
|
355
|
+
by_cohort={k: _bars(v, "repo, entrypoint and model") for k, v in by_cohort.items()},
|
|
356
|
+
by_entrypoint_model={
|
|
357
|
+
k: _bars(v, "entrypoint and model") for k, v in by_entrypoint_model.items()
|
|
358
|
+
},
|
|
359
|
+
by_model={k: _bars(v, "model") for k, v in by_model.items()},
|
|
360
|
+
days=_day_curves(kept, before=today),
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _bars(summaries: Sequence[Summary], level: str) -> Bars:
|
|
365
|
+
# A measure a run has no value for (a cache share without a five-response
|
|
366
|
+
# streak) stays out of the percentiles rather than counting as perfect.
|
|
367
|
+
columns = {
|
|
368
|
+
name: [float(v) for s in summaries if (v := getattr(s, name)) is not None]
|
|
369
|
+
for name in _MEASURES
|
|
370
|
+
}
|
|
371
|
+
return Bars(
|
|
372
|
+
n=len(summaries),
|
|
373
|
+
level=level,
|
|
374
|
+
p5={k: _quantile(v, 0.05) for k, v in columns.items()},
|
|
375
|
+
p50={k: _quantile(v, 0.5) for k, v in columns.items()},
|
|
376
|
+
p95={k: _quantile(v, 0.95) for k, v in columns.items()},
|
|
377
|
+
p99={k: _quantile(v, 0.99) for k, v in columns.items()},
|
|
378
|
+
curve=_by_minute([s.curve for s in summaries]),
|
|
379
|
+
burn=_by_minute([s.burn for s in summaries]),
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _by_minute(curves: Sequence[Sequence[int]]) -> tuple[tuple[float, float, float], ...]:
|
|
384
|
+
"""At each active minute, p50, p95 and p99 over the runs that reached it."""
|
|
385
|
+
longest = max((len(c) for c in curves), default=0)
|
|
386
|
+
rows: list[tuple[float, float, float]] = []
|
|
387
|
+
for minute in range(longest):
|
|
388
|
+
values = [float(c[minute]) for c in curves if len(c) > minute]
|
|
389
|
+
rows.append((_quantile(values, 0.5), _quantile(values, 0.95), _quantile(values, 0.99)))
|
|
390
|
+
return tuple(rows)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def dated_slots(summary: Summary) -> Iterator[tuple[date, str, int, int]]:
|
|
394
|
+
"""A summary's slots as `(date, model, quarter hour, tokens)`.
|
|
395
|
+
|
|
396
|
+
A key is `date|model|slot`; a row written before slots carried their date
|
|
397
|
+
is `model|slot`, on its run's day. The model is recorded text and may hold
|
|
398
|
+
a `|`, so the date is split off the front and the slot off the back.
|
|
399
|
+
"""
|
|
400
|
+
for key, tokens in summary.slots.items():
|
|
401
|
+
head, _, rest = key.partition("|")
|
|
402
|
+
try:
|
|
403
|
+
on = date.fromisoformat(head)
|
|
404
|
+
except ValueError:
|
|
405
|
+
on, rest = summary.day, key
|
|
406
|
+
model, _, slot = rest.rpartition("|")
|
|
407
|
+
yield on, model, int(slot), tokens
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _in_window(on: date, before: date) -> bool:
|
|
411
|
+
"""A date the day curve and the usual read: one of the 30 days before
|
|
412
|
+
`before`. A run counts by its last response, but a long-resumed run's
|
|
413
|
+
older days are outside the window and count nowhere (cost-phase-1 §1)."""
|
|
414
|
+
return before - timedelta(days=WINDOW.days) <= on < before
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def tokens_by_working_day(summaries: Iterable[Summary], *, before: date) -> dict[date, int]:
|
|
418
|
+
"""Recorded tokens on each working day of the 30 before `before`, on every
|
|
419
|
+
model: the days the day curve reads (cost-phase-1 §1, "The clock")."""
|
|
420
|
+
days: dict[date, int] = {}
|
|
421
|
+
for summary in summaries:
|
|
422
|
+
for on, _model, _slot, tokens in dated_slots(summary):
|
|
423
|
+
if _in_window(on, before):
|
|
424
|
+
days[on] = days.get(on, 0) + tokens
|
|
425
|
+
return days
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _by_model_day(summaries: Iterable[Summary], before: date) -> dict[str, dict[date, list[int]]]:
|
|
429
|
+
by_model_day: dict[str, dict[date, list[int]]] = {}
|
|
430
|
+
for summary in summaries:
|
|
431
|
+
for on, model, slot, tokens in dated_slots(summary):
|
|
432
|
+
if not _in_window(on, before):
|
|
433
|
+
continue
|
|
434
|
+
days = by_model_day.setdefault(model, {})
|
|
435
|
+
day = days.setdefault(on, [0] * 96)
|
|
436
|
+
day[slot] += tokens
|
|
437
|
+
return by_model_day
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _day_curves(summaries: Sequence[Summary], *, before: date) -> dict[str, DayCurve]:
|
|
441
|
+
"""Per model, cumulative tokens by quarter hour on each working day (a day
|
|
442
|
+
with any recorded response) of the 30 before `before`, its median, and the
|
|
443
|
+
spread ADR-0093 scores by. Each slot counts on its own date, so a run across
|
|
444
|
+
midnight adds to both days rather than all to its last."""
|
|
445
|
+
by_model_day = _by_model_day(summaries, before)
|
|
446
|
+
curves: dict[str, DayCurve] = {}
|
|
447
|
+
for model, days in by_model_day.items():
|
|
448
|
+
cumulative = [_accumulate(slots) for slots in days.values()]
|
|
449
|
+
usual = tuple(median(day[s] for day in cumulative) for s in range(96))
|
|
450
|
+
spread = []
|
|
451
|
+
for s in range(96):
|
|
452
|
+
logs = [math.log((day[s] + 1) / (usual[s] + 1)) for day in cumulative]
|
|
453
|
+
centre = median(logs)
|
|
454
|
+
mad = median(abs(x - centre) for x in logs)
|
|
455
|
+
spread.append(max(1.48 * mad, 0.25))
|
|
456
|
+
curves[model] = DayCurve(
|
|
457
|
+
days=len(cumulative),
|
|
458
|
+
by_day={day: tuple(_accumulate(slots)) for day, slots in days.items()},
|
|
459
|
+
usual=usual,
|
|
460
|
+
spread=tuple(spread),
|
|
461
|
+
)
|
|
462
|
+
return curves
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _accumulate(slots: Sequence[int]) -> list[int]:
|
|
466
|
+
total, out = 0, []
|
|
467
|
+
for value in slots:
|
|
468
|
+
total += value
|
|
469
|
+
out.append(total)
|
|
470
|
+
return out
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _quantile(values: Sequence[float], share: float) -> float:
|
|
474
|
+
if not values:
|
|
475
|
+
return 0.0
|
|
476
|
+
ordered = sorted(values)
|
|
477
|
+
position = share * (len(ordered) - 1)
|
|
478
|
+
low = math.floor(position)
|
|
479
|
+
high = math.ceil(position)
|
|
480
|
+
return ordered[low] + (ordered[high] - ordered[low]) * (position - low)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _p90(values: Sequence[int]) -> int:
|
|
484
|
+
return int(_quantile([float(v) for v in values], 0.9)) if values else 0
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _responses(run: Run[Any]) -> list[ModelResponseLike]:
|
|
488
|
+
return [r for s in run.sessions for r in s.responses if r.occurred_at is not None]
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _last_activity(run: Run[Any]) -> datetime | None:
|
|
492
|
+
times = [r.occurred_at for r in _responses(run) if r.occurred_at is not None]
|
|
493
|
+
return max(times) if times else None
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""The environment's time zone, which every local day, quarter hour and
|
|
2
|
+
window of cost is in (cost-phase-1 §1, "The clock")."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
from datetime import UTC, datetime, tzinfo
|
|
8
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
9
|
+
|
|
10
|
+
_LOCALTIME = "/etc/localtime"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def environment_zone() -> tzinfo:
|
|
14
|
+
"""The environment's time zone, with its daylight-saving rules: the zone
|
|
15
|
+
`TZ` names, else the one `/etc/localtime` links to.
|
|
16
|
+
|
|
17
|
+
Each response is placed in the local day and quarter hour of its own
|
|
18
|
+
moment. The offset in effect today would place every response from before
|
|
19
|
+
a clock change an hour off, and some near midnight on the wrong date. Only
|
|
20
|
+
when neither names a zone this release can load is the current offset
|
|
21
|
+
used, and then a clock change moves history by an hour.
|
|
22
|
+
"""
|
|
23
|
+
for name in (os.environ.get("TZ"), _linked()):
|
|
24
|
+
if not name:
|
|
25
|
+
continue
|
|
26
|
+
try:
|
|
27
|
+
return ZoneInfo(name.removeprefix(":"))
|
|
28
|
+
except (ZoneInfoNotFoundError, ValueError):
|
|
29
|
+
continue
|
|
30
|
+
return datetime.now().astimezone().tzinfo or UTC
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _linked() -> str | None:
|
|
34
|
+
"""The zone `/etc/localtime` links to, by its name in the zone database."""
|
|
35
|
+
try:
|
|
36
|
+
target = os.readlink(_LOCALTIME)
|
|
37
|
+
except OSError:
|
|
38
|
+
return None
|
|
39
|
+
_, marker, name = target.partition("zoneinfo/")
|
|
40
|
+
return name if marker else None
|