aggregate_api 1.0.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 (63) hide show
  1. aggregate_api/__init__.py +41 -0
  2. aggregate_api/__main__.py +154 -0
  3. aggregate_api/app.py +206 -0
  4. aggregate_api/audit.py +395 -0
  5. aggregate_api/bounds.py +331 -0
  6. aggregate_api/cache.py +319 -0
  7. aggregate_api/capability.py +823 -0
  8. aggregate_api/completion.py +219 -0
  9. aggregate_api/config.py +363 -0
  10. aggregate_api/cors.py +61 -0
  11. aggregate_api/examples.py +620 -0
  12. aggregate_api/layer_pricing.py +840 -0
  13. aggregate_api/library.py +94 -0
  14. aggregate_api/library_notes.py +96 -0
  15. aggregate_api/models.py +1407 -0
  16. aggregate_api/net.py +281 -0
  17. aggregate_api/pnl.py +101 -0
  18. aggregate_api/pricing.py +778 -0
  19. aggregate_api/resources.py +257 -0
  20. aggregate_api/routes/__init__.py +8 -0
  21. aggregate_api/routes/decl.py +327 -0
  22. aggregate_api/routes/examples.py +82 -0
  23. aggregate_api/routes/meta.py +282 -0
  24. aggregate_api/routes/objects.py +4119 -0
  25. aggregate_api/routes/status.py +466 -0
  26. aggregate_api/serializers.py +565 -0
  27. aggregate_api/sessions.py +353 -0
  28. aggregate_api/static/aggregate-api-logo-512.png +0 -0
  29. aggregate_api/static/aggregate-api-logo.png +0 -0
  30. aggregate_api/static/aggregate-api-trim.png +0 -0
  31. aggregate_api/static/android-chrome-192x192.png +0 -0
  32. aggregate_api/static/android-chrome-512x512.png +0 -0
  33. aggregate_api/static/apple-touch-icon.png +0 -0
  34. aggregate_api/static/assets/bootstrap-icons-BeopsB42.woff +0 -0
  35. aggregate_api/static/assets/bootstrap-icons-mSm7cUeB.woff2 +0 -0
  36. aggregate_api/static/assets/bootstrap-ohb1VZ53.js +5 -0
  37. aggregate_api/static/assets/codemirror-h62DHGGa.js +14 -0
  38. aggregate_api/static/assets/csv-grid.worker-DKzHGXac.js +4 -0
  39. aggregate_api/static/assets/echarts-B7o9sc00.js +40 -0
  40. aggregate_api/static/assets/echarts-gl-DG1Uf6wE.js +4282 -0
  41. aggregate_api/static/assets/lite-CUlcD8p4.css +1 -0
  42. aggregate_api/static/assets/lite-Dd2TnT4M.js +1 -0
  43. aggregate_api/static/assets/main-Bxhxa55v.css +9 -0
  44. aggregate_api/static/assets/main-CmoEiPit.js +9 -0
  45. aggregate_api/static/assets/tables-BHCF7qIF.js +8 -0
  46. aggregate_api/static/assets/tables-CxvajLr7.css +1 -0
  47. aggregate_api/static/favicon-16x16.png +0 -0
  48. aggregate_api/static/favicon-32x32.png +0 -0
  49. aggregate_api/static/favicon.ico +0 -0
  50. aggregate_api/static/index.html +912 -0
  51. aggregate_api/static/lite.html +83 -0
  52. aggregate_api/static/logo.png +0 -0
  53. aggregate_api/static/site.webmanifest +14 -0
  54. aggregate_api/static/sw.js +78 -0
  55. aggregate_api/status.py +536 -0
  56. aggregate_api/status_page.html +546 -0
  57. aggregate_api/tables.py +316 -0
  58. aggregate_api-1.0.0.dist-info/METADATA +187 -0
  59. aggregate_api-1.0.0.dist-info/RECORD +63 -0
  60. aggregate_api-1.0.0.dist-info/WHEEL +5 -0
  61. aggregate_api-1.0.0.dist-info/entry_points.txt +2 -0
  62. aggregate_api-1.0.0.dist-info/licenses/LICENSE +28 -0
  63. aggregate_api-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,536 @@
1
+ """Process-lifetime counters and buffers behind ``GET /v1/status``.
2
+
3
+ The route lives in ``routes/status.py``; this module is the state it reports and
4
+ the arithmetic it needs. Split so that the state has no opinion about HTTP and
5
+ imports nothing from ``routes``, which is what lets ``routes/objects.py``
6
+ increment counters without an import cycle.
7
+
8
+ The invariant, stated once
9
+ --------------------------
10
+
11
+ **The status subsystem persists nothing and holds nothing unbounded.** Every
12
+ number it reports is either computed at request time, or a process-lifetime
13
+ counter, or an entry in a fixed-size ring buffer. Restarting the service clears
14
+ all of it, by construction rather than by a cleanup step. That is the author's
15
+ ruling of 2026-08-18 ("do not let it grow too large, and reset it on each
16
+ reboot") held as an invariant rather than as a set of limits, because an
17
+ invariant survives the next panel and a set of limits does not.
18
+
19
+ Concretely: counters are plain integers, so they start at zero when the process
20
+ starts and every reading of them is labeled "since ``<process start>``" with the
21
+ uptime beside it, so a small number after a restart is never read as a quiet
22
+ day. Text lives in :class:`collections.deque` with ``maxlen`` set, so the cap is
23
+ structural and cannot be forgotten at a call site. Everything else, the build
24
+ statistics and the cache and session rows, is a query against something already
25
+ bounded and is not retained here at all.
26
+
27
+ The budget is roughly 250 kB: fifty programs at two kilobytes, five hundred
28
+ float samples, twenty refusal records, and a few dozen integers. A future panel
29
+ that cannot fit that is a query and not a buffer.
30
+
31
+ **This does not touch the audit log.** That is SQLite on disk and survives
32
+ restarts, which is the point of an audit log. Section 8 question 4 of
33
+ ``dev/done/plan-site-status-page.md`` stays open.
34
+
35
+ Thread safety
36
+ -------------
37
+
38
+ One lock over the whole module. Every counter is incremented from a request
39
+ thread and read from another, and the alternative to a lock is a set of numbers
40
+ that are individually atomic and collectively inconsistent, which for a page
41
+ whose job is to be believed is worse than the nanoseconds. The critical sections
42
+ are integer adds and deque appends.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import os
48
+ import re
49
+ import threading
50
+ import time
51
+ from collections import Counter, deque
52
+ from datetime import datetime, timezone
53
+
54
+ #: How many programs the key-scope buffer keeps, and how much of each. Fifty at
55
+ #: two kilobytes is the bulk of this module's memory budget.
56
+ PROGRAM_BUFFER = 50
57
+ PROGRAM_CHARS = 2000
58
+
59
+ #: How many gate refusals to keep. Address and timestamp only, no bodies.
60
+ REFUSAL_BUFFER = 20
61
+
62
+ #: The shape ``web/src/session.js`` mints: ``<ISO timestamp>-<uuid4>``. Used
63
+ #: only to split a display id, so anything else falls through to a plain cut.
64
+ _MINTED_ID = re.compile(r"\A(\d{4}-\d{2}-\d{2}T[\d:.]+Z)-(.+)\Z")
65
+
66
+ #: How many preview durations to retain for the percentile. A lifetime sum and
67
+ #: count give the mean; a percentile needs samples, and this many is a recent
68
+ #: window rather than a history, which is the honest thing to report anyway.
69
+ PREVIEW_SAMPLES = 512
70
+
71
+ #: Array counts behind :func:`estimated_bytes`. Deliberately round: see its
72
+ #: Notes for why this is arithmetic and not a measurement.
73
+ _ARRAYS_PER_AGGREGATE = 8
74
+ _ARRAYS_PER_UNIT = 4
75
+
76
+ _lock = threading.Lock()
77
+
78
+ # ----------------------------------------------------------------------
79
+ # Process facts
80
+ # ----------------------------------------------------------------------
81
+ _started_at: datetime | None = None
82
+ _started_monotonic: float | None = None
83
+ _library_load_ms: float | None = None
84
+
85
+ # ----------------------------------------------------------------------
86
+ # Counters. Plain integers, zero at process start, read under the lock.
87
+ # ----------------------------------------------------------------------
88
+ _counts: Counter = Counter()
89
+
90
+ # ----------------------------------------------------------------------
91
+ # Bounded buffers
92
+ # ----------------------------------------------------------------------
93
+ _preview_ms: deque = deque(maxlen=PREVIEW_SAMPLES)
94
+ _programs: deque = deque(maxlen=PROGRAM_BUFFER)
95
+ _refusals: deque = deque(maxlen=REFUSAL_BUFFER)
96
+
97
+
98
+ def _now_iso() -> str:
99
+ """ISO 8601 UTC timestamp, seconds precision. Matches the page's columns."""
100
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
101
+
102
+
103
+ # ----------------------------------------------------------------------
104
+ # Lifecycle
105
+ # ----------------------------------------------------------------------
106
+ def mark_started() -> None:
107
+ """Record the process start time, once.
108
+
109
+ Notes
110
+ -----
111
+ Called from :func:`aggregate_api.app.create_app`, which runs once in
112
+ production and once per app in the tests. The guard makes it once per
113
+ process in both: a test that builds ten apps still reports one uptime, and
114
+ an uptime that resets under a test client would make every "since process
115
+ start" label a lie in exactly the place they are checked.
116
+ """
117
+ global _started_at, _started_monotonic
118
+ with _lock:
119
+ if _started_at is None:
120
+ _started_at = datetime.now(timezone.utc)
121
+ _started_monotonic = time.monotonic()
122
+
123
+
124
+ def record_library_load(elapsed_ms: float) -> None:
125
+ """Record what reading the recipe library cost at boot, in milliseconds."""
126
+ global _library_load_ms
127
+ with _lock:
128
+ _library_load_ms = float(elapsed_ms)
129
+
130
+
131
+ def process_facts() -> dict:
132
+ """Start time, uptime, PID, and the boot library load cost.
133
+
134
+ Returns
135
+ -------
136
+ dict
137
+ ``started_at`` is ``None`` before :func:`mark_started`, which cannot
138
+ happen through a route (the app has to exist first) but can in a unit
139
+ test importing this module alone.
140
+ """
141
+ with _lock:
142
+ started_at = _started_at
143
+ started_monotonic = _started_monotonic
144
+ library_load_ms = _library_load_ms
145
+ uptime = None
146
+ if started_monotonic is not None:
147
+ uptime = round(time.monotonic() - started_monotonic, 1)
148
+ return {
149
+ "pid": os.getpid(),
150
+ "started_at": started_at.isoformat(timespec="seconds") if started_at else None,
151
+ "uptime_s": uptime,
152
+ "library_load_ms": (round(library_load_ms, 1)
153
+ if library_load_ms is not None else None),
154
+ }
155
+
156
+
157
+ # ----------------------------------------------------------------------
158
+ # Key scope, section 4.5 of the plan
159
+ # ----------------------------------------------------------------------
160
+ def record_key_scope(scope: str, reason: str | None = None) -> None:
161
+ """Count one keying decision from ``routes/objects.py::_cache_key``.
162
+
163
+ Parameters
164
+ ----------
165
+ scope : {'shared', 'session'}
166
+ Which key builder the request used.
167
+ reason : {'session_reference', 'preview_unavailable'}, optional
168
+ Why the session key was taken. Ignored for ``'shared'``.
169
+
170
+ Notes
171
+ -----
172
+ The two reasons mean opposite things and are counted apart for that reason.
173
+ ``session_reference`` is the rule working: the program touched a name its own
174
+ session declared, so it cannot share a slot. ``preview_unavailable`` is the
175
+ previewer declining to speak about the program at all, which is a fail-closed
176
+ path that is usually a program about to fail its build and is occasionally a
177
+ previewer refusing what the builder accepts. Only the second is a finding,
178
+ and telling them apart is the whole value of the panel.
179
+ """
180
+ with _lock:
181
+ _counts[f"key_{scope}"] += 1
182
+ if scope == "session" and reason:
183
+ _counts[f"key_reason_{reason}"] += 1
184
+
185
+
186
+ def record_preview_ms(elapsed_ms: float) -> None:
187
+ """Sample one ``Underwriter.preview`` call.
188
+
189
+ Notes
190
+ -----
191
+ Worth watching because this parse sits on the cache **hit** path, where it
192
+ did not before a110: ``post_object``'s docstring names it as the
193
+ acknowledged cost of keying on what a program means rather than on how it
194
+ is spelled. A hit that grows to build-like latency would make that trade
195
+ look different.
196
+ """
197
+ with _lock:
198
+ _counts["preview_calls"] += 1
199
+ _counts["preview_total_us"] += int(elapsed_ms * 1000)
200
+ _preview_ms.append(float(elapsed_ms))
201
+
202
+
203
+ def record_unpreviewable_build(program: str, session_id: str, kind: str | None,
204
+ elapsed_ms: int) -> None:
205
+ """Keep a program the previewer refused and the builder then accepted.
206
+
207
+ Parameters
208
+ ----------
209
+ program : str
210
+ The DecL as it arrived. Truncated to :data:`PROGRAM_CHARS`.
211
+ session_id : str
212
+ The caller's session. Shortened on the way in by
213
+ :func:`short_session_id`, so nothing here ever holds a full one.
214
+ kind : str or None
215
+ What the build produced.
216
+ elapsed_ms : int
217
+ What the request cost.
218
+
219
+ Notes
220
+ -----
221
+ **This narrow class is the only text this module keeps.** A program that
222
+ previewed ``None`` and then failed its build is an ordinary parse error and
223
+ is already in the audit log with a better message. A program that previewed
224
+ ``None`` and then **built** is the previewer refusing what the builder
225
+ accepted: a shareable object took a private cache slot, so a room pays a
226
+ build each instead of one between them. That is an upstream ask against the
227
+ library's ``Underwriter.preview``, and an ask needs the program verbatim,
228
+ which is why author ruling 3 of 2026-08-18 lets this page hold program text.
229
+ """
230
+ text, truncated = truncate(program, PROGRAM_CHARS)
231
+ with _lock:
232
+ _counts["unpreviewable_builds"] += 1
233
+ _programs.append({
234
+ "at": _now_iso(),
235
+ "session_id": short_session_id(session_id),
236
+ "kind": kind,
237
+ "elapsed_ms": elapsed_ms,
238
+ "program": text,
239
+ "truncated": truncated,
240
+ })
241
+
242
+
243
+ def key_scope_state() -> dict:
244
+ """The key-scope panel: counts, rate, reasons, preview timing, programs."""
245
+ with _lock:
246
+ shared = _counts["key_shared"]
247
+ session = _counts["key_session"]
248
+ reasons = {
249
+ "session_reference": _counts["key_reason_session_reference"],
250
+ "preview_unavailable": _counts["key_reason_preview_unavailable"],
251
+ }
252
+ calls = _counts["preview_calls"]
253
+ total_us = _counts["preview_total_us"]
254
+ samples = list(_preview_ms)
255
+ programs = list(_programs)
256
+ unpreviewable_builds = _counts["unpreviewable_builds"]
257
+ keyed = shared + session
258
+ return {
259
+ "shared": shared,
260
+ "session": session,
261
+ "session_rate": round(session / keyed, 4) if keyed else None,
262
+ "reasons": reasons,
263
+ "preview_calls": calls,
264
+ "preview_mean_ms": round(total_us / calls / 1000, 2) if calls else None,
265
+ "preview_p95_ms": percentile(samples, 0.95),
266
+ "preview_sample_size": len(samples),
267
+ "unpreviewable_builds": unpreviewable_builds,
268
+ "recent_unpreviewable": list(reversed(programs)),
269
+ }
270
+
271
+
272
+ # ----------------------------------------------------------------------
273
+ # Builds
274
+ # ----------------------------------------------------------------------
275
+ class build_slot: # noqa: N801 (a context manager used as a statement reads better lowercase)
276
+ """Hold the build semaphore and count the wait, the run, and the queue.
277
+
278
+ Parameters
279
+ ----------
280
+ semaphore : threading.Semaphore
281
+ The single-slot build semaphore from ``routes/objects.py``. Passed in
282
+ rather than imported so this module keeps knowing nothing about routes.
283
+
284
+ Notes
285
+ -----
286
+ Written as a context manager so the three counters cannot drift from each
287
+ other: a ``waiting`` that is decremented on one path out and not another
288
+ would climb forever and the page would report a queue that is not there.
289
+ Entering counts a waiter, acquiring converts it to an in-flight build, and
290
+ leaving releases both regardless of how the body exited.
291
+ """
292
+
293
+ __slots__ = ("_semaphore",)
294
+
295
+ def __init__(self, semaphore) -> None:
296
+ self._semaphore = semaphore
297
+
298
+ def __enter__(self):
299
+ with _lock:
300
+ _counts["build_waiting"] += 1
301
+ self._semaphore.acquire()
302
+ with _lock:
303
+ _counts["build_waiting"] -= 1
304
+ _counts["build_in_flight"] += 1
305
+ _counts["builds_started"] += 1
306
+ return self
307
+
308
+ def __exit__(self, exc_type, exc, tb):
309
+ with _lock:
310
+ _counts["build_in_flight"] -= 1
311
+ self._semaphore.release()
312
+ return False
313
+
314
+
315
+ def record_build_timeout() -> None:
316
+ """Count one build abandoned at ``build_timeout_s``."""
317
+ with _lock:
318
+ _counts["build_timeouts"] += 1
319
+
320
+
321
+ def build_state() -> dict:
322
+ """Live build state: in flight, queued, started and timed out since start."""
323
+ with _lock:
324
+ return {
325
+ "in_flight": _counts["build_in_flight"],
326
+ "waiting": _counts["build_waiting"],
327
+ "started": _counts["builds_started"],
328
+ "timeouts": _counts["build_timeouts"],
329
+ }
330
+
331
+
332
+ # ----------------------------------------------------------------------
333
+ # The chart-document cache
334
+ # ----------------------------------------------------------------------
335
+ def record_cache(channel: str, event: str) -> None:
336
+ """Count one ``hit``, ``miss``, ``store`` or ``eviction`` on one cache.
337
+
338
+ Parameters
339
+ ----------
340
+ channel : str
341
+ Which cache is reporting, ``"chart"`` or ``"exhibit"``. It namespaces
342
+ the counter keys, so the two revalidation caches are counted apart
343
+ while sharing one implementation.
344
+ event : str
345
+ The event to count.
346
+
347
+ Notes
348
+ -----
349
+ ``_counts`` is a ``Counter``, so a channel needs no registration here: a
350
+ key that has never been written reads as zero.
351
+ """
352
+ with _lock:
353
+ _counts[f"{channel}_{event}"] += 1
354
+
355
+
356
+ def cache_state(channel: str, entries: int, max_entries: int) -> dict:
357
+ """One revalidation cache's panel. Size is passed in, counters come from here."""
358
+ with _lock:
359
+ hits = _counts[f"{channel}_hit"]
360
+ misses = _counts[f"{channel}_miss"]
361
+ evictions = _counts[f"{channel}_eviction"]
362
+ looks = hits + misses
363
+ return {
364
+ "entries": entries,
365
+ "max": max_entries,
366
+ "hits": hits,
367
+ "misses": misses,
368
+ "hit_rate": round(hits / looks, 4) if looks else None,
369
+ "evictions": evictions,
370
+ }
371
+
372
+
373
+ # ----------------------------------------------------------------------
374
+ # Gate refusals
375
+ # ----------------------------------------------------------------------
376
+ def record_refusal(address: str, path: str) -> None:
377
+ """Keep one refused status request: address, path and time, nothing else.
378
+
379
+ Notes
380
+ -----
381
+ A refusal on a correctly configured box means something changed, which is
382
+ why it is retained at all and why ``routes/status.py`` also logs it at
383
+ WARNING. No body and no headers are kept: the useful fact is that an address
384
+ outside the private list reached the app, and everything past that belongs
385
+ to the web server's own log.
386
+ """
387
+ with _lock:
388
+ _counts["gate_refusals"] += 1
389
+ _refusals.append({"at": _now_iso(), "address": address, "path": path})
390
+
391
+
392
+ def gate_state() -> dict:
393
+ """Refusal count and the recent refusals, newest first."""
394
+ with _lock:
395
+ return {
396
+ "refusals": _counts["gate_refusals"],
397
+ "recent": list(reversed(_refusals)),
398
+ }
399
+
400
+
401
+ # ----------------------------------------------------------------------
402
+ # Helpers
403
+ # ----------------------------------------------------------------------
404
+ def truncate(text: str, limit: int) -> tuple[str, bool]:
405
+ """Cut ``text`` to ``limit`` characters, reporting whether it was cut.
406
+
407
+ Returns
408
+ -------
409
+ (str, bool)
410
+ The text and whether anything was dropped. The flag travels rather than
411
+ an ellipsis in the string, so the page can mark the cut and a test can
412
+ assert on the length without parsing prose out of the payload.
413
+ """
414
+ if text is None:
415
+ return "", False
416
+ if len(text) <= limit:
417
+ return text, False
418
+ return text[:limit], True
419
+
420
+
421
+ def short_session_id(session_id: str | None) -> str | None:
422
+ """Shorten a session id for display, keeping the useful half.
423
+
424
+ Parameters
425
+ ----------
426
+ session_id : str or None
427
+ A minted id (``<ISO timestamp>-<uuid4>``), :data:`sessions.ANONYMOUS`,
428
+ or anything a client sent that passed the shape check.
429
+
430
+ Returns
431
+ -------
432
+ str or None
433
+ The timestamp in full, a middle dot, and eight characters of the uuid.
434
+ Anything not matching that shape is cut to 24 characters. ``None`` in,
435
+ ``None`` out.
436
+
437
+ Notes
438
+ -----
439
+ **Applied server side, so the guarantee holds for the JSON and not only for
440
+ the page.** A session id is a namespace and not a security boundary: anyone
441
+ holding one gets that session's objects, which is exactly why no login is
442
+ needed for it. A status page rendering them in full would turn a private
443
+ page into a list of credentials the moment anyone shares a screenshot, and
444
+ a payload carrying them in full would do the same for anyone who saved the
445
+ JSON. Truncating in the page would leave the second hole open.
446
+
447
+ Eight characters of a uuid4 is enough to line a row up with an audit row or
448
+ a log line, and is not enough to impersonate. The timestamp travels whole
449
+ because it is the part an operator actually reads.
450
+ """
451
+ if not session_id:
452
+ return session_id
453
+ match = _MINTED_ID.match(session_id)
454
+ if match:
455
+ return f"{match.group(1)}·{match.group(2)[:8]}…"
456
+ if len(session_id) > 24:
457
+ return f"{session_id[:24]}…"
458
+ return session_id
459
+
460
+
461
+ def percentile(samples: list[float], q: float) -> float | None:
462
+ """The ``q`` quantile of ``samples`` by nearest rank, or ``None`` if empty.
463
+
464
+ Notes
465
+ -----
466
+ Nearest rank rather than an interpolated quantile, and no numpy. The input
467
+ is at most :data:`PREVIEW_SAMPLES` floats on a page refreshed every ten
468
+ seconds; the difference between the two definitions is smaller than the
469
+ difference between two consecutive samples, and this one has no import.
470
+ """
471
+ if not samples:
472
+ return None
473
+ ordered = sorted(samples)
474
+ index = min(len(ordered) - 1, max(0, int(round(q * (len(ordered) - 1)))))
475
+ return round(ordered[index], 2)
476
+
477
+
478
+ def estimated_bytes(obj, log2: int) -> int | None:
479
+ """An order-of-magnitude size for a built object, computed not measured.
480
+
481
+ Parameters
482
+ ----------
483
+ obj : Aggregate or Portfolio or other
484
+ The cached object.
485
+ log2 : int
486
+ The entry's log2, or 0 when the request asked for the library default.
487
+
488
+ Returns
489
+ -------
490
+ int or None
491
+ Estimated bytes, or ``None`` when the grid size cannot be established.
492
+
493
+ Notes
494
+ -----
495
+ **Deliberately not measured.** ``sys.getsizeof`` reports the shell of a
496
+ Python object and nothing it points at, so sizing an ``Aggregate`` honestly
497
+ would mean walking into numpy arrays and pandas frames: slow, and wrong the
498
+ moment a frame is shared between two entries. Worse, several of those frames
499
+ are built lazily on first access, so a status page that touched them to
500
+ measure them would *create* the memory it is reporting, on a timer.
501
+
502
+ So the estimate is arithmetic on what is already known. The density arrays
503
+ are ``2**log2`` float64, and the counts are round numbers standing for "the
504
+ handful an aggregate carries" and "the few more each portfolio unit adds".
505
+ The page labels the column estimated for exactly this reason.
506
+ """
507
+ grid = getattr(obj, "log2", 0) or log2
508
+ if not grid or grid <= 0:
509
+ return None
510
+ cell = (2 ** int(grid)) * 8
511
+ arrays = _ARRAYS_PER_AGGREGATE
512
+ units = getattr(obj, "agg_list", None)
513
+ if units is not None:
514
+ try:
515
+ arrays += _ARRAYS_PER_UNIT * len(units)
516
+ except TypeError:
517
+ pass
518
+ return cell * arrays
519
+
520
+
521
+ def reset() -> None:
522
+ """Drop every counter and buffer. For tests, and for nothing else.
523
+
524
+ Notes
525
+ -----
526
+ Not called by :func:`aggregate_api.app.create_app`. In production this
527
+ module's state is exactly process-lifetime, which is what every "since
528
+ process start" label on the page promises, and a reset wired into app
529
+ creation would quietly make that promise false the first time anything built
530
+ a second app. The tests that need a clean slate call this themselves.
531
+ """
532
+ with _lock:
533
+ _counts.clear()
534
+ _preview_ms.clear()
535
+ _programs.clear()
536
+ _refusals.clear()