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,4119 @@
1
+ """Object lifecycle routes -- the heart of the api.
2
+
3
+ Endpoints
4
+ ---------
5
+
6
+ The /v1/objects/* family covers everything object-shaped:
7
+
8
+ * ``POST /v1/objects`` -- build + cache; returns slim manifest.
9
+ * ``GET /v1/objects`` -- list cache contents.
10
+ * ``GET /v1/objects/{id}`` -- per-object manifest.
11
+ * ``DELETE /v1/objects/{id}`` -- evict from cache.
12
+ * ``GET /v1/objects/{id}/info`` -- text summary.
13
+ * ``GET /v1/objects/{id}/meta`` -- note / tags / hints / program / pprogram.
14
+ * ``GET /v1/objects/{id}/summary`` -- summary_df risk view (moments + percentiles).
15
+ * ``GET /v1/objects/{id}/tail_df`` -- return-period / exceedance table.
16
+ * ``GET /v1/objects/{id}/validation_df`` -- moment-vs-estimate QA table.
17
+ * ``GET /v1/objects/{id}/stats_df`` -- stats_df DataFrame.
18
+ * ``GET /v1/objects/{id}/density_df`` -- paginated density frame.
19
+ * ``GET /v1/objects/{id}/kappa`` -- Portfolio exeqa_* slice.
20
+ * ``GET /v1/objects/{id}/reins_description`` -- reinsurance text block.
21
+ * ``GET /v1/objects/{id}/reins_summary_df`` -- per-layer summary frame.
22
+ * ``GET /v1/objects/{id}/reins_stats_df`` -- per-layer stats frame.
23
+ * ``GET /v1/objects/{id}/reins_density_df`` -- density preview frame.
24
+ * ``GET /v1/objects/{id}/frame/{which}.csv`` -- full-frame CSV download.
25
+ * ``GET /v1/objects/{id}/frame/{which}`` -- table document for the
26
+ static view (``?format=ir``), built from the DataFrame rather than the
27
+ flattened wire format.
28
+ * ``GET /v1/objects/{id}/plot`` -- SVG/PNG image (native .plot()).
29
+ * ``POST /v1/objects/{id}/sharpen`` -- audit the grid, move to a better
30
+ one, and pin the outcome. Derivation: answers with DecL plus the object.
31
+ * ``POST /v1/objects/{id}/hints`` -- pin the realized grid into the
32
+ object's own ``hints{}``. Derivation.
33
+ * ``POST /v1/objects/{id}/pnl`` -- wrap the object in a P&L. Derivation.
34
+ * ``POST /v1/objects/{id}/explode`` -- the same P&L walked layer by layer,
35
+ ``pnl`` to ``xpnl``. Derivation.
36
+ * ``POST /v1/objects/{id}/reins`` -- cede a layer, optionally quoting
37
+ every layer and writing the premium into the clause. Derivation.
38
+ * ``POST /v1/objects/{id}/layers/indication`` -- the no-build quote for a list
39
+ of layers, for the Quick Re preview line.
40
+ * ``POST /v1/objects/{id}/pnl/pentagon`` -- the ledger as a pentagon,
41
+ one frame per solvency level, for the ``PnL / Pentagon`` figure.
42
+ * ``POST /v1/objects/{id}/pricing/preview`` -- the pentagon as scalars.
43
+ * ``POST /v1/objects/{id}/pricing/calibrate`` -- the ``pricing.calibrate`` and
44
+ ``pricing.stand_alone`` exhibit envelopes, both perspectives.
45
+ * ``POST /v1/objects/{id}/pricing/allocate`` -- the ``pricing.allocate``
46
+ exhibit envelope, both perspectives.
47
+ * ``POST /v1/objects/{id}/pricing/evaluate`` -- the ``pricing.evaluate``
48
+ envelope, both perspectives.
49
+
50
+ Build pipeline (POST /v1/objects)
51
+ ---------------------------------
52
+
53
+ 1. Validate ``log2`` against the cap. Reject early.
54
+ 2. Compute the content-hash id for the (canonical_decl, log2, bs)
55
+ triple.
56
+ 3. If cached: bump LRU, audit ``status='ok'``, return slim response.
57
+ 4. Otherwise: acquire the build semaphore (single concurrent
58
+ build), submit to a thread-pool with a wall-clock timeout, and
59
+ either store the result + audit ``ok`` or audit
60
+ ``parse_error / build_error / timeout``.
61
+
62
+ Why a thread-pool + future timeout
63
+ ----------------------------------
64
+
65
+ ``concurrent.futures.ThreadPoolExecutor`` is the simplest way to
66
+ get a hard wall-clock cap on a synchronous library call. Python
67
+ can't truly cancel a CPU-bound thread, but the api stops waiting
68
+ on it and returns 504 so the SPA doesn't hang. The thread keeps
69
+ running until ``build()`` returns -- documented caveat in the plan.
70
+
71
+ Per-button-fetch UX
72
+ -------------------
73
+
74
+ The build response carries only ``id``, ``kind``, ``name``,
75
+ ``warnings``, ``cached``, ``elapsed_ms``. The SPA shows that
76
+ immediately and only fetches info/summary/plot/pricing when the
77
+ user clicks the matching button. Second visits hit the cache and
78
+ return in milliseconds.
79
+ """
80
+
81
+ from __future__ import annotations
82
+
83
+ import copy
84
+ import json
85
+ import logging
86
+ import math
87
+ import numbers
88
+ import re
89
+ import threading
90
+ import time
91
+ import warnings
92
+ from collections import OrderedDict
93
+ from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
94
+ from contextlib import contextmanager
95
+ from datetime import datetime, timezone
96
+ from pathlib import PurePath
97
+ from typing import Annotated, Any, Literal
98
+
99
+ import anyio
100
+ import pandas as pd
101
+
102
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request
103
+ from fastapi.responses import Response
104
+ from pydantic import Field
105
+
106
+ from lark.exceptions import UnexpectedInput, VisitError
107
+
108
+ from aggregate import Distortion, Severity
109
+ from aggregate import charts as agg_charts
110
+ from aggregate import exhibits as agg_exhibits
111
+ from aggregate.constants import FIRST_CLASS_CLASSES, NEAR_FIRST_CLASS
112
+ from aggregate.parser import UnderwritingLexer
113
+ from aggregate.parser_errors import ErrorReport, format_error
114
+ from aggregate.underwriter import RecipeNotFound
115
+
116
+ from .. import models
117
+ from ..audit import AuditLog
118
+ from ..cache import (
119
+ CacheEntry, ObjectCache, canonicalize_decl, object_id, qualified_object_id,
120
+ )
121
+ from ..bounds import run_allocation, run_envelope, run_pricing_bounds
122
+ from ..capability import (
123
+ PNL_PREMIUM_STYLE_SUPPORTED, can_sharpen, capability_for, narrative_for,
124
+ )
125
+ from ..config import Settings, get_settings
126
+ from ..layer_pricing import (
127
+ indication as layer_indication, policy_limit, price_clause)
128
+ from ..library import get_underwriter
129
+ from ..library_notes import from_library
130
+ from ..net import client_address
131
+ from ..sessions import SESSION_HEADER, SessionRegistry, normalize_session_id
132
+ from .. import status as status_state
133
+ from ..pnl import run_pnl_pentagon
134
+ from ..pricing import (
135
+ run_calibration, run_evaluation, run_natural_allocation,
136
+ run_pricing_preview, run_ruin,
137
+ )
138
+ from ..tables import MAX_ROWS, frame_document, frame_document_dict
139
+ from ..serializers import (
140
+ bin_density,
141
+ bivariate_marginal_frame,
142
+ display_log2_for,
143
+ frame_to_payload,
144
+ info_to_payload,
145
+ pnl_density_frame,
146
+ reset_index_safe,
147
+ severity_density_frame,
148
+ )
149
+
150
+
151
+ logger = logging.getLogger(__name__)
152
+
153
+ router = APIRouter()
154
+
155
+
156
+ # ----------------------------------------------------------------------
157
+ # DecL hints{} log2 scan
158
+ # ----------------------------------------------------------------------
159
+ # The log2 cap (``AGGAPI_LOG2_CAP``) is a DoS guard, but a program can
160
+ # dodge a request-level cap by embedding ``hints{ log2=24 }`` in the DecL
161
+ # source -- ``build()`` honors that hint, so the effective grid is 2**24
162
+ # regardless of the request log2. We extract the log2 out of any
163
+ # ``hints{ ... }`` block straight from the source, before building, and
164
+ # enforce the cap against the *effective* log2 (request vs hint, whichever
165
+ # is larger). ``[^}]*`` keeps the match inside one block so a bs-only
166
+ # ``hints{}`` can't false-match; ``findall`` + max handles a multi-line
167
+ # ``port`` with several agg lines. This is a guard, not a parser: a
168
+ # non-integer log2 expression won't match ``\d+`` and slips through -- not
169
+ # a real hint form, so acceptable.
170
+ _HINTS_LOG2 = re.compile(r"hints\s*\{[^}]*\blog2\s*=\s*(\d+)", re.IGNORECASE)
171
+
172
+
173
+ # ----------------------------------------------------------------------
174
+ # Process-wide singletons
175
+ # ----------------------------------------------------------------------
176
+ # The cache and audit log are created lazily on first use. They're
177
+ # *not* created at import time because tests rely on env-var-driven
178
+ # config (audit-db location) being read after monkeypatching.
179
+ # ``_get_cache`` / ``_get_audit`` are pulled via Depends so the
180
+ # objects stay in module-level state where production code wants
181
+ # them, but they're reachable for monkeypatching in tests.
182
+ #
183
+ # Build-side concurrency: a single semaphore caps in-flight heavy
184
+ # builds at 1, regardless of how many requests are queued up. Reads
185
+ # (info / summary / plot) don't touch it -- they're O(ms) lookups
186
+ # on the already-built object.
187
+ _cache_lock = threading.Lock()
188
+ _cache_singleton: ObjectCache | None = None
189
+ _audit_singleton: AuditLog | None = None
190
+ _sessions_singleton: SessionRegistry | None = None
191
+
192
+ # Single-slot semaphore = only one heavy build runs at a time.
193
+ # Heavy builds happen rarely (most requests are cache hits); the
194
+ # semaphore prevents an accidental "build a 2**18 portfolio four
195
+ # times" pile-up from saturating the box.
196
+ _build_semaphore = threading.Semaphore(1)
197
+
198
+ # A small thread pool, one worker, used solely to enforce build
199
+ # timeouts. ``future.result(timeout=T)`` is the cleanest pattern
200
+ # for "give up on a synchronous call after N seconds" in Python.
201
+ _build_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="agg-build")
202
+
203
+
204
+ def _get_cache(settings: Settings = Depends(get_settings)) -> ObjectCache:
205
+ """Lazy-init the cache singleton with the configured max size."""
206
+ global _cache_singleton
207
+ with _cache_lock:
208
+ if _cache_singleton is None or _cache_singleton._max != settings.cache_max:
209
+ _cache_singleton = ObjectCache(max_entries=settings.cache_max)
210
+ return _cache_singleton
211
+
212
+
213
+ def _get_audit(settings: Settings = Depends(get_settings)) -> AuditLog:
214
+ """Lazy-init the audit-log singleton at the configured DB path."""
215
+ global _audit_singleton
216
+ with _cache_lock:
217
+ if _audit_singleton is None or str(_audit_singleton.db_path) != settings.audit_db:
218
+ _audit_singleton = AuditLog(settings.audit_db)
219
+ return _audit_singleton
220
+
221
+
222
+ def _get_sessions(settings: Settings = Depends(get_settings)) -> SessionRegistry:
223
+ """Lazy-init the session registry at the configured size and TTL."""
224
+ global _sessions_singleton
225
+ with _cache_lock:
226
+ if (_sessions_singleton is None
227
+ or _sessions_singleton._max != settings.session_max
228
+ or _sessions_singleton._ttl != settings.session_ttl_s):
229
+ _sessions_singleton = SessionRegistry(
230
+ max_sessions=settings.session_max, ttl_s=settings.session_ttl_s)
231
+ return _sessions_singleton
232
+
233
+
234
+ def _get_session_uw(
235
+ request: Request,
236
+ session: str | None = Query(
237
+ None,
238
+ alias="session",
239
+ description=(
240
+ "Session id, for the two paths a browser cannot set a header on: "
241
+ "this download and the CSV exports. Everything else sends "
242
+ "X-Aggregate-Session."
243
+ ),
244
+ ),
245
+ sessions: SessionRegistry = Depends(_get_sessions),
246
+ ) -> Any:
247
+ """Dependency: the caller's own recipe base.
248
+
249
+ Reads ``X-Aggregate-Session``, falling back to the ``session`` query
250
+ parameter for the routes a browser reaches by navigation rather than by
251
+ ``fetch``, and to one shared anonymous session when neither is present.
252
+
253
+ Notes
254
+ -----
255
+ Returns the fork itself rather than the id, because every caller wants the
256
+ base. Where the id is also wanted (the audit row, the qualified cache key)
257
+ the route reads it through :func:`session_id_of` off the same request.
258
+ """
259
+ sid = normalize_session_id(request.headers.get(SESSION_HEADER) or session)
260
+ return sessions.underwriter(sid, get_underwriter())
261
+
262
+
263
+ def session_id_of(request: Request, session: str | None = None) -> str:
264
+ """The caller's session id, normalized, from header or query parameter.
265
+
266
+ Separate from :func:`_get_session_uw` so a route can record the id without
267
+ taking a fork it does not need, and so the two can never disagree about
268
+ which id a request carries.
269
+ """
270
+ return normalize_session_id(request.headers.get(SESSION_HEADER) or session)
271
+
272
+
273
+ def reset_singletons() -> None:
274
+ """Drop the cached cache + audit + sessions so the next request re-inits.
275
+
276
+ Hook for tests that swap env vars across cases -- the
277
+ ``client`` fixture in ``tests/api/conftest.py`` calls this.
278
+ """
279
+ global _cache_singleton, _audit_singleton, _sessions_singleton
280
+ with _cache_lock:
281
+ _cache_singleton = None
282
+ _audit_singleton = None
283
+ _sessions_singleton = None
284
+ _chart_cache.clear()
285
+ _exhibit_cache.clear()
286
+
287
+
288
+ # ----------------------------------------------------------------------
289
+ # The revalidation caches
290
+ # ----------------------------------------------------------------------
291
+ class RevalidationCache:
292
+ """An LRU of ``(etag, body)``, keyed on everything that changes the bytes.
293
+
294
+ Parameters
295
+ ----------
296
+ channel : str
297
+ The telemetry channel this instance reports on, which namespaces its
298
+ counters on the status page.
299
+ max_entries : int
300
+ How many entries to hold.
301
+
302
+ Notes
303
+ -----
304
+ A key carries everything that changes the bytes, which is what makes it the
305
+ same key the ETag answers for. The cache sits *above* the object cache and
306
+ can never cause a build, so no document parameter is ever a reason to re-run
307
+ an FFT. An ``oid`` is the content hash of ``(decl, log2, bs)``, cached
308
+ objects are immutable and the builds are deterministic, so an entry cannot
309
+ go stale under its own key: the only way to get different numbers is a
310
+ different key.
311
+
312
+ What it buys is the revalidation path. A conditional GET has to know the
313
+ document's hash before it can answer 304, and the hash is only known by
314
+ building the document; without a cache every ``If-None-Match`` would redo
315
+ the whole build in order to reply "nothing changed". Measured on a three
316
+ unit portfolio, that was 210 ms on ``exhibit/tail`` and 48 ms on
317
+ ``exhibit/summary``, against 7 ms for a chart answering off this cache.
318
+
319
+ Bounded by entries rather than by bytes, because what a reader generates in
320
+ one sitting is one object's documents at a few settings, and the payloads
321
+ within a channel are the same order of size as each other.
322
+ """
323
+
324
+ def __init__(self, channel: str, max_entries: int) -> None:
325
+ self.channel = channel
326
+ self.max_entries = max_entries
327
+ self._entries: OrderedDict[tuple, tuple[str, bytes]] = OrderedDict()
328
+ self._lock = threading.Lock()
329
+
330
+ def __len__(self) -> int:
331
+ with self._lock:
332
+ return len(self._entries)
333
+
334
+ def get(self, key: tuple) -> tuple[str, bytes] | None:
335
+ """Return the cached ``(etag, body)`` for ``key``, or None, marking it used."""
336
+ with self._lock:
337
+ hit = self._entries.get(key)
338
+ if hit is not None:
339
+ self._entries.move_to_end(key)
340
+ status_state.record_cache(self.channel,
341
+ "hit" if hit is not None else "miss")
342
+ return hit
343
+
344
+ def store(self, key: tuple, etag: str, body: bytes) -> None:
345
+ """File ``(etag, body)`` under ``key``, evicting the least recently read."""
346
+ evicted = 0
347
+ with self._lock:
348
+ self._entries[key] = (etag, body)
349
+ self._entries.move_to_end(key)
350
+ while len(self._entries) > self.max_entries:
351
+ self._entries.popitem(last=False)
352
+ evicted += 1
353
+ status_state.record_cache(self.channel, "store")
354
+ for _ in range(evicted):
355
+ status_state.record_cache(self.channel, "eviction")
356
+
357
+ def clear(self) -> None:
358
+ """Drop every entry, for ``reset_singletons``."""
359
+ with self._lock:
360
+ self._entries.clear()
361
+
362
+
363
+ # Small on purpose. A joint surface at the public ceiling of 256 cells per axis
364
+ # is a few hundred kB; at the local default of 1024 it can be a few MB, so eight
365
+ # entries is a worst case of a few tens of MB.
366
+ _CHART_CACHE_MAX = 8
367
+ _chart_cache = RevalidationCache("chart", _CHART_CACHE_MAX)
368
+
369
+ # Exhibit envelopes run a few kB against a chart's few MB, so a much larger
370
+ # count is still a far smaller worst case. Sixty four holds every exhibit a
371
+ # reader is likely to open on one object under both perspectives, which is the
372
+ # working set that matters: the cost this removes is paid on the *return* to a
373
+ # leaf, and returning is what reading an exhibit pane consists of.
374
+ _EXHIBIT_CACHE_MAX = 64
375
+ _exhibit_cache = RevalidationCache("exhibit", _EXHIBIT_CACHE_MAX)
376
+
377
+
378
+ # ----------------------------------------------------------------------
379
+ # Helpers
380
+ # ----------------------------------------------------------------------
381
+
382
+ def _client_ip(request: Request) -> str:
383
+ """The address this request came from, honoring one trusted proxy.
384
+
385
+ Notes
386
+ -----
387
+ A one-line delegation to :func:`aggregate_api.net.client_address`, kept as a
388
+ name here because every audit call site reads it and the indirection is the
389
+ point: the rule for which forwarded element to trust is written once, beside
390
+ the gate that depends on it being right.
391
+
392
+ Through a111 this read ``request.client.host`` and nothing else. Both Caddy
393
+ front doors proxy to ``127.0.0.1:8001``, so every production row recorded
394
+ ``ip = '127.0.0.1'``, the ``builds_ip`` index indexed one value, and
395
+ :meth:`aggregate_api.audit.AuditLog.by_ip` could not answer the question it
396
+ exists for. Rows written before a112 are not retroactively meaningful.
397
+ """
398
+ return client_address(request)
399
+
400
+
401
+ def _now_iso() -> str:
402
+ """ISO 8601 timestamp with millisecond precision."""
403
+ return datetime.now(timezone.utc).isoformat(timespec="milliseconds")
404
+
405
+
406
+ class _NoteCollector(logging.Handler):
407
+ """A logging handler that keeps formatted records in a list."""
408
+
409
+ def __init__(self) -> None:
410
+ super().__init__(level=logging.WARNING)
411
+ self.notes: list[str] = []
412
+
413
+ def emit(self, record: logging.LogRecord) -> None:
414
+ # Never let a bad format string in someone else's log call break a
415
+ # build: the notes are a courtesy, the object is the product.
416
+ try:
417
+ self.notes.append(record.getMessage())
418
+ except Exception: # noqa: BLE001 -- see above
419
+ pass
420
+
421
+
422
+ @contextmanager
423
+ def _collecting_notes():
424
+ """Collect what ``aggregate`` says while a build runs, on both channels.
425
+
426
+ Yields a list that fills with the messages the library emitted at WARNING
427
+ and above. Empty is the common case and means the build had nothing to say.
428
+
429
+ Notes
430
+ -----
431
+ **Two channels, because the library uses two.** ``logger.warning`` is the
432
+ larger by far (``_aggregate``, ``underwriter`` and ``parser`` alone account
433
+ for most of it) and carries the messages a reader most wants, such as a
434
+ splice whose components do not meet. ``warnings.warn`` carries the rest,
435
+ including the library's own ``IgnoredDecLClauseWarning`` family. Capturing
436
+ only one of them would have looked like it worked, on whichever example was
437
+ tried first.
438
+
439
+ **Both mutate process-global state, and this runs on the build worker
440
+ thread.** That is safe here for a structural reason rather than a hopeful
441
+ one: ``_build_semaphore`` admits one build at a time and
442
+ ``_build_executor`` has a single worker, so there is exactly one writer to
443
+ the logger's handler list and to the warnings filters while this is open.
444
+ Entering the context *inside* the worker (rather than around
445
+ ``future.result()`` on the calling thread) is deliberate:
446
+ ``catch_warnings`` swaps module state that a warning raised on another
447
+ thread would not reliably see.
448
+
449
+ **Both channels are scoped to the library, and both need scoping.** The
450
+ handler goes on the ``aggregate`` logger rather than the root, so nothing
451
+ this service logs about itself is mistaken for something the model said.
452
+ The warnings half needs the same discipline, and
453
+ :mod:`aggregate_api.library_notes` is where that rule now lives, because
454
+ this was not the only capture site: ``pricing.py`` held two more and kept
455
+ everything they caught, which is how the audit log's own
456
+ ``unclosed database in <sqlite3.Connection ...>`` reached a reader's status
457
+ strip through the Price tab while this route filtered it out correctly.
458
+ Both use :func:`~aggregate_api.library_notes.from_library` now.
459
+
460
+ The loop below is spelled out rather than using
461
+ :func:`~aggregate_api.library_notes.library_warnings`, because this one has
462
+ to interleave with the logging collector: the notes from both channels land
463
+ in one list, in the order they were said.
464
+ """
465
+ collector = _NoteCollector()
466
+ lib_logger = logging.getLogger("aggregate")
467
+ lib_logger.addHandler(collector)
468
+ # A library logger with no handler and no propagation would drop records
469
+ # before ours ran; and one whose level is above WARNING would never emit
470
+ # them at all. Force both for the duration and restore after.
471
+ was_level = lib_logger.level
472
+ if was_level > logging.WARNING or was_level == logging.NOTSET:
473
+ lib_logger.setLevel(logging.WARNING)
474
+ try:
475
+ with warnings.catch_warnings(record=True) as caught:
476
+ warnings.simplefilter("always")
477
+ yield collector.notes
478
+ for w in caught:
479
+ if from_library(getattr(w, "filename", "")):
480
+ collector.notes.append(str(w.message))
481
+ finally:
482
+ lib_logger.removeHandler(collector)
483
+ lib_logger.setLevel(was_level)
484
+
485
+
486
+ def _run_build(uw, decl: str, log2: int, bs: float):
487
+ """Invoke the caller's ``build()``, collecting what it says.
488
+
489
+ Pulled into a helper so the thread-pool target is a plain
490
+ function -- closures over ``log2=0`` / ``bs=0`` are the
491
+ library's "let me pick" signal, so we forward the request's
492
+ values verbatim.
493
+
494
+ Parameters
495
+ ----------
496
+ uw : aggregate.underwriter.Underwriter
497
+ The caller's fork, from :func:`_get_session_uw`. Passed in rather than
498
+ reached for, because which base builds a program is the whole of what
499
+ keeps one user's ``agg Cat`` out of another user's program, and a
500
+ helper that reached for a global could not be told otherwise.
501
+
502
+ Returns
503
+ -------
504
+ tuple
505
+ ``(obj, notes)``, the built object and the library's WARNING-and-above
506
+ messages. See :func:`_collecting_notes` for why the capture is opened
507
+ here, on the worker, rather than around the future.
508
+ """
509
+ # log2=0 / bs=0 are the underlying ``build()``'s "use defaults"
510
+ # sentinels; pass them through when the request omitted those
511
+ # knobs.
512
+ with _collecting_notes() as notes:
513
+ obj = uw(decl, log2=log2, bs=bs)
514
+ return obj, notes
515
+
516
+
517
+ def _preview(uw, text: str):
518
+ """What ``text`` resolves to in ``uw``, or ``None`` if it will not say.
519
+
520
+ Raises only :class:`aggregate.underwriter.RecipeNotFound`. A program that
521
+ cannot be previewed for any other reason is one that cannot be keyed
522
+ either, and the answer to that is to give it a private key and let the
523
+ build path report the real error, which it does better than this could: a
524
+ parse failure comes back as the library's ``ErrorReport``, with line,
525
+ column and caret.
526
+
527
+ The missing name is the exception, and it is let through on purpose. It is
528
+ the shape an expired session takes: the fork holding what this user built
529
+ has been evicted, so their own ``agg.NAME`` now names nothing. Swallowing
530
+ it here would qualify the key and then possibly find an object still
531
+ cached under it, answering a program whose reference no longer resolves.
532
+ Better to say what is missing.
533
+
534
+ Notes
535
+ -----
536
+ This runs **outside** the build slot, which is new. Every parse used to be
537
+ serialized by accident, being inside the one-worker executor. The library
538
+ documents ``preview`` as holding no instance state (a private parser, a
539
+ private cycle guard) and the Lark grammar it leans on is a module
540
+ singleton, so concurrent previews are safe by construction rather than by
541
+ luck; ``tests/test_sessions.py`` pins it.
542
+ """
543
+ try:
544
+ return uw.preview(text)
545
+ except RecipeNotFound:
546
+ raise
547
+ except Exception: # noqa: BLE001 -- fail closed, see the docstring
548
+ return None
549
+
550
+
551
+ def _is_library_entry(source) -> bool:
552
+ """True when a resolved reference means the same thing to everybody.
553
+
554
+ The library records provenance as the ``.agg`` file an entry was read from,
555
+ or the sentinel string ``'session'`` for one a build wrote. So the test is
556
+ "did this come from a file", and it is written that way round on purpose:
557
+ anything unrecognized answers False and the program gets a private key.
558
+ Failing toward a redundant build costs one build; failing toward a shared
559
+ one serves somebody else's answer.
560
+ """
561
+ return isinstance(source, PurePath)
562
+
563
+
564
+ def _cache_key(preview, session_id: str, canonical: str, log2: int, bs: float):
565
+ """The cache id for this request, and which rule produced it.
566
+
567
+ A program that resolved nothing, or resolved only entries read from the
568
+ library file, means the same thing in every session: the shared key, one
569
+ build for the whole room. A program that touched anything its own session
570
+ declared, **including a library name that session overwrote**, is private:
571
+ the session id joins the hash.
572
+
573
+ Returns
574
+ -------
575
+ (str, str, str or None)
576
+ The id, ``'shared'`` or ``'session'`` for the audit row, and why the
577
+ session key was taken. The reason is ``None`` for a shared key, and
578
+ otherwise one of two that mean opposite things. ``'session_reference'``
579
+ is the rule working: the program touched a name its own session
580
+ declared, so it cannot share a slot. ``'preview_unavailable'`` is the
581
+ previewer declining to speak about the program at all, which is usually
582
+ a program about to fail its build and is occasionally the previewer
583
+ refusing what the builder accepts. Only the second is a finding, and
584
+ ``GET /v1/status`` counts them apart for that reason.
585
+ """
586
+ if preview is None:
587
+ return (qualified_object_id(session_id, canonical, log2, bs),
588
+ "session", "preview_unavailable")
589
+ if all(_is_library_entry(ref.source) for ref in preview.resolved):
590
+ return object_id(canonical, log2, bs), "shared", None
591
+ return (qualified_object_id(session_id, canonical, log2, bs),
592
+ "session", "session_reference")
593
+
594
+
595
+ def _register(uw, preview) -> None:
596
+ """File a previewed program's declarations in the caller's own base.
597
+
598
+ The build path registers what it parses, so on a cache **miss** this has
599
+ already happened. On a **hit** nothing was parsed, and without this the
600
+ user who was served a cached object could not then refer to it: their next
601
+ ``agg.NAME`` or ``sev agg.NAME`` would fail on a name their own base never
602
+ saw, and their ``.agg`` download would omit it. So a hit registers too, and
603
+ the two paths leave the same base behind.
604
+
605
+ Notes
606
+ -----
607
+ The bare-name route registers nothing: the entry was already there, and
608
+ filing it again would re-mark a library entry as this session's, which is
609
+ exactly the flag the cache rule reads. ``expr`` is skipped for the reason
610
+ the library skips it, being an answer rather than a declaration.
611
+ """
612
+ if preview is None or preview.route != "program":
613
+ return
614
+ for statement in preview.statements:
615
+ if statement.kind == "expr":
616
+ continue
617
+ uw.add_recipe(statement.kind, statement.name, statement.spec,
618
+ statement.program)
619
+
620
+
621
+ def _missing_entry_detail(exc: RecipeNotFound) -> dict:
622
+ """The 422 body for a name the caller's own recipe base does not hold.
623
+
624
+ Structured rather than a bare sentence, because the app can act on it. The
625
+ common cause is not a typo: it is an expired session. A fork is dropped when
626
+ it goes idle past the TTL, when the registry evicts it under pressure, or
627
+ when the server restarts, and after that a user's reference to something
628
+ they built themselves names nothing. Their program is still in the SPA's
629
+ history, so naming the ``kind`` and ``name`` lets the error pane offer the
630
+ rebuild rather than describing it.
631
+
632
+ Returns
633
+ -------
634
+ dict
635
+ ``error``, ``kind``, ``name`` and ``message``. The ``message`` is the
636
+ library's own sentence, kept verbatim: it already explains the case
637
+ where the name parsed and then went away.
638
+ """
639
+ return {
640
+ "error": "recipe_not_found",
641
+ "kind": getattr(exc, "kind", None),
642
+ "name": getattr(exc, "name", "") or "",
643
+ "message": getattr(exc, "message", None) or str(exc),
644
+ }
645
+
646
+
647
+ def _resolve_object(oid: str, cache: ObjectCache) -> CacheEntry:
648
+ """Fetch an entry or raise 404."""
649
+ entry = cache.get(oid)
650
+ if entry is None:
651
+ raise HTTPException(status_code=404, detail=f"object {oid} not in cache")
652
+ return entry
653
+
654
+
655
+ async def _locked_entry(oid: str):
656
+ """Dependency: resolve an object and hold its lock for the whole request.
657
+
658
+ Every route that pulls data off a built object depends on this rather than
659
+ calling :func:`_resolve_object` in its body, which makes the guarantee
660
+ structural instead of a habit each new handler has to remember.
661
+
662
+ Why a lock at all, for something described as a read: an ``Aggregate`` or
663
+ ``Portfolio`` materializes several frames lazily and caches them on the
664
+ instance, so the first read *is* a write. FastAPI runs these synchronous
665
+ handlers in a thread pool, so two requests for different frames of the same
666
+ object are genuinely two threads racing to build them.
667
+
668
+ Measured, not theoretical. Fetching ``unit_density_df`` and ``tail_df``
669
+ together for one Portfolio (exactly what the Overview exhibit does, in a
670
+ single ``Promise.all``) raised ``KeyError: "['F', 'S'] not in index"`` from
671
+ inside ``Portfolio.unit_density_df`` on roughly half of cold-object runs,
672
+ and never once the frames were warm.
673
+
674
+ The lock is per entry, so unrelated objects still serve in parallel, and
675
+ contention is confined to the first access of each frame.
676
+
677
+ A bare generator, deliberately **not** wrapped in ``@contextmanager``:
678
+ FastAPI drives a yield-dependency as an iterator itself, and the wrapper
679
+ hands it a context-manager object instead, which fails with
680
+ ``'_GeneratorContextManager' object is not an iterator``.
681
+
682
+ **Asynchronous and bounded, because waiting here in a worker thread could
683
+ kill the process.** FastAPI runs a sync dependency and a sync handler in two
684
+ separate ``run_in_threadpool`` calls, each taking one of anyio's 40
685
+ thread-limiter tokens. So a request that has acquired this lock still needs a
686
+ *second* token to reach its handler and release it. While this was a sync
687
+ dependency, forty requests blocking here held every token in threads that
688
+ could not progress, the holder could never get a token to finish, and
689
+ nothing broke the cycle: the process parked at 0% CPU with every sync route
690
+ unreachable, ``/v1/health`` included, until it was restarted.
691
+
692
+ a201 bounded the wait, which stopped the process dying and was not enough:
693
+ the same shape returned as a livelock that only the timeouts could break, 33
694
+ of 50 callers taking a 503 after ten seconds while the 17 winners ran at 590
695
+ ms apiece against a 46 ms critical section. Waiting on the event loop
696
+ instead, which is what a202 does below, is the fix: a waiter that holds no
697
+ token cannot starve the holder.
698
+
699
+ That is not hypothetical and not about load. Measured at a200, from a fresh
700
+ server each round, concurrent reads of **one** object: 39 callers survived,
701
+ 45 killed it. The cliff is at 40 because that is the token count. No client
702
+ disconnect is involved; aborting a request mid-critical-section releases the
703
+ lock correctly every time, which is a theory worth not re-testing.
704
+
705
+ The demonstration meets this immediately, because the shared cache key puts
706
+ the whole room on one ``CacheEntry`` and therefore on one lock. See
707
+ ``dev/plan-demo-load.md`` item 0.
708
+
709
+ A 503 is the right refusal: the object exists and the caller may have it in
710
+ a moment, which is what ``Retry-After`` says. Raising *before* the ``try``
711
+ is deliberate, so a failed acquire cannot reach the ``finally`` and release
712
+ a lock this request never held.
713
+
714
+ Yields
715
+ ------
716
+ CacheEntry
717
+ """
718
+ # Both dependencies are resolved by hand rather than through Depends. A sync
719
+ # dependency costs a thread token, and token pressure is this function's
720
+ # whole problem; `_get_cache` and `get_settings` are both singleton lookups,
721
+ # so the injection bought nothing here but the token.
722
+ settings = get_settings()
723
+ entry = _resolve_object(oid, _get_cache(settings))
724
+
725
+ # Wait on the event loop, never in a worker thread. `acquire(blocking=True)`
726
+ # would park a thread that is holding a token, which is the livelock
727
+ # described above: the request that holds the lock cannot get a token to run
728
+ # its handler, because the waiters are holding all of them. Polling a
729
+ # non-blocking acquire between `anyio.sleep` checkpoints keeps every waiter
730
+ # on the loop, where it costs no token, so the holder always gets one and
731
+ # always makes progress.
732
+ #
733
+ # A poll rather than an async lock on purpose. `CacheEntry.lock` is a plain
734
+ # `threading.Lock` because the object it guards is touched from worker
735
+ # threads, and swapping it for `anyio.Lock` would make every sync holder an
736
+ # async one. Five milliseconds against critical sections measured at 46 ms
737
+ # (a display-resolution `density_df`, which is what the SPA asks for) is
738
+ # under a tenth of a handoff, and the deadline is the real contract.
739
+ #
740
+ # Not FIFO, so a waiter can in principle be passed over. Bounded by the
741
+ # deadline below, which is the same answer a fair queue would eventually
742
+ # give.
743
+ deadline = time.monotonic() + settings.entry_lock_timeout_s
744
+ while not entry.lock.acquire(blocking=False):
745
+ if time.monotonic() >= deadline:
746
+ raise HTTPException(
747
+ status_code=503,
748
+ detail=f"object {oid} is busy; retry shortly",
749
+ headers={"Retry-After": "2"},
750
+ )
751
+ await anyio.sleep(0.005)
752
+ try:
753
+ yield entry
754
+ finally:
755
+ entry.lock.release()
756
+
757
+
758
+ def _has_reinsurance(obj: Any) -> bool:
759
+ """Does this object's distribution sit net of a cession?
760
+
761
+ Reads the cession specs directly (``occ_reins`` / ``agg_reins``) rather than
762
+ materializing ``reins_summary_df``, because this is called on the build path
763
+ for every object and that frame is not cheap.
764
+
765
+ A ``Portfolio`` carries no cession of its own, so it is asked about its
766
+ units, and a ``PnL`` carries none either, so it is asked about its engine.
767
+ Both recursions are gated on the class name rather than on iterability: an
768
+ ``Aggregate`` is iterable too, and walking one here would be a loop with no
769
+ base case.
770
+
771
+ Notes
772
+ -----
773
+ **The P&L case was wrong until a113**, and silently: a ``PnL`` has no
774
+ ``occ_reins`` attribute at all, so a P&L over a reinsured engine reported
775
+ ``has_reins`` False on every build response since the P&L work landed. Two
776
+ things follow it. The summary strip's flag, and the choice of moments in
777
+ :func:`_summary_fields`, which prefers the realized pair net of a cession
778
+ because those are the ones describing what is on screen, and was handing a
779
+ reinsured P&L the analytic pair.
780
+
781
+ Parameters
782
+ ----------
783
+ obj : Any
784
+
785
+ Returns
786
+ -------
787
+ bool
788
+ """
789
+ if getattr(obj, "occ_reins", None) is not None:
790
+ return True
791
+ if getattr(obj, "agg_reins", None) is not None:
792
+ return True
793
+ if type(obj).__name__ == "Portfolio":
794
+ return any(_has_reinsurance(unit) for unit in obj)
795
+ if type(obj).__name__ == "PnL":
796
+ return _has_reinsurance(getattr(obj, "engine", None))
797
+ return False
798
+
799
+
800
+ def _value_type(obj: Any) -> str | None:
801
+ """The sign convention the object is read on: ``'loss'`` or ``'payoff'``.
802
+
803
+ Parameters
804
+ ----------
805
+ obj : Any
806
+ Any first-class object.
807
+
808
+ Returns
809
+ -------
810
+ str or None
811
+ The convention's label, or ``None`` for a kind that has no orientation
812
+ to report (a ``Distortion``, a ``Severity``).
813
+
814
+ Notes
815
+ -----
816
+ **Always reported, including ``'loss'``.** a57 printed it only for
817
+ ``'payoff'``, reasoning that loss is the default and stamping it on every
818
+ build adds noise to a line that has been trimmed twice. The reasoning was
819
+ sound and the outcome was that the field printed for **no object the app can
820
+ build**: ``Aggregate`` and ``Portfolio`` both answer ``'loss'`` and were
821
+ suppressed, and a ``PnL`` has no ``value_type`` at all. The author's ruling,
822
+ 2026-08-10, is that the convention is worth a word on every build, which is
823
+ what asking for it in the first place meant.
824
+
825
+ **Read off the object, never asserted about it.** a65 carried a special
826
+ case here: a ``PnL`` exposed neither ``value_type`` nor the internal
827
+ ``_is_loss_value``, and it is the one kind whose reading genuinely depends
828
+ on the convention, so the app stated ``payoff`` on the author's ruling
829
+ ("implied by the name, profit positive and loss negative") while knowing
830
+ that was the app holding a fact about a library class from outside it. It
831
+ came out at a68: ``aggregate`` 1.0.0a248 states the convention on the class,
832
+ which is where it belongs, and this is a plain read again.
833
+ """
834
+ declared = getattr(obj, "value_type", None)
835
+ return str(declared) if declared is not None else None
836
+
837
+
838
+ def _summary_fields(obj: Any) -> dict:
839
+ """Headline grid and moments for the build summary: ``bs``, ``log2``,
840
+ ``mean``, ``cv``, ``validation``, plus the program's own ``note`` and
841
+ ``tags``.
842
+
843
+ ``Aggregate`` and ``Portfolio`` carry the analytic moments on ``actual_m`` /
844
+ ``actual_cv`` and the realized (model-output) ones on ``est_m`` / ``est_cv``.
845
+ The summary shows the analytic value, which is what the program asked for,
846
+ and falls back to the estimate: a ``PnL`` has only ``est_*`` (its outcome is
847
+ emergent, so there is no input mean to report).
848
+
849
+ **Under reinsurance that order reverses**, and it is not a preference. The
850
+ two attributes then describe two different random variables: ``actual_m`` is
851
+ the analytic mean of the *subject* (gross) book, while the object's density,
852
+ every percentile, ``summary_df`` and the plotted distribution are all
853
+ **net**. On one measured program (limits ``250 500 1000 2000 xs 0`` with a
854
+ ``750 xs 750`` occurrence cession) that is 12,000 against 549.5, so the
855
+ summary bar reported a number twenty-two times the one in the table directly
856
+ beneath it, and the exhibit drew its mean reference line off the end of the
857
+ axis. The library is not wrong here: ``validation_description`` says
858
+ "reinsurance; subject not unreasonable", which is it telling you exactly
859
+ which variable ``actual_m`` belongs to.
860
+
861
+ Notes
862
+ -----
863
+ The ``agg_m`` / ``agg_cv`` spellings this used to read were renamed to
864
+ ``actual_*`` at ``aggregate`` 1.0.0a149, and there is no alias. Everything is
865
+ getattr-gated, so an object kind carrying none of them reports ``None``
866
+ rather than raising.
867
+ """
868
+ def _num(*names: str) -> float | None:
869
+ # First present, float-coercible attribute wins; skip missing or
870
+ # non-numeric ones so a PnL's ``est_m`` backs up the ``actual_m`` miss.
871
+ for name in names:
872
+ v = getattr(obj, name, None)
873
+ if v is None:
874
+ continue
875
+ try:
876
+ return float(v)
877
+ except (TypeError, ValueError):
878
+ continue
879
+ return None
880
+
881
+ # The terse verdict ("not unreasonable" / "fails sev mean, agg mean") is
882
+ # ``validation_description``. Do NOT read ``validation_explanation``: since
883
+ # aggregate 1.0.0a172 that is the long form it always claimed to be, a whole
884
+ # paragraph naming what was checked, which would swamp the one-line summary.
885
+ # getattr-gated so an object kind without it simply reports ``None``.
886
+ validation: str | None = None
887
+ description = getattr(obj, "validation_description", None)
888
+ if description is not None:
889
+ validation = str(description)
890
+
891
+ # Net of a cession, the realized moments are the ones that describe what is
892
+ # on screen; gross, the analytic ones are exact and the estimates carry
893
+ # discretization error. Either way the fallback is the other one.
894
+ reinsured = _has_reinsurance(obj)
895
+ m, cv = ("est_m", "actual_m"), ("est_cv", "actual_cv")
896
+ if not reinsured:
897
+ m, cv = m[::-1], cv[::-1]
898
+
899
+ # ``log2`` rides beside ``bs`` because the two are one fact: bs is how fine
900
+ # the grid is and log2 is how far it reaches, and bs * 2**log2 is the window
901
+ # the object was computed on. An int, and int-coerced rather than
902
+ # float-coerced, so the strip prints ``log2 = 16`` and not ``16.0``.
903
+ log2 = getattr(obj, "log2", None)
904
+ try:
905
+ log2 = int(log2) if log2 is not None else None
906
+ except (TypeError, ValueError):
907
+ log2 = None
908
+
909
+ value_type = _value_type(obj)
910
+
911
+ # The program's own ``note{}`` and ``tags{}``, so the status strip can say
912
+ # them on every build without a second request. They ride with the moments
913
+ # rather than on fields of their own because all three call sites (cache
914
+ # miss, cache hit, the manifest a derivation returns) want them, and one of
915
+ # them would otherwise forget to ask.
916
+ #
917
+ # **The note is verbatim, and it is not only the author's prose.** A grid
918
+ # audit merges its own verdict into the same field
919
+ # (``aggregate._program._SHARPEN_NOTE``, the ``sharpen: `` chunk
920
+ # :mod:`aggregate_api.capability` tests for), so after a Sharpen the note
921
+ # carries the library's sentence beside the author's. That is what the note
922
+ # says, and editing it down is not the app's call.
923
+ note = getattr(obj, "note", None)
924
+ note = str(note).strip() if note is not None else ""
925
+ tags = [str(t) for t in (getattr(obj, "tags", ()) or ())]
926
+
927
+ # An object built from a pair carries no scalar moments of its own (its
928
+ # ``bs`` is genuinely two numbers, and ``actual_m`` describes a single
929
+ # ``Aggregate``), but its TOTAL does: the joint's total distribution is
930
+ # the pair's headline reading, and ``stats_df`` publishes it in the
931
+ # ``total`` column of the ``(component, measure)`` frame (library
932
+ # ``[Bivariate-Punchup]``, a330). Filled only when the scalar path found
933
+ # nothing, so every other kind is untouched. The desktop strip is also
934
+ # untouched: its pair branch renders from ``components`` and never reads
935
+ # these scalars. The consumer is the lite page's tiles, empty for a pair
936
+ # through a170.
937
+ mean = _num(*m)
938
+ cv = _num(*cv)
939
+ if mean is None and cv is None:
940
+ mean = _total_stat(obj, "mean")
941
+ cv = _total_stat(obj, "cv")
942
+
943
+ return {
944
+ "bs": _num("bs"),
945
+ "log2": log2,
946
+ "mean": mean,
947
+ "cv": cv,
948
+ "validation": validation,
949
+ "has_reins": reinsured,
950
+ "policy_limit": policy_limit(obj),
951
+ "value_type": str(value_type) if value_type is not None else None,
952
+ "note": note or None,
953
+ "tags": tags,
954
+ "components": _component_fields(obj),
955
+ }
956
+
957
+
958
+ def _total_stat(obj: Any, stat: str) -> float | None:
959
+ """One aggregate statistic of a pair's total, off ``stats_df['total']``.
960
+
961
+ Parameters
962
+ ----------
963
+ obj : Any
964
+ The built object; only one carrying a ``stats_df`` with a ``total``
965
+ column and the ``(component, measure)`` row index answers.
966
+ stat : str
967
+ ``'mean'`` / ``'cv'`` / ``'skew'``, a row of the ``agg`` block.
968
+
969
+ Returns
970
+ -------
971
+ float or None
972
+ The statistic, or ``None`` wherever the frame, the column, or the row
973
+ is absent or non-finite: this is a fallback, never a requirement.
974
+ """
975
+ stats = getattr(obj, "stats_df", None)
976
+ if stats is None or "total" not in getattr(stats, "columns", ()):
977
+ return None
978
+ try:
979
+ value = float(stats.loc[("agg", stat), "total"])
980
+ except (KeyError, IndexError, TypeError, ValueError):
981
+ return None
982
+ return value if math.isfinite(value) else None
983
+
984
+
985
+ def _component_fields(obj: Any) -> list[dict]:
986
+ """Per-component grid and moments, for an object built from a pair.
987
+
988
+ Returns
989
+ -------
990
+ list of dict
991
+ ``{"name", "bs", "log2", "mean", "cv"}`` per component, or ``[]`` for
992
+ an object that is not a pair.
993
+
994
+ Notes
995
+ -----
996
+ Only a ``BivariateAggregate`` answers this today, and it is the reason the
997
+ block exists: its ``bs`` is a **two element list**, one grid per axis, so
998
+ every scalar field in :func:`_summary_fields` comes back ``None`` for it
999
+ and its status line said nothing but a name and a kind. The pair is the
1000
+ honest answer, not a scalar chosen from it.
1001
+
1002
+ Additive, deliberately. Widening ``bs`` / ``log2`` / ``mean`` / ``cv`` to
1003
+ "scalar or pair" would change the response type every other kind is read
1004
+ with, to describe one kind; a block that is empty everywhere else costs
1005
+ those kinds nothing.
1006
+
1007
+ ``log2`` is derived rather than read: a bivariate carries no ``log2``
1008
+ attribute, only the per-axis grids, and the axis length is what log2 means
1009
+ (the library's own ``bs_description`` computes it the same way).
1010
+
1011
+ **The moments come off ``stats_df``, not off ``units``.** a57 read them from
1012
+ ``obj.units``, on the stated belief that it holds a list of ordinary
1013
+ ``Aggregate`` objects. It does not: ``units`` is ``None`` on a
1014
+ ``BivariateAggregate``, so both moments resolved to ``None`` and the strip
1015
+ printed ``mean (?, ?) . CV (?, ?)`` for every pair built since. ``stats_df``
1016
+ is the public frame that has them, and its columns are exactly
1017
+ ``unit_names``, so the pair lines up by name rather than by position. The
1018
+ ``units`` path is kept ahead of it for a kind that does carry components,
1019
+ and costs nothing when there are none.
1020
+
1021
+ **The row key is ``("agg", stat)``, and it moved at library a330.** Until
1022
+ then a bivariate's ``stats_df`` was indexed by basis, so this read
1023
+ ``("theoretical", stat)`` and fell back to ``("empirical", stat)``.
1024
+ ``[Bivariate-Punchup]`` gave the pair the Portfolio layout: the index is now
1025
+ ``(component, measure)`` over ``meta`` / ``freq`` / ``sev`` / ``agg``
1026
+ blocks, and the columns are the unit names plus ``independent`` and
1027
+ ``total``. Neither old key exists, so both moments resolved to ``None``
1028
+ again and the strip went back to printing ``mean (?, ?) . CV (?, ?)``,
1029
+ which is the a57 bug arriving by a new route.
1030
+
1031
+ ``agg`` is the analytic aggregate moment, which is what ``theoretical``
1032
+ meant, so the preference this docstring used to record is kept rather than
1033
+ dropped. There is no second basis to fall back to any more, and a key that
1034
+ is not there already resolves to ``None`` through the ``except`` below.
1035
+ """
1036
+ axis_xs = getattr(obj, "axis_xs", None)
1037
+ names = getattr(obj, "unit_names", None)
1038
+ units = getattr(obj, "units", None)
1039
+ bss = getattr(obj, "bs", None)
1040
+ if not axis_xs or not names or not isinstance(bss, (list, tuple)):
1041
+ return []
1042
+ stats = getattr(obj, "stats_df", None)
1043
+
1044
+ def _moment(unit: Any, *candidates: str) -> float | None:
1045
+ for name in candidates:
1046
+ v = getattr(unit, name, None)
1047
+ if v is None:
1048
+ continue
1049
+ try:
1050
+ return float(v)
1051
+ except (TypeError, ValueError):
1052
+ continue
1053
+ return None
1054
+
1055
+ def _from_stats(column: str, stat: str) -> float | None:
1056
+ """One statistic for one axis, by name, out of the pair's stats frame."""
1057
+ if stats is None or column not in getattr(stats, "columns", ()):
1058
+ return None
1059
+ try:
1060
+ value = float(stats.loc[("agg", stat), column])
1061
+ except (KeyError, IndexError, TypeError, ValueError):
1062
+ return None
1063
+ return value if math.isfinite(value) else None
1064
+
1065
+ out: list[dict] = []
1066
+ for i, name in enumerate(names):
1067
+ try:
1068
+ n = len(axis_xs[i])
1069
+ bs = float(bss[i])
1070
+ except (IndexError, TypeError, ValueError):
1071
+ continue
1072
+ unit = units[i] if units is not None and i < len(units) else None
1073
+ mean = _moment(unit, "actual_m", "est_m") if unit is not None else None
1074
+ cv = _moment(unit, "actual_cv", "est_cv") if unit is not None else None
1075
+ out.append({
1076
+ "name": str(name),
1077
+ "bs": bs,
1078
+ "log2": int(round(math.log2(n))) if n > 0 else None,
1079
+ "mean": mean if mean is not None else _from_stats(str(name), "mean"),
1080
+ "cv": cv if cv is not None else _from_stats(str(name), "cv"),
1081
+ })
1082
+ return out
1083
+
1084
+
1085
+ # Raw-moment statistic labels (E[X], E[X^2], E[X^3]). The displayed stats /
1086
+ # reins-stats tables drop these rows -- nobody reads E[X^2]; the human-readable
1087
+ # mean / cv / skew (and the ``meta`` block) carry the story. The full-frame CSV
1088
+ # download keeps them (the "give me everything" export).
1089
+ _RAW_MOMENTS = frozenset({"ex1", "ex2", "ex3"})
1090
+
1091
+
1092
+ def _drop_raw_moments(df):
1093
+ """Drop the ``ex1`` / ``ex2`` / ``ex3`` rows; keep mean / cv / skew (+ meta).
1094
+
1095
+ Parameters
1096
+ ----------
1097
+ df : pandas.DataFrame or None
1098
+ ``stats_df`` / ``reins_stats_df``, whose rows carry a 2-level
1099
+ ``(group, statistic)`` MultiIndex (the ``meta`` group has its own
1100
+ labels and no ``ex*``, so it's untouched). None passes through, because
1101
+ this composes inside ``_CSV_FRAMES`` resolvers and "the object has no
1102
+ such frame" is an ordinary answer there, reported as a 400 further up.
1103
+
1104
+ Returns
1105
+ -------
1106
+ pandas.DataFrame or None
1107
+ The frame with the raw-moment rows removed. Filters on the
1108
+ *innermost* index level, so it works for a flat index too.
1109
+ """
1110
+ if df is None:
1111
+ return None
1112
+ stat = df.index.get_level_values(-1)
1113
+ return df[~stat.isin(_RAW_MOMENTS)]
1114
+
1115
+
1116
+ def _resolve_frame(obj: Any, name: str):
1117
+ """Return the named frame, calling it when it is a method.
1118
+
1119
+ The risk frames are properties as of ``aggregate`` 1.0.0a149, which turned
1120
+ ``tail_df`` from a method into one. Both shapes are still resolved, so a
1121
+ frame that goes back to being callable (or a class that never converted)
1122
+ keeps working: a missing or ``None`` attribute yields ``None`` (the route
1123
+ answers 400), a callable is invoked with its defaults, anything else is
1124
+ returned as-is.
1125
+
1126
+ Parameters
1127
+ ----------
1128
+ obj : Any
1129
+ The built object (Aggregate / Portfolio / BivariateAggregate / ...).
1130
+ name : str
1131
+ Attribute name to resolve to a DataFrame.
1132
+
1133
+ Returns
1134
+ -------
1135
+ pandas.DataFrame or None
1136
+ """
1137
+ attr = getattr(obj, name, None)
1138
+ if attr is None:
1139
+ return None
1140
+ return attr() if callable(attr) else attr
1141
+
1142
+
1143
+ def _bs_window_frame(obj: Any):
1144
+ """The grid-sizing frame, as the library publishes it.
1145
+
1146
+ One resolution shared by the JSON route and the CSV download so the two
1147
+ cannot answer differently.
1148
+
1149
+ Parameters
1150
+ ----------
1151
+ obj : Any
1152
+
1153
+ Returns
1154
+ -------
1155
+ pandas.DataFrame or None
1156
+
1157
+ Notes
1158
+ -----
1159
+ **The published frame, not the private one.** This read
1160
+ ``obj._bs_window_df`` first through a70 and fell back to the public
1161
+ attribute, because the private probe frame is two columns wider (``W``, the
1162
+ window width, and ``coverage``) and those two are the diagnostic the pane
1163
+ exists for. Preferring it meant this service had decided that the library's
1164
+ published view of its own grid search was the wrong one, which is not a
1165
+ decision it gets to make: the app draws the ``bs_window`` exhibit now, and
1166
+ if two columns are missing from it they are missing upstream. Asked for in
1167
+ ``aggregate_REFACTOR/dev/note-from-aggregate-api-round-6.md``.
1168
+ """
1169
+ return _resolve_frame(obj, "bs_window_df")
1170
+
1171
+
1172
+ def _sharpen_score_frame(obj: Any):
1173
+ """The sharpen probe's score grid: ``d_bs`` down, ``d_log2`` across.
1174
+
1175
+ The library's own documented picture of the audit
1176
+ (``_bucket_window.py:1835``), and the one worth leading with. ``sharpen_df``
1177
+ has one row per probed cell and twenty columns; this is the single number
1178
+ that decides between them, laid out as the grid the probe actually walked,
1179
+ so the shape of the search and where the winner sits are both visible at a
1180
+ glance. Lower is better. A ragged walk leaves NaN in the corners it never
1181
+ reached, which is information rather than a gap: it says the probe ran out
1182
+ of budget in that direction.
1183
+
1184
+ Parameters
1185
+ ----------
1186
+ obj : Any
1187
+
1188
+ Returns
1189
+ -------
1190
+ pandas.DataFrame or None
1191
+ ``None`` before ``sharpen()`` has run, and on any object whose frame
1192
+ does not carry the two index levels (nothing does today, but this route
1193
+ must not 500 if that changes).
1194
+ """
1195
+ df = _resolve_frame(obj, "sharpen_df")
1196
+ if df is None or df.empty or "score" not in df.columns:
1197
+ return None
1198
+ if "d_log2" not in (df.index.names or []):
1199
+ return None
1200
+ try:
1201
+ return df["score"].unstack("d_log2")
1202
+ except Exception: # noqa: BLE001 -- a frame that will not pivot has no grid
1203
+ return None
1204
+
1205
+
1206
+ #: The three moments the reins stats tables report, in reading order.
1207
+ _REINS_MOMENTS = ["mean", "cv", "skew"]
1208
+
1209
+ # `_reins_stats_transposed` and `_REINS_COMPONENTS` came out at a71, with the
1210
+ # `reins_stats_terms` and `reins_stats_moments` routes they fed. They turned
1211
+ # the library's layering analysis on its side and split it in two, which was
1212
+ # this service deciding how a table it does not own should be read. The
1213
+ # library serves that analysis as the `reins` exhibit's first block and the
1214
+ # app draws what it is given; if the orientation is wrong it is wrong there.
1215
+
1216
+
1217
+ def collapse_program(decl: str) -> str:
1218
+ """One line of DecL from however the text arrived.
1219
+
1220
+ Collapse newlines, tabs and ``\\`` line-continuations to single spaces so a
1221
+ program formatted across several indented lines builds without the ugly
1222
+ continuation character. DecL treats a bare newline as a *program separator*,
1223
+ but the same program on one line parses fine. Runs of whitespace are
1224
+ replaced rather than deleted so tokens do not merge (``100\\nclaims`` to
1225
+ ``100 claims``), and any ``\\`` goes first so existing continuation programs
1226
+ fold in too.
1227
+
1228
+ Done up front on the build path so the hints scan, the cache key, the build
1229
+ and any parse-error caret all see the same source. This is a single-object
1230
+ playground (one program per build), so merging newline-separated programs is
1231
+ not a regression.
1232
+
1233
+ Shared rather than inlined because the derivation routes have to reach the
1234
+ **same cache key** an ordinary build of the same text would. The library
1235
+ renders derived programs in its multi-line spread layout, so without this
1236
+ the id would be computed over different bytes and rebuilding a derived
1237
+ program from the editor would miss its own cache slot.
1238
+
1239
+ Parameters
1240
+ ----------
1241
+ decl : str
1242
+
1243
+ Returns
1244
+ -------
1245
+ str
1246
+ One line, or ``''`` for a program that holds no statement.
1247
+
1248
+ Notes
1249
+ -----
1250
+ **Comments go through the library, not through a regex here.** Until
1251
+ 1.0.0a138 this was one ``re.sub`` over the raw text, on a documented
1252
+ assumption that had stopped being true: "``#`` comments are not accepted in
1253
+ the input box, so nothing gets swallowed". They are, and it did. Flattening
1254
+ first puts a leading ``# a note`` in front of the program, so the whole
1255
+ statement became one comment and the library preprocessed it to nothing,
1256
+ which the reader saw as an unexplained parse failure. ``//`` failed the same
1257
+ way. A trailing comment survived, but only because ``build()`` preprocesses
1258
+ downstream; the rule was never working here.
1259
+
1260
+ :meth:`aggregate.parser.UnderwritingLexer.preprocess` is where the comment
1261
+ rules live: full-line and inline, ``#`` and ``//``, with ``note{}`` /
1262
+ ``tags{}`` / ``hints{}`` bodies lifted out first so a ``#`` in prose stays
1263
+ prose. Reimplementing that here would be a second copy of a rule the library
1264
+ owns, and it would drift. It is public and already imported by
1265
+ ``routes.decl`` for the same reason, so the sanctioned import surface does
1266
+ not move.
1267
+
1268
+ The statements come back as a list, and they are joined with a space rather
1269
+ than answered as a list, because merging is what this function has always
1270
+ done. The trailing ``re.sub`` stays for the same reason: it is what makes
1271
+ the output **byte identical** to the old one on every program without a
1272
+ comment, which is not tidiness but the cache key. Checked against nine, from
1273
+ a multi-line ``port`` through the bivariate's nested ``dbvsev`` matrices to
1274
+ a ``note{}`` body holding a ``#``.
1275
+ """
1276
+ text = decl.replace("\\", " ")
1277
+ statements = UnderwritingLexer.preprocess(text)
1278
+ return re.sub(r"\s+", " ", " ".join(statements)).strip()
1279
+
1280
+
1281
+ # ----------------------------------------------------------------------
1282
+ # POST /v1/objects
1283
+ # ----------------------------------------------------------------------
1284
+
1285
+ # Discriminated on ``kind``, which both members already declare as a ``Literal``:
1286
+ # the six object kinds on one side and ``'value'`` on the other. A union rather
1287
+ # than six null fields on ``BuildResponse``, because almost nothing a build
1288
+ # manifest carries applies to a number. See :class:`models.ValueResponse`.
1289
+ _BuildOrValue = Annotated[
1290
+ models.BuildResponse | models.ValueResponse,
1291
+ Field(discriminator="kind"),
1292
+ ]
1293
+
1294
+
1295
+ @router.post("/objects", response_model=_BuildOrValue)
1296
+ def post_object(
1297
+ req: models.BuildRequest,
1298
+ request: Request,
1299
+ settings: Settings = Depends(get_settings),
1300
+ cache: ObjectCache = Depends(_get_cache),
1301
+ audit: AuditLog = Depends(_get_audit),
1302
+ uw: Any = Depends(_get_session_uw),
1303
+ sessions: SessionRegistry = Depends(_get_sessions),
1304
+ ) -> dict:
1305
+ """Build (or retrieve from cache) an aggregate object.
1306
+
1307
+ Returns the slim manifest; the SPA fetches heavier panes on
1308
+ demand via the per-button GETs. Same (decl, log2, bs) is
1309
+ idempotent -- the second call returns ``cached=True`` with
1310
+ the same ``id``.
1311
+
1312
+ Notes
1313
+ -----
1314
+ **The program is previewed before it is keyed.** What a program means
1315
+ depends on what its references resolve to, and that is a fact about the
1316
+ caller's own recipe base rather than about the text, so the text alone
1317
+ cannot decide which cache slot the answer belongs in. The order is
1318
+ therefore preview, key, look up, build; :func:`_cache_key` holds the rule
1319
+ and the reasoning.
1320
+
1321
+ **What it costs.** A cache hit now pays a parse it did not pay before, tens
1322
+ of milliseconds against builds measured in hundreds, and a miss parses
1323
+ twice, once here and once inside the build. Both are stated rather than
1324
+ discovered: the alternative is keying on text that no longer determines the
1325
+ object, which is not a slower answer but a wrong one.
1326
+ """
1327
+ # Resolve effective knobs: a missing log2 / bs from the request
1328
+ # means "use library defaults" -- which the underlying build()
1329
+ # signals via 0. We hash the *requested* values (0 included)
1330
+ # so two callers asking for "defaults" share the same cache slot.
1331
+ eff_log2 = req.log2 if req.log2 is not None else 0
1332
+ eff_bs = req.bs if req.bs is not None else 0.0
1333
+ ip = _client_ip(request)
1334
+ sid = session_id_of(request)
1335
+ t0 = time.monotonic()
1336
+
1337
+ req.decl = collapse_program(req.decl)
1338
+
1339
+ # A program that holds no statement, which since a138 is a real arrival
1340
+ # rather than an impossible one: the collapse strips comments now, so a box
1341
+ # holding nothing but ``# a note`` reaches here empty. Answered in its own
1342
+ # words. The library's answer is "build() expects a single output, got 0;
1343
+ # use build_many() for batched programs", which is about ``build_many`` and
1344
+ # is addressed to a reader who wrote a comment.
1345
+ #
1346
+ # Screened here rather than in the SPA because the comment rules are the
1347
+ # library's, and a client that could tell a comments-only program from an
1348
+ # empty one would be holding a copy of them.
1349
+ if not req.decl:
1350
+ elapsed = int((time.monotonic() - t0) * 1000)
1351
+ message = "this program holds no statement: it is empty, or all comments"
1352
+ audit.record_build(
1353
+ ip=ip, decl=req.decl, log2=eff_log2, bs=eff_bs,
1354
+ status="build_error", error_msg=message,
1355
+ elapsed_ms=elapsed, session_id=sid,
1356
+ )
1357
+ raise HTTPException(status_code=422, detail=message)
1358
+
1359
+ # Cap check is cheap; do it before the cache lookup so an
1360
+ # over-cap request never reaches the build path. Enforce against the
1361
+ # *effective* log2: the larger of the request log2 and any log2 set
1362
+ # via a DecL hints{} clause (which build() would otherwise honor,
1363
+ # bypassing a request-only cap). Other hints (bs, etc.) pass through
1364
+ # untouched -- this guard only vetoes an over-cap log2.
1365
+ hint_log2 = max((int(m) for m in _HINTS_LOG2.findall(req.decl)), default=0)
1366
+ effective_log2 = max(eff_log2, hint_log2)
1367
+ if effective_log2 > settings.log2_cap:
1368
+ elapsed = int((time.monotonic() - t0) * 1000)
1369
+ audit.record_build(
1370
+ ip=ip, decl=req.decl, log2=eff_log2, bs=eff_bs,
1371
+ status="limit_exceeded",
1372
+ error_msg=f"log2 {effective_log2} exceeds cap {settings.log2_cap}",
1373
+ elapsed_ms=elapsed,
1374
+ session_id=sid,
1375
+ )
1376
+ raise HTTPException(
1377
+ status_code=422,
1378
+ detail=f"log2 {effective_log2} exceeds AGGAPI_LOG2_CAP={settings.log2_cap}",
1379
+ )
1380
+
1381
+ canonical = canonicalize_decl(req.decl)
1382
+ preview_t0 = time.monotonic()
1383
+ try:
1384
+ preview = _preview(uw, req.decl)
1385
+ except RecipeNotFound as exc:
1386
+ detail = _missing_entry_detail(exc)
1387
+ elapsed = int((time.monotonic() - t0) * 1000)
1388
+ audit.record_build(
1389
+ ip=ip, decl=req.decl, log2=eff_log2, bs=eff_bs,
1390
+ status="build_error", error_msg=detail["message"],
1391
+ elapsed_ms=elapsed, session_id=sid,
1392
+ )
1393
+ raise HTTPException(status_code=422, detail=detail) from None
1394
+ status_state.record_preview_ms((time.monotonic() - preview_t0) * 1000)
1395
+ oid, key_scope, key_reason = _cache_key(
1396
+ preview, sid, canonical, eff_log2, eff_bs)
1397
+ status_state.record_key_scope(key_scope, key_reason)
1398
+
1399
+ # Cache hit -- return slim manifest immediately.
1400
+ cached_entry = cache.get(oid)
1401
+ if cached_entry is not None:
1402
+ # File the program in the caller's own base even though nothing was
1403
+ # built: see :func:`_register` for why a hit has to register too.
1404
+ _register(uw, preview)
1405
+ elapsed = int((time.monotonic() - t0) * 1000)
1406
+ audit.record_build(
1407
+ ip=ip, decl=req.decl, log2=eff_log2, bs=eff_bs,
1408
+ status="ok", object_id=oid, kind=cached_entry.kind,
1409
+ elapsed_ms=elapsed, session_id=sid, key_scope=key_scope,
1410
+ )
1411
+ return {
1412
+ "id": oid,
1413
+ "kind": cached_entry.kind,
1414
+ "name": cached_entry.name,
1415
+ # The build's warnings, not this request's: they belong to the
1416
+ # object and were stored with it, so a hit says what the miss said.
1417
+ "warnings": list(cached_entry.notes),
1418
+ "cached": True,
1419
+ "elapsed_ms": elapsed,
1420
+ **_summary_fields(cached_entry.obj),
1421
+ # Computed on the hit path too, never cached alongside the entry:
1422
+ # ``can_sharpen`` reads the object's own note, which a Sharpen can
1423
+ # move under a live id, so a stored copy could go stale.
1424
+ "capability": capability_for(cached_entry.obj),
1425
+ }
1426
+
1427
+ # Cache miss -- fire the build, gated by the semaphore +
1428
+ # wall-clock timeout. Note: the semaphore only serializes the
1429
+ # *future submission*, not the wait. With one worker the
1430
+ # semaphore is technically redundant (the worker serializes
1431
+ # naturally), but it makes intent explicit.
1432
+ with status_state.build_slot(_build_semaphore):
1433
+ future = _build_executor.submit(_run_build, uw, req.decl, eff_log2, eff_bs)
1434
+ try:
1435
+ obj, build_notes = future.result(timeout=settings.build_timeout_s)
1436
+ except FuturesTimeout:
1437
+ status_state.record_build_timeout()
1438
+ elapsed = int((time.monotonic() - t0) * 1000)
1439
+ audit.record_build(
1440
+ ip=ip, decl=req.decl, log2=eff_log2, bs=eff_bs,
1441
+ status="timeout",
1442
+ error_msg=f"build exceeded {settings.build_timeout_s}s",
1443
+ elapsed_ms=elapsed,
1444
+ session_id=sid, key_scope=key_scope,
1445
+ )
1446
+ raise HTTPException(status_code=504, detail="build timeout")
1447
+ except ValueError as exc:
1448
+ # A DecL parse failure surfaces as a ValueError. Newer
1449
+ # ``aggregate`` attaches the structured ErrorReport as
1450
+ # ``exc.report`` and raises with ``from None`` (so
1451
+ # ``__cause__`` is empty); older builds left the Lark
1452
+ # UnexpectedInput on ``__cause__``. Treat either as a parse
1453
+ # error and recover the rich report via format_error (which
1454
+ # honors both conventions). Anything else is a build-time
1455
+ # validation error and we fall through.
1456
+ is_parse_error = isinstance(getattr(exc, "report", None), ErrorReport) or \
1457
+ isinstance(exc.__cause__, UnexpectedInput)
1458
+ if is_parse_error:
1459
+ report = format_error(req.decl, exc)
1460
+ elapsed = int((time.monotonic() - t0) * 1000)
1461
+ audit.record_build(
1462
+ ip=ip, decl=req.decl, log2=eff_log2, bs=eff_bs,
1463
+ status="parse_error", error_msg=report.message,
1464
+ elapsed_ms=elapsed,
1465
+ session_id=sid, key_scope=key_scope,
1466
+ )
1467
+ raise HTTPException(status_code=422, detail=report.to_dict())
1468
+ # Library-side validation error (e.g. invalid spec).
1469
+ elapsed = int((time.monotonic() - t0) * 1000)
1470
+ audit.record_build(
1471
+ ip=ip, decl=req.decl, log2=eff_log2, bs=eff_bs,
1472
+ status="build_error", error_msg=str(exc),
1473
+ elapsed_ms=elapsed,
1474
+ session_id=sid, key_scope=key_scope,
1475
+ )
1476
+ raise HTTPException(status_code=422, detail=str(exc))
1477
+ except UnexpectedInput as exc:
1478
+ # Defensive: if the parser ever surfaces a raw Lark
1479
+ # exception without the ValueError wrap, handle it the
1480
+ # same way.
1481
+ report = format_error(req.decl, exc)
1482
+ elapsed = int((time.monotonic() - t0) * 1000)
1483
+ audit.record_build(
1484
+ ip=ip, decl=req.decl, log2=eff_log2, bs=eff_bs,
1485
+ status="parse_error", error_msg=report.message,
1486
+ elapsed_ms=elapsed,
1487
+ session_id=sid, key_scope=key_scope,
1488
+ )
1489
+ raise HTTPException(status_code=422, detail=report.to_dict())
1490
+ except VisitError as exc:
1491
+ # Lark wraps any exception raised *inside* the transformer in a
1492
+ # ``VisitError``; the real cause (e.g. a ``ValueError("Unknown
1493
+ # distortion kind 'pd'; available: …")`` from an unknown distortion
1494
+ # kind) hangs off ``.orig_exc``. These are user-input errors with an
1495
+ # informative message, so surface them in the 422 family rather than
1496
+ # letting them fall through to the catch-all 500.
1497
+ orig = getattr(exc, "orig_exc", None) or exc
1498
+ elapsed = int((time.monotonic() - t0) * 1000)
1499
+ audit.record_build(
1500
+ ip=ip, decl=req.decl, log2=eff_log2, bs=eff_bs,
1501
+ status="build_error", error_msg=str(orig),
1502
+ elapsed_ms=elapsed,
1503
+ session_id=sid, key_scope=key_scope,
1504
+ )
1505
+ raise HTTPException(status_code=422, detail=str(orig))
1506
+ except RecipeNotFound as exc:
1507
+ # The same case the preview reports, reached the other way: a
1508
+ # deferred ``sev agg.NAME`` resolves at build time, not parse time,
1509
+ # so a referent that went away between the two lands here.
1510
+ detail = _missing_entry_detail(exc)
1511
+ elapsed = int((time.monotonic() - t0) * 1000)
1512
+ audit.record_build(
1513
+ ip=ip, decl=req.decl, log2=eff_log2, bs=eff_bs,
1514
+ status="build_error", error_msg=detail["message"],
1515
+ elapsed_ms=elapsed, session_id=sid, key_scope=key_scope,
1516
+ )
1517
+ raise HTTPException(status_code=422, detail=detail) from None
1518
+ except (NotImplementedError, KeyError) as exc:
1519
+ # Two more shapes of "your program cannot be built", both of which
1520
+ # the library already reports well and neither of which is a server
1521
+ # fault, so neither belongs in the 500 family:
1522
+ #
1523
+ # * ``NotImplementedError`` for an unsupported combination, e.g.
1524
+ # ``xpnl`` over a portfolio ("the portfolio total hides its
1525
+ # units, so there is nothing to explode. Use 'pnl' ...").
1526
+ # * ``KeyError`` for a ``sev.X`` / ``agg.X`` / ``port.X`` reference
1527
+ # that resolves to nothing ("no recipe named 'X' of kind 'port'").
1528
+ #
1529
+ # ``str()`` on a KeyError re-quotes its argument, which would show
1530
+ # the user a message wrapped in stray quotes, so read args[0].
1531
+ detail = (exc.args[0] if isinstance(exc, KeyError) and exc.args
1532
+ else str(exc))
1533
+ elapsed = int((time.monotonic() - t0) * 1000)
1534
+ audit.record_build(
1535
+ ip=ip, decl=req.decl, log2=eff_log2, bs=eff_bs,
1536
+ status="build_error", error_msg=str(detail),
1537
+ elapsed_ms=elapsed,
1538
+ session_id=sid, key_scope=key_scope,
1539
+ )
1540
+ raise HTTPException(status_code=422, detail=str(detail))
1541
+ except Exception as exc:
1542
+ elapsed = int((time.monotonic() - t0) * 1000)
1543
+ audit.record_build(
1544
+ ip=ip, decl=req.decl, log2=eff_log2, bs=eff_bs,
1545
+ status="build_error", error_msg=str(exc),
1546
+ elapsed_ms=elapsed,
1547
+ session_id=sid, key_scope=key_scope,
1548
+ )
1549
+ raise HTTPException(status_code=500, detail=str(exc))
1550
+
1551
+ # Classify the result. The api serves exactly the six DecL-creatable kinds
1552
+ # the library declares as first-class (plus near-first-class ``sev``), and
1553
+ # nothing else. They do not all carry the same surface: an Aggregate and a
1554
+ # Portfolio have the lot, while a Distortion, a BivariateAggregate and a PnL
1555
+ # have the reporting frames but no pricing / reinsurance / bs window, and a
1556
+ # Severity is a look-through onto a frozen scipy variable with ``info`` and
1557
+ # ``plot`` but no frames at all. Every frame route answers a clean 400 for a
1558
+ # kind that does not carry it, so the SPA degrades rather than erroring.
1559
+ kind = _classify_object(obj)
1560
+
1561
+ # A program that means a number, which DecL has always allowed: the
1562
+ # top-level ``answer`` rule carries ``expr``, so ``(2+2)`` and ``2/3`` are
1563
+ # programs and ``build()`` answers each with a float. Through a137 the api
1564
+ # built them and then refused the result two lines below, reporting the
1565
+ # library's own answer as an unsupported kind.
1566
+ #
1567
+ # Nothing is cached and no recipe is registered: there is no object, so
1568
+ # there is no slot to fill and nothing a later route could fetch against an
1569
+ # id. The audit records it under its own status, so the operator's page does
1570
+ # not read arithmetic as object builds.
1571
+ #
1572
+ # ``isinstance`` against ``numbers.Real`` rather than a kind-name test:
1573
+ # ``(2+2)`` comes back a Python float and ``(exp(1))`` a ``numpy.float64``,
1574
+ # and asking what a thing *is* beats keeping a list of the names it answers
1575
+ # to. ``bool`` is excluded because it is a Real in Python and is not what
1576
+ # any DecL expression means.
1577
+ if isinstance(obj, numbers.Real) and not isinstance(obj, bool):
1578
+ elapsed = int((time.monotonic() - t0) * 1000)
1579
+ audit.record_build(
1580
+ ip=ip, decl=req.decl, log2=eff_log2, bs=eff_bs,
1581
+ status="value", elapsed_ms=elapsed,
1582
+ session_id=sid, key_scope=key_scope,
1583
+ )
1584
+ return {
1585
+ "kind": "value",
1586
+ "value": float(obj),
1587
+ "decl": req.decl,
1588
+ "elapsed_ms": elapsed,
1589
+ }
1590
+
1591
+ if kind not in SUPPORTED_KINDS:
1592
+ elapsed = int((time.monotonic() - t0) * 1000)
1593
+ audit.record_build(
1594
+ ip=ip, decl=req.decl, log2=eff_log2, bs=eff_bs,
1595
+ status="build_error",
1596
+ error_msg=f"unsupported kind {kind!r}",
1597
+ elapsed_ms=elapsed,
1598
+ session_id=sid, key_scope=key_scope,
1599
+ )
1600
+ raise HTTPException(
1601
+ status_code=422,
1602
+ detail=(
1603
+ f"api supports {', '.join(repr(k) for k in SUPPORTED_KINDS)} "
1604
+ f"only; got {kind!r}"
1605
+ ),
1606
+ )
1607
+
1608
+ entry = CacheEntry(
1609
+ obj=obj,
1610
+ decl=req.decl,
1611
+ log2=eff_log2,
1612
+ bs=eff_bs,
1613
+ kind=kind,
1614
+ name=getattr(obj, "name", "<anonymous>"),
1615
+ created_at=datetime.now(timezone.utc),
1616
+ notes=build_notes,
1617
+ )
1618
+ cache.put(oid, entry)
1619
+ elapsed = int((time.monotonic() - t0) * 1000)
1620
+ sessions.record_build(sid)
1621
+ if preview is None:
1622
+ # The previewer refused a program the builder then accepted, so a
1623
+ # shareable object took a private slot and a room pays a build each
1624
+ # instead of one between them. Kept verbatim because that is an upstream
1625
+ # ask against the library's ``preview`` and an ask needs the program.
1626
+ # The previewed-None-and-then-failed case is an ordinary parse error and
1627
+ # is deliberately not kept: the audit log already has it, with a better
1628
+ # message.
1629
+ status_state.record_unpreviewable_build(req.decl, sid, kind, elapsed)
1630
+ audit.record_build(
1631
+ ip=ip, decl=req.decl, log2=eff_log2, bs=eff_bs,
1632
+ status="ok", object_id=oid, kind=kind, elapsed_ms=elapsed,
1633
+ session_id=sid, key_scope=key_scope,
1634
+ )
1635
+ return {
1636
+ "id": oid,
1637
+ "kind": kind,
1638
+ "name": entry.name,
1639
+ "warnings": build_notes,
1640
+ "cached": False,
1641
+ "elapsed_ms": elapsed,
1642
+ **_summary_fields(obj),
1643
+ "capability": capability_for(obj),
1644
+ }
1645
+
1646
+
1647
+ # Class name -> the parser's own kind token. The keys are exactly
1648
+ # ``aggregate.constants.FIRST_CLASS_CLASSES`` plus ``NEAR_FIRST_CLASS``, and the
1649
+ # values are the kinds ``Underwriter._factory`` dispatches on, so the api speaks
1650
+ # the library's vocabulary rather than a parallel one of its own. Note
1651
+ # ``bvagg``, not ``bivariate``: where the two disagree the library wins.
1652
+ _KIND_OF_CLASS = {
1653
+ "Aggregate": "agg",
1654
+ "Portfolio": "port",
1655
+ "BivariateAggregate": "bvagg",
1656
+ "PnL": "pnl",
1657
+ "Distortion": "distortion",
1658
+ "Severity": "sev",
1659
+ }
1660
+
1661
+ # The two taxonomies whose subclasses reach the api under the base name.
1662
+ # ``build('dist X ph .7')`` returns a ``DistortionPH`` and
1663
+ # ``build('sev X lognorm 50 cv 1.5')`` a ``SeverityScipy``; both flatten to the
1664
+ # base kind so the endpoints treat every member uniformly. The specific subclass
1665
+ # still shows up in ``info``.
1666
+ _KIND_OF_BASE = ((Distortion, "distortion"), (Severity, "sev"))
1667
+
1668
+ # What POST /v1/objects will build, in the library's own vocabulary.
1669
+ SUPPORTED_KINDS = ("agg", "port", "sev", "distortion", "bvagg", "pnl")
1670
+
1671
+ # Guard: the contract declares which classes flow through to this service, so a
1672
+ # class added upstream without a kind here should be noticed, not silently
1673
+ # lower-cased into a stray kind string.
1674
+ _UNMAPPED_FCC = tuple(
1675
+ name for name in (*FIRST_CLASS_CLASSES, *NEAR_FIRST_CLASS)
1676
+ if name not in _KIND_OF_CLASS
1677
+ )
1678
+ if _UNMAPPED_FCC: # pragma: no cover -- fires only on an upstream addition
1679
+ logger.warning(
1680
+ "first-class classes with no api kind mapping: %s", ", ".join(_UNMAPPED_FCC)
1681
+ )
1682
+
1683
+
1684
+ def _classify_object(obj: Any) -> str:
1685
+ """Return the parser kind for a built object, or the lower-cased class name.
1686
+
1687
+ Uses class discrimination because a built object carries no ``.kind`` of its
1688
+ own: the kind lives on the :class:`Recipe`, and ``build()`` unwraps to the
1689
+ object. A ``BivariateAggregate`` (``bivariate`` / ``bv`` / ``clash`` and the
1690
+ ``netceded`` / ``grossceded`` / ``grossnet`` view pairs) maps to ``'bvagg'``,
1691
+ and a ``PnL`` (built by both ``pnl`` and ``xpnl``) to ``'pnl'``.
1692
+
1693
+ Parameters
1694
+ ----------
1695
+ obj : Any
1696
+ A built object.
1697
+
1698
+ Returns
1699
+ -------
1700
+ str
1701
+ A parser kind token, or the lower-cased class name for anything the
1702
+ contract does not cover.
1703
+ """
1704
+ kind = _KIND_OF_CLASS.get(type(obj).__name__)
1705
+ if kind is not None:
1706
+ return kind
1707
+ for base, base_kind in _KIND_OF_BASE:
1708
+ if isinstance(obj, base):
1709
+ return base_kind
1710
+ return type(obj).__name__.lower()
1711
+
1712
+
1713
+ # ----------------------------------------------------------------------
1714
+ # GET /v1/objects -- cache listing
1715
+ # ----------------------------------------------------------------------
1716
+
1717
+ @router.get("/objects", response_model=models.ObjectListResponse)
1718
+ def list_objects(cache: ObjectCache = Depends(_get_cache)) -> dict:
1719
+ """Return a snapshot of cache contents, MRU last."""
1720
+ # Recover the (id, entry) pairing by scanning the cache.
1721
+ # The cache holds the OrderedDict internally; we expose it via
1722
+ # .list() but lose the id. Walk the internal dict directly
1723
+ # *with* the lock through a small helper.
1724
+ items = []
1725
+ # Internal access: read the OrderedDict items under lock.
1726
+ with cache._lock: # noqa: SLF001 -- intentional cross-module use
1727
+ for oid, entry in cache._store.items():
1728
+ items.append({
1729
+ "id": oid,
1730
+ "kind": entry.kind,
1731
+ "name": entry.name,
1732
+ "ts": entry.created_at.isoformat(timespec="milliseconds"),
1733
+ })
1734
+ return {"objects": items}
1735
+
1736
+
1737
+ # ----------------------------------------------------------------------
1738
+ # GET /v1/session/models.agg -- download the session's built programs
1739
+ # ----------------------------------------------------------------------
1740
+
1741
+ # Dependency order for the canonical ('agg') export, mirroring the library's
1742
+ # write order: a sev precedes the agg that uses it, an agg precedes the port,
1743
+ # bvagg and pnl that reference it, so the emitted file re-loads cleanly. A
1744
+ # distortion depends on nothing and sorts last. An unlisted kind falls to 99.
1745
+ _KIND_ORDER = {
1746
+ "sev": 0, "agg": 1, "port": 2, "bvagg": 3, "pnl": 4, "distortion": 5,
1747
+ }
1748
+
1749
+
1750
+ @router.get("/session/models.agg")
1751
+ def get_session_models(
1752
+ form: Literal["raw", "agg"] = Query(
1753
+ "raw",
1754
+ description=(
1755
+ "'raw' = programs exactly as submitted, verbatim (from the object "
1756
+ "cache; compact syntax like ranges preserved); 'agg' = canonical, "
1757
+ "line-wrapped, dependency-ordered DecL from the underwriter's session "
1758
+ "recipes (re-loadable)."
1759
+ ),
1760
+ ),
1761
+ cache: ObjectCache = Depends(_get_cache),
1762
+ uw: Any = Depends(_get_session_uw),
1763
+ ) -> Response:
1764
+ """Download every DecL program built this session as one ``.agg`` file.
1765
+
1766
+ Two forms, kept deliberately distinct. ``raw`` walks the api object cache and
1767
+ emits each built object's program **verbatim** -- your exact source, compact
1768
+ syntax and all (a range ``[10:100:10]`` stays ``[10:100:10]``). ``agg`` reads
1769
+ the shared underwriter's recipe base, keeps the entries it
1770
+ flagged ``source='session'`` (every in-session ``build(...)``), renders each
1771
+ through ``decl_writer.spec_to_decl`` (verbatim fallback) and then
1772
+ ``format_program`` for the spread / line-wrapped layout, in dependency order
1773
+ -- a **canonical, re-flowed, re-loadable** set (ranges expanded to
1774
+ ``[10 20 ... 100]``). Formatting ``raw`` too would collapse it into ``agg``,
1775
+ so it is intentionally left un-reflowed.
1776
+
1777
+ Notes
1778
+ -----
1779
+ **Scope differs between the two forms, and that is not a wart.** ``agg``
1780
+ reads the caller's own recipe base, so it is exactly this session's
1781
+ programs. ``raw`` walks the object cache, which is process-wide and shared
1782
+ by design (that sharing is what lets a room on one hero example pay for one
1783
+ build), so on a busy deployment it returns programs other people typed. The
1784
+ canonical form is the one the menu offers, and the one to reach for.
1785
+
1786
+ The session travels in the query string here rather than in a header,
1787
+ because this URL is opened by navigation and a navigation cannot carry one.
1788
+ A request with neither lands in the anonymous session, whose base holds
1789
+ whatever other headerless clients put there.
1790
+ """
1791
+ programs: list[str] = []
1792
+ if form == "raw":
1793
+ # Programs exactly as typed -- unique decls in cache (MRU) order. This is
1794
+ # deliberately NOT run through ``format_program``: that re-parses and so
1795
+ # expands compact syntax (a range ``[10:100:10]`` becomes
1796
+ # ``[10 20 ... 100]``). Preserving the user's exact source -- ranges and
1797
+ # all -- is the whole point of the ``raw`` form; the ``agg`` form is the
1798
+ # canonical, re-flowed one.
1799
+ seen: set[str] = set()
1800
+ with cache._lock: # noqa: SLF001 -- intentional cross-module use
1801
+ for entry in cache._store.values():
1802
+ decl = entry.decl.strip()
1803
+ if decl and decl not in seen:
1804
+ seen.add(decl)
1805
+ programs.append(decl)
1806
+ else: # form == "agg"
1807
+ from aggregate.decl_writer import format_program, spec_to_decl
1808
+
1809
+ # ``recipes`` replaced ``knowledge`` at aggregate 1.0.0a164: one frame,
1810
+ # one class, indexed (kind, name), with ``source`` marking where an
1811
+ # entry came from. A program built through this api is a session entry.
1812
+ # Read off the caller's own fork, which is the base their builds
1813
+ # registered into: the same base under ``--library`` since a109, and
1814
+ # theirs alone rather than the process's since a110.
1815
+ recipes = uw.recipes
1816
+ session = recipes[recipes["source"] == "session"]
1817
+ # (kind, name) MultiIndex; order by kind dependency then name.
1818
+ rows = sorted(
1819
+ session.itertuples(),
1820
+ key=lambda r: (_KIND_ORDER.get(r.Index[0], 99), r.Index[1]),
1821
+ )
1822
+ for r in rows:
1823
+ kind, name = r.Index
1824
+ # Canonical text from the parsed spec (ranges expanded, deduped) ...
1825
+ try:
1826
+ text = spec_to_decl(r.spec, kind, name)
1827
+ except Exception: # noqa: BLE001
1828
+ # Best-effort: any spec the unparser can't render (minimum /
1829
+ # mixture distortions, or a kind it doesn't cover) falls back to
1830
+ # the verbatim program. Never 500 over one un-round-trippable entry.
1831
+ text = r.program if isinstance(r.program, str) else ""
1832
+ if not text.strip():
1833
+ continue
1834
+ # ... then the spread text layout (line wraps) for readability.
1835
+ #
1836
+ # `trailer=True`, because `format_program` defaults it to False and
1837
+ # would drop the `note{}`, `tags{}` and `hints{}` that `spec_to_decl`
1838
+ # emitted ten lines up. This file is the re-loadable export: a
1839
+ # program whose `hints{}` was stripped on the way out rebuilds on a
1840
+ # different grid from the one it was written for, silently. Fixed at
1841
+ # a51; every `.agg` downloaded before that is missing its trailers.
1842
+ try:
1843
+ text = format_program(text, fmt="text", trailer=True)
1844
+ except Exception: # noqa: BLE001 -- keep the unwrapped canonical text
1845
+ pass
1846
+ programs.append(text.strip())
1847
+
1848
+ header = f"# aggregate_api session models ({form}), {len(programs)} program(s)"
1849
+ body = header + "\n\n" + "\n\n".join(programs) + "\n"
1850
+ return Response(
1851
+ content=body,
1852
+ media_type="text/plain",
1853
+ headers={
1854
+ "Content-Disposition": 'attachment; filename="session-models.agg"',
1855
+ },
1856
+ )
1857
+
1858
+
1859
+ # ----------------------------------------------------------------------
1860
+ # GET /v1/objects/{id} -- manifest
1861
+ # ----------------------------------------------------------------------
1862
+
1863
+ @router.get("/objects/{oid}", response_model=models.ObjectManifest)
1864
+ def get_manifest(oid: str, cache: ObjectCache = Depends(_get_cache)) -> dict:
1865
+ entry = _resolve_object(oid, cache)
1866
+ return {
1867
+ "id": oid,
1868
+ "kind": entry.kind,
1869
+ "name": entry.name,
1870
+ "decl": entry.decl,
1871
+ "log2": entry.log2,
1872
+ "bs": entry.bs,
1873
+ "created_at": entry.created_at.isoformat(timespec="milliseconds"),
1874
+ }
1875
+
1876
+
1877
+ # ----------------------------------------------------------------------
1878
+ # DELETE /v1/objects/{id}
1879
+ # ----------------------------------------------------------------------
1880
+
1881
+ @router.delete("/objects/{oid}", response_model=models.DeleteResponse)
1882
+ def delete_object(oid: str, cache: ObjectCache = Depends(_get_cache)) -> dict:
1883
+ if not cache.delete(oid):
1884
+ raise HTTPException(status_code=404, detail=f"object {oid} not in cache")
1885
+ return {"ok": True}
1886
+
1887
+
1888
+ # ----------------------------------------------------------------------
1889
+ # GET /v1/objects/{id}/info
1890
+ # ----------------------------------------------------------------------
1891
+
1892
+ @router.get("/objects/{oid}/info", response_model=models.InfoResponse)
1893
+ def get_info(oid: str, entry: CacheEntry = Depends(_locked_entry)) -> dict:
1894
+ return info_to_payload(entry.obj)
1895
+
1896
+
1897
+ # ----------------------------------------------------------------------
1898
+ # GET /v1/objects/{id}/meta
1899
+ # ----------------------------------------------------------------------
1900
+
1901
+ @router.get("/objects/{oid}/meta", response_model=models.ObjectMetaResponse)
1902
+ def get_meta(oid: str, entry: CacheEntry = Depends(_locked_entry)) -> dict:
1903
+ """The object's own DecL metadata: trailer clauses plus both programs.
1904
+
1905
+ Every first-class citizen carries ``note`` / ``tags`` / ``hints`` and the
1906
+ ``program`` / ``pprogram`` pair, so this is one route for all six kinds.
1907
+ Those three clauses are the whole trailer since ``aggregate`` 1.0.0a301
1908
+ retired ``doc{{{...}}}``, which this route had always declined to serve.
1909
+
1910
+ Notes
1911
+ -----
1912
+ Read through ``getattr`` rather than direct attribute access. The library
1913
+ declares its own contract holes in
1914
+ ``aggregate.constants.FCC_CONTRACT_EXCEPTIONS`` (empty as of 1.0.0a172, but
1915
+ the mechanism exists precisely because they recur), and an empty clause
1916
+ comes back as ``''``, which serializes as ``null`` here so the SPA can test
1917
+ presence without trimming.
1918
+ """
1919
+ obj = entry.obj
1920
+
1921
+ def text(name: str) -> str | None:
1922
+ value = getattr(obj, name, None)
1923
+ if value is None:
1924
+ return None
1925
+ value = str(value).strip()
1926
+ return value or None
1927
+
1928
+ tags = getattr(obj, "tags", ()) or ()
1929
+ return {
1930
+ "kind": entry.kind,
1931
+ "name": entry.name,
1932
+ "note": text("note"),
1933
+ "tags": [str(t) for t in tags],
1934
+ "hints": text("hints"),
1935
+ "program": text("program"),
1936
+ "pprogram": text("pprogram"),
1937
+ }
1938
+
1939
+
1940
+ # ----------------------------------------------------------------------
1941
+ # GET /v1/objects/{id}/summary
1942
+ # ----------------------------------------------------------------------
1943
+
1944
+ @router.get("/objects/{oid}/summary", response_model=models.FrameResponse)
1945
+ def get_summary(oid: str, entry: CacheEntry = Depends(_locked_entry)) -> dict:
1946
+ """At-a-glance risk view -- moments + key percentiles (``summary_df``).
1947
+
1948
+ Since ``aggregate`` 1.0.0a113 ``summary_df`` is the user-facing risk
1949
+ frame (Freq / Sev / Agg rows; ``E[X] | SD | CV | Skew | p0.01 | p0.50 |
1950
+ p0.99``), not the old moment-validation table -- that moved to
1951
+ :func:`get_validation_df` (``validation_df``). ``CV`` and the Freq-row
1952
+ percentiles are blank (NaN -> JSON ``null``) by design.
1953
+ """
1954
+ df = _resolve_frame(entry.obj, "summary_df")
1955
+ if df is None:
1956
+ raise HTTPException(
1957
+ status_code=400,
1958
+ detail=f"summary not available for {entry.kind!r}",
1959
+ )
1960
+ # ``summary_df`` is a property returning a DataFrame; we want its
1961
+ # named index in the payload too, so promote it to a column when
1962
+ # possible (reset_index_safe handles index/column collisions).
1963
+ df = reset_index_safe(df)
1964
+ return frame_to_payload(df)
1965
+
1966
+
1967
+ # ----------------------------------------------------------------------
1968
+ # GET /v1/objects/{id}/tail_df
1969
+ # ----------------------------------------------------------------------
1970
+
1971
+ @router.get("/objects/{oid}/tail_df", response_model=models.FrameResponse)
1972
+ def get_tail_df(oid: str, entry: CacheEntry = Depends(_locked_entry)) -> dict:
1973
+ """Return-period / exceedance table (``tail_df``).
1974
+
1975
+ The centerpiece risk view: columns ``p | VaR | TVaR | xsVaR | VaR/Mean``,
1976
+ indexed by return period ``T`` on an ``Aggregate`` and by ``(unit, T)`` on a
1977
+ ``Portfolio``, whose ladder includes the 1-in-200 / 1-in-250 capital
1978
+ anchors. ``None`` before a grid exists (no realised density) -> 400.
1979
+
1980
+ Notes
1981
+ -----
1982
+ A ``BivariateAggregate`` has no ``tail_df`` and answers 400. It once carried
1983
+ the name for a different report entirely (where the realized mass sits on
1984
+ each axis), which ``aggregate`` 1.0.0a171 renamed ``axis_support_df`` because
1985
+ two reports under one name is how a reader gets the wrong one.
1986
+ """
1987
+ df = _resolve_frame(entry.obj, "tail_df")
1988
+ if df is None:
1989
+ raise HTTPException(
1990
+ status_code=400,
1991
+ detail=f"tail_df not available for {entry.kind!r}",
1992
+ )
1993
+ return frame_to_payload(reset_index_safe(df))
1994
+
1995
+
1996
+ # ----------------------------------------------------------------------
1997
+ # GET /v1/objects/{id}/validation_df
1998
+ # ----------------------------------------------------------------------
1999
+
2000
+ @router.get("/objects/{oid}/validation_df", response_model=models.FrameResponse)
2001
+ def get_validation_df(oid: str, entry: CacheEntry = Depends(_locked_entry)) -> dict:
2002
+ """Moment-vs-estimate QA table (``validation_df``).
2003
+
2004
+ The old ``summary_df`` payload, renamed upstream: theoretical vs
2005
+ empirical moments with the per-moment error, reading "not unreasonable"
2006
+ on a clean build. Demoted under the SPA's **More** menu now that
2007
+ ``summary_df`` is the headline risk view.
2008
+ """
2009
+ df = _resolve_frame(entry.obj, "validation_df")
2010
+ if df is None:
2011
+ raise HTTPException(
2012
+ status_code=400,
2013
+ detail=f"validation_df not available for {entry.kind!r}",
2014
+ )
2015
+ return frame_to_payload(reset_index_safe(df))
2016
+
2017
+
2018
+ # ----------------------------------------------------------------------
2019
+ # GET /v1/objects/{id}/stats_df
2020
+ # ----------------------------------------------------------------------
2021
+
2022
+ @router.get("/objects/{oid}/stats_df", response_model=models.FrameResponse)
2023
+ def get_stats_df(oid: str, entry: CacheEntry = Depends(_locked_entry)) -> dict:
2024
+ df = getattr(entry.obj, "stats_df", None)
2025
+ if df is None:
2026
+ raise HTTPException(
2027
+ status_code=400,
2028
+ detail=f"stats_df not available for {entry.kind!r}",
2029
+ )
2030
+ return frame_to_payload(reset_index_safe(_drop_raw_moments(df)))
2031
+
2032
+
2033
+ # ----------------------------------------------------------------------
2034
+ # GET /v1/objects/{id}/density_df
2035
+ # ----------------------------------------------------------------------
2036
+
2037
+ @router.get("/objects/{oid}/density_df", response_model=models.FrameResponse)
2038
+ def get_density_df(
2039
+ oid: str,
2040
+ cols: str | None = Query(
2041
+ None,
2042
+ description="Comma-separated subset of column names.",
2043
+ ),
2044
+ start: int | None = Query(None, ge=0),
2045
+ stop: int | None = Query(None, ge=0),
2046
+ downsample: int | None = Query(None, ge=1, le=10_000),
2047
+ nonzero: bool = Query(
2048
+ False,
2049
+ description="Drop zero-mass rows (keep only p_total > 0) before slicing.",
2050
+ ),
2051
+ resolution: Literal["full", "display"] = Query(
2052
+ "full",
2053
+ description=(
2054
+ "'full' = every grid point, unbinned (what a plot wants); "
2055
+ "'display' = binned to a power-of-two grid (what a table wants)."
2056
+ ),
2057
+ ),
2058
+ view: Literal["marginal", "joint"] = Query(
2059
+ "marginal",
2060
+ description=(
2061
+ "BivariateAggregate only. 'marginal' = the two component marginals; "
2062
+ "'joint' = the full joint-density matrix."
2063
+ ),
2064
+ ),
2065
+ entry: CacheEntry = Depends(_locked_entry),
2066
+ ) -> dict:
2067
+ """The density frame. Full resolution by default.
2068
+
2069
+ ``resolution='full'`` ships every grid point, which is what a plot wants, and
2070
+ is the default. A discretized aggregate is routinely **atomic**: layer limits
2071
+ and occurrence cessions put point masses in the severity and the aggregate
2072
+ inherits them at every multiple, so single ``bs``-wide buckets carry whole
2073
+ percentage points of probability against a continuum three orders of
2074
+ magnitude below. *Any* binning merges an atom with its neighbours and turns a
2075
+ spine into a triangle, and no threshold avoids it, because from the frame
2076
+ alone an atom is not distinguishable from a tall continuum bucket. So the
2077
+ honest answer is to ship the grid and let the client draw it.
2078
+
2079
+ ``resolution='display'`` bins to a power-of-two grid (see
2080
+ ``display_log2_for``): masses (``p_total`` / ``p_sev`` / ``p_*``) are summed
2081
+ and the pointwise columns (``loss`` / ``F`` / ``S`` / ``ex***``) take the
2082
+ super-bucket right edge, so ``p_total`` stays faithful (sums to ~1) rather
2083
+ than being understated by an even-spaced stride. That is the right shape for
2084
+ a **table**, where 2**16 rows is not a reading experience.
2085
+
2086
+ A ``PnL`` has no DataFrame ``density_df`` (it is a dict of per-leg grids);
2087
+ its grand-result density is synthesized (:func:`pnl_density_frame`) into the
2088
+ same ``loss / p_total / F / S`` shape.
2089
+
2090
+ A ``BivariateAggregate`` answers with its two component **marginals** by
2091
+ default (:func:`bivariate_marginal_frame`). Its joint density is a matrix of
2092
+ 2**16 cells or more, which is a picture rather than a table; ask for it with
2093
+ ``view='joint'``, which the Overview heatmap does.
2094
+
2095
+ Objects without a build grid (a distortion's g-curve) honor the legacy
2096
+ ``cols`` / ``start`` / ``stop`` / ``downsample`` / ``nonzero`` params.
2097
+ """
2098
+ col_list = [c.strip() for c in cols.split(",")] if cols else None
2099
+ binned = resolution == "display"
2100
+
2101
+ if entry.kind == "bvagg" and view == "marginal":
2102
+ df = bivariate_marginal_frame(entry.obj)
2103
+ if col_list:
2104
+ df = df[[c for c in col_list if c in df.columns]]
2105
+ return frame_to_payload(df)
2106
+
2107
+ if entry.kind == "sev":
2108
+ # A Severity has no density_df at all; sample the frozen variable onto a
2109
+ # quantile-spaced grid. Not binned: the grid is already the display grid
2110
+ # and its `pdf` is an ordinate, not a mass, so summing it would be wrong.
2111
+ df = severity_density_frame(entry.obj)
2112
+ if col_list:
2113
+ df = df[[c for c in col_list if c in df.columns]]
2114
+ return frame_to_payload(df)
2115
+
2116
+ if entry.kind == "pnl":
2117
+ # A PnL's density_df is a dict of per-leg GridDistributions, not a
2118
+ # DataFrame. Synthesize the grand-result density in the standard
2119
+ # loss / p_total / F / S shape. When binning is asked for, the
2120
+ # positional reduction tolerates the signed P&L outcome axis;
2121
+ # ``(n - 1).bit_length()`` is ceil(log2(n)), so a grid already at or
2122
+ # under the display size skips it.
2123
+ df = pnl_density_frame(entry.obj)
2124
+ if col_list:
2125
+ df = df[[c for c in col_list if c in df.columns]]
2126
+ if not binned:
2127
+ return frame_to_payload(df)
2128
+ sum_cols = {c for c in df.columns if c.startswith("p")}
2129
+ display_log2 = display_log2_for(len(df.columns))
2130
+ source_log2 = max(display_log2, (len(df) - 1).bit_length())
2131
+ return frame_to_payload(
2132
+ bin_density(df, source_log2, sum_cols=sum_cols, display_log2=display_log2)
2133
+ )
2134
+
2135
+ df = getattr(entry.obj, "density_df", None)
2136
+ if df is None:
2137
+ raise HTTPException(
2138
+ status_code=400,
2139
+ detail=f"density_df not available for {entry.kind!r}",
2140
+ )
2141
+ # density_df is indexed by loss; surface that as a column for
2142
+ # the SPA so it can render the x-axis without a separate query.
2143
+ # ``loss`` is already a column on the frame so reset_index_safe
2144
+ # avoids the collision.
2145
+ df = reset_index_safe(df)
2146
+
2147
+ if col_list:
2148
+ df = df[[c for c in col_list if c in df.columns]]
2149
+
2150
+ source_log2 = getattr(entry.obj, "log2", None)
2151
+ if source_log2 is not None and binned:
2152
+ # Bin the full grid down: p_* columns sum, loss/F/S right-edge.
2153
+ sum_cols = {c for c in df.columns if c.startswith("p")}
2154
+ return frame_to_payload(
2155
+ bin_density(
2156
+ df, source_log2, sum_cols=sum_cols,
2157
+ display_log2=display_log2_for(len(df.columns)),
2158
+ )
2159
+ )
2160
+ if source_log2 is not None:
2161
+ return frame_to_payload(df)
2162
+
2163
+ # No build grid: leave the frame as-is and honor the legacy slice params.
2164
+ if nonzero and "p_total" in df.columns:
2165
+ df = df[df["p_total"] > 0]
2166
+ return frame_to_payload(
2167
+ df, cols=col_list, start=start, stop=stop, downsample=downsample,
2168
+ )
2169
+
2170
+
2171
+ # ----------------------------------------------------------------------
2172
+ # GET /v1/objects/{id}/unit_density_df -- Portfolio only
2173
+ # ----------------------------------------------------------------------
2174
+
2175
+ @router.get("/objects/{oid}/unit_density_df", response_model=models.FrameResponse)
2176
+ def get_unit_density_df(
2177
+ oid: str,
2178
+ resolution: Literal["full", "display"] = Query(
2179
+ "full",
2180
+ description="'full' = every grid point; 'display' = binned.",
2181
+ ),
2182
+ entry: CacheEntry = Depends(_locked_entry),
2183
+ ) -> dict:
2184
+ """Per-unit densities and survivals on the portfolio's common grid.
2185
+
2186
+ Columns ``loss``, then ``p_<unit>`` and ``S_<unit>`` for each unit, plus the
2187
+ portfolio's own ``p_total`` and ``S``. This is what the Overview exhibit
2188
+ draws for a portfolio: one density series and one exceedance series per
2189
+ unit, alongside the total, which is the diversification story.
2190
+
2191
+ Notes
2192
+ -----
2193
+ A ``Portfolio.density_df`` carries ``p_total`` and the per-unit *allocation*
2194
+ columns (``exa_*``, ``lev_*``, ...) but no per-unit densities. Since the
2195
+ windowed-grid work those live on ``unit_density_df()``, a long frame indexed
2196
+ ``(unit, loss)``, and unstacking it recovers the wide common-index form.
2197
+ Verified to align with the portfolio grid even when the units are on wildly
2198
+ different scales.
2199
+
2200
+ Two pandas details worth knowing, both load bearing:
2201
+
2202
+ * ``unit_density_df()`` carries ``unit`` as **both** an index level and a
2203
+ column, so a bare ``groupby('unit')`` raises ``ValueError: ambiguous``.
2204
+ Nothing here groups, but the same trap catches the next reader.
2205
+ * Binning treats a ``p``-prefixed column as a mass to **sum** and everything
2206
+ else as a pointwise value read at the super-bucket right edge. That is
2207
+ exactly right for the ``S_*`` survivals, so both families bin correctly in
2208
+ one pass.
2209
+ """
2210
+ if entry.kind != "port":
2211
+ raise HTTPException(
2212
+ status_code=400,
2213
+ detail=f"unit_density_df is Portfolio-only; got {entry.kind!r}",
2214
+ )
2215
+ obj = entry.obj
2216
+ long = obj.unit_density_df()
2217
+ out = {}
2218
+ for stat, prefix in (("p", "p_"), ("S", "S_")):
2219
+ if stat not in long.columns:
2220
+ continue
2221
+ wide = long[stat].unstack("unit")
2222
+ for unit in wide.columns:
2223
+ out[f"{prefix}{unit}"] = wide[unit]
2224
+
2225
+ total = obj.density_df
2226
+ df = pd.DataFrame(out)
2227
+ df.insert(0, "loss", total["loss"].to_numpy() if "loss" in total else df.index)
2228
+ for name in ("p_total", "S"):
2229
+ if name in total.columns:
2230
+ df[name] = total[name].to_numpy()
2231
+
2232
+ source_log2 = getattr(obj, "log2", None)
2233
+ if source_log2 is None or resolution == "full":
2234
+ return frame_to_payload(df.reset_index(drop=True))
2235
+ # The widest density payload the api serves: 2 * units + 3 columns. The cell
2236
+ # budget trades rows for those columns so a 12-unit portfolio ships the same
2237
+ # number of JSON numbers as a 2-unit one.
2238
+ sum_cols = {c for c in df.columns if c.startswith("p")}
2239
+ return frame_to_payload(
2240
+ bin_density(
2241
+ df, source_log2, sum_cols=sum_cols,
2242
+ display_log2=display_log2_for(len(df.columns)),
2243
+ )
2244
+ )
2245
+
2246
+
2247
+ # ----------------------------------------------------------------------
2248
+ # GET /v1/objects/{id}/kappa -- Portfolio only
2249
+ # ----------------------------------------------------------------------
2250
+
2251
+ @router.get("/objects/{oid}/kappa", response_model=models.FrameResponse)
2252
+ def get_kappa(
2253
+ oid: str,
2254
+ downsample: int | None = Query(None, ge=1, le=10_000),
2255
+ entry: CacheEntry = Depends(_locked_entry),
2256
+ ) -> dict:
2257
+ """Per-unit conditional expected losses (the ``exeqa_*`` slice)."""
2258
+ if entry.kind != "port":
2259
+ raise HTTPException(status_code=400, detail="kappa is Portfolio-only")
2260
+ df = entry.obj.density_df
2261
+ # Build the kappa-slice: loss + every exeqa_* column.
2262
+ exeqa = [c for c in df.columns if c.startswith("exeqa_")]
2263
+ if not exeqa:
2264
+ raise HTTPException(status_code=400, detail="no exeqa_* columns on density_df")
2265
+ df = reset_index_safe(df)[["loss", *exeqa]]
2266
+ # Bin to the power-of-two display grid. ``exeqa_*`` are conditional
2267
+ # expectations (pointwise in x), not masses, so every column right-edges
2268
+ # (sum_cols empty). The full-frame CSV stays exact.
2269
+ source_log2 = getattr(entry.obj, "log2", None)
2270
+ if source_log2 is not None:
2271
+ df = bin_density(
2272
+ df, source_log2, sum_cols=set(),
2273
+ display_log2=display_log2_for(len(df.columns)),
2274
+ )
2275
+ return frame_to_payload(df, downsample=downsample)
2276
+
2277
+
2278
+ # ----------------------------------------------------------------------
2279
+ # GET /v1/objects/{id}/bs_window_df -- bucket/window estimator summary
2280
+ # ----------------------------------------------------------------------
2281
+
2282
+ @router.get("/objects/{oid}/bs_window_df", response_model=models.FrameResponse)
2283
+ def get_bs_window_df(oid: str, entry: CacheEntry = Depends(_locked_entry)) -> dict:
2284
+ """Bucket/window estimator summary.
2285
+
2286
+ A small per-method frame the library builds while choosing the grid
2287
+ (``bs`` / ``log2`` / ``x_min``); the ``selected`` row marks the method
2288
+ actually used.
2289
+
2290
+ **The published frame.** Two attributes carry a version of this, and this
2291
+ route read the private ``_bs_window_df`` in preference from a45 to a70, on
2292
+ the grounds that it is two columns wider (``W``, the window width, and
2293
+ ``coverage``). Both readings were wrong. Reading *only* the private one
2294
+ 404'd on a ``BivariateAggregate``, which carries the public frame alone,
2295
+ while the capability list reported the exhibit as available; preferring it
2296
+ was this service ruling that the library's published view of its own grid
2297
+ search is the wrong one. The app draws the ``bs_window`` exhibit now, the
2298
+ two columns are asked for upstream, and this route serves what the library
2299
+ publishes. A kind carrying neither (a P&L, a severity, a distortion) gets a
2300
+ clean 400.
2301
+ """
2302
+ df = _bs_window_frame(entry.obj)
2303
+ if df is None:
2304
+ raise HTTPException(
2305
+ status_code=400, detail="bs window summary not available for this object"
2306
+ )
2307
+ return frame_to_payload(reset_index_safe(df))
2308
+
2309
+
2310
+ # ----------------------------------------------------------------------
2311
+ # Reinsurance -- text description + per-layer frames
2312
+ # ----------------------------------------------------------------------
2313
+ # Fallback row budget for a density preview on an object *without* a build
2314
+ # grid (no ``log2`` to bin against). Grid-backed objects (agg / port) bin to a
2315
+ # faithful power-of-two display grid instead, see ``bin_density``. The csv download
2316
+ # carries the full frame.
2317
+ DENSITY_PREVIEW_ROWS = 20
2318
+
2319
+
2320
+ def _frame_attr(obj: Any, name: str):
2321
+ """Return ``getattr(obj, name)`` as a DataFrame, or ``None``.
2322
+
2323
+ Reinsurance frames are properties that return ``None`` when the
2324
+ object carries no reinsurance; we treat a missing attribute the same
2325
+ way so the route can answer with a uniform 400.
2326
+ """
2327
+ df = getattr(obj, name, None)
2328
+ if df is None:
2329
+ return None
2330
+ return df
2331
+
2332
+
2333
+ @router.get(
2334
+ "/objects/{oid}/reins_description",
2335
+ response_model=models.ReinsDescriptionResponse,
2336
+ )
2337
+ def get_reins_description(oid: str, entry: CacheEntry = Depends(_locked_entry)) -> dict:
2338
+ """Always-visible text block describing the reinsurance program.
2339
+
2340
+ ``Aggregate.reins_description`` is a short string attribute (e.g.
2341
+ ``"Ceded to 100% share of 15 xs 5 per occurrence"``), empty when the
2342
+ object carries no reinsurance. ``Portfolio`` has no such attribute --
2343
+ there we report availability from ``reins_summary_df`` and leave the text
2344
+ empty (the Reins table carries the detail).
2345
+ """
2346
+ obj = entry.obj
2347
+ # ``reins_summary_df`` is None exactly when the object has no reinsurance, so
2348
+ # it's the canonical availability signal. ``reins_description`` is a plain
2349
+ # string property carrying the human-readable blurb (empty otherwise).
2350
+ has_reins = _frame_attr(obj, "reins_summary_df") is not None
2351
+ text = str(getattr(obj, "reins_description", "") or "").strip() if has_reins else ""
2352
+ return {"available": has_reins, "text": text}
2353
+
2354
+
2355
+ @router.get("/objects/{oid}/reins_summary_df", response_model=models.FrameResponse)
2356
+ def get_reins_summary_df(oid: str, entry: CacheEntry = Depends(_locked_entry)) -> dict:
2357
+ """Per-layer gross/ceded/net reference-vs-model frame."""
2358
+ df = _frame_attr(entry.obj, "reins_summary_df")
2359
+ if df is None:
2360
+ raise HTTPException(status_code=400, detail="no reinsurance on this object")
2361
+ return frame_to_payload(reset_index_safe(df))
2362
+
2363
+
2364
+ @router.get("/objects/{oid}/reins_stats_df", response_model=models.FrameResponse)
2365
+ def get_reins_stats_df(oid: str, entry: CacheEntry = Depends(_locked_entry)) -> dict:
2366
+ """Per-layer summary statistics (small frame -> shown in full)."""
2367
+ df = _frame_attr(entry.obj, "reins_stats_df")
2368
+ if df is None:
2369
+ raise HTTPException(status_code=400, detail="no reinsurance on this object")
2370
+ return frame_to_payload(reset_index_safe(_drop_raw_moments(df)))
2371
+
2372
+
2373
+ @router.get("/objects/{oid}/reins_density_df", response_model=models.FrameResponse)
2374
+ def get_reins_density_df(
2375
+ oid: str,
2376
+ resolution: Literal["full", "display"] = Query(
2377
+ "full",
2378
+ description="'full' = every grid point; 'display' = binned.",
2379
+ ),
2380
+ entry: CacheEntry = Depends(_locked_entry),
2381
+ ) -> dict:
2382
+ """Reinsurance densities, full resolution by default.
2383
+
2384
+ Same reasoning as :func:`get_density_df`: the Reins exhibit is a plot, and a
2385
+ ceded distribution is more atomic than a gross one, not less (a layer output
2386
+ piles every loss above its limit onto one point). ``resolution='display'``
2387
+ bins for the table: every ``p_*`` column sums and ``loss`` right-edges, so
2388
+ the previewed masses stay faithful.
2389
+ """
2390
+ df = _frame_attr(entry.obj, "reins_density_df")
2391
+ if df is None:
2392
+ raise HTTPException(status_code=400, detail="no reinsurance on this object")
2393
+ df = reset_index_safe(df)
2394
+ source_log2 = getattr(entry.obj, "log2", None)
2395
+ if source_log2 is not None and resolution == "display":
2396
+ sum_cols = {c for c in df.columns if c.startswith("p")}
2397
+ return frame_to_payload(
2398
+ bin_density(
2399
+ df, source_log2, sum_cols=sum_cols,
2400
+ display_log2=display_log2_for(len(df.columns)),
2401
+ )
2402
+ )
2403
+ if source_log2 is not None:
2404
+ return frame_to_payload(df)
2405
+ # No build grid to reason about: fall back to a small even-spaced preview.
2406
+ return frame_to_payload(df, downsample=DENSITY_PREVIEW_ROWS)
2407
+
2408
+
2409
+ # ----------------------------------------------------------------------
2410
+ # The named frames, and the one place that resolves them
2411
+ # ----------------------------------------------------------------------
2412
+ # Maps a frame name to a callable that yields its DataFrame **with the index
2413
+ # intact**. Every by-name consumer goes through here: the CSV download and the
2414
+ # table-document route.
2415
+ #
2416
+ # Callables rather than attribute names, and that is the point. Some frames are
2417
+ # not simply an attribute: ``stats_df`` and ``reins_stats_df`` drop their raw
2418
+ # ``ex1`` / ``ex2`` / ``ex3`` moment rows before anyone sees them, and when that
2419
+ # step lived only in the JSON route the other two paths quietly disagreed with
2420
+ # it. A portfolio's More > Stats showed 26 rows statically and 17 interactively,
2421
+ # from the same button, because two paths resolved "the frame called stats_df"
2422
+ # independently. One resolver makes that class of drift impossible rather than
2423
+ # fixing this instance of it.
2424
+ #
2425
+ # The JSON routes above still apply their own steps; they are the same steps.
2426
+ _CSV_FRAMES = {
2427
+ "summary": lambda o: _resolve_frame(o, "summary_df"),
2428
+ # ``tail_df`` is a method on agg / port; ``_resolve_frame`` calls it.
2429
+ "tail_df": lambda o: _resolve_frame(o, "tail_df"),
2430
+ "validation_df": lambda o: _resolve_frame(o, "validation_df"),
2431
+ "stats_df": lambda o: _drop_raw_moments(_resolve_frame(o, "stats_df")),
2432
+ # The P&L accounting family (aggregate 1.0.0a204, [PnL-Economic-Frames]).
2433
+ # ``economic_df`` is the ledger sheet that used to answer to ``stats_df``
2434
+ # on a PnL; that name now delegates to the wrapped engine's moment store,
2435
+ # so without these two entries the ledger would be unreachable until the
2436
+ # economics tab lands. No raw-moment drop: neither is a moment store.
2437
+ "economic_df": lambda o: _resolve_frame(o, "economic_df"),
2438
+ "economic_ratios_df": lambda o: _resolve_frame(o, "economic_ratios_df"),
2439
+ "density_df": lambda o: _resolve_frame(o, "density_df"),
2440
+ "bs_window_df": lambda o: _bs_window_frame(o),
2441
+ # The grid audit, in two views: the score grid the probe walked, and the
2442
+ # full per-cell detail behind it. Both are ``None`` until ``sharpen()`` runs,
2443
+ # which is what the ``has_sharpen`` capability flag reports, so the leaf that
2444
+ # reads them is dark rather than empty before then.
2445
+ "sharpen_score": lambda o: _sharpen_score_frame(o),
2446
+ "sharpen_df": lambda o: _resolve_frame(o, "sharpen_df"),
2447
+ "reins_summary_df": lambda o: _resolve_frame(o, "reins_summary_df"),
2448
+ # The layering analysis, transposed so the layers run down the rows, and
2449
+ # split into the layer's own terms and what it does to the moments. The
2450
+ # untransposed frame stays reachable under its own name for the CSV
2451
+ # download, which is the "give me exactly what the library built" export.
2452
+ "reins_stats_df": lambda o: _drop_raw_moments(_resolve_frame(o, "reins_stats_df")),
2453
+ "reins_density_df": lambda o: _resolve_frame(o, "reins_density_df"),
2454
+ }
2455
+
2456
+
2457
+ def _named_frame(entry: CacheEntry, which: str):
2458
+ """Resolve a frame by name, or raise the right HTTP error.
2459
+
2460
+ Parameters
2461
+ ----------
2462
+ entry : CacheEntry
2463
+ The cached object and its kind.
2464
+ which : str
2465
+ A key of ``_CSV_FRAMES``.
2466
+
2467
+ Returns
2468
+ -------
2469
+ pandas.DataFrame
2470
+ The frame, index intact. Flattening belongs to the caller, and only on
2471
+ the wire formats that need it.
2472
+ """
2473
+ resolve = _CSV_FRAMES.get(which)
2474
+ if resolve is None:
2475
+ raise HTTPException(
2476
+ status_code=404,
2477
+ detail=f"unknown frame {which!r}; expected one of {sorted(_CSV_FRAMES)}",
2478
+ )
2479
+ if entry.kind == "pnl" and which == "density_df":
2480
+ # A PnL's density_df is a dict of GridDistributions, not a frame; export
2481
+ # the grand-result density instead (the full, unbinned shape the Density
2482
+ # tab previews). All the PnL's other frames are real DataFrames and flow
2483
+ # through the generic path.
2484
+ df = pnl_density_frame(entry.obj)
2485
+ else:
2486
+ df = resolve(entry.obj)
2487
+ if df is None:
2488
+ raise HTTPException(
2489
+ status_code=400, detail=f"{which} not available for {entry.kind!r}"
2490
+ )
2491
+ return df
2492
+
2493
+
2494
+ # ----------------------------------------------------------------------
2495
+ # GET /v1/objects/{id}/frame/{which}.csv -- full-frame download
2496
+ # ----------------------------------------------------------------------
2497
+
2498
+ @router.get("/objects/{oid}/frame/{which}.csv")
2499
+ def get_frame_csv(
2500
+ oid: str, which: str, entry: CacheEntry = Depends(_locked_entry)
2501
+ ) -> Response:
2502
+ """Return the full named frame as a CSV download.
2503
+
2504
+ Notes
2505
+ -----
2506
+ Exactly what the on-screen table shows, which has not always been true: the
2507
+ raw-moment rows were dropped for the screen and exported here. See
2508
+ ``_CSV_FRAMES``.
2509
+ """
2510
+ df = _named_frame(entry, which)
2511
+ csv_text = reset_index_safe(df).to_csv(index=False)
2512
+ return Response(
2513
+ content=csv_text,
2514
+ media_type="text/csv",
2515
+ headers={
2516
+ "Content-Disposition": f'attachment; filename="{entry.name}-{which}.csv"',
2517
+ },
2518
+ )
2519
+
2520
+
2521
+ # ----------------------------------------------------------------------
2522
+ # GET /v1/objects/{id}/plot
2523
+ # ----------------------------------------------------------------------
2524
+
2525
+ # ----------------------------------------------------------------------
2526
+ # GET /v1/objects/{id}/frame/{which}?format=ir -- the static-view document
2527
+ # ----------------------------------------------------------------------
2528
+ # Declared **after** the `.csv` route above and that ordering is load bearing:
2529
+ # a path parameter matches a dot, so `{which}` here would happily swallow
2530
+ # `summary.csv` and answer JSON to a download request. Starlette matches in
2531
+ # declaration order, so `.csv` wins as long as it stays first. A test pins it.
2532
+
2533
+ @router.get("/objects/{oid}/frame/{which}")
2534
+ def get_frame_document(
2535
+ oid: str,
2536
+ which: str,
2537
+ format: str = Query("ir", description="ir"),
2538
+ request: Request = None,
2539
+ entry: CacheEntry = Depends(_locked_entry),
2540
+ ) -> Response:
2541
+ """Return the named frame as a table document (the IR).
2542
+
2543
+ The presentation counterpart to the ``.csv`` route above, resolving the same
2544
+ ``_CSV_FRAMES`` names through the same ``_resolve_frame``. It builds from the
2545
+ **DataFrame**, not from the wire format, because the two things worth having
2546
+ are exactly the two ``FrameResponse`` discards: a sparsified row index (a
2547
+ portfolio's ``tail_df`` otherwise reprints the unit name on all ten of its
2548
+ return-period rows) and spanned MultiIndex column headers.
2549
+
2550
+ Notes
2551
+ -----
2552
+ The body is ``canonical_json`` bytes rather than a Pydantic model, because
2553
+ the document's own content hash is the ETag and re-serializing through
2554
+ Pydantic would break the byte-for-byte determinism that makes the hash mean
2555
+ anything.
2556
+
2557
+ Large frames truncate rather than fail (``tables.MAX_ROWS``), and say so in
2558
+ the document's notes. The SPA still sends anything over a few hundred rows to
2559
+ the interactive grid, which is the honest instrument for them.
2560
+
2561
+ **``precision`` came out at a68**, and nothing replaced it server side. It
2562
+ reprinted the document with the per-column formats dropped, which existed
2563
+ because the *exhibit* route served documents that had thrown their numbers
2564
+ away and a client had nothing local to reprint. Every served document now
2565
+ carries the exact value beside the formatted string, here through
2566
+ ``include_raw`` and on the exhibit route through the library's own
2567
+ ``INCLUDE_RAW`` (``aggregate`` 1.0.0a246), so full precision is a rendering
2568
+ choice the client makes without asking. Two implementations of one idea, one
2569
+ of which cost a round trip, is worse than one that costs nothing.
2570
+ """
2571
+ if format != "ir":
2572
+ raise HTTPException(
2573
+ status_code=422, detail=f"unknown format {format!r}; expected 'ir'"
2574
+ )
2575
+ df = _named_frame(entry, which)
2576
+ try:
2577
+ # `formats=which`, so a frame's own name is its format key. This route
2578
+ # passed none at all through a50, which left `summary`, `tail_df`,
2579
+ # `stats_df`, `validation_df`, `bs_window_df` and every reins frame to
2580
+ # dtype inference alone; that reads a column's magnitude and drops the
2581
+ # decimals on anything averaging over 20,000, so a book worth pricing
2582
+ # showed its money as whole units. A name with no `tables.FORMATS` entry
2583
+ # resolves to nothing and behaves exactly as before.
2584
+ body, doc_hash = frame_document(df, which, formats=which)
2585
+ except ValueError as exc:
2586
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
2587
+
2588
+ # The document stamps its own hash; quote it per RFC 7232. Cached objects are
2589
+ # immutable and the build is deterministic, so a repeat request on an
2590
+ # unchanged object always revalidates rather than re-transferring.
2591
+ etag = f'"{doc_hash}"'
2592
+ if request is not None and request.headers.get("if-none-match") == etag:
2593
+ return Response(status_code=304, headers={"ETag": etag})
2594
+ return Response(
2595
+ content=body,
2596
+ media_type="application/json",
2597
+ headers={"ETag": etag, "Cache-Control": "no-cache"},
2598
+ )
2599
+
2600
+
2601
+ # ----------------------------------------------------------------------
2602
+ # GET /v1/objects/{id}/quantiles -- q(p) for the reinsurance quick-edit form
2603
+ # ----------------------------------------------------------------------
2604
+
2605
+ def _snap(value: float, digits: int = 3) -> float:
2606
+ """Round to ``digits`` significant figures, for a number a person will type.
2607
+
2608
+ The quick-edit form turns probabilities into a layer, and a layer is
2609
+ something an underwriter writes down: ``1000 xs 500``, not
2610
+ ``1,234,567.8901 xs 987,654.3210``. Quantiles land on the FFT grid and carry
2611
+ every digit of it, so without this the form produces arithmetic rather than
2612
+ a program.
2613
+
2614
+ Three significant figures, which is the resolution a real layer is quoted
2615
+ at. Exact zero and non-finite values pass through: there is no leading digit
2616
+ to round to.
2617
+ """
2618
+ if not math.isfinite(value) or value == 0:
2619
+ return value
2620
+ exp = math.floor(math.log10(abs(value)))
2621
+ factor = 10 ** (digits - 1 - exp)
2622
+ return round(value * factor) / factor
2623
+
2624
+
2625
+ @router.get("/objects/{oid}/quantiles", response_model=models.QuantilesResponse)
2626
+ def get_quantiles(
2627
+ oid: str,
2628
+ p: str = Query(..., description="Comma-separated probabilities in (0, 1)."),
2629
+ snap: bool = Query(True, description="Round to 3 significant figures."),
2630
+ basis: str = Query(
2631
+ "aggregate",
2632
+ description="Which distribution to read: aggregate|occurrence."),
2633
+ entry: CacheEntry = Depends(_locked_entry),
2634
+ ) -> dict:
2635
+ """Quantiles at the given probabilities, on the annual or the per-claim law.
2636
+
2637
+ Exists for Quick Re, which lets attach and detach be written as
2638
+ probabilities (``50%``) as well as as amounts. There was no way to ask for
2639
+ ``q(p)`` before it: ``tail_df`` carries VaR by return period, so ``q(0.99)``
2640
+ was reachable and ``q(0.5)`` was not.
2641
+
2642
+ Returns both the exact quantile and the snapped one, rather than choosing
2643
+ for the caller: the form writes the snapped value into a program a person
2644
+ then reads, and the exact value is what anyone checking the arithmetic
2645
+ wants.
2646
+
2647
+ Notes
2648
+ -----
2649
+ **The basis is not a convenience, it is the difference between a layer and a
2650
+ no-op.** An occurrence cession applies to a single claim and an aggregate
2651
+ cession to the year, so a percentage means a different number on each tier.
2652
+ Reading both off the annual distribution produced exactly the failure you
2653
+ would expect and this route was shipped with: on
2654
+ ``100 claims 1000 xs 0 sev lognorm 50 cv 2``, the annual median is 4,847
2655
+ while no single claim can exceed 1,000, so ``occurrence net of 1830 xs 4850``
2656
+ is a treaty that can never attach. It builds, it validates, and it cedes
2657
+ nothing.
2658
+
2659
+ ``q_sev`` is the per-claim quantile function and is aggregate level, so it
2660
+ answers for a mixture too, where the individual ``sevs`` components cannot.
2661
+ A kind carrying neither function gets a clean 400 rather than a wrong number.
2662
+ """
2663
+ if basis not in ("aggregate", "occurrence"):
2664
+ raise HTTPException(
2665
+ status_code=422,
2666
+ detail=f"unknown basis {basis!r}; expected 'aggregate' or 'occurrence'")
2667
+ try:
2668
+ ps = [float(v) for v in p.split(",") if v.strip()]
2669
+ except ValueError as exc:
2670
+ raise HTTPException(
2671
+ status_code=422, detail=f"p must be numbers: {exc}") from exc
2672
+ if not ps:
2673
+ raise HTTPException(status_code=422, detail="p is empty")
2674
+ if not all(0 < v < 1 for v in ps):
2675
+ raise HTTPException(
2676
+ status_code=422, detail="every p must lie strictly inside (0, 1)")
2677
+ name = "q_sev" if basis == "occurrence" else "q"
2678
+ q = getattr(entry.obj, name, None)
2679
+ # A pair carries no ``q`` of its own, but its total does: the joint's
2680
+ # total distribution is the pair's aggregate law, so its quantile function
2681
+ # is the honest ``aggregate``-basis answer (it is what the lite tiles read
2682
+ # P99 from). The occurrence basis stays a 400, since there is no single
2683
+ # per-claim law behind a pair.
2684
+ if not callable(q) and basis == "aggregate":
2685
+ total = getattr(entry.obj, "total", None)
2686
+ q = getattr(total, "q", None) if total is not None else None
2687
+ if not callable(q):
2688
+ raise HTTPException(
2689
+ status_code=400,
2690
+ detail=f"a {entry.kind!r} carries no {basis} quantile function",
2691
+ )
2692
+ out = []
2693
+ for v in ps:
2694
+ try:
2695
+ exact = float(q(v))
2696
+ except Exception as exc: # noqa: BLE001 -- reported, not raised
2697
+ raise HTTPException(
2698
+ status_code=400, detail=f"{name}({v}) failed: {exc}") from exc
2699
+ out.append({"p": v, "q": exact,
2700
+ "snapped": _snap(exact) if snap else exact})
2701
+ return {"quantiles": out}
2702
+
2703
+
2704
+ # ----------------------------------------------------------------------
2705
+ # GET /v1/objects/{id}/chart/{name} -- the chart-document route
2706
+ # ----------------------------------------------------------------------
2707
+
2708
+ #: The wire encodings a caller may ask for: the closed vocabulary of
2709
+ #: ``dev/plan-3d-plot.md`` section 2.3, which the surface block's ``dtype``
2710
+ #: declares back. Spelled as a ``Literal`` so an unknown name is a 422 off the
2711
+ #: schema, with the four valid names in the message, rather than a 500 out of
2712
+ #: an emitter that was handed a word it does not know.
2713
+ ChartEncoding = Literal["f32b64", "f64b64", "u16log12b64", "json"]
2714
+
2715
+
2716
+ def _chart_options(
2717
+ window: float | None,
2718
+ detail: int | None,
2719
+ encoding: str | None,
2720
+ lee: bool | None,
2721
+ annotate: str | None,
2722
+ settings: Settings,
2723
+ ) -> dict:
2724
+ """The chart parameters the caller actually set, as emitter options.
2725
+
2726
+ Parameters
2727
+ ----------
2728
+ window : float or None
2729
+ Quantile depth: keep ``q(10**-window)`` to ``q(1 - 10**-window)`` of
2730
+ each marginal, 0 meaning the whole grid.
2731
+ detail : int or None
2732
+ Target cells per axis after the display reduction.
2733
+ encoding : str or None
2734
+ One of :data:`ChartEncoding`.
2735
+ lee : bool or None
2736
+ Draw the quantile curve beside each tower of the structure chart.
2737
+ annotate : str or None
2738
+ Comma-separated annotation fields for the structure chart's layer
2739
+ labels. The empty string is a real request, for bare rectangles, and
2740
+ is not the same as the parameter being absent.
2741
+ settings : Settings
2742
+ Live config, read for ``max_chart_detail``.
2743
+
2744
+ Returns
2745
+ -------
2746
+ dict
2747
+ Keyword options for ``charts.build_chart_doc``, holding only what the
2748
+ caller named.
2749
+
2750
+ Raises
2751
+ ------
2752
+ HTTPException
2753
+ 422 for a ``window`` off the half-step lattice, or a ``detail`` above
2754
+ this deployment's ceiling.
2755
+
2756
+ Notes
2757
+ -----
2758
+ Only what the caller set travels. An option this route supplies by itself
2759
+ would be this service having an opinion about a library default, and would
2760
+ also make every chart that takes no such option fail the moment the route
2761
+ grew a parameter for one that does.
2762
+
2763
+ Both checks are 422 rather than a silent clamp, which is the plan's
2764
+ acceptance criterion and the right reading anyway: a request for detail the
2765
+ deployment will not serve was asking for something specific, and answering
2766
+ it with something else while returning 200 is the response lying about what
2767
+ it is. The ceiling is a setting, so it cannot be a ``le=`` on the query
2768
+ parameter; the schema carries ``ge=16`` and the description names the env
2769
+ var.
2770
+
2771
+ ``window`` is on a half-step lattice because that is what the SPA's spinner
2772
+ walks, and because a continuum of depths would make the chart cache and the
2773
+ ETag answer for a parameter nobody can reproduce by hand.
2774
+
2775
+ ``annotate`` is **not** validated here. The emitter owns the vocabulary and
2776
+ raises ``ValueError`` naming the whole of it for a word it does not know,
2777
+ which the route already turns into a 422; a copy of the list here would be
2778
+ a second place to keep it, and the first one to go stale.
2779
+ """
2780
+ options: dict = {}
2781
+ if window is not None:
2782
+ if round(window * 2) != window * 2:
2783
+ raise HTTPException(
2784
+ status_code=422,
2785
+ detail=f"window must be a multiple of 0.5; got {window}",
2786
+ )
2787
+ options["window"] = window
2788
+ if detail is not None:
2789
+ if detail > settings.max_chart_detail:
2790
+ raise HTTPException(
2791
+ status_code=422,
2792
+ detail=(
2793
+ f"detail {detail} is above this deployment's ceiling of "
2794
+ f"{settings.max_chart_detail}; raise AGGAPI_MAX_CHART_DETAIL "
2795
+ "to serve finer grids"
2796
+ ),
2797
+ )
2798
+ options["detail"] = detail
2799
+ if encoding is not None:
2800
+ options["encoding"] = encoding
2801
+ if lee is not None:
2802
+ options["lee"] = lee
2803
+ if annotate is not None:
2804
+ # ``annotate=`` is bare rectangles, a real request and not an absent
2805
+ # one, so the split is guarded rather than the string being falsy
2806
+ # checked: `"".split(",")` is `['']`, one field named nothing.
2807
+ options["annotate"] = tuple(
2808
+ field.strip() for field in annotate.split(",") if field.strip()
2809
+ )
2810
+ return options
2811
+
2812
+
2813
+ @router.get("/objects/{oid}/chart/{name}")
2814
+ def get_chart_document(
2815
+ oid: str,
2816
+ name: str,
2817
+ window: float | None = Query(
2818
+ None, ge=0, le=12,
2819
+ description=(
2820
+ "Quantile depth, in multiples of 0.5: keep q(10**-window) to "
2821
+ "q(1 - 10**-window) of each marginal. 0 keeps the whole grid. "
2822
+ "Omitted, the library chooses. Grid charts only."
2823
+ ),
2824
+ ),
2825
+ detail: int | None = Query(
2826
+ None, ge=16,
2827
+ description=(
2828
+ "Target cells per axis after the display reduction. A target, not "
2829
+ "a promise: the reduction blocks by powers of two, and the "
2830
+ "document reports what it reached. Capped by "
2831
+ "AGGAPI_MAX_CHART_DETAIL (default 1024). Grid charts only."
2832
+ ),
2833
+ ),
2834
+ encoding: ChartEncoding | None = Query(
2835
+ None,
2836
+ description=(
2837
+ "Wire encoding of the grid block: f32b64 (default upstream), "
2838
+ "f64b64, u16log12b64, or json. Grid charts only."
2839
+ ),
2840
+ ),
2841
+ lee: bool | None = Query(
2842
+ None,
2843
+ description=(
2844
+ "Draw the quantile curve of the distribution each tower is read "
2845
+ "against beside it, sharing its loss axis, so every boundary "
2846
+ "reads off as a return period. Structure chart only, and it "
2847
+ "needs a built object: asked of one that has not been updated it "
2848
+ "is a 422 rather than a silently plainer chart."
2849
+ ),
2850
+ ),
2851
+ annotate: str | None = Query(
2852
+ None,
2853
+ description=(
2854
+ "Comma-separated annotation fields beside each layer, any subset "
2855
+ "of geometry, premium, el, lr, rol, lol, sd, pr_attach, "
2856
+ "pr_detach, reinstatements, cede, rendered in that order whatever "
2857
+ "order they are given. Empty for bare rectangles. A field whose "
2858
+ "source is absent is omitted, so one selection serves an "
2859
+ "un-updated object, a built one and a priced one. Structure "
2860
+ "chart only."
2861
+ ),
2862
+ ),
2863
+ request: Request = None,
2864
+ entry: CacheEntry = Depends(_locked_entry),
2865
+ settings: Settings = Depends(get_settings),
2866
+ ) -> Response:
2867
+ """Return the named chart as a chart document (the chart IR).
2868
+
2869
+ The chart sibling of the frame-document route above. The library emitter
2870
+ owns every semantic decision (which series, on which axes, at which
2871
+ scales, and the mass-preserving display reduction that used to live in
2872
+ ``surfaceGrid`` client side); this route only serializes and
2873
+ revalidates. Names resolve through ``aggregate.charts.available_charts``,
2874
+ so a new library emitter appears here with zero endpoint changes; an
2875
+ unknown or unavailable name is a 404 carrying the capability set.
2876
+
2877
+ Notes
2878
+ -----
2879
+ The body is ``canonical_json`` bytes rather than a Pydantic model,
2880
+ because the document's own content hash is the ETag and re-serializing
2881
+ would break the byte determinism that makes the hash mean anything.
2882
+
2883
+ Built through ``charts.build_chart_doc``, the library's one public entry
2884
+ point, rather than by reaching into the ``CHARTS`` registry and calling
2885
+ the emitter here. That is not a style preference: this route did the
2886
+ latter and broke when ``CHARTS`` values grew a third field (``primary``,
2887
+ for :func:`aggregate.charts.primary_chart`), because a two-name unpack
2888
+ of a three-field record raises. Depending on the accessor instead of the
2889
+ container is what makes the next field a non-event.
2890
+
2891
+ Two things come free with the move. ``build_chart_doc`` stamps
2892
+ ``generator`` with the producing ``aggregate`` version, so a document on
2893
+ the wire now says what built it; and it enforces the emitter's own
2894
+ availability predicate, which is a second, narrower gate than the
2895
+ ``available_charts`` check below. That check stays, because it is what
2896
+ turns an unavailable name into a 404 that names what *is* available.
2897
+
2898
+ ``window``, ``detail`` and ``encoding`` (``dev/plan-3d-plot.md`` section 3)
2899
+ are the grid knob, and only the knob: which grid a caller gets is the
2900
+ library's decision, taken before the reduction, and this route neither
2901
+ crops nor re-reduces what it is handed. Cropping downstream cannot recover
2902
+ resolution that was already averaged away, which is the whole argument for
2903
+ plumbing the parameters upstream instead of doing the work here: on one
2904
+ test surface the same quantile window applied to the fine lattice leaves
2905
+ 232 cells, and applied to the emitted display grid leaves 8, starting in
2906
+ the wrong place.
2907
+
2908
+ ``lee`` and ``annotate`` are the structure chart's, added at a154. They
2909
+ are content options in the sense the library's ``charts/__init__``
2910
+ docstring allows, which is the only kind this route carries: what the
2911
+ document *says*, never how it is drawn. Without them the Lee curves the
2912
+ emitter offers could not be asked for at all, and every structure document
2913
+ would arrive at the library's default label selection.
2914
+
2915
+ They all go in the URL rather than a header because they change the bytes,
2916
+ so they belong in the thing the ETag answers for, and a URL that names its
2917
+ own resolution is shareable and shows up in a log.
2918
+
2919
+ A chart whose emitter takes none of them says so with a 422 naming what was
2920
+ sent. Silently dropping an option the caller asked for would return a grid
2921
+ that is not the one requested, under a 200 and an ETag that both claim it
2922
+ is.
2923
+ """
2924
+ available = agg_charts.available_charts(entry.obj)
2925
+ if name not in available:
2926
+ raise HTTPException(
2927
+ status_code=404,
2928
+ detail=f"no chart {name!r} for this object; available: {available}",
2929
+ )
2930
+ options = _chart_options(window, detail, encoding, lee, annotate, settings)
2931
+ key = (oid, name, window, detail, encoding, lee, annotate)
2932
+ hit = _chart_cache.get(key)
2933
+ if hit is not None:
2934
+ etag, body = hit
2935
+ else:
2936
+ try:
2937
+ doc = agg_charts.build_chart_doc(entry.obj, name, **options)
2938
+ except TypeError as exc:
2939
+ # An emitter that does not take one of these. The message is
2940
+ # CPython's "got an unexpected keyword argument", matched rather
2941
+ # than guessed at from a signature: reading the signature means
2942
+ # resolving the registry entry and the dispatch by hand, which is
2943
+ # the reach this route was rewritten to stop making.
2944
+ if not options or "unexpected keyword argument" not in str(exc):
2945
+ raise
2946
+ raise HTTPException(
2947
+ status_code=422,
2948
+ detail=(
2949
+ f"chart {name!r} takes none of "
2950
+ f"{', '.join(sorted(options))}: window, detail and "
2951
+ "encoding apply to the grid charts, whose display "
2952
+ "lattice is chosen at emission, and lee and annotate to "
2953
+ "the structure chart"
2954
+ ),
2955
+ ) from exc
2956
+ except ValueError as exc:
2957
+ # Availability is screened above, so with options in hand a
2958
+ # ValueError here is the emitter rejecting a parameter value.
2959
+ if not options:
2960
+ raise
2961
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
2962
+ body = agg_charts.canonical_json(doc)
2963
+ etag = f'"{doc.hash}"'
2964
+ _chart_cache.store(key, etag, body)
2965
+ if request is not None and request.headers.get("if-none-match") == etag:
2966
+ return Response(status_code=304, headers={"ETag": etag})
2967
+ return Response(
2968
+ content=body,
2969
+ media_type="application/json",
2970
+ headers={"ETag": etag, "Cache-Control": "no-cache"},
2971
+ )
2972
+
2973
+
2974
+ # ----------------------------------------------------------------------
2975
+ # The derivations: a program that reproduces an object you arrived at
2976
+ # ----------------------------------------------------------------------
2977
+ # Three buttons, one idea. Sharpening a grid and wrapping an object in a P&L
2978
+ # both produce a new object, and each returns the DecL that reproduces it, so
2979
+ # nothing is derived behind the user's back. The grammar knowledge lives in the
2980
+ # library (``aggregate/dev/done/plan-derived-programs.md``); these routes only
2981
+ # call it and file the result.
2982
+
2983
+
2984
+ def spread(program: str) -> str:
2985
+ """A derived program as the reader gets it: one clause per indented line.
2986
+
2987
+ The text these routes hand back lands in the editor, which is the whole
2988
+ point of a derivation: you read what it did, and you can edit it. Through
2989
+ a50 all three collapsed it to one line first, and a portfolio wrapped in a
2990
+ P&L came back as several hundred characters of unbroken DecL that nobody
2991
+ could read or edit with any confidence.
2992
+
2993
+ ``trailer=True`` is **not** optional here, and the default is ``False``.
2994
+ Without it ``format_program`` renders the bare declaration and silently
2995
+ drops the ``note{}``, ``tags{}`` and ``hints{}`` clauses, which for Sharpen
2996
+ is the entire result: the whole contract of a sharpened program is that the
2997
+ ``hints{}`` it writes rides along, so building the returned text reproduces
2998
+ the grid the probe chose. A Sharpen that returned its program without its
2999
+ hints would look right and rebuild on the old grid.
3000
+
3001
+ Best effort. The library declines to render a spec it cannot round-trip
3002
+ (minimum and mixture distortions, mostly), and a program that will not
3003
+ re-render is still a program worth handing back, so the collapsed form is
3004
+ the fallback rather than a 500.
3005
+
3006
+ Parameters
3007
+ ----------
3008
+ program : str
3009
+ Collapsed DecL, as ``collapse_program`` leaves it.
3010
+
3011
+ Returns
3012
+ -------
3013
+ str
3014
+ The spread rendering, or ``program`` unchanged if it will not render.
3015
+ """
3016
+ if not program.strip():
3017
+ return program
3018
+ from aggregate.decl_writer import format_program
3019
+
3020
+ try:
3021
+ text = format_program(program, layout="spread", trailer=True)
3022
+ except Exception: # noqa: BLE001 -- an unrenderable program is not an error
3023
+ return program
3024
+ return text.strip() or program
3025
+
3026
+
3027
+ # The ``pnl`` head of a P&L program, with the grammar's own boundary rule so
3028
+ # ``pnlx`` is not mistaken for it. Mirrors the ``PNL`` terminal in ``decl.lark``.
3029
+ _PNL_HEAD = re.compile(r"pnl(?![a-zA-Z0-9._:~\-])")
3030
+
3031
+ # Where the trailer starts, if there is one. The ``peel`` clause goes in front
3032
+ # of it: see :func:`explode_program`.
3033
+ _TRAILER_HEAD = re.compile(r"(?:note|tags|hints)\{")
3034
+
3035
+
3036
+ def explode_program(program: str, peel: str | None = "bottom-up") -> str:
3037
+ """The ``xpnl`` that walks a consolidated P&L layer by layer.
3038
+
3039
+ ``pnl`` and ``xpnl`` share an identical body in the grammar, so the whole
3040
+ transform is the leading keyword plus the optional ``peel`` clause. The text
3041
+ is a rewrite of the program the object was built from, not a re-render of
3042
+ the object, so nothing about the P&L is recomputed here.
3043
+
3044
+ Parameters
3045
+ ----------
3046
+ program : str
3047
+ Collapsed DecL for a ``pnl`` program, as :func:`collapse_program`
3048
+ leaves it.
3049
+ peel : str or None, default 'bottom-up'
3050
+ The walk direction, or ``None`` to write no ``peel`` clause. Omitted
3051
+ for an engine with no reinsurance, which has no layers to walk and
3052
+ which the library refuses to peel.
3053
+
3054
+ Returns
3055
+ -------
3056
+ str
3057
+ Collapsed DecL for the exploded program.
3058
+
3059
+ Raises
3060
+ ------
3061
+ ValueError
3062
+ If ``program`` does not lead with the ``pnl`` keyword, which includes
3063
+ the ``xpnl`` that is already exploded.
3064
+
3065
+ Notes
3066
+ -----
3067
+ **The clause goes before the trailer, and that is the whole difficulty.**
3068
+ The rule is ``... expense_less peel_clause trailer`` (``decl.lark:133``),
3069
+ and a P&L inherits its engine's trailer: an engine carrying
3070
+ ``note{...} hints{...}`` wraps into a P&L carrying both after the expense
3071
+ clause, verified against ``pnl_program`` on 1.0.0a305. Sharpen and Hints
3072
+ write exactly those clauses, so appending at the end would be a parse error
3073
+ for any program that had been through either button.
3074
+
3075
+ Taking the **earliest** of ``note{``, ``tags{`` and ``hints{`` is safe
3076
+ because a P&L carries at most one trailer. The inline engine slot has no
3077
+ trailer of its own (``agg_source`` in the grammar), which is why
3078
+ ``pnl_program`` lifts the engine's onto the wrapper, and reading the first
3079
+ match also does the right thing for a note whose text happens to mention
3080
+ another clause.
3081
+ """
3082
+ program = program.strip()
3083
+ if not _PNL_HEAD.match(program):
3084
+ raise ValueError(
3085
+ "an explode rewrites a 'pnl' program, and this one does not "
3086
+ "start with the pnl keyword")
3087
+ program = "x" + program
3088
+ if peel is None:
3089
+ return program
3090
+ clause = f"peel {peel}"
3091
+ trailer = _TRAILER_HEAD.search(program)
3092
+ if trailer is None:
3093
+ return f"{program} {clause}"
3094
+ head = program[:trailer.start()].rstrip()
3095
+ return f"{head} {clause} {program[trailer.start():]}"
3096
+
3097
+
3098
+ def _manifest(oid: str, entry: CacheEntry) -> dict:
3099
+ """The build manifest for an object already in the cache."""
3100
+ return {
3101
+ "id": oid,
3102
+ "kind": entry.kind,
3103
+ "name": entry.name,
3104
+ "warnings": [],
3105
+ "cached": False,
3106
+ "elapsed_ms": 0,
3107
+ **_summary_fields(entry.obj),
3108
+ "capability": capability_for(entry.obj),
3109
+ }
3110
+
3111
+
3112
+ @router.post("/objects/{oid}/sharpen", response_model=models.DerivedResponse)
3113
+ def post_sharpen(
3114
+ oid: str,
3115
+ request: Request,
3116
+ settings: Settings = Depends(get_settings),
3117
+ cache: ObjectCache = Depends(_get_cache),
3118
+ entry: CacheEntry = Depends(_locked_entry),
3119
+ uw: Any = Depends(_get_session_uw),
3120
+ ) -> dict:
3121
+ """Audit the grid, move to a better one, and say so in DecL.
3122
+
3123
+ ``update`` chooses a grid from the analytic moments before any FFT runs;
3124
+ this audits that choice afterwards and takes the best cell that does not
3125
+ cost more. The outcome is pinned onto the object's own ``program``, ``note``
3126
+ and ``hints`` by the library, so ``build(program)`` reproduces the sharpened
3127
+ object and the three records cannot disagree.
3128
+
3129
+ Notes
3130
+ -----
3131
+ **The probe runs on a copy, and the original entry is left alone.**
3132
+ ``sharpen`` moves its object in place, and until a111 it moved the cached
3133
+ one: the entry was re-filed under the id its new ``sharpen_program`` hashes
3134
+ to and the old id was deleted. On a personal instance that is invisible,
3135
+ because the only viewer is the one who pressed the button. On a shared one
3136
+ it breaks everybody else looking at that object: their grid changes
3137
+ underneath them and the id they hold stops resolving. The object cache is
3138
+ deliberately shared, so that a room on one hero example pays for one build,
3139
+ which is what makes an in-place sharpen everybody's business.
3140
+
3141
+ So the probe takes ``copy.deepcopy`` of the object first, and the sharpened
3142
+ copy is filed as a **new** entry under the key of its own program while the
3143
+ original entry stays exactly as it was. The response carries the new id,
3144
+ which the SPA already follows.
3145
+
3146
+ **The copy is affordable, measured rather than assumed.** 10.5 ms for a
3147
+ log2 16 aggregate and 31.4 ms for a three-unit portfolio, against rebuilds
3148
+ of 73.5 and 453 ms for the same two. Rebuilding instead would also throw
3149
+ away the probe, which is the expensive part and has already run.
3150
+
3151
+ **The new id is the one an ordinary build would produce**, so rebuilding the
3152
+ derived program from the editor is a cache hit rather than a second probe.
3153
+
3154
+ **The api's own cap reaches the probe.** ``sharpen`` defaults to
3155
+ ``log2_cap=20`` and ``AGGAPI_LOG2_CAP`` defaults to 18, so an unattended
3156
+ probe could land on a grid the build route would then refuse, leaving the
3157
+ user with a derived program the app cannot honor.
3158
+ """
3159
+ if not hasattr(entry.obj, "sharpen"):
3160
+ raise HTTPException(
3161
+ status_code=400,
3162
+ detail="sharpening applies to an Aggregate or a Portfolio")
3163
+ if not can_sharpen(entry.obj):
3164
+ raise HTTPException(
3165
+ status_code=400,
3166
+ detail=("this program already carries a sharpen verdict; a second "
3167
+ "audit of a confirmed grid is a slow no-op"))
3168
+
3169
+ # The copy is taken under the entry lock this route already holds, so
3170
+ # nothing can be mid-write in the object being copied.
3171
+ obj = copy.deepcopy(entry.obj)
3172
+
3173
+ # Same guards as a build, because a probe is several builds: it re-updates
3174
+ # the object across a line search of neighboring cells. Counted the same way
3175
+ # too, so the queue depth GET /v1/status reports is the real one: a probe
3176
+ # holding the slot blocks a build exactly as another build would.
3177
+ with status_state.build_slot(_build_semaphore):
3178
+ future = _build_executor.submit(
3179
+ lambda: obj.sharpen(log2_cap=settings.log2_cap))
3180
+ try:
3181
+ future.result(timeout=settings.build_timeout_s)
3182
+ except FuturesTimeout:
3183
+ raise HTTPException(
3184
+ status_code=504,
3185
+ detail=f"sharpen exceeded {settings.build_timeout_s}s")
3186
+ except Exception as exc: # noqa: BLE001 -- reported to the user
3187
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
3188
+
3189
+ program = collapse_program(getattr(obj, "sharpen_program", "") or "")
3190
+ if not program:
3191
+ # The library declines to pin an object built programmatically, or one
3192
+ # whose program cannot be re-parsed. The grid still moved, so this is
3193
+ # not an error; there is simply no text to hand back.
3194
+ raise HTTPException(
3195
+ status_code=422,
3196
+ detail="the probe ran but this object carries no program to pin")
3197
+
3198
+ # The id is computed over the **collapsed** text, and stays that way. An
3199
+ # ordinary build of the returned program goes through ``post_object``, which
3200
+ # collapses before it hashes (see ``:504``), so hashing the spread form here
3201
+ # would re-file this entry under an id no build could ever ask for and the
3202
+ # editor's rebuild would miss its own cache slot. What the reader gets and
3203
+ # what the cache is keyed on differ only in whitespace, which is exactly the
3204
+ # difference ``collapse_program`` exists to make irrelevant.
3205
+ #
3206
+ # Through the same rule as a build, for the same reason: the sharpened text
3207
+ # carries whatever references the original had, so if a rebuild of it would
3208
+ # be keyed privately then this entry has to be filed privately too, or the
3209
+ # rebuild misses the slot this just wrote.
3210
+ # Not counted toward the key-scope panel, deliberately. That panel measures
3211
+ # how often a *submitted program* shares, and a derived id re-keyed here
3212
+ # would double-count the program the reader is about to submit anyway.
3213
+ new_oid, _, _ = _cache_key(_preview(uw, program), session_id_of(request),
3214
+ canonicalize_decl(program), 0, 0.0)
3215
+ # A new entry, with its own lock. The sharpened object is a different object
3216
+ # from the one still serving under ``oid``, so sharing a lock between them
3217
+ # would serialize two unrelated readers for nothing.
3218
+ new_entry = CacheEntry(
3219
+ obj=obj,
3220
+ decl=program,
3221
+ log2=0,
3222
+ bs=0.0,
3223
+ kind=entry.kind,
3224
+ name=getattr(obj, "name", entry.name),
3225
+ created_at=datetime.now(timezone.utc),
3226
+ # The build's warnings travel with the copy, because they describe the
3227
+ # object rather than the request: this one was built by that build.
3228
+ notes=list(entry.notes),
3229
+ )
3230
+ cache.put(new_oid, new_entry)
3231
+ return {
3232
+ "program": spread(program),
3233
+ "description": getattr(obj, "sharpen_description", None) or None,
3234
+ **_manifest(new_oid, new_entry),
3235
+ }
3236
+
3237
+
3238
+ @router.post("/objects/{oid}/hints", response_model=models.DerivedResponse)
3239
+ def post_hints(
3240
+ oid: str,
3241
+ request: Request,
3242
+ settings: Settings = Depends(get_settings),
3243
+ cache: ObjectCache = Depends(_get_cache),
3244
+ audit: AuditLog = Depends(_get_audit),
3245
+ entry: CacheEntry = Depends(_locked_entry),
3246
+ uw: Any = Depends(_get_session_uw),
3247
+ sessions: SessionRegistry = Depends(_get_sessions),
3248
+ ) -> dict:
3249
+ """Pin this object's realized grid into its own ``hints{}`` clause.
3250
+
3251
+ The declaration comes back carrying ``log2``, ``bs`` and ``normalize`` as the
3252
+ object actually computed them, merged into whatever ``hints{}`` it already
3253
+ had rather than replacing the clause, so a declared ``padding`` survives and
3254
+ only the grid moves. ``aggregate._program.with_hints``, upstream since
3255
+ ``aggregate`` 1.0.0a291.
3256
+
3257
+ Notes
3258
+ -----
3259
+ **Why this earns a button next to Sharpen.** A ``sev agg.NAME`` reference
3260
+ requires the referenced declaration to state ``log2`` and ``bs``, because the
3261
+ reference stands for the distribution that declaration *outputs* and so the
3262
+ declaration has to say at what resolution, or the severity moves with the
3263
+ ambient defaults instead of with the model. The library's resolver refuses an
3264
+ unpinned target and its error names this very method. So this is the step
3265
+ that turns a candidate inner into one an outer may reference: get it right
3266
+ interactively, press this, build the text it hands back.
3267
+
3268
+ **No request body**, unlike ``pnl``. ``with_hints(**extra)`` accepts further
3269
+ hint keys, but the app has no opinion to offer about ``padding`` or
3270
+ ``normalize``, and a form for them would be the app holding a view about the
3271
+ library's settings. Everything it needs comes off the object.
3272
+
3273
+ **No cap guard**, unlike ``sharpen``. The probe can land on a grid the build
3274
+ route would then refuse, which is why ``post_sharpen`` clamps to
3275
+ ``AGGAPI_LOG2_CAP``. This one writes down the grid the object **already built
3276
+ on**, and it only exists because the build route let that grid through, so
3277
+ the pinned ``log2`` is at or under the cap by construction.
3278
+
3279
+ **Nothing is mutated and nothing is re-filed.** Unlike sharpening, this
3280
+ reads the object and writes text; the derived program then goes through the
3281
+ ordinary build path, which is :func:`post_object` called directly rather
3282
+ than reimplemented, so the log2 cap, the semaphore, the wall-clock timeout,
3283
+ the audit row and the whole parse-error surface all apply unchanged.
3284
+
3285
+ ``description`` is left empty on purpose. Sharpen fills it because its
3286
+ verdict is a fact about a probe that the reader cannot see; here the result
3287
+ *is* the text, and the ``hints{}`` clause is sitting in the editor.
3288
+ """
3289
+ obj = entry.obj
3290
+ if not hasattr(obj, "with_hints"):
3291
+ raise HTTPException(
3292
+ status_code=400,
3293
+ detail="pinning a grid applies to an Aggregate or a Portfolio")
3294
+ try:
3295
+ program = obj.with_hints()
3296
+ except ValueError as exc:
3297
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
3298
+
3299
+ program = collapse_program(program)
3300
+ built = post_object(models.BuildRequest(decl=program), request,
3301
+ settings, cache, audit, uw, sessions)
3302
+ return {"program": spread(program), "description": None, **built}
3303
+
3304
+
3305
+ @router.post("/objects/{oid}/pnl", response_model=models.DerivedResponse)
3306
+ def post_pnl(
3307
+ oid: str,
3308
+ req: models.PnlProgramRequest,
3309
+ request: Request,
3310
+ settings: Settings = Depends(get_settings),
3311
+ cache: ObjectCache = Depends(_get_cache),
3312
+ audit: AuditLog = Depends(_get_audit),
3313
+ entry: CacheEntry = Depends(_locked_entry),
3314
+ uw: Any = Depends(_get_session_uw),
3315
+ sessions: SessionRegistry = Depends(_get_sessions),
3316
+ ) -> dict:
3317
+ """Wrap this object in a P&L and return the program that does it.
3318
+
3319
+ ``pnl NAME_PnL <premium> less <engine> less <expense>``, with the object's
3320
+ own body inlined as the engine, so the text is self-contained and builds
3321
+ anywhere rather than only in the session that wrote it. The premium head
3322
+ is ``derive premium`` when the exposure states one (the engine's technical
3323
+ premium grossed up for the expense clause, so premium net of expenses
3324
+ returns the technical premium exactly; upstream since ``aggregate``
3325
+ 1.0.0a270, ``inherit premium`` before that), and otherwise expected loss
3326
+ over ``loss_ratio``, rounded where the number is produced
3327
+ (``aggregate._program._pnl_consideration``, upstream since ``aggregate``
3328
+ 1.0.0a251; this route rewrote the text itself until a95).
3329
+
3330
+ **The cover is priced too**, since a114. The three combined ratios engage
3331
+ the library's technical premium ladder (``aggregate`` 1.0.0a306): the net
3332
+ book and each cession are priced separately and added, then grossed up once
3333
+ for expenses, and each ceded premium is written into the program as a
3334
+ ``deposit``. That is what silences the ``ZeroPremiumCessionWarning`` a bare
3335
+ cession raises, and it is the difference between the button writing a book
3336
+ with reasonable numbers in it and one whose reinsurance is free.
3337
+
3338
+ Two request fields route rather than pass through, since a183.
3339
+ ``form='xpnl'`` runs the wrapped program through :func:`explode_program`,
3340
+ so one press answers with the book broken out layer by layer; it refuses a
3341
+ portfolio engine with the same sentence :func:`post_explode` uses.
3342
+ ``premium_style='rate'`` asks for each priced layer's premium as a ``rate``
3343
+ of the stated gross premium and is refused with a 400 until the installed
3344
+ library's ``pnl_program`` accepts the keyword.
3345
+
3346
+ Notes
3347
+ -----
3348
+ **A portfolio engine takes the old path, and that is the library's scope
3349
+ rather than this route's timidity.** The ladder prices the cessions of a
3350
+ single aggregate engine and refuses a portfolio by name, so sending the
3351
+ app's default ratios to one would turn every portfolio press into an error
3352
+ pane. The ratios are dropped for a portfolio **only when the caller did not
3353
+ ask for them**: an explicit ``net_combined_ratio`` in the body reaches the
3354
+ library and is refused there, with a message naming the reason, because
3355
+ silently ignoring what a caller asked for is worse than refusing it. The
3356
+ consequence worth knowing is that a portfolio P&L is still sized off
3357
+ ``loss_ratio`` while an aggregate one is sized off the ladder.
3358
+
3359
+ **A layer already carrying a ``rate`` clause is a 422**, from the library.
3360
+ A rate resolves against the P&L premium, and the premium is what the ladder
3361
+ is computing, so it cannot enter the sum at a known amount. The library's
3362
+ ruling is to refuse and name the layer rather than solve a circularity or
3363
+ break its own margin identity, and the message tells the reader to restate
3364
+ the layer as a deposit. Surfacing that beats writing a book whose numbers
3365
+ quietly do not add up.
3366
+
3367
+ Unlike sharpening this mutates nothing, so there is no re-filing to do: the
3368
+ derived text goes through the ordinary build path, which is
3369
+ :func:`post_object` called directly rather than reimplemented. That is
3370
+ deliberate. Every guard the build route carries (the log2 cap, the
3371
+ semaphore, the wall-clock timeout, the audit row and the whole parse-error
3372
+ surface) applies unchanged to a derived program, and a second
3373
+ implementation would be a second place for them to drift.
3374
+ """
3375
+ obj = entry.obj
3376
+ if not hasattr(obj, "pnl_program"):
3377
+ raise HTTPException(
3378
+ status_code=400,
3379
+ detail="a P&L wraps an Aggregate or a Portfolio")
3380
+
3381
+ # The two gates on the request's own fields, before any work. The explode
3382
+ # refusal is `post_explode`'s sentence, so the two routes cannot disagree;
3383
+ # the rate refusal is the api being honest about the installed library,
3384
+ # whose `pnl_program` does not take `premium_style` until the upstream ask
3385
+ # ships (see `capability.PNL_PREMIUM_STYLE_SUPPORTED`).
3386
+ if req.form == "xpnl" and type(obj).__name__ == "Portfolio":
3387
+ raise HTTPException(
3388
+ status_code=400,
3389
+ detail=("the portfolio total hides its units, so there is nothing "
3390
+ "to explode"))
3391
+ style: dict = {}
3392
+ if req.premium_style == "rate":
3393
+ if not PNL_PREMIUM_STYLE_SUPPORTED:
3394
+ raise HTTPException(
3395
+ status_code=400,
3396
+ detail=("premium_style='rate' needs an aggregate library whose "
3397
+ "pnl_program accepts it; this install's does not yet"))
3398
+ style = {"premium_style": "rate"}
3399
+
3400
+ ladder = {"net_combined_ratio": req.net_combined_ratio,
3401
+ "occ_combined_ratio": req.occ_combined_ratio,
3402
+ "agg_combined_ratio": req.agg_combined_ratio}
3403
+ if (type(obj).__name__ == "Portfolio"
3404
+ and not req.model_fields_set & set(ladder)):
3405
+ ladder = {}
3406
+
3407
+ try:
3408
+ program = obj.pnl_program(loss_ratio=req.loss_ratio,
3409
+ expense_ratio=req.expense_ratio,
3410
+ **ladder, **style)
3411
+ except ValueError as exc:
3412
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
3413
+
3414
+ program = collapse_program(program)
3415
+ if req.form == "xpnl":
3416
+ # The same transform press two applies, run here so one press answers
3417
+ # "the exploded P&L of what is in the box". The peel rule is
3418
+ # `post_explode`'s: walk the layers when the engine has reinsurance,
3419
+ # write no clause otherwise.
3420
+ try:
3421
+ program = explode_program(
3422
+ program,
3423
+ peel="bottom-up" if _has_reinsurance(obj) else None)
3424
+ except ValueError as exc:
3425
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
3426
+ built = post_object(models.BuildRequest(decl=program), request,
3427
+ settings, cache, audit, uw, sessions)
3428
+ return {"program": spread(program), "description": None, **built}
3429
+
3430
+
3431
+ @router.post("/objects/{oid}/explode", response_model=models.DerivedResponse)
3432
+ def post_explode(
3433
+ oid: str,
3434
+ request: Request,
3435
+ settings: Settings = Depends(get_settings),
3436
+ cache: ObjectCache = Depends(_get_cache),
3437
+ audit: AuditLog = Depends(_get_audit),
3438
+ entry: CacheEntry = Depends(_locked_entry),
3439
+ uw: Any = Depends(_get_session_uw),
3440
+ sessions: SessionRegistry = Depends(_get_sessions),
3441
+ ) -> dict:
3442
+ """Break this P&L out layer by layer: the ``xpnl`` of the same program.
3443
+
3444
+ Press two of the P&L button's two step story. Press one wraps an object in a
3445
+ P&L and shows the consolidated total; this swaps ``pnl`` for ``xpnl`` and
3446
+ adds ``peel bottom-up``, so the same book comes back as the walk up through
3447
+ its reinsurance layers, lowest attaching first.
3448
+
3449
+ **No request body.** There is one thing to do here and no convention to
3450
+ state, which is the ``hints`` shape rather than the ``pnl`` one.
3451
+
3452
+ Notes
3453
+ -----
3454
+ **A route rather than a text edit in the browser.** The transform needs a
3455
+ rebuild either way, so there is no round trip to save. Here it sits beside
3456
+ the grammar knowledge the other derivations already keep server side, it is
3457
+ covered by ``pytest``, and both gates below are read off the live object
3458
+ instead of guessed from the text.
3459
+
3460
+ **The two gates.** A portfolio engine is refused outright: ``xpnl`` over a
3461
+ portfolio raises ``NotImplementedError`` upstream ("the portfolio total
3462
+ hides its units, so there is nothing to explode"), and refusing here as a
3463
+ 400 lets the button go dark rather than making the press produce an error
3464
+ pane. An engine with no reinsurance still explodes, and simply writes no
3465
+ ``peel`` clause: that is the correct ``xpnl``, one group per step with a
3466
+ single step, where a peel clause would be refused for having no layers to
3467
+ walk.
3468
+
3469
+ **The reinsurance gate looks through** ``PnL.engine``. It reads the engine
3470
+ rather than the P&L because the cession lives on the wrapped object, which
3471
+ is the same reason :func:`_has_reinsurance` learned to look through a P&L.
3472
+
3473
+ **Nothing is mutated.** The exploded text goes through the ordinary build
3474
+ path, :func:`post_object` called directly rather than reimplemented, so the
3475
+ log2 cap, the semaphore, the wall-clock timeout, the audit row and the whole
3476
+ parse-error surface apply unchanged.
3477
+
3478
+ ``description`` is left empty, as for ``pnl`` and ``hints``: the result is
3479
+ the text, and it is sitting in the editor.
3480
+ """
3481
+ obj = entry.obj
3482
+ if entry.kind != "pnl":
3483
+ raise HTTPException(
3484
+ status_code=400,
3485
+ detail="an explode applies to a P&L")
3486
+
3487
+ engine = getattr(obj, "engine", None)
3488
+ if type(engine).__name__ == "Portfolio":
3489
+ raise HTTPException(
3490
+ status_code=400,
3491
+ detail=("the portfolio total hides its units, so there is nothing "
3492
+ "to explode"))
3493
+
3494
+ try:
3495
+ program = explode_program(
3496
+ collapse_program(entry.decl),
3497
+ peel="bottom-up" if _has_reinsurance(engine) else None)
3498
+ except ValueError as exc:
3499
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
3500
+
3501
+ built = post_object(models.BuildRequest(decl=program), request,
3502
+ settings, cache, audit, uw, sessions)
3503
+ return {"program": spread(program), "description": None, **built}
3504
+
3505
+
3506
+ def _quote_span(row: dict) -> str:
3507
+ """One quote row's layer as a span, the way a slip writes it.
3508
+
3509
+ ``1,000 xs 1,000 @ 95%``, and ``inf xs 10,000`` on an unlimited cover, which
3510
+ is how the request spells one. The placement is dropped on a whole-layer
3511
+ share, since ``@ 100%`` says nothing.
3512
+ """
3513
+ limit = row["limit"]
3514
+ span = "inf" if limit is None else f"{limit:,g}"
3515
+ text = f"{span} xs {row['attach']:,g}"
3516
+ share = row["share"]
3517
+ return text if abs(share - 1.0) < 1e-9 else f"{text} @ {share:.0%}"
3518
+
3519
+
3520
+ def _quote_frame(quotes: list[dict]) -> pd.DataFrame:
3521
+ """The quote sheet as a frame, indexed by layer name.
3522
+
3523
+ Parameters
3524
+ ----------
3525
+ quotes : list of dict
3526
+ ``layer_pricing.layer_quotes`` rows, in clause order.
3527
+
3528
+ Returns
3529
+ -------
3530
+ pandas.DataFrame
3531
+ Twelve columns in reading order, indexed by ``Layer``: the layer's name
3532
+ and span, what it is expected to cost and how variable that is, the four
3533
+ candidate prices side by side with the one that bound, and then the
3534
+ price and the two ratios it implies.
3535
+
3536
+ Notes
3537
+ -----
3538
+ The index is the label rather than the span, which is the point of the
3539
+ column: a span says what a layer covers and a label says where it sits in
3540
+ the stack. Both are carried, since a reader checking a quote against the
3541
+ clause needs the span and a reader reading down the tower needs the name.
3542
+
3543
+ ``Span`` and ``Binds`` are left as object dtype deliberately, so no
3544
+ thousands separator reaches a string, and the amounts stay numeric so the
3545
+ interactive grid can sort and filter on them.
3546
+ """
3547
+ return pd.DataFrame(
3548
+ {
3549
+ "Span": [_quote_span(q) for q in quotes],
3550
+ "EL": [q["el"] for q in quotes],
3551
+ "CV": [q["cv"] for q in quotes],
3552
+ "SD load": [q["sd_load"] for q in quotes],
3553
+ "PH": [q["ph"] for q in quotes],
3554
+ "Dual": [q["dual"] for q in quotes],
3555
+ "Min ROL": [q["min_rol"] for q in quotes],
3556
+ "Binds": [q["binds"] for q in quotes],
3557
+ "Premium": [q["premium"] for q in quotes],
3558
+ "ROL": [q["rol"] for q in quotes],
3559
+ "LR": [q["loss_ratio"] for q in quotes],
3560
+ },
3561
+ index=pd.Index([q["label"] for q in quotes], name="Layer"),
3562
+ )
3563
+
3564
+
3565
+ @router.post("/objects/{oid}/reins", response_model=models.DerivedResponse)
3566
+ def post_reins(
3567
+ oid: str,
3568
+ req: models.ReinsProgramRequest,
3569
+ request: Request,
3570
+ settings: Settings = Depends(get_settings),
3571
+ cache: ObjectCache = Depends(_get_cache),
3572
+ audit: AuditLog = Depends(_get_audit),
3573
+ entry: CacheEntry = Depends(_locked_entry),
3574
+ uw: Any = Depends(_get_session_uw),
3575
+ sessions: SessionRegistry = Depends(_get_sessions),
3576
+ ) -> dict:
3577
+ """Cede a layer, and return the program that rebuilds the net object.
3578
+
3579
+ The clause cannot simply be appended to the program text: an occurrence
3580
+ cession sits **before** the frequency clause and an aggregate cession after
3581
+ it, so anyone splicing strings rather than specs gets it wrong. The library
3582
+ mutates the spec and re-renders instead, and this route only asks.
3583
+
3584
+ Like the P&L wrap this mutates nothing, so the derived text goes through
3585
+ the ordinary build path. The derived object is ``NAME_net``, and Reset on
3586
+ the action row is the way back to the gross one, which is why the
3587
+ reinsurance pane needs no reset of its own.
3588
+
3589
+ Notes
3590
+ -----
3591
+ Two refusals come straight from the library and are 422s here, because both
3592
+ are a statement about the program rather than a server fault: a malformed
3593
+ clause, and an occurrence cession joined to an ``approximate`` clause, which
3594
+ the parser rejects, so returning the text would hand back something that
3595
+ cannot build.
3596
+
3597
+ **``price`` writes a quote into every layer**, as a ``deposit`` or a
3598
+ ``rol``, and brings the per-layer arithmetic back in ``quotes``. The two
3599
+ paths meet at the same place: pricing rewrites the clause and everything
3600
+ after it is unchanged, so the route stays usable both ways and an unpriced
3601
+ cession costs exactly what it always did. See
3602
+ :mod:`aggregate_api.layer_pricing` for the method and the constants.
3603
+ """
3604
+ obj = entry.obj
3605
+ if not hasattr(obj, "reins_program"):
3606
+ raise HTTPException(
3607
+ status_code=400,
3608
+ detail="a cession applies to an Aggregate")
3609
+ cession, quotes = req.cession, None
3610
+ if req.price:
3611
+ try:
3612
+ cession, quotes = price_clause(uw, obj, cession, cede=req.cede)
3613
+ except ValueError as exc:
3614
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
3615
+ try:
3616
+ program = obj.reins_program(cession)
3617
+ except ValueError as exc:
3618
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
3619
+
3620
+ program = collapse_program(program)
3621
+ built = post_object(models.BuildRequest(decl=program), request,
3622
+ settings, cache, audit, uw, sessions)
3623
+ # The quote sheet rides as a table document as well as as rows, so the SPA
3624
+ # can render it in whichever table view the page is set to rather than
3625
+ # always as the interactive grid. Keyed `quotes`, the way `BoundsResponse`
3626
+ # keys its own `table`. The rows stay on the wire: they are the api's answer
3627
+ # for a caller that is not a browser.
3628
+ ir = None
3629
+ if quotes:
3630
+ ir = {"quotes": frame_document_dict(
3631
+ _quote_frame(quotes), which="reins_quotes",
3632
+ formats="reins_quotes")}
3633
+ return {"program": spread(program), "description": None,
3634
+ "quotes": quotes, "ir": ir, **built}
3635
+
3636
+
3637
+ @router.post("/objects/{oid}/layers/indication",
3638
+ response_model=models.LayerIndicationResponse)
3639
+ def post_layer_indication(
3640
+ oid: str,
3641
+ req: models.LayerIndicationRequest,
3642
+ entry: CacheEntry = Depends(_locked_entry),
3643
+ ) -> dict:
3644
+ """What a list of layers would be quoted at, with no build.
3645
+
3646
+ The Quick Re preview line's other half. The clause it shows says what the
3647
+ row would write; this says what that coverage costs, so the reader is
3648
+ choosing an attachment against a price rather than against nothing.
3649
+
3650
+ Notes
3651
+ -----
3652
+ **No build, which is why this can sit on a keystroke.** Every figure is a
3653
+ reading of the already built gross object's own severity and aggregate
3654
+ survival curves, so the whole response is arithmetic over the lattice. The
3655
+ full press costs one small auxiliary build per layer and can only revise
3656
+ these numbers *upward*, which is what makes showing them honest; the
3657
+ reasoning is on :func:`aggregate_api.layer_pricing.indication`.
3658
+
3659
+ A layer the library would refuse is a 422 rather than a server fault, on the
3660
+ same reading as the cession route: it is a statement about the program.
3661
+ """
3662
+ obj = entry.obj
3663
+ if not hasattr(obj, "reins_program"):
3664
+ raise HTTPException(
3665
+ status_code=400,
3666
+ detail="a cession applies to an Aggregate")
3667
+ try:
3668
+ rows = [
3669
+ layer_indication(
3670
+ obj, layer.tier, layer.attach,
3671
+ layer.limit if layer.limit is not None else math.inf,
3672
+ layer.share, cede=req.cede)
3673
+ for layer in req.layers
3674
+ ]
3675
+ except (ValueError, IndexError, KeyError) as exc:
3676
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
3677
+ return {"indications": rows}
3678
+
3679
+
3680
+ @router.get("/objects/{oid}/narrative", response_model=models.NarrativeResponse)
3681
+ def get_narrative(oid: str, entry: CacheEntry = Depends(_locked_entry)) -> dict:
3682
+ """Everything this object says about itself in prose.
3683
+
3684
+ The ``info`` block first, then one section per text field it carries, each
3685
+ with its short form and its long one. Absorbs the old "Info (raw)" view,
3686
+ which showed the first of those and none of the rest.
3687
+
3688
+ Derived by suffix rather than from a list, so a narrative the library adds
3689
+ upstream appears here with no endpoint change, which is the same contract
3690
+ the exhibit and chart routes keep.
3691
+ """
3692
+ return {
3693
+ "info": str(getattr(entry.obj, "info", "") or ""),
3694
+ "sections": narrative_for(entry.obj),
3695
+ }
3696
+
3697
+
3698
+ # ----------------------------------------------------------------------
3699
+ # Pricing bounds: how much of the price the distortion decides
3700
+ # ----------------------------------------------------------------------
3701
+
3702
+ def _resolve_risk(obj: Any, text: str, settings: Settings, uw):
3703
+ """A named unit of this portfolio, or a line built from a DecL fragment.
3704
+
3705
+ The two ways a user names a second risk, and they are tried in that order
3706
+ because a unit name is unambiguous and free while a fragment is a build.
3707
+
3708
+ Parameters
3709
+ ----------
3710
+ obj : Any
3711
+ The reference object, whose units are searched first.
3712
+ text : str
3713
+ A unit name, or DecL for a line that does not exist yet.
3714
+ settings : Settings
3715
+ For the log2 cap, which a fragment has to respect exactly as a typed
3716
+ program does: it is the same build, reached by a different door.
3717
+ uw : aggregate.underwriter.Underwriter
3718
+ The caller's own base. A fragment is a declaration and registers itself,
3719
+ so building it in the process base would file a user's ad-hoc line where
3720
+ every other user's programs resolve names, which is the collision this
3721
+ whole phase removes.
3722
+
3723
+ Returns
3724
+ -------
3725
+ (str, object)
3726
+ Display name and the risk.
3727
+ """
3728
+ name = text.strip()
3729
+ if not name:
3730
+ raise ValueError("name a risk, or write the DecL for one")
3731
+ for unit in getattr(obj, "unit_names", []) or []:
3732
+ if str(unit) == name:
3733
+ return name, obj[name]
3734
+
3735
+ program = collapse_program(name)
3736
+ hint_log2 = max((int(m) for m in _HINTS_LOG2.findall(program)), default=0)
3737
+ if hint_log2 > settings.log2_cap:
3738
+ raise ValueError(
3739
+ f"log2 {hint_log2} exceeds AGGAPI_LOG2_CAP={settings.log2_cap}")
3740
+ try:
3741
+ built = uw(program)
3742
+ except Exception as exc: # noqa: BLE001 -- reported as a 422
3743
+ raise ValueError(
3744
+ f"{name!r} is not a unit of this object, and does not build: "
3745
+ f"{exc}") from exc
3746
+ return getattr(built, "name", name), built
3747
+
3748
+
3749
+ @router.get("/objects/{oid}/bounds/envelope")
3750
+ def get_bounds_envelope(
3751
+ oid: str,
3752
+ premium: float = Query(..., gt=0, description="Target premium."),
3753
+ assets: float | None = Query(None, gt=0, description="Asset cap."),
3754
+ n_resamples: int = Query(50, ge=0, le=500,
3755
+ description="Bracketing curves inside the band."),
3756
+ request: Request = None,
3757
+ entry: CacheEntry = Depends(_locked_entry),
3758
+ ) -> Response:
3759
+ """The envelope: every distortion consistent with this premium.
3760
+
3761
+ A GET because the answer is identified entirely by its query, which is what
3762
+ makes it cacheable and revalidatable.
3763
+
3764
+ Serves the **chart document** since a60, not a rendered image. It used to
3765
+ ship SVG or PNG from a matplotlib figure and was the last thing in the api
3766
+ importing matplotlib; the library's ``chart_envelope`` emitter publishes the
3767
+ same picture as semantics, so this route serializes and the browser draws.
3768
+ The reader gets a chart they can zoom and read values off, and the two
3769
+ renderers cannot disagree about what the envelope is, because there is one
3770
+ document behind both.
3771
+
3772
+ See :mod:`aggregate_api.bounds` for why fifty resamples is cheap.
3773
+ """
3774
+ try:
3775
+ body, doc_hash = run_envelope(
3776
+ entry.obj, premium=premium, assets=assets,
3777
+ n_resamples=n_resamples)
3778
+ except ValueError as exc:
3779
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
3780
+ etag = f'"{doc_hash}"'
3781
+ if request is not None and request.headers.get("if-none-match") == etag:
3782
+ return Response(status_code=304, headers={"ETag": etag})
3783
+ return Response(
3784
+ content=body,
3785
+ media_type="application/json",
3786
+ headers={"ETag": etag, "Cache-Control": "no-cache"},
3787
+ )
3788
+
3789
+
3790
+ @router.post("/objects/{oid}/bounds/allocation", response_model=models.BoundsResponse)
3791
+ def post_allocation_bounds(
3792
+ oid: str,
3793
+ req: models.BoundsRequest,
3794
+ ir: bool = Query(False, description="Also return a table document."),
3795
+ entry: CacheEntry = Depends(_locked_entry),
3796
+ ) -> dict:
3797
+ """Per-unit natural-allocation ranges consistent with the total premium.
3798
+
3799
+ Portfolio only, and not by our choice: the calculation reads the ``exeqa_*``
3800
+ columns of a portfolio's density frame, which a single aggregate has no
3801
+ analogue of.
3802
+ """
3803
+ try:
3804
+ return run_allocation(entry.obj, premium=req.premium,
3805
+ assets=req.assets, ir=ir)
3806
+ except ValueError as exc:
3807
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
3808
+
3809
+
3810
+ @router.post("/objects/{oid}/bounds/pricing", response_model=models.BoundsResponse)
3811
+ def post_pricing_bounds(
3812
+ oid: str,
3813
+ req: models.BoundsRequest,
3814
+ ir: bool = Query(False, description="Also return a table document."),
3815
+ settings: Settings = Depends(get_settings),
3816
+ entry: CacheEntry = Depends(_locked_entry),
3817
+ uw: Any = Depends(_get_session_uw),
3818
+ ) -> dict:
3819
+ """Given this object priced to ``premium``, what can a second risk cost?
3820
+
3821
+ The question behind quoting a new line off an existing book: the
3822
+ calibration is carried across, and the width of the answer is how much of
3823
+ the second price the choice of distortion decides.
3824
+
3825
+ Each entry in ``against`` is a unit of the current portfolio or a DecL
3826
+ fragment for a line that does not exist yet. A fragment is an ordinary
3827
+ build and answers to the same log2 cap.
3828
+
3829
+ **An empty ``against`` on a portfolio means every unit.** That is the
3830
+ question a portfolio makes you want to ask, and having to type one unit name
3831
+ to ask any of it made the default answer nothing at all.
3832
+ ``Portfolio.pricing_bounds`` takes a source, a list or a dict, so this is a
3833
+ default rather than a loop. Naming a unit narrows to that one; naming a DecL
3834
+ fragment prices a line that does not exist yet, and both still work.
3835
+
3836
+ An aggregate has no units to default to, so an empty ``against`` there is
3837
+ still the error it always was: there is no second risk to price.
3838
+ """
3839
+ try:
3840
+ against = list(req.against)
3841
+ if not against:
3842
+ against = [str(u) for u in (getattr(entry.obj, "unit_names", None) or [])]
3843
+ targets = dict(_resolve_risk(entry.obj, text, settings, uw)
3844
+ for text in against)
3845
+ return run_pricing_bounds(entry.obj, premium=req.premium,
3846
+ targets=targets, assets=req.assets, ir=ir)
3847
+ except ValueError as exc:
3848
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
3849
+
3850
+
3851
+ # ----------------------------------------------------------------------
3852
+ # GET /v1/objects/{id}/exhibits and /exhibit/{name} -- business exhibits
3853
+ # ----------------------------------------------------------------------
3854
+
3855
+ @router.get("/objects/{oid}/exhibits")
3856
+ def list_exhibits(oid: str, entry: CacheEntry = Depends(_locked_entry)) -> dict:
3857
+ """List the exhibits this object can serve, with their perspectives.
3858
+
3859
+ A passthrough of ``aggregate.exhibits.available_exhibits``: the library
3860
+ owns the capability set (its registrations plus per object predicates),
3861
+ so a new library exhibit appears here with zero endpoint changes. No per
3862
+ kind tables in the route. The client grays out chips whose capability is
3863
+ absent; it never hides them.
3864
+ """
3865
+ items = agg_exhibits.available_exhibits(entry.obj)
3866
+ return {
3867
+ "exhibits": [
3868
+ {
3869
+ "name": name,
3870
+ "title": agg_exhibits.EXHIBITS[name][0].title,
3871
+ "perspectives": [p.value for p in perspectives],
3872
+ }
3873
+ for name, perspectives in items
3874
+ ]
3875
+ }
3876
+
3877
+
3878
+ @router.get("/objects/{oid}/exhibit/{name}")
3879
+ def get_exhibit(
3880
+ oid: str,
3881
+ name: str,
3882
+ perspective: str = Query("raw", description="raw|insurer"),
3883
+ request: Request = None,
3884
+ entry: CacheEntry = Depends(_locked_entry),
3885
+ ) -> Response:
3886
+ """Return the named exhibit envelope: TableDoc blocks plus metadata.
3887
+
3888
+ The exhibit sibling of the frame-document route. The library owns the
3889
+ business translation per perspective (captions, row flags, drops,
3890
+ relabeling); this route only serializes and revalidates. An unknown or
3891
+ unavailable name is a 404 carrying the capability set; an unsupported
3892
+ perspective is a 400 (the enum has four values; raw and insurer are
3893
+ served at 1.0).
3894
+
3895
+ Notes
3896
+ -----
3897
+ The body is deterministic UTF-8 JSON (sorted keys, compact separators,
3898
+ ``canonical_dict`` blocks), so the exhibit hash (sha256 over the block
3899
+ document hashes) works as the ETag under the same revalidation contract
3900
+ as the table and chart documents.
3901
+
3902
+ That determinism is also what lets ``_exhibit_cache`` answer a conditional
3903
+ GET without rebuilding. Through a134 this route built the exhibit,
3904
+ serialized it, computed the hash, compared ``If-None-Match`` and on a match
3905
+ discarded all of it, which cost 210 ms on a three unit portfolio's
3906
+ ``tail`` to reply "nothing changed". Availability is still screened first,
3907
+ so an unknown name is a 404 before any cache is consulted.
3908
+ """
3909
+ available = dict(agg_exhibits.available_exhibits(entry.obj))
3910
+ if name not in available:
3911
+ raise HTTPException(
3912
+ status_code=404,
3913
+ detail=(f"no exhibit {name!r} for this object; "
3914
+ f"available: {sorted(available)}"),
3915
+ )
3916
+ # ``MAX_ROWS`` is a constant, so it is not part of the key: were it ever to
3917
+ # become a query parameter it would have to join, since it changes the
3918
+ # bytes.
3919
+ key = (oid, name, perspective)
3920
+ hit = _exhibit_cache.get(key)
3921
+ if hit is not None:
3922
+ etag, body = hit
3923
+ else:
3924
+ try:
3925
+ # The same row cap the api's own documents take, so a reader cannot
3926
+ # meet two different truncation points depending on which route a
3927
+ # leaf happens to use. The library's own default is 200;
3928
+ # ``tables.MAX_ROWS`` is 500 and is the number this service has been
3929
+ # serving all along.
3930
+ exhibit = agg_exhibits.build_exhibit(entry.obj, name, perspective,
3931
+ max_rows=MAX_ROWS)
3932
+ except (NotImplementedError, ValueError) as exc:
3933
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
3934
+ body = json.dumps(
3935
+ exhibit.to_payload(), sort_keys=True, separators=(",", ":"),
3936
+ ensure_ascii=False, allow_nan=False,
3937
+ ).encode("utf-8")
3938
+ etag = f'"{exhibit.hash}"'
3939
+ _exhibit_cache.store(key, etag, body)
3940
+ if request is not None and request.headers.get("if-none-match") == etag:
3941
+ return Response(status_code=304, headers={"ETag": etag})
3942
+ return Response(
3943
+ content=body,
3944
+ media_type="application/json",
3945
+ headers={"ETag": etag, "Cache-Control": "no-cache"},
3946
+ )
3947
+
3948
+
3949
+ # ----------------------------------------------------------------------
3950
+ # POST /v1/objects/{id}/pricing/{preview,calibrate,allocate,evaluate}
3951
+ # ----------------------------------------------------------------------
3952
+ # The Pricing group, served through the official channel. Where the three older
3953
+ # routes below build pandas frames here and hand them over with this repo's
3954
+ # opinion about how they print, these hold the result object the library returns
3955
+ # and serve the exhibits registered on it. The frames, the formats, the captions
3956
+ # and the row emphasis are all the library's, which is the purist ruling applied
3957
+ # to the last pane that was making its own.
3958
+ #
3959
+ # Every library ``ValueError`` on this path is written to be shown to a reader as
3960
+ # a sentence, so all three routes turn one into a 400 whose ``detail`` is the
3961
+ # message verbatim. Three reach the app: the unbounded anchor guard on ``p=1``,
3962
+ # the loss-ratio target that implies a premium above the assets, and the
3963
+ # "exactly one of" validations. The first two land in the preview line.
3964
+
3965
+ @router.post("/objects/{oid}/pricing/preview",
3966
+ response_model=models.PricingPreviewResponse)
3967
+ def post_pricing_preview(
3968
+ oid: str,
3969
+ req: models.PricingPreviewRequest,
3970
+ entry: CacheEntry = Depends(_locked_entry),
3971
+ ) -> dict:
3972
+ """The pentagon this anchor and target imply, as scalars.
3973
+
3974
+ The cheapest question in the group and the only one that answers with
3975
+ numbers rather than documents: no distortion is fitted and nothing is
3976
+ allocated. It feeds the Calibrate form's live preview line, which is also
3977
+ where a refusal belongs, since a reader who has typed an impossible anchor
3978
+ should learn it where they are looking rather than after pressing a button.
3979
+
3980
+ See :func:`aggregate_api.pricing.run_pricing_preview`.
3981
+ """
3982
+ try:
3983
+ return run_pricing_preview(entry.obj, p=req.p, a=req.a, coc=req.coc,
3984
+ lr=req.lr, premium=req.premium,
3985
+ basis=req.basis,
3986
+ expense_ratio=req.expense_ratio)
3987
+ except ValueError as exc:
3988
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
3989
+
3990
+
3991
+ @router.post("/objects/{oid}/pnl/pentagon",
3992
+ response_model=models.PnLPentagonResponse)
3993
+ def post_pnl_pentagon(
3994
+ oid: str,
3995
+ req: models.PnLPentagonRequest,
3996
+ entry: CacheEntry = Depends(_locked_entry),
3997
+ ) -> dict:
3998
+ """The ledger as a pentagon, at each level of the leaf's strip.
3999
+
4000
+ A route rather than an exhibit, because the level is a form input and the
4001
+ exhibit route takes only a perspective. The figure it feeds is a bespoke SVG
4002
+ the app draws, so there is no library document to serve here, only the
4003
+ numbers it draws from.
4004
+
4005
+ See :func:`aggregate_api.pnl.run_pnl_pentagon`.
4006
+ """
4007
+ try:
4008
+ return run_pnl_pentagon(entry.obj, periods=req.periods)
4009
+ except ValueError as exc:
4010
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
4011
+
4012
+
4013
+ @router.post("/objects/{oid}/pricing/calibrate",
4014
+ response_model=models.PricingExhibitsResponse)
4015
+ def post_pricing_calibrate(
4016
+ oid: str,
4017
+ req: models.PricingCalibrateRequest,
4018
+ entry: CacheEntry = Depends(_locked_entry),
4019
+ ) -> dict:
4020
+ """Fit the standard distortion set, and serve the two exhibits it supports.
4021
+
4022
+ One press fills two subtabs. ``pricing.calibrate`` is the per-family receipt
4023
+ and ``pricing.stand_alone`` prices each part on its own with those same
4024
+ fitted families, the views of a cession or the units of a book, so both come
4025
+ back from one POST and stepping between the two leaves costs nothing.
4026
+
4027
+ See :func:`aggregate_api.pricing.run_calibration`.
4028
+ """
4029
+ try:
4030
+ return run_calibration(entry.obj, p=req.p, a=req.a, coc=req.coc,
4031
+ lr=req.lr, premium=req.premium,
4032
+ basis=req.basis,
4033
+ expense_ratio=req.expense_ratio)
4034
+ except ValueError as exc:
4035
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
4036
+
4037
+
4038
+ @router.post("/objects/{oid}/pricing/allocate",
4039
+ response_model=models.PricingExhibitsResponse)
4040
+ def post_pricing_allocate(
4041
+ oid: str,
4042
+ req: models.PricingAllocateRequest,
4043
+ entry: CacheEntry = Depends(_locked_entry),
4044
+ ) -> dict:
4045
+ """Split one calibrated premium across the parts of the object.
4046
+
4047
+ The other half of the calibrate press's old bundle, and the opposite reading
4048
+ of it. Where ``pricing/calibrate`` prices each part as a distribution in its
4049
+ own right, this decomposes one premium: the book's total across its units,
4050
+ or an occurrence program's gross premium into its ceded and net halves, with
4051
+ the two footing to the whole exactly.
4052
+
4053
+ Its own press because it is its own cost. On an occurrence program the
4054
+ library builds the joint distribution of gross and ceded to read the kappa
4055
+ curve off, which is real work and is not what a reader asking for a
4056
+ calibration ordered.
4057
+
4058
+ See :func:`aggregate_api.pricing.run_natural_allocation`, whose longer name
4059
+ keeps ``bounds.run_allocation`` beside it meaning what it always has.
4060
+ """
4061
+ try:
4062
+ return run_natural_allocation(entry.obj, p=req.p, a=req.a, coc=req.coc,
4063
+ lr=req.lr, premium=req.premium,
4064
+ basis=req.basis,
4065
+ expense_ratio=req.expense_ratio)
4066
+ except ValueError as exc:
4067
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
4068
+
4069
+
4070
+ @router.post("/objects/{oid}/pricing/evaluate",
4071
+ response_model=models.PricingExhibitsResponse)
4072
+ def post_pricing_evaluate(
4073
+ oid: str,
4074
+ req: models.PricingEvaluateRequest,
4075
+ entry: CacheEntry = Depends(_locked_entry),
4076
+ ) -> dict:
4077
+ """The breakeven acceptability panel for a premium already held.
4078
+
4079
+ Pricing asks what an obligation is worth at a chosen capital level; this asks
4080
+ how much stress the position survives. Anchored at the level a calibration
4081
+ was struck at, the two close a round trip: evaluating a family's own implied
4082
+ premium recovers that family's calibrated parameters.
4083
+
4084
+ See :func:`aggregate_api.pricing.run_evaluation`.
4085
+ """
4086
+ try:
4087
+ return run_evaluation(entry.obj, premium=req.premium, basis=req.basis,
4088
+ p=req.p, a=req.a,
4089
+ expense_ratio=req.expense_ratio)
4090
+ except ValueError as exc:
4091
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
4092
+
4093
+
4094
+ @router.post("/objects/{oid}/ruin", response_model=models.RuinResponse)
4095
+ def post_ruin(
4096
+ oid: str,
4097
+ req: models.RuinRequest,
4098
+ entry: CacheEntry = Depends(_locked_entry),
4099
+ ) -> dict:
4100
+ """The Pr Ruin pane: sample surplus paths and the probability of ruin.
4101
+
4102
+ One POST answers with both halves the pane draws, the two-panel ``ruin``
4103
+ chart document and the ``ruin`` exhibit envelopes, because both move
4104
+ together under the debounced form and neither can travel the generic
4105
+ GETs: the chart needs options the chart route does not carry, and the
4106
+ exhibit registers on the ``RuinResult`` the request builds rather than
4107
+ on the cached object. A POST also never meets the chart cache, so the
4108
+ Sample action (``sample: true``) re-rolls honestly instead of replaying
4109
+ the first draw from under a seedless cache key.
4110
+
4111
+ See :func:`aggregate_api.pricing.run_ruin`, and ``dev/plan-pk-tab.md``.
4112
+ """
4113
+ try:
4114
+ return run_ruin(entry.obj, p=req.p, a=req.a, coc=req.coc, lr=req.lr,
4115
+ premium=req.premium, ruin_p=req.ruin_p, u=req.u,
4116
+ seed=req.seed, sample=req.sample, n_plot=req.n_plot,
4117
+ detail=req.detail)
4118
+ except ValueError as exc:
4119
+ raise HTTPException(status_code=400, detail=str(exc)) from exc