File size: 22,291 Bytes
092334a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c14ceee
092334a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c9d432b
 
092334a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c9d432b
 
 
 
 
092334a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
618de96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
092334a
 
618de96
 
 
 
 
 
 
 
 
092334a
 
 
 
 
 
 
da5297e
 
 
 
 
 
 
 
 
 
092334a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c9d432b
 
 
 
 
 
 
 
 
 
 
 
092334a
 
c9d432b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
092334a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
"""pages.py β€” Y1: the page-data envelope, and the ONE place its rules live (W2-2).

THE POINT OF THIS FILE IS WAVE 3. Collections and Procurement must land by adding a BUILDER, not
by inventing a shape β€” so every rule the envelope has (the closed `fmt` enum, the drill grammar,
the no-silent-caps contract, how BU scope is resolved) lives here as a constructor a builder calls,
never as a convention a builder is expected to remember. A rule that exists only in a docstring is
a rule the second page gets wrong.

WHAT A BUILDER OWES, and nothing more:
    metrics(team_id, granularity, today)   -> a dict of plain data, SCOPE-SHAPED ONLY
    blocks(metrics, team_id, granularity)  -> [block, ...] built with the constructors below
    TITLE / SUBTITLE / MODULE / CONTROLS
`build_envelope` does the rest: it resolves the BU, caches the metrics on the SCOPE key, assembles
the per-request half, and returns the envelope.

β›” THE CACHE LINE, WHICH IS WHERE WAVE 1 SHIPPED A CROSS-USER LEAK. What may be cached on a
`(page, team_id, doc_mode, day)` key is the BUILDER'S METRICS β€” the answer to a question two users
with the same scope are asking identically. What may NEVER be cached there is anything with a
per-USER stratum, and on this envelope that is `controls` (its `bu.options` come from
`perms.allowed_bu_labels(user)`, i.e. from the user RECORD). Wave 1's defect was exactly this shape
one layer down: a whole payload cached on a scope key while carrying per-user fields and overlay
cells. It survived 129 green checks because every fixture user had a distinct scope pair. So the
split is structural here, not remembered: `metrics()` cannot see the session at all β€” it takes
`team_id`, and `verify_api.py` asserts the cached object holds nothing user-shaped.

⚠ THE CLIENT FORMATS. The server never sends a pre-formatted number, so one formatter change fixes
every page β€” and `fmt` is a CLOSED enum for the same reason. Two vocabularies exist and they are
not interchangeable: `FMTS` for kpi/table/validation, and viz `FieldType` for a chart block's
`fields[]` (those objects are fed straight into `chartData`). `money` vs `currency` is the trap.
"""
import time

# `deps` FIRST, deliberately: it is what puts `platform/` on `sys.path`, so importing it
# ahead of any `core.*` makes this module importable in whatever order a caller reaches it.
from deps import Session, err, perms          # noqa: I001

import core.context as ctxlib                 # noqa: E402

# ── the two vocabularies ─────────────────────────────────────────────────────────────────────────
#: The Y1 client-format enum for kpi / table / validation values. CLOSED on purpose: a page that
#: needs a new one adds it HERE and to the client's `fmt` at the same time, which is the property
#: that keeps one formatter authoritative. A table column with NO `fmt` renders verbatim β€” that is
#: how a text column is expressed without opening the enum.
FMTS = ("money", "num", "pct", "int", "date")

#: The viz `FieldType` values a chart block's `fields[]` may use β€” a DIFFERENT vocabulary, because
#: `chartData(spec, rows, fieldByKey)` consumes those objects directly. `currency`, not `money`.
VIZ_TYPES = ("text", "status", "currency", "int", "pct", "date")

#: `chartData`'s own display cap (`MAX_BUCKETS`). A payload must fit it at the SOURCE: the engine
#: truncates past 12 and, on a date axis, sorts ASCENDING first β€” so a 13-month series loses the
#: CURRENT month, the one anybody actually looks at. Mirrored here so a builder can size its own
#: series without importing anything from the client tree.
MAX_BUCKETS = 12

#: Drill kinds the client is expected to understand. `decomp` names a WINDOW + a scope; the others
#: name an ENTITY. ⚠ THIS WAVE THEY ARE DECLARATIONS β€” there is no resolver route (Β§5 Y1 amendment
#: 4). Each descriptor is COMPLETE, so wave 3 adds the resolver with no shape change, and Y6's
#: panel must say it is thin rather than look finished.
DRILL_KINDS = ("decomp", "customer", "sku", "rep", "bu")


def _fmt_ok(fmt, allowed, where):
    if fmt is not None and fmt not in allowed:
        # A wrong `fmt` renders β€” wrongly β€” and nothing complains. Refusing it at construction is
        # the only moment anybody finds out, so this raises rather than coercing.
        raise ValueError(f"{where}: fmt {fmt!r} is not one of {allowed}")
    return fmt


# ── block constructors ───────────────────────────────────────────────────────────────────────────
def kpi(key, label, value, fmt="money", delta=None, delta_fmt=None, delta_label=None,
        delta_dir=None, note=None, drill=None):
    """One KPI card. `delta` is a NUMBER or None β€” never a string.

    THE ZERO-REVENUE RULE, ported from `app.py:2846`: a period with no orders yet must not show an
    alarming βˆ’100%. It sends NO number (`delta=None`) plus a `delta_label` and `delta_dir='off'`,
    so no formatter is involved and the card states the situation instead of computing a ratio
    against nothing.

    THE BIG-RATIO RULE IS THE CLIENT'S: a tiny LY base makes `+14,975%` read as noise, and
    Streamlit rewrites it per-card as `+150Γ— LY`. That belongs in ONE formatter (`fmt.pct` renders
    |v| β‰₯ 999 as a multiple), so the server just sends the number.
    """
    out = {"key": key, "label": label, "value": value,
           "fmt": _fmt_ok(fmt, FMTS, f"kpi {key}")}
    if delta is not None:
        out["delta"] = delta
        out["delta_fmt"] = _fmt_ok(delta_fmt or "pct", FMTS, f"kpi {key} delta")
    if delta_label:
        out["delta_label"] = delta_label
    if delta_dir:
        out["delta_dir"] = delta_dir            # up | down | flat | off β€” a TONE hint, not the sign
    if note:
        out["note"] = note
    if drill:
        out["drill"] = drill
    return out


def kpis(key, items):
    return {"type": "kpis", "key": key, "items": list(items)}


def section(key, label, note=None):
    out = {"type": "section", "key": key, "label": label}
    if note:
        out["note"] = note
    return out


def column(key, label, fmt=None, align=None):
    """A table column. `fmt` omitted β‡’ render verbatim (that is how text columns work)."""
    out = {"key": key, "label": label}
    if fmt:
        out["fmt"] = _fmt_ok(fmt, FMTS, f"column {key}")
    if align:
        out["align"] = align
    elif fmt in ("money", "num", "pct", "int"):
        out["align"] = "right"                  # numbers align right by default, everywhere
    return out


def table(key, columns, rows, total=None, drill=None, download=None, empty=None):
    """A table block. `shown`/`total` ALWAYS ship β€” `shown == total` still sends both.

    Owner rule [[no-unverifiable-aggregates]]: a silent `[:N]` cap is a defect. `total` defaults to
    `len(rows)` so a builder that forgot to truncate cannot accidentally claim it did not.
    """
    rows = list(rows)
    out = {"type": "table", "key": key, "columns": list(columns), "rows": rows,
           "shown": len(rows), "total": int(total if total is not None else len(rows))}
    if drill:
        out["drill"] = drill
    if download:
        out["download"] = download
    if empty:
        out["empty"] = empty
    return out


def chart(key, spec, series, fields, rows, x_order=None, drill=None, total=None,
          delta_key=None):
    """A chart block: ONE x, N SERIES.

    β›” WHY `series[]` AND NOT `ChartSpec.splitBy`. The viz engine has no series dimension:
    `chartData()` buckets on `x` only and `DashboardView` draws one flat `buckets[]`. `splitBy` is
    persisted, validated and offered in the chart editor but never reaches the arithmetic or the
    renderer, so a `splitBy` chart draws identically to one without it. A YoY comparison is
    therefore N `chartData` calls over the SAME wide rows, one per series `y` β€” which also means
    nothing about `chartData` changes and `verify_charts.py`'s verdict stays put, which W2-5's
    pure-move proof requires.

    β›” WHY `x_order`. The engine sorts buckets by VALUE unless the x field is date-family β€” a trend
    would render as a revenue-ordered sawtooth. And date-family is not an escape: it buckets to
    `YYYY-MM`, so a weekly axis would collapse into months. So the SERVER declares the chronology
    and the client re-orders the buckets it got back.
    """
    for f in fields:
        if f.get("type") not in VIZ_TYPES:
            raise ValueError(f"chart {key}: field {f.get('key')!r} type {f.get('type')!r} is not a "
                             f"viz FieldType {VIZ_TYPES} β€” `money` is the kpi/table vocabulary")
    rows = list(rows)
    out = {"type": "chart", "key": key, "spec": dict(spec), "series": list(series),
           "fields": list(fields), "rows": rows,
           "shown": len(rows), "total": int(total if total is not None else len(rows))}
    if x_order is not None:
        out["x_order"] = list(x_order)
    if drill:
        out["drill"] = drill
    if delta_key:
        # The internal-tool rule: a YoY chart PRINTS the % on the chart. The SERVER computes the
        # per-bucket delta (this row key) so the client never re-derives a figure β€” a partial
        # period sends None and honestly gets no label.
        out["delta_key"] = str(delta_key)
    return out


def validation(checks, note=None):
    """The reconciliation panel, from a module's `validate()` output.

    `actual` is the module's `a` β€” the number the page shows. `expected` is its `b` β€” the
    INDEPENDENT aggregate it must tie to. The signed gap rides in `note` rather than being
    recomputed client-side, because the module's own tolerance decided `ok` and a second
    subtraction in the client could disagree with it.
    """
    out = {"type": "validation", "key": "validation",
           "checks": [{"name": c.get("check") or c.get("name") or "",
                       "ok": bool(c.get("ok")),
                       "actual": c.get("a"), "expected": c.get("b"),
                       "fmt": "money" if isinstance(c.get("a"), float) else "num",
                       "note": f"gap {c.get('gap')}"} for c in (checks or [])]}
    if note:
        out["note"] = note
    return out


# ── drill descriptors: ONE grammar, three placements ─────────────────────────────────────────────
def decomp_drill(window_row, scope_type="period", scope_value=None, label=None):
    """A WINDOW + scope descriptor. Mirrors `ui/drawers._decomp_desc` key-for-key, so the eventual
    resolver can serve both front-ends from one function β€” and accepts both row shapes the sales
    module produces (`date_from`/`date_to` on the scorecard, `start`/`end` on a trend row)."""
    return {"kind": "decomp", "label": label,
            "scope_type": scope_type, "scope_value": scope_value,
            "date_from": window_row.get("date_from") or window_row.get("start"),
            "date_to": window_row.get("date_to") or window_row.get("end"),
            "cmp_from": window_row.get("cmp_from"), "cmp_to": window_row.get("cmp_to")}


def entity_drill(kind, id_key, label_key=None):
    """Block-level: every row of this block opens the same kind of entity panel."""
    if kind not in DRILL_KINDS:
        raise ValueError(f"drill kind {kind!r} is not one of {DRILL_KINDS}")
    out = {"kind": kind, "id_key": id_key}
    if label_key:
        out["label_key"] = label_key
    return out


def row_drill(row_key="_drill"):
    """Block-level: each row carries its OWN descriptor under `row_key`."""
    return {"kind": "decomp", "row_key": row_key}


def truncate(rows, limit):
    """`(rows[:limit], total)` β€” the ONLY sanctioned way to cap a list. Returning the total beside
    the slice is what makes "showing N of M" possible; a bare `[:N]` is the defect."""
    rows = list(rows)
    return rows[:limit], len(rows)


# ── BU scope: the one line that could fail open ──────────────────────────────────────────────────
def resolve_bu(session: Session, bu):
    """The effective `team_id` for this request. `bu` NARROWS; it never authorises.

    β›” THE RULE, AND WHY IT IS SHAPED THIS WAY. `perms.scope_team_id` derives scope from the user
    RECORD precisely because a UI selector defaults to 'All' β†’ consolidated, and mirroring that
    default on an API with no selector would BE the leak. So a query parameter cannot widen:

      * absent / 'all' β†’ `scope_team_id(user)`, which is None ONLY when the user may see every BU
        and is the PINNED team_id otherwise. A Royal-only user asking for 'all' gets 6, and 'all'
        for them honestly means "everything you may see".
      * an explicit id β†’ must be in `allowed_team_ids(user)`, else **403**. Never silently coerced
        to their own BU: a coerced parameter hides a client bug behind data that looks right.
      * anything else β†’ 400.
    """
    raw = "" if bu is None else str(bu).strip().lower()
    if raw in ("", "all"):
        return perms.scope_team_id(session.user)
    try:
        want = int(raw)
    except (TypeError, ValueError):
        raise err(400, "bad_bu", "bu must be 'all' or a business-unit id")
    if want not in perms.allowed_team_ids(session.user):
        raise err(403, "bu_forbidden",
                  "your account does not have access to that business unit")
    return want


def bu_control(session: Session, team_id):
    """The BU picker, built from the user's OWN permitted labels β€” which is exactly why `controls`
    is assembled per request and never cached on a scope key."""
    labels = perms.allowed_bu_labels(session.user)
    options = []
    for lab in labels:
        if lab == "All":
            options.append({"value": "all", "label": "All"})
            continue
        tid = ctxlib.BRANDS.get(lab)
        if tid is not None:
            options.append({"value": tid, "label": lab})
    return {"key": "bu", "kind": "bu", "label": "Business Unit",
            "value": "all" if team_id is None else team_id, "options": options}


def choice_control(key, label, value, options):
    return {"key": key, "kind": "choice", "label": label, "value": value,
            "options": [{"value": v, "label": l} for v, l in options]}


def bu_label(team_id):
    """'All Business Units' / 'Fisch' / 'Royal' β€” for prose, never for a permission decision."""
    if team_id is None:
        return "All Business Units"
    for lab, tid in ctxlib.BRANDS.items():
        if tid == team_id:
            return lab
    return str(team_id)


# ── the registry + the assembler ─────────────────────────────────────────────────────────────────
#: page key -> builder module. Wave 3 adds `'ar'` and `'procurement'` HERE and nowhere else β€” the
#: route, the grant wall, the BU resolution, the caching and the envelope are already written.
#: ⚠ The key must be the REGISTRY key, because it is what `session.require()` gates on.
#:
#: ⭐ DEBT D-52, DECIDED 2026-08-06 (wave 25) β€” **EMPTY IN PRODUCTION, ON PURPOSE, AND THE ROUTE
#: STAYS.** The register read this as "a route that can only 404", with "delete it" as one of
#: two exits. Measured before deciding, and it is not an orphan:
#:   Β· `web/src/pages/pageApi.ts` fetches `${API_V1}/pages/{key}`, `PageSurface`/`PageView`
#:     render the envelope, and `shell/Shell.tsx` mounts it β€” a live client vertical;
#:   Β· `verify_api` section G drives this route end-to-end with `pages_sales` as an explicit
#:     fixture (~350 lines: the `fmt` enums, the drill grammar, the BU wall, the cache-key
#:     discipline that closed a cross-user leak);
#:   Β· CLAUDE.md's conventions and `ARCHITECTURE.md` Β§7 both name it as THE way a dashboard page
#:     ships β€” *"a page is a builder, not a surface"*.
#: Deleting it would remove a documented extension seam and that coverage, in exchange for
#: retiring a 404 that is already honest (`unknown_page`, with a sentence). And registering a
#: builder would resurrect Sales, which the owner DELETED in wave 16.
#: So the third exit: the emptiness is DECLARED and GATED (`verify_api` section W25-3) rather
#: than incidental, so nobody re-reads a bare `{}` as an accident again. ⚠ The genuinely wrong
#: thing D-52 found is separate and stands: `routes_keychain.py`'s `pausedNote` describes a
#: measure path that no longer exists. That file is in no session's fence this wave.
_BUILDERS = {}

#: The reason `_BUILDERS` is empty, in one machine-readable line. Not decoration: `verify_api`
#: asserts this and the emptiness TOGETHER, so registering a builder without retiring this note
#: goes red β€” which is the only way a "deliberately empty" claim can stay true.
BUILDERS_EMPTY_BECAUSE = (
    "the Sales page was deleted by owner ruling in wave 16; no dashboard page is published "
    "today. The route, the envelope and the grant wall stay β€” a new page ships by registering "
    "a builder here (ARCHITECTURE.md Β§7), not by rebuilding this file."
)


def register(key, builder):
    _BUILDERS[str(key)] = builder


def builder_for(key):
    """The builder for `key`, or None. A missing key is a 404 at the route β€” NOT a 403: an
    unported page is a fact about our roadmap, not about this user's grants.

    ⚠ Wave 16 (owner item 7, R3/R11): `pages_sales` is UNREGISTERED β€” the lazy self-import
    that used to live here is gone, so a production process holds ZERO builders and every
    /pages/{key} is a 404 until the next surface (Collections / Procurement) registers.
    The registry mechanism itself is untouched: a future builder self-registers on import,
    and routes_pages.py should import it at module level so deploy_web.py's import walk can
    see it (the wave-9 missing-module scar). pages_sales.py stays on disk as the template
    and as verify_api section G's fixture β€” the gate imports it explicitly.
    """
    return _BUILDERS.get(str(key or "").strip().lower())


def known_pages():
    return sorted(_BUILDERS)


#: How long a scope's metrics stay warm. Matches `routes_customers._CACHE_TTL` β€” the underlying
#: pull is the same order of cost and a second TTL would be a second thing to reason about.
_CACHE_TTL = 900
#: The metrics cache is bounded for the reason every cache in this process is: an entry per
#: (page Γ— scope Γ— day) that is never evicted is a memory leak with a tenant-shaped growth curve.
_CACHE_MAX = 24


def _cached_metrics(session: Session, page_key, team_id, granularity, builder):
    """The SCOPE-shaped half, cached on the runtime (per tenant, LRU-bounded).

    The key carries the DAY: every metric here is relative to "today" (YTD, WTD, the trailing 12
    months), so an entry that outlives midnight serves yesterday's question with today's label.
    """
    return _metrics_for(session.runtime, page_key, team_id, granularity, builder)


def _metrics_for(rt, page_key, team_id, granularity, builder):
    """Session-free half of `_cached_metrics` (the prewarm thread calls it too).

    STALE-WHILE-REFRESH (scope_cache): the first `/pages/sales` per scope is genuinely slow (it
    runs `sales.validate()`); after that, expiry serves the stale copy and refreshes off-thread
    instead of parking a person on a 20-second rebuild.
    """
    import scope_cache

    today = time.strftime("%Y-%m-%d")
    key = ("page", page_key, team_id, granularity, today)

    def _build():
        return builder.metrics(team_id=team_id, granularity=granularity)

    def _evict():
        # A `while`, not a one-shot slice: evicting a fixed batch leaves the map above its own
        # bound whenever it is more than that batch over, which is a bound that does not bind.
        # ⚠ This dict is SHARED with `routes_customers`'s pool entries (they key on `("pool", …)`,
        # these on `("page", …)`), so either side's eviction can drop the other's entry. Harmless β€”
        # a miss is a rebuild β€” and deliberately one bounded map per tenant rather than two.
        while len(rt.pool_cache) > _CACHE_MAX:
            rt.pool_cache.pop(min(rt.pool_cache, key=lambda k: rt.pool_cache[k][0]), None)

    return scope_cache.get(rt.pool_cache, key, _CACHE_TTL, _build, _evict)


def warm_default(rt):
    """Boot prewarm: every registered page's consolidated metrics at its default granularity β€”
    the envelope the first visitor after a deploy actually asks for. main.py's thread only."""
    for key in known_pages():
        builder = builder_for(key)
        if builder is not None:
            _metrics_for(rt, key, None, builder.resolve_granularity(None), builder)


def build_envelope(session: Session, page_key, bu=None, period=None):
    """The Y1 envelope for `page_key`, scoped to this session. Raises the contracted errors."""
    builder = builder_for(page_key)
    if builder is None:
        raise err(404, "unknown_page", f"no page data is published for {page_key!r}")
    # The GRANT WALL, on the builder's registry key. 403, never an empty 200 β€” an empty page reads
    # as "you have no data" and is how a permission bug hides in plain sight.
    session.require(builder.MODULE)
    team_id = resolve_bu(session, bu)
    granularity = builder.resolve_granularity(period)
    metrics = _cached_metrics(session, page_key, team_id, granularity, builder)
    return {
        "key": page_key,
        "title": builder.TITLE,
        "subtitle": builder.subtitle(team_id),
        # UTC with an explicit Z. A naive local timestamp from a container whose TZ nobody set is
        # indistinguishable from a wrong one.
        "as_of": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(metrics["pulled_at"])),
        "controls": [bu_control(session, team_id)] + builder.controls(granularity),
        "blocks": builder.blocks(metrics, team_id, granularity),
    }