beatrina 0.8.6

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 (114) hide show
  1. package/LICENSE +92 -0
  2. package/NOTICES +72 -0
  3. package/README.md +124 -0
  4. package/bin/beatrina.mjs +223 -0
  5. package/bin/cli.mjs +80 -0
  6. package/bin/failsafe.mjs +74 -0
  7. package/bin/identity.mjs +45 -0
  8. package/bin/prova-post.mjs +51 -0
  9. package/bin/sessions.mjs +95 -0
  10. package/bin/shortcut.mjs +151 -0
  11. package/bin/update-check.mjs +55 -0
  12. package/bin/upgrade.mjs +76 -0
  13. package/build-info.json +1 -0
  14. package/carmar_V0.8.6.html +1310 -0
  15. package/check/acceptance.mjs +278 -0
  16. package/check/session.mjs +215 -0
  17. package/engines/js/document-exec.mjs +82 -0
  18. package/engines/js/persist.mjs +214 -0
  19. package/engines/js/worker.mjs +424 -0
  20. package/engines/python/adapter.py +577 -0
  21. package/engines/python/analyze.py +814 -0
  22. package/engines/python/bootstrap.py +309 -0
  23. package/engines/python/dataview.py +735 -0
  24. package/engines/python/debugger.py +346 -0
  25. package/engines/python/document_exec.py +158 -0
  26. package/engines/python/engine.json +28 -0
  27. package/engines/python/handoff.py +118 -0
  28. package/engines/python/worker.py +564 -0
  29. package/engines/r/engine.json +25 -0
  30. package/engines/r/handoff.R +92 -0
  31. package/failsafe/ai-policy.R +255 -0
  32. package/failsafe/ai-store.R +373 -0
  33. package/failsafe/cite.R +418 -0
  34. package/failsafe/journal.R +684 -0
  35. package/failsafe/plugins.R +809 -0
  36. package/failsafe/serve.R +5500 -0
  37. package/host/ai-policy.mjs +218 -0
  38. package/host/deployment.mjs +160 -0
  39. package/host/engine-js.mjs +98 -0
  40. package/host/engine-pool.mjs +383 -0
  41. package/host/engine-python.mjs +228 -0
  42. package/host/engine-r.mjs +206 -0
  43. package/host/engine-stdio.mjs +401 -0
  44. package/host/journal-store.mjs +749 -0
  45. package/host/main.mjs +503 -0
  46. package/host/planes/README.md +41 -0
  47. package/host/planes/ai-store.mjs +327 -0
  48. package/host/planes/ai.mjs +467 -0
  49. package/host/planes/analyze.mjs +397 -0
  50. package/host/planes/cite.mjs +517 -0
  51. package/host/planes/files.mjs +0 -0
  52. package/host/planes/jobs.mjs +704 -0
  53. package/host/planes/journal.mjs +53 -0
  54. package/host/planes/latex.mjs +201 -0
  55. package/host/planes/mcp.mjs +493 -0
  56. package/host/planes/pair.mjs +325 -0
  57. package/host/planes/pipe-term.mjs +122 -0
  58. package/host/planes/plugins.mjs +112 -0
  59. package/host/planes/proc-tree.mjs +76 -0
  60. package/host/planes/sessions.mjs +434 -0
  61. package/host/planes/settings.mjs +164 -0
  62. package/host/planes/terminal.mjs +286 -0
  63. package/host/planes/test-file.mjs +80 -0
  64. package/host/planes/update.mjs +214 -0
  65. package/host/plugin-store.mjs +838 -0
  66. package/host/server.mjs +441 -0
  67. package/host/settings.mjs +379 -0
  68. package/host/update-record.mjs +59 -0
  69. package/host/user-dirs.mjs +117 -0
  70. package/host/windows-runtime.mjs +145 -0
  71. package/host/worker-plane.mjs +713 -0
  72. package/host/ws.mjs +190 -0
  73. package/kernel/analyze.R +668 -0
  74. package/kernel/deployment.R +165 -0
  75. package/kernel/examples/NOTICE.md +38 -0
  76. package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
  77. package/kernel/fileio.R +656 -0
  78. package/kernel/index.html +96 -0
  79. package/kernel/job-run.R +391 -0
  80. package/kernel/jobs.R +276 -0
  81. package/kernel/kernel-protocol +1 -0
  82. package/kernel/kernel-version +1 -0
  83. package/kernel/kernel.R +671 -0
  84. package/kernel/knitr-run.R +245 -0
  85. package/kernel/latex.R +609 -0
  86. package/kernel/mcp/carmar-mcp.mjs +516 -0
  87. package/kernel/notebook-page.R +67 -0
  88. package/kernel/plugins/csl/apa/apa.csl +2273 -0
  89. package/kernel/plugins/csl/apa/plugin.json +19 -0
  90. package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
  91. package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
  92. package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
  93. package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
  94. package/kernel/plugins/csl/ieee/ieee.csl +519 -0
  95. package/kernel/plugins/csl/ieee/plugin.json +19 -0
  96. package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
  97. package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
  98. package/kernel/plugins/csl/nature/nature.csl +189 -0
  99. package/kernel/plugins/csl/nature/plugin.json +19 -0
  100. package/kernel/plugins/latex/apa7/apa7.json +14 -0
  101. package/kernel/plugins/latex/apa7/plugin.json +19 -0
  102. package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
  103. package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
  104. package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
  105. package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
  106. package/kernel/project.R +131 -0
  107. package/kernel/settings.R +410 -0
  108. package/kernel/sniff.R +769 -0
  109. package/kernel/worker-boot.R +22 -0
  110. package/kernel/worker.R +3496 -0
  111. package/lib/agent-authoring-contract.js +547 -0
  112. package/lib/cell-kinds.js +108 -0
  113. package/lib/engine-labels.js +324 -0
  114. package/package.json +32 -0
@@ -0,0 +1,735 @@
1
+ """dataview.py — the data viewer's two ops, `view` and `colstats`, in pandas.
2
+
3
+ Port of `spike/worker.R`'s `view_payload`, `view_filter`, `match_filter`,
4
+ `describe_column` and `colstats_payload`. Every rule below was read out of that
5
+ file, not recalled.
6
+
7
+ WHERE THIS RUNS, AND WHY IT IS NOT THE SUPERVISOR. `view` needs the objects in
8
+ the running session, so it is answered by the evaluating child — exactly as R
9
+ routes `view`/`colstats` to `worker.R` and not to `serve.R`. It therefore queues
10
+ behind a running cell, which is R's behaviour too. The file ops do NOT (see
11
+ `fileio.py`): a Save must not wait for a forty-second fit, but a data viewer
12
+ asking about `df` while `df` is being rebuilt has nothing to answer with.
13
+
14
+ This module is a SINGLE BODY WITH TWO CALLERS, the shape `fileio.R` records:
15
+ the test process imports it directly (fast, no kernel), and `bootstrap.py`
16
+ ships its source into the kernel, where it is exec'd on FIRST USE. Lazy for
17
+ B4's reason — a session that never opens the viewer must not pay pandas'
18
+ import at boot.
19
+
20
+ THE TWO RULES THAT ARE CORRECTNESS, NOT POLISH:
21
+
22
+ 1. The preview is bounded by BYTES as well as rows. 500 rows of 20 KB strings
23
+ is a 10 MB frame that stalls the socket, and one cell can hold a fitted
24
+ model. Rows are capped, columns are capped, CELLS are clipped, and then the
25
+ page is measured as it will actually ship.
26
+
27
+ 2. When shedding rows to fit that ceiling, ESTIMATE the per-row cost and keep
28
+ what fits — never halve repeatedly. `emit_dataframe` halved (200 → 100 →
29
+ 50) and it cost a third of the rows that would have fit; the number it
30
+ lands on is also one the reader cannot predict from anything on screen.
31
+ `view_payload` one file over had the right rule, and this is that rule.
32
+
33
+ ONE TRAP R HAS THAT PYTHON DOES NOT, and one Python has that R does not.
34
+ R's `I()` wrappers are absent here because a Python list of length 1 is still a
35
+ list — jsonlite's auto_unbox, which shipped `"bins": 10` for a constant column
36
+ and broke every client that mapped over it, has no Python equivalent. In
37
+ exchange, `json.dumps` will happily write bare `NaN`/`Infinity`, which are not
38
+ JSON and which `JSON.parse` rejects: every number that reaches a payload goes
39
+ through `_num()`, which maps them to null the way jsonlite's `na = "null"` does.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import json
45
+ import math
46
+ import re
47
+
48
+ MAX_VIEW_ROWS = 500
49
+ MAX_VIEW_COLS = 100
50
+ MAX_VIEW_BYTES = 512 * 1024
51
+ MAX_VIEW_CELL_CHARS = 512
52
+ MAX_VIEW_LABEL_CHARS = 128
53
+ MAX_STATS_LEVELS = 15
54
+ STATS_BINS = 24
55
+ VIEW_BINS = 12
56
+
57
+
58
+ # ── formatting ──────────────────────────────────────────────────────────────
59
+ # Numbers come back BOTH ways — as strings formatted HERE (so the card never
60
+ # re-invents significant digits in JavaScript) and, where a drawing needs them,
61
+ # as raw numerics. The two never disagree because they come from one value.
62
+
63
+ def _num(x):
64
+ """A JSON-safe number, or None. NaN and ±Inf are not JSON."""
65
+ if x is None:
66
+ return None
67
+ try:
68
+ v = float(x)
69
+ except (TypeError, ValueError):
70
+ return None
71
+ if v != v or math.isinf(v):
72
+ return None
73
+ return v
74
+
75
+
76
+ def fmt_num(x) -> str:
77
+ try:
78
+ v = float(x)
79
+ except (TypeError, ValueError):
80
+ return "NA"
81
+ if v != v:
82
+ return "NaN"
83
+ if math.isinf(v):
84
+ return "Inf" if v > 0 else "-Inf"
85
+ if abs(v) >= 1e5 or (abs(v) < 1e-3 and v != 0):
86
+ return f"{v:.2e}" # R's format(digits = 3, scientific)
87
+ s = f"{v:.3f}".rstrip("0").rstrip(".")
88
+ return "0" if s in ("", "-", "-0") else s
89
+
90
+
91
+ def fmt_count(x) -> str:
92
+ try:
93
+ return f"{int(x):,}"
94
+ except (TypeError, ValueError):
95
+ return "NA"
96
+
97
+
98
+ def fmt_pct(part, whole) -> str:
99
+ """A share as a bare percentage: "85.3%".
100
+
101
+ Every percentage on the card comes from here. R and JavaScript do not round
102
+ halves the same way — R's round() goes to even, so 50/4000 is 1.2% while
103
+ JavaScript's toFixed(1) gives 1.3% — and a card that computed some of its
104
+ own percentages printed both, for the same count, two inches apart. Python's
105
+ format() rounds half to even like R, so one formatter is one answer.
106
+ Always one decimal: a column reading "1.2% / 1.1% / 1% / 1%" loses the eye.
107
+ """
108
+ whole = _num(whole)
109
+ if whole is None or whole <= 0:
110
+ return ""
111
+ return f"{100.0 * float(part) / whole:.1f}%"
112
+
113
+
114
+ def fmt_share(part, whole) -> str:
115
+ """A share as count and percentage: "3,412 (85.3%)"."""
116
+ w = _num(whole)
117
+ if w is None or w <= 0:
118
+ return fmt_count(part)
119
+ return f"{fmt_count(part)} ({fmt_pct(part, whole)})"
120
+
121
+
122
+ def _pair(label, value):
123
+ return {"label": label, "value": value}
124
+
125
+
126
+ def as_count(x, default: int) -> int:
127
+ """Coerce a wire-supplied count, FALLING BACK rather than erroring: a
128
+ malformed offset from a client must degrade to the default, not kill the
129
+ pane."""
130
+ if isinstance(x, bool) or x is None:
131
+ return default
132
+ try:
133
+ return int(x)
134
+ except (TypeError, ValueError):
135
+ return default
136
+
137
+
138
+ # ── searching and filtering ─────────────────────────────────────────────────
139
+
140
+ def _escape_regex(s: str) -> str:
141
+ return re.escape(str(s))
142
+
143
+
144
+ def as_search_text(col):
145
+ """A column as searchable text, whatever it holds."""
146
+ import pandas as pd
147
+ if isinstance(col.dtype, pd.CategoricalDtype):
148
+ col = col.astype("object")
149
+ return col.astype("string").fillna("")
150
+
151
+
152
+ def contains_ci(text, needle):
153
+ """Case-insensitive substring match.
154
+
155
+ R's note here is worth keeping because the Python trap is the mirror image:
156
+ `grepl(fixed = TRUE, ignore.case = TRUE)` silently ignores ignore.case, so
157
+ searching "Cohesion" found nothing in a column of "cohesion". pandas'
158
+ `str.contains(case=False)` honours the flag but treats the needle as a
159
+ REGEX, so `f[1]` would be a character class and `a.c` would match "abc".
160
+ Escaping the needle and keeping regex on gives both properties at once —
161
+ every metacharacter inert, folding that works for non-ASCII too.
162
+ """
163
+ return text.str.contains(_escape_regex(needle), case=False, na=False, regex=True)
164
+
165
+
166
+ def match_filter(col, spec):
167
+ """One column filter spec → a boolean keep-mask.
168
+
169
+ NEVER evaluates the spec: comparisons and ranges are parsed, everything
170
+ else is literal text.
171
+ """
172
+ import numpy as np
173
+ import pandas as pd
174
+
175
+ spec = str(spec).strip()
176
+ n = len(col)
177
+ if not spec:
178
+ return np.ones(n, dtype=bool)
179
+ na = col.isna().to_numpy()
180
+ if spec.upper() == "NA":
181
+ return na
182
+
183
+ if pd.api.types.is_bool_dtype(col.dtype):
184
+ wanted = spec.upper()
185
+ if wanted in ("TRUE", "T"):
186
+ return (~na) & col.fillna(False).to_numpy(dtype=bool)
187
+ if wanted in ("FALSE", "F"):
188
+ return (~na) & (~col.fillna(True).to_numpy(dtype=bool))
189
+
190
+ if pd.api.types.is_numeric_dtype(col.dtype) and not pd.api.types.is_bool_dtype(col.dtype):
191
+ values = pd.to_numeric(col, errors="coerce").to_numpy(dtype="float64", na_value=np.nan)
192
+ bounds = spec.split("..")
193
+ if len(bounds) == 2:
194
+ lo, hi = _float_or_none(bounds[0]), _float_or_none(bounds[1])
195
+ if lo is not None and hi is not None:
196
+ return (~na) & (values >= lo) & (values <= hi)
197
+ hit = re.match(r"^\s*(>=|<=|!=|==|=|>|<)\s*(-?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?)\s*$", spec)
198
+ if hit:
199
+ target = float(hit.group(2))
200
+ op = hit.group(1)
201
+ with np.errstate(invalid="ignore"):
202
+ mask = {">=": values >= target, "<=": values <= target,
203
+ "!=": values != target, "==": values == target,
204
+ "=": values == target, ">": values > target,
205
+ "<": values < target}[op]
206
+ return (~na) & mask
207
+ target = _float_or_none(spec)
208
+ if target is not None:
209
+ return (~na) & (values == target)
210
+
211
+ text = as_search_text(col)
212
+ if spec.startswith("="):
213
+ want = spec[1:].lower()
214
+ return (~na) & (text.str.lower() == want).to_numpy(dtype=bool)
215
+ if spec.startswith("!"):
216
+ # NA rows are KEPT by a negated filter — R's `is.na(col) | !contains`.
217
+ return na | (~contains_ci(text, spec[1:]).to_numpy(dtype=bool))
218
+ return (~na) & contains_ci(text, spec).to_numpy(dtype=bool)
219
+
220
+
221
+ def _float_or_none(s):
222
+ try:
223
+ return float(str(s).strip())
224
+ except (TypeError, ValueError):
225
+ return None
226
+
227
+
228
+ def view_filter(obj, query=None, filters=None):
229
+ """Narrow a frame by the viewer's search box and its per-column filters.
230
+
231
+ Shared with the statistics card, so a profile of "the rows you are looking
232
+ at" cannot disagree with the rows the grid is actually showing. A panel that
233
+ quietly profiled the whole frame while the grid showed a filtered subset
234
+ would be worse than no panel: both numbers look authoritative and only one
235
+ answers the question asked.
236
+ """
237
+ import numpy as np
238
+
239
+ count = 0
240
+ if isinstance(filters, dict) and filters:
241
+ for name in list(filters.keys()):
242
+ if name not in obj.columns:
243
+ continue
244
+ spec = filters[name]
245
+ if spec is None or not str(spec).strip():
246
+ continue
247
+ obj = obj[match_filter(obj[name], spec)]
248
+ count += 1
249
+ query = str(query).strip() if isinstance(query, str) else ""
250
+ if query and len(obj):
251
+ keep = np.zeros(len(obj), dtype=bool)
252
+ for name in obj.columns:
253
+ keep |= contains_ci(as_search_text(obj[name]), query).to_numpy(dtype=bool)
254
+ obj = obj[keep]
255
+ return obj, count, query
256
+
257
+
258
+ # ── column description (per page, must stay cheap) ──────────────────────────
259
+
260
+ def _kind_of(col):
261
+ import pandas as pd
262
+ dtype = col.dtype
263
+ if pd.api.types.is_bool_dtype(dtype):
264
+ return "logical"
265
+ if pd.api.types.is_datetime64_any_dtype(dtype) or isinstance(dtype, pd.PeriodDtype):
266
+ return "date"
267
+ if pd.api.types.is_numeric_dtype(dtype):
268
+ return "numeric"
269
+ return "categorical"
270
+
271
+
272
+ def _epoch_seconds(col):
273
+ """Datetimes as epoch SECONDS, whatever resolution pandas chose.
274
+
275
+ NOT `.astype("int64") / 1e9`. pandas 3 stores a datetime column as
276
+ `datetime64[us]` by default, so that expression divides MICROseconds by a
277
+ billion and every date in 2020 reads as the 19th of January 1970 — a value
278
+ that is finite, plausibly shaped, orders correctly and is completely wrong.
279
+ Found by printing the first real reply, not by a test. Subtracting the epoch
280
+ and asking for total seconds is resolution-independent, and it is also what
281
+ R's `as.numeric` on a POSIXct means.
282
+ """
283
+ import pandas as pd
284
+ stamps = pd.to_datetime(col, errors="coerce")
285
+ tz = getattr(stamps.dtype, "tz", None)
286
+ epoch = pd.Timestamp("1970-01-01", tz=tz) if tz is not None else pd.Timestamp("1970-01-01")
287
+ return (stamps - epoch).dt.total_seconds().to_numpy()
288
+
289
+
290
+ def _show_time(v) -> str:
291
+ """An epoch-seconds value as a timestamp, seconds resolution — what R's
292
+ `format.POSIXct` prints, so a quartile does not arrive with six decimals."""
293
+ import pandas as pd
294
+ try:
295
+ return pd.Timestamp(float(v), unit="s").strftime("%Y-%m-%d %H:%M:%S")
296
+ except (ValueError, OverflowError, OSError):
297
+ return fmt_num(v)
298
+
299
+
300
+ def _numeric_values(col, kind):
301
+ """The column's present values as float64 — epoch seconds for a date."""
302
+ import numpy as np
303
+ import pandas as pd
304
+ if kind == "date":
305
+ return _epoch_seconds(col.dropna())
306
+ return pd.to_numeric(col.dropna(), errors="coerce").to_numpy(dtype="float64",
307
+ na_value=np.nan)
308
+
309
+
310
+ def describe_column(col, name: str) -> dict:
311
+ """Describe one column the way a data viewer shows it: type, a sparkline's
312
+ worth of shape, a stat label, and how much is missing.
313
+
314
+ The bins are computed HERE rather than shipping the column: a 500k-row
315
+ numeric vector is 4 MB of JSON and 12 counts is 60 bytes, and the viewer
316
+ only ever draws the 12.
317
+ """
318
+ import numpy as np
319
+
320
+ n = int(len(col))
321
+ # `isna().sum()` and not `isna().to_numpy()`: this runs for every column of
322
+ # every page, and materialising a million-element boolean to count it — and
323
+ # then slicing with it — is measurably the difference between a 230 ms page
324
+ # and a 150 ms one. Only the logical branch needs the mask itself.
325
+ missing = int(col.isna().sum())
326
+ kind = _kind_of(col)
327
+ base = {"name": name, "class": str(col.dtype), "n": n, "missing": missing}
328
+ miss_pair = [_pair("Missing", fmt_share(missing, n))] if missing > 0 else []
329
+
330
+ if kind in ("numeric", "date"):
331
+ values = _numeric_values(col, kind)
332
+ values = values[np.isfinite(values)]
333
+ if values.size == 0:
334
+ return {**base, "kind": kind, "bins": [], "stat": "all missing",
335
+ "summary": [_pair("Missing", fmt_share(missing, n))]}
336
+ lo, hi = float(values.min()), float(values.max())
337
+ # np.histogram, not value_counts: an empty bin must still be a bin, or a
338
+ # gap in the data silently shortens the sparkline and every bar after it
339
+ # shifts left. (R's note against table(); the same trap, the same fix.)
340
+ if hi == lo:
341
+ bins = [int(values.size)]
342
+ else:
343
+ bins = [int(v) for v in np.histogram(values, bins=VIEW_BINS,
344
+ range=(lo, hi))[0]]
345
+ med = float(np.median(values))
346
+ mu = float(values.mean())
347
+ sigma = float(values.std(ddof=1)) if values.size > 1 else float("nan")
348
+ # A date's VALUES stay epoch seconds, because the sparkline draws them,
349
+ # but every string a reader sees is a date. R has no date branch here at
350
+ # all — a Date falls through to the categorical arm, which builds a
351
+ # value_counts over a million distinct timestamps to print a level count
352
+ # nobody wants. A range of "1.58e+06 – 1.58e+06" was the first draft of
353
+ # this line and is the reason the branch exists.
354
+ show = _show_time if kind == "date" else fmt_num
355
+ return {**base, "kind": kind, "bins": bins,
356
+ "min": _num(lo), "max": _num(hi), "median": _num(med),
357
+ "mean": _num(mu), "sd": _num(sigma),
358
+ "summary": [_pair("Mean", show(mu)),
359
+ _pair("Std. dev.", fmt_num(sigma)),
360
+ _pair("Minimum", show(lo)),
361
+ _pair("Median", show(med)),
362
+ _pair("Maximum", show(hi))] + miss_pair,
363
+ "stat": f"{show(lo)} – {show(hi)}"}
364
+
365
+ if kind == "logical":
366
+ na = col.isna().to_numpy()
367
+ truthy = col.fillna(False).to_numpy(dtype=bool)
368
+ yes = int((truthy & ~na).sum())
369
+ no = int((~truthy & ~na).sum())
370
+ return {**base, "kind": "logical", "bins": [yes, no],
371
+ "levels": ["TRUE", "FALSE"],
372
+ "summary": [_pair("TRUE", fmt_share(yes, yes + no)),
373
+ _pair("FALSE", fmt_share(no, yes + no))] + miss_pair,
374
+ "stat": f"{yes} true / {no} false"}
375
+
376
+ # `value_counts()` straight off the column, NOT off `as_search_text()`: the
377
+ # search text is a full second copy of a million strings (27 ms measured) and
378
+ # value_counts already drops missing and already groups whatever the dtype
379
+ # holds. Only the handful of level LABELS that ship need stringifying.
380
+ tab = col.value_counts()
381
+ present = n - missing
382
+ top = tab.head(VIEW_BINS)
383
+ # The commonest levels answer "what is in here" far better than the level
384
+ # COUNT alone, and they are already computed for the sparkline. Four are
385
+ # listed when four is all there is, so a column with exactly four levels does
386
+ # not show three and silently swallow the last one.
387
+ lead = tab.head(len(tab) if len(tab) <= 4 else 3)
388
+ tops = [_pair(str(k)[:MAX_VIEW_LABEL_CHARS], fmt_share(int(v), present))
389
+ for k, v in lead.items()]
390
+ return {**base, "kind": "categorical",
391
+ "bins": [int(v) for v in top.to_numpy()],
392
+ "levels": [str(k)[:MAX_VIEW_LABEL_CHARS] for k in top.index],
393
+ "nlevels": int(len(tab)),
394
+ "summary": [_pair("Distinct levels", fmt_count(len(tab)))] + tops + miss_pair,
395
+ "stat": f"{len(tab)} level{'' if len(tab) == 1 else 's'}"}
396
+
397
+
398
+ # ── the statistics card (one column, on demand) ─────────────────────────────
399
+
400
+ def colstats_payload(col, column_name: str) -> dict:
401
+ """Everything a statistics card shows about ONE column.
402
+
403
+ Deliberately separate from describe_column(): that one runs for every column
404
+ of every page and must stay cheap, so it ships 12 bins and a label. This one
405
+ runs when a reader asks about a single column and can afford quantiles,
406
+ shape moments and a level table.
407
+ """
408
+ import numpy as np
409
+ import pandas as pd
410
+
411
+ n = int(len(col))
412
+ ok = col.dropna()
413
+ missing = n - int(len(ok))
414
+ kind = _kind_of(col)
415
+ base = {"column": column_name, "class": str(col.dtype), "n": n,
416
+ "missing": missing, "present": int(len(ok)),
417
+ "distinct": int(ok.nunique(dropna=True))}
418
+
419
+ if kind in ("numeric", "date"):
420
+ dated = kind == "date"
421
+ x = _numeric_values(col, kind)
422
+ x = x[np.isfinite(x)]
423
+ if x.size == 0:
424
+ return {**base, "kind": kind,
425
+ "summary": [_pair("Present", "0 — every value is missing")]}
426
+
427
+ show = _show_time if dated else fmt_num
428
+ q1, med, q3 = (float(v) for v in np.quantile(x, [0.25, 0.5, 0.75]))
429
+ iqr = q3 - q1
430
+ m = float(x.mean())
431
+ s = float(x.std(ddof=1)) if x.size > 1 else float("nan")
432
+ # Fences are Tukey's, so "outlier" here means what a boxplot means by it
433
+ # — not a normal-theory z cut, which would be a different claim about
434
+ # data nobody has shown is normal.
435
+ lo_fence, hi_fence = q1 - 1.5 * iqr, q3 + 1.5 * iqr
436
+ outliers = int(((x < lo_fence) | (x > hi_fence)).sum())
437
+ # Moments by hand rather than by dependency: both are one line.
438
+ if math.isfinite(s) and s > 0:
439
+ z = (x - m) / s
440
+ skew, kurt = float((z ** 3).mean()), float((z ** 4).mean() - 3)
441
+ else:
442
+ skew = kurt = float("nan")
443
+ lo, hi = float(x.min()), float(x.max())
444
+ if hi == lo:
445
+ bins = [int(x.size)]
446
+ else:
447
+ bins = [int(v) for v in np.histogram(x, bins=STATS_BINS, range=(lo, hi))[0]]
448
+ mad = float(np.median(np.abs(x - med))) * 1.4826 # R's mad(), constant included
449
+ summary = [
450
+ _pair("Mean", show(m)), _pair("Std. dev.", fmt_num(s)),
451
+ _pair("Minimum", show(lo)), _pair("1st quartile", show(q1)),
452
+ _pair("Median", show(med)), _pair("3rd quartile", show(q3)),
453
+ _pair("Maximum", show(hi)), _pair("IQR", fmt_num(iqr)),
454
+ _pair("Median abs. dev.", fmt_num(mad)),
455
+ _pair("Skewness", fmt_num(skew)), _pair("Excess kurtosis", fmt_num(kurt)),
456
+ _pair("Outliers (1.5 IQR)", fmt_share(outliers, int(x.size)))]
457
+ if not dated:
458
+ summary += [_pair("Zeros", fmt_share(int((x == 0).sum()), int(x.size))),
459
+ _pair("Negative", fmt_share(int((x < 0).sum()), int(x.size)))]
460
+ else:
461
+ summary += [_pair("Span", f"{fmt_num((hi - lo) / 86400.0)} days")]
462
+ return {**base, "kind": kind, "summary": summary, "bins": bins,
463
+ "binMin": _num(lo), "binMax": _num(hi),
464
+ "box": {"min": _num(lo), "q1": _num(q1), "median": _num(med),
465
+ "q3": _num(q3), "max": _num(hi),
466
+ "lower": _num(max(lo_fence, lo)),
467
+ "upper": _num(min(hi_fence, hi)),
468
+ "outliers": outliers}}
469
+
470
+ if kind == "logical":
471
+ na = col.isna().to_numpy()
472
+ truthy = col.fillna(False).to_numpy(dtype=bool)
473
+ yes = int((truthy & ~na).sum())
474
+ no = int((~truthy & ~na).sum())
475
+ return {**base, "kind": "logical",
476
+ "summary": [_pair("TRUE", fmt_share(yes, yes + no)),
477
+ _pair("FALSE", fmt_share(no, yes + no)),
478
+ _pair("Missing", fmt_share(missing, n))],
479
+ "levels": ["TRUE", "FALSE"], "counts": [yes, no],
480
+ "shares": [fmt_pct(yes, yes + no), fmt_pct(no, yes + no)],
481
+ "other": 0}
482
+
483
+ text = as_search_text(ok)
484
+ tab = text.value_counts()
485
+ top = tab.head(MAX_STATS_LEVELS)
486
+ widths = text.str.len().to_numpy()
487
+ total = int(len(text))
488
+ summary = [
489
+ _pair("Distinct levels", fmt_count(len(tab))),
490
+ _pair("Most common", f"{tab.index[0]} — {fmt_share(int(tab.iloc[0]), total)}"
491
+ if len(tab) else "—"),
492
+ _pair("Least common", f"{tab.index[-1]} — {fmt_share(int(tab.iloc[-1]), total)}"
493
+ if len(tab) else "—"),
494
+ _pair("Empty strings", fmt_share(int((text == "").sum()), total)),
495
+ _pair("Shortest", fmt_count(int(widths.min())) if widths.size else "—"),
496
+ _pair("Longest", fmt_count(int(widths.max())) if widths.size else "—"),
497
+ _pair("Mean length", fmt_num(float(widths.mean())) if widths.size else "—")]
498
+ counts = [int(v) for v in top.to_numpy()]
499
+ other = total - sum(counts)
500
+ return {**base, "kind": "categorical", "summary": summary,
501
+ "levels": [str(k)[:MAX_VIEW_LABEL_CHARS] for k in top.index],
502
+ "counts": counts,
503
+ "shares": [fmt_pct(c, total) for c in counts],
504
+ "other": other, "otherShare": fmt_pct(other, total),
505
+ "nlevels": int(len(tab))}
506
+
507
+
508
+ # ── the page ────────────────────────────────────────────────────────────────
509
+
510
+ def to_frame(obj):
511
+ """Anything → a DataFrame, or None for "not a table"."""
512
+ import pandas as pd
513
+ if isinstance(obj, pd.DataFrame):
514
+ return obj
515
+ try:
516
+ if isinstance(obj, pd.Series):
517
+ return obj.to_frame(name=obj.name if obj.name is not None else "value")
518
+ return pd.DataFrame(obj)
519
+ except Exception:
520
+ return None
521
+
522
+
523
+ def _cell(v, limit: int):
524
+ """One display value, JSON-safe and bounded.
525
+
526
+ A single cell can dwarf the row and column caps (logs, embedded documents,
527
+ accidental blobs, a fitted model in an object column). The viewer is a
528
+ preview, so a complex value becomes a description and text is clipped BEFORE
529
+ JSON encoding — which is also the hard upper bound when the page has one row
530
+ and row shedding cannot help.
531
+ """
532
+ if v is None:
533
+ return None, False
534
+ if isinstance(v, bool):
535
+ return v, False
536
+ if isinstance(v, int):
537
+ return v, False
538
+ if isinstance(v, float):
539
+ return _num(v), False
540
+ if isinstance(v, str):
541
+ if len(v) > limit:
542
+ return v[:limit] + "…", True
543
+ return v, False
544
+ try:
545
+ import numpy as np
546
+ if isinstance(v, np.generic):
547
+ return _cell(v.item(), limit)
548
+ except Exception:
549
+ pass
550
+ try:
551
+ import pandas as pd
552
+ if v is pd.NaT or (not isinstance(v, (list, dict, tuple, set)) and pd.isna(v)):
553
+ return None, False
554
+ except Exception:
555
+ pass
556
+ s = str(v)
557
+ if len(s) > limit:
558
+ return s[:limit] + "…", True
559
+ return s, False
560
+
561
+
562
+ def _uniquify(names):
563
+ """R's make.unique, so two columns clipped to the same label stay two."""
564
+ seen, out = {}, []
565
+ for raw in names:
566
+ name = str(raw)[:MAX_VIEW_LABEL_CHARS]
567
+ if name in seen:
568
+ seen[name] += 1
569
+ name = f"{name}.{seen[name]}"
570
+ else:
571
+ seen[name] = 0
572
+ out.append(name)
573
+ return out
574
+
575
+
576
+ def view_payload(obj, shown_name, offset=None, limit=None, sort=None, desc=False,
577
+ col_offset=None, col_limit=None, query=None, filters=None) -> dict:
578
+ """The true shape, whole-column descriptions, and one row × column window.
579
+
580
+ Sorting happens HERE, not in the client: the client only ever holds one
581
+ page, so a client-side sort would order 200 rows of a million-row frame and
582
+ call it sorted. It runs on the full frame BEFORE any windowing, so a column
583
+ window still sees globally ordered rows.
584
+
585
+ The reply always states both what EXISTS (`nrow`/`ncol`) and what it
586
+ actually CONTAINS (`shown`/`shownCols`, the effective `limit`/`colLimit`,
587
+ the clamp flags) — nothing downstream should have to guess.
588
+ """
589
+ frame = to_frame(obj)
590
+ if frame is None:
591
+ return {"name": shown_name, "error": "not a table"}
592
+
593
+ total_rows = int(len(frame))
594
+ offset = max(0, as_count(offset, 0))
595
+ limit_req = max(1, as_count(limit, 200))
596
+ limit = min(limit_req, MAX_VIEW_ROWS)
597
+ col_offset = max(0, as_count(col_offset, 0))
598
+ col_limit_req = max(1, as_count(col_limit, 30))
599
+ col_limit = min(col_limit_req, MAX_VIEW_COLS)
600
+
601
+ frame, filter_count, query = view_filter(frame, query, filters)
602
+ if isinstance(sort, str) and sort in set(str(c) for c in frame.columns):
603
+ # kind="stable" so ties keep their original order: an unstable sort makes
604
+ # the same page of the same frame come back shuffled between requests.
605
+ frame = frame.sort_values(by=sort, ascending=not bool(desc), kind="stable",
606
+ na_position="last")
607
+
608
+ ncol = int(frame.shape[1])
609
+ cidx = list(range(col_offset, min(col_offset + col_limit, ncol)))
610
+ ridx_hi = min(offset + limit, len(frame))
611
+ page = frame.iloc[offset:ridx_hi, cidx] if cidx else frame.iloc[offset:ridx_hi, []]
612
+
613
+ source_names = [str(c) for c in page.columns]
614
+ display_names = _uniquify(source_names)
615
+
616
+ clipped = 0
617
+ rows = []
618
+ # itertuples over iterrows: iterrows boxes every row into a Series, which on
619
+ # a 500-row × 100-column page is 50,000 object allocations for a preview.
620
+ for record in page.itertuples(index=False, name=None):
621
+ row = {}
622
+ for i, value in enumerate(record):
623
+ cell, was_clipped = _cell(value, MAX_VIEW_CELL_CHARS)
624
+ clipped += int(was_clipped)
625
+ row[display_names[i]] = cell
626
+ rows.append(row)
627
+
628
+ # PAYLOAD GUARD. The caps above bound CELLS, not bytes. Measure the page as
629
+ # it will actually ship and shed rows until it fits, reporting the shrunken
630
+ # window rather than silently serving it.
631
+ body = len(json.dumps(rows, allow_nan=False).encode("utf-8"))
632
+ if body > MAX_VIEW_BYTES and len(rows) > 1:
633
+ # ESTIMATE, never halve. Halving (200 → 100 → 50) is invisible from a
634
+ # small head and wasteful from a large one: a text-heavy frame that would
635
+ # fit 74 rows lands on 50, and the number it lands on is one the reader
636
+ # cannot predict from anything on screen.
637
+ per_row = body / len(rows)
638
+ keep = max(1, int(MAX_VIEW_BYTES // per_row))
639
+ if keep < len(rows):
640
+ rows = rows[:keep]
641
+ limit = keep
642
+ # The estimate assumes rows cost about the same; one enormous cell
643
+ # breaks that, so the halving loop stays underneath it as the
644
+ # fallback and almost never runs.
645
+ while (len(json.dumps(rows, allow_nan=False).encode("utf-8")) > MAX_VIEW_BYTES
646
+ and len(rows) > 1):
647
+ rows = rows[:max(1, len(rows) // 2)]
648
+ limit = len(rows)
649
+
650
+ # `columns` describes the WINDOWED SET only, but each description covers the
651
+ # WHOLE column — the sparkline must show the full distribution, not a page's.
652
+ columns = [describe_column(frame.iloc[:, index], display)
653
+ for index, display in zip(cidx, display_names)]
654
+
655
+ return {"name": shown_name, "nrow": int(len(frame)), "ncol": ncol,
656
+ "totalRows": total_rows,
657
+ "filtered": bool(query) or filter_count > 0, "filterCount": filter_count,
658
+ "offset": offset, "limit": limit,
659
+ "colOffset": col_offset, "colLimit": col_limit,
660
+ "limitClamped": limit < limit_req,
661
+ "colLimitClamped": col_limit < col_limit_req,
662
+ "clippedCells": clipped,
663
+ "columns": columns, "rows": rows,
664
+ "shown": len(rows), "shownCols": len(display_names)}
665
+
666
+
667
+ # ── the two entry points the kernel calls ───────────────────────────────────
668
+
669
+ def _resolve(name, namespace):
670
+ """The object behind a viewer request.
671
+
672
+ A bare identifier is a LOOKUP; anything else is evaluated, which is what
673
+ `emit_view` does (`eval(parse(text = name), globalenv())`) and is safe here
674
+ for the same reason it is safe there — this is the evaluating session, and
675
+ the string came from a name the user chose in their own notebook. The lookup
676
+ comes first so the common case runs no code at all.
677
+ """
678
+ if not isinstance(name, str) or not name.strip():
679
+ return None, "bad name"
680
+ if name in namespace:
681
+ return namespace[name], None
682
+ if name.isidentifier():
683
+ return None, "not found"
684
+ try:
685
+ return eval(name, namespace), None # noqa: S307 — see the docstring
686
+ except Exception:
687
+ return None, "not found"
688
+
689
+
690
+ def view_reply(spec: dict, namespace: dict) -> dict:
691
+ name = spec.get("name")
692
+ shown = spec.get("label") or name
693
+ obj, err = _resolve(name, namespace)
694
+ if err:
695
+ return {"name": shown, "error": err}
696
+ return view_payload(obj, shown,
697
+ offset=spec.get("offset"), limit=spec.get("limit"),
698
+ sort=spec.get("sort"), desc=spec.get("desc"),
699
+ col_offset=spec.get("colOffset"),
700
+ col_limit=spec.get("colLimit"),
701
+ query=spec.get("query"), filters=spec.get("filters"))
702
+
703
+
704
+ def colstats_reply(spec: dict, namespace: dict) -> dict:
705
+ """One column, profiled over the rows the GRID IS SHOWING.
706
+
707
+ Takes the same `query` and `filters` as `view`, so the panel describes the
708
+ visible data rather than a different population that happens to share a name.
709
+ """
710
+ name = spec.get("name")
711
+ column = spec.get("column")
712
+ column = column[0] if isinstance(column, list) and column else column
713
+ if not isinstance(column, str) or not column:
714
+ return {"name": name, "column": column, "error": "not found"}
715
+ obj, err = _resolve(name, namespace)
716
+ if err:
717
+ return {"name": name, "column": column, "error": err}
718
+ frame = to_frame(obj)
719
+ if frame is None:
720
+ return {"name": name, "column": column, "error": "not found"}
721
+ total = int(len(frame))
722
+ frame, count, query = view_filter(frame, spec.get("query"), spec.get("filters"))
723
+ names = [str(c) for c in frame.columns]
724
+ # The viewer truncates long column names for display; match on the truncated
725
+ # name too, or a card opened from a clipped header can never find its column.
726
+ if column in names:
727
+ hit = names.index(column)
728
+ else:
729
+ clipped = [n[:MAX_VIEW_LABEL_CHARS] for n in names]
730
+ hit = clipped.index(column) if column in clipped else -1
731
+ if hit < 0:
732
+ return {"name": name, "column": column, "error": "no such column"}
733
+ return {"name": name, **colstats_payload(frame.iloc[:, hit], column),
734
+ "rows": int(len(frame)), "totalRows": total,
735
+ "filtered": bool(query) or count > 0}