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.
Files changed (46) hide show
  1. stacktrace_cli/__init__.py +1 -1
  2. stacktrace_cli/analysis.py +13 -1
  3. stacktrace_cli/cli.py +3 -1
  4. stacktrace_cli/cost/__init__.py +6 -0
  5. stacktrace_cli/cost/baseline.py +493 -0
  6. stacktrace_cli/cost/clock.py +40 -0
  7. stacktrace_cli/cost/components.py +143 -0
  8. stacktrace_cli/cost/evidence.py +69 -0
  9. stacktrace_cli/cost/ledger.py +58 -0
  10. stacktrace_cli/cost/measures.py +351 -0
  11. stacktrace_cli/cost/models.py +38 -0
  12. stacktrace_cli/cost/rules.py +1138 -0
  13. stacktrace_cli/cost/runs.py +126 -0
  14. stacktrace_cli/cost/tokens.py +69 -0
  15. stacktrace_cli/cost/totals.py +62 -0
  16. stacktrace_cli/daemon/cli.py +46 -0
  17. stacktrace_cli/daemon/config.py +42 -0
  18. stacktrace_cli/daemon/costs.py +882 -0
  19. stacktrace_cli/daemon/db.py +65 -0
  20. stacktrace_cli/daemon/fleet.py +3 -1
  21. stacktrace_cli/daemon/jobs.py +90 -0
  22. stacktrace_cli/daemon/observer.py +17 -2
  23. stacktrace_cli/daemon/presentation.py +33 -0
  24. stacktrace_cli/daemon/runtime.py +107 -8
  25. stacktrace_cli/daemon/scan.py +31 -0
  26. stacktrace_cli/daemon/store.py +243 -2
  27. stacktrace_cli/detector/finding.py +1 -1
  28. stacktrace_cli/detector/render.py +14 -12
  29. stacktrace_cli/detector/rules.py +58 -4
  30. stacktrace_cli/monitor/costs.py +375 -0
  31. stacktrace_cli/monitor/offline.py +266 -0
  32. stacktrace_cli/monitor/render.py +79 -21
  33. stacktrace_cli/monitor/server.py +5 -0
  34. stacktrace_cli/monitor/site/app.js +64 -1
  35. stacktrace_cli/monitor/site/cost.js +436 -0
  36. stacktrace_cli/monitor/site/index.html +8 -0
  37. stacktrace_cli/monitor/site/inventory.js +1 -1
  38. stacktrace_cli/monitor/site/styles.css +23 -0
  39. stacktrace_cli/monitor/state.py +5 -0
  40. stacktrace_cli/monitor/watch.py +16 -0
  41. stacktrace_cli/sessions/lineage.py +24 -0
  42. stacktrace_cli/sessions/protocols.py +81 -8
  43. {stacktrace_cli-0.8.0.dist-info → stacktrace_cli-0.9.0.dist-info}/METADATA +2 -2
  44. {stacktrace_cli-0.8.0.dist-info → stacktrace_cli-0.9.0.dist-info}/RECORD +46 -29
  45. {stacktrace_cli-0.8.0.dist-info → stacktrace_cli-0.9.0.dist-info}/WHEEL +0 -0
  46. {stacktrace_cli-0.8.0.dist-info → stacktrace_cli-0.9.0.dist-info}/entry_points.txt +0 -0
@@ -2,7 +2,7 @@
2
2
 
3
3
  import logging as _logging
4
4
 
5
- __version__ = "0.8.0"
5
+ __version__ = "0.9.0"
6
6
 
7
7
  # Only the daemon attaches a handler (ADR-0048). Without this one, a WARNING
8
8
  # logged in any other process would reach Python's last-resort handler and
@@ -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 _unplaced(read(cutoff), window_start=window_start, window_end=window_end)
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