Datarails Table Profiling
Field-level statistics for Finance OS tables. The MCP profile tools are
thin — they return only the basic aggregates listed below. Everything
richer (percentiles, std dev, outlier flags, null rates, cardinality
interpretation) is computed by this skill, not the tool. State the
provenance of every number you present.
Tool reality check
| Tool |
What it returns |
profile_numeric_fields |
SUM, AVG, MIN, MAX, COUNT per numeric field — relayed in the backend-native DR_Values/col_keys/row_keys layout, with no aggregator label attached to individual values (see the reading rule in Step 2) |
profile_categorical_fields |
distinct-count + first 10 sample values per field (capped at 5 fields per call — pass an explicit fields list; without one it defaults to upload/mapping metadata columns, not business dimensions) |
start_aggregation_by_alias / …_by_id → poll get_aggregation_result_by_alias / …_by_id |
grouped totals; the only path to per-value frequencies and null counts |
start_distinct_values_by_alias / …_by_id → poll get_distinct_values_result_by_alias / …_by_id (pass limit to the result tool) |
the full distinct-value list for one field |
What the tools do NOT return: median, std dev, variance, percentiles,
mode, distribution shape, outlier flags, per-value frequency for
categorical fields, null counts. The skill derives these.
Workflow
Step 1: Verify Authentication
If any tool call fails with an authentication or connection error, guide
the user to connect via the Connectors UI ("+" → Connectors → Datarails →
Connect).
Async fetch — aggregations and distinct values run as start → poll. start_aggregation_by_id/_by_alias and start_distinct_values_by_id/_by_alias take the same arguments as the retired blocking calls (dimensions/metrics/filters; table id + field id, or alias + field alias) and return immediately with {"status": "pending", "handle": {...}}. Echo that handle back verbatim to the matching get_aggregation_result_by_* / get_distinct_values_result_by_* tool: a {"status": "running", "retry_after_seconds": N} response means poll again with the same handle after ~N seconds (≈5s) — it is not an error, and large jobs may take several polls; when ready, the result arrives in the familiar shape (for distinct values, pass limit to the result tool). An expired/unknown-handle error means restart with the start_* tool. Transitional fallback: if the start_* tools aren't available on the connector (older server), the blocking twins get_aggregated_data_by_* / get_distinct_values_by_* still work with the same arguments.
Truncated results. Any data tool may return {"data": [...], "truncated": true, "total_rows": N, "returned_rows": M, "guidance": "..."} when the result exceeds the response size limit (~50 KB). The data prefix is incomplete — never compute totals, shares, or trends from it, and never present it as the full result. On aggregations the top-level totals field is unaffected by truncation (computed across all groups, not just the returned prefix) — read grand totals from it instead of re-fetching. Narrow the query (fewer dimensions, more filters, fewer selected columns — or a business metric for a named KPI) and re-fetch only when the rows themselves are needed beyond the cap; with totals present, a SUM/COUNT/MIN/MAX grand total never requires a re-fetch or chunking by dimension (AVG, COUNT_UNIQUE and UNIQUE_VALUES never read totals — true average = SUM total ÷ COUNT total from two calls; true distinct count = the distinct-values tools). A truncated response without totals (pre-rollout cache) cannot answer a grand-total question from its prefix. Re-run the aggregation once — a fresh run may miss the stale entry and return totals. If the re-run still carries no totals, stop re-running and fall back to narrowing or chunking by dimension until the responses are complete, then sum those rows. Never total the prefix.
Step 2: Resolve the table, then gather baseline aggregates
If you already identified this table and its fields earlier in THIS
conversation, reuse them. Discovery is cheap but not free.
Resolve the table. If you were handed a name/alias rather than a numeric
id, call list_data_models first — each entry carries both the numeric
id (for the by-id tools) and the alias (for the by-alias tools; empty
when a table has no alias). Prefer the alias path when an alias exists.
Field schema. If the table has an alias, list_aliased_fields(<alias>)
(business-friendly aliases); otherwise get_fields_by_id(<table_id>)
(capture each field's numeric id — the by-id tools address fields by id).
profile_numeric_fields(table_id) — per-field SUM/AVG/MIN/MAX/COUNT.
Reading the profiling response. The response is not a labeled
per-field dict — it arrives in the backend-native aggregate layout
(DR_Values / col_keys / row_keys arrays), with values possibly
duplicated per stat and no aggregator label attached to individual
values. Inspect the keys and map every value to its statistic via
those keys before labeling anything. NEVER present a number as
MIN/MAX/AVG/COUNT without confirming which key it belongs to —
mislabeling here (e.g. calling a MAX an AVG) silently corrupts every
downstream derivation. If the mapping is ambiguous, cross-check ONE
field with a direct start_aggregation_by_alias / …_by_id call →
poll the matching get_aggregation_result_by_* (handle) until ready
(async-fetch pattern) — MIN and MAX in two separate calls (one
aggregation per field per call) — and use that to anchor the
interpretation before deriving outliers or ranges from it.
profile_categorical_fields(table_id, fields=[...]) — always pass an
explicit fields list of business dimensions chosen from the field
schema discovered in item 2 (account-hierarchy levels, scenario,
entity/department-like fields, dates). Called without fields, the
tool defaults to the table's upload/mapping metadata columns
(tab/label/user/mapper-style bookkeeping fields) — profiling those
tells the user nothing about their data. Never call it bare. The tool
also silently drops fields beyond 5 per call. Use this to get distinct
counts and sample values.
For per-value frequency or null counts: start_aggregation_by_alias
(or …_by_id) grouping by the field with COUNT as the metric →
poll get_aggregation_result_by_alias / …_by_id (handle) until
ready (async-fetch pattern).
For the full distinct-value list of a single field:
start_distinct_values_by_alias(<alias>, <field_alias>) (or
start_distinct_values_by_id(<table_id>, <field_id>)) → poll
get_distinct_values_result_by_alias / …_by_id (handle, limit)
until ready (async-fetch pattern). If a distinct call errors, fall
back to sampling rows via get_data_by_alias /
get_data_by_id (small limit, project just that field) and dedupe.
Alias coverage is per field, not per table. A table having an alias does not mean its fields are aliased — real orgs often expose only a handful of aliased fields (e.g. ~5 of ~185 on a mapped financials table), and the load-bearing fields (amount, scenario, account groups, dates) are frequently not among them. Treat the alias/by-id choice per field: get_fields_by_id(<id>) returns every field with its numeric id and its alias (empty if none). Address a field by alias (via the *_by_alias tools) when it has one, else by numeric id (via the *_by_id tools). By-id always works — never abandon the query because the aliased set is thin.
Step 3: Derive richer stats client-side
Range / outlier flags (numeric)
- From the
profile_numeric_fields call you have MIN, MAX, AVG, COUNT —
each mapped to its statistic via the response keys (the reading rule
in Step 2). Do not derive bands from an unmapped or ambiguous value.
- Approximate spread =
MAX - MIN; flag values beyond
[AVG - spread, AVG + spread] as out-of-band candidates. This is a
coarse substitute for |z| > 3 — true std dev is not available.
- These are table-level data statistics, not financial figures. The
whole-history scan deliberately mixes all dates and all scenarios — it
profiles the data (distribution, nulls, cardinality), and truncating the
window would hide exactly the out-of-range values a profile exists to
surface. Never present a profiled SUM/AVG as a P&L number (the CLAUDE.md
period-scoping rule applies to financial reporting, and this output is
labeled accordingly); when the user wants a business-meaningful statistic,
re-run the aggregation with an explicit scenario + date-range filter and
label that window.
- These bands are computed over the table's WHOLE history (no period
scope).
/dr-anomalies computes the same band scoped to the latest
complete fiscal year, so the two will report different counts — label
the window in the output.
- Pooling scenarios widens the bands — say so, and offer to split. On a
table carrying plan and actuals rows together,
MAX - MIN spans both, so
the band is wider than any single scenario's and a real actuals outlier can
fall inside it. State which scenarios the profile pooled (discover them via
distinct values of the scenario-like field — never assume a name exists),
and when the numbers matter, re-run per scenario by adding the scenario
field as a grouping dimension or filtering to one value.
- To surface the specific flagged rows you can filter directly:
get_data_by_alias(<alias>, select=[...], filters=[{"name": <amount_alias>, "values": {"type": "advanced", "val": [{"condition": "gt", "value": "<upper_band>"}]}}]) (or the by-id twin, or an or-chained lt for the
low tail). Advanced comparison filters are supported.
- For tighter bounds, call
start_aggregation_by_alias (or …_by_id) with
the numeric field bucketed into deciles or sign×magnitude buckets → poll
get_aggregation_result_by_* (handle) until ready; the
per-bucket row counts give a usable distribution shape.
Percentiles (numeric)
- Not returned by the tool. Approximate via
start_aggregation_by_alias
(or …_by_id) with a sign×magnitude bucketing of the numeric field →
poll get_aggregation_result_by_* (handle) until ready;
cumulative row counts across buckets give approximate P25/P50/P75/P95.
- If the user needs exact percentiles, say so honestly: the only way is to
pull every value (
get_data_by_alias / get_data_by_id page at ≤500
rows/page), which is rarely the whole table.
Null counts and rates
start_aggregation_by_alias (or …_by_id) with the field as a dimension
→ poll get_aggregation_result_by_* (handle) until ready —
surfaces the null/blank bucket alongside named values; divide its COUNT by
the total to get the rate. (Or filter directly with an advanced is null
condition.)
Per-value frequencies (categorical)
profile_categorical_fields returns a sample of values but no
counts. Get counts via start_aggregation_by_alias (or …_by_id)
grouped by the field → poll get_aggregation_result_by_* until ready.
Cardinality interpretation
- Compute
distinct_count / total_rows. Conventional bands:
< 0.001 very low, < 0.05 low, < 0.5 medium, ≥ 0.5 high.
High cardinality on a column that should be a key (or that should
be a dimension table) is worth flagging.
Step 4: Present results
When showing derived statistics, annotate which tool call sourced the
inputs. Always be honest about which numbers are exact (returned by the
tool) versus approximated (computed by this skill from bucketed
aggregates).
Arguments
| Argument |
Description |
<table_id> |
Required — the table to profile |
--numeric |
Profile only numeric fields |
--categorical |
Profile only categorical fields |
--field <name> |
Profile a specific field in depth |
--fields <a,b,c> |
Profile specific fields (comma-separated) |
Example Interactions
(Illustrative — table ids, field names, and figures below are invented;
your org's tables, fields, and values will differ. Stat annotations like
(tool: MIN, MAX) are only legitimate after key-mapping the
DR_Values/col_keys/row_keys response per the reading rule in Step 2 —
the tool itself does not label values.)
User: "/dr-profile 999901"
The narrative below is the target presentation. Annotate every line with
its source so the user can re-derive it.
📊 Profile: GL Transactions (ID: 999901)
═══════════════════════════════════════════════════
📈 NUMERIC FIELDS (8 columns) (from profile_numeric_fields)
═══════════════════════════════════════════════════
amount
├── Range: -1,250,000 to 8,750,000 (tool: MIN, MAX)
├── Mean: 45,231 (tool: AVG)
├── Count: 125,432 (tool: COUNT)
├── Null rate: derived from start_aggregation_by_alias → poll
│ get_aggregation_result_by_alias (dimensions=[amount],
│ count null bucket / total) → 0%
├── Range-band outliers: 127 values outside [AVG±spread]
│ (skill heuristic, not z-score; std dev unavailable)
└── Decile bucketing: see start_aggregation_by_alias(amount, deciles)
→ get_aggregation_result_by_alias for an approximate distribution.
quantity
├── Range: 0 to 10,000 (tool)
├── Mean: 125 (tool)
├── Null rate: 0.98% (derived from aggregate)
└── Distribution: see start_aggregation_by_alias → result poll for buckets.
═══════════════════════════════════════════════════
📋 CATEGORICAL FIELDS (5 of 16 — tool caps at 5)
═══════════════════════════════════════════════════
account_code (156 distinct, from profile_categorical_fields)
├── Sample values: 4000-100, 4000-200, 5100-300, ...
├── Frequencies: derived from start_aggregation_by_alias → result poll
│ Top: 4000-100 (10.0%), 4000-200 (6.6%), 5100-300 (6.3%)
└── Cardinality: 156 / 125,432 = 0.12% → LOW (skill interpretation)
department (12 distinct)
├── Top: Sales (35%), Marketing (22%), Operations (18%)
├── Null rate: 0.71% (derived from aggregate)
└── Cardinality: VERY LOW (12 / 125,432 = 0.01%)
vendor_id (45,231 distinct → HIGH CARDINALITY ⚠️)
├── Cardinality: 36% — likely a key or dimension reference.
├── Null rate: 1.87% (derived)
└── Recommendation (skill): consider whether vendor_id should be
joined against a vendor master.
Note: profile_categorical_fields only returns 5 fields per call.
The remaining 11 categorical fields were not profiled in this run —
re-invoke with --fields a,b,c to cover them.
User: "/dr-profile 999901 --numeric"
📈 Numeric Profile: GL Transactions
Source: profile_numeric_fields(999901) for MIN/MAX/AVG/COUNT;
start_aggregation_by_alias → get_aggregation_result_by_alias
per field for null rates and decile distributions.
| Field | Min | Max | Mean | Count | Null % | Out-of-band |
|-----------|--------|---------|---------|---------|---------|-------------|
| amount | -1.25M | 8.75M | 45,231 | 125,432 | 0% | 127 (skill) |
| quantity | 0 | 10,000 | 125 | 124,198 | 0.98% | 23 (skill) |
| unit_cost | 0.01 | 15,000 | 89.50 | 125,432 | 0% | 45 (skill) |
"Out-of-band" uses the range heuristic, not std dev. For tighter
bounds on a specific field run /dr-profile 999901 --field <name>.
User: "/dr-profile 999901 --field amount"
📊 Field Profile: amount (GL Transactions)
Source: profile_numeric_fields(999901) +
start_aggregation_by_alias(<alias>, dimensions=[amount-bucket], COUNT)
→ poll get_aggregation_result_by_alias(handle) until ready
Type: DECIMAL (schema)
Tool-provided stats:
├── Count: 125,432
├── Min: -1,250,000
├── Max: 8,750,000
├── Mean (AVG): 45,231.45
└── Sum: 5,672,541,330.00
Skill-derived (from bucketed aggregate):
├── Null rate: 0%
├── Approximate percentiles (sign × magnitude buckets):
│ P25 ≈ 5,000 P50 ≈ 12,500 P75 ≈ 35,000 P95 ≈ 250,000
├── Distribution: right-skewed (small values dominant, long
│ positive tail).
└── Range-band outliers: 127 values outside [AVG ± (MAX-MIN)].
115 above, 12 below. This is a coarser flag than a true
z-score test because std dev is not returned by the API.
💡 Recommendation: Investigate the 127 flagged transactions.
Workflow:
1. Fetch the flagged rows directly with an advanced comparison
filter: get_data_by_alias(<alias>, select=[...], filters=[{"name":
<amount_alias>, "values": {"type": "advanced", "val": [{"condition":
"gt", "value": "<upper_band>"}]}}]) (or the by-id twin; or-chain a
`lt` for the low tail).
2. Or bucket the field via start_aggregation_by_alias (group by
sign and order-of-magnitude; poll get_aggregation_result_by_alias
until ready) to surface specific values, then hand
them to /dr-query for deeper investigation.
Data Quality Indicators
| Symbol |
Meaning |
| ⚠️ |
Potential issue requiring attention |
| ❌ |
Data quality problem detected |
| ✅ |
Field looks healthy |
| 📊 |
Statistical insight (skill-derived) |
Tips
- Profile before running
/dr-anomalies so you understand the baseline.
- A high null rate (>5%) usually points to a data-collection problem.
- High cardinality on a non-key column may indicate a missing
dimension table.
- "Out-of-band" flags from this skill are heuristic — verify against
business rules before treating them as errors.
- Be explicit when answering: "MIN was returned by the tool;
P25 is approximated from a bucketed aggregate."
- Never label a profiling number MIN/MAX/AVG/COUNT until you've mapped
it via the response keys; when unsure, anchor with one aggregation
start→poll cross-check (
start_aggregation_by_* →
get_aggregation_result_by_*; MIN and MAX in separate calls).
- Never call
profile_categorical_fields without fields — the bare
call profiles upload/mapping metadata columns, not business data.
Related Skills
/dr-tables — discover available tables and call start_aggregation_by_alias → get_aggregation_result_by_alias
/dr-anomalies — same client-side computation pattern, focused on
data-quality findings
/dr-query — fetch specific rows once you know the IDs
1---2name: dr-profile3description: Profile Datarails Finance OS table fields — how is a field distributed? Per-field statistics over the table's whole history — ranges, approximate percentiles, null rates, cardinality interpretation, and range-outlier flags — no severity ranking, no period scope. The MCP tools return baseline aggregates (SUM/AVG/MIN/MAX/COUNT for numeric, distinct-value samples for categorical); this skill derives the statistics client-side. For severity-ranked data-quality findings scoped to the latest fiscal year, use the anomalies skill.4---56# Datarails Table Profiling78Field-level statistics for Finance OS tables. The MCP profile tools are9thin — they return only the basic aggregates listed below. Everything10richer (percentiles, std dev, outlier flags, null rates, cardinality11interpretation) is computed by this skill, not the tool. State the12provenance of every number you present.1314## Tool reality check1516| Tool | What it returns |17|---|---|18| `profile_numeric_fields` | `SUM, AVG, MIN, MAX, COUNT` per numeric field — relayed in the backend-native `DR_Values`/`col_keys`/`row_keys` layout, with **no aggregator label attached to individual values** (see the reading rule in Step 2) |19| `profile_categorical_fields` | distinct-count + first 10 sample values per field (capped at 5 fields per call — pass an explicit `fields` list; **without one it defaults to upload/mapping metadata columns, not business dimensions**) |20| `start_aggregation_by_alias` / `…_by_id` → poll `get_aggregation_result_by_alias` / `…_by_id` | grouped totals; the only path to per-value frequencies and null counts |21| `start_distinct_values_by_alias` / `…_by_id` → poll `get_distinct_values_result_by_alias` / `…_by_id` (pass `limit` to the result tool) | the full distinct-value list for one field |2223What the tools do NOT return: median, std dev, variance, percentiles,24mode, distribution shape, outlier flags, per-value frequency for25categorical fields, null counts. The skill derives these.2627## Workflow2829### Step 1: Verify Authentication3031If any tool call fails with an authentication or connection error, guide32the user to connect via the Connectors UI ("+" → Connectors → Datarails →33Connect).3435> **Async fetch — aggregations and distinct values run as start → poll.** `start_aggregation_by_id`/`_by_alias` and `start_distinct_values_by_id`/`_by_alias` take the same arguments as the retired blocking calls (dimensions/metrics/filters; table id + field id, or alias + field alias) and return immediately with `{"status": "pending", "handle": {...}}`. Echo that `handle` back verbatim to the matching `get_aggregation_result_by_*` / `get_distinct_values_result_by_*` tool: a `{"status": "running", "retry_after_seconds": N}` response means poll again with the same handle after ~N seconds (≈5s) — it is not an error, and large jobs may take several polls; when ready, the result arrives in the familiar shape (for distinct values, pass `limit` to the result tool). An expired/unknown-handle error means restart with the `start_*` tool. *Transitional fallback:* if the `start_*` tools aren't available on the connector (older server), the blocking twins `get_aggregated_data_by_*` / `get_distinct_values_by_*` still work with the same arguments.3637> **Truncated results.** Any data tool may return `{"data": [...], "truncated": true, "total_rows": N, "returned_rows": M, "guidance": "..."}` when the result exceeds the response size limit (~50 KB). The `data` prefix is **incomplete** — never compute totals, shares, or trends from it, and never present it as the full result. On aggregations the top-level `totals` field is **unaffected by truncation** (computed across all groups, not just the returned prefix) — read grand totals from it instead of re-fetching. Narrow the query (fewer dimensions, more filters, fewer selected columns — or a business metric for a named KPI) and re-fetch **only when the rows themselves are needed** beyond the cap; with `totals` present, a SUM/COUNT/MIN/MAX grand total never requires a re-fetch or chunking by dimension (AVG, COUNT_UNIQUE and UNIQUE_VALUES never read `totals` — true average = SUM total ÷ COUNT total from two calls; true distinct count = the distinct-values tools). A truncated response **without** `totals` (pre-rollout cache) cannot answer a grand-total question from its prefix. Re-run the aggregation **once** — a fresh run may miss the stale entry and return `totals`. If the re-run still carries no `totals`, stop re-running and fall back to narrowing or chunking by dimension until the responses are complete, then sum those rows. Never total the prefix.3839### Step 2: Resolve the table, then gather baseline aggregates4041**If you already identified this table and its fields earlier in THIS42conversation, reuse them.** Discovery is cheap but not free.43441. Resolve the table. If you were handed a name/alias rather than a numeric45 id, call `list_data_models` first — each entry carries **both** the numeric46 `id` (for the by-id tools) and the `alias` (for the by-alias tools; empty47 when a table has no alias). **Prefer the alias path when an alias exists.**482. Field schema. If the table has an alias, `list_aliased_fields(<alias>)`49 (business-friendly aliases); otherwise `get_fields_by_id(<table_id>)`50 (capture each field's numeric `id` — the by-id tools address fields by id).513. `profile_numeric_fields(table_id)` — per-field SUM/AVG/MIN/MAX/COUNT.5253 **Reading the profiling response.** The response is not a labeled54 per-field dict — it arrives in the backend-native aggregate layout55 (`DR_Values` / `col_keys` / `row_keys` arrays), with values possibly56 duplicated per stat and **no aggregator label attached to individual57 values**. Inspect the keys and map every value to its statistic via58 those keys before labeling anything. NEVER present a number as59 MIN/MAX/AVG/COUNT without confirming which key it belongs to —60 mislabeling here (e.g. calling a MAX an AVG) silently corrupts every61 downstream derivation. If the mapping is ambiguous, cross-check ONE62 field with a direct `start_aggregation_by_alias` / `…_by_id` call →63 poll the matching `get_aggregation_result_by_*` (handle) until ready64 (async-fetch pattern) — MIN and MAX in two separate calls (one65 aggregation per field per call) — and use that to anchor the66 interpretation before deriving outliers or ranges from it.67684. `profile_categorical_fields(table_id, fields=[...])` — **always pass an69 explicit `fields` list of business dimensions chosen from the field70 schema discovered in item 2** (account-hierarchy levels, scenario,71 entity/department-like fields, dates). Called without `fields`, the72 tool defaults to the table's upload/mapping **metadata** columns73 (tab/label/user/mapper-style bookkeeping fields) — profiling those74 tells the user nothing about their data. Never call it bare. The tool75 also silently drops fields beyond 5 per call. Use this to get distinct76 counts and sample values.775. For per-value frequency or null counts: `start_aggregation_by_alias`78 (or `…_by_id`) grouping by the field with `COUNT` as the metric →79 poll `get_aggregation_result_by_alias` / `…_by_id` (handle) until80 ready (async-fetch pattern).816. For the full distinct-value list of a single field:82 `start_distinct_values_by_alias(<alias>, <field_alias>)` (or83 `start_distinct_values_by_id(<table_id>, <field_id>)`) → poll84 `get_distinct_values_result_by_alias` / `…_by_id` (handle, `limit`)85 until ready (async-fetch pattern). If a distinct call errors, fall86 back to sampling rows via `get_data_by_alias` /87 `get_data_by_id` (small `limit`, project just that field) and dedupe.8889> **Alias coverage is per field, not per table.** A table having an alias does *not* mean its fields are aliased — real orgs often expose only a handful of aliased fields (e.g. ~5 of ~185 on a mapped financials table), and the load-bearing fields (`amount`, `scenario`, account groups, dates) are frequently *not* among them. Treat the alias/by-id choice **per field**: `get_fields_by_id(<id>)` returns every field with its numeric `id` and its `alias` (empty if none). Address a field by alias (via the `*_by_alias` tools) when it has one, else by numeric `id` (via the `*_by_id` tools). By-id always works — never abandon the query because the aliased set is thin.9091### Step 3: Derive richer stats client-side9293**Range / outlier flags (numeric)**94- From the `profile_numeric_fields` call you have `MIN, MAX, AVG, COUNT` —95 **each mapped to its statistic via the response keys** (the reading rule96 in Step 2). Do not derive bands from an unmapped or ambiguous value.97- Approximate spread = `MAX - MIN`; flag values beyond98 `[AVG - spread, AVG + spread]` as out-of-band candidates. This is a99 coarse substitute for `|z| > 3` — true std dev is not available.100- **These are table-level data statistics, not financial figures.** The101 whole-history scan deliberately mixes all dates and all scenarios — it102 profiles the *data* (distribution, nulls, cardinality), and truncating the103 window would hide exactly the out-of-range values a profile exists to104 surface. Never present a profiled SUM/AVG as a P&L number (the CLAUDE.md105 period-scoping rule applies to financial reporting, and this output is106 labeled accordingly); when the user wants a business-meaningful statistic,107 re-run the aggregation with an explicit scenario + date-range filter and108 label that window.109- **These bands are computed over the table's WHOLE history (no period110 scope).** `/dr-anomalies` computes the same band scoped to the latest111 complete fiscal year, so the two will report different counts — label112 the window in the output.113- **Pooling scenarios widens the bands — say so, and offer to split.** On a114 table carrying plan and actuals rows together, `MAX - MIN` spans both, so115 the band is wider than any single scenario's and a real actuals outlier can116 fall inside it. State which scenarios the profile pooled (discover them via117 distinct values of the scenario-like field — never assume a name exists),118 and when the numbers matter, re-run per scenario by adding the scenario119 field as a grouping dimension or filtering to one value.120- To surface the specific flagged rows you can filter directly:121 `get_data_by_alias(<alias>, select=[...], filters=[{"name": <amount_alias>,122 "values": {"type": "advanced", "val": [{"condition": "gt", "value":123 "<upper_band>"}]}}])` (or the by-id twin, or an `or`-chained `lt` for the124 low tail). Advanced comparison filters are supported.125- For tighter bounds, call `start_aggregation_by_alias` (or `…_by_id`) with126 the numeric field bucketed into deciles or sign×magnitude buckets → poll127 `get_aggregation_result_by_*` (handle) until ready; the128 per-bucket row counts give a usable distribution shape.129130**Percentiles (numeric)**131- Not returned by the tool. Approximate via `start_aggregation_by_alias`132 (or `…_by_id`) with a sign×magnitude bucketing of the numeric field →133 poll `get_aggregation_result_by_*` (handle) until ready;134 cumulative row counts across buckets give approximate P25/P50/P75/P95.135- If the user needs exact percentiles, say so honestly: the only way is to136 pull every value (`get_data_by_alias` / `get_data_by_id` page at ≤500137 rows/page), which is rarely the whole table.138139**Null counts and rates**140- `start_aggregation_by_alias` (or `…_by_id`) with the field as a dimension141 → poll `get_aggregation_result_by_*` (handle) until ready —142 surfaces the null/blank bucket alongside named values; divide its COUNT by143 the total to get the rate. (Or filter directly with an advanced `is null`144 condition.)145146**Per-value frequencies (categorical)**147- `profile_categorical_fields` returns a sample of values but no148 counts. Get counts via `start_aggregation_by_alias` (or `…_by_id`)149 grouped by the field → poll `get_aggregation_result_by_*` until ready.150151**Cardinality interpretation**152- Compute `distinct_count / total_rows`. Conventional bands:153 `< 0.001` very low, `< 0.05` low, `< 0.5` medium, `≥ 0.5` high.154 High cardinality on a column that should be a key (or that should155 be a dimension table) is worth flagging.156157### Step 4: Present results158159When showing derived statistics, annotate which tool call sourced the160inputs. Always be honest about which numbers are exact (returned by the161tool) versus approximated (computed by this skill from bucketed162aggregates).163164## Arguments165166| Argument | Description |167|----------|-------------|168| `<table_id>` | Required — the table to profile |169| `--numeric` | Profile only numeric fields |170| `--categorical` | Profile only categorical fields |171| `--field <name>` | Profile a specific field in depth |172| `--fields <a,b,c>` | Profile specific fields (comma-separated) |173174## Example Interactions175176(Illustrative — table ids, field names, and figures below are invented;177your org's tables, fields, and values will differ. Stat annotations like178`(tool: MIN, MAX)` are only legitimate after key-mapping the179`DR_Values`/`col_keys`/`row_keys` response per the reading rule in Step 2 —180the tool itself does not label values.)181182**User: "/dr-profile 999901"**183184The narrative below is the target presentation. Annotate every line with185its source so the user can re-derive it.186187```188📊 Profile: GL Transactions (ID: 999901)189190═══════════════════════════════════════════════════191📈 NUMERIC FIELDS (8 columns) (from profile_numeric_fields)192═══════════════════════════════════════════════════193194amount195├── Range: -1,250,000 to 8,750,000 (tool: MIN, MAX)196├── Mean: 45,231 (tool: AVG)197├── Count: 125,432 (tool: COUNT)198├── Null rate: derived from start_aggregation_by_alias → poll199│ get_aggregation_result_by_alias (dimensions=[amount],200│ count null bucket / total) → 0%201├── Range-band outliers: 127 values outside [AVG±spread]202│ (skill heuristic, not z-score; std dev unavailable)203└── Decile bucketing: see start_aggregation_by_alias(amount, deciles)204 → get_aggregation_result_by_alias for an approximate distribution.205206quantity207├── Range: 0 to 10,000 (tool)208├── Mean: 125 (tool)209├── Null rate: 0.98% (derived from aggregate)210└── Distribution: see start_aggregation_by_alias → result poll for buckets.211212═══════════════════════════════════════════════════213📋 CATEGORICAL FIELDS (5 of 16 — tool caps at 5)214═══════════════════════════════════════════════════215216account_code (156 distinct, from profile_categorical_fields)217├── Sample values: 4000-100, 4000-200, 5100-300, ...218├── Frequencies: derived from start_aggregation_by_alias → result poll219│ Top: 4000-100 (10.0%), 4000-200 (6.6%), 5100-300 (6.3%)220└── Cardinality: 156 / 125,432 = 0.12% → LOW (skill interpretation)221222department (12 distinct)223├── Top: Sales (35%), Marketing (22%), Operations (18%)224├── Null rate: 0.71% (derived from aggregate)225└── Cardinality: VERY LOW (12 / 125,432 = 0.01%)226227vendor_id (45,231 distinct → HIGH CARDINALITY ⚠️)228├── Cardinality: 36% — likely a key or dimension reference.229├── Null rate: 1.87% (derived)230└── Recommendation (skill): consider whether vendor_id should be231 joined against a vendor master.232233Note: profile_categorical_fields only returns 5 fields per call.234The remaining 11 categorical fields were not profiled in this run —235re-invoke with --fields a,b,c to cover them.236```237238**User: "/dr-profile 999901 --numeric"**239240```241📈 Numeric Profile: GL Transactions242243Source: profile_numeric_fields(999901) for MIN/MAX/AVG/COUNT;244 start_aggregation_by_alias → get_aggregation_result_by_alias245 per field for null rates and decile distributions.246247| Field | Min | Max | Mean | Count | Null % | Out-of-band |248|-----------|--------|---------|---------|---------|---------|-------------|249| amount | -1.25M | 8.75M | 45,231 | 125,432 | 0% | 127 (skill) |250| quantity | 0 | 10,000 | 125 | 124,198 | 0.98% | 23 (skill) |251| unit_cost | 0.01 | 15,000 | 89.50 | 125,432 | 0% | 45 (skill) |252253"Out-of-band" uses the range heuristic, not std dev. For tighter254bounds on a specific field run /dr-profile 999901 --field <name>.255```256257**User: "/dr-profile 999901 --field amount"**258259```260📊 Field Profile: amount (GL Transactions)261262Source: profile_numeric_fields(999901) +263 start_aggregation_by_alias(<alias>, dimensions=[amount-bucket], COUNT)264 → poll get_aggregation_result_by_alias(handle) until ready265266Type: DECIMAL (schema)267Tool-provided stats:268├── Count: 125,432269├── Min: -1,250,000270├── Max: 8,750,000271├── Mean (AVG): 45,231.45272└── Sum: 5,672,541,330.00273274Skill-derived (from bucketed aggregate):275├── Null rate: 0%276├── Approximate percentiles (sign × magnitude buckets):277│ P25 ≈ 5,000 P50 ≈ 12,500 P75 ≈ 35,000 P95 ≈ 250,000278├── Distribution: right-skewed (small values dominant, long279│ positive tail).280└── Range-band outliers: 127 values outside [AVG ± (MAX-MIN)].281 115 above, 12 below. This is a coarser flag than a true282 z-score test because std dev is not returned by the API.283284💡 Recommendation: Investigate the 127 flagged transactions.285 Workflow:286 1. Fetch the flagged rows directly with an advanced comparison287 filter: get_data_by_alias(<alias>, select=[...], filters=[{"name":288 <amount_alias>, "values": {"type": "advanced", "val": [{"condition":289 "gt", "value": "<upper_band>"}]}}]) (or the by-id twin; or-chain a290 `lt` for the low tail).291 2. Or bucket the field via start_aggregation_by_alias (group by292 sign and order-of-magnitude; poll get_aggregation_result_by_alias293 until ready) to surface specific values, then hand294 them to /dr-query for deeper investigation.295```296297## Data Quality Indicators298299| Symbol | Meaning |300|--------|---------|301| ⚠️ | Potential issue requiring attention |302| ❌ | Data quality problem detected |303| ✅ | Field looks healthy |304| 📊 | Statistical insight (skill-derived) |305306## Tips307308- Profile before running `/dr-anomalies` so you understand the baseline.309- A high null rate (>5%) usually points to a data-collection problem.310- High cardinality on a non-key column may indicate a missing311 dimension table.312- "Out-of-band" flags from this skill are heuristic — verify against313 business rules before treating them as errors.314- Be explicit when answering: "MIN was returned by the tool;315 P25 is approximated from a bucketed aggregate."316- Never label a profiling number MIN/MAX/AVG/COUNT until you've mapped317 it via the response keys; when unsure, anchor with one aggregation318 start→poll cross-check (`start_aggregation_by_*` →319 `get_aggregation_result_by_*`; MIN and MAX in separate calls).320- Never call `profile_categorical_fields` without `fields` — the bare321 call profiles upload/mapping metadata columns, not business data.322323## Related Skills324325- `/dr-tables` — discover available tables and call `start_aggregation_by_alias` → `get_aggregation_result_by_alias`326- `/dr-anomalies` — same client-side computation pattern, focused on327 data-quality findings328- `/dr-query` — fetch specific rows once you know the IDs