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.
- aggregate_api/__init__.py +41 -0
- aggregate_api/__main__.py +154 -0
- aggregate_api/app.py +206 -0
- aggregate_api/audit.py +395 -0
- aggregate_api/bounds.py +331 -0
- aggregate_api/cache.py +319 -0
- aggregate_api/capability.py +823 -0
- aggregate_api/completion.py +219 -0
- aggregate_api/config.py +363 -0
- aggregate_api/cors.py +61 -0
- aggregate_api/examples.py +620 -0
- aggregate_api/layer_pricing.py +840 -0
- aggregate_api/library.py +94 -0
- aggregate_api/library_notes.py +96 -0
- aggregate_api/models.py +1407 -0
- aggregate_api/net.py +281 -0
- aggregate_api/pnl.py +101 -0
- aggregate_api/pricing.py +778 -0
- aggregate_api/resources.py +257 -0
- aggregate_api/routes/__init__.py +8 -0
- aggregate_api/routes/decl.py +327 -0
- aggregate_api/routes/examples.py +82 -0
- aggregate_api/routes/meta.py +282 -0
- aggregate_api/routes/objects.py +4119 -0
- aggregate_api/routes/status.py +466 -0
- aggregate_api/serializers.py +565 -0
- aggregate_api/sessions.py +353 -0
- aggregate_api/static/aggregate-api-logo-512.png +0 -0
- aggregate_api/static/aggregate-api-logo.png +0 -0
- aggregate_api/static/aggregate-api-trim.png +0 -0
- aggregate_api/static/android-chrome-192x192.png +0 -0
- aggregate_api/static/android-chrome-512x512.png +0 -0
- aggregate_api/static/apple-touch-icon.png +0 -0
- aggregate_api/static/assets/bootstrap-icons-BeopsB42.woff +0 -0
- aggregate_api/static/assets/bootstrap-icons-mSm7cUeB.woff2 +0 -0
- aggregate_api/static/assets/bootstrap-ohb1VZ53.js +5 -0
- aggregate_api/static/assets/codemirror-h62DHGGa.js +14 -0
- aggregate_api/static/assets/csv-grid.worker-DKzHGXac.js +4 -0
- aggregate_api/static/assets/echarts-B7o9sc00.js +40 -0
- aggregate_api/static/assets/echarts-gl-DG1Uf6wE.js +4282 -0
- aggregate_api/static/assets/lite-CUlcD8p4.css +1 -0
- aggregate_api/static/assets/lite-Dd2TnT4M.js +1 -0
- aggregate_api/static/assets/main-Bxhxa55v.css +9 -0
- aggregate_api/static/assets/main-CmoEiPit.js +9 -0
- aggregate_api/static/assets/tables-BHCF7qIF.js +8 -0
- aggregate_api/static/assets/tables-CxvajLr7.css +1 -0
- aggregate_api/static/favicon-16x16.png +0 -0
- aggregate_api/static/favicon-32x32.png +0 -0
- aggregate_api/static/favicon.ico +0 -0
- aggregate_api/static/index.html +912 -0
- aggregate_api/static/lite.html +83 -0
- aggregate_api/static/logo.png +0 -0
- aggregate_api/static/site.webmanifest +14 -0
- aggregate_api/static/sw.js +78 -0
- aggregate_api/status.py +536 -0
- aggregate_api/status_page.html +546 -0
- aggregate_api/tables.py +316 -0
- aggregate_api-1.0.0.dist-info/METADATA +187 -0
- aggregate_api-1.0.0.dist-info/RECORD +63 -0
- aggregate_api-1.0.0.dist-info/WHEEL +5 -0
- aggregate_api-1.0.0.dist-info/entry_points.txt +2 -0
- aggregate_api-1.0.0.dist-info/licenses/LICENSE +28 -0
- 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
|