A way for the agent to register new per-column summary stats at runtime and have them appear in buckaroo's stats table on the next render, with no buckaroo restart and no parallel registration system.
The phrase "the LLM knows xorq" is load-bearing: we assume the agent
can write a valid col -> ibis_expr function. We do not invent a DSL.
A stat is a single Python function that takes one ibis column expression
and returns one ibis Value (scalar or struct). The agent writes the
function, calls an MCP tool, the function lands on disk under the
project, and buckaroo picks it up the next time a /load_expr session
is created for an entry. No new endpoint to add the stat at the wire
level; no hot-reload inside an existing session in V1.
Example a Claude Code session might produce:
# ~/.tallyman/projects/<name>/stats/percent_emails.py
def compute(col):
return (col.cast("string").contains("@").sum() / col.count()).name("percent_emails")This is exactly the function that goes into a generated ColAnalysis
subclass — no transformation, no parsing, no DSL. It compiles through
buckaroo's existing compile_batch_expr (PR #766) into the same batched
aggregate that drives every built-in stat, so the push-down model is
preserved end-to-end.
| Decision | Rationale |
|---|---|
| Stats are pure Python functions, not a DSL | The agent knows xorq. Inventing a parallel grammar would only restrict it. The function signature (col) -> Value is the contract. |
| One file per stat | Trivial CRUD: write/replace/delete by filename. Mirrors how catalog/entries/<hash>/ is organised. |
| Stats live under the project directory | The project is the artifact. A handed-off project carries its stats. tallyman serve shows them unchanged. |
Buckaroo picks them up at /load_expr time, not via a new WS message |
One scan per session-load is enough — every relevant render path goes through that. No new wire shape, no need to coordinate with running sessions. |
Restricted exec namespace (no __builtins__, only ibis / xorq) |
Not a real sandbox, but closes the function's reachable surface — no __import__, no open, no filesystem. Return-type checked. |
Scalar OR struct return is fine; framework derives provides_defaults from the schema |
Matches what built-in ColAnalysis classes already do. |
~/.tallyman/projects/<name>/
├── catalog/ # unchanged
├── stats/ # NEW
│ ├── percent_emails.py # one function per file, def compute(col): ...
│ ├── n_distinct_log10.py
│ └── _disabled/ # (optional) parked stats kept around for history
│ └── pct_negatives.py
└── tallyman.toml # (optional) list of enabled stats, or "all"
Three new MCP tools on the tallyman-notebooks side:
| Tool | Behaviour |
|---|---|
catalog_add_summary_stat(name, source) |
Validate the source (parse + restricted-exec dry-run on a tiny in-memory table), write stats/<name>.py, return success. Errors if the function doesn't return an ibis.Value, or if name collides with a built-in stat. |
catalog_remove_summary_stat(name) |
Move stats/<name>.py → stats/_disabled/<name>.py (keeps the source around — see open question 3). |
catalog_list_summary_stats() |
Return [{name, source, schema, applies_to_dtypes}]. |
The companion's entry-detail page gains a small "custom stats" disclosure under the existing build-artifacts section, listing the active stats and the file path for each. No mutation UI in V1 — adding/removing stats is through the agent.
One new helper in ~/code/buckaroo/buckaroo/server/xorq_loading.py,
called from LoadExprHandler.post between load_expr_build_dir and
the XorqServerDataflow construction:
def load_project_stat_klasses(build_dir: str) -> list[type[ColAnalysis]]:
"""Scan <project_root>/stats/*.py, generate one ColAnalysis subclass per
function, return the list. Caller appends to analysis_klasses."""The handler reads the project root from a new field in the /load_expr
request body (project_root: str) — tallyman-notebooks passes it, headless
callers omit it and get the existing built-in stats only. Each generated
class is a thin shim:
class _Generated_percent_emails(ColAnalysis):
requires_summary = []
provides_defaults = {"percent_emails": np.nan}
summary_stats_display = ["percent_emails"]
@staticmethod
def computed_summary(_sd, col):
return {"percent_emails": _compute_percent_emails(col)}…where _compute_percent_emails is the function exec'd out of the
user's .py file, in a namespace of {'__builtins__': {}, 'ibis': ibis, 'xorq': xorq}. The dataflow's analysis_klasses is
builtins + generated — built-ins win on name collision (the MCP
tool already rejects collisions upstream, but defence in depth).
No change to compile_batch_expr or the stats pipeline — generated
classes go through the same path as the built-ins.
The buckaroo subprocess is the data process. execing source from disk
into it is the obvious risk. Mitigations:
- Restricted globals on exec.
{'__builtins__': {}, 'ibis': ibis, 'xorq': xorq}. No__import__, noopen, noeval. The function literally cannot reach those names. - Return-type check. Reject anything that isn't
ibis.expr.types.Value. A function that side-effects but doesn't return an ibis expr fails the type check on first dry run. - Dry-run on add.
catalog_add_summary_statruns the function against a 1-row in-memory ibis table at write time. Syntax errors and shape errors fail at the MCP call, not later in the buckaroo subprocess. - No
__getattribute__walk on the input column. The column argument is a real ibisColumn— its attribute surface is what it is. Reachingcol.__class__.__bases__[0].__subclasses__()is theoretically possible and a known sandbox-escape pattern, but the function still has nowhere to call arbitrary things to — no__builtins__.
This is not a real sandbox. It's a thin "no obvious foot-gun" wall. Acceptable because: the agent is trusted, the data process is local, and the project directory is the artifact (a handed-off project's stats are inspectable Python files, not opaque blobs).
Claude Code terminal tallyman-notebooks MCP buckaroo subprocess
│ │ │
│ catalog_add_summary_stat( │ │
│ "percent_emails", │ │
│ "def compute(col): ...") │ │
├──────────────────────────────────►│ │
│ │ dry-run on 1-row table │
│ │ write stats/percent_emails.py│
│ │ POST /internal/notify │
│ │ kind=stats_changed │
│ │ │
│ (later, user clicks an entry) │ │
│ │ GET /catalog/<hash> │
│ │ BuckarooManager.ensure_session│
│ ├──────────POST /load_expr────►│
│ │ body: {build_dir, project_root}
│ │ │ load_expr_build_dir(build_dir)
│ │ │ load_project_stat_klasses(project_root/stats)
│ │ │ XorqServerDataflow(expr,
│ │ │ extra_klasses=[...])
│ │ ◄─session_id────────────────┤
│ │ │
/internal/notify kind=stats_changed doesn't trigger anything beyond a
client-side SSE event today — it's there so the companion's catalog page
can show "+1 stat: percent_emails" in the build banner. Sessions already
loaded keep their old analysis_klasses until they're re-loaded
(natural: the user clicks a different entry, that session gets the new
stats; or /load_expr is re-called on the same hash, which we already
do because session caches invalidate on buckaroo restart).
This means stat changes don't propagate to a session you're currently
viewing. If that turns out to matter for the demo, V2 ships a
/refresh_session endpoint that re-runs load_project_stat_klasses and
rebuilds the dataflow's analysis_klasses in place. Out of scope V1.
Could slot between beats 4 ("verify shoe_sales by state") and 5 ("refine shoe_sales"). New beat:
"I want to know what fraction of region names look like outliers — add a custom stat called
single_letter_regionthat flags how many rows have a region with len ≤ 1."
Companion effect: the stat appears in the column-stats panel for the
region column on shoe_sales and every other entry that has a
region column. Demonstrates the agent extending the analytical
vocabulary of the environment live — a thesis-level moment for the
talk.
-
Per-column applicability.
percent_emailsonly makes sense on string columns;n_distinct_log10only on columns with a meaningful cardinality. Today'sColAnalysismodel runs every analysis on every column. Cheap option: let the function check the column type and returnibis.null()for inapplicable columns. Cleaner option: a second metadata keyapplies_to: ["string"]next tocomputethat the generator reads. Lean: optional metadata, default = run-on-all. -
Naming collisions across stats. Two custom stats both named
pct_x— the secondaddrejects. Two stats both emitting a key calledpct_x(e.g., one ispct_xand one returns a struct with apct_xfield) — also rejected at registration. The merged_sd namespace is flat; we don't try to scope. -
Removal semantics. Soft delete via
_disabled/keeps history; hard delete loses it. Lean: soft, with a separatecatalog_purge_summary_stattool if the agent wants to hard-clean. Matches the "catalog is curated, not append-only" principle from the main plan, but adds a second-class trash bin. -
Multi-column stats (e.g., "correlation of price and qty", "delta between started_at and ended_at"). Today's
ColAnalysissurface is column-at-a-time. Multi-column stats want a different shape — function takes the whole frame, returns a row-level stat or a scalar. Out of scope V1. Possible V2: a siblingstats_frame/directory with functions of signature(expr) -> Value. -
Stat failures in the batched query. If one bad stat blows up
compile_batch_expr, do we lose all the stats for that render? Today's pipeline doesn't try-each. Lean: V1 catches at the generator level (each generatedcomputed_summarywraps in try/except, returns{name: np.nan}on failure, logs once). The batched query stays whole; the bad column just shows nan + a small icon in the stats panel. -
Editing a stat without an explicit MCP call. A user opens
stats/percent_emails.pyin an editor and saves changes. Shouldtallyman runwatch the directory and re-emitstats_changed? Probably yes for ergonomics, but V1 can require the agent to re-callcatalog_add_summary_statto pick up changes. Watcher is a V2 hardening item. -
MCP tool boundary. Does
catalog_add_summary_statgo intallyman_mcpor buckaroo's own MCP surface (pip install buckaroo[mcp])? Lean: tallyman-notebooks side. The stats live under tallyman-notebooks's project directory; buckaroo just consumes a directory path. If a future buckaroo-mcp wants to mirror this, it can.
- Hot-swap of stats in already-loaded sessions. Add → click an entry → see the stat. Don't try to invalidate existing sessions.
- Multi-column / whole-frame stats.
- Per-column-type applicability rules.
- File watcher for stats-edited-outside-the-MCP-tool.
- Sharing stats across projects.
- Built-in stat overrides (renaming
meanto mean something else). Built-ins win on collision; if you want a different mean, name it something else.
-
buckaroo side (
~/code/buckarooon a feature branch offsmorgasbord/scoped-sd):buckaroo/server/xorq_loading.py: addload_project_stat_klasses(stats_dir)buckaroo/server/handlers.py:LoadExprHandler.post: accept optionalproject_root, call the new helper, passextra_klassesthrough.buckaroo/server/xorq_loading.py:XorqServerDataflow: threadextra_klassesintoanalysis_klasses.- Test: a project dir with one
stats/foo.pyproduces a session whose stats includefoo.
-
tallyman-notebooks side:
tallyman_mcp/server.py: addcatalog_add_summary_stat,catalog_remove_summary_stat,catalog_list_summary_stats.tallyman_core/: a tinysummary_stats.pymodule — write/read/list/dry-run.tallyman_companion/buckaroo_lifecycle.py:ensure_session: passproject_rootin the/load_exprbody.- Tests: dry-run rejects bad source;
/load_exprcarriesproject_root; integration test that an added stat shows up in the/api/data/<hash>response (or wherever stats land in the companion's view).
About a day's work end-to-end, mostly tests.