aggregate_api 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. aggregate_api/__init__.py +41 -0
  2. aggregate_api/__main__.py +154 -0
  3. aggregate_api/app.py +206 -0
  4. aggregate_api/audit.py +395 -0
  5. aggregate_api/bounds.py +331 -0
  6. aggregate_api/cache.py +319 -0
  7. aggregate_api/capability.py +823 -0
  8. aggregate_api/completion.py +219 -0
  9. aggregate_api/config.py +363 -0
  10. aggregate_api/cors.py +61 -0
  11. aggregate_api/examples.py +620 -0
  12. aggregate_api/layer_pricing.py +840 -0
  13. aggregate_api/library.py +94 -0
  14. aggregate_api/library_notes.py +96 -0
  15. aggregate_api/models.py +1407 -0
  16. aggregate_api/net.py +281 -0
  17. aggregate_api/pnl.py +101 -0
  18. aggregate_api/pricing.py +778 -0
  19. aggregate_api/resources.py +257 -0
  20. aggregate_api/routes/__init__.py +8 -0
  21. aggregate_api/routes/decl.py +327 -0
  22. aggregate_api/routes/examples.py +82 -0
  23. aggregate_api/routes/meta.py +282 -0
  24. aggregate_api/routes/objects.py +4119 -0
  25. aggregate_api/routes/status.py +466 -0
  26. aggregate_api/serializers.py +565 -0
  27. aggregate_api/sessions.py +353 -0
  28. aggregate_api/static/aggregate-api-logo-512.png +0 -0
  29. aggregate_api/static/aggregate-api-logo.png +0 -0
  30. aggregate_api/static/aggregate-api-trim.png +0 -0
  31. aggregate_api/static/android-chrome-192x192.png +0 -0
  32. aggregate_api/static/android-chrome-512x512.png +0 -0
  33. aggregate_api/static/apple-touch-icon.png +0 -0
  34. aggregate_api/static/assets/bootstrap-icons-BeopsB42.woff +0 -0
  35. aggregate_api/static/assets/bootstrap-icons-mSm7cUeB.woff2 +0 -0
  36. aggregate_api/static/assets/bootstrap-ohb1VZ53.js +5 -0
  37. aggregate_api/static/assets/codemirror-h62DHGGa.js +14 -0
  38. aggregate_api/static/assets/csv-grid.worker-DKzHGXac.js +4 -0
  39. aggregate_api/static/assets/echarts-B7o9sc00.js +40 -0
  40. aggregate_api/static/assets/echarts-gl-DG1Uf6wE.js +4282 -0
  41. aggregate_api/static/assets/lite-CUlcD8p4.css +1 -0
  42. aggregate_api/static/assets/lite-Dd2TnT4M.js +1 -0
  43. aggregate_api/static/assets/main-Bxhxa55v.css +9 -0
  44. aggregate_api/static/assets/main-CmoEiPit.js +9 -0
  45. aggregate_api/static/assets/tables-BHCF7qIF.js +8 -0
  46. aggregate_api/static/assets/tables-CxvajLr7.css +1 -0
  47. aggregate_api/static/favicon-16x16.png +0 -0
  48. aggregate_api/static/favicon-32x32.png +0 -0
  49. aggregate_api/static/favicon.ico +0 -0
  50. aggregate_api/static/index.html +912 -0
  51. aggregate_api/static/lite.html +83 -0
  52. aggregate_api/static/logo.png +0 -0
  53. aggregate_api/static/site.webmanifest +14 -0
  54. aggregate_api/static/sw.js +78 -0
  55. aggregate_api/status.py +536 -0
  56. aggregate_api/status_page.html +546 -0
  57. aggregate_api/tables.py +316 -0
  58. aggregate_api-1.0.0.dist-info/METADATA +187 -0
  59. aggregate_api-1.0.0.dist-info/RECORD +63 -0
  60. aggregate_api-1.0.0.dist-info/WHEEL +5 -0
  61. aggregate_api-1.0.0.dist-info/entry_points.txt +2 -0
  62. aggregate_api-1.0.0.dist-info/licenses/LICENSE +28 -0
  63. aggregate_api-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,620 @@
1
+ """The DecL example library, read from ``aggregate``'s recipe base.
2
+
3
+ Since ``aggregate`` 1.0.0a159 the three shipped libraries (``examples.agg``,
4
+ ``cookbook.agg``, ``actuarial-severity-curves.agg``) are one **``library.agg``**
5
+ with globally unique descriptive names and a namespaced ``tags{}`` clause on
6
+ every entry. The old single-letter filing prefixes (``E.LimitProfile``) are gone,
7
+ and with them the text-parsing this module used to do: there is no Contents block
8
+ to read, no ``<Letter>.<Name>`` convention to match, and no ``note{...}`` to
9
+ strip out of a folded statement.
10
+
11
+ Everything now comes off the loaded ``Underwriter``:
12
+
13
+ ``build.recipes``
14
+ One row per entry, indexed ``(kind, name)``. ``note`` is a boolean audit
15
+ flag, not text; ``tags`` is a tuple of slugs.
16
+ ``build.recipe(name)``
17
+ The :class:`aggregate.recipe.Recipe` itself, carrying the text: ``note``,
18
+ ``tags``, ``hints`` and ``decl``.
19
+
20
+ ``Recipe.as_read`` is the entry's DecL as its ``.agg`` file spells it, and
21
+ ``Recipe.seq`` is the position it was read at. Both arrived in ``aggregate``
22
+ 1.0.0a320 and both are also columns on ``recipes``.
23
+
24
+ **One flat list, in the file's own order.** ``library.agg`` is written as a
25
+ reading order and the entries inside a part build on one another. Through a121
26
+ none of that reached the app: the frame arrives alphabetical, because
27
+ ``Underwriter._recipes_frame`` ends in ``.sort_index()``, and this module then
28
+ grouped on a tag namespace, ordered the groups by a hand kept tuple and sorted
29
+ again inside each one. Reading order is meaning, and under the purist ruling
30
+ (author, 2026-08-10) the library owns meaning, so the app sorts on ``seq`` and
31
+ stops there. The ``# ---`` banners in ``library.agg`` are comments and stay
32
+ comments: nothing in ``aggregate`` parses them, so the list carries no headings
33
+ at all.
34
+
35
+ **Pills, not groups.** Every item carries a render-ready ``pills`` list, ordered
36
+ kind, then ``topic:``, then ``role:``, so the SPA draws it as given and the
37
+ namespace-to-color mapping has exactly one authority. ``tags`` stays as full
38
+ slugs, for the search haystack and for anyone reading the api directly, and
39
+ ``kind`` keeps a field of its own, because the recipe's type is not a tag.
40
+
41
+ Cached per process; call ``load_examples.cache_clear()`` to pick up an edited
42
+ library without a restart.
43
+
44
+ Returned shape mirrors :class:`aggregate_api.models.ExamplesResponse`::
45
+
46
+ {
47
+ "items": [
48
+ {"name": "ExposureLimitProfile", "kind": "agg",
49
+ "note": "A premium by limit by loss ratio profile, ...",
50
+ "decl": "agg ExposureLimitProfile\\n [10_000 ...",
51
+ "tags": ["topic:aggregate", "role:hero", "role:advanced"],
52
+ "pills": [{"ns": "kind", "value": "agg"},
53
+ {"ns": "topic", "value": "aggregate"},
54
+ {"ns": "role", "value": "hero"},
55
+ {"ns": "role", "value": "advanced"}]},
56
+ ...
57
+ ],
58
+ "facets": {
59
+ "kind": [{"value": "agg", "count": 94}, ...],
60
+ "topic": [{"value": "aggregate", "count": 27}, ...],
61
+ "role": [{"value": "intro", "count": 32}, ...]
62
+ }
63
+ }
64
+ """
65
+
66
+ from __future__ import annotations
67
+
68
+ import logging
69
+ import re
70
+ from functools import lru_cache
71
+ from typing import Sequence
72
+
73
+ from aggregate.decl_writer import format_program, spec_to_decl
74
+
75
+ from .library import get_underwriter
76
+
77
+ logger = logging.getLogger(__name__)
78
+
79
+ # The three pill namespaces, in the order a row draws them. ``kind`` leads
80
+ # because it is the recipe's own type rather than a tag, and it is read off the
81
+ # frame index rather than out of ``tags``.
82
+ #
83
+ # An ordering table stood here through a121, three of them in fact
84
+ # (``_TOPIC_ORDER``, ``_ROLE_ORDER``, ``_KIND_TITLES``) plus ``_title`` and
85
+ # ``_sort_key`` to read them, and they are gone with the grouped view. They were
86
+ # the app deciding what the library means: the topic tuple had entries in it
87
+ # that no library entry claimed (``bounds``, ``ruin``) and was missing two that
88
+ # existed (``picks``, ``tweedie``), so the two real ones fell into an
89
+ # alphabetical tail. ``seq`` answers the whole question and the library owns it.
90
+ PILL_NAMESPACES = ("kind", "topic", "role")
91
+
92
+
93
+ # The two filing clauses a library entry loses on the way to the editor, as the
94
+ # grammar's own terminals rather than as a guess at them. ``decl.lark`` lines 830
95
+ # to 832 define the trailer as ``/note\{[^}]*\}/``, ``/hints\{[^}]*\}/`` and
96
+ # ``/tags\{[^}]*\}/``: the body cannot contain a closing brace, so ``[^}]*`` is
97
+ # exact here and not an approximation.
98
+ #
99
+ # **This mirrors those three terminals**, the way ``web/src/decl-keywords.json``
100
+ # is documented as mirroring ``parser_errors._TERMINAL_LABELS``, which puts it
101
+ # under agreement 6 of the oversight charter: a grammar change to the trailer is
102
+ # checked against this constant.
103
+ #
104
+ # ``\s*`` before the clause is what does the tidying, and it is why no second
105
+ # pass is needed. A clause on its own line takes the newline and the indent in
106
+ # front of it, so the line goes with it; a clause trailing one that also carries
107
+ # ``hints{}`` takes the single space in front of it, so no double space is left
108
+ # behind. Everything else in the line, including any alignment the file wrote
109
+ # inside a bracketed list, is untouched.
110
+ _FILING_CLAUSES = re.compile(r"\s*(?:note|tags)\{[^}]*\}")
111
+
112
+
113
+ def _strip_filing_clauses(text: str) -> str:
114
+ """Return `text` without its ``note{}`` and ``tags{}`` clauses.
115
+
116
+ Parameters
117
+ ----------
118
+ text : str
119
+ A DecL program, as its file spells it.
120
+
121
+ Returns
122
+ -------
123
+ str
124
+
125
+ Notes
126
+ -----
127
+ **``hints{}`` stays, and that is not negotiable.** Sixteen library entries
128
+ pin a grid, and a reference to one of them means one fixed thing only while
129
+ the clause travels with the program. A program without it rebuilds on
130
+ whatever grid the next build chooses.
131
+
132
+ **The note and the tags go because they are filing metadata in front of a
133
+ reader.** Someone watching the app should see the program, not the program
134
+ plus its catalog card, and the prose belongs on the status strip where prose
135
+ goes. The strip is fed from the menu item's own ``note`` and ``tags`` fields
136
+ instead, which is a different channel and one that does not require the
137
+ clauses to survive a build.
138
+
139
+ This deliberately differs from what happens when a reader types a ``note{}``
140
+ themselves, which is kept and shown. The asymmetry is accepted (author,
141
+ 2026-08-24): one is the library filing an entry, the other is a reader saying
142
+ something about their own program.
143
+
144
+ One pass, because ``re.sub`` scans the original string: two clauses in a row
145
+ are both matched against the text as it stands, so the second is not left
146
+ holding whitespace the first exposed.
147
+ """
148
+ return _FILING_CLAUSES.sub("", text).strip()
149
+
150
+ # The ``source`` marking an entry the api itself built. Every object built
151
+ # through ``POST /v1/objects`` is added to the underwriter's recipe base, so
152
+ # without this filter a user's own programs would appear in the Examples menu,
153
+ # untagged and unnamed.
154
+ _SESSION_SOURCE = "session"
155
+
156
+
157
+ def _library_only(frame):
158
+ """Drop session-built entries, leaving the loaded library.
159
+
160
+ Parameters
161
+ ----------
162
+ frame : pandas.DataFrame
163
+ ``Underwriter.recipes``.
164
+
165
+ Returns
166
+ -------
167
+ pandas.DataFrame
168
+ The same frame without rows whose ``source`` is ``'session'``.
169
+
170
+ Notes
171
+ -----
172
+ **This is why the menu reads the process base and not a session fork.**
173
+ Every build route takes a fork of its own since a110, and it would look
174
+ tidier for the menu to do the same. It would also be wrong: a user who
175
+ overwrites a library name marks it ``source='session'`` in *their* fork, so
176
+ this filter would then drop the library's entry from their menu, and the
177
+ entry they would be looking for would simply be missing. The menu is a view
178
+ of the library, which is the same for everybody; the fork is a view of what
179
+ one user has declared.
180
+ """
181
+ if "source" not in frame.columns:
182
+ return frame
183
+ return frame[frame["source"] != _SESSION_SOURCE]
184
+
185
+
186
+ def _decl_of(kind: str, name: str, recipe) -> str:
187
+ """The runnable declaration for an entry, as its own file spells it.
188
+
189
+ ``Recipe.as_read`` (``aggregate`` 1.0.0a320) is the entry's DecL exactly as
190
+ it stands in ``library.agg``: laid out over several lines, indented as
191
+ written, comments and the terminating ``;`` removed, the trailer kept. It is
192
+ what this serves whenever it is there, which is every library entry, **less
193
+ its ``note{}`` and ``tags{}``**. It is empty only for a session build, which
194
+ never had a file to come from, and that is what the writer pair below is
195
+ still here for.
196
+
197
+ **Why the filing clauses come off**, since a119 and a120 deliberately put
198
+ them on and the reasoning for that is recorded below. Both are true at once:
199
+ an object carries the note and tags its own program declares, so a stripped
200
+ entry builds an object with neither, *and* a reader watching the app should
201
+ see the program rather than the program plus its catalog card. The way out
202
+ is not to undo a119, it is to feed the status strip on a different channel.
203
+ The menu item already carries ``note`` and ``tags`` as fields, and the SPA
204
+ now reads them from there when the object declares none of its own. So the
205
+ clauses can go and nothing on screen is lost.
206
+
207
+ ``hints{}`` is untouched. See :func:`_strip_filing_clauses`.
208
+
209
+ **Why the file's own text rather than the canonical render.**
210
+ ``spec_to_decl`` then ``format_program`` round trips through the spec, and
211
+ the parser evaluates or expands several spellings on the way in and keeps
212
+ only the result. So through a121 the editor received ``ph 2/3`` as
213
+ ``ph 0.6666666666666666``, ``ceded to tower [0 25 50 75 100 125]`` as five
214
+ and-chained layers, ``dsev [1:6]`` as ``dsev [1 2 3 4 5 6]`` and
215
+ ``sev (100 / exp(1.5**2/2)) * lognorm 1.5`` as
216
+ ``sev 32.465246735834974 * lognorm 1.5``. Those spellings are what several
217
+ of the entries exist to teach, and an example that teaches a spelling has to
218
+ arrive carrying it. Teaching the reader to write ``ph 2/3`` and then handing
219
+ them the float is the example failing at its one job.
220
+
221
+ Making the writer invert them is real work and a separate decision, tracked
222
+ upstream as ``[Unparser-Reference-Gaps]``; it is worth doing for
223
+ ``Recipe.decl``, the ``.agg`` export and the cookbook pages. Nothing here
224
+ waits on it, because ``as_read`` never went through the spec at all.
225
+
226
+ **The fallback keeps its trailer, and that is why the pair is spelled out.**
227
+ ``Recipe.decl`` is this pair at the default ``trailer=False``, which drops the
228
+ ``note{}`` and ``tags{}`` that ``spec_to_decl`` just wrote. Through a118 that
229
+ is what the menu served, so every library entry arrived in the editor stripped
230
+ of both, built an object carrying neither, and had nothing to show on the
231
+ status strip. Only a hand-typed trailer ever reached it. ``hints{}`` survives
232
+ either way and matters as much: a program without it rebuilds on a different
233
+ grid from the one the entry was written for.
234
+
235
+ Placement is the library's business, not the app's, which is the other reason
236
+ to route through the writer. DecL binds a trailer to the declaration it
237
+ follows, so it goes last on an ``agg`` and directly after the name on a
238
+ ``port``, before the first unit; writing it at the end of a portfolio instead
239
+ would bind it to the last unit, and would do so silently, because the program
240
+ still parses and the object still builds.
241
+
242
+ Parameters
243
+ ----------
244
+ kind : str
245
+ The recipe's kind, as the writer needs it.
246
+ name : str
247
+ The entry's name, likewise.
248
+ recipe : Any
249
+ A resolved :class:`aggregate.recipe.Recipe`.
250
+
251
+ Returns
252
+ -------
253
+ str
254
+
255
+ Notes
256
+ -----
257
+ The writer can **refuse**, and one shipped entry makes it. A composite
258
+ distortion (``dist X minimum dist.A dist.B``) holds constructed
259
+ ``Distortion`` objects in its spec rather than names, so ``spec_to_decl``
260
+ raises ``NotImplementedError`` and there is nothing canonical to render.
261
+ Falling back to the stored program keeps the entry usable and loses only the
262
+ canonical layout; a stored trailer is already where the library put it. Both
263
+ of those paths are now reached only by a session build, since a library
264
+ entry answers with ``as_read`` before either runs.
265
+ """
266
+ as_read = (getattr(recipe, "as_read", "") or "").strip()
267
+ if as_read:
268
+ return _strip_filing_clauses(as_read)
269
+ text = ""
270
+ try:
271
+ text = spec_to_decl(getattr(recipe, "spec", None), kind, name) or ""
272
+ except Exception: # noqa: BLE001 (one un-round-trippable entry must not 500)
273
+ logger.debug("spec_to_decl declined %s.%s; using the stored program", kind, name)
274
+ if not text.strip():
275
+ text = getattr(recipe, "program", "") or ""
276
+ try:
277
+ text = format_program(text, fmt="text", trailer=True)
278
+ except Exception: # noqa: BLE001 -- keep the unwrapped text
279
+ pass
280
+ return text.strip()
281
+
282
+
283
+ def _entry(kind: str, name: str, recipe) -> dict:
284
+ """One example item from a resolved :class:`Recipe`.
285
+
286
+ ``note`` is **optional**: most library entries carry one and it is preferred,
287
+ but nothing guarantees it, so a missing note serializes as ``None`` and every
288
+ consumer treats that as ordinary rather than as a defect.
289
+
290
+ ``note`` and ``tags`` are served **only** as fields. They were also inside
291
+ ``decl`` from a119 to a125, on the argument that the clauses are what make a
292
+ built object carry them so the status strip can print them afterward. That
293
+ was true and is no longer the arrangement: the clauses are filing metadata,
294
+ and a reader watching the app should see the program rather than the program
295
+ plus its catalog card, so they are stripped and the strip reads these fields
296
+ instead. See :func:`_strip_filing_clauses`.
297
+
298
+ So these two fields are now load bearing rather than a convenience: they are
299
+ the only channel by which an entry's prose reaches the page. Nothing is lost
300
+ from the payload, and no consumer of the api loses anything either; only
301
+ ``decl`` changed.
302
+ """
303
+ note = getattr(recipe, "note", "") or ""
304
+ tags = list(getattr(recipe, "tags", ()) or ())
305
+ return {
306
+ "name": name,
307
+ "kind": kind,
308
+ "tags": tags,
309
+ "note": note.strip() or None,
310
+ "decl": _decl_of(kind, name, recipe),
311
+ "pills": _pills(kind, tags),
312
+ }
313
+
314
+
315
+ def _namespace_values(tags, namespace: str) -> list[str]:
316
+ """The bare values of `tags` carrying `namespace`, e.g. ``topic:severity``."""
317
+ prefix = f"{namespace}:"
318
+ return [t[len(prefix):] for t in tags if t.startswith(prefix)]
319
+
320
+
321
+ def _pills(kind: str, tags: Sequence[str]) -> list[dict]:
322
+ """Render-ready pills for one entry: kind first, then topics, then roles.
323
+
324
+ Parameters
325
+ ----------
326
+ kind : str
327
+ The recipe's type, straight off the frame index.
328
+ tags : sequence of str
329
+ The entry's tags as full slugs, in the order its file declares them.
330
+
331
+ Returns
332
+ -------
333
+ list of dict
334
+ ``{"ns": ..., "value": ...}`` per pill, ``ns`` one of
335
+ :data:`PILL_NAMESPACES`.
336
+
337
+ Notes
338
+ -----
339
+ Built here rather than in the SPA so the namespace-to-color mapping has one
340
+ authority, and so a client that renders the row does not have to know how a
341
+ slug splits.
342
+
343
+ Within a namespace the file's own tag order is kept rather than sorted, on
344
+ the same reasoning as the list order itself: the entry declares its tags in
345
+ an order and nothing in the app knows better.
346
+
347
+ **A value can repeat across namespaces, and both pills are drawn.**
348
+ ``topic:pnl`` sits on all eight ``pnl`` entries and ``topic:distortion`` on
349
+ all six ``distortion`` ones, so those fourteen rows spell the same word
350
+ twice in two colors. The library owns its vocabulary, so the app serves what
351
+ is there and the redundant tags come out of ``library.agg`` upstream (author
352
+ ruling, 2026-08-24). Suppressing one here would put the app back in the
353
+ business of deciding what the library means, and would leave a topic filter
354
+ selecting rows that show no matching topic pill.
355
+ """
356
+ pills = [{"ns": "kind", "value": kind}]
357
+ for namespace in ("topic", "role"):
358
+ pills += [{"ns": namespace, "value": value}
359
+ for value in _namespace_values(tags, namespace)]
360
+ return pills
361
+
362
+
363
+ def _facets(items: list[dict]) -> dict[str, list[dict]]:
364
+ """Count each pill value per namespace, in order of first appearance.
365
+
366
+ Parameters
367
+ ----------
368
+ items : list of dict
369
+ Entries in file order, each carrying ``pills``.
370
+
371
+ Returns
372
+ -------
373
+ dict
374
+ One list of ``{"value": ..., "count": ...}`` per namespace in
375
+ :data:`PILL_NAMESPACES`, every namespace present even when empty.
376
+
377
+ Notes
378
+ -----
379
+ First appearance, not alphabetical and not by count, so the filter bar reads
380
+ in the library's order too: ``severity`` stands where the severity entries
381
+ start rather than between ``reinsurance`` and ``tweedie``. A plain ``dict``
382
+ is the whole mechanism, since it keeps insertion order.
383
+
384
+ Counted over the items actually returned, so a filtered payload is self
385
+ describing: its counts always add up to what the caller can see.
386
+ """
387
+ counts: dict[str, dict[str, int]] = {ns: {} for ns in PILL_NAMESPACES}
388
+ for item in items:
389
+ for pill in item["pills"]:
390
+ bucket = counts[pill["ns"]]
391
+ bucket[pill["value"]] = bucket.get(pill["value"], 0) + 1
392
+ return {
393
+ namespace: [{"value": value, "count": count}
394
+ for value, count in bucket.items()]
395
+ for namespace, bucket in counts.items()
396
+ }
397
+
398
+
399
+ @lru_cache(maxsize=1)
400
+ def load_examples() -> dict:
401
+ """Return the whole example library, one flat list in the file's own order.
402
+
403
+ Returns
404
+ -------
405
+ dict
406
+ Matches :class:`aggregate_api.models.ExamplesResponse`.
407
+
408
+ Raises
409
+ ------
410
+ RuntimeError
411
+ If ``recipes`` carries no ``seq`` column, which means an ``aggregate``
412
+ older than 1.0.0a320 and no reading order to serve.
413
+
414
+ Notes
415
+ -----
416
+ ``sort_values('seq')`` is the reading order, and asking for it is the whole
417
+ of what this does about order. The frame's own default is alphabetical by
418
+ ``(kind, name)`` and stays that way, which is what a person reading it at a
419
+ prompt wants; ``seq`` is the column that says where the statement stood in
420
+ its ``.agg`` file.
421
+
422
+ **Each entry appears exactly once.** The grouped payload emitted one row per
423
+ ``topic:`` tag, 182 rows for 151 entries, purely to feed a view that no
424
+ longer exists, and the SPA then deduplicated them again to build its search
425
+ index.
426
+
427
+ Cached for the process. Resolving the base is cheap, since nothing is built,
428
+ but it walks all of it, so it is not worth repeating per request. Filtering
429
+ runs against this cached payload rather than against the frame, which is why
430
+ :func:`filter_examples` is a separate call.
431
+ """
432
+ uw = get_underwriter()
433
+ frame = _library_only(uw.recipes)
434
+ if "seq" not in frame.columns:
435
+ raise RuntimeError(
436
+ "Underwriter.recipes carries no 'seq' column, so there is no reading "
437
+ "order to serve; aggregate 1.0.0a320 or newer is required"
438
+ )
439
+
440
+ items = []
441
+ for kind, name in frame.sort_values("seq").index:
442
+ try:
443
+ recipe = uw.recipe(name, kind)
444
+ except Exception: # noqa: BLE001 (one bad entry must not blank the menu)
445
+ logger.warning("skipping library entry %s.%s: cannot resolve", kind, name)
446
+ continue
447
+ items.append(_entry(kind, name, recipe))
448
+ return {"items": items, "facets": _facets(items)}
449
+
450
+
451
+ def filter_examples(payload: dict, kind=None, topic=None, role=None) -> dict:
452
+ """Narrow a payload to the entries matching every namespace given.
453
+
454
+ Parameters
455
+ ----------
456
+ payload : dict
457
+ The full payload from :func:`load_examples`.
458
+ kind, topic, role : sequence of str, optional
459
+ Pill values to keep, per namespace. An empty or absent sequence means
460
+ that namespace does not filter.
461
+
462
+ Returns
463
+ -------
464
+ dict
465
+ The same shape, with ``items`` narrowed and ``facets`` recounted over
466
+ what survived. The unfiltered payload is returned unchanged when nothing
467
+ was asked for.
468
+
469
+ Notes
470
+ -----
471
+ OR within a namespace, AND across them, which is what makes "advanced
472
+ reinsurance" and "intro or intermediate" both expressible.
473
+
474
+ **The SPA does not use this.** The whole payload is 151 entries fetched
475
+ once, and filtering in the browser is instant and keeps the pills and the
476
+ list in step with no round trip. This exists so a notebook user can say
477
+ ``GET /v1/examples?role=intro``.
478
+
479
+ Nothing is mutated: `payload` is the ``lru_cache``d object every other
480
+ caller holds, and the items inside the returned list are shared with it, so
481
+ a caller must treat the result as read-only too.
482
+ """
483
+ wanted = {
484
+ namespace: set(values)
485
+ for namespace, values in (("kind", kind), ("topic", topic), ("role", role))
486
+ if values
487
+ }
488
+ if not wanted:
489
+ return payload
490
+ items = [
491
+ item for item in payload["items"]
492
+ if all(
493
+ any(p["ns"] == namespace and p["value"] in values for p in item["pills"])
494
+ for namespace, values in wanted.items()
495
+ )
496
+ ]
497
+ return {"items": items, "facets": _facets(items)}
498
+
499
+
500
+ @lru_cache(maxsize=1)
501
+ def load_heroes() -> dict:
502
+ """Return the landing-page hero entries, those tagged ``role:hero``.
503
+
504
+ Returns
505
+ -------
506
+ dict
507
+ ``{"items": [...]}`` in the same item shape as :func:`load_examples`,
508
+ in the library's reading order.
509
+
510
+ Notes
511
+ -----
512
+ File order, like the full listing, so the name sort that stood here through
513
+ a121 is gone with the rest of the app's ordering machinery. The order is
514
+ read off each resolved ``Recipe.seq`` rather than by sorting the frame,
515
+ because ``discover``'s lightweight directory path returns a name-indexed
516
+ frame carrying ``program`` and nothing else: ``seq`` is a column on
517
+ ``recipes``, not on what ``discover`` hands back.
518
+
519
+ ``discover(tags=...)`` is the library's own selection verb and the tag is
520
+ **plural**. Its lightweight directory path filters the recipe frame without
521
+ building anything, so this is a frame filter, not eight FFTs. (The singular
522
+ ``tag=`` used to bind into ``**kwargs`` and silently return the whole base;
523
+ ``aggregate`` 1.0.0a173 made that a ``TypeError``.)
524
+
525
+ The result is intersected with the library names so a session-built program
526
+ that happens to carry ``tags{role:hero}`` cannot reach the landing gallery.
527
+ """
528
+ uw = get_underwriter()
529
+ library = set(_library_only(uw.recipes).index.get_level_values("name"))
530
+ found = uw.discover(tags="role:hero")
531
+ ranked = []
532
+ for name in found.index:
533
+ if name not in library:
534
+ continue
535
+ try:
536
+ recipe = uw.recipe(name)
537
+ except Exception: # noqa: BLE001
538
+ logger.warning("skipping hero %s: cannot resolve", name)
539
+ continue
540
+ ranked.append((getattr(recipe, "seq", 0), _entry(recipe.kind, name, recipe)))
541
+ ranked.sort(key=lambda pair: pair[0])
542
+ return {"items": [item for _, item in ranked]}
543
+
544
+
545
+ # Points in a hero sparkline. Enough to show a shape at thumbnail size and
546
+ # nothing like enough to read a number off, which is the point: the card is an
547
+ # invitation, not an exhibit.
548
+ SPARKLINE_POINTS = 48
549
+
550
+
551
+ def _sparkline(obj) -> list[float] | None:
552
+ """A normalized density silhouette for a built object, or ``None``.
553
+
554
+ Returns
555
+ -------
556
+ list of float or None
557
+ ``SPARKLINE_POINTS`` values scaled so the peak is 1, cropped to the
558
+ q(0.999) window so a heavy tail does not flatten the shape into a
559
+ spike at the origin. ``None`` when the object carries no usable
560
+ density.
561
+
562
+ Notes
563
+ -----
564
+ Bins by **summing** into equal-width buckets rather than sampling every
565
+ n-th point. On a spiky discrete support, sampling would land between the
566
+ atoms and return a row of zeros, so the thumbnail for a dice book would be
567
+ a flat line.
568
+ """
569
+ import numpy as np
570
+
571
+ frame = getattr(obj, "density_df", None)
572
+ if frame is None or "p_total" not in getattr(frame, "columns", ()):
573
+ return None
574
+ mass = np.asarray(frame["p_total"], dtype=float)
575
+ if mass.size == 0 or not np.isfinite(mass).any():
576
+ return None
577
+ # Crop to the visible body, mirroring the exhibit's density window.
578
+ cdf = np.cumsum(np.nan_to_num(mass))
579
+ hi = int(np.searchsorted(cdf, 0.999)) + 1
580
+ mass = np.nan_to_num(mass[:max(hi, SPARKLINE_POINTS)])
581
+ if mass.size < SPARKLINE_POINTS:
582
+ mass = np.pad(mass, (0, SPARKLINE_POINTS - mass.size))
583
+ # Sum into equal buckets; a ragged tail bucket is fine at this resolution.
584
+ edges = np.linspace(0, mass.size, SPARKLINE_POINTS + 1).astype(int)
585
+ binned = np.array([mass[a:b].sum() for a, b in zip(edges[:-1], edges[1:])])
586
+ peak = binned.max()
587
+ if not np.isfinite(peak) or peak <= 0:
588
+ return None
589
+ return [round(float(v), 5) for v in binned / peak]
590
+
591
+
592
+ @lru_cache(maxsize=1)
593
+ def load_hero_sparklines() -> dict:
594
+ """Return ``{name: [floats]}`` silhouettes for the hero gallery.
595
+
596
+ Notes
597
+ -----
598
+ **This builds every hero**, which is why it is a separate call rather than
599
+ a field on :func:`load_heroes`. One of them (``CatXOLTower``) carries
600
+ ``hints{log2=16}``, so a cold call costs seconds. The SPA therefore asks
601
+ for it *after* first paint and lets the cards sit on their placeholder art
602
+ until it lands: nothing on the landing path may wait on this.
603
+
604
+ Cached for the process, so only the first caller pays. A hero that fails to
605
+ build is skipped rather than raising, since a missing thumbnail is a
606
+ cosmetic loss and a 500 here would be a real one.
607
+ """
608
+ from aggregate import build as _build
609
+
610
+ out: dict[str, list[float]] = {}
611
+ for item in load_heroes()["items"]:
612
+ try:
613
+ obj = _build(item["decl"])
614
+ spark = _sparkline(obj)
615
+ except Exception: # noqa: BLE001
616
+ logger.warning("no sparkline for hero %s", item["name"])
617
+ continue
618
+ if spark is not None:
619
+ out[item["name"]] = spark
620
+ return {"sparklines": out}