Pulse
Pulse is a self-describing, high-performance tabular data processing engine. It ships as a Go library (github.com/frankbardon/pulse) and as a single CLI binary (bin/pulse). Every .pulse file carries its own schema in the header, so consumers (programs, agents, and humans) can discover what a file contains without an external catalog.
The library is the primary deliverable. The CLI is a thin adapter that exposes the same operations on the command line, and an embedded MCP server (pulse mcp) exposes them to LLM agents.
Where to go from here
| If you are… | Start with |
|---|---|
| New to Pulse | Installation → Your First Cohort → CLI Tour |
| Driving Pulse from the shell | Command Line Reference |
| Embedding Pulse in a Go program | Library Embedding |
| Curious about the binary format | .pulse File Format |
| Hacking on Pulse itself | Internals and Contributing |
| Wiring Pulse into an LLM agent | MCP Integration (Pointer), then the in-binary skill pack |
LLM-facing surface
LLM agents do not read this site. Pulse exposes a Model Context Protocol server (pulse mcp) and ships 19 embedded skills under skills/ that LLMs load on demand via the pulse_skills_list and pulse_skills_get tools. The skill voice is MCP-only (tool calls, JSON payloads). This site is the human-facing counterpart — same engine, different idiom.
See How LLMs Use Pulse for a short pointer table.
Source of truth
The authoritative architectural contract for Pulse lives in the repository’s CLAUDE.md. When this site and CLAUDE.md disagree, CLAUDE.md wins; please open an issue.
- Repository: https://github.com/frankbardon/pulse
- Hosted docs: https://frankbardon.github.io/pulse/
Installation
Audience: new users who want a working pulse binary on their PATH.
This page walks through installing Pulse, the prerequisites it needs, and how to verify the install. Pulse is distributed as a single static Go binary; there is no installer, no daemon, and no config file.
LLM agents using MCP: see the
session-bootstrapskill viapulse_skills_get— it covers session bootstrap rather than local install.
Prerequisites
| Requirement | Minimum |
|---|---|
| Go toolchain | 1.24 (see go.mod) |
| OS | Linux, macOS, or Windows (anywhere Go cross-compiles) |
| Disk | A few MB for the binary; cohort files live wherever you point PULSE_DATA_DIR |
go.mod is the source of truth for the supported Go version; if it drifts
from this page the go.mod value wins.
Install with go install
The fastest path on a developer machine:
go install github.com/frankbardon/pulse/cmd/pulse@latest
This drops a pulse binary at $(go env GOBIN) (typically ~/go/bin).
Make sure that directory is on your PATH.
Pin a specific release by replacing @latest with a tag:
go install github.com/frankbardon/pulse/cmd/pulse@v0.2.0
Build from source
The same binary, built reproducibly from a checkout:
git clone https://github.com/frankbardon/pulse.git
cd pulse
make build
# Binary at ./bin/pulse
The Makefile is documented in CLAUDE.md → Build /
Env;
the relevant targets are make build, make test, make lint, and
make cover.
Configure the data directory
Pulse reads and writes .pulse files under a base directory called
PULSE_DATA_DIR. Most commands accept absolute paths and will work without
it, but pulse mcp requires the variable so the MCP server can enumerate
cohorts:
export PULSE_DATA_DIR=/var/data/pulse
The repo Makefile auto-loads a .env file from the repo root, so you can
also drop PULSE_DATA_DIR=... there for local development.
PULSE_DATA_DIR is the only required environment variable. See
Flag Reference for the full list of CLI flags and
environment knobs.
Verify
pulse --version
pulse --json | head -20
pulse --json prints the root manifest — the full self-description of
commands, components, field types, and embedded skills. If you see a
top-level format_version: "1.0" envelope, the install is working.
Where to go next
- New to the file format and vocabulary? Your First Cohort
- Want a quick map of every command? CLI Tour
- Embedding Pulse in a Go program? Go API Overview
- Wiring Pulse into an MCP-aware client?
pulse mcp
Your First Cohort
Audience: new CLI users. This is a five-minute tour: import a CSV,
inspect the resulting .pulse file, run an aggregation, and export the
result back.
LLM agents using MCP: the equivalent tour for an agent is the
session-bootstrapskill, fetched viapulse_skills_get. That skill speaks in tool calls and JSON payloads; this page speaks in shell commands.
1. Pick a CSV
For this walkthrough we’ll assume a file called sales.csv with columns
like:
order_id,region,product,units,revenue,sold_on
1,west,widget,3,29.97,2024-01-04
2,east,gadget,1,19.99,2024-01-04
3,west,widget,7,69.93,2024-01-05
...
Any CSV with a header row works. Pulse also imports TSV, NDJSON, JSON-array, Parquet, Arrow IPC, and Excel — see Flag Reference for per-format flags.
2. Import to a .pulse file
pulse import csv --input sales.csv --output sales.pulse
Pulse samples up to 500 rows by default to infer a schema (you can change
that with --sample-rows). Each column gets a typed binary representation
and, if it looks like a low-cardinality string, a categorical dictionary.
Want to control the schema explicitly? Generate a template, edit it, and re-import:
# Editable schema template
pulse import schema-template sales.csv > sales.schema.json
# Edit sales.schema.json — set types, add descriptions
# Then import with the schema
pulse import csv --input sales.csv --schema sales.schema.json --output sales.pulse
See Field Types for the type catalog and Dictionary Blocks for how categoricals are encoded.
3. Inspect
The .pulse file is fully self-describing. Read it back:
pulse cohort inspect sales.pulse
Output is a table of fields, their types, and the description string
stored in the header. Add --json for the structured envelope, or
--full-dict to print every categorical entry instead of truncating
after 100.
pulse cohort inspect sales.pulse --json
The envelope is documented in pulse cohort inspect.
4. Validate a request before running it
Pulse separates validation from execution. Write a tiny request file:
{
"cohort": {"filename": "sales.pulse"},
"groups": [{"type": "GROUP_CATEGORY", "field": "region"}],
"aggregations": [
{"type": "AGG_COUNT", "field": "order_id", "label": "orders"},
{"type": "AGG_SUM", "field": "revenue", "label": "total_revenue"}
]
}
Save it as request.json, then check whether it makes sense against the
cohort’s schema:
pulse api predict --request request.json
You’ll see Valid: true, the schema’s field count, and any warnings
(e.g., aggregating something numeric on a categorical field). Predict
never reads record data, so it’s safe to iterate on a request without
touching a multi-GB cohort.
See pulse api predict and the
tool-predict skill for the full predict loop.
5. Execute
pulse api process --request request.json --json
The response is wrapped in the standard envelope (format_version,
data, errors, warnings). data carries the result rows and a
metadata block with total_rows and filtered_rows.
If your result is large, swap --json for --stream to receive rows as
NDJSON, one line at a time — useful for pipelines that don’t want to
buffer the whole result. See Streaming &
ProcessStream for which request shapes
actually stream end-to-end inside the engine vs which buffer.
6. Export
You’re done with the .pulse file? Export to whatever your downstream
tool understands:
pulse export csv --input sales.pulse --output sales.out.csv
pulse export parquet --input sales.pulse --output sales.out.parquet
pulse export excel --input sales.pulse --output sales.out.xlsx
To skip the intermediate .pulse entirely and convert in one shot, use
pulse convert source.csv target.parquet — see the top-level
README for
the full convert recipe.
What you didn’t see
- Compose: batch multiple requests in one call —
pulse api compose. - Sample / Facet: cheap read-only probes —
api sample,api facet. - Window / Feature / Test operators: pull from the skill pack
(
window-design,feature-engineering,statistical-testing) viapulse skills show <name>.
For a full map of the CLI, see the CLI Tour.
CLI Tour
Audience: anyone who wants a map of every pulse subcommand before
diving into per-command details.
This page is a one-liner index of the CLI tree. Each row links to its
detailed chapter where applicable; commands that are minor variants of
each other (per-format import/export leaves, per-leaf shard
maintenance commands) are listed compactly.
LLM agents using MCP: there is no equivalent skill — agents drive Pulse through MCP tools, not the CLI. Start at the
session-bootstrapskill instead.
Top-level groups
pulse [--json] [--slim]
├── import Tabular → .pulse (csv, tsv, ndjson, jsonarray, parquet, arrow, excel, auto)
├── export .pulse → tabular (same format set)
├── convert Tabular → tabular, with .pulse as the transparent middle
├── cohort Inspect or filter an existing .pulse file
├── api Processing operations (process, process-chain, compose, predict, sample, facet)
├── shard Build and maintain shard archives
├── synth Generate synthetic cohorts (from-schema, from-profile)
├── profile Capture a statistical profile of a cohort
├── skills Read the embedded LLM skill pack
├── examples Search and fetch the embedded runnable request library
├── errors Look up an error code's message and recovery fixups
└── mcp Run the Model Context Protocol server over stdio
Bare pulse --json prints the self-describing root manifest — commands,
components, field types, and skill metadata in one envelope. Pass
--slim to drop prose descriptions for size-sensitive clients.
End-to-end worked tour
A complete read-aggregate cycle, one command per stage:
# 1. Import a CSV into a managed .pulse handle (TTL sidecar tracked).
pulse import auto sales.csv --handle sales --ttl 30d
# 2. Read the cohort's schema — types, descriptions, dictionaries.
pulse cohort inspect sales.pulse
# 3. Validate a request against the schema without running it.
cat > req.json <<'EOF'
{
"cohort": {"filename": "sales.pulse"},
"groups": [{"type": "GROUP_CATEGORY", "field": "region"}],
"aggregations": [{"type": "AGG_SUM", "field": "revenue", "label": "total"}]
}
EOF
pulse api predict --request req.json --json | jq '.data | {valid, streamable, defaults_applied}'
# 4. Execute the same request.
pulse api process --request req.json --json
api predict reads only the header and schema, so it stays cheap even
on multi-GB cohorts; the streamable / streamable_reasons fields
tell you whether the request will run through the single-pass streaming
path or be buffered. See Performance Notes.
Pipeline order
Inside api process, operators run in a fixed sequence:
Load -> Features -> Filter -> Attributes -> Group -> Aggregate -> Windows -> Sort -> Output
Features run before filterers (so derived columns are addressable as filter, group, attribute, and window inputs); windows run after aggregation, on the post-aggregate row set.
API operations
The “processing facade” — these are the operations exposed via the Go library API and the MCP tool set.
| Command | Purpose | Chapter |
|---|---|---|
pulse api process | Execute one request against a cohort | api process |
pulse api process-chain | Source-rooted linear chain of mergeable processing stages | (see cmd/pulse/main.go) |
pulse api compose | Execute multiple requests in batch / parallel | api compose |
pulse api predict | Validate a request without executing | api predict |
pulse api sample | Return up to N rows | api sample |
pulse api facet | Return distinct values of a field (simple) or a multi-field rich summary | api facet |
api process-chain accepts a ChainRequest whose stages each see the
previous stage’s rows as their input cohort. Mergeable-only at v1 —
non-mergeable stages fail with PULSE_CHAIN_NOT_MERGEABLE so callers
can fall back to per-stage api process calls.
Cohort lifecycle
| Command | Purpose | Chapter |
|---|---|---|
pulse cohort inspect PATH | Read header + schema (no record data) | cohort inspect |
pulse cohort filter | Write a filtered subset to a new .pulse (single file, shard archive, or archive.pulse#shard.pulse anchor) | See Internals → Architecture |
Import / export / convert
pulse import <format> and pulse export <format> share the same flag
shape per format (--input, --output, --schema for import).
Supported formats today:
csv · tsv · ndjson · jsonarray · parquet · arrow · excel
Each format has a per-leaf command (e.g. pulse import csv). Run
pulse import --help or pulse export --help for the full list.
pulse import auto SOURCE auto-detects the source format and converts
into the managed .pulse pool under PULSE_IMPORTS_DIR, with a TTL
sidecar (--ttl 7d by default, pin to opt out). Use pulse import list to see managed handles and pulse import drop HANDLE to remove
one.
pulse convert SOURCE TARGET chains import + export with no
intermediate file unless --keep-pulse PATH is passed. Format is
auto-detected from extensions.
Shard archives
A .pulse path may resolve to either a single-file cohort or a
shard archive (uncompressed Zip64 carrying one canonical
_schema.pulse entry plus N standalone shard payloads). Every facade
method (Process, Compose, Sample, Facet, Inspect, Predict,
ProcessStream) operates transparently on the union of shards.
| Command | Purpose |
|---|---|
pulse shard create ARCHIVE --include SHARD ... | Create a new archive from one or more single-file .pulse shards (atomic temp+rename) |
pulse shard add ARCHIVE SHARD | Append a shard to an existing archive (cohesion validated) |
pulse shard remove ARCHIVE BASENAME | Remove a shard from an archive by basename |
pulse shard list ARCHIVE | List shards inside an archive with per-shard record counts |
pulse shard extract ARCHIVE BASENAME | Write one shard’s standalone .pulse bytes to stdout |
pulse shard verify ARCHIVE | Strict dict-prefix cohesion check across shards |
pulse shard compact ARCHIVE | Defragment the archive in place |
The archive.pulse#shard.pulse anchor opens one shard as a one-shard
cohort. See Internals → Managing Shard Archives.
Synthetic data
| Command | Purpose | Chapter |
|---|---|---|
pulse synth from-schema | Generate from a JSON spec | synth from-schema |
pulse synth from-profile | Generate from a captured profile | synth from-profile |
pulse profile create | Capture a profile from an existing cohort | profile create |
Self-description & LLM surface
| Command | Purpose | Chapter |
|---|---|---|
pulse --json | Root manifest (commands, components, field types, skills) | manifest |
pulse skills list | List embedded skills with metadata | How LLMs Use Pulse |
pulse skills show NAME | Print a skill’s full markdown body | same |
pulse examples search | Search embedded runnable request examples by tag, category, or operator | Examples Library |
pulse examples get NAME | Print one example’s full JSON body | same |
pulse errors lookup CODE | Print an error code’s canonical message and recovery fixups | (see errors/) |
pulse mcp | Serve MCP over stdio | mcp |
Cross-cutting flags
Most leaves accept --json (envelope output), --no-defaults (turn off
smart operator-type inference — see
api predict → Smart defaults),
and --echo-request (include the normalized request on
envelope.request). Full list: Flag Reference.
The single environment variable to know is PULSE_DATA_DIR — see
Installation.
Analysis Guide
A reference to the analysis this instance offers, rendered from the guidance metadata Pulse ships with every operator: what each one is for, the questions it answers, when to use something else, and the terms it relies on.
- Operator catalog: one page per operator category.
- Reading your results: how to read each output field, and the counts beside every result.
- Glossary: plain-language definitions of the terms the guidance uses.
- Skills: every skill as an agent reads it.
Operator catalog
The operators this instance offers, one page per category. Each page opens with a summary table and then a detail block per operator.
- Aggregators (33)
- Attributes (16)
- Filterers (12)
- Groupers (9)
- Window operators (11)
- Feature operators (9)
- Statistical tests (26)
- Regressions (3)
- Matrix operators (8)
- Overlays (44)
- Synth distributions (15)
Aggregators
The aggregators this instance offers: what each is for, the questions it answers and when to reach for something else. Operators are sorted by name; each name links to its detail block below.
| Operator | In plain words | Answers questions like | Level | Instead, when… |
|---|---|---|---|---|
AGG_AVERAGE | Average of a numeric field, over all rows or per group. | What is the average order value? | basic | AGG_MEDIAN when the field is skewed or has extreme values and you want the typical row.AGG_WEIGHTED_MEAN when rows carry weights.AGG_WELFORD when you also need the spread around the average. |
AGG_CI_LOWER | Lower end of a confidence interval for the mean of a numeric field, showing how precisely the average is pinned down. | How precisely do we know the average satisfaction in each segment? | intermediate | AGG_CI_UPPER when you want the upper end of the same interval.TEST_WELCH when you want to test whether two group means differ.AGG_PERCENTILE when you want the range individual rows fall in, not the mean. |
AGG_CI_UPPER | Upper end of a confidence interval for the mean of a numeric field, showing how precisely the average is pinned down. | What is the highest average wait time the data are consistent with at 95% confidence? | intermediate | AGG_CI_LOWER when you want the lower end of the same interval.TEST_WELCH when you want to test whether two group means differ. |
AGG_COUNT | Number of rows that have a value in a field, over all rows or per group. | How many responses did each region return? | basic | AGG_NULL_COUNT when you want the rows where the field is empty.AGG_DISTINCT_COUNT when you want how many different values appear, not how many rows.GROUP_CATEGORY when you want a count for every value of the field: group by it, then count.AGG_FREQUENCY when you want how many rows hold one particular value, such as the Yes answers. |
AGG_DISTINCT_COUNT | Number of different values a field takes, over all rows or per group. | How many different customers ordered this month? | basic | AGG_COUNT when you want how many rows there are, not how many different values.GROUP_CATEGORY when you want how many rows hold each value: group by the field, then count.AGG_SET_DISTINCT_VALUES when the field is a multi-select and each combination is the unit. |
AGG_DISTINCT_SUM | Total of a numeric field counting each key once, such as a respondent’s weight summed once per respondent. | What is the weighted base when each respondent appears on several rows? | intermediate | AGG_SUM when every row is its own unit and should be added.AGG_DISTINCT_COUNT when you want how many keys there are, not a total. |
AGG_FREQUENCY | How many rows hold one chosen value of a field, such as the Yes answers, over all rows or per group. | How many respondents answered Yes in each region? | basic | AGG_MODE_COUNT when you want how many rows share the field’s most common value, whatever it is.GROUP_CATEGORY when you want a count for every value of the field: group by it, then count rows.FILTER_INCLUDE when you want rows holding any of several values: keep them, then count rows.AGG_SET_FREQUENCY when the field is a multi-select. |
AGG_KURTOSIS | How heavy a numeric field’s tails are next to a normal distribution: positive when extreme values are more common. | Do transaction amounts have more extreme values than a bell curve would give? | advanced | AGG_SKEWNESS when you want to know whether the field is lopsided.TEST_SHAPIRO_WILK when you want a test of whether the field follows a normal shape. |
AGG_MAE | Average size of a prediction’s miss in the actual’s own units, comparing an actual column with a prediction column row by row. | On average, how many units off is the sales model on the held-out weeks? | intermediate | AGG_RMSE when large misses should count for more than small ones.AGG_MAPE when you want the miss as a share of the actual value. |
AGG_MAPE | Average size of a prediction’s miss as a share of the actual value, comparing an actual column with a prediction column row by row. | On the held-out weeks, how far off is the sales model, as a share of actual sales? | intermediate | AGG_MAE when actual values can be 0 or close to 0, where a share is undefined or explodes.AGG_RMSE when large misses should count for more than small ones. |
AGG_MAX | Largest value of a numeric or date field, over all rows or per group. | What is the latest order date in each region? | basic | AGG_MIN when you want the smallest value.AGG_PERCENTILE when one extreme row would mislead and you want a high value that ignores it.AGG_RANGE when you want the distance between smallest and largest. |
AGG_MEDIAN | Middle value of a numeric field once sorted: the typical row, not pulled by a few extreme values. | What is the typical household income in each region? | basic | AGG_PERCENTILE when you want a value other than the middle, such as the 90th percentile.AGG_AVERAGE when the field is roughly symmetric and you want a figure that combines across groups.AGG_MODE when the field holds categories rather than numbers. |
AGG_MIN | Smallest value of a numeric or date field, over all rows or per group. | What is the earliest order date in each region? | basic | AGG_MAX when you want the largest value.AGG_PERCENTILE when one extreme row would mislead and you want a low value that ignores it.AGG_RANGE when you want the distance between smallest and largest. |
AGG_MODE | Most common value of a field, over all rows or per group. | What is the most common answer to the preferred-brand question? | basic | GROUP_CATEGORY when you want the count of every value, not only the top one: group by the field, then count.AGG_MEDIAN when the field is numeric and you want its middle value.AGG_FREQUENCY when you want how many rows hold one particular value, such as the Yes answers.AGG_SET_FREQUENCY when the field is a multi-select. |
AGG_MODE_COUNT | How many rows share a field’s most common value, over all rows or per group. | How many respondents gave the most common answer? | basic | GROUP_CATEGORY when you want a count for every value of the field: group by it, then count rows.capability:facet when you want per-value counts for several fields in one call.AGG_MODE when you want which value is most common, not how many rows hold it.AGG_FREQUENCY when you want how many rows hold one particular value, such as the Yes answers.AGG_DISTINCT_COUNT when you want how many different values there are.AGG_SET_FREQUENCY when the field is a multi-select. |
AGG_NULL_COUNT | Number of rows where a field has no value, over all rows or per group. | How many respondents skipped the income question? | basic | AGG_COUNT when you want the rows that do have a value.FILTER_NULL when you want to keep or drop the rows with no value. |
AGG_PERCENTILE | Value of a numeric field below which a chosen share of rows fall, such as the 90th percentile of delivery time. | How long do the slowest 10% of deliveries take in each region? | basic | AGG_MEDIAN when you want the 50th percentile.ATTR_PERCENTILE when you want each row’s own percentile position.GROUP_QUANTILE when you want rows split into equal-sized bands. |
AGG_RANGE | Distance between the smallest and largest value of a numeric field, over all rows or per group. | How far apart are the cheapest and most expensive orders? | basic | AGG_STDDEV when you want a spread measure that uses every row, not only the two extremes.AGG_PERCENTILE when you want the spread of the middle of the data, ignoring extremes. |
AGG_RATIO | Total of one field divided by the total of another, over all rows or per group, such as revenue per order. | What is revenue per visit in each channel? | basic | ATTR_FORMULA when you want the average of each row’s own ratio: compute it per row (guarding a 0 denominator), then average it.AGG_WEIGHTED_MEAN when you want an average where some rows count more than others. |
AGG_RMSE | Typical size of a prediction’s miss in the actual’s own units, with large misses counting more: the root of the average squared miss. | How many units off is the sales model on the held-out weeks, with big misses weighing more? | intermediate | AGG_MAE when every unit of miss should count the same, so a few large misses do not dominate.AGG_MAPE when you want the miss as a share of the actual value. |
AGG_SET_CARDINALITY_AVG | For a multi-select field, the average number of options chosen per row. | How many brands does a respondent recognise on average? | basic | AGG_SET_CARDINALITY_SUM when you want the total number of selections.AGG_SET_FREQUENCY when you want which options were chosen. |
AGG_SET_CARDINALITY_SUM | For a multi-select field, the total number of options chosen across all rows. | How many brand mentions did the survey collect in total? | basic | AGG_SET_CARDINALITY_AVG when you want the number per row.AGG_SET_FREQUENCY when you want the count for each option. |
AGG_SET_DISTINCT_VALUES | For a multi-select field, how many different combinations of options appear. | How many different combinations of channels do customers use? | basic | AGG_SET_FREQUENCY when you want the count of each single option.AGG_SET_UNION when you want every option chosen by anyone.GROUP_SET_VALUE when you want how common each combination is: a count alone cannot show whether one dominates. |
AGG_SET_FREQUENCY | For a multi-select field, how many rows chose each option, over all rows or per group. | How many respondents selected each brand they have heard of? | basic | GROUP_CATEGORY when the field holds one value per row: group by it, then count.AGG_SET_DISTINCT_VALUES when you want how many different combinations were chosen. |
AGG_SET_INTERSECTION | For a multi-select field, the options chosen by every row, over all rows or per group. | Which brands did every respondent in this segment recognise? | basic | AGG_SET_UNION when you want every option chosen by anyone.AGG_SET_FREQUENCY when you want how many rows chose each option. |
AGG_SET_UNION | For a multi-select field, every option chosen by at least one row, over all rows or per group. | Which brands were mentioned by anyone in this segment? | basic | AGG_SET_INTERSECTION when you want the options every row chose.AGG_SET_FREQUENCY when you want how many rows chose each option. |
AGG_SKEWNESS | How lopsided a numeric field is: positive when a long tail runs to high values, negative when it runs to low ones. | Are incomes in each region bunched low with a few very high ones? | intermediate | AGG_KURTOSIS when you want to know whether the tails are heavy rather than lopsided.TEST_SHAPIRO_WILK when you want a test of whether the field follows a normal shape.AGG_MEDIAN when you want the typical value of a skewed field. |
AGG_STDDEV | Typical distance of a numeric field’s values from their average, in the field’s own units, over all rows or per group. | How much do delivery times vary around the average in each region? | basic | AGG_CI_LOWER when you want how precisely the average itself is known.AGG_WELFORD when you want the sample form (dividing by n - 1) together with the mean and n.AGG_PERCENTILE when the field has extreme values and you want a spread they cannot drag. |
AGG_SUM | Total of a numeric field, over all rows or per group. | What is total revenue by region? | basic | AGG_AVERAGE when you want the typical value per row rather than the total.AGG_WEIGHTED_MEAN when rows carry weights and you want a weighted average.AGG_DISTINCT_SUM when a value repeats on several rows of the same key and must be added once per key.WIN_RUNNING_SUM when you want the total so far, row by row. |
AGG_VARIANCE | Spread of a numeric field as the average squared distance from its mean, in squared units, over all rows or per group. | How much does the order value vary within each channel, in squared units? | intermediate | AGG_STDDEV when you want the spread in the field’s own units, which is easier to read.AGG_WELFORD when you want the sample variance (dividing by n - 1) with the mean and n, as pooling and study-size planning expect. |
AGG_WEIGHTED_MEAN | Average of a numeric field where each row counts in proportion to a weight field, such as a survey weight. | What is the weighted average satisfaction per region? | intermediate | AGG_AVERAGE when every row should count equally.AGG_RATIO when you want one total divided by another. |
AGG_WELFORD | Mean, sample variance and row count of a numeric field in one result: what a comparison of group means needs. | What are the mean, variance and size of each cell, so cells can be tested against each other? | intermediate | AGG_AVERAGE when you want only the average.AGG_STDDEV when you want the population spread (dividing by n) on its own.TEST_WELCH when you want the comparison itself on raw rows. |
AGG_ZSCORE | Population mean and standard deviation of a numeric field, for standardizing it; the value itself is always 0. | What centre and scale standardize this field in each group? | intermediate | ATTR_ZSCORE when you want a z-score on every row.OVERLAY_ZSCORE_VS_TOTAL when you want each group’s value against all groups.AGG_STDDEV when you want only the spread. |
Operators
AGG_AVERAGE
Average of a numeric field, over all rows or per group.
Level: basic
Also known as: mean, arithmetic mean
Questions it answers:
- What is the average order value?
- What is the typical rating in each region?
Use cases by domain:
- survey: Mean satisfaction score per segment.
- ops: Average order value by sales channel.
- science: Mean measurement per treatment arm.
Assumptions:
- Missing values are skipped: the average is over rows that have a value. A group where no row has a value reads 0, not empty, so check n in the components before reporting it.
Use something else:
AGG_MEDIANwhen the field is skewed or has extreme values and you want the typical row.AGG_WEIGHTED_MEANwhen rows carry weights.AGG_WELFORDwhen you also need the spread around the average.
Glossary: mean, median, outlier, skew
Skill: op-agg-average
AGG_CI_LOWER
Lower end of a confidence interval for the mean of a numeric field, showing how precisely the average is pinned down.
Level: intermediate
Questions it answers:
- How precisely do we know the average satisfaction in each segment?
- What is the lowest average order value the data are consistent with at 95% confidence?
Use cases by domain:
- survey: Error bars on a mean score per segment.
- ops: Range for average handling time per team.
- science: Interval for a mean measurement per arm.
Assumptions:
- Rows are independent draws, unweighted.
- Normal critical value (1.96 at 95%), not Student’s t, so small groups, and strongly skewed fields even well past 30 rows, get an interval that is too narrow.
- Empty (NaN) when fewer than two rows have a value.
Use something else:
AGG_CI_UPPERwhen you want the upper end of the same interval.TEST_WELCHwhen you want to test whether two group means differ.AGG_PERCENTILEwhen you want the range individual rows fall in, not the mean.
Glossary: confidence-interval, mean, standard-error, sample-size, independence
Skill: op-agg-ci-lower
AGG_CI_UPPER
Upper end of a confidence interval for the mean of a numeric field, showing how precisely the average is pinned down.
Level: intermediate
Questions it answers:
- What is the highest average wait time the data are consistent with at 95% confidence?
- How wide is the uncertainty around each region’s mean score?
Use cases by domain:
- survey: Error bars on a mean score per segment.
- ops: Upper end of the 95% interval for average delivery time per carrier: the true average can still lie above it.
- science: Interval for a mean measurement per arm.
Assumptions:
- Rows are independent draws, unweighted.
- Normal critical value (1.96 at 95%), not Student’s t, so small groups, and strongly skewed fields even well past 30 rows, get an interval that is too narrow.
- Empty (NaN) when fewer than two rows have a value.
Use something else:
AGG_CI_LOWERwhen you want the lower end of the same interval.TEST_WELCHwhen you want to test whether two group means differ.
Glossary: confidence-interval, mean, standard-error, sample-size, independence
Skill: op-agg-ci-upper
AGG_COUNT
Number of rows that have a value in a field, over all rows or per group.
Level: basic
Questions it answers:
- How many responses did each region return?
- How many orders were placed through each channel?
Use cases by domain:
- survey: Number of answered questionnaires per segment.
- ops: Order count per store per day.
- science: Number of recorded measurements per treatment arm.
Assumptions:
- Rows with a missing value in the field are not counted.
Use something else:
AGG_NULL_COUNTwhen you want the rows where the field is empty.AGG_DISTINCT_COUNTwhen you want how many different values appear, not how many rows.GROUP_CATEGORYwhen you want a count for every value of the field: group by it, then count.AGG_FREQUENCYwhen you want how many rows hold one particular value, such as the Yes answers.
Glossary: missing-value, sample-size
Skill: op-agg-count
AGG_DISTINCT_COUNT
Number of different values a field takes, over all rows or per group.
Level: basic
Questions it answers:
- How many different customers ordered this month?
- Does the respondent ID appear once per row, or are there duplicates?
Use cases by domain:
- survey: Number of distinct respondents per wave.
- ops: Unique customers per store.
- science: Number of distinct subjects per site.
Assumptions:
- Missing values are not counted as a value.
Use something else:
AGG_COUNTwhen you want how many rows there are, not how many different values.GROUP_CATEGORYwhen you want how many rows hold each value: group by the field, then count.AGG_SET_DISTINCT_VALUESwhen the field is a multi-select and each combination is the unit.
Glossary: missing-value
Skill: op-agg-distinct-count
AGG_DISTINCT_SUM
Total of a numeric field counting each key once, such as a respondent’s weight summed once per respondent.
Level: intermediate
Questions it answers:
- What is the weighted base when each respondent appears on several rows?
- What is the total contract value when each contract repeats on every line item?
Use cases by domain:
- survey: Weighted base of a segment in a cohort with one row per answer.
- ops: Total customer credit limit when each customer has many orders.
Assumptions:
- The value is the same on every row of a key: when it differs, the first value seen is kept.
- A row missing either the value or the key adds nothing.
Use something else:
AGG_SUMwhen every row is its own unit and should be added.AGG_DISTINCT_COUNTwhen you want how many keys there are, not a total.
Glossary: missing-value, weighting
Skill: op-agg-distinct-sum
AGG_FREQUENCY
How many rows hold one chosen value of a field, such as the Yes answers, over all rows or per group.
Level: basic
Questions it answers:
- How many respondents answered Yes in each region?
- How many orders were returned each day?
Use cases by domain:
- survey: Number of Yes answers to a question per segment.
- ops: Orders with the Returned status per store per day.
- science: Positive results per treatment arm.
Assumptions:
- Missing values are skipped: they are neither counted nor part of the base the share in the components divides by.
- The value is matched as a row filter matches it: a category label, otherwise a number; a value no row holds counts 0, so check its spelling.
- The result equals keeping only that value with a row filter, then counting rows.
Use something else:
AGG_MODE_COUNTwhen you want how many rows share the field’s most common value, whatever it is.GROUP_CATEGORYwhen you want a count for every value of the field: group by it, then count rows.FILTER_INCLUDEwhen you want rows holding any of several values: keep them, then count rows.AGG_SET_FREQUENCYwhen the field is a multi-select.
Glossary: missing-value
Skill: op-agg-frequency
AGG_KURTOSIS
How heavy a numeric field’s tails are next to a normal distribution: positive when extreme values are more common.
Level: advanced
Questions it answers:
- Do transaction amounts have more extreme values than a bell curve would give?
- Are response times prone to rare, very long waits?
Use cases by domain:
- ops: Spotting fields where rare extreme values dominate risk.
- science: Checking a measurement’s tails before trusting a mean-based test.
Assumptions:
- Missing values are skipped.
- Population excess kurtosis g2 (dividing by n, minus 3): a normal shape scores 0, and at small n it differs from the adjusted G2 most packages print.
- It is 0 when n is 0 or 1 or every value is the same.
Use something else:
AGG_SKEWNESSwhen you want to know whether the field is lopsided.TEST_SHAPIRO_WILKwhen you want a test of whether the field follows a normal shape.
Glossary: kurtosis, normal-distribution, outlier
Skill: op-agg-kurtosis
AGG_MAE
Average size of a prediction’s miss in the actual’s own units, comparing an actual column with a prediction column row by row.
Level: intermediate
Also known as: mean absolute error
Questions it answers:
- On average, how many units off is the sales model on the held-out weeks?
- Is the forecast miss larger for some stores than for others?
Use cases by domain:
- survey: Average miss of a predicted score against the observed score, in scale points.
- ops: Average holdout miss of a demand forecast, in units.
- science: Average absolute error of a calibration model on the validation samples.
Assumptions:
- A row is used only when both the actual and the prediction have a value.
- Train and holdout rows are separated with a filter or a range grouper; the operator itself never splits rows.
Use something else:
AGG_RMSEwhen large misses should count for more than small ones.AGG_MAPEwhen you want the miss as a share of the actual value.
Glossary: residual, overfitting, missing-value
Skill: op-agg-mae
AGG_MAPE
Average size of a prediction’s miss as a share of the actual value, comparing an actual column with a prediction column row by row.
Level: intermediate
Also known as: mean absolute percentage error
Questions it answers:
- On the held-out weeks, how far off is the sales model, as a share of actual sales?
- Is the forecast’s percentage miss larger in some regions than in others?
Use cases by domain:
- survey: Percentage miss of a model-predicted response rate against the observed rate per wave.
- ops: Holdout forecast accuracy of weekly sales, as a share of actual sales.
- science: Relative error of a calibration model on the validation samples.
Assumptions:
- A row is used only when both the actual and the prediction have a value; a row whose actual is exactly 0 is left out and counted in components n_zero_actual.
- The result is a fraction (0.12 means 12%), not a percentage.
- Train and holdout rows are separated with a filter or a range grouper; the operator itself never splits rows.
Use something else:
AGG_MAEwhen actual values can be 0 or close to 0, where a share is undefined or explodes.AGG_RMSEwhen large misses should count for more than small ones.
Glossary: residual, overfitting, missing-value
Skill: op-agg-mape
AGG_MAX
Largest value of a numeric or date field, over all rows or per group.
Level: basic
Questions it answers:
- What is the latest order date in each region?
- Is any order value implausibly large?
Use cases by domain:
- survey: Highest household size reported, to catch typing errors.
- ops: Largest single order per sales channel.
- science: Peak reading per run.
Assumptions:
- Missing values are skipped; a group where no row has a value reads 0, not empty, so check n in the components before reporting it.
- It rests on one row, so a single bad record sets it.
Use something else:
AGG_MINwhen you want the smallest value.AGG_PERCENTILEwhen one extreme row would mislead and you want a high value that ignores it.AGG_RANGEwhen you want the distance between smallest and largest.
Glossary: outlier, percentile
Skill: op-agg-max
AGG_MEDIAN
Middle value of a numeric field once sorted: the typical row, not pulled by a few extreme values.
Level: basic
Questions it answers:
- What is the typical household income in each region?
- What is the typical delivery time when a few deliveries take weeks?
Use cases by domain:
- survey: Typical income per segment when a few incomes are very large.
- ops: Typical order value when a few bulk orders inflate the average.
- science: Typical response time per condition.
Assumptions:
- Missing values are skipped; a group where no row has a value reads 0, not empty, so check n in the components before reporting it.
- With an even number of values it is the halfway point between the two middle values.
Use something else:
AGG_PERCENTILEwhen you want a value other than the middle, such as the 90th percentile.AGG_AVERAGEwhen the field is roughly symmetric and you want a figure that combines across groups.AGG_MODEwhen the field holds categories rather than numbers.
Glossary: median, mean, outlier, percentile, skew
Skill: op-agg-median
AGG_MIN
Smallest value of a numeric or date field, over all rows or per group.
Level: basic
Questions it answers:
- What is the earliest order date in each region?
- Are there negative or impossible values in the age field?
Use cases by domain:
- survey: Lowest age reported, to catch out-of-range answers.
- ops: First order date per customer segment.
- science: Lowest reading per sensor, to spot faulty sensors.
Assumptions:
- Missing values are skipped; a group where no row has a value reads 0, not empty, so check n in the components before reporting it.
- It rests on one row, so a single bad record sets it.
Use something else:
AGG_MAXwhen you want the largest value.AGG_PERCENTILEwhen one extreme row would mislead and you want a low value that ignores it.AGG_RANGEwhen you want the distance between smallest and largest.
Glossary: outlier, percentile
Skill: op-agg-min
AGG_MODE
Most common value of a field, over all rows or per group.
Level: basic
Questions it answers:
- What is the most common answer to the preferred-brand question?
- Which product is ordered most often in each store?
Use cases by domain:
- survey: Most chosen option per question and segment.
- ops: Most frequent payment method per channel.
- science: Most common category of outcome per group.
Assumptions:
- Missing values are skipped; a group where no row has a value reads 0, not empty, so check n in the components before reporting it.
- When several values share the top count, the smallest wins (for categories, the first in the dictionary); tie_count in the components shows it happened.
Use something else:
GROUP_CATEGORYwhen you want the count of every value, not only the top one: group by the field, then count.AGG_MEDIANwhen the field is numeric and you want its middle value.AGG_FREQUENCYwhen you want how many rows hold one particular value, such as the Yes answers.AGG_SET_FREQUENCYwhen the field is a multi-select.
Glossary: median
Skill: op-agg-mode
AGG_MODE_COUNT
How many rows share a field’s most common value, over all rows or per group.
Level: basic
Questions it answers:
- How many respondents gave the most common answer?
- How many orders used the most popular payment method?
- Grouped by the same field, how many rows fall in each category?
Use cases by domain:
- survey: Size of the largest answer group per question and segment.
- ops: Orders carrying the most common status per day.
- science: Size of the most common outcome category per treatment arm.
Assumptions:
- Missing values are skipped, so only rows that have a value are counted.
- Grouped by the same field, each group holds one value, so the result is that group’s row count.
- When several values tie for most common the count is the same; the components name the smallest tied value as mode_value.
Use something else:
GROUP_CATEGORYwhen you want a count for every value of the field: group by it, then count rows.capability:facetwhen you want per-value counts for several fields in one call.AGG_MODEwhen you want which value is most common, not how many rows hold it.AGG_FREQUENCYwhen you want how many rows hold one particular value, such as the Yes answers.AGG_DISTINCT_COUNTwhen you want how many different values there are.AGG_SET_FREQUENCYwhen the field is a multi-select.
Glossary: missing-value
Skill: op-agg-mode-count
AGG_NULL_COUNT
Number of rows where a field has no value, over all rows or per group.
Level: basic
Questions it answers:
- How many respondents skipped the income question?
- Which regions have the most orders missing a delivery date?
Use cases by domain:
- survey: Item non-response per question and per segment.
- ops: Records missing a required field, per data source.
- science: Missing measurements per site before an analysis.
Assumptions:
- Only a stored null counts: an empty selection in a multi-select field is a value, not a null.
Use something else:
AGG_COUNTwhen you want the rows that do have a value.FILTER_NULLwhen you want to keep or drop the rows with no value.
Glossary: missing-value
Skill: op-agg-null-count
AGG_PERCENTILE
Value of a numeric field below which a chosen share of rows fall, such as the 90th percentile of delivery time.
Level: basic
Questions it answers:
- How long do the slowest 10% of deliveries take in each region?
- What income marks the top quarter of respondents?
Use cases by domain:
- survey: Quartiles of household income per segment.
- ops: 95th-percentile response time per service.
- science: Reference range (2.5th to 97.5th percentile) of a measurement.
Assumptions:
- Missing values are skipped.
- Linear interpolation between the two nearest sorted values, so the result may be a value no row holds.
Use something else:
AGG_MEDIANwhen you want the 50th percentile.ATTR_PERCENTILEwhen you want each row’s own percentile position.GROUP_QUANTILEwhen you want rows split into equal-sized bands.
Glossary: percentile, median, outlier
Skill: op-agg-percentile
AGG_RANGE
Distance between the smallest and largest value of a numeric field, over all rows or per group.
Level: basic
Questions it answers:
- How far apart are the cheapest and most expensive orders?
- How widely do delivery times span in each depot?
Use cases by domain:
- survey: Span of ages within each segment.
- ops: Spread of daily sales between the slowest and busiest store.
- science: Span of readings across repeat measurements.
Assumptions:
- Missing values are skipped; a group where no row has a value reads 0, not empty, so check n in the components before reporting it.
- It rests on the two most extreme rows, so one outlier can stretch it a lot.
Use something else:
AGG_STDDEVwhen you want a spread measure that uses every row, not only the two extremes.AGG_PERCENTILEwhen you want the spread of the middle of the data, ignoring extremes.
Glossary: outlier, standard-deviation
Skill: op-agg-range
AGG_RATIO
Total of one field divided by the total of another, over all rows or per group, such as revenue per order.
Level: basic
Questions it answers:
- What is revenue per visit in each channel?
- What share of budgeted hours were actually worked per team?
Use cases by domain:
- survey: Completes per invitation sent, per wave.
- ops: Revenue per order, or cost per unit, by region.
- science: Events per person-year of follow-up per arm.
Assumptions:
- A row missing either field is left out of both totals.
- Empty (NaN) when the denominator total is 0.
Use something else:
ATTR_FORMULAwhen you want the average of each row’s own ratio: compute it per row (guarding a 0 denominator), then average it.AGG_WEIGHTED_MEANwhen you want an average where some rows count more than others.
Glossary: mean
Skill: op-agg-ratio
AGG_RMSE
Typical size of a prediction’s miss in the actual’s own units, with large misses counting more: the root of the average squared miss.
Level: intermediate
Also known as: root mean squared error
Questions it answers:
- How many units off is the sales model on the held-out weeks, with big misses weighing more?
- Which of two models misses less on the same rows?
Use cases by domain:
- survey: Error of a predicted satisfaction score against the observed score, in scale points.
- ops: Holdout error of a handling-time model, in minutes.
- science: Prediction error of a dose-response model on the validation subjects.
Assumptions:
- A row is used only when both the actual and the prediction have a value.
- Each miss is squared before averaging, so a few large misses can dominate it.
- Train and holdout rows are separated with a filter or a range grouper; the operator itself never splits rows.
Use something else:
AGG_MAEwhen every unit of miss should count the same, so a few large misses do not dominate.AGG_MAPEwhen you want the miss as a share of the actual value.
Glossary: residual, overfitting, missing-value
Skill: op-agg-rmse
AGG_SET_CARDINALITY_AVG
For a multi-select field, the average number of options chosen per row.
Level: basic
Questions it answers:
- How many brands does a respondent recognise on average?
- How many features does a typical customer have turned on?
Use cases by domain:
- survey: Average number of answers to a select-all-that-apply question, per segment.
- ops: Average number of products per customer bundle.
Assumptions:
- Rows with an empty selection count as zero and pull the average down; null rows are skipped.
Use something else:
AGG_SET_CARDINALITY_SUMwhen you want the total number of selections.AGG_SET_FREQUENCYwhen you want which options were chosen.
Glossary: mean, missing-value
Skill: op-agg-set-cardinality-avg
AGG_SET_CARDINALITY_SUM
For a multi-select field, the total number of options chosen across all rows.
Level: basic
Questions it answers:
- How many brand mentions did the survey collect in total?
- How many add-ons were sold across all orders?
Use cases by domain:
- survey: Total mentions for a select-all-that-apply question, as a base for share of mentions.
- ops: Total add-ons attached to orders per channel.
Assumptions:
- Null rows are skipped; an empty selection adds zero.
Use something else:
AGG_SET_CARDINALITY_AVGwhen you want the number per row.AGG_SET_FREQUENCYwhen you want the count for each option.
Skill: op-agg-set-cardinality-sum
AGG_SET_DISTINCT_VALUES
For a multi-select field, how many different combinations of options appear.
Level: basic
Questions it answers:
- How many different combinations of channels do customers use?
- How many different combinations of brands were picked (an empty answer counts as one)?
Use cases by domain:
- survey: Variety of answer patterns to a select-all-that-apply question.
- ops: Number of distinct product bundles sold.
Assumptions:
- Each combination is one value: choosing A and B differs from choosing A alone.
- An empty selection counts as one combination; null rows are skipped.
Use something else:
AGG_SET_FREQUENCYwhen you want the count of each single option.AGG_SET_UNIONwhen you want every option chosen by anyone.GROUP_SET_VALUEwhen you want how common each combination is: a count alone cannot show whether one dominates.
Skill: op-agg-set-distinct-values
AGG_SET_FREQUENCY
For a multi-select field, how many rows chose each option, over all rows or per group.
Level: basic
Questions it answers:
- How many respondents selected each brand they have heard of?
- How many customers use each payment method?
Use cases by domain:
- survey: Counts per option of a select-all-that-apply question.
- ops: Customers per enabled feature.
Assumptions:
- A row with several options is counted once under each, so the counts can add up to more than the rows.
Use something else:
GROUP_CATEGORYwhen the field holds one value per row: group by it, then count.AGG_SET_DISTINCT_VALUESwhen you want how many different combinations were chosen.
Skill: op-agg-set-frequency
AGG_SET_INTERSECTION
For a multi-select field, the options chosen by every row, over all rows or per group.
Level: basic
Questions it answers:
- Which brands did every respondent in this segment recognise?
- Which features does every customer on a plan use?
Use cases by domain:
- survey: Options recognised by the whole segment.
- ops: Features common to every account on a plan.
Assumptions:
- Null rows are skipped, but one row with an empty selection empties the result.
Use something else:
AGG_SET_UNIONwhen you want every option chosen by anyone.AGG_SET_FREQUENCYwhen you want how many rows chose each option.
Skill: op-agg-set-intersection
AGG_SET_UNION
For a multi-select field, every option chosen by at least one row, over all rows or per group.
Level: basic
Questions it answers:
- Which brands were mentioned by anyone in this segment?
- Which features does at least one customer in each plan use?
Use cases by domain:
- survey: Options that received any mention per segment.
- ops: Features used by anyone on each plan.
Assumptions:
- Null rows are skipped.
Use something else:
AGG_SET_INTERSECTIONwhen you want the options every row chose.AGG_SET_FREQUENCYwhen you want how many rows chose each option.
Skill: op-agg-set-union
AGG_SKEWNESS
How lopsided a numeric field is: positive when a long tail runs to high values, negative when it runs to low ones.
Level: intermediate
Questions it answers:
- Are incomes in each region bunched low with a few very high ones?
- Is the delivery-time distribution symmetric or dragged out by slow orders?
Use cases by domain:
- survey: Whether a rating scale piles up at one end per segment.
- ops: Whether order values have a long tail of large orders.
- science: Checking a measurement’s shape before choosing a test.
Assumptions:
- Missing values are skipped.
- Population moment coefficient g1 (dividing by n): at small n it runs smaller in size than the adjusted G1 most packages print.
- It is 0 when n is 0 or 1 or every value is the same.
Use something else:
AGG_KURTOSISwhen you want to know whether the tails are heavy rather than lopsided.TEST_SHAPIRO_WILKwhen you want a test of whether the field follows a normal shape.AGG_MEDIANwhen you want the typical value of a skewed field.
Glossary: skew, outlier, median, normal-distribution
Skill: op-agg-skewness
AGG_STDDEV
Typical distance of a numeric field’s values from their average, in the field’s own units, over all rows or per group.
Level: basic
Also known as: standard deviation, sd
Questions it answers:
- How much do delivery times vary around the average in each region?
- Which product line’s order values vary the most in absolute terms (divide by each line’s average to compare relative consistency)?
Use cases by domain:
- survey: How widely satisfaction scores spread within each segment.
- ops: Day-to-day variability of order volume per store.
- science: Spread of a measurement within each treatment arm.
Assumptions:
- Missing values are skipped.
- Population form: the squared distances are averaged over n, not n - 1.
Use something else:
AGG_CI_LOWERwhen you want how precisely the average itself is known.AGG_WELFORDwhen you want the sample form (dividing by n - 1) together with the mean and n.AGG_PERCENTILEwhen the field has extreme values and you want a spread they cannot drag.
Glossary: standard-deviation, variance, mean, outlier
Skill: op-agg-stddev
AGG_SUM
Total of a numeric field, over all rows or per group.
Level: basic
Questions it answers:
- What is total revenue by region?
- How many units did each product line sell in total?
Use cases by domain:
- survey: Total of a per-respondent weight to get the weighted base of each segment.
- ops: Revenue per sales channel per month.
- science: Total dose delivered per treatment arm.
Assumptions:
- Missing values are skipped: they add nothing to the total.
Use something else:
AGG_AVERAGEwhen you want the typical value per row rather than the total.AGG_WEIGHTED_MEANwhen rows carry weights and you want a weighted average.AGG_DISTINCT_SUMwhen a value repeats on several rows of the same key and must be added once per key.WIN_RUNNING_SUMwhen you want the total so far, row by row.
Glossary: missing-value
Skill: op-agg-sum
AGG_VARIANCE
Spread of a numeric field as the average squared distance from its mean, in squared units, over all rows or per group.
Level: intermediate
Questions it answers:
- How much does the order value vary within each channel, in squared units?
- Which sites show the most variable readings?
Use cases by domain:
- ops: Variability of daily demand per product, for a safety-stock formula.
- science: Variance of a measurement across every unit of a fully measured batch.
Assumptions:
- Missing values are skipped.
- Population form: the squared distances are averaged over n, not n - 1.
Use something else:
AGG_STDDEVwhen you want the spread in the field’s own units, which is easier to read.AGG_WELFORDwhen you want the sample variance (dividing by n - 1) with the mean and n, as pooling and study-size planning expect.
Glossary: variance, standard-deviation, mean
Skill: op-agg-variance
AGG_WEIGHTED_MEAN
Average of a numeric field where each row counts in proportion to a weight field, such as a survey weight.
Level: intermediate
Questions it answers:
- What is the weighted average satisfaction per region?
- What is the average price per unit when each order counts by its quantity?
Use cases by domain:
- survey: Population-weighted mean score per segment.
- ops: Volume-weighted average price per product.
- science: Precision-weighted mean of repeated measurements.
Assumptions:
- Rows missing the value or the weight, or with weight 0, are left out of the average; when no row is left (or the weights add up to 0) it reads 0, not empty, so check sum_weights in the components.
- Negative weights are not refused and can make the result unstable.
Use something else:
AGG_AVERAGEwhen every row should count equally.AGG_RATIOwhen you want one total divided by another.
Glossary: weighting, mean, effective-sample-size
Skill: op-agg-weighted-mean
AGG_WELFORD
Mean, sample variance and row count of a numeric field in one result: what a comparison of group means needs.
Level: intermediate
Questions it answers:
- What are the mean, variance and size of each cell, so cells can be tested against each other?
- How do the average and spread of scores compare across segments?
Use cases by domain:
- survey: Per-cell mean score, variance and base feeding a t-test overlay on a crosstab.
- science: Per-arm summary statistics for a two-sample comparison.
Assumptions:
- Missing values are skipped; only plain integer and float fields are accepted.
- The variance divides by n - 1 and is 0 when fewer than two rows have a value.
Use something else:
AGG_AVERAGEwhen you want only the average.AGG_STDDEVwhen you want the population spread (dividing by n) on its own.TEST_WELCHwhen you want the comparison itself on raw rows.
Glossary: mean, variance, sample-size, t-statistic
Skill: op-agg-welford
AGG_ZSCORE
Population mean and standard deviation of a numeric field, for standardizing it; the value itself is always 0.
Level: intermediate
Questions it answers:
- What centre and scale standardize this field in each group?
- How many standard deviations does the group’s last row (in row order) sit from the group’s average, that row included?
Use cases by domain:
- ops: How far the group’s last row, in a fixed row order, sits from the group’s centre.
- science: Group centre and scale for standardizing a measurement by hand.
Assumptions:
- Missing values are skipped; the spread is the population form (dividing by n).
- The last row is the last in row order, not the latest by date, and it is part of the mean and spread it is compared with, which damps its own score; for a reading against its past use OVERLAY_ZSCORE_VS_ROLLING.
- Not streamable: the whole group is read before the result is ready.
Use something else:
ATTR_ZSCOREwhen you want a z-score on every row.OVERLAY_ZSCORE_VS_TOTALwhen you want each group’s value against all groups.AGG_STDDEVwhen you want only the spread.
Glossary: z-score, mean, standard-deviation
Skill: op-agg-zscore
Attributes
The attributes this instance offers: what each is for, the questions it answers and when to reach for something else. Operators are sorted by name; each name links to its detail block below.
| Operator | In plain words | Answers questions like | Level | Instead, when… |
|---|---|---|---|---|
ATTR_CODE_IN | Adds a 1 / 0 column saying whether each row’s code is one of a listed set, keeping every row in the base. | What share of all respondents gave a top-two-box answer (codes 4 or 5)? | basic | FILTER_INCLUDE when you want to keep only the rows with those codes.AGG_FREQUENCY when you want how often a single value occurs.ATTR_SET_HAS when the field is a multi-select. |
ATTR_DATE_PART | Adds a calendar part of a date or timestamp to every row, such as the year, the month, a year-month like 202403 or the hour. | Which month of the year does each order fall in, so seasons can be compared across years? | basic | GROUP_DATE when you only want rows grouped by day, week, month or year.FEAT_DATE_FEATURES when you want several calendar columns at once, including weekday. |
ATTR_FACTOR_SCORE | Adds to every row its score on one factor behind several related numeric fields: an estimate of the hidden trait they measure together. | What is each respondent’s score on the satisfaction factor behind these rating questions? | advanced | MAT_FACTOR when you want how many factors there are and which fields load on each.ATTR_PC_SCORE when you want a summary of all the fields’ variance rather than a common trait. |
ATTR_FORMULA | Adds a number to every row computed from the row’s own fields with an expression, such as price * qty. | What is each order’s line total from its price and quantity? | intermediate | FILTER_EXPRESSION when you want to keep or drop rows by a rule rather than add a column.FEAT_LOG when you want the logarithm of a field. |
ATTR_MAHALANOBIS | Adds to every row how unusual its mix of several numeric fields is, allowing for how those fields usually vary together. | Which respondents gave an unusual pattern of answers across the whole battery, even if no single answer is extreme? | advanced | ATTR_ZSCORE when only one field matters.TEST_HOTELLING_T2 when you want how far apart groups are, not single rows. |
ATTR_NORMALIZED | Rescales a numeric field to 0 to 1 on every row: 0 is the smallest value, 1 the largest. | How can fields on very different scales be put on one 0-to-1 scale before combining them? | basic | ATTR_PERCENTILE when the field has extreme values that would squeeze the rest of the range.ATTR_ZSCORE when you want distance from the mean in standard deviations.ATTR_FORMULA when you want a composite on the scales’ fixed endpoints (1-5, 0-10), such as (x - 1) / 4. |
ATTR_PC_SCORE | Adds to every row its score on one principal component of several related numeric fields: one number standing in for what they share. | What is each respondent’s score on the main dimension behind these rating questions? | advanced | MAT_PCA when you want how many dimensions there are and which fields load on each.ATTR_MAHALANOBIS when you want how unusual a row is across the fields. |
ATTR_PERCENTILE | Adds to every row its percentile rank, rank / n * 100: the share of rows at or below it when values are untied. | Where does each store’s revenue rank among all stores, as a percentage? | basic | AGG_PERCENTILE when you want the value at a chosen percentile, such as the 90th.WIN_RANK when you want ranks within partitions, or tied values to share a rank.GROUP_QUANTILE when you want rows split into equal-sized bands. |
ATTR_REG_FITTED | Adds to every row the value a straight-line model predicts for its target from its predictors. | What order value would we expect for each customer given their visits and tenure? | advanced | REG_OLS when you want the coefficients, R-squared and p-values of the model.ATTR_REG_RESIDUAL when you want how far each row sits from its prediction.REG_GLM when the target is yes/no or a count. |
ATTR_REG_LEVERAGE | Adds to every row its leverage: how unusual its predictor values are, and so how hard it can pull a straight-line fit. | Which rows have predictor values so far from the rest that they could steer the model? | advanced | ATTR_REG_RESIDUAL when you want rows the model predicts badly.REG_OLS when you want the model itself. |
ATTR_REG_PREDICT | Adds to every row the value a model fitted once, elsewhere in the request, predicts for it from the row’s own predictor values. | How close are the model’s predictions on weeks it was not fitted to (a holdout)? | advanced | ATTR_REG_FITTED when the model should be fitted to these same rows inside the slot.REG_OLS when you want the coefficients, their standard errors and p-values. |
ATTR_REG_RESIDUAL | Adds to every row its residual: the actual target value minus the value a straight-line model predicts for it. | Which stores sell far more or less than their size and footfall predict? | advanced | REG_OLS when you want the model’s summary (coefficients, R-squared).ATTR_REG_LEVERAGE when you want how unusual each row’s predictor values are.ATTR_ZSCORE when you want how far a value is from the mean, with no model. |
ATTR_SET_HAS | Adds a 1 / 0 column saying whether each row’s multi-select field includes one named option. | Which respondents ticked ‘price’ among their reasons, as a column to cross with other answers? | basic | FILTER_SET_CONTAINS_ANY when you want to keep only rows that include any of several options.AGG_SET_FREQUENCY when you want how often each option was chosen. |
ATTR_SET_POPCOUNT | Adds to every row the number of options its multi-select field has selected. | How many reasons did each respondent tick? | basic | AGG_SET_CARDINALITY_AVG when you want the average number of selections per group.ATTR_SET_HAS when you want whether one particular option was chosen. |
ATTR_TSCORE | Adds to every row its T-score: the z-score rescaled so the mean is 50 and one standard deviation is 10. | How does each candidate’s test result compare with the group, on a 50-centred scale? | intermediate | ATTR_ZSCORE when you want plain standard-deviation units centred on 0.ATTR_PERCENTILE when you want each row’s percentile rank (the share at or below it when values are untied). |
ATTR_ZSCORE | Adds to every row its z-score: how many standard deviations the row’s value sits above or below the mean. | Which orders are unusually large compared with all orders? | intermediate | AGG_ZSCORE when you want the centre and spread of each group, not a score per row.ATTR_PERCENTILE when the field is skewed or has extreme values and you want a position they cannot distort.OVERLAY_ZSCORE_VS_TOTAL when you want each group’s value against all groups. |
Operators
ATTR_CODE_IN
Adds a 1 / 0 column saying whether each row’s code is one of a listed set, keeping every row in the base.
Level: basic
Questions it answers:
- What share of all respondents gave a top-two-box answer (codes 4 or 5)?
- Which orders carry one of the priority status codes, as a column to average by region?
Use cases by domain:
- survey: Top-box flag to average as a share of the whole weighted base: non-answers stay in the denominator unless FILTER_NULL drops them first.
- ops: Flag for orders whose status code is in a chosen group.
Assumptions:
- On a categorical field a code is a dictionary label; one absent from the dictionary matches nothing.
- A row with a missing value reads 0, the same as a row with another code.
Use something else:
FILTER_INCLUDEwhen you want to keep only the rows with those codes.AGG_FREQUENCYwhen you want how often a single value occurs.ATTR_SET_HASwhen the field is a multi-select.
Glossary: missing-value
Skill: op-attr-code-in
ATTR_DATE_PART
Adds a calendar part of a date or timestamp to every row, such as the year, the month, a year-month like 202403 or the hour.
Level: basic
Questions it answers:
- Which month of the year does each order fall in, so seasons can be compared across years?
- What year-month label should each response carry for a monthly breakdown?
Use cases by domain:
- survey: Tag each response with its fieldwork year-month.
- ops: Month-of-year column for comparing seasonal order volume.
- science: Collection year per sample for a by-year breakdown.
Assumptions:
- Reads a date field (epoch days) or a datetime field; a datetime is read on the local clock of its time zone, and the hour part needs a datetime.
- The part is an encoded number (YYYYMM for year_month), so arithmetic on it is meaningless.
- A missing date reads 0.
Use something else:
GROUP_DATEwhen you only want rows grouped by day, week, month or year.FEAT_DATE_FEATURESwhen you want several calendar columns at once, including weekday.
Glossary: missing-value
Skill: op-attr-date-part
ATTR_FACTOR_SCORE
Adds to every row its score on one factor behind several related numeric fields: an estimate of the hidden trait they measure together.
Level: advanced
Also known as: factor score, regression factor score, Thurstone score, Bartlett score, latent score
Questions it answers:
- What is each respondent’s score on the satisfaction factor behind these rating questions?
- Can I score every row on the two constructs a factor analysis found, to compare segments on them?
Use cases by domain:
- survey: Score each respondent on the constructs an attitude battery measures, then crosstab or test the scores by segment.
- science: Estimate each subject’s standing on a latent trait from several noisy indicators before a later model.
Assumptions:
- The factors come from one fit over every row that passed the filters (or the subset fit_on selects), never per group; every filtered row is scored.
- The fit is MAT_FACTOR’s with the same settings, so factor numbers and directions match its loadings: read them before naming a score.
- A row missing any of the fields is left out of the fit and gets no score (listwise deletion).
- There must be more complete rows than fields, the factor count must be identified, and no field may be constant or a sum of the others; otherwise every score is empty, with a warning.
Use something else:
MAT_FACTORwhen you want how many factors there are and which fields load on each.ATTR_PC_SCOREwhen you want a summary of all the fields’ variance rather than a common trait.
Glossary: factor, loading, correlation, z-score, listwise-deletion
Skill: op-attr-factor-score
ATTR_FORMULA
Adds a number to every row computed from the row’s own fields with an expression, such as price * qty.
Level: intermediate
Questions it answers:
- What is each order’s line total from its price and quantity?
- Which rows meet a combined rule, as a 0/1 column for counting?
Use cases by domain:
- survey: Sum of several rating items into one score per respondent.
- ops: Margin per order from revenue and cost.
- science: Body-mass index from recorded weight and height.
Assumptions:
- A missing field enters the expression as nil, and an operator that cannot take nil fails the request. A guard such as x ?? 0 turns each missing value into a real 0, which pulls averages down, so prefer dropping those rows with FILTER_NULL or pick a fallback that means something.
- True / false results become 1 / 0.
- It cannot read another attribute’s column from the same request.
Use something else:
FILTER_EXPRESSIONwhen you want to keep or drop rows by a rule rather than add a column.FEAT_LOGwhen you want the logarithm of a field.
Glossary: missing-value
Skill: op-attr-formula
ATTR_MAHALANOBIS
Adds to every row how unusual its mix of several numeric fields is, allowing for how those fields usually vary together.
Level: advanced
Also known as: mahalanobis distance, multivariate outlier score, d-squared
Questions it answers:
- Which respondents gave an unusual pattern of answers across the whole battery, even if no single answer is extreme?
- Which transactions have an odd combination of amount, frequency and basket size?
Use cases by domain:
- survey: Flag straight-liners and odd answer patterns across a rating battery before analysis.
- ops: Screen orders whose size, discount and delivery time together look unlike the rest.
- science: Find samples whose set of measurements is unusual as a whole.
Assumptions:
- The typical row and the spread come from one fit over every row that passed the filters (or the subset fit_on selects), never per group; every filtered row is scored.
- A row missing any of the fields is left out of the fit and gets no score (listwise deletion).
- The p output reads the score against a chi-square curve, which holds only when the fields are roughly jointly normal.
- There must be more complete rows than fields, and no field may be constant or a sum of the others; otherwise every score is empty, with a warning.
Use something else:
ATTR_ZSCOREwhen only one field matters.TEST_HOTELLING_T2when you want how far apart groups are, not single rows.
Glossary: distance, outlier, covariance, mean, listwise-deletion, chi-square, p-value, normal-distribution, degrees-of-freedom
Skill: op-attr-mahalanobis
ATTR_NORMALIZED
Rescales a numeric field to 0 to 1 on every row: 0 is the smallest value, 1 the largest.
Level: basic
Questions it answers:
- How can fields on very different scales be put on one 0-to-1 scale before combining them?
- Where does each row’s value sit between the lowest and highest seen?
Use cases by domain:
- survey: Rescale items to 0-1 by their observed lowest and highest answers, before comparing their spread.
- ops: Scale each site’s load between its observed minimum and maximum.
- harness: Bound an input to 0..1 before handing it to a scoring model.
Assumptions:
- Computed over every row that passed the filters, before grouping: a group never re-centres it.
- It uses the OBSERVED lowest and highest values, not a scale’s endpoints: if nobody answered 1 on a 1-5 item, 2 maps to 0, so the same raw score lands at different positions across items, filtered subsets or waves.
- A missing value reads 0, the same as the minimum, as does every row when all values are equal; in a composite that pulls the score down.
Use something else:
ATTR_PERCENTILEwhen the field has extreme values that would squeeze the rest of the range.ATTR_ZSCOREwhen you want distance from the mean in standard deviations.ATTR_FORMULAwhen you want a composite on the scales’ fixed endpoints (1-5, 0-10), such as (x - 1) / 4.
Glossary: outlier, missing-value
Skill: op-attr-normalized
ATTR_PC_SCORE
Adds to every row its score on one principal component of several related numeric fields: one number standing in for what they share.
Level: advanced
Also known as: principal component score, pc score, component score, index score
Questions it answers:
- What is each respondent’s score on the main dimension behind these rating questions?
- Can I replace these five correlated measures with one summary column for a later model?
Use cases by domain:
- survey: Score each respondent on the leading dimension of an attitude battery, to segment or chart by it.
- science: Reduce many correlated measurements of each specimen to a few component columns before further analysis.
Assumptions:
- The components come from one fit over every row that passed the filters (or the subset fit_on selects), never per group; every filtered row is scored.
- A component’s direction (which end is high) follows MAT_PCA’s rule, not the meaning of the fields: read the loadings before naming a score.
- A row missing any of the fields is left out of the fit and gets no score (listwise deletion).
- There must be more complete rows than fields, and no field may be constant or a sum of the others; otherwise every score is empty, with a warning.
Use something else:
MAT_PCAwhen you want how many dimensions there are and which fields load on each.ATTR_MAHALANOBISwhen you want how unusual a row is across the fields.
Glossary: principal-component, eigenvalue, loading, correlation, covariance, z-score, listwise-deletion
Skill: op-attr-pc-score
ATTR_PERCENTILE
Adds to every row its percentile rank, rank / n * 100: the share of rows at or below it when values are untied.
Level: basic
Questions it answers:
- Where does each store’s revenue rank among all stores, as a percentage?
- Which respondents are in the top tenth for spend?
Use cases by domain:
- survey: Percentile of each respondent’s household income.
- ops: Percentile rank of each order’s delivery time.
- science: Percentile of each sample’s reading within the batch.
Assumptions:
- Computed over every row that passed the filters, before grouping: a group never re-centres it.
- Tied values do not share a percentile: each tied row takes its own rank in an arbitrary order, so equal values can read tens of points apart.
- Rows tied at a cut-off are split across it arbitrarily and can change between runs; cut on the value (AGG_PERCENTILE, then a filter) for a reproducible top tenth.
- Not streamable: every value is sorted before the first row is ready.
Use something else:
AGG_PERCENTILEwhen you want the value at a chosen percentile, such as the 90th.WIN_RANKwhen you want ranks within partitions, or tied values to share a rank.GROUP_QUANTILEwhen you want rows split into equal-sized bands.
Glossary: percentile, rank, ties
Skill: op-attr-percentile
ATTR_REG_FITTED
Adds to every row the value a straight-line model predicts for its target from its predictors.
Level: advanced
Questions it answers:
- What order value would we expect for each customer given their visits and tenure?
- Which stores sell above or below what their size and footfall predict?
Use cases by domain:
- survey: Expected satisfaction per respondent from their attribute ratings.
- ops: Expected handling time per ticket from queue length and agent tenure.
- science: Expected response per subject from dose and body weight.
Assumptions:
- The model is fitted to the rows that passed the filters and have the target and every predictor (listwise deletion).
- Each ATTR_REG_* slot fits its own straight-line model; the request’s regressions are not reused.
- A prediction reflects association in these rows, never what would happen if a predictor were changed.
- A row missing any predictor reads 0, which is not a prediction.
Use something else:
REG_OLSwhen you want the coefficients, R-squared and p-values of the model.ATTR_REG_RESIDUALwhen you want how far each row sits from its prediction.REG_GLMwhen the target is yes/no or a count.
Glossary: regression-coefficient, overfitting, listwise-deletion
Skill: op-attr-reg-fitted
ATTR_REG_LEVERAGE
Adds to every row its leverage: how unusual its predictor values are, and so how hard it can pull a straight-line fit.
Level: advanced
Questions it answers:
- Which rows have predictor values so far from the rest that they could steer the model?
- Which customers have predictor values extreme enough that they could pull the fitted line (read their residuals to see whether they do)?
Use cases by domain:
- ops: Find the few very large accounts that could dominate a revenue model; read their residuals before concluding they do.
- science: Screen subjects with extreme dose or weight before trusting a fit.
Assumptions:
- The model is fitted to the rows that passed the filters and have the target and every predictor (listwise deletion).
- Each ATTR_REG_* slot fits its own straight-line model; the request’s regressions are not reused.
- Unpenalized least squares only: any penalty is refused.
- It looks at the predictors only, never at the target.
Use something else:
ATTR_REG_RESIDUALwhen you want rows the model predicts badly.REG_OLSwhen you want the model itself.
Glossary: outlier, residual, listwise-deletion
Skill: op-attr-reg-leverage
ATTR_REG_PREDICT
Adds to every row the value a model fitted once, elsewhere in the request, predicts for it from the row’s own predictor values.
Level: advanced
Questions it answers:
- How close are the model’s predictions on weeks it was not fitted to (a holdout)?
- How much of each week’s sales does the TV term of the fitted model account for?
Use cases by domain:
- survey: Score this wave’s respondents with the satisfaction model fitted on last wave.
- ops: Predict handling time for new tickets from a model fitted on last quarter’s.
- science: Predict the held-out subjects’ response from the dose model fitted on the rest.
Assumptions:
- The model is fitted once, on the fit request’s own rows, before any slot runs; scoring never refits.
- The scoring rows must carry every predictor column the fit used, built the same way (same features); an absent one-hot column is the reference level.
- A row missing any predictor the score reads gets no value (null), never 0.
- A prediction reflects association in the fitted rows, never what would happen if a predictor were changed.
Use something else:
ATTR_REG_FITTEDwhen the model should be fitted to these same rows inside the slot.REG_OLSwhen you want the coefficients, their standard errors and p-values.
Glossary: regression-coefficient, overfitting, link-function, missing-value
Skill: op-attr-reg-predict
ATTR_REG_RESIDUAL
Adds to every row its residual: the actual target value minus the value a straight-line model predicts for it.
Level: advanced
Questions it answers:
- Which stores sell far more or less than their size and footfall predict?
- Do the model’s misses grow with the predicted value, or bend in a curve?
Use cases by domain:
- survey: Respondents far more or less satisfied than their ratings predict.
- ops: Tickets that took far longer than queue length and tenure predict.
- science: Check a dose-response fit for curvature or growing spread.
Assumptions:
- The model is fitted to the rows that passed the filters and have the target and every predictor (listwise deletion).
- Each ATTR_REG_* slot fits its own straight-line model; the request’s regressions are not reused.
- A row missing the target or any predictor reads 0, the same as a row the model fits exactly.
Use something else:
REG_OLSwhen you want the model’s summary (coefficients, R-squared).ATTR_REG_LEVERAGEwhen you want how unusual each row’s predictor values are.ATTR_ZSCOREwhen you want how far a value is from the mean, with no model.
Glossary: residual, heteroscedasticity, outlier, listwise-deletion
Skill: op-attr-reg-residual
ATTR_SET_HAS
Adds a 1 / 0 column saying whether each row’s multi-select field includes one named option.
Level: basic
Questions it answers:
- Which respondents ticked ‘price’ among their reasons, as a column to cross with other answers?
- Which tickets carry the ‘urgent’ tag?
Use cases by domain:
- survey: Flag for one option of a multiple-choice question, to average as a share among those who answered: drop missing answers with FILTER_NULL first, since they read 0.
- ops: Flag for orders tagged with a given promotion.
Assumptions:
- The option must be in the field’s dictionary; an unknown one is refused.
- A row with a missing value reads 0, the same as a row that did not choose the option.
Use something else:
FILTER_SET_CONTAINS_ANYwhen you want to keep only rows that include any of several options.AGG_SET_FREQUENCYwhen you want how often each option was chosen.
Glossary: missing-value
Skill: op-attr-set-has
ATTR_SET_POPCOUNT
Adds to every row the number of options its multi-select field has selected.
Level: basic
Questions it answers:
- How many reasons did each respondent tick?
- Which customers selected three or more interests?
Use cases by domain:
- survey: Number of brands each respondent is aware of.
- ops: Number of tags on each support ticket.
Assumptions:
- An empty selection reads 0, and so does a row with a missing value.
Use something else:
AGG_SET_CARDINALITY_AVGwhen you want the average number of selections per group.ATTR_SET_HASwhen you want whether one particular option was chosen.
Glossary: missing-value
Skill: op-attr-set-popcount
ATTR_TSCORE
Adds to every row its T-score: the z-score rescaled so the mean is 50 and one standard deviation is 10.
Level: intermediate
Questions it answers:
- How does each candidate’s test result compare with the group, on a 50-centred scale?
- Which respondents score more than one standard deviation above average?
Use cases by domain:
- survey: Report standardized scale scores on a 50-centred scale, where negative values are rare.
- science: Express assessment scores on a mean-50, SD-10 scale relative to the rows analysed (not to a published norm group).
Assumptions:
- Computed over every row that passed the filters, before grouping: a group never re-centres it.
- Uses the population standard deviation (dividing by n).
- A missing value reads 50, as does every row when all values are equal.
Use something else:
ATTR_ZSCOREwhen you want plain standard-deviation units centred on 0.ATTR_PERCENTILEwhen you want each row’s percentile rank (the share at or below it when values are untied).
Glossary: z-score, standard-deviation, mean
Skill: op-attr-tscore
ATTR_ZSCORE
Adds to every row its z-score: how many standard deviations the row’s value sits above or below the mean.
Level: intermediate
Also known as: standard score, standardized score
Questions it answers:
- Which orders are unusually large compared with all orders?
- How do scores measured on different scales compare once put on one footing?
Use cases by domain:
- survey: Put rating items with different spreads on a common scale before combining them.
- ops: Flag deliveries far slower than typical for review.
- science: Standardize a measurement before comparing it with another.
Assumptions:
- Computed over every row that passed the filters, before grouping: a group never re-centres it.
- Uses the population standard deviation (dividing by n).
- A missing value reads 0, as does every row when all values are equal.
Use something else:
AGG_ZSCOREwhen you want the centre and spread of each group, not a score per row.ATTR_PERCENTILEwhen the field is skewed or has extreme values and you want a position they cannot distort.OVERLAY_ZSCORE_VS_TOTALwhen you want each group’s value against all groups.
Glossary: z-score, standard-deviation, mean, outlier
Skill: op-attr-zscore
Filterers
The filterers this instance offers: what each is for, the questions it answers and when to reach for something else. Operators are sorted by name; each name links to its detail block below.
| Operator | In plain words | Answers questions like | Level | Instead, when… |
|---|---|---|---|---|
FILTER_DATE_RANGES | Keeps only the rows whose date falls inside one of a set of named date ranges, such as custom fiscal quarters. | What happened during the two campaign periods only? | basic | GROUP_DATE_RANGES when you want each row labelled with its range instead of dropping the rest.GROUP_DATE when you want plain calendar periods such as months.FILTER_RANGE when the field is a number rather than a date. |
FILTER_EXCLUDE | Drops the rows whose field holds one of the listed values and keeps the rest, such as removing test accounts. | What are the totals once internal test accounts are taken out? | basic | FILTER_INCLUDE when you want to keep only the listed values.FILTER_NULL when you want to drop the rows where the field is missing.FILTER_SET_CONTAINS_NONE when the field is a multi-select answer. |
FILTER_EXPRESSION | Keeps the rows for which a written yes-or-no condition is true, for rules the other filters cannot state, such as two fields together. | Which customers spent over 500 and joined this year? | intermediate | FILTER_INCLUDE when you only need to keep a list of values.FILTER_RANGE when you only need a low and a high limit.FILTER_NULL when you only need rows where a field is or is not missing. |
FILTER_FALSE | Keeps only the rows where a yes/no field is no, such as lapsed accounts or unfinished surveys. | What do the customers who did not renew have in common? | basic | FILTER_TRUE when you want the rows where the field is yes.FILTER_NULL when you want only the rows where the field is missing. |
FILTER_INCLUDE | Keeps only the rows whose field holds one of the listed values, such as two regions or three product codes. | What do the figures look like for the North and South regions only? | basic | FILTER_EXCLUDE when you want to drop the listed values and keep everything else.FILTER_RANGE when the field is numeric and you want everything between two limits.FILTER_SET_CONTAINS_ANY when the field is a multi-select answer.AGG_FREQUENCY when you only want how many rows hold one value, not the rows themselves. |
FILTER_NULL | Keeps either the rows where a field is missing or the rows where it is present, to find gaps or set them aside. | Who skipped the income question, so their other answers can be compared with those who answered? | basic | AGG_NULL_COUNT when you only want to count the missing values, not filter on them.FILTER_EXCLUDE when you want to drop particular values, not missing ones. |
FILTER_RANGE | Keeps only the rows whose number lies between a low and a high limit, both limits included. | What do adults aged 18 to 34 say? | basic | FILTER_EXPRESSION when you need an open limit, such as strictly above a value.FILTER_DATE_RANGES when the field is a date and you want named periods.GROUP_RANGE when you want to split the values into bands rather than keep one. |
FILTER_SET_CONTAINS_ALL | Keeps the rows whose multi-select answer includes every listed option, other options allowed, such as people who use both A and B. | How do people who use both our app and our website rate us? | basic | FILTER_SET_CONTAINS_ANY when one of the listed options is enough.FILTER_SET_EQUALS when the row must hold exactly the listed options and nothing else. |
FILTER_SET_CONTAINS_ANY | Keeps the rows whose multi-select answer includes at least one of the listed options, such as anyone who uses brand A or B. | What do people who use either of our two apps think of the service? | basic | FILTER_SET_CONTAINS_ALL when the row must include every listed option.FILTER_SET_CONTAINS_NONE when the row must include none of the listed options.FILTER_INCLUDE when the field holds one value per row, not several. |
FILTER_SET_CONTAINS_NONE | Keeps the rows whose multi-select answer includes none of the listed options, such as people who use neither A nor B. | What do people who use none of the competitor apps think? | basic | FILTER_SET_CONTAINS_ANY when you want rows that include one of the options.FILTER_EXCLUDE when the field holds one value per row, not several. |
FILTER_SET_EQUALS | Keeps the rows whose multi-select answer is exactly the listed options, no more and no fewer, such as people who use only A. | How many customers use our app and nothing else? | basic | FILTER_SET_CONTAINS_ALL when other options are allowed alongside the listed ones.GROUP_SET_VALUE when you want to see every combination with its count. |
FILTER_TRUE | Keeps only the rows where a yes/no field is yes, such as active accounts or completed surveys. | What do only the completed interviews say? | basic | FILTER_FALSE when you want the rows where the field is no.FILTER_EXPRESSION when the condition involves more than one field.GROUP_CATEGORY when you want yes and no side by side as groups, not one of them. |
Operators
FILTER_DATE_RANGES
Keeps only the rows whose date falls inside one of a set of named date ranges, such as custom fiscal quarters.
Level: basic
Questions it answers:
- What happened during the two campaign periods only?
- What are the figures for the first half of our fiscal year?
Use cases by domain:
- survey: Keep interviews from the two fieldwork windows of this wave.
- ops: Keep orders placed during the holiday trading periods.
Assumptions:
- Runs before attributes, groups and aggregations, so every figure in the result comes from the rows it keeps.
- Both ends of each range are inclusive; a range with no start or no end is open on that side.
- The ranges may not overlap; overlapping or duplicate ranges are refused, not merged.
- A date-and-time field is matched by the day it falls on.
- A row whose field is missing is dropped.
Use something else:
GROUP_DATE_RANGESwhen you want each row labelled with its range instead of dropping the rest.GROUP_DATEwhen you want plain calendar periods such as months.FILTER_RANGEwhen the field is a number rather than a date.
Glossary: missing-value
Skill: op-filter-date-ranges
FILTER_EXCLUDE
Drops the rows whose field holds one of the listed values and keeps the rest, such as removing test accounts.
Level: basic
Questions it answers:
- What are the totals once internal test accounts are taken out?
- How do the results change without the store that was closed for refit?
Use cases by domain:
- survey: Drop the “Don’t know” answers before working out shares.
- ops: Drop orders flagged as internal or test.
Assumptions:
- Runs before attributes, groups and aggregations, so every figure in the result comes from the rows it keeps.
- Several filters in one request all have to keep a row for it to stay: they combine as AND, never OR.
- A row whose field is missing is kept, since it does not hold a listed value.
- On a category field each listed value must be a known label, and an unknown label is an error. On a number or date field the values are read as numbers, and one that no row holds simply matches nothing.
Use something else:
FILTER_INCLUDEwhen you want to keep only the listed values.FILTER_NULLwhen you want to drop the rows where the field is missing.FILTER_SET_CONTAINS_NONEwhen the field is a multi-select answer.
Glossary: missing-value
Skill: op-filter-exclude
FILTER_EXPRESSION
Keeps the rows for which a written yes-or-no condition is true, for rules the other filters cannot state, such as two fields together.
Level: intermediate
Questions it answers:
- Which customers spent over 500 and joined this year?
- Which records have an end date before their start date?
Use cases by domain:
- survey: Keep respondents who rated the brand 9 or 10 and bought in the last month.
- ops: Keep orders where the shipped quantity is below the ordered quantity.
- harness: Express a user’s free-form condition as one filter.
Assumptions:
- Runs before attributes, groups and aggregations, so every figure in the result comes from the rows it keeps.
- The condition must give true or false for every row; any other result is an error.
- When a missing value makes the condition unknown, such as a missing number compared with 5, the row is dropped.
- A missing value compared with == is false and with != is true; write x ?? 0 to fill it first.
Use something else:
FILTER_INCLUDEwhen you only need to keep a list of values.FILTER_RANGEwhen you only need a low and a high limit.FILTER_NULLwhen you only need rows where a field is or is not missing.
Glossary: missing-value
Skill: op-filter-expression
FILTER_FALSE
Keeps only the rows where a yes/no field is no, such as lapsed accounts or unfinished surveys.
Level: basic
Questions it answers:
- What do the customers who did not renew have in common?
- How many interviews were left unfinished, by region?
Use cases by domain:
- survey: Keep respondents who did not complete the survey.
- ops: Keep orders not yet shipped.
Assumptions:
- Runs before attributes, groups and aggregations, so every figure in the result comes from the rows it keeps.
- By default the field must be a yes/no field; a truthy option treats 0, an empty text and a missing value as no. On a category field it tests the label text, so a label such as “No” is not no; use FILTER_INCLUDE on the no label instead.
- A missing value is dropped by default but kept under the truthy option, where it counts as no.
Use something else:
FILTER_TRUEwhen you want the rows where the field is yes.FILTER_NULLwhen you want only the rows where the field is missing.
Glossary: missing-value
Skill: op-filter-false
FILTER_INCLUDE
Keeps only the rows whose field holds one of the listed values, such as two regions or three product codes.
Level: basic
Questions it answers:
- What do the figures look like for the North and South regions only?
- How did customers on the two premium plans answer?
Use cases by domain:
- survey: Keep only respondents from the three markets in this wave’s report.
- ops: Keep only orders from the stores in one district.
Assumptions:
- Runs before attributes, groups and aggregations, so every figure in the result comes from the rows it keeps.
- Several filters in one request all have to keep a row for it to stay: they combine as AND, never OR.
- A row whose field is missing is dropped.
- On a category field each listed value must be a known label, and an unknown label is an error. On a number or date field the values are read as numbers, and one that no row holds simply matches nothing.
Use something else:
FILTER_EXCLUDEwhen you want to drop the listed values and keep everything else.FILTER_RANGEwhen the field is numeric and you want everything between two limits.FILTER_SET_CONTAINS_ANYwhen the field is a multi-select answer.AGG_FREQUENCYwhen you only want how many rows hold one value, not the rows themselves.
Glossary: missing-value
Skill: op-filter-include
FILTER_NULL
Keeps either the rows where a field is missing or the rows where it is present, to find gaps or set them aside.
Level: basic
Questions it answers:
- Who skipped the income question, so their other answers can be compared with those who answered?
- What are the averages over only the rows that have a value?
Use cases by domain:
- survey: Look at who skipped a question to see whether they differ from those who answered.
- ops: Find orders with no delivery date recorded.
- science: Set aside samples with no measurement before comparing groups.
Assumptions:
- Runs before attributes, groups and aggregations, so every figure in the result comes from the rows it keeps.
- Missing means no recorded value; a 0, an empty text or an empty multi-select answer is a value, not missing.
- Dropping rows with gaps can bias the result when the gaps are not random.
Use something else:
AGG_NULL_COUNTwhen you only want to count the missing values, not filter on them.FILTER_EXCLUDEwhen you want to drop particular values, not missing ones.
Glossary: missing-value, listwise-deletion
Skill: op-filter-null
FILTER_RANGE
Keeps only the rows whose number lies between a low and a high limit, both limits included.
Level: basic
Questions it answers:
- What do adults aged 18 to 34 say?
- What is the average order once impossible values above 10,000 are left out?
Use cases by domain:
- survey: Keep respondents aged 18 to 34.
- ops: Keep delivery times between 0 and 72 hours, dropping clearly wrong entries.
- science: Keep readings inside the instrument’s working range.
Assumptions:
- Runs before attributes, groups and aggregations, so every figure in the result comes from the rows it keeps.
- Several filters in one request all have to keep a row for it to stay: they combine as AND, never OR.
- Both limits are inclusive: a value equal to the low or the high limit is kept.
- On a date field the limits are day numbers counted from 1 January 1970, not written dates.
- A row whose field is missing is dropped.
Use something else:
FILTER_EXPRESSIONwhen you need an open limit, such as strictly above a value.FILTER_DATE_RANGESwhen the field is a date and you want named periods.GROUP_RANGEwhen you want to split the values into bands rather than keep one.
Glossary: outlier, missing-value
Skill: op-filter-range
FILTER_SET_CONTAINS_ALL
Keeps the rows whose multi-select answer includes every listed option, other options allowed, such as people who use both A and B.
Level: basic
Questions it answers:
- How do people who use both our app and our website rate us?
- Which tickets carry both the urgent and the billing tags?
Use cases by domain:
- survey: Keep respondents who bought both brands in the last month.
- ops: Keep orders that included both a phone and a case.
Assumptions:
- Runs before attributes, groups and aggregations, so every figure in the result comes from the rows it keeps.
- Each listed value must be one of the field’s known labels; an unknown label is an error, not an empty match.
- An empty list of options keeps every row that has an answer.
- A row whose field is missing is dropped.
Use something else:
FILTER_SET_CONTAINS_ANYwhen one of the listed options is enough.FILTER_SET_EQUALSwhen the row must hold exactly the listed options and nothing else.
Glossary: missing-value
Skill: op-filter-set-contains-all
FILTER_SET_CONTAINS_ANY
Keeps the rows whose multi-select answer includes at least one of the listed options, such as anyone who uses brand A or B.
Level: basic
Questions it answers:
- What do people who use either of our two apps think of the service?
- Which customers paid by any card at least once?
Use cases by domain:
- survey: Keep respondents aware of at least one of the two new brands.
- ops: Keep tickets tagged with any of the billing tags.
Assumptions:
- Runs before attributes, groups and aggregations, so every figure in the result comes from the rows it keeps.
- Each listed value must be one of the field’s known labels; an unknown label is an error, not an empty match.
- An empty list of options keeps no rows.
- A row whose field is missing is dropped.
Use something else:
FILTER_SET_CONTAINS_ALLwhen the row must include every listed option.FILTER_SET_CONTAINS_NONEwhen the row must include none of the listed options.FILTER_INCLUDEwhen the field holds one value per row, not several.
Glossary: missing-value
Skill: op-filter-set-contains-any
FILTER_SET_CONTAINS_NONE
Keeps the rows whose multi-select answer includes none of the listed options, such as people who use neither A nor B.
Level: basic
Questions it answers:
- What do people who use none of the competitor apps think?
- Which orders had no discount code applied?
Use cases by domain:
- survey: Keep respondents unaware of every competitor brand.
- ops: Keep tickets with none of the escalation tags.
Assumptions:
- Runs before attributes, groups and aggregations, so every figure in the result comes from the rows it keeps.
- Each listed value must be one of the field’s known labels; an unknown label is an error, not an empty match.
- A row whose field is missing is kept, since it does not hold a listed value.
Use something else:
FILTER_SET_CONTAINS_ANYwhen you want rows that include one of the options.FILTER_EXCLUDEwhen the field holds one value per row, not several.
Glossary: missing-value
Skill: op-filter-set-contains-none
FILTER_SET_EQUALS
Keeps the rows whose multi-select answer is exactly the listed options, no more and no fewer, such as people who use only A.
Level: basic
Questions it answers:
- How many customers use our app and nothing else?
- Which respondents picked exactly these two reasons?
Use cases by domain:
- survey: Keep the respondents loyal to one brand only.
- ops: Keep orders paid with exactly one card and no other method.
Assumptions:
- Runs before attributes, groups and aggregations, so every figure in the result comes from the rows it keeps.
- Each listed value must be one of the field’s known labels; an unknown label is an error, not an empty match.
- An empty list keeps the rows that picked nothing, which differs from a missing answer.
- A row whose field is missing is dropped.
Use something else:
FILTER_SET_CONTAINS_ALLwhen other options are allowed alongside the listed ones.GROUP_SET_VALUEwhen you want to see every combination with its count.
Glossary: missing-value
Skill: op-filter-set-equals
FILTER_TRUE
Keeps only the rows where a yes/no field is yes, such as active accounts or completed surveys.
Level: basic
Questions it answers:
- What do only the completed interviews say?
- What are the figures for active subscribers?
Use cases by domain:
- survey: Keep respondents who passed the attention check.
- ops: Keep orders flagged as delivered.
Assumptions:
- Runs before attributes, groups and aggregations, so every figure in the result comes from the rows it keeps.
- By default the field must be a yes/no field; a truthy option treats any non-zero number and any non-empty text as yes. On a category field it tests the label text, so a label such as “No” counts as yes; filter a Yes/No category with FILTER_INCLUDE on the yes label instead.
- A row whose field is missing is dropped.
Use something else:
FILTER_FALSEwhen you want the rows where the field is no.FILTER_EXPRESSIONwhen the condition involves more than one field.GROUP_CATEGORYwhen you want yes and no side by side as groups, not one of them.
Glossary: missing-value
Skill: op-filter-true
Groupers
The groupers this instance offers: what each is for, the questions it answers and when to reach for something else. Operators are sorted by name; each name links to its detail block below.
| Operator | In plain words | Answers questions like | Level | Instead, when… |
|---|---|---|---|---|
GROUP_CATEGORY | Splits the rows into one group per distinct value of a field, such as one group per region or per answer option. | What is the average spend in each region? | basic | GROUP_RANGE when the field is a number you want in bands, not one group per value.GROUP_DATE when the field is a date and you want months or quarters.GROUP_SET_PER_ELEMENT when the field is a multi-select answer. |
GROUP_DATE | Splits the rows by calendar period of a date, such as month, quarter, ISO week or weekday, to see a measure over time. | How many orders came in each month? | basic | GROUP_DATE_RANGES when your periods are custom, such as campaign windows.OVERLAY_INDEX_VS_PRIOR when you want each period against the one before it.WIN_RUNNING_SUM when you want a running total down the periods. |
GROUP_DATE_RANGES | Labels each row with the named date range its date falls in, such as custom fiscal quarters or before and after a launch. | How did sales compare before, during and after the campaign? | basic | GROUP_DATE when plain calendar months, quarters or weeks are enough.FILTER_DATE_RANGES when you want to keep only the rows inside the ranges. |
GROUP_KMEANS | Finds k segments of rows that are alike across several numbers at once, such as customer types by spend, visits and tenure. | What natural customer segments do spend, visits and tenure suggest? | advanced | GROUP_CATEGORY when you already know the groups.GROUP_QUANTILE when you want bands of one number.ATTR_MAHALANOBIS when you want how unusual each row is. |
GROUP_QUANTILE | Splits the rows into equal-sized groups by rank of a number, such as quartiles or deciles of spend, to compare top and bottom. | How do the top 25% of spenders differ from the bottom 25%? | intermediate | GROUP_RANGE when you want bands of a fixed width, such as every 10 years of age.ATTR_PERCENTILE when you want each row’s rank as a number, not a group.AGG_PERCENTILE when you want the cut-off values themselves. |
GROUP_RANGE | Splits a number into bands of equal width, such as ages 20-30 and 30-40, to see how values spread or to compare bands. | How many customers fall in each 10-year age band? | basic | GROUP_QUANTILE when you want groups of equal size rather than equal width.GROUP_ROUNDED when you want each band named by its lower edge alone.FILTER_RANGE when you want to keep one band, not split into all of them. |
GROUP_ROUNDED | Groups a number by rounding it down to a multiple of a step, such as 23 and 27 both to 20, naming each group by that multiple. | How many orders are there at each price point, to the nearest lower 10? | basic | GROUP_RANGE when you want the band’s both edges in its name.GROUP_QUANTILE when you want groups of equal size. |
GROUP_SET_PER_ELEMENT | Splits a multi-select answer into one group per option, counting each row once in every option it picked. | How many respondents picked each brand they are aware of? | intermediate | GROUP_SET_VALUE when you want each exact combination of options as one group.AGG_SET_FREQUENCY when you only want the count per option, without other figures. |
GROUP_SET_VALUE | Groups the rows by the exact combination of options picked in a multi-select answer, such as A only, A and B, or none. | Which combinations of payment methods do customers use, and how common is each? | intermediate | GROUP_SET_PER_ELEMENT when you want one group per option, counting a row in each it picked.FILTER_SET_EQUALS when you want to keep one combination rather than see them all. |
Operators
GROUP_CATEGORY
Splits the rows into one group per distinct value of a field, such as one group per region or per answer option.
Level: basic
Questions it answers:
- What is the average spend in each region?
- How many respondents chose each answer?
Use cases by domain:
- survey: Satisfaction score by age band or by brand.
- ops: Orders and revenue per store.
- science: Mean reading per treatment arm.
Assumptions:
- Buckets are built from the rows the filters kept; each aggregation is then worked out once per bucket.
- A row whose field is missing lands in no bucket; it is counted as missing, not as a bucket of its own.
- Groups appear in alphabetical order unless an include list names them, which also sets their order and drops the rest.
- A field with very many distinct values makes very many groups; narrow it with a filter first.
Use something else:
GROUP_RANGEwhen the field is a number you want in bands, not one group per value.GROUP_DATEwhen the field is a date and you want months or quarters.GROUP_SET_PER_ELEMENTwhen the field is a multi-select answer.
Glossary: missing-value
Skill: op-group-category
GROUP_DATE
Splits the rows by calendar period of a date, such as month, quarter, ISO week or weekday, to see a measure over time.
Level: basic
Questions it answers:
- How many orders came in each month?
- Which day of the week gets the most complaints?
Use cases by domain:
- survey: Satisfaction by fieldwork month.
- ops: Weekly order volume, or revenue by fiscal quarter.
- science: Daily count of observations.
Assumptions:
- Buckets are built from the rows the filters kept; each aggregation is then worked out once per bucket.
- Named with no period it groups by month; picked by default for a date field with no grouper type, it groups by day.
- Weeks are ISO weeks, Monday to Sunday, numbered within the ISO year.
- A fiscal offset applies to years and quarters only; a fiscal year is named for the calendar year it ends in.
- A date-and-time field is grouped by the day it falls on.
- Only buckets that hold at least one row appear; an empty band or period is left out, not shown as zero.
- A row whose field is missing lands in no bucket; it is counted as missing, not as a bucket of its own.
Use something else:
GROUP_DATE_RANGESwhen your periods are custom, such as campaign windows.OVERLAY_INDEX_VS_PRIORwhen you want each period against the one before it.WIN_RUNNING_SUMwhen you want a running total down the periods.
Glossary: missing-value
Skill: op-group-date
GROUP_DATE_RANGES
Labels each row with the named date range its date falls in, such as custom fiscal quarters or before and after a launch.
Level: basic
Questions it answers:
- How did sales compare before, during and after the campaign?
- What are the totals for each of our custom fiscal quarters?
Use cases by domain:
- survey: Scores per fieldwork wave, where waves have irregular dates.
- ops: Revenue per promotion period.
Assumptions:
- Buckets are built from the rows the filters kept; each aggregation is then worked out once per bucket.
- Both ends of each range are inclusive; the ranges may not overlap.
- A row outside every range goes to an unmatched group (named “unmatched” unless you rename it), not dropped.
- A date-and-time field is matched by the day it falls on.
- A row whose field is missing lands in no bucket; it is counted as missing, not as a bucket of its own.
Use something else:
GROUP_DATEwhen plain calendar months, quarters or weeks are enough.FILTER_DATE_RANGESwhen you want to keep only the rows inside the ranges.
Glossary: missing-value
Skill: op-group-date-ranges
GROUP_KMEANS
Finds k segments of rows that are alike across several numbers at once, such as customer types by spend, visits and tenure.
Level: advanced
Questions it answers:
- What natural customer segments do spend, visits and tenure suggest?
- Which respondent types stand out across the rating battery?
Use cases by domain:
- survey: Attitudinal segments from a battery of ratings.
- ops: Store or customer types from several operating figures.
- science: Groups of samples with similar measurement profiles.
Assumptions:
- Buckets are built from the rows the filters kept; each aggregation is then worked out once per bucket.
- Segments are found, not given: each row joins the nearest segment centre, and the centres are chosen to keep rows close to their own.
- Numbers are put on a common scale first by default, so no field dominates because of its units.
- Segments are named cluster_1 to cluster_k from largest to smallest and come back in that order; the same data and seed give the same names.
- It needs every row before it can name any segment, so it cannot stream.
- A row missing any of the numbers lands in no segment; it is counted as missing.
Use something else:
GROUP_CATEGORYwhen you already know the groups.GROUP_QUANTILEwhen you want bands of one number.ATTR_MAHALANOBISwhen you want how unusual each row is.
Skill: op-group-kmeans
GROUP_QUANTILE
Splits the rows into equal-sized groups by rank of a number, such as quartiles or deciles of spend, to compare top and bottom.
Level: intermediate
Questions it answers:
- How do the top 25% of spenders differ from the bottom 25%?
- What is the average basket in each decile of customer value?
Use cases by domain:
- survey: Satisfaction by income quartile.
- ops: Return rate by decile of order value.
- science: Outcome by quartile of exposure.
Assumptions:
- Buckets are built from the rows the filters kept; each aggregation is then worked out once per bucket.
- Groups are cut by rank among the rows with a value, so each holds as close to the same count as possible; under a row weight each holds as close to the same total weight as possible, a heavy row is never split, and counts stay row counts.
- Equal values can fall in two neighbouring groups when a cut lands among them.
- Groups are named Q1 to Q4 for quartiles, D1 to D10 for deciles, P1 to P100 for percentiles and B1, B2 and so on otherwise; Q1 is the lowest.
- Groups come back in text order of their names, so deciles read D1, D10, D2, … D9; sort them by number before reading a trend.
- It needs every row before it can cut, so it cannot stream.
- A row whose field is missing lands in no bucket; it is counted as missing, not as a bucket of its own.
Use something else:
GROUP_RANGEwhen you want bands of a fixed width, such as every 10 years of age.ATTR_PERCENTILEwhen you want each row’s rank as a number, not a group.AGG_PERCENTILEwhen you want the cut-off values themselves.
Glossary: percentile, rank, ties
Skill: op-group-quantile
GROUP_RANGE
Splits a number into bands of equal width, such as ages 20-30 and 30-40, to see how values spread or to compare bands.
Level: basic
Questions it answers:
- How many customers fall in each 10-year age band?
- What does the spread of delivery times look like in 1-hour bands?
Use cases by domain:
- survey: Respondents per age band.
- ops: Orders per 50-unit order-value band, as a histogram.
- science: Count of readings per band of temperature.
Assumptions:
- Buckets are built from the rows the filters kept; each aggregation is then worked out once per bucket.
- Each band is named low-high and holds values from its low edge up to, but not including, its high edge.
- Bands start at multiples of the width, counted from 0; with no width given it is 1, or 10 when picked by default for a numeric field.
- Rows come back in text order of the band names (100-150 before 50-100); sort by the low edge and add the missing bands before charting a histogram.
- Only buckets that hold at least one row appear; an empty band or period is left out, not shown as zero.
- A row whose field is missing lands in no bucket; it is counted as missing, not as a bucket of its own.
Use something else:
GROUP_QUANTILEwhen you want groups of equal size rather than equal width.GROUP_ROUNDEDwhen you want each band named by its lower edge alone.FILTER_RANGEwhen you want to keep one band, not split into all of them.
Glossary: missing-value
Skill: op-group-range
GROUP_ROUNDED
Groups a number by rounding it down to a multiple of a step, such as 23 and 27 both to 20, naming each group by that multiple.
Level: basic
Questions it answers:
- How many orders are there at each price point, to the nearest lower 10?
- What is the count of scores in each block of 5 points?
Use cases by domain:
- survey: Respondents per 5-point block of a 0-100 score.
- ops: Orders per 10-unit price step, keyed by the step’s start.
- science: Readings binned to a whole-number step.
Assumptions:
- Buckets are built from the rows the filters kept; each aggregation is then worked out once per bucket.
- Values are rounded down, not to the nearest: 19 with a step of 10 goes to 10, and -3 goes to -10.
- It makes the same groups as equal-width bands of the same step; only the names differ.
- Only buckets that hold at least one row appear; an empty band or period is left out, not shown as zero.
- A row whose field is missing lands in no bucket; it is counted as missing, not as a bucket of its own.
Use something else:
GROUP_RANGEwhen you want the band’s both edges in its name.GROUP_QUANTILEwhen you want groups of equal size.
Glossary: missing-value
Skill: op-group-rounded
GROUP_SET_PER_ELEMENT
Splits a multi-select answer into one group per option, counting each row once in every option it picked.
Level: intermediate
Questions it answers:
- How many respondents picked each brand they are aware of?
- What is the average rating among users of each feature?
Use cases by domain:
- survey: Awareness count per brand from a pick-all-that-apply question.
- ops: Tickets per tag, where a ticket can carry several tags.
Assumptions:
- Buckets are built from the rows the filters kept; each aggregation is then worked out once per bucket.
- A row that picked three options is counted in three groups, so group counts add up to more than the number of rows.
- A row that picked nothing lands in no group, and neither does a missing answer.
Use something else:
GROUP_SET_VALUEwhen you want each exact combination of options as one group.AGG_SET_FREQUENCYwhen you only want the count per option, without other figures.
Glossary: missing-value
Skill: op-group-set-per-element
GROUP_SET_VALUE
Groups the rows by the exact combination of options picked in a multi-select answer, such as A only, A and B, or none.
Level: intermediate
Questions it answers:
- Which combinations of payment methods do customers use, and how common is each?
- How many respondents use our app only, versus our app and a competitor’s?
Use cases by domain:
- survey: Brand-repertoire groups: which sets of brands people buy together.
- ops: Orders per combination of add-ons.
Assumptions:
- Buckets are built from the rows the filters kept; each aggregation is then worked out once per bucket.
- Each row lands in exactly one group, named by its picked options joined with |.
- A row that picked nothing gets its own group with an empty name, which differs from a missing answer.
- A row whose field is missing lands in no bucket; it is counted as missing, not as a bucket of its own.
Use something else:
GROUP_SET_PER_ELEMENTwhen you want one group per option, counting a row in each it picked.FILTER_SET_EQUALSwhen you want to keep one combination rather than see them all.
Glossary: missing-value
Skill: op-group-set-value
Window operators
The window operators this instance offers: what each is for, the questions it answers and when to reach for something else. Operators are sorted by name; each name links to its detail block below.
| Operator | In plain words | Answers questions like | Level | Instead, when… |
|---|---|---|---|---|
WIN_DELTA | Adds the change from a set number of rows earlier in the field’s own units, such as orders this month minus last month. | By how many orders did each month go up or down on the month before? | basic | WIN_PCT_CHANGE when you want the change relative to the earlier value.OVERLAY_DELTA_VS_PRIOR when you want each period’s change from the one before as a decoration on the result. |
WIN_DENSE_RANK | Ranks rows within each partition in the chosen order; rows with equal values share a rank and the next follows on: 1, 2, 2, 3. | Which price tier is each product in, counting equal prices as one tier? | basic | WIN_RANK when the rank should skip past equal values, as in sports standings.WIN_ROW_NUMBER when every row needs its own number even when values are equal. |
WIN_EWMA | Adds a smoothed series: each new value counts for a fixed share w, the previous smoothed level for 1 - w, so older values fade. | What is the underlying level of daily demand once day-to-day noise is damped? | intermediate | WIN_MOVING_AVG when you want every row in a fixed window to count equally.OVERLAY_ZSCORE_VS_ROLLING when you want how unusual each period is against its recent run. |
WIN_LAG | Adds to every row the value of a field from a set number of rows earlier in the ordered series, such as last month’s sales. | What were each store’s sales in the month before, on the same row as this month’s? | basic | WIN_DELTA when you want the difference from the earlier row, not its value.WIN_LEAD when you want the value from a later row.OVERLAY_INDEX_VS_PRIOR when you want each period against the period before it as an index. |
WIN_LEAD | Adds to every row the value of a field from a set number of rows later in the ordered series, such as next month’s sales. | What did each customer spend in the following month, beside this month’s spend? | basic | WIN_LAG when you want the value from an earlier row.WIN_DELTA when you want how much the field changed between rows. |
WIN_MOVING_AVG | Adds a moving average: the mean of a field over a fixed number of neighbouring rows, smoothing short-term swings in a series. | What is the trailing seven-day average of daily orders? | basic | WIN_RUNNING_AVG when you want the average of everything up to this row.WIN_EWMA when you want recent rows to count more than older ones.OVERLAY_INDEX_VS_ROLLING_MEAN when you want each period against its recent average. |
WIN_PCT_CHANGE | Adds the change from a set number of rows earlier as a fraction of the earlier value: 0.05 is a 5% rise on last month. | By what percentage did each month’s revenue grow on the month before? | basic | WIN_DELTA when the earlier value can be 0, near 0 or negative, or the field is itself a percentage.OVERLAY_YOY when you want the same year-ago period as the comparison.OVERLAY_INDEX_VS_PRIOR when you want each period as an index value against the one before. |
WIN_RANK | Ranks rows within each partition in the chosen order; rows with equal values share a rank and the next skips: 1, 2, 2, 4. | Where does each store rank on revenue within its region? | basic | WIN_DENSE_RANK when equal values should share a rank with no gap after them.WIN_ROW_NUMBER when every row needs its own number even when values are equal.ATTR_PERCENTILE when you want each record’s position as a percentage of all records. |
WIN_ROW_NUMBER | Numbers the rows 1, 2, 3 within each partition in the chosen order, for picking the first few rows of every group. | Which are the top three products by revenue in each region? | basic | WIN_RANK when rows with equal values should share a position.WIN_DENSE_RANK when you want ranks without gaps after equal values. |
WIN_RUNNING_AVG | Adds the average of a field over the rows so far in the ordered series, such as the average order value to date. | What is the average monthly revenue for the year so far, at each month? | basic | WIN_MOVING_AVG when you want the average of a fixed number of recent rows.AGG_AVERAGE when you want one average per group, not a running one. |
WIN_RUNNING_SUM | Adds a running total of a field down the ordered rows, such as revenue for the year to date. | What is the year-to-date revenue at the end of each month? | basic | AGG_SUM when you want one total per group, not a running one.WIN_RUNNING_AVG when you want the running average instead of the total. |
Operators
WIN_DELTA
Adds the change from a set number of rows earlier in the field’s own units, such as orders this month minus last month.
Level: basic
Questions it answers:
- By how many orders did each month go up or down on the month before?
- How many points did satisfaction move between waves?
Use cases by domain:
- survey: Change in a brand’s awareness, in percentage points, from the last wave.
- ops: Week-on-week change in tickets opened per queue.
- science: Change in each subject’s reading since the previous visit.
Assumptions:
- Runs on the result’s rows after grouping: one row per group, or one per filtered record when nothing is grouped.
- Computed separately within each partition_by slice, in order_by order; with no partition the whole result is one series.
- Steps are rows, not calendar periods: a period with no row is skipped, not filled, so one row back can span two periods.
- The first rows of each partition, and any row where either value is missing, read null; an earlier value of 0 is a real change.
Use something else:
WIN_PCT_CHANGEwhen you want the change relative to the earlier value.OVERLAY_DELTA_VS_PRIORwhen you want each period’s change from the one before as a decoration on the result.
Glossary: percentage-point, baseline, missing-value
Skill: op-win-delta
WIN_DENSE_RANK
Ranks rows within each partition in the chosen order; rows with equal values share a rank and the next follows on: 1, 2, 2, 3.
Level: basic
Questions it answers:
- Which price tier is each product in, counting equal prices as one tier?
- With scores ordered desc, how many distinct scores sit above each respondent’s (the dense rank minus 1)?
Use cases by domain:
- survey: Position of each rating level from the top, with equal ratings sharing one position.
- ops: Tier number of each store by sales, equal sales sharing a tier.
- science: Order of distinct dose levels within each trial arm.
Assumptions:
- Runs on the result’s rows after grouping: one row per group, or one per filtered record when nothing is grouped.
- Rank 1 goes to the first row in order_by order: the smallest value, or the largest when the key is desc.
- Rows tie only when equal on every order_by key.
- Rows with a missing order_by value sort last, in both directions.
Use something else:
WIN_RANKwhen the rank should skip past equal values, as in sports standings.WIN_ROW_NUMBERwhen every row needs its own number even when values are equal.
Skill: op-win-dense-rank
WIN_EWMA
Adds a smoothed series: each new value counts for a fixed share w, the previous smoothed level for 1 - w, so older values fade.
Level: intermediate
Also known as: exponential smoothing, exponentially weighted moving average
Questions it answers:
- What is the underlying level of daily demand once day-to-day noise is damped?
- Is the smoothed defect rate drifting up over recent batches?
Use cases by domain:
- survey: Smoothed tracking score that reacts to recent waves more than old ones.
- ops: Smoothed daily demand for a stock forecast.
- science: Smoothed sensor reading that follows slow drift and damps spikes.
Assumptions:
- Runs on the result’s rows after grouping: one row per group, or one per filtered record when nothing is grouped.
- Computed separately within each partition_by slice, in order_by order; with no partition the whole result is one series.
- Steps are rows, not calendar periods: a period with no row is skipped, not filled, so one row back can span two periods.
- The share w is set directly (params.alpha, above 0 and at most 1) or as its complement (params.decay = 1 - w, at least 0 and below 1); it seeds from the partition’s first value present.
- A missing value reads null and the smoothing carries over it.
Use something else:
WIN_MOVING_AVGwhen you want every row in a fixed window to count equally.OVERLAY_ZSCORE_VS_ROLLINGwhen you want how unusual each period is against its recent run.
Glossary: mean, missing-value
Skill: op-win-ewma
WIN_LAG
Adds to every row the value of a field from a set number of rows earlier in the ordered series, such as last month’s sales.
Level: basic
Questions it answers:
- What were each store’s sales in the month before, on the same row as this month’s?
- What score did each respondent give in the previous wave?
Use cases by domain:
- survey: Previous wave’s score beside the current one for each tracked brand.
- ops: Last week’s order count beside this week’s, per warehouse.
- science: The previous reading beside each measurement in a time series.
Assumptions:
- Runs on the result’s rows after grouping: one row per group, or one per filtered record when nothing is grouped.
- Computed separately within each partition_by slice, in order_by order; with no partition the whole result is one series.
- Steps are rows, not calendar periods: a period with no row is skipped, not filled, so one row back can span two periods.
- The first rows of each partition have no earlier row and read null, or the default when one is set.
Use something else:
WIN_DELTAwhen you want the difference from the earlier row, not its value.WIN_LEADwhen you want the value from a later row.OVERLAY_INDEX_VS_PRIORwhen you want each period against the period before it as an index.
Glossary: missing-value
Skill: op-win-lag
WIN_LEAD
Adds to every row the value of a field from a set number of rows later in the ordered series, such as next month’s sales.
Level: basic
Questions it answers:
- What did each customer spend in the following month, beside this month’s spend?
- What reading came next after each measurement?
Use cases by domain:
- survey: Next wave’s answer beside the current one, to see who changed their mind.
- ops: Next shipment date beside each order, to measure the wait.
- science: The following reading beside each measurement in a series.
Assumptions:
- Runs on the result’s rows after grouping: one row per group, or one per filtered record when nothing is grouped.
- Computed separately within each partition_by slice, in order_by order; with no partition the whole result is one series.
- Steps are rows, not calendar periods: a period with no row is skipped, not filled, so one row back can span two periods.
- The last rows of each partition have no later row and read null, or the default when one is set.
Use something else:
WIN_LAGwhen you want the value from an earlier row.WIN_DELTAwhen you want how much the field changed between rows.
Glossary: missing-value
Skill: op-win-lead
WIN_MOVING_AVG
Adds a moving average: the mean of a field over a fixed number of neighbouring rows, smoothing short-term swings in a series.
Level: basic
Also known as: rolling mean, simple moving average
Questions it answers:
- What is the trailing seven-day average of daily orders?
- Is the trend in weekly sign-ups rising once the week-to-week noise is smoothed out?
Use cases by domain:
- survey: Three-wave average of a tracking score to steady small-sample waves.
- ops: Seven-day average of daily orders.
- science: Smooth a noisy sensor series before reading its trend.
Assumptions:
- Runs on the result’s rows after grouping: one row per group, or one per filtered record when nothing is grouped.
- Computed separately within each partition_by slice, in order_by order; with no partition the whole result is one series.
- Steps are rows, not calendar periods: a period with no row is skipped, not filled, so one row back can span two periods.
- The frame must be bounded on both sides; near a partition’s edges it holds fewer rows.
- Missing values are skipped, so the figure covers the values present; a frame with no value reads null.
Use something else:
WIN_RUNNING_AVGwhen you want the average of everything up to this row.WIN_EWMAwhen you want recent rows to count more than older ones.OVERLAY_INDEX_VS_ROLLING_MEANwhen you want each period against its recent average.
Glossary: rolling-mean, mean, missing-value
Skill: op-win-moving-avg
WIN_PCT_CHANGE
Adds the change from a set number of rows earlier as a fraction of the earlier value: 0.05 is a 5% rise on last month.
Level: basic
Questions it answers:
- By what percentage did each month’s revenue grow on the month before?
- Which regions grew fastest relative to their own previous quarter?
Use cases by domain:
- survey: Relative change in sample size per wave.
- ops: Month-on-month growth in revenue per product line.
- science: Relative change in a measurement since the previous visit.
Assumptions:
- Runs on the result’s rows after grouping: one row per group, or one per filtered record when nothing is grouped.
- Computed separately within each partition_by slice, in order_by order; with no partition the whole result is one series.
- Steps are rows, not calendar periods: a period with no row is skipped, not filled, so one row back can span two periods.
- The first rows of each partition, any row where either value is missing, and any row whose earlier value is 0 read null.
Use something else:
WIN_DELTAwhen the earlier value can be 0, near 0 or negative, or the field is itself a percentage.OVERLAY_YOYwhen you want the same year-ago period as the comparison.OVERLAY_INDEX_VS_PRIORwhen you want each period as an index value against the one before.
Glossary: baseline, percentage-point, index-value, missing-value
Skill: op-win-pct-change
WIN_RANK
Ranks rows within each partition in the chosen order; rows with equal values share a rank and the next skips: 1, 2, 2, 4.
Level: basic
Questions it answers:
- Where does each store rank on revenue within its region?
- Which product came first in each month by units sold?
Use cases by domain:
- survey: Rank of each brand on preference within every market.
- ops: Rank of each warehouse on on-time delivery within its region.
- science: Rank of each site’s yield within each season.
Assumptions:
- Runs on the result’s rows after grouping: one row per group, or one per filtered record when nothing is grouped.
- Rank 1 goes to the first row in order_by order: the smallest value, or the largest when the key is desc.
- Rows tie only when equal on every order_by key.
- Rows with a missing order_by value sort last, in both directions.
Use something else:
WIN_DENSE_RANKwhen equal values should share a rank with no gap after them.WIN_ROW_NUMBERwhen every row needs its own number even when values are equal.ATTR_PERCENTILEwhen you want each record’s position as a percentage of all records.
Skill: op-win-rank
WIN_ROW_NUMBER
Numbers the rows 1, 2, 3 within each partition in the chosen order, for picking the first few rows of every group.
Level: basic
Questions it answers:
- Which are the top three products by revenue in each region?
- What is each customer’s first order, by date?
Use cases by domain:
- survey: Number each respondent’s answers in the order given.
- ops: Top five stores by sales within each region.
- harness: Number result rows to page through them in a fixed order.
Assumptions:
- Runs on the result’s rows after grouping: one row per group, or one per filtered record when nothing is grouped.
- Row 1 goes to the first row in order_by order: the smallest value, or the largest when the key is desc, so set desc for a top-N.
- Rows with equal order_by values still get different numbers, in an arbitrary but repeatable order.
- Rows with a missing order_by value sort last, in both directions.
Use something else:
WIN_RANKwhen rows with equal values should share a position.WIN_DENSE_RANKwhen you want ranks without gaps after equal values.
Skill: op-win-row-number
WIN_RUNNING_AVG
Adds the average of a field over the rows so far in the ordered series, such as the average order value to date.
Level: basic
Questions it answers:
- What is the average monthly revenue for the year so far, at each month?
- How has the cumulative average score settled as more waves came in?
Use cases by domain:
- survey: Average satisfaction to date across fieldwork days.
- ops: Average handling time to date, month by month.
- science: Cumulative mean of repeated measurements as each one is added.
Assumptions:
- Runs on the result’s rows after grouping: one row per group, or one per filtered record when nothing is grouped.
- Computed separately within each partition_by slice, in order_by order; with no partition the whole result is one series.
- The frame sets which rows count: no preceding bound with following 0 gives the average from the start to this row.
- Missing values are skipped, so the figure covers the values present; a frame with no value reads null.
Use something else:
WIN_MOVING_AVGwhen you want the average of a fixed number of recent rows.AGG_AVERAGEwhen you want one average per group, not a running one.
Glossary: mean, missing-value
Skill: op-win-running-avg
WIN_RUNNING_SUM
Adds a running total of a field down the ordered rows, such as revenue for the year to date.
Level: basic
Questions it answers:
- What is the year-to-date revenue at the end of each month?
- How many responses had come in by each day of fieldwork?
Use cases by domain:
- survey: Cumulative completes per day against the fieldwork quota.
- ops: Year-to-date sales per region, month by month.
- science: Cumulative dose received by each visit.
Assumptions:
- Runs on the result’s rows after grouping: one row per group, or one per filtered record when nothing is grouped.
- Computed separately within each partition_by slice, in order_by order; with no partition the whole result is one series.
- The frame sets which rows add up: no preceding bound with following 0 gives the total from the start to this row.
- Missing values are skipped, so the figure covers the values present; a frame with no value reads null.
Use something else:
AGG_SUMwhen you want one total per group, not a running one.WIN_RUNNING_AVGwhen you want the running average instead of the total.
Glossary: missing-value
Skill: op-win-running-sum
Feature operators
The feature operators this instance offers: what each is for, the questions it answers and when to reach for something else. Operators are sorted by name; each name links to its detail block below.
| Operator | In plain words | Answers questions like | Level | Instead, when… |
|---|---|---|---|---|
FEAT_BUCKETIZE | Puts each value into an ordered bin, using cut points you give or bins holding about equal numbers of records, and adds the bin number. | Which income band is each customer in? | basic | GROUP_RANGE when you want results grouped by fixed-width bands.GROUP_QUANTILE when you want results grouped by equal-count bins. |
FEAT_DATE_FEATURES | Splits a date into year, month, day, day of week and quarter columns, so each part can be filtered, grouped or modelled. | Do orders differ by day of the week? | basic | ATTR_DATE_PART when you need just one part of the date.GROUP_DATE when you want results grouped by day, month or year. |
FEAT_FREQUENCY_ENCODE | Replaces each category with the share of records that carry it, one number that says how common the category is. | How common is each customer’s city, as a single number a model can use? | intermediate | GROUP_CATEGORY when you want the count table itself, one row per category: group by the field, then count.FEAT_ONE_HOT when the field has few categories and each should be its own column. |
FEAT_LOG | Adds a column holding the natural log of 1 + the value, which pulls in a long tail of large values so a skewed field is easier to model. | Can I tame the long tail of customer spend before fitting a model on it? | intermediate | FEAT_SQRT when the tail is only moderate and a gentler squeeze is enough.ATTR_ZSCORE when you want each value in standard units from the mean, not a squeezed scale.FEAT_BUCKETIZE when you want ordered bins instead of a continuous transform. |
FEAT_ONE_HOT | Turns a category field into one 0/1 column per category, so a model or a sum can treat each category as its own yes/no. | How do I feed region into a regression as separate yes/no predictors? | basic | FEAT_FREQUENCY_ENCODE when the field has hundreds of categories and one column each is too many.GROUP_CATEGORY when you want how many rows fall in each category: group by the field, then count. |
FEAT_POLY | Adds columns holding the value squared, cubed and so on up to a chosen power, so a straight-line model can bend into a curve. | Does satisfaction rise with tenure and then level off? | advanced | REG_OLS when you want to fit the curve itself, not just build its columns.ATTR_FORMULA when you want one custom transform of the value. |
FEAT_SQRT | Adds a column holding the square root of the value, a gentler squeeze than a log for counts with a moderate long tail. | Can I soften the few very large complaint counts without a full log? | intermediate | FEAT_LOG when the tail is very long and needs a stronger squeeze.ATTR_ZSCORE when you want each value in standard units from the mean. |
FEAT_TARGET_ENCODE | Replaces each category with the average outcome of the records in that category, optionally pulled toward the overall average. | How do I give a model a single number for each of a thousand postcodes? | advanced | AGG_AVERAGE when you want to report each category’s average outcome.FEAT_ONE_HOT when the field has few categories and each should be its own column.FEAT_FREQUENCY_ENCODE when you want how common each category is, without using the outcome. |
FEAT_TRAIN_TEST_SPLIT | Labels every record train (0), validation (1) or test (2) by a seeded shuffle, so a model can be checked on records it never learned from. | How do I hold back 20% of records to test a model on? | intermediate | capability:sample when you just want a random subset of rows to look at.GROUP_CATEGORY when you want to compare groups that already exist. |
Operators
FEAT_BUCKETIZE
Puts each value into an ordered bin, using cut points you give or bins holding about equal numbers of records, and adds the bin number.
Level: basic
Questions it answers:
- Which income band is each customer in?
- Which spend decile (tenth of orders ranked by spend) does each order fall in?
Use cases by domain:
- survey: Age bands for each respondent, from fixed cut points.
- ops: Order value tenths, as a column later steps can filter on.
- science: Exposure bands for each subject, for a banded analysis.
Assumptions:
- Runs on every record before filters, so a filter can use the new column but never narrows what the feature sees.
- Bin 0 is the lowest; a value equal to a cut point goes in the bin below it.
- Equal-count bins use cut points from every record of the cohort, and repeated values can leave bins uneven. A missing input value gives a missing output.
Use something else:
GROUP_RANGEwhen you want results grouped by fixed-width bands.GROUP_QUANTILEwhen you want results grouped by equal-count bins.
Glossary: percentile, missing-value
Skill: op-feat-bucketize
FEAT_DATE_FEATURES
Splits a date into year, month, day, day of week and quarter columns, so each part can be filtered, grouped or modelled.
Level: basic
Questions it answers:
- Do orders differ by day of the week?
- Is there a seasonal pattern by month across several years?
Use cases by domain:
- survey: Quarter and weekday of each interview, to check fieldwork timing.
- ops: Weekday and month of each order, as model inputs for demand.
- science: Month of each sample, to look for seasonal effects.
Assumptions:
- Runs on every record before filters, so a filter can use the new column but never narrows what the feature sees.
- Accepts a date or datetime field; a datetime is read on the local clock of its time zone and adds an hour column. Day of week is 0 for Sunday through 6 for Saturday.
- A missing date gives missing values in every column.
Use something else:
ATTR_DATE_PARTwhen you need just one part of the date.GROUP_DATEwhen you want results grouped by day, month or year.
Glossary: missing-value
Skill: op-feat-date-features
FEAT_FREQUENCY_ENCODE
Replaces each category with the share of records that carry it, one number that says how common the category is.
Level: intermediate
Questions it answers:
- How common is each customer’s city, as a single number a model can use?
- Which records belong to rare product codes?
Use cases by domain:
- survey: How common each respondent’s free-text employer code is.
- ops: How common each SKU is, to flag rare items.
- science: How common each species label is in the sample.
Assumptions:
- Runs on every record before filters, so a filter can use the new column but never narrows what the feature sees.
- Its figures come from every record of the cohort, before any filter: filtering afterwards does not recompute them.
- The share is out of records with a category; records missing it read missing and are left out of the total.
Use something else:
GROUP_CATEGORYwhen you want the count table itself, one row per category: group by the field, then count.FEAT_ONE_HOTwhen the field has few categories and each should be its own column.
Glossary: missing-value
Skill: op-feat-frequency-encode
FEAT_LOG
Adds a column holding the natural log of 1 + the value, which pulls in a long tail of large values so a skewed field is easier to model.
Level: intermediate
Questions it answers:
- Can I tame the long tail of customer spend before fitting a model on it?
- How does income look on a log-like scale, where for large values doubling counts about the same everywhere?
Use cases by domain:
- survey: Log of reported household income before using it as a predictor.
- ops: Log of order value, where a few huge orders dwarf the rest.
- science: Log of a concentration that spans several orders of magnitude.
Assumptions:
- Runs on every record before filters, so a filter can use the new column but never narrows what the feature sees.
- Computes ln(1 + x), not ln(x): 0 maps to 0, and values between -1 and 0 map below 0.
- A value of -1 or less has no log and reads missing, with no error; A missing input value gives a missing output.
Use something else:
FEAT_SQRTwhen the tail is only moderate and a gentler squeeze is enough.ATTR_ZSCOREwhen you want each value in standard units from the mean, not a squeezed scale.FEAT_BUCKETIZEwhen you want ordered bins instead of a continuous transform.
Glossary: skew, missing-value
Skill: op-feat-log
FEAT_ONE_HOT
Turns a category field into one 0/1 column per category, so a model or a sum can treat each category as its own yes/no.
Level: basic
Also known as: dummy coding, dummy variables
Questions it answers:
- How do I feed region into a regression as separate yes/no predictors?
- Which rows are in each plan tier, as columns I can sum?
Use cases by domain:
- survey: One yes/no column per answer to a single-choice question.
- ops: One column per warehouse for a model of delivery time.
- science: One column per treatment arm in a design table.
Assumptions:
- Runs on every record before filters, so a filter can use the new column but never narrows what the feature sees.
- Columns come from the field’s dictionary, so a category with no rows still gets an all-zero column.
- A missing category reads 0 in every column; there is no separate unknown column.
- In a model with an intercept, leave one category’s column out as the reference: all of them together are perfectly collinear and the fit is refused.
Use something else:
FEAT_FREQUENCY_ENCODEwhen the field has hundreds of categories and one column each is too many.GROUP_CATEGORYwhen you want how many rows fall in each category: group by the field, then count.
Glossary: missing-value, multicollinearity
Skill: op-feat-one-hot
FEAT_POLY
Adds columns holding the value squared, cubed and so on up to a chosen power, so a straight-line model can bend into a curve.
Level: advanced
Questions it answers:
- Does satisfaction rise with tenure and then level off?
- Is the effect of temperature on yield curved rather than straight?
Use cases by domain:
- survey: Age and age squared as predictors, for an effect that peaks in mid-life.
- ops: Order size and its square, for a cost curve that steepens.
- science: Dose, dose squared and dose cubed for a curved dose-response fit.
Assumptions:
- Runs on every record before filters, so a filter can use the new column but never narrows what the feature sees.
- Emits powers 2 up to the degree (at most 10); the power-1 term is the original column, which you add yourself.
- Raw powers grow fast and move together; centre or standardise the field first. A missing input value gives a missing output.
Use something else:
REG_OLSwhen you want to fit the curve itself, not just build its columns.ATTR_FORMULAwhen you want one custom transform of the value.
Glossary: regression-coefficient, multicollinearity, missing-value
Skill: op-feat-poly
FEAT_SQRT
Adds a column holding the square root of the value, a gentler squeeze than a log for counts with a moderate long tail.
Level: intermediate
Questions it answers:
- Can I soften the few very large complaint counts without a full log?
- What does the field look like with its big values pulled in a little?
Use cases by domain:
- survey: Square root of the number of open-text mentions per respondent.
- ops: Square root of daily ticket counts before modelling them.
- science: Square root of event counts, a common step for count data.
Assumptions:
- Runs on every record before filters, so a filter can use the new column but never narrows what the feature sees.
- A negative value has no real square root and reads missing, with no error; A missing input value gives a missing output.
Use something else:
FEAT_LOGwhen the tail is very long and needs a stronger squeeze.ATTR_ZSCOREwhen you want each value in standard units from the mean.
Glossary: missing-value
Skill: op-feat-sqrt
FEAT_TARGET_ENCODE
Replaces each category with the average outcome of the records in that category, optionally pulled toward the overall average.
Level: advanced
Questions it answers:
- How do I give a model a single number for each of a thousand postcodes?
- What is the average sale price of each product category, on every record?
Use cases by domain:
- survey: Average satisfaction of each respondent’s employer, as a predictor.
- ops: Average delivery delay of each carrier route, on every shipment.
- science: Average response of each batch from earlier or held-out runs, as a covariate (an average that includes a row’s own response leaks it).
Assumptions:
- Runs on every record before filters, so a filter can use the new column but never narrows what the feature sees.
- Its figures come from every record of the cohort, before any filter: filtering afterwards does not recompute them.
- It reads no split column: placing FEAT_TRAIN_TEST_SPLIT first changes nothing, and test rows and each row’s own outcome still feed every average.
- Smoothing s gives (n * category average + s * overall average) / (n + s); 0 means no pull.
Use something else:
AGG_AVERAGEwhen you want to report each category’s average outcome.FEAT_ONE_HOTwhen the field has few categories and each should be its own column.FEAT_FREQUENCY_ENCODEwhen you want how common each category is, without using the outcome.
Glossary: mean, overfitting, missing-value
Skill: op-feat-target-encode
FEAT_TRAIN_TEST_SPLIT
Labels every record train (0), validation (1) or test (2) by a seeded shuffle, so a model can be checked on records it never learned from.
Level: intermediate
Questions it answers:
- How do I hold back 20% of records to test a model on?
- Can I split records so each class keeps its share in train and test?
Use cases by domain:
- survey: Hold out a share of respondents to check a scoring model.
- ops: Train, validation and test sets for a churn model.
- harness: A repeatable split so two runs compare models on the same rows.
Assumptions:
- Runs on every record before filters, so a filter can use the new column but never narrows what the feature sees.
- Each share is the ratio times the record count, rounded; with stratify it is applied within each category.
- The same seed on the same records in the same order gives the same labels; adding or reordering records reshuffles them.
- The split does not isolate other features: frequency encoding, target encoding and equal-count bucketing in the same request still learn from test rows, so build those from the train rows in a separate request.
Use something else:
capability:samplewhen you just want a random subset of rows to look at.GROUP_CATEGORYwhen you want to compare groups that already exist.
Glossary: overfitting
Skill: op-feat-train-test-split
Statistical tests
The statistical tests this instance offers: what each is for, the questions it answers and when to reach for something else. Operators are sorted by name; each name links to its detail block below.
| Operator | In plain words | Answers questions like | Level | Instead, when… |
|---|---|---|---|---|
TEST_ANOVA_F | Checks whether the average of a numeric measure differs across three or more groups. | Does average spend differ across regions? | intermediate | TEST_ANOVA_WELCH when the groups have clearly unequal spread.TEST_KRUSKAL_WALLIS when the measure is heavily skewed or only ordered.TEST_ANOVA_RM when the same subjects are measured in every group.TEST_WELCH when there are only two groups.TEST_TUKEY_HSD when you need to know which pairs of groups differ. |
TEST_ANOVA_RM | Checks whether the average differs across conditions when every subject is measured under each condition. | Do the same panel members rate the three ad concepts differently on average? | advanced | TEST_ANOVA_F when each group holds different, unrelated subjects.TEST_PAIRED_T when there are only two conditions. |
TEST_ANOVA_WELCH | Checks whether the average of a numeric measure differs across groups when the groups may have unequal spread. | Does average response time differ across regions whose variability is very different? | intermediate | TEST_KRUSKAL_WALLIS when the measure is heavily skewed or only ordered.TEST_ANOVA_RM when the same subjects are measured in every group.TEST_WELCH when there are only two groups, or you want pairwise follow-ups: run TEST_WELCH per pair and set multiplicity on the request (for example holm) to adjust their p-values for multiple comparisons.TEST_ANOVA_F when spreads are similar and you want Tukey’s pairwise follow-up. |
TEST_BARTLETT_SPHERICITY | Checks whether a set of numeric fields are related at all, the first check before summarising them as a few underlying dimensions. | Do the ten satisfaction items move together enough to be worth reducing to a few scores? | advanced | MAT_CORRELATION when you want how strongly each pair is related.MAT_PCA when you want the dimensions themselves. |
TEST_BOX_M | Checks whether several groups share one spread pattern across a set of numeric fields, the assumption behind comparing average profiles. | Before comparing segment profiles, do the segments have similar spread across the metrics? | advanced | TEST_BROWN_FORSYTHE when there is only one numeric field.TEST_MANOVA when you want whether the average profiles differ. |
TEST_BROWN_FORSYTHE | Checks whether the spread of a numeric field differs across groups: a robust test of equal variances. | Is delivery time more variable at some warehouses than at others? | advanced | TEST_ANOVA_WELCH when you want to compare the group averages themselves.TEST_SHAPIRO_WILK when you want to check whether a measure is bell-shaped. |
TEST_CHISQ | Checks whether two categorical fields are associated by comparing a cross-tabulation with the counts expected if unrelated. | Is preferred channel associated with age band? | intermediate | TEST_FISHER_EXACT when the table is 2x2 and some expected counts are small.TEST_PROP_Z when you compare a yes/no rate between exactly two groups and want a confidence interval.TEST_SPEARMAN_R when both fields are numeric.OVERLAY_CHISQ_VS_POP when you compare one subgroup’s mix with the whole population. |
TEST_FISHER_EXACT | Exact test of whether two yes/no fields are associated, built for small 2x2 tables. | In a small pilot, did the treated group recover more often than the control group? | intermediate | TEST_CHISQ when either field has more than two categories.TEST_PROP_Z when counts are large and you want the difference in rates with a confidence interval. |
TEST_HOTELLING_T2 | Checks whether several numeric fields, taken together as one profile, differ from a target, between two groups, or before vs after. | Do the two app versions differ on the whole set of engagement metrics at once? | advanced | TEST_T when there is only one numeric field.TEST_WELCH when you want each field compared on its own. |
TEST_KENDALL_TAU | Measures how often pairs of rows agree in order on two numeric fields: a rank-based link suited to small samples and ties. | In a small panel, do judges who score one entry higher also score the other higher? | intermediate | TEST_SPEARMAN_R when the data are large; its cost grows with the square of the row count.TEST_PEARSON_R when you want the strength of a straight-line link.TEST_TREND when you want to know whether one ordered series keeps rising or falling. |
TEST_KRUSKAL_WALLIS | Rank-based check of whether values tend to be larger in some groups than in others, across two or more groups. | Do satisfaction ratings tend to differ across the four regions? | intermediate | TEST_ANOVA_WELCH when the data are roughly normal and you want to compare averages.TEST_MANN_WHITNEY_U when there are only two groups.TEST_ANOVA_RM when the same subjects are measured in every group and the measure is roughly normal (Pulse has no Friedman test for skewed repeated measures; for two conditions use TEST_WILCOXON_SR). |
TEST_KS | Checks whether a numeric field’s values follow the same distribution in two groups, comparing their whole shape. | Do order values in the two regions have the same overall distribution? | intermediate | TEST_SHAPIRO_WILK when you want to check one field against the normal distribution.TEST_MANN_WHITNEY_U when you only care whether one group’s values tend to be larger.OVERLAY_KS_VS_POP when you compare a subgroup with the whole population. |
TEST_MANN_WHITNEY_U | Rank-based check of whether values in one of two groups tend to be larger than in the other. | Do customers on the new plan tend to rate the service higher? | intermediate | TEST_WILCOXON_SR when the same subjects are measured twice.TEST_KRUSKAL_WALLIS when there are three or more groups.TEST_WELCH when the data are roughly normal and you want the difference in averages.TEST_KS when you care about any difference in the shape of the two distributions. |
TEST_MANOVA | Checks whether several numeric fields, taken together as one profile, differ across two or more groups. | Do our customer segments differ on the whole set of spend, visit and basket metrics at once? | advanced | TEST_ANOVA_F when there is only one numeric field.TEST_HOTELLING_T2 when there are only two groups and you want the T² figure. |
TEST_MARDIA | Checks whether a set of numeric fields looks jointly normal, through their combined skew and tail weight. | Before comparing segment profiles, are the metrics close enough to jointly normal? | advanced | TEST_SHAPIRO_WILK when there is only one numeric field and you want a sharper normality test.TEST_BOX_M when you want whether the groups share one covariance. |
TEST_PAIRED_T | Checks whether the average change between two measurements of the same rows, such as before and after, differs from zero. | On average, did customers’ spend differ between the period before and after the loyalty programme started? | intermediate | TEST_WELCH when the two sets of values come from different, unrelated subjects.TEST_WILCOXON_SR when the per-row differences are skewed or have extreme values.TEST_ANOVA_RM when each subject is measured under three or more conditions. |
TEST_PEARSON_R | Measures how strongly two numeric fields rise and fall together along a straight line. | Do customers who spend more also visit more often? | intermediate | TEST_SPEARMAN_R when the link is consistent but curved, or the values are ranks.TEST_KENDALL_TAU when the sample is small or has many tied values.TEST_CHISQ when both fields are categories.REG_OLS when you want to predict one field from several others.TEST_TREND when you want to know whether one ordered series keeps rising or falling. |
TEST_PROP_Z | Checks whether the rate of one outcome, such as conversion, differs between two groups. | Does the new checkout convert at a different rate from the old one? | basic | TEST_FISHER_EXACT when some groups have only a handful of successes or failures.TEST_CHISQ when there are more than two groups or more than two outcomes.TEST_WELCH when the outcome is a numeric measure rather than a yes/no rate. |
TEST_SHAPIRO_WILK | Checks whether a numeric field looks normally distributed, overall or within each group. | Is response time roughly bell-shaped, or clearly skewed or heavy-tailed? | intermediate | TEST_KS when you compare the distributions of two groups with each other.TEST_BROWN_FORSYTHE when you want to check whether groups have equal spread. |
TEST_SPEARMAN_R | Measures how consistently one numeric field rises (or falls) as the other rises, using ranks, so the link need not be a straight line. | Do higher-ranked products also tend to sell more, even if not in proportion? | intermediate | TEST_PEARSON_R when you want the strength of a straight-line link.TEST_KENDALL_TAU when the sample is small or many values are tied.TEST_CHISQ when both fields are categories.TEST_TREND when you want to know whether one ordered series keeps rising or falling. |
TEST_T | Checks whether a numeric field’s average differs from a target value, or between two groups (Welch’s version). | Is the average order value different from our target of 50? | intermediate | TEST_PAIRED_T when the same subjects are measured twice, such as before and after.TEST_MANN_WHITNEY_U when two groups are compared and the measure is heavily skewed, has extreme values or is only ordered.TEST_ANOVA_WELCH when there are three or more groups.TEST_PROP_Z when the outcome is a yes/no rate rather than a numeric measure.TEST_WELCH when the request is always a two-group comparison and should say so by name.TEST_Z_TWO_SAMPLE when both groups are large and reporting conventions call for a z-test. |
TEST_TREND | Checks whether an ordered series, such as monthly totals, tends to keep rising or keep falling. | Is monthly churn creeping up? | intermediate | REG_OLS when you want the size of a straight-line slope.TEST_WELCH when you compare the averages of two specific periods.TEST_KENDALL_TAU when you relate two numeric fields rather than one series in order. |
TEST_TUKEY_HSD | After an ANOVA, compares every pair of group averages to find which ones differ, keeping the overall false-alarm rate in check. | Which regions differ from each other in average spend? | advanced | TEST_ANOVA_F when you only need to know whether any group differs.TEST_WELCH when groups have clearly unequal spread: run pairwise Welch tests with multiplicity set on the request to adjust for multiple comparisons.TEST_MANN_WHITNEY_U when the measure is heavily skewed or only ordered: run pairwise Mann-Whitney tests with multiplicity set on the request to adjust for multiple comparisons. |
TEST_WELCH | Checks whether the average of a numeric field differs between two groups, without assuming they have equal spread. | Do customers in the two pricing arms spend different amounts on average? | intermediate | TEST_PAIRED_T when the same subjects are measured twice, such as before and after.TEST_MANN_WHITNEY_U when the measure is heavily skewed, has extreme values or is only ordered.TEST_ANOVA_WELCH when there are three or more groups.TEST_T when you compare one group’s average with a fixed target value.TEST_KS when you care about the whole shape of the two distributions, not just the average. |
TEST_WILCOXON_SR | Rank-based check of whether paired before/after values tend to shift in one direction, without assuming a bell curve. | Did each customer’s rating tend to go up after the redesign? | intermediate | TEST_MANN_WHITNEY_U when the two sets of values come from different, unrelated subjects.TEST_PAIRED_T when the per-row differences are roughly normal and you want the average change. |
TEST_Z_TWO_SAMPLE | Large-sample check of whether two groups differ in average, with the p-value read from the normal distribution. | In a large survey, do the two regions differ in average score? | intermediate | TEST_WELCH when either group is small; the t-distribution p-value is more honest there.TEST_PROP_Z when the outcome is a yes/no rate.TEST_PAIRED_T when the same subjects are measured twice. |
Operators
TEST_ANOVA_F
Checks whether the average of a numeric measure differs across three or more groups.
Level: intermediate
Also known as: anova, one-way anova, aov
Questions it answers:
- Does average spend differ across regions?
- Do the treatment arms produce different average outcomes?
Use cases by domain:
- survey: Compare mean satisfaction across age bands.
- ops: Compare average delivery time across warehouses.
- science: Compare mean yield across fertiliser treatments.
Assumptions:
- Rows are independent of each other, within and across groups.
- The measure is roughly normally distributed within each group.
- Groups have similar variances; TEST_BROWN_FORSYTHE checks this.
- It is an overall test: it says whether any group average stands apart, not which one.
Use something else:
TEST_ANOVA_WELCHwhen the groups have clearly unequal spread.TEST_KRUSKAL_WALLISwhen the measure is heavily skewed or only ordered.TEST_ANOVA_RMwhen the same subjects are measured in every group.TEST_WELCHwhen there are only two groups.TEST_TUKEY_HSDwhen you need to know which pairs of groups differ.
Follow up with:
TEST_TUKEY_HSDwhen the groups differ and you want to know which pairs of groups do.OVERLAY_PAIRWISE_WELCH_Twhen the groups are the rows of a crosstab of averages and you want every pair tested.
Glossary: effect-size, eta-squared, f-statistic, homogeneity-of-variance, normal-distribution, null-hypothesis, omega-squared, p-value, post-hoc-test, variance
Skill: op-test-anova-f
TEST_ANOVA_RM
Checks whether the average differs across conditions when every subject is measured under each condition.
Level: advanced
Also known as: repeated measures anova, within-subjects anova
Questions it answers:
- Do the same panel members rate the three ad concepts differently on average?
- Does each patient’s score change across the baseline, mid-point and final visits?
Use cases by domain:
- survey: Compare each respondent’s ratings of several brands asked in one survey.
- ops: Compare each store’s sales across three promotion periods.
- science: Compare each subject’s response across repeated measurement sessions.
Assumptions:
- The same subjects (SubjectField) are measured once under every condition; subjects missing a condition are dropped.
- Subjects are independent of each other.
- The measure is roughly normally distributed within each condition.
- Sphericity: differences between each pair of conditions are about equally variable. No correction is applied, so violations make the p-value too small.
Use something else:
TEST_ANOVA_Fwhen each group holds different, unrelated subjects.TEST_PAIRED_Twhen there are only two conditions.
Glossary: f-statistic, independence, normal-distribution, p-value, partial-eta-squared, repeated-measures, sphericity
Skill: op-test-anova-rm
TEST_ANOVA_WELCH
Checks whether the average of a numeric measure differs across groups when the groups may have unequal spread.
Level: intermediate
Also known as: welch's anova, oneway.test
Questions it answers:
- Does average response time differ across regions whose variability is very different?
- Do the store formats differ in average basket size, given some formats are far more variable?
Use cases by domain:
- survey: Compare mean scores across segments of very different sizes and spreads.
- ops: Compare average processing time across sites with uneven variability.
- science: Compare treatment means when the spread grows with the dose.
Assumptions:
- Rows are independent of each other, within and across groups.
- The measure is roughly normally distributed within each group.
- Groups may have unequal variances; each group needs some spread (a constant group is refused).
- It is an overall test: it says whether any group average stands apart, not which one.
Use something else:
TEST_KRUSKAL_WALLISwhen the measure is heavily skewed or only ordered.TEST_ANOVA_RMwhen the same subjects are measured in every group.TEST_WELCHwhen there are only two groups, or you want pairwise follow-ups: run TEST_WELCH per pair and set multiplicity on the request (for example holm) to adjust their p-values for multiple comparisons.TEST_ANOVA_Fwhen spreads are similar and you want Tukey’s pairwise follow-up.
Follow up with:
OVERLAY_PAIRWISE_WELCH_Twhen the groups differ and you want each pair tested without assuming equal spread.TEST_TUKEY_HSDwhen the groups differ and their spreads turn out similar after all.
Glossary: degrees-of-freedom, f-statistic, homogeneity-of-variance, independence, multiple-comparisons, normal-distribution, omega-squared, p-value, variance
Skill: op-test-anova-welch
TEST_BARTLETT_SPHERICITY
Checks whether a set of numeric fields are related at all, the first check before summarising them as a few underlying dimensions.
Level: advanced
Also known as: bartlett sphericity test, bartlett's test of sphericity, factorability test
Questions it answers:
- Do the ten satisfaction items move together enough to be worth reducing to a few scores?
- Are these sensor readings related at all, or is each one independent noise?
Use cases by domain:
- survey: Check a rating battery is factorable before a principal component analysis or a reliability score.
- science: Check a set of measurements share variation before reducing them to components.
Assumptions:
- Rows are independent of each other.
- The fields are roughly jointly normal.
- A row with any field missing is dropped from the test (listwise).
- With many rows almost any correlation is significant, so read the size of the correlations too.
Use something else:
MAT_CORRELATIONwhen you want how strongly each pair is related.MAT_PCAwhen you want the dimensions themselves.
Glossary: chi-square, correlation, degrees-of-freedom, independence, listwise-deletion, normal-distribution, p-value, principal-component, sample-size, test-statistic
Skill: op-test-bartlett-sphericity
TEST_BOX_M
Checks whether several groups share one spread pattern across a set of numeric fields, the assumption behind comparing average profiles.
Level: advanced
Also known as: box's m, box's m test, equal covariance test
Questions it answers:
- Before comparing segment profiles, do the segments have similar spread across the metrics?
- Is it safe to pool the two groups’ covariance for a multivariate comparison?
Use cases by domain:
- survey: Check segments share one covariance across a rating battery before a profile comparison.
- science: Check treatment arms share one covariance across outcomes before a MANOVA.
Assumptions:
- Rows are independent of each other, within and across the groups.
- The fields are roughly jointly normal within each group; the test is very sensitive to departures, so a small p can reflect non-normality rather than unequal spread.
- A row with any field missing is dropped from the test (listwise).
- Every group has more complete rows than fields.
Use something else:
TEST_BROWN_FORSYTHEwhen there is only one numeric field.TEST_MANOVAwhen you want whether the average profiles differ.
Glossary: chi-square, covariance, degrees-of-freedom, f-statistic, homogeneity-of-variance, independence, listwise-deletion, normal-distribution, p-value, sample-size, test-statistic
Skill: op-test-box-m
TEST_BROWN_FORSYTHE
Checks whether the spread of a numeric field differs across groups: a robust test of equal variances.
Level: advanced
Also known as: levene's test, median-centred levene test
Questions it answers:
- Is delivery time more variable at some warehouses than at others?
- How different is the spread of the measure across groups?
Use cases by domain:
- survey: Check whether some segments answer far more variably than others.
- ops: Compare the consistency of processing times across sites.
- science: Check whether measurement spread differs across treatment groups.
Assumptions:
- Rows are independent of each other, within and across groups.
- It measures distance from each group’s median, so it stays reliable when the data are not normal.
- Very small groups make the medians unstable.
- A large p-value is not evidence that the spreads are equal; many analysts simply use Welch’s tests rather than pre-testing.
Use something else:
TEST_ANOVA_WELCHwhen you want to compare the group averages themselves.TEST_SHAPIRO_WILKwhen you want to check whether a measure is bell-shaped.
Follow up with:
TEST_ANOVA_WELCHwhen the spreads differ and you still want to compare group averages.
Glossary: f-statistic, homogeneity-of-variance, independence, median, p-value, variance
Skill: op-test-brown-forsythe
TEST_CHISQ
Checks whether two categorical fields are associated by comparing a cross-tabulation with the counts expected if unrelated.
Level: intermediate
Also known as: chi square, chi-square test, pearson chi-square, chisq.test
Questions it answers:
- Is preferred channel associated with age band?
- Is the mix of plan types different across regions?
Use cases by domain:
- survey: Check whether brand preference varies by region.
- ops: Check whether ticket category is associated with the support channel.
- science: Check whether outcome category depends on treatment arm.
Assumptions:
- Each row is counted once and rows are independent of each other.
- It works on raw counts, never on percentages or averages.
- Expected counts should mostly be 5 or more (Cochran’s rule: none below 1 and no more than a fifth below 5); below that the p-value is unreliable, and Pulse warns when any cell is under 5.
- No continuity correction is applied.
Use something else:
TEST_FISHER_EXACTwhen the table is 2x2 and some expected counts are small.TEST_PROP_Zwhen you compare a yes/no rate between exactly two groups and want a confidence interval.TEST_SPEARMAN_Rwhen both fields are numeric.OVERLAY_CHISQ_VS_POPwhen you compare one subgroup’s mix with the whole population.
Follow up with:
OVERLAY_FISHER_EXACT_CELLwhen the table shows an association and you want to see which cells drive it.OVERLAY_CHISQ_ROWwhen you want to see which rows depart from the overall column mix.
Glossary: chi-square, continuity-correction, cramers-v, cross-tabulation, degrees-of-freedom, independence, p-value, phi
Skill: op-test-chisq
TEST_FISHER_EXACT
Exact test of whether two yes/no fields are associated, built for small 2x2 tables.
Level: intermediate
Also known as: fisher's exact test, fisher.test
Questions it answers:
- In a small pilot, did the treated group recover more often than the control group?
- With only a few dozen responses, is opting in associated with the sign-up channel?
Use cases by domain:
- survey: Compare a yes/no answer between two small subgroups.
- ops: Compare a rare failure rate between two production lines.
- science: Compare a binary outcome between two small treatment arms.
Assumptions:
- Rows are independent of each other, and each row is counted once.
- Strictly 2x2: both fields must have exactly two levels.
- Row and column totals are treated as fixed, which makes its p-values tend to run a little large, so it misses real associations somewhat more often than it needs to.
- The two-sided p-value sums every table no more likely than the one observed; other tools sometimes double a one-sided tail and disagree slightly.
Use something else:
TEST_CHISQwhen either field has more than two categories.TEST_PROP_Zwhen counts are large and you want the difference in rates with a confidence interval.
Glossary: cross-tabulation, exact-test, independence, odds-ratio, p-value, two-tailed
Skill: op-test-fisher-exact
TEST_HOTELLING_T2
Checks whether several numeric fields, taken together as one profile, differ from a target, between two groups, or before vs after.
Level: advanced
Also known as: hotelling t2, hotelling's t-squared, multivariate t test
Questions it answers:
- Do the two app versions differ on the whole set of engagement metrics at once?
- Is our average satisfaction profile across five attributes different from the benchmark profile?
- Did the same customers’ ratings shift as a whole between the before and after surveys?
Use cases by domain:
- survey: Compare two segments on a battery of rating items in one test.
- ops: Compare two sites on throughput, wait time and error rate together.
- science: Compare treatment and control on several outcomes without one test per outcome.
Assumptions:
- Rows are independent of each other, within and across the groups.
- The fields are roughly jointly normal; with many rows the test tolerates departures.
- The two-sample form assumes both groups share one covariance across the fields unless variance is set to unequal.
- A row with any field missing is dropped from the test (listwise).
- There are more complete rows than fields.
Use something else:
TEST_Twhen there is only one numeric field.TEST_WELCHwhen you want each field compared on its own.
Glossary: covariance, degrees-of-freedom, f-statistic, independence, listwise-deletion, mean, normal-distribution, p-value, sample-size, test-statistic
Skill: op-test-hotelling-t2
TEST_KENDALL_TAU
Measures how often pairs of rows agree in order on two numeric fields: a rank-based link suited to small samples and ties.
Level: intermediate
Also known as: kendall, kendall's tau, tau-b
Questions it answers:
- In a small panel, do judges who score one entry higher also score the other higher?
- Do two coarse rating scales tend to agree?
Use cases by domain:
- survey: Relate two short rating scales with many tied answers.
- ops: Check whether two priority rankings agree.
- science: Relate two measures in a small study.
Assumptions:
- Pairs of values are independent of each other.
- It detects monotonic links; ties in either field are corrected for (tau-b).
- The p-value is a two-sided normal approximation.
Use something else:
TEST_SPEARMAN_Rwhen the data are large; its cost grows with the square of the row count.TEST_PEARSON_Rwhen you want the strength of a straight-line link.TEST_TRENDwhen you want to know whether one ordered series keeps rising or falling.
Follow up with:
REG_OLSwhen the link looks roughly straight and you want to predict one field from the other.
Glossary: correlation, independence, kendall-tau, monotonic-trend, p-value, rank, ties, two-tailed
Skill: op-test-kendall-tau
TEST_KRUSKAL_WALLIS
Rank-based check of whether values tend to be larger in some groups than in others, across two or more groups.
Level: intermediate
Also known as: kruskal-wallis h test, kruskal.test
Questions it answers:
- Do satisfaction ratings tend to differ across the four regions?
- Do skewed repair times differ across product lines?
Use cases by domain:
- survey: Compare 1-5 ratings across several customer segments.
- ops: Compare skewed wait times across branches.
- science: Compare a non-normal outcome across several treatment groups.
Assumptions:
- Rows are independent of each other, within and across groups.
- It tests whether values tend to be larger in some groups; it reads as a difference in medians only when all groups have the same shape.
- It is an overall test: to find which groups differ, follow up with pairwise Mann-Whitney tests and set multiplicity on the request to adjust them for multiple comparisons.
- The p-value comes from a chi-square approximation that is shaky for very small groups.
Use something else:
TEST_ANOVA_WELCHwhen the data are roughly normal and you want to compare averages.TEST_MANN_WHITNEY_Uwhen there are only two groups.TEST_ANOVA_RMwhen the same subjects are measured in every group and the measure is roughly normal (Pulse has no Friedman test for skewed repeated measures; for two conditions use TEST_WILCOXON_SR).
Follow up with:
TEST_MANN_WHITNEY_Uwhen the groups differ and you want a rank-based comparison of each pair, read with multiple-comparison caution.
Glossary: chi-square, epsilon-squared, independence, median, multiple-comparisons, non-parametric, p-value, rank, ties
Skill: op-test-kruskal-wallis
TEST_KS
Checks whether a numeric field’s values follow the same distribution in two groups, comparing their whole shape.
Level: intermediate
Also known as: kolmogorov-smirnov test, ks test, ks.test
Questions it answers:
- Do order values in the two regions have the same overall distribution?
- Has the shape of response times changed between the old and new system?
Use cases by domain:
- survey: Compare the full spread of scores between two survey waves.
- ops: Detect a change in the shape of latency between two releases.
- science: Compare measurement distributions between two instruments.
Assumptions:
- Rows are independent of each other, within and across the two groups.
- It compares two observed groups; it is not a normality test with parameters estimated from the data (that needs the Lilliefors correction).
- With few rows per group it has low power; with very large groups even trivial differences in shape are flagged.
- The p-value is a two-sided large-sample approximation; many tied values make it conservative.
Use something else:
TEST_SHAPIRO_WILKwhen you want to check one field against the normal distribution.TEST_MANN_WHITNEY_Uwhen you only care whether one group’s values tend to be larger.OVERLAY_KS_VS_POPwhen you compare a subgroup with the whole population.
Glossary: goodness-of-fit, independence, non-parametric, p-value, statistical-power, statistical-significance, test-statistic, ties, two-tailed
Skill: op-test-ks
TEST_MANN_WHITNEY_U
Rank-based check of whether values in one of two groups tend to be larger than in the other.
Level: intermediate
Also known as: mann-whitney, wilcoxon rank-sum test
Questions it answers:
- Do customers on the new plan tend to rate the service higher?
- Do response times at one site tend to run longer than at the other?
Use cases by domain:
- survey: Compare 1-5 ratings between two segments.
- ops: Compare skewed resolution times between two support teams.
- science: Compare a non-normal outcome between treatment and control.
Assumptions:
- Rows are independent of each other, within and across the two groups.
- It tests whether a value from one group tends to exceed one from the other; it reads as a difference in medians only when both groups have the same shape.
- The p-value is a two-sided large-sample approximation with tie and continuity corrections, not an exact value.
Use something else:
TEST_WILCOXON_SRwhen the same subjects are measured twice.TEST_KRUSKAL_WALLISwhen there are three or more groups.TEST_WELCHwhen the data are roughly normal and you want the difference in averages.TEST_KSwhen you care about any difference in the shape of the two distributions.
Glossary: continuity-correction, independence, median, non-parametric, p-value, rank, rank-biserial, test-statistic, ties, two-tailed
Skill: op-test-mann-whitney-u
TEST_MANOVA
Checks whether several numeric fields, taken together as one profile, differ across two or more groups.
Level: advanced
Also known as: manova, multivariate anova, pillai's trace, wilks' lambda
Questions it answers:
- Do our customer segments differ on the whole set of spend, visit and basket metrics at once?
- Do the three store formats differ on their combined service ratings?
Use cases by domain:
- survey: Compare several segments on a battery of rating items in one test.
- ops: Compare several sites on throughput, wait time and error rate together.
- science: Compare several treatment arms on more than one outcome without one ANOVA per outcome.
Assumptions:
- Rows are independent of each other, within and across the groups.
- The fields are roughly jointly normal within each group; with many rows the test tolerates departures.
- The groups share one covariance across the fields; Pillai’s trace, the headline, is the most forgiving when they do not.
- A row with any field missing is dropped from the test (listwise).
- There are at least as many complete rows as fields plus groups.
Use something else:
TEST_ANOVA_Fwhen there is only one numeric field.TEST_HOTELLING_T2when there are only two groups and you want the T² figure.
Glossary: covariance, degrees-of-freedom, eigenvalue, f-statistic, independence, listwise-deletion, mean, normal-distribution, p-value, sample-size, test-statistic
Skill: op-test-manova
TEST_MARDIA
Checks whether a set of numeric fields looks jointly normal, through their combined skew and tail weight.
Level: advanced
Also known as: mardia's test, multivariate normality test, multivariate skewness and kurtosis
Questions it answers:
- Before comparing segment profiles, are the metrics close enough to jointly normal?
- Are the distance-based outlier flags safe to read as chi-square p-values?
Use cases by domain:
- survey: Check a rating battery’s joint shape before a profile comparison or a factor model.
- science: Check the joint normality assumption across outcomes before a MANOVA.
Assumptions:
- Rows are independent of each other.
- A row with any field missing is dropped from the test (listwise).
- There are more complete rows than fields; below 20 rows the skewness test uses a small-sample correction.
- With many rows even trivial departures are flagged; with few it misses real ones, so look at the shape too.
Use something else:
TEST_SHAPIRO_WILKwhen there is only one numeric field and you want a sharper normality test.TEST_BOX_Mwhen you want whether the groups share one covariance.
Glossary: chi-square, covariance, degrees-of-freedom, independence, kurtosis, listwise-deletion, normal-distribution, p-value, sample-size, skew, test-statistic
Skill: op-test-mardia
TEST_PAIRED_T
Checks whether the average change between two measurements of the same rows, such as before and after, differs from zero.
Level: intermediate
Also known as: paired t test, paired-samples t test, dependent t test
Questions it answers:
- On average, did customers’ spend differ between the period before and after the loyalty programme started?
- Do patients’ scores differ between the first and second visit?
Use cases by domain:
- survey: Compare each respondent’s rating of two products in the same questionnaire.
- ops: Compare each store’s weekly sales before and after a layout change.
- science: Compare each subject’s measurement before and after treatment.
Assumptions:
- Each row pairs two measurements of the same subject, held side by side in Field and Field2.
- Pairs are independent of each other.
- The per-row differences are roughly normally distributed; the two fields themselves need not be.
- Rows missing either value are dropped. The p-value is two-sided.
Use something else:
TEST_WELCHwhen the two sets of values come from different, unrelated subjects.TEST_WILCOXON_SRwhen the per-row differences are skewed or have extreme values.TEST_ANOVA_RMwhen each subject is measured under three or more conditions.
Glossary: cohens-d, independence, normal-distribution, p-value, paired-data, t-statistic, two-tailed
Skill: op-test-paired-t
TEST_PEARSON_R
Measures how strongly two numeric fields rise and fall together along a straight line.
Level: intermediate
Also known as: pearson, pearson correlation, pearson's r
Questions it answers:
- Do customers who spend more also visit more often?
- Does study time go with higher test scores?
Use cases by domain:
- survey: Check whether satisfaction tracks likelihood to recommend.
- ops: See whether ad spend moves with weekly revenue.
- science: Relate dose to measured response.
Assumptions:
- The relationship is roughly a straight line.
- Pairs of values are independent of each other.
- A few extreme values can dominate the result.
- The p-value assumes both fields are roughly normal; with small samples that matters most.
- r is the covariance of the two fields (details.covariance, dividing by n - 1) divided by both sample standard deviations (the square roots of details.variance_x and variance_y, also n - 1), which makes it unit-free; AGG_STDDEV divides by n, so it does not reproduce r.
Use something else:
TEST_SPEARMAN_Rwhen the link is consistent but curved, or the values are ranks.TEST_KENDALL_TAUwhen the sample is small or has many tied values.TEST_CHISQwhen both fields are categories.REG_OLSwhen you want to predict one field from several others.TEST_TRENDwhen you want to know whether one ordered series keeps rising or falling.
Follow up with:
REG_OLSwhen you want to predict one field from the other, or add more predictors.
Glossary: confidence-interval, correlation, covariance, independence, normal-distribution, outlier, p-value, r-squared, standard-deviation
Skill: op-test-pearson-r
TEST_PROP_Z
Checks whether the rate of one outcome, such as conversion, differs between two groups.
Level: basic
Also known as: two-proportion z test, prop.test
Questions it answers:
- Does the new checkout convert at a different rate from the old one?
- Is the share of promoters different between the two regions?
Use cases by domain:
- survey: Compare the share answering yes between two segments.
- ops: Compare conversion rates between two experiment arms.
- science: Compare response rates between treatment and control.
Assumptions:
- Rows are independent of each other, within and across the two groups.
- Each group needs at least 10 successes and 10 failures for the normal approximation.
- The p-value is two-sided; the confidence interval on the rate difference is the simple Wald interval, which is rough near 0% or 100%.
Use something else:
TEST_FISHER_EXACTwhen some groups have only a handful of successes or failures.TEST_CHISQwhen there are more than two groups or more than two outcomes.TEST_WELCHwhen the outcome is a numeric measure rather than a yes/no rate.
Glossary: cohens-h, confidence-interval, independence, normal-distribution, p-value, two-tailed, z-score
Skill: op-test-prop-z
TEST_SHAPIRO_WILK
Checks whether a numeric field looks normally distributed, overall or within each group.
Level: intermediate
Also known as: shapiro test, shapiro.test
Questions it answers:
- Is response time roughly bell-shaped, or clearly skewed or heavy-tailed?
- How far do scores in each group depart from a normal shape?
Use cases by domain:
- survey: Describe how far an index score departs from a normal shape, alongside a plot of it.
- ops: Check whether processing times are bell-shaped or heavily skewed.
- science: Check the normality assumption per treatment group.
Assumptions:
- Rows are independent of each other.
- Needs at least 3 values per group; the p-value is only calibrated for 5 to 5000 values, so treat it as advisory below 5 or above 5000 (Pulse warns).
- With few rows it has low power and can miss real departures; with very large samples even trivial departures are flagged, so look at the shape too.
- Pulse computes the Shapiro-Francia form, a close approximation to Shapiro-Wilk; with SplitBy the headline is the group that departs most.
Use something else:
TEST_KSwhen you compare the distributions of two groups with each other.TEST_BROWN_FORSYTHEwhen you want to check whether groups have equal spread.
Follow up with:
TEST_MANN_WHITNEY_Uwhen the shape is far from normal and you planned a two-group t-test.TEST_KRUSKAL_WALLISwhen the shape is far from normal and you planned an ANOVA across three or more groups.
Glossary: goodness-of-fit, independence, kurtosis, normal-distribution, null-hypothesis, p-value, skew, statistical-power, statistical-significance, test-statistic
Skill: op-test-shapiro-wilk
TEST_SPEARMAN_R
Measures how consistently one numeric field rises (or falls) as the other rises, using ranks, so the link need not be a straight line.
Level: intermediate
Also known as: spearman, spearman's rho, spearman rank correlation
Questions it answers:
- Do higher-ranked products also tend to sell more, even if not in proportion?
- Does satisfaction tend to rise with tenure?
Use cases by domain:
- survey: Relate two rating-scale answers to each other.
- ops: Check whether skewed order size tends to grow with customer age.
- science: Relate dose to a response that levels off.
Assumptions:
- Pairs of values are independent of each other.
- It detects monotonic links (one keeps rising as the other rises, or falls); a U-shaped link can score near zero.
- The p-value uses a t approximation that weakens with small samples or many tied values.
Use something else:
TEST_PEARSON_Rwhen you want the strength of a straight-line link.TEST_KENDALL_TAUwhen the sample is small or many values are tied.TEST_CHISQwhen both fields are categories.TEST_TRENDwhen you want to know whether one ordered series keeps rising or falling.
Follow up with:
REG_OLSwhen the link looks roughly straight and you want to predict one field from the other.
Glossary: correlation, independence, monotonic-trend, outlier, p-value, rank, spearman-rho, ties
Skill: op-test-spearman-r
TEST_T
Checks whether a numeric field’s average differs from a target value, or between two groups (Welch’s version).
Level: intermediate
Also known as: t test, one-sample t test
Questions it answers:
- Is the average order value different from our target of 50?
- Do new and returning customers spend different amounts on average?
Use cases by domain:
- survey: Compare mean satisfaction between two regions, or against a target score.
- ops: Check average handling time against a service-level target.
- science: Compare the mean response of a treatment group with a control group.
Assumptions:
- Rows are independent of each other, within and across groups.
- The two-group version uses Welch’s correction, so the groups need not have equal variances.
- The average is roughly normally distributed: safe for large groups, risky for small skewed ones.
- The p-value is two-sided: it looks for a difference in either direction.
Use something else:
TEST_PAIRED_Twhen the same subjects are measured twice, such as before and after.TEST_MANN_WHITNEY_Uwhen two groups are compared and the measure is heavily skewed, has extreme values or is only ordered.TEST_ANOVA_WELCHwhen there are three or more groups.TEST_PROP_Zwhen the outcome is a yes/no rate rather than a numeric measure.TEST_WELCHwhen the request is always a two-group comparison and should say so by name.TEST_Z_TWO_SAMPLEwhen both groups are large and reporting conventions call for a z-test.
Glossary: cohens-d, confidence-interval, homogeneity-of-variance, independence, normal-distribution, p-value, t-statistic, two-tailed, variance
Skill: op-test-t
TEST_TREND
Checks whether an ordered series, such as monthly totals, tends to keep rising or keep falling.
Level: intermediate
Also known as: mann-kendall test
Questions it answers:
- Is monthly churn creeping up?
- Has weekly average wait time been falling over the year?
Use cases by domain:
- survey: Check whether wave-on-wave satisfaction keeps moving one way.
- ops: Check whether a smoothed daily error rate is drifting.
- science: Check whether yearly readings show a steady drift.
Assumptions:
- It runs on result rows (a post-test) in OrderBy order, such as a grouped or windowed series.
- Successive points are independent; seasonality or autocorrelation makes the p-value too small.
- It detects a monotonic trend, not a straight-line slope and not which periods differ.
- The p-value is a two-sided normal approximation; treat it as advisory below about 10 points (Pulse warns below 8).
Use something else:
REG_OLSwhen you want the size of a straight-line slope.TEST_WELCHwhen you compare the averages of two specific periods.TEST_KENDALL_TAUwhen you relate two numeric fields rather than one series in order.
Glossary: independence, kendall-tau, monotonic-trend, non-parametric, p-value, test-statistic, two-tailed
Skill: op-test-trend
TEST_TUKEY_HSD
After an ANOVA, compares every pair of group averages to find which ones differ, keeping the overall false-alarm rate in check.
Level: advanced
Also known as: tukey hsd, tukey's honest significant difference
Questions it answers:
- Which regions differ from each other in average spend?
- Which treatment arms have different average outcomes?
Use cases by domain:
- survey: Find which age bands differ in mean satisfaction after an overall ANOVA.
- ops: Find which warehouses differ in average delivery time.
- science: Find which fertiliser treatments differ in mean yield.
Assumptions:
- Rows are independent of each other, within and across groups.
- Groups have similar variances, as in the standard ANOVA.
- It reads per-group averages and counts from result rows plus ms_within and df_within from a preceding TEST_ANOVA_F.
- Its p-values are already adjusted for multiple comparisons across every pair (they hold the family-wise error), using the studentized range (Tukey-Kramer for unequal group sizes); a request-level or instance multiplicity correction skips it, so they are never adjusted twice.
Use something else:
TEST_ANOVA_Fwhen you only need to know whether any group differs.TEST_WELCHwhen groups have clearly unequal spread: run pairwise Welch tests with multiplicity set on the request to adjust for multiple comparisons.TEST_MANN_WHITNEY_Uwhen the measure is heavily skewed or only ordered: run pairwise Mann-Whitney tests with multiplicity set on the request to adjust for multiple comparisons.
Glossary: alpha, family-wise-error, homogeneity-of-variance, independence, multiple-comparisons, p-value, post-hoc-test, studentized-range
Skill: op-test-tukey-hsd
TEST_WELCH
Checks whether the average of a numeric field differs between two groups, without assuming they have equal spread.
Level: intermediate
Also known as: welch's t test, unequal variances t test
Questions it answers:
- Do customers in the two pricing arms spend different amounts on average?
- Is average delivery time different between the two depots?
Use cases by domain:
- survey: Compare mean satisfaction between two customer segments.
- ops: Compare average order value between two sales channels.
- science: Compare the mean outcome of treatment and control groups of different sizes.
Assumptions:
- Rows are independent of each other, within and across the two groups.
- Each group’s average is roughly normally distributed; large groups make this safe.
- Unequal variances are allowed: the degrees of freedom are adjusted (Welch-Satterthwaite).
- The p-value is two-sided.
Use something else:
TEST_PAIRED_Twhen the same subjects are measured twice, such as before and after.TEST_MANN_WHITNEY_Uwhen the measure is heavily skewed, has extreme values or is only ordered.TEST_ANOVA_WELCHwhen there are three or more groups.TEST_Twhen you compare one group’s average with a fixed target value.TEST_KSwhen you care about the whole shape of the two distributions, not just the average.
Glossary: cohens-d, degrees-of-freedom, homogeneity-of-variance, independence, normal-distribution, p-value, t-statistic, two-tailed
Skill: op-test-welch
TEST_WILCOXON_SR
Rank-based check of whether paired before/after values tend to shift in one direction, without assuming a bell curve.
Level: intermediate
Also known as: wilcoxon signed-rank test, signed-rank test
Questions it answers:
- Did each customer’s rating tend to go up after the redesign?
- Do patients’ skewed symptom scores tend to fall between visits?
Use cases by domain:
- survey: Compare each respondent’s ratings of two concepts on a short scale.
- ops: Compare each machine’s skewed downtime before and after maintenance.
- science: Compare each subject’s non-normal measurement before and after treatment.
Assumptions:
- Each row pairs two measurements of the same subject, held side by side in Field and Field2.
- Pairs are independent of each other.
- The differences are roughly symmetric around their centre; the test asks whether that centre is zero.
- Pairs whose values are equal are dropped; the p-value is a two-sided large-sample approximation and needs at least 6 pairs that differ.
Use something else:
TEST_MANN_WHITNEY_Uwhen the two sets of values come from different, unrelated subjects.TEST_PAIRED_Twhen the per-row differences are roughly normal and you want the average change.
Glossary: continuity-correction, independence, non-parametric, normal-distribution, p-value, paired-data, rank, rank-biserial, test-statistic, ties, two-tailed
Skill: op-test-wilcoxon-sr
TEST_Z_TWO_SAMPLE
Large-sample check of whether two groups differ in average, with the p-value read from the normal distribution.
Level: intermediate
Also known as: two-sample z test
Questions it answers:
- In a large survey, do the two regions differ in average score?
- Across thousands of sessions, is average basket size different between the two app versions?
Use cases by domain:
- survey: Compare mean ratings between two large respondent groups where reporting conventions call for z.
- ops: Compare average spend between two high-traffic storefronts.
- science: Compare means of two large samples where the t and normal tails agree.
Assumptions:
- Rows are independent of each other, within and across the two groups.
- Both groups are large; with small groups the normal p-value comes out too small.
- Unequal variances are allowed: the standard error is the same as Welch’s t-test.
- The p-value is two-sided.
Use something else:
TEST_WELCHwhen either group is small; the t-distribution p-value is more honest there.TEST_PROP_Zwhen the outcome is a yes/no rate.TEST_PAIRED_Twhen the same subjects are measured twice.
Glossary: independence, normal-distribution, p-value, sample-size, standard-error, two-tailed, variance, z-score
Skill: op-test-z-two-sample
Regressions
The regressions this instance offers: what each is for, the questions it answers and when to reach for something else. Operators are sorted by name; each name links to its detail block below.
| Operator | In plain words | Answers questions like | Level | Instead, when… |
|---|---|---|---|---|
REG_BAYES_LINEAR | Fits a straight-line model of a numeric outcome under a prior, reporting each coefficient’s posterior mean and credible interval. | Given the data and our prior belief, what range of values is plausible for the price coefficient? | advanced | REG_OLS when you want p-values, resampled standard errors or stepwise predictor selection (none run on this type).REG_GLM when the outcome is yes/no (0/1) or a count.TEST_PEARSON_R when you only want how closely two numeric fields follow a straight line together. |
REG_GLM | Fits a generalized linear model: logistic regression for yes/no outcomes, Poisson for counts, gamma for positive skewed amounts. | Which customer traits are associated with the chance of churning, holding the others fixed? | advanced | REG_OLS when the outcome is a numeric measure with roughly symmetric, constant-spread residuals.TEST_PROP_Z when you only want to know whether a yes/no rate differs between two groups.TEST_CHISQ when the outcome and the single predictor are both categories. |
REG_OLS | Fits a straight-line model that predicts a numeric outcome from one or more numeric predictors, by least squares. | Which of price, discount and season are associated with weekly sales, holding the others fixed? | intermediate | REG_GLM when the outcome is yes/no (0/1) or a count.REG_BAYES_LINEAR when you want intervals that combine the data with prior knowledge about the coefficients.TEST_PEARSON_R when you only want how closely two numeric fields follow a straight line together.TEST_ANOVA_F when the predictor is a set of groups and you want to know whether the group averages differ.ATTR_REG_RESIDUAL when you want each row’s residual, to check the residual assumptions or find rows the model fits badly. |
Operators
REG_BAYES_LINEAR
Fits a straight-line model of a numeric outcome under a prior, reporting each coefficient’s posterior mean and credible interval.
Level: advanced
Also known as: bayesian linear regression
Questions it answers:
- Given the data and our prior belief, what range of values is plausible for the price coefficient?
- How do the coefficients shift when last year’s estimates are used as the prior?
Use cases by domain:
- survey: Fit a key-driver model on a small wave, using the previous wave’s coefficients as the prior mean.
- ops: Relate cost per order to volume when only a few weeks of data exist.
- science: Combine a small new study with earlier estimates of a dose-response slope.
Assumptions:
- Rows are independent of each other: repeated rows for the same customer, or values over time, make the reported uncertainty too small.
- The outcome changes linearly with each predictor, with normal residuals of constant spread (the model’s likelihood).
- The prior is conjugate Normal-Inverse-Gamma: each coefficient is normal around prior_mu (zeros by default, intercept first) with one shared precision for every coefficient, intercept included, measured relative to the residual variance: each coefficient’s prior standard deviation is σ / sqrt(prior_precision), so the prior’s strength changes with the noise level. The residual variance is inverse-gamma.
- The default prior is weak (precision 0.001), so with enough rows the coefficients sit close to REG_OLS. Its pull depends on each predictor’s units: a coefficient that is large because its predictor’s units are small is pulled harder toward prior_mu.
- Credible intervals are probability statements about each coefficient given the prior and the model; a wrong prior or a wrong model makes them wrong too.
- A coefficient describes association with the outcome, holding the other predictors fixed; it shows a causal effect only when the design supports one (for example random assignment), never from the fit alone, and a predictor left out of the model can drive both.
- Strongly related predictors (multicollinearity) make coefficients unstable; the prior keeps even an exactly collinear set solvable, but then the prior, not the data, decides how the shared association is split between them.
- Predictors must be numeric: encode a category as 0/1 columns first (FEAT_ONE_HOT) and leave one level out, or the columns are collinear with the intercept.
- Rows with a null target or predictor are dropped before fitting (listwise deletion); n_obs counts the rows used.
Use something else:
REG_OLSwhen you want p-values, resampled standard errors or stepwise predictor selection (none run on this type).REG_GLMwhen the outcome is yes/no (0/1) or a count.TEST_PEARSON_Rwhen you only want how closely two numeric fields follow a straight line together.
Glossary: credible-interval, heteroscedasticity, independence, listwise-deletion, multicollinearity, normal-distribution, posterior, prior, r-squared, regression-coefficient, residual
Skill: op-reg-bayes-linear
REG_GLM
Fits a generalized linear model: logistic regression for yes/no outcomes, Poisson for counts, gamma for positive skewed amounts.
Level: advanced
Also known as: generalized linear model, logistic regression, poisson regression, glm
Questions it answers:
- Which customer traits are associated with the chance of churning, holding the others fixed?
- How does the expected number of support tickets change with account size?
Use cases by domain:
- survey: Relate a yes/no answer (would recommend) to respondent attributes.
- ops: Model the count of incidents per site from traffic and staffing.
- science: Relate the probability of a response to dose and age.
Assumptions:
- Rows are independent of each other: repeated rows for the same customer, or values over time, make the reported uncertainty too small.
- The family must match the outcome: binomial needs a 0/1 (or packed_bool) target, Poisson non-negative counts, gamma positive values.
- Only the canonical link runs for each family (logit for binomial, log for Poisson, inverse for gamma); the predictors act linearly on that link scale.
- Each p-value is a Wald z test of the null hypothesis that its coefficient is 0 given the other predictors, with the dispersion fixed at 1. Overdispersed counts (variance above the mean) make Poisson p-values too small; gamma data rarely have dispersion 1, so gamma standard errors and p-values are unreliable, and the gamma path is not yet checked against a reference implementation.
- If a predictor separates the 0s from the 1s perfectly in a logistic model, the fit cannot converge and Pulse refuses it.
- A coefficient describes association with the outcome, holding the other predictors fixed; it shows a causal effect only when the design supports one (for example random assignment), never from the fit alone, and a predictor left out of the model can drive both.
- Strongly related predictors (multicollinearity) make coefficients unstable and their uncertainty large; Pulse refuses an exactly collinear set.
- Predictors must be numeric: encode a category as 0/1 columns first (FEAT_ONE_HOT) and leave one level out, or the columns are collinear with the intercept.
- Rows with a null target or predictor are dropped before fitting (listwise deletion); n_obs counts the rows used.
Use something else:
REG_OLSwhen the outcome is a numeric measure with roughly symmetric, constant-spread residuals.TEST_PROP_Zwhen you only want to know whether a yes/no rate differs between two groups.TEST_CHISQwhen the outcome and the single predictor are both categories.
Glossary: deviance, generalized-linear-model, independence, link-function, listwise-deletion, logistic-regression, multicollinearity, null-hypothesis, odds-ratio, overdispersion, p-value, pseudo-r-squared, regression-coefficient, skew, standard-error
Skill: op-reg-glm
REG_OLS
Fits a straight-line model that predicts a numeric outcome from one or more numeric predictors, by least squares.
Level: intermediate
Also known as: linear regression, ordinary least squares, lm
Questions it answers:
- Which of price, discount and season are associated with weekly sales, holding the others fixed?
- How much does predicted order value change per extra visit, with the other predictors held fixed?
- How much of the variation in satisfaction do these attribute ratings account for together?
Use cases by domain:
- survey: Relate overall satisfaction to several attribute ratings at once (often called key-driver analysis, though it shows which ratings go with satisfaction, not which ones drive it).
- ops: Model ticket handling time from queue length and agent tenure.
- science: Estimate how a measured response changes with dose while adjusting for body weight.
Assumptions:
- Rows are independent of each other: repeated rows for the same customer, or values over time, make the reported uncertainty too small.
- The outcome changes linearly with each predictor; model a curve by adding transformed predictors (FEAT_POLY, FEAT_LOG).
- Residuals have roughly constant spread: Pulse reports classic standard errors, not heteroscedasticity-robust ones. With few rows the residuals should also be roughly normal for the p-values to hold.
- Each p-value tests the null hypothesis that its coefficient is 0 given the other predictors, on a t distribution with n - p - 1 degrees of freedom (p predictors).
- A coefficient describes association with the outcome, holding the other predictors fixed; it shows a causal effect only when the design supports one (for example random assignment), never from the fit alone, and a predictor left out of the model can drive both.
- Strongly related predictors (multicollinearity) make coefficients unstable and their uncertainty large; Pulse refuses an exactly collinear set.
- Penalized fits (l1, l2, elasticnet) shrink coefficients toward 0 by design, so their p-values lose the usual reading; l1 and elasticnet standard errors are approximate and Pulse warns.
- Predictors must be numeric: encode a category as 0/1 columns first (FEAT_ONE_HOT) and leave one level out, or the columns are collinear with the intercept.
- Rows with a null target or predictor are dropped before fitting (listwise deletion); n_obs counts the rows used.
Use something else:
REG_GLMwhen the outcome is yes/no (0/1) or a count.REG_BAYES_LINEARwhen you want intervals that combine the data with prior knowledge about the coefficients.TEST_PEARSON_Rwhen you only want how closely two numeric fields follow a straight line together.TEST_ANOVA_Fwhen the predictor is a set of groups and you want to know whether the group averages differ.ATTR_REG_RESIDUALwhen you want each row’s residual, to check the residual assumptions or find rows the model fits badly.
Glossary: adjusted-r-squared, degrees-of-freedom, heteroscedasticity, independence, listwise-deletion, multicollinearity, normal-distribution, null-hypothesis, outlier, overfitting, p-value, r-squared, regression-coefficient, residual, standard-error
Skill: op-reg-ols
Matrix operators
The matrix operators this instance offers: what each is for, the questions it answers and when to reach for something else. Operators are sorted by name; each name links to its detail block below.
| Operator | In plain words | Answers questions like | Level | Instead, when… |
|---|---|---|---|---|
MAT_COLLINEARITY | Whether candidate predictors overlap so much that a regression on them would give unstable coefficients, and which ones are tangled. | Before I model satisfaction on these eight ratings, are any of them so related that their effects cannot be told apart? | intermediate | REG_OLS when you want the fitted effects of the predictors on an outcome.MAT_CORRELATION when you only want to see which measures go together, pair by pair. |
MAT_CORRELATION | How closely every pair of numeric fields moves together, in a line or in rank order, as one square table of values from -1 to 1. | Which of these ten rating items go together most strongly? | intermediate | TEST_PEARSON_R when you need a p-value or confidence interval for one pair.MAT_COVARIANCE when you want the joint spread in the fields’ own units.TEST_SPEARMAN_R when you need a p-value for one pair’s rank correlation.TEST_KENDALL_TAU when you need a p-value for one pair’s Kendall tau.MAT_PARTIAL_CORRELATION when you want each pair’s link with other fields held fixed. |
MAT_COVARIANCE | How every pair in a set of numeric fields varies together, as one square table with each field’s variance on the diagonal. | How do these five rating scales vary together across respondents? | intermediate | TEST_PEARSON_R when you want how closely two numeric fields follow a straight line together, on a scale from -1 to 1.AGG_WELFORD when you want the spread of one field on its own. |
MAT_DISTANCE | How far apart segments are across several numbers at once: the gap between each pair of segment centres. | Which customer segments are most alike on spend, visits and tenure, and which stand furthest apart? | advanced | TEST_MANOVA when you want to test whether the centres differ.ATTR_MAHALANOBIS when you want how unusual each row is. |
MAT_FACTOR | Which hidden qualities a set of related measures reflects, and how strongly each measure is tied to each, before building scale scores. | Do these twelve attitude questions reflect two or three underlying qualities, and which questions go with which? | advanced | MAT_PCA when you want summaries of all the spread, not just what the measures share.MAT_RELIABILITY when you already know the items measure one quality and want its reliability. |
MAT_PARTIAL_CORRELATION | How closely each pair of numeric fields moves together once other fields are held fixed, as one square table of values from -1 to 1. | Does satisfaction still track price once delivery time is held fixed? | advanced | MAT_CORRELATION when you want each pair’s link with nothing held fixed.REG_OLS when you want how much each field moves the outcome, in its own units.TEST_PEARSON_R when you need a p-value for one pair. |
MAT_PCA | How many underlying dimensions a set of related measures covers and which measures belong to each, so a few summaries can stand in for many. | How many distinct things do these twelve rating questions actually measure? | advanced | MAT_RELIABILITY when you want to check that one set of items measures a single quality reliably.MAT_CORRELATION when you only want to see which measures go together, pair by pair. |
MAT_RELIABILITY | How consistently a set of rating items measures one thing, before you add them up into a score, with a check of how each item fits the rest. | Are these five satisfaction questions reliable enough to average into one score? | intermediate | MAT_CORRELATION when you only want to see which items go together, pair by pair.TEST_PEARSON_R when you want a p-value for one pair of items. |
Operators
MAT_COLLINEARITY
Whether candidate predictors overlap so much that a regression on them would give unstable coefficients, and which ones are tangled.
Level: intermediate
Also known as: vif, variance inflation, multicollinearity, condition index
Questions it answers:
- Before I model satisfaction on these eight ratings, are any of them so related that their effects cannot be told apart?
- Which of these predictors are near-duplicates of the others?
Use cases by domain:
- survey: Screening a driver-analysis battery for questions that say almost the same thing before regressing the overall score on them.
- science: Checking that measured covariates are not near-linear combinations of each other before fitting a model.
Assumptions:
- The fields are the predictors only; the outcome plays no part, so the check is the same whatever you later model.
- It reads straight-line overlap among the predictors; it does not test any relationship with the outcome.
- A predictor that is an exact combination of others is refused, naming the fields involved.
- A row with any predictor missing is dropped from every figure (listwise deletion) unless pairwise deletion is chosen.
Use something else:
REG_OLSwhen you want the fitted effects of the predictors on an outcome.MAT_CORRELATIONwhen you only want to see which measures go together, pair by pair.
Glossary: multicollinearity, correlation, regression-coefficient, listwise-deletion, pairwise-deletion
Skill: op-mat-collinearity
MAT_CORRELATION
How closely every pair of numeric fields moves together, in a line or in rank order, as one square table of values from -1 to 1.
Level: intermediate
Also known as: correlation matrix, cor, rank correlation, spearman correlation matrix, kendall correlation matrix
Questions it answers:
- Which of these ten rating items go together most strongly?
- How do the sensor readings line up with one another, pair by pair?
Use cases by domain:
- survey: Correlation table of a battery of rating items, to see which items hang together before building a scale.
- ops: Which weekly metrics move together across stores, in one table instead of one request per pair.
- science: Pairwise straight-line links among several measured responses across units.
Assumptions:
- A row with any member missing is dropped from every cell (listwise deletion).
- Each r reads only a straight-line link; a curved relationship can still be strong.
- A few extreme rows can move an r a lot.
- r is unit-free: each pair’s co-moment divided by both members’ spreads, the same arithmetic as TEST_PEARSON_R.
- The spearman and kendall methods rank each member first, so they read any steady rise or fall, curved or not, and resist extreme rows; each cell is the TEST_SPEARMAN_R or TEST_KENDALL_TAU figure for its pair.
Use something else:
TEST_PEARSON_Rwhen you need a p-value or confidence interval for one pair.MAT_COVARIANCEwhen you want the joint spread in the fields’ own units.TEST_SPEARMAN_Rwhen you need a p-value for one pair’s rank correlation.TEST_KENDALL_TAUwhen you need a p-value for one pair’s Kendall tau.MAT_PARTIAL_CORRELATIONwhen you want each pair’s link with other fields held fixed.
Glossary: correlation, covariance, listwise-deletion, outlier, rank, spearman-rho, kendall-tau
Skill: op-mat-correlation
MAT_COVARIANCE
How every pair in a set of numeric fields varies together, as one square table with each field’s variance on the diagonal.
Level: intermediate
Also known as: covariance matrix, cov
Questions it answers:
- How do these five rating scales vary together across respondents?
- What is the covariance table of the sensor readings, to feed a later model?
Use cases by domain:
- survey: Covariance table of a battery of rating items, the input a scale or factor analysis starts from.
- science: Covariance of several measured responses across units, for a multivariate summary.
Assumptions:
- A row with any member missing is dropped from every cell (listwise deletion).
- Sample form by default: the squared deviations are divided by n - 1 (ddof 0 divides by n).
- The figures depend on each field’s units: a field in larger units gets larger entries.
Use something else:
TEST_PEARSON_Rwhen you want how closely two numeric fields follow a straight line together, on a scale from -1 to 1.AGG_WELFORDwhen you want the spread of one field on its own.
Glossary: covariance, variance, listwise-deletion
Skill: op-mat-covariance
MAT_DISTANCE
How far apart segments are across several numbers at once: the gap between each pair of segment centres.
Level: advanced
Also known as: centroid distance, distance matrix, segment separation, dist
Questions it answers:
- Which customer segments are most alike on spend, visits and tenure, and which stand furthest apart?
- How far apart are the regions’ average profiles across the rating battery?
Use cases by domain:
- survey: Comparing the average rating profiles of segments or regions.
- ops: Checking whether found clusters are well apart before naming them.
Assumptions:
- A centre is the segment’s (weighted) average of every number; a distance compares centres, not the spread inside each segment.
- Numbers in large units dominate unless standardize is on; mahalanobis also allows for how the numbers vary together.
- A row missing any number, or landing in no segment, is left out and counted.
Use something else:
TEST_MANOVAwhen you want to test whether the centres differ.ATTR_MAHALANOBISwhen you want how unusual each row is.
Follow up with:
GROUP_KMEANSwhen the segments still need finding.
Glossary: centroid, distance, z-score, covariance, listwise-deletion
Skill: op-mat-distance
MAT_FACTOR
Which hidden qualities a set of related measures reflects, and how strongly each measure is tied to each, before building scale scores.
Level: advanced
Also known as: exploratory factor analysis, efa, principal axis factoring, minres, maximum likelihood factor analysis, factanal
Questions it answers:
- Do these twelve attitude questions reflect two or three underlying qualities, and which questions go with which?
- Which items in this battery tie weakly to every quality and could be dropped?
Use cases by domain:
- survey: Finding the themes behind a long attitude battery and which questions belong to each, before averaging them into scale scores.
- science: Checking whether a set of test scores reflects one general ability or several separate ones.
Assumptions:
- The measures are numeric and related by straight-line links; a factor is a pattern in the shared spread; it does not show that an underlying cause exists.
- You choose how many factors to keep; read the variance each accounts for and the residual figures, and try one more and one fewer.
- Read KMO and Bartlett’s test first: weakly related measures give unstable factors.
- A row with any measure missing is dropped from every figure (listwise deletion) unless pairwise deletion is chosen.
Use something else:
MAT_PCAwhen you want summaries of all the spread, not just what the measures share.MAT_RELIABILITYwhen you already know the items measure one quality and want its reliability.
Follow up with:
MAT_RELIABILITYwhen the items of one factor are to be added up into a score.
Glossary: factor, loading, eigenvalue, correlation, listwise-deletion, pairwise-deletion
Skill: op-mat-factor
MAT_PARTIAL_CORRELATION
How closely each pair of numeric fields moves together once other fields are held fixed, as one square table of values from -1 to 1.
Level: advanced
Also known as: partial r, controlling for, partial correlation matrix, partial correlations
Questions it answers:
- Does satisfaction still track price once delivery time is held fixed?
- Which of these ratings are linked directly, rather than only through the overall score?
Use cases by domain:
- survey: Driver analysis: which rating items stay linked to the overall score once the other items are held fixed.
- ops: Whether two store metrics still move together once foot traffic, which drives both, is held fixed.
- science: Separating direct links among measured responses from links that run through a shared third measure.
Assumptions:
- Each pair is held fixed for the other members by default, or for exactly the fields named in params.control.
- Only straight-line links are removed and measured; a curved link to a held-fixed field leaves a trace.
- A row with any member or control missing is dropped from every cell (listwise deletion) unless pairwise deletion is chosen, which can leave the input inconsistent; that input is refused unless it is repaired.
- A field that is an exact straight-line mix of the others leaves nothing to compare, so the matrix is refused.
Use something else:
MAT_CORRELATIONwhen you want each pair’s link with nothing held fixed.REG_OLSwhen you want how much each field moves the outcome, in its own units.TEST_PEARSON_Rwhen you need a p-value for one pair.
Glossary: partial-correlation, correlation, listwise-deletion, pairwise-deletion, multicollinearity
Skill: op-mat-partial-correlation
MAT_PCA
How many underlying dimensions a set of related measures covers and which measures belong to each, so a few summaries can stand in for many.
Level: advanced
Also known as: principal components, dimension reduction, kmo, bartlett's test
Questions it answers:
- How many distinct things do these twelve rating questions actually measure?
- Is this set of measures correlated enough to be worth summarising into components?
Use cases by domain:
- survey: Checking how many themes a long attitude battery covers, and which questions load on each, before building scale scores.
- science: Reducing many correlated measurements of the same specimens to a few summary dimensions.
Assumptions:
- The measures are numeric and related by straight-line links; components summarise shared spread, they are not proven causes.
- On a covariance the measures with the largest spread dominate; use the correlation unless the units are shared and comparable.
- KMO and Bartlett’s test say whether the correlations are strong enough to summarise; read them before the components.
- A row with any measure missing is dropped from every figure (listwise deletion) unless pairwise deletion is chosen.
Use something else:
MAT_RELIABILITYwhen you want to check that one set of items measures a single quality reliably.MAT_CORRELATIONwhen you only want to see which measures go together, pair by pair.
Glossary: principal-component, eigenvalue, loading, correlation, listwise-deletion, pairwise-deletion
Skill: op-mat-pca
MAT_RELIABILITY
How consistently a set of rating items measures one thing, before you add them up into a score, with a check of how each item fits the rest.
Level: intermediate
Also known as: cronbach's alpha, scale reliability, internal consistency, mcdonald's omega
Questions it answers:
- Are these five satisfaction questions reliable enough to average into one score?
- Which item in this battery fits the others worst and should be dropped?
Use cases by domain:
- survey: Checking a battery of agree-disagree items, some worded in reverse, before summing it into a scale score.
- science: Checking that several measured indicators of one trait agree well enough to be combined.
Assumptions:
- The items are meant to measure one shared quality; the figures do not check that there is only one.
- Items worded in reverse must be named in params.reverse with the scale’s range, or they pull the figures down.
- Alpha treats every item as equally tied to the shared quality; omega lets each item’s tie differ and needs at least 3 items.
- A row with any item missing is dropped from every figure (listwise deletion) unless pairwise deletion is chosen.
Use something else:
MAT_CORRELATIONwhen you only want to see which items go together, pair by pair.TEST_PEARSON_Rwhen you want a p-value for one pair of items.
Glossary: reliability, correlation, listwise-deletion, pairwise-deletion
Skill: op-mat-reliability
Overlays
The overlays this instance offers: what each is for, the questions it answers and when to reach for something else. Operators are sorted by name; each name links to its detail block below.
| Operator | In plain words | Answers questions like | Level | Instead, when… |
|---|---|---|---|---|
OVERLAY_CHISQ_COL | Chi-square test per crosstab column: checks whether each column’s spread across the rows departs from the table’s overall row mix. | Which banner columns have an answer mix unlike the total? | intermediate | OVERLAY_CHISQ_ROW when the groups are the rows rather than the columns.OVERLAY_CHISQ_MATRIX when you want one test for the whole table.OVERLAY_FISHER_EXACT_CELL when you want to know which single cells stand out. |
OVERLAY_CHISQ_MATRIX | Chi-square test on a whole crosstab: checks whether the row and column categories are associated, with one p-value for the table. | Is preferred channel associated with age band in this crosstab? | intermediate | OVERLAY_CHISQ_ROW when you want to know which rows have a mix unlike the overall one.OVERLAY_FISHER_EXACT_CELL when you want to know which single cells stand out.TEST_FISHER_EXACT when the table is 2x2 and some expected counts are below 5.TEST_CHISQ when you also want how strongly the two fields are associated (Cramer’s V), from raw rows.OVERLAY_CHISQ_VS_REF when you compare this table’s mix with another Compose request’s table. |
OVERLAY_CHISQ_ROW | Chi-square test per crosstab row: checks whether each row’s spread across the columns departs from the table’s overall column mix. | Which regions have an answer mix unlike the overall mix? | intermediate | OVERLAY_CHISQ_COL when the groups are the columns rather than the rows.OVERLAY_CHISQ_MATRIX when you want one test for the whole table.OVERLAY_FISHER_EXACT_CELL when you want to know which single cells stand out. |
OVERLAY_CHISQ_VS_POP | Chi-square goodness-of-fit test on a facet: checks whether a subset’s category mix departs from a comparison population’s mix. | Does this segment’s brand mix differ from the whole customer base’s? | intermediate | OVERLAY_KS_VS_POP when the field is numeric.OVERLAY_INDEX_VS_POP when you want to see which categories are over- or under-represented rather than one test.TEST_CHISQ when you compare two separate groups from raw rows rather than a subset with a population. |
OVERLAY_CHISQ_VS_REF | Chi-square test in a Compose request: checks whether a target request’s crosstab mix departs from the reference request’s mix. | Has the mix of answers in this wave shifted from last wave? | advanced | TEST_CHISQ when both results are samples of similar size and you want a test that allows for uncertainty in both: stack the rows, label each source and cross them.OVERLAY_PROP_Z_CELL when you want to see which cells moved rather than one overall test.OVERLAY_DELTA_VS_REF when you want the size of each cell’s change. |
OVERLAY_DELTA_VS_BASELINE | Each point of an ordered series minus a chosen baseline point, in the value’s own units: how much it has moved since then. | How many more orders a week do we get than in the launch week? | basic | OVERLAY_INDEX_VS_BASELINE when you want a ratio rather than a gap.OVERLAY_DELTA_VS_PRIOR when you want each point against the one before it.TEST_TREND when you want to test for a steady rise or fall across the series. |
OVERLAY_DELTA_VS_MARGIN | Each crosstab cell minus its row, column or grand margin, in the cell’s own units: how far a cell sits above or below its margin. | How many points above or below the overall average rating is each segment, per question? | basic | OVERLAY_INDEX_VS_MARGIN when you want a ratio rather than a gap.OVERLAY_ZSCORE_VS_MARGIN when you want the gaps in standard deviations.OVERLAY_FISHER_EXACT_CELL when you want a test of whether a cell’s count departs from what the margins predict. |
OVERLAY_DELTA_VS_PRIOR | Each point of an ordered series minus the point before it, in the value’s own units: how much it changed from the previous period. | How many more or fewer orders did we get than the month before? | basic | OVERLAY_INDEX_VS_PRIOR when you want the change as a ratio.WIN_DELTA when you want the change as a window column on the result rather than an overlay.OVERLAY_DELTA_VS_BASELINE when you want each point against a fixed starting point. |
OVERLAY_DELTA_VS_REF | Each cell or group of a target Compose request minus the same coordinate in the reference request, in the target’s own units. | How many points did each segment’s score move since last wave? | intermediate | OVERLAY_INDEX_VS_REF when you want a ratio rather than a gap.OVERLAY_T_CELL when you want a test of whether the means differ.OVERLAY_PROP_Z_CELL when you want a test of whether the shares differ. |
OVERLAY_DELTA_VS_SIBLING | Each group’s value minus one named group’s value, in the value’s own units: how far every group sits from, say, the control group. | How much more or less revenue than the flagship store does each store bring in? | basic | OVERLAY_INDEX_VS_SIBLING when you want a ratio rather than a gap.TEST_WELCH when you want to test whether two groups’ averages differ, from raw rows.OVERLAY_DELTA_VS_BASELINE when the reference is a fixed position in an ordered series. |
OVERLAY_DELTA_VS_STAGE | A process-chain stage’s result minus an earlier stage’s result at the same coordinate, in the later stage’s own units. | How many records or how much revenue does each filtering stage remove? | intermediate | OVERLAY_INDEX_VS_STAGE when you want a ratio rather than a gap.OVERLAY_DELTA_VS_REF when the results come from separate requests rather than chain stages. |
OVERLAY_FISHER_EXACT_CELL | Fisher’s exact test for every crosstab cell: checks whether being in that row goes with being in that column more or less than expected. | Which answer-by-segment cells stand out in this banner table? | advanced | OVERLAY_CHISQ_MATRIX when you want one test for the whole table.OVERLAY_PROP_Z_CELL when you compare the same cell across two Compose requests.TEST_FISHER_EXACT when you only have one 2x2 table of raw rows. |
OVERLAY_FORMULA | Computes a custom figure for every cell, group or total from an expression over the value and its margins, totals or prior point. | Can I show each cell as its gap from the row margin divided by the grand total? | advanced | ATTR_FORMULA when you need a derived field on every row before aggregation.OVERLAY_INDEX_VS_MARGIN when a built-in kind already computes it, such as an index against a margin.OVERLAY_SHARE_OF_ROW when you want a share of a row. |
OVERLAY_INDEX_VS_BASELINE | Each point of an ordered series as an index value against a chosen baseline point (point / baseline x 100), e.g. growth since launch. | How have monthly sales grown relative to the launch month? | basic | OVERLAY_DELTA_VS_BASELINE when you want the gap in the value’s own units.OVERLAY_INDEX_VS_PRIOR when you want each point against the one before it.OVERLAY_YOY when you want each period against the same period a year earlier.TEST_TREND when you want to test for a steady rise or fall across the series. |
OVERLAY_INDEX_VS_MARGIN | Index value of each crosstab cell against its row, column or grand margin (cell / margin x 100): which cells over- or under-index? | Which segments over-index on each answer compared with the total? | basic | OVERLAY_SHARE_OF_ROW when you want the raw share (0 to 1) of the row.OVERLAY_DELTA_VS_MARGIN when you want the gap in the cell’s own units.OVERLAY_INDEX_VS_POP when you compare a facet subset with a comparison population. |
OVERLAY_INDEX_VS_POP | Each category’s (or histogram bin’s) share in a facet subset as an index value against its share in a comparison population (x 100). | Which brands over-index among young buyers compared with all buyers? | basic | OVERLAY_CHISQ_VS_POP when you want one test of whether the subset’s mix differs.OVERLAY_ZSCORE_VS_POP when you want the differences on a standardized scale.OVERLAY_INDEX_VS_MARGIN when the comparison is within a crosstab rather than a facet. |
OVERLAY_INDEX_VS_PRIOR | Each point of an ordered series as an index value against the point before it (point / prior x 100): period-on-period change. | By how much did sales grow or shrink from each month to the next? | basic | OVERLAY_DELTA_VS_PRIOR when you want the change in the value’s own units.OVERLAY_YOY when the data are seasonal and you want the same period a year earlier.OVERLAY_INDEX_VS_ROLLING_MEAN when you want each point against its recent average rather than one point. |
OVERLAY_INDEX_VS_REF | Index value of each cell or group of a target Compose request against the same spot in the reference request (target / reference x 100). | How does this wave’s result per segment compare with last wave’s, scaled to 100? | intermediate | OVERLAY_DELTA_VS_REF when you want the gap in the value’s own units.OVERLAY_PANEL_INDEX_VS_REF when you compare several targets with one reference.OVERLAY_PROP_Z_CELL when you want a test of whether the shares differ. |
OVERLAY_INDEX_VS_ROLLING_MEAN | Each point of an ordered series as an index value against the rolling mean of the previous W points: is this period above its recent run? | Is this week’s volume above or below the average of the last four weeks? | intermediate | OVERLAY_ZSCORE_VS_ROLLING when you want the gap in standard deviations of the window.OVERLAY_INDEX_VS_PRIOR when you want each point against just the one before it.WIN_MOVING_AVG when you want the smoothed series itself as a column. |
OVERLAY_INDEX_VS_SIBLING | Each group’s value as an index value against one named group (group / sibling x 100), such as every store against the flagship. | How does each store’s revenue compare with the flagship store’s? | basic | OVERLAY_DELTA_VS_SIBLING when you want the gap in the value’s own units.OVERLAY_INDEX_VS_BASELINE when the reference is a fixed position in an ordered series, such as the first month.TEST_WELCH when you want to test whether two groups’ averages differ, from raw rows. |
OVERLAY_INDEX_VS_STAGE | Index value of a process-chain stage’s result against an earlier stage’s result at the same coordinate (target / reference x 100). | What share of the starting figure is left after each filtering stage, scaled to 100? | intermediate | OVERLAY_DELTA_VS_STAGE when you want the gap in the value’s own units.OVERLAY_INDEX_VS_REF when the results come from separate requests rather than chain stages. |
OVERLAY_INDEX_VS_TOTAL | Each group’s value on a grouped result as an index value against the sum over all groups (group / total x 100). | How big is each region relative to the whole, on a 0 to 100 scale? | basic | OVERLAY_SHARE_OF_TOTAL when you want the raw share (0 to 1).OVERLAY_ZSCORE_VS_TOTAL when you want each group against the average group.OVERLAY_INDEX_VS_SIBLING when you want each group against one chosen group. |
OVERLAY_KS_VS_POP | Kolmogorov-Smirnov test on a numeric facet: checks whether a subset’s distribution departs from a comparison population’s. | Does this segment’s spend distribution differ from all customers’? | advanced | OVERLAY_CHISQ_VS_POP when the field is categorical.TEST_KS when you have two separate groups of raw rows.OVERLAY_INDEX_VS_POP when you want to see where the shapes differ rather than one test. |
OVERLAY_PAIRWISE_PROBIT_T | Pairwise t-tests on probit-transformed shares between rows (or columns) of one crosstab, matching a convention some survey tools use. | Which segments differ in share when tested the way our survey tool does? | advanced | OVERLAY_PAIRWISE_PROP_Z when you want the standard test of two shares.OVERLAY_PAIRWISE_WELCH_T when the cells are averages of a numeric measure. |
OVERLAY_PAIRWISE_PROP_Z | Two-proportion z-tests between every pair of rows (or columns) of one crosstab, per column (or row): which segments differ in share? | Which age bands differ from each other in the share choosing each brand? | advanced | OVERLAY_PAIRWISE_WELCH_T when the cells are averages of a numeric measure rather than shares.OVERLAY_PROP_Z_PANEL when the groups are separate Compose requests.OVERLAY_CHISQ_MATRIX when you want one overall test of whether the rows differ at all. |
OVERLAY_PAIRWISE_TWO_MEANS_Z | Large-sample z-tests between every pair of rows (or columns) of one crosstab of averages: which segments differ in mean? | With thousands of respondents per cell, which segments differ in average rating? | advanced | OVERLAY_PAIRWISE_WELCH_T when any cell is small; the t-based version is more honest there.OVERLAY_PAIRWISE_WEIGHTED_TWO_MEANS_Z when the cells are weighted averages.OVERLAY_PAIRWISE_PROP_Z when the cells are shares rather than averages. |
OVERLAY_PAIRWISE_WEIGHTED_TWO_MEANS_Z | Large-sample z-tests between every pair of rows (or columns) of one crosstab of weighted averages, with a sample-size basis you choose. | Which weighted segments differ from each other in average satisfaction? | advanced | OVERLAY_PAIRWISE_WELCH_T when the cells are unweighted averages.AGG_WEIGHTED_MEAN when you only need the weighted averages themselves. |
OVERLAY_PAIRWISE_WELCH_T | Welch t-tests between every pair of rows (or columns) of one crosstab of averages: which segments differ in mean? | Which age bands differ from each other in average spend, product by product? | advanced | OVERLAY_PAIRWISE_TWO_MEANS_Z when every cell is large and you want the normal-curve version.OVERLAY_PAIRWISE_WEIGHTED_TWO_MEANS_Z when the cells are weighted averages.OVERLAY_PAIRWISE_PROP_Z when the cells are shares rather than averages.TEST_TUKEY_HSD when you compare groups from raw rows and want a post-test whose p-values come already adjusted across every pair, with no multiplicity block. |
OVERLAY_PANEL_INDEX_VS_REF | Index values of several target Compose requests against one shared reference request, one layer per target (target / reference x 100). | How do the last four waves each compare with the benchmark wave? | intermediate | OVERLAY_INDEX_VS_REF when there is only one target.OVERLAY_PROP_Z_PANEL when you want tests between every pair of requests. |
OVERLAY_PROP_Z_CELL | Two-proportion z-test for every crosstab cell in a Compose request: does the target’s share in that cell differ from the reference’s? | Which answer shares changed between this wave and last wave? | intermediate | OVERLAY_PROP_Z_PANEL when you compare three or more requests at once.OVERLAY_PAIRWISE_PROP_Z when the groups are rows or columns of one crosstab.TEST_FISHER_EXACT when a cell has only a handful of successes or failures: stack both sources’ rows and run an exact 2x2 test.OVERLAY_DELTA_VS_REF when you want the size of the change rather than a p-value. |
OVERLAY_PROP_Z_PANEL | Two-proportion z-tests between every pair of requests in a Compose panel, for each crosstab cell: which waves or markets differ in share? | Across four waves, which pairs of waves differ in this answer’s share? | advanced | OVERLAY_PROP_Z_CELL when there is only one target.OVERLAY_PANEL_INDEX_VS_REF when you want index values against one reference rather than tests.OVERLAY_PAIRWISE_PROP_Z when the groups are rows or columns of one crosstab. |
OVERLAY_RANK | Ranks each cell of a target Compose request’s crosstab (1 = largest) within its row, its column or the whole table. | Which product is the top seller in each region? | basic | OVERLAY_SHARE_OF_TOTAL when you want each cell’s share of the whole.OVERLAY_ZSCORE_VS_MARGIN when you want how far each cell sits from its row or column.OVERLAY_DELTA_VS_REF when you want the change from the reference request. |
OVERLAY_SHARE_OF_COL | Each crosstab cell as a share of its column margin (0 to 1): the mix of rows within each column, as a 100% stacked column shows it. | Within each channel, what share of orders comes from each region? | basic | OVERLAY_SHARE_OF_ROW when you want the mix within each row.OVERLAY_SHARE_OF_TOTAL when you want each cell’s share of the whole table.OVERLAY_CHISQ_MATRIX when you want to test whether the column mixes differ. |
OVERLAY_SHARE_OF_ROW | Each crosstab cell as a share of its row margin (0 to 1): the mix of columns within each row, as a 100% stacked bar shows it. | Within each region, what share of orders goes to each channel? | basic | OVERLAY_SHARE_OF_COL when you want the mix within each column.OVERLAY_INDEX_VS_MARGIN when you want the same figure on a 0 to 100 index scale.OVERLAY_CHISQ_MATRIX when you want to test whether the row mixes differ. |
OVERLAY_SHARE_OF_TOTAL | Each crosstab cell or group of a grouped result as a share of the grand total (0 to 1): what part of the whole each piece makes up. | What share of all revenue does each region bring in? | basic | OVERLAY_SHARE_OF_ROW when you want the share within each row of a crosstab.OVERLAY_INDEX_VS_TOTAL when you want the same figure x 100 on a grouped result.OVERLAY_ZSCORE_VS_TOTAL when you want each group’s position against the average group. |
OVERLAY_T_CELL | Welch t-test for every crosstab cell in a Compose request: does the target’s mean in that cell differ from the reference’s? | Which segment-by-product cells changed in average rating since last wave? | advanced | OVERLAY_Z_CELL when every cell is large and you want the normal-curve version.OVERLAY_T_VS_REF when the results are grouped series rather than crosstabs.OVERLAY_PROP_Z_CELL when the cells hold shares rather than averages.OVERLAY_PAIRWISE_WELCH_T when the groups are rows or columns of one crosstab. |
OVERLAY_T_VS_REF | Welch t-test for every group of a grouped result in a Compose request: does the target’s mean differ from the reference’s? | Which regions’ average spend changed between this quarter and last? | advanced | OVERLAY_Z_VS_REF when every group is large and you want the normal-curve version.OVERLAY_T_CELL when the results are crosstabs rather than grouped series.OVERLAY_DELTA_VS_REF when you want the size of the change rather than a p-value. |
OVERLAY_YOY | Each period of a date-grouped series as an index value against the same period one year earlier (x 100): year-over-year change. | How do this year’s monthly sales compare with the same months last year? | basic | OVERLAY_INDEX_VS_PRIOR when you want each period against the one before.OVERLAY_INDEX_VS_BASELINE when you want each period against one fixed starting period.TEST_TREND when you want to test for a steady rise or fall across the series. |
OVERLAY_ZSCORE_VS_MARGIN | Each crosstab cell’s gap from its row, column or grand margin, in standard deviations of the cells in that slice: which cells stand out? | Which segment-by-question averages sit unusually far from the question’s overall average? | intermediate | OVERLAY_FISHER_EXACT_CELL when you want a test of whether a cell’s count departs from what the margins predict.OVERLAY_DELTA_VS_MARGIN when the gap in the cell’s own units is easier to explain.OVERLAY_INDEX_VS_MARGIN when you want a ratio to the margin. |
OVERLAY_ZSCORE_VS_POP | Puts each category’s share gap with a comparison population on a z-score scale; for numeric bins it says nothing about the subset. | Which answers does this segment pick more or less often than the comparison population, relative to how much answer shares vary? | intermediate | OVERLAY_CHISQ_VS_POP when you want a test of whether the subset’s category mix differs.OVERLAY_KS_VS_POP when you want a test of whether a numeric distribution differs.OVERLAY_INDEX_VS_POP when a ratio is easier to explain. |
OVERLAY_ZSCORE_VS_ROLLING | How many standard deviations each point sits from the rolling mean of the previous W points: a simple flag for unusual periods. | Which days had unusually high or low orders compared with the previous two weeks? | intermediate | TEST_TREND when you want to test for a steady rise or fall across the whole series.OVERLAY_INDEX_VS_ROLLING_MEAN when you want the ratio to the rolling mean.ATTR_ZSCORE when you want a standardized column for each row rather than per period. |
OVERLAY_ZSCORE_VS_TOTAL | How many standard deviations each group’s value sits from the average of all groups on a grouped result: which groups stand out? | Which stores’ sales are unusually high or low compared with the other stores? | intermediate | TEST_ANOVA_WELCH when you want to test whether group averages differ, from raw rows.OVERLAY_SHARE_OF_TOTAL when you want each group’s share of the total.OVERLAY_DELTA_VS_SIBLING when you want each group against one chosen group. |
OVERLAY_Z_CELL | Large-sample z-test for every crosstab cell in a Compose request: does the target’s mean in that cell differ from the reference’s? | With large samples, which segment-by-question means moved since last wave? | advanced | OVERLAY_T_CELL when any cell is small; the t-based version is more honest there.OVERLAY_Z_VS_REF when the results are grouped series rather than crosstabs.OVERLAY_PROP_Z_CELL when the cells hold shares rather than averages. |
OVERLAY_Z_VS_REF | Large-sample z-test for every group of a grouped result in a Compose request: does the target’s mean differ from the reference’s? | With large samples, which regions’ average spend changed since last quarter? | advanced | OVERLAY_T_VS_REF when any group is small; the t-based version is more honest there.OVERLAY_Z_CELL when the results are crosstabs rather than grouped series.OVERLAY_DELTA_VS_REF when you want the size of the change rather than a p-value. |
Operators
OVERLAY_CHISQ_COL
Chi-square test per crosstab column: checks whether each column’s spread across the rows departs from the table’s overall row mix.
Level: intermediate
Questions it answers:
- Which banner columns have an answer mix unlike the total?
- Which months have a spread of order types unlike the whole year?
Use cases by domain:
- survey: Flag the banner columns whose answer mix departs from the total column.
- ops: Flag the channels whose spread of order sizes departs from the overall spread.
- science: Flag the treatment arms whose outcome mix departs from the pooled mix.
Assumptions:
- Cells must hold counts of independent rows (AGG_COUNT); Pulse runs it on any cell aggregator, but sums, averages or weighted counts make the test meaningless.
- The null hypothesis is that the column’s mix matches the overall row mix. That overall mix includes the column itself, so a large column pulls it toward itself: the statistic is smaller than a column-versus-rest test’s by the factor (N - column total) / N, and the p-value is too large (conservative), most of all for big columns.
- Each column is a separate test, so with many columns some small p-values turn up by luck; to adjust for multiple comparisons, set multiplicity on the overlay (holm or bonferroni hold the family-wise error, bh or by the false discovery rate) to add adjusted p-values beside the raw ones.
- Every expected count should be about 5 or more; a warning flags columns where some are lower.
Use something else:
OVERLAY_CHISQ_ROWwhen the groups are the rows rather than the columns.OVERLAY_CHISQ_MATRIXwhen you want one test for the whole table.OVERLAY_FISHER_EXACT_CELLwhen you want to know which single cells stand out.
Glossary: chi-square, cross-tabulation, degrees-of-freedom, false-discovery-rate, family-wise-error, goodness-of-fit, multiple-comparisons, null-hypothesis, p-value
Skill: op-overlay-chisq-col
OVERLAY_CHISQ_MATRIX
Chi-square test on a whole crosstab: checks whether the row and column categories are associated, with one p-value for the table.
Level: intermediate
Questions it answers:
- Is preferred channel associated with age band in this crosstab?
- Does the mix of answers differ across regions in the table?
Use cases by domain:
- survey: Check whether answer choice is associated with respondent segment in a banner table.
- ops: Check whether ticket category is associated with support site.
- science: Check whether outcome category is associated with treatment arm.
Assumptions:
- Cells must hold counts of independent rows (AGG_COUNT); Pulse runs it on any cell aggregator, but sums, averages or weighted counts make the test meaningless.
- The null hypothesis is that row and column categories are independent; the p-value comes from the chi-square approximation.
- Every expected count should be about 5 or more; a warning flags tables where some are lower.
- A small p-value says the table departs from independence, not how strongly or which cells drive it.
Use something else:
OVERLAY_CHISQ_ROWwhen you want to know which rows have a mix unlike the overall one.OVERLAY_FISHER_EXACT_CELLwhen you want to know which single cells stand out.TEST_FISHER_EXACTwhen the table is 2x2 and some expected counts are below 5.TEST_CHISQwhen you also want how strongly the two fields are associated (Cramer’s V), from raw rows.OVERLAY_CHISQ_VS_REFwhen you compare this table’s mix with another Compose request’s table.
Follow up with:
OVERLAY_FISHER_EXACT_CELLwhen the table shows an association and you want to see which cells drive it.OVERLAY_CHISQ_ROWwhen you want to see which rows depart from the overall column mix.
Glossary: chi-square, cross-tabulation, degrees-of-freedom, independence, null-hypothesis, p-value, sample-size
Skill: op-overlay-chisq-matrix
OVERLAY_CHISQ_ROW
Chi-square test per crosstab row: checks whether each row’s spread across the columns departs from the table’s overall column mix.
Level: intermediate
Questions it answers:
- Which regions have an answer mix unlike the overall mix?
- Which product lines get a different spread of complaint types?
Use cases by domain:
- survey: Flag the segments whose answer mix departs from the total sample’s mix.
- ops: Flag the sites whose spread of ticket types departs from the company-wide spread.
- science: Flag the sites whose outcome mix departs from the pooled mix.
Assumptions:
- Cells must hold counts of independent rows (AGG_COUNT); Pulse runs it on any cell aggregator, but sums, averages or weighted counts make the test meaningless.
- The null hypothesis is that the row’s mix matches the overall column mix. That overall mix includes the row itself, so a large row pulls it toward itself: the statistic is smaller than a row-versus-rest test’s by the factor (N - row total) / N, and the p-value is too large (conservative), most of all for big rows.
- Each row is a separate test, so with many rows some small p-values turn up by luck; to adjust for multiple comparisons, set multiplicity on the overlay (holm or bonferroni hold the family-wise error, bh or by the false discovery rate) to add adjusted p-values beside the raw ones.
- Every expected count should be about 5 or more; a warning flags rows where some are lower.
Use something else:
OVERLAY_CHISQ_COLwhen the groups are the columns rather than the rows.OVERLAY_CHISQ_MATRIXwhen you want one test for the whole table.OVERLAY_FISHER_EXACT_CELLwhen you want to know which single cells stand out.
Glossary: chi-square, cross-tabulation, degrees-of-freedom, false-discovery-rate, family-wise-error, goodness-of-fit, multiple-comparisons, null-hypothesis, p-value
Skill: op-overlay-chisq-row
OVERLAY_CHISQ_VS_POP
Chi-square goodness-of-fit test on a facet: checks whether a subset’s category mix departs from a comparison population’s mix.
Level: intermediate
Questions it answers:
- Does this segment’s brand mix differ from the whole customer base’s?
- Is the subset’s spread of ticket types unlike the population’s?
Use cases by domain:
- survey: Check whether a filtered segment’s answer mix departs from the full sample’s.
- ops: Check whether one region’s order-type mix departs from the national mix.
- science: Check whether a cohort’s category mix departs from a reference population’s.
Assumptions:
- The subset’s figures are counts of independent rows.
- The null hypothesis is that the subset’s mix matches the population’s. The population mix is treated as known and fixed; when the subset is a large part of the population the two overlap and the p-value is only approximate.
- Only categories both sides show are compared, and the population’s shares are rescaled over them, so population nulls and categories cut from a top-K listing do not distort the expected counts.
- With a very large subset even tiny differences in mix give small p-values; read an OVERLAY_INDEX_VS_POP layer on the same facet for how big each category’s shift is.
- Every expected count should be about 5 or more; a warning flags layers where some are lower.
Use something else:
OVERLAY_KS_VS_POPwhen the field is numeric.OVERLAY_INDEX_VS_POPwhen you want to see which categories are over- or under-represented rather than one test.TEST_CHISQwhen you compare two separate groups from raw rows rather than a subset with a population.
Glossary: chi-square, degrees-of-freedom, goodness-of-fit, independence, index-value, null-hypothesis, p-value, sample-size
Skill: op-overlay-chisq-vs-pop
OVERLAY_CHISQ_VS_REF
Chi-square test in a Compose request: checks whether a target request’s crosstab mix departs from the reference request’s mix.
Level: advanced
Questions it answers:
- Has the mix of answers in this wave shifted from last wave?
- Does the test market’s category mix differ from the control market’s?
Use cases by domain:
- survey: Check whether this wave’s answer mix departs from the previous wave’s.
- ops: Check whether this quarter’s ticket mix departs from last quarter’s.
- science: Check whether a replication’s category mix departs from the original study’s.
Assumptions:
- Cells must hold counts of independent rows (AGG_COUNT); Pulse runs it on any cell aggregator, but sums, averages or weighted counts make the test meaningless.
- The null hypothesis is that the target’s mix matches the reference’s. The reference mix is treated as fixed expected shares and its own sampling uncertainty is ignored, so with a small reference the p-value is too small.
- Target and reference should be separate, independent sets of rows.
- The p-value is carried in the layer’s scalar slot; the chi-square value is in summary.statistic.
- Every expected count should be about 5 or more; a warning flags layers where some are lower.
Use something else:
TEST_CHISQwhen both results are samples of similar size and you want a test that allows for uncertainty in both: stack the rows, label each source and cross them.OVERLAY_PROP_Z_CELLwhen you want to see which cells moved rather than one overall test.OVERLAY_DELTA_VS_REFwhen you want the size of each cell’s change.
Glossary: baseline, chi-square, cross-tabulation, degrees-of-freedom, goodness-of-fit, independence, null-hypothesis, p-value
Skill: op-overlay-chisq-vs-ref
OVERLAY_DELTA_VS_BASELINE
Each point of an ordered series minus a chosen baseline point, in the value’s own units: how much it has moved since then.
Level: basic
Questions it answers:
- How many more orders a week do we get than in the launch week?
- How many points has satisfaction moved since the first wave?
Use cases by domain:
- survey: Show each wave’s score as a gap from the first wave.
- ops: Show each week’s volume as a gap from a reference week.
- science: Show each time point as a change from the pre-treatment measurement.
Assumptions:
- The baseline is a position in the host’s order, so the series must be ordered (for example by GROUP_DATE).
- When the values are percentages the gap is in percentage points, not percent.
Use something else:
OVERLAY_INDEX_VS_BASELINEwhen you want a ratio rather than a gap.OVERLAY_DELTA_VS_PRIORwhen you want each point against the one before it.TEST_TRENDwhen you want to test for a steady rise or fall across the series.
Glossary: baseline, percentage-point
Skill: op-overlay-delta-vs-baseline
OVERLAY_DELTA_VS_MARGIN
Each crosstab cell minus its row, column or grand margin, in the cell’s own units: how far a cell sits above or below its margin.
Level: basic
Questions it answers:
- How many points above or below the overall average rating is each segment, per question?
- Which store-by-month cells sit furthest from their store’s overall average?
Use cases by domain:
- survey: Show each segment’s mean rating as a gap from the total-sample mean.
- ops: Show each site’s average cycle time as a gap from the company average.
- science: Show each subgroup mean as a gap from the pooled mean.
Assumptions:
- The margin is the host’s own margin figure: a total for counts and sums (so every cell sits below it), an overall average for means. It reads most naturally with averages or shares.
- The gap keeps the cell’s units; when cells are percentages it is in percentage points.
Use something else:
OVERLAY_INDEX_VS_MARGINwhen you want a ratio rather than a gap.OVERLAY_ZSCORE_VS_MARGINwhen you want the gaps in standard deviations.OVERLAY_FISHER_EXACT_CELLwhen you want a test of whether a cell’s count departs from what the margins predict.
Glossary: baseline, cross-tabulation, margin, mean, percentage-point
Skill: op-overlay-delta-vs-margin
OVERLAY_DELTA_VS_PRIOR
Each point of an ordered series minus the point before it, in the value’s own units: how much it changed from the previous period.
Level: basic
Questions it answers:
- How many more or fewer orders did we get than the month before?
- How many points did satisfaction move from each wave to the next?
Use cases by domain:
- survey: Wave-on-wave change of a tracker metric in points.
- ops: Month-on-month change in ticket volume.
- science: Step-to-step change of a repeated measurement.
Assumptions:
- The series must be ordered. The first point has no prior and gets no value; a missing point is skipped and the next one compares with the last present point.
- When the values are percentages the change is in percentage points, not percent.
Use something else:
OVERLAY_INDEX_VS_PRIORwhen you want the change as a ratio.WIN_DELTAwhen you want the change as a window column on the result rather than an overlay.OVERLAY_DELTA_VS_BASELINEwhen you want each point against a fixed starting point.
Glossary: baseline, percentage-point
Skill: op-overlay-delta-vs-prior
OVERLAY_DELTA_VS_REF
Each cell or group of a target Compose request minus the same coordinate in the reference request, in the target’s own units.
Level: intermediate
Questions it answers:
- How many points did each segment’s score move since last wave?
- How much more or less did each region sell this year than last?
Use cases by domain:
- survey: Show this wave’s table as point changes from last wave.
- ops: Show this period’s site figures as changes from the prior period.
- science: Show a treatment cohort’s subgroup means as gaps from the control cohort’s.
Assumptions:
- Both requests must share a schema and line up on the same keys; a coordinate missing from the reference gets no value and a warning.
- When the values are percentages the gap is in percentage points, not percent.
Use something else:
OVERLAY_INDEX_VS_REFwhen you want a ratio rather than a gap.OVERLAY_T_CELLwhen you want a test of whether the means differ.OVERLAY_PROP_Z_CELLwhen you want a test of whether the shares differ.
Glossary: baseline, percentage-point
Skill: op-overlay-delta-vs-ref
OVERLAY_DELTA_VS_SIBLING
Each group’s value minus one named group’s value, in the value’s own units: how far every group sits from, say, the control group.
Level: basic
Questions it answers:
- How much more or less revenue than the flagship store does each store bring in?
- How many points above or below the control arm is each arm’s average?
Use cases by domain:
- survey: Show every segment’s mean rating as a gap from a reference segment.
- ops: Show every site’s cost as a gap from the best-run site.
- science: Show every arm’s mean as a gap from the control arm.
Assumptions:
- The named group must exist in the result; an unknown group gives no values and a warning.
- When the values are percentages the gap is in percentage points, not percent.
Use something else:
OVERLAY_INDEX_VS_SIBLINGwhen you want a ratio rather than a gap.TEST_WELCHwhen you want to test whether two groups’ averages differ, from raw rows.OVERLAY_DELTA_VS_BASELINEwhen the reference is a fixed position in an ordered series.
Glossary: baseline, percentage-point
Skill: op-overlay-delta-vs-sibling
OVERLAY_DELTA_VS_STAGE
A process-chain stage’s result minus an earlier stage’s result at the same coordinate, in the later stage’s own units.
Level: intermediate
Questions it answers:
- How many records or how much revenue does each filtering stage remove?
- How far does the refined stage’s figure move from the first stage’s?
Use cases by domain:
- survey: Show how cleaning the sample moves each figure.
- ops: Show how much volume each funnel stage drops.
- harness: Diff a chain’s final stage against its source stage in one response.
Assumptions:
- Both stages must have the same result shape; otherwise the layer is empty with a warning.
- When the values are percentages the gap is in percentage points, not percent.
Use something else:
OVERLAY_INDEX_VS_STAGEwhen you want a ratio rather than a gap.OVERLAY_DELTA_VS_REFwhen the results come from separate requests rather than chain stages.
Glossary: baseline, percentage-point
Skill: op-overlay-delta-vs-stage
OVERLAY_FISHER_EXACT_CELL
Fisher’s exact test for every crosstab cell: checks whether being in that row goes with being in that column more or less than expected.
Level: advanced
Questions it answers:
- Which answer-by-segment cells stand out in this banner table?
- In a small table, which cells hold more cases than the margins predict?
Use cases by domain:
- survey: Flag the cells of a banner table where a segment picks an answer more or less often than the rest.
- ops: Flag the site-by-fault cells that occur more or less often than the totals predict.
- science: Flag the arm-by-outcome cells of a small table without relying on a large-sample approximation.
Assumptions:
- Cells must hold counts of independent rows (AGG_COUNT); Pulse runs it on any cell aggregator, but sums, averages or weighted counts make the test meaningless.
- Each cell is tested as its own 2x2 table: this row versus the rest, by this column versus the rest. The null hypothesis is that the two are independent; the p-value is two-sided.
- Every cell is a separate test and the tables overlap, so with many cells some small p-values turn up by luck; to adjust for multiple comparisons, set multiplicity on the overlay (holm or bonferroni hold the family-wise error, bh or by the false discovery rate) to add adjusted p-values beside the raw ones.
- It stays valid with small counts, which makes it the backstop when chi-square expected counts are low; the low-count warning is advisory only.
Use something else:
OVERLAY_CHISQ_MATRIXwhen you want one test for the whole table.OVERLAY_PROP_Z_CELLwhen you compare the same cell across two Compose requests.TEST_FISHER_EXACTwhen you only have one 2x2 table of raw rows.
Glossary: chi-square, cross-tabulation, exact-test, false-discovery-rate, family-wise-error, independence, margin, multiple-comparisons, null-hypothesis, p-value, two-tailed
Skill: op-overlay-fisher-exact-cell
OVERLAY_FORMULA
Computes a custom figure for every cell, group or total from an expression over the value and its margins, totals or prior point.
Level: advanced
Questions it answers:
- Can I show each cell as its gap from the row margin divided by the grand total?
- Can I flag each month whose value is more than 10% above the prior month?
Use cases by domain:
- survey: A house-style index that no built-in kind computes.
- ops: A custom ratio of each cell to its margins for a dashboard.
- harness: Prototype a new overlay before registering it as an extension kind.
Assumptions:
- Pulse checks the variable names, not the statistics: a formula that divides by a margin or compares values is only as sound as you make it.
- The variables depend on the host shape (cell and margins for a crosstab, value, total and prior for a series, value for a total).
Use something else:
ATTR_FORMULAwhen you need a derived field on every row before aggregation.OVERLAY_INDEX_VS_MARGINwhen a built-in kind already computes it, such as an index against a margin.OVERLAY_SHARE_OF_ROWwhen you want a share of a row.
Glossary: margin
Skill: op-overlay-formula
OVERLAY_INDEX_VS_BASELINE
Each point of an ordered series as an index value against a chosen baseline point (point / baseline x 100), e.g. growth since launch.
Level: basic
Questions it answers:
- How have monthly sales grown relative to the launch month?
- How does each wave’s score compare with the first wave, scaled to 100?
Use cases by domain:
- survey: Track a tracker metric relative to the first wave.
- ops: Track weekly volume relative to a reference week.
- science: Express each time point relative to the pre-treatment measurement.
Assumptions:
- The baseline is a position in the host’s order, so the series must be ordered (for example by GROUP_DATE).
- A zero baseline gives no values and a warning; a small baseline makes every index swing widely.
Use something else:
OVERLAY_DELTA_VS_BASELINEwhen you want the gap in the value’s own units.OVERLAY_INDEX_VS_PRIORwhen you want each point against the one before it.OVERLAY_YOYwhen you want each period against the same period a year earlier.TEST_TRENDwhen you want to test for a steady rise or fall across the series.
Glossary: baseline, index-value
Skill: op-overlay-index-vs-baseline
OVERLAY_INDEX_VS_MARGIN
Index value of each crosstab cell against its row, column or grand margin (cell / margin x 100): which cells over- or under-index?
Level: basic
Questions it answers:
- Which segments over-index on each answer compared with the total?
- Which product-by-region cells are well above their region’s average?
Use cases by domain:
- survey: Index each segment’s mean rating against the total-sample mean.
- ops: Index each site’s average cost against the company-wide average.
- science: Index each subgroup mean against the pooled mean.
Assumptions:
- The margin is the host’s own margin figure. For averages 100 means the same as the overall average; for counts and sums the margin is a total, so the index is the cell’s share x 100, not a comparison with a typical cell.
- A zero margin gives no value and a warning; a small margin makes the index swing widely.
Use something else:
OVERLAY_SHARE_OF_ROWwhen you want the raw share (0 to 1) of the row.OVERLAY_DELTA_VS_MARGINwhen you want the gap in the cell’s own units.OVERLAY_INDEX_VS_POPwhen you compare a facet subset with a comparison population.
Glossary: baseline, cross-tabulation, index-value, margin
Skill: op-overlay-index-vs-margin
OVERLAY_INDEX_VS_POP
Each category’s (or histogram bin’s) share in a facet subset as an index value against its share in a comparison population (x 100).
Level: basic
Questions it answers:
- Which brands over-index among young buyers compared with all buyers?
- Which price bands are over-represented in this segment?
Use cases by domain:
- survey: Profile a segment: which answers it over- or under-indexes on against the full sample.
- ops: Show which product categories a region buys more or less of than the whole business.
- science: Show which categories are over-represented in a cohort against the reference population.
Assumptions:
- Small categories give unstable index values: a handful of rows can produce 300 or 20, so check the counts behind them.
- A category absent from the population gets no value and a warning; a numeric field needs IncludeHistogram for bins.
Use something else:
OVERLAY_CHISQ_VS_POPwhen you want one test of whether the subset’s mix differs.OVERLAY_ZSCORE_VS_POPwhen you want the differences on a standardized scale.OVERLAY_INDEX_VS_MARGINwhen the comparison is within a crosstab rather than a facet.
Glossary: baseline, index-value, sample-size
Skill: op-overlay-index-vs-pop
OVERLAY_INDEX_VS_PRIOR
Each point of an ordered series as an index value against the point before it (point / prior x 100): period-on-period change.
Level: basic
Questions it answers:
- By how much did sales grow or shrink from each month to the next?
- Is this week’s volume above or below last week’s, scaled to 100?
Use cases by domain:
- survey: Wave-on-wave change of a tracker metric.
- ops: Month-on-month growth of orders.
- science: Step-to-step change of a repeated measurement.
Assumptions:
- The series must be ordered. The first point has no prior and gets no value; a missing point is skipped and the next one compares with the last present point, so a gap can span more than one period.
- A zero prior gives no value and a warning.
Use something else:
OVERLAY_DELTA_VS_PRIORwhen you want the change in the value’s own units.OVERLAY_YOYwhen the data are seasonal and you want the same period a year earlier.OVERLAY_INDEX_VS_ROLLING_MEANwhen you want each point against its recent average rather than one point.
Glossary: baseline, index-value
Skill: op-overlay-index-vs-prior
OVERLAY_INDEX_VS_REF
Index value of each cell or group of a target Compose request against the same spot in the reference request (target / reference x 100).
Level: intermediate
Questions it answers:
- How does this wave’s result per segment compare with last wave’s, scaled to 100?
- How does each region’s revenue this year compare with last year’s?
Use cases by domain:
- survey: Index this wave’s table against last wave’s.
- ops: Index this period’s site figures against the prior period’s.
- science: Index a treatment cohort’s subgroup means against the control cohort’s.
Assumptions:
- Both requests must share a schema and line up on the same keys; a coordinate missing from the reference gets no value and a warning.
- A zero reference value gives no value; a small one makes the index swing widely. params.scale changes the 100.
Use something else:
OVERLAY_DELTA_VS_REFwhen you want the gap in the value’s own units.OVERLAY_PANEL_INDEX_VS_REFwhen you compare several targets with one reference.OVERLAY_PROP_Z_CELLwhen you want a test of whether the shares differ.
Glossary: baseline, index-value
Skill: op-overlay-index-vs-ref
OVERLAY_INDEX_VS_ROLLING_MEAN
Each point of an ordered series as an index value against the rolling mean of the previous W points: is this period above its recent run?
Level: intermediate
Questions it answers:
- Is this week’s volume above or below the average of the last four weeks?
- Which days ran well above their recent average?
Use cases by domain:
- survey: Compare each wave with the average of the last few waves.
- ops: Compare each day’s orders with the average of the previous week.
- science: Compare each reading with the average of the preceding readings.
Assumptions:
- The series must be ordered and params.window set. The first W points get no value while the window fills; a missing point does not advance the window.
- A short window reacts fast but is jumpy; a zero rolling mean gives no value and a warning.
Use something else:
OVERLAY_ZSCORE_VS_ROLLINGwhen you want the gap in standard deviations of the window.OVERLAY_INDEX_VS_PRIORwhen you want each point against just the one before it.WIN_MOVING_AVGwhen you want the smoothed series itself as a column.
Glossary: baseline, index-value, rolling-mean
Skill: op-overlay-index-vs-rolling-mean
OVERLAY_INDEX_VS_SIBLING
Each group’s value as an index value against one named group (group / sibling x 100), such as every store against the flagship.
Level: basic
Questions it answers:
- How does each store’s revenue compare with the flagship store’s?
- How does each region’s average rating compare with the home region’s?
Use cases by domain:
- survey: Index every segment against a chosen reference segment.
- ops: Index every site against the best-run site.
- science: Index every arm’s mean against the control arm.
Assumptions:
- The named group must exist in the result; an unknown group gives no values and a warning.
- A zero or small reference value makes every index swing widely; a zero one gives no values.
Use something else:
OVERLAY_DELTA_VS_SIBLINGwhen you want the gap in the value’s own units.OVERLAY_INDEX_VS_BASELINEwhen the reference is a fixed position in an ordered series, such as the first month.TEST_WELCHwhen you want to test whether two groups’ averages differ, from raw rows.
Glossary: baseline, index-value
Skill: op-overlay-index-vs-sibling
OVERLAY_INDEX_VS_STAGE
Index value of a process-chain stage’s result against an earlier stage’s result at the same coordinate (target / reference x 100).
Level: intermediate
Questions it answers:
- What share of the starting figure is left after each filtering stage, scaled to 100?
- How does the refined stage’s result compare with the first stage’s?
Use cases by domain:
- survey: Show how a cleaned sample’s figures compare with the raw sample’s.
- ops: Show how much of the starting volume each funnel stage keeps.
- harness: Compare a chain’s final stage with its source stage in one response.
Assumptions:
- Both stages must have the same result shape; otherwise the layer is empty with a warning.
- A zero reference value gives no value and a warning.
Use something else:
OVERLAY_DELTA_VS_STAGEwhen you want the gap in the value’s own units.OVERLAY_INDEX_VS_REFwhen the results come from separate requests rather than chain stages.
Glossary: baseline, index-value
Skill: op-overlay-index-vs-stage
OVERLAY_INDEX_VS_TOTAL
Each group’s value on a grouped result as an index value against the sum over all groups (group / total x 100).
Level: basic
Questions it answers:
- How big is each region relative to the whole, on a 0 to 100 scale?
- What part of total sales, scaled to 100, does each channel make up?
Use cases by domain:
- survey: Each segment’s share of all responses, scaled to 100.
- ops: Each product line’s share of total revenue, scaled to 100.
Assumptions:
- 100 means the group equals the whole total, not the typical group, so values are usually far below 100.
- It makes sense only when the value adds up across groups, such as counts or sums. A zero total gives no value and a warning.
Use something else:
OVERLAY_SHARE_OF_TOTALwhen you want the raw share (0 to 1).OVERLAY_ZSCORE_VS_TOTALwhen you want each group against the average group.OVERLAY_INDEX_VS_SIBLINGwhen you want each group against one chosen group.
Glossary: baseline, index-value
Skill: op-overlay-index-vs-total
OVERLAY_KS_VS_POP
Kolmogorov-Smirnov test on a numeric facet: checks whether a subset’s distribution departs from a comparison population’s.
Level: advanced
Questions it answers:
- Does this segment’s spend distribution differ from all customers’?
- Has the shape of response times in this region drifted from the fleet-wide shape?
Use cases by domain:
- survey: Check whether a segment’s score distribution departs from the full sample’s.
- ops: Check whether one site’s latency distribution departs from the fleet’s.
- science: Check whether a cohort’s measurement distribution departs from a reference population’s.
Assumptions:
- The curves are rebuilt from histograms or percentiles, not raw values: request matching histograms (or percentiles) on both arms. With matching histograms, coarse bins understate the gap and make the test conservative. When Pulse falls back to percentiles it interpolates between them, so D can come out too large or too small and the p-value has no guaranteed direction; prefer matching histograms.
- It treats subset and population as two independent samples. When the subset is part of the population they overlap: D shrinks by the subset’s share of the population, so the p-value is too large (conservative), badly so when the subset is a big part of the population. Compare against the rest of the population instead when you can.
- It compares two observed distributions; it is not a test against a named distribution with parameters estimated from the data, which would need the Lilliefors correction.
- With few rows it has low power; with very large groups even trivial differences in shape give small p-values.
- The null hypothesis is that both share one distribution; the p-value is a two-sided large-sample approximation.
Use something else:
OVERLAY_CHISQ_VS_POPwhen the field is categorical.TEST_KSwhen you have two separate groups of raw rows.OVERLAY_INDEX_VS_POPwhen you want to see where the shapes differ rather than one test.
Glossary: goodness-of-fit, independence, non-parametric, null-hypothesis, p-value, percentile, statistical-power, two-tailed
Skill: op-overlay-ks-vs-pop
OVERLAY_PAIRWISE_PROBIT_T
Pairwise t-tests on probit-transformed shares between rows (or columns) of one crosstab, matching a convention some survey tools use.
Level: advanced
Questions it answers:
- Which segments differ in share when tested the way our survey tool does?
- Do the probit-scale shares of each region differ from each other?
Use cases by domain:
- survey: Reproduce the pairwise share tests of a survey tool that works on the probit scale.
- ops: Match a legacy report’s pairwise rate tests that used the probit transform.
Assumptions:
- It treats each probit value as having spread 1/sqrt(n). The true spread is at least about 1.25/sqrt(n) and larger near 0% or 100%, so its p-values come out too small at every share. Use it only to reproduce a tool that applies it, never as evidence on its own.
- Shares of exactly 0 or 1 are clipped to 1e-10 from the edge, which maps them to about ±6.4 on the probit scale. Any pair involving a 0% or 100% share then gets a huge t and a near-zero p-value whatever the n; treat those pairs as unreadable.
- The two groups in each pair are independent samples. The null hypothesis is equal probit values; each p-value is two-sided, from a t distribution with n_i + n_j - 2 degrees of freedom.
- Pulse reports raw p-values: with many cells or pairs some small values turn up by luck alone, so adjust for multiple comparisons: set multiplicity on the overlay (holm or bonferroni hold the family-wise error, bh or by the false discovery rate) to add adjusted p-values beside the raw ones.
Use something else:
OVERLAY_PAIRWISE_PROP_Zwhen you want the standard test of two shares.OVERLAY_PAIRWISE_WELCH_Twhen the cells are averages of a numeric measure.
Glossary: degrees-of-freedom, false-discovery-rate, family-wise-error, independence, multiple-comparisons, null-hypothesis, p-value, probit, t-statistic, two-tailed
Skill: op-overlay-pairwise-probit-t
OVERLAY_PAIRWISE_PROP_Z
Two-proportion z-tests between every pair of rows (or columns) of one crosstab, per column (or row): which segments differ in share?
Level: advanced
Questions it answers:
- Which age bands differ from each other in the share choosing each brand?
- Which pairs of regions differ in their share of late deliveries?
Use cases by domain:
- survey: Letter-test banner columns: which segments differ in the share giving each answer.
- ops: Compare late-delivery shares between every pair of depots.
- science: Compare response shares between every pair of dose groups.
Assumptions:
- The two groups in each pair are independent samples; a multi-select (set) grouper can put one row in both, which breaks that.
- Choose n_source to match how the cell value was made and p_source to match a percentage or a proportion; a mismatch silently skips every pair.
- The null hypothesis for each pair is that the two shares are equal; each p-value is two-sided and needs roughly 10 successes and 10 failures per side.
- Pulse reports raw p-values: with many cells or pairs some small values turn up by luck alone, so adjust for multiple comparisons: set multiplicity on the overlay (holm or bonferroni hold the family-wise error, bh or by the false discovery rate) to add adjusted p-values beside the raw ones.
Use something else:
OVERLAY_PAIRWISE_WELCH_Twhen the cells are averages of a numeric measure rather than shares.OVERLAY_PROP_Z_PANELwhen the groups are separate Compose requests.OVERLAY_CHISQ_MATRIXwhen you want one overall test of whether the rows differ at all.
Glossary: cross-tabulation, false-discovery-rate, family-wise-error, independence, multiple-comparisons, null-hypothesis, p-value, sample-size, two-tailed
Skill: op-overlay-pairwise-prop-z
OVERLAY_PAIRWISE_TWO_MEANS_Z
Large-sample z-tests between every pair of rows (or columns) of one crosstab of averages: which segments differ in mean?
Level: advanced
Questions it answers:
- With thousands of respondents per cell, which segments differ in average rating?
- Which pairs of high-volume stores differ in average basket size?
Use cases by domain:
- survey: Letter-test large banner columns on a mean rating where reporting calls for z.
- ops: Compare average order value between every pair of high-traffic channels.
- science: Compare means between every pair of large groups where the t and normal tails agree.
Assumptions:
- Cells must use AGG_WELFORD, which supplies each cell’s mean, variance and n; any other cell aggregator is refused.
- The two groups in each pair are independent samples. It reads p from the normal curve, so with small cells the p-values come out too small.
- The null hypothesis for each pair is equal means; each p-value is two-sided.
- Pulse reports raw p-values: with many cells or pairs some small values turn up by luck alone, so adjust for multiple comparisons: set multiplicity on the overlay (holm or bonferroni hold the family-wise error, bh or by the false discovery rate) to add adjusted p-values beside the raw ones.
Use something else:
OVERLAY_PAIRWISE_WELCH_Twhen any cell is small; the t-based version is more honest there.OVERLAY_PAIRWISE_WEIGHTED_TWO_MEANS_Zwhen the cells are weighted averages.OVERLAY_PAIRWISE_PROP_Zwhen the cells are shares rather than averages.
Glossary: false-discovery-rate, family-wise-error, independence, mean, multiple-comparisons, normal-distribution, null-hypothesis, p-value, sample-size, standard-error, two-tailed
Skill: op-overlay-pairwise-two-means-z
OVERLAY_PAIRWISE_WEIGHTED_TWO_MEANS_Z
Large-sample z-tests between every pair of rows (or columns) of one crosstab of weighted averages, with a sample-size basis you choose.
Level: advanced
Questions it answers:
- Which weighted segments differ from each other in average satisfaction?
- Which regions differ in weighted average spend once survey weights are applied?
Use cases by domain:
- survey: Letter-test banner columns on a weighted mean rating.
- ops: Compare volume-weighted average prices between every pair of suppliers.
- science: Compare design-weighted means between every pair of strata.
Assumptions:
- Cells must use AGG_WEIGHTED_MEAN or a weighted AGG_AVERAGE; n_basis is required. With weights the sum of weights is the sample size, which suits weights that count repeated rows and is refused when the cell carries sampling weights; kish uses the effective sample size and suits survey weights.
- It accounts for weighting only, not for clustering or other design effects, so with such designs the p-values come out too small.
- The two groups in each pair are independent samples. The null hypothesis for each pair is equal weighted means; each p-value is two-sided from the normal curve.
- Pulse reports raw p-values: with many cells or pairs some small values turn up by luck alone, so adjust for multiple comparisons: set multiplicity on the overlay (holm or bonferroni hold the family-wise error, bh or by the false discovery rate) to add adjusted p-values beside the raw ones.
Use something else:
OVERLAY_PAIRWISE_WELCH_Twhen the cells are unweighted averages.AGG_WEIGHTED_MEANwhen you only need the weighted averages themselves.
Glossary: effective-sample-size, false-discovery-rate, family-wise-error, independence, mean, multiple-comparisons, null-hypothesis, p-value, sample-size, two-tailed, weighting
Skill: op-overlay-pairwise-weighted-two-means-z
OVERLAY_PAIRWISE_WELCH_T
Welch t-tests between every pair of rows (or columns) of one crosstab of averages: which segments differ in mean?
Level: advanced
Questions it answers:
- Which age bands differ from each other in average spend, product by product?
- Which pairs of depots differ in average delivery time per month?
Use cases by domain:
- survey: Letter-test banner columns on a mean rating.
- ops: Compare average handling time between every pair of teams, per queue.
- science: Compare mean outcomes between every pair of dose groups, per site.
Assumptions:
- Cells must use AGG_WELFORD, which supplies each cell’s mean, variance and n; any other cell aggregator is refused.
- The two groups in each pair are independent samples. Equal variances are not assumed, and each mean should be roughly normal: safe for large cells, risky for small skewed ones.
- The null hypothesis for each pair is equal means; each p-value is two-sided.
- Pulse reports raw p-values: with many cells or pairs some small values turn up by luck alone, so adjust for multiple comparisons: set multiplicity on the overlay (holm or bonferroni hold the family-wise error, bh or by the false discovery rate) to add adjusted p-values beside the raw ones.
Use something else:
OVERLAY_PAIRWISE_TWO_MEANS_Zwhen every cell is large and you want the normal-curve version.OVERLAY_PAIRWISE_WEIGHTED_TWO_MEANS_Zwhen the cells are weighted averages.OVERLAY_PAIRWISE_PROP_Zwhen the cells are shares rather than averages.TEST_TUKEY_HSDwhen you compare groups from raw rows and want a post-test whose p-values come already adjusted across every pair, with no multiplicity block.
Glossary: degrees-of-freedom, false-discovery-rate, family-wise-error, homogeneity-of-variance, independence, mean, multiple-comparisons, normal-distribution, null-hypothesis, p-value, t-statistic, two-tailed, variance
Skill: op-overlay-pairwise-welch-t
OVERLAY_PANEL_INDEX_VS_REF
Index values of several target Compose requests against one shared reference request, one layer per target (target / reference x 100).
Level: intermediate
Questions it answers:
- How do the last four waves each compare with the benchmark wave?
- How does each market compare with the reference market, cell by cell?
Use cases by domain:
- survey: Index every wave of a tracker against a benchmark wave.
- ops: Index every region’s table against the national table.
- science: Index several treatment cohorts against one control cohort.
Assumptions:
- Every target must share a schema with the reference and line up on the same keys; OverlayOptions.MaxPanelTargets caps the number of targets.
- A zero reference value gives no value; a small one makes the index swing widely.
Use something else:
OVERLAY_INDEX_VS_REFwhen there is only one target.OVERLAY_PROP_Z_PANELwhen you want tests between every pair of requests.
Glossary: baseline, index-value
Skill: op-overlay-panel-index-vs-ref
OVERLAY_PROP_Z_CELL
Two-proportion z-test for every crosstab cell in a Compose request: does the target’s share in that cell differ from the reference’s?
Level: intermediate
Questions it answers:
- Which answer shares changed between this wave and last wave?
- Where does the test market’s share differ from the control market’s?
Use cases by domain:
- survey: Flag the answer shares that moved between two survey waves.
- ops: Flag the defect rates per line and shift that differ between two plants.
- science: Compare response rates per subgroup between a treatment and a control cohort.
Assumptions:
- Cell values must be counts and each row margin is that row’s sample size: the share tested is cell / row margin. A cell whose row margin is missing or zero on either side gets no test (NaN, with a warning).
- Target and reference are separate, independent samples; rows that appear in both make the p-value wrong.
- The null hypothesis is that the two shares are equal. The normal approximation needs roughly 10 successes and 10 failures on each side; the p-value is two-sided.
- Every cell is a separate test, so with many cells some small p-values turn up by luck; to adjust for multiple comparisons, set multiplicity on the overlay (holm or bonferroni hold the family-wise error, bh or by the false discovery rate) to add adjusted p-values beside the raw ones.
Use something else:
OVERLAY_PROP_Z_PANELwhen you compare three or more requests at once.OVERLAY_PAIRWISE_PROP_Zwhen the groups are rows or columns of one crosstab.TEST_FISHER_EXACTwhen a cell has only a handful of successes or failures: stack both sources’ rows and run an exact 2x2 test.OVERLAY_DELTA_VS_REFwhen you want the size of the change rather than a p-value.
Glossary: cross-tabulation, false-discovery-rate, family-wise-error, independence, margin, multiple-comparisons, null-hypothesis, p-value, sample-size, standard-error, two-tailed
Skill: op-overlay-prop-z-cell
OVERLAY_PROP_Z_PANEL
Two-proportion z-tests between every pair of requests in a Compose panel, for each crosstab cell: which waves or markets differ in share?
Level: advanced
Questions it answers:
- Across four waves, which pairs of waves differ in this answer’s share?
- Which of five markets differ from each other in brand share?
Use cases by domain:
- survey: Compare answer shares between every pair of waves in a tracking study.
- ops: Compare defect rates between every pair of plants.
- science: Compare response rates between every pair of study sites.
Assumptions:
- Cell values must be counts; by default each slot’s row margin is its sample size (n_source picks another). A pair involving a slot whose row margin is missing or zero gets no test (NaN, with a warning).
- The slots are separate, independent samples; rows that appear in more than one slot make the p-values wrong.
- The null hypothesis for each pair is that the two shares are equal; each p-value is two-sided and needs roughly 10 successes and 10 failures per side.
- Each cell gets one test per pair of slots, so the count grows fast. Pulse reports raw p-values: with many cells or pairs some small values turn up by luck alone, so adjust for multiple comparisons: set multiplicity on the overlay (holm or bonferroni hold the family-wise error, bh or by the false discovery rate) to add adjusted p-values beside the raw ones.
Use something else:
OVERLAY_PROP_Z_CELLwhen there is only one target.OVERLAY_PANEL_INDEX_VS_REFwhen you want index values against one reference rather than tests.OVERLAY_PAIRWISE_PROP_Zwhen the groups are rows or columns of one crosstab.
Glossary: cross-tabulation, false-discovery-rate, family-wise-error, independence, margin, multiple-comparisons, null-hypothesis, p-value, sample-size, two-tailed
Skill: op-overlay-prop-z-panel
OVERLAY_RANK
Ranks each cell of a target Compose request’s crosstab (1 = largest) within its row, its column or the whole table.
Level: basic
Questions it answers:
- Which product is the top seller in each region?
- Where does each answer rank within its segment?
Use cases by domain:
- survey: Rank answers within each segment for a top-box summary.
- ops: Rank products within each region by revenue.
- science: Rank conditions within each site by mean outcome.
Assumptions:
- A rank hides how far apart values are: first and second may be nearly equal or far apart.
- Tied values share the average rank, and missing cells are left out, so ranks cover only the cells present.
- The reference request only anchors alignment; its values are not used.
Use something else:
OVERLAY_SHARE_OF_TOTALwhen you want each cell’s share of the whole.OVERLAY_ZSCORE_VS_MARGINwhen you want how far each cell sits from its row or column.OVERLAY_DELTA_VS_REFwhen you want the change from the reference request.
Glossary: cross-tabulation, rank, ties
Skill: op-overlay-rank
OVERLAY_SHARE_OF_COL
Each crosstab cell as a share of its column margin (0 to 1): the mix of rows within each column, as a 100% stacked column shows it.
Level: basic
Questions it answers:
- Within each channel, what share of orders comes from each region?
- In each banner column, how are answers split across the options?
Use cases by domain:
- survey: Column percentages: how each banner column’s answers split across the options.
- ops: How each month’s tickets split across categories.
- science: How each outcome category splits across arms.
Assumptions:
- Shares add to 1 down a column only when the cell aggregator adds up, such as counts or sums; for averages the ratio is not a share.
- A zero column margin gives no value and a warning.
Use something else:
OVERLAY_SHARE_OF_ROWwhen you want the mix within each row.OVERLAY_SHARE_OF_TOTALwhen you want each cell’s share of the whole table.OVERLAY_CHISQ_MATRIXwhen you want to test whether the column mixes differ.
Glossary: cross-tabulation, margin
Skill: op-overlay-share-of-col
OVERLAY_SHARE_OF_ROW
Each crosstab cell as a share of its row margin (0 to 1): the mix of columns within each row, as a 100% stacked bar shows it.
Level: basic
Questions it answers:
- Within each region, what share of orders goes to each channel?
- For each age band, how are answers split across the options?
Use cases by domain:
- survey: Row percentages: how each segment’s answers split across the options.
- ops: How each site’s tickets split across categories.
- science: How each arm’s outcomes split across categories.
Assumptions:
- Shares add to 1 across a row only when the cell aggregator adds up, such as counts or sums; for averages the ratio is not a share.
- A zero row margin gives no value and a warning.
Use something else:
OVERLAY_SHARE_OF_COLwhen you want the mix within each column.OVERLAY_INDEX_VS_MARGINwhen you want the same figure on a 0 to 100 index scale.OVERLAY_CHISQ_MATRIXwhen you want to test whether the row mixes differ.
Glossary: cross-tabulation, margin
Skill: op-overlay-share-of-row
OVERLAY_SHARE_OF_TOTAL
Each crosstab cell or group of a grouped result as a share of the grand total (0 to 1): what part of the whole each piece makes up.
Level: basic
Questions it answers:
- What share of all revenue does each region bring in?
- What part of all responses falls in each segment-by-answer cell?
Use cases by domain:
- survey: Total percentages: each cell’s share of all respondents.
- ops: Each product line’s share of total revenue.
- science: Each category’s share of all observations.
Assumptions:
- Shares add to 1 only when the value adds up, such as counts or sums; for averages the ratio is not a share.
- A zero grand total gives no value and a warning.
Use something else:
OVERLAY_SHARE_OF_ROWwhen you want the share within each row of a crosstab.OVERLAY_INDEX_VS_TOTALwhen you want the same figure x 100 on a grouped result.OVERLAY_ZSCORE_VS_TOTALwhen you want each group’s position against the average group.
Glossary: cross-tabulation, margin
Skill: op-overlay-share-of-total
OVERLAY_T_CELL
Welch t-test for every crosstab cell in a Compose request: does the target’s mean in that cell differ from the reference’s?
Level: advanced
Questions it answers:
- Which segment-by-product cells changed in average rating since last wave?
- Where does the new site’s average handling time differ from the old site’s?
Use cases by domain:
- survey: Flag the mean ratings per segment and question that moved between two waves.
- ops: Compare average cycle time per line and shift between two plants.
- science: Compare mean outcomes per subgroup between a treatment and a control cohort.
Assumptions:
- Cells must use AGG_WELFORD, which supplies each cell’s mean, variance and n. Without it the test uses one variance and n per side for every cell (params variance_target / variance_ref / sample_size_target / sample_size_ref, default variance 1 and n 2). Those p-values describe the values you supplied, not each cell’s own spread, and they change with the measure’s units.
- Target and reference are separate, independent samples. Equal variances are not assumed, and each mean should be roughly normal.
- The null hypothesis is equal means in the cell; the p-value is two-sided.
- Every cell is a separate test, so with many cells some small p-values turn up by luck; to adjust for multiple comparisons, set multiplicity on the overlay (holm or bonferroni hold the family-wise error, bh or by the false discovery rate) to add adjusted p-values beside the raw ones.
Use something else:
OVERLAY_Z_CELLwhen every cell is large and you want the normal-curve version.OVERLAY_T_VS_REFwhen the results are grouped series rather than crosstabs.OVERLAY_PROP_Z_CELLwhen the cells hold shares rather than averages.OVERLAY_PAIRWISE_WELCH_Twhen the groups are rows or columns of one crosstab.
Glossary: cross-tabulation, false-discovery-rate, family-wise-error, homogeneity-of-variance, independence, mean, multiple-comparisons, null-hypothesis, p-value, t-statistic, two-tailed, variance
Skill: op-overlay-t-cell
OVERLAY_T_VS_REF
Welch t-test for every group of a grouped result in a Compose request: does the target’s mean differ from the reference’s?
Level: advanced
Questions it answers:
- Which regions’ average spend changed between this quarter and last?
- In which age bands does the test cohort’s mean score differ from the control’s?
Use cases by domain:
- survey: Flag the segments whose mean rating moved between two waves.
- ops: Compare average resolution time per team between two periods.
- science: Compare mean outcome per site between two cohorts.
Assumptions:
- Group values must come from AGG_WELFORD, which supplies each group’s mean, variance and n. Without it the test uses one variance and n per side for every group (params variance_target / variance_ref / sample_size_target / sample_size_ref, default variance 1 and n 2); those p-values describe the values you supplied, not each group’s own spread, and they change with the measure’s units.
- Target and reference are separate, independent samples. Equal variances are not assumed, and each mean should be roughly normal.
- The null hypothesis is equal means in the group; the two-sided p-value is carried in summary.statistic.
- Each group is a separate test, so with many groups some small p-values turn up by luck; to adjust for multiple comparisons, set multiplicity on the overlay (holm or bonferroni hold the family-wise error, bh or by the false discovery rate) to add adjusted p-values beside the raw ones.
Use something else:
OVERLAY_Z_VS_REFwhen every group is large and you want the normal-curve version.OVERLAY_T_CELLwhen the results are crosstabs rather than grouped series.OVERLAY_DELTA_VS_REFwhen you want the size of the change rather than a p-value.
Glossary: false-discovery-rate, family-wise-error, homogeneity-of-variance, independence, mean, multiple-comparisons, null-hypothesis, p-value, t-statistic, two-tailed, variance
Skill: op-overlay-t-vs-ref
OVERLAY_YOY
Each period of a date-grouped series as an index value against the same period one year earlier (x 100): year-over-year change.
Level: basic
Questions it answers:
- How do this year’s monthly sales compare with the same months last year?
- Is this quarter’s volume up or down on the same quarter last year?
Use cases by domain:
- survey: Compare each quarter’s tracker score with the same quarter a year before.
- ops: Compare monthly orders with the same month last year, removing seasonality.
- science: Compare seasonal readings with the same season a year earlier.
Assumptions:
- The host’s single grouper must be GROUP_DATE. For weekly and coarser periods the comparison steps back a fixed number of positions (12 for months), so the series must have no missing periods.
- The first year has no value. A zero prior-year value gives no value and a warning, and 29 February has no counterpart in a non-leap year.
Use something else:
OVERLAY_INDEX_VS_PRIORwhen you want each period against the one before.OVERLAY_INDEX_VS_BASELINEwhen you want each period against one fixed starting period.TEST_TRENDwhen you want to test for a steady rise or fall across the series.
Glossary: baseline, index-value
Skill: op-overlay-yoy
OVERLAY_ZSCORE_VS_MARGIN
Each crosstab cell’s gap from its row, column or grand margin, in standard deviations of the cells in that slice: which cells stand out?
Level: intermediate
Questions it answers:
- Which segment-by-question averages sit unusually far from the question’s overall average?
- Which store-by-month cells stand out from their store’s typical month?
Use cases by domain:
- survey: Highlight the stand-out cells of a table of mean ratings for a heatmap.
- ops: Highlight the site-by-week averages that stand out from each site’s own average.
- science: Highlight subgroup means far from the pooled mean on a common scale.
Assumptions:
- It describes rather than tests: there is no p-value behind it, and a value of 2 or 3 is not a probability statement about the data.
- The standard deviation is the spread of the cell values across the slice (dividing by the number of cells), not the sampling error of a cell; slices with few cells give unstable values.
- The centre is the host’s margin figure, so it reads naturally when that is an overall average; for counts or sums the margin is a total and every cell sits far below it.
Use something else:
OVERLAY_FISHER_EXACT_CELLwhen you want a test of whether a cell’s count departs from what the margins predict.OVERLAY_DELTA_VS_MARGINwhen the gap in the cell’s own units is easier to explain.OVERLAY_INDEX_VS_MARGINwhen you want a ratio to the margin.
Glossary: cross-tabulation, margin, outlier, standard-deviation, z-score
Skill: op-overlay-zscore-vs-margin
OVERLAY_ZSCORE_VS_POP
Puts each category’s share gap with a comparison population on a z-score scale; for numeric bins it says nothing about the subset.
Level: intermediate
Questions it answers:
- Which answers does this segment pick more or less often than the comparison population, relative to how much answer shares vary?
- Which categories stand out in this region compared with the whole business?
Use cases by domain:
- survey: Rank the answers a segment over- or under-picks, on one scale.
- ops: Highlight the categories a region buys unusually often or rarely.
- science: Highlight categories whose share in a cohort sits far from the population’s.
Assumptions:
- It describes rather than tests: there is no p-value behind it, and a value of 2 or 3 is not a probability statement about the data.
- For categories it divides each share gap by the spread of the population’s shares across categories, not by a standard error, so it does not shrink as the subset grows.
- For a numeric field it standardizes each histogram bin’s centre against the population mean and standard deviation: the subset’s counts never enter it, so the values are the same for any subset. Use OVERLAY_KS_VS_POP to compare a numeric subset.
Use something else:
OVERLAY_CHISQ_VS_POPwhen you want a test of whether the subset’s category mix differs.OVERLAY_KS_VS_POPwhen you want a test of whether a numeric distribution differs.OVERLAY_INDEX_VS_POPwhen a ratio is easier to explain.
Glossary: baseline, standard-deviation, standard-error, z-score
Skill: op-overlay-zscore-vs-pop
OVERLAY_ZSCORE_VS_ROLLING
How many standard deviations each point sits from the rolling mean of the previous W points: a simple flag for unusual periods.
Level: intermediate
Questions it answers:
- Which days had unusually high or low orders compared with the previous two weeks?
- Which readings jumped far from their recent run?
Use cases by domain:
- survey: Flag waves whose score is far from the last few waves.
- ops: Flag days whose volume is far from the recent run.
- science: Flag readings far from the preceding readings.
Assumptions:
- It describes rather than tests: there is no p-value behind it, and a value of 2 or 3 is not a probability statement about the data.
- The standard deviation is the sample spread of the window (dividing by n - 1); a short window gives a jumpy one, and a trend or seasonality makes many points look unusual.
- The series must be ordered and params.window set; points get no value until the window holds at least 2 values.
Use something else:
TEST_TRENDwhen you want to test for a steady rise or fall across the whole series.OVERLAY_INDEX_VS_ROLLING_MEANwhen you want the ratio to the rolling mean.ATTR_ZSCOREwhen you want a standardized column for each row rather than per period.
Glossary: outlier, rolling-mean, standard-deviation, z-score
Skill: op-overlay-zscore-vs-rolling
OVERLAY_ZSCORE_VS_TOTAL
How many standard deviations each group’s value sits from the average of all groups on a grouped result: which groups stand out?
Level: intermediate
Questions it answers:
- Which stores’ sales are unusually high or low compared with the other stores?
- Which regions stand out from the rest on average rating?
Use cases by domain:
- survey: Highlight segments whose mean rating is far from the average segment.
- ops: Flag sites whose volume is far from the typical site’s.
- science: Put group means on a common scale before plotting.
Assumptions:
- It describes rather than tests: there is no p-value behind it, and a value of 2 or 3 is not a probability statement about the data.
- The standard deviation is the spread of the group values themselves (dividing by the number of groups), not the spread of rows within a group; with few groups the values are unstable.
Use something else:
TEST_ANOVA_WELCHwhen you want to test whether group averages differ, from raw rows.OVERLAY_SHARE_OF_TOTALwhen you want each group’s share of the total.OVERLAY_DELTA_VS_SIBLINGwhen you want each group against one chosen group.
Glossary: mean, outlier, standard-deviation, z-score
Skill: op-overlay-zscore-vs-total
OVERLAY_Z_CELL
Large-sample z-test for every crosstab cell in a Compose request: does the target’s mean in that cell differ from the reference’s?
Level: advanced
Questions it answers:
- With large samples, which segment-by-question means moved since last wave?
- Which store-by-category average baskets differ between two high-traffic regions?
Use cases by domain:
- survey: Flag mean ratings that moved between two large survey waves.
- ops: Compare average spend per category between two high-volume storefronts.
- science: Compare subgroup means of two large cohorts where the t and normal tails agree.
Assumptions:
- Cells must use AGG_WELFORD, which supplies each cell’s mean, variance and n. Without it the test uses one variance and n per side for every cell (params variance_target / variance_ref / sample_size_target / sample_size_ref, default variance 1 and n 2). Those p-values describe the values you supplied, not each cell’s own spread, and they change with the measure’s units.
- Target and reference are separate, independent samples. It reads p from the normal curve, so with small cells the p-values come out too small.
- The null hypothesis is equal means in the cell; the p-value is two-sided.
- Every cell is a separate test, so with many cells some small p-values turn up by luck; to adjust for multiple comparisons, set multiplicity on the overlay (holm or bonferroni hold the family-wise error, bh or by the false discovery rate) to add adjusted p-values beside the raw ones.
Use something else:
OVERLAY_T_CELLwhen any cell is small; the t-based version is more honest there.OVERLAY_Z_VS_REFwhen the results are grouped series rather than crosstabs.OVERLAY_PROP_Z_CELLwhen the cells hold shares rather than averages.
Glossary: cross-tabulation, false-discovery-rate, family-wise-error, independence, mean, multiple-comparisons, normal-distribution, null-hypothesis, p-value, standard-error, two-tailed
Skill: op-overlay-z-cell
OVERLAY_Z_VS_REF
Large-sample z-test for every group of a grouped result in a Compose request: does the target’s mean differ from the reference’s?
Level: advanced
Questions it answers:
- With large samples, which regions’ average spend changed since last quarter?
- Which high-volume channels’ average basket differs between the two periods?
Use cases by domain:
- survey: Flag segment mean ratings that moved between two large waves.
- ops: Compare average order value per channel between two high-traffic periods.
- science: Compare per-site means of two large cohorts.
Assumptions:
- Group values must come from AGG_WELFORD, which supplies each group’s mean, variance and n. Without it the test uses one variance and n per side for every group (params variance_target / variance_ref / sample_size_target / sample_size_ref, default variance 1 and n 2); those p-values describe the values you supplied, not each group’s own spread, and they change with the measure’s units.
- Target and reference are separate, independent samples. It reads p from the normal curve, so with small groups the p-values come out too small.
- The null hypothesis is equal means in the group; the two-sided p-value is carried in summary.statistic.
- Each group is a separate test, so with many groups some small p-values turn up by luck; to adjust for multiple comparisons, set multiplicity on the overlay (holm or bonferroni hold the family-wise error, bh or by the false discovery rate) to add adjusted p-values beside the raw ones.
Use something else:
OVERLAY_T_VS_REFwhen any group is small; the t-based version is more honest there.OVERLAY_Z_CELLwhen the results are crosstabs rather than grouped series.OVERLAY_DELTA_VS_REFwhen you want the size of the change rather than a p-value.
Glossary: false-discovery-rate, family-wise-error, independence, mean, multiple-comparisons, normal-distribution, null-hypothesis, p-value, standard-error, two-tailed
Skill: op-overlay-z-vs-ref
Synth distributions
The synth distributions this instance offers: what each is for, the questions it answers and when to reach for something else. Operators are sorted by name; each name links to its detail block below.
| Operator | In plain words | Answers questions like | Level | Instead, when… |
|---|---|---|---|---|
bernoulli | Draws a yes/no value per row: 1 with a chosen chance p and 0 otherwise, such as whether a customer churned. | How do I mark 12% of synthetic customers as churned? | basic | weighted_categorical when there are more than two outcomes.set_bernoulli when each row can tick several options.constant when every row should get the same value. |
constant | Puts the same value on every row, for a placeholder field or a fixed flag in a test fixture. | How do I fill a column with the same country code? | basic | monotonic_from when each row needs a distinct ID.weighted_categorical when the value should vary over a few labels.bernoulli when a flag should be set on only some rows. |
discrete | Draws whole-number levels at set shares, such as a 1 to 7 rating scale, so each level appears about as often as declared. | How do I generate answers on a 1 to 5 scale where most people choose 4? | intermediate | normal when the field is a continuous measure.weighted_categorical when the levels are text labels.poisson when the counts follow an average rate rather than fixed shares. |
exponential | Draws positive waiting times where short waits are common and long ones rare, set by how often events happen. | How do I simulate the time between customer arrivals? | intermediate | poisson when you want whole-number counts of events per period.pareto when a few values should be extremely large.lognormal when values bunch around a typical size rather than near zero. |
lognormal | Draws positive numbers with a long right tail, like incomes or order values: most are modest and a few are very large. | How do I simulate skewed order values? | intermediate | normal when values are spread evenly around a centre.pareto when the tail should follow a power law, as with wealth or city sizes.exponential when the values are waiting times between events. |
mixture | Draws from two or more bell curves blended by weight, for data with several peaks, like weekday and weekend order sizes. | How do I simulate a column with two separate peaks? | advanced | normal when one peak is enough.lognormal when there is one peak with a long right tail.discrete when the field is a coded scale with a few fixed levels. |
monotonic_from | Counts up (or down) by a fixed step from a start value, one step per row, to make unique IDs or row numbers. | How do I give each synthetic row a unique customer ID? | basic | constant when every row should get the same value.uniform when the numbers should be random, not in order.regex when IDs should be text codes like AB-1234. |
normal | Draws numbers from a bell curve around a chosen centre and spread, optionally clipped to a low and a high bound. | How do I generate realistic test scores that cluster around 70? | basic | lognormal when values are always positive with a long right tail, like income.mixture when the data has two or more peaks.discrete when the field is a coded scale with a few levels, like 1 to 7. |
pareto | Draws heavy-tailed values above a minimum, where a small share of rows holds most of the total, like wealth or file sizes. | How do I simulate customers where the top fifth spend most of the money? | advanced | lognormal when the tail is long but values have a typical size.exponential when the values are waiting times between events.uniform when every value in a range should be equally likely. |
poisson | Draws whole-number counts of events per period, such as visits per day, around a chosen average. | How many orders might a store get per hour? | intermediate | exponential when you want the time between events, not how many.bernoulli when each row is a yes/no outcome.discrete when you know the exact share of each count. |
regex | Generates text matching a pattern, such as order codes like AB-1234, for realistic-looking IDs and labels. | How do I generate postcodes that look real? | intermediate | weighted_categorical when the values come from a short known list.monotonic_from when IDs must be unique.constant when every row should carry the same text. |
set_bernoulli | Fills a multi-select answer: each option is ticked on its own at its own rate, such as 40% picking email and 25% SMS. | How do I simulate a tick-all-that-apply question? | intermediate | weighted_categorical when each row picks exactly one answer.bernoulli when there is only one yes/no flag. |
uniform | Draws numbers between a low and a high bound, every value equally likely, for flat filler columns or random noise. | How do I fill a test column with random numbers between 0 and 100? | basic | normal when values should cluster around a typical value.uniform_date when the field is a calendar date.discrete when the field is a coded scale with a few fixed levels. |
uniform_date | Draws calendar dates between a start and an end date, every day equally likely, both ends included. | How do I spread synthetic orders across 2024? | basic | uniform when the field is a plain number, not a date.constant when every row shares one date. |
weighted_categorical | Picks one label per row from a list, each at its own share, such as 50% North, 30% South and 20% West. | How do I give synthetic respondents a realistic region mix? | basic | bernoulli when there are just two outcomes stored as 1 and 0 or true and false.set_bernoulli when each row can pick several options.discrete when the levels are numbers on a scale. |
Operators
bernoulli
Draws a yes/no value per row: 1 with a chosen chance p and 0 otherwise, such as whether a customer churned.
Level: basic
Questions it answers:
- How do I mark 12% of synthetic customers as churned?
- How do I generate a random true/false flag?
Use cases by domain:
- survey: Whether each respondent is a current customer, at the source’s 35% share.
- ops: A late-delivery flag on about 8% of orders.
Assumptions:
- p is between 0 and 1; on a true/false field 1 is true, on a number field it is the number 1.
- The observed share wanders from p in a small cohort and settles as rows grow.
- A synthetic copy rebuilds every true/false field this way from its observed share; a model on it shifts which rows get 1 (a probit), not the share.
- The same spec and seed give the same rows every time; change the seed for a fresh draw.
Use something else:
weighted_categoricalwhen there are more than two outcomes.set_bernoulliwhen each row can tick several options.constantwhen every row should get the same value.
Glossary: probit
Skill: op-synth-bernoulli
constant
Puts the same value on every row, for a placeholder field or a fixed flag in a test fixture.
Level: basic
Questions it answers:
- How do I fill a column with the same country code?
- How do I add a fixed version field to test data?
Use cases by domain:
- ops: A single-site extract where every row carries the same store code.
- harness: A schema-version field that is always 3.
Assumptions:
- The value is checked against the field type when the spec is read: text on a number field is refused, and a multi-select field takes a list of option names.
- It uses no randomness, so adding or removing this field leaves every other field’s draws unchanged.
Use something else:
monotonic_fromwhen each row needs a distinct ID.weighted_categoricalwhen the value should vary over a few labels.bernoulliwhen a flag should be set on only some rows.
Skill: op-synth-constant
discrete
Draws whole-number levels at set shares, such as a 1 to 7 rating scale, so each level appears about as often as declared.
Level: intermediate
Questions it answers:
- How do I generate answers on a 1 to 5 scale where most people choose 4?
- How do I reproduce the exact spread of a rating question?
Use cases by domain:
- survey: A 0 to 10 likelihood-to-recommend item with the source’s real share at each point.
- ops: Items per order, where 1, 2 and 3 cover nearly every order.
Assumptions:
- values are listed in strictly ascending order with no repeats; weights may be raw counts and default to equal.
- A synthetic copy rebuilds every whole-number field with up to 64 distinct levels this way; above 64 it falls back to a clipped bell curve.
- The field is drawn on its own unless the spec adds correlations, models or rules that tie it to other fields.
- The same spec and seed give the same rows every time; change the seed for a fresh draw.
Use something else:
normalwhen the field is a continuous measure.weighted_categoricalwhen the levels are text labels.poissonwhen the counts follow an average rate rather than fixed shares.
Skill: op-synth-discrete
exponential
Draws positive waiting times where short waits are common and long ones rare, set by how often events happen.
Level: intermediate
Questions it answers:
- How do I simulate the time between customer arrivals?
- How do I generate realistic call durations for a load test?
Use cases by domain:
- ops: Minutes between support tickets at one every 4 minutes on average (lambda 0.25).
- science: Time to failure of parts that fail at a steady rate.
Assumptions:
- lambda is a rate, not the average: the mean is 1/lambda, so lambda 0.5 gives an average of 2.
- There is no bound: a rare extreme value can appear, so cap it with a constraint when the field must stay in range.
- On a u8 to u64 field each draw is rounded to the nearest whole number; a negative draw is stored as 0 and one past the type’s top stops there.
- The same spec and seed give the same rows every time; change the seed for a fresh draw.
Use something else:
poissonwhen you want whole-number counts of events per period.paretowhen a few values should be extremely large.lognormalwhen values bunch around a typical size rather than near zero.
Glossary: mean
Skill: op-synth-exponential
lognormal
Draws positive numbers with a long right tail, like incomes or order values: most are modest and a few are very large.
Level: intermediate
Questions it answers:
- How do I simulate skewed order values?
- How do I generate realistic household incomes?
Use cases by domain:
- ops: Order values with a typical basket near 40 and occasional orders in the thousands.
- science: Particle sizes or concentrations that vary by multiples rather than by fixed amounts.
Assumptions:
- mu and sigma describe the LOG of the value, not the value: the median value is e^mu and the average is e^(mu + sigma²/2).
- A larger sigma gives a longer tail; sigma must be above 0.
- There is no bound: a rare extreme value can appear, so cap it with a constraint when the field must stay in range.
- On a u8 to u64 field each draw is rounded to the nearest whole number; a negative draw is stored as 0 and one past the type’s top stops there.
- The same spec and seed give the same rows every time; change the seed for a fresh draw.
Use something else:
normalwhen values are spread evenly around a centre.paretowhen the tail should follow a power law, as with wealth or city sizes.exponentialwhen the values are waiting times between events.
Skill: op-synth-lognormal
mixture
Draws from two or more bell curves blended by weight, for data with several peaks, like weekday and weekend order sizes.
Level: advanced
Questions it answers:
- How do I simulate a column with two separate peaks?
- How do I generate order sizes where small and bulk orders form distinct groups?
Use cases by domain:
- ops: Basket values from a mix of small top-up orders and large weekly shops.
- science: Readings from two instruments with different calibrations pooled into one column.
Assumptions:
- Each row first picks a component by weight, then draws from that component’s bell curve; without weights each component is equally likely.
- means and stds are listed per component and must be the same length, at least two.
- A synthetic copy fits two components automatically when they describe the data clearly better than one; three or more are written by hand.
- There is no bound: a rare extreme value can appear, so cap it with a constraint when the field must stay in range.
- The same spec and seed give the same rows every time; change the seed for a fresh draw.
Use something else:
normalwhen one peak is enough.lognormalwhen there is one peak with a long right tail.discretewhen the field is a coded scale with a few fixed levels.
Glossary: normal-distribution
Skill: op-synth-mixture
monotonic_from
Counts up (or down) by a fixed step from a start value, one step per row, to make unique IDs or row numbers.
Level: basic
Questions it answers:
- How do I give each synthetic row a unique customer ID?
- How do I number rows from 1000 upward?
Use cases by domain:
- ops: Sequential invoice numbers starting at 50,000.
- harness: A primary-key column for a fixture that a lookup index is built on.
Assumptions:
- The first row gets start, the next start plus step, and so on; step must not be 0, and a negative step counts down.
- A row a constraint rejects still uses up a number, so the IDs can have gaps.
- Pick a field wide enough: past the type’s top value every row repeats the top, so the IDs stop being unique.
- It uses no randomness, so adding or removing this field leaves every other field’s draws unchanged.
Use something else:
constantwhen every row should get the same value.uniformwhen the numbers should be random, not in order.regexwhen IDs should be text codes like AB-1234.
Skill: op-synth-monotonic-from
normal
Draws numbers from a bell curve around a chosen centre and spread, optionally clipped to a low and a high bound.
Level: basic
Questions it answers:
- How do I generate realistic test scores that cluster around 70?
- How do I make a numeric column with a known average and spread?
Use cases by domain:
- survey: Synthetic satisfaction scores averaging 7 with a standard deviation of 1.5.
- science: Simulated measurement error around a true reading.
Assumptions:
- mean sets the centre and std the spread; std must be above 0.
- Bounds clip rather than redraw: a draw past a bound is set to that bound, so tight bounds pile values up at the edges.
- On a u8 to u64 field each draw is rounded to the nearest whole number; a negative draw is stored as 0 and one past the type’s top stops there.
- The field is drawn on its own unless the spec adds correlations, models or rules that tie it to other fields.
- The same spec and seed give the same rows every time; change the seed for a fresh draw.
Use something else:
lognormalwhen values are always positive with a long right tail, like income.mixturewhen the data has two or more peaks.discretewhen the field is a coded scale with a few levels, like 1 to 7.
Glossary: normal-distribution, standard-deviation
Skill: op-synth-normal
pareto
Draws heavy-tailed values above a minimum, where a small share of rows holds most of the total, like wealth or file sizes.
Level: advanced
Questions it answers:
- How do I simulate customers where the top fifth spend most of the money?
- How do I generate file sizes with a few huge files?
Use cases by domain:
- ops: Account balances with xm 10,000 and alpha 1.16, close to an 80/20 split.
- science: Event sizes that follow a power law, such as city populations.
Assumptions:
- xm is the smallest value drawn; alpha sets the tail, and a smaller alpha means a heavier tail. Both must be above 0.
- With alpha at or below 1 the mean is infinite and averages over the generated rows never settle. Between 1 and 2 the mean exists but the variance is infinite, so averages settle only slowly and jump when a huge value lands.
- There is no bound: a rare extreme value can appear, so cap it with a constraint when the field must stay in range.
- On a u8 to u64 field each draw is rounded to the nearest whole number; a negative draw is stored as 0 and one past the type’s top stops there.
- The same spec and seed give the same rows every time; change the seed for a fresh draw.
Use something else:
lognormalwhen the tail is long but values have a typical size.exponentialwhen the values are waiting times between events.uniformwhen every value in a range should be equally likely.
Skill: op-synth-pareto
poisson
Draws whole-number counts of events per period, such as visits per day, around a chosen average.
Level: intermediate
Questions it answers:
- How many orders might a store get per hour?
- How do I simulate defect counts per batch?
Use cases by domain:
- ops: Support calls per hour at an average of 6.
- science: Particle counts per time window from a steady source.
Assumptions:
- lambda is both the average count and its variance; real counts often vary more than that (overdispersion), which this cannot reproduce.
- From an average of 30 up, the draw is a rounded bell-curve approximation instead of the exact method.
- The field is drawn on its own unless the spec adds correlations, models or rules that tie it to other fields.
- The same spec and seed give the same rows every time; change the seed for a fresh draw.
Use something else:
exponentialwhen you want the time between events, not how many.bernoulliwhen each row is a yes/no outcome.discretewhen you know the exact share of each count.
Glossary: variance, overdispersion
Skill: op-synth-poisson
regex
Generates text matching a pattern, such as order codes like AB-1234, for realistic-looking IDs and labels.
Level: intermediate
Questions it answers:
- How do I generate postcodes that look real?
- How do I fill a SKU column with codes like SKU-00042?
Use cases by domain:
- ops: Masked account codes that keep the real shape but none of the real values.
- harness: Order references shaped like the production format, for a parser test.
Assumptions:
- Open-ended repeats such as * and + are capped at max_repeat copies (default 8); back-references are not supported.
- Generated strings are not guaranteed unique, and each distinct one becomes a dictionary entry, so a wide pattern on many rows can overflow a narrow categorical field.
- The field is drawn on its own unless the spec adds correlations, models or rules that tie it to other fields.
- The same spec and seed give the same rows every time; change the seed for a fresh draw.
Use something else:
weighted_categoricalwhen the values come from a short known list.monotonic_fromwhen IDs must be unique.constantwhen every row should carry the same text.
Skill: op-synth-regex
set_bernoulli
Fills a multi-select answer: each option is ticked on its own at its own rate, such as 40% picking email and 25% SMS.
Level: intermediate
Questions it answers:
- How do I simulate a tick-all-that-apply question?
- How do I generate which channels each customer has opted into?
Use cases by domain:
- survey: Brands each respondent has bought in the last year.
- ops: Features enabled on each account.
Assumptions:
- options are listed in bit order and become the field’s dictionary; frequencies default to 0.5 each.
- Options are drawn independently unless the spec pairs this field with another, in which case a paired option follows the captured joint pattern.
- A row with no option ticked is an empty answer, not a missing one.
- The same spec and seed give the same rows every time; change the seed for a fresh draw.
Use something else:
weighted_categoricalwhen each row picks exactly one answer.bernoulliwhen there is only one yes/no flag.
Skill: op-synth-set-bernoulli
uniform
Draws numbers between a low and a high bound, every value equally likely, for flat filler columns or random noise.
Level: basic
Questions it answers:
- How do I fill a test column with random numbers between 0 and 100?
- How do I add flat random noise to a synthetic cohort?
Use cases by domain:
- ops: Random handling times between 1 and 5 minutes for a load test.
- harness: A placeholder score column in a test fixture.
Assumptions:
- Before rounding, the low bound can be drawn and the high bound never is, so min 0 and max 1 gives values from 0 up to just under 1.
- On a u8 to u64 field the draws are rounded, so the high bound can appear and the two end values come out about half as often as the rest; for equally likely whole numbers use discrete with equal weights.
- On a u8 to u64 field each draw is rounded to the nearest whole number; a negative draw is stored as 0 and one past the type’s top stops there.
- The field is drawn on its own unless the spec adds correlations, models or rules that tie it to other fields.
- The same spec and seed give the same rows every time; change the seed for a fresh draw.
Use something else:
normalwhen values should cluster around a typical value.uniform_datewhen the field is a calendar date.discretewhen the field is a coded scale with a few fixed levels.
Skill: op-synth-uniform
uniform_date
Draws calendar dates between a start and an end date, every day equally likely, both ends included.
Level: basic
Questions it answers:
- How do I spread synthetic orders across 2024?
- How do I generate sign-up dates within the last quarter?
Use cases by domain:
- survey: Interview dates spread over a six-week fieldwork period.
- ops: Order dates across a fiscal year for a reporting test.
Assumptions:
- start and end are written as YYYY-MM-DD; end may equal start, which puts every row on that day, but may not come before it.
- Dates before 1970-01-01 are allowed.
- The field is drawn on its own unless the spec adds correlations, models or rules that tie it to other fields.
- The same spec and seed give the same rows every time; change the seed for a fresh draw.
Use something else:
Skill: op-synth-uniform-date
weighted_categorical
Picks one label per row from a list, each at its own share, such as 50% North, 30% South and 20% West.
Level: basic
Questions it answers:
- How do I give synthetic respondents a realistic region mix?
- How do I assign 70% of test users to plan A?
Use cases by domain:
- survey: Age band and region with the same mix as the real sample.
- harness: Plan tier on test accounts at a fixed 70/20/10 split.
Assumptions:
- weights may be raw counts and need not sum to 1; without weights every label is equally likely.
- The labels become the field’s dictionary, so the list must fit the categorical width (256 labels for categorical_u8).
- The field is drawn on its own unless the spec adds correlations, models or rules that tie it to other fields.
- The same spec and seed give the same rows every time; change the seed for a fresh draw.
Use something else:
bernoulliwhen there are just two outcomes stored as 1 and 0 or true and false.set_bernoulliwhen each row can pick several options.discretewhen the levels are numbers on a scale.
Skill: op-synth-weighted-categorical
Reading your results
How to read what the operators this instance offers return, one page per result family, plus the counts that ride beside every result.
- Reading test results
- Reading regression results
- Reading matrix results
- Reading overlay results
- Reading descriptive results
- Reading the components
Reading test results
How to read each output field of the statistical tests this instance offers. Each operator lists its output fields: what the value means, the labelled bands of a published convention where one applies, what its sign says, and the caveats to keep in mind.
Reading a p-value
Every field below marked as following this reading is read the same way.
The chance of seeing a result at least this extreme if the test’s null hypothesis were true (usually no difference or link; for a shape test, the stated shape) and its assumptions held. Below the chosen alpha (0.05 unless the request sets another) the result is called significant.
Caveats:
- Significant is not the same as important: with enough rows a trivial difference is significant, so read the effect size for how big it is.
- Not significant does not mean there is no difference: the data may simply be too few or too noisy to detect one.
- When more than one test runs, some will be significant by chance alone; adjust for multiple comparisons (set multiplicity, then read p_adjusted beside the raw p-value) or treat isolated hits with caution.
- Under a row weight it rests on the effective sample size reported beside n (sum_weights for frequency weights, Kish’s n_eff for probability weights), not on the row count; it accounts for unequal weights only, not strata or clusters, so a design-based p-value from survey software can be larger.
Statistical tests
TEST_ANOVA_F
Checks whether the average of a numeric measure differs across three or more groups. See its catalog entry.
statistic
F compares how far apart the group averages are with how much rows vary inside each group; larger values are stronger evidence that the groups differ by more than within-group noise; the p-value says whether this F is large enough to be surprising.
Caveats:
- F says whether some groups differ, not which ones: run TEST_TUKEY_HSD to find the pairs.
- The classic F assumes equal variances across groups; with unequal spreads and unequal group sizes prefer TEST_ANOVA_WELCH.
p_value
Follows the shared reading: see Reading a p-value.
details.effect_size.eta_squared
The share of all variation in the measure that group membership accounts for in this sample, from 0 to 1.
Bands (convention: Cohen (1988); a labelled convention, not a rule):
| Value | Label |
|---|---|
| below 0.01 | very small |
| from 0.01 to below 0.06 | small |
| from 0.06 to below 0.14 | medium |
| 0.14 and above | large |
Caveats:
- Eta squared overstates the effect in small samples; prefer omega squared when reporting.
details.effect_size.omega_squared
A less biased estimate of the share of variation group membership accounts for, adjusted for sample size and the number of groups.
Bands (convention: Cohen (1988); a labelled convention, not a rule):
| Value | Label |
|---|---|
| below 0.01 | very small |
| from 0.01 to below 0.06 | small |
| from 0.06 to below 0.14 | medium |
| 0.14 and above | large |
Caveats:
- The raw estimate goes below zero when F is under 1; Pulse reports 0 instead: the sample shows no measurable share of variation, which does not show that the true effect is zero, especially with small groups.
TEST_ANOVA_RM
Checks whether the average differs across conditions when every subject is measured under each condition. See its catalog entry.
statistic
F compares how far apart the condition averages are with the leftover noise once each subject’s own level is removed; larger values are stronger evidence that conditions differ by more than that noise; the p-value says whether this F is large enough to be surprising.
Caveats:
- No sphericity correction (such as Greenhouse-Geisser) is applied: with three or more conditions whose differences vary unevenly, the p-value runs too small.
- It says whether some conditions differ, not which ones.
p_value
Follows the shared reading: see Reading a p-value.
details.effect_size.partial_eta_squared
The share of the within-subject variation, after removing differences between subjects, that the conditions account for, from 0 to 1.
Caveats:
- There are no sourced bands for repeated-measures partial eta squared, so none are attached: Cohen’s 0.01 / 0.06 / 0.14 were set for between-groups designs, and excluding subject variance makes this figure read larger for the same shift; compare it only with other repeated-measures results.
details.dropped_subjects
How many subjects were left out because they lacked a value for at least one condition; only complete subjects enter the test.
Caveats:
- If dropped subjects differ from the rest (for example, people who quit early), the result describes the completers only.
TEST_ANOVA_WELCH
Checks whether the average of a numeric measure differs across groups when the groups may have unequal spread. See its catalog entry.
statistic
Welch’s F compares the spread of the group averages with the noise inside groups, weighting each group by its own variance; larger values are stronger evidence that the averages differ by more than that noise; the p-value says whether this F is large enough to be surprising.
Caveats:
- It says whether some groups differ, not which ones.
- The df slot holds the between-groups degrees of freedom; the adjusted within-groups df is in details.df_within and is usually not a whole number.
p_value
Follows the shared reading: see Reading a p-value.
details.effect_size.omega_squared
Estimated share of variation group membership accounts for, from Welch’s F plugged into the classic omega-squared formula; it approximates TEST_ANOVA_F’s omega squared and matches it exactly only when the variances are equal.
Bands (convention: Cohen (1988); a labelled convention, not a rule):
| Value | Label |
|---|---|
| below 0.01 | very small |
| from 0.01 to below 0.06 | small |
| from 0.06 to below 0.14 | medium |
| 0.14 and above | large |
Caveats:
- The raw estimate goes below zero when F is under 1; Pulse reports 0 instead: the sample shows no measurable share of variation, which does not show that the true effect is zero, especially with small groups.
TEST_BARTLETT_SPHERICITY
Checks whether a set of numeric fields are related at all, the first check before summarising them as a few underlying dimensions. See its catalog entry.
statistic
A chi-square that grows as the fields’ correlations move away from all zero; a small p-value says the fields are related enough to summarise as fewer dimensions.
Caveats:
- With many rows even tiny correlations are significant: a small p shows the fields are not independent, not that they factor well.
- It is null, with a warning, when a field is constant or is the sum of others, or when the effective row count is too small.
p_value
Follows the shared reading: see Reading a p-value.
details.determinant
The determinant of the correlation matrix, 1 when the fields are uncorrelated and near 0 when they are strongly related; the statistic grows as it shrinks.
TEST_BOX_M
Checks whether several groups share one spread pattern across a set of numeric fields, the assumption behind comparing average profiles. See its catalog entry.
statistic
A chi-square that grows as the groups’ spreads and correlations across the fields differ; a small p-value says the groups do not share one covariance.
Caveats:
- It is very sensitive to non-normal data and to large groups, so a small p is common; many analysts only worry below 0.001.
- It is null, with a warning, when a group has no more complete rows than fields or a field is constant within a group.
p_value
Follows the shared reading: see Reading a p-value.
details.m
Box’s M, the raw gap between the pooled and the per-group covariances before the small-sample correction; 0 when every group’s covariance is the same.
details.f
Box’s M rescaled to an approximate F on details.df1 and details.df2 degrees of freedom, with its own details.f_p_value; an alternative to the chi-square for small groups.
TEST_BROWN_FORSYTHE
Checks whether the spread of a numeric field differs across groups: a robust test of equal variances. See its catalog entry.
statistic
F on each row’s distance from its group median: larger values are stronger evidence that the groups differ in spread; the p-value says whether this F is large enough to be surprising.
Caveats:
- It tests spread only, not averages.
p_value
Follows the shared reading: see Reading a p-value.
TEST_CHISQ
Checks whether two categorical fields are associated by comparing a cross-tabulation with the counts expected if unrelated. See its catalog entry.
statistic
Pearson’s chi-square adds up, over every cell, how far the observed count is from the count expected if the two fields were unrelated. Larger values mean the table departs more from that pattern.
Caveats:
- Chi-square grows with the number of rows, so it does not measure the strength of the association: read Cramer’s V.
- No continuity (Yates) correction is applied, so on a 2x2 table the p-value is smaller than R’s chisq.test default.
p_value
Follows the shared reading: see Reading a p-value.
details.expected_min
The smallest expected count across all cells.
Caveats:
- Below 5 the chi-square approximation becomes unreliable (Pulse also warns); for a 2x2 table use TEST_FISHER_EXACT instead.
details.effect_size.cramers_v
Cramer’s V, the strength of the association from 0 (unrelated) to 1 (one field fully determines the other), comparable across sample sizes.
Caveats:
- There are no standard bands that hold for every table size: Cohen’s small / medium / large benchmarks for V are 0.1 / 0.3 / 0.5 divided by the square root of df*, the smaller of rows - 1 and columns - 1.
- V carries no direction; inspect the table to see which cells drive it.
details.effect_size.phi
Phi, reported for 2x2 tables only: the strength of the association from 0 to 1, equal to Cramer’s V and to Cohen’s w there.
Bands (convention: Cohen (1988); a labelled convention, not a rule):
| Value | Label |
|---|---|
| below 0.1 | very small |
| from 0.1 to below 0.3 | small |
| from 0.3 to below 0.5 | medium |
| 0.5 and above | large |
Caveats:
- Pulse reports phi unsigned; inspect the table to see which way the association runs.
TEST_FISHER_EXACT
Exact test of whether two yes/no fields are associated, built for small 2x2 tables. See its catalog entry.
statistic
The sample odds ratio (ad)/(bc) of the 2x2 table in details.contingency, also in details.odds_ratio: 1 means the odds of the first column are the same in both rows, above 1 higher in the first row, below 1 lower.
Bands (convention: Cohen (1988) d benchmarks converted via d = ln(OR)*sqrt(3)/pi (Chinn 2000); a labelled convention, not a rule):
| Value | Label |
|---|---|
| below 1.44 | very small |
| from 1.44 to below 2.48 | small |
| from 2.48 to below 4.27 | medium |
| 4.27 and above | large |
Caveats:
- Bands apply to max(OR, 1/OR), so an odds ratio of 0.25 reads like one of 4.
- Rows and columns follow the order values were first seen (details.row_labels / col_labels), so the ratio can come out inverted from what you expect: check the labels.
- A zero in b or c gives an infinite ratio and a zero in a or d gives 0; with zero cells read the p-value and the table, not the ratio.
- This is the plain cross-product ratio, not the conditional estimate R’s fisher.test reports, so the two differ slightly.
p_value
Follows the shared reading: see Reading a p-value.
TEST_HOTELLING_T2
Checks whether several numeric fields, taken together as one profile, differ from a target, between two groups, or before vs after. See its catalog entry.
statistic
T² is the squared distance between the observed average profile and the target profile (one sample) or between the two groups’ average profiles, measured in units of the fields’ own spread and correlation; larger values are stronger evidence that the profiles differ; the p-value says whether it is large enough to be surprising.
Caveats:
- T² says whether the profiles differ as a whole, not on which fields: compare the per-field averages in details.mean, details.means or details.mean_diff to see where the gap lies.
- With variance unequal, details.df2 is an approximation and usually fractional; it does not assume the two groups share one covariance.
- It is null, with a warning, when the fields’ covariance is singular, for example when one field is constant or is the sum of others.
p_value
Follows the shared reading: see Reading a p-value.
details.f
T² rescaled to an F statistic on details.df1 and details.df2 degrees of freedom; the p-value is read from this F.
TEST_KENDALL_TAU
Measures how often pairs of rows agree in order on two numeric fields: a rank-based link suited to small samples and ties. See its catalog entry.
statistic
Kendall’s tau-b, from -1 to +1: the share of row pairs that are ordered the same way on both fields minus the share ordered the opposite way, adjusted for ties.
Sign:
+: the two fields tend to rise together-: one field tends to fall as the other rises
Caveats:
- Correlation is not causation: a third factor may drive both fields.
- There are no sourced bands for tau, so none are attached; tau usually comes out smaller than Pearson’s r or Spearman’s rho for the same data, so do not read it on their scale.
p_value
Follows the shared reading: see Reading a p-value.
TEST_KRUSKAL_WALLIS
Rank-based check of whether values tend to be larger in some groups than in others, across two or more groups. See its catalog entry.
statistic
H measures how far each group’s average rank sits from the overall average rank, corrected for ties; larger values are stronger evidence that some groups tend to have higher values than others; the p-value says whether this H is large enough to be surprising.
Caveats:
- It says whether some groups tend to be higher, not which ones: follow up with pairwise rank tests adjusted for multiple comparisons.
- The p-value reads H against a chi-square distribution with groups minus 1 degrees of freedom, which needs roughly five or more rows per group.
p_value
Follows the shared reading: see Reading a p-value.
details.effect_size.epsilon_squared
The share of variation in the ranks that group membership accounts for, from 0 to 1.
Caveats:
- There are no sourced bands for rank-based epsilon squared, so none are attached; Cohen’s eta-squared benchmarks were set for raw-value variance.
- It is computed on ranks, so it describes how well groups order the values, not the share of variation in the raw values.
- Left out when every value is tied.
TEST_KS
Checks whether a numeric field’s values follow the same distribution in two groups, comparing their whole shape. See its catalog entry.
statistic
D is the largest vertical gap between the two groups’ cumulative distributions, from 0 (identical) to 1 (no overlap at all).
Caveats:
- D is most sensitive to differences near the middle of the distributions and less to differences in the tails.
- D shows that the shapes differ, not how: plot both distributions to see whether it is the centre, the spread or the tails.
p_value
Follows the shared reading: see Reading a p-value.
TEST_MANN_WHITNEY_U
Rank-based check of whether values in one of two groups tend to be larger than in the other. See its catalog entry.
statistic
U counts, over every pair of rows from the two groups, how often one group’s value is larger (ties count half). Pulse reports the smaller of the two counts, from 0 to half of n_A times n_B: the smaller U, the more the groups separate.
Caveats:
- U carries no direction; read details.z or the rank-biserial for which group tends to be larger.
- R’s wilcox.test reports the first group’s count instead of the smaller one, so the two can disagree while the p-values match.
p_value
Follows the shared reading: see Reading a p-value.
details.z
The normal score the p-value is read from, with a continuity correction of one half.
Sign:
+: the first group in details.groups (alphabetical order) is higher-: the second group in details.groups (alphabetical order) is higher
Caveats:
- The p-value always uses this large-sample approximation, even for small groups where an exact test would be more accurate.
details.effect_size.rank_biserial
Rank-biserial correlation, from -1 to +1: the chance that a random row from the first group beats one from the second, minus the reverse.
Sign:
+: the first group in details.groups (alphabetical order) is higher-: the second group in details.groups (alphabetical order) is higher
Caveats:
- There are no sourced bands for rank-biserial r that Pulse could verify, so none are attached; (r + 1) / 2 is the probability that a first-group row is larger, ties counting half.
- It measures how often one group’s values exceed the other’s, not a difference in medians.
TEST_MANOVA
Checks whether several numeric fields, taken together as one profile, differ across two or more groups. See its catalog entry.
statistic
Pillai’s trace, between 0 and the smaller of the field count and the group count minus one, sums how much of the fields’ joint spread lies between the group average profiles; larger values are stronger evidence that the profiles differ; the p-value says whether it is large enough to be surprising.
Caveats:
- It says whether some groups’ profiles differ as a whole, not which groups or which fields: compare the per-group averages in details.means.
- It is null, with a warning, when the fields’ within-group covariance is singular, for example when one field is constant inside every group or is the sum of others.
p_value
Follows the shared reading: see Reading a p-value.
details.f
Pillai’s trace rescaled to an approximate F on details.df1 and details.df2 degrees of freedom; the p-value is read from this F.
details.wilks
Wilks’ lambda, between 0 and 1, the share of the fields’ joint spread left inside groups; smaller is stronger evidence, with its own f, df1, df2 and p_value.
details.hotelling_lawley
The Hotelling–Lawley trace, the between-group spread measured against the within-group spread; larger is stronger evidence, with its own f, df1, df2 and p_value.
details.roy
Roy’s largest root, the separation along the single best direction through the fields, with its own f, df1, df2 and p_value.
Caveats:
- Its F is an upper bound (upper_bound is true), so its p-value is a lower bound and overstates the evidence; read it beside Pillai’s.
TEST_MARDIA
Checks whether a set of numeric fields looks jointly normal, through their combined skew and tail weight. See its catalog entry.
statistic
The skewness chi-square (default) or the kurtosis z named by variant; a small p-value says the fields are not jointly normal.
Caveats:
- A large p only means no departure was detected, which is likely with few rows.
- It is null, with a warning, when there are no more complete rows than fields or a field is constant or the sum of others.
p_value
Follows the shared reading: see Reading a p-value.
details.skewness
Mardia’s multivariate skewness, 0 for jointly normal data; it grows as the joint shape leans to one side.
details.kurtosis
Mardia’s multivariate kurtosis, p(p + 2) for p jointly normal fields; above that the tails are heavier than normal, below it lighter.
TEST_PAIRED_T
Checks whether the average change between two measurements of the same rows, such as before and after, differs from zero. See its catalog entry.
statistic
t is the average of the per-row differences (Field - Field2) measured in standard errors of that average. Values far from 0 in either direction are unlikely if the true average difference is zero.
Sign:
+: Field is larger than Field2 on average-: Field2 is larger than Field on average
p_value
Follows the shared reading: see Reading a p-value.
details.mean_diff
The average of Field - Field2 over complete pairs, in the field’s own units.
Sign:
+: Field is larger than Field2 on average-: Field2 is larger than Field on average
details.ci_low
Lower end of the confidence interval for the average difference (Field - Field2) at the 1 - alpha level (95% by default).
Caveats:
- An interval that excludes 0 matches a p-value below alpha.
details.ci_high
Upper end of the confidence interval for the average difference (Field - Field2).
details.effect_size.cohens_d
d_z: the average difference divided by the standard deviation of the differences (not the d_av of the two fields’ own spreads).
Sign:
+: Field is larger than Field2 on average-: Field2 is larger than Field on average
Caveats:
- No sourced bands apply to d_z: Cohen’s 0.2 / 0.5 / 0.8 were set for gaps between independent groups. d_z = d / sqrt(2(1 - r)), so it reads larger than a between-group d when Field and Field2 correlate above 0.5 and smaller below it.
- Do not compare d_z directly with a two-group d; report the mean difference alongside it.
- When the standard deviation is zero, d is undefined and the key is left out rather than reported as 0.
TEST_PEARSON_R
Measures how strongly two numeric fields rise and fall together along a straight line. See its catalog entry.
statistic
r measures how closely the two fields follow a straight line together, from -1 to +1; 0 means no straight-line link.
Bands (convention: Cohen (1988); a labelled convention, not a rule):
| Absolute value | Label |
|---|---|
| below 0.1 | very small |
| from 0.1 to below 0.3 | small |
| from 0.3 to below 0.5 | medium |
| 0.5 and above | large |
Sign:
+: the two fields tend to rise together-: one field tends to fall as the other rises
Caveats:
- Correlation is not causation: a third factor may drive both fields.
- An r near zero rules out only a straight-line link; a curved relationship can still be strong.
- A few extreme rows, or a narrow range of either field, can move r a lot.
p_value
Follows the shared reading: see Reading a p-value.
details.ci_low
Lower end of the confidence interval for r at the 1 - alpha level (95% by default).
Caveats:
- With fewer than four pairs, or a perfect r, no interval can be computed and both ends are set to r: this is not a precise estimate. Treat r as highly uncertain and do not report the interval.
details.ci_high
Upper end of the confidence interval for r at the 1 - alpha level (95% by default).
TEST_PROP_Z
Checks whether the rate of one outcome, such as conversion, differs between two groups. See its catalog entry.
statistic
z: the first group’s success rate minus the second’s, in standard errors computed from the pooled rate. Values far from 0 in either direction are unlikely if the true rates are equal.
Sign:
+: the first group in details.groups (alphabetical order) is higher-: the second group in details.groups (alphabetical order) is higher
Caveats:
- The normal approximation needs at least 10 successes and 10 failures in each group.
p_value
Follows the shared reading: see Reading a p-value.
details.diff
The first group’s success rate minus the second’s, as a proportion (0.05 = 5 percentage points).
Sign:
+: the first group in details.groups (alphabetical order) is higher-: the second group in details.groups (alphabetical order) is higher
details.ci_low
Lower end of the confidence interval for details.diff at the 1 - alpha level (95% by default).
Caveats:
- The interval uses the unpooled (Wald) standard error while the test uses the pooled one, so near the alpha boundary the two can disagree.
- The Wald interval is poor with few successes or failures, or rates near 0 or 1.
details.ci_high
Upper end of the confidence interval for details.diff.
details.effect_size.cohens_h
Cohen’s h, the gap between the two rates on an arcsine scale that treats a change from 0.01 to 0.05 as larger than one from 0.45 to 0.49.
Bands (convention: Cohen (1988); a labelled convention, not a rule):
| Absolute value | Label |
|---|---|
| below 0.2 | very small |
| from 0.2 to below 0.5 | small |
| from 0.5 to below 0.8 | medium |
| 0.8 and above | large |
Sign:
+: the first group in details.groups (alphabetical order) is higher-: the second group in details.groups (alphabetical order) is higher
Caveats:
- Cohen’s 0.2 / 0.5 / 0.8 benchmarks for h are the same as for d, applied to |h|.
TEST_SHAPIRO_WILK
Checks whether a numeric field looks normally distributed, overall or within each group. See its catalog entry.
statistic
W’ (Shapiro-Francia) measures how closely the sorted values follow the straight line expected of normal data, up to 1; values clearly below 1 point to skew, heavy tails or other departures from normal.
Caveats:
- With SplitBy, the headline W’ and p-value belong to the group with the smallest p; read details.per_group for every group.
p_value
Caveats:
- A small p-value is evidence against normality; a large one only means a departure was not detected, which is likely with few rows.
- With SplitBy the headline is the smallest p across groups, so it is more likely to be small than any single group’s p.
Follows the shared reading: see Reading a p-value.
details.per_group
One entry per group (or one overall without SplitBy) with n, w, z and p_value, plus a warning when the p-value is advisory (fewer than 5 or more than 5000 rows, or a degenerate sample).
TEST_SPEARMAN_R
Measures how consistently one numeric field rises (or falls) as the other rises, using ranks, so the link need not be a straight line. See its catalog entry.
statistic
Spearman’s rho is Pearson’s r computed on ranks, from -1 to +1: how consistently one field rises (or falls) as the other rises, whether or not in a straight line.
Sign:
+: the two fields tend to rise together-: one field tends to fall as the other rises
Caveats:
- Correlation is not causation: a third factor may drive both fields.
- There are no sourced bands for rho, so none are attached: Cohen’s r benchmarks were set for Pearson’s r.
- The p-value uses a t approximation with n - 2 degrees of freedom; many ties make it unreliable (Pulse warns).
p_value
Follows the shared reading: see Reading a p-value.
TEST_T
Checks whether a numeric field’s average differs from a target value, or between two groups (Welch’s version). See its catalog entry.
statistic
t is the gap between averages measured in standard errors: the sample mean minus the target value (one-sample), or the first group’s mean minus the second’s (two-sample, Welch). Values far from 0 in either direction are unlikely if the true gap is zero.
Sign:
+: the sample mean is above the target, or the first group in details.groups has the higher mean-: the sample mean is below the target, or the second group in details.groups has the higher mean
Caveats:
- The two-sample form is Welch’s: degrees of freedom (df) are adjusted for unequal variances and are usually not a whole number.
p_value
Follows the shared reading: see Reading a p-value.
details.diff
Two-sample only: the first group’s mean minus the second’s, in the field’s own units.
Sign:
+: the first group in details.groups (alphabetical order) is higher-: the second group in details.groups (alphabetical order) is higher
details.ci_low
Lower end of the confidence interval at the 1 - alpha level (95% by default): for the mean itself in the one-sample form, for the difference in means (details.diff) in the two-sample form.
Caveats:
- In the one-sample form the interval is around the mean, not around mean minus target: compare it with the target value directly.
details.ci_high
Upper end of the same confidence interval: for the mean (one-sample) or for the difference in means (two-sample).
details.effect_size.cohens_d
The gap in standard-deviation units: (mean - target) / SD for one sample, (first mean - second mean) / pooled SD for two groups. 0.5 means the averages sit half a standard deviation apart.
Bands (convention: Cohen (1988); a labelled convention, not a rule):
| Absolute value | Label |
|---|---|
| below 0.2 | very small |
| from 0.2 to below 0.5 | small |
| from 0.5 to below 0.8 | medium |
| 0.8 and above | large |
Sign:
+: the mean is above the target, or the first group in details.groups is higher-: the mean is below the target, or the second group in details.groups is higher
Caveats:
- This d divides by the pooled standard deviation of the two groups, even though the test itself does not assume equal variances; when spreads differ a lot, d describes neither group exactly.
- When the standard deviation is zero, d is undefined and the key is left out rather than reported as 0.
TEST_TREND
Checks whether an ordered series, such as monthly totals, tends to keep rising or keep falling. See its catalog entry.
statistic
The Mann-Kendall Z score: the trend count S (details.s) in standard errors, with a continuity correction. Values far from 0 point to a consistent tendency to rise or fall (not necessarily at an even rate).
Sign:
+: the series tends to rise over the OrderBy sequence-: the series tends to fall over the OrderBy sequence
p_value
Follows the shared reading: see Reading a p-value.
details.s
S counts every pair of points: +1 when the later point is higher, -1 when it is lower, 0 when tied.
Sign:
+: more later points are higher than earlier ones-: more later points are lower than earlier ones
details.tau
S as a share of all pairs of points, from -1 (always falling) to +1 (always rising): how consistent the trend is, not how steep.
Sign:
+: a rising trend-: a falling trend
Caveats:
- Tied points count as neither rising nor falling, so many ties pull tau toward 0.
TEST_TUKEY_HSD
After an ANOVA, compares every pair of group averages to find which ones differ, keeping the overall false-alarm rate in check. See its catalog entry.
statistic
Not used: TEST_TUKEY_HSD reports no single statistic and leaves this slot at 0. Each pair’s studentized range q is in details.comparisons.
p_value
Caveats:
- This is the smallest Tukey-adjusted p-value across all pairs, already corrected for multiple comparisons; read details.comparisons for which pairs it belongs to.
Follows the shared reading: see Reading a p-value.
details.comparisons
One entry per pair of groups (a, b in alphabetical order): diff is mean a minus mean b, q the studentized range, p_adj the family-wise adjusted p-value, and ci_low / ci_high a simultaneous confidence interval for diff.
Caveats:
- p_adj and the intervals already allow for the many comparisons made, so do not adjust them again.
- An interval that excludes 0 matches p_adj below alpha.
TEST_WELCH
Checks whether the average of a numeric field differs between two groups, without assuming they have equal spread. See its catalog entry.
statistic
Welch’s t: the first group’s mean minus the second’s, in standard errors computed from each group’s own variance. Values far from 0 in either direction are unlikely if the true gap is zero.
Sign:
+: the first group in details.groups (alphabetical order) is higher-: the second group in details.groups (alphabetical order) is higher
Caveats:
- Degrees of freedom (df) come from the Welch-Satterthwaite formula and are usually not a whole number.
p_value
Follows the shared reading: see Reading a p-value.
details.diff
The first group’s mean minus the second’s, in the field’s own units.
Sign:
+: the first group in details.groups (alphabetical order) is higher-: the second group in details.groups (alphabetical order) is higher
details.ci_low
Lower end of the Interval for the difference between the two group averages (first minus second) at the 1 - alpha level (95% by default).
Caveats:
- An interval that excludes 0 matches a p-value below alpha; its width shows how precisely the difference is pinned down.
details.ci_high
Upper end of the Interval for the difference between the two group averages (first minus second) at the 1 - alpha level (95% by default).
details.effect_size.cohens_d
The gap between the two means in pooled-standard-deviation units; 0.5 means they sit half a standard deviation apart.
Bands (convention: Cohen (1988); a labelled convention, not a rule):
| Absolute value | Label |
|---|---|
| below 0.2 | very small |
| from 0.2 to below 0.5 | small |
| from 0.5 to below 0.8 | medium |
| 0.8 and above | large |
Sign:
+: the first group in details.groups has the higher average-: the second group in details.groups has the higher average
Caveats:
- This d divides by the pooled standard deviation of the two groups, even though the test itself does not assume equal variances; when spreads differ a lot, d describes neither group exactly.
- When the standard deviation is zero, d is undefined and the key is left out rather than reported as 0.
TEST_WILCOXON_SR
Rank-based check of whether paired before/after values tend to shift in one direction, without assuming a bell curve. See its catalog entry.
statistic
The smaller of the two signed-rank sums: rank the non-zero differences (Field - Field2) by size, add the ranks of the positive ones and of the negative ones, and keep the smaller. The smaller it is, the more one direction dominates.
Caveats:
- It carries no direction; read details.z or the rank-biserial for which field tends to be larger.
p_value
Follows the shared reading: see Reading a p-value.
details.z
The normal score the p-value is read from, with a continuity correction of one half.
Sign:
+: Field tends to be larger than Field2-: Field2 tends to be larger than Field
Caveats:
- The p-value always uses this large-sample approximation, even for few pairs where an exact test would be more accurate.
details.zero_diffs
How many pairs had Field equal to Field2; they are dropped before ranking, and details.n counts only the pairs that remain.
Caveats:
- Many zero differences shrink the test to the pairs that changed; report how many were set aside.
details.effect_size.rank_biserial
Matched-pairs rank-biserial correlation, from -1 to +1: the rank mass of positive differences minus that of negative ones, as a share of the total.
Sign:
+: Field tends to be larger than Field2-: Field2 tends to be larger than Field
Caveats:
- There are no sourced bands for rank-biserial r that Pulse could verify, so none are attached.
- Computed over non-zero differences only, matching the test.
TEST_Z_TWO_SAMPLE
Large-sample check of whether two groups differ in average, with the p-value read from the normal distribution. See its catalog entry.
statistic
z: the first group’s mean minus the second’s, in standard errors built from each group’s sample variance, read against the normal distribution. Values far from 0 in either direction are unlikely if the true gap is zero.
Sign:
+: the first group in details.groups (alphabetical order) is higher-: the second group in details.groups (alphabetical order) is higher
Caveats:
- The normal reference is only accurate for large groups; with small groups the p-value is too small, so prefer TEST_WELCH.
p_value
Follows the shared reading: see Reading a p-value.
details.diff
The first group’s mean minus the second’s, in the field’s own units.
Sign:
+: the first group in details.groups (alphabetical order) is higher-: the second group in details.groups (alphabetical order) is higher
details.ci_low
Lower end of the normal-theory Interval for the difference between the two group averages (first minus second) at the 1 - alpha level (95% by default).
Caveats:
- An interval that excludes 0 matches a p-value below alpha; its width shows how precisely the difference is pinned down.
details.ci_high
Upper end of the normal-theory Interval for the difference between the two group averages (first minus second) at the 1 - alpha level (95% by default).
details.effect_size.cohens_d
The gap between the two means in pooled-standard-deviation units; 0.5 means they sit half a standard deviation apart.
Bands (convention: Cohen (1988); a labelled convention, not a rule):
| Absolute value | Label |
|---|---|
| below 0.2 | very small |
| from 0.2 to below 0.5 | small |
| from 0.5 to below 0.8 | medium |
| 0.8 and above | large |
Sign:
+: the first group in details.groups has the higher average-: the second group in details.groups has the higher average
Caveats:
- This d divides by the pooled standard deviation of the two groups, even though the test itself does not assume equal variances; when spreads differ a lot, d describes neither group exactly.
- When the standard deviation is zero, d is undefined and the key is left out rather than reported as 0.
Reading regression results
How to read each output field of the regressions this instance offers. Each operator lists its output fields: what the value means, the labelled bands of a published convention where one applies, what its sign says, and the caveats to keep in mind.
Regressions
REG_BAYES_LINEAR
Fits a straight-line model of a numeric outcome under a prior, reporting each coefficient’s posterior mean and credible interval. See its catalog entry.
coefficients.*
Posterior mean of the coefficient: the expected difference in the outcome between rows one unit apart on this predictor, holding the other predictors fixed, after combining the data with the prior.
Sign:
+: higher values of the predictor are associated with a higher outcome, other predictors held fixed-: higher values of the predictor are associated with a lower outcome, other predictors held fixed
Caveats:
- Association, not causation: the coefficient shows how the outcome moves with this predictor in these rows, holding the other predictors fixed; a causal reading needs a design that supports it, and a predictor left out of the model can drive both.
- With the default weak prior and enough rows it is close to the REG_OLS coefficient; a stronger prior pulls it toward prior_mu (0 by default).
- Its size depends on the predictor’s units, so do not rank predictors by raw coefficients unless they share units; standardize the predictors first to compare them.
- (intercept) is the prediction when every predictor is 0, which may lie outside the data and mean nothing.
std_errors.*
Scale of the coefficient’s Student-t posterior: how uncertain the coefficient is given the data and the prior. With many rows it is close to the posterior standard deviation.
Caveats:
- It is a posterior spread, not a frequentist standard error, and no p-value is built from it.
- Under a robust covariance (hc0-hc3, cluster, hac) it is instead the scale of Muller’s sandwich posterior, a normal centred on the same posterior mean whose spread is the sandwich, not the model’s: the posterior covariance is REPLACED, so the model’s own uncertainty no longer enters.
credible_intervals.*
[lower, upper] equal-tailed posterior interval at credible_level (95% by default): given the prior and the model, the coefficient lies in this range with that probability.
Caveats:
- It is a probability statement about the coefficient conditional on the prior and the model, not a statement about repeated samples; if either is wrong, so is the probability.
- With the default weak prior and many rows per predictor it is close to the OLS confidence interval. With few rows it is narrower, because the default variance prior divides by about n rather than n - p - 1 (about 19% narrower at 20 rows and 5 predictors). With an informative prior it also reflects that prior.
- An interval that excludes 0 is not a significance test; report the interval itself.
- Under a robust covariance it is the sandwich posterior’s normal interval (no df), a substitute for the posterior: honest about heteroskedastic or dependent rows, but no longer the conjugate model’s probability statement.
ci_lower.*
Lower bound of the coefficient’s two-sided confidence interval at conf_level: the equal-tailed posterior interval on the coefficient’s Student-t posterior with df degrees of freedom, as in credible_intervals.
Caveats:
- With conf_level unset it is built at credible_level, so it equals credible_intervals; it reads like them, as a probability given the prior and the model.
ci_upper.*
Upper bound of the coefficient’s two-sided confidence interval at conf_level: the equal-tailed posterior interval on the coefficient’s Student-t posterior with df degrees of freedom, as in credible_intervals.
Caveats:
- With conf_level unset it is built at credible_level, so it equals credible_intervals; it reads like them, as a probability given the prior and the model.
conf_level
The coverage ci_lower and ci_upper were built at (the request’s conf_level, 0.95 by default).
df
Degrees of freedom of the coefficients’ Student-t posterior, 2 * a_n, which the interval’s critical value reads.
r2
R-squared of the posterior-mean fit: the share of the outcome’s variation the posterior-mean coefficients account for in these rows.
Caveats:
- It is computed from the posterior-mean coefficients, not averaged over the posterior, so it is not a Bayesian R-squared.
- A strong prior pulls the fit away from least squares, so it can sit below the REG_OLS value and even below 0; there are no published bands for it, so none are attached.
adj_r2
The posterior-mean fit’s R-squared corrected for the number of predictors: 1 - (1 - r2) * (n - 1) / (n - p - 1).
Caveats:
- It can fall below 0 when the predictors fit no better than chance would.
- There are no published bands for adjusted R-squared, so none are attached: Cohen’s R-squared benchmarks are stated for R-squared itself.
residual_std_err
Typical size of a residual, in the outcome’s units: how far actual values sit from the fitted line on a typical row. Here it is sqrt(b_n / a_n) from the posterior on the variance, combining the data with the prior on it.
Caveats:
- Under the default prior that is about sqrt(RSS / n), so it runs below REG_OLS’s value (which divides by n - p - 1), most visibly with few rows per predictor; do not read the gap as a better fit.
n_obs
Rows used in the fit, after rows with a null target or predictor were dropped.
Caveats:
- Few rows per predictor make every estimate unstable and invite overfitting.
- Under a row weight it stays the row count: the fit’s sample size is sum_weights (frequency weights) or n_eff, Kish’s effective sample size (probability weights), and the standard errors and residual degrees of freedom read that, not n_obs.
REG_GLM
Fits a generalized linear model: logistic regression for yes/no outcomes, Poisson for counts, gamma for positive skewed amounts. See its catalog entry.
coefficients.*
The change in the linear predictor, on the link scale, for a one-unit increase in this predictor, holding the other predictors fixed: log-odds for binomial (logit link), the log of the expected count for Poisson (log link), the inverse of the expected value for gamma (inverse link).
Sign:
+: higher predictor values are associated with a higher probability (binomial) or expected count (Poisson); under gamma’s inverse link, with a LOWER expected value-: higher predictor values are associated with a lower probability (binomial) or expected count (Poisson); under gamma’s inverse link, with a HIGHER expected value
Caveats:
- Raised to the power e, a binomial coefficient is an odds ratio and a Poisson one a rate ratio per one-unit increase: multiplicative, not additive. An odds ratio is not a ratio of probabilities.
- Association, not causation: the coefficient shows how the outcome moves with this predictor in these rows, holding the other predictors fixed; a causal reading needs a design that supports it, and a predictor left out of the model can drive both.
- Its size depends on the predictor’s units, so do not rank predictors by raw coefficients unless they share units; standardize the predictors first to compare them.
- With strongly related predictors (multicollinearity) a coefficient can swing, or even flip sign, between fits on similar data.
- A very large coefficient with a very large standard error in a logistic model is a sign of near-separation; do not read it as a strong association.
std_errors.*
How much the coefficient would vary from sample to sample, from the inverse of the fitted information matrix, with the dispersion fixed at 1.
Caveats:
- With overdispersion (counts varying more than their mean) the Poisson standard errors are too small.
- Gamma data rarely have dispersion 1, so gamma standard errors are unreliable.
- Unless the request’s covariance names a robust one: hc0-hc3 (sandwich over the fit’s scores, which also absorbs overdispersion), cluster (rows grouped by a field) or hac (a series in time); p_values, the intervals and vcov follow it, still Wald z.
p_values.*
Caveats:
- Wald z at dispersion fixed to 1: with overdispersed counts the Poisson p-values come out too small, and gamma p-values are unreliable.
Follows the shared reading: see Reading a p-value.
ci_lower.*
Lower bound of the coefficient’s two-sided confidence interval at conf_level: the coefficient minus (lower) or plus (upper) a standard normal critical value times its standard error (a Wald interval), on the link scale.
Caveats:
- It is a statement about the method, not this one range: intervals built this way cover the true coefficient in about conf_level of repeated samples, if the model’s assumptions hold.
- Each interval covers one coefficient on its own; several intervals read together do not jointly cover at conf_level.
- Wald at dispersion fixed to 1: too narrow with overdispersed counts, unreliable for gamma, and poor near separation in a logistic model.
- Raised to the power e, the bounds of a binomial or Poisson coefficient bound its odds ratio or rate ratio.
ci_upper.*
Upper bound of the coefficient’s two-sided confidence interval at conf_level: the coefficient minus (lower) or plus (upper) a standard normal critical value times its standard error (a Wald interval), on the link scale.
Caveats:
- It is a statement about the method, not this one range: intervals built this way cover the true coefficient in about conf_level of repeated samples, if the model’s assumptions hold.
- Each interval covers one coefficient on its own; several intervals read together do not jointly cover at conf_level.
- Wald at dispersion fixed to 1: too narrow with overdispersed counts, unreliable for gamma, and poor near separation in a logistic model.
- Raised to the power e, the bounds of a binomial or Poisson coefficient bound its odds ratio or rate ratio.
conf_level
The coverage ci_lower and ci_upper were built at (the request’s conf_level, 0.95 by default).
deviance
How far the fitted predictions sit from the observed outcomes, as twice the log-likelihood gap to a model that fits every row exactly; lower is closer.
Caveats:
- It has no fixed scale: compare it only with null_deviance or with another model fitted to the same rows and outcome.
- For a 0/1 outcome the deviance alone does not show whether the model fits.
null_deviance
The deviance of the intercept-only model, which predicts the overall mean for every row: the baseline the predictors are judged against.
pseudo_r2
1 - deviance / null_deviance: the share of the intercept-only model’s deviance the predictors remove, from 0 to 1. For a 0/1 binomial outcome this equals McFadden’s pseudo-R-squared.
Caveats:
- It is not a share of variance explained and runs much lower than an OLS R-squared for an equally useful model, so OLS rules of thumb do not apply; there are no published bands for it, so none are attached.
- Compare it only across models of the same outcome and family.
n_obs
Rows used in the fit, after rows with a null target or predictor were dropped.
Caveats:
- Few rows per predictor make every estimate unstable and invite overfitting.
- Under a row weight it stays the row count: the fit’s sample size is sum_weights (frequency weights) or n_eff, Kish’s effective sample size (probability weights), and the standard errors and residual degrees of freedom read that, not n_obs.
converged_iters
Iteratively reweighted least squares steps taken before the coefficients stopped changing (relative change below tol).
Caveats:
- A fit that cannot converge within max_iters is refused rather than reported.
REG_OLS
Fits a straight-line model that predicts a numeric outcome from one or more numeric predictors, by least squares. See its catalog entry.
coefficients.*
The difference in the outcome’s expected value between rows one unit apart on this predictor, holding the other predictors fixed, in outcome units per predictor unit (an association, not the effect of changing it).
Sign:
+: higher values of the predictor are associated with a higher outcome, other predictors held fixed-: higher values of the predictor are associated with a lower outcome, other predictors held fixed
Caveats:
- Association, not causation: the coefficient shows how the outcome moves with this predictor in these rows, holding the other predictors fixed; a causal reading needs a design that supports it, and a predictor left out of the model can drive both.
- Its size depends on the predictor’s units, so do not rank predictors by raw coefficients unless they share units; standardize the predictors first to compare them.
- With strongly related predictors (multicollinearity) a coefficient can swing, or even flip sign, between fits on similar data.
- (intercept) is the prediction when every predictor is 0, which may lie outside the data and mean nothing.
- Penalized fits (l1, l2, elasticnet) shrink coefficients toward 0 on purpose; an l1 coefficient shrunk to 0 means the penalty dropped it, not that the predictor is unrelated to the outcome.
std_errors.*
How much the coefficient would vary from sample to sample; smaller means a more precise estimate. It is the classic standard error, which assumes residuals of constant spread, unless the request’s covariance names a robust one.
Caveats:
- Classic: with heteroscedasticity (residual spread that changes) it can be too small or too large, and with dependent rows it is usually too small; covariance hc3 guards the first, cluster (rows grouped by a field) or hac (a series in time) the second, and p_values, the intervals and vcov follow it.
- For l1 and elasticnet fits it is a plug-in approximation over the kept predictors (Pulse warns); request resample for an empirical one.
p_values.*
Caveats:
- The request’s alpha param is the penalty strength for l1 / l2 / elasticnet fits, not a significance level; Pulse applies no significance threshold to these p-values.
- On a penalized fit (l1, l2, elasticnet) the coefficients are shrunk on purpose, so these p-values do not have the usual reading; l1 and elasticnet ones are approximate.
Follows the shared reading: see Reading a p-value.
ci_lower.*
Lower bound of the coefficient’s two-sided confidence interval at conf_level: the coefficient minus (lower) or plus (upper) a Student-t critical value on df times its standard error.
Caveats:
- It is a statement about the method, not this one range: intervals built this way cover the true coefficient in about conf_level of repeated samples, if the model’s assumptions hold.
- Each interval covers one coefficient on its own; several intervals read together do not jointly cover at conf_level.
- Under resample the critical value is the normal one and the standard error the resampled one; df is then absent.
- On a penalized fit the interval is centred on a shrunken coefficient, so it does not have the usual coverage; l1 and elasticnet ones exist only for the kept predictors and are approximate.
ci_upper.*
Upper bound of the coefficient’s two-sided confidence interval at conf_level: the coefficient minus (lower) or plus (upper) a Student-t critical value on df times its standard error.
Caveats:
- It is a statement about the method, not this one range: intervals built this way cover the true coefficient in about conf_level of repeated samples, if the model’s assumptions hold.
- Each interval covers one coefficient on its own; several intervals read together do not jointly cover at conf_level.
- Under resample the critical value is the normal one and the standard error the resampled one; df is then absent.
- On a penalized fit the interval is centred on a shrunken coefficient, so it does not have the usual coverage; l1 and elasticnet ones exist only for the kept predictors and are approximate.
conf_level
The coverage ci_lower and ci_upper were built at (the request’s conf_level, 0.95 by default).
df
Residual degrees of freedom the intervals’ critical value reads: n - p - 1, with sum_weights or n_eff in place of n on a weighted fit, so it can be fractional; under covariance cluster, the number of clusters minus 1.
Caveats:
- Null when it is 0 or below: too few rows (or too small an effective sample) for the predictors, and every bound is then null too.
r2
R-squared: the share of the outcome’s variation around its mean that the fitted model accounts for in these rows, from 0 to 1.
Bands (convention: Cohen (1988) f-squared benchmarks converted via R2 = f2/(1+f2); a labelled convention, not a rule):
| Value | Label |
|---|---|
| below 0.02 | very small |
| from 0.02 to below 0.13 | small |
| from 0.13 to below 0.26 | medium |
| 0.26 and above | large |
Caveats:
- It never falls when a predictor is added, even a useless one; compare models of different sizes with adj_r2.
- It is measured on the rows the model was fitted to, so it overstates how well the model will predict new rows.
- The bands are Cohen’s benchmarks for a population R-squared, set for behavioural-science research as a last resort; what counts as a good fit depends on the field and the outcome.
- A high value does not mean the model is right or that the predictors cause the outcome.
adj_r2
R-squared corrected for the number of predictors: 1 - (1 - r2) * (n - 1) / (n - p - 1), for p predictors.
Caveats:
- It can fall below 0 when the predictors fit no better than chance would.
- There are no published bands for adjusted R-squared, so none are attached: Cohen’s R-squared benchmarks are stated for R-squared itself.
residual_std_err
Typical size of a residual, in the outcome’s units: how far actual values sit from the fitted line on a typical row. It divides the residual sum of squares by n - p - 1.
n_obs
Rows used in the fit, after rows with a null target or predictor were dropped.
Caveats:
- Few rows per predictor make every estimate unstable and invite overfitting.
- Under a row weight it stays the row count: the fit’s sample size is sum_weights (frequency weights) or n_eff, Kish’s effective sample size (probability weights), and the standard errors and residual degrees of freedom read that, not n_obs.
converged_iters
Coordinate-descent iterations an l1 or elasticnet fit took to converge; absent for fits solved in closed form.
Caveats:
- A value at max_iters suggests the fit stopped before settling; raise max_iters or tol.
Reading matrix results
How to read the standardised results of the matrix operators this instance offers. Each operator lists its output fields: what the value means, the labelled bands of a published convention where one applies, what its sign says, and the caveats to keep in mind.
Matrix operators
MAT_COLLINEARITY
Whether candidate predictors overlap so much that a regression on them would give unstable coefficients, and which ones are tangled. See its catalog entry.
scalars.max_vif
The largest variance inflation factor among the predictors (each one’s is also listed): how many times larger that predictor’s coefficient variance is than it would be if it were unrelated to the other predictors. 1 means no overlap; tolerance, its reciprocal, is the share of the predictor’s spread the others do not explain.
Caveats:
- No convention bands it. Cut-offs of 5 or 10 are rules of thumb only: O’Brien (2007) shows they are arbitrary, and a large sample can make a high value harmless while a small one can make a modest value matter.
- It depends only on the predictors, not on any outcome; it says how unstable coefficients would be, not whether a predictor matters.
- Under params.repair it is read off the nearest consistent correlation table, not the observed one.
scalars.condition_number
The largest of Belsley’s condition indices: how close the predictors (with the intercept, unless params.center is true) come to an exact straight-line dependency. 1 means none; large values mean some combination of predictors nearly cancels out.
Caveats:
- No convention bands it. Belsley, Kuh and Welsch’s guideline (an index above about 30 where two or more variables put a large share of their variance on that dimension) is a rule of thumb; read the variance-decomposition proportions to see which variables are involved.
- With the intercept included (the default) a predictor whose values sit far from zero relative to their spread raises it even when the predictors are unrelated; params.center true removes that part.
primary.values
The predictors’ correlation table that the variance inflation factors and the centered diagnostics are read from: each off-diagonal cell is the correlation of its row and column predictors, from -1 to +1.
Sign:
+: the two fields tend to rise together-: one field tends to fall as the other rises
Caveats:
- A predictor can be heavily inflated with no single large pairwise correlation, when it is close to a combination of several others; read the variance inflation factors, not just this table.
MAT_CORRELATION
How closely every pair of numeric fields moves together, in a line or in rank order, as one square table of values from -1 to 1. See its catalog entry.
primary.values
Each off-diagonal cell is the correlation of its row and column members under params.method, from -1 to +1. pearson (default): r, how closely the two follow a straight line together; spearman: rho, r on the members’ ranks, how steadily one rises or falls with the other; kendall: tau-b, the share of agreeing minus disagreeing row pairs, tie-adjusted. 0 means no such link. The diagonal is 1.
Bands (convention: Cohen (1988); a labelled convention, not a rule):
| Absolute value | Label |
|---|---|
| below 0.1 | very small |
| from 0.1 to below 0.3 | small |
| from 0.3 to below 0.5 | medium |
| 0.5 and above | large |
Sign:
+: the two fields tend to rise together-: one field tends to fall as the other rises
Caveats:
- Correlation is not causation: a third factor may drive both fields.
- A null cell means a member had no spread (constant, or too few rows), so its correlation is undefined, not 0.
- A Pearson r near zero rules out only a straight-line link; a curved relationship can still be strong.
- The bands are Cohen’s for r; Kendall’s tau-b runs smaller than r or rho for the same strength (about two thirds of rho), so read a kendall cell against lower cut-offs.
- The matrix reports no p-values; run TEST_PEARSON_R, TEST_SPEARMAN_R or TEST_KENDALL_TAU on a pair to test it.
scalars.determinant
The determinant of the correlation table: 1 when no member is linearly related to the others, falling toward 0 as members become linear combinations of one another.
Caveats:
- Null when the table is not positive definite, including when any cell is null.
- No published convention bands it; a value near 0 flags near-redundant members.
MAT_FACTOR
Which hidden qualities a set of related measures reflects, and how strongly each measure is tied to each, before building scale scores. See its catalog entry.
primary.values
Each cell is a loading: how strongly the row’s measure is tied to the column’s factor. Without a rotation, or with varimax, it is the correlation between the two; under promax or oblimin it is the measure’s own tie to that factor with the other factors held fixed. A factor is named by the measures with the largest loadings.
Sign:
+: the measure rises with the factor-: the measure falls as the factor rises; a factor’s overall sign is a convention (each column is made to sum positive)
Caveats:
- Unrotated loadings put most measures on the first factor; a rotation gives a cleaner pattern without changing how well the factors fit (the communalities).
- Under an oblique rotation the factors correlate (auxiliary.factor_correlation), so loadings on correlated factors overlap.
- A uniqueness at or below 0, or held at the maximum-likelihood floor (a Heywood case, flagged with a warning), means the fit is improper; try fewer factors or drop a near-duplicate measure.
scalars.kmo
The Kaiser-Meyer-Olkin measure of sampling adequacy: how much of the measures’ correlation is shared across the whole set rather than tied to single pairs, from 0 to 1. Higher means a factor analysis is more worthwhile.
Bands (convention: Kaiser (1974); a labelled convention, not a rule):
| Value | Label |
|---|---|
| below 0.5 | unacceptable |
| from 0.5 to below 0.6 | miserable |
| from 0.6 to below 0.7 | mediocre |
| from 0.7 to below 0.8 | middling |
| from 0.8 to below 0.9 | meritorious |
| 0.9 and above | marvelous |
Caveats:
- Null when the correlation table is singular (a measure is an exact combination of others).
scalars.bartlett_p
Follows the shared reading: see Reading a p-value.
scalars.bartlett_chisq
Bartlett’s test statistic: how far the correlation table is from one in which no measure is related to any other. Large values mean the measures are related enough to factor.
Caveats:
- With many rows it is almost always large; read KMO for how worthwhile the analysis is.
scalars.rmsr
The root-mean-square residual: the typical size of the correlations the factors leave unexplained, on the correlation scale. Smaller means the factors reproduce the correlations more closely.
Caveats:
- No convention bands it here; it always shrinks as factors are added, so weigh it against how readable the extra factor is.
scalars.empirical_p
Follows the shared reading: see Reading a p-value.
scalars.empirical_chisq
The empirical chi-square: the sum of the squared leftover correlations times the inference size. It measures the residual size, on df degrees of freedom, and is not a likelihood test of the model.
Caveats:
- It rests on the inference size: the rows (listwise), the summed weight or the effective sample size under a weight, or the smallest pair under pairwise deletion.
- Null p when df is 0 or below: the model then has no spare correlations to test against.
scalars.likelihood_p
Follows the shared reading: see Reading a p-value.
scalars.likelihood_chisq
Maximum likelihood only: the likelihood-ratio chi-square, with Bartlett’s correction, of the k-factor model against a free correlation table, on df degrees of freedom. A small p says the factors leave correlation unexplained.
Caveats:
- With many rows it flags even trivial misfit; read tli and rmsea beside it.
- It assumes the measures are multivariate normal and rests on the inference size (the rows, the summed weight or the effective sample size).
scalars.tli
Maximum likelihood only: the Tucker-Lewis index, how much better the factor model fits per degree of freedom than a model of unrelated measures. Near 1 or above means a close fit; it is not capped at 1.
Caveats:
- No convention bands it here; cut-offs such as .95 come from confirmatory models under specific conditions.
- Null when df is 0 or below.
scalars.rmsea
Maximum likelihood only: the root-mean-square error of approximation, the misfit per degree of freedom after allowing for sampling, as a point estimate. 0 means no misfit beyond chance; smaller is better.
Caveats:
- No convention bands it here; cut-offs such as .05 or .08 are rules of thumb; read rmsea_lower and rmsea_upper for its uncertainty.
- Null when df is 0 or below; with few rows or few df it is unstable.
scalars.rmsea_lower
Maximum likelihood only: the lower bound of the RMSEA confidence interval at conf_level (90% by default, as psych reports it). 0 when even a perfectly fitting model would often give a chi-square this large.
Caveats:
- It inverts the noncentral chi-square and assumes multivariate normal measures; with few rows the interval is wide.
- Null when df is 0 or below, or when no bound exists on the searched range.
scalars.rmsea_upper
Maximum likelihood only: the upper bound of the RMSEA confidence interval at conf_level (90% by default). A small upper bound says the misfit per degree of freedom is plausibly small.
Caveats:
- The bounds divide by the inference size minus one where the point estimate divides by the inference size itself (psych’s rule), so with very few rows the point can sit outside the interval; such a bound is null.
- Null when df is 0 or below.
scalars.bic
Maximum likelihood only: the Bayesian information criterion of the fit, the likelihood chi-square minus df times the log of the inference size. Compare it across factor counts on the same measures: the lower value is preferred.
Caveats:
- Only differences between fits of the same measures and rows mean anything; the value alone does not.
- Null when df is 0 or below.
MAT_PARTIAL_CORRELATION
How closely each pair of numeric fields moves together once other fields are held fixed, as one square table of values from -1 to 1. See its catalog entry.
primary.values
Each off-diagonal cell is the correlation of its row and column members once the held-fixed fields are taken out of both (every other member, or the params.control fields), from -1 to +1. 0 means no straight-line link is left after holding them fixed. The diagonal is 1.
Bands (convention: Cohen (1988); a labelled convention, not a rule):
| Absolute value | Label |
|---|---|
| below 0.1 | very small |
| from 0.1 to below 0.3 | small |
| from 0.3 to below 0.5 | medium |
| 0.5 and above | large |
Sign:
+: the two fields tend to rise together-: one field tends to fall as the other rises
Caveats:
- Correlation is not causation: a third factor may drive both fields.
- A partial r can be much smaller than, or even opposite in sign to, the plain r when the held-fixed fields drive both members; holding fixed a field that is itself an outcome of the pair can also create a link that is not there.
- A null cell means the input had an undefined correlation (a member with no spread, or too few rows), so no cell can be computed.
- Under params.repair the cells come from the nearest consistent correlation table, not the observed one; the PULSE_MATRIX_NOT_PSD warning says how far it moved.
- The matrix reports no p-values.
MAT_PCA
How many underlying dimensions a set of related measures covers and which measures belong to each, so a few summaries can stand in for many. See its catalog entry.
primary.values
Each cell is a loading: how strongly the row’s measure is tied to the column’s component, the correlation between the two on a correlation input. A component is named by the measures with the largest loadings; components are listed from the one that summarises the most spread down.
Sign:
+: the measure rises with the component-: the measure falls as the component rises; a component’s overall sign is a convention (its largest loading is made positive, or under a rotation its loadings sum positive)
Caveats:
- Unrotated, a measure loading on several components at once is common and does not mean it measures several things; params.rotation varimax redistributes the kept variance into a cleaner pattern without changing the communalities.
- Components summarise shared spread; they do not show that an underlying cause exists.
- On a covariance input the loadings are in the measures’ own units, so they are not comparable across measures.
scalars.kmo
The Kaiser-Meyer-Olkin measure of sampling adequacy: how much of the measures’ correlation is shared across the whole set rather than tied to single pairs, from 0 to 1. Higher means a component summary is more worthwhile.
Bands (convention: Kaiser (1974); a labelled convention, not a rule):
| Value | Label |
|---|---|
| below 0.5 | unacceptable |
| from 0.5 to below 0.6 | miserable |
| from 0.6 to below 0.7 | mediocre |
| from 0.7 to below 0.8 | middling |
| from 0.8 to below 0.9 | meritorious |
| 0.9 and above | marvelous |
Caveats:
- Null when the correlation table is singular (a measure is an exact combination of others).
- The per-measure version of this reading is also reported; a measure with a low value fits the set poorly.
scalars.bartlett_p
Follows the shared reading: see Reading a p-value.
scalars.bartlett_chisq
Bartlett’s test statistic: how far the correlation table is from one in which no measure is related to any other. Large values mean the measures are related enough to summarise.
Caveats:
- With many rows it is almost always large; read KMO for how worthwhile a summary is.
- It rests on the inference size: the rows (listwise), the summed weight or the effective sample size under a weight, or the smallest pair under pairwise deletion.
MAT_RELIABILITY
How consistently a set of rating items measures one thing, before you add them up into a score, with a check of how each item fits the rest. See its catalog entry.
scalars.alpha
Cronbach’s alpha: how consistently the items measure one shared quality, from the share of the summed score’s spread that the items share. 1 means the items move in lockstep; it can fall below 0 when items pull against each other, which usually means a reverse-worded item was not reversed.
Bands (convention: George & Mallery (2003); a labelled convention, not a rule):
| Value | Label |
|---|---|
| below 0.5 | unacceptable |
| from 0.5 to below 0.6 | poor |
| from 0.6 to below 0.7 | questionable |
| from 0.7 to below 0.8 | acceptable |
| from 0.8 to below 0.9 | good |
| 0.9 and above | excellent |
Caveats:
- Alpha rises with the number of items, so a long battery can reach a high alpha with weakly related items.
- A high alpha does not show the items measure only one thing.
- Alpha assumes every item is equally tied to the shared quality; when the ties differ it understates reliability, which omega allows for.
scalars.alpha_standardized
Alpha computed on the items’ correlations instead of their raw spreads: the alpha the battery would have if every item were first put on the same scale.
Bands (convention: George & Mallery (2003); a labelled convention, not a rule):
| Value | Label |
|---|---|
| below 0.5 | unacceptable |
| from 0.5 to below 0.6 | poor |
| from 0.6 to below 0.7 | questionable |
| from 0.7 to below 0.8 | acceptable |
| from 0.8 to below 0.9 | good |
| 0.9 and above | excellent |
Caveats:
- It differs from alpha when the items’ spreads differ; report the one that matches how the score is built (raw sums or standardised items).
scalars.omega
McDonald’s omega: the share of the summed score’s spread due to the one shared quality, from a one-factor fit that lets each item’s tie to it differ. Read on the same 0 to 1 scale as alpha; it is usually at least as high.
Caveats:
- No published convention bands omega; it is not read against the alpha bands.
- Null with a warning when the battery has 2 items, when the fit puts an item’s leftover spread at or below zero (a Heywood case), or when a pairwise table is inconsistent and params.repair is not set. When the fit does not converge, omega is kept as the last iterate’s value and a warning says so; treat it as provisional.
- The fit assumes one shared quality; when the items reflect several, omega from one factor misstates reliability.
scalars.mean_inter_item_r
The average correlation among the items, the strength of the typical pair; standardized alpha is built from it and the item count.
Caveats:
- An average hides spread: one item unrelated to the rest pulls it down; check the table and item_total_r.
- Like any correlation it shows the items move together, not that one causes another.
Reading overlay results
How to read the figures each overlay kind this instance offers lays over its host. Each operator lists its output fields: what the value means, the labelled bands of a published convention where one applies, what its sign says, and the caveats to keep in mind.
Overlays
OVERLAY_CHISQ_COL
Chi-square test per crosstab column: checks whether each column’s spread across the rows departs from the table’s overall row mix. See its catalog entry.
summary.statistic
In each series entry (one per crosstab column): chi-square for that column’s counts against the counts its column total would give at the table’s overall row shares. 0 means the column’s mix matches the overall mix; read the entry’s p_value rather than the raw value.
Caveats:
- Chi-square grows with the counts and with the number of cells, so it does not measure how strongly the categories are associated; read the p-value for the test and the cell values (or TEST_CHISQ’s Cramer’s V on raw rows) for strength.
- The overall row shares include the column itself, so a column that holds most of the table pulls them toward itself and reads closer to 0.
summary.p_value
Caveats:
- Every column is a separate test and Pulse reports the p-values raw: with many columns some small ones turn up by luck, so adjust for multiple comparisons: set multiplicity on the overlay and read summary.p_adjusted.
- It comes from the chi-square approximation; Pulse warns once per column whose expected counts fall below 5. A column with a zero total gets no value (NaN).
Follows the shared reading: see Reading a p-value.
summary.parameters.df
In each series entry: degrees of freedom of the column’s chi-square, rows - 1, the same for every column.
OVERLAY_CHISQ_MATRIX
Chi-square test on a whole crosstab: checks whether the row and column categories are associated, with one p-value for the table. See its catalog entry.
scalar
The chi-square statistic for the whole crosstab, also in summary.statistic: the sum over cells of (observed - expected)^2 / expected, where expected is the count the row and column totals predict if the two fields were independent. 0 means the table matches that pattern exactly; how far above 0 is unusual depends on summary.parameters.df, so read summary.p_value rather than the raw value.
Caveats:
- Chi-square grows with the counts and with the number of cells, so it does not measure how strongly the categories are associated; read the p-value for the test and the cell values (or TEST_CHISQ’s Cramer’s V on raw rows) for strength.
- No continuity (Yates) correction is applied.
summary.statistic
The same chi-square statistic as scalar.
summary.p_value
Caveats:
- It comes from the chi-square approximation, which is unreliable when expected counts fall below about 5; Pulse warns (PULSE_OVERLAY_EXPECTED_LOW) when they do.
- It is meaningful only when the cells count independent rows (AGG_COUNT); on sums or averages it is not a valid p-value.
- On a probability-weighted host the table is first scaled to its effective sample size (summary.parameters.n_eff): a first-order Kish approximation, not the Rao-Scott correction survey software applies.
Follows the shared reading: see Reading a p-value.
summary.parameters.df
Degrees of freedom of the chi-square reference curve: (rows - 1) x (columns - 1) of the host crosstab.
Caveats:
- It reflects the table’s shape, not its number of rows of data.
- A row or column whose total is 0 adds nothing to the statistic but still counts in df, which makes the p-value too large; drop empty rows and columns from the host first.
OVERLAY_CHISQ_ROW
Chi-square test per crosstab row: checks whether each row’s spread across the columns departs from the table’s overall column mix. See its catalog entry.
summary.statistic
In each series entry (one per crosstab row): chi-square for that row’s counts against the counts its row total would give at the table’s overall column shares. 0 means the row’s mix matches the overall mix; read the entry’s p_value rather than the raw value.
Caveats:
- Chi-square grows with the counts and with the number of cells, so it does not measure how strongly the categories are associated; read the p-value for the test and the cell values (or TEST_CHISQ’s Cramer’s V on raw rows) for strength.
- The overall column shares include the row itself, so a row that holds most of the table pulls them toward itself and reads closer to 0.
summary.p_value
Caveats:
- Every row is a separate test and Pulse reports the p-values raw: with many rows some small ones turn up by luck, so adjust for multiple comparisons: set multiplicity on the overlay and read summary.p_adjusted.
- It comes from the chi-square approximation; Pulse warns once per row whose expected counts fall below 5. A row with a zero total gets no value (NaN).
Follows the shared reading: see Reading a p-value.
summary.parameters.df
In each series entry: degrees of freedom of the row’s chi-square, columns - 1, the same for every row.
OVERLAY_CHISQ_VS_POP
Chi-square goodness-of-fit test on a facet: checks whether a subset’s category mix departs from a comparison population’s mix. See its catalog entry.
scalar
The chi-square statistic, also in summary.statistic: the subset’s category counts against the counts the population’s shares predict at the subset’s size, with the shares rescaled over the categories both sides show (population nulls and categories cut from a top-K listing are left out). 0 means the subset’s mix matches the population’s; read summary.p_value rather than the raw value.
Caveats:
- It grows with the subset’s size, so a very big subset gives a big value for a slight shift in mix; OVERLAY_INDEX_VS_POP shows which categories moved and by how much.
summary.statistic
The same chi-square statistic as scalar.
summary.p_value
Caveats:
- The population’s shares are treated as known and fixed; when the subset is a big part of the population the two overlap and the p-value is only approximate.
- With a single category there is nothing to compare and the p-value is NaN.
Follows the shared reading: see Reading a p-value.
summary.parameters.df
Degrees of freedom: the number of compared categories (the subset’s facet values that the population also shows), minus 1.
Caveats:
- A population category that is missing from the subset’s facet values is left out of both the statistic and df, which understates a departure made of absent categories.
- A subset category the population never shows is impossible under the population’s mix, so it cannot be tested: it is left out of the statistic, the subset’s total and df, and Pulse warns (PULSE_OVERLAY_REF_ZERO) once per such category. Read those warnings as departures in their own right.
OVERLAY_CHISQ_VS_REF
Chi-square test in a Compose request: checks whether a target request’s crosstab mix departs from the reference request’s mix. See its catalog entry.
scalar
Caveats:
- On this kind the scalar holds the p-value (the same value as summary.p_value), not the chi-square; the chi-square is in summary.statistic.
- The reference mix is treated as fixed, so its own sampling uncertainty is ignored and with a small reference the p-value comes out too small.
Follows the shared reading: see Reading a p-value.
summary.p_value
Caveats:
- It is the same value as scalar; see that reading for why a small reference makes it too small.
Follows the shared reading: see Reading a p-value.
summary.statistic
The chi-square statistic: the target’s cell counts against the counts the reference’s cell shares predict at the target’s total. 0 means the two tables have the same mix; read the p-value rather than the raw value.
Caveats:
- Chi-square grows with the counts and with the number of cells, so it does not measure how strongly the categories are associated; read the p-value for the test and the cell values (or TEST_CHISQ’s Cramer’s V on raw rows) for strength.
- Cells the reference never shows (reference count 0) are left out of the statistic and df, even when the target has counts there; those counts still set the scale. A shift into new cells is not detected, so check them directly (OVERLAY_DELTA_VS_REF).
summary.parameters.df
Degrees of freedom: the number of cells present in both requests with a positive expected count, minus 1.
Caveats:
- Because the reference shares are taken as fixed, df is cells - 1, not the (rows - 1) x (columns - 1) of a test that treats both tables as samples (TEST_CHISQ on stacked rows).
OVERLAY_FISHER_EXACT_CELL
Fisher’s exact test for every crosstab cell: checks whether being in that row goes with being in that column more or less than expected. See its catalog entry.
cells.value
Caveats:
- Each cell holds the two-sided exact p-value of its own 2x2 table: this row versus the rest, by this column versus the rest.
- It does not say which way or how far the cell departs: compare the cell’s count with what its row and column totals predict (or an OVERLAY_INDEX_VS_MARGIN layer) for direction and size.
- Every cell is a separate test and Pulse reports the p-values raw: with many cells some small ones turn up by luck, so before flagging cells adjust for multiple comparisons: set multiplicity on the overlay and read payload.p_adjusted.
Follows the shared reading: see Reading a p-value.
OVERLAY_KS_VS_POP
Kolmogorov-Smirnov test on a numeric facet: checks whether a subset’s distribution departs from a comparison population’s. See its catalog entry.
scalar
D, also in summary.statistic: the largest vertical gap between the subset’s and the population’s cumulative distributions, from 0 (the same curve) to 1 (no overlap at all).
Caveats:
- Pulse rebuilds both curves from histograms or percentiles, not raw values, so D is measured only at bin edges or percentile points and can understate the true gap.
- D shows that the shapes differ, not how: compare the histograms to see whether it is the centre, the spread or the tails.
summary.statistic
The same D as scalar.
summary.p_value
Caveats:
- With few rows the test has low power and misses real differences; with very many rows even trivial differences in shape give small p-values.
- It compares two observed distributions. It is not valid for testing against a named distribution with parameters estimated from the data, which needs the Lilliefors correction.
- It is a two-sided large-sample approximation built from the full row counts in summary.parameters, while D comes from the binned curves.
Follows the shared reading: see Reading a p-value.
summary.parameters.n_subset
The number of rows behind the subset’s numeric facet; one of the two counts the p-value’s large-sample formula uses.
summary.parameters.n_pop
The number of rows behind the population’s numeric summary; the other count the p-value uses.
Caveats:
- When the subset is part of the population these rows include the subset’s, so the two samples overlap: D shrinks and this count overstates the comparison sample, and the p-value comes out too large (conservative).
OVERLAY_PAIRWISE_PROBIT_T
Pairwise t-tests on probit-transformed shares between rows (or columns) of one crosstab, matching a convention some survey tools use. See its catalog entry.
cells.value
Caveats:
- Each cell holds the two-sided p-value for one pair, from a t curve on n_i + n_j - 2 degrees of freedom after a probit transform of each share; it is absent when a leg is unreadable or the test is degenerate.
- The probit simplification differs from the textbook two-proportion test (OVERLAY_PAIRWISE_PROP_Z), so the two give different p-values for the same cells.
- Every cell is one pair at one level of the other axis, and Pulse reports the p-values raw: with many pairs some small ones turn up by luck, so before flagging pairs adjust for multiple comparisons: set multiplicity on the overlay and read payload.p_adjusted.
- The layer does not report an effect size: read the two cell values for how big each gap is.
Follows the shared reading: see Reading a p-value.
OVERLAY_PAIRWISE_PROP_Z
Two-proportion z-tests between every pair of rows (or columns) of one crosstab, per column (or row): which segments differ in share? See its catalog entry.
cells.value
Caveats:
- Each cell holds the two-sided pooled two-proportion z-test p-value for one pair of rows (or columns), named by the pair-axis key; it is absent when a leg is unreadable or the test is degenerate.
- Every cell is one pair at one level of the other axis, and Pulse reports the p-values raw: with many pairs some small ones turn up by luck, so before flagging pairs adjust for multiple comparisons: set multiplicity on the overlay and read payload.p_adjusted.
- The normal approximation needs roughly 10 successes and 10 failures per leg, and an n_source or p_source that does not match the cell values skips pairs.
- The layer does not report an effect size: read the two cell values for how big each gap is.
Follows the shared reading: see Reading a p-value.
OVERLAY_PAIRWISE_TWO_MEANS_Z
Large-sample z-tests between every pair of rows (or columns) of one crosstab of averages: which segments differ in mean? See its catalog entry.
cells.value
Caveats:
- Each cell holds the two-sided z-test p-value for one pair of means, read from the normal curve; it is absent when a leg is unreadable or the test is degenerate.
- With small cells the normal curve gives p-values that are too small; OVERLAY_PAIRWISE_WELCH_T is the safer reading there.
- Every cell is one pair at one level of the other axis, and Pulse reports the p-values raw: with many pairs some small ones turn up by luck, so before flagging pairs adjust for multiple comparisons: set multiplicity on the overlay and read payload.p_adjusted.
- The layer does not report an effect size: read the two cell values for how big each gap is.
Follows the shared reading: see Reading a p-value.
OVERLAY_PAIRWISE_WEIGHTED_TWO_MEANS_Z
Large-sample z-tests between every pair of rows (or columns) of one crosstab of weighted averages, with a sample-size basis you choose. See its catalog entry.
cells.value
Caveats:
- Each cell holds the two-sided z-test p-value for one pair of weighted means; params.n_basis sets the sample size (sum of weights, or Kish’s effective sample size).
- It accounts for weighting only, not clustering or other design effects, so with such designs the p-values come out too small.
- Every cell is one pair at one level of the other axis, and Pulse reports the p-values raw: with many pairs some small ones turn up by luck, so before flagging pairs adjust for multiple comparisons: set multiplicity on the overlay and read payload.p_adjusted.
- The layer does not report an effect size: read the two cell values for how big each gap is.
Follows the shared reading: see Reading a p-value.
OVERLAY_PAIRWISE_WELCH_T
Welch t-tests between every pair of rows (or columns) of one crosstab of averages: which segments differ in mean? See its catalog entry.
cells.value
Caveats:
- Each cell holds the two-sided Welch t-test p-value for one pair of means, with Welch-Satterthwaite degrees of freedom; it is absent when a leg is unreadable or the test is degenerate.
- Each mean should be roughly normal: safe for big cells, risky for small skewed ones.
- Every cell is one pair at one level of the other axis, and Pulse reports the p-values raw: with many pairs some small ones turn up by luck, so before flagging pairs adjust for multiple comparisons: set multiplicity on the overlay and read payload.p_adjusted.
- The layer does not report an effect size: read the two cell values for how big each gap is.
Follows the shared reading: see Reading a p-value.
OVERLAY_PROP_Z_CELL
Two-proportion z-test for every crosstab cell in a Compose request: does the target’s share in that cell differ from the reference’s? See its catalog entry.
cells.value
Caveats:
- Each cell holds the two-sided p-value comparing the target’s share (cell / row total) with the reference’s share in the same cell.
- A cell whose row total is missing or zero on either side is NaN, with a warning (PULSE_OVERLAY_REF_ZERO): it gets no test.
- The normal approximation needs roughly 10 successes and 10 failures on each side; with fewer the p-value is unreliable.
- Every cell is a separate test and Pulse reports the p-values raw: with many cells some small ones turn up by luck, so before flagging cells adjust for multiple comparisons: set multiplicity on the overlay and read payload.p_adjusted.
- The layer does not report an effect size: read the gap itself (for example OVERLAY_DELTA_VS_REF) for how big a difference is.
Follows the shared reading: see Reading a p-value.
OVERLAY_PROP_Z_PANEL
Two-proportion z-tests between every pair of requests in a Compose panel, for each crosstab cell: which waves or markets differ in share? See its catalog entry.
cells.value
Caveats:
- Each cell holds a list of two-sided p-values, one per pair of requests, in upper-triangle order with the reference at index 0: (0,1), (0,2), …, (1,2), …; M requests give M(M-1)/2 entries.
- The number of tests is cells times pairs, so it grows fast: with many cells and pairs some small p-values turn up by luck. Pulse reports them raw; to adjust for multiple comparisons set multiplicity on the overlay, which adds payload.p_adjusted (every list entry joins the family).
- The normal approximation needs roughly 10 successes and 10 failures per side.
- A pair involving a slot whose row margin is missing or zero is NaN, with a warning: it gets no test.
- The layer does not report an effect size: compare the slots’ cell shares (cell / row total) for how big each gap is.
Follows the shared reading: see Reading a p-value.
OVERLAY_T_CELL
Welch t-test for every crosstab cell in a Compose request: does the target’s mean in that cell differ from the reference’s? See its catalog entry.
cells.value
Caveats:
- Each cell holds the two-sided Welch t-test p-value comparing the target’s mean in that cell with the reference’s.
- Without AGG_WELFORD values (or explicit variance and sample-size params) Pulse computes the p-value from placeholder inputs (variance 1, n 2), and it then describes those placeholders, not your data.
- Every cell is a separate test and Pulse reports the p-values raw: with many cells some small ones turn up by luck, so before flagging cells adjust for multiple comparisons: set multiplicity on the overlay and read payload.p_adjusted.
- The layer does not report an effect size: read the gap itself (for example OVERLAY_DELTA_VS_REF) for how big a difference is.
Follows the shared reading: see Reading a p-value.
OVERLAY_T_VS_REF
Welch t-test for every group of a grouped result in a Compose request: does the target’s mean differ from the reference’s? See its catalog entry.
summary.statistic
Caveats:
- Despite the field name, each series entry’s summary.statistic holds that group’s two-sided Welch t-test p-value, not a t value; Pulse does not emit t.
- Without AGG_WELFORD values (or explicit variance and sample-size params) Pulse computes the p-value from placeholder inputs (variance 1, n 2), and it then describes those placeholders, not your data.
- Every group is a separate test and Pulse reports the p-values raw: with many groups some small ones turn up by luck, so adjust for multiple comparisons: set multiplicity on the overlay and read summary.p_adjusted.
- The layer does not report an effect size: read the gap itself (for example OVERLAY_DELTA_VS_REF) for how big a difference is.
Follows the shared reading: see Reading a p-value.
OVERLAY_ZSCORE_VS_MARGIN
Each crosstab cell’s gap from its row, column or grand margin, in standard deviations of the cells in that slice: which cells stand out? See its catalog entry.
cells.value
How far each cell sits from its row, column or grand margin figure, in standard deviations of the cell values across that slice (dividing by the number of cells). 0 means the cell equals the margin figure.
Sign:
+: the value sits above its centre-: the value sits below its centre
Caveats:
- There are no sourced bands for reading this z-score: the familiar 1.96 / 2.58 cut-offs are critical values of a test statistic built from a standard error, and this value is built from a spread of values instead, so it has no p-value behind it.
- The spread is that of the cells in the slice, not the sampling error of one cell, so a cell built from few rows can sit far out without being unusual.
- For counts or sums the margin is a total, so every cell sits far below it; the reading suits averages.
OVERLAY_ZSCORE_VS_POP
Puts each category’s share gap with a comparison population on a z-score scale; for numeric bins it says nothing about the subset. See its catalog entry.
summary.statistic
In each series entry, for a categorical field: the category’s share in the subset minus its share in the population, divided by the standard deviation of the population’s shares across categories. For a numeric field: a histogram bin’s centre against the population’s mean, in population standard deviations.
Sign:
+: the value sits above its centre-: the value sits below its centre
Caveats:
- There are no sourced bands for reading this z-score: the familiar 1.96 / 2.58 cut-offs are critical values of a test statistic built from a standard error, and this value is built from a spread of values instead, so it has no p-value behind it.
- For categories the divisor is the spread of shares across categories, not a standard error: it does not shrink as the subset grows, and it changes with how many categories there are.
- For a numeric field it says where each bin sits on the population’s scale, not how the subset differs; use OVERLAY_KS_VS_POP for that.
OVERLAY_ZSCORE_VS_ROLLING
How many standard deviations each point sits from the rolling mean of the previous W points: a simple flag for unusual periods. See its catalog entry.
summary.statistic
In each series entry: how far the point sits from the mean of up to W points before it, in sample standard deviations of those points. 0 means the point equals its recent average.
Sign:
+: the value sits above its centre-: the value sits below its centre
Caveats:
- There are no sourced bands for reading this z-score: the familiar 1.96 / 2.58 cut-offs are critical values of a test statistic built from a standard error, and this value is built from a spread of values instead, so it has no p-value behind it.
- A short window gives a jumpy spread, and a trend or seasonal pattern makes many points look unusual; points stay empty (NaN) until the window holds 2 values.
- Until W prior points exist the window is partial (from 2 points up), so the first values rest on a very unstable spread; treat them with caution.
OVERLAY_ZSCORE_VS_TOTAL
How many standard deviations each group’s value sits from the average of all groups on a grouped result: which groups stand out? See its catalog entry.
summary.statistic
In each series entry: how far the group’s value sits from the average of all groups, in standard deviations of the group values (dividing by the number of groups). 0 means the group equals that average.
Sign:
+: the value sits above its centre-: the value sits below its centre
Caveats:
- There are no sourced bands for reading this z-score: the familiar 1.96 / 2.58 cut-offs are critical values of a test statistic built from a standard error, and this value is built from a spread of values instead, so it has no p-value behind it.
- With N groups no value can sit further than sqrt(N - 1) from 0 (Shiffler’s 1988 bound, (N - 1)/sqrt(N), restated for the divide-by-N spread Pulse uses), so with 5 groups nothing passes 2; compare values within a layer, not against a fixed cut-off.
- The spread is between groups, not between rows within a group.
OVERLAY_Z_CELL
Large-sample z-test for every crosstab cell in a Compose request: does the target’s mean in that cell differ from the reference’s? See its catalog entry.
cells.value
Caveats:
- Each cell holds the two-sided z-test p-value comparing the target’s mean in that cell with the reference’s, read from the normal curve; with small cells it comes out too small.
- Without AGG_WELFORD values (or explicit variance and sample-size params) Pulse computes the p-value from placeholder inputs (variance 1, n 2), and it then describes those placeholders, not your data.
- Every cell is a separate test and Pulse reports the p-values raw: with many cells some small ones turn up by luck, so before flagging cells adjust for multiple comparisons: set multiplicity on the overlay and read payload.p_adjusted.
- The layer does not report an effect size: read the gap itself (for example OVERLAY_DELTA_VS_REF) for how big a difference is.
Follows the shared reading: see Reading a p-value.
OVERLAY_Z_VS_REF
Large-sample z-test for every group of a grouped result in a Compose request: does the target’s mean differ from the reference’s? See its catalog entry.
summary.statistic
Caveats:
- Despite the field name, each series entry’s summary.statistic holds that group’s two-sided z-test p-value, not a z value; Pulse does not emit z.
- It is read from the normal curve, so with small groups it comes out too small.
- Without AGG_WELFORD values (or explicit variance and sample-size params) Pulse computes the p-value from placeholder inputs (variance 1, n 2), and it then describes those placeholders, not your data.
- Every group is a separate test and Pulse reports the p-values raw: with many groups some small ones turn up by luck, so adjust for multiple comparisons: set multiplicity on the overlay and read summary.p_adjusted.
- The layer does not report an effect size: read the gap itself (for example OVERLAY_DELTA_VS_REF) for how big a difference is.
Follows the shared reading: see Reading a p-value.
Reading descriptive results
How to read the descriptive results whose number a name alone does not explain: standardised, scale-free, model-based, transformed or relative figures. Each operator lists its output fields: what the value means, the labelled bands of a published convention where one applies, what its sign says, and the caveats to keep in mind.
Aggregators
AGG_CI_LOWER
Lower end of a confidence interval for the mean of a numeric field, showing how precisely the average is pinned down. See its catalog entry.
value
The lower end of a confidence interval for the group’s mean at the requested confidence (95% by default): mean - z * standard error, where the standard error is the sample standard deviation (dividing by n - 1) over sqrt(n).
Caveats:
- The interval is about the MEAN, not about individual rows: most rows can, and usually do, fall outside it.
- The confidence level describes the method: over repeated samples, intervals built this way capture the true mean that share of the time. Any one interval either contains it or not.
- Pulse uses the normal critical value (1.96 at 95%), not Student’s t, so on small groups (under about 30 rows) the interval is too narrow; on a strongly skewed or heavy-tailed field it can stay too narrow, and lopsided, on much larger groups, so check AGG_SKEWNESS first. The components key t_critical holds that normal value.
- It assumes rows are independent draws. A row weight is honoured, its sample size read as the sum of weights (frequency) or Kish’s effective sample size (probability, components n_eff); clustered or repeated rows, or design-based (strata / cluster) variance, are not modelled, so the interval understates the uncertainty there.
- Overlapping intervals do not show two means are equal: means can differ even when their intervals overlap, so compare groups with TEST_WELCH, not by eye.
- Empty (NaN) when fewer than two rows have a value (weighted: when the sample size is 1 or less).
AGG_CI_UPPER
Upper end of a confidence interval for the mean of a numeric field, showing how precisely the average is pinned down. See its catalog entry.
value
The upper end of a confidence interval for the group’s mean at the requested confidence (95% by default): mean + z * standard error, where the standard error is the sample standard deviation (dividing by n - 1) over sqrt(n).
Caveats:
- The interval is about the MEAN, not about individual rows: most rows can, and usually do, fall outside it.
- The confidence level describes the method: over repeated samples, intervals built this way capture the true mean that share of the time. Any one interval either contains it or not.
- Pulse uses the normal critical value (1.96 at 95%), not Student’s t, so on small groups (under about 30 rows) the interval is too narrow; on a strongly skewed or heavy-tailed field it can stay too narrow, and lopsided, on much larger groups, so check AGG_SKEWNESS first. The components key t_critical holds that normal value.
- It assumes rows are independent draws. A row weight is honoured, its sample size read as the sum of weights (frequency) or Kish’s effective sample size (probability, components n_eff); clustered or repeated rows, or design-based (strata / cluster) variance, are not modelled, so the interval understates the uncertainty there.
- Overlapping intervals do not show two means are equal: means can differ even when their intervals overlap, so compare groups with TEST_WELCH, not by eye.
- Empty (NaN) when fewer than two rows have a value (weighted: when the sample size is 1 or less).
AGG_KURTOSIS
How heavy a numeric field’s tails are next to a normal distribution: positive when extreme values are more common. See its catalog entry.
value
How heavy the tails are next to a normal distribution. Pulse computes the population EXCESS kurtosis g2: the average fourth-power z-score minus 3, (m4/n) / (m2/n)^2 - 3, where the components m2 and m4 are SUMS of squared and fourth-power distances from the mean (not yet divided by n), so a normal distribution scores 0. It can never fall below -2.
Sign:
+: heavier tails than a normal distribution: extreme values turn up more often than the spread suggests-: lighter tails than a normal distribution: values far from the mean turn up less often than the spread suggests, as in a flat (uniform) or two-humped spread; it does not mean the values are bounded
Caveats:
- There are no sourced bands for kurtosis: the cut-offs quoted for it (such as 2 or 7) are pass/fail normality screens from particular fields, disagree on whether 3 has been subtracted, and are not graded readings.
- Small-n bias: g2 differs from the adjusted G2 that Excel KURT and SPSS print, and on small groups the gap is large; neither is stable on a handful of rows.
- It is 0 when n is 0 or 1 or the variance is zero (every value the same), so a 0 from a tiny or constant group says nothing about shape: check components n.
- It measures the tails, not how pointed the peak looks, and a single extreme value can dominate it.
AGG_MAE
Average size of a prediction’s miss in the actual’s own units, comparing an actual column with a prediction column row by row. See its catalog entry.
value
The average size of the miss between prediction and actual, ignoring sign, in the actual’s own units. 0 means every prediction is exact.
Caveats:
- It says how big the misses are, not which way: a model that is always too high and one that is always too low can share a value.
- It has the actual’s units, so compare it only between models scored on the same rows and target.
- A row counts only when both the actual and the prediction have a value; components n_scored is the rows used and n_null_prediction the rows skipped for a missing prediction. Empty (NaN) when no row is used.
- Measured on the rows the model was fitted to, it is optimistic: read it on held-out rows (a filter or range grouper keeps them apart) to judge how the model does on new data.
AGG_MAPE
Average size of a prediction’s miss as a share of the actual value, comparing an actual column with a prediction column row by row. See its catalog entry.
value
The average of each row’s miss divided by its actual value, ignoring sign, as a FRACTION: 0.12 means the predictions are off by 12% of the actual value on average. 0 means every prediction is exact.
Caveats:
- Rows whose actual is exactly 0 are left out (their share is undefined) and counted in components n_zero_actual; actual values near 0 can still inflate it sharply.
- It punishes over-prediction more than under-prediction of the same size, because the share is taken of the actual.
- A row counts only when both the actual and the prediction have a value; components n_scored is the rows used and n_null_prediction the rows skipped for a missing prediction. Empty (NaN) when no row is used.
- Measured on the rows the model was fitted to, it is optimistic: read it on held-out rows (a filter or range grouper keeps them apart) to judge how the model does on new data.
AGG_PERCENTILE
Value of a numeric field below which a chosen share of rows fall, such as the 90th percentile of delivery time. See its catalog entry.
value
The value below which the requested share of the group’s values fall, in the field’s own units: at percentile 90 about 90% of values sit at or below it. Pulse interpolates linearly between the two nearest sorted values (R’s default, type 7), so it may be a value no row holds.
Caveats:
- Tools use different interpolation rules, so on small groups another tool can return a somewhat different value for the same percentile.
- A percentile near 0 or 100 on a small group rests on one or two rows and moves a lot when they change.
- An empty group reads 0, not empty: check components n.
AGG_RATIO
Total of one field divided by the total of another, over all rows or per group, such as revenue per order. See its catalog entry.
value
The total of the numerator field divided by the total of the denominator field over the group’s rows (a ratio of totals), such as revenue per order. Its units are the numerator’s per unit of the denominator’s.
Caveats:
- It is not the average of each row’s own ratio: rows with larger denominators count for more, which is usually what a rate should do.
- A row missing either field is left out of BOTH totals.
- Empty (NaN) when the denominator total is 0.
- Read it as a share (times 100 for a percentage) only when the numerator counts a part of what the denominator counts.
AGG_RMSE
Typical size of a prediction’s miss in the actual’s own units, with large misses counting more: the root of the average squared miss. See its catalog entry.
value
The square root of the average squared miss between prediction and actual, in the actual’s own units. 0 means every prediction is exact; it is never below AGG_MAE on the same rows.
Caveats:
- Squaring makes a few large misses dominate it; a gap well above AGG_MAE on the same rows says the misses are uneven.
- It has the actual’s units, so compare it only between models scored on the same rows and target.
- A row counts only when both the actual and the prediction have a value; components n_scored is the rows used and n_null_prediction the rows skipped for a missing prediction. Empty (NaN) when no row is used.
- Measured on the rows the model was fitted to, it is optimistic: read it on held-out rows (a filter or range grouper keeps them apart) to judge how the model does on new data.
AGG_SKEWNESS
How lopsided a numeric field is: positive when a long tail runs to high values, negative when it runs to low ones. See its catalog entry.
value
How lopsided the values are around their mean. Pulse computes the population moment coefficient g1: the average cubed z-score, (m3/n) / (m2/n)^1.5, where the components m2 and m3 are SUMS of squared and cubed distances from the mean (not yet divided by n). 0 means the values balance around the mean.
Sign:
+: a longer or heavier tail to the right: a few values sit far above the rest-: a longer or heavier tail to the left: a few values sit far below the rest
Caveats:
- The sign does not fix whether the mean sits above or below the median: the two often disagree on whole-number or many-peaked fields, so read AGG_MEDIAN beside AGG_AVERAGE for that.
- There are no sourced bands for skewness: the 0.5 / 1 rules of thumb in circulation trace to secondary quotations that could not be checked against their source, so compare values with each other rather than with a fixed cut-off.
- Small-n bias: g1 runs smaller in size than the adjusted G1 that Excel SKEW, SPSS and SAS print (G1 = g1 * sqrt(n(n-1)) / (n-2)), so the two disagree on small groups and converge as n grows; on a handful of rows neither is stable.
- It is 0 when n is 0 or 1 or the variance is zero (every value the same), so a 0 from a tiny or constant group says nothing about shape: check components n.
- Deviations are cubed, so one extreme value can set the sign and size on its own.
AGG_STDDEV
Typical distance of a numeric field’s values from their average, in the field’s own units, over all rows or per group. See its catalog entry.
value
The typical distance of a value from the group’s mean, in the field’s own units. 0 means every value is the same; for a roughly bell-shaped field about two thirds of the values sit within one standard deviation of the mean.
Caveats:
- It is the spread of the rows, not the precision of the mean: for how precisely the average is known, read AGG_CI_LOWER / AGG_CI_UPPER.
- Pulse divides by n (the population form); most tools report the sample form, dividing by n - 1, which is slightly larger. The gap matters only on small groups; AGG_WELFORD gives the sample form.
- Each distance is squared before averaging, so a few extreme values inflate it; for a skewed field a percentile range describes the spread better.
- A group with a single value reads 0: no spread can be seen in one value, which is not the same as a group whose values agree.
AGG_VARIANCE
Spread of a numeric field as the average squared distance from its mean, in squared units, over all rows or per group. See its catalog entry.
value
The average squared distance of a value from the group’s mean, in the field’s units squared (dollars squared, minutes squared). Its square root is AGG_STDDEV, which is in the field’s own units and easier to read.
Caveats:
- Squared units make the size hard to judge directly. For pooling or planning a study’s size use AGG_WELFORD’s variance (dividing by n - 1), which those formulas expect.
- Pulse divides by n (the population form); most tools report the sample form, dividing by n - 1, which is slightly larger. The gap matters only on small groups; AGG_WELFORD gives the sample form.
- Extreme values weigh heavily, since each distance is squared.
- A group with a single value reads 0: no spread can be seen in one value, which is not the same as a group whose values agree.
AGG_WEIGHTED_MEAN
Average of a numeric field where each row counts in proportion to a weight field, such as a survey weight. See its catalog entry.
value
The average of the field with each row counted in proportion to its weight: sum(value * weight) / sum(weight), in the field’s own units. With equal weights it equals AGG_AVERAGE.
Caveats:
- Rows missing the value or the weight, or with weight 0, are left out of the average yet still count in components n; read sum_weights for the base.
- With survey or other sampling weights, uneven weights cost precision: components n_eff (Kish’s effective sample size) is the row count the estimate is roughly worth, never more than the rows that count. It does not apply to frequency weights (a weight counting repeated units, such as a quantity, where the base is sum_weights) or to inverse-variance weights.
- It reads 0 when no row has a usable weight; check components sum_weights before trusting a 0.
- Negative, NaN and infinite weights are excluded and counted in components n_weight_invalid, with a warning, so the result stays within the range of the values.
AGG_WELFORD
Mean, sample variance and row count of a numeric field in one result: what a comparison of group means needs. See its catalog entry.
value.*
Three columns per group: mean (the average), variance (the SAMPLE variance, dividing by n - 1, in the field’s units squared) and n (the rows with a value). Together they are what a t or z comparison of group means needs; the components add stddev, the square root of variance.
Caveats:
- variance is 0 when n is below 2: no spread could be estimated, which is not the same as values that agree.
- variance here divides by n - 1, so on the same rows it is larger than AGG_VARIANCE (which divides by n).
- With no rows the result is empty, not zeros.
AGG_ZSCORE
Population mean and standard deviation of a numeric field, for standardizing it; the value itself is always 0. See its catalog entry.
value
The average of every row’s z-score within the group, which is 0 by construction (up to rounding): the value carries no information about the data. The usable numbers are in the components: pop_mean and pop_stddev (the centre and the population spread, dividing by n), target_value (the LAST row’s value) and zscore (that row’s z-score).
Caveats:
- For a z-score on every row use ATTR_ZSCORE; for each group’s value against all groups use OVERLAY_ZSCORE_VS_TOTAL.
- There are no sourced bands for reading a z-score of this kind: the familiar 1.96 / 2.58 cut-offs are critical values of a test statistic built from a standard error, and this one is built from a spread of values instead, so it has no p-value behind it.
- Which row is last depends on row order (not on date), so the components zscore is reproducible only on a fixed order.
- The last row is part of the mean and spread it is scored against, which damps its zscore (never beyond sqrt(n - 1) in size), so it understates how unusual that row is; for a reading against its own past use OVERLAY_ZSCORE_VS_ROLLING.
- The value and the components zscore read 0 when every value is the same (no spread); every component reads 0 when the group is empty.
Attributes
ATTR_FACTOR_SCORE
Adds to every row its score on one factor behind several related numeric fields: an estimate of the hidden trait they measure together. See its catalog entry.
value
The row’s estimated score on the chosen factor, on the scale of the fields standardized over the fitted rows: 0 is an average row, positive is toward the end where the fields loading positively on the factor are high.
Caveats:
- The direction is MAT_FACTOR’s convention (each factor’s loadings sum positive), not a meaning: check its loadings on the same fields before calling a high score good or bad.
- A factor score is an estimate, not the factor itself: regression scores are shrunk toward 0 (their spread is below 1), Bartlett scores are unbiased but noisier.
- After an oblique rotation (promax, oblimin) the factors correlate, so scores on two factors move together.
- The fit is over every filtered row (or the fit_on subset), not the row’s group, so scores are comparable across groups. Under a row weight the fit is weighted; a row with no usable weight is left out of the fit but still scored.
- A row missing any field reads null, and every row reads null, with a warning, when the fit cannot score (too few rows, a constant or redundant field, a factor count the fields cannot identify).
ATTR_MAHALANOBIS
Adds to every row how unusual its mix of several numeric fields is, allowing for how those fields usually vary together. See its catalog entry.
value
With output d2 (the default): the row’s squared Mahalanobis distance D² = (x - mean)’ S^-1 (x - mean) across the p fields, the distance from the fitted average row in units of the fields’ own spread and correlation; 0 is exactly average and it is never negative. With output p: the chance of a D² at least this large under a chi-square curve on p degrees of freedom, so a small value marks an unusual row.
Caveats:
- D² grows with the number of fields: over the fitted rows it averages about p, so compare it with p (or read output p), never with a fixed cut-off across batteries.
- The p output assumes the fields are jointly normal; on skewed or bounded fields (rating scales) it is only a ranking, and screening many rows flags some by chance.
- Extreme rows in the fit pull the mean and inflate the spread, masking themselves and each other; fit_on can fit on a clean subset while still scoring every row.
- The fit is over every filtered row (or the fit_on subset), not the row’s group. Under a row weight the mean and covariance are weighted; a row with no usable weight is left out of the fit but still scored.
- A row missing any field reads null, and every row reads null, with a warning, when the fit has no more complete rows than fields or a field is constant or a combination of others.
ATTR_NORMALIZED
Rescales a numeric field to 0 to 1 on every row: 0 is the smallest value, 1 the largest. See its catalog entry.
value
Where the row’s value sits between the smallest and largest value, on a 0-to-1 scale: (value - min) / (max - min). 0 is the minimum, 1 the maximum and 0.5 halfway between them, which is not the median.
Caveats:
- One extreme value sets the minimum or maximum and squeezes every other row into a narrow part of the scale; ATTR_PERCENTILE is not affected that way.
- A row with a missing value reads 0, the same as the minimum, and every row reads 0 when all values are equal (max = min).
- The scale comes from every row that passed the filters, not from the row’s group: averaging it per group shows how each group sits against the whole, and a different filter gives different values.
ATTR_PC_SCORE
Adds to every row its score on one principal component of several related numeric fields: one number standing in for what they share. See its catalog entry.
value
The row’s score on the chosen principal component of the fields, standardized so the fitted rows average 0 with a standard deviation of 1: 0 is an average row, +1 is one standard deviation toward the end where the fields with positive loadings are high.
Caveats:
- The sign is a convention (MAT_PCA’s: each component’s largest loading is positive), not a meaning: check MAT_PCA’s loadings on the same fields before calling a high score good or bad.
- A component summarises the spread the fields share; it does not say why they move together, and a later component carries less of their variance and is noisier.
- On the correlation every field counts equally; on the covariance a field with a large spread dominates the score.
- The fit is over every filtered row (or the fit_on subset), not the row’s group, so scores are comparable across groups. Under a row weight the fit is weighted; a row with no usable weight is left out of the fit but still scored.
- A row missing any field reads null, and every row reads null, with a warning, when the fit has no more complete rows than fields or a field is constant or a combination of others.
ATTR_PERCENTILE
Adds to every row its percentile rank, rank / n * 100: the share of rows at or below it when values are untied. See its catalog entry.
value
The row’s position among the sorted values, as a percentage: rank / n * 100, where the smallest value has rank 1 and n counts the rows with a value. The largest value reads 100 and the smallest 100 / n, so for a value no other row shares it is the share of rows at or below this one; tied values break that (see below).
Caveats:
- Tied values do not share a percentile: each tied row takes its own rank in an arbitrary order, so equal values can read different percentiles, far apart on a field with few distinct values (a 1-5 rating); use WIN_RANK when ties must share a rank.
- A row with a missing value reads 0, which no row with a value can: treat 0 as missing.
- Other tools define it differently (smallest at 0, or ties at their midpoint), so the same row can read a few points apart elsewhere.
- Equal steps in percentile are not equal steps in the value: near the middle of a bell-shaped field a small change in value moves the percentile a lot.
- The scale comes from every row that passed the filters, not from the row’s group: averaging it per group shows how each group sits against the whole, and a different filter gives different values.
ATTR_REG_FITTED
Adds to every row the value a straight-line model predicts for its target from its predictors. See its catalog entry.
value
The target value the fitted straight-line model predicts for the row from its predictors: intercept + sum(coefficient * predictor), in the target’s units. The model is fitted by least squares to the rows that passed the filters and have the target and every predictor.
Caveats:
- The model describes association in these rows: a fitted value is what rows with these predictor values look like here, not what would happen to a row if a predictor were changed; a causal reading needs a design that supports it.
- The model is fitted and read on the same rows, so it matches them more closely than it will match new rows (overfitting), most of all with many predictors and few rows.
- A straight-line model: where the target bends with a predictor, fitted values miss in a pattern over parts of its range; read ATTR_REG_RESIDUAL to see it.
- A row missing the target still gets a fitted value when its predictors are present; a row missing any predictor reads 0, which is not a prediction.
- With a penalty (l1, l2, elasticnet) the coefficients are deliberately shrunk toward 0, so fitted values sit closer to the target’s mean and the residuals are larger overall (their sum of squares rises) than an unpenalized fit’s, though a single row’s can go either way; use no penalty for diagnostics.
ATTR_REG_LEVERAGE
Adds to every row its leverage: how unusual its predictor values are, and so how hard it can pull a straight-line fit. See its catalog entry.
value
How far the row’s predictor values sit from the other rows’ (from the predictors’ means, scaled by their spread and correlation), and so how hard the row can pull the fitted line toward itself: the hat-matrix diagonal 1/n + (x - mean)’ M^-1 (x - mean), where M is the predictors’ centred sum-of-squares-and-cross-products matrix. Over the n rows in the fit it runs from 1/n to 1 and averages p / n, where p counts the coefficients including the intercept.
Caveats:
- A common rule of thumb flags leverage above 2p / n, twice the average (Hoaglin & Welsch 1978); it is a screening threshold, not a graded convention, and with few rows per coefficient it flags many rows.
- It reads the predictors only, never the target: a high-leverage row can sit right on the line. Whether it moved the fit depends on its residual too (ATTR_REG_RESIDUAL); influence measures that combine the two, such as Cook’s distance, are not computed.
- A row outside the fit (missing the target) still gets a leverage from its predictors, which can exceed 1 when it lies beyond the fitted rows; a row missing any predictor reads 0, below the 1/n floor, so 0 marks a missing row.
- Unpenalized least squares only; a request with a penalty is refused.
ATTR_REG_PREDICT
Adds to every row the value a model fitted once, elsewhere in the request, predicts for it from the row’s own predictor values. See its catalog entry.
value
The target value the named fitted model predicts for the row from the row’s own predictor values: intercept + sum(coefficient * predictor) at the fit’s full precision, in the target’s units — for a GLM on the response scale (the inverse link applied: a probability, a count) unless scale is link. With terms, only the listed terms are summed: that part of the prediction, a contribution in the target’s units (link units for a GLM).
Caveats:
- On rows the model was fitted to it equals ATTR_REG_FITTED exactly and matches them more closely than it will match new rows (overfitting): judge a model on rows it was not fitted to.
- A coefficient’s confidence bound gives the range of that one term, not a prediction interval for a row.
- The model describes association in these rows: a fitted value is what rows with these predictor values look like here, not what would happen to a row if a predictor were changed; a causal reading needs a design that supports it.
- A row missing any predictor the score reads gets null, never 0.
ATTR_REG_RESIDUAL
Adds to every row its residual: the actual target value minus the value a straight-line model predicts for it. See its catalog entry.
value
Actual minus predicted: the row’s target value minus the value the straight-line model predicts for it, in the target’s units. Over the rows used in an unpenalized fit the residuals average 0.
Sign:
+: the row’s target is above what the model predicts from its predictors-: the row’s target is below what the model predicts from its predictors
Caveats:
- A residual is what this model leaves unexplained, not a measurement error and not the effect of any one thing left out: another set of predictors gives other residuals.
- It is in the target’s units, so there are no sourced cut-offs for a large one; compare it with the model’s typical miss (REG_OLS residual_std_err) times sqrt(1 - leverage) from ATTR_REG_LEVERAGE, since a high-leverage row’s residual varies less, for a row-fair comparison.
- A row with high leverage (ATTR_REG_LEVERAGE) pulls the line toward itself, so its residual can be small even when the row is unusual.
- Patterns across rows (a curve, or spread that grows with the predicted value) point to a missing term or heteroscedasticity, not to rows that are wrong.
- The model describes association in these rows: a fitted value is what rows with these predictor values look like here, not what would happen to a row if a predictor were changed; a causal reading needs a design that supports it.
- A row missing the target or any predictor reads 0, the same as a row the model fits exactly.
- With a penalty (l1, l2, elasticnet) the coefficients are deliberately shrunk toward 0, so fitted values sit closer to the target’s mean and the residuals are larger overall (their sum of squares rises) than an unpenalized fit’s, though a single row’s can go either way; use no penalty for diagnostics.
ATTR_TSCORE
Adds to every row its T-score: the z-score rescaled so the mean is 50 and one standard deviation is 10. See its catalog entry.
value
The z-score rescaled to a mean of 50 and a standard deviation of 10: 50 + 10 * z, with the population standard deviation (dividing by n). 60 is one standard deviation above the mean, 40 one below.
Caveats:
- Unrelated to Student’s t or a t test: the name comes from educational testing, and the value carries no p-value.
- There are no sourced bands for reading a z-score of this kind: the familiar 1.96 / 2.58 cut-offs are critical values of a test statistic built from a standard error, and this one is built from the spread of the values instead, so it has no p-value behind it.
- It is not a percentile: 70 does not mean the 70th percentile. Only on a roughly normal field does 60 sit near the 84th percentile.
- One extreme row inflates the standard deviation and pulls every other score toward the centre; on n rows no z-score can exceed sqrt(n - 1) in size (Shiffler 1988), so on a small set a modest score may be the most extreme possible.
- The scale comes from every row that passed the filters, not from the row’s group: averaging it per group shows how each group sits against the whole, and a different filter gives different values.
- Under a row weight the mean and standard deviation are weighted (dividing by the sum of weights), the same for frequency and probability weights; a row with no usable weight adds nothing to them but is still scored.
- A row with a missing value reads 50, the same as an exactly average row, and every row reads 50 when all values are equal; an extreme row can fall below 0 or above 100.
ATTR_ZSCORE
Adds to every row its z-score: how many standard deviations the row’s value sits above or below the mean. See its catalog entry.
value
How many standard deviations the row’s value sits from the mean: (value - mean) / standard deviation, with the population standard deviation (dividing by n). 0 is exactly average, 1 is one standard deviation above it.
Sign:
+: the row’s value is above the mean-: the row’s value is below the mean
Caveats:
- There are no sourced bands for reading a z-score of this kind: the familiar 1.96 / 2.58 cut-offs are critical values of a test statistic built from a standard error, and this one is built from the spread of the values instead, so it has no p-value behind it.
- Rules such as ‘about 95% of rows lie within 2 standard deviations’ hold only for a roughly bell-shaped (normal) field; on a skewed field far more rows can sit beyond 2 on the long side.
- One extreme row inflates the standard deviation and pulls every other score toward the centre; on n rows no z-score can exceed sqrt(n - 1) in size (Shiffler 1988), so on a small set a modest score may be the most extreme possible.
- The scale comes from every row that passed the filters, not from the row’s group: averaging it per group shows how each group sits against the whole, and a different filter gives different values.
- Under a row weight the mean and standard deviation are weighted (dividing by the sum of weights), the same for frequency and probability weights; a row with no usable weight adds nothing to them but is still scored.
- A row with a missing value reads 0, the same as an exactly average row, and every row reads 0 when all values are equal (no spread); check the source field before reading a 0 as average.
Window operators
WIN_DELTA
Adds the change from a set number of rows earlier in the field’s own units, such as orders this month minus last month. See its catalog entry.
value
The change since the row periods rows earlier, in the field’s own units: current - earlier. A delta of 12 on an order count is 12 more orders than the earlier row.
Sign:
+: the current value is above the earlier one-: the current value is below the earlier one
Caveats:
- It is in the field’s units, so a change of 10 is large on a small field and trivial on a large one; read WIN_PCT_CHANGE for the change relative to the earlier value.
- On a field that is itself a percentage the delta is in percentage points: 40% to 45% reads 5, a 12.5% relative rise.
- An earlier value of 0 is a real change (the delta is the current value), unlike WIN_PCT_CHANGE, which reads null there.
- A step is one row of the result in order_by order, not a calendar period: when a period has no row (a month with no sales), the comparison silently spans the gap, so check the series has a row for every period.
- One period’s change mixes real movement with ordinary noise; judge it against how much the series usually moves from period to period.
- The first periods rows of each partition, and any row where either value is missing, read null.
WIN_DENSE_RANK
Ranks rows within each partition in the chosen order; rows with equal values share a rank and the next follows on: 1, 2, 2, 3. See its catalog entry.
value
The row’s position among the distinct order_by values within its partition, starting at 1. Rows equal on every order_by key share a rank, and the next rank follows on with no gap: 1, 2, 2, 3. The largest rank is the number of distinct values, not the number of rows.
Caveats:
- Rank 1 is the first row in order_by order: the smallest value with an ascending key, the largest with desc. Say which when reporting a ‘top’ rank.
- A rank is a position, not a distance: rank 1 and rank 2 can be almost equal or far apart, so read the values beside the ranks. Rank 3 of 5 rows is not rank 3 of 500; compare positions across partitions of different size with care.
- Rows tie only when equal on every order_by key; rows with a missing order_by value sort last and tie with each other, so they share the last rank.
- Dense rank 3 does not mean two rows came first: many rows can share ranks 1 and 2. Use WIN_RANK when the count of rows ahead matters.
WIN_EWMA
Adds a smoothed series: each new value counts for a fixed share w, the previous smoothed level for 1 - w, so older values fade. See its catalog entry.
value
A smoothed level of the field: s = alpha * current + (1 - alpha) * previous s, seeded with the partition’s first value present. A value k rows back carries weight alpha * (1 - alpha)^k, so alpha near 1 follows the latest value closely and alpha near 0 changes slowly.
Caveats:
- The seed enters at full weight, so the start of each partition leans on the first value: with alpha 0.1 it still carries about 35% of the weight ten rows later (0.9^10). Read the early rows with care.
- It lags behind a trend: on a steadily rising series the smoothed value sits below the latest value.
- alpha is given directly or as decay (alpha = 1 - decay, the carry-over share adstock work quotes); some tools set it from a span (alpha = 2 / (span + 1)), which is not accepted here, so convert first.
- A missing value reads null and the smoothing carries over it: the next value is blended in as if the gap were one step.
- The required frame is not used: the smoothing always runs from the partition’s first row.
- A step is one row of the result in order_by order, not a calendar period: when a period has no row (a month with no sales), the comparison silently spans the gap, so check the series has a row for every period.
WIN_MOVING_AVG
Adds a moving average: the mean of a field over a fixed number of neighbouring rows, smoothing short-term swings in a series. See its catalog entry.
value
The mean of the field over the rows in the frame around this one: from preceding rows before it to following rows after it, within the partition and in order_by order. preceding 6, following 0 is a trailing average of 7 rows.
Caveats:
- Near the start and end of each partition the frame runs off the edge and the mean covers fewer rows, so the first values are noisier and not comparable with the rest.
- A trailing frame (following 0) lags behind turns in the series by about half its width; a centred frame (preceding equal to following) does not lag, and an uneven one lags by half the difference, but any frame with following above 0 uses later rows, so it cannot be computed until they exist.
- Missing values are skipped, so the mean is over the values present, not the frame width; a frame with no value reads null.
- With groups, each row is one group’s figure and counts once whatever the group’s size: an average over rows is an average of group figures, not the average over all records.
- A step is one row of the result in order_by order, not a calendar period: when a period has no row (a month with no sales), the comparison silently spans the gap, so check the series has a row for every period.
WIN_PCT_CHANGE
Adds the change from a set number of rows earlier as a fraction of the earlier value: 0.05 is a 5% rise on last month. See its catalog entry.
value
The change since the row periods rows earlier, as a fraction of that earlier value: (current - earlier) / earlier. 0.05 is a 5% rise, -0.2 a 20% fall and 1 a doubling; the value is not multiplied by 100.
Sign:
+: the current value is above the earlier one (when the earlier value is positive)-: the current value is below the earlier one (when the earlier value is positive)
Caveats:
- A small earlier value makes the fraction large and jumpy: a move from 0.5 to 3 reads 5 (a 500% rise), so a big percentage on a tiny base says little; read WIN_DELTA beside it. An earlier value of exactly 0 reads null.
- On a field that can be negative the sign follows the division, not the direction: a rise from -10 to -5 reads -0.5. Use WIN_DELTA for such a field.
- Rises and falls are not symmetric: after a fall of 0.5 (50%) it takes a rise of 1 (100%) to get back, so averaging or adding period changes misstates the overall change.
- On a field that is itself a percentage, 40% to 45% reads 0.125 (a 12.5% relative rise), not 5 percentage points; WIN_DELTA gives the points.
- A step is one row of the result in order_by order, not a calendar period: when a period has no row (a month with no sales), the comparison silently spans the gap, so check the series has a row for every period.
- One period’s change mixes real movement with ordinary noise; judge it against how much the series usually moves from period to period.
- The first periods rows of each partition, and any row where either value is missing, read null.
WIN_RANK
Ranks rows within each partition in the chosen order; rows with equal values share a rank and the next skips: 1, 2, 2, 4. See its catalog entry.
value
The row’s position in order_by order within its partition, starting at 1. Rows equal on every order_by key share a rank, and the next rank skips past them: 1, 2, 2, 4. A row’s rank is 1 plus the number of rows strictly before it.
Caveats:
- Rank 1 is the first row in order_by order: the smallest value with an ascending key, the largest with desc. Say which when reporting a ‘top’ rank.
- A rank is a position, not a distance: rank 1 and rank 2 can be almost equal or far apart, so read the values beside the ranks. Rank 3 of 5 rows is not rank 3 of 500; compare positions across partitions of different size with care.
- Rows tie only when equal on every order_by key; rows with a missing order_by value sort last and tie with each other, so they share the last rank.
- Tied rows make the ranks skip numbers, so the ranks used are fewer than the rows; to count distinct values use WIN_DENSE_RANK.
WIN_RUNNING_AVG
Adds the average of a field over the rows so far in the ordered series, such as the average order value to date. See its catalog entry.
value
The mean of the field over the rows in the frame. With the usual frame (no preceding bound, following 0) it is the cumulative average: the mean of every row from the partition’s start up to and including this one.
Caveats:
- Early values rest on few rows and swing; later ones rest on the whole history and barely move, so a late cumulative average says little about the recent level (use WIN_MOVING_AVG for that).
- A frame with a following bound above 0, or none, takes in later rows too.
- Missing values are skipped, so the mean is over the values present; a frame with no value reads null.
- With groups, each row is one group’s figure and counts once whatever the group’s size: an average over rows is an average of group figures, not the average over all records.
Feature operators
FEAT_FREQUENCY_ENCODE
Replaces each category with the share of records that carry it, one number that says how common the category is. See its catalog entry.
value
The share of records carrying this record’s category, as a fraction between 0 and 1: 0.25 means one in four records with a category have this one. Every record in a category gets the same value.
Caveats:
- The share is out of records WITH a category: records missing the field read missing and are left out of the total, so shares across categories add up to 1 among those records only.
- Computed over every record of the cohort before any filter runs, so a filtered result still carries figures from the records it filtered out.
- Two different categories with the same count get the same value, so the column cannot tell them apart.
- It is a share, not a count: the same category reads differently on a larger or smaller cohort.
FEAT_LOG
Adds a column holding the natural log of 1 + the value, which pulls in a long tail of large values so a skewed field is easier to model. See its catalog entry.
value
The natural log of 1 + the value: ln(1 + x). 0 reads 0, e - 1 (about 1.72) reads 1, 9 reads about 2.3 and 99 about 4.6; each step of about 0.69 is a doubling of 1 + x, so equal steps are equal ratios of 1 + x, not equal amounts.
Sign:
+: the original value is above 0-: the original value is between -1 and 0
Caveats:
- A value of -1 or less has no log and reads missing, with no error raised: count the missing rows before trusting a model built on the column.
- The + 1 shift keeps 0 usable, but equal steps are equal ratios of 1 + x, not of x, and the shape depends on the units: on values much smaller than 1 the result is close to the value itself, on values much larger than 1 it is close to ln(x), so rescaling the field (dollars vs thousands) changes the shape of the result.
- Averages, sums and differences of the transformed column are on the new scale: transforming them back does not give the average of the original values.
- A missing input gives a missing output.
FEAT_POLY
Adds columns holding the value squared, cubed and so on up to a chosen power, so a straight-line model can bend into a curve. See its catalog entry.
value.*
One column per power k from 2 up to the degree, named <prefix>_<k> (prefix is the label, by default <field>_poly), each holding the value raised to that power: x_poly_2 is x squared, x_poly_3 is x cubed. There is no power-1 column; the original field is it.
Caveats:
- A single power column means little alone: the curve shows in a model’s coefficients on x and its powers together, and those change when the field is centred first.
- Powers of a raw field move almost in step with each other and with x, which makes a model’s coefficients unstable; centre or standardise the field before expanding it.
- Values grow fast: x = 100 to the power 10 is 1e20, and very large inputs can overflow to infinity rather than read missing.
- Even powers lose the sign: x = -3 and x = 3 both give 9 in the squared column.
- A missing input gives a missing value in every power column.
FEAT_SQRT
Adds a column holding the square root of the value, a gentler squeeze than a log for counts with a moderate long tail. See its catalog entry.
value
The square root of the value: 4 reads 2, 100 reads 10, 10,000 reads 100. Large values are pulled in more than small ones, but less sharply than a log.
Caveats:
- A negative value has no real square root and reads missing, with no error raised; 0 reads 0.
- Between 0 and 1 the root is LARGER than the value (0.25 reads 0.5), so a field of fractions is stretched, not squeezed.
- Averages, sums and differences of the transformed column are on the new scale: transforming them back does not give the average of the original values.
- A missing input gives a missing output.
FEAT_TARGET_ENCODE
Replaces each category with the average outcome of the records in that category, optionally pulled toward the overall average. See its catalog entry.
value
The average of the outcome field over records in this record’s category, in the outcome’s units; with smoothing s, (n * category average + s * overall average) / (n + s), so a category with few records sits nearer the overall average.
Caveats:
- Target leakage: every average includes the record’s own outcome and the outcomes of validation and test records, because the encoder reads no split column. Placing FEAT_TRAIN_TEST_SPLIT first does not change a single value, and filtering to split 0 afterwards keeps the leaked figures.
- A model trained on this column will look better than it is: with no smoothing, a category seen once encodes exactly its own outcome. To encode from the training rows only, compute each category’s AGG_AVERAGE of the outcome on split 0 in a separate request and map those averages onto the validation and test records yourself. For the training records, leave each record’s own outcome out (out-of-fold or leave-one-out averages) and smooth, since a split-0 average still contains it.
- Computed over every record of the cohort before any filter runs, so a filtered result still carries figures from the records it filtered out.
- Only records with both a category and an outcome count; a category whose outcomes are all missing reads the overall average, and a record missing the category reads missing.
Reading the components
Every figure Pulse reports rests on a count of the rows behind it, and Response.Components carries those counts beside the result so you can judge how much weight a figure can bear.
What n_null means for your conclusions. n counts the rows an operator actually used; n_null counts the rows it skipped because the value was missing. A figure describes the n rows only, never the full cohort. When n_null is a large share of n + n_null, ask why the values are missing before you generalise: if the missing rows differ from the rest (non-response that tracks the outcome, a sensor that fails under load), the figure is biased toward the rows that answered, and no amount of extra data fixes that. Report n with every figure, compare n_null across groups before comparing the groups themselves, and treat a group whose n is small as a rough indication rather than an estimate.
The floor every entry carries
Pulse fills these keys on every entry of a slot, ahead of the operator’s own keys.
Aggregators
| Key | Type | Emitted | Meaning |
|---|---|---|---|
n | int | always | Number of records aggregated (non-null inputs). |
n_null | int | always | Number of null inputs encountered. |
Groupers
| Key | Type | Emitted | Meaning |
|---|---|---|---|
total_n | int | always | Total records partitioned across all buckets (post-filter). |
n_null | int | always | Records that landed in the null/skip path (no bucket assignment). |
Filterers
| Key | Type | Emitted | Meaning |
|---|---|---|---|
n_in | int | always | Records that entered this filter pass. |
n_out | int | always | Records that passed (entered the downstream stage). |
n_null_input | int | always | Records where the filter input field was null at evaluation time. |
Matrix operators
| Key | Type | Emitted | Meaning |
|---|---|---|---|
n | int | always | Rows the co-moment counted: listwise the complete rows, pairwise the rows with any member present; weight-0 rows count. |
n_null | int | always | Rows skipped for missing members: listwise any member null, pairwise every member null. |
n_listwise_dropped | int | always | Rows listwise deletion dropped; 0 under pairwise. |
min_pair_n | int | when its condition holds | Pairwise only: the smallest pair N (the minimum of auxiliary.n). |
max_pair_n | int | when its condition holds | Pairwise only: the largest pair N (the maximum of auxiliary.n). |
Weighted floor keys
A slot run under a row weight adds these keys beside its floor; their absence means the slot was unweighted. n and n_null stay raw row counts under a weight.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
sum_weights | float64 | when its condition holds | Weighted slots only: the sum of the valid weights of the rows whose value was present. |
n_eff | float64 | when its condition holds | Weighted slots of kind probability only: Kish effective sample size (sum of weights)^2 / sum of squared weights. |
n_weight_invalid | int | when its condition holds | Weighted slots only: rows with a present value whose weight was invalid (null, negative, NaN/Inf, non-integer frequency) and was excluded. |
Mergeability
Each operator’s components carry a mergeability class, which says when a streamed run can report them:
- mergeable: the components fold chunk by chunk, so a streamed run reports running values as it goes.
- partial: the components fold across chunks, but the fold grows with the data (a set or map union), so Pulse may stage it until the end of the run.
- none: the components need the whole input at once (a sorted view), so a streamed run reports them only at the end.
Operator components
Each operator’s own keys, beyond the floor and the weighted floor keys.
Aggregators
AGG_AVERAGE
Mergeability: mergeable. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
sum | float64 | always | Running sum of non-null field values; combined with n to recover the mean (the weighted sum Σw·x on a weighted slot). |
sum_weighted | float64 | when its condition holds | Weighted slots only: Σw·x over the contributing rows. |
weighted_mean | float64 | when its condition holds | Weighted slots only: Σw·x / Σw (the scalar). |
m2_weighted | float64 | when its condition holds | Weighted slots only: Σw(x − mean)², the weighted second central moment. |
sum_weights_sq | float64 | when its condition holds | Weighted slots only: Σw², the Kish n_eff denominator. |
weighted_variance | float64 | when its condition holds | Weighted slots only: m2_weighted / (Σw − 1), the frequency-weights sample variance; 0 when Σw ≤ 1. |
AGG_CI_LOWER
Mergeability: mergeable. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
mean | float64 | always | Welford-running mean of the field. |
stderr | float64 | always | Standard error of the mean derived from variance and n. |
alpha | float64 | always | Significance level (1 - confidence). |
t_critical | float64 | always | Critical value used to scale the standard error. |
lower | float64 | always | Resolved lower bound of the confidence interval. |
AGG_CI_UPPER
Mergeability: mergeable. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
mean | float64 | always | Welford-running mean of the field. |
stderr | float64 | always | Standard error of the mean derived from variance and n. |
alpha | float64 | always | Significance level (1 - confidence). |
t_critical | float64 | always | Critical value used to scale the standard error. |
upper | float64 | always | Resolved upper bound of the confidence interval. |
AGG_COUNT
Mergeability: mergeable. See its catalog entry.
Only the floor keys.
AGG_DISTINCT_COUNT
Mergeability: partial. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
cardinality | int | always | Number of distinct non-null values observed. |
AGG_DISTINCT_SUM
Mergeability: partial. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
sum | float64 | always | Sum of the first value observed for each distinct key. |
distinct_count | int | always | Number of distinct keys that contributed to the sum. |
AGG_FREQUENCY
Mergeability: mergeable. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
match_count | int | always | Non-null rows equal to params.value (= the scalar); on a weighted slot their sum of weights (a float). |
share | float64 | always | match_count / n; omitted when n is 0. On a weighted slot the matching sum of weights over the sum of weights of the non-null rows (omitted when that is 0). |
AGG_KURTOSIS
Mergeability: mergeable. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
mean | float64 | always | Running mean of non-null field values. |
m2 | float64 | always | Second-moment accumulator (sum of squared deviations from the running mean). |
m3 | float64 | always | Third-moment accumulator (sum of cubed deviations from the running mean). |
m4 | float64 | always | Fourth-moment accumulator (sum of fourth-power deviations from the running mean). |
kurtosis | float64 | always | Population excess kurtosis g2 = m4 / (n * variance^2) - 3 from m2, m4 and n (not the small-sample-adjusted G2); 0 when n <= 1 or the variance is zero. Weighted: the moments are Σw-weighted and Σw replaces n. |
AGG_MAE
Mergeability: mergeable. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
loss_sum | float64 | always | Running sum of the per-row loss over contributing rows (weighted slot: of weight * loss). Additive across partitions. |
weight_sum | float64 | always | Running sum of the weights of contributing rows; the contributing-row count on an unweighted slot. Additive across partitions. |
n_scored | int | always | Rows that contributed: actual and prediction both answered (and, weighted, the weight was valid and > 0). |
n_null_prediction | int | always | Rows whose actual answered but whose prediction is null; skipped. The floor’s n / n_null key on the actual (field) only. |
mae | float64 | always | Resolved MAE: loss_sum / weight_sum (NaN, null in JSON, when no row contributed). |
AGG_MAPE
Mergeability: mergeable. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
loss_sum | float64 | always | Running sum of the per-row loss over contributing rows (weighted slot: of weight * loss). Additive across partitions. |
weight_sum | float64 | always | Running sum of the weights of contributing rows; the contributing-row count on an unweighted slot. Additive across partitions. |
n_scored | int | always | Rows that contributed: actual and prediction both answered (and, weighted, the weight was valid and > 0). |
n_null_prediction | int | always | Rows whose actual answered but whose prediction is null; skipped. The floor’s n / n_null key on the actual (field) only. |
n_zero_actual | int | always | Rows whose actual and prediction both answered but whose actual is exactly 0: their percentage error is undefined, so they are excluded from the figure. |
mape | float64 | always | Resolved MAPE fraction: loss_sum / weight_sum (NaN, null in JSON, when no row contributed). |
AGG_MAX
Mergeability: mergeable. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
max | float64 | always | Largest non-null value observed. |
AGG_MEDIAN
Mergeability: none. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
position_low | int | always | Lower index used to bracket the median in the sorted value set (weighted: the expanded-index floor of 0.5·(Σw − 1), probability weights rescaled to Σw = n). |
position_high | int | always | Upper index used to bracket the median in the sorted value set (weighted: the expanded-index ceiling of 0.5·(Σw − 1), probability weights rescaled to Σw = n). |
median | float64 | always | Resolved median value (linear interpolation between the bracketing positions). |
AGG_MIN
Mergeability: mergeable. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
min | float64 | always | Smallest non-null value observed. |
AGG_MODE
Mergeability: partial. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
value | any | always | Most-frequent value observed (smallest-value tie-break). |
count | int | always | Row count of the modal value; on a weighted slot its sum of weights (a float). |
distinct_count | int | always | Number of distinct values observed. |
tie_count | int | always | Number of values tied with the mode at the same maximum count. |
AGG_MODE_COUNT
Mergeability: partial. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
distinct_count | int | always | Number of distinct values observed. |
mode_value | any | always | Most-frequent value — the largest sum of weights on a weighted slot (ties broken by the smallest value, matching AGG_MODE). |
mode_count | int | always | Row count of the modal value; on a weighted slot its sum of weights (a float). |
AGG_NULL_COUNT
Mergeability: mergeable. See its catalog entry.
Only the floor keys.
AGG_PERCENTILE
Mergeability: none. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
p | float64 | always | Percentile requested, in [0, 100]. |
position | int | always | Index into the sorted value set used to resolve the percentile (weighted: the expanded-index floor of p·(Σw − 1), probability weights rescaled to Σw = n). |
lower | float64 | always | Lower bracketing value used during interpolation. |
upper | float64 | always | Upper bracketing value used during interpolation. |
method | string | always | Interpolation method (e.g. “linear”). |
value | float64 | always | Resolved percentile value. |
AGG_RANGE
Mergeability: mergeable. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
min | float64 | always | Smallest non-null value observed. |
max | float64 | always | Largest non-null value observed. |
AGG_RATIO
Mergeability: mergeable. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
numerator | float64 | always | Running sum of the numerator field (weighted slot: of weight * numerator). |
denominator | float64 | always | Running sum of the denominator field (weighted slot: of weight * denominator). |
ratio | float64 | always | Resolved ratio: numerator / denominator (NaN, null in JSON, when denominator is zero). |
AGG_RMSE
Mergeability: mergeable. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
loss_sum | float64 | always | Running sum of the per-row loss over contributing rows (weighted slot: of weight * loss). Additive across partitions. |
weight_sum | float64 | always | Running sum of the weights of contributing rows; the contributing-row count on an unweighted slot. Additive across partitions. |
n_scored | int | always | Rows that contributed: actual and prediction both answered (and, weighted, the weight was valid and > 0). |
n_null_prediction | int | always | Rows whose actual answered but whose prediction is null; skipped. The floor’s n / n_null key on the actual (field) only. |
mse | float64 | always | Mean squared error loss_sum / weight_sum; rmse is its square root (NaN, null in JSON, when no row contributed). |
rmse | float64 | always | Resolved RMSE: sqrt(mse). |
AGG_SET_CARDINALITY_AVG
Mergeability: mergeable. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
sum_cardinality | int | always | Sum of popcounts across contributing rows; weighted slot: sum(w * popcount) (a float). |
avg_cardinality | float64 | always | Average popcount per contributing row (sum_cardinality / n; weighted slot: / sum of weights). |
AGG_SET_CARDINALITY_SUM
Mergeability: mergeable. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
sum_cardinality | int | always | Sum of popcounts across contributing rows — total label selections seen; weighted slot: sum(w * popcount) (a float). |
AGG_SET_DISTINCT_VALUES
Mergeability: mergeable. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
mask_union | []uint64 | always | Bitwise OR of every contributing row’s set mask, as four little-endian 64-bit words (words[0] = bits 0-63). Fixed length at every set rung, so a 256-bit mask is carried whole and a consumer indexes words[bit/64] without knowing the column width. |
popcount | int | always | Number of bits set in mask_union (count of distinct labels observed). |
labels | []string | always | Resolved dictionary labels for every bit set in mask_union. |
AGG_SET_FREQUENCY
Mergeability: partial. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
total_label_observations | int | always | Sum of popcounts across contributing rows (total label selections seen); weighted slot: the weight-summed selections (a float). |
distinct_labels | int | always | Number of distinct labels observed at least once (weighted slot: with a positive weight sum). |
per_label_count | map[string]int | always | Per-label row count: how many rows had each label selected; weighted slot: each label’s sum of weights (float values). |
AGG_SET_INTERSECTION
Mergeability: mergeable. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
mask_intersection | []uint64 | always | Bitwise AND of every contributing row’s set mask, as four little-endian 64-bit words (words[0] = bits 0-63). Fixed length at every set rung, so a 256-bit mask is carried whole and a consumer indexes words[bit/64] without knowing the column width. |
popcount | int | always | Number of bits set in mask_intersection. |
labels | []string | always | Resolved dictionary labels for every bit set in mask_intersection. |
AGG_SET_UNION
Mergeability: mergeable. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
mask_union | []uint64 | always | Bitwise OR of every contributing row’s set mask, as four little-endian 64-bit words (words[0] = bits 0-63). Fixed length at every set rung, so a 256-bit mask is carried whole and a consumer indexes words[bit/64] without knowing the column width. |
popcount | int | always | Number of bits set in mask_union. |
labels | []string | always | Resolved dictionary labels for every bit set in mask_union. |
AGG_SKEWNESS
Mergeability: mergeable. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
mean | float64 | always | Running mean of non-null field values. |
m2 | float64 | always | Second-moment accumulator (sum of squared deviations from the running mean). |
m3 | float64 | always | Third-moment accumulator (sum of cubed deviations from the running mean). |
skewness | float64 | always | Population skewness g1 = m3 / (n * sd^3) from m2, m3 and n (not the small-sample-adjusted G1); 0 when n <= 1 or the variance is zero. Weighted: the moments are Σw-weighted and Σw replaces n. |
AGG_STDDEV
Mergeability: mergeable. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
mean | float64 | always | Running Welford mean of non-null field values. |
m2 | float64 | always | Welford second-moment accumulator (sum of squared deviations from the running mean). |
variance | float64 | always | Population variance derived from m2 / n. |
stddev | float64 | always | Population standard deviation (square root of variance). |
AGG_SUM
Mergeability: mergeable. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
sum | float64 | always | Running sum of non-null field values. |
AGG_VARIANCE
Mergeability: mergeable. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
mean | float64 | always | Running Welford mean of non-null field values. |
m2 | float64 | always | Welford second-moment accumulator (sum of squared deviations from the running mean). |
variance | float64 | always | Population variance derived from m2 / n. |
AGG_WEIGHTED_MEAN
Mergeability: mergeable. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
sum_weighted | float64 | always | Running sum of (field * weight) across contributing rows. |
weighted_mean | float64 | always | Resolved weighted mean: sum_weighted / sum_weights. |
m2_weighted | float64 | always | Weighted second central moment: sum(weight * (field - weighted_mean)^2). |
sum_weights_sq | float64 | always | Running sum of squared weights; feeds the Kish effective sample size. |
weighted_variance | float64 | always | Frequency-weights variance m2_weighted / (sum_weights - 1); 0 when sum_weights <= 1. |
AGG_WELFORD
Mergeability: mergeable. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
mean | float64 | always | Running Welford mean of non-null field values. |
m2 | float64 | always | Welford second-moment accumulator (sum of squared deviations from the running mean). |
variance | float64 | always | Unbiased sample variance derived from m2 / (n-1). |
stddev | float64 | always | Sample standard deviation (square root of variance). |
AGG_ZSCORE
Mergeability: mergeable. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
pop_mean | float64 | always | Population mean used as the z-score center. |
pop_stddev | float64 | always | Population standard deviation used as the z-score scale. |
target_value | float64 | always | Value being standardized against the population summary. |
zscore | float64 | always | Standardized score: (target_value - pop_mean) / pop_stddev. |
Groupers
GROUP_CATEGORY
Mergeability: mergeable. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
dict_size | int | always | Number of distinct values observed across all buckets (cardinality of the partition key). |
buckets | []bucket | always | Per-bucket records, ordered by emission; each entry carries {key, label, count}. |
GROUP_DATE
Mergeability: mergeable. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
granularity | string | always | Calendar component used to bucket (hour, day, day_of_week, week, month, quarter, year). |
range_start | string | always | ISO date string of the earliest period observed (YYYY-MM-DDTHH for component=hour). |
range_end | string | always | ISO date string of the latest period observed (YYYY-MM-DDTHH for component=hour). |
n_buckets | int | always | Number of distinct calendar buckets observed. |
buckets | []bucket | always | Per-bucket records, ordered by emission; each entry carries {key, period_start, period_end, count}. |
GROUP_DATE_RANGES
Mergeability: mergeable. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
n_ranges | int | always | Number of configured labeled date ranges. |
unmatched_label | string | always | Bucket label used for rows outside every configured range. |
buckets | []bucket | always | Per-bucket records in supplied range order (unmatched last when observed); each entry carries {key, label, count}. |
GROUP_KMEANS
Mergeability: none. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
k | int | always | Cluster count (params.k). |
seed | int | always | The resolved PRNG seed (params.seed 0 resolves to the fixed default). |
n_init | int | always | Seeded starts run (params.n_init). |
max_iter | int | always | Lloyd iteration cap per start (params.max_iter). |
standardize | bool | always | Whether members were z-scored before clustering. |
members | []string | always | The resolved member columns, axis order. |
center | []float64 | always | Per-member standardization mean (0 when standardize is false). |
scale | []float64 | always | Per-member standardization sd (1 when standardize is false or a member has no spread). |
distinct_points | int | always | Distinct member tuples among the clustered rows. |
buckets | []bucket | always | Per-cluster records in emission (size) order; each carries {key, count, size, within_ss, centroid, centroid_standardized}: count is rows, size the (frequency-weighted) mass, centroid in original units. |
total_ss | float64 | always | Total sum of squares about the grand centroid, in the clustering space. |
within_ss | float64 | always | Total within-cluster sum of squares. |
between_ss | float64 | always | total_ss − within_ss. |
between_ss_ratio | float64 | always | between_ss / total_ss (null when total_ss is 0). |
iterations | int | always | Lloyd iterations the winning start ran. |
converged | bool | always | Whether the winning start reached a fixed point before max_iter. |
best_init | int | always | The winning start, 1-based. |
GROUP_QUANTILE
Mergeability: none. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
n_quantiles | int | always | Number of equal-population quantile buckets requested (Group.Interval). |
method | string | always | Quantile interpolation method (e.g. “linear”). |
edges | []float64 | always | Sorted cutpoints separating adjacent quantile buckets. |
buckets | []bucket | always | Per-bucket records, ordered by emission; each entry carries {key, low, high, count}. |
GROUP_RANGE
Mergeability: mergeable. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
interval | float64 | always | Bucket width on the value axis (Group.Interval). |
range_min | float64 | always | Smallest non-null value observed across all buckets. |
range_max | float64 | always | Largest non-null value observed across all buckets. |
n_buckets | int | always | Number of half-open range buckets emitted. |
edges | []float64 | always | Sorted cutpoints separating adjacent range buckets. |
buckets | []bucket | always | Per-bucket records, ordered by emission; each entry carries {key, low, high, count}. |
underflow_count | int | always | Records below the lowest configured bucket edge. |
overflow_count | int | always | Records at or above the highest configured bucket edge. |
GROUP_ROUNDED
Mergeability: mergeable. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
precision | float64 | always | Rounding increment used as the bucket scalar (Group.Interval). |
edges | []float64 | always | Sorted rounded scalars separating adjacent buckets. |
buckets | []bucket | always | Per-bucket records, ordered by emission; each entry carries {key, low, high, count}. |
GROUP_SET_PER_ELEMENT
Mergeability: mergeable. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
total_label_observations | int | always | Sum of buckets[].count across the partition; may exceed total_n because each row fans into one bucket per selected label. |
buckets | []bucket | always | Per-label records, ordered by emission; each entry carries {key, label, count, dict_index}. |
GROUP_SET_VALUE
Mergeability: mergeable. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
n_empty_mask | int | always | Records whose set mask was the empty selection (zero-bit mask is a valid distinct bucket from null). |
buckets | []bucket | always | Per-bucket records, ordered by emission; each entry carries {key, count, labels} plus exactly one of mask (uint64, when the selection fits 64 bits) or mask_words ([]uint64, little-endian low word first, when a set_u128/set_u256 selection reaches bit 64 or above). The two are mutually exclusive: a uint64 mask carrying only the low word of a wide selection would be a plausible wrong number. |
Filterers
FILTER_DATE_RANGES
Mergeability: mergeable. See its catalog entry.
Only the floor keys.
FILTER_EXCLUDE
Mergeability: mergeable. See its catalog entry.
Only the floor keys.
FILTER_EXPRESSION
Mergeability: mergeable. See its catalog entry.
Only the floor keys.
FILTER_FALSE
Mergeability: mergeable. See its catalog entry.
Only the floor keys.
FILTER_INCLUDE
Mergeability: mergeable. See its catalog entry.
Only the floor keys.
FILTER_NULL
Mergeability: mergeable. See its catalog entry.
Only the floor keys.
FILTER_RANGE
Mergeability: mergeable. See its catalog entry.
Only the floor keys.
FILTER_SET_CONTAINS_ALL
Mergeability: mergeable. See its catalog entry.
Only the floor keys.
FILTER_SET_CONTAINS_ANY
Mergeability: mergeable. See its catalog entry.
Only the floor keys.
FILTER_SET_CONTAINS_NONE
Mergeability: mergeable. See its catalog entry.
Only the floor keys.
FILTER_SET_EQUALS
Mergeability: mergeable. See its catalog entry.
Only the floor keys.
FILTER_TRUE
Mergeability: mergeable. See its catalog entry.
Only the floor keys.
Matrix operators
MAT_COLLINEARITY
Mergeability: mergeable. See its catalog entry.
Only the floor keys.
MAT_CORRELATION
Mergeability: mergeable. See its catalog entry.
Only the floor keys.
MAT_COVARIANCE
Mergeability: mergeable. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
ddof | int | always | The delta degrees of freedom the covariance used (params.ddof, default 1). |
MAT_DISTANCE
Mergeability: mergeable. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
metric | string | always | The metric the distances use (params.metric). |
standardize | bool | always | Whether the members were z-scored first (params.standardize). |
buckets | int | always | Buckets compared (k). |
n_null_key | int | always | Filter-passing rows the between grouper placed in no bucket (a null key, an include rejection, a GROUP_KMEANS row with a null member); excluded from every figure. |
MAT_FACTOR
Mergeability: mergeable. See its catalog entry.
Only the floor keys.
MAT_PARTIAL_CORRELATION
Mergeability: mergeable. See its catalog entry.
Only the floor keys.
MAT_PCA
Mergeability: mergeable. See its catalog entry.
Only the floor keys.
MAT_RELIABILITY
Mergeability: mergeable. See its catalog entry.
| Key | Type | Emitted | Meaning |
|---|---|---|---|
iterations | int | when its condition holds | Coordinate sweeps the one-factor minres fit behind omega ran (cap 1000); absent when no fit ran (2 items, an undefined or unrepaired non-PSD input). |
converged | bool | when its condition holds | Whether the minres fit met its tolerance (1e-12) within the cap; false comes with PULSE_MATRIX_NOT_CONVERGED. Absent when no fit ran. |
Glossary
Plain-language definitions of the terms the guidance relies on, sorted by ID. Catalog entries link here for every term they cite.
adjusted-r-squared
R-squared corrected for the number of predictors: it rises only when an added predictor’s t value exceeds 1 in size, which a useless predictor still does about one time in three.
Why care: Use it to compare models with different numbers of predictors on the same rows. It can fall below zero when the predictors fit worse than chance would predict.
Forms: adjusted r-squared, adjusted r squared, adjusted r², adj_r2
See also: r-squared, overfitting
alpha
The p-value threshold you pick before looking at the data, below which you call a result significant.
Why care: It is the false-alarm rate you accept when there is truly no effect: the share of such tests you would wrongly call significant. Pick it up front; moving it after seeing results defeats its purpose.
Forms: alpha, significance level
See also: p-value, statistical-significance, multiple-comparisons
baseline
The reference a figure is compared with: a chosen period, a named group, a population, a total or another request’s result.
Why care: Every comparison is only as meaningful as its baseline. Say which one you used, and pick one that is stable and large enough to compare against.
See also: index-value, margin
centroid
The centre point of a cluster: the average of every field across the rows in it.
Why care: Comparing centroids is how you describe what makes one segment different from another.
Forms: centroid, centroids
See also: distance, similarity
chi-square
A test statistic that adds up how far observed counts in each cell sit from the counts you would expect if nothing were going on.
Why care: It measures the evidence that two categorical fields are related, but not how strongly; pair it with Cramer’s V or phi for the size.
Forms: chi-square, chi-squared, chi square, chi squared
See also: test-statistic, independence, cramers-v, cross-tabulation
cohens-d
The difference between two means expressed in standard deviations.
Why care: It makes differences comparable across measures with different units: a gap of half a standard deviation is the same standardized size on any scale, though whether it matters depends on the measure.
Forms: cohen’s d, cohens d, cohens_d
See also: effect-size, standard-deviation, t-statistic
cohens-h
The difference between two proportions, put on a scale that treats a change near 0% or 100% as bigger than the same change near 50%.
Why care: Comparing raw percentage-point gaps can mislead near the edges; this puts proportion differences on an even footing.
Forms: cohen’s h, cohens h, cohens_h
See also: effect-size, cohens-d
confidence-interval
A range built by a method that, over repeated samples, captures the true value a stated share of the time (such as 95%); any single interval either contains it or not.
Why care: It shows the uncertainty in plain units. A wide interval means you know less than the single estimate suggests.
Forms: confidence interval, confidence intervals
See also: standard-error, p-value, sample-size
continuity-correction
A half-step adjustment used when a smooth curve approximates a statistic that moves in whole steps, such as a count or a rank sum.
Why care: It keeps approximate p-values from coming out too small in small samples. Tools differ on whether they apply it, which explains small mismatches between them.
Forms: continuity correction, yates correction, yates’ correction
See also: p-value, exact-test
correlation
How closely two measures move together in a straight line, from -1 (opposite) through 0 (no straight-line link) to 1 (in lockstep).
Why care: It shows association, not cause. It also misses curved relationships and can be distorted by a few outliers.
Forms: correlation, correlations, correlation coefficient
See also: covariance, r-squared, outlier, independence
covariance
How two numeric fields vary together: positive when they rise together, negative when one rises as the other falls.
Why care: Its size depends on the units, so it is hard to read on its own; correlation is the unit-free version.
Forms: covariance, covariances
See also: correlation, variance
cramers-v
The strength of the relationship between two categorical fields, from 0 (unrelated) to 1 (one fully determines the other).
Why care: A chi-square p-value weighs the evidence that the fields are related; Cramer’s V says how strongly, and unlike chi-square it does not grow with the number of rows. Compare V only between tables of the same shape.
Forms: cramer’s v, cramers v, cramers_v
See also: chi-square, phi, effect-size, cross-tabulation
credible-interval
A Bayesian range that holds a parameter with a stated probability, given the prior, the model and the data.
Why care: Unlike a confidence interval it is a direct probability statement about the parameter, but that probability is conditional on the prior and the model; change either and the interval changes.
Forms: credible interval, credible intervals
See also: posterior, prior, confidence-interval
cross-tabulation
A table that counts rows for every combination of two categorical fields, one along the rows and one along the columns.
Why care: It is the simplest way to see whether two categories go together; a chi-square test then says how surprising the pattern would be if the two fields were unrelated.
Forms: cross-tabulation, contingency table, contingency tables
See also: chi-square, cramers-v
degrees-of-freedom
How many independent pieces of information a statistic was built from, usually the row or group count minus the things estimated along the way.
Why care: Tests need it to turn a statistic into a p-value. Very few degrees of freedom means the result rests on thin evidence.
Forms: degrees of freedom, degree of freedom
See also: test-statistic, sample-size
deviance
A measure of how far a fitted model’s predictions sit from the observed outcomes, on a log-likelihood scale; lower means a closer fit.
Why care: On its own the number has no fixed scale. Compare it with the null deviance (the intercept-only model) or with another model fitted to the same rows and outcome.
Forms: deviance, deviances, null deviance
See also: generalized-linear-model, pseudo-r-squared
distance
A number for how far apart two rows are across several fields; zero means identical.
Why care: Clustering groups rows by distance, so fields with large units dominate unless you standardise them first.
See also: similarity, centroid, z-score
effect-size
A number that says how BIG a difference or relationship is, on a scale that does not grow just because you have more rows.
Why care: It says how big the difference is, while the p-value says how surprising the data would be if there were no difference. Whether that size matters depends on your context. Report both.
Forms: effect size, effect sizes
See also: p-value, statistical-significance, cohens-d, eta-squared, cramers-v
effective-sample-size
The number of unweighted rows that would give the same precision as your weighted sample.
Why care: Uneven weights cost precision: a weighted sample of 1,000 can carry the information of far fewer rows.
Forms: effective sample size, effective n
See also: weighting, sample-size, standard-error
eigenvalue
How much of the total variation in a set of fields one underlying dimension accounts for.
Why care: It ranks the dimensions a principal component analysis finds; dimensions with small eigenvalues explain little and are usually dropped.
Forms: eigenvalue, eigenvalues
See also: principal-component, variance
epsilon-squared
The rank-based counterpart of eta-squared, used with Kruskal-Wallis: how much of the ordering of rows is explained by group.
Why care: It gives a size to a non-parametric group comparison, which otherwise only yields a p-value.
Forms: epsilon squared, epsilon-squared, epsilon_squared
See also: eta-squared, non-parametric, rank
eta-squared
The share of the total variation in an outcome that is explained by which group a row belongs to.
Why care: It turns an ANOVA into “how much does group membership matter”. It runs a little high in small samples; omega-squared corrects for that.
Forms: eta squared, eta-squared, eta_squared
See also: effect-size, omega-squared, partial-eta-squared, f-statistic
exact-test
A test whose p-value is computed exactly from every possible arrangement of the data rather than from a large-sample approximation.
Why care: It stays valid with tiny counts where approximations such as chi-square break down. The cost is computation, so it suits small tables.
Forms: exact test, exact tests
See also: p-value, chi-square, continuity-correction
f-statistic
The test statistic of an ANOVA: how much group means spread apart compared with how much rows spread within groups.
Why care: Values near 1 mean the groups differ about as much as noise would make them; larger values are stronger evidence against all group means being equal. Read the p-value to judge how large is large, and an effect size for how big the difference is.
Forms: f-statistic, f statistic, f-ratio
See also: test-statistic, variance, eta-squared, post-hoc-test
factor
An unobserved quality, such as satisfaction, that several measured fields are assumed to reflect together.
Why care: Grouping questions into factors lets you measure something no single question captures well.
Forms: latent factor, latent factors, factor analysis
See also: loading, principal-component, reliability
false-discovery-rate
The expected share of false positives among the results you call significant.
Why care: Holding it at alpha (Benjamini-Hochberg, Benjamini-Yekutieli) keeps more power than family-wise control when many tests run, but accepts that a few of the hits may be false.
Forms: false discovery rate, fdr
See also: multiple-comparisons, family-wise-error, alpha
family-wise-error
The chance that a group of tests produces at least one false positive, counted over the whole group rather than test by test.
Why care: Holding it at alpha (Bonferroni, Holm) makes any single significant hit trustworthy, at the cost of missing real but modest effects when the group is large.
Forms: family-wise error, family-wise error rate, familywise error, fwer
See also: multiple-comparisons, false-discovery-rate, alpha
generalized-linear-model
A regression for outcomes that are not plain numbers on a line, such as yes/no or counts: a link function ties the predictors to the outcome’s expected value.
Why care: It keeps predictions in range (probabilities between zero and one, counts above zero), but its coefficients are on the link scale, so read them through the link before talking about sizes.
Forms: generalized linear model, generalized linear models, glm, glms
See also: link-function, logistic-regression, deviance, regression-coefficient
goodness-of-fit
How well observed data match a stated distribution or a set of expected counts.
Why care: A goodness-of-fit test can only flag a mismatch. A large p-value does not show the data follow the distribution, and with few rows real departures go undetected.
Forms: goodness of fit, goodness-of-fit
See also: normal-distribution, chi-square, statistical-power
heteroscedasticity
Residuals whose spread changes across the data, for example growing as the predicted value grows, instead of staying constant.
Why care: Classic regression standard errors assume a constant spread; when it changes, they and the p-values built on them can be off in either direction, even though the coefficients themselves stay unbiased.
Forms: heteroscedasticity, heteroskedasticity, heteroscedastic, non-constant variance
See also: residual, standard-error, homogeneity-of-variance
homogeneity-of-variance
The assumption that every group has roughly the same spread (variance) around its own mean.
Why care: Classic ANOVA and Tukey’s test rely on it; when spreads differ, especially with unequal group sizes, their p-values drift. Welch’s versions do not need it.
Forms: homogeneity of variance, equal variances, equal variance, homoscedasticity
See also: variance, f-statistic, sphericity
independence
Two things are independent when knowing one tells you nothing about the other; rows are independent when no row influences another.
Why care: Most tests assume independent rows. Repeated answers from the same person break it, and need a paired or repeated-measures test instead.
See also: chi-square, correlation, paired-data, repeated-measures
index-value
A ratio to a baseline scaled so the baseline is 100: 120 is 20% above it, 80 is 20% below it.
Why care: It puts groups of very different sizes on one scale, but it hides the absolute gap and swings wildly when the baseline is small.
Forms: index value, index values
See also: baseline, percentage-point
kendall-tau
A rank correlation from -1 to 1 based on how many pairs of rows are in the same order on both fields versus the opposite order.
Why care: It copes well with small samples and many ties. It usually comes out smaller than Spearman’s rho on the same data, so do not compare the two directly.
Forms: kendall’s tau, kendall tau, tau-b, tau_b
See also: correlation, spearman-rho, rank, ties
kurtosis
How heavy a distribution’s tails are compared with the normal distribution: how prone it is to extreme values.
Why care: Heavy tails mean outliers turn up more often than a bell curve would predict, which can mislead tests that assume normality.
Forms: kurtosis
See also: skew, normal-distribution, outlier
link-function
The transformation a generalized linear model applies to the outcome’s expected value before relating it to the predictors, such as the log or the log-odds.
Why care: A coefficient moves the outcome on the link scale, not in the outcome’s own units: under a log link it multiplies the expected value, under the logit it multiplies the odds.
Forms: link function, link functions
See also: generalized-linear-model, logistic-regression, odds-ratio
listwise-deletion
Dropping a whole row from an analysis when any of the fields it uses is missing.
Why care: Every figure then comes from the same rows, which keeps them consistent, but you can lose many rows when several fields each have gaps.
Forms: listwise deletion
See also: pairwise-deletion, missing-value
loading
How strongly one original field is tied to a component or factor.
Why care: Loadings are how you name a component: the fields with the largest loadings tell you what it represents.
Forms: factor loading, factor loadings, component loading, component loadings
See also: principal-component, factor
logistic-regression
A regression for a yes/no outcome that models the log-odds of yes as a straight-line function of the predictors.
Why care: Each coefficient is a change in log-odds; raised to the power e it becomes an odds ratio. Odds are not probabilities, so a constant odds ratio means different probability shifts at different starting points.
Forms: logistic regression, logistic regressions, logit, log-odds
See also: generalized-linear-model, odds-ratio, link-function
margin
In a cross-tabulation, the figure for a whole row, a whole column or the whole table (the grand total), shown along its edges.
Why care: Cells are often read against their margin. The margin is whatever the cell aggregator gives for the full row or column: a total for counts and sums, an overall average for means.
Forms: margin, margins, marginal total, marginal totals
See also: cross-tabulation, baseline
mean
The average: add the values up and divide by how many there are.
Why care: It is the usual “typical value”, but a few extreme values can drag it far from where most rows sit; compare it with the median.
See also: median, outlier, skew
median
The middle value once the values are sorted: half the rows sit below it, half above.
Why care: It barely moves when a few extreme values appear, so it is a safer “typical value” for skewed things like income or response time.
See also: mean, percentile, skew
missing-value
A field with no recorded value for a row (a null).
Why care: How missing values are handled changes results: dropping them can bias a sample if they are not missing at random.
See also: listwise-deletion, pairwise-deletion
monotonic-trend
A pattern that keeps going one way, always rising or always falling, though not necessarily in a straight line.
Why care: Rank-based measures such as Spearman’s rho, Kendall’s tau and the Mann-Kendall trend test detect this kind of pattern; they can miss a link that rises and then falls.
Forms: monotonic, monotone, monotonic trend
See also: spearman-rho, kendall-tau, correlation
multicollinearity
Predictors in a model that are strongly related to each other.
Why care: The model can still predict well, but it cannot tell the overlapping predictors’ effects apart, so their coefficients become unstable and hard to trust.
Forms: multicollinearity, collinearity, collinear
See also: regression-coefficient, correlation
multiple-comparisons
Running many tests at once, which raises the chance that at least one comes out significant by luck alone.
Why care: Twenty tests at the usual alpha will on average flag one false positive even when nothing is going on. Adjust for it, or treat a lone hit with suspicion.
Forms: multiple comparisons, multiple testing
See also: p-value, alpha, post-hoc-test, family-wise-error, false-discovery-rate
non-parametric
A method that works on ranks or counts instead of assuming the data follow a particular shape such as the normal distribution.
Why care: Reach for one when data are skewed, have outliers or are ordinal ratings. It is robust, at the cost of some power when the data really are normal.
Forms: non-parametric, nonparametric, distribution-free
See also: rank, normal-distribution, outlier
normal-distribution
The symmetric bell-shaped pattern many measurements roughly follow, with most values near the mean and fewer further out.
Why care: Many tests assume it. Strong skew or heavy tails make those tests less trustworthy; a non-parametric test is the usual fallback.
Forms: normal distribution, normally distributed, bell curve
See also: skew, kurtosis, non-parametric, z-score
null-hypothesis
The boring default a test assumes until the data argue otherwise, usually “there is no difference” or “there is no relationship”.
Why care: A test only measures how surprising your data would be if the null were true. Failing to reject it is not proof that it is true.
Forms: null hypothesis, null hypotheses
See also: p-value, statistical-significance, test-statistic
odds-ratio
How many times higher the odds of an outcome are in one group than in another; 1 means no difference.
Why care: Logistic models report effects this way. Odds are not probabilities, so an odds ratio of 2 does not mean “twice as likely” unless the outcome is rare.
Forms: odds ratio, odds ratios
See also: regression-coefficient, effect-size
omega-squared
An estimate of the share of variation explained by groups, adjusted so it does not overstate the effect the way eta-squared does in small samples.
Why care: Prefer it when groups are small; it is the less optimistic, more honest version of eta-squared.
Forms: omega squared, omega-squared, omega_squared
See also: eta-squared, effect-size
outlier
A value far away from the rest of the data.
Why care: Outliers can be errors or the most interesting rows. Either way they can pull means and correlations badly, so look before you drop them.
See also: z-score, percentile, median, non-parametric
overdispersion
Count data that vary more than a Poisson model allows, which assumes the variance equals the mean.
Why care: It is common in real counts. A model that ignores it reports standard errors that are too small and p-values that look stronger than the data support.
Forms: overdispersion, overdispersed, over-dispersion
See also: generalized-linear-model, standard-error, variance
overfitting
When a model learns the noise in its data instead of the real pattern.
Why care: An overfit model looks excellent on the data it was built from and disappoints on new data. Fewer predictors or more rows help.
Forms: overfitting, overfit, over-fitting
See also: r-squared, sample-size
p-value
The probability of a result at least this extreme if the test’s null hypothesis (no difference, no link, a normal shape…) and its assumptions were all true.
Why care: Small values (below your alpha) mean the data would be surprising if there were no difference, which counts as evidence against ‘no difference’. It is not the chance that the difference is real, and it says nothing about how BIG it is.
Forms: p-value, p-values, p value, p values
See also: effect-size, alpha, multiple-comparisons
paired-data
Two measurements that belong together, such as the same customer before and after a change, held on one row.
Why care: Pairing removes the differences between subjects, so paired tests are more sensitive. Treating paired data as two separate groups wastes that and breaks the independence assumption.
Forms: paired data, paired samples, paired measurements
See also: independence, repeated-measures
pairwise-deletion
Using every row that has both fields of a given pair, so each pair of fields may rest on a different set of rows.
Why care: It keeps more data than listwise deletion, but figures computed from different rows may not fit together consistently.
Forms: pairwise deletion
See also: listwise-deletion, missing-value, correlation
partial-correlation
The correlation between two measures after the straight-line part of chosen other measures has been taken out of both.
Why care: It separates a direct link from one that appears only because both measures follow a third; like any correlation it shows association, not cause.
Forms: partial correlation, partial correlations, partial r
See also: correlation, multicollinearity
partial-eta-squared
Eta-squared computed after setting aside variation explained by other factors, such as differences between the people measured repeatedly.
Why care: Used for repeated-measures designs. It is not comparable with plain eta-squared, so compare like with like.
Forms: partial eta squared, partial eta-squared, partial_eta_squared
See also: eta-squared, effect-size
percentage-point
The plain difference between two percentages: 40% to 45% is a rise of 5 percentage points, which is a 12.5% relative rise.
Why care: Mixing up points and percent changes overstates or understates a change; say which one a figure is.
Forms: percentage point, percentage points
See also: index-value
percentile
The value at or below which about a given share of rows fall (about 90% sit at or below the 90th); a percentile rank is the reverse, a row’s position as a percentage.
Why care: Percentiles describe a spread without assuming any shape, and are the honest way to report things like “most users wait less than X”.
See also: median, outlier, rank
phi
The strength of the relationship in a two-by-two table, from 0 (unrelated) to 1 (perfectly related).
Why care: It is the yes/no-by-yes/no special case of Cramer’s V, and reads like a correlation between two binary fields.
Forms: phi, phi coefficient
See also: cramers-v, chi-square, correlation
post-hoc-test
A follow-up test run after an overall test is significant, to find which specific pairs of groups differ.
Why care: An overall test only says “some group differs”. Post-hoc tests say which ones, and they correct for running many pairwise comparisons.
Forms: post-hoc, post hoc
See also: multiple-comparisons, f-statistic
posterior
In a Bayesian analysis, the distribution of a parameter after combining the prior with the data, given the model.
Why care: Point estimates (such as the posterior mean) and credible intervals are summaries of it. It is only as trustworthy as the prior and the model that produced it.
Forms: posterior, posterior distribution, posterior mean, posterior means
See also: prior, credible-interval
principal-component
A new combined measure built from many correlated fields so that a few of them capture most of the variation.
Why care: It shrinks dozens of overlapping ratings into a handful of summary scores, making patterns easier to see and model.
Forms: principal component, principal components, pca
See also: eigenvalue, loading, factor
prior
In a Bayesian analysis, the distribution that states what values a parameter is believed to take before the data are seen.
Why care: The result blends the prior with the data. A weak prior lets the data dominate; a strong or badly chosen one pulls estimates toward it, so always report which prior was used.
Forms: prior distribution, prior distributions, prior belief, prior beliefs
See also: posterior, credible-interval
probit
A transform that maps a proportion to the z-score at which the normal curve has that share below it: 0.5 maps to 0, 0.84 to about 1.
Why care: It stretches proportions near 0 and 1, where small changes in share matter more; tests built on it are a convention some survey tools follow.
Forms: probit
See also: z-score, normal-distribution
pseudo-r-squared
A fit summary for models such as logistic regression where ordinary R-squared does not apply; Pulse’s is one minus deviance over null deviance.
Why care: It is not a share of variance explained and runs much lower than R-squared for an equally useful model, so R-squared rules of thumb do not carry over. Compare it only across models of the same outcome.
Forms: pseudo-r-squared, pseudo r-squared, pseudo r squared, pseudo-r², pseudo_r2, mcfadden
See also: deviance, r-squared, logistic-regression
r-squared
The share of the variation in an outcome that a model explains, from 0 (nothing) to 1 (everything).
Why care: Higher means the model tracks the outcome more closely, but it always rises as you add predictors, even useless ones; watch for overfitting.
Forms: r-squared, r squared, r², r2, coefficient of determination
See also: residual, overfitting, correlation
raking
Adjusting weights step by step until the weighted sample matches known population totals on several characteristics at once.
Why care: It is the standard way to make a survey sample look like the population on age, region and the like, without needing every combination’s total.
Forms: raking, rake, iterative proportional fitting
See also: weighting, effective-sample-size
rank
A value’s position once all values are sorted: 1 for the smallest, 2 for the next, and so on.
Why care: Working on ranks instead of raw values makes a method far less sensitive to extreme values and usable on ordinal ratings.
See also: non-parametric, percentile, rank-biserial
rank-biserial
An effect size for rank tests: how much more often a row from one group outranks a row from the other, from -1 to 1.
Why care: It gives Mann-Whitney and Wilcoxon results a size and a direction; zero means neither side tends to rank higher.
Forms: rank-biserial, rank biserial, rank_biserial
See also: rank, non-parametric, effect-size
regression-coefficient
How much the predicted outcome changes when one predictor goes up by one unit, holding the other predictors fixed.
Why care: It is the “how much” of a driver. Its size depends on the predictor’s units, and with correlated predictors it can be unstable.
Forms: regression coefficient, regression coefficients
See also: standard-error, multicollinearity, r-squared, odds-ratio
reliability
How consistently a set of questions measures the same thing.
Why care: A scale built from unreliable questions mixes signal with noise, so averages and comparisons based on it are weaker.
See also: factor, correlation
repeated-measures
A design where the same subjects are measured several times, under different conditions or at different moments.
Why care: Measurements from one subject are related, so tests for independent groups misjudge the noise. Repeated-measures tests separate subject-to-subject differences from the effect of the condition.
Forms: repeated measures, repeated-measures, within-subject
See also: paired-data, sphericity, independence
residual
The gap between an actual value and what a model predicted for it.
Why care: Patterns in the residuals reveal what the model misses; large ones flag rows the model fits badly.
Forms: residual, residuals
rolling-mean
The average of the last few points in a series (a moving average), recomputed at every step.
Why care: It smooths out short-term swings, so a point far from its rolling mean stands out; a short window reacts fast but is jumpy.
Forms: rolling mean, rolling average, moving average
sample-size
How many rows (or respondents, or measurements) an estimate or test is based on.
Why care: Small samples give noisy estimates and weak tests; very large ones make tiny, unimportant differences significant.
See also: standard-error, statistical-power, effective-sample-size
similarity
A number for how alike two rows or items are; the mirror image of distance.
Why care: It drives “customers like this one” style matching. Different measures weigh shared absences differently, so pick one that fits your data.
See also: distance, correlation
skew
How lopsided a distribution is: a long tail to the right (positive) or to the left (negative).
Why care: Skewed data pull the mean toward the tail, so the median often describes them better, and tests assuming a normal shape suffer.
Forms: skew, skewed, skewness
See also: normal-distribution, median, kurtosis
spearman-rho
A rank correlation from -1 to 1: Pearson’s correlation computed on the ranks of the values instead of the values themselves.
Why care: It captures any relationship that keeps going one way, straight or curved, and resists outliers; it misses U-shaped links.
Forms: spearman’s rho, spearman rho, spearman correlation
See also: correlation, kendall-tau, rank, monotonic-trend
sphericity
In a repeated-measures design, the assumption that the differences between every pair of conditions are about equally variable.
Why care: When it fails, the repeated-measures F-test rejects too often. Pulse applies no correction for it, so the p-value can come out well below its true value; treat any result short of a very small p with caution.
Forms: sphericity
See also: repeated-measures, f-statistic, homogeneity-of-variance
standard-deviation
The typical distance of a value from the mean, in the same units as the data.
Why care: It tells you how spread out values are. Two groups with the same mean can behave very differently if their standard deviations differ.
Forms: standard deviation, standard deviations
See also: variance, z-score, standard-error
standard-error
How much an estimate such as a mean would wobble from sample to sample; it shrinks as you add rows.
Why care: It is the uncertainty of the estimate, not the spread of the data. Confidence intervals and most test statistics are built from it.
Forms: standard error, standard errors
See also: standard-deviation, confidence-interval, sample-size
statistical-power
The chance a test detects a difference that really exists.
Why care: Small samples have low power, so a non-significant result from them is weak evidence of no difference: the test may simply have been unable to see it.
Forms: statistical power
See also: sample-size, statistical-significance, effect-size
statistical-significance
A result is statistically significant when its p-value falls below your chosen alpha.
Why care: Significant means data this extreme would be unusual if there were truly no effect; it is not the chance the effect is real, nor that it is important. With enough rows a tiny difference becomes significant; check the effect size (for a shape test, the statistic itself).
Forms: statistically significant, statistical significance
See also: p-value, alpha, effect-size, statistical-power
steady-state
The mix of states a flow settles into if the same switching probabilities keep applying period after period.
Why care: It shows where shares are heading in the long run, assuming nothing about the switching behaviour changes.
Forms: steady state, steady-state, stationary distribution
See also: stochastic-matrix
stochastic-matrix
A table of probabilities of moving from each state (row) to each state (column), where every row adds up to 1.
Why care: It describes flows such as customers switching brands or plans from one period to the next, and lets you project them forward.
Forms: stochastic matrix, transition matrix, transition matrices
See also: steady-state
studentized-range
The distribution of the gap between the largest and smallest of several group means, measured in standard errors.
Why care: Tukey’s test reads its pairwise p-values from it, which is how it accounts for comparing every pair at once rather than one pair alone.
Forms: studentized range, studentised range
See also: post-hoc-test, multiple-comparisons, standard-error
t-statistic
The test statistic of a t-test: a difference in means divided by its standard error.
Why care: Bigger in absolute size means the difference is large relative to its standard error, which shrinks as rows are added, so a big t can come from a tiny difference in a large sample. Its sign tells you which side is higher.
Forms: t-statistic, t statistic
See also: test-statistic, standard-error, degrees-of-freedom
test-statistic
The single number a test computes from your data to measure how far it sits from the null hypothesis.
Why care: It is the raw ingredient of the p-value. Its scale differs from test to test, so compare effect sizes, not statistics or p-values, across tests; a p-value also depends on the sample size.
Forms: test statistic, test statistics
See also: p-value, t-statistic, f-statistic, chi-square
ties
Values that are exactly equal, so they share a rank.
Why care: Rank-based tests give tied values their average rank and correct for them, but many ties (as on short rating scales) weaken the test and make its large-sample p-value less accurate.
Forms: ties, tied values, tie correction
See also: rank, non-parametric
two-tailed
A two-tailed (two-sided) test looks for a difference in either direction; a one-tailed test looks in only one direction chosen in advance.
Why care: Pulse’s tests report two-sided p-values. Halving one to claim a one-sided result after seeing which way the data went inflates false alarms.
Forms: two-tailed, two-sided, one-tailed, one-sided
variance
Roughly the average squared distance of values from their mean: divided by n in the population form, or by n - 1 in the sample form most tools print.
Why care: A measure of spread in squared units. Most tests are built on the sample form, but its squared units are hard to read; the standard deviation is the same idea in the original units.
Forms: variance, variances
See also: standard-deviation, covariance
weighting
Giving some rows more say than others in a figure, usually so a sample better matches the population it stands for.
Why care: Weights fix known imbalances, but heavy weights make results noisier; check the effective sample size.
See also: raking, effective-sample-size
z-score
How many standard deviations a value sits above or below the mean. A z test statistic is the same idea for an estimate: how many standard errors it sits from the null’s value.
Why care: It puts values from different scales on one footing and makes unusual values easy to spot.
Forms: z-score, z-scores, z score, z scores
See also: standard-deviation, outlier, normal-distribution
Skills
Every skill this instance serves, as an agent reads it, sorted by name.
aggregation-design: Aggregation + filterer slot semantics — whataggregationsdoes, how it composes withfilterers, when smart defaults fire, when to useattributesinstead. Topical design; per-AGG / per-FILTER detail lives in atomic op-* skills.attribute-composition: Attribute composition rules — slot ordering, two-pass vs row-local attributes, the formula expression environment, attributes vs features. Topical design; per-attribute detail lives in atomic op-attr-* skills.cohort-null-states: How a .pulse cohort stores absence — the per-record null bitmap, no in-band sentinels, out-of-sample null promotion on inferred imports, and the null / empty / selected states of set and categorical cells across every import and export format.cohort-parent-groups: Parent groups in format 0x02 cohorts — storing a repeated parent block once, when a group pays for itself, declaring one at import with a key check, suggesting and gating groups, converting an existing cohort with dedup, and opt-in global-constant elision.cohort-schema-design: Entry point for .pulse cohort schemas — the 20-type field matrix, selection heuristics, bit-packed runs, description-length cap, the single-file vs shard-archive shapes, and which focused schema skill answers each nullability, width, parent-group, sharding and sidecar-index question. Use when picking schema types, evaluating storage layout, or interpreting a cohort returned by pulse_inspect.cohort-sharding: Shard archives in depth — strict structural cohesion, dictionary union-merge, set-rung auto-widen on create and add, grouped shards, the anchor syntax, union-wide memory for materializing operations, single-writer concurrency, and transport-only compression for moving a cohort.cohort-sidecar-index: The sidecar point-lookup index beside a cohort — the v3 file format, the O(1) seek path, three-outcome staleness, discovery through the indexes.json manifest, invalidation by a cohort rewrite, keyable types and constraints.cohort-width-overflow: What happens when a value outgrows its field width — inferred categorical and numeric rungs promote on the full row pass, declared widths refuse row by row, set cardinality and shard dictionary overflow, and how convert reports it.compose-fits: ComposedRequest.fits — named regression fits that run once before any Compose slot (sweep slots included) and bind into every slot by name, with their own rows, the responsefitsmap, refusals and predict parity.compose-requests: ComposedRequest batch semantics — order-preserving slot-by-index dispatch, optional parallel execution, slot labels, post-slot Compose-overlay fold that decorates results without mutating per-slot Components.compose-sweeps: ComposedRequest.sweep — one request body with axis placeholders expands into many ordinary Compose slots (grid or zip), with a label pattern, per-slot overlays, limit counting and predict parity.crosstab-guide: Crosstab slot — rows × columns groupers, margins-from-raw-rows, normalize / normalize_level / normalize_within, matrix vs long, fused vs buffered, CrosstabComponents indexing. Topical; per-aggregator/grouper math lives in op-* atomics.crosstab-margin-aggregations: Auxiliary margin-only figures on a crosstab — a second base beside the cell metric in one scan, the cell-admission rule, present semantics and the display-flag gatederived-cohorts: Materialise a request’s derived columns (features, attributes, windows) into a new cohort so a later request can regress, group, filter or join on them — when to, the two-step recipe, what the output holds.expression-language: The per-row expression environment shared by the formula attribute and the expression filter — field binding by type, operators, built-in functions, set helpers, null handling, embedder functions and lookup tables. Not request templating.facet-design: FacetSchema endpoint — per-field summaries, histograms, additive contribution counts, streamability tradeoffs, four FACET-host overlay kinds against Population. Per-OVERLAY math in op-overlay-* atomics.feature-engineering: Feature slot semantics — choosing a transform, pre-filter ordering (features run before filters), per-row vs global-pass cost, target-leakage trap, train/test split. Topical design; per-feature detail lives in atomic op-feat-* skills.financial-cohorts: decimal128 patterns — precision/scale propagation, banker’s rounding, divide-by-zero policy, currency conventions, three consumer-facing decimal error codes. Use when a cohort has monetary fields or a response carries PULSE_DECIMAL_OVERFLOW / PULSE_PRECISION_LOSS / PULSE_DECIMAL_DIVIDE_BY_ZERO.glossary: Plain-language glossary of the statistical terms Pulse guidance uses. Use when a result, purpose or skill names a term you need explained.grouper-design: Grouper slot semantics — multi-grouper composition (key product), fused crosstab eligibility, Group.Include inclusion-list, smart defaults per field type. Topical design; per-GROUP detail lives in atomic op-group-* skills.intents: The closed intent taxonomy: the kinds of question an analysis answers, how each sounds, and the field shapes it fits. Use to map a user’s question to an intent ID.item-fan-out: for_each on a window, feature or attribute item — one item fans out in place over parameter axes (grid or zip) into ordinary items, with the sweep placeholders, a derived-column limit and predict parity.join-design: Pushdown hash-join surface — Request.Joins envelope, JoinSpec, OnPair key-type compatibility, As-prefix, v1 limits, and validating a join before running it.label-display: Translate categorical IDs to display labels at output time without rewriting the cohort. LabelBinding entries on Request / SampleRequest / FacetRequest / ExportJob. Display-only — filters / formulas / sort keys still see raw values.matrix-results: Vectors and matrices on a Request — naming a battery of numeric fields once, getting a covariance or correlation matrix back, the encodings, missing-data modes, warnings, per-group matrices, predict’s cost estimate and what is refused.multiplicity-correction: Multiple-comparison correction on a Request — the multiplicity block (method, family, alpha), which p-values are corrected together, the per-surface family table and its refusals, precedence, the additive adjusted outputs, row and column geometry on pairwise overlays, and what is never corrected.multivariate-design: Choosing a multivariate operator on a battery of numeric fields — which matrix answers which question, what to do with missing data and weights, when a decomposition needs a repaired matrix, and where p-values stand. Topical design; per-operator params in the atomic op-mat-* skills.multivariate-outputs: Long-form output and parameter detail for the multivariate operators — how rotated PCA and factor loadings, the maximum-likelihood fit indices, centroid distances, row scores and the vector tests lay out their figures. Topical design; the atomic op-mat-, op-attr- and op-test-* skills keep the short form.op-agg-average: Arithmetic mean of a numeric field across the input set.op-agg-ci-lower: Lower bound of the confidence interval for the mean.op-agg-ci-upper: Upper bound of the confidence interval for the mean.op-agg-count: Count records that pass the active filter, optionally per group.op-agg-distinct-count: Count distinct non-null values across the input set.op-agg-distinct-sum: Sum a value field once per distinct key.op-agg-frequency: Value count — how many non-null rows hold one chosen value (params.value); one float64 per output row.op-agg-kurtosis: Population excess kurtosis g2 (m4 / m2^2 - 3, dividing by n) via online moments.op-agg-mae: Mean absolute error of a prediction against an actual — mean(|y - prediction|).op-agg-mape: Mean absolute percentage error of a prediction against an actual — mean(|y - prediction| / |y|), as a fraction.op-agg-max: Largest non-null value of the field.op-agg-median: 50th percentile of the field; requires sorting the full value set.op-agg-min: Smallest non-null value of the field.op-agg-mode: Most-frequent value of the field (ties broken by the smallest value).op-agg-mode-count: Modal count — how many rows hold the field’s most common value; one float64 per output row.op-agg-null-count: Count records where the field is null. The inverse of a non-null count.op-agg-percentile: Configurable percentile of the field; requires sorting the full value set.op-agg-range: Spread (max minus min) of the field across the input set.op-agg-ratio: Emits sum(numerator_field) / sum(denominator_field). The Aggregation’s own Field is ignored.op-agg-rmse: Root mean squared error of a prediction against an actual — sqrt(mean((y - prediction)^2)).op-agg-set-cardinality-avg: Average popcount per contributing row — typical number of selections.op-agg-set-cardinality-sum: Sum of popcounts across contributing rows — total label selections seen.op-agg-set-distinct-values: Count of distinct exact mask values seen; each combination is atomic.op-agg-set-frequency: Per-bit row count — how many rows had each set label selected.op-agg-set-intersection: Bitwise-AND a set field across rows; returns labels for every bit set in every contributing row.op-agg-set-union: Bitwise-OR a set field across rows; returns labels for every bit set in any contributing row.op-agg-skewness: Population skewness g1 (m3 / m2^1.5, dividing by n) via online moments.op-agg-stddev: Population standard deviation via Welford’s online algorithm.op-agg-sum: Sum the numeric values of a field across the input set.op-agg-variance: Population variance via Welford’s online algorithm.op-agg-weighted-mean: Weighted arithmetic mean — sum(field * weight) / sum(weight).op-agg-welford: Streaming Welford-Pébaÿ moment triple — running mean, sample variance (n-1), and observed count.op-agg-zscore: Standardized z-score aggregate — mean-centered, stddev-scaled summary.op-attr-code-in: Per-row 0/1 — whether a field’s value is one of a list of codes; its mean is the share of the whole base.op-attr-date-part: Extract a calendar component (year, month, day, year_month, …, hour) from a date or datetime field.op-attr-factor-score: Per-row score on one common factor of a vector of numeric members, from an inline fit with the factor-analysis matrix operator’s knobs, loadings, order and sign; regression or Bartlett scores; fit_on fits on a subset and still scores every row.op-attr-formula: Per-row expression evaluation against the record’s fields via expr-lang.op-attr-mahalanobis: Per-row Mahalanobis distance D² (or its chi-square p) across a vector of numeric members, from a cohort-wide fit; fit_on fits on a subset and still scores every row.op-attr-normalized: Per-row min-max normalized column — (value − min) / (max − min) ∈ [0, 1].op-attr-pc-score: Per-row standardized score on one principal component of a vector of numeric members, with the PCA matrix operator’s sign and order and optional varimax; fit_on fits on a subset and still scores every row.op-attr-percentile: Per-row percentile rank column against the post-filter value set; requires sorting.op-attr-reg-fitted: Per-row fitted value ŷᵢ = Xᵢ · β + β₀ from an OLS refit during the attribute prepass.op-attr-reg-leverage: Per-row hat-matrix diagonal hᵢᵢ from an unpenalized OLS refit — leverage diagnostic.op-attr-reg-predict: Per-row score from a bound model (a composefitsentry or a loadedmodelsfile) — no refit; whole model or a terms subset.op-attr-reg-residual: Per-row residual yᵢ − ŷᵢ from an OLS refit during the attribute prepass.op-attr-set-has: Per-row 0/1 — whether the configured label’s bit is set on a set field.op-attr-set-popcount: Per-row popcount of a set field — number of selected labels.op-attr-tscore: Per-row T-score column — z-score rescaled to mean 50, stddev 10.op-attr-zscore: Per-row standardized z-score column — (value − mean) / stddev via two-pass Welford.op-feat-bucketize: Bin a numeric column into ordered buckets — explicit boundaries or N equal-population quantiles.op-feat-date-features: Expand a date or datetime field into year / month / day / day-of-week / quarter (+ hour) columns.op-feat-frequency-encode: Replace each categorical value with its observed relative frequency in the cohort.op-feat-log: Per-row log1p(x) of a numeric field; emits one f64 column. Standard skew-tamer.op-feat-one-hot: One-hot encode a categorical field as one f64 column per dictionary entry.op-feat-poly: Per-row polynomial expansion x^2..x^Degree of a numeric field — the basis for a polynomial regression.op-feat-sqrt: Per-row sqrt(x) of a numeric field; emits one f64 column.op-feat-target-encode: Replace each categorical value with the smoothed mean of a numeric Target field for that category. Leakage trap — see Gotchas.op-feat-train-test-split: Deterministic split-assignment column (0=train, 1=val, 2=test) for ML workflows.op-filter-date-ranges: Keep records whose date/datetime field falls inside any of a validated set of labeled date ranges.op-filter-exclude: Drop records whose field value appears in the supplied Values list.op-filter-expression: Keep records for which an expr-lang expression evaluates truthy against record fields.op-filter-false: Keep records where Field is logically false. Strict packed_bool by default; opt into JS falsiness.op-filter-include: Keep records whose field value appears in the supplied Values list.op-filter-null: Keep or drop records based on null state of a field.op-filter-range: Keep records whose numeric field value falls within [low, high] inclusive.op-filter-set-contains-all: Keep records whose set field has every bit in the supplied label mask set.op-filter-set-contains-any: Keep records whose set field shares at least one bit with the supplied label mask.op-filter-set-contains-none: Keep records whose set field shares no bits with the supplied label mask.op-filter-set-equals: Keep records whose set field mask exactly equals the supplied label mask.op-filter-true: Keep records where Field is logically true. Strict packed_bool by default; opt into JS truthiness.op-group-category: Partition records by exact field value; ideal for categorical fields.op-group-date: Partition date/datetime records by calendar component (hour..year); week_start picks the week day; fiscal_offset shifts year/quarter.op-group-date-ranges: Bucket date records by inline labeled date ranges; the range label becomes the bucket key.op-group-kmeans: Segment records into k clusters by seeded, deterministic k-means over a vector of numeric members; keys cluster_1..cluster_k by descending size.op-group-quantile: Partition records into N equal-population quantile buckets (Q1..Q4 / D1..D10 / P1..P100).op-group-range: Partition numeric records into half-open ranges [a, b); Interval controls bucket width.op-group-rounded: Round each numeric value down to a multiple of Interval (floor, not nearest) and group by that scalar.op-group-set-per-element: Fan each row into one bucket per selected label (multi-key). Cardinality multiplies with set popcount.op-group-set-value: Partition rows by the exact set mask — one bucket per unique combination; key = sorted labels joined with “|”.op-mat-collinearity: Collinearity check of candidate regression predictors (no response) — VIF and tolerance per predictor, Belsley’s condition indices and variance-decomposition proportions (uncentered with the intercept, or centered), listwise or pairwise, weighted.op-mat-correlation: Correlation matrix of a vector’s numeric members — Pearson (default), Spearman or Kendall tau-b via params.method — listwise or pairwise, weighted; one MatrixResult per spec (per group bucket when grouped), no p-values.op-mat-covariance: Covariance matrix of a vector’s numeric members (listwise or pairwise, sample by default), weighted under frequency and probability weights; one MatrixResult per spec (per group bucket when grouped).op-mat-distance: Distances between segment centroids — a k × k matrix over the buckets a grouper (params.between: any built-in, date family included, or a registered extension grouper) cuts the rows into; euclidean, sqeuclidean, manhattan, chebyshev, cosine or mahalanobis (pooled within-bucket covariance); optional standardize; weighted centroids.op-mat-factor: Exploratory factor analysis of a battery — minres, principal-axis or maximum-likelihood extraction from the SMC, varimax / promax / oblimin rotation, loadings (p × k), communalities, uniquenesses, variance accounted, rmsr, the empirical chi-square, KMO and Bartlett, ML’s likelihood chi-square, TLI, RMSEA with its interval and BIC; listwise or pairwise, weighted.op-mat-partial-correlation: Partial correlation matrix of a vector’s numeric members — each pair with every other member, or the params.control fields, held fixed — listwise or pairwise, weighted; non-PSD input refused unless repair “nearest”, singular input refused.op-mat-pca: Principal component analysis of a battery — loadings and eigenvectors (p × k, rectangular), every eigenvalue with explained / cumulative shares, communalities, KMO (overall and per member) and Bartlett’s sphericity test; correlation or covariance input, listwise or pairwise, weighted.op-mat-reliability: Scale reliability of a battery — Cronbach’s alpha, standardized alpha, McDonald’s omega (one-factor minres), mean inter-item r, per-item item-total r / alpha if deleted / mean / sd — with reverse-keyed items flipped before the fold; listwise or pairwise, weighted.op-overlay-chisq-col: Per-column χ² goodness-of-fit test across the host crosstab’s contingency table.op-overlay-chisq-matrix: Whole-matrix χ² independence test across the host crosstab’s row × column contingency table.op-overlay-chisq-row: Per-row χ² goodness-of-fit test across the host crosstab’s contingency table.op-overlay-chisq-vs-pop: χ² goodness-of-fit comparing host Facet subset distribution against the resolved population distribution.op-overlay-chisq-vs-ref: Compose-host whole-matrix χ² comparing target slot’s matrix against the reference slot’s matrix (rescaled to target N).op-overlay-delta-vs-baseline: Per-point additive delta against a fixed positional baseline of an ordered SERIES host.op-overlay-delta-vs-margin: Per-cell additive delta against the matching axis margin (cell − margin).op-overlay-delta-vs-prior: Per-point streamable windowed additive delta against the immediately preceding point of an ordered SERIES host.op-overlay-delta-vs-ref: Compose-host additive delta of target slot value against the matching reference slot value (matrix or series).op-overlay-delta-vs-sibling: Per-group additive delta against a sibling group named by (Field, Value) on the SERIES host.op-overlay-delta-vs-stage: Whole-chain additive delta of target stage’s result against the reference stage’s result.op-overlay-fisher-exact-cell: Per-cell Fisher’s exact two-sided p-value over a 2×2 contingency formed from each host crosstab cell + its margins.op-overlay-formula: Expression-driven projection computed via expr-lang against a per-host-shape variable namespace.op-overlay-index-vs-baseline: Per-point ratio index against a fixed positional baseline of an ordered SERIES host (×100).op-overlay-index-vs-margin: Per-cell index against the matching axis margin (100 × cell / margin).op-overlay-index-vs-pop: Per-value population-comparison index for a Facet host (subset_freq / pop_freq × 100).op-overlay-index-vs-prior: Per-point streamable windowed index against the immediately preceding point of an ordered SERIES host (×100).op-overlay-index-vs-ref: Compose-host ratio index of target slot value against the matching reference slot value (matrix or series).op-overlay-index-vs-rolling-mean: Per-point windowed index against the arithmetic mean of the W preceding points of an ordered SERIES host.op-overlay-index-vs-sibling: Per-group ratio index against a sibling group named by (Field, Value) on the SERIES host (×100).op-overlay-index-vs-stage: Whole-chain ratio index of target stage’s result against the reference stage’s result (×100).op-overlay-index-vs-total: Per-group streamable ratio index against the SERIES host’s grand total (×100).op-overlay-ks-vs-pop: Kolmogorov-Smirnov distance + p-value comparing host Facet NUMERIC distribution against the resolved population.op-overlay-pairwise-probit-t: Intra-matrix axis-pairwise probit t-test (Φ⁻¹ transform of each leg’s proportion, Student-t tail).op-overlay-pairwise-prop-z: Intra-matrix axis-pairwise pooled-SE two-proportion z-test (row-vs-row or col-vs-col within one crosstab).op-overlay-pairwise-two-means-z: Intra-matrix axis-pairwise two-means z-test on AGG_WELFORD cells (normal-CDF tail, no df).op-overlay-pairwise-weighted-two-means-z: Intra-matrix axis-pairwise two-means z-test on weighted-mean cells under a required n_basis (weights | kish).op-overlay-pairwise-welch-t: Intra-matrix axis-pairwise Welch–Satterthwaite t-test on AGG_WELFORD cells.op-overlay-panel-index-vs-ref: Compose-host multi-reference index — indexes every target slot against a shared reference; emits one layer per target.op-overlay-prop-z-cell: Compose-host per-cell two-proportion z-test against the reference slot’s matching cell.op-overlay-prop-z-panel: Compose-host multi-reference per-cell pairwise two-proportion z-test across N+1 slots.op-overlay-rank: Compose-host per-cell rank of each target cell within a configurable population (row / column / matrix).op-overlay-share-of-col: Per-cell share-of-column ratio (cell / col_margin) — raw share, sums to 1.0 per column.op-overlay-share-of-row: Per-cell share-of-row ratio (cell / row_margin) — raw share, sums to 1.0 per row.op-overlay-share-of-total: Share-of-grand-total ratio — dual-shape (matrix per-cell or series per-group); raw share sums to 1.0.op-overlay-t-cell: Compose-host per-cell Welch t-test against the reference slot’s matching cell; parity overlay reading from CellComponents.op-overlay-t-vs-ref: Compose-host per-group Welch t-test against the reference slot’s matching group (SERIES); parity overlay reading from CellComponents.op-overlay-yoy: Per-point year-over-year ratio against the same period one year prior; requires GROUP_DATE host.op-overlay-z-cell: Compose-host per-cell two-sample z-test on the means against the reference slot’s matching cell; parity overlay reading from CellComponents.op-overlay-z-vs-ref: Compose-host per-group two-sample z-test on the means against the reference slot’s matching group (SERIES); parity overlay reading from CellComponents.op-overlay-zscore-vs-margin: Per-cell standardized-margin z-score — (cell − margin) / sd where sd is the population SD within the same margin slice.op-overlay-zscore-vs-pop: Per-value population-comparison z-score for a Facet host — (subset_freq − pop_freq) / sd_pop.op-overlay-zscore-vs-rolling: Per-point windowed z-score against the rolling-window mean + SAMPLE SD of the W preceding points.op-overlay-zscore-vs-total: Per-group streamable z-score against the SERIES host’s grand-total distribution (population SD).op-reg-bayes-linear: Bayesian linear regression with a conjugate Normal-Inverse-Gamma prior; emits posterior means, std errors, and credible intervals. Streams the same sufficient statistics as an OLS fit.op-reg-glm: Generalized linear model via IRLS; supports binomial (logistic), poisson, and gamma families with family-specific link functions. Always buffered.op-reg-mod-resample: Spec-level resampling modifier (jackknife / bootstrap) that composes with an OLS or GLM fit; replaces analytical std errors with empirical estimates. Non-empty value forces the buffered path.op-reg-mod-selection: Spec-level subset-selection modifier (forward / backward / stepwise) that composes with an OLS or GLM fit; drives a greedy search over predictors against an information criterion. Non-empty value forces the buffered path.op-reg-ols: Ordinary least squares with optional l1/l2/elasticnet penalty; covers simple, multiple, ridge, lasso, and elastic-net regression over streaming sufficient statistics.op-synth-bernoulli: Bernoulli samples emitting 0 or 1 with probability p; pairs with packed_bool or unsigned int fields.op-synth-constant: Emit a constant value on every row; useful for unit testing, sentinel columns, and placeholder fields.op-synth-discrete: Exact per-level histogram for an integer column; what a profile reconstructs every u4/u8/u16/u32/u64 field from.op-synth-exponential: Exponential samples with rate parameter lambda; mean is 1/lambda.op-synth-lognormal: Log-normal samples parameterised by log-space mu and sigma; output strictly positive.op-synth-mixture: Mixture-of-normals samples — per-row component draw by weight, then a Gaussian from that component; reproduces bimodal/multimodal shapes.op-synth-monotonic-from: Strictly increasing or decreasing integer counter; deterministic per row, ignores RNG. Synthetic primary keys.op-synth-normal: Gaussian samples with optional [min, max] clamp; default mean=0, std=1.op-synth-pareto: Heavy-tailed Pareto samples; xm is the scale (minimum), alpha the shape (tail weight).op-synth-poisson: Poisson-distributed non-negative integer counts with mean lambda; Knuth multiplicative below 30, normal-approximation above.op-synth-regex: String samples generated from a Perl/RE2 regex pattern; walks regexp/syntax AST with bounded repetition.op-synth-set-bernoulli: Multi-select set_* bitmask generation — independent per-option Bernoulli draws, or a joint-structure resample when a paired field is declared.op-synth-uniform: Uniform real-valued samples in [min, max); default [0, 1).op-synth-uniform-date: Uniform calendar-date samples in [start, end] inclusive; days-since-epoch internally.op-synth-weighted-categorical: Discrete categorical draws from a values list with optional weights; uniform when weights absent.op-test-anova-f: One-way ANOVA F-test comparing the means of a numeric Field across k groups defined by SplitBy.op-test-anova-rm: Repeated-measures one-way ANOVA; rows pivoted on SubjectField × SplitBy condition.op-test-anova-welch: Heteroscedasticity-robust one-way ANOVA with Welch-Satterthwaite df correction; streamable per-group Welford.op-test-bartlett-sphericity: Bartlett’s sphericity test on a vector’s correlation matrix (is it the identity?); the factorability check, bit-equal to the PCA operator’s Bartlett scalars.op-test-box-m: Box’s M test of one shared covariance across a SplitBy of 2+ levels; chi-square headline, F approximation in details. The MANOVA / Hotelling assumption check.op-test-brown-forsythe: Homogeneity-of-variance test; one-way ANOVA on absolute deviations from per-group medians.op-test-chisq: Chi-square independence test on a 2D Rows × Cols contingency table.op-test-fisher-exact: Exact two-sided p-value for a 2×2 Rows × Cols contingency table; small-sample CHISQ alternative.op-test-hotelling-t2: Hotelling’s T² on a mean vector of p numeric members; one-sample or paired (difference vector) against params.mu, or two-sample over a 2-level SplitBy with pooled or unequal covariance.op-test-kendall-tau: Concordance-based correlation between Field and Field2; buffered O(n²) pair count under tie correction.op-test-kruskal-wallis: Nonparametric k-group location test; ANOVA-like rank sums under tie correction.op-test-ks: Kolmogorov-Smirnov two-sample distribution test on Field partitioned by SplitBy.op-test-mann-whitney-u: Nonparametric two-sample location test; buffered tie-corrected rank sum (Mann-Whitney U).op-test-manova: One-way MANOVA of a mean vector of p numeric members across a SplitBy of 2+ levels; Pillai’s trace headline, with Wilks, Hotelling–Lawley and Roy in details.op-test-mardia: Mardia’s multivariate normality test on a vector — skewness χ² and kurtosis z; buffered.op-test-paired-t: Paired-sample t-test on the per-row difference d = Field − Field2; streamable via Welford on d.op-test-pearson-r: Parametric Pearson correlation test between two numeric fields; streamable via online cross-product.op-test-prop-z: Two-proportion z-test on the success rate of Field across two SplitBy groups; streamable via success / total counts.op-test-shapiro-wilk: Shapiro-Francia (W′) normality test on Field; runs per-group when SplitBy is set; p advisory outside 5..5000.op-test-spearman-r: Rank-based correlation between Field and Field2 (monotonic association); buffered mid-ranks then Pearson.op-test-t: One-sample or two-sample Welch t-test on a numeric field; SplitBy switches to the two-sample variant.op-test-trend: Mann-Kendall trend test over an ordered numeric series; tier-2 over windowed result rows.op-test-tukey-hsd: Tier-2 post-hoc pairwise comparison of group means using Tukey’s Honestly Significant Difference (Tukey-Kramer).op-test-welch: Explicit two-sample Welch t-test on a numeric field across a categorical SplitBy partition.op-test-wilcoxon-sr: Wilcoxon signed-rank test on per-row difference d = Field − Field2; buffered tie-corrected sign-rank.op-test-z-two-sample: Two-sample z-test on Field means across two SplitBy groups; identical SE to Welch’s t-test but p-value via standard normal Φ.op-win-delta: Point difference against the rowperiodspositions earlier in the ordered partition — the subtraction counterpart of a percent change.op-win-dense-rank: Dense rank with no gaps after ties (1, 2, 2, 3, …) within the ordered partition.op-win-ewma: Exponentially weighted moving average; s_i = alpha*x_i + (1-alpha)*s_{i-1}.op-win-lag: Per-row value of Field from N rows earlier in the ordered partition.op-win-lead: Per-row value of Field from N rows later in the ordered partition.op-win-moving-avg: Moving average of Field over a bounded Frame; both ends must be set.op-win-pct-change: Fractional change (0.05 = 5%) against the rowperiodspositions earlier in the ordered partition.op-win-rank: Sparse rank with gaps after ties (1, 2, 2, 4, …) within the ordered partition.op-win-row-number: 1-based row index within the ordered partition; never ties.op-win-running-avg: Running average of Field over the configured Frame within the ordered partition.op-win-running-sum: Running total of Field over the configured Frame within the ordered partition.overlay-system: Overlay framework — OverlaySpec composition, six reference families, three payload shapes, host-arm wiring (SERIES / FACET / CHAIN / FORMULA), parity overlays + Welford migration. Per-kind detail lives in op-overlay-* atomics.pairwise-n-sources: The sample-size vocabulary shared by every OVERLAY_PAIRWISE_* kind — the nine n_source modes, the three distinct-key modes and their cell-aggregator admission, the slab partition gate, the null rules, and p_source.predict-advisories: Predict advisories — coded, non-blocking notes in a predict result that the chosen analysis may not fit the data (a two-group test over many groups, many uncorrected p-values, an SPSS nominal or ordinal field treated as a quantity, an unused SPSS weight); how they differ from warnings, what each code means, what it suggests, and how an embedder suppresses one.process-chain: ChainRequest source-rooted linear pipeline — mergeable-only v1 gate, per-stage Response.Components, dual-slot overlays (per-stage + whole-chain), StageRef resolution, shape-divergence warning.regression-inference: Coefficient confidence intervals (ci_lower / ci_upper at conf_level) and the coefficient covariance and correlation on a regression — the per-family critical value, the opt-in vcov flag, where each engine’s and modifier’s matrix comes from, how it relates to the standard errors, weighted fits, the refusals, and what R’s vcov does differently. Topical design; per-model params in atomic op-reg-* skills.regression-modeling: Choosing and composing a regression — outcome type, priors, penalties, the resampling and stepwise modifiers, polynomial terms built upstream, and how textbook regression names map to specs. Topical design; per-model detail in atomic op-reg-* skills.request-envelope: Request shapes, envelope contract, slot keys, smart defaults, streamability and time zones. Use when authoring or adapting any Pulse Request.request-templating: Stored parameterised JSON requests — the {target, variables, body} wrapper, $var / {{}} / $when substitution, the nine variable types, directory discovery, hot reload, and the PULSE_TEMPLATE_* family. Not expr-lang.response-components: How Response.Components carries the constituent parts of every aggregation, grouper, filterer, and crosstab cellresponse-shaping: The requestreturnblock — choose which parts of a response come back (presets, include / exclude paths) and how many significant digits floats carry on the wire; what is projected, what stays exact, and how it composes with streaming and instance defaults.session-bootstrap: Canonical MCP session order — manifest once, then examples → predict → process → errors_lookup. Skill-name derivation from operator names. Use first on every new MCP session.session-format-flags: Per-format CLI flags an agent must know — source-side read knobs (Excel sheet, SPSS missing-value mode and charset), parent-group declaration on the managed import path, the import source zone, the export render zone, and the four .sav write knobs with their refusal defaults.session-skill-routing: Which skill to fetch for a given trigger — operator family prefixes, manifest catalog entries, request and response slots, request roots, CLI leaves and plain-language questions — plus how to read data.components on first sight.session-synth-flags: The profile-capture and synth-generation CLI flags an agent must know — the five additive profile create sections, emitting the generating spec, supplying and suggesting structural rules — and why a model coefficient is a latent-scale quantity.spss-cohorts: Entry point for SPSS .sav / .zsav cohorts — the three surprises (extra derived columns, codes-not-labels dictionaries, the label-tables directory trap), why dictionaries hold codes, and which focused SPSS skill answers each import, missing-value, sidecar and .sav-writing question. Read this when a cohort came from SPSS or when emitting a .sav.spss-export: Writing .sav — the dictionary + data-section writer, bytecode compression, the four write flags, why the writer takes the whole cohort rather than rendered rows, how a .sav-to-.sav convert carries the source through, and asking export predict first.spss-export-fidelity: How far the .sav round-trip claim is proven — what CI gates, what was checked only locally against independent readers, what nothing corroborates — and the write-side charset rules (source charset kept, no replacement characters, no silent truncation, encode-measure-segment).spss-export-values: What a .sav export writes for each value — the variable-name policy and –sanitize-names, set_* masks at every width, the missing state each null takes, original codes versus dictionary positions, byte order, and labels that live only on user-missing codes.spss-import-schema: How a .sav / .zsav file imports — the declared dictionary replaces inference, the SPSS-to-Pulse type mapping, the three data encodings, charset and byte-order rules, and the fatal-vs-warning damage diagnostics. Read this when an SPSS import types a column unexpectedly or refuses a file.spss-metadata-sidecar: The cohort.pulse.spss.json sidecar an SPSS import writes — what it records that the .pulse format cannot, the code-label-dictionary-ID triple labels come from, its fingerprint, and the absent / stale / invalid / ignored read verdicts.spss-missing-values: How SPSS user-missing and system-missing values land in a cohort — the numeric <var>_missing sibling versus the flagged categorical code, why the two arms deliberately differ, –spss-missing, and filtering on the code rather than the label.spss-response-sets: SPSS multiple-response sets and the columns an import synthesises — the derived set_* mask beside a multiple-dichotomy set’s constituents, why multiple-category sets derive nothing, and the closed derived registry the export folds by. Read this when a cohort has more columns than its .sav had variables.statistical-testing: Tier-1 (tests) vs tier-2 (post_tests) pairing, assumption gates, ANOVA + post-hoc composition, p-value conventions. Topical design; per-TEST detail in atomic op-test-* skills.streaming-and-watching: Reactive Pulse primitives — Request.Hash() cache keys, StreamResult[T] incremental output with per-chunk + terminal Components, Watch / WatchDir mutation observation, FilterToFileWithRequest deterministic derived cohorts, manifest CommandAnnotations caching policy.synth-conflicts: Synth conditional relationship conflicts — the shared claim space, the fixed priority orderresolveConflictsapplies once per spec, how rule / model / shape pre-claims compose, and where the dropped-relationship warnings surface.synth-correlations: Synth speccorrelations— the Gaussian-copula construction, which marginals and field types may participate, how rank and Pearson correlation survive, and how unmeasured matrix entries are completed and reported.synth-determinism: The synth determinism contract — byte-identical.pulseoutput per spec and seed, byte-identical profile documents per input and seed, and the two non-RNG threats to capture (map iteration order, float fusion).synth-fidelity-report: Thepulse synth from-profiletagged top-up contract (_syntheticcolumn, new output path,--rowscounts new rows) and the--fidelity-reportsections — marginals, pairs, set fields — scored only over relationships generation actually applied.synth-marginals: Why a profiledpacked_boolreconstructs asbernoulliand a small integer asdiscreterather than a clamped normal — the three writers each arm covers, probit and ordered-probit model draws, and what that does to recovery and correlation.synth-model-draw: How synth generates a modelled numeric — the composed latent draw, firing terms and the “other” catch-all, the single clamp, draw ordering, and why a coefficient is latent-scale and non-linear in value space.synth-model-recovery: The synth fidelity report’smodelssection — refitting each applied model on the generated rows, comparing on the latent scale (or a calibrated probit score for step targets), flagging bands, and how to read a flag on a quantized target.synth-model-selection: How synth--fit-modelschooses predictors — the top-K collapse, the variance-explained floor, marginal scoring and refit-on-rank-deficiency, main effects only, zero-predictor models — and how thin levels are shrunk by ridge rather than dropped.synth-models: Entry point for synth--fit-models— per-numeric linear model capture and its wire shape, the latent-scale reading of a coefficient, what a model retires, and which focused skill covers selection, the draw, residual correlations and recovery.synth-profile-capture: Whatpulse profile createcaptures per field and per pair (conditional pairs, thin-pair warning, run-continuation, shape fitting), howSpecFromProfilereconstructs each field, and how categorical joint structure is generated.synth-residual-correlations: Synth--residual-correlations— capturing the correlation among fitted model residuals (full submatrix, measured vs unmeasured), the correlated residual draw that lets a numeric be both conditioned and correlated, and its zero-predictor boundary.synth-residual-recovery: The synth fidelity report’smodel_residual_correlationssection — captured vs recovered residual correlation per applied pair, why it is notpairwise, its bounded worst-first listing, and why step-target endpoints stay unmeasured.synth-rule-claims: Which synth rules pre-claim a field ahead of its linear model, conditional pairs and residual correlations, the four exclusions that are silent if got backwards, and why the fidelity report is the reason.synth-rule-detectors: The--suggest-rulesdetector rules that are silent if got backwards — rounded gates, co-missingness, near and always-null blocks, the narrow dependency search and discovered band edges — and why emitted candidate order is applied order.synth-rule-expressions: Synthconstraints[]and the expression side ofrules[]— the row environment’s types,isnull, theset_exprcoercion matrix, set-option addressing, and the pre-rounding gotcha with itsround(notint) remedy.synth-rule-nulls: Synth rule null handling —null_togetheras one null decision per question block (first field wins), andowns_nullsmaking a gate the field’s only source of absence, with the divergence warnings each emits.synth-rule-validation: Synth rule validation and diagnostics — the eight eagerPULSE_SYNTH_RULE_*refusals, the non-nullable-target refusal, and the never-fired warning with its three cause arms.synth-rules-from-profile: Reaching synth rules from the profile path —--rules(replace semantics, load-time validation) and--emit-spec(the spec that generated) — andprofile create --suggest-rules: proposed candidates, inert evidence, fixed thresholds and the three detectors.synth-set-fields: Howset_*(multi-select) fields are profiled per option, captured jointly per option pair, read safely on the wide rungs, and generated deterministically with a pre-registered dictionary.synth-shape-fit: Synth--fit-shape— the two-component mixture capture and its keep rule, and how a shape-fitted numeric still accepts model conditioning through its mixture quantile, with a fixed-count bisection for determinism.synth-structural-rules: Entry point for synth specrules[]andconstraints[]— the five rule slots, declaration order and last-write-wins, why the rule pass runs last, and which focused skill covers expressions, null handling, validation, claims and profile-derived rules.synthetic-data: Entry point for synthetic data —pulse synth from-schemavspulse synth from-profile(CLI + library; no MCP tool), the spec shape, the distribution registry, the determinism rule, and which focused synth skill answers each question.time-zones: Time zones end to end — which zone a request uses, how a datetime buckets by local calendar day, import in a source zone with DST policy, native timestamps, local-offset export and the tz database version. Topical design.tool-compose: Execute a batch of processing requests in one round-trip.tool-dedup: Deduplicate an existing cohort’s repeated parent blocks into parent groups (format 0x02), or suggest groups read-only.tool-drop: Remove a managed-import handle — delete its .pulse file and sidecar.tool-errors-lookup: Look up Pulse error code metadata — message and fixup hints.tool-examples-get: Fetch one runnable request example from the embedded library by name.tool-examples-search: Search the runnable request-example library for templates matching a question.tool-explain: Say in plain words what a request will do, or what a result found — closed verdicts, conventional strength bands and caveats.tool-facet: Return distinct values for one field in a cohort.tool-facet-schema: Multi-field rich facet — counts, nulls, percentiles, histograms, additive contributions.tool-import: Import a tabular source file (or pass through .pulse) into a managed handle.tool-imports-list: Enumerate every managed-import handle with sidecar metadata.tool-inspect: Read header + schema of a .pulse file without touching record data.tool-label-resolve: Reverse-resolve a human-readable name (typo-tolerant) to raw categorical key(s).tool-label-tables: List registered label tables — ID→display-name dictionaries for categorical fields.tool-lookup: Resolve a point lookup against a cohort’s prebuilt sidecar index.tool-manifest: Bootstrap blob — call once per session for the operator + capability catalog.tool-materialize: Write a request’s derived columns (features, attributes, windows) as ordinary fields of a new cohort that later requests read.tool-predict: Validate a request against a cohort schema without executing.tool-process: Execute one pre-built processing request against a cohort.tool-process-chain: Linear chain of stages; stage N+1 consumes stage N’s output rows.tool-range-tables: List registered range tables — named, reusable sets of labeled date ranges.tool-recommend: Turn a question kind (an intent ID) into ranked draft requests — placeholder skeletons, or predict-checked drafts bound to a cohort’s fields.tool-sample: Return up to N rows from a cohort for eyeball / preview.tool-skills-get: Fetch the markdown body of one named skill.tool-skills-list: List the skill pack — domain guides and atomic operator/type/tool refs, deployment-added skills included.type-categorical-u16: 2-byte categorical with inline dictionary up to 65,536 labels.type-categorical-u32: 4-byte categorical with inline dictionary up to ~4.29B labels.type-categorical-u8: 1-byte categorical with inline dictionary up to 256 labels.type-date: Day-granularity date stored as epoch days (signed since 1970-01-01).type-datetime: Second-granularity instant stored as signed epoch seconds (int64, negative = pre-1970), naive UTC.type-decimal128: 128-bit fixed-point decimal with per-field (precision, scale); exact base-10 arithmetic.type-f32: IEEE-754 single-precision float; ~7 significant digits.type-f64: IEEE-754 double-precision float; ~15 significant digits.type-packed-bool: 1-bit boolean; bit-packed with adjacent packed_bool / u4 fields.type-set-u128: 16-byte multi-select bitmask over an inline dictionary of up to 128 labels.type-set-u16: 2-byte multi-select bitmask over an inline dictionary of up to 16 labels.type-set-u256: 32-byte multi-select bitmask over an inline dictionary of up to 256 labels — the widest set rung.type-set-u32: 4-byte multi-select bitmask over an inline dictionary of up to 32 labels.type-set-u64: 8-byte multi-select bitmask over an inline dictionary of up to 64 labels.type-set-u8: 1-byte multi-select bitmask over an inline dictionary of up to 8 labels.type-u16: 2-byte unsigned integer; fits cardinals through 65,535.type-u32: 4-byte unsigned integer; the common wide-cardinal default.type-u4: 4-bit unsigned integer; bit-packed with adjacent u4 / packed_bool fields.type-u64: 8-byte unsigned integer; for sub-day timestamps and very wide cardinals.type-u8: 1-byte unsigned integer; fits cardinals through 255.weighting: Row weights on a Request — the request / per-slot weight and the instance default, probability vs frequency kinds, resolution order, invalid-weight exclusion, which operators honour, skip or refuse a weight, weighted inference on the effective sample size, the unweighted-base recipe and the weighted floor keys.window-design: Window slot semantics — partition / order / frame, what window operators share conceptually, streamability per window. Topical design; per-WIN detail lives in atomic op-win-* skills.
name: aggregation-design
description: Aggregation + filterer slot semantics — what `aggregations` does, how it composes with `filterers`, when smart defaults fire, when to use `attributes` instead. Topical design; per-AGG / per-FILTER detail lives in atomic op-* skills.
type: guide
kind: design
applies_to: process, compose, predict
covers: [AGG, FILTER, aggregations, filterers]
Aggregation design
aggregations and filterers drive Response.Data rows and per-stage counters in Response.Components. Per-operator detail: atomic op-agg-* / op-filter-* skills.
Slot identity
| Slot | Type | When | Output |
|---|---|---|---|
filterers | []types.Filterer | Before grouping + aggregation | drops rows; counters → Components.Filterers[i] |
aggregations | []types.Aggregation | After grouping | scalar (or Rich payload) per group → Response.Data |
Request order: features → filterers → attributes → groups → aggregations → windows → sort.
Shapes
aggregations entry: {type, field, label, params?}. type is the aggregator constant; field is the source column; label names the output cell; params carries op-specific knobs.
filterers entry: {type, field?, values?, expression?, label}. Only keys relevant to each filterer’s type are consulted; the manifest lists the filterers this instance offers.
Choosing an aggregator
Pick by the question, then let each operator’s guidance name its alternatives. pulse_skills_get intents → describe (typical value, spread), composition (counts, shares), distribution_shape (skew, tails); keep the manifest components.aggregators entries carrying that intent, then read op-agg-<name>. Four criteria decide most cases:
- Unit — every row, each distinct key, or one particular value’s rows?
- Shape — skewed or extreme values favour an order statistic over a mean.
- Weights — rows carrying weights need a weighted form.
- Uncertainty — need spread or an interval, not just a centre? Pick the Welford triple or CI bounds, which also feed tests.
Chaining
Without groups, one aggregation yields ONE scalar per Request; with groups, one per group key.
filterers chain in declared order — the next filterer sees only rows the previous kept. Counters fold: n_in[i] == n_out[i-1], n_in[0] == decoded record count. The funnel reconstructs without re-reading the Request.
Smart defaults
When an aggregations entry names field but omits type, the engine infers from schema type: numeric → a sum, categorical / packed-bool → the modal count, set_* → the per-member set frequency, date → never defaulted. filterers are never defaulted.
Predict reports filled slots under data.defaults_applied. Disable via pulse.Options{DisableDefaults: true} / --no-defaults. Full table: request-envelope.
attributes vs aggregations
attributes add a derived COLUMN per row and never collapse groups; aggregations collapse a group’s rows to a SCALAR (or rich payload). To aggregate a derived column, declare the attribute (it runs before groups) and aggregate its label — attribute-composition.
Components contract
Every Response.Components.Aggregations[i] carries a universal floor: n (non-null inputs) + n_null (null inputs), filled by the orchestrator; floor-only operators (a plain row count) leave the operator map empty. Operator-specific keys ride in Operator map[string]any, declared per operator on its ComponentSchema (internal/descriptor/capabilities_aggregators.go) and mirrored at manifest.components_schemas.aggregators[<op>].keys.
Each aggregator declares one class (components_schemas.aggregators[<op>].mergeability); it follows from the state the statistic needs:
Mergeable— fixed-size running state (sums, counts, extrema, the Welford triple, CI bounds, one value’s count). Folds across chunks via the sameMergeOnlinepath as the scalar; streaming chunks carryComponentsDelta.Partial— state is a map or set of values (modes, distinct counts / sums, per-member set frequencies); the merge is staged at terminal flush.None— needs every value at once (order statistics: median, percentiles); components emit only on terminal buffered flush and predict surfacesBufferedComponents=true.
The same floor surfaces different Operator payloads — a sum carries {sum}; the Welford aggregator {mean, m2, variance, stddev}, whose (mean, variance, n) is byte-equal to the Welch t-test’s numerators; the modal count {distinct_count, mode_value, mode_count}, filled at terminal flush.
Response.Components.Filterers[i] carries a uniform {n_in, n_out, n_null_input} floor across every filterer; no per-operator slot today.
Full Components contract: response-components.
Type admissibility
Numeric-only ops on a categorical field surface PULSE_AGG_NOT_MEANINGFUL_FOR_CATEGORICAL; count / frequency / mode accept any type. Decimal inputs may fall back to f64 with PULSE_PRECISION_LOSS — see financial-cohorts.
Gotchas
- Defaults never cross categories — a categorical
fieldwithtypeomitted gets the modal count, never a sum. - Filterers chain in declared order; reordering changes per-stage counters but not the final row set.
- Filterers can’t see attribute output (
filterersruns beforeattributes). To filter a derived column, restate it as an expression filter over source fields, or stage via Compose / ProcessChain. - Forced-buffered ops (order statistics, decimal paths) materialize the union of shards on a shard archive — memory scales with shard count.
- Tiny groups produce unstable stats. Pair non-trivial aggregations with a row count; higher moments need
n ≥ 2.
See
- Recipes:
pulse_examples_search tags=["cohort-analysis"],tags=["welford-triple"],tags=["welch"],tags=["pre-filter"]plus atomicop-agg-<name>/op-filter-<name>. request-envelope— slot keys, smart defaults, streamability.response-components— Components contract + per-family typed shape.attribute-composition— when to derive a column instead of aggregating.grouper-design,financial-cohorts,cohort-schema-design— partitions, decimal rules,set_*+ sharded buffered-op memory.
name: attribute-composition
description: Attribute composition rules — slot ordering, two-pass vs row-local attributes, the formula expression environment, attributes vs features. Topical design; per-attribute detail lives in atomic op-attr-* skills.
type: guide
kind: design
applies_to: process, compose, predict
covers: [ATTR, attributes]
Attribute composition
attributes add derived COLUMNS per record. Each entry computes one new value per row from existing fields, extending output without modifying the underlying cohort. Design contract here; per-ATTR detail (formulas, null rules, regression diagnostics) lives in atomic op-attr-* skills.
Slot position
Pipeline order: features → filterers → attributes → groups → aggregations → windows → sort. Attributes run AFTER filterers and BEFORE groups:
- Filterers see source fields only — never an attribute label.
- Groupers / aggregators / windows CAN reference an attribute label as
field.
Entry shape: {type, field?, label, params?, expression?}. type is the attribute constant; field is the source for single-source attributes; label names the new column.
Choosing an attribute
Decide what the new column must say about a row, then pick from the manifest components.attributes entries whose intents match (pulse_skills_get intents: prepare, segment, data_quality for outlier flags, drivers for model diagnostics) and read op-attr-<name>; each operator’s guidance names its alternatives.
- Position against the column (standard deviations, a 0–1 range, a percentile rank) — a two-pass score. Skewed data or extreme values distort mean-based scores; a rank-based one resists them.
- Position against a model (fitted value, residual, leverage) — a regression diagnostic; for the model’s own coefficients run a regression instead.
- A rule over this row’s fields (ratio, flag, recode) — the formula attribute.
- A calendar part or a multi-select test — the dedicated row-local attributes.
- Keeping or dropping rows is a filter, not an attribute.
Composition rules
- Labels must be unique — never a schema field, feature output or earlier label. Shadowing →
SERVICE_VALIDATION, runtime and predict. - No in-slot chaining. A formula cannot reference another attribute’s label. To stage a derived value into another formula, use Compose / ProcessChain.
- Two-pass attributes — anything scoring a row against the column or a model — need a pre-pass over filter-passing rows to compute aggregate statistics (mean / stddev / min/max / quantile / OLS coefficients) before pass 2 emits per-row output. Handled transparently — no full-dataset buffering.
- Row-local attributes (formula, calendar part, set membership / popcount) stream — one pass, no state.
- Null inputs propagate. Null source → null output for most attributes. A formula is stricter — arithmetic on a null field raises
PROCESSING_RUNTIMEunless guarded by??. - Output is coerced to a scalar. Numbers pass through;
bool→1.0/0.0; a formula’snil→ null; anything else errors. Attributes do not emit Rich payloads.
ATTR vs FEAT
Both add columns. Split:
| Aspect | attributes (ATTR_*) | features (FEAT_*) |
|---|---|---|
| Position | After filterers, before groups | Before filterers |
| Sees post-filter rows | yes | no |
| Output column | label (rebindable) | fixed per FEAT |
| Use for | per-record scoring, formulas, regression diagnostics | encoding (one-hot, target, frequency), transforms (log, sqrt, bucketize), date features |
Rule: a derived value a filterer must see is a FEAT; otherwise ATTR. They coexist — FEAT output is in scope for both filterers and attributes.
Formula expressions
The formula attribute evaluates a per-row expr-lang expression: categoricals bind as label strings, set_* fields as label lists, a null field as nil (guard with ??). Binding table, operators, functions (vector reductions, scalar maths), set helpers and embedder ExprFunctions / lookup(...): expression-language.
Worked example — a rating battery. Name the items once; each row gets the mean of its valid items when ≥ 6 of 8 answered (else null, never 0) and a straight-lining flag:
"vectors": [{"name": "battery", "fields": ["q*"]}],
"attributes": [
{"type": "ATTR_FORMULA", "label": "battery_mean", "expression": "vcount(battery) >= 6 ? vmean(battery) : nil"},
{"type": "ATTR_FORMULA", "label": "flat", "expression": "vsd(battery) == 0"}
]
As a filter expression, vsd(battery) > 0 drops flat responders. A vector named like a field or a feature / attribute / window label is PULSE_VECTOR_NAME_COLLISION; one only an expression reads raises no unreferenced warning.
Streamability
Two-pass attributes stream via the two-pass orchestrator (iter.Reset()). Buffer-forcing happens only when combined with a window, a buffered grouper, or another buffered op. Predict reports per-slot streamability under data.streamable_reasons. Full table: request-envelope and streaming-and-watching.
Components
Attributes emit scalars, not Response.Components (which covers aggregations, groupers, filterers, crosstab, run). To audit attribute output, read Response.Data or wrap the attribute in an aggregation.
Gotchas
- Categorical fields appear as strings; arithmetic on them errors. Branch on the label and emit a number.
- Two-pass attributes against shard archives: pre-pass is per-shard for
Mergeablepaths and across-shard union for buffered paths. Seecohort-schema-design. - Attribute output is f64 by default; declare
decimal128source explicitly if precision matters — seefinancial-cohorts.
See
- Per-ATTR recipes:
pulse_examples_search tags=["feature-engineering"],tags=["regression"],tags=["outlier-detection"]plus atomicop-attr-<name>for each ATTR. aggregation-design— when to aggregate instead of derive a column.feature-engineering— FEAT_* pre-filter column production.request-envelope— slot keys, streamability, smart defaults.expression-language— the formula / filter expression environment.streaming-and-watching— per-slot streamability classification.
name: cohort-null-states
description: How a .pulse cohort stores absence — the per-record null bitmap, no in-band sentinels, out-of-sample null promotion on inferred imports, and the null / empty / selected states of set and categorical cells across every import and export format.
type: guide
kind: design
applies_to: inspect, predict, process, compose, sample, facet
covers: [nullability, null bitmap, set fields, categorical, empty mask, null promotion, undefined figures]
Cohort null states
Part of the .pulse schema surface; entry skill cohort-schema-design (field-type matrix).
Nullability + per-record bitmap
When at least one field is Nullable: true, every record carries a trailing bitmap of ceil(field_count / 8) bytes after the payload:
- Field index
i→ bytei/8, biti%8(LSB-first).1= null,0= present. - No nullable fields ⇒ no bitmap; zero-overhead path.
The bitmap is the sole null mechanism. No type has an inline sentinel — decimal128 all-zero bits is decimal zero, not null. Null-skip semantics for sum/mean/percentile are central.
Inferred imports promote on out-of-sample nulls. Inference decides nullability from the first --sample-rows (default 500) rows only; a null (""/null/na/n/a) past that window promotes the field and continues rather than failing — PULSE_IMPORT_NULL_PROMOTED + ImportReport.PromotedFields / Result.promoted_fields. Every inferred text/columnar import (csv, tsv, ndjson, jsonarray, parquet, arrow, excel). An explicit --schema is a contract: a null in a declared-non-nullable field stays PULSE_IMPORT_ROW_ERROR. Avoid surprises by marking sometimes-missing fields "nullable": true, or raising --sample-rows.
Null, empty and selected
set_* mask bit i = label dict[i] selected; empty mask is a valid value (NOT null).
A set cell has THREE states and every import/export format keeps them apart. Null (no answer), empty mask (answered, selected nothing) and a selection are distinct data — “ticked none of these” and “skipped the question” give different denominators. External forms, identical at every rung from set_u8 to set_u256:
| State | Flat text (csv / tsv / excel) | JSON (ndjson / jsonarray) | arrow / parquet |
|---|---|---|---|
| null | "" (any null token) | null | validity bit clear |
| empty mask | | — a bare delimiter, no token | "|" on write, [] also accepted on read | zero-length LIST<UTF8> |
| selection | A|B | "A|B" | ["A","B"] |
One marker (a bare |, the default set delimiter) serves every format, so the convention cannot drift between them, and it survives a third-party round trip because it is ordinary cell text — unlike CSV’s ,, versus ,"",, a spreadsheet has nothing to normalise away. It works because isNullToken does not recognise \| and the token splitter drops empty tokens, so \| yields zero tokens and no dictionary entry. Widening the null-token set to cover a lone delimiter, or retaining empty tokens, re-collapses the two states SILENTLY — both spellings keep importing and only the meaning changes.
categorical_* has the same pair — an empty-string VALUE and a null — and no marker can carry it, because any text is a legal categorical value. It rides an out-of-band channel instead: the export row spells a null nil and an empty value "" (io.NullAwareWriter), and a source declares its own nulls per row (io.NullAwareReader). ndjson / jsonarray / arrow / parquet carry both states; csv / tsv / excel have one spelling for an absent value and read BOTH back as null — a documented gap, asserted in internal/io/nullcell/, never a silent one. A blank cell in a non-dictionary column (u32, date, …) is a null on every format.
Undefined figures in results
A result figure can be UNDEFINED even when every input is present: a ratio over an all-zero denominator (including a crosstab cell whose rows all carry weight 0), a CI bound under two rows, an index-vs-prior series’ first entry, an unfilled rolling window. In Go it is NaN (test math.IsNaN); on every JSON surface — --json envelopes, --stream NDJSON rows, MCP tool results, an embedder’s own json.Marshal of a result type — it is null in place, key kept, and the rest of the response serialises normally. Absent still means “not reported for this kind”; null means “reported, undefined here”. For an untyped fragment (a streamed row) use types.MarshalFinite. Hand a result back unedited (e.g. to pulse explain --response): a null figure is read as undefined again, never as 0 — do not replace it with a number.
name: cohort-parent-groups
description: Parent groups in format 0x02 cohorts — storing a repeated parent block once, when a group pays for itself, declaring one at import with a key check, suggesting and gating groups, converting an existing cohort with dedup, and opt-in global-constant elision.
type: guide
kind: design
applies_to: inspect, predict, process, compose, sample, facet
covers: [parent groups, 0x02, --group, --suggest-groups, dedup, constant elision, --elide-constants]
Parent groups
Part of the .pulse schema surface; entry skill cohort-schema-design. Byte layout: .claude/reference/byte-layout.md (Parent groups).
Parent groups (format 0x02)
A denormalised join repeats its parent block on every child row. A parent group stores each distinct tuple of its member fields once, in a dictionary in the schema block, and each row carries a 4-byte index — N independent groups per cohort, a field in at most one. A constant group holds one entry and no per-row index (a column with one value everywhere). Members keep their own types, nullability and dictionaries; decode is indistinguishable from the ungrouped cohort, so requests, filters, groupers and exports address member fields by name exactly as before. Worth it only when the group is wider than its 4-byte index and repeats heavily — a narrow or rarely-repeating group makes the file bigger and the resident dictionary costs memory. pulse widen does not accept a grouped cohort (coded refusal); a shard archive reconciles every arrival to its ONE group layout (cohort-sharding, Cohesion). pulse_inspect reports each group’s realized ratio and resident dictionary bytes, header-only. A filter whose fields all sit in ONE group is evaluated once per dictionary entry instead of per row (same results and counts) — keep a filter off a parent/child mix to get it. Filter-to-file keeps a cohort grouped: header, groups and dictionaries are carried unchanged, and entries no surviving row uses are NOT pruned. Inspect’s ratio (surviving rows ÷ carried entries) therefore drops after a selective filter. That is expected. Re-dedup the output to prune.
Declaring a group at import: pulse import <fmt> --group KEY[,KEY...]:MEMBER[,MEMBER...], repeatable, one group per flag (ImportJob.Groups). Key fields identify the parent; members are what the key determines. Two rows with the same key but a differing member (value or null) FAIL the import (PULSE_GROUP_MEMBER_NOT_CONSTANT, naming field, record and source row) — never a silently bigger dictionary. No colon = a plain tuple group, no key check. A field in two groups, an unknown field or a malformed value fails before the row pass. Declare what you know from the upstream join; the 500-row inference sample cannot confirm a parent block. To find or size one first, run pulse import predict --suggest-groups. It nominates single-field keys over the first ≤10,000 rows, then measures each over EVERY row with the import’s own gate. You get the ratio, resident dictionary, projected file size and a ready-to-paste --group value. It only suggests. import predict --group … evaluates a declaration without writing anything. Each group is gated on its own numbers: members no wider than the 4-byte index → dropped (PULSE_GROUP_TOO_NARROW); dedup ratio below --dedup-ratio-floor (default 2) or no net saving → still written with PULSE_DEDUP_LOW_RATIO (ratio, resident dictionary bytes, byte delta); --strict makes either an error. ImportReport.Groups carries each verdict and its numbers.
Managed imports (pulse import auto --group, pulse_import groups / suggest_groups) take the same declarations at the DEFAULT floor, findings as warnings — no floor, strict or elision slot.
An existing cohort converts with pulse dedup COHORT --group … (Pulse.Dedup, pulse_dedup): same declarations, gate and flags, in place (atomic) or --out; --suggest-groups finds candidates over its records. It reports invalidated sidecars and rebuilds none. Single-file only.
Global-constant elision is opt-in: pulse import <fmt> --elide-constants / ImportJob.ElideConstants. Fields holding one value on EVERY imported row — decided over the full row pass, never the 500-row inference sample — go into one constant group (ImportReport.ElidedConstants). A column null everywhere is a (null) constant; one value plus some nulls is NOT. Nothing is elided below 2 rows, or when declaring the constants costs more schema bytes than it saves — the cohort then stays byte-identical 0x01. All-constant keeps the lowest-index field in the row. The output is 0x02: binaries predating it cannot open it.
name: cohort-schema-design
description: Entry point for .pulse cohort schemas — the 20-type field matrix, selection heuristics, bit-packed runs, description-length cap, the single-file vs shard-archive shapes, and which focused schema skill answers each nullability, width, parent-group, sharding and sidecar-index question. Use when picking schema types, evaluating storage layout, or interpreting a cohort returned by pulse_inspect.
type: guide
kind: design
applies_to: inspect, predict, process, compose, sample, facet
covers: [u4, u8, u16, u32, u64, f32, f64, decimal128, categorical_u8, categorical_u16, categorical_u32, packed_bool, date, datetime, set_u8, set_u16, set_u32, set_u64, set_u128, set_u256]
Cohort schema design
Pick the right .pulse field type, decide nullability, address shards. The schema lives in the cohort header; pulse_inspect is the surface for reading it.
Which skill answers what
| Question | Skill |
|---|---|
Null bitmap; null promotion on import; null vs empty vs selected in set_* / categorical cells per format | cohort-null-states |
| A value outgrew its width — promote or refuse? | cohort-width-overflow |
Storing a repeated parent block once (0x02), --group, dedup, constant elision | cohort-parent-groups |
| Shard archive cohesion, auto-widen, anchors, memory, concurrency, moving a cohort | cohort-sharding |
| The point-lookup sidecar index: format, staleness, discovery, keyable types | cohort-sidecar-index |
A cohort imported from .sav / .zsav | spss-cohorts |
| One type’s bytes, range, null and dictionary rules | atomic type-<kebab> |
Field-type matrix (all 20)
| Name | Bytes | Dict? | Bit-packed? | Notes |
|---|---|---|---|---|
u4 | 0 | no | yes | 0..15 |
u8 | 1 | no | no | 0..255 |
u16 | 2 | no | no | 0..65,535 |
u32 | 4 | no | no | 0..~4.29B |
u64 | 8 | no | no | |
f32 | 4 | no | no | ~7 sig digits; index key = raw bit pattern (-0.0/NaN caveat) |
f64 | 8 | no | no | ~15 sig digits; same bit-pattern caveat |
date | 4 | no | no | signed int32 epoch days (pre-1970 negative) |
datetime | 8 | no | no | epoch seconds, naive UTC; index-key literal parses as a datetime, never a float |
packed_bool | 0 | no | yes | 1 bit |
categorical_u8 | 1 | inline, ≤256 | no | dict-encoded; index key = dictionary ID |
categorical_u16 | 2 | inline, ≤65,536 | no | |
categorical_u32 | 4 | inline, ≤~4.29B | no | |
decimal128 | 16 | no | no | per-field (precision, scale); index key = exact mantissa, no float round-trip |
set_u8 | 1 | shared, ≤8 labels | no | multi-select bitmask; not index-keyable |
set_u16 | 2 | shared, ≤16 | no | |
set_u32 | 4 | shared, ≤32 | no | |
set_u64 | 8 | shared, ≤64 | no | |
set_u128 | 16 | shared, ≤128 | no | |
set_u256 | 32 | shared, ≤256 | no | widest rung; 256 is a hard ceiling |
set_* mask bit i = label dict[i] selected; empty mask is a valid value (NOT null). Nullability is opt-in per field via the bitmap; all 20 types participate identically. How null, empty and selected stay apart in every format: cohort-null-states.
date and datetime are NOT interchangeable — days vs. seconds, a factor of 86,400. Both are accepted by the date grouper, the date-range grouper and the date-range filter, which day-truncate datetime to the UTC calendar day. Sub-second timestamps: u64 microseconds. Resolution, timezone and text-format detail belong to type-date / type-datetime.
Selection heuristics
Counts / IDs → smallest unsigned width that fits the max. Measurements → f32 (import inference picks f64 unless --float f32); scores or wide dynamic range → f64. Money → decimal128 (see financial-cohorts). Booleans → packed_bool; small ordinals (Likert, grades) → u4. Strings → always categorical, width by distinct cardinality. Multi-select → set_*, smallest width whose cap covers the distinct labels — the width is paid by every record, and above 256 labels there is no set type at all. Sometimes-missing → pick the base type, then Nullable: true.
Bit-packed runs
u4 and packed_bool report ByteSize() == 0 and share bytes with adjacent packed fields. Place packed fields together for optimal layout. Reordering can change byte offsets even when types are unchanged.
Field descriptions
Capped at 1000 bytes per field; over-cap → PULSE_IMPORT_DESCRIPTION_TOO_LONG. Empty, sub-10-character, or generic (“n/a”, “tbd”, “unknown”, “field”, “data”, “value”, “column”) → PULSE_FIELD_DESCRIPTION_LOW_QUALITY (warning; error under --strict). Style: concise, third-person, present-tense — what the field represents, its units, its domain semantics.
Sharded cohorts
A .pulse path resolves to one of two shapes, dispatched on the leading 4 bytes. Single-file: magic PULSE\x00\x00\x00 + format byte 0x01 or 0x02, then schema, dicts, records (0x02 adds a length-prefixed schema extension block carrying parent groups before the records; writers emit 0x01 unless the schema declares a group; 0x01 cohorts stay readable forever, and a binary older than 0x02 refuses a 0x02 cohort loud). Shard archive: uncompressed Zip64 (Method 0), magic PK\x03\x04, a reserved _schema.pulse entry (header-only canonical schema + SHRD trailer with aggregate_record_count + shard_count) plus N standalone shard payloads. Old single-file readers fail loud on archive magic. archive.pulse#shard.pulse opens one shard. Go embedders read via Cohort.Reader() (archive-order index, canonical schema) and write via Pulse.NewCohortBuilder (= explicit-schema import; Groups / ElideConstants = --group / --elide-constants; Shards splits a new archive; target a.pulse#new.pulse appends one shard via AddShard): docs/src/library/cohort-reader.md. Cohesion, set_* auto-widen, grouped shards, memory and concurrency: cohort-sharding.
Cross-links
financial-cohorts (decimal128 rules) · response-components (data.components.run.shard_count + partial_cohort_reason) · aggregation-design / grouper-design / attribute-composition (set_* operator surfaces) · tool-lookup (point-lookup MCP surface on the sidecar format) · tool-import (import MCP surface incl. the SPSS format enum) · label-display (resolving SPSS value labels from stored codes) · spss-cohorts (the full .sav / .zsav surface) · docs/src/internals/managing-shard-archives.md.
name: cohort-sharding
description: Shard archives in depth — strict structural cohesion, dictionary union-merge, set-rung auto-widen on create and add, grouped shards, the anchor syntax, union-wide memory for materializing operations, single-writer concurrency, and transport-only compression for moving a cohort.
type: guide
kind: design
applies_to: inspect, predict, process, compose, sample, facet
covers: [shard archives, cohesion, auto-widen, anchor syntax, transfer, zstd]
Sharded cohorts in depth
Part of the .pulse schema surface; entry skill cohort-schema-design (Sharded cohorts — the two on-disk shapes). Operations guide: docs/src/internals/managing-shard-archives.md.
Sharded cohorts — cohesion
- Structural: strict at insert, on every dimension but one. Field count, names, type bytes, byte offsets, bit positions must match canonical. Mismatch →
PULSE_SHARD_SCHEMA_MISMATCH. The single exception is theset_*RUNG — see auto-widen below. - Descriptions: tolerant. Divergence →
PULSE_SHARD_DESCRIPTION_DIVERGENCE(warning); canonical wins. - Categorical / set dictionaries: union-merge. Canonical entries first; new entries appended; incoming records byte-rewritten with remapped indices.
- Prefix-only validator raises
PULSE_SHARD_DICT_DIVERGENCEwhen embedders coordinate dicts upstream. Unchanged by auto-widen. set_*auto-widen. Two forces trigger it, and they compose. (1) A dictionary union that outgrows a set field’s bitmask. (2) The arriving shard declaring a different rung — a re-import of a new period infers the rung its own data needs, so aset_u128shard legitimately meets aset_u64archive. Either way the field is promoted to the narrowest rung that holds every member, across_schema.pulseand every shard payload, instead of the shard being refused. Refusing (2) forced a full re-import of every prior period to reach a layout a mechanical re-stride already produces.- Only ever wider. A shard arriving at a NARROWER rung never narrows the archive — narrowing would drop every selection above the target’s ceiling, silently, with the record still decoding to a smaller and entirely plausible selection. The arriving shard is promoted to the archive’s rung instead, and only that one shard is rewritten.
- Create widens too.
pulse shard create/Pulse.CreateShardArchiveauto-widens on exactly the same conditions asshard add. One rule, no asymmetry: the same two files must not produce an archive when passed together and an error when passed one after the other.CreateShardArchivereturns a*CreateShardArchiveResultrather than a bare error for the same reasonAddSharddoes — so the warning has nowhere to be dropped. - The whole-archive rewrite is atomic (temp + fsync + rename) and emits a mandatory
PULSE_SHARD_SET_WIDENEDwarning naming field, old rung, new rung, the rung the arriving shard declared and the shards rewritten — read it: an archive-wide re-stride costs far more than an append.details.archive_widenedtells the two directions apart (from != to⇒ the archive moved; equal ⇒ only the arriving shard did). Pastset_u256there is nowhere to widen to andPULSE_SHARD_DICT_WIDTH_OVERFLOWstands. Categorical widths are unaffected; they stay fixed at folder creation. pulse shard verifyreports set-width headroom per set field (entries used, capacity, headroom, next rung) so an impending widen is foreseeable rather than a surprise the nextshard addbills for.- Grouped (
0x02) shards. An archive has ONE parent-group layout — part of strict cohesion, soshard verifyrefuses a0x01shard in a0x02archive. On create/add the ARCHIVE’s layout wins: a grouped arrival into an ungrouped archive is stored flattened; an arrival with other groups (or none) is re-encoded into the archive’s. Group dictionaries union-merge canonical-first (stored shards untouched, only the arrival renumbered); past the u32 index space →PULSE_SHARD_DICT_WIDTH_OVERFLOW. An arrival that disagrees with a CONSTANT group promotes it to indexed across every shard; one that violates a declared key is refused (PULSE_GROUP_MEMBER_NOT_CONSTANT). Layout changes emit the mandatoryPULSE_SHARD_GROUPS_REWRITTEN(details.reason,archive_rewritten);shard verifyaddsgroup_index_headroom(a constant group always shows 0). To keep appends cheap, import every period with the same--groupflags and don’t elide a column that varies between periods.pulse deduprefuses archives — dedup each shard, thenshard create.
Anchor syntax
respondents/Q1.pulse → full sharded cohort (union semantics)
respondents/Q1.pulse#20190101.pulse → named shard, as one-shard cohort
Anchor against single-file → PULSE_ARCHIVE_MAGIC_INVALID. Missing anchor → PULSE_SHARD_MISSING.
Memory shape
Materializing ops (percentile / median aggs, the percentile attribute, quantile and date groupers, windows, decimal paths, tier-1 tests + groupers/features/two-pass attrs, tier-2 post tests) materialize across the union of shards. Median-of-medians is not the median. Memory scales with shard count.
Concurrency
No concurrent-writer protection: two writers race, last wins. Readers snapshot at open. Caller owns single-writer architecture or an advisory lock.
Moving a cohort (transport-only compression)
Compress only to move it: pulse export transfer writes <cohort>.zst (one standard zstd stream of the exact bytes — single-file or archive, any format version); pulse import transfer restores a byte-identical .pulse (sha256 in both reports). Never at rest — random access, mmap, lookup and parallel decode need the raw bytes, so any read surface refuses an artifact with PULSE_COHORT_COMPRESSED. CLI/library only, no MCP tool. Parent groups and zstd remove the same repetition: a sorted flat cohort compresses to about what a grouped one does. Detail: docs/src/format/transfer.md.
name: cohort-sidecar-index
description: The sidecar point-lookup index beside a cohort — the v3 file format, the O(1) seek path, three-outcome staleness, discovery through the indexes.json manifest, invalidation by a cohort rewrite, keyable types and constraints.
type: guide
kind: design
applies_to: inspect, predict, process, compose, sample, facet
covers: [sidecar index, point lookup, idx, indexes.json, staleness, keyable types]
Sidecar index
Part of the .pulse schema surface; entry skill cohort-schema-design (field-type matrix — per-type key caveats).
Sidecar index
Pulse.Lookup / pulse index build use a separate file — cohort.pulse.<keyhash>.idx; the .pulse layout stays untouched. Format v3, in order: 9-byte header (magic PULSEIDX + version 0x03, its own magic/version distinct from encoding.MagicBytes / FormatVersion) → 32-byte SHA-256 source fingerprint → key-spec (ordered key columns + field types) → SourceSize (u64) + SourceModTime (i64 Unix ns) staleness snapshot → u32 bucket_count → fixed-width bucket_count × u64 offset table (directly addressable, O(1) single-bucket seek) → self-delimited bucket data, FNV-1a hash buckets → []uint64 row-id multimap.
A lookup hashes the key, seeks its offset entry, seeks that bucket’s data, then seeks each matched record via RecordLocator — never a full-cohort or full-index read.
Staleness, three outcomes, not two. A size mismatch is an instant PULSE_INDEX_STALE (the fingerprint covers the whole file, so a different length cannot hash to it). A full size+mtime match serves hash-free — that is the O(1) property. An mtime that moved while the size held is NOT staleness: mtime resolution is not preserved across filesystems (the snapshot is Unix ns, an S3 LastModified is whole seconds, and every copy/restore/cache hop truncates), so it escalates to the sidecar’s own SHA-256 and refuses only if the content really differs. The digest is memoised per (path, size, mtime), so a backend that never preserves ns pays one hash, not one per lookup. pulse index verify is authoritative: it always hashes and never reads that memo. The residual gap is unchanged — an in-place edit preserving BOTH size and mtime passes Lookup and is caught only by verify.
Discovery: cohort.pulse.indexes.json. A sidecar’s filename is a hash OF its key tuple, so it cannot be opened without already knowing the answer, and object storage cannot list a directory. pulse index build therefore also upserts a keyless JSON manifest at that deterministic path carrying every index’s ordered key tuple (plus types and counts), so pulse index list / Pulse.ListIndexes answers with one read and no directory listing. Absent ⇒ falls back to the directory listing; both available ⇒ union. Malformed ⇒ PULSE_INDEX_MANIFEST_INVALID (never a silent degrade to the listing, which would succeed locally and answer “no indexes” on a bucket); names a sidecar that is gone ⇒ PULSE_INDEX_MANIFEST_STALE, repaired by pulse index build or pulse index drop (drop prunes an orphaned entry). It is one of Pulse’s own *.json sidecars, so a PULSE_LABEL_TABLES_DIR / PULSE_RANGE_TABLES_DIR walk skips it by suffix.
A cohort rewrite invalidates it. pulse widen changes the cohort’s byte length, so the index (and the SPSS metadata sidecar) self-invalidate on the size fingerprint — neither can serve stale data. Nothing is rebuilt automatically: that would attach unbounded work to a bounded metadata operation. pulse widen instead REPORTS what it invalidated and the exact pulse index build ... --key a,b that rebuilds it, discovered through the manifest (the key tuple is unrecoverable from the hashed filename). --json carries it as data.invalidated_sidecars; both arms print nothing when there are no sidecars. Library: Pulse.InvalidatedSidecars.
Keyable types (processing.IsIndexKeyableFieldType): every type in the field-type matrix EXCEPT set_* — a multi-select mask has no single unambiguous equality value, so filter on it with the set filter instead. Per-type equality caveats are in that matrix’s Notes column.
Constraints: single-file cohorts only (the archive.pulse#shard.pulse anchor is a tested single-shard workaround); equality-only, full-key required, composite-key order significant end to end. The PULSE_INDEX_* / PULSE_LOOKUP_* codes carry their fixups in pulse errors lookup CODE; the point-lookup MCP surface is tool-lookup.
name: cohort-width-overflow
description: What happens when a value outgrows its field width — inferred categorical and numeric rungs promote on the full row pass, declared widths refuse row by row, set cardinality and shard dictionary overflow, and how convert reports it.
type: guide
kind: design
applies_to: inspect, predict, process, compose, sample, facet
covers: [width overflow, width promotion, categorical, set fields, column_type_overrides, import, convert]
Width overflow
Part of the .pulse schema surface; entry skill cohort-schema-design (field-type matrix, selection heuristics).
Width overflow
PULSE_IMPORT_CATEGORICAL_OVERFLOW/PULSE_IMPORT_CATEGORICAL_UNBOUNDED— categorical width exceeded or dict unbounded. A full DECLARED rung is a capacity violation, not a row condition: every later unseen category would be lost for the rest of the file (an import row-errors each one; an INFERRED rung promotes instead, below). On a declared rungConvertJob.Runrefuses outright — it stops at the offending cell (row/column/type/max_entries/valuein details) and writes no--keep-pulseintermediate.PULSE_IMPORT_SET_OVERFLOW—set_*cardinality exceeded width.PULSE_SHARD_DICT_WIDTH_OVERFLOW— shard insert would expand union dict past declared width. For aset_*field this now fires only pastset_u256; below that the archive auto-widens (cohort-sharding, Cohesion).
Inferred widths promote; declared widths refuse. Inference sizes categorical_* rungs (≤200 distinct in the sample → u8) and integer widths from the first --sample-rows rows. On the full row pass an INFERRED field whose value outgrows that is promoted to the narrowest type holding it: categorical_u8 → u16 → u32; u4 → u8 → u16 → u32 → u64 for a non-negative integer; u4..u32 → f64 for any other number (never from u64, where f64 is not exact); f32 → f64 for a value outside f32’s RANGE (past MaxFloat32, or a non-zero magnitude f32 flushes to zero) — floats infer f64 unless --float f32, which picks f32 by that same range test, so a value f32 merely rounds (0.1) never promotes. A non-boolean in a packed_bool stays a row error: no type holds both losslessly. Rows already read are re-strided in memory — the cohort is byte-identical to one inferred from the whole file, and a cohort that never overflows is unchanged. One PULSE_IMPORT_WIDTH_PROMOTED per field (ImportReport.WidthWarnings / width_warnings). pulse convert over an inferred schema runs the same per-cell decision: it passes cell text through unchanged, reports the promoted types and warnings (ConvertReport.WidthWarnings), and its --keep-pulse cohort is byte-identical to a plain import. A declared width — --schema, column_type_overrides, an authoritative SPSS / Arrow / Parquet schema — never promotes: its overflow stays a per-row PULSE_IMPORT_ROW_ERROR, as does a non-number and a dictionary past categorical_u32. A column_type_overrides column is stricter still: any value it cannot hold refuses the import (PULSE_IMPORT_OVERRIDE_INVALID). Parent-group widths, the viability gate and constant elision are judged on the promoted widths; import predict sees them only on its measured pass.
Mitigation for declared widths: pick them with growth headroom up front.
name: compose-fits
description: ComposedRequest.fits — named regression fits that run once before any Compose slot (sweep slots included) and bind into every slot by name, with their own rows, the response `fits` map, refusals and predict parity.
type: guide
kind: design
applies_to: compose, predict
covers: [ComposedRequest, FitSpec, ComposedResponse]
requires: [capability:compose_fits]
Compose fits
A fit is a named regression that a Compose batch runs ONCE, before any slot, so every slot reads the same fitted model by name instead of refitting it or retyping its coefficients. Use it when several slots — a train and a holdout, a set of scenarios, a sweep — must all use one model.
{
"fits": [
{"name": "mmm",
"request": {
"cohort": {"filename": "weekly.pulse"},
"filterers": [{"type": "<range filter>", "field": "week", "values": ["0", "90"]}],
"features": [{"type": "<feature>", "field": "tv_spend", "label": "tv_sat"}],
"regressions": [{"type": "<regression>", "target": "revenue", "predictors": ["tv_sat", "price"]}]}}
],
"requests": [
{"label": "train", "cohort": {"filename": "weekly.pulse"}, "...": "..."},
{"label": "holdout", "cohort": {"filename": "weekly.pulse"}, "...": "..."}
]
}
The fit request
requestis an ordinary request holding exactly ONE regression and nogroupsorcrosstab— one model over one row set. Fit a second model as a second entry.- Its own cohort, filters, features and weight decide the rows the model is fit on, independently of every slot. Fitting weeks 0–90 while a slot filters weeks 91–103 is the train/holdout split; no split parameter exists.
- Its
returnis ignored: the whole result is always reported. - Every fit runs exactly once per batch, whatever the slot count; under parallel compose the fits run concurrently among themselves, and serial and parallel answers are byte-identical.
Names
nameis required and unique. Fits share ONE name namespace; a duplicate isPULSE_MODEL_NAME_COLLISION, and a reference to a name nothing binds isPULSE_MODEL_UNKNOWN(details.availablelists the bound names).- Every slot receives every fit — explicit slots and sweep-expanded slots alike — so a sweep axis can vary the slot while the model stays fixed.
- The model’s predictor list is authoritative. A slot that scores against it supplies those columns itself (its own features); a one-hot column left out of
predictorsis the reference level, never inferred. - A slot scores a fit per row with
ATTR_REG_PREDICT(params: {fit: "<name>"}, optionalterms/scale/bound): no refit, full precision, null on a null predictor; seeop-attr-reg-predict. - A slot’s formula or filter expression reads a fit’s figure with
coef("<name>", "<term>"[, "point|lower|upper|se"]); seeexpression-language.
Response
fits on the compose response is an object keyed by fit name, each value the fit’s full regression result (coefficients, standard errors, p-values, fit statistics; covariance when the spec asks for it). It is omitted without fits. It sits outside the compose-level return — always emitted, never rounded — because slots score from those exact figures. Slot responses are unchanged: a fit adds no row to any slot.
Saving
save: "models/mmm.pulse-model.json" on an entry (or on any regression spec) writes the fitted model as a versioned model file under the data root once it has run: atomic, full precision, named after the fit. The path must end .pulse-model.json and stay under the root (PULSE_MODEL_SAVE_INVALID, checked before any fit runs); a failed write is PULSE_MODEL_WRITE_FAILED; replacing a file succeeds with PULSE_MODEL_OVERWRITTEN in the compose response’s warnings (details.fit). Two saves naming one file anywhere in the batch are refused (reason: duplicate, details.sites); every save path — fits and slots — is checked before any fit or slot runs.
Loading
models: [{name: "mmm", path: "models/mmm.pulse-model.json"}] on any request (slot, fit request, process, chain stage, facet) loads a saved model under name into the same namespace: references resolve it exactly like a fit (bit for bit), on any cohort holding its predictor columns (provenance is informational). Every file is read before anything runs — in a batch, before any fit — so a load sees the file as it was before the batch. Refusals: PULSE_MODEL_REF_INVALID (no name, bad path), PULSE_MODEL_FILE_UNREADABLE, PULSE_MODEL_FILE_INVALID, PULSE_MODEL_VERSION_UNSUPPORTED, and PULSE_MODEL_NAME_COLLISION for a name a fit or another entry binds (details.sources names both).
Refusals
PULSE_FIT_INVALIDwithdetails.reason:name_missing,request_missing,regression_count(withcount),grouped. Checked before any fit or slot runs.- A fit request’s own refusal (an unknown field, a bad regression spec) keeps its code and gains
details.fit, the 0-based entry index — as a slot’s gainsdetails.request. With several failing fits the lowest index is reported. - Every refusal locates the fit with
details.fit.
Validate before executing
pulse_predict with composed (PredictCompose) runs the same structural rules, then checks each fit request like a slot plus its regression spec, located by details.fit; a valid batch lists fits: [{name, type, target, predictors, cohort}] — predictors as written, never fitted. Predict never reads a record.
See
compose-requests (slots, labels, parallel) · compose-sweeps (the slots a sweep adds receive the fits too) · regression-modeling (choosing the regression a fit runs) · regression-inference (reading its result).
name: compose-requests
description: ComposedRequest batch semantics — order-preserving slot-by-index dispatch, optional parallel execution, slot labels, post-slot Compose-overlay fold that decorates results without mutating per-slot Components.
type: guide
kind: design
applies_to: compose, predict
covers: [ComposedRequest, pulse_compose, OverlayLayer]
requires: [capability:compose]
Compose requests
ComposedRequest bundles N independent Request objects into one round-trip (pulse_compose, pulse api compose, pulse.Compose). Each slot is a complete Request — its own cohort, filters, groups and aggregations — and runs exactly as it would alone.
When to compose
- Several independent questions in one call — a summary, a breakdown and a crosstab of one survey, or one request over several cohorts (a parameter grid:
compose-sweeps). - Comparing slots — a Compose-host overlay reads two or more finished slots and decorates the comparison (reference vs target, or a multi-slot panel).
- One model, many slots — fit once, every slot reads it by name (
compose-fits).
Not a fit when a later step consumes an earlier step’s OUTPUT rows — that is a chain (process-chain). Every slot opens and decodes its own cohort: composing saves round-trips, not decode work.
{
"requests": [
{"label": "a_grades",
"cohort": {"filename": "students.pulse"},
"aggregations": [{"type": "<aggregator>", "field": "score"}],
"filterers": [{"type": "<value filter>", "field": "grade", "values": ["A"]}]},
{"label": "by_dept",
"cohort": {"filename": "students.pulse"},
"aggregations": [{"field": "score"}],
"groups": [{"field": "department"}]}
]
}
<…> = any operator the manifest lists for that slot. Slot 2 leans on smart defaults: a field with no type gets the default for its schema type (request-envelope).
Order-preserving slot dispatch
The response is a ComposedResponse object {responses, overlays}: responses[i] answers requests[i], whatever order slots finished in. Each slot carries its own full Response — metadata, data, crosstab, components. Filter state in slot i never affects slot j. A slot’s return shapes responses[i]; a top-level return takes overlays… paths only.
{
"responses": [
{"data": [...], "metadata": {"total_rows": 1000, "filtered_rows": 50}},
{"data": [...], "metadata": {"total_rows": 1000, "filtered_rows": 1000}}
],
"overlays": [ /* one OverlayLayer per Compose overlay spec; key omitted when there are none */ ]
}
Slot labels
Each Request takes an optional label. An empty label is auto-filled with request_<index+1> (1-based) on a clone — the caller’s request is never mutated. Labels are how a Compose overlay names its Reference / Target slots, so set them explicitly whenever an overlay refers to a slot.
Two slots resolving to the same final label (duplicates, or a caller label colliding with an auto label) ⇒ PULSE_COMPOSE_LABEL_COLLISION before any slot runs. Omitting labels keeps wire bytes and CanonicalHash identical to label-free callers.
Parallel execution
pulse.ComposeParallel with ComposeOptions{MaxWorkers, PerRequestTimeout, FailFast} runs slots on a bounded pool (MaxWorkers 0 ⇒ GOMAXPROCS; 1 ⇒ serial). CLI: pulse api compose --parallel N (default 1 = serial, 0 = GOMAXPROCS) and --no-fail-fast. FailFast cancels in-flight siblings on the first error; without it every slot runs and failures fold into one SERVICE_INTERNAL {failed_indices, first_error}. Response order always matches request order.
Located refusals. A slot’s own refusal (an unsupported zone, more than one join) keeps its code and gains details.request — the 0-based slot index — serial and parallel alike.
Post-slot Compose-overlay fold
ComposedRequest.Overlays runs AFTER every slot finalises. The fold reads each slot’s finished crosstab / data / components (read-only) and writes one sibling overlays[i] layer per spec. It never mutates a slot’s payload or components — overlays are additive decorations keyed to host coordinates (overlay-system).
Compose-host overlay kinds resolve Reference.SlotLabel / Target.SlotLabel against the labels above; find them among the manifest overlays[] entries serving compare_groups or benchmark and read their atomic skills. The usual refusal is schema divergence — the reference and target slots group or crosstab on different axes; keep the compared slots’ shapes identical and vary only the filter or cohort. Per-layer diagnostics land on overlays[i].warnings.
Library knobs on OverlaySpec.Options: DictPrefixFast (match slot schemas by byte-equal dictionary PREFIX; only when verified) and MaxPanelTargets (default 16; overflow ⇒ PULSE_OVERLAY_PANEL_TARGETS_OVER_CAP).
Multiplicity
ComposedRequest.multiplicity is every slot’s default; compose family pools all slots and Compose-host layers, the rest stay per slot; serial == parallel, --stream emits no corrections (multiplicity-correction).
Per-slot Components contract
Every slot’s components is emitted independently — the universal floor ({n, n_null} per aggregator, {total_n, n_null} per grouper, {n_in, n_out, n_null_input} per filterer) plus each operator’s own keys. The overlay fold runs AFTER that emission and treats it as read-only input, so a client can render per-slot components immediately and the overlay layer on top. --no-components / DisableComponents suppresses them on every slot (response-components).
Validate before executing
pulse_predict takes one Request: loop it over the slots to catch field typos, missing dictionaries and type mismatches before paying for pulse_compose. Compose overlay specs are checked when the fold runs, after the slots — a mislabelled overlay costs the full batch; check every SlotLabel against your labels.
See
response-components (per-slot components shape) · overlay-system (overlay hosts and resolution) · streaming-and-watching (Request.Hash() per-slot cache keys; --stream emits per-row events and skips the overlay fold) · process-chain (when slots depend on each other).
name: compose-sweeps
description: ComposedRequest.sweep — one request body with axis placeholders expands into many ordinary Compose slots (grid or zip), with a label pattern, per-slot overlays, limit counting and predict parity.
type: guide
kind: design
applies_to: compose, predict
covers: [ComposedRequest, SweepSpec, pulse_compose]
requires: [capability:compose_sweep]
Compose sweeps
A sweep is one request body plus axes of values. Before any slot runs, Pulse expands it into ordinary Compose slots, appended after the explicit requests. Each expanded slot then runs exactly like a hand-written one (compose-requests). Use it when the same question repeats over a parameter grid; write plain requests when the slots differ in shape.
{
"sweep": {
"axes": [
{"name": "region", "values": ["north", "south"]},
{"name": "cut", "values": [10, 20, 30]}
],
"mode": "grid",
"label": "{{region}}_{{cut}}",
"request": {
"cohort": {"filename": "sales.pulse"},
"filterers": [{"type": "<value filter>", "field": "region", "values": [{"$var": "region"}]}],
"aggregations": [{"field": "revenue", "label": "rev_{{cut}}"}]
}
}
}
Axes and modes
- An axis is a unique identifier
nameplus a non-empty list of scalarvalues(string, number, boolean). Numbers keep the digits as written. modegrid(default) is the Cartesian product, first axis slowest: 2 x 3 values => 6 slots.zipwalks the axes in lockstep and every axis must be the same length: 3 + 3 values => 3 slots.
Placeholders
{{axis}}inside a string splices the value as text.{"$var": "axis"}as a whole value keeps its JSON type (a number stays a number). A$whenguard may name an axis.{{inside an object key is refused.- Every axis must be referenced by the body, an authored
labelpattern or the overlays; a placeholder naming no axis is refused. Both ridePULSE_SWEEP_INVALIDwithdetails.reason(axis_unreferenced,placeholder_unknown,placeholder_invalid). - This is NOT expr-lang and not the stored-template surface: substitution happens before the body is decoded.
Labels
Omit label for the default: the axis=value pairs joined by _. An authored pattern may use any axis. The request body must not set its own label (request_label_set), and a pattern rendering empty is refused (label_empty). Labels must stay unique across explicit and sweep slots, or PULSE_COMPOSE_LABEL_COLLISION, before any slot runs. Overlays name sweep slots by these labels.
Strict body
The substituted body decodes STRICTLY into a Request: an unknown key or a wrong-typed value is PULSE_SWEEP_INVALID request_decode (details.unknown_field), even where a plain compose body would ignore it. The failure is found at expansion, before any slot runs.
Overlays
sweep.overlays is an array of compose overlay specs, substituted per expanded slot and appended after the request’s own overlays. Use {{axis}} in reference / targets to point each copy at its slot label.
With explicit requests, limits, predict
requestsandsweepcombine: explicit slots first (indices unchanged), sweep slots after. A request with only asweepis valid.MaxComposeSlotscounts the EXPANDED total (explicit + sweep); an over-limit batch isPULSE_LIMIT_EXCEEDEDbefore anything runs, serial and parallel alike.pulse_predictwithcomposed(andPredictCompose) expands too: same limit, label and slot checks as runtime, plus asweepsummary{axes[{name,count}], mode, expanded_count, labels}. Predict a sweep before running it.--echo-request/EchoRequestechoes the expanded slots the service ran. MCP fills a missingreturnin the sweep body with thestandarddefault, never overwriting an authored one.
Rank
sweep.rank {by, order, top} orders the SWEEP slots (never explicit ones) by one number and reports ranking: [{label, value, rank}] on the ComposedResponse, 1-based, order asc (default) or desc, top keeps the first N (>= 1). by is a dot path into each slot’s unshaped response: an object key, or on a list the element whose name or label equals the segment (never an index), ending on a number, e.g. components.aggregations.rev.n or regressions.fit.residual_std_err. Two matching elements, or a name and label that differ, are ambiguous.
- The batch is refused
PULSE_SWEEP_RANK_PATH(details.reasonsyntax,missing,not_number,ambiguous) whenbyresolves in no slot, hits a non-number or is ambiguous; an empty segment issyntax. - A null (NaN) value, or a path missing in this slot only, EXCLUDES that slot with a
PULSE_SWEEP_RANK_PATHwarning on its own response. omitemptyfigures vanish at exactly 0, so that slot reads as missing and is excluded (TODO #300).- A ranked slot always computes the ranked part, even if its
returnexcludes it.rankingsits outside the composereturn: always emitted, never rounded. --streamends with one{"ranking":[...]}line (even[]); rank-free sweeps emit none.
See
compose-requests (slots, labels, overlay fold, parallel) · overlay-system (overlay hosts) · item-fan-out (the same axes on ONE window / feature / attribute item; refused inside a sweep body) · request-templating (the stored-template surface this is not).
name: crosstab-guide
description: Crosstab slot — rows × columns groupers, margins-from-raw-rows, normalize / normalize_level / normalize_within, matrix vs long, fused vs buffered, CrosstabComponents indexing. Topical; per-aggregator/grouper math lives in op-* atomics.
type: guide
kind: design
applies_to: process, compose, predict
covers: [Crosstab, CrosstabComponents]
requires: [capability:crosstab]
Crosstab
Request.Crosstab pivots ONE cell aggregation across rows × columns; margins and normalize are cross-cell. Exclusive with top-level groups + aggregations (PULSE_CROSSTAB_CONFLICTS_WITH_GROUPS).
{"crosstab": {
"rows": [{"type": "<grouper>", "field": "region"}],
"columns": [{"type": "<grouper>", "field": "segment"}],
"cell": {"type": "<aggregator>", "field": "id", "label": "n"}
}}
Result Response.Crosstab.Matrix — RowKeys, ColumnKeys, Cells, margins, GrandTotal.
Choosing a crosstab
- Need margins (row / column / grand totals) or shares (row %, column %)? → crosstab.
- Only one figure per key combination, no totals? → plain
groups+aggregations(grouper-design). - Several figures per cell? → one crosstab per figure (Compose them). A second figure only on the MARGINS (e.g. an unweighted base,
"weight": null) ridesmargin_aggregations— a record reaches it only if it reached a CELL (crosstab-margin-aggregations). - Comparing cells statistically (share index, cell z, χ², pairwise column tests)? → a crosstab plus overlays (
overlay-system).
Axes, cell
rows, columns are each a list of groupers — any grouper, either axis. Several per axis = nested headers in composite-key order. Empty axes / missing cell ⇒ PULSE_CROSSTAB_EMPTY_ROWS / _EMPTY_COLUMNS / _MISSING_CELL.
Include ordering is per axis. Each axis honors its own grouper include order — a non-empty list emits keys in listed order (per key position on a nested axis), else alphabetical. Zero-record include values drop.
cell is one aggregation. Most cells are scalars (MatrixCell.Value: float64). Map- or list-valued cells (manifest crosstab.map_valued_cell_aggregators and the set unions / intersections) refuse a non-none normalize with PULSE_CROSSTAB_NORMALIZE_MAP_VALUED — normalize a scalar aggregator instead.
Margins recompute from raw rows
Row / column / grand margins aggregate the raw rows of that margin, NOT the cell values. So mean / median / stddev / percentile margins are correct, and cell-sum agreement holds only for true sums. Distinct-count margins are the union — a respondent in two rows counts once in the column margin. A row weight weighs cells and every margin alike; normalize divides weighted by weighted.
The manifest classifies every cell aggregator: crosstab.summable_aggregators, mean_reducible_aggregators, independent_aggregators (own margin accumulator) and recompute_aggregators (needs a raw-row rescan, so it never fuses).
Normalize
none (default), row, column, total — each cell divided by the matching margin. Zero margin ⇒ MatrixCell.Present=false. Normalize computes the matching margin even when margins.* is false (not displayed).
normalize_level: L— same-axis rollup; cells sharing the firstL+1groupers sum to 1. Rejections:_OUT_OF_RANGE,_WITHOUT_NESTED_AXIS,_INCOMPATIBLE(withtotal).normalize_within: W— fixes a prefix of the opposite axis in the denominator; composes withnormalize_level. Canonical:rows=[brand],columns=[wave,response],normalize=row,normalize_within=0— each cell is brand’s wave-share to that response. Rejections:_WITHIN_OUT_OF_RANGE,_WITHIN_WITHOUT_AXIS,_WITHIN_INCOMPATIBLE.
Shape
matrix (default) — Response.Crosstab.Matrix incl. NormalizeApplied. long — one row per cell on Response.Data, margin rows tagged _margin: "row"|"column"|"grand"|"<axis>_at_<depth>".
Streamability
Crosstab output is buffered (pulse predict reports streamable_reasons); what varies is how the grid is BUILT.
Fused mergeable path
Records fold into per-cell / per-margin state in one decode pass — memory O(cells + margins), not O(records). Automatic (predict: crosstab_fusable); identical output. It applies when:
- the cell aggregator is not in
crosstab.recompute_aggregators, and everymargin_aggregationsentry is mergeable; - every axis grouper keys one record at a time — all but the rank-based quantile one, fan-out included, any axis (extension: declared
Streamable/FansOut); - nothing needs whole-cohort context: no join, no tests, features, formula attributes or expression filters, no decimal cell field.
Overlays never prevent fusing. A fan-out axis makes margins non-additive on both paths (a 3-option record counts 3× in row margins).
Grid cap. Rows × columns over max_crosstab_cells refuses either path with PULSE_LIMIT_EXCEEDED; predict flags it possible.
Joins. A joined crosstab runs buffered over the JOINED rows: axes / cell may name as-prefixed right fields; an unmatched left row is counted nowhere; a 1:N match counts once per joined row (join-design).
Components — Response.Components.Crosstab
Mirrors the matrix coordinate-for-coordinate: CellComponents[r][c] ↔ Cells[r][c] (cell aggregator keys + floor {n, n_null}; nil for an empty cell), RowKeyComponents / ColumnKeyComponents ↔ the key tuples (a nested axis carries {axes: [{field, bucket}]}), RowMarginCounts / RowMarginComponents ↔ RowMargins (column symmetric), GrandTotalCount / GrandTotalComponents ↔ GrandTotal — populated iff the matching matrix slot is. CellCounts[r][c] is a RECORD count (n + n_null, raw under a weight), not the sample size.
Tests + overlays compose
Row-level tests / post_tests run on raw rows beside a crosstab. Crosstab is the MATRIX overlay host (share, margin compare, inferential, pairwise): specs ride Request.Overlays, layers Response.Overlays[i]; shares / indices of a weighted host are weighted; the cell t / z overlays read {n, mean, variance} from CellComponents (overlay-system).
See
multiplicity-correction,crosstab-margin-aggregations,response-components,overlay-system,grouper-design,aggregation-design(margin reducibility),statistical-testing.
name: crosstab-margin-aggregations
description: Auxiliary margin-only figures on a crosstab — a second base beside the cell metric in one scan, the cell-admission rule, present semantics and the display-flag gate
type: guide
kind: design
applies_to: process, compose, predict
covers: [Crosstab, CrosstabComponents, margin_aggregations]
requires: [capability:crosstab]
Crosstab margin aggregations
crosstab.cell is ONE aggregation. crosstab.margin_aggregations (optional, additive) carries extra aggregations evaluated into the row / column / grand margin accumulators only, never into a cell. Reach for it when a table needs a second figure per margin read against the cells — canonically an unweighted respondent base beside a weighted metric, or a distinct-respondent count beside a sum — without a second scan.
{"crosstab": {
"rows": [...], "columns": [...],
"cell": {"type": "<weighted metric>", "field": "spend", "label": "wspend"},
"margins": {"rows": true, "columns": true, "grand": true},
"margin_aggregations": [{"type": "<count>", "field": "respondent_id", "label": "base"}]
}}
Manifest: crosstab.supports_margin_aggregations + crosstab.margin_aggregation_rules.
Validation
- Effective label =
label, elseTYPE_field; unique across the slot and distinct from the cell’s, because figures are keyed by label. Faults:PULSE_CROSSTAB_MARGIN_AGG_INVALID(null entry / no type),PULSE_CROSSTAB_MARGIN_AGG_DUPLICATE_LABEL; an unknown operator is refused. - Declared on a crosstab that DISPLAYS no margin ⇒
PULSE_CROSSTAB_MARGIN_AGG_UNOBSERVEDwarning — the request runs, the figures have nowhere to land.
The admission rule
A record contributes to an auxiliary ONLY IF IT CONTRIBUTED TO A CELL. This is the least guessable property of the surface. Two exclusions follow, and they are the entire difference from the cell’s own margins beside it:
- a record whose CELL FIELD IS NULL;
- a record whose axis key a grouper
includelist EXCLUDED.
The cell’s own margins count both. Deliberate, no knob: an auxiliary is a base the cells beside it are read against, so it must see the records they saw; a cohort-wide base answers a different question while looking identical. Reading an auxiliary’s n as a cohort count is wrong SILENTLY — every figure still renders, only the base is off.
Both execution arms (fused and buffered) apply the rule identically; nothing in the response says which ran, and nothing needs to.
Where the figures land
On Response.Components.Crosstab:
| Key | Indexed by |
|---|---|
row_margin_aggregations[r] | RowKeys order |
column_margin_aggregations[c] | ColumnKeys order |
grand_total_aggregations | — |
Each is a map keyed by effective label; each entry is {value, present, components}. components = the floor {n, n_null} over ADMITTED records plus that operator’s own component keys — so a distinct-sum auxiliary exposes its distinct_count per margin slot beside the scalar: two figures off one scan.
- They sit BESIDE
row_margin_components(the CELL aggregator’s margin, which admits every filter-passing record routed to the row), never inside it. Merging the two would file differently-based figures under one roof. presentis load-bearing, not a zero value. A slot admitting no record carriespresent: falseand NOvaluekey — an aggregator over an empty set has no defined output, and a fabricated0would be indistinguishable from a real one. Itscomponentsstill carry the floor, whosen = 0is true.- Declaring no auxiliary emits none of the three keys.
The display-flag gate
Emission rides the DISPLAY flags alone (margins.rows / .columns / .grand), the rule the cell’s own margin figures follow. A normalize direction does not count: normalize makes the engine compute a margin as a denominator, and an auxiliary is never a denominator. So margins all false + normalize: row|column|total + a declared auxiliary does the work and emits nothing — predict warns PULSE_CROSSTAB_MARGIN_AGG_UNOBSERVED.
Expecting figures and the block is absent? Check the display flag first.
Fused execution
An auxiliary must be mergeable and non-decimal for the crosstab to fuse; otherwise the request runs buffered — the auxiliary is never dropped. Its margin-reducibility class is not consulted: it has no cells to reduce from.
See
crosstab-guide (axes, margins, normalize) · response-components (floor, crosstab block).
name: derived-cohorts
description: Materialise a request's derived columns (features, attributes, windows) into a new cohort so a later request can regress, group, filter or join on them — when to, the two-step recipe, what the output holds.
type: guide
kind: design
applies_to: materialize, process
covers: [Materialize, MaterializeResult, pulse_materialize, derived columns]
requires: [capability:materialize, capability:process]
Derived cohorts
A derived column — a window, a feature output, an attribute — exists only inside the request that computes it. Regressions, groupers, filters, joins and tests read COHORT fields, so they cannot name one (SERVICE_VALIDATION: unknown field). Materialising writes the derived columns as ordinary fields of a NEW cohort; the next request reads them like any other field.
Surfaces: MCP pulse_materialize (tool-materialize), CLI pulse api materialize -r REQ.json -o OUT.pulse [--overwrite] [--strict] [--json], library Pulse.Materialize(ctx, req, out, MaterializeOptions{Overwrite, Strict}).
When to materialise
- A model over a window: a lagged, smoothed or cumulative predictor.
- Grouping or filtering on a derived value: a log-spend bucket, a rank.
- One derived column feeding several later requests: compute once, read many times.
A single request that only DISPLAYS a derived value needs no materialise step: run it directly. A chain stage cannot carry windows or features (process-chain), so a chain is no substitute.
The two-step recipe
Step 1 — materialise (the request carries only derived columns and filters):
{
"request": {
"cohort": {"filename": "weekly.pulse"},
"windows": [{
"type": "<a WIN_* operator>", "field": "spend", "label": "spend_lag1",
"partition_by": ["region"], "order_by": [{"field": "week"}]
}]
},
"out": "weekly_derived.pulse"
}
Step 2 — point the next request at out and reference the column by its label: a regression with "predictors": ["spend_lag1"], a grouper on it, a filter on it.
Label every output you will reference; without a label the column takes the operator’s default output name (the derived_columns list in the result names each one).
What the output holds
- Rows: one per filter-passing source record, in source order (an archive’s shards in archive order) — or in
sortorder, keyed on source fields or derived columns withpulse_process’s comparator.sortmoves rows, never values: each window already ran under its ownorder_by. Features see every record; attributes and windows the survivors — exactly aspulse_processcomputes them. - Columns: the source fields verbatim (types, nullability, dictionaries, descriptions), then one nullable
f64per derived output in pipeline order — feature outputs (every output of a multi-output feature), attributes, windows. Each gets a generated description naming operator, field, params, partition / order, the frame only when the operator reads it (not when its manifest entry saysignores_frame) and the source cohort; an operator flaggedreads_expression(a formula) is described by its expression verbatim (<TYPE> = a * 0.9 — derived from …). - Integer window outputs (row number, rank) are stored as
f64; a non-numeric output (a lag over a categorical) isPULSE_MATERIALIZE_OUTPUT_NOT_NUMERIC. - Always a single-file cohort; source sidecars are not carried — rebuild an index on the new cohort if you need one.
Refusals
All before anything is written; the write itself is atomic.
| Code | Cause | Fix |
|---|---|---|
PULSE_MATERIALIZE_SLOT_UNSUPPORTED | a slot other than cohort, label, features, filterers, attributes, windows, sort, weight, time_zone, disable_components (details.slot) | move that slot to the step-2 request |
PULSE_MATERIALIZE_NO_DERIVED | no feature, attribute or window | nothing to write: run the request directly |
PULSE_MATERIALIZE_OUTPUT_UNDECLARED | an embedder-registered feature, whose outputs cannot be named before it runs | apply it in the step-2 request instead |
SERVICE_VALIDATION | out exists | pick a new path, or overwrite (stale sidecars are listed, never rebuilt) |
Process’s own pre-flight runs first, so a request pulse_process refuses is refused here with the same code.
Gotchas
- Windows need their frame. A cumulative window without
frame({"mode": "rows", "preceding": null, "following": 0}) sums the whole partition on every row. - Filters shape the rows. A window over the survivors differs from one over every record; filter in step 1 only when step 2 should see the same population.
- Re-materialise after the source changes: the output is a snapshot, not a view.
- Do not round-trip through an export and
pulse_import. Re-importing a streamed result is lossy (types re-inferred, columns projected); materialise instead.
name: expression-language
description: The per-row expression environment shared by the formula attribute and the expression filter — field binding by type, operators, built-in functions, set helpers, null handling, embedder functions and lookup tables. Not request templating.
type: guide
kind: design
applies_to: process, compose, predict
covers: [expression, expr-lang, formula, set-helpers, vector-functions, lookup, ExprFunctions, coef]
Expression language
Two operators evaluate an expr-lang/expr (v1.17.x) string once per row: the formula attribute (the result becomes a new column) and the expression filter (a truthy result keeps the row). Both compile through one environment, so everything below holds for both; only null handling differs. Per-operator params live in their atomic op-* skills.
This is NOT request templating: $var / {{}} / $when are substituted into the request before decode (request-templating); an expression runs over row fields at execution time. There is no interop.
Field binding
Fields are referenced by bare name; the value a name binds depends on the schema type:
| Field type | Binds as | Write |
|---|---|---|
numeric (u*, f*) | number | weight_kg / (height_m ** 2) |
categorical_* | dictionary STRING, never the index | brand == "Apple", region in ["NA", "EU"] |
set_* | sorted []string of member labels | "a" in tags, has_any(tags, "a", "b") |
decimal128 | Decimal128 | — |
Arithmetic on a categorical errors — branch on the label and emit a number.
Operators and functions
- Operators: arithmetic (
+ - * / % **), comparison, logical (and or not), membership / pattern (in; string-onlycontains,startsWith,endsWith,matches), range (..), nil-coalesce (??), ternary. - Functions: numeric (
abs,ceil,floor,round,min,max,sum,mean,median), cast (int,float,string), collection (len,keys,values,concat,sort,uniq,filter,map,reduce,all,any), string (join,split,replace,trim,lower,upper,hasPrefix,hasSuffix,indexOf), JSON / time (toJSON,fromJSON,now,date,duration). - Set helpers:
has_any,has_all,has_none,popcount,set_union,set_intersect,set_diff,set_xor.containsis a string operator keyword, never a set helper — test membership with"x" in tags. %on a field is floatmath.Mod(dividend’s sign);% 0→ NaN, as/ 0→ ±Inf.
Vector and maths built-ins
A vector is a literal list, [5, 5, 1, 1] or [q1, q2, q3]; a nil element is NaN. A request’s named vector binds by name, vmean(battery) (null member → NaN). Reductions skip NaN; none valid (or empty) → NaN, vsum too.
| Function | Result |
|---|---|
vsum vmean vmin vmax norm (L2) | float |
vcount | int count of valid elements |
vsd | sample SD (ddof 1); < 2 valid → NaN |
dot dist cosine (vector pair) | pairwise-complete; zero norm → cosine NaN; unequal lengths → PROCESSING_VECTOR_LENGTH_MISMATCH |
argmax argmin | 0-based int index; none valid → NaN |
vcenter vzscore vnormalize (mode "l1" / "l2", case-sensitive) | a vector, only inside a call: vmax(vzscore([q1, q2, q3])); top-level → PROCESSING_CONFIG |
sqrt log (natural) exp pow(score, 2) | IEEE scalar (sqrt(-1) NaN); the only built-ins an overlay formula admits |
jaccard dice overlap hamming (two sets: field or ["a", "b"], matched by label; a dictionary-less set pairs only with another) | float, hamming int; null set → NaN; two empty → NaN, hamming 0 |
A non-finite result is null on the wire. These names are reserved: an embedder ExprFunctions entry using one → PULSE_EXTENSION_NAME_RESERVED.
Null handling
A null field binds nil. Guard with x ?? 0 or x == nil ? a : b. What an unguarded nil does depends on the host:
- Formula — arithmetic on
nilraisesPROCESSING_RUNTIME; anilresult is a null value. - Filter —
==is false,!=true;>/+/lenonnilmake the predicate UNKNOWN and the row is dropped.
The program compiles once per request: bad syntax or a type error is PROCESSING_RUNTIME at build, before any row is read.
Embedder extensions
pulse.Options.Extensions.ExprFunctions merges custom functions into the environment; LookupTables is reachable as lookup(table, keys...) — an unknown table is PULSE_LOOKUP_TABLE_UNKNOWN, a missing key PULSE_LOOKUP_MISS. Both hosts see them. Recipe: docs/src/internals/extension-points.md.
Bound models
coef(fit, term[, stat]) reads a compose fit’s figure at full precision: term is a predictor or "(intercept)"; stat is point (default), lower / upper (interval at the fit’s conf_level) or se. Marginal return: coef("mmm", "tv_sat") * tv / (1 + tv). Codes: PULSE_MODEL_UNKNOWN, _TERM_UNKNOWN, _STAT_UNKNOWN (predict checks literal arguments), _INTERVALS_UNAVAILABLE (fit has no standard errors). A figure the fit lacks for one term is NaN, never 0. An embedder coef shadows it.
Patterns
{"label": "bmi", "expression": "weight_kg / (height_m ** 2)"}
{"label": "is_premium", "expression": "brand in [\"Apple\", \"Samsung\"] ? 1 : 0"}
A formula’s result is coerced to a scalar (bool → 1.0 / 0.0); a filter’s must be bool — a non-bool on a non-null row is PROCESSING_RUNTIME.
Gotchas
- A formula sees source fields, feature output and EARLIER attribute labels (declared order), never a later one.
total(expr)— cohort-wide sum of a row-level expression over post-filter rows; allowed ONLY in the formula attribute (PULSE_FORMULA_TOTAL_SITEin a filter or overlay formula). One argument, no nesting; unweighted; nulls skipped. Makes the formula two-pass (one extra scan). Not overridable byExprFunctions.- Filters run before attributes, so a filter expression cannot read a derived column — restate it over source fields.
See
attribute-composition— where the formula attribute sits among attributes.aggregation-design— filter chaining and per-stage counters.request-templating— the request-authoring parameters this language is not.docs/src/internals/extension-points.md—ExprFunctions+LookupTables.
name: facet-design
description: FacetSchema endpoint — per-field summaries, histograms, additive contribution counts, streamability tradeoffs, four FACET-host overlay kinds against Population. Per-OVERLAY math in op-overlay-* atomics.
type: guide
kind: design
applies_to: facet
covers: [FacetRequest, FacetSchema, OVERLAY_INDEX_VS_POP, OVERLAY_ZSCORE_VS_POP, OVERLAY_CHISQ_VS_POP, OVERLAY_KS_VS_POP]
requires: [capability:facet]
Facet design
Two entry points. pulse.Facet(ctx, path, field) returns distinct values. pulse.FacetSchema(ctx, *FacetRequest) is the rich multi-field endpoint — counts, null tallies, streaming numeric stats, optional percentiles, fixed-width histograms, additive contribution counts, FACET-host overlays.
{
"cohort": {"filename": "cohort.pulse"},
"fields": ["region", "age", "is_active"],
"filterers": [{"type":"<value filter>","field":"country","values":["US"]}],
"additive_fields": ["region"],
"discrete_top_k": 50,
"numeric_percentiles": [0.5, 0.95, 0.99],
"include_histogram": true,
"histogram_bins": 20,
"histogram_range": [0.0, 100.0]
}
FacetResult.Fields[i] is one FacetField per fields entry; FacetResult.Additive[F] is one per AdditiveFields entry. Each FacetField has Kind ∈ {discrete, numeric}, source field type, description, null count, and either Discrete (sorted desc by count, ties asc by value-string) or Numeric (count/sum/min/max/mean/stddev via Welford; percentiles + histogram only when requested).
Discrete vs numeric dispatch
Field type drives dispatch: categorical_* + packed_bool ⇒ discrete (dictionary fast path; bool emits "true"/"false"). All numeric / date / decimal ⇒ numeric. numeric_percentiles on a non-numeric field is a no-op (predict warning). Histograms require a numeric field plus caller-supplied histogram_range — no two-pass auto-range. A field’s distinct values past max_groups refuse PULSE_LIMIT_EXCEEDED.
TopK, percentiles, histograms
discrete_top_k > 0 keeps top K by count (desc, ties asc), sets Discrete.TruncatedAt = distinct - K, emits a warning. numeric_percentiles ∈ (0,1) force the buffered path on requested fields (full sort + linear interp). Keys serialise as p<int> or p%g. Values match the percentile aggregator to float64. Histograms stream into fixed-width buckets; bin width (max-min)/bins; values outside [min,max] drop from the histogram (still feed mean/stddev/count). histogram_bins defaults to 20, caps at 256.
Additive contribution counts
For each AdditiveFields entry F, the engine builds a scope filter = base Filterers minus every clause targeting F, then runs a parallel discrete accumulator. Result at FacetResult.Additive[F]. Answers “if I added V to my filter on F, how many records survive?” per V. When no base clause names F, the scope filter equals the base filter.
Expression-filter restriction: the scope-stripper cannot remove a clause hidden inside an expression body, so a request naming an additive field inside an expression filter is rejected with SERVICE_VALIDATION. Filter that field with a discrete include / exclude value filter instead.
Streamability
Single-pass when: no numeric_percentiles; AND include_histogram=false OR histogram_range supplied; AND every filterer is row-local. Buffered otherwise. Manifest: manifest.facet.streamable_conditions.
A filterer naming an unknown field — or, outside an expression filter, none — is refused by FacetSchema and predict alike with Process’s rule: SERVICE_VALIDATION filter references unknown field: <f>.
FACET-host overlays
FacetRequest.Overlays decorates with population-comparison statistics. Each OverlaySpec produces one OverlayLayer in FacetResult.Overlays in slot order. The legal kinds are manifest facet.supported_overlay_kinds, all against a Ref.Population reference cohort:
| Kind | Shape | Stream | Host arm | Computes |
|---|---|---|---|---|
OVERLAY_INDEX_VS_POP | per-value series | yes | discrete or numeric | (subset_freq / pop_freq) * 100 per value/bin |
OVERLAY_ZSCORE_VS_POP | per-value series | yes | discrete or numeric | (subset_freq - pop_mean) / pop_sd |
OVERLAY_CHISQ_VS_POP | scalar | buffered | discrete only | χ² goodness-of-fit + df + p-value |
OVERLAY_KS_VS_POP | scalar | buffered | numeric only | KS D-statistic + asymptotic p-value |
Common contract: Ref.Population REQUIRED (Cohort names the comparison .pulse); any other family ⇒ PULSE_OVERLAY_REF_INCOMPATIBLE_WITH_SHAPE. Scope = group only; Level = 0, Within = 0 (non-zero ⇒ _LEVEL_OUT_OF_RANGE). Host-field via OverlaySpec.Params["field"]; omit only when FacetRequest has one Field. Unknown field ⇒ _REF_INCOMPATIBLE_WITH_SHAPE with {field, available_fields}. A discrete-only kind on a numeric field ⇒ _REF_INCOMPATIBLE_WITH_SHAPE; a numeric-only kind on a categorical field ⇒ _SCOPE_UNSUPPORTED. Runtime recurses FacetSchema against the population, forwarding NumericPercentiles/IncludeHistogram.
Mixing streamable + buffered kinds forces the orchestrator buffered; descriptive math stays byte-equivalent. Same cohort may serve as host + population (recursion strips Filterers). Warning codes: PULSE_OVERLAY_REF_ZERO (per entry for the series kinds; once per layer for a degenerate population on the scalar tests), PULSE_OVERLAY_EXPECTED_LOW (the χ² kind when any expected cell is < 5).
Multiplicity
Facet overlay specs take multiplicity (family layer); each layer is corrected alone (multiplicity-correction).
Picking FacetSchema vs Process
- Distinct values of one field, no counts ⇒
pulse.Facet. - Per-value counts or a numeric summary of N fields, each on its own ⇒
FacetSchema. - “How many would survive if I added value V to my filter?” ⇒
FacetSchemawithadditive_fields. - Counts or statistics split by a COMBINATION of keys ⇒
Processwithgroups+aggregations(grouper-design). FacetSchema is not a grouper — cross-tabs belong inProcess.
See
overlay-system (overlay framework) · aggregation-design (percentile semantics) · statistical-testing (row-level χ² / KS) · label-display (labels on FacetField values).
name: feature-engineering
description: Feature slot semantics — choosing a transform, pre-filter ordering (features run before filters), per-row vs global-pass cost, target-leakage trap, train/test split. Topical design; per-feature detail lives in atomic op-feat-* skills.
type: guide
kind: design
applies_to: process, compose, predict
covers: [FEAT, features, PULSE_FEAT_TARGET_LEAKAGE_RISK]
Feature engineering
features adds DERIVED COLUMNS that the rest of the request can reference by label — the ML-pipeline transforms: variance-stabilising (log / sqrt), binning, categorical encoding (one-hot, frequency, target), date decomposition, polynomial expansion and train/test assignment. Per-operator detail (formula, null rules, output column naming, accepted types, degree caps) lives in atomic op-feat-* skills.
Choosing a feature
pulse_skills_get intents → prepare (every feature serves it), narrowed by distribution_shape (taming a long tail), relationship (curvature for a model), composition / segment (encoding categories, assigning partitions), change_over_time (calendar parts). Keep the manifest components.features entries carrying it, then read op-feat-<name>; each one’s guidance names its alternatives. The deciding questions:
- Must a filter, grouper or test downstream consume the column? Then it is a feature; if only post-filter rows matter, derive an attribute (
attribute-composition). - A reported table, not a model input? Display bins are groupers; a category’s average outcome is an aggregation.
- Encoding a categorical — few categories → one column each; many → a single numeric code (frequency, or target — mind the leakage trap below).
- Does it read the whole cohort? That sets its cost class (below) and leakage risk.
Slot position — pre-filter
Features run before filters: features → filterers → attributes → groups → aggregations → windows → sort.
- A feature’s output column is addressable by every downstream stage (filterers, attributes, groupers, aggregators, windows) — bucketize then filter on the bucket; filter on a split tag.
- Features see the RAW record set before filtering. A feature’s stats (frequency, target mean, quantile cutpoints) reflect the entire cohort, not the filtered subset.
Composition rules
- Order matters. Later features can reference earlier features’ labels.
- Labels unique within the slot. Collision →
PROCESSING_CONFIG. - Naming — one-to-one ops emit one column (
<PREFIX>_<field>unless labelled); fan-out ops (one-hot, date parts, polynomial) emit a prefixed column family. The atomic skill names them; predict reports the post-feature schema. - Two cost classes:
- Per-row — the value depends only on this row (log / sqrt, one-hot, date parts, polynomial, bucketize with explicit boundaries). One pass.
- Global-pass — the value depends on the whole cohort (frequency / target encoding, train/test assignment, bucketize by quantiles). A precompute sweep, then per-row emit.
Streamability
Stream-eligible when every feature implements the streaming computer interface (embedder-authored features implement extend.StreamingFeatureComputer) AND the rest is stream-eligible (online aggregators, no groups, attributes, or windows). Per-row features stream record-by-record. Global-pass features precompute then rewind via iter.Reset() — slice iterator O(1), file-backed iterator re-reads the file (doubles I/O).
Train/test assignment materialises its table in precompute — O(rows) memory either way. Predict reports per-slot streamability under data.streamable_reasons.
Target-leakage trap — PULSE_FEAT_TARGET_LEAKAGE_RISK
Target encoding replaces a categorical with the MEAN of a numeric target over rows sharing that category. Optional smoothing s shrinks rare categories toward the global mean: encoded = (n * mean_cat + s * mean_global) / (n + s).
The trap: each encoded value averages EVERY row’s target — test / val rows and the row’s own included. The encoder reads no split column and features run pre-filter, so a split == 0 filter keeps the leaked means.
Predict surfaces PULSE_FEAT_TARGET_LEAKAGE_RISK (warning; error under --strict / Options.Strict: true) on EVERY target encoder — a preceding train/test split changes no value, so it does not silence it. Train-only means: a separate request averaging the target grouped by the category, filtered to split == 0, mapped back yourself.
Train / test / split semantics
The train/test split feature tags each row in a numeric split column. 0 = train, 1 = val, 2 = test; two- or three-element ratios, optional stratify (per-class shuffle keeps class balance), deterministic seed.
Downstream: keep split == 0 with a value filter for train-only; group by split for per-partition metrics. Same seed + same rows in the same order ⇒ same labels.
Components
Features emit per-record columns, not Response.Components. To audit a feature column, read it from Response.Data or wrap it in an aggregation.
Gotchas
- Fan-out column sets come from the SCHEMA (a one-hot column per dictionary entry, even for categories absent from the rows), so predict and the executor agree without a scan.
- Predict refuses mutually exclusive params (bucketize: boundaries XOR quantiles) as
PROCESSING_CONFIGand a source type outside the manifestaccepts_typesasSERVICE_VALIDATION— check it before using adecimal128or categorical source (financial-cohorts). - Polynomial expansion overflows unstandardised —
Degree=10on|x|=100yields1e20. Standardise first. - Features can’t reference attribute labels (attributes run after). Stage via Compose / ProcessChain.
See
- Recipes:
pulse_examples_search tags=["feature-engineering"],tags=["target-encoding"],tags=["polynomial-regression"],tags=["train-test-split"]plus atomicop-feat-<name>. attribute-composition— when to derive a column AFTER the filter instead.regression-modeling— polynomial columns upstream of a linear fit.request-envelope— slot keys, streamability, smart defaults.streaming-and-watching— streaming-vs-buffered pipeline selection.pulse_errors_lookup—PULSE_FEAT_TARGET_LEAKAGE_RISKrecovery playbook.
name: financial-cohorts
description: decimal128 patterns — precision/scale propagation, banker's rounding, divide-by-zero policy, currency conventions, three consumer-facing decimal error codes. Use when a cohort has monetary fields or a response carries PULSE_DECIMAL_OVERFLOW / PULSE_PRECISION_LOSS / PULSE_DECIMAL_DIVIDE_BY_ZERO.
type: guide
kind: design
applies_to: process, compose, predict, inspect
covers: [decimal128, PULSE_DECIMAL_OVERFLOW, PULSE_PRECISION_LOSS, PULSE_DECIMAL_DIVIDE_BY_ZERO]
Financial cohorts (decimal128)
decimal128 is the only defensible type for money-movement workloads — up to 38 decimal digits, per-field declared (precision, scale), banker’s rounding, no NaN, no infinity, no in-band null sentinel.
When to pick decimal128
- Currency, billing, accounting, tax, payments, ledger entries.
- Anything reconciling to a downstream system of record (GL, payments rail, exchange).
- Cohorts auditors will read.
f64 is fine for descriptive analytics where last-cent precision is irrelevant. Once the column drives money movement, switch to decimal.
Type spec
decimal128(precision, scale) — precision = total significant digits (1–38); scale = digits after the decimal point (0–precision).
| Type | Use for |
|---|---|
decimal128(20, 6) | USD ledger with sub-penny accumulation |
decimal128(18, 2) | Standard USD / EUR amounts |
decimal128(38, 18) | Crypto native units |
decimal128(38, 0) | Integer-only counts beyond u64 |
Both precision and scale persist in the schema; pulse_inspect surfaces both per field.
Rounding
Banker’s rounding (half-to-even). One mode, no per-op override. IEEE 754 default; the only unbiased choice for large-series aggregation. At scale 0: 0.5 → 0, 1.5 → 2, 2.5 → 2, -1.5 → -2, 1.6 → 2.
Precision propagation (SQL:2016 / Arrow Decimal128)
| Op | Result precision | Result scale |
|---|---|---|
(p1,s1) + (p2,s2) | max(p1-s1, p2-s2) + max(s1,s2) + 1 | max(s1, s2) |
(p1,s1) - (p2,s2) | same as + | same as + |
(p1,s1) * (p2,s2) | p1 + p2 | s1 + s2 |
(p1,s1) / (p2,s2) | p1 + s2 + 1 | max(s1+s2, MIN_SCALE) where MIN_SCALE = 4 |
All result precisions clamp at 38. When clamping kicks in and the value overflows, PULSE_DECIMAL_OVERFLOW.
Examples: (10,4) + (10,4) → (11,4). (10,4) * (10,4) → (20,8). (10,4) / (10,4) → (15,8).
Mixed-type arithmetic
No implicit cast between decimal128 and f64. Mixing them in a filter expression or a formula raises a type error (expression-language). Downcast via an explicit attribute.
Divide-by-zero
Decimal divide-by-zero raises PULSE_DECIMAL_DIVIDE_BY_ZERO. No NaN, no infinity. Guard divisors with a range filter if the data contains zeros.
Aggregations on decimal fields
An aggregator takes a decimal field iff its manifest accepts_types lists decimal128; predict refuses the rest with PULSE_AGG_NOT_MEANINGFUL_FOR_DECIMAL. Registered extension aggregators are exempt (exact values via DecimalValue). What a supported one returns follows from the math it needs:
| Kind | Result |
|---|---|
| sum | decimal128 (overflow → error) |
| min / max | decimal128 |
| mean | decimal128; f64 fallback + PULSE_PRECISION_LOSS warning on overflow |
| variance | decimal128 at 2 * mean_scale (f64 fallback) |
| standard deviation | decimal128 at mean_scale, banker-rounded sqrt (f64 fallback) |
| counts (rows, distinct values) | int |
v1 not supported on decimal: order statistics (median, percentiles), standardised and shape moments (z-score, skewness, kurtosis), modes, range.
Precision-loss path on the mean
Default path accumulates decimal128 sum and divides by count. If running sum would overflow decimal128(38) mid-aggregation: re-run with f64 accumulators, emit PULSE_PRECISION_LOSS warning (not an error). For audited workloads, split or coarsen so the warning never fires.
Null state
decimal128 opts into nullability via Field.Nullable = true + the per-record bitmap (see cohort-schema-design). Every 16-byte pattern is a legitimate value; the bitmap is the sole authority. Importers parse configured null tokens ("", "null", "na", "n/a", case-insensitive) into bitmap bits on nullable fields and reject them on non-nullable fields with PULSE_IMPORT_ROW_ERROR.
Filtering
The standard comparison filterers — range, include / exclude, expression — work natively on decimal128 fields. No dedicated filter type.
Feature operators
Feature transforms are float math: none emits a decimal column, and categorical- or date-only transforms never take a decimal source. Which accept one is the manifest accepts_types of each feature (predict refuses the rest with SERVICE_VALIDATION). Feature outputs are NOT auditor-defensible — for decimal precision downstream, aggregate on the original column.
Importer accepts / rejects
CSV / TSV / NDJSON / JSON-array importers parse decimal strings strictly. Accepted: "123", "-123.45", "+0.001". Rejected: "$1,234.56", "1 234.56", "1.5e3", leading/trailing whitespace. Malformed row → PULSE_IMPORT_ROW_ERROR; row is skipped.
Request shape skeleton
{
"aggregations": [
{"type": "AGG_SUM", "field": "amount_usd"},
{"type": "AGG_AVERAGE", "field": "amount_usd"}
]
}
The sum stays in decimal128(p, s). The mean preserves precision by default; large cohorts may fall back to f64 with a PULSE_PRECISION_LOSS warning.
v1 deferred
- Per-op rounding-mode override.
MIN_SCALEconfiguration on division (locked at 4).- Decimal-aware median / percentile / mode.
Cross-links
cohort-schema-design— schema layout, null bitmap, shards.aggregation-design— aggregator slot semantics; per-op decimal support inop-agg-*.pulse_errors_lookup— canonical message + fixups for every error code mentioned here.
name: glossary
description: Plain-language glossary of the statistical terms Pulse guidance uses. Use when a result, purpose or skill names a term you need explained.
type: reference
kind: reference
Glossary
Plain-language definitions of the terms Pulse’s guidance relies on, sorted by ID. Purposes cite these IDs in their glossary lists.
adjusted-r-squared
R-squared corrected for the number of predictors: it rises only when an added predictor’s t value exceeds 1 in size, which a useless predictor still does about one time in three.
Why it matters: Use it to compare models with different numbers of predictors on the same rows. It can fall below zero when the predictors fit worse than chance would predict.
Written as: adjusted r-squared, adjusted r squared, adjusted r², adj_r2
See also: r-squared, overfitting
alpha
The p-value threshold you pick before looking at the data, below which you call a result significant.
Why it matters: It is the false-alarm rate you accept when there is truly no effect: the share of such tests you would wrongly call significant. Pick it up front; moving it after seeing results defeats its purpose.
Written as: alpha, significance level
See also: p-value, statistical-significance, multiple-comparisons
baseline
The reference a figure is compared with: a chosen period, a named group, a population, a total or another request’s result.
Why it matters: Every comparison is only as meaningful as its baseline. Say which one you used, and pick one that is stable and large enough to compare against.
See also: index-value, margin
centroid
The centre point of a cluster: the average of every field across the rows in it.
Why it matters: Comparing centroids is how you describe what makes one segment different from another.
Written as: centroid, centroids
See also: distance, similarity
chi-square
A test statistic that adds up how far observed counts in each cell sit from the counts you would expect if nothing were going on.
Why it matters: It measures the evidence that two categorical fields are related, but not how strongly; pair it with Cramer’s V or phi for the size.
Written as: chi-square, chi-squared, chi square, chi squared
See also: test-statistic, independence, cramers-v, cross-tabulation
cohens-d
The difference between two means expressed in standard deviations.
Why it matters: It makes differences comparable across measures with different units: a gap of half a standard deviation is the same standardized size on any scale, though whether it matters depends on the measure.
Written as: cohen’s d, cohens d, cohens_d
See also: effect-size, standard-deviation, t-statistic
cohens-h
The difference between two proportions, put on a scale that treats a change near 0% or 100% as bigger than the same change near 50%.
Why it matters: Comparing raw percentage-point gaps can mislead near the edges; this puts proportion differences on an even footing.
Written as: cohen’s h, cohens h, cohens_h
See also: effect-size, cohens-d
confidence-interval
A range built by a method that, over repeated samples, captures the true value a stated share of the time (such as 95%); any single interval either contains it or not.
Why it matters: It shows the uncertainty in plain units. A wide interval means you know less than the single estimate suggests.
Written as: confidence interval, confidence intervals
See also: standard-error, p-value, sample-size
continuity-correction
A half-step adjustment used when a smooth curve approximates a statistic that moves in whole steps, such as a count or a rank sum.
Why it matters: It keeps approximate p-values from coming out too small in small samples. Tools differ on whether they apply it, which explains small mismatches between them.
Written as: continuity correction, yates correction, yates’ correction
See also: p-value, exact-test
correlation
How closely two measures move together in a straight line, from -1 (opposite) through 0 (no straight-line link) to 1 (in lockstep).
Why it matters: It shows association, not cause. It also misses curved relationships and can be distorted by a few outliers.
Written as: correlation, correlations, correlation coefficient
See also: covariance, r-squared, outlier, independence
covariance
How two numeric fields vary together: positive when they rise together, negative when one rises as the other falls.
Why it matters: Its size depends on the units, so it is hard to read on its own; correlation is the unit-free version.
Written as: covariance, covariances
See also: correlation, variance
cramers-v
The strength of the relationship between two categorical fields, from 0 (unrelated) to 1 (one fully determines the other).
Why it matters: A chi-square p-value weighs the evidence that the fields are related; Cramer’s V says how strongly, and unlike chi-square it does not grow with the number of rows. Compare V only between tables of the same shape.
Written as: cramer’s v, cramers v, cramers_v
See also: chi-square, phi, effect-size, cross-tabulation
credible-interval
A Bayesian range that holds a parameter with a stated probability, given the prior, the model and the data.
Why it matters: Unlike a confidence interval it is a direct probability statement about the parameter, but that probability is conditional on the prior and the model; change either and the interval changes.
Written as: credible interval, credible intervals
See also: posterior, prior, confidence-interval
cross-tabulation
A table that counts rows for every combination of two categorical fields, one along the rows and one along the columns.
Why it matters: It is the simplest way to see whether two categories go together; a chi-square test then says how surprising the pattern would be if the two fields were unrelated.
Written as: cross-tabulation, contingency table, contingency tables
See also: chi-square, cramers-v
degrees-of-freedom
How many independent pieces of information a statistic was built from, usually the row or group count minus the things estimated along the way.
Why it matters: Tests need it to turn a statistic into a p-value. Very few degrees of freedom means the result rests on thin evidence.
Written as: degrees of freedom, degree of freedom
See also: test-statistic, sample-size
deviance
A measure of how far a fitted model’s predictions sit from the observed outcomes, on a log-likelihood scale; lower means a closer fit.
Why it matters: On its own the number has no fixed scale. Compare it with the null deviance (the intercept-only model) or with another model fitted to the same rows and outcome.
Written as: deviance, deviances, null deviance
See also: generalized-linear-model, pseudo-r-squared
distance
A number for how far apart two rows are across several fields; zero means identical.
Why it matters: Clustering groups rows by distance, so fields with large units dominate unless you standardise them first.
See also: similarity, centroid, z-score
effect-size
A number that says how BIG a difference or relationship is, on a scale that does not grow just because you have more rows.
Why it matters: It says how big the difference is, while the p-value says how surprising the data would be if there were no difference. Whether that size matters depends on your context. Report both.
Written as: effect size, effect sizes
See also: p-value, statistical-significance, cohens-d, eta-squared, cramers-v
effective-sample-size
The number of unweighted rows that would give the same precision as your weighted sample.
Why it matters: Uneven weights cost precision: a weighted sample of 1,000 can carry the information of far fewer rows.
Written as: effective sample size, effective n
See also: weighting, sample-size, standard-error
eigenvalue
How much of the total variation in a set of fields one underlying dimension accounts for.
Why it matters: It ranks the dimensions a principal component analysis finds; dimensions with small eigenvalues explain little and are usually dropped.
Written as: eigenvalue, eigenvalues
See also: principal-component, variance
epsilon-squared
The rank-based counterpart of eta-squared, used with Kruskal-Wallis: how much of the ordering of rows is explained by group.
Why it matters: It gives a size to a non-parametric group comparison, which otherwise only yields a p-value.
Written as: epsilon squared, epsilon-squared, epsilon_squared
See also: eta-squared, non-parametric, rank
eta-squared
The share of the total variation in an outcome that is explained by which group a row belongs to.
Why it matters: It turns an ANOVA into “how much does group membership matter”. It runs a little high in small samples; omega-squared corrects for that.
Written as: eta squared, eta-squared, eta_squared
See also: effect-size, omega-squared, partial-eta-squared, f-statistic
exact-test
A test whose p-value is computed exactly from every possible arrangement of the data rather than from a large-sample approximation.
Why it matters: It stays valid with tiny counts where approximations such as chi-square break down. The cost is computation, so it suits small tables.
Written as: exact test, exact tests
See also: p-value, chi-square, continuity-correction
f-statistic
The test statistic of an ANOVA: how much group means spread apart compared with how much rows spread within groups.
Why it matters: Values near 1 mean the groups differ about as much as noise would make them; larger values are stronger evidence against all group means being equal. Read the p-value to judge how large is large, and an effect size for how big the difference is.
Written as: f-statistic, f statistic, f-ratio
See also: test-statistic, variance, eta-squared, post-hoc-test
factor
An unobserved quality, such as satisfaction, that several measured fields are assumed to reflect together.
Why it matters: Grouping questions into factors lets you measure something no single question captures well.
Written as: latent factor, latent factors, factor analysis
See also: loading, principal-component, reliability
false-discovery-rate
The expected share of false positives among the results you call significant.
Why it matters: Holding it at alpha (Benjamini-Hochberg, Benjamini-Yekutieli) keeps more power than family-wise control when many tests run, but accepts that a few of the hits may be false.
Written as: false discovery rate, fdr
See also: multiple-comparisons, family-wise-error, alpha
family-wise-error
The chance that a group of tests produces at least one false positive, counted over the whole group rather than test by test.
Why it matters: Holding it at alpha (Bonferroni, Holm) makes any single significant hit trustworthy, at the cost of missing real but modest effects when the group is large.
Written as: family-wise error, family-wise error rate, familywise error, fwer
See also: multiple-comparisons, false-discovery-rate, alpha
generalized-linear-model
A regression for outcomes that are not plain numbers on a line, such as yes/no or counts: a link function ties the predictors to the outcome’s expected value.
Why it matters: It keeps predictions in range (probabilities between zero and one, counts above zero), but its coefficients are on the link scale, so read them through the link before talking about sizes.
Written as: generalized linear model, generalized linear models, glm, glms
See also: link-function, logistic-regression, deviance, regression-coefficient
goodness-of-fit
How well observed data match a stated distribution or a set of expected counts.
Why it matters: A goodness-of-fit test can only flag a mismatch. A large p-value does not show the data follow the distribution, and with few rows real departures go undetected.
Written as: goodness of fit, goodness-of-fit
See also: normal-distribution, chi-square, statistical-power
heteroscedasticity
Residuals whose spread changes across the data, for example growing as the predicted value grows, instead of staying constant.
Why it matters: Classic regression standard errors assume a constant spread; when it changes, they and the p-values built on them can be off in either direction, even though the coefficients themselves stay unbiased.
Written as: heteroscedasticity, heteroskedasticity, heteroscedastic, non-constant variance
See also: residual, standard-error, homogeneity-of-variance
homogeneity-of-variance
The assumption that every group has roughly the same spread (variance) around its own mean.
Why it matters: Classic ANOVA and Tukey’s test rely on it; when spreads differ, especially with unequal group sizes, their p-values drift. Welch’s versions do not need it.
Written as: homogeneity of variance, equal variances, equal variance, homoscedasticity
See also: variance, f-statistic, sphericity
independence
Two things are independent when knowing one tells you nothing about the other; rows are independent when no row influences another.
Why it matters: Most tests assume independent rows. Repeated answers from the same person break it, and need a paired or repeated-measures test instead.
See also: chi-square, correlation, paired-data, repeated-measures
index-value
A ratio to a baseline scaled so the baseline is 100: 120 is 20% above it, 80 is 20% below it.
Why it matters: It puts groups of very different sizes on one scale, but it hides the absolute gap and swings wildly when the baseline is small.
Written as: index value, index values
See also: baseline, percentage-point
kendall-tau
A rank correlation from -1 to 1 based on how many pairs of rows are in the same order on both fields versus the opposite order.
Why it matters: It copes well with small samples and many ties. It usually comes out smaller than Spearman’s rho on the same data, so do not compare the two directly.
Written as: kendall’s tau, kendall tau, tau-b, tau_b
See also: correlation, spearman-rho, rank, ties
kurtosis
How heavy a distribution’s tails are compared with the normal distribution: how prone it is to extreme values.
Why it matters: Heavy tails mean outliers turn up more often than a bell curve would predict, which can mislead tests that assume normality.
Written as: kurtosis
See also: skew, normal-distribution, outlier
link-function
The transformation a generalized linear model applies to the outcome’s expected value before relating it to the predictors, such as the log or the log-odds.
Why it matters: A coefficient moves the outcome on the link scale, not in the outcome’s own units: under a log link it multiplies the expected value, under the logit it multiplies the odds.
Written as: link function, link functions
See also: generalized-linear-model, logistic-regression, odds-ratio
listwise-deletion
Dropping a whole row from an analysis when any of the fields it uses is missing.
Why it matters: Every figure then comes from the same rows, which keeps them consistent, but you can lose many rows when several fields each have gaps.
Written as: listwise deletion
See also: pairwise-deletion, missing-value
loading
How strongly one original field is tied to a component or factor.
Why it matters: Loadings are how you name a component: the fields with the largest loadings tell you what it represents.
Written as: factor loading, factor loadings, component loading, component loadings
See also: principal-component, factor
logistic-regression
A regression for a yes/no outcome that models the log-odds of yes as a straight-line function of the predictors.
Why it matters: Each coefficient is a change in log-odds; raised to the power e it becomes an odds ratio. Odds are not probabilities, so a constant odds ratio means different probability shifts at different starting points.
Written as: logistic regression, logistic regressions, logit, log-odds
See also: generalized-linear-model, odds-ratio, link-function
margin
In a cross-tabulation, the figure for a whole row, a whole column or the whole table (the grand total), shown along its edges.
Why it matters: Cells are often read against their margin. The margin is whatever the cell aggregator gives for the full row or column: a total for counts and sums, an overall average for means.
Written as: margin, margins, marginal total, marginal totals
See also: cross-tabulation, baseline
mean
The average: add the values up and divide by how many there are.
Why it matters: It is the usual “typical value”, but a few extreme values can drag it far from where most rows sit; compare it with the median.
See also: median, outlier, skew
median
The middle value once the values are sorted: half the rows sit below it, half above.
Why it matters: It barely moves when a few extreme values appear, so it is a safer “typical value” for skewed things like income or response time.
See also: mean, percentile, skew
missing-value
A field with no recorded value for a row (a null).
Why it matters: How missing values are handled changes results: dropping them can bias a sample if they are not missing at random.
See also: listwise-deletion, pairwise-deletion
monotonic-trend
A pattern that keeps going one way, always rising or always falling, though not necessarily in a straight line.
Why it matters: Rank-based measures such as Spearman’s rho, Kendall’s tau and the Mann-Kendall trend test detect this kind of pattern; they can miss a link that rises and then falls.
Written as: monotonic, monotone, monotonic trend
See also: spearman-rho, kendall-tau, correlation
multicollinearity
Predictors in a model that are strongly related to each other.
Why it matters: The model can still predict well, but it cannot tell the overlapping predictors’ effects apart, so their coefficients become unstable and hard to trust.
Written as: multicollinearity, collinearity, collinear
See also: regression-coefficient, correlation
multiple-comparisons
Running many tests at once, which raises the chance that at least one comes out significant by luck alone.
Why it matters: Twenty tests at the usual alpha will on average flag one false positive even when nothing is going on. Adjust for it, or treat a lone hit with suspicion.
Written as: multiple comparisons, multiple testing
See also: p-value, alpha, post-hoc-test, family-wise-error, false-discovery-rate
non-parametric
A method that works on ranks or counts instead of assuming the data follow a particular shape such as the normal distribution.
Why it matters: Reach for one when data are skewed, have outliers or are ordinal ratings. It is robust, at the cost of some power when the data really are normal.
Written as: non-parametric, nonparametric, distribution-free
See also: rank, normal-distribution, outlier
normal-distribution
The symmetric bell-shaped pattern many measurements roughly follow, with most values near the mean and fewer further out.
Why it matters: Many tests assume it. Strong skew or heavy tails make those tests less trustworthy; a non-parametric test is the usual fallback.
Written as: normal distribution, normally distributed, bell curve
See also: skew, kurtosis, non-parametric, z-score
null-hypothesis
The boring default a test assumes until the data argue otherwise, usually “there is no difference” or “there is no relationship”.
Why it matters: A test only measures how surprising your data would be if the null were true. Failing to reject it is not proof that it is true.
Written as: null hypothesis, null hypotheses
See also: p-value, statistical-significance, test-statistic
odds-ratio
How many times higher the odds of an outcome are in one group than in another; 1 means no difference.
Why it matters: Logistic models report effects this way. Odds are not probabilities, so an odds ratio of 2 does not mean “twice as likely” unless the outcome is rare.
Written as: odds ratio, odds ratios
See also: regression-coefficient, effect-size
omega-squared
An estimate of the share of variation explained by groups, adjusted so it does not overstate the effect the way eta-squared does in small samples.
Why it matters: Prefer it when groups are small; it is the less optimistic, more honest version of eta-squared.
Written as: omega squared, omega-squared, omega_squared
See also: eta-squared, effect-size
outlier
A value far away from the rest of the data.
Why it matters: Outliers can be errors or the most interesting rows. Either way they can pull means and correlations badly, so look before you drop them.
See also: z-score, percentile, median, non-parametric
overdispersion
Count data that vary more than a Poisson model allows, which assumes the variance equals the mean.
Why it matters: It is common in real counts. A model that ignores it reports standard errors that are too small and p-values that look stronger than the data support.
Written as: overdispersion, overdispersed, over-dispersion
See also: generalized-linear-model, standard-error, variance
overfitting
When a model learns the noise in its data instead of the real pattern.
Why it matters: An overfit model looks excellent on the data it was built from and disappoints on new data. Fewer predictors or more rows help.
Written as: overfitting, overfit, over-fitting
See also: r-squared, sample-size
p-value
The probability of a result at least this extreme if the test’s null hypothesis (no difference, no link, a normal shape…) and its assumptions were all true.
Why it matters: Small values (below your alpha) mean the data would be surprising if there were no difference, which counts as evidence against ‘no difference’. It is not the chance that the difference is real, and it says nothing about how BIG it is.
Written as: p-value, p-values, p value, p values
See also: effect-size, alpha, multiple-comparisons
paired-data
Two measurements that belong together, such as the same customer before and after a change, held on one row.
Why it matters: Pairing removes the differences between subjects, so paired tests are more sensitive. Treating paired data as two separate groups wastes that and breaks the independence assumption.
Written as: paired data, paired samples, paired measurements
See also: independence, repeated-measures
pairwise-deletion
Using every row that has both fields of a given pair, so each pair of fields may rest on a different set of rows.
Why it matters: It keeps more data than listwise deletion, but figures computed from different rows may not fit together consistently.
Written as: pairwise deletion
See also: listwise-deletion, missing-value, correlation
partial-correlation
The correlation between two measures after the straight-line part of chosen other measures has been taken out of both.
Why it matters: It separates a direct link from one that appears only because both measures follow a third; like any correlation it shows association, not cause.
Written as: partial correlation, partial correlations, partial r
See also: correlation, multicollinearity
partial-eta-squared
Eta-squared computed after setting aside variation explained by other factors, such as differences between the people measured repeatedly.
Why it matters: Used for repeated-measures designs. It is not comparable with plain eta-squared, so compare like with like.
Written as: partial eta squared, partial eta-squared, partial_eta_squared
See also: eta-squared, effect-size
percentage-point
The plain difference between two percentages: 40% to 45% is a rise of 5 percentage points, which is a 12.5% relative rise.
Why it matters: Mixing up points and percent changes overstates or understates a change; say which one a figure is.
Written as: percentage point, percentage points
See also: index-value
percentile
The value at or below which about a given share of rows fall (about 90% sit at or below the 90th); a percentile rank is the reverse, a row’s position as a percentage.
Why it matters: Percentiles describe a spread without assuming any shape, and are the honest way to report things like “most users wait less than X”.
See also: median, outlier, rank
phi
The strength of the relationship in a two-by-two table, from 0 (unrelated) to 1 (perfectly related).
Why it matters: It is the yes/no-by-yes/no special case of Cramer’s V, and reads like a correlation between two binary fields.
Written as: phi, phi coefficient
See also: cramers-v, chi-square, correlation
post-hoc-test
A follow-up test run after an overall test is significant, to find which specific pairs of groups differ.
Why it matters: An overall test only says “some group differs”. Post-hoc tests say which ones, and they correct for running many pairwise comparisons.
Written as: post-hoc, post hoc
See also: multiple-comparisons, f-statistic
posterior
In a Bayesian analysis, the distribution of a parameter after combining the prior with the data, given the model.
Why it matters: Point estimates (such as the posterior mean) and credible intervals are summaries of it. It is only as trustworthy as the prior and the model that produced it.
Written as: posterior, posterior distribution, posterior mean, posterior means
See also: prior, credible-interval
principal-component
A new combined measure built from many correlated fields so that a few of them capture most of the variation.
Why it matters: It shrinks dozens of overlapping ratings into a handful of summary scores, making patterns easier to see and model.
Written as: principal component, principal components, pca
See also: eigenvalue, loading, factor
prior
In a Bayesian analysis, the distribution that states what values a parameter is believed to take before the data are seen.
Why it matters: The result blends the prior with the data. A weak prior lets the data dominate; a strong or badly chosen one pulls estimates toward it, so always report which prior was used.
Written as: prior distribution, prior distributions, prior belief, prior beliefs
See also: posterior, credible-interval
probit
A transform that maps a proportion to the z-score at which the normal curve has that share below it: 0.5 maps to 0, 0.84 to about 1.
Why it matters: It stretches proportions near 0 and 1, where small changes in share matter more; tests built on it are a convention some survey tools follow.
Written as: probit
See also: z-score, normal-distribution
pseudo-r-squared
A fit summary for models such as logistic regression where ordinary R-squared does not apply; Pulse’s is one minus deviance over null deviance.
Why it matters: It is not a share of variance explained and runs much lower than R-squared for an equally useful model, so R-squared rules of thumb do not carry over. Compare it only across models of the same outcome.
Written as: pseudo-r-squared, pseudo r-squared, pseudo r squared, pseudo-r², pseudo_r2, mcfadden
See also: deviance, r-squared, logistic-regression
r-squared
The share of the variation in an outcome that a model explains, from 0 (nothing) to 1 (everything).
Why it matters: Higher means the model tracks the outcome more closely, but it always rises as you add predictors, even useless ones; watch for overfitting.
Written as: r-squared, r squared, r², r2, coefficient of determination
See also: residual, overfitting, correlation
raking
Adjusting weights step by step until the weighted sample matches known population totals on several characteristics at once.
Why it matters: It is the standard way to make a survey sample look like the population on age, region and the like, without needing every combination’s total.
Written as: raking, rake, iterative proportional fitting
See also: weighting, effective-sample-size
rank
A value’s position once all values are sorted: 1 for the smallest, 2 for the next, and so on.
Why it matters: Working on ranks instead of raw values makes a method far less sensitive to extreme values and usable on ordinal ratings.
See also: non-parametric, percentile, rank-biserial
rank-biserial
An effect size for rank tests: how much more often a row from one group outranks a row from the other, from -1 to 1.
Why it matters: It gives Mann-Whitney and Wilcoxon results a size and a direction; zero means neither side tends to rank higher.
Written as: rank-biserial, rank biserial, rank_biserial
See also: rank, non-parametric, effect-size
regression-coefficient
How much the predicted outcome changes when one predictor goes up by one unit, holding the other predictors fixed.
Why it matters: It is the “how much” of a driver. Its size depends on the predictor’s units, and with correlated predictors it can be unstable.
Written as: regression coefficient, regression coefficients
See also: standard-error, multicollinearity, r-squared, odds-ratio
reliability
How consistently a set of questions measures the same thing.
Why it matters: A scale built from unreliable questions mixes signal with noise, so averages and comparisons based on it are weaker.
See also: factor, correlation
repeated-measures
A design where the same subjects are measured several times, under different conditions or at different moments.
Why it matters: Measurements from one subject are related, so tests for independent groups misjudge the noise. Repeated-measures tests separate subject-to-subject differences from the effect of the condition.
Written as: repeated measures, repeated-measures, within-subject
See also: paired-data, sphericity, independence
residual
The gap between an actual value and what a model predicted for it.
Why it matters: Patterns in the residuals reveal what the model misses; large ones flag rows the model fits badly.
Written as: residual, residuals
See also: r-squared, outlier
rolling-mean
The average of the last few points in a series (a moving average), recomputed at every step.
Why it matters: It smooths out short-term swings, so a point far from its rolling mean stands out; a short window reacts fast but is jumpy.
Written as: rolling mean, rolling average, moving average
See also: mean, baseline
sample-size
How many rows (or respondents, or measurements) an estimate or test is based on.
Why it matters: Small samples give noisy estimates and weak tests; very large ones make tiny, unimportant differences significant.
See also: standard-error, statistical-power, effective-sample-size
similarity
A number for how alike two rows or items are; the mirror image of distance.
Why it matters: It drives “customers like this one” style matching. Different measures weigh shared absences differently, so pick one that fits your data.
See also: distance, correlation
skew
How lopsided a distribution is: a long tail to the right (positive) or to the left (negative).
Why it matters: Skewed data pull the mean toward the tail, so the median often describes them better, and tests assuming a normal shape suffer.
Written as: skew, skewed, skewness
See also: normal-distribution, median, kurtosis
spearman-rho
A rank correlation from -1 to 1: Pearson’s correlation computed on the ranks of the values instead of the values themselves.
Why it matters: It captures any relationship that keeps going one way, straight or curved, and resists outliers; it misses U-shaped links.
Written as: spearman’s rho, spearman rho, spearman correlation
See also: correlation, kendall-tau, rank, monotonic-trend
sphericity
In a repeated-measures design, the assumption that the differences between every pair of conditions are about equally variable.
Why it matters: When it fails, the repeated-measures F-test rejects too often. Pulse applies no correction for it, so the p-value can come out well below its true value; treat any result short of a very small p with caution.
Written as: sphericity
See also: repeated-measures, f-statistic, homogeneity-of-variance
standard-deviation
The typical distance of a value from the mean, in the same units as the data.
Why it matters: It tells you how spread out values are. Two groups with the same mean can behave very differently if their standard deviations differ.
Written as: standard deviation, standard deviations
See also: variance, z-score, standard-error
standard-error
How much an estimate such as a mean would wobble from sample to sample; it shrinks as you add rows.
Why it matters: It is the uncertainty of the estimate, not the spread of the data. Confidence intervals and most test statistics are built from it.
Written as: standard error, standard errors
See also: standard-deviation, confidence-interval, sample-size
statistical-power
The chance a test detects a difference that really exists.
Why it matters: Small samples have low power, so a non-significant result from them is weak evidence of no difference: the test may simply have been unable to see it.
Written as: statistical power
See also: sample-size, statistical-significance, effect-size
statistical-significance
A result is statistically significant when its p-value falls below your chosen alpha.
Why it matters: Significant means data this extreme would be unusual if there were truly no effect; it is not the chance the effect is real, nor that it is important. With enough rows a tiny difference becomes significant; check the effect size (for a shape test, the statistic itself).
Written as: statistically significant, statistical significance
See also: p-value, alpha, effect-size, statistical-power
steady-state
The mix of states a flow settles into if the same switching probabilities keep applying period after period.
Why it matters: It shows where shares are heading in the long run, assuming nothing about the switching behaviour changes.
Written as: steady state, steady-state, stationary distribution
See also: stochastic-matrix
stochastic-matrix
A table of probabilities of moving from each state (row) to each state (column), where every row adds up to 1.
Why it matters: It describes flows such as customers switching brands or plans from one period to the next, and lets you project them forward.
Written as: stochastic matrix, transition matrix, transition matrices
See also: steady-state
studentized-range
The distribution of the gap between the largest and smallest of several group means, measured in standard errors.
Why it matters: Tukey’s test reads its pairwise p-values from it, which is how it accounts for comparing every pair at once rather than one pair alone.
Written as: studentized range, studentised range
See also: post-hoc-test, multiple-comparisons, standard-error
t-statistic
The test statistic of a t-test: a difference in means divided by its standard error.
Why it matters: Bigger in absolute size means the difference is large relative to its standard error, which shrinks as rows are added, so a big t can come from a tiny difference in a large sample. Its sign tells you which side is higher.
Written as: t-statistic, t statistic
See also: test-statistic, standard-error, degrees-of-freedom
test-statistic
The single number a test computes from your data to measure how far it sits from the null hypothesis.
Why it matters: It is the raw ingredient of the p-value. Its scale differs from test to test, so compare effect sizes, not statistics or p-values, across tests; a p-value also depends on the sample size.
Written as: test statistic, test statistics
See also: p-value, t-statistic, f-statistic, chi-square
ties
Values that are exactly equal, so they share a rank.
Why it matters: Rank-based tests give tied values their average rank and correct for them, but many ties (as on short rating scales) weaken the test and make its large-sample p-value less accurate.
Written as: ties, tied values, tie correction
See also: rank, non-parametric
two-tailed
A two-tailed (two-sided) test looks for a difference in either direction; a one-tailed test looks in only one direction chosen in advance.
Why it matters: Pulse’s tests report two-sided p-values. Halving one to claim a one-sided result after seeing which way the data went inflates false alarms.
Written as: two-tailed, two-sided, one-tailed, one-sided
See also: p-value, alpha
variance
Roughly the average squared distance of values from their mean: divided by n in the population form, or by n - 1 in the sample form most tools print.
Why it matters: A measure of spread in squared units. Most tests are built on the sample form, but its squared units are hard to read; the standard deviation is the same idea in the original units.
Written as: variance, variances
See also: standard-deviation, covariance
weighting
Giving some rows more say than others in a figure, usually so a sample better matches the population it stands for.
Why it matters: Weights fix known imbalances, but heavy weights make results noisier; check the effective sample size.
See also: raking, effective-sample-size
z-score
How many standard deviations a value sits above or below the mean. A z test statistic is the same idea for an estimate: how many standard errors it sits from the null’s value.
Why it matters: It puts values from different scales on one footing and makes unusual values easy to spot.
Written as: z-score, z-scores, z score, z scores
See also: standard-deviation, outlier, normal-distribution
name: grouper-design
description: Grouper slot semantics — multi-grouper composition (key product), fused crosstab eligibility, Group.Include inclusion-list, smart defaults per field type. Topical design; per-GROUP detail lives in atomic op-group-* skills.
type: guide
kind: design
applies_to: process, compose, predict
covers: [GROUP, Crosstab, groups]
Grouper design
groups partitions records BEFORE aggregation; each aggregations entry folds inside the bucket. Per-grouper detail lives in the atomic op-group-* skills.
Slot identity
groups entry: {type, field, label?, interval?, params?, include?}. One bucket key per post-filter row; output rows carry it as <field> (or label).
Pipeline order: features → filterers → attributes → groups → aggregations → windows → sort. Grouping runs AFTER attributes, so derived columns are addressable as field.
Choosing a grouper
Read the intent first (pulse_skills_get intents): compare_groups, composition, change_over_time, segment or distribution_shape. Manifest components.groupers[] carries each grouper’s intents, accepts_types and params — filter on the field’s type, then decide:
- One bucket per distinct value of a categorical, boolean or low-cardinality field? → the category grouper.
- A number in bands? Fixed-width bands with a
"low-high"key, or bands keyed by their rounded-down floor — or EQUAL-COUNT bands (by rank, so equal values may straddle two)? Equal-count bands buffer the whole input. - Calendar periods (hour, day, week — ISO or any start day, month, quarter, fiscal year, weekday)? → the date grouper. Custom named periods (campaign windows, irregular fiscal quarters)? → the date-ranges grouper.
- A multi-select field? One bucket per OPTION (a record lands in every option it chose — fan-out) or one bucket per exact COMBINATION?
Each grouper’s Purpose.NotFor names the sibling to use when the choice is wrong.
High cardinality. Each distinct key is a bucket; past the instance max_groups limit the run is refused PULSE_LIMIT_EXCEEDED. pulse_predict flags a dictionary over it as a possible limit_findings entry.
Composition (key product)
With N entries (N ≥ 2), the engine forms the cartesian key product: each row receives a composite key (g0, ..., gN-1). Empty groups collapses to one global bucket.
Plain groups emit one flat row per composite key under Response.Data — the canonical cross-tab mechanism. Reach for Request.Crosstab only when margins or normalisation matter (crosstab-guide).
Smart defaults
When an entry names field but omits type, the engine infers from the schema type:
| Field type | Default grouper |
|---|---|
numeric (u4/u*, f32/f64, decimal128) | GROUP_RANGE (Interval=10) |
categorical_*, packed_bool | GROUP_CATEGORY |
date, datetime | GROUP_DATE (component=day) |
set_* | GROUP_SET_PER_ELEMENT |
Never overrides an explicit type; predict reports filled slots at data.defaults_applied; disable via --no-defaults. Full table: request-envelope.
Group.Include — inclusion list
Group.Include []string restricts a grouper to an allow-list of bucket keys. A row whose key (for a fan-out grouper, each fan-out key) is not listed is skipped — identical to the null-skip path. Empty / nil → “no filter”. Supported by:
GROUP_CATEGORY— the label string.GROUP_SET_VALUE— the pipe-joined composite key.GROUP_SET_PER_ELEMENT— each fan-out label.
Other groupers ignore include — filter the source field with a value filter instead. Streamability is preserved (O(1) membership per record).
Order-significant. A non-empty include also fixes emission order: buckets appear in listed order (plain grouped Data + the grouper’s buckets component, and each crosstab axis independently). Empty / nil keeps alphabetical (or dictionary-index) order. Zero-record include values are still dropped.
Fused crosstab eligibility
A crosstab builds its grid in one decode pass (much lower peak memory) when every axis grouper keys records one at a time. Every built-in grouper does, EXCEPT the equal-count (quantile) grouper, which needs a finalize-time sorted view — the only grouper that forces a buffered crosstab. Fan-out groupers fuse at any axis position, on either or both axes; axis keys are the product of each position’s key set. Overlays never prevent fusing (crosstab-guide, overlay-system).
Embedder groupers opt in by implementing extend.StreamingGrouper or extend.MultiKeyStreamingGrouper and returning extend.ErrGrouperKeyNull on nulls; implementing neither stays correct and runs buffered.
Components
Every Response.Components.Groupers[i] carries the universal floor total_n (post-filter records partitioned) + n_null (records that took the null / skip path) — ALL post-filter records, unlike the aggregator’s non-null n. Operator-specific keys ride operator; per-grouper lists are in manifest.components_schemas.groupers and each atomic skill.
Components mergeability is read off the manifest: mergeable groupers fold across streaming chunks; a none grouper (equal-count bands) emits its keys only at the terminal flush (buffered_components: true in predict). Shape: response-components.
Gotchas
- Fan-out:
sum(buckets[].count) > total_nis correct; on a crosstab axis its margins are non-additive (a 3-option row counts 3× across row margins, once in the grand total). - Empty-mask
set_*: the combination grouper buckets it under the empty key; the per-option grouper skips it. Neither incrementsn_null. - Weekday components emit names that lex-sort, not Sun→Sat — sort explicitly.
- Band groupers reject categorical fields at construction.
See
- Recipes:
pulse_examples_search tags=["cohort-analysis"|"cross-tabulation"|"distribution-shape"|"survey"]. crosstab-guide(Crosstab shape, margins, normalisation),aggregation-design(what folds inside the bucket),request-envelope(slot keys, smart defaults),time-zones(datetime day buckets,hour,week_start),response-components(grouper floor),streaming-and-watching(streamability).
name: intents
description: The closed intent taxonomy: the kinds of question an analysis answers, how each sounds, and the field shapes it fits. Use to map a user's question to an intent ID.
type: reference
kind: reference
Intents
The closed set of question kinds an analysis can answer. Operator purposes and examples cite these IDs; a shape lists the roles a cohort’s fields must fill for the question to be answerable.
Analytic intents
benchmark
Benchmark against a reference.
Sounds like:
- How does this compare to the total?
- Is this group above or below the population?
- How does this wave compare to the last?
Shapes:
- measure (numeric|categorical|bool, 1 or more) + group (categorical|bool|date, exactly 1)
change_over_time
Change over time.
Sounds like:
- Is it going up?
- How does this month compare to last?
Shapes:
- time (date, exactly 1) + measure (numeric|categorical|bool, 1 or more)
compare_groups
Compare groups.
Sounds like:
- Do A and B differ?
- Is this segment different from the rest?
Shapes:
- outcome (numeric|categorical|bool, exactly 1) + group (categorical|bool, exactly 1)
composition
Mix and share.
Sounds like:
- What’s the mix?
- What share does each option have?
- Which attributes go with which brand?
Shapes:
- category (categorical|bool|set, exactly 1) + by (categorical|bool, optional)
data_quality
Data quality.
Sounds like:
- Is this data trustworthy?
- Who answered carelessly?
- How much is missing?
Shapes:
- fields (numeric|categorical|date|bool|set, 1 or more)
describe
Describe a measure.
Sounds like:
- What’s the typical value of X?
- What’s the total X?
- How spread out is X?
Shapes:
- measure (numeric, 1 or more)
- measure (categorical|bool|set, 1 or more)
distribution_shape
Distribution shape.
Sounds like:
- Is it normally distributed?
- Are there outliers?
- Is it skewed?
Shapes:
- measure (numeric, 1 or more)
drivers
Find drivers.
Sounds like:
- What explains Y?
- Which factors matter most for Y?
Shapes:
- outcome (numeric|categorical|bool, exactly 1) + predictors (numeric|categorical|bool, 1 or more)
flows
Flows between states.
Sounds like:
- Where do customers move between states?
- Who switched from A to B?
Shapes:
- from (categorical|bool, exactly 1) + to (categorical|bool, exactly 1)
measure_construct
Measure a construct.
Sounds like:
- Do these questions measure one thing?
- Combine these items into a score.
Shapes:
- items (numeric, 2 or more)
relationship
Relationship between measures.
Sounds like:
- Do X and Y move together?
- Is X associated with Y?
Shapes:
- measures (numeric, 2 or more)
- categories (categorical|bool, exactly 2)
segment
Segment into groups.
Sounds like:
- Are there natural groups of customers?
- Split this into tiers.
Shapes:
- measures (numeric, 1 or more)
Tooling intents
lookup
Look up records.
Sounds like:
- Show me the record for this ID.
- Fetch the rows for this key.
Shapes:
- key (numeric|categorical|date, 1 or more)
prepare
Prepare data.
Sounds like:
- Keep only last year’s rows.
- Derive a new column from these fields.
Shapes:
- fields (numeric|categorical|date|bool|set, 1 or more)
simulate
Simulate data.
Sounds like:
- Generate a synthetic copy of this cohort.
- Make test data with this schema.
Shapes:
- fields (numeric|categorical|date|bool|set, 1 or more)
name: item-fan-out
description: for_each on a window, feature or attribute item — one item fans out in place over parameter axes (grid or zip) into ordinary items, with the sweep placeholders, a derived-column limit and predict parity.
type: guide
kind: design
applies_to: process, compose, predict
covers: [ForEach, Window, Feature, Attribute]
requires: [capability:for_each]
Item fan-out (for_each)
for_each on ONE windows, features or attributes item replaces it, in place, by one ordinary item per axis combination before anything else checks the request. Use it for a parameter grid of near-identical derived columns (an adstock grid of smoothing windows over several channels) instead of writing every item out. Plain items in the same list are untouched; the expanded list keeps declared order, which is the output column order.
"windows": [{
"for_each": {
"axes": [{"name": "ch", "values": ["tv", "search"]},
{"name": "n", "values": [1, 2, 4]}],
"mode": "grid"
},
"type": "<lag window>", "field": "{{ch}}_spend", "label": "{{ch}}_lag{{n}}",
"order_by": [{"field": "week"}], "params": {"offset": {"$var": "n"}}
}]
Six items: tv_lag1, tv_lag2, tv_lag4, search_lag1, … in that order.
Axes, modes, placeholders
- Axes and modes are the compose sweep’s (
compose-sweeps): unique identifier names, non-empty scalar values;grid(default) is the Cartesian product, first axis slowest;zipwalks equal-length axes in lockstep. {{axis}}splices text inside any string —label,field, a formulaexpression.{"$var": "axis"}splices a whole value type-preserving, but only insideparams(opaque JSON); a typed key such asorder_bycannot hold the marker.- Every axis must be referenced in the item and every placeholder must name an axis. Not expr-lang: there is no arithmetic.
The label-axis idiom (zip)
A label cannot compute 70 from 0.7. Carry the label text as its own axis and zip it with the parameter axis, so each label pairs with exactly one value (an adstock grid of smoothing windows taking a decay):
"for_each": {"axes": [{"name": "d", "values": [10, 30, 50, 70]},
{"name": "decay", "values": [0.1, 0.3, 0.5, 0.7]}],
"mode": "zip"},
"label": "tv_ad{{d}}", "params": {"decay": {"$var": "decay"}}
Under grid the same two axes would make 16 items, most mislabelled. To cross channels with zipped pairs, use one zip item per channel.
Strictness and refusals
- Each rendered item decodes STRICTLY into its slot type: an unknown key is refused.
- Every fault is
PULSE_FOR_EACH_INVALIDwithdetails.field(e.g.windows[0].for_each.axes[1]) anddetails.reason: the sweep’s structural reasons,axis_unreferenced,placeholder_unknown,placeholder_invalid,item_decode. - A
for_eachinside a composesweepbody is refused (nested_in_sweep): add the item’s axes to the sweep instead.
Limits, predict, hashing
Limits.MaxDerivedColumns(default 1,000) counts every plain item plus every expanded one across the three lists, before any item renders; over it isPULSE_LIMIT_EXCEEDED.- Predict expands exactly as the runtime does and reports each fan-out under
for_each[{path, axes, mode, expanded_count}]; its other answers judge the expanded items. Predict before running a large grid. - Every request host expands: a process run, each compose slot, each chain stage, and a materialize run (every expanded item becomes a column). The echoed request carries the expanded items; its canonical hash equals the hand-written twin’s.
Templates, MCP and Explain
- In a stored request template (
request-templating) the renderer leaves afor_eachitem’s own{{axis}}and{"$var": "axis"}untouched for the expansion — no escape needed. Template variables still substitute beside them and insidefor_eachitself, so"values": {"$var": "decays"}takes an axis list from alistvariable. Axis names must be literal; a template variable named like an axis is refused at load (PULSE_TEMPLATE_AXIS_COLLISION). - A cohort-bound MCP tool schema lets an item WITH
for_eachspell its field, type,partition_by/order_bykeys and other enum-bound strings as{{axis}}placeholders; an item without one keeps the strict enums. - Explain describes the expanded items (each naming its column) and states each fan-out.
See
compose-sweeps (the same axes over whole requests) · window-design (window frames and ordering) · feature-engineering (multi-output features)
name: join-design
description: Pushdown hash-join surface — Request.Joins envelope, JoinSpec, OnPair key-type compatibility, As-prefix, v1 limits, and validating a join before running it.
type: guide
kind: design
applies_to: process, predict
covers: [Joins, JoinSpec, OnPair]
requires: [capability:joins]
Join design
Request.Joins is a pushdown hash join. v1 supports exactly one inner join per Request: the right (build) side materialises in RAM, hashed by the join-key tuple; the left streams as the probe. Joined records then flow through the ordinary pipeline (filter → attribute → group → aggregate) exactly like single-cohort records.
When to join
Join when the question needs columns that live in two cohorts sharing a key (orders + customers, responses + panel demographics). Skip it when:
- the second cohort only supplies a display name for a code — a label table does that at output time without a join (
label-display); - the lookup is a small numeric
key → valuemap used inside an expression — an embedderLookupTablesentry is cheaper (expression-language); - you need several analyses over one cohort — that is a batch, not a join (
compose-requests).
{
"cohort": {"filename": "left.pulse"},
"joins": [{
"right": "right.pulse",
"kind": "inner",
"as": "r_",
"on": [{"left_field": "id", "right_field": "user_id"}]
}],
"aggregations": [
{"type": "<aggregator>", "field": "score"},
{"type": "<aggregator>", "field": "r_bonus"}
]
}
<aggregator> stands for any aggregator the manifest lists (components.aggregators[]); the join is independent of what runs downstream.
| Field | Meaning |
|---|---|
right | Right-side cohort: single-file .pulse, shard archive, or archive.pulse#shard.pulse anchor. |
kind | "inner" (empty = "inner"). "left", "outer", "anti" are reserved. |
on | Equi-join key pairs (OnPair[]), AND-ed for composite keys. |
as | Optional prefix on every right-side field name in the joined schema. |
The joined schema is left_fields + right_fields (right prefixed by as). Every downstream slot sees the union; reference right-side columns by their prefixed name (r_bonus).
v1 envelope
The manifest join block (max_joins_per_request, kinds, spill_bytes, limitations) is the live statement of these limits.
- One join per Request. Two or more ⇒
PULSE_JOIN_TOO_MANY. For a multi-hop question, pre-join into a cohort. In a chain only stage 0 may join. - Inner only.
"left"/"outer"/"anti"⇒PULSE_JOIN_KIND_NOT_IMPLEMENTED. An unmatched left row is dropped — it reaches no group, cell or count. - No spill. The right side materialises fully in RAM,
O(right_records). Put the SMALLER cohort on the right; the build side is alwaysright(no automatic swap). A right side over the instance’smax_join_build_rowslimit — or a join whose memory estimate passes a setmax_estimated_memory— is refusedPULSE_LIMIT_EXCEEDEDbefore any record decodes;pulse_predictreports it as acertainlimit_findingsentry.pulse_inspectreports each side’srecord_countwithout reading records. - Crosstab honours the join.
Request.Crosstabcrosstabs the JOINED rows — axes and cell may nameas-prefixed fields; unmatched left rows reach no cell or margin; always buffered. - No shard-parallel join. A shard-archive left side runs serial on a joined request.
OnPair key type compatibility
Equi-keys must compare equal after normalisation. Accepted:
- Identical types (
u32↔u32). categorical_*↔categorical_*at any width (dictionary strings compare as text).- The unsigned-int / float / date numeric family, interchangeably (
u32↔f64↔date).
Rejected:
- Any
set_*column, either side, even at identical rungs. A bitmask has no unambiguous equality value, and the key would be a lossy float echo of the mask, so different selections collapse onto one key and unrelated rows join silently. Filter on set membership instead. Carrying a set column THROUGH a join (non-key) is fine. decimal128↔ any other type (precision/scale matter for hashing).categorical_*↔ a non-categorical numeric type.
A mismatch is PULSE_JOIN_TYPE_MISMATCH with both field names + types in details; a set-key rejection adds details.reason = "set_key". Fix by re-importing one side with a matching type.
Known limit: a u64 key above 2^53 compares through its float echo, so two ids rounding to one float join as equal. decimal128 keys compare on their exact mantissa.
Field collisions and the as prefix
Two fields with the same name in the union ⇒ PULSE_JOIN_FIELD_COLLISION. Set as to prefix every right-side column ("as": "r_" turns bonus into r_bonus). Categorical right-side fields keep their own dictionaries, so they render the right side’s strings.
Validate before executing
pulse_predict validates a join against BOTH cohorts’ headers + schemas, never their records: kind, key existence, key-type compatibility, empty keys, collisions and the one-join limit, with the SAME codes, messages and details the runtime raises (PULSE_JOIN_KIND_NOT_IMPLEMENTED, PULSE_JOIN_FIELD_UNKNOWN, PULSE_JOIN_TYPE_MISMATCH, PULSE_JOIN_KEYS_EMPTY, PULSE_JOIN_FIELD_COLLISION, PULSE_JOIN_TOO_MANY). Every other slot is then checked against the JOINED schema, so a typo’d r_ field surfaces before any decode. pulse errors lookup CODE gives each code’s fixups.
Cost
- Build scales with right-side record count; a tall right cohort (10M+ rows) can dominate memory.
- Probe is
O(left_records), one hash lookup per row; categorical keys resolve through the dictionary per row.
See
aggregation-design (operators over the joined schema) · cohort-schema-design (shard archives + archive.pulse#shard.pulse anchors) · process-chain (joining at stage 0) · docs/src/internals/ (adding a join kind + spill wiring).
name: label-display
description: Translate categorical IDs to display labels at output time without rewriting the cohort. LabelBinding entries on Request / SampleRequest / FacetRequest / ExportJob. Display-only — filters / formulas / sort keys still see raw values.
type: guide
kind: design
applies_to: process, compose, sample, facet, inspect, predict
covers: [LabelBinding, pulse_label_tables, pulse_label_resolve]
requires: [capability:labels]
Label display
Pulse stores categorical fields as dictionary indices resolving to compact strings on read ("US", "M01.2", internal SKUs). When that string is itself a code rather than a display label, the label overlay rewrites or augments it at output time without touching the on-disk schema.
When to use
Use a binding when the cohort stores an identifier (ISO code, SKU, ICD code, enum) and the user wants a readable name; OR the mapping is runtime-controlled (external store, evolves independently, varies per audience). Skip when the label is intrinsic and stable (import a second categorical column), the translation is computed (a formula attribute — expression-language), or the mapping is analytic semantics used by filters/sort (denormalise at import).
Two-step setup
1. Register a label table
Tables live on pulse.Options.Extensions.LabelTables, or load from disk via PULSE_LABEL_TABLES_DIR.
LabelTables: map[string]pulse.LabelTable{
"country_names": {Description: "ISO → name",
Rows: map[string]string{"US": "United States", "CA": "Canada"}},
// Function-driven for external stores:
"icd_descriptions": {Lookup: func(k string)(string,bool,error){...}},
}
PULSE_LABEL_TABLES_DIR auto-loads *.json (filename minus .json = table name). Flat {"US":"United States"} or wrapped {"description":"...","rows":{...}}. Programmatic + disk-loaded can’t share a name (pulse.New rejects).
Pulse’s own sidecars are skipped; anything else that fails to parse is fatal. The loader excludes *.spss.json (SPSS metadata), *.meta.json (managed import) and *.indexes.json (lookup-index catalog) by suffix before reading, so pointing the variable at a directory that also holds cohorts is safe; a skipped sidecar registers no table. Every OTHER *.json under the root is parsed as a label table, and one that fails hard-fails pulse.New naming the path — a typo must not become a silently missing table. PULSE_RANGE_TABLES_DIR behaves identically. A dedicated directory is still cleaner.
2. Attach a binding
Each LabelBinding pairs a categorical field with a table and mode:
{"labels":[{"field":"country","table":"country_names","mode":"replace"}]}
Modes
Replace — value rewritten in place. Group keys, aggregation keys, sample row cells, exported cells show the label. Two source values mapping to the same label disambiguate by appending the source in parens ("United States (US)" vs "United States (USA)") and emit PULSE_LABEL_COLLISION. Rows-backed tables pre-pass for symmetric rendering; function-driven tables detect collisions online only. Use augment for lossless round-trip.
Augment — raw value preserved; sibling "<field>_label" column added. Existing <field>_label or agg/attr label collision ⇒ PULSE_LABEL_FIELD_COLLISION at validation.
Surface coverage
| Surface | Replace | Augment |
|---|---|---|
Sample (SampleWithRequest) | row value → label | row + <field>_label |
Facet (FacetSchema) | FacetValueCount.Value → label | sibling FacetField |
Process group keys | group key → label | sibling column in each output row |
Export / Convert | column value → label | extra column inserted after source |
Display-only. Filters, formula attributes, sort keys, and group keys still see raw values. Labels NEVER gate which records pass through the pipeline.
Missing values
When a source value isn’t in the table the resolver falls back to the raw resolved categorical string and accumulates a per-field miss count. One PULSE_LABEL_LOOKUP_MISS warning per bound field summarises unresolved-row count and absent source values.
Validation surface
Every label-bound request is validated before any record bytes are read — pulse_predict reports the same codes:
| Code | Meaning |
|---|---|
PULSE_LABEL_FIELD_UNKNOWN | Binding references a field not in the schema. |
PULSE_LABEL_FIELD_NOT_CATEGORICAL | Bound field is not categorical_u8/u16/u32. |
PULSE_LABEL_TABLE_UNKNOWN | Table not registered. |
PULSE_LABEL_FIELD_COLLISION | Augment sibling collides with an existing field or agg label. |
PULSE_LABEL_DUPLICATE_BINDING | Two bindings target the same field. |
PULSE_LABEL_COLLISION (warning) | Two source values resolve to the same label. |
PULSE_LABEL_LOOKUP_MISS (warning) | Rows have categorical values absent from the table. |
MCP discovery — pulse_label_tables + pulse_label_resolve
Labels are output-only, so the INPUT direction needs a lookup. pulse_label_tables lists registered tables, row counts and whether each is enumerable (reverse-searchable). pulse_label_resolve turns a user’s display name (typo-tolerant) into the raw key a filter or grouper needs, ranked {key, value, score} in [0,1]. Use the top hit when it scores ≥ 0.9 and is clearly ahead; otherwise show the candidates and ask.
What labels do NOT do
- They don’t filter. Filter
country = "United States"won’t match; writecountry = "US". - They don’t sort. Sort keys see raw values.
- They don’t persist. The
.pulsefile is unchanged; reopening with a differentLabelTablesproduces different output without re-encoding. - They don’t work for non-categoricals. To label a numeric, derive a categorical column at import time.
See
docs/src/internals/extension-points.md— registeringLabelTablesonOptions.Extensions.skills/facet-design.md— surfacing labels onFacetFieldvalues.skills/tool-label-tables.md/skills/tool-label-resolve.md—pulse_label_tables/pulse_label_resolveschemas.skills/spss-cohorts.md— why an SPSS cohort stores codes, and where its labels live.
name: matrix-results
description: Vectors and matrices on a Request — naming a battery of numeric fields once, getting a covariance or correlation matrix back, the encodings, missing-data modes, warnings, per-group matrices, predict's cost estimate and what is refused.
type: guide
kind: design
applies_to: process, compose, predict
covers: [vectors, matrices, covariance, correlation, pairwise deletion, listwise deletion, upper triangle, top_pairs, determinant]
requires: [capability:matrices]
Vectors and matrices
A vector names numeric fields once; a matrix slot turns a vector into a square result (every member against every member). Both are additive: no vectors / matrices is byte-identical to before, and format_version stays "1.1".
1. Name the vector
vectors: [{name, fields | pattern, labels?, coerce?}] — exactly one of fields / pattern. A name equal to a field or a feature / attribute / window label is PULSE_VECTOR_NAME_COLLISION.
- A literal
fieldsentry keeps the caller’s position; a glob (q*) expands in place in schema order;patternis an unanchored regexp in schema order. - Members: integer and float types.
packed_boolonly withcoerce: "binary". Categorical,set_*,date,datetime,decimal128are refused (PULSE_VECTOR_MEMBER_TYPE). labels: one per member, elsePULSE_VECTOR_LABELS_MISMATCH. A vector no slot or expression uses warnsPULSE_VECTOR_UNREFERENCED.pulse predictechoesresolved_vectors: check the members.
2. Ask for the matrix
matrices: [{name?, type, vector | fields, params?, weight?, encoding?}]; the answer is Response.matrices[], one MatrixResult per spec in request order.
MAT_COVARIANCE— covariance;params.ddof0 or 1 (default 1).MAT_CORRELATION— Pearson r, orparams.methodspearman/kendall(frequency weights only); no p-values;params.summary.top_pairs: klists the k strongest pairs.MAT_PARTIAL_CORRELATION— partial r,params.control"all"or fields held fixed; non-PSD input fatal unlessparams.repair: "nearest"(a repair stopped at its iteration cap also warnsPULSE_MATRIX_NOT_CONVERGED).MAT_COLLINEARITY— VIF, tolerance, Belsley condition indices + variance decomposition;params.center.MAT_DISTANCE— bucket × bucket centroid distances (square, keyed by bucket label), centroids (rectangular k × p), sizes;params.between,metric,standardize.MAT_FACTOR— factor loadings (rectangular p × k), communalities, uniquenesses, rmsr (ml: likelihood chi-square, TLI, RMSEA, BIC);params.factors,extraction,rotation.MAT_PCA— loadings (rectangular p × k), eigenvalues, KMO, Bartlett;params.componentsk /kaiser/{variance}.MAT_RELIABILITY— alpha, omega, item diagnostics;params.reverse+scale_min/scale_maxflip reverse-keyed items.
Reading a result: primary is the matrix (row_keys = members, labels only when you set them); scalars.determinant is null unless the matrix is positive definite; kind is square_symmetric or rectangular (p × k); warnings lists data-quality findings. Undefined cells are null, never NaN or 0.
encoding: full (default, p rows of p cells) or upper (row r holds the p − r cells from the diagonal, values[r][k] = cell (r, r + k)) — about half the bytes.
3. Missing data and weights
params.missing: listwise (default, drop a row if any member is null) or pairwise (each cell uses the rows where both members are present; auxiliary.n gives each pair’s row count).
- Pairwise matrices of three or more members can fail to be positive semi-definite: a warning on covariance / correlation, FATAL on a decomposition unless
params.repair: "nearest"(PULSE_MATRIX_NOT_PSD). max_drop_share(listwise only) warnsPULSE_MATRIX_LISTWISE_HEAVY_DROPwhen more than that share of rows was dropped.PULSE_MATRIX_INSUFFICIENT_N(under two rows or no weight mass) andPULSE_MATRIX_ZERO_VARIANCE(a constant member: its correlation row and column are null; covariance keeps an exact 0).- Weights follow
weighting: frequency and probability both fold into a weighted covariance. A row of weight 0 counts towardnbut adds no mass.
4. Per group
A request with groups returns one MatrixResult per non-empty bucket and spec, each with group_key / group_header, in the same order as Response.data (sort included). A thin bucket is emitted with nulls and PULSE_MATRIX_INSUFFICIENT_N. Each bucket equals the ungrouped matrix over exactly its rows. Only the first groups entry is executed.
5. Components and cost
components.matrices[i] is the floor: n, n_null, n_listwise_dropped, pairwise min_pair_n / max_pair_n, weighted sum_weights / n_eff / n_weight_invalid (response-components).
pulse predict returns matrices[] before any row is read: shape, axis_keys, accumulator_bytes, pairwise_psd_risk (false guarantees no NOT_PSD warning), streamable. For a grouped request it adds:
| Key | Meaning |
|---|---|
bucket_basis | where the bucket count comes from: ungrouped, dictionary, boolean, include, quantile_bins, unknown |
estimated_buckets | upper bound on buckets (omitted when unknown) |
estimated_cells | buckets × p² |
estimated_bytes | merge blocks (ceil(records / 4096), per shard) × buckets × accumulator_bytes — the run’s state |
A range or date grouper is unknown: the three figures are omitted, never guessed. They are a report; the host’s max_estimated_memory limit (opt-in) counts estimated_bytes in its memory estimate.
6. What is refused
matriceswithjoins, or on a chain stage after the first:PULSE_MATRIX_UNSUPPORTED_SOURCE.matriceswithcrosstab:PULSE_MATRIX_HOST_CONFLICT.- Matrices are not streamed row by row: they arrive at the final flush.
ProcessStreamrows carry no matrices.
Serial, parallel-decode and sharded runs return bit-identical matrices on one cohort file; a multi-shard archive matches its single-file twin within rounding only.
Streamability is per spec, not per type: a spec whose params need every row at once (a rank method) is buffered — predict matrices[].streamable / mergeable false — so the whole request runs buffered (ProcessStream too) and serially.
See
multivariate-design · request-envelope · weighting · response-components · grouper-design
name: multiplicity-correction
description: Multiple-comparison correction on a Request — the multiplicity block (method, family, alpha), which p-values are corrected together, the per-surface family table and its refusals, precedence, the additive adjusted outputs, row and column geometry on pairwise overlays, and what is never corrected.
type: guide
kind: design
applies_to: process, compose, facet, predict
covers: [multiplicity, p_adjusted, significant_adjusted, family-wise error, false discovery rate, Bonferroni, Holm, Benjamini-Hochberg]
requires: [capability:multiplicity]
Multiple-comparison correction
Many tests in one answer raise the chance that one comes out significant by luck. Name a multiplicity block and Pulse adds corrected p-values BESIDE the raw ones; raw p_value / reject_null never change. No block (or {"method": "none"}) is byte-identical to before.
The block
multiplicity: {method, family, alpha}, every key optional:
method—none,bonferroni,holm(both family-wise error),bh,by(false discovery rate;byis valid under any dependence). Same results as Rp.adjust.family— which p-values are corrected together (below).alpha— overlay significance level in (0, 1), default0.05. A test reads its ownalpha, soalphaon a test’s block is refused.
It rides on Request, each tests[] / post_tests[] entry, each overlays[] spec, the Compose ComposedRequest and its overlay specs, and engine-wide as Options.DefaultMultiplicity. Each key falls through on its own: slot → request → ComposedRequest → engine default → none.
Families
| Family | Pools | Offered on |
|---|---|---|
layer | one overlay layer | overlays (default) |
row / column | one row / column of one layer’s matrix | overlays whose payload is a MATRIX |
request | the request’s tests, post-tests and request-family overlays | request tests (default), request overlays |
compose | every compose member over all slots and Compose-host layers | inside Compose only |
Refused PULSE_MULTIPLICITY_INVALID: row / column on a non-matrix kind, compose outside Compose, request on a Compose-host overlay, anything but layer / row / column on a facet overlay, alpha on a test. An INHERITED family a surface does not offer falls back to that surface’s default; only an explicit one is refused. Members of one request / compose family resolving to different methods are PULSE_MULTIPLICITY_CONFLICT. An explicit block on a descriptive overlay is accepted and inert.
pulse predict applies the same rules to a Request without running it.
Outputs (all additive)
- Test result:
p_adjusted,significant_adjusted(against the test’s ownalpha) andmultiplicity {method, family, alpha, m};mis the number of defined p-values corrected together. - Overlay summary:
p_adjusted/significant_adjustedbesidep_value(or thestatisticthe t / z-versus-reference kinds carry their p in). - Overlay matrix payload: parallel
payload.p_adjusted/payload.significant_adjustedgrids on identical headers and coordinates; a panel cell’s vector maps element for element. - Overlay layer: a
multiplicityecho, present only when a correction ran. - An undefined p (NaN) gives
p_adjusted: null, nosignificant_adjusted, and is left out ofm.
Row and column geometry
row / column make one family per row / column INDEX of the layer’s own matrix, by coordinate: never across rows, columns or layers. A pairwise overlay’s rows are the compared groups, so row corrects each group’s comparisons against all others. Every element of a panel cell joins its cell’s row / column. The echo adds m_per (family sizes aligned with the rows, or columns; 0 where an index has no defined p) and m is their sum.
Where it runs
- Process, every arm: serial, streaming, fused or buffered crosstab, join, shard, parallel decode.
- Compose: after all slots finish. Serial and parallel are byte-identical; non-compose families stay per slot,
composepools all.--streamemits rows only (no corrections). A failed slot errors the batch before any fold. - ProcessChain: each stage folds its own request, never across stages; the whole-chain overlays take no block.
- Facet: each overlay layer on its own.
- Tests from extensions join families like built-ins.
Never corrected
TEST_TUKEY_HSDis already family-wise: an explicit non-noneblock on it is refused, an inherited one skipped, and it never joins a family.- Regression coefficient p-values are out of scope.
- A p-value outside any family (no block resolved) stays raw, and predict’s
p_values {total, uncorrected, basis, threshold}counts those:uncorrectedat or abovethreshold(10) with no block anywhere raises thePULSE_ADVISORY_MANY_TESTSadvisory (predict-advisories).basisisexact,dictionary(assumes every dictionary entry becomes a bucket, so it may over- or under-count) orlower_bound.
Choosing
Few planned tests, any false positive costly: holm. Screening many cells, tolerating a few false hits: bh. Hits possibly dependent in odd ways: by. Correct within the question asked: row for “does any group differ from the others”, layer for one table, request / compose for one headline claim over many tests.
See
statistical-testing · predict-advisories · overlay-system · compose-requests · process-chain · facet-design · request-envelope · response-components · pulse_skills_get glossary (family-wise-error, false-discovery-rate) · pulse_errors_lookup for PULSE_MULTIPLICITY_*.
name: multivariate-design
description: Choosing a multivariate operator on a battery of numeric fields — which matrix answers which question, what to do with missing data and weights, when a decomposition needs a repaired matrix, and where p-values stand. Topical design; per-operator params in the atomic op-mat-* skills.
type: guide
kind: design
applies_to: process, compose, predict
covers: [multivariate, battery, correlation matrix, partial correlation, reliability, PCA, collinearity, VIF, missing data, repair, rank correlation]
requires: [capability:matrices]
Choosing a multivariate operator
Every operator here reads one vector (vectors, see matrix-results) and answers with a matrix plus vectors and scalars. Pick by the QUESTION, then set the missing-data mode.
Which operator answers my question
| Question | Operator |
|---|---|
| How do the fields co-vary, in their own units? | MAT_COVARIANCE |
How strongly do fields move together (linear, or monotone with params.method)? | MAT_CORRELATION |
Is a link left once the other fields (or control) are held fixed? | MAT_PARTIAL_CORRELATION |
| Do these items measure one scale (alpha, omega, which item to drop)? | MAT_RELIABILITY |
| How many dimensions carry the variance; is the battery factorable (KMO, Bartlett)? | MAT_PCA |
| Which hidden qualities do the items reflect (factor analysis, rotation)? | MAT_FACTOR |
| Are candidate predictors too redundant to fit together (VIF, condition index)? | MAT_COLLINEARITY |
| Which rows are unusual across the fields as a whole? | ATTR_MAHALANOBIS (a row score) |
| Each row’s score on a component (a summary column)? | ATTR_PC_SCORE (a row score) |
| Each row’s score on a factor (a construct estimate)? | ATTR_FACTOR_SCORE (a row score) |
| Which segments do the rows fall into across the fields? | GROUP_KMEANS (a grouper) |
| How far apart are the segments’ centres across the fields? | MAT_DISTANCE (params.between a grouper) |
How uncertain are the coefficients of a fitted model, and how do they co-move? That is not a matrix slot: set "vcov": true on the regression (regression-inference).
Rank correlation (spearman, kendall) suits ordinal data, outliers and monotone curves. It is buffered and serial, takes frequency weights only, and stays off the streaming path. PCA is a variance summary, not a factor model; MAT_FACTOR fits one (minres, principal axis or maximum likelihood with fit indices; varimax, promax or oblimin); MAT_PCA rotation: varimax gives a simpler loading pattern.
Missing data
listwise(default): one row set for every cell; the matrix is PSD by construction. Prefer it when few rows drop;max_drop_sharewarns when many do.pairwise: each cell uses its own rows (auxiliary.nhas the counts). It keeps more data but the matrix can fail to be PSD.- Decompositions (partial correlation, PCA, factor analysis, collinearity, the omega of reliability) refuse a non-PSD pairwise input with
PULSE_MATRIX_NOT_PSD.params.repair: "nearest"projects it to the nearest correlation matrix (Higham) and warns with the size of the change; a repair stopped at its cap also warnsPULSE_MATRIX_NOT_CONVERGED. Reliability keeps alpha and nulls omega instead of refusing. - A singular matrix is refused as
PULSE_MATRIX_SINGULARwithrank,condition_numberanddependent_fields: drop or combine the named fields, do not repair. - Pairwise inference sizes use a conservative N* (the smallest pair);
PULSE_MATRIX_PAIRWISE_N_STARsays so on PCA and factor analysis.
Weights
Frequency weights work everywhere. Probability weights work on the co-moment operators (and use n_eff for sizes) but rank methods refuse them (PULSE_WEIGHT_UNSUPPORTED). Weight 0 counts toward n and adds no mass. See weighting.
Significance
Matrices carry figures, not p-values. Rank and partial correlation have no raw p or interval yet, and nothing corrects for the many comparisons across the cells of a matrix: a grid of p = 0.05 cells hides false positives. Treat the values as descriptive until matrix p-values and their correction arrive. To test whether whole mean profiles differ (one sample against a target, paired before / after sets, or two groups with pooled or unequal covariance), run TEST_HOTELLING_T2 on the same vector; it is a test with a p-value. Across three or more groups run TEST_MANOVA (Pillai’s trace headline; Wilks, Hotelling–Lawley and Roy beside it). Check their equal-covariance assumption with TEST_BOX_M. TEST_BARTLETT_SPHERICITY is PCA’s Bartlett test as a standalone test. TEST_MARDIA checks the joint normality these tests assume.
Per-test correction on other operators: multiplicity-correction.
Sanity checks
- Few rows against many members makes every figure unstable; read
components.matrices[i].nand the per-pair counts before trusting a cell. - A
nullcell is undefined (constant member, thin bucket), never 0. - Grouped requests return one matrix per bucket; compare them as separate answers.
See
matrix-results— slot, encodings, refusals.multivariate-outputs— how the PCA, factor, distance, score and vector-test figures are laid out.op-mat-pcaop-mat-factorop-mat-distanceop-mat-reliabilityop-mat-collinearityregression-inference— coefficient covariance.
name: multivariate-outputs
description: Long-form output and parameter detail for the multivariate operators — how rotated PCA and factor loadings, the maximum-likelihood fit indices, centroid distances, row scores and the vector tests lay out their figures. Topical design; the atomic op-mat-*, op-attr-* and op-test-* skills keep the short form.
type: guide
kind: design
applies_to: process, compose, predict
covers: [PCA rotation, factor fit indices, RMSEA, centroid distance, factor score, Hotelling, MANOVA, vector test details]
requires: [capability:matrices]
Multivariate outputs in detail
multivariate-design picks the operator; the atomic skill lists its params and a one-line output. This file holds the longer reading of the figures. Everything here is descriptive layout, not new params.
PCA
primary holds the loadings (eigenvector · √λ), rectangular, columns PC1…PCk, always the full p rows. auxiliary.eigenvectors has the same shape.
With rotation: varimax (stats::varimax, Kaiser-normalized, k ≥ 2) primary holds the rotated loadings: columns ordered by descending variance accounted, each summing positive. auxiliary.unrotated keeps the originals and vectors.rotated_variance gives each rotated column’s Σ loading². Eigenvalues, shares, communalities and eigenvectors stay unrotated, so explained_variance is still per eigenvalue: read rotated_variance for the rotated split, and expect PC1 to be the column with the most variance, not the first eigenvector.
Factor analysis
primary holds the loadings (F1…Fk, rotated when set); auxiliary.unrotated appears on rotated runs and factor_correlation (k × k) on promax / oblimin. kappa is the promax power (default 4), gamma the oblimin γ (default 0); any other rotation refuses them. extraction follows psych fm: minres, principal_axis (pa) or ml (factanal, ψ held in [0.005, 1]).
vectors carry communalities, uniquenesses, variance_accounted; pairwise runs add auxiliary.n. scalars always carry rmsr, empirical_chisq, df, empirical_p, kmo, bartlett_*, iterations, converged (1/0) and factors_retained. empirical_chisq measures the residual size; it is not a likelihood test.
ml adds the likelihood fit: likelihood_chisq (Bartlett-corrected, on df), likelihood_p, tli, rmsea with rmsea_lower / rmsea_upper (psych RMSEA lower / upper; conf_level sets the coverage, default 0.9) and bic, null when df ≤ 0. These need N* > 1 + (2p + 5)/6 + 2k/3 (PULSE_MATRIX_INSUFFICIENT_N) and a positive-definite R (PULSE_MATRIX_SINGULAR). Identification: (p − k)² ≥ p + k, so 1 factor needs 3 members and 2 need 5 (PULSE_MATRIX_NOT_IDENTIFIED). A uniqueness held at 0.005 is a Heywood case, kept with PULSE_MATRIX_HEYWOOD.
Centroid distances
between takes a grouper spec as in groups ({type, field | vector | fields, params, include, interval}): any built-in, extension groupers too; no own weight, and tz only on a date-family grouper (else the request time_zone; predict echoes it at matrices[i].params.between). primary is k × k, square_symmetric, keyed by bucket label in the grouper’s order (a clustering grouper labels buckets cluster_1… by size), zero diagonal. auxiliary.centroids is k × p; centroids_standardized appears with standardize, pooled_covariance with mahalanobis (computed over the pooled within-bucket covariance, on raw centroids). vectors.sizes is Σw per bucket and counts the rows. The metric list is local; U27’s shared registry folds it in later. cosine is 1 − cos; the other metrics follow stats::dist.
Components add metric, standardize, buckets and n_null_key (rows in no bucket, excluded). A massless bucket gives a null centroid and PULSE_MATRIX_INSUFFICIENT_N scope buckets; mahalanobis with N* ≤ k uses scope pooled.
Factor scores
One f64 per record: zᵀw on members standardized by the fit. Regression weights are w = R⁻¹LΦ, Bartlett weights w = U⁻²L(LᵀU⁻²L)⁻¹ (psych factor.scores). Default label ATTR_FACTOR_SCORE_<vector or first field>_F<factor>. The score has mean 0 over the fit rows; a null member gives a null score. The fit takes the factor knobs (factors, extraction, rotation, kappa, gamma; no repair).
All scores null with a warning: a singular fit (PULSE_MATRIX_SINGULAR), N* ≤ p (PULSE_MATRIX_INSUFFICIENT_N), k not identified or "kaiser" keeping fewer than factor (PULSE_MATRIX_NOT_IDENTIFIED), Bartlett over a uniqueness ≤ 0 (PULSE_MATRIX_HEYWOOD). Scored with a warning: a Heywood case under regression, or an iteration cap (PULSE_MATRIX_NOT_CONVERGED, kept beside a refusal too).
Hotelling’s T²
Statistic = T², DF = p. Details: f, df1, df2, n, n_dropped, members; one-sample adds mean, mu; paired members2, mean_diff, mu; two-sample groups (sorted), means, mean_diff. Variant: one_sample, paired, two_sample_pooled, two_sample_unequal. variance: unequal is Krishnamoorthy–Yu (fractional df2), Welch’s t at p = 1, and refused on one-sample / paired. Needs n > p (one-sample, paired), n₁+n₂ > p+1 (pooled), n > p per level (unequal). unequal at p = 1 is Welch’s t. Weighted: moments on w*, N* = Σw or n_eff, and Details add sum_weights (+ n_eff) shaped like n.
MANOVA
Statistic = Pillai’s V, DF its df1, PValue its p. Details: f, df1, df2, members, groups (sorted), n, n_dropped, means, df_hypothesis (k − 1), df_residual (N* − k), eigenvalues (of E⁻¹H) and pillai / wilks / hotelling_lawley / roy, each {statistic, f, df1, df2, p_value}. roy.upper_bound is true: Roy’s p is a lower bound. A null p_value means R would print NA. Needs n ≥ p + k complete rows. Weighted: N*_g = Σw or n_eff, and Details add per-level sum_weights (+ n_eff).
See
multivariate-design— which operator answers which question.matrix-results— the matrix slot, encodings and refusals.
op-agg-average
name: op-agg-average
description: Arithmetic mean of a numeric field across the input set.
kind: operator
category: AGG
operator: AGG_AVERAGE
type: reference
applies_to: process, compose, predict
examples_tags: [streaming-friendly, data-quality]
Use when
Average of a numeric field, over all rows or per group.
Questions it answers:
- What is the average order value?
- What is the typical rating in each region?
Use something else:
AGG_MEDIANwhen the field is skewed or has extreme values and you want the typical row.AGG_WEIGHTED_MEANwhen rows carry weights.AGG_WELFORDwhen you also need the spread around the average.
Params
Weight: honours a resolved row weight (weight on the request or slot, or Options.DefaultWeight; "weight": null opts out) — weighted it is Σw·x / Σw. Invalid weights (null, negative, NaN/Inf, fractional under frequency) are excluded and warned (PULSE_WEIGHT_INVALID_ROWS); zero contributes nothing.
Inputs
| Param | Accepted field types |
|---|---|
Field | numeric: u8/u16/u32/u64, f32/f64, decimal128, date, datetime, packed_bool, u4 |
Output
Scalar float64. Per-group when wired under a grouper.
Components
Weighted slots add floor keys sum_weights (Σw), n_eff (Kish; probability only) and n_weight_invalid; absent ⇒ unweighted. n/n_null stay raw counts.
Weighted adds sum_weighted, weighted_mean, m2_weighted, sum_weights_sq, weighted_variance (m2/(Σw−1)).
Universal floor {n, n_null} plus operator-specific:
| Key | Type | Notes |
|---|---|---|
sum | float64 | Running sum; mean = sum / n |
- Mergeability:
Mergeable - Streaming: per-chunk emits running mean via (sum, n)
Gotchas
- Null inputs skipped — mean is over
nnon-null, not the cohort. decimal128under any weight in force (default included) →PULSE_WEIGHT_UNSUPPORTED;"weight": nullopts out.
See
pulse_examples_search tags=[streaming-friendly]- Skills:
aggregation-design,response-components
op-agg-ci-lower
name: op-agg-ci-lower
description: Lower bound of the confidence interval for the mean.
kind: operator
category: AGG
operator: AGG_CI_LOWER
type: reference
applies_to: process, compose, predict
examples_tags: [hypothesis-test, streaming-friendly]
Use when
Lower end of a confidence interval for the mean of a numeric field, showing how precisely the average is pinned down.
Questions it answers:
- How precisely do we know the average satisfaction in each segment?
- What is the lowest average order value the data are consistent with at 95% confidence?
Use something else:
AGG_CI_UPPERwhen you want the upper end of the same interval.
Params
| Name | Type | Default | Description |
|---|---|---|---|
confidence | float | 0.95 | Confidence level in (0, 1). |
method | string | "normal" | "normal" (Welford-streamable) today; "bootstrap" reserved. |
Bound = mean ∓ z·√(s²/n), z = qnorm(1 − α/2). Weight (both kinds; "weight": null opts out): weighted mean, s² on w* = w·N*/Σw, stderr √(s²/N*), N* = Σw (frequency) or Kish n_eff (probability); same z. Invalid weights excluded and warned.
Inputs
| Param | Accepted field types |
|---|---|
Field | numeric (no decimal128): u8/u16/u32/u64, f32/f64, date, datetime, packed_bool, u4 |
Output
Scalar float64 — lower CI bound. NaN when n < 2 (weighted: N* ≤ 1; null in JSON).
Reading the output
value: The lower end of a confidence interval for the group’s mean at the requested confidence (95% by default): mean - z * standard error, where the standard error is the sample standard deviation (dividing by n - 1) over sqrt(n).- Caveat: The interval is about the MEAN, not about individual rows: most rows can, and usually do, fall outside it.
- Caveat: The confidence level describes the method: over repeated samples, intervals built this way capture the true mean that share of the time. Any one interval either contains it or not.
Components
Weighted: floor adds sum_weights, n_eff (probability), n_weight_invalid.
Universal floor {n, n_null} plus operator-specific:
| Key | Type | Notes |
|---|---|---|
mean | float64 | Welford running mean |
stderr | float64 | Standard error of the mean |
alpha | float64 | 1 - confidence |
t_critical | float64 | Normal critical z (qnorm(1 − α/2)) |
lower | float64 | Resolved lower bound |
- Mergeability:
Mergeable - Streaming: per-chunk Welford
Gotchas
- Normal z, not t: small samples get narrower intervals than t.
n < 2(weighted N* ≤ 1) → NaN (no variance estimate)."bootstrap"method returnsPROCESSING_CONFIGuntil the buffered follow-up lands.
See
pulse_examples_search tags=[hypothesis-test]- Skills:
aggregation-design,statistical-testing,response-components
op-agg-ci-upper
name: op-agg-ci-upper
description: Upper bound of the confidence interval for the mean.
kind: operator
category: AGG
operator: AGG_CI_UPPER
type: reference
applies_to: process, compose, predict
examples_tags: [hypothesis-test, streaming-friendly]
Use when
Upper end of a confidence interval for the mean of a numeric field, showing how precisely the average is pinned down.
Questions it answers:
- What is the highest average wait time the data are consistent with at 95% confidence?
- How wide is the uncertainty around each region’s mean score?
Use something else:
AGG_CI_LOWERwhen you want the lower end of the same interval.
Params
| Name | Type | Default | Description |
|---|---|---|---|
confidence | float | 0.95 | Confidence level in (0, 1). |
method | string | "normal" | "normal" (Welford-streamable) today; "bootstrap" reserved. |
Bound = mean ∓ z·√(s²/n), z = qnorm(1 − α/2). Weight (both kinds; "weight": null opts out): weighted mean, s² on w* = w·N*/Σw, stderr √(s²/N*), N* = Σw (frequency) or Kish n_eff (probability); same z. Invalid weights excluded and warned.
Inputs
| Param | Accepted field types |
|---|---|
Field | numeric (no decimal128): u8/u16/u32/u64, f32/f64, date, datetime, packed_bool, u4 |
Output
Scalar float64 — upper CI bound. NaN when n < 2 (weighted: N* ≤ 1; null in JSON).
Reading the output
value: The upper end of a confidence interval for the group’s mean at the requested confidence (95% by default): mean + z * standard error, where the standard error is the sample standard deviation (dividing by n - 1) over sqrt(n).- Caveat: The interval is about the MEAN, not about individual rows: most rows can, and usually do, fall outside it.
- Caveat: The confidence level describes the method: over repeated samples, intervals built this way capture the true mean that share of the time. Any one interval either contains it or not.
Components
Weighted: floor adds sum_weights, n_eff (probability), n_weight_invalid.
Universal floor {n, n_null} plus operator-specific:
| Key | Type | Notes |
|---|---|---|
mean | float64 | Welford running mean |
stderr | float64 | Standard error of the mean |
alpha | float64 | 1 - confidence |
t_critical | float64 | Normal critical z (qnorm(1 − α/2)) |
upper | float64 | Resolved upper bound |
- Mergeability:
Mergeable - Streaming: per-chunk Welford
Gotchas
- Normal z, not t: small samples get narrower intervals than t.
n < 2(weighted N* ≤ 1) → NaN."bootstrap"method returnsPROCESSING_CONFIGuntil the buffered follow-up lands.
See
pulse_examples_search tags=[hypothesis-test]- Skills:
aggregation-design,statistical-testing,response-components
op-agg-count
name: op-agg-count
description: Count records that pass the active filter, optionally per group.
kind: operator
category: AGG
operator: AGG_COUNT
type: reference
applies_to: process, compose, predict
examples_tags: [streaming-friendly, cohort-analysis]
Use when
Number of rows that have a value in a field, over all rows or per group.
Questions it answers:
- How many responses did each region return?
- How many orders were placed through each channel?
Use something else:
AGG_NULL_COUNTwhen you want the rows where the field is empty.AGG_DISTINCT_COUNTwhen you want how many different values appear, not how many rows.
Params
Weight: honours a resolved row weight (weight on the request or slot, or Options.DefaultWeight; "weight": null opts out) — weighted it is Σw over value-present rows (a float). Invalid weights (null, negative, NaN/Inf, fractional under frequency) are excluded and warned (PULSE_WEIGHT_INVALID_ROWS); zero contributes nothing.
Inputs
| Param | Accepted field types |
|---|---|
Field | any cohort field type (numeric, categorical, date, packed_bool, set_*, decimal128) |
AGG_COUNT counts non-null rows.
Output
Scalar int64. Per-group when wired under a grouper; otherwise one row across the cohort.
Components
Weighted slots add floor keys sum_weights (Σw), n_eff (Kish; probability only) and n_weight_invalid; absent ⇒ unweighted. n/n_null stay raw counts.
Floor only — no operator-specific keys. Universal {n, n_null} per response-components contract.
- Mergeability:
Mergeable - Streaming: per-chunk emission; orchestrator sums
Gotchas
- Counts non-null inputs only.
- Never a smart default: an omitted
Typenever infersAGG_COUNT— name it explicitly. decimal128under any weight in force (default included) →PULSE_WEIGHT_UNSUPPORTED;"weight": nullopts out.
See
pulse_examples_search tags=[streaming-friendly]- Skills:
aggregation-design,response-components
op-agg-distinct-count
name: op-agg-distinct-count
description: Count distinct non-null values across the input set.
kind: operator
category: AGG
operator: AGG_DISTINCT_COUNT
type: reference
applies_to: process, compose, predict
examples_tags: [cardinality-analysis, streaming-friendly]
Use when
Number of different values a field takes, over all rows or per group.
Questions it answers:
- How many different customers ordered this month?
- Does the respondent ID appear once per row, or are there duplicates?
Use something else:
AGG_COUNTwhen you want how many rows there are, not how many different values.GROUP_CATEGORYwhen you want how many rows hold each value: group by the field, then count.
Params
None.
Inputs
| Param | Accepted field types |
|---|---|
Field | any cohort field type EXCEPT set_* (categorical_*, numeric, date, datetime, packed_bool, decimal128) |
Output
Scalar int64 — number of distinct non-null values. Per-group when wired under a grouper.
Components
Universal floor {n, n_null} plus operator-specific:
| Key | Type | Notes |
|---|---|---|
cardinality | int | Distinct non-null values observed |
- Mergeability:
Partial— distinct-set merge - Streaming: per-chunk; merge unions per-chunk seen-sets
Gotchas
- High-cardinality fields → memory growth proportional to distinct values.
- Counts non-null only; nulls collapsed.
set_*rejected at build time withPROCESSING_CONFIG.
See
pulse_examples_search tags=[cardinality-analysis]- Skills:
aggregation-design,response-components
op-agg-distinct-sum
name: op-agg-distinct-sum
description: Sum a value field once per distinct key.
kind: operator
category: AGG
operator: AGG_DISTINCT_SUM
type: reference
applies_to: process, compose, predict
examples_tags: [cardinality-analysis, streaming-friendly]
Use when
Total of a numeric field counting each key once, such as a respondent’s weight summed once per respondent.
Questions it answers:
- What is the weighted base when each respondent appears on several rows?
- What is the total contract value when each contract repeats on every line item?
Use something else:
AGG_SUMwhen every row is its own unit and should be added.
Params
distinct_by (string, required) — field holding the distinct key. Absent or empty: PROCESSING_CONFIG, never defaulted to Field; unknown: SERVICE_VALIDATION.
Inputs
| Param | Accepted field types |
|---|---|
Field | numeric, no decimal128 (incl. u4, date, datetime, packed_bool) |
distinct_by | any numeric-coded field, categorical_* included |
Output
Scalar float64 — one value per key, summed.
Components
Universal floor {n, n_null} plus:
| Key | Type | Notes |
|---|---|---|
sum | float64 | Sum of the first value seen per key |
distinct_count | int | Distinct keys that contributed |
Partialmerge (per-key map union);MarginIndependentmargins — crosstabs fuse.
Gotchas
- FIRST VALUE WINS on a key with conflicting values; a merge keeps the receiver’s, so it holds across shards too.
- Null in EITHER half contributes nothing and registers NO key; a later real row counts.
- A plain sum of a per-respondent weight multiplies it by row count; this gives the weighted base.
- Memory grows per key.
See
pulse_examples_search tags=[cardinality-analysis]- Skills:
aggregation-design,crosstab-guide
op-agg-frequency
name: op-agg-frequency
description: Value count — how many non-null rows hold one chosen value (params.value); one float64 per output row.
kind: operator
category: AGG
operator: AGG_FREQUENCY
type: reference
applies_to: process, compose, predict
examples_tags: [cross-tabulation, streaming-friendly]
Use when
How many rows hold one chosen value of a field, such as the Yes answers, over all rows or per group.
Questions it answers:
- How many respondents answered Yes in each region?
- How many orders were returned each day?
Use something else:
AGG_MODE_COUNTwhen you want how many rows share the field’s most common value, whatever it is.FILTER_INCLUDEwhen you want rows holding any of several values: keep them, then count rows.
Params
| Name | Type | Default | Description |
|---|---|---|---|
value | string | (required) | Matched like an include filter: category label, else a number (date days, datetime seconds, packed_bool 1/0) |
Weight-aware ("weight": null opts out): Σw of matches (a float); share = Σw_match/Σw.
Inputs
| Param | Accepted field types |
|---|---|
Field | any type EXCEPT set_* |
Output
Scalar float64 — rows equal to value, per group.
Components
Weighted: floor adds sum_weights/n_eff/n_weight_invalid.
Universal floor {n, n_null} plus operator-specific:
| Key | Type | Notes |
|---|---|---|
match_count | int | Rows equal to value (= scalar; weighted: Σw) |
share | float64 | match_count / n; omitted when n is 0 |
- Mergeability:
Mergeable(counters sum); ProcessChain admits it
Gotchas
- Missing
value→PROCESSING_CONFIG(runtime and predict). - A value no row holds counts 0, not an error.
- Nulls: not counted, not in
share’s base. set_*rejected withPROCESSING_CONFIG.
See
pulse_examples_search tags=[cross-tabulation]- Skills:
aggregation-design,response-components
op-agg-kurtosis
name: op-agg-kurtosis
description: Population excess kurtosis g2 (m4 / m2^2 - 3, dividing by n) via online moments.
kind: operator
category: AGG
operator: AGG_KURTOSIS
type: reference
applies_to: process, compose, predict
examples_tags: [distribution-shape, streaming-friendly]
Use when
How heavy a numeric field’s tails are next to a normal distribution: positive when extreme values are more common.
Questions it answers:
- Do transaction amounts have more extreme values than a bell curve would give?
- Are response times prone to rare, very long waits?
Use something else:
AGG_SKEWNESSwhen you want to know whether the field is lopsided.
Params
Weight-aware ("weight": null opts out): weighted population moments, Σw in place of n. Invalid weights excluded (PULSE_WEIGHT_INVALID_ROWS).
Inputs
| Param | Accepted field types |
|---|---|
Field | numeric (no decimal128): u8/u16/u32/u64, f32/f64, date, datetime, packed_bool, u4 |
Output
Scalar float64 — population excess kurtosis g2 = m4 / m2^2 − 3 (moments divide by n), NOT the adjusted G2 of Excel KURT / SPSS. Per-group when wired under a grouper.
Reading the output
value: How heavy the tails are next to a normal distribution. Pulse computes the population EXCESS kurtosis g2: the average fourth-power z-score minus 3, (m4/n) / (m2/n)^2 - 3, where the components m2 and m4 are SUMS of squared and fourth-power distances from the mean (not yet divided by n), so a normal distribution scores 0. It can never fall below -2.- Caveat: Small-n bias: g2 differs from the adjusted G2 that Excel KURT and SPSS print, and on small groups the gap is large; neither is stable on a handful of rows.
Components
Weighted adds floor sum_weights, n_eff (probability), n_weight_invalid.
Universal floor {n, n_null} plus operator-specific:
| Key | Type | Notes |
|---|---|---|
mean | float64 | Running mean |
m2 | float64 | Second-moment accumulator |
m3 | float64 | Third-moment accumulator |
m4 | float64 | Fourth-moment accumulator |
kurtosis | float64 | Derived from m2, m4, n |
- Mergeability:
Mergeable(Chan moments combine) - Streaming: per-chunk online moments
Gotchas
n <= 1or zero variance →0(not NaN): checknbefore reading a 0.- Small-n bias vs adjusted G2 is large on small groups; g2 ≥ −2 always.
- Excess kurtosis (normal = 0), NOT raw kurtosis (normal = 3).
decimal128rejected.
See
pulse_examples_search tags=[distribution-shape]- Skills:
aggregation-design,response-components
op-agg-mae
name: op-agg-mae
description: Mean absolute error of a prediction against an actual — mean(|y - prediction|).
kind: operator
category: AGG
operator: AGG_MAE
type: reference
applies_to: process, compose, predict
examples_tags: [regression, compose]
Use when
Average size of a prediction’s miss in the actual’s own units, comparing an actual column with a prediction column row by row.
Questions it answers:
- On average, how many units off is the sales model on the held-out weeks?
- Is the forecast miss larger for some stores than for others?
Use something else:
AGG_RMSEwhen large misses should count for more than small ones.
Params
prediction_field — required; the predicted column (typically an ATTR_REG_PREDICT score column). Missing, or a non-numeric column on either side (categorical, set, date, datetime, decimal128), is PROCESSING_CONFIG; an unknown name SERVICE_VALIDATION — predict and runtime alike.
Weight-aware: Σw·|y − ŷ| / Σw. A decimal128 side under a weight → PULSE_WEIGHT_UNSUPPORTED.
Inputs
Field is the actual: u4–u64, f32/f64, packed_bool. No train/holdout param — split with a filter or GROUP_RANGE range grouper.
Output
Scalar float64 in the actual’s units. Rows with a null on either side are skipped; none left → NaN (null).
Reading the output
value: The average size of the miss between prediction and actual, ignoring sign, in the actual’s own units. 0 means every prediction is exact.- Caveat: It says how big the misses are, not which way: a model that is always too high and one that is always too low can share a value.
- Caveat: It has the actual’s units, so compare it only between models scored on the same rows and target.
Components
Floor {n, n_null} (on field only) (weighted adds sum_weights, n_eff, n_weight_invalid) plus loss_sum (Σw·|y − ŷ|), weight_sum, n_scored, n_null_prediction, mae. Mergeable; streams.
Gotchas
- Sign-blind: a model always too high and one always too low can share a value.
See
pulse_examples_search tags=[regression, compose]- Skills:
op-agg-rmse,op-agg-mape,op-attr-reg-predict
op-agg-mape
name: op-agg-mape
description: Mean absolute percentage error of a prediction against an actual — mean(|y - prediction| / |y|), as a fraction.
kind: operator
category: AGG
operator: AGG_MAPE
type: reference
applies_to: process, compose, predict
examples_tags: [regression, compose]
Use when
Average size of a prediction’s miss as a share of the actual value, comparing an actual column with a prediction column row by row.
Questions it answers:
- On the held-out weeks, how far off is the sales model, as a share of actual sales?
- Is the forecast’s percentage miss larger in some regions than in others?
Use something else:
AGG_MAEwhen actual values can be 0 or close to 0, where a share is undefined or explodes.
Params
prediction_field — required; the predicted column (typically an ATTR_REG_PREDICT score column). Missing, or a non-numeric column on either side (categorical, set, date, datetime, decimal128), is PROCESSING_CONFIG; an unknown name SERVICE_VALIDATION — predict and runtime alike.
Weight-aware: Σw·loss / Σw. A decimal128 side under a weight → PULSE_WEIGHT_UNSUPPORTED.
Inputs
Field is the actual: u4–u64, f32/f64, packed_bool. No train/holdout param — split with a filter or GROUP_RANGE range grouper.
Output
Scalar float64 FRACTION (0.12 = 12%). Rows with a null on either side, or an actual of exactly 0, are skipped; none left → NaN (null).
Reading the output
value: The average of each row’s miss divided by its actual value, ignoring sign, as a FRACTION: 0.12 means the predictions are off by 12% of the actual value on average. 0 means every prediction is exact.- Caveat: Rows whose actual is exactly 0 are left out (their share is undefined) and counted in components n_zero_actual; actual values near 0 can still inflate it sharply.
- Caveat: It punishes over-prediction more than under-prediction of the same size, because the share is taken of the actual.
Components
Floor {n, n_null} (on field only) (weighted adds sum_weights, n_eff, n_weight_invalid) plus loss_sum, weight_sum, n_scored, n_null_prediction, n_zero_actual (zero actuals skipped), mape. Mergeable; streams.
Gotchas
- Not ×100. Near-zero actuals still inflate it; prefer
AGG_MAEthere.
See
pulse_examples_search tags=[regression, compose]- Skills:
op-agg-rmse,op-agg-mae,op-attr-reg-predict
op-agg-max
name: op-agg-max
description: Largest non-null value of the field.
kind: operator
category: AGG
operator: AGG_MAX
type: reference
applies_to: process, compose, predict
examples_tags: [financial, streaming-friendly]
Use when
Largest value of a numeric or date field, over all rows or per group.
Questions it answers:
- What is the latest order date in each region?
- Is any order value implausibly large?
Use something else:
AGG_MINwhen you want the smallest value.AGG_PERCENTILEwhen one extreme row would mislead and you want a high value that ignores it.AGG_RANGEwhen you want the distance between smallest and largest.
Params
None.
Inputs
| Param | Accepted field types |
|---|---|
Field | numeric: u8/u16/u32/u64, f32/f64, decimal128, date, datetime, packed_bool, u4 |
Output
Scalar float64. Per-group when wired under a grouper.
Components
Universal floor {n, n_null} plus operator-specific:
| Key | Type | Notes |
|---|---|---|
max | float64 | Largest non-null value observed |
- Mergeability:
Mergeable - Streaming: per-chunk emits running max
Gotchas
- Null rows skipped;
n_nullcounts them. - All-null cohort → emits NaN.
See
pulse_examples_search tags=[streaming-friendly]- Skills:
aggregation-design,response-components
op-agg-median
name: op-agg-median
description: 50th percentile of the field; requires sorting the full value set.
kind: operator
category: AGG
operator: AGG_MEDIAN
type: reference
applies_to: process, compose, predict
examples_tags: [distribution-shape, buffered-pipeline]
Use when
Middle value of a numeric field once sorted: the typical row, not pulled by a few extreme values.
Questions it answers:
- What is the typical household income in each region?
- What is the typical delivery time when a few deliveries take weeks?
Use something else:
AGG_PERCENTILEwhen you want a value other than the middle, such as the 90th percentile.AGG_MODEwhen the field holds categories rather than numbers.
Params
Weight-aware ("weight": null opts out): Hmisc wtd.quantile — sort by value; h = 0.5·(Σw−1); x(k) = first value with cumulative Σw ≥ k+1 (snapped to an integer within 1e-9); interpolate x(⌊h⌋)..x(⌈h⌉). probability weights first rescaled to Σw = n (rows used) — scale-invariant; frequency weights stay raw = type 7 on duplicated rows. Invalid weights excluded (PULSE_WEIGHT_INVALID_ROWS).
Inputs
| Param | Accepted field types |
|---|---|
Field | numeric (no decimal128): u8/u16/u32/u64, f32/f64, date, datetime, packed_bool, u4 |
Output
Scalar float64. Per-group when wired under a grouper.
Components
Weighted adds floor sum_weights, n_eff (probability), n_weight_invalid.
Universal floor {n, n_null} plus operator-specific:
| Key | Type | Notes |
|---|---|---|
position_low | int | Lower bracket index (weighted: ⌊h⌋, expanded) |
position_high | int | Upper bracket index (weighted: ⌈h⌉) |
median | float64 | Resolved median (linear interpolation) |
- Mergeability:
None— exact median needs full sort - Streaming: NOT streamable (buffered only).
Gotchas
- Buffered-only path: full input materialised before sort.
- Outlier-robust, unlike the mean.
See
pulse_examples_search tags=[distribution-shape]- Skills:
aggregation-design,response-components
op-agg-min
name: op-agg-min
description: Smallest non-null value of the field.
kind: operator
category: AGG
operator: AGG_MIN
type: reference
applies_to: process, compose, predict
examples_tags: [financial, streaming-friendly]
Use when
Smallest value of a numeric or date field, over all rows or per group.
Questions it answers:
- What is the earliest order date in each region?
- Are there negative or impossible values in the age field?
Use something else:
AGG_MAXwhen you want the largest value.AGG_PERCENTILEwhen one extreme row would mislead and you want a low value that ignores it.
Params
None.
Inputs
| Param | Accepted field types |
|---|---|
Field | numeric: u8/u16/u32/u64, f32/f64, decimal128, date, datetime, packed_bool, u4 |
Output
Scalar float64. Per-group when wired under a grouper.
Components
Universal floor {n, n_null} plus operator-specific:
| Key | Type | Notes |
|---|---|---|
min | float64 | Smallest non-null value observed |
- Mergeability:
Mergeable - Streaming: per-chunk emits running min
Gotchas
- Null rows skipped;
n_nullcounts them. - All-null cohort → emits NaN (sentinel for “no data”).
- Decimal128 preserved on input; float64 output coerces the comparison space.
See
pulse_examples_search tags=[streaming-friendly]- Skills:
aggregation-design,response-components
op-agg-mode
name: op-agg-mode
description: Most-frequent value of the field (ties broken by the smallest value).
kind: operator
category: AGG
operator: AGG_MODE
type: reference
applies_to: process, compose, predict
examples_tags: [cardinality-analysis, cross-tabulation]
Use when
Most common value of a field, over all rows or per group.
Questions it answers:
- What is the most common answer to the preferred-brand question?
- Which product is ordered most often in each store?
Use something else:
GROUP_CATEGORYwhen you want the count of every value, not only the top one: group by the field, then count.AGG_MEDIANwhen the field is numeric and you want its middle value.
Params
Weight-aware ("weight": null opts out): the value with the largest Σw; count becomes that Σw (a float). Invalid weights excluded (PULSE_WEIGHT_INVALID_ROWS).
Inputs
| Param | Accepted field types |
|---|---|
Field | any field type EXCEPT set_* |
Output
Scalar float64 — the modal value (categorical: its dictionary index). Per-group under a grouper. ProcessChain admits it: a later stage reads that index as a number, not a label.
Components
Weighted adds floor sum_weights, n_eff (probability), n_weight_invalid.
Universal floor {n, n_null} plus operator-specific:
| Key | Type | Notes |
|---|---|---|
value | any | Most-frequent value |
count | int | Row count of the modal value (weighted: Σw) |
distinct_count | int | Distinct values observed |
tie_count | int | Values tied at the max count |
- Mergeability:
Partial— map allocation - Streaming: per-value counts merged at flush
Gotchas
- Ties go to the SMALLEST value (or index), not the first seen;
tie_count > 1flags a tie. - High cardinality costs memory; pre-filter.
set_*→PROCESSING_CONFIG(no modal scalar).
See
pulse_examples_search tags=[cardinality-analysis]- Skills:
aggregation-design,response-components
op-agg-mode-count
name: op-agg-mode-count
description: Modal count — how many rows hold the field's most common value; one float64 per output row.
kind: operator
category: AGG
operator: AGG_MODE_COUNT
type: reference
applies_to: process, compose, predict
examples_tags: [cross-tabulation, cardinality-analysis]
Use when
How many rows share a field’s most common value, over all rows or per group.
Questions it answers:
- How many respondents gave the most common answer?
- How many orders used the most popular payment method?
Use something else:
GROUP_CATEGORYwhen you want a count for every value of the field: group by it, then count rows.capability:facetwhen you want per-value counts for several fields in one call.
Params
Weight-aware ("weight": null opts out): the largest per-value Σw (a float), its value the weighted mode. Invalid weights excluded (PULSE_WEIGHT_INVALID_ROWS).
Inputs
| Param | Accepted field types |
|---|---|
Field | any type EXCEPT set_* |
Output
Scalar float64 — the modal count, NOT a per-value map. Per group under a grouper; grouped by category on the same field it equals each group’s row count.
Components
Weighted adds floor sum_weights, n_eff (probability), n_weight_invalid.
Universal floor {n, n_null} plus operator-specific:
| Key | Type | Notes |
|---|---|---|
distinct_count | int | Number of distinct values |
mode_value | any | Modal value (smallest wins a tie) |
mode_count | int | Row count of the modal value (= scalar; weighted: Σw) |
- Mergeability:
Partial— per-value count map merged exactly; ProcessChain admits it - Streaming: count maps merged at flush
Gotchas
- Smart default for categorical_* and packed_bool. Memory grows with distinct values.
- Crosstab margin = modal count of the margin’s own rows, never a sum of cells (class
independent: fuses). set_*→PROCESSING_CONFIG.
See
pulse_examples_search tags=[cross-tabulation]- Skills:
aggregation-design,response-components
op-agg-null-count
name: op-agg-null-count
description: Count records where the field is null. The inverse of a non-null count.
kind: operator
category: AGG
operator: AGG_NULL_COUNT
type: reference
applies_to: process, compose, predict
examples_tags: [data-quality, streaming-friendly]
Use when
Number of rows where a field has no value, over all rows or per group.
Questions it answers:
- How many respondents skipped the income question?
- Which regions have the most orders missing a delivery date?
Use something else:
AGG_COUNTwhen you want the rows that do have a value.FILTER_NULLwhen you want to keep or drop the rows with no value.
Params
None.
Inputs
| Param | Accepted field types |
|---|---|
Field | any cohort field type (categorical_, numeric, date, packed_bool, set_, decimal128) |
Output
Scalar int64 — count of null inputs. Per-group when wired under a grouper.
Components
Floor only — no operator-specific keys. Universal {n, n_null} per response-components contract.
- Mergeability:
Mergeable - Streaming: per-chunk; orchestrator sums
Gotchas
- Field must be nullable in the schema; non-nullable fields always return 0.
- Set-typed empty mask is NOT a null — distinct from missing.
See
pulse_examples_search tags=[data-quality]- Skills:
aggregation-design,cohort-schema-design,response-components
op-agg-percentile
name: op-agg-percentile
description: Configurable percentile of the field; requires sorting the full value set.
kind: operator
category: AGG
operator: AGG_PERCENTILE
type: reference
applies_to: process, compose, predict
examples_tags: [distribution-shape, buffered-pipeline]
Use when
Value of a numeric field below which a chosen share of rows fall, such as the 90th percentile of delivery time.
Questions it answers:
- How long do the slowest 10% of deliveries take in each region?
- What income marks the top quarter of respondents?
Use something else:
AGG_MEDIANwhen you want the 50th percentile.ATTR_PERCENTILEwhen you want each row’s own percentile position.
Params
| Name | Type | Default | Description |
|---|---|---|---|
percentile | float | (required) | Percentile in [0, 100]. e.g. 95 for p95. |
Weight-aware ("weight": null opts out): Hmisc wtd.quantile — sort by value; h = p·(Σw−1); x(k) = first value with cumulative Σw ≥ k+1 (snapped to an integer within 1e-9); interpolate x(⌊h⌋)..x(⌈h⌉). probability weights first rescaled to Σw = n (rows used) — scale-invariant; frequency weights stay raw = type 7 on duplicated rows. Invalid weights excluded (PULSE_WEIGHT_INVALID_ROWS).
Inputs
| Param | Accepted field types |
|---|---|
Field | numeric (no decimal128): u8/u16/u32/u64, f32/f64, date, datetime, packed_bool, u4 |
Output
Scalar float64. Per-group when wired under a grouper.
Reading the output
value: The value below which the requested share of the group’s values fall, in the field’s own units: at percentile 90 about 90% of values sit at or below it. Pulse interpolates linearly between the two nearest sorted values (R’s default, type 7), so it may be a value no row holds.- Caveat: Tools use different interpolation rules, so on small groups another tool can return a somewhat different value for the same percentile.
- Caveat: A percentile near 0 or 100 on a small group rests on one or two rows and moves a lot when they change.
Components
Weighted adds floor sum_weights, n_eff (probability), n_weight_invalid.
Universal floor {n, n_null} plus operator-specific:
| Key | Type | Notes |
|---|---|---|
p | float64 | Requested percentile |
position | int | Index in sorted set (weighted: ⌊h⌋, expanded) |
lower | float64 | Lower bracket value |
upper | float64 | Upper bracket value |
method | string | Interpolation method (e.g. "linear") |
value | float64 | Resolved percentile |
- Mergeability:
None— exact percentile needs full sort - Streaming: NOT streamable
Gotchas
- Buffered full-input path; cohort-sized memory peak.
- Out-of-range
percentilerejected.
See
pulse_examples_search tags=[distribution-shape]- Skills:
aggregation-design,response-components
op-agg-range
name: op-agg-range
description: Spread (max minus min) of the field across the input set.
kind: operator
category: AGG
operator: AGG_RANGE
type: reference
applies_to: process, compose, predict
examples_tags: [distribution-shape, streaming-friendly]
Use when
Distance between the smallest and largest value of a numeric field, over all rows or per group.
Questions it answers:
- How far apart are the cheapest and most expensive orders?
- How widely do delivery times span in each depot?
Use something else:
AGG_STDDEVwhen you want a spread measure that uses every row, not only the two extremes.AGG_PERCENTILEwhen you want the spread of the middle of the data, ignoring extremes.
Params
None.
Inputs
| Param | Accepted field types |
|---|---|
Field | numeric (no decimal128): u8/u16/u32/u64, f32/f64, date, datetime, packed_bool, u4 |
Output
Scalar float64 — max - min. Per-group when wired under a grouper.
Components
Universal floor {n, n_null} plus operator-specific:
| Key | Type | Notes |
|---|---|---|
min | float64 | Smallest non-null value |
max | float64 | Largest non-null value |
- Mergeability:
Mergeable - Streaming: per-chunk tracks (min, max)
Gotchas
- Outlier-sensitive — one extreme blows the spread.
- All-null cohort → NaN.
See
pulse_examples_search tags=[distribution-shape]- Skills:
aggregation-design,response-components
op-agg-ratio
name: op-agg-ratio
description: Emits sum(numerator_field) / sum(denominator_field). The Aggregation's own Field is ignored.
kind: operator
category: AGG
operator: AGG_RATIO
type: reference
applies_to: process, compose, predict
examples_tags: [proportion-analysis, streaming-friendly]
Use when
Total of one field divided by the total of another, over all rows or per group, such as revenue per order.
Questions it answers:
- What is revenue per visit in each channel?
- What share of budgeted hours were actually worked per team?
Use something else:
ATTR_FORMULAwhen you want the average of each row’s own ratio: compute it per row (guarding a 0 denominator), then average it.
Params
numerator_field, denominator_field — required; a schema field OR a derived label (attribute incl. ATTR_REG_PREDICT, feature output), read numerically (categorical → its code). Unknown → SERVICE_VALIDATION, predict and runtime alike.
Weight-aware ("weight": null opts out): Σw·num / Σw·den. Weighted decimal128 num/den → PULSE_WEIGHT_UNSUPPORTED.
Inputs
Field IGNORED (ignores_field) but required; any type.
Output
float64 Σnum / Σden. Den sum 0 → NaN (Go), null (JSON); no error.
Reading the output
value: The total of the numerator field divided by the total of the denominator field over the group’s rows (a ratio of totals), such as revenue per order. Its units are the numerator’s per unit of the denominator’s.- Caveat: It is not the average of each row’s own ratio: rows with larger denominators count for more, which is usually what a rate should do.
- Caveat: A row missing either field is left out of BOTH totals.
- Caveat: Empty (NaN) when the denominator total is 0.
Components
Floor {n, n_null} (weighted: sum_weights, n_eff, n_weight_invalid), plus numerator, denominator (sums), ratio (null if den 0). Mergeable; streams.
ROI recipe
Attribute contrib per row; params {"numerator_field":"contrib","denominator_field":"spend"} → Σcontrib/Σspend exactly, per group too. Marginal ROI or an interval bound: numerator = its own per-row label.
Gotchas
- A row null on either column skips both sums;
ncounts the rest. weightmust name a cohort field, not a label.
See
pulse_examples_search tags=[proportion-analysis]- Skills:
aggregation-design,response-components
op-agg-rmse
name: op-agg-rmse
description: Root mean squared error of a prediction against an actual — sqrt(mean((y - prediction)^2)).
kind: operator
category: AGG
operator: AGG_RMSE
type: reference
applies_to: process, compose, predict
examples_tags: [regression, compose]
Use when
Typical size of a prediction’s miss in the actual’s own units, with large misses counting more: the root of the average squared miss.
Questions it answers:
- How many units off is the sales model on the held-out weeks, with big misses weighing more?
- Which of two models misses less on the same rows?
Use something else:
AGG_MAEwhen every unit of miss should count the same, so a few large misses do not dominate.
Params
prediction_field — required; the predicted column (typically an ATTR_REG_PREDICT score column). Missing, or a non-numeric column on either side (categorical, set, date, datetime, decimal128), is PROCESSING_CONFIG; an unknown name SERVICE_VALIDATION — predict and runtime alike.
Weight-aware: √(Σw·(y − ŷ)² / Σw), the root of the pooled MSE. A decimal128 side under a weight → PULSE_WEIGHT_UNSUPPORTED.
Inputs
Field is the actual: u4–u64, f32/f64, packed_bool. No train/holdout param — split with a filter or GROUP_RANGE range grouper.
Output
Scalar float64 in the actual’s units. Rows with a null on either side are skipped; none left → NaN (null).
Reading the output
value: The square root of the average squared miss between prediction and actual, in the actual’s own units. 0 means every prediction is exact; it is never below AGG_MAE on the same rows.- Caveat: Squaring makes a few large misses dominate it; a gap well above AGG_MAE on the same rows says the misses are uneven.
- Caveat: It has the actual’s units, so compare it only between models scored on the same rows and target.
Components
Floor {n, n_null} (on field only) (weighted adds sum_weights, n_eff, n_weight_invalid) plus loss_sum (Σw·(y − ŷ)²), weight_sum, n_scored, n_null_prediction, mse, rmse. Mergeable (pooled sums, root at finalize); streams.
Gotchas
- Squaring lets a few large misses dominate; always ≥
AGG_MAEon the same rows.
See
pulse_examples_search tags=[regression, compose]- Skills:
op-agg-mae,op-agg-mape,op-attr-reg-predict
op-agg-set-cardinality-avg
name: op-agg-set-cardinality-avg
description: Average popcount per contributing row — typical number of selections.
kind: operator
category: AGG
operator: AGG_SET_CARDINALITY_AVG
type: reference
applies_to: process, compose, predict
examples_tags: [cardinality-analysis, streaming-friendly]
Use when
For a multi-select field, the average number of options chosen per row.
Questions it answers:
- How many brands does a respondent recognise on average?
- How many features does a typical customer have turned on?
Use something else:
AGG_SET_CARDINALITY_SUMwhen you want the total number of selections.AGG_SET_FREQUENCYwhen you want which options were chosen.
Params
None. Weight-aware ("weight": null opts out): Σw·popcount / Σw.
Inputs
| Param | Accepted field types |
|---|---|
Field | any set rung — set_u8, set_u16, set_u32, set_u64, set_u128, set_u256 |
Output
Scalar float64 — sum_cardinality / n.
Components
Weighted adds floor sum_weights, n_eff (probability), n_weight_invalid.
Universal floor {n, n_null} plus operator-specific:
| Key | Type | Notes |
|---|---|---|
sum_cardinality | int | Sum of popcounts |
avg_cardinality | float64 | Avg per row |
- Mergeability:
Mergeable - Margin: mean-reducible — needs per-cell
nto combine - Streaming: per-chunk
Gotchas
- “Typical selections per respondent” — survey-friendly summary.
- Empty masks count toward
n(lowering the average); pre-filter if you want only non-empty rows.
See
pulse_examples_search tags=[cardinality-analysis]- Skills:
aggregation-design,cohort-schema-design,response-components
op-agg-set-cardinality-sum
name: op-agg-set-cardinality-sum
description: Sum of popcounts across contributing rows — total label selections seen.
kind: operator
category: AGG
operator: AGG_SET_CARDINALITY_SUM
type: reference
applies_to: process, compose, predict
examples_tags: [cardinality-analysis, streaming-friendly]
Use when
For a multi-select field, the total number of options chosen across all rows.
Questions it answers:
- How many brand mentions did the survey collect in total?
- How many add-ons were sold across all orders?
Use something else:
AGG_SET_CARDINALITY_AVGwhen you want the number per row.AGG_SET_FREQUENCYwhen you want the count for each option.
Params
None. Weight-aware ("weight": null opts out): Σw·popcount (a float).
Inputs
| Param | Accepted field types |
|---|---|
Field | any set rung — set_u8, set_u16, set_u32, set_u64, set_u128, set_u256 |
Output
Scalar int64 — total selections seen across rows (weighted: float64).
Components
Weighted adds floor sum_weights, n_eff (probability), n_weight_invalid.
Universal floor {n, n_null} plus operator-specific:
| Key | Type | Notes |
|---|---|---|
sum_cardinality | int | Sum of popcounts |
- Mergeability:
Mergeable; margin-summable - Streaming: per-chunk popcount sum
Gotchas
- Counts label selections, not rows —
sum_cardinality ≥ nfor any non-empty masks. - Empty masks contribute 0 (and count toward
n).
See
pulse_examples_search tags=[cardinality-analysis]- Skills:
aggregation-design,cohort-schema-design,response-components
op-agg-set-distinct-values
name: op-agg-set-distinct-values
description: Count of distinct exact mask values seen; each combination is atomic.
kind: operator
category: AGG
operator: AGG_SET_DISTINCT_VALUES
type: reference
applies_to: process, compose, predict
examples_tags: [cardinality-analysis, cohort-analysis]
Use when
For a multi-select field, how many different combinations of options appear.
Questions it answers:
- How many different combinations of channels do customers use?
- How many different combinations of brands were picked (an empty answer counts as one)?
Use something else:
AGG_SET_FREQUENCYwhen you want the count of each single option.AGG_SET_UNIONwhen you want every option chosen by anyone.
Params
None.
Inputs
| Param | Accepted field types |
|---|---|
Field | any set rung — set_u8, set_u16, set_u32, set_u64, set_u128, set_u256 |
Output
Scalar int64 — count of distinct exact masks.
Components
Universal floor {n, n_null} plus operator-specific:
| Key | Type | Notes |
|---|---|---|
mask_union | []uint64 | Bitwise OR of every row’s mask; 4 little-endian words, words[0] = bits 0-63 |
popcount | int | Distinct labels observed |
labels | []string | Resolved dictionary labels |
- Mergeability:
Mergeablevia union of seen-mask sets - Streaming: per-chunk
Gotchas
- Treats each unique combination as atomic —
{Visa, Amex}≠{Visa}. - Components carry the union, NOT a list of distinct masks (mask space can be huge).
See
pulse_examples_search tags=[cardinality-analysis]- Skills:
aggregation-design,cohort-schema-design,response-components
op-agg-set-frequency
name: op-agg-set-frequency
description: Per-bit row count — how many rows had each set label selected.
kind: operator
category: AGG
operator: AGG_SET_FREQUENCY
type: reference
applies_to: process, compose, predict
examples_tags: [cardinality-analysis, cross-tabulation]
Use when
For a multi-select field, how many rows chose each option, over all rows or per group.
Questions it answers:
- How many respondents selected each brand they have heard of?
- How many customers use each payment method?
Use something else:
GROUP_CATEGORYwhen the field holds one value per row: group by it, then count.AGG_SET_DISTINCT_VALUESwhen you want how many different combinations were chosen.
Params
None. Weight-aware ("weight": null opts out): Σw per member — map values become floats.
Inputs
| Param | Accepted field types |
|---|---|
Field | any set rung — set_u8, set_u16, set_u32, set_u64, set_u128, set_u256 |
Output
Rich map[string]int — label→row count (weighted: map[string]float64, Σw). Scalar fallback = max single-label frequency.
Components
Weighted adds floor sum_weights, n_eff (probability), n_weight_invalid.
Universal floor {n, n_null} plus operator-specific:
| Key | Type | Notes |
|---|---|---|
total_label_observations | int | Sum of popcounts (label selections) |
distinct_labels | int | Distinct labels seen ≥ once |
per_label_count | map[string]int | Label → row count |
- Mergeability:
Partial— bin-by-bin sum, but map allocation expensive; staged at terminal flush - Margin: summable for crosstab
Gotchas
- Survey-friendly default (“respondents per issuer”).
- Used as Crosstab Cell aggregator the result is a map-valued cell payload.
- Per-row a respondent may contribute to multiple bins —
total_label_observationscan exceedn.
See
pulse_examples_search tags=[cardinality-analysis]- Skills:
aggregation-design,crosstab-guide,response-components
op-agg-set-intersection
name: op-agg-set-intersection
description: Bitwise-AND a set field across rows; returns labels for every bit set in every contributing row.
kind: operator
category: AGG
operator: AGG_SET_INTERSECTION
type: reference
applies_to: process, compose, predict
examples_tags: [cardinality-analysis, cohort-analysis]
Use when
For a multi-select field, the options chosen by every row, over all rows or per group.
Questions it answers:
- Which brands did every respondent in this segment recognise?
- Which features does every customer on a plan use?
Use something else:
AGG_SET_UNIONwhen you want every option chosen by anyone.AGG_SET_FREQUENCYwhen you want how many rows chose each option.
Params
None.
Inputs
| Param | Accepted field types |
|---|---|
Field | any set rung — set_u8, set_u16, set_u32, set_u64, set_u128, set_u256 |
Output
Rich []string — resolved dictionary labels. Scalar fallback = popcount of the intersection mask.
Components
Universal floor {n, n_null} plus operator-specific:
| Key | Type | Notes |
|---|---|---|
mask_intersection | []uint64 | Bitwise AND across contributing rows; 4 little-endian words, words[0] = bits 0-63 |
popcount | int | Bits set in mask_intersection |
labels | []string | Resolved dictionary labels |
- Mergeability:
Mergeableper-chunk - Margin reducibility: NOT margin-reducible — crosstab margins recompute from raw rows
Gotchas
- AND across all rows ≠ AND across cells; do NOT pool margins.
- One row with all-zero bits → empty intersection for the group.
See
pulse_examples_search tags=[cardinality-analysis]- Skills:
aggregation-design,cohort-schema-design,response-components
op-agg-set-union
name: op-agg-set-union
description: Bitwise-OR a set field across rows; returns labels for every bit set in any contributing row.
kind: operator
category: AGG
operator: AGG_SET_UNION
type: reference
applies_to: process, compose, predict
examples_tags: [cardinality-analysis, cohort-analysis]
Use when
For a multi-select field, every option chosen by at least one row, over all rows or per group.
Questions it answers:
- Which brands were mentioned by anyone in this segment?
- Which features does at least one customer in each plan use?
Use something else:
AGG_SET_INTERSECTIONwhen you want the options every row chose.AGG_SET_FREQUENCYwhen you want how many rows chose each option.
Params
None.
Inputs
| Param | Accepted field types |
|---|---|
Field | any set rung — set_u8, set_u16, set_u32, set_u64, set_u128, set_u256 |
Non-set fields rejected at construction time.
Output
Rich []string — resolved dictionary labels. Scalar fallback = popcount of the union mask.
Components
Universal floor {n, n_null} plus operator-specific:
| Key | Type | Notes |
|---|---|---|
mask_union | []uint64 | Bitwise OR across contributing rows; 4 little-endian words, words[0] = bits 0-63 |
popcount | int | Bits set in mask_union |
labels | []string | Resolved dictionary labels |
- Mergeability:
Mergeable; margin-summable for crosstab - Streaming: per-chunk; OR is associative
Gotchas
- Empty mask is a valid “no selection” — distinct from null.
- Union mask only honors bits seen in non-null rows.
See
pulse_examples_search tags=[cardinality-analysis]- Skills:
aggregation-design,cohort-schema-design,response-components
op-agg-skewness
name: op-agg-skewness
description: Population skewness g1 (m3 / m2^1.5, dividing by n) via online moments.
kind: operator
category: AGG
operator: AGG_SKEWNESS
type: reference
applies_to: process, compose, predict
examples_tags: [distribution-shape, streaming-friendly]
Use when
How lopsided a numeric field is: positive when a long tail runs to high values, negative when it runs to low ones.
Questions it answers:
- Are incomes in each region bunched low with a few very high ones?
- Is the delivery-time distribution symmetric or dragged out by slow orders?
Use something else:
AGG_KURTOSISwhen you want to know whether the tails are heavy rather than lopsided.
Params
Weight-aware ("weight": null opts out): weighted population moments, Σw in place of n. Invalid weights excluded (PULSE_WEIGHT_INVALID_ROWS).
Inputs
| Param | Accepted field types |
|---|---|
Field | numeric (no decimal128): u8/u16/u32/u64, f32/f64, date, datetime, packed_bool, u4 |
Output
Scalar float64 — population skewness g1 = m3 / m2^1.5 (moments divide by n), NOT the adjusted G1 of Excel SKEW / SPSS. Per-group when wired under a grouper.
Reading the output
value: How lopsided the values are around their mean. Pulse computes the population moment coefficient g1: the average cubed z-score, (m3/n) / (m2/n)^1.5, where the components m2 and m3 are SUMS of squared and cubed distances from the mean (not yet divided by n). 0 means the values balance around the mean.- Sign: positive means a longer or heavier tail to the right: a few values sit far above the rest; negative means a longer or heavier tail to the left: a few values sit far below the rest.
Components
Weighted adds floor sum_weights, n_eff (probability), n_weight_invalid.
Universal floor {n, n_null} plus operator-specific:
| Key | Type | Notes |
|---|---|---|
mean | float64 | Running mean |
m2 | float64 | Squared-deviation accumulator |
m3 | float64 | Cubed-deviation accumulator |
skewness | float64 | Derived from m2, m3, n |
- Mergeability:
Mergeable(Chan moments combine) - Streaming: per-chunk online moments
Gotchas
n <= 1or zero variance →0(not NaN): checknbefore reading a 0.- Small-n bias: G1 = g1·√(n(n−1))/(n−2), so |g1| < |G1| on small groups.
decimal128rejected — cast viaATTR_FORMULA.- Sensitive to outliers; pre-filter or use rank-based alternatives.
See
pulse_examples_search tags=[distribution-shape]- Skills:
aggregation-design,response-components
op-agg-stddev
name: op-agg-stddev
description: Population standard deviation via Welford's online algorithm.
kind: operator
category: AGG
operator: AGG_STDDEV
type: reference
applies_to: process, compose, predict
examples_tags: [distribution-shape, streaming-friendly]
Use when
Typical distance of a numeric field’s values from their average, in the field’s own units, over all rows or per group.
Questions it answers:
- How much do delivery times vary around the average in each region?
- Which product line’s order values vary the most in absolute terms (divide by each line’s average to compare relative consistency)?
Params
Weight: honours a resolved row weight (weight on the request or slot, or Options.DefaultWeight; "weight": null opts out) — weighted it is √(m2_w / Σw). Invalid weights (null, negative, NaN/Inf, fractional under frequency) are excluded and warned (PULSE_WEIGHT_INVALID_ROWS); zero contributes nothing.
Inputs
| Param | Accepted field types |
|---|---|
Field | numeric: u8/u16/u32/u64, f32/f64, decimal128, date, datetime, packed_bool, u4 |
Output
Scalar float64 — population stddev (n-denominator); decimal128 input yields a decimal-scaled result. Per-group when wired under a grouper.
Reading the output
value: The typical distance of a value from the group’s mean, in the field’s own units. 0 means every value is the same; for a roughly bell-shaped field about two thirds of the values sit within one standard deviation of the mean.- Caveat: It is the spread of the rows, not the precision of the mean: for how precisely the average is known, read AGG_CI_LOWER / AGG_CI_UPPER.
- Caveat: Each distance is squared before averaging, so a few extreme values inflate it; for a skewed field a percentile range describes the spread better.
Components
Weighted slots add floor keys sum_weights (Σw), n_eff (Kish; probability only) and n_weight_invalid; absent ⇒ unweighted. n/n_null stay raw counts.
Universal floor {n, n_null} plus operator-specific:
| Key | Type | Notes |
|---|---|---|
mean | float64 | Welford running mean |
m2 | float64 | Sum of squared deviations |
variance | float64 | m2 / n |
stddev | float64 | √variance |
- Mergeability:
Mergeable(Chan parallel-merge) - Streaming: per-chunk; merge via Chan combine
Gotchas
- Population stddev (
ndenominator), not sample (n-1). decimal128is supported, but not by Welford: a decimal two-pass (mean, then Σ(x−μ)², then decimalSqrt). An overflowing intermediate drops the WHOLE aggregate to an f64 pass and warnsPULSE_DECIMAL_PRECISION_LOSS. The decimal claim therefore rests partly on an f64 fallback.- Single-row group → 0.
decimal128under any weight in force (default included) →PULSE_WEIGHT_UNSUPPORTED;"weight": nullopts out.
See
pulse_examples_search tags=[distribution-shape]- Skills:
aggregation-design,response-components
op-agg-sum
name: op-agg-sum
description: Sum the numeric values of a field across the input set.
kind: operator
category: AGG
operator: AGG_SUM
type: reference
applies_to: process, compose, predict
examples_tags: [financial, streaming-friendly]
Use when
Total of a numeric field, over all rows or per group.
Questions it answers:
- What is total revenue by region?
- How many units did each product line sell in total?
Use something else:
AGG_AVERAGEwhen you want the typical value per row rather than the total.AGG_WEIGHTED_MEANwhen rows carry weights and you want a weighted average.WIN_RUNNING_SUMwhen you want the total so far, row by row.
Params
Weight: honours a resolved row weight (weight on the request or slot, or Options.DefaultWeight; "weight": null opts out) — weighted it is Σw·x. Invalid weights (null, negative, NaN/Inf, fractional under frequency) are excluded and warned (PULSE_WEIGHT_INVALID_ROWS); zero contributes nothing.
Inputs
| Param | Accepted field types |
|---|---|
Field | numeric: u8/u16/u32/u64, f32/f64, decimal128, date, datetime, packed_bool, u4 |
Decimal128 sums stay in decimal (banker-rounded precision propagation); all other numerics emit float64.
Output
Scalar float64 (decimal128 preserved when input is decimal). One row per group when wired under a grouper.
Components
Weighted slots add floor keys sum_weights (Σw), n_eff (Kish; probability only) and n_weight_invalid; absent ⇒ unweighted. n/n_null stay raw counts.
Universal floor {n, n_null} plus operator-specific:
| Key | Type | Notes |
|---|---|---|
sum | float64 | Running sum of non-null values |
- Mergeability:
Mergeable - Streaming: per-chunk; orchestrator sums
Gotchas
- Smart default for numeric fields when
Typeomitted. - Set-typed fields (
set_*) NOT supported. - Overflow on huge u64 sums silently promotes through float64.
decimal128under any weight in force (default included) →PULSE_WEIGHT_UNSUPPORTED;"weight": nullopts out.
See
pulse_examples_search tags=[financial]- Skills:
aggregation-design,response-components
op-agg-variance
name: op-agg-variance
description: Population variance via Welford's online algorithm.
kind: operator
category: AGG
operator: AGG_VARIANCE
type: reference
applies_to: process, compose, predict
examples_tags: [distribution-shape, streaming-friendly]
Use when
Spread of a numeric field as the average squared distance from its mean, in squared units, over all rows or per group.
Questions it answers:
- How much does the order value vary within each channel, in squared units?
- Which sites show the most variable readings?
Use something else:
AGG_STDDEVwhen you want the spread in the field’s own units, which is easier to read.
Params
Weight: honours a resolved row weight (weight on the request or slot, or Options.DefaultWeight; "weight": null opts out) — weighted it is the population m2_w / Σw. Invalid weights (null, negative, NaN/Inf, fractional under frequency) are excluded and warned (PULSE_WEIGHT_INVALID_ROWS); zero contributes nothing.
Inputs
| Param | Accepted field types |
|---|---|
Field | numeric: u8/u16/u32/u64, f32/f64, decimal128, date, datetime, packed_bool, u4 |
Output
Scalar float64 — population variance (n-denominator); decimal128 input yields a decimal-scaled result (scale doubled). Per-group when wired under a grouper.
Reading the output
value: The average squared distance of a value from the group’s mean, in the field’s units squared (dollars squared, minutes squared). Its square root is AGG_STDDEV, which is in the field’s own units and easier to read.- Caveat: Squared units make the size hard to judge directly. For pooling or planning a study’s size use AGG_WELFORD’s variance (dividing by n - 1), which those formulas expect.
- Caveat: Extreme values weigh heavily, since each distance is squared.
Components
Weighted slots add floor keys sum_weights (Σw), n_eff (Kish; probability only) and n_weight_invalid; absent ⇒ unweighted. n/n_null stay raw counts.
Universal floor {n, n_null} plus operator-specific:
| Key | Type | Notes |
|---|---|---|
mean | float64 | Welford running mean |
m2 | float64 | Sum of squared deviations |
variance | float64 | m2 / n |
- Mergeability:
Mergeable(Chan parallel-merge) - Streaming: per-chunk Welford
Gotchas
- Population variance (
n), not sample (n-1). decimal128is supported, but not by Welford: a decimal two-pass (mean, then Σ(x−μ)²). An overflowing intermediate drops the WHOLE aggregate to an f64 pass and warnsPULSE_DECIMAL_PRECISION_LOSS. The decimal claim therefore rests partly on an f64 fallback.- Single-row group → 0.
decimal128under any weight in force (default included) →PULSE_WEIGHT_UNSUPPORTED;"weight": nullopts out.
See
pulse_examples_search tags=[distribution-shape]- Skills:
aggregation-design,response-components
op-agg-weighted-mean
name: op-agg-weighted-mean
description: Weighted arithmetic mean — sum(field * weight) / sum(weight).
kind: operator
category: AGG
operator: AGG_WEIGHTED_MEAN
type: reference
applies_to: process, compose, predict
examples_tags: [streaming-friendly, comparison]
Use when
Average of a numeric field where each row counts in proportion to a weight field, such as a survey weight.
Questions it answers:
- What is the weighted average satisfaction per region?
- What is the average price per unit when each order counts by its quantity?
Use something else:
AGG_AVERAGEwhen every row should count equally.AGG_RATIOwhen you want one total divided by another.
Params
| Name | Type | Default | Description |
|---|---|---|---|
weight_field | string | (none) | Per-row weight field; sugar for a slot weight of kind probability |
The weighted mean of the core family: same figure and engine, own type name and components. The weight is weight_field, else the slot / request weight, else Options.DefaultWeight. A weight_field that differs from the slot weight (or a "weight": null), or no weight resolving at all, is PROCESSING_CONFIG. Invalid weights (null, negative, NaN/Inf) are excluded and warned (PULSE_WEIGHT_INVALID_ROWS).
Inputs
| Param | Accepted field types |
|---|---|
Field | numeric except decimal128 (incl. date, datetime, packed_bool, u4) |
weight_field | numeric, same family |
Output
Scalar float64 — exact Σ field·w / Σw (a few ULP from releases that used the running mean).
Reading the output
value: The average of the field with each row counted in proportion to its weight: sum(value * weight) / sum(weight), in the field’s own units. With equal weights it equals AGG_AVERAGE.- Caveat: Rows missing the value or the weight, or with weight 0, are left out of the average yet still count in components n; read sum_weights for the base.
- Caveat: It reads 0 when no row has a usable weight; check components sum_weights before trusting a 0.
Components
Floor {n, n_null} plus n_weight_invalid (rows excluded for an invalid weight); operator keys (all float64; empty cell all 0):
| Key | Notes |
|---|---|
sum_weighted | Σ field·w |
sum_weights | Σw |
weighted_mean | Σ field·w / Σw |
m2_weighted | Σw(x − mean)² |
sum_weights_sq | Σw² |
weighted_variance | m2/(Σw−1); 0 if Σw ≤ 1 |
n_eff | Kish (Σw)²/Σw² |
- Mergeability:
Mergeable(weighted Chan-Welford) - Streaming: per-chunk
Gotchas
- Invalid/zero-weight rows skip the mean but STILL count in floor
n(n/n_nulltrackField); size fromsum_weights/n_eff. - Unknown
weight_field→SERVICE_VALIDATION(predict + runtime). decimal128rejected.
See
pulse_examples_search tags=[streaming-friendly]- Skills:
aggregation-design,response-components,op-overlay-pairwise-weighted-two-means-z
op-agg-welford
name: op-agg-welford
description: Streaming Welford-Pébaÿ moment triple — running mean, sample variance (n-1), and observed count.
kind: operator
category: AGG
operator: AGG_WELFORD
type: reference
applies_to: process, compose, predict
examples_tags: [welford-triple, distribution-shape, buffered-pipeline]
Use when
Mean, sample variance and row count of a numeric field in one result: what a comparison of group means needs.
Questions it answers:
- What are the mean, variance and size of each cell, so cells can be tested against each other?
- How do the average and spread of scores compare across segments?
Use something else:
AGG_AVERAGEwhen you want only the average.TEST_WELCHwhen you want the comparison itself on raw rows.
Params
Weight: honours a resolved row weight (weight on the request or slot, or Options.DefaultWeight; "weight": null opts out) — weighted the variance is the sample m2_w / (Σw − 1); N stays the contributing row count. Invalid weights (null, negative, NaN/Inf, fractional under frequency) are excluded and warned (PULSE_WEIGHT_INVALID_ROWS); zero contributes nothing.
Inputs
| Param | Accepted field types |
|---|---|
Field | strict scalar numeric: u8/u16/u32/u64, f32/f64 |
decimal128, date, and bit-packed types rejected.
Output
Scalar float64 — running mean (NaN no rows; null in JSON). Rich: WelfordTriple{Mean, Variance, N} via RichAggregator.
Reading the output
value.*: Three columns per group: mean (the average), variance (the SAMPLE variance, dividing by n - 1, in the field’s units squared) and n (the rows with a value). Together they are what a t or z comparison of group means needs; the components add stddev, the square root of variance.- Caveat: variance is 0 when n is below 2: no spread could be estimated, which is not the same as values that agree.
- Caveat: variance here divides by n - 1, so on the same rows it is larger than AGG_VARIANCE (which divides by n).
Components
Weighted slots add floor keys sum_weights (Σw), n_eff (Kish; probability only) and n_weight_invalid; absent ⇒ unweighted. n/n_null stay raw counts.
Universal floor {n, n_null} plus operator-specific:
| Key | Type | Notes |
|---|---|---|
mean | float64 | Welford running mean |
m2 | float64 | Sum of squared deviations |
variance | float64 | Unbiased sample variance (m2/(n-1)) |
stddev | float64 | Sample standard deviation |
- Mergeability:
Mergeable(Chan-Welford combine); margin recompute - Streaming: per-chunk; rich payload exposed at terminal flush
Gotchas
- Sample variance (
n-1), not population (n). - The rich triple is what cell-level t / z overlays read, via
Components.Crosstab.CellComponents. - Margin reducibility = recompute, not pool by addition.
See
pulse_examples_search tags=[welford-triple]- Skills:
aggregation-design,overlay-system,response-components
op-agg-zscore
name: op-agg-zscore
description: Standardized z-score aggregate — mean-centered, stddev-scaled summary.
kind: operator
category: AGG
operator: AGG_ZSCORE
type: reference
applies_to: process, compose, predict
examples_tags: [distribution-shape, comparison]
Use when
Population mean and standard deviation of a numeric field, for standardizing it; the value itself is always 0.
Questions it answers:
- What centre and scale standardize this field in each group?
- How many standard deviations does the group’s last row (in row order) sit from the group’s average, that row included?
Use something else:
ATTR_ZSCOREwhen you want a z-score on every row.
Params
None.
Inputs
| Param | Accepted field types |
|---|---|
Field | numeric (no decimal128): u8/u16/u32/u64, f32/f64, date, datetime, packed_bool, u4 |
Output
Scalar float64 — the MEAN of the group’s row z-scores, 0 by construction. The usable figures are the components; zscore there is the LAST row’s (target_value - pop_mean) / pop_stddev. Per-group when wired under a grouper.
Reading the output
value: The average of every row’s z-score within the group, which is 0 by construction (up to rounding): the value carries no information about the data. The usable numbers are in the components: pop_mean and pop_stddev (the centre and the population spread, dividing by n), target_value (the LAST row’s value) and zscore (that row’s z-score).- Caveat: For a z-score on every row use ATTR_ZSCORE; for each group’s value against all groups use OVERLAY_ZSCORE_VS_TOTAL.
Components
Universal floor {n, n_null} plus operator-specific:
| Key | Type | Notes |
|---|---|---|
pop_mean | float64 | Population mean (center) |
pop_stddev | float64 | Population stddev (scale) |
target_value | float64 | Value standardized |
zscore | float64 | Resolved score |
- Mergeability:
Mergeable - Streaming: NOT streamable — finalize needs full deviation sum
Gotchas
- Buffered path only.
- Zero stddev or empty group →
0(not NaN). decimal128rejected.
See
pulse_examples_search tags=[distribution-shape]- Skills:
aggregation-design,attribute-composition,response-components
op-attr-code-in
name: op-attr-code-in
description: Per-row 0/1 — whether a field's value is one of a list of codes; its mean is the share of the whole base.
kind: operator
category: ATTR
operator: ATTR_CODE_IN
type: reference
applies_to: process, compose, predict
examples_tags: [feature-engineering, proportion-analysis, streaming-friendly]
Use when
Adds a 1 / 0 column saying whether each row’s code is one of a listed set, keeping every row in the base.
Questions it answers:
- What share of all respondents gave a top-two-box answer (codes 4 or 5)?
- Which orders carry one of the priority status codes, as a column to average by region?
Use something else:
FILTER_INCLUDEwhen you want to keep only the rows with those codes.
Params
| Name | Type | Default | Description |
|---|---|---|---|
codes | array | (required) | Non-empty; strings or integers (4 ≡ "4"); a fraction → PROCESSING_CONFIG |
Inputs
| Param | Accepted field types |
|---|---|
Field | categorical_u8/u16/u32 (by LABEL, not id); u4/u8/u16/u32/u64 (by value) |
Label (output name) | required; target → PROCESSING_CONFIG |
Output
packed_bool per row: 1 if the value is a code, else 0. Null → 0.
Gotchas
- The label’s mean (
AGG_WEIGHTED_MEAN) is the share of the WHOLE base (top-box %).FILTER_INCLUDEon the codes shrinks the base: every cell reads 100%. - Nulls count as “no”. To drop them, put
FILTER_NULL(values: ["is_not_null"]) first. - Integer field: a code not a whole number in range (
u40..15,u80..255…) →PROCESSING_CONFIG. - A categorical code absent from the dictionary matches nothing; predict warns
PULSE_ATTR_CODE_NOT_IN_DICTIONARY(error under--strict). Fieldmust be a cohort schema field: a column derived by an earlier attribute or feature →PROCESSING_CONFIG“unknown field”.- Row-local: streams; fuses on crosstabs.
See
pulse_examples_search tags=[proportion-analysis, feature-engineering]- Skills:
attribute-composition,op-attr-set-has,weighting
op-attr-date-part
name: op-attr-date-part
description: Extract a calendar component (year, month, day, year_month, ..., hour) from a date or datetime field.
kind: operator
category: ATTR
operator: ATTR_DATE_PART
type: reference
applies_to: process, compose, predict
examples_tags: [time-series, feature-engineering, streaming-friendly]
Attributes emit row-level scalars; they do not produce Response.Components.
Use when
Adds a calendar part of a date or timestamp to every row, such as the year, the month, a year-month like 202403 or the hour.
Questions it answers:
- Which month of the year does each order fall in, so seasons can be compared across years?
- What year-month label should each response carry for a monthly breakdown?
Use something else:
GROUP_DATEwhen you only want rows grouped by day, week, month or year.
Params
part— enum, required:day,hour,month,month_day,year,year_month,year_month_day.
Inputs
Field — date or datetime (a schema field). Label — required, new column name.
Output
One f64 integer per record: year = YYYY, month = 1..12, day = 1..31, year_month = YYYYMM, year_month_day = YYYYMMDD, month_day = MMDD, hour = 0..23. Null source → 0 (not null).
Gotchas
- Row-local one-pass — streams cleanly.
- Useful as a grouping key (e.g.
month_dayfor seasonality) or as aFEATsubstitute when post-filter visibility is needed. - Unknown
part, non-date-family field,houron adate→PROCESSING_CONFIG. - Zone-capable: a
datetimereads the LOCAL clock (slottz→time_zone→ default → UTC). Adateignores zones: explicittzrefused, inherited not applied.
See
pulse_examples_search tags=[time-series]- Skills:
attribute-composition,op-attr-formula,request-envelope(Time zones)
op-attr-factor-score
name: op-attr-factor-score
description: Per-row score on one common factor of a vector of numeric members, from an inline fit with the factor-analysis matrix operator's knobs, loadings, order and sign; regression or Bartlett scores; fit_on fits on a subset and still scores every row.
kind: operator
category: ATTR
operator: ATTR_FACTOR_SCORE
type: reference
applies_to: process, compose, predict
examples_tags: [multivariate, feature-engineering, streaming-friendly]
Row-level scalars; no Response.Components.
Use when
Adds to every row its score on one factor behind several related numeric fields: an estimate of the hidden trait they measure together.
Questions it answers:
- What is each respondent’s score on the satisfaction factor behind these rating questions?
- Can I score every row on the two constructs a factor analysis found, to compare segments on them?
Params
factor— required, 1-based.method—regression(default) orbartlett.missing—listwiseonly.factors,extraction,rotation,kappa,gamma— the factor fit’s knobs (MAT_FACTOR’s); norepair.fit_on—"filtered"(default) or a filterer array picking the FIT rows; every filtered row is scored.weight— fit only; weight-0 rows are scored, not fitted.
Slot: exactly one of vector (a vectors entry) / fields; no field.
Inputs
Members: u4…u64, f32/f64 source columns.
Output
One f64 per record: zᵀw on fit-standardized members (psych factor.scores); mean 0 over the fit rows; null member → null.
Reading the output
value: The row’s estimated score on the chosen factor, on the scale of the fields standardized over the fitted rows: 0 is an average row, positive is toward the end where the fields loading positively on the factor are high.- Caveat: The direction is MAT_FACTOR’s convention (each factor’s loadings sum positive), not a meaning: check its loadings on the same fields before calling a high score good or bad.
- Caveat: After an oblique rotation (promax, oblimin) the factors correlate, so scores on two factors move together.
Gotchas
- Fit is cohort-wide, never per group; groupers or features force buffered. Two-pass, never merged.
- All null + warning:
PULSE_MATRIX_SINGULAR,_INSUFFICIENT_N,_NOT_IDENTIFIED,_HEYWOOD(Bartlett). Scored + warning: a regression Heywood case,PULSE_MATRIX_NOT_CONVERGED.
See
pulse_examples_search tags=[multivariate, feature-engineering]- Skills:
multivariate-outputs(causes),op-mat-factor,op-attr-pc-score
op-attr-formula
name: op-attr-formula
description: Per-row expression evaluation against the record's fields via expr-lang.
kind: operator
category: ATTR
operator: ATTR_FORMULA
type: reference
applies_to: process, compose, predict
examples_tags: [feature-engineering, streaming-friendly]
Row-level scalars; no Response.Components.
Use when
Adds a number to every row computed from the row’s own fields with an expression, such as price * qty.
Questions it answers:
- What is each order’s line total from its price and quantity?
- Which rows meet a combined rule, as a 0/1 column for counting?
Use something else:
FILTER_EXPRESSIONwhen you want to keep or drop rows by a rule rather than add a column.FEAT_LOGwhen you want the logarithm of a field.
Params
| Name | Type | Default | Description |
|---|---|---|---|
Expression | string | (required) | expr-lang/expr v1.17.x string evaluated per row. |
Inputs
| Param | Accepted field types |
|---|---|
Field | any referenced field type |
Label | required — new column name |
Output
One float64 per record (bools → 1.0 / 0.0); a nil result → null (n_null). Null binds nil: guard x ?? 0 / x == nil ? a : b; unguarded arithmetic on nil → PROCESSING_RUNTIME.
Gotchas
- Reads source fields and EARLIER attribute labels (declared order); stage across requests via Compose / ProcessChain.
total(expr)(this operator only; lowercase, one argument, no nesting): cohort-wide sum of a row-levelexprover post-filter rows — the same figure on every row, also when grouped. Unweighted regardless ofRequest.Weight; null-argument rows skipped. Empty total →null; zero total as divisor →null. Pro-rata:x * 1000 / total(x). Subset total:x * (1 - 1000 / total(week_index >= 52 ? x : 0)).- A
totalformula is two-pass (like the z-score attribute): streams alone, buffers beside groups / features; shard archive, parallel decode andShardWorkersrun sequentially (whole-cohort total);ProcessChainrefuses it (PULSE_CHAIN_NOT_MERGEABLE). Predict addsPULSE_ADVISORY_EXTRA_PASSper formula (added_scans). - Misuse:
total()in an expression filter or overlay formula (Compose host too) →PULSE_FORMULA_TOTAL_SITE; nested →_TOTAL_NESTED; arity ≠ 1 →_TOTAL_ARITY. An embedderExprFunctionnamedtotal→PULSE_EXTENSION_NAME_RESERVED. - Compiled once; bad syntax / types →
PROCESSING_RUNTIMEat build. - Categorical → STRING (
==/in);set_*→[]string("a" in tags,has_any(tags, "a", "b")). %on a field: floatmath.Mod(score % 1, dividend’s sign);% 0→ NaN, as/ 0→ ±Inf.
See
pulse_examples_search tags=[feature-engineering]- Skills:
attribute-composition,expression-language(maths / vector functions,ExprFunctions,lookup(...))
op-attr-mahalanobis
name: op-attr-mahalanobis
description: Per-row Mahalanobis distance D² (or its chi-square p) across a vector of numeric members, from a cohort-wide fit; fit_on fits on a subset and still scores every row.
kind: operator
category: ATTR
operator: ATTR_MAHALANOBIS
type: reference
applies_to: process, compose, predict
examples_tags: [outlier-detection, multivariate, streaming-friendly]
Attributes emit row-level scalars; they do not produce Response.Components.
Use when
Adds to every row how unusual its mix of several numeric fields is, allowing for how those fields usually vary together.
Questions it answers:
- Which respondents gave an unusual pattern of answers across the whole battery, even if no single answer is extreme?
- Which transactions have an odd combination of amount, frequency and basket size?
Use something else:
ATTR_ZSCOREwhen only one field matters.
Params
output—d2(default) orp(χ² upper tail on p df).missing—listwiseonly.fit_on—"filtered"(default) or an inline filterer array picking the FIT rows; every filtered row is scored.weight— both kinds; fit only (N* = Σw or n_eff, cov M2*/(N* − 1)); a zero-weight row is scored, not fitted.
Slot: exactly one of vector (a vectors entry) / fields (inline, globs ok); no field. Default label ATTR_MAHALANOBIS_<vector> or _<first fields entry>.
Inputs
Members: u4…u64, f32/f64 (packed_bool via coerce: binary); source columns only.
Output
One f64 per record: D² = (x − μ)ᵀS⁻¹(x − μ) ≥ 0, or its p. A null member → null.
Reading the output
value: With output d2 (the default): the row’s squared Mahalanobis distance D² = (x - mean)’ S^-1 (x - mean) across the p fields, the distance from the fitted average row in units of the fields’ own spread and correlation; 0 is exactly average and it is never negative. With output p: the chance of a D² at least this large under a chi-square curve on p degrees of freedom, so a small value marks an unusual row.
Gotchas
- Fit is cohort-wide, never per group; groupers or features force buffered.
- Singular fit or N* ≤ p: all scores null +
PULSE_MATRIX_SINGULAR/PULSE_MATRIX_INSUFFICIENT_Nwarning. - Two-pass, never merged.
See
pulse_examples_search tags=[outlier-detection, multivariate]- Skills:
attribute-composition,multivariate-design,op-attr-zscore
op-attr-normalized
name: op-attr-normalized
description: Per-row min-max normalized column — (value − min) / (max − min) ∈ [0, 1].
kind: operator
category: ATTR
operator: ATTR_NORMALIZED
type: reference
applies_to: process, compose, predict
examples_tags: [feature-engineering, buffered-pipeline]
Attributes emit row-level scalars; they do not produce Response.Components.
Use when
Rescales a numeric field to 0 to 1 on every row: 0 is the smallest value, 1 the largest.
Questions it answers:
- How can fields on very different scales be put on one 0-to-1 scale before combining them?
- Where does each row’s value sit between the lowest and highest seen?
Use something else:
ATTR_PERCENTILEwhen the field has extreme values that would squeeze the rest of the range.
Params
None.
Inputs
| Param | Accepted field types |
|---|---|
Field | numeric (no decimal128): u8/u16/u32/u64, f32/f64, date, packed_bool, u4 |
Label | required — new column name |
Output
One float64 per record in [0, 1]. Null source → 0 (not null).
Reading the output
value: Where the row’s value sits between the smallest and largest value, on a 0-to-1 scale: (value - min) / (max - min). 0 is the minimum, 1 the maximum and 0.5 halfway between them, which is not the median.- Caveat: One extreme value sets the minimum or maximum and squeezes every other row into a narrow part of the scale; ATTR_PERCENTILE is not affected that way.
- Caveat: A row with a missing value reads 0, the same as the minimum, and every row reads 0 when all values are equal (max = min).
Gotchas
- Not weightable (a min-max rescale has no weighted meaning):
Options.DefaultWeightis skipped; an explicit slot or request weight isPROCESSING_CONFIG. - Two-pass: pre-pass tracks min/max across filter-passing rows; pass 2 emits per row.
- Constant field (
max == min) →0per row. - Outlier-sensitive — one extreme value compresses the rest of the range.
decimal128rejected.- Frequently used as a feature input for a downstream formula or external ML — but no in-slot chaining; stage via Compose / ProcessChain.
set_*rejected at build time withPROCESSING_CONFIG— a bitmask has no value to standardise.
See
pulse_examples_search tags=[feature-engineering]- Skills:
attribute-composition,op-attr-zscore,op-attr-percentile
op-attr-pc-score
name: op-attr-pc-score
description: Per-row standardized score on one principal component of a vector of numeric members, with the PCA matrix operator's sign and order and optional varimax; fit_on fits on a subset and still scores every row.
kind: operator
category: ATTR
operator: ATTR_PC_SCORE
type: reference
applies_to: process, compose, predict
examples_tags: [multivariate, feature-engineering, streaming-friendly]
Row-level scalars; no Response.Components.
Use when
Adds to every row its score on one principal component of several related numeric fields: one number standing in for what they share.
Questions it answers:
- What is each respondent’s score on the main dimension behind these rating questions?
- Can I replace these five correlated measures with one summary column for a later model?
Use something else:
ATTR_MAHALANOBISwhen you want how unusual a row is across the fields.
Params
component— required, 1-based.on—correlation(default) orcovariance.missing—listwiseonly.rotation—none(default) orvarimaxwithcomponents= k rotated within (component ≤ k ≤ p).fit_on—"filtered"(default) or a filterer array picking the FIT rows; every filtered row is scored.weight— both kinds; fit only. A zero-weight row is scored, not fitted.
Slot: exactly one of vector (a vectors entry) / fields; no field. Default label ATTR_PC_SCORE_<vector or first field>_PC<component>.
Inputs
Members: u4…u64, f32/f64; source columns only.
Output
One f64 per record: mean 0, variance 1 over the fit rows; corr(score, member) = the MAT_PCA loading. Null member → null.
Reading the output
value: The row’s score on the chosen principal component of the fields, standardized so the fitted rows average 0 with a standard deviation of 1: 0 is an average row, +1 is one standard deviation toward the end where the fields with positive loadings are high.- Caveat: The sign is a convention (MAT_PCA’s: each component’s largest loading is positive), not a meaning: check MAT_PCA’s loadings on the same fields before calling a high score good or bad.
Gotchas
- Fit is cohort-wide, never per group; groupers or features force buffered.
- Singular fit or N* ≤ p: all null (
PULSE_MATRIX_SINGULAR/_INSUFFICIENT_N). - Two-pass, never merged.
See
pulse_examples_search tags=[multivariate, feature-engineering]- Skills:
multivariate-design,op-mat-pca,op-attr-mahalanobis
op-attr-percentile
name: op-attr-percentile
description: Per-row percentile rank column against the post-filter value set; requires sorting.
kind: operator
category: ATTR
operator: ATTR_PERCENTILE
type: reference
applies_to: process, compose, predict
examples_tags: [cohort-analysis, distribution-shape, buffered-pipeline]
Attributes emit row-level scalars; they do not produce Response.Components.
Use when
Adds to every row its percentile rank, rank / n * 100: the share of rows at or below it when values are untied.
Questions it answers:
- Where does each store’s revenue rank among all stores, as a percentage?
- Which respondents are in the top tenth for spend?
Use something else:
AGG_PERCENTILEwhen you want the value at a chosen percentile, such as the 90th.GROUP_QUANTILEwhen you want rows split into equal-sized bands.
Params
None.
Inputs
| Param | Accepted field types |
|---|---|
Field | numeric (no decimal128): u8/u16/u32/u64, f32/f64, date, packed_bool, u4 |
Label | required — new column name |
Output
One float64 per record in (0, 100] — rank / n * 100 within the filter-passing value set (smallest = 100/n). Null source → 0 (not null).
Reading the output
value: The row’s position among the sorted values, as a percentage: rank / n * 100, where the smallest value has rank 1 and n counts the rows with a value. The largest value reads 100 and the smallest 100 / n, so for a value no other row shares it is the share of rows at or below this one; tied values break that (see below).- Caveat: A row with a missing value reads 0, which no row with a value can: treat 0 as missing.
- Caveat: Other tools define it differently (smallest at 0, or ties at their midpoint), so the same row can read a few points apart elsewhere.
Gotchas
- Never weighted (a weighted rank cannot reproduce the distinct unweighted ranks): any row weight in force on the slot (request, slot or
Options.DefaultWeight) isPULSE_WEIGHT_UNSUPPORTEDwithdetails.reason; set"weight": nullon the slot to run it unweighted. - NOT streamable — pre-pass sorts the full filter-passing field; cohort-sized memory peak. Predict surfaces this under
streamable_reasons. - Ties do NOT share a percentile: each tied row gets its own rank, in arbitrary order.
decimal128rejected.- Under shard archives the sort runs per-shard along the
Mergeablepath; pass 2 emits global ranks once shards merge. set_*rejected at build time withPROCESSING_CONFIG— a bitmask has no value to standardise.
See
pulse_examples_search tags=[distribution-shape]- Skills:
attribute-composition,op-attr-normalized,op-agg-percentile
op-attr-reg-fitted
name: op-attr-reg-fitted
description: Per-row fitted value ŷᵢ = Xᵢ · β + β₀ from an OLS refit during the attribute prepass.
kind: operator
category: ATTR
operator: ATTR_REG_FITTED
type: reference
applies_to: process, compose, predict
examples_tags: [regression, ols, buffered-pipeline]
Attributes emit row-level scalars; they do not produce Response.Components.
Use when
Adds to every row the value a straight-line model predicts for its target from its predictors.
Questions it answers:
- What order value would we expect for each customer given their visits and tenure?
- Which stores sell above or below what their size and footfall predict?
Use something else:
REG_OLSwhen you want the coefficients, R-squared and p-values of the model.REG_GLMwhen the target is yes/no or a count.
Params
| Name | Type | Description |
|---|---|---|
Target | string | Dependent field (required). |
Predictors | []string | Independent fields (required). |
Penalty | enum | "", l1, l2, elasticnet. |
Alpha | float | Regularization strength. |
L1Ratio | float | Elasticnet mix. |
weight | slot weight | null opts out; REG_OLS’s kinds: WLS refit; residual raw y − ŷ. |
Inputs
| Param | Accepted field types |
|---|---|
Target / Predictors | numeric (no decimal128) |
Label | required — new column name |
Output
One float64 per record — the model’s prediction ŷᵢ. Null (never 0) when any predictor is null, so a downstream aggregation counts the row in n_null; a null target alone still gets ŷᵢ.
Reading the output
value: The target value the fitted straight-line model predicts for the row from its predictors: intercept + sum(coefficient * predictor), in the target’s units. The model is fitted by least squares to the rows that passed the filters and have the target and every predictor.- Caveat: The model describes association in these rows: a fitted value is what rows with these predictor values look like here, not what would happen to a row if a predictor were changed; a causal reading needs a design that supports it.
Gotchas
- Two-pass: prepass refits independently per ATTR_REG_* slot (Option A — no fit sharing).
Mergeableper-shard; predict reports streamability.- Penalized fits (
l1/l2/elasticnet) reuse the same machinery; mis-tunedAlpha/L1Ratioshrinks coefficients toward zero.
See
pulse_examples_search tags=[regression]- Skills:
regression-modeling,attribute-composition,op-attr-reg-residual
op-attr-reg-leverage
name: op-attr-reg-leverage
description: Per-row hat-matrix diagonal hᵢᵢ from an unpenalized OLS refit — leverage diagnostic.
kind: operator
category: ATTR
operator: ATTR_REG_LEVERAGE
type: reference
applies_to: process, compose, predict
examples_tags: [regression, ols, outlier-detection, buffered-pipeline]
Attributes emit row-level scalars; they do not produce Response.Components.
Use when
Adds to every row its leverage: how unusual its predictor values are, and so how hard it can pull a straight-line fit.
Questions it answers:
- Which rows have predictor values so far from the rest that they could steer the model?
- Which customers have predictor values extreme enough that they could pull the fitted line (read their residuals to see whether they do)?
Use something else:
REG_OLSwhen you want the model itself.
Params
| Name | Type | Default | Description |
|---|---|---|---|
Target | string | (required) | Dependent variable field. |
Predictors | []string | (required) | Independent variable fields. |
weight | slot weight | inherited | null opts out; REG_OLS kinds. Diag of W½X(XᵀWX)⁻¹XᵀW½ (R hatvalues); excluded row: 0. |
Inputs
| Param | Accepted field types |
|---|---|
Target / Predictors | numeric (no decimal128) |
Label | required — new column name |
Output
One float64 per record in [0, 1] — hᵢᵢ = 1/n + (xᵢ − μ_x)ᵀ · M2_xx⁻¹ · (xᵢ − μ_x). Sum across the fit set equals p + 1 (predictors + intercept). Null (never 0) when any predictor is null; a row the weighted refit excludes stays 0.
Reading the output
value: How far the row’s predictor values sit from the other rows’ (from the predictors’ means, scaled by their spread and correlation), and so how hard the row can pull the fitted line toward itself: the hat-matrix diagonal 1/n + (x - mean)’ M^-1 (x - mean), where M is the predictors’ centred sum-of-squares-and-cross-products matrix. Over the n rows in the fit it runs from 1/n to 1 and averages p / n, where p counts the coefficients including the intercept.- Caveat: Unpenalized least squares only; a request with a penalty is refused.
Gotchas
- Unpenalized OLS only — any non-empty
PenaltyraisesPROCESSING_CONFIG. - High leverage flags outliers in PREDICTOR space (vs residuals, which flag the response). Rule of thumb: hᵢᵢ > 2(p+1)/n.
- Two-pass — pre-pass fits OLS, pass 2 emits per row.
See
pulse_examples_search tags=[regression, outlier-detection]- Skills:
regression-modeling,attribute-composition,op-attr-reg-residual
op-attr-reg-predict
name: op-attr-reg-predict
description: Per-row score from a bound model (a compose `fits` entry or a loaded `models` file) — no refit; whole model or a terms subset.
kind: operator
category: ATTR
operator: ATTR_REG_PREDICT
type: reference
applies_to: compose, predict
examples_tags: [regression, ols, compose]
Attributes emit row-level scalars; they do not produce Response.Components.
Use when
Adds to every row the value a model fitted once, elsewhere in the request, predicts for it from the row’s own predictor values.
Questions it answers:
- How close are the model’s predictions on weeks it was not fitted to (a holdout)?
- How much of each week’s sales does the TV term of the fitted model account for?
Use something else:
ATTR_REG_FITTEDwhen the model should be fitted to these same rows inside the slot.
Params
| Name | Type | Description |
|---|---|---|
fit | string | Bound model name (required). |
terms | []string | Score only these terms, (intercept) opt-in; fit order. Omit: whole model. |
scale | enum | GLM only: response (default, inverse link) or link. |
bound | enum | point (default), lower, upper: ci bound × column. Needs terms = one predictor. |
Inputs
| Param | Accepted field types |
|---|---|
| model predictors | numeric slot columns (schema, features, earlier labels) |
label | output name; default ATTR_REG_PREDICT_<fit> |
Output
One f64 per record; null when a column the score reads is null. Training rows equal the fit’s own fitted values bit for bit.
Reading the output
value: The target value the named fitted model predicts for the row from the row’s own predictor values: intercept + sum(coefficient * predictor) at the fit’s full precision, in the target’s units — for a GLM on the response scale (the inverse link applied: a probability, a count) unless scale is link. With terms, only the listed terms are summed: that part of the prediction, a contribution in the target’s units (link units for a GLM).- Caveat: A coefficient’s confidence bound gives the range of that one term, not a prediction interval for a row.
Gotchas
- The fit’s predictor list is authoritative: build the same columns (same features) in the slot. An absent one-hot column is the reference level; scoring never infers a level.
- A coefficient bound is not a prediction interval. A term with no bound scores null.
- Row-local: streams, merges, materializes. Row weight ignored (the fit’s own applied).
- Under selection a dropped feature scores 0.
- Save, then load:
save: "models/m.pulse-model.json"on the fit; a later request declaresmodels: [{name: "m", path: "models/m.pulse-model.json"}]and scoresfit: "m"— bit-equal to the in-compose fit, any cohort with the same predictor columns.
See
pulse_examples_search tags=[compose]- Skills:
compose-fits,regression-modeling,op-attr-reg-fitted,expression-language(coef())
op-attr-reg-residual
name: op-attr-reg-residual
description: Per-row residual yᵢ − ŷᵢ from an OLS refit during the attribute prepass.
kind: operator
category: ATTR
operator: ATTR_REG_RESIDUAL
type: reference
applies_to: process, compose, predict
examples_tags: [regression, ols, outlier-detection, buffered-pipeline]
Attributes emit row-level scalars; they do not produce Response.Components.
Use when
Adds to every row its residual: the actual target value minus the value a straight-line model predicts for it.
Questions it answers:
- Which stores sell far more or less than their size and footfall predict?
- Do the model’s misses grow with the predicted value, or bend in a curve?
Use something else:
REG_OLSwhen you want the model’s summary (coefficients, R-squared).
Params
| Name | Type | Description |
|---|---|---|
Target | string | Dependent field (required). |
Predictors | []string | Independent fields (required). |
Penalty | enum | "", l1, l2, elasticnet. |
Alpha | float | Regularization strength. |
L1Ratio | float | Elasticnet mix. |
weight | slot weight | null opts out; REG_OLS’s kinds: WLS refit; residual raw y − ŷ. |
Inputs
| Param | Accepted field types |
|---|---|
Target / Predictors | numeric (no decimal128) |
Label | required — new column name |
Output
One float64 per record — yᵢ − ŷᵢ; null (never 0) when the target or any predictor is null, so a downstream aggregation counts the row in n_null. With an intercept (always present for OLS) Σ residuals ≈ 0 across the fit set.
Reading the output
value: Actual minus predicted: the row’s target value minus the value the straight-line model predicts for it, in the target’s units. Over the rows used in an unpenalized fit the residuals average 0.- Sign: positive means the row’s target is above what the model predicts from its predictors; negative means the row’s target is below what the model predicts from its predictors.
- Caveat: A residual is what this model leaves unexplained, not a measurement error and not the effect of any one thing left out: another set of predictors gives other residuals.
Gotchas
- Two-pass: runs an independent fit per slot.
- Large residuals flag response-space outliers.
- Penalized residuals bias-shrunk; for diagnostics prefer unpenalized.
See
pulse_examples_search tags=[regression, outlier-detection]- Skills:
regression-modeling,attribute-composition,op-attr-reg-fitted
op-attr-set-has
name: op-attr-set-has
description: Per-row 0/1 — whether the configured label's bit is set on a set field.
kind: operator
category: ATTR
operator: ATTR_SET_HAS
type: reference
applies_to: process, compose, predict
examples_tags: [feature-engineering, streaming-friendly]
Attributes emit row-level scalars; they do not produce Response.Components.
Use when
Adds a 1 / 0 column saying whether each row’s multi-select field includes one named option.
Questions it answers:
- Which respondents ticked ‘price’ among their reasons, as a column to cross with other answers?
- Which tickets carry the ‘urgent’ tag?
Use something else:
FILTER_SET_CONTAINS_ANYwhen you want to keep only rows that include any of several options.
Params
| Name | Type | Default | Description |
|---|---|---|---|
label | string | (required) | Dictionary label whose bit position is checked per row. Resolved against the set field’s inline dictionary at schema time. |
Inputs
| Param | Accepted field types |
|---|---|
Field | any set rung — set_u8, set_u16, set_u32, set_u64, set_u128, set_u256 |
Label (the new column name) | required |
Output
One packed_bool per record — 1 if the named label’s bit is set, 0 otherwise. Null source → 0 (not null).
Gotchas
- Row-local one-pass — streams cleanly.
- Unknown
label(not in the field’s dictionary) →PROCESSING_CONFIG. - Frequently fanned out — one slot per label of interest — to build per-label binary cohorts. Multi-label tests are cheaper as one set filter or one
ATTR_FORMULAusinghas_any/has_all. - Coerces to
f64(1.0 / 0.0) when read by downstream numeric ops.
See
pulse_examples_search tags=[feature-engineering]- Skills:
attribute-composition,cohort-schema-design,op-attr-set-popcount
op-attr-set-popcount
name: op-attr-set-popcount
description: Per-row popcount of a set field — number of selected labels.
kind: operator
category: ATTR
operator: ATTR_SET_POPCOUNT
type: reference
applies_to: process, compose, predict
examples_tags: [feature-engineering, cardinality-analysis, streaming-friendly]
Attributes emit row-level scalars; they do not produce Response.Components.
Use when
Adds to every row the number of options its multi-select field has selected.
Questions it answers:
- How many reasons did each respondent tick?
- Which customers selected three or more interests?
Use something else:
AGG_SET_CARDINALITY_AVGwhen you want the average number of selections per group.ATTR_SET_HASwhen you want whether one particular option was chosen.
Params
None.
Inputs
| Param | Accepted field types |
|---|---|
Field | any set rung — set_u8, set_u16, set_u32, set_u64, set_u128, set_u256 |
Label | required — new column name |
Output
One integer per record, 0..set width inclusive — 0..256 at set_u256, hence emits_type u16, not u8. Empty mask → 0 (valid, distinct from null). Null source → 0 (not null).
Gotchas
- Row-local one-pass — streams cleanly.
- Use as a downstream filterable signal (“respondents who selected ≥ 3 issues”) — pair with
FILTER_GTEafter this attribute, or precompute asFEATif you need it before the filterers slot. - A fully-selected
set_u256gives 256, one past au8’s 255 — size any destination column fromemits_type, not from the rung’s byte width.
See
pulse_examples_search tags=[cardinality-analysis]- Skills:
attribute-composition,cohort-schema-design,op-attr-set-has
op-attr-tscore
name: op-attr-tscore
description: Per-row T-score column — z-score rescaled to mean 50, stddev 10.
kind: operator
category: ATTR
operator: ATTR_TSCORE
type: reference
applies_to: process, compose, predict
examples_tags: [distribution-shape, buffered-pipeline]
Attributes emit row-level scalars; they do not produce Response.Components.
Use when
Adds to every row its T-score: the z-score rescaled so the mean is 50 and one standard deviation is 10.
Questions it answers:
- How does each candidate’s test result compare with the group, on a 50-centred scale?
- Which respondents score more than one standard deviation above average?
Use something else:
ATTR_ZSCOREwhen you want plain standard-deviation units centred on 0.
Params
None. Weight (both kinds, same scores; "weight": null opts out): weighted mean and population sd √(Σw(x − μ)²/Σw); a zero / invalid-weight row adds no mass but is still scored.
Inputs
| Param | Accepted field types |
|---|---|
Field | numeric (no decimal128): u8/u16/u32/u64, f32/f64, date, packed_bool, u4 |
Label | required — new column name |
Output
One float64 per record — 50 + 10 * zscore. Null source → 50 (not null).
Reading the output
value: The z-score rescaled to a mean of 50 and a standard deviation of 10: 50 + 10 * z, with the population standard deviation (dividing by n). 60 is one standard deviation above the mean, 40 one below.- Caveat: Unrelated to Student’s t or a t test: the name comes from educational testing, and the value carries no p-value.
- Caveat: It is not a percentile: 70 does not mean the 70th percentile. Only on a roughly normal field does 60 sit near the 84th percentile.
Gotchas
- Two-pass: a Welford pre-pass computes the mean and sd. Reading-friendly scale for survey / education contexts (mean 50, sd 10, no negatives in the typical range).
- Zero stddev →
50per row. decimal128rejected.- Not a percentile — same shape as the underlying distribution.
set_*rejected at build time withPROCESSING_CONFIG— a bitmask has no value to standardise.
See
pulse_examples_search tags=[distribution-shape]- Skills:
attribute-composition,op-attr-zscore,op-attr-normalized
op-attr-zscore
name: op-attr-zscore
description: Per-row standardized z-score column — (value − mean) / stddev via two-pass Welford.
kind: operator
category: ATTR
operator: ATTR_ZSCORE
type: reference
applies_to: process, compose, predict
examples_tags: [outlier-detection, distribution-shape, buffered-pipeline]
Attributes emit row-level scalars; they do not produce Response.Components.
Use when
Adds to every row its z-score: how many standard deviations the row’s value sits above or below the mean.
Questions it answers:
- Which orders are unusually large compared with all orders?
- How do scores measured on different scales compare once put on one footing?
Use something else:
AGG_ZSCOREwhen you want the centre and spread of each group, not a score per row.
Params
None. Weight (both kinds, same scores; "weight": null opts out): weighted mean and population sd √(Σw(x − μ)²/Σw); a zero / invalid-weight row adds no mass but is still scored.
Inputs
| Param | Accepted field types |
|---|---|
Field | numeric (no decimal128): u8/u16/u32/u64, f32/f64, date, packed_bool, u4 |
Label | required — new column name |
Output
One float64 per record — (value − pop_mean) / pop_stddev. Null source → 0 (not null).
Reading the output
value: How many standard deviations the row’s value sits from the mean: (value - mean) / standard deviation, with the population standard deviation (dividing by n). 0 is exactly average, 1 is one standard deviation above it.- Sign: positive means the row’s value is above the mean; negative means the row’s value is below the mean.
- Caveat: Rules such as ‘about 95% of rows lie within 2 standard deviations’ hold only for a roughly bell-shaped (normal) field; on a skewed field far more rows can sit beyond 2 on the long side.
Gotchas
- Two-pass: pre-pass computes mean/stddev over filter-passing rows, then pass 2 emits per row. Orchestrator handles transparently — no full buffering unless paired with a buffered op downstream.
- Zero stddev (constant field) →
0per row. decimal128rejected.- Two-pass attributes are not mergeable: shard archives and parallel decode take the sequential shard iterator, walking every shard for both passes — global standardization.
set_*rejected at build time withPROCESSING_CONFIG— a bitmask has no value to standardise.
See
pulse_examples_search tags=[outlier-detection]- Skills:
attribute-composition,op-attr-tscore,op-agg-zscore
op-feat-bucketize
name: op-feat-bucketize
description: Bin a numeric column into ordered buckets — explicit boundaries or N equal-population quantiles.
kind: operator
category: FEAT
operator: FEAT_BUCKETIZE
type: reference
applies_to: process, compose, predict
examples_tags: [feature-engineering, pre-filter, feature-pipeline]
Feature operators emit derived columns; no Response.Components.
Use when
Puts each value into an ordered bin, using cut points you give or bins holding about equal numbers of records, and adds the bin number.
Questions it answers:
- Which income band is each customer in?
- Which spend decile (tenth of orders ranked by spend) does each order fall in?
Use something else:
GROUP_RANGEwhen you want results grouped by fixed-width bands.
Params
EXACTLY ONE of (manifest exactly_one_of: cutpoints; a Recommend draft names boundaries):
boundaries(list[float]) — sorted ascending cutpoints;vlands in bucketiwhereboundaries[i-1] < v <= boundaries[i].quantiles(int) — count of equal-population buckets.
Inputs
Field — numeric u4/u8/u16/u32/u64, f32/f64, date. No decimal128, no categorical, no packed_bool.
Output
One u32-coded f64 column at Label (default BUCKET_<field>) holding the bucket INDEX (0..N).
Gotchas
- Neither OR both of
boundaries/quantiles→SERVICE_VALIDATIONat predict (runtimePROCESSING_CONFIG). - Quantile mode is GLOBAL-PASS — sweeps the cohort for N-1 cutpoints before emitting. Streaming still works (state survives
iter.Reset()); file-backed iterators pay a second I/O. Explicit mode is PER-ROW, stateless. - Boundaries are EXCLUSIVE on the lower edge — a value on a cutpoint goes to the LOWER index (
SearchFloat64s). - Null inputs emit a null bucket; a range filter works on the integer code.
See
pulse_examples_search tags=[feature-engineering]- Skills:
feature-engineering,op-group-quantile(aggregator-slot analogue),op-feat-one-hot
op-feat-date-features
name: op-feat-date-features
description: Expand a date or datetime field into year / month / day / day-of-week / quarter (+ hour) columns.
kind: operator
category: FEAT
operator: FEAT_DATE_FEATURES
type: reference
applies_to: process, compose, predict
examples_tags: [feature-engineering, time-series, pre-filter]
Feature operators emit derived columns; no Response.Components.
Use when
Splits a date into year, month, day, day of week and quarter columns, so each part can be filtered, grouped or modelled.
Questions it answers:
- Do orders differ by day of the week?
- Is there a seasonal pattern by month across several years?
Use something else:
ATTR_DATE_PARTwhen you need just one part of the date.GROUP_DATEwhen you want results grouped by day, month or year.
Params
None. Field (required) — params block is unused.
Inputs
Field — date or datetime; any other type is refused.
Output
Columns prefixed by Label (default the field name):
| Column | Type | Range |
|---|---|---|
<prefix>_year | f64 | year |
<prefix>_month | f64 | 1..12 |
<prefix>_day | f64 | 1..31 |
<prefix>_dow | f64 | 0..6, 0 = Sunday |
<prefix>_quarter | f64 | 1..4 |
<prefix>_hour | f64 | 0..23 — datetime only |
Gotchas
- Null source → every column
null. dow0= Sunday, NOT ISO; ignoresweek_start.- Suffixes are
dow/quarter(notday_of_week/is_weekend). - Zone-capable: a
datetimereads the LOCAL clock (slottz→time_zone→ default → UTC). Adateignores zones: explicittzrefused, inherited not applied. - Streamable per-row.
See
pulse_examples_search tags=[time-series],tags=[feature-engineering]- Skills:
feature-engineering,op-attr-date-part,request-envelope(Time zones)
op-feat-frequency-encode
name: op-feat-frequency-encode
description: Replace each categorical value with its observed relative frequency in the cohort.
kind: operator
category: FEAT
operator: FEAT_FREQUENCY_ENCODE
type: reference
applies_to: process, compose, predict
examples_tags: [feature-engineering, cardinality-analysis, pre-filter]
Feature operators emit derived columns; no Response.Components.
Use when
Replaces each category with the share of records that carry it, one number that says how common the category is.
Questions it answers:
- How common is each customer’s city, as a single number a model can use?
- Which records belong to rare product codes?
Use something else:
GROUP_CATEGORYwhen you want the count table itself, one row per category: group by the field, then count.
Params
None. Field (required, categorical) — params block is unused.
Inputs
Field — categorical_u8, categorical_u16, categorical_u32.
Output
One f64 column written to Label (default FREQ_<field>). Value per row = count[category] / total_non_null over the FULL cohort (pre-filter). Range (0, 1] for non-null inputs.
Reading the output
value: The share of records carrying this record’s category, as a fraction between 0 and 1: 0.25 means one in four records with a category have this one. Every record in a category gets the same value.- Caveat: The share is out of records WITH a category: records missing the field read missing and are left out of the total, so shares across categories add up to 1 among those records only.
- Caveat: Computed over every record of the cohort before any filter runs, so a filtered result still carries figures from the records it filtered out.
Gotchas
- GLOBAL-PASS: PrePass tallies categories cohort-wide, Finalize freezes the denominator, EmitRow serves O(1) per row. Streamable via
iter.Reset(); file-backed iterators pay a second I/O. - Frequencies reflect the RAW cohort because FEAT runs PRE-FILTER. To encode within a filtered subset, stage via Compose / ProcessChain.
- Cohort with zero non-null categories → every row emits
null. - Null categories on individual rows emit
null. - Non-categorical fields rejected at construction with
PROCESSING_CONFIG(“must be categorical”).
See
pulse_examples_search tags=[cardinality-analysis],tags=[feature-engineering]- Skills:
feature-engineering,op-feat-one-hot,op-feat-target-encode
op-feat-log
name: op-feat-log
description: Per-row log1p(x) of a numeric field; emits one f64 column. Standard skew-tamer.
kind: operator
category: FEAT
operator: FEAT_LOG
type: reference
applies_to: process, compose, predict
examples_tags: [feature-engineering, distribution-shape, pre-filter, streaming-friendly]
Feature operators emit derived columns; no Response.Components.
Use when
Adds a column holding the natural log of 1 + the value, which pulls in a long tail of large values so a skewed field is easier to model.
Questions it answers:
- Can I tame the long tail of customer spend before fitting a model on it?
- How does income look on a log-like scale, where for large values doubling counts about the same everywhere?
Params
None. Field (required, numeric) — params block is unused.
Inputs
| Param | Accepted field types |
|---|---|
Field | numeric: u4/u8/u16/u32/u64, f32/f64, date. decimal128 accepted via f64 approximation. (no categorical / packed_bool) |
Output
One f64 column written to Label (default LOG_<field>). Formula log1p(x) = ln(1 + x) — the +1 shift keeps x=0 -> 0 instead of -inf.
Reading the output
value: The natural log of 1 + the value: ln(1 + x). 0 reads 0, e - 1 (about 1.72) reads 1, 9 reads about 2.3 and 99 about 4.6; each step of about 0.69 is a doubling of 1 + x, so equal steps are equal ratios of 1 + x, not equal amounts.- Sign: positive means the original value is above 0; negative means the original value is between -1 and 0.
- Caveat: A value of -1 or less has no log and reads missing, with no error raised: count the missing rows before trusting a model built on the column.
- Caveat: A missing input gives a missing output.
Gotchas
- Inputs with
x <= -1(where1 + x <= 0) produce anull, not an error — the rest of the cohort keeps going. - Null inputs propagate to null outputs.
- Streamable per-row — pairs cleanly with online aggregators downstream.
- Pre-filter slot: a range filter on the log column references
LOG_<field>(default label).
See
pulse_examples_search tags=[feature-engineering]- Skills:
feature-engineering,op-feat-sqrt,op-feat-poly
op-feat-one-hot
name: op-feat-one-hot
description: One-hot encode a categorical field as one f64 column per dictionary entry.
kind: operator
category: FEAT
operator: FEAT_ONE_HOT
type: reference
applies_to: process, compose, predict
examples_tags: [feature-engineering, pre-filter, feature-pipeline]
Feature operators emit derived columns; no Response.Components.
Use when
Turns a category field into one 0/1 column per category, so a model or a sum can treat each category as its own yes/no.
Questions it answers:
- How do I feed region into a regression as separate yes/no predictors?
- Which rows are in each plan tier, as columns I can sum?
Use something else:
FEAT_FREQUENCY_ENCODEwhen the field has hundreds of categories and one column each is too many.
Params
None. Field (required, categorical) — params block is unused.
Inputs
Field — categorical_u8, categorical_u16, categorical_u32 (must carry a dictionary).
Output
ONE column per dictionary entry, named <prefix>_<category>, prefix = Label (default <field>). Each row holds 1.0 in its category’s column, 0.0 elsewhere. The column set comes from the SCHEMA dictionary at construction, so predict reports the full post-feature schema without scanning records.
Gotchas
- Non-categorical or dictionary-less
Field→PROCESSING_CONFIG. - Whitespace in labels normalises to
_for column-name safety ("New York"-><prefix>_New_York); other punctuation is the caller’s problem. - Null categories emit ALL ZERO across the one-hot block (mirrors
Compute). No implicit “unknown” column. - A dictionary category absent from the records still gets an all-zero column, which keeps downstream layouts stable.
- Streamable per-row.
See
pulse_examples_search tags=[feature-engineering]- Skills:
feature-engineering,op-feat-frequency-encode,op-feat-target-encode
op-feat-poly
name: op-feat-poly
description: Per-row polynomial expansion x^2..x^Degree of a numeric field — the basis for a polynomial regression.
kind: operator
category: FEAT
operator: FEAT_POLY
type: reference
applies_to: process, compose, predict
examples_tags: [feature-engineering, polynomial, pre-filter, streaming-friendly]
Feature operators emit derived columns; no Response.Components.
Use when
Adds columns holding the value squared, cubed and so on up to a chosen power, so a straight-line model can bend into a curve.
Questions it answers:
- Does satisfaction rise with tenure and then level off?
- Is the effect of temperature on yield curved rather than straight?
Use something else:
REG_OLSwhen you want to fit the curve itself, not just build its columns.
Params
degree — int, required. >= 2 AND <= 10 (MaxPolyDegree). Degree 1 is the original column; reference it directly downstream.
Inputs
Field — numeric u4/u8/u16/u32/u64, f32/f64, date; decimal128 via f64. No categorical, packed_bool, datetime or set_* (predict refuses them; the runtime does not yet).
Output
Degree - 1 columns <prefix>_<k> for k = 2..Degree, prefix default <field>_poly (override via Label). Each holds x^k by iterative multiplication (power *= v). The ORIGINAL column is untouched, so a linear regression over {x, x_poly_2, x_poly_3, …} is the polynomial-regression basis.
Reading the output
value.*: One column per power k from 2 up to the degree, named <prefix>_<k> (prefix is the label, by default <field>_poly), each holding the value raised to that power: x_poly_2 is x squared, x_poly_3 is x cubed. There is no power-1 column; the original field is it.- Caveat: A single power column means little alone: the curve shows in a model’s coefficients on x and its powers together, and those change when the field is centred first.
- Caveat: Even powers lose the sign: x = -3 and x = 3 both give 9 in the squared column.
Gotchas
degreeoutside[2, 10]→PROCESSING_CONFIG.- OVERFLOW WITHOUT STANDARDISATION:
x^10at|x| = 100is already1e20. Centre / standardise first; an orthogonal basis is out of scope for v1. - Null inputs → null on every emitted column. Streamable per-row.
See
pulse_examples_search tags=[polynomial],tags=[feature-engineering]- Skills:
feature-engineering,regression-modeling,op-feat-log(skew alternative),op-feat-sqrt
op-feat-sqrt
name: op-feat-sqrt
description: Per-row sqrt(x) of a numeric field; emits one f64 column.
kind: operator
category: FEAT
operator: FEAT_SQRT
type: reference
applies_to: process, compose, predict
examples_tags: [feature-engineering, distribution-shape, pre-filter, streaming-friendly]
Feature operators emit derived columns; no Response.Components.
Use when
Adds a column holding the square root of the value, a gentler squeeze than a log for counts with a moderate long tail.
Questions it answers:
- Can I soften the few very large complaint counts without a full log?
- What does the field look like with its big values pulled in a little?
Use something else:
FEAT_LOGwhen the tail is very long and needs a stronger squeeze.
Params
None. Field (required, numeric) — params block is unused.
Inputs
| Param | Accepted field types |
|---|---|
Field | numeric: u4/u8/u16/u32/u64, f32/f64, date. decimal128 accepted via f64 approximation. (no categorical / packed_bool) |
Output
One f64 column written to Label (default SQRT_<field>). Formula sqrt(x).
Reading the output
value: The square root of the value: 4 reads 2, 100 reads 10, 10,000 reads 100. Large values are pulled in more than small ones, but less sharply than a log.- Caveat: A negative value has no real square root and reads missing, with no error raised; 0 reads 0.
- Caveat: Between 0 and 1 the root is LARGER than the value (0.25 reads 0.5), so a field of fractions is stretched, not squeezed.
- Caveat: Averages, sums and differences of the transformed column are on the new scale: transforming them back does not give the average of the original values.
Gotchas
- Negative inputs (
x < 0) emitnull— sqrt of negative is undefined for real-valued features. No error. - Null inputs propagate to null outputs.
- Gentler skew compression than a log — prefer it for moderate-skew counts.
- Streamable per-row.
See
pulse_examples_search tags=[feature-engineering]- Skills:
feature-engineering,op-feat-log,op-feat-poly
op-feat-target-encode
name: op-feat-target-encode
description: Replace each categorical value with the smoothed mean of a numeric Target field for that category. Leakage trap — see Gotchas.
kind: operator
category: FEAT
operator: FEAT_TARGET_ENCODE
type: reference
applies_to: process, compose, predict
examples_tags: [feature-engineering, feature-pipeline, leakage-risk, pre-filter]
Feature operators emit derived columns; no Response.Components.
Use when
Replaces each category with the average outcome of the records in that category, optionally pulled toward the overall average.
Questions it answers:
- How do I give a model a single number for each of a thousand postcodes?
- What is the average sale price of each product category, on every record?
Use something else:
AGG_AVERAGEwhen you want to report each category’s average outcome.
Params
target— string, required. Numeric field whose grouped mean replaces the category.smoothing— float, default0.0. Additive prior weight toward the global target mean;>= 0.
encoded = (count_cat * mean_cat + smoothing * mean_global) / (count_cat + smoothing)
smoothing=0 → unsmoothed per-category mean. Larger values pull rare categories toward the cohort mean.
Inputs
Field — categorical_u8, categorical_u16, categorical_u32. params.target — numeric (categorical rejected at construction).
Output
One f64 column at Label (default TARGET_<field>): the (smoothed) mean of target over rows sharing the categorical value.
Reading the output
value: The average of the outcome field over records in this record’s category, in the outcome’s units; with smoothing s, (n * category average + s * overall average) / (n + s), so a category with few records sits nearer the overall average.- Caveat: Target leakage: every average includes the record’s own outcome and the outcomes of validation and test records, because the encoder reads no split column. Placing FEAT_TRAIN_TEST_SPLIT first does not change a single value, and filtering to split 0 afterwards keeps the leaked figures.
Gotchas
- TARGET LEAKAGE —
PULSE_FEAT_TARGET_LEAKAGE_RISKon every use (errors under--strict). Means use EVERY row’s target (test rows, own row): no split column is read, so a prior split orsplit == 0filter changes nothing. Seefeature-engineering. - Missing
target, non-numerictarget, orsmoothing < 0→PROCESSING_CONFIG. - GLOBAL-PASS: PrePass tallies per-category + global (sum, count); Finalize freezes
globalMean. Streamable viaiter.Reset()(file-backed: second I/O). - Zero non-null targets → every row null. Category with all targets null →
globalMean.
See
pulse_examples_search tags=[leakage-risk]- Skills:
feature-engineering(target-leakage trap),op-feat-train-test-split,op-feat-frequency-encode
op-feat-train-test-split
name: op-feat-train-test-split
description: Deterministic split-assignment column (0=train, 1=val, 2=test) for ML workflows.
kind: operator
category: FEAT
operator: FEAT_TRAIN_TEST_SPLIT
type: reference
applies_to: process, compose, predict
examples_tags: [feature-engineering, feature-pipeline, pre-filter]
Feature operators emit derived columns; no Response.Components.
Use when
Labels every record train (0), validation (1) or test (2) by a seeded shuffle, so a model can be checked on records it never learned from.
Questions it answers:
- How do I hold back 20% of records to test a model on?
- Can I split records so each class keeps its share in train and test?
Use something else:
capability:samplewhen you just want a random subset of rows to look at.
Params
| Name | Type | Default | Description |
|---|---|---|---|
ratios | list[float] | (required) | Two elements → train + val; three → all three splits. Must sum to 1.0 within 1e-6. |
seed | int | 0 | Same seed ⇒ byte-identical assignments. |
stratify | string | — | Optional categorical field; ratios applied per category for class balance. |
Inputs
Field not consulted — assignments are positional / per-shuffle; may be omitted. params.stratify — categorical_u8 / u16 / u32 only.
Output
One u8-valued f64 column at Label (default split), from internal/processing/feature: feature.SplitTrain = 0, feature.SplitVal = 1, feature.SplitTest = 2.
Gotchas
ratioslength outside[2, 3], any negative ratio, a sum off1.0by more than1e-6, or a non-categoricalstratify→PROCESSING_CONFIG.- GLOBAL-PASS: PrePass collects row count + stratify keys, Finalize materialises the assignment table (O(rows) memory), EmitRow yields it in PrePass order. EmitRow over-call (more rows than PrePass) →
PROCESSING_INTERNAL. - Streamable via
iter.Reset(); file-backed iterators pay a second I/O. - Stratified mode seeds each group distinctly, so groups never share one shuffle.
- Does NOT protect
FEAT_TARGET_ENCODE: it reads no split column, soPULSE_FEAT_TARGET_LEAKAGE_RISKstill fires.
See
pulse_examples_search tags=[feature-engineering]- Skills:
feature-engineering(train / test / split semantics),op-feat-target-encode,op-filter-include
op-filter-date-ranges
name: op-filter-date-ranges
description: Keep records whose date/datetime field falls inside any of a validated set of labeled date ranges.
kind: operator
category: FILTER
operator: FILTER_DATE_RANGES
type: reference
applies_to: process, compose, predict, facet, sample
examples_tags: [cohort-analysis, streaming-friendly]
Use when
Keeps only the rows whose date falls inside one of a set of named date ranges, such as custom fiscal quarters.
Questions it answers:
- What happened during the two campaign periods only?
- What are the figures for the first half of our fiscal year?
Use something else:
GROUP_DATE_RANGESwhen you want each row labelled with its range instead of dropping the rest.FILTER_RANGEwhen the field is a number rather than a date.
Params
One source in Params (not Values) — ranges XOR table:
ranges—[]{label,start,end}, ISO; null bound = open; inclusive.table— registeredRangeTable(Extensions orPULSE_RANGE_TABLES_DIR).tz— slot key (notparams); IANA, beatstime_zone.
Inputs
Field — date or datetime, else PROCESSING_CONFIG. datetime floors to its local day in the resolved zone (UTC default); bounds (inline or table; tables carry no zone) are local days.
Output
Keeps rows whose day is in any range; label unused, no column.
Components
Floor only — {n_in, n_out, n_null_input}. Mergeable (additive).
Gotchas
- Null date → dropped.
- Both/neither →
PULSE_RANGE_SOURCE_AMBIGUOUS; unknown table →PULSE_RANGE_TABLE_UNKNOWN. - Overlap/dup →
PULSE_RANGE_OVERLAP/_DUPLICATE_LABEL; bad literal, start>end →_INVALID. start: 2026-03-01,tz: Europe/Berlinkeeps2026-02-28T23:30Z.tzondate, non-UTC on a derived field, anytzunderFilterToFileWithRequest→PROCESSING_CONFIG.- Row-local streamable.
See
- Skills:
op-group-date-ranges,facet-design,request-envelope(Time zones)
op-filter-exclude
name: op-filter-exclude
description: Drop records whose field value appears in the supplied Values list.
kind: operator
category: FILTER
operator: FILTER_EXCLUDE
type: reference
applies_to: process, compose, predict
examples_tags: [cohort-analysis, streaming-friendly]
Use when
Drops the rows whose field holds one of the listed values and keeps the rest, such as removing test accounts.
Questions it answers:
- What are the totals once internal test accounts are taken out?
- How do the results change without the store that was closed for refit?
Use something else:
FILTER_INCLUDEwhen you want to keep only the listed values.FILTER_NULLwhen you want to drop the rows where the field is missing.
Params
| Name | Type | Default | Description |
|---|---|---|---|
Values | []string | (required) | Block-list. Categorical fields resolve labels through the dictionary; numeric fields parse each entry via strconv.ParseFloat. |
Inputs
| Param | Accepted field types |
|---|---|
Field | any cohort field type EXCEPT set_* (categorical_*, numeric, date, datetime, packed_bool, decimal128) |
Output
Row-level predicate. Drop when Field’s value is in Values; pass otherwise. No emitted column.
Components
Floor only — no operator-specific keys. Universal {n_in, n_out, n_null_input} per response-components contract. Mergeable across chunks; counters fold by simple addition.
Gotchas
- Null rows PASS this filter (an include filter drops them).
- Unknown categorical label in
Values→PROCESSING_CONFIGat build time, surfaced via predict. set_*rejected at build time withPROCESSING_CONFIG— a bitmask has no comparable scalar at any rung.
See
pulse_examples_search tags=[cohort-analysis]- Skills:
aggregation-design,response-components,op-filter-include
op-filter-expression
name: op-filter-expression
description: Keep records for which an expr-lang expression evaluates truthy against record fields.
kind: operator
category: FILTER
operator: FILTER_EXPRESSION
type: reference
applies_to: process, compose, predict
examples_tags: [cohort-analysis, feature-engineering, streaming-friendly]
Use when
Keeps the rows for which a written yes-or-no condition is true, for rules the other filters cannot state, such as two fields together.
Questions it answers:
- Which customers spent over 500 and joined this year?
- Which records have an end date before their start date?
Use something else:
FILTER_INCLUDEwhen you only need to keep a list of values.FILTER_RANGEwhen you only need a low and a high limit.
Params
| Name | Type | Default | Description |
|---|---|---|---|
Expression | string | (required) | expr-lang/expr v1.17.x bool predicate. |
Inputs
| Param | Accepted field types |
|---|---|
Field | unused — the expression names fields. |
Categorical → STRING; set_* → []string (has_any(tags, "a")); decimal128 → Decimal128.
Output
Row-level predicate. No emitted column.
Components
Floor only: universal {n_in, n_out, n_null_input} (response-components). n_null_input stays 0 — no fixed input field. Mergeable.
Gotchas
- Compiled once; bad syntax / types →
PROCESSING_RUNTIMEat build. - Null binds
nil:==false,!=true,x ?? 0fills;>/+/lenon nil → predicate UNKNOWN, row dropped. - Error / non-bool on a non-null row →
PROCESSING_RUNTIME. %on a field: floatmath.Mod(age % 2 == 0, dividend’s sign);% 0→ NaN, as/ 0→ ±Inf.- No attribute output — filters run first.
See
pulse_examples_search tags=[feature-engineering]- Skills:
expression-language(maths / vector functions,ExprFunctions,LookupTables),aggregation-design,response-components
op-filter-false
name: op-filter-false
description: Keep records where Field is logically false. Strict packed_bool by default; opt into JS falsiness.
kind: operator
category: FILTER
operator: FILTER_FALSE
type: reference
applies_to: process, compose, predict
examples_tags: [cohort-analysis, streaming-friendly]
Use when
Keeps only the rows where a yes/no field is no, such as lapsed accounts or unfinished surveys.
Questions it answers:
- What do the customers who did not renew have in common?
- How many interviews were left unfinished, by region?
Use something else:
FILTER_TRUEwhen you want the rows where the field is yes.FILTER_NULLwhen you want only the rows where the field is missing.
Params
| Name | Type | Default | Description |
|---|---|---|---|
Values | []string | empty (strict) | Omit, or ["strict"], requires packed_bool and matches raw 0. ["truthy"] enables JavaScript-style falsiness across any field type. Other values → PROCESSING_CONFIG. |
Inputs
| Param | Accepted field types |
|---|---|
Field | strict mode: packed_bool only. Truthy mode: any cohort field type. |
Output
Row-level predicate. Pass when value is false / falsy. No emitted column.
Components
Floor only — no operator-specific keys. Universal {n_in, n_out, n_null_input} per response-components contract. Mergeable across chunks.
Gotchas
- Strict mode on non-
packed_bool→PROCESSING_CONFIG. Fix:Values=["truthy"]. - Truthy kept-set:
0,NaN,"",null→ kept. Null rows DROP in strict but PASS in truthy — null is JS-falsy, while strict treats null as “no observation”.
See
pulse_examples_search tags=[cohort-analysis]- Skills:
aggregation-design,response-components,op-filter-true
op-filter-include
name: op-filter-include
description: Keep records whose field value appears in the supplied Values list.
kind: operator
category: FILTER
operator: FILTER_INCLUDE
type: reference
applies_to: process, compose, predict
examples_tags: [cohort-analysis, streaming-friendly]
Use when
Keeps only the rows whose field holds one of the listed values, such as two regions or three product codes.
Questions it answers:
- What do the figures look like for the North and South regions only?
- How did customers on the two premium plans answer?
Use something else:
FILTER_EXCLUDEwhen you want to drop the listed values and keep everything else.FILTER_SET_CONTAINS_ANYwhen the field is a multi-select answer.
Params
| Name | Type | Default | Description |
|---|---|---|---|
Values | []string | (required) | Allow-list. Categorical fields resolve labels through the dictionary; numeric fields parse each entry via strconv.ParseFloat. |
Inputs
| Param | Accepted field types |
|---|---|
Field | any cohort field type EXCEPT set_* (categorical_*, numeric, date, datetime, packed_bool, decimal128) |
Output
Row-level predicate. Pass when Field’s value is in Values; drop otherwise. No emitted column.
Components
Floor only — no operator-specific keys. Universal {n_in, n_out, n_null_input} per response-components contract. Mergeable across chunks; counters fold by simple addition.
Gotchas
- Null rows fail the predicate (dropped).
- Unknown categorical label in
Values→PROCESSING_CONFIGat build time, surfaced via predict. - Non-numeric values on a numeric field →
PROCESSING_CONFIG(parse error). - Filters chain in declared order; this one sees only rows the previous kept.
set_*rejected at build time withPROCESSING_CONFIG— a bitmask has no comparable scalar at any rung.
See
pulse_examples_search tags=[cohort-analysis]- Skills:
aggregation-design,response-components,op-filter-exclude
op-filter-null
name: op-filter-null
description: Keep or drop records based on null state of a field.
kind: operator
category: FILTER
operator: FILTER_NULL
type: reference
applies_to: process, compose, predict
examples_tags: [data-quality, cohort-analysis, streaming-friendly]
Use when
Keeps either the rows where a field is missing or the rows where it is present, to find gaps or set them aside.
Questions it answers:
- Who skipped the income question, so their other answers can be compared with those who answered?
- What are the averages over only the rows that have a value?
Use something else:
AGG_NULL_COUNTwhen you only want to count the missing values, not filter on them.
Params
| Name | Type | Default | Description |
|---|---|---|---|
Values | [1]string | (required) | "is_null" keeps null-valued rows; "is_not_null" keeps non-null rows. Any other value → PROCESSING_CONFIG. |
Inputs
| Param | Accepted field types |
|---|---|
Field | any cohort field type — null state is read from the per-record null bitmap. |
Output
Row-level predicate. Pass per the configured mode. No emitted column.
Components
Floor only — no operator-specific keys. Universal {n_in, n_out, n_null_input} per response-components contract. n_null_input matches n_out when mode is is_null, and n_in − n_out when mode is is_not_null (book-keeping mirrors the predicate). Mergeable across chunks.
Gotchas
Fieldis required; empty →PROCESSING_CONFIG.- Reads the bitmap only — the underlying type’s sentinel value (e.g.
NaN, emptyset_*) is NOT treated as null. - For “non-null AND in a set”, put this filter (
is_not_null) before the value filter.
See
pulse_examples_search tags=[data-quality]- Skills:
aggregation-design,response-components,cohort-schema-design
op-filter-range
name: op-filter-range
description: Keep records whose numeric field value falls within [low, high] inclusive.
kind: operator
category: FILTER
operator: FILTER_RANGE
type: reference
applies_to: process, compose, predict
examples_tags: [cohort-analysis, streaming-friendly]
Use when
Keeps only the rows whose number lies between a low and a high limit, both limits included.
Questions it answers:
- What do adults aged 18 to 34 say?
- What is the average order once impossible values above 10,000 are left out?
Use something else:
FILTER_EXPRESSIONwhen you need an open limit, such as strictly above a value.FILTER_DATE_RANGESwhen the field is a date and you want named periods.
Params
| Name | Type | Default | Description |
|---|---|---|---|
Values | [2]string | (required) | [low, high] — both parsed via strconv.ParseFloat. Exactly two entries; any other count → PROCESSING_CONFIG. |
Inputs
| Param | Accepted field types |
|---|---|
Field | numeric: u8/u16/u32/u64, f32/f64, date, packed_bool, decimal128 |
Output
Row-level predicate. Pass when low ≤ value ≤ high; drop otherwise. No emitted column.
Components
Floor only — no operator-specific keys. Universal {n_in, n_out, n_null_input} per response-components contract. Mergeable across chunks; counters fold by simple addition.
Gotchas
- Inclusive bounds on both ends. Open intervals require
FILTER_EXPRESSION. - Null rows fail the predicate (dropped).
dateinterpreted as the numeric day-since-epoch — pass numeric strings, not formatted dates.
See
pulse_examples_search tags=[cohort-analysis]- Skills:
aggregation-design,response-components,op-filter-expression
op-filter-set-contains-all
name: op-filter-set-contains-all
description: Keep records whose set field has every bit in the supplied label mask set.
kind: operator
category: FILTER
operator: FILTER_SET_CONTAINS_ALL
type: reference
applies_to: process, compose, predict
examples_tags: [cohort-analysis, streaming-friendly]
Use when
Keeps the rows whose multi-select answer includes every listed option, other options allowed, such as people who use both A and B.
Questions it answers:
- How do people who use both our app and our website rate us?
- Which tickets carry both the urgent and the billing tags?
Use something else:
FILTER_SET_CONTAINS_ANYwhen one of the listed options is enough.
Params
| Name | Type | Default | Description |
|---|---|---|---|
Values | []string | (required) | Dictionary labels. Resolved at build time to a single query mask; per-row check is a single bitwise AND-eq. |
Inputs
| Param | Accepted field types |
|---|---|
Field | any set rung — set_u8, set_u16, set_u32, set_u64, set_u128, set_u256 |
Output
Row-level predicate. Pass when row & query == query. No emitted column.
Components
Floor only — no operator-specific keys. Universal {n_in, n_out, n_null_input} per response-components contract. Mergeable across chunks.
Gotchas
- Empty
Valuesresolves to query=0 — every row passes (trivially “contains all of nothing”). - Unknown label in
Values→PROCESSING_CONFIG. - Label whose dictionary bit position exceeds the set’s width →
PROCESSING_CONFIG. - Null rows DROP. Use this for AND-of-features survey logic (“respondents who picked X and Y”).
See
pulse_examples_search tags=[cohort-analysis]- Skills:
aggregation-design,response-components,op-filter-set-contains-any,op-filter-set-contains-none,op-filter-set-equals
op-filter-set-contains-any
name: op-filter-set-contains-any
description: Keep records whose set field shares at least one bit with the supplied label mask.
kind: operator
category: FILTER
operator: FILTER_SET_CONTAINS_ANY
type: reference
applies_to: process, compose, predict
examples_tags: [cohort-analysis, streaming-friendly]
Use when
Keeps the rows whose multi-select answer includes at least one of the listed options, such as anyone who uses brand A or B.
Questions it answers:
- What do people who use either of our two apps think of the service?
- Which customers paid by any card at least once?
Use something else:
FILTER_SET_CONTAINS_ALLwhen the row must include every listed option.FILTER_INCLUDEwhen the field holds one value per row, not several.
Params
| Name | Type | Default | Description |
|---|---|---|---|
Values | []string | (required) | Dictionary labels. Resolved at build time to a single query mask; per-row check is a single bitwise AND. |
Inputs
| Param | Accepted field types |
|---|---|
Field | any set rung — set_u8, set_u16, set_u32, set_u64, set_u128, set_u256 |
Output
Row-level predicate. Pass when row & query != 0. No emitted column.
Components
Floor only — no operator-specific keys. Universal {n_in, n_out, n_null_input} per response-components contract. Mergeable across chunks.
Gotchas
- Empty
Valuesresolves to a zero query — every row drops. Validate caller input. - Unknown label in
Values→PROCESSING_CONFIG. - Label whose dictionary bit position exceeds the set’s width →
PROCESSING_CONFIG. - Null rows DROP (like an include filter).
See
pulse_examples_search tags=[cohort-analysis]- Skills:
aggregation-design,response-components,op-filter-set-contains-all,op-filter-set-contains-none,op-filter-set-equals
op-filter-set-contains-none
name: op-filter-set-contains-none
description: Keep records whose set field shares no bits with the supplied label mask.
kind: operator
category: FILTER
operator: FILTER_SET_CONTAINS_NONE
type: reference
applies_to: process, compose, predict
examples_tags: [cohort-analysis, streaming-friendly]
Use when
Keeps the rows whose multi-select answer includes none of the listed options, such as people who use neither A nor B.
Questions it answers:
- What do people who use none of the competitor apps think?
- Which orders had no discount code applied?
Use something else:
FILTER_SET_CONTAINS_ANYwhen you want rows that include one of the options.FILTER_EXCLUDEwhen the field holds one value per row, not several.
Params
| Name | Type | Default | Description |
|---|---|---|---|
Values | []string | (required) | Dictionary labels. Resolved at build time to a single query mask; per-row check is a single bitwise AND-eq-zero. |
Inputs
| Param | Accepted field types |
|---|---|
Field | any set rung — set_u8, set_u16, set_u32, set_u64, set_u128, set_u256 |
Output
Row-level predicate. Pass when row & query == 0. No emitted column.
Components
Floor only — no operator-specific keys. Universal {n_in, n_out, n_null_input} per response-components contract. Mergeable across chunks.
Gotchas
- Null rows PASS (like an exclude filter); add a null filter if you need them out too.
- Unknown label in
Values→PROCESSING_CONFIG. - Label whose dictionary bit position exceeds the set’s width →
PROCESSING_CONFIG. - Useful as a NOT-of-features survey filter (“respondents who picked neither X nor Y”).
See
pulse_examples_search tags=[cohort-analysis]- Skills:
aggregation-design,response-components,op-filter-set-contains-any,op-filter-set-contains-all,op-filter-set-equals
op-filter-set-equals
name: op-filter-set-equals
description: Keep records whose set field mask exactly equals the supplied label mask.
kind: operator
category: FILTER
operator: FILTER_SET_EQUALS
type: reference
applies_to: process, compose, predict
examples_tags: [cohort-analysis, streaming-friendly]
Use when
Keeps the rows whose multi-select answer is exactly the listed options, no more and no fewer, such as people who use only A.
Questions it answers:
- How many customers use our app and nothing else?
- Which respondents picked exactly these two reasons?
Use something else:
FILTER_SET_CONTAINS_ALLwhen other options are allowed alongside the listed ones.GROUP_SET_VALUEwhen you want to see every combination with its count.
Params
| Name | Type | Default | Description |
|---|---|---|---|
Values | []string | (required) | Dictionary labels. Resolved at build time to a single query mask; per-row check is a single equality. |
Inputs
| Param | Accepted field types |
|---|---|
Field | any set rung — set_u8, set_u16, set_u32, set_u64, set_u128, set_u256 |
Output
Row-level predicate. Pass when row == query. No emitted column.
Components
Floor only — no operator-specific keys. Universal {n_in, n_out, n_null_input} per response-components contract. Mergeable across chunks.
Gotchas
- Empty
Valuesresolves to query=0 — only rows whose set is exactly empty pass. An empty mask is a valid “no selection” set value, distinct from null. - Unknown label in
Values→PROCESSING_CONFIG. - Label whose dictionary bit position exceeds the set’s width →
PROCESSING_CONFIG. - Null rows DROP.
See
pulse_examples_search tags=[cohort-analysis]- Skills:
aggregation-design,response-components,op-filter-set-contains-any,op-filter-set-contains-all,op-filter-set-contains-none
op-filter-true
name: op-filter-true
description: Keep records where Field is logically true. Strict packed_bool by default; opt into JS truthiness.
kind: operator
category: FILTER
operator: FILTER_TRUE
type: reference
applies_to: process, compose, predict
examples_tags: [cohort-analysis, streaming-friendly]
Use when
Keeps only the rows where a yes/no field is yes, such as active accounts or completed surveys.
Questions it answers:
- What do only the completed interviews say?
- What are the figures for active subscribers?
Use something else:
FILTER_FALSEwhen you want the rows where the field is no.FILTER_EXPRESSIONwhen the condition involves more than one field.
Params
| Name | Type | Default | Description |
|---|---|---|---|
Values | []string | empty (strict) | Omit, or ["strict"], requires packed_bool and matches raw 1. ["truthy"] enables JavaScript-style coercion across any field type. Other values → PROCESSING_CONFIG. |
Inputs
| Param | Accepted field types |
|---|---|
Field | strict mode: packed_bool only. Truthy mode: any cohort field type. |
Output
Row-level predicate. Pass when value is true / truthy. No emitted column.
Components
Floor only — no operator-specific keys. Universal {n_in, n_out, n_null_input} per response-components contract. Mergeable across chunks.
Gotchas
- Strict mode on non-
packed_bool→PROCESSING_CONFIGwith the suggestedValues=["truthy"]fix. - Truthy mode coerces:
0,NaN,"",null→ falsy; everything else → truthy. Null rows DROP in both directions. decimal128truthiness usesSign() != 0.
See
pulse_examples_search tags=[cohort-analysis]- Skills:
aggregation-design,response-components,op-filter-false
op-group-category
name: op-group-category
description: Partition records by exact field value; ideal for categorical fields.
kind: operator
category: GROUP
operator: GROUP_CATEGORY
type: reference
applies_to: process, compose, predict
examples_tags: [cohort-analysis, streaming-friendly]
Use when
Splits the rows into one group per distinct value of a field, such as one group per region or per answer option.
Questions it answers:
- What is the average spend in each region?
- How many respondents chose each answer?
Use something else:
GROUP_RANGEwhen the field is a number you want in bands, not one group per value.GROUP_DATEwhen the field is a date and you want months or quarters.
Params
None. Group.Label overrides the output column name; Group.Include []string allow-lists bucket keys (label strings) and sets emission order.
Inputs
Field — any cohort field type EXCEPT set_*.
Output
One bucket per distinct value; key = stringified value (categoricals resolve through the dictionary). Smart default for categorical_* / packed_bool when Type omitted.
Non-empty Include is order-significant: buckets emit in Include order (Data + Components.buckets). Empty/absent keeps the prior alphabetical order — byte-identical.
Components
Floor {total_n, n_null} + dict_size (int, distinct values observed) and buckets ([]bucket of {key, label, count} in emission order). Mergeable; StreamableGrouper, so eligible for fused crosstab.
Gotchas
Includematches the post-dictionary label; zero-record values drop, never emit empty.- High-cardinality fields blow memory — filter to the values you need first.
set_*rejected at build time withPROCESSING_CONFIG— a bitmask has no single scalar to bucket on at any rung.
See
pulse_examples_search tags=[cohort-analysis]- Skills:
grouper-design,response-components,crosstab-guide
op-group-date
name: op-group-date
description: Partition date/datetime records by calendar component (hour..year); week_start picks the week day; fiscal_offset shifts year/quarter.
kind: operator
category: GROUP
operator: GROUP_DATE
type: reference
applies_to: process, compose, predict
examples_tags: [time-series, streaming-friendly]
Use when
Splits the rows by calendar period of a date, such as month, quarter, ISO week or weekday, to see a measure over time.
Questions it answers:
- How many orders came in each month?
- Which day of the week gets the most complaints?
Use something else:
GROUP_DATE_RANGESwhen your periods are custom, such as campaign windows.OVERLAY_INDEX_VS_PRIORwhen you want each period against the one before it.
Params
component: enum, defaultmonth:hour,day,day_of_week,week,month,quarter,year.week_start:weekonly;monday(default, ISO2026-W09) ..sunday(key = first day: 2026-03-01).fiscal_offset: int -11..11;year/quarteronly; keysFY(end-year).tz: slot key (IANA), beatstime_zone.
Inputs
Field: date, datetime (in the resolved zone). hour: datetime only.
Output
String key (2024-Q1, FY2025-Q1, 2026-03-29T03). Smart default (date-family).
Components
Floor {total_n, n_null} + granularity, range_start / range_end, n_buckets, buckets ({key, period_start, period_end, count}; hour period = label). Mergeable; Streamable=false (stream: GROUP_CATEGORY over ATTR_DATE_PART).
Gotchas
day_of_weeklex-sorts;Group.Includeignored.- Local wall clock: a skipped DST hour has no bucket; a repeated one is ONE.
PROCESSING_CONFIG:tzondate, zone on a derived field,houroffdatetime, misplacedweek_start/fiscal_offset.
See
pulse_examples_search tags=[time-series]- Skills:
grouper-design,response-components,request-envelope(Time zones)
op-group-date-ranges
name: op-group-date-ranges
description: Bucket date records by inline labeled date ranges; the range label becomes the bucket key.
kind: operator
category: GROUP
operator: GROUP_DATE_RANGES
type: reference
applies_to: process, compose, predict
examples_tags: [time-series, cohort-analysis]
Use when
Labels each row with the named date range its date falls in, such as custom fiscal quarters or before and after a launch.
Questions it answers:
- How did sales compare before, during and after the campaign?
- What are the totals for each of our custom fiscal quarters?
Use something else:
GROUP_DATEwhen plain calendar months, quarters or weeks are enough.
Params
One source — ranges XOR table:
ranges— ordered[]{label,start,end}; ISO, omitted = open, inclusive.table— registeredRangeTable(Extensions orPULSE_RANGE_TABLES_DIR).unmatched_label— defaultunmatched; not a range label.tz— slot key (notparams); IANA, beatstime_zone.
Inputs
Field — date or datetime (floored to its local day in the resolved zone, UTC default). Range bounds (inline or table; tables carry no zone) are local days.
Output
Range label per row, else unmatched_label; supplied order.
Components
Floor {total_n, n_null} + n_ranges (int), unmatched_label (string), buckets ({key, label, count}, unmatched last). Mergeable, streamable.
Gotchas
- Both/neither source →
PULSE_RANGE_SOURCE_AMBIGUOUS; unknown table →PULSE_RANGE_TABLE_UNKNOWN; non-date →PROCESSING_CONFIG. - Overlap / dup label / bad bound →
PULSE_RANGE_OVERLAP/_DUPLICATE_LABEL/_INVALID. tzondate, or non-UTC on a derived field →PROCESSING_CONFIG.Group.Includenot honoured.
See
- Skills:
grouper-design,response-components,request-envelope(Time zones)
op-group-kmeans
name: op-group-kmeans
description: Segment records into k clusters by seeded, deterministic k-means over a vector of numeric members; keys cluster_1..cluster_k by descending size.
kind: operator
category: GROUP
operator: GROUP_KMEANS
type: reference
applies_to: process, compose, predict
examples_tags: [multivariate, buffered-pipeline]
Use when
Finds k segments of rows that are alike across several numbers at once, such as customer types by spend, visits and tenure.
Questions it answers:
- What natural customer segments do spend, visits and tenure suggest?
- Which respondent types stand out across the rating battery?
Use something else:
GROUP_CATEGORYwhen you already know the groups.GROUP_QUANTILEwhen you want bands of one number.
Params
k(required, 2 ≤ k ≤ distinct tuples).seedint64 (0 = fixed).max_iter100,n_init10 (lowest within-SS wins).standardizetrue.missinglistwise.weight: frequency only (a row is w points).
Slot: exactly one of vector (a vectors entry) / fields; no field. Key column GROUP_KMEANS_<vector> or _<first fields entry>.
Inputs
Members: u4…u64, f32/f64.
Output
Keys cluster_1…cluster_k, emitted largest first (ties: lexicographic centroid). A null member → n_null.
Components
{total_n, n_null}, the params, members, center / scale, distinct_points, buckets {key, count, size, within_ss, centroid, centroid_standardized}, total_ss, within_ss, between_ss, between_ss_ratio, iterations, converged, best_init. Mergeability None.
Gotchas
- Labels depend only on the member tuples, never on row or shard order.
- Buffered, never merged; predict
row_buffer_bytes= n·p·8 + k·p·8. - Best start at
max_iter:PULSE_MATRIX_NOT_CONVERGED(solverkmeans_lloyd).
See
pulse_examples_search tags=[multivariate]- Skills:
multivariate-design,op-attr-mahalanobis
op-group-quantile
name: op-group-quantile
description: Partition records into N equal-population quantile buckets (Q1..Q4 / D1..D10 / P1..P100).
kind: operator
category: GROUP
operator: GROUP_QUANTILE
type: reference
applies_to: process, compose, predict
examples_tags: [distribution-shape, buffered-pipeline]
Use when
Splits the rows into equal-sized groups by rank of a number, such as quartiles or deciles of spend, to compare top and bottom.
Questions it answers:
- How do the top 25% of spenders differ from the bottom 25%?
- What is the average basket in each decile of customer value?
Use something else:
GROUP_RANGEwhen you want bands of a fixed width, such as every 10 years of age.
Params
| Name | Type | Default | Description |
|---|---|---|---|
interval | int | 4 | Number of buckets; set on Group.Interval. 4 (quartiles), 10 (deciles), 100 (percentiles). |
Weight (both kinds; "weight": null opts out): buckets cut at the weighted order statistics (the weighted-percentile rule; probability rescaled to the row count). A heavy row spanning a cut lands whole in the higher bucket; count / total_n stay raw rows.
Inputs
| Param | Accepted field types |
|---|---|
Field | numeric: u4/u8/u16/u32/u64, f32/f64 (no decimal128) |
Output
Bucket label per row (Qk / Dk / Pk).
Components
Universal floor {total_n, n_null} plus operator-specific:
| Key | Type | Notes |
|---|---|---|
n_quantiles | int | Equal-population buckets |
method | string | Interpolation ("linear") |
edges | []float64 | Sorted cutpoints |
buckets | []bucket | {key, low, high, count} |
- Mergeability:
None—BufferedComponents=true; needs sorted full input - Streaming:
Streamable=false— terminal-only.
Gotchas
- Forces buffered execution — disables fused crosstab.
- Rejects categorical/decimal128 at construction.
Group.Includenot honoured — buckets are derived ranks.
See
pulse_examples_search tags=[distribution-shape]- Skills:
grouper-design,response-components,op-group-range
op-group-range
name: op-group-range
description: Partition numeric records into half-open ranges [a, b); Interval controls bucket width.
kind: operator
category: GROUP
operator: GROUP_RANGE
type: reference
applies_to: process, compose, predict
examples_tags: [cohort-analysis, streaming-friendly]
Use when
Splits a number into bands of equal width, such as ages 20-30 and 30-40, to see how values spread or to compare bands.
Questions it answers:
- How many customers fall in each 10-year age band?
- What does the spread of delivery times look like in 1-hour bands?
Use something else:
GROUP_QUANTILEwhen you want groups of equal size rather than equal width.GROUP_ROUNDEDwhen you want each band named by its lower edge alone.
Params
| Name | Type | Default | Description |
|---|---|---|---|
interval | float | (required) | Bucket width on value axis; set on Group.Interval. |
Inputs
| Param | Accepted field types |
|---|---|
Field | numeric: u4/u8/u16/u32/u64, f32/f64 (no decimal128) |
Output
String label "low-high" per row ("10-20" holds [10, 20)). Smart default (Interval=10) for numeric.
Components
Universal floor {total_n, n_null} plus:
| Key | Type | Notes |
|---|---|---|
interval | float64 | Bucket width |
range_min | float64 | Smallest non-null |
range_max | float64 | Largest non-null |
n_buckets | int | Buckets emitted |
edges | []float64 | Sorted cutpoints |
buckets | []bucket | {key, low, high, count} |
underflow_count | int | Below low |
overflow_count | int | At/above high |
- Mergeability:
Mergeable - Streaming:
StreamableGrouper— eligible for fused crosstab
Gotchas
- Half-open
[low, high)— upper edge belongs to next bucket. - Rejects categorical/decimal128 at construction.
Group.Includenot honoured.
See
pulse_examples_search tags=[cohort-analysis]- Skills:
grouper-design,op-group-rounded
op-group-rounded
name: op-group-rounded
description: Round each numeric value down to a multiple of Interval (floor, not nearest) and group by that scalar.
kind: operator
category: GROUP
operator: GROUP_ROUNDED
type: reference
applies_to: process, compose, predict
examples_tags: [cohort-analysis, streaming-friendly]
Use when
Groups a number by rounding it down to a multiple of a step, such as 23 and 27 both to 20, naming each group by that multiple.
Questions it answers:
- How many orders are there at each price point, to the nearest lower 10?
- What is the count of scores in each block of 5 points?
Use something else:
GROUP_RANGEwhen you want the band’s both edges in its name.GROUP_QUANTILEwhen you want groups of equal size.
Params
| Name | Type | Default | Description |
|---|---|---|---|
interval | float | (required) | Rounding increment; set on Group.Interval. |
Inputs
| Param | Accepted field types |
|---|---|
Field | numeric: u4/u8/u16/u32/u64, f32/f64 (no decimal128) |
Output
Floored numeric key per row. Each value rounds DOWN to a multiple of Interval (19→10, -3→-10), never to the nearest; key is that scalar.
Components
Universal floor {total_n, n_null} plus operator-specific:
| Key | Type | Notes |
|---|---|---|
precision | float64 | Rounding increment (Group.Interval) |
edges | []float64 | Sorted rounded scalars separating buckets |
buckets | []bucket | {key, low, high, count} per emission |
- Mergeability:
Mergeable - Streaming:
StreamableGrouper— eligible for fused crosstab
Gotchas
- Same partition as
GROUP_RANGEat equal width; only the key differs ("10"vs"10-20"). - Rejects categorical/decimal128 at construction.
Group.Includenot honoured — filter source field instead.
See
pulse_examples_search tags=[cohort-analysis]- Skills:
grouper-design,response-components,op-group-range
op-group-set-per-element
name: op-group-set-per-element
description: Fan each row into one bucket per selected label (multi-key). Cardinality multiplies with set popcount.
kind: operator
category: GROUP
operator: GROUP_SET_PER_ELEMENT
type: reference
applies_to: process, compose, predict
examples_tags: [cardinality-analysis, cohort-analysis]
Use when
Splits a multi-select answer into one group per option, counting each row once in every option it picked.
Questions it answers:
- How many respondents picked each brand they are aware of?
- What is the average rating among users of each feature?
Use something else:
GROUP_SET_VALUEwhen you want each exact combination of options as one group.AGG_SET_FREQUENCYwhen you only want the count per option, without other figures.
Params
None. Group.Label renames the output column; Group.Include allow-lists fan-out labels and fixes emission order.
Inputs
| Param | Accepted field types |
|---|---|
Field | any set rung — set_u8/u16/u32/u64/u128/u256 |
Output
One string key per set bit per row. Smart default for set_*. GroupType.FansOut() true — implements MultiKeyStreamingGrouper. Non-empty Include overrides dict-index emission order; zero-record labels dropped.
Components
Floor {total_n, n_null} plus:
| Key | Type | Notes |
|---|---|---|
total_label_observations | int | Sum of buckets[].count; MAY exceed total_n — fan-out |
buckets | []bucket | {key, label, count, dict_index} |
- Mergeability:
Mergeable - Is a fused-crosstab axis — admitted at any position, either or both axes.
Gotchas
sum(buckets[].count) > total_nis CORRECT.- Empty-mask rows skipped; do NOT increment
n_null. Null rows do. - Crosstab margins are non-additive: a 3-label row counts 3× across row margins, once in the grand total. Both paths agree.
See
pulse_examples_search tags=[cohort-analysis]- Skills:
grouper-design,crosstab-guide,op-group-set-value
op-group-set-value
name: op-group-set-value
description: Partition rows by the exact set mask — one bucket per unique combination; key = sorted labels joined with "|".
kind: operator
category: GROUP
operator: GROUP_SET_VALUE
type: reference
applies_to: process, compose, predict
examples_tags: [cardinality-analysis, cohort-analysis]
Use when
Groups the rows by the exact combination of options picked in a multi-select answer, such as A only, A and B, or none.
Questions it answers:
- Which combinations of payment methods do customers use, and how common is each?
- How many respondents use our app only, versus our app and a competitor’s?
Use something else:
GROUP_SET_PER_ELEMENTwhen you want one group per option, counting a row in each it picked.
Params
None. Group.Label overrides the output column name; Group.Include []string allow-lists composite keys (pipe-joined sorted labels, "AMEX|VISA") and sets emission order.
Inputs
Field — any set rung: set_u8/set_u16/set_u32/set_u64/set_u128/set_u256.
Output
String bucket key per row ("AMEX|VISA"). Empty-mask rows land under an empty-key bucket — a valid selection, distinct from null.
Non-empty Include is order-significant: buckets emit in listed composite-key order (Data + Components.buckets); empty/absent keeps the prior alphabetical order, byte-identical. Zero-record entries drop.
Components
Floor {total_n, n_null} + n_empty_mask (int, empty zero-bit selections) and buckets ([]bucket of {key, count, labels} per emission) plus exactly one of mask (uint64, when the selection fits 64 bits) or mask_words ([]uint64, low word first, when a set_u128/set_u256 selection reaches bit 64+). Mutually exclusive: a mask holding only a wide selection’s low word is a plausible wrong number. Mergeable; StreamableGrouper, so eligible for fused crosstab.
Gotchas
- Empty mask is a real bucket — does NOT increment
n_null. Null rows do. - Bucket key is the sorted-label pipe-join; source dict order irrelevant.
See
pulse_examples_search tags=[cohort-analysis]- Skills:
grouper-design,response-components,op-group-set-per-element
op-mat-collinearity
name: op-mat-collinearity
description: Collinearity check of candidate regression predictors (no response) — VIF and tolerance per predictor, Belsley's condition indices and variance-decomposition proportions (uncentered with the intercept, or centered), listwise or pairwise, weighted.
kind: operator
category: MAT
operator: MAT_COLLINEARITY
type: reference
applies_to: process, compose, predict
examples_tags: [matrix, correlation-analysis]
Slot and members as for op-mat-covariance. Members are the predictors only.
Use when
Whether candidate predictors overlap so much that a regression on them would give unstable coefficients, and which ones are tangled.
Questions it answers:
- Before I model satisfaction on these eight ratings, are any of them so related that their effects cannot be told apart?
- Which of these predictors are near-duplicates of the others?
Params
| Name | Type | Default | Description |
|---|---|---|---|
center | bool | false | Belsley on centered predictors (no intercept). |
repair | nearest | none | Read the nearest correlation matrix. |
missing, max_drop_share, weight, encoding | As op-mat-correlation. |
Inputs
Integer / float members.
Output
primary: the predictors’ correlation R. vectors: vif (diag R⁻¹), tolerance (1/VIF), condition_indices (ascending, first 1). auxiliary.variance_decomposition: kind rectangular, a row per variable ((intercept) first unless center), columns D1…Dq in condition-index order, rows sum to 1; pairwise auxiliary.n. scalars: condition_number, max_vif.
Reading the output
scalars.max_vif: The largest variance inflation factor among the predictors (each one’s is also listed): how many times larger that predictor’s coefficient variance is than it would be if it were unrelated to the other predictors. 1 means no overlap; tolerance, its reciprocal, is the share of the predictor’s spread the others do not explain.primary.values: The predictors’ correlation table that the variance inflation factors and the centered diagnostics are read from: each off-diagonal cell is the correlation of its row and column predictors, from -1 to +1.
Components
n, n_null, n_listwise_dropped; pairwise min_pair_n / max_pair_n; weighted sum_weights, n_eff, n_weight_invalid.
Gotchas
- Uncentered (default) flags collinearity with the intercept; VIF is the same either way.
- Exactly or near-exactly collinear predictors (rank tolerance): fatal
PULSE_MATRIX_SINGULARwithdependent_fields. Non-PSD pairwise R: fatalPULSE_MATRIX_NOT_PSDunlessrepair. - VIF unbanded: 5 / 10 are rules of thumb only.
See
pulse_examples_search tags=[matrix, correlation-analysis]- Skills:
matrix-results,op-mat-correlation,op-reg-ols,weighting
op-mat-correlation
name: op-mat-correlation
description: Correlation matrix of a vector's numeric members — Pearson (default), Spearman or Kendall tau-b via params.method — listwise or pairwise, weighted; one MatrixResult per spec (per group bucket when grouped), no p-values.
kind: operator
category: MAT
operator: MAT_CORRELATION
type: reference
applies_to: process, compose, predict
examples_tags: [matrix, correlation-analysis]
Slot: matrices[i] {type, vector | fields, params, weight, encoding}; members as for op-mat-covariance.
Use when
How closely every pair of numeric fields moves together, in a line or in rank order, as one square table of values from -1 to 1.
Questions it answers:
- Which of these ten rating items go together most strongly?
- How do the sensor readings line up with one another, pair by pair?
Use something else:
TEST_PEARSON_Rwhen you need a p-value or confidence interval for one pair.
Params
| Name | Type | Default | Description |
|---|---|---|---|
method | pearson | spearman | kendall | pearson | Rank methods: buffered, serial. |
missing | listwise | pairwise | listwise | Pairwise: per-pair rows (rank methods re-rank per pair). |
max_drop_share | 0–1 | none | Listwise drop-share warning. |
summary.top_pairs | int ≥ 1 | none | k strongest pairs. |
weight | slot weight | inherited | Pearson: both kinds; rank: frequency only. null opts out. |
encoding | full | upper | full | upper: row r holds p − r cells. |
Inputs
Integer / float members; packed_bool under coerce: "binary".
Output
primary: r (ρ / τ-b under a rank method) in [−1, 1], diagonal 1; a zero-spread member’s row and column are null. scalars.determinant (null unless PD); pairwise auxiliary.n (pair N); warnings. vectors.top_pairs [{row, col, r, n}]: by |r| desc, ties in axis order, no diagonal / null pairs.
Reading the output
primary.values: Each off-diagonal cell is the correlation of its row and column members under params.method, from -1 to +1. pearson (default): r, how closely the two follow a straight line together; spearman: rho, r on the members’ ranks, how steadily one rises or falls with the other; kendall: tau-b, the share of agreeing minus disagreeing row pairs, tie-adjusted. 0 means no such link. The diagonal is 1.- Bands (Cohen (1988), absolute value): very small below 0.1; small 0.1 to 0.3; medium 0.3 to 0.5; large 0.5 and above.
Components
components.matrices[i]: n, n_null, n_listwise_dropped; pairwise min_pair_n / max_pair_n; weighted sum_weights, n_eff, n_weight_invalid.
Gotchas
- Each cell is the matching two-field test (Pearson, Spearman, Kendall) over its pair’s rows. No p-values (
auxiliary.parrives with U28): run the test per pair. Weight-0 rows count inn, add no mass. - Rank methods: predict
streamable/mergeablefalse; a probability weight isPULSE_WEIGHT_UNSUPPORTED. Kendall τ-b runs smaller than ρ. - Pairwise r at p ≥ 3 can be non-PSD (predict
pairwise_psd_risk) →PULSE_MATRIX_NOT_PSD; also_INSUFFICIENT_N,_ZERO_VARIANCE,_LISTWISE_HEAVY_DROP. - Refusals and grouping:
matrix-results.
See
pulse_examples_search tags=[matrix, correlation-analysis]- Skills:
matrix-results,op-mat-covariance,weighting
op-mat-covariance
name: op-mat-covariance
description: Covariance matrix of a vector's numeric members (listwise or pairwise, sample by default), weighted under frequency and probability weights; one MatrixResult per spec (per group bucket when grouped).
kind: operator
category: MAT
operator: MAT_COVARIANCE
type: reference
applies_to: process, compose, predict
examples_tags: [matrix, covariance]
Slot: matrices[i] {type, vector | fields, params, weight, encoding}; members: vectors[] or inline fields.
Use when
How every pair in a set of numeric fields varies together, as one square table with each field’s variance on the diagonal.
Questions it answers:
- How do these five rating scales vary together across respondents?
- What is the covariance table of the sensor readings, to feed a later model?
Use something else:
TEST_PEARSON_Rwhen you want how closely two numeric fields follow a straight line together, on a scale from -1 to 1.
Params
| Name | Type | Default | Description |
|---|---|---|---|
ddof | 0 | 1 | 1 | Denominator Σw − ddof. |
missing | listwise | pairwise | listwise | Pairwise: per-pair rows. |
max_drop_share | 0–1 | none | Listwise drop-share warning. |
weight | slot weight | inherited | Both kinds; null opts out. |
encoding | full | upper | full | upper: row r holds p − r cells. |
Inputs
Integer/float members; packed_bool under coerce: "binary".
Output
primary covariance, scalars.determinant (null unless PD), pairwise auxiliary.n, warnings; undefined cells null. Grouped: one per bucket (matrix-results).
Components
components.matrices[i]: n, n_null, n_listwise_dropped; pairwise min_pair_n / max_pair_n; weighted sum_weights, n_eff, n_weight_invalid; operator.ddof.
Gotchas
- Warnings:
PULSE_MATRIX_INSUFFICIENT_N,_ZERO_VARIANCE(row stays 0),_LISTWISE_HEAVY_DROP,_NOT_PSD(pairwise). - Weight 0 counts in
n, adds no mass. Predict:pairwise_psd_risk(pairwise, p ≥ 2). summaryis refused (top_pairs:op-mat-correlation). Refusals and worker invariance:matrix-results.
See
pulse_examples_search tags=[covariance]- Skills:
matrix-results,weighting
op-mat-distance
name: op-mat-distance
description: Distances between segment centroids — a k × k matrix over the buckets a grouper (params.between: any built-in, date family included, or a registered extension grouper) cuts the rows into; euclidean, sqeuclidean, manhattan, chebyshev, cosine or mahalanobis (pooled within-bucket covariance); optional standardize; weighted centroids.
kind: operator
category: MAT
operator: MAT_DISTANCE
type: reference
applies_to: process, compose, predict
examples_tags: [matrix, multivariate]
As op-mat-covariance; members are centroid coordinates.
Use when
How far apart segments are across several numbers at once: the gap between each pair of segment centres.
Questions it answers:
- Which customer segments are most alike on spend, visits and tenure, and which stand furthest apart?
- How far apart are the regions’ average profiles across the rating battery?
Use something else:
TEST_MANOVAwhen you want to test whether the centres differ.
Params
between— required grouper spec as ingroups, extension groupers too; no ownweight.metric—euclidean(default),sqeuclidean,manhattan,chebyshev,cosine,mahalanobis.standardize— z-score first.missing(listwise),max_drop_share,weight,encoding:op-mat-correlation.
Inputs
Integer / float members.
Output
primary: k × k, bucket-label keys, zero diagonal. auxiliary.centroids (k × p); vectors.sizes, counts.
Components
n, n_null, n_listwise_dropped; weighted sum_weights, n_eff, n_weight_invalid; metric, standardize, buckets, n_null_key; min_pair_n / max_pair_n never (listwise).
Gotchas
- Streams when
betweendoes; a whole-set or extension grouper buffers. - Date-family between: its
tz, elsetime_zone. GROUP_KMEANS: buffered, frequency weights only.- Massless bucket: null centroid (
PULSE_MATRIX_INSUFFICIENT_N); collinear:PULSE_MATRIX_SINGULAR. Centres only; no p-values.
See
pulse_examples_search tags=[matrix, multivariate]- Skills:
multivariate-outputs op-group-kmeans
op-mat-factor
name: op-mat-factor
description: Exploratory factor analysis of a battery — minres, principal-axis or maximum-likelihood extraction from the SMC, varimax / promax / oblimin rotation, loadings (p × k), communalities, uniquenesses, variance accounted, rmsr, the empirical chi-square, KMO and Bartlett, ML's likelihood chi-square, TLI, RMSEA with its interval and BIC; listwise or pairwise, weighted.
kind: operator
category: MAT
operator: MAT_FACTOR
type: reference
applies_to: process, compose, predict
examples_tags: [matrix, correlation-analysis]
As op-mat-covariance; always correlation.
Use when
Which hidden qualities a set of related measures reflects, and how strongly each measure is tied to each, before building scale scores.
Questions it answers:
- Do these twelve attitude questions reflect two or three underlying qualities, and which questions go with which?
- Which items in this battery tie weakly to every quality and could be dropped?
Params
factors— int k ≤ p orkaiser(default).extraction—minres(default),principal_axis,ml.rotation—none(default),varimax,promax,oblimin;kappa(4) /gamma(0) for promax / oblimin.repair—nearest.conf_level—ml, default 0.9: RMSEA interval coverage.missing,max_drop_share,weight,encoding:op-mat-correlation.
Inputs
Integer / float members.
Output
primary: loadings F1…Fk. auxiliary: unrotated, factor_correlation; vectors: communalities, uniquenesses. scalars: fit figures (rmsr, kmo, …); ml adds likelihood_chisq, tli, rmsea, bic.
Reading the output
primary.values: Each cell is a loading: how strongly the row’s measure is tied to the column’s factor. Without a rotation, or with varimax, it is the correlation between the two; under promax or oblimin it is the measure’s own tie to that factor with the other factors held fixed. A factor is named by the measures with the largest loadings.scalars.bartlett_p: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.
Components
n, n_null, n_listwise_dropped; pairwise min_pair_n / max_pair_n; weighted sum_weights, n_eff, n_weight_invalid.
Gotchas
- (p − k)² < p + k: null fit,
PULSE_MATRIX_NOT_IDENTIFIED. - Uniqueness ≤ 0:
PULSE_MATRIX_HEYWOOD; cap hit:PULSE_MATRIX_NOT_CONVERGED. mlneeds positive-definite R and enough rows.
See
pulse_examples_search tags=[matrix, correlation-analysis]- Skills:
multivariate-outputs,op-mat-pca
op-mat-partial-correlation
name: op-mat-partial-correlation
description: Partial correlation matrix of a vector's numeric members — each pair with every other member, or the params.control fields, held fixed — listwise or pairwise, weighted; non-PSD input refused unless repair "nearest", singular input refused.
kind: operator
category: MAT
operator: MAT_PARTIAL_CORRELATION
type: reference
applies_to: process, compose, predict
examples_tags: [matrix, correlation-analysis]
Slot and members as for op-mat-covariance.
Use when
How closely each pair of numeric fields moves together once other fields are held fixed, as one square table of values from -1 to 1.
Questions it answers:
- Does satisfaction still track price once delivery time is held fixed?
- Which of these ratings are linked directly, rather than only through the overall score?
Use something else:
MAT_CORRELATIONwhen you want each pair’s link with nothing held fixed.
Params
| Name | Type | Default | Description |
|---|---|---|---|
control | "all" | [fields] | all | Held-fixed set; a listed member leaves the axis, an outside field joins the fold. |
repair | nearest | none | Repair non-PSD input. |
missing, max_drop_share, weight, encoding | As op-mat-correlation. |
Inputs
Integer / float members and controls.
Output
primary: partial r over the non-control members, diagonal 1; all null when an input r is undefined. Pairwise auxiliary.n; warnings. No p-values.
Reading the output
primary.values: Each off-diagonal cell is the correlation of its row and column members once the held-fixed fields are taken out of both (every other member, or the params.control fields), from -1 to +1. 0 means no straight-line link is left after holding them fixed. The diagonal is 1.- Bands (Cohen (1988), absolute value): very small below 0.1; small 0.1 to 0.3; medium 0.3 to 0.5; large 0.5 and above.
- Sign: positive means the two fields tend to rise together; negative means one field tends to fall as the other rises.
Components
Over members + outside controls: n, n_null, n_listwise_dropped; pairwise min_pair_n / max_pair_n; weighted sum_weights, n_eff, n_weight_invalid.
Gotchas
- Non-PSD pairwise input: FATAL
PULSE_MATRIX_NOT_PSD;repair: "nearest"(Higham) warns withfrobenius_adjustment. - Collinear fields:
PULSE_MATRIX_SINGULAR(dependent_fields).
See
pulse_examples_search tags=[matrix, correlation-analysis]- Skills:
matrix-results,op-mat-correlation,weighting
op-mat-pca
name: op-mat-pca
description: Principal component analysis of a battery — loadings and eigenvectors (p × k, rectangular), every eigenvalue with explained / cumulative shares, communalities, KMO (overall and per member) and Bartlett's sphericity test; correlation or covariance input, listwise or pairwise, weighted.
kind: operator
category: MAT
operator: MAT_PCA
type: reference
applies_to: process, compose, predict
examples_tags: [matrix, correlation-analysis]
Use when
How many underlying dimensions a set of related measures covers and which measures belong to each, so a few summaries can stand in for many.
Questions it answers:
- How many distinct things do these twelve rating questions actually measure?
- Is this set of measures correlated enough to be worth summarising into components?
Use something else:
MAT_CORRELATIONwhen you only want to see which measures go together, pair by pair.
Params
on—correlation(default) orcovariance.rotation—none,varimax(k ≥ 2).repair—nearest.components— int k (≤ p),kaiser(λ > 1, default) or{variance: s}; required oncovariance.missing,max_drop_share,weight,encoding:op-mat-correlation.
Inputs
Integer / float members.
Output
primary: loadings (eigenvector · √λ), PC1…PCk. auxiliary: eigenvectors, n (pairwise), unrotated. vectors: eigenvalues, explained_variance, cumulative, communalities, kmo_msa, rotated_variance. scalars: kmo, bartlett_*, components_retained.
Reading the output
primary.values: Each cell is a loading: how strongly the row’s measure is tied to the column’s component, the correlation between the two on a correlation input. A component is named by the measures with the largest loadings; components are listed from the one that summarises the most spread down.scalars.kmo: The Kaiser-Meyer-Olkin measure of sampling adequacy: how much of the measures’ correlation is shared across the whole set rather than tied to single pairs, from 0 to 1. Higher means a component summary is more worthwhile.
Components
n, n_null, n_listwise_dropped; pairwise min_pair_n / max_pair_n; weighted sum_weights, n_eff, n_weight_invalid.
Gotchas
- Signs are free. Rotated: read
rotated_variance. - Bartlett’s n: rows, Σw or n_eff, pairwise the smallest pair.
- Singular: KMO, Bartlett null (
PULSE_MATRIX_SINGULAR). Non-PSD pairwise:PULSE_MATRIX_NOT_PSDunlessrepair.
See
pulse_examples_search tags=[matrix, correlation-analysis]- Skills:
multivariate-outputs,op-mat-correlation
op-mat-reliability
name: op-mat-reliability
description: Scale reliability of a battery — Cronbach's alpha, standardized alpha, McDonald's omega (one-factor minres), mean inter-item r, per-item item-total r / alpha if deleted / mean / sd — with reverse-keyed items flipped before the fold; listwise or pairwise, weighted.
kind: operator
category: MAT
operator: MAT_RELIABILITY
type: reference
applies_to: process, compose, predict
examples_tags: [matrix, correlation-analysis]
Slot and members as for op-mat-covariance; at least 2 items.
Use when
How consistently a set of rating items measures one thing, before you add them up into a score, with a check of how each item fits the rest.
Questions it answers:
- Are these five satisfaction questions reliable enough to average into one score?
- Which item in this battery fits the others worst and should be dropped?
Use something else:
MAT_CORRELATIONwhen you only want to see which items go together, pair by pair.
Params
| Name | Type | Default | Description |
|---|---|---|---|
reverse | [fields] | none | Reverse-keyed items: x’ = min + max − x before the fold. |
scale_min, scale_max | number | none | Battery range; both or neither, required with reverse. |
repair | nearest | none | Fit omega on the nearest correlation matrix. |
missing, max_drop_share, weight, encoding | As op-mat-correlation. |
Inputs
Integer / float items.
Output
primary: inter-item correlation. scalars: alpha, alpha_standardized, omega, mean_inter_item_r. vectors (axis order): item_total_r (corrected), alpha_if_deleted (null at 2 items), item_mean, item_sd. Pairwise auxiliary.n; warnings. No p-values.
omega = (Σλ)² / ((Σλ)² + Σψ) from the minres fit (the model form, not psych::omega’s observed-total omega.tot, which reads lower on a misfit battery).
Reading the output
scalars.alpha: Cronbach’s alpha: how consistently the items measure one shared quality, from the share of the summed score’s spread that the items share. 1 means the items move in lockstep; it can fall below 0 when items pull against each other, which usually means a reverse-worded item was not reversed.- Caveat: A high alpha does not show the items measure only one thing.
scalars.alpha_standardized: Alpha computed on the items’ correlations instead of their raw spreads: the alpha the battery would have if every item were first put on the same scale.
Components
n, n_null, n_listwise_dropped; pairwise min_pair_n / max_pair_n; weighted sum_weights, n_eff, n_weight_invalid; iterations, converged when the omega fit ran.
Gotchas
reversewithout the range, or an item value outside it:PROCESSING_CONFIG(never clamped or inferred).- omega null + warning: 2 items (
PULSE_MATRIX_NOT_IDENTIFIED), a Heywood fit (PULSE_MATRIX_HEYWOOD), non-PSD pairwise input withoutrepair(PULSE_MATRIX_NOT_PSD). Alpha always computes. - Negative alpha usually means an unreversed item.
See
pulse_examples_search tags=[matrix, correlation-analysis]- Skills:
matrix-results,op-mat-correlation,weighting
op-overlay-chisq-col
name: op-overlay-chisq-col
kind: operator
category: OVERLAY
operator: OVERLAY_CHISQ_COL
description: Per-column χ² goodness-of-fit test across the host crosstab's contingency table.
type: reference
applies_to: process, compose
examples_tags: [overlay, cross-tabulation, hypothesis-test]
Overlays decorate the host; no Response.Components.
Use when
Chi-square test per crosstab column: checks whether each column’s spread across the rows departs from the table’s overall row mix.
Questions it answers:
- Which banner columns have an answer mix unlike the total?
- Which months have a spread of order types unlike the whole year?
Use something else:
OVERLAY_CHISQ_ROWwhen the groups are the rows rather than the columns.
Params
Scope (enum, required) — must be column. Ref (object, empty) — implicit-margin — leave empty. Level/Within must be 0. Other Ref arms → PULSE_OVERLAY_REF_INCOMPATIBLE_WITH_SHAPE.
multiplicity— optional{method, family, alpha}; addsp_adjustedfigures, raw p untouched (multiplicity-correction).
Host shape
MATRIX crosstab (Response.Crosstab.Matrix). Compatible with any crosstab regardless of cell aggregator; reads observed × expected from row/column margins recomputed by the buffered orchestrator.
Output
SERIES — OverlayLayer.Payload.Shape = "series". One SeriesEntry per column key carrying Summary.Statistic (χ² value), Summary.PValue, Summary.Parameters["df"] = rows - 1. Layer Baseline unset (inferential).
Reading the output
summary.statistic: In each series entry (one per crosstab column): chi-square for that column’s counts against the counts its column total would give at the table’s overall row shares. 0 means the column’s mix matches the overall mix; read the entry’s p_value rather than the raw value.summary.p_value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.
Gotchas
- Weighted host (both kinds, any source): cells are Σw; under probability each column is scaled to its margin’s Kish
n_eff(expected-low on the scaled column) — first-order Kish, not Rao-Scott. Each entry’sParametersadds that column’ssum_weights(+n_eff). Probability host with components disabled ⇒PROCESSING_CONFIG(non_effto scale by; predict AND runtime). - Reuses
chiSquareSurvival— p-values byte-equal to every χ² test and overlay on the same contingency. - Any
expected < 5in a column emits ONEPULSE_OVERLAY_EXPECTED_LOWper offending column. - Absent host cell treated as observed count of 0.
- Buffered (inherent — host crosstab path always recomputes margins from raw rows).
See
op-overlay-chisq-matrix
name: op-overlay-chisq-matrix
kind: operator
category: OVERLAY
operator: OVERLAY_CHISQ_MATRIX
description: Whole-matrix χ² independence test across the host crosstab's row × column contingency table.
type: reference
applies_to: process, compose
examples_tags: [overlay, cross-tabulation, hypothesis-test]
Overlays decorate the host; no Response.Components.
Use when
Chi-square test on a whole crosstab: checks whether the row and column categories are associated, with one p-value for the table.
Questions it answers:
- Is preferred channel associated with age band in this crosstab?
- Does the mix of answers differ across regions in the table?
Use something else:
OVERLAY_CHISQ_ROWwhen you want to know which rows have a mix unlike the overall one.
Params
Scope (enum, required) — must be matrix. Ref (object, empty) — implicit-margin — leave empty. Level/Within must be 0.
multiplicity— optional{method, family, alpha}; addsp_adjustedfigures, raw p untouched (multiplicity-correction).
Host shape
MATRIX crosstab (Response.Crosstab.Matrix). SCALAR-payload pattern shared by sibling χ² / post-test overlays.
Output
SCALAR — OverlayLayer.Payload.Shape = "scalar". Payload.Scalar carries χ²; OverlaySummary{Statistic, PValue, Parameters["df"]} where df = (rows-1)*(cols-1). Layer Baseline unset (inferential — no ratio centerpoint).
Reading the output
scalar: The chi-square statistic for the whole crosstab, also in summary.statistic: the sum over cells of (observed - expected)^2 / expected, where expected is the count the row and column totals predict if the two fields were independent. 0 means the table matches that pattern exactly; how far above 0 is unusual depends on summary.parameters.df, so read summary.p_value rather than the raw value.- Caveat: No continuity (Yates) correction is applied.
summary.statistic: The same chi-square statistic as scalar.
Gotchas
- Weighted host (both kinds, any source): cells are Σw. Frequency: Pearson on the Σw table. Probability: the table scaled to its Kish
n_efffirst (PULSE_OVERLAY_EXPECTED_LOWon the scaled expected) — a first-order Kish approximation, not Rao-Scott.Summary.Parametersaddssum_weights(+n_eff). Probability host with components disabled ⇒PROCESSING_CONFIG(non_effto scale by; predict AND runtime). - Expected cell formula:
row_margin × col_margin / grand_total. p-value viachiSquareSurvival— byte-equal to the χ² test on the same contingency. - Any
expected < 5→ ONEPULSE_OVERLAY_EXPECTED_LOWper layer. - Absent host cell treated as observed count of 0.
- Scope MUST be
matrix. PopulatedRefarm →PULSE_OVERLAY_REF_INCOMPATIBLE_WITH_SHAPE. - Buffered (inherent — margins recomputed from raw rows).
See
op-overlay-chisq-row
name: op-overlay-chisq-row
kind: operator
category: OVERLAY
operator: OVERLAY_CHISQ_ROW
description: Per-row χ² goodness-of-fit test across the host crosstab's contingency table.
type: reference
applies_to: process, compose
examples_tags: [overlay, cross-tabulation, hypothesis-test]
Overlays decorate the host; no Response.Components.
Use when
Chi-square test per crosstab row: checks whether each row’s spread across the columns departs from the table’s overall column mix.
Questions it answers:
- Which regions have an answer mix unlike the overall mix?
- Which product lines get a different spread of complaint types?
Use something else:
OVERLAY_CHISQ_COLwhen the groups are the columns rather than the rows.
Params
Scope (enum, required) — must be row. Ref (object, empty) — implicit-margin — leave empty. Level/Within must be 0.
multiplicity— optional{method, family, alpha}; addsp_adjustedfigures, raw p untouched (multiplicity-correction).
Host shape
MATRIX crosstab (Response.Crosstab.Matrix). Family: implicit-margin χ² (no Ref). Compatible with any crosstab regardless of cell aggregator; row-axis twin of the column χ².
Output
SERIES — OverlayLayer.Payload.Shape = "series". One SeriesEntry per row key carrying Summary.Statistic (row χ²), Summary.PValue, Summary.Parameters["df"] = cols - 1. Entries align element-for-element with host RowKeys. Layer Baseline unset.
Reading the output
summary.statistic: In each series entry (one per crosstab row): chi-square for that row’s counts against the counts its row total would give at the table’s overall column shares. 0 means the row’s mix matches the overall mix; read the entry’s p_value rather than the raw value.summary.p_value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.
Gotchas
- Weighted host (both kinds, any source): cells are Σw; under probability each row is scaled to its margin’s Kish
n_eff(expected-low on the scaled row) — first-order Kish, not Rao-Scott. Each entry’sParametersadds that row’ssum_weights(+n_eff). Probability host with components disabled ⇒PROCESSING_CONFIG(non_effto scale by; predict AND runtime). - Reuses
chiSquareSurvival— byte-equal p-values to every χ² test and overlay on the same contingency. - Any
expected < 5in a row emits ONEPULSE_OVERLAY_EXPECTED_LOWper offending row. - Absent host cell treated as observed count of 0.
- Scope MUST be
row. PopulatedRefarm →PULSE_OVERLAY_REF_INCOMPATIBLE_WITH_SHAPE. - Buffered (inherent).
See
op-overlay-chisq-vs-pop
name: op-overlay-chisq-vs-pop
kind: operator
category: OVERLAY
operator: OVERLAY_CHISQ_VS_POP
description: χ² goodness-of-fit comparing host Facet subset distribution against the resolved population distribution.
type: reference
applies_to: facet
examples_tags: [overlay, facet, hypothesis-test]
Rides on FacetRequest.Overlays. Overlays decorate the host; no Response.Components.
Use when
Chi-square goodness-of-fit test on a facet: checks whether a subset’s category mix departs from a comparison population’s mix.
Questions it answers:
- Does this segment’s brand mix differ from the whole customer base’s?
- Is the subset’s spread of ticket types unlike the population’s?
Use something else:
OVERLAY_KS_VS_POPwhen the field is numeric.
Params
Scope required, must be group. Ref.Population required — {Cohort: "<name>"}, the comparison-population cohort; other Ref arms → PULSE_OVERLAY_REF_INCOMPATIBLE_WITH_SHAPE. Level / Within must be 0.
multiplicity— optional{method, family, alpha}; addsp_adjustedfigures, raw p untouched (multiplicity-correction).
Host shape
FACET — discrete arm only; numeric host → PULSE_OVERLAY_REF_INCOMPATIBLE_WITH_SHAPE.
Output
SCALAR — Payload.Scalar carries χ²; OverlaySummary{Statistic, PValue, Parameters["df"]}, df = compared categories − 1. Baseline unset.
Reading the output
scalar: The chi-square statistic, also in summary.statistic: the subset’s category counts against the counts the population’s shares predict at the subset’s size, with the shares rescaled over the categories both sides show (population nulls and categories cut from a top-K listing are left out). 0 means the subset’s mix matches the population’s; read summary.p_value rather than the raw value.summary.statistic: The same chi-square statistic as scalar.
Gotchas
- Pop shares renormalise over the compared categories (pop nulls / top-K tail ignored): R
chisq.test(x, p, rescale.p=TRUE). A subset value the pop lacks is dropped from χ², N,df+ onePULSE_OVERLAY_REF_ZERO. Byte-equal survival to the χ² test. - Any
expected < 5→ ONEPULSE_OVERLAY_EXPECTED_LOWper layer, carrying the low-expected category count. - Empty host distribution, empty population, or no compared category → NaN statistic +
PULSE_OVERLAY_REF_ZERO. Single category (df = 0) → NaN p-value. - Buffered (inferential — FacetSchema post-finalize hook; byte-identical streaming vs buffered).
See
op-overlay-chisq-vs-ref
name: op-overlay-chisq-vs-ref
kind: operator
category: OVERLAY
operator: OVERLAY_CHISQ_VS_REF
description: Compose-host whole-matrix χ² comparing target slot's matrix against the reference slot's matrix (rescaled to target N).
type: reference
applies_to: compose
examples_tags: [overlay, compose, cross-tabulation, hypothesis-test]
Compose-only. Overlays decorate the host; no Response.Components.
Use when
Chi-square test in a Compose request: checks whether a target request’s crosstab mix departs from the reference request’s mix.
Questions it answers:
- Has the mix of answers in this wave shifted from last wave?
- Does the test market’s category mix differ from the control market’s?
Use something else:
OVERLAY_PROP_Z_CELLwhen you want to see which cells moved rather than one overall test.
Params
Scope must be matrix. Reference required — reference slot label. Targets required — one target slot label.
multiplicity— optional{method, family, alpha}; addsp_adjustedfigures, raw p untouched (multiplicity-correction).
Host shape
COMPOSE — MATRIX crosstab on reference + target. Schema-match + key-alignment gates at the slot barrier; OverlayOptions.DictPrefixFast enables the byte-equal dictionary prefix probe.
Output
SCALAR — Payload.Scalar carries the p-value (= Summary.PValue), NOT χ²; χ² is on Summary.Statistic, plus Parameters["df"]. One layer per target.
Reading the output
scalar: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.summary.p_value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.summary.parameters.df: Degrees of freedom: the number of cells present in both requests with a positive expected count, minus 1.
Gotchas
- Weighted slots (both kinds): cells are Σw; under probability the target table is scaled to its Kish
n_eff(the reference is the fixed distribution; expected-low on the scaled table) — first-order Kish, not Rao-Scott.Summary.Parametersadds the target’ssum_weights(+n_eff). A probability TARGET with components disabled ⇒PROCESSING_CONFIG(predict AND runtime). - Reference scaled to target N:
expected = ref_cell × (target_N / ref_N). ReuseschiSquareSurvival— byte-equal to the χ² test on the same contingency. df = (target cells with expected > 0) - 1.- Any
expected < 5→ ONEPULSE_OVERLAY_EXPECTED_LOWper layer (canonical χ² low-count rule). target_N == 0,ref_N == 0, or everyexpected == 0→ NaN +PULSE_OVERLAY_REF_ZERO.- Dict-prefix drift between slots →
PULSE_OVERLAY_COMPOSE_DICT_DIVERGENCE(underDictPrefixFast). - Buffered (inferential; the Compose slot barrier always buffers).
See
op-overlay-delta-vs-baseline
name: op-overlay-delta-vs-baseline
kind: operator
category: OVERLAY
operator: OVERLAY_DELTA_VS_BASELINE
description: Per-point additive delta against a fixed positional baseline of an ordered SERIES host.
type: reference
applies_to: process, compose
examples_tags: [overlay, time-series, before-after]
Overlays decorate the host; no Response.Components.
Use when
Each point of an ordered series minus a chosen baseline point, in the value’s own units: how much it has moved since then.
Questions it answers:
- How many more orders a week do we get than in the launch week?
- How many points has satisfaction moved since the first wave?
Use something else:
OVERLAY_INDEX_VS_BASELINEwhen you want a ratio rather than a gap.
Params
Scope must be group. Ref.BaselineIndex.Position (int, required) — >= 0; positional anchor in host order. Level/Within must be 0. Other Ref arms → PULSE_OVERLAY_REF_INCOMPATIBLE_WITH_SHAPE.
Host shape
SERIES — ordered grouped Process host (e.g. a date grouper). Subtractive twin of the baseline index.
Output
SERIES — one SeriesEntry per host group key in host order, carrying delta = point - baseline on Summary.Statistic. Baseline ordinal itself emits 0.0 (self-vs-self). Layer Baseline = 0 (delta family centerpoint).
Gotchas
- Out-of-range
Position→PULSE_OVERLAY_REF_UNKNOWN(predict + runtime viaResolveBaselineIndex). - Zero baseline → no warning (subtraction defined for every finite value; delta becomes raw host value). The ratio twin raises
PULSE_OVERLAY_REF_ZEROinstead. - Absent host point →
SeriesEntrywith unsetStatistic(canonical absent-slot shape). - Buffered —
host.ValueAt(Position)read post-finalize viaApplyOverlaysSeries.
See
- Skills:
overlay-system,op-overlay-index-vs-baseline.
op-overlay-delta-vs-margin
name: op-overlay-delta-vs-margin
kind: operator
category: OVERLAY
operator: OVERLAY_DELTA_VS_MARGIN
description: Per-cell additive delta against the matching axis margin (cell − margin).
type: reference
applies_to: process, compose
examples_tags: [overlay, cross-tabulation, before-after]
Overlays decorate the host; no Response.Components.
Use when
Each crosstab cell minus its row, column or grand margin, in the cell’s own units: how far a cell sits above or below its margin.
Questions it answers:
- How many points above or below the overall average rating is each segment, per question?
- Which store-by-month cells sit furthest from their store’s overall average?
Use something else:
OVERLAY_INDEX_VS_MARGINwhen you want a ratio rather than a gap.
Params
Scope must be cell. Ref.Margin.Axis (enum, required) — row / column / grand. Level/Within optional (default 0): nested-axis prefix truncation of the margin denominator, each in [0, axis depth) else PULSE_OVERLAY_LEVEL_OUT_OF_RANGE. Other Ref arms → PULSE_OVERLAY_REF_INCOMPATIBLE_WITH_SHAPE.
Host shape
MATRIX crosstab (Response.Crosstab.Matrix). Subtractive sibling of the margin index (ratio) and the margin z-score (standardized). Compatible with any cell aggregator.
Output
MATRIX — OverlayLayer.Payload.Matrix.Cells[r][c].Value = cell - margin. Mirrors host RowKeys / ColumnKeys / headers. Absent host cells stay absent on the overlay. Layer Baseline = 0 (delta family centerpoint).
Gotchas
- Preserves host cell’s units — a $-valued summed cell minus a $-valued row margin yields a $-valued deviation in the same currency.
- No division — never raises
PULSE_OVERLAY_REF_ZERO. Unlike the margin index and the share overlays. Axis = grandis supported (all three axes).- Buffered (inherent — host crosstab path always recomputes margins from raw rows).
See
op-overlay-delta-vs-prior
name: op-overlay-delta-vs-prior
kind: operator
category: OVERLAY
operator: OVERLAY_DELTA_VS_PRIOR
description: Per-point streamable windowed additive delta against the immediately preceding point of an ordered SERIES host.
type: reference
applies_to: process, compose
examples_tags: [overlay, time-series, trend-detection, streaming-friendly]
Overlays decorate the host; no Response.Components.
Use when
Each point of an ordered series minus the point before it, in the value’s own units: how much it changed from the previous period.
Questions it answers:
- How many more or fewer orders did we get than the month before?
- How many points did satisfaction move from each wave to the next?
Use something else:
OVERLAY_INDEX_VS_PRIORwhen you want the change as a ratio.
Params
Scope must be group. Ref.Prior (object, empty) — implicit-default — empty Ref also accepted. Level/Within must be 0.
Host shape
SERIES — ordered grouped Process host. Family: windowed prior (Ref.Prior).
Output
SERIES — one SeriesEntry per host group key in host order, carrying delta = point − prior on Summary.Statistic, in the metric’s own units. Layer Baseline = 0.
Choosing between this and the index twin
Absolute-difference twin of the prior-period index, as the baseline delta is to the baseline index. Same host, same ref arm, same carrier — subtraction instead of division.
Pick this one for “how much did it change”; pick the index for “what proportion of the prior is this”. An index of 101.6 and a delta of +1.5 answer different questions and are not interchangeable in a report.
Gotchas
- Single-state lag carrier (one
float64) — streamable inside the streaming Process fold. - First ordinal → NaN, no warning. A delta of
0would CLAIM nothing changed about a comparison that was never made. - Absent host point → NaN + carrier does NOT advance (next present point still subtracts the last present value).
- NEVER emits
PULSE_OVERLAY_REF_ZERO— unlike the index twin. A prior of exactly zero is a valid subtrahend (value − 0 = value); there is no degenerate-denominator branch to hit. Its absence is deliberate, not an omission. Ref.Prior.Lagreserved for future window-N priors; v1 ships lag-1 only.- Empty
Refand populatedRef.Priorboth spell lag-1. - Other
Refarms →PULSE_OVERLAY_REF_INCOMPATIBLE_WITH_SHAPE.
See
op-overlay-delta-vs-ref
name: op-overlay-delta-vs-ref
kind: operator
category: OVERLAY
operator: OVERLAY_DELTA_VS_REF
description: Compose-host additive delta of target slot value against the matching reference slot value (matrix or series).
type: reference
applies_to: compose
examples_tags: [overlay, compose, before-after]
Compose-only dual-shape. Overlays decorate the host; no Response.Components.
Use when
Each cell or group of a target Compose request minus the same coordinate in the reference request, in the target’s own units.
Questions it answers:
- How many points did each segment’s score move since last wave?
- How much more or less did each region sell this year than last?
Use something else:
OVERLAY_INDEX_VS_REFwhen you want a ratio rather than a gap.OVERLAY_T_CELLwhen you want a test of whether the means differ.
Params
Scope (enum, required) — cell (matrix host) or group (series host). Reference (string, required) — reference slot label. Targets ([]string, required) — target slot labels (one or more). Other Ref arms → PULSE_OVERLAY_REF_INCOMPATIBLE_WITH_SHAPE.
Host shape
COMPOSE dual-shape: MATRIX crosstab OR SERIES grouped Process on reference + target. Schema-match, key-alignment and dict-prefix gates run at the slot barrier. Subtractive twin of the reference index.
Output
MATRIX (cell host) or SERIES (group host) — per-coordinate delta = target - ref. Preserves target slot’s units. Layer Baseline = 0.
Gotchas
- No division — zero reference never raises
PULSE_OVERLAY_REF_ZERO(mirrors per-Request DELTA family). - Target key absent from the reference →
PULSE_OVERLAY_REF_ZEROwithref_missing=true, entry NaN. - SERIES dispatch is fold-only (one accumulator per group), streamable per
OverlayStreamability; MATRIX is forced buffered by the slot barrier.
See
op-overlay-delta-vs-sibling
name: op-overlay-delta-vs-sibling
kind: operator
category: OVERLAY
operator: OVERLAY_DELTA_VS_SIBLING
description: Per-group additive delta against a sibling group named by (Field, Value) on the SERIES host.
type: reference
applies_to: process, compose
examples_tags: [overlay, comparison, before-after]
Overlays decorate the host; no Response.Components.
Use when
Each group’s value minus one named group’s value, in the value’s own units: how far every group sits from, say, the control group.
Questions it answers:
- How much more or less revenue than the flagship store does each store bring in?
- How many points above or below the control arm is each arm’s average?
Use something else:
OVERLAY_INDEX_VS_SIBLINGwhen you want a ratio rather than a gap.
Params
Scope must be group. Ref.Sibling.Field (string, required) — grouper field on the host. Ref.Sibling.Value (string, required) — axis-key value identifying the sibling group. Other Ref arms → PULSE_OVERLAY_REF_INCOMPATIBLE_WITH_SHAPE.
Host shape
SERIES — grouped Process host. Subtractive twin of the sibling index. Sibling resolved via internal/processing/overlay_sibling_resolver.go.
Output
SERIES — one SeriesEntry per host group, carrying delta = group - sibling on Summary.Statistic. Sibling group itself emits 0.0 (self-vs-self). Layer Baseline = 0.
Gotchas
- Unknown
(Field, Value)pair → ONEPULSE_OVERLAY_REF_UNKNOWNper layer + NaN across entries. - Zero sibling → no warning (subtraction is defined; delta becomes the raw group value), unlike the ratio twin.
- Absent host group →
SeriesEntrywith unsetStatistic. - Buffered — the sibling resolver needs materialised per-group accumulators (
ApplyOverlaysSeries).
See
op-overlay-delta-vs-stage
name: op-overlay-delta-vs-stage
kind: operator
category: OVERLAY
operator: OVERLAY_DELTA_VS_STAGE
description: Whole-chain additive delta of target stage's result against the reference stage's result.
type: reference
applies_to: process
examples_tags: [overlay, before-after]
Lives on ChainRequest.Overlays (dual-slot host — ChainOverlaySpec). Decorates ChainResponse.Overlays; per-stage overlays are independent. Overlays emit no Response.Components.
Use when
A process-chain stage’s result minus an earlier stage’s result at the same coordinate, in the later stage’s own units.
Questions it answers:
- How many records or how much revenue does each filtering stage remove?
- How far does the refined stage’s figure move from the first stage’s?
Use something else:
OVERLAY_INDEX_VS_STAGEwhen you want a ratio rather than a gap.
Params
Scope required, echoed verbatim — the capability declares total (there is no chain scope). ChainOverlaySpec has no Level / Within. Ref (a StageRef) required — {Index: N} or {Name: "stage-id"}. Target — {Index} or {Name}, default the latest stage.
Host shape
CHAIN — ProcessChain with reference + target stage’s host result shape (scalar / series / matrix). Subtractive twin of the stage index.
Output
Shape inherited from target stage. Per-coordinate delta = target_val - ref_val, in the target stage’s own units. Layer Baseline = 0.
Gotchas
- No division — zero reference never raises
PULSE_OVERLAY_REF_ZERO. Distinct from the ratio twin. - Target shape ≠ ref shape →
PULSE_OVERLAY_CHAIN_STAGE_SHAPE_DIVERGENT+ NaN across coordinates. - Unknown stage →
PULSE_OVERLAY_REF_UNKNOWN(PULSE_OVERLAY_TARGET_UNKNOWNwhen it lands). - Buffered (whole-chain barrier runs after every stage finalises by construction).
See
- Skills:
overlay-system,process-chain,op-overlay-index-vs-stage.
op-overlay-fisher-exact-cell
name: op-overlay-fisher-exact-cell
kind: operator
category: OVERLAY
operator: OVERLAY_FISHER_EXACT_CELL
description: Per-cell Fisher's exact two-sided p-value over a 2×2 contingency formed from each host crosstab cell + its margins.
type: reference
applies_to: process, compose
examples_tags: [overlay, cross-tabulation, hypothesis-test, exact-test, small-sample]
Overlays decorate the host; no Response.Components.
Use when
Fisher’s exact test for every crosstab cell: checks whether being in that row goes with being in that column more or less than expected.
Questions it answers:
- Which answer-by-segment cells stand out in this banner table?
- In a small table, which cells hold more cases than the margins predict?
Use something else:
OVERLAY_CHISQ_MATRIXwhen you want one test for the whole table.
Params
Scope must be cell. Ref (object, empty) — implicit-margin — leave empty. Level/Within must be 0.
multiplicity— optional{method, family, alpha}; addsp_adjustedfigures, raw p untouched (multiplicity-correction).
Host shape
MATRIX crosstab. Implicit-margin family (no Ref). Canonical low-count χ² backstop — the correct surface when expected < 5 breaks χ² approximation.
Output
MATRIX — Cells[r][c].Value = two-sided p-value as float64. Mirrors host RowKeys / ColumnKeys. Absent host cells stay absent on the overlay. Layer Baseline unset (inferential).
Reading the output
cells.value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.- Caveat: Each cell holds the two-sided exact p-value of its own 2x2 table: this row versus the rest, by this column versus the rest.
- Caveat: It does not say which way or how far the cell departs: compare the cell’s count with what its row and column totals predict (or an OVERLAY_INDEX_VS_MARGIN layer) for direction and size.
Gotchas
- Frequency-only: on a frequency-weighted host the exact test runs on the integer Σw table and
Summary.Parametersaddssum_weights; a probability weight — on the overlay slot OR only on the host cell — isPULSE_WEIGHT_UNSUPPORTEDnaming the kind (predict AND runtime). - 2×2 contingency:
[cell, row_margin-cell; col_margin-cell, grand-row_margin-col_margin+cell]. ReuseslogHypergeometric— byte-equal to the Fisher exact test on the same 2×2. - Cochran rule: any of four expected counts
< 1OR>= 20%of expected< 5→ ONEPULSE_OVERLAY_EXPECTED_LOWper offending cell. Advisory only — Fisher stays exact. grand_total <= 0→ absent cells everywhere + ONEPULSE_OVERLAY_REF_ZERO.- Populated
Refarm →PULSE_OVERLAY_REF_INCOMPATIBLE_WITH_SHAPE. - Buffered (inherent).
See
op-overlay-formula
name: op-overlay-formula
kind: operator
category: OVERLAY
operator: OVERLAY_FORMULA
description: Expression-driven projection computed via expr-lang against a per-host-shape variable namespace.
type: reference
applies_to: process, compose
examples_tags: [overlay, feature-engineering]
Overlays decorate the host; no Response.Components.
Use when
Computes a custom figure for every cell, group or total from an expression over the value and its margins, totals or prior point.
Questions it answers:
- Can I show each cell as its gap from the row margin divided by the grand total?
- Can I flag each month whose value is more than 10% above the prior month?
Use something else:
ATTR_FORMULAwhen you need a derived field on every row before aggregation.
Params
Scope required — cell (MATRIX) / group (SERIES) / total (SCALAR). params.formula required — an expr-lang/expr expression. params.baseline_position (int, optional) — SERIES only, opts in the baseline variable.
Host shape
ANY shape. Per-shape namespace via types.FormulaNamespace:
- MATRIX:
cell,margin_row|col|grand,sd_row|col|grand(+ref_cellon Compose). - SERIES:
value,total,prior(+ opt-inbaseline,ref_valueon Compose). - SCALAR:
value(+refon Compose).
Output
Shape matches host; each evaluation yields one float64. Layer Baseline unset.
Gotchas
- Compile-once / run-many via
expr.Compile. - Predict-time AST walk validates identifiers; unknown →
PULSE_OVERLAY_FORMULA_INVALID_IDENT+ allowed set. - Parse error →
PULSE_OVERLAY_FORMULA_PARSE_ERROR; non-coercible result →PULSE_OVERLAY_FORMULA_TYPE_MISMATCH. - Embedder
ExprFunctionswiden the function surface; variables are fixed per host shape (widen via a customOverlayKindsentry).lookup(...)needs a registered LookupTable. - Built-ins: scalar
sqrt/log/exp/powand the set helpers; the vector functions (vmean,dot, …) arePULSE_OVERLAY_FORMULA_INVALID_IDENTon predict (request and Compose hosts) and runtime. total(expr)is a formula-attribute-only function: calling it here (Compose host included) isPULSE_FORMULA_TOTAL_SITEon predict and runtime. Use thetotalvariable (SERIES) instead.- Buffered at v1 (margins / totals / SDs need post-fold state).
See
- Skills:
overlay-system,op-attr-formula;docs/src/internals/extension-points.md.
op-overlay-index-vs-baseline
name: op-overlay-index-vs-baseline
kind: operator
category: OVERLAY
operator: OVERLAY_INDEX_VS_BASELINE
description: Per-point ratio index against a fixed positional baseline of an ordered SERIES host (×100).
type: reference
applies_to: process, compose
examples_tags: [overlay, time-series, comparison]
Overlays decorate the host; no Response.Components.
Use when
Each point of an ordered series as an index value against a chosen baseline point (point / baseline x 100), e.g. growth since launch.
Questions it answers:
- How have monthly sales grown relative to the launch month?
- How does each wave’s score compare with the first wave, scaled to 100?
Use something else:
OVERLAY_DELTA_VS_BASELINEwhen you want the gap in the value’s own units.
Params
Scope must be group. Ref.BaselineIndex.Position (int, required) — >= 0; positional anchor in host order. Level/Within must be 0. Other Ref arms → PULSE_OVERLAY_REF_INCOMPATIBLE_WITH_SHAPE.
Host shape
SERIES — ordered grouped Process host (e.g. a date grouper). Ratio twin of the baseline delta.
Output
SERIES — one SeriesEntry per host group key in host order, carrying index = point / baseline × 100 on Summary.Statistic. Baseline ordinal itself emits 100.0 (self-vs-self). Layer Baseline = 100.
Gotchas
- Out-of-range
Position→PULSE_OVERLAY_REF_UNKNOWN(predict + runtime viaResolveBaselineIndex). - Zero baseline → NaN across entries + ONE
PULSE_OVERLAY_REF_ZEROper layer, unlike the subtractive twin. - Absent host point →
SeriesEntrywith unsetStatistic. - Absent baseline ordinal yields
0.0from host → routes to zero-baseline arm. - Buffered —
host.ValueAt(Position)post-finalize viaApplyOverlaysSeries. - Weighted host → weighted figure (reads the host payload).
See
op-overlay-index-vs-margin
name: op-overlay-index-vs-margin
kind: operator
category: OVERLAY
operator: OVERLAY_INDEX_VS_MARGIN
description: Per-cell index against the matching axis margin (100 × cell / margin).
type: reference
applies_to: process, compose
examples_tags: [overlay, cross-tabulation, comparison]
Overlays decorate the host; no Response.Components.
Use when
Index value of each crosstab cell against its row, column or grand margin (cell / margin x 100): which cells over- or under-index?
Questions it answers:
- Which segments over-index on each answer compared with the total?
- Which product-by-region cells are well above their region’s average?
Use something else:
OVERLAY_SHARE_OF_ROWwhen you want the raw share (0 to 1) of the row.
Params
Scope (enum, required) — cell, row, or column. Ref.Margin.Axis (enum, required) — row / column / grand. Level/Within optional (default 0): nested-axis prefix truncation of the margin denominator, each in [0, axis depth) else PULSE_OVERLAY_LEVEL_OUT_OF_RANGE.
Host shape
MATRIX crosstab (Response.Crosstab.Matrix). Ratio sibling of the margin delta and the margin z-score. Foundational kind establishing the share-margin pattern.
Output
MATRIX (cell scope) or SERIES (row/column scope). Cells[r][c].Value = 100 × cell / margin. Mirrors host RowKeys / ColumnKeys. Absent host cells stay absent. Layer Baseline = 100 (ratio family centerpoint).
Gotchas
margin == 0→ NaN cell + ONEPULSE_OVERLAY_REF_ZEROper affected slice (not per cell).- All three axes supported.
Axis = grandis the grand-total share × 100. - Empty
Ref.Margin→PULSE_OVERLAY_REF_INCOMPATIBLE_WITH_SHAPE. - Distinct from
OVERLAY_SHARE_OF_*(raw ratio, no ×100). Kind names kept distinct — don’t authoring-confuse. - Buffered (host crosstab always recomputes margins from raw rows).
- Weighted host → weighted figure (reads the host payload).
See
op-overlay-index-vs-pop
name: op-overlay-index-vs-pop
kind: operator
category: OVERLAY
operator: OVERLAY_INDEX_VS_POP
description: Per-value population-comparison index for a Facet host (subset_freq / pop_freq × 100).
type: reference
applies_to: facet
examples_tags: [overlay, facet, comparison]
Rides on FacetRequest.Overlays. Overlays decorate the host; no Response.Components.
Use when
Each category’s (or histogram bin’s) share in a facet subset as an index value against its share in a comparison population (x 100).
Questions it answers:
- Which brands over-index among young buyers compared with all buyers?
- Which price bands are over-represented in this segment?
Use something else:
OVERLAY_CHISQ_VS_POPwhen you want one test of whether the subset’s mix differs.
Params
Scope must be group. Ref.Population.Cohort (string, required) — comparison-population cohort name. Level/Within must be 0. Other Ref arms → PULSE_OVERLAY_REF_INCOMPATIBLE_WITH_SHAPE.
Host shape
FACET — FacetResult, discrete arm or numeric arm with IncludeHistogram=true. Population view via processing.FacetPopulationView.
Output
SERIES — one SeriesEntry per host value in payload order (categorical walks FacetDiscrete.Values, numeric walks histogram bins), carrying index = subset_freq / pop_freq × 100 on Summary.Statistic. Layer Baseline = 100.
Gotchas
pop_freq == 0, or a value absent from the population dict (treated as zero pop_freq) → ONEPULSE_OVERLAY_REF_ZEROper affected entry + SKIP it (Statisticunset).- Numeric host without
IncludeHistogram→ zero entries (no per-value buckets). - Unknown population field →
PULSE_OVERLAY_REF_UNKNOWNfrom the resolver. - Streamable — post-finalize fold; byte-identical streaming vs buffered.
See
op-overlay-index-vs-prior
name: op-overlay-index-vs-prior
kind: operator
category: OVERLAY
operator: OVERLAY_INDEX_VS_PRIOR
description: Per-point streamable windowed index against the immediately preceding point of an ordered SERIES host (×100).
type: reference
applies_to: process, compose
examples_tags: [overlay, time-series, trend-detection, streaming-friendly]
Overlays decorate the host; no Response.Components.
Use when
Each point of an ordered series as an index value against the point before it (point / prior x 100): period-on-period change.
Questions it answers:
- By how much did sales grow or shrink from each month to the next?
- Is this week’s volume above or below last week’s, scaled to 100?
Use something else:
OVERLAY_DELTA_VS_PRIORwhen you want the change in the value’s own units.
Params
Scope must be group. Ref.Prior (object, empty) — implicit-default — empty Ref also accepted. Level/Within must be 0.
Host shape
SERIES — ordered grouped Process host. Family: windowed prior (Ref.Prior). First streamable windowed kind.
Output
SERIES — one SeriesEntry per host group key in host order, carrying index = point / prior × 100 on Summary.Statistic. Layer Baseline = 100.
Gotchas
- Single-state lag carrier (one
float64) — streamable inside the streaming Process fold. - NaN entries are
statistic: nullin JSON. - First ordinal → NaN, no warning (“no comparison available” ≠ “denominator zero”).
- Absent host point → no
statistickey + carrier does NOT advance (next present point still divides by last present value). - Zero prior → NaN + ONE
PULSE_OVERLAY_REF_ZEROper layer. Ref.Prior.Lagreserved for future window-N priors; v1 ships lag-1 only.- Empty
Refand populatedRef.Priorboth spell lag-1. - Other
Refarms →PULSE_OVERLAY_REF_INCOMPATIBLE_WITH_SHAPE. - Weighted host → weighted figure (reads the host payload).
See
op-overlay-index-vs-ref
name: op-overlay-index-vs-ref
kind: operator
category: OVERLAY
operator: OVERLAY_INDEX_VS_REF
description: Compose-host ratio index of target slot value against the matching reference slot value (matrix or series).
type: reference
applies_to: compose
examples_tags: [overlay, compose, comparison]
Compose-only dual-shape. Overlays decorate the host; no Response.Components.
Use when
Index value of each cell or group of a target Compose request against the same spot in the reference request (target / reference x 100).
Questions it answers:
- How does this wave’s result per segment compare with last wave’s, scaled to 100?
- How does each region’s revenue this year compare with last year’s?
Use something else:
OVERLAY_DELTA_VS_REFwhen you want the gap in the value’s own units.
Params
Scope required — cell (matrix host) or group (series host). Reference / Targets required slot labels (one or more targets). params.scale (float, default 100) — set 1 for a raw ratio.
Host shape
COMPOSE dual-shape: MATRIX crosstab OR SERIES grouped Process on reference + target. Schema-match + key-alignment + dict-prefix gates at the slot barrier. Ratio twin of the reference delta.
Output
MATRIX (cell host) or SERIES (group host) — per-coordinate (target / ref) × scale. Layer Baseline = scale (default 100).
Gotchas
- Zero reference → NaN + ONE
PULSE_OVERLAY_REF_ZEROper coord. - Missing reference coordinate →
PULSE_OVERLAY_REF_ZEROwithref_missing=true. - SERIES dispatch is fold-only (streamable per
OverlayStreamability); MATRIX is forced buffered by the slot barrier. OverlayOptions.DictPrefixFastenables the byte-equal dictionary prefix probe. OtherRefarms →PULSE_OVERLAY_REF_INCOMPATIBLE_WITH_SHAPE.- Weighted slots → weighted figures (reads the slot payloads).
See
op-overlay-index-vs-rolling-mean
name: op-overlay-index-vs-rolling-mean
kind: operator
category: OVERLAY
operator: OVERLAY_INDEX_VS_ROLLING_MEAN
description: Per-point windowed index against the arithmetic mean of the W preceding points of an ordered SERIES host.
type: reference
applies_to: process, compose
examples_tags: [overlay, time-series, window-operator]
Overlays decorate the host; no Response.Components.
Use when
Each point of an ordered series as an index value against the rolling mean of the previous W points: is this period above its recent run?
Questions it answers:
- Is this week’s volume above or below the average of the last four weeks?
- Which days ran well above their recent average?
Use something else:
OVERLAY_ZSCORE_VS_ROLLINGwhen you want the gap in standard deviations of the window.
Params
Scope must be group. Ref.RollingMean empty marker tags the ref family; other Ref arms → PULSE_OVERLAY_REF_INCOMPATIBLE_WITH_SHAPE. params.window required, positive width W. Level / Within must be 0.
Host shape
SERIES ordered grouped Process host, mirroring the WIN_* window-via-Params convention.
Output
SERIES — one SeriesEntry per host group key in host order carrying index = point / mean(prior W) × 100 on Summary.Statistic. Layer Baseline = 100.
Gotchas
- Carrier: Welford
(count, mean, M2)trio in a W-wide per-group ring buffer; M2 is reserved so the rolling z-score reads SD off it. - Missing
params.window→PULSE_OVERLAY_PARAM_MISSING;window <= 0→PULSE_OVERLAY_LEVEL_OUT_OF_RANGE. - NaN entries are
statistic: nullin JSON. - First W present ordinals → NaN, no warning (window unfilled). Absent host point → no
statistickey, ring does NOT advance. Zero rolling mean → NaN + ONEPULSE_OVERLAY_REF_ZERO. - Buffered — the ring buffer widens streaming-fold state past v1’s single-state lag.
- Weighted host → weighted figure (reads the host payload).
See
op-overlay-index-vs-sibling
name: op-overlay-index-vs-sibling
kind: operator
category: OVERLAY
operator: OVERLAY_INDEX_VS_SIBLING
description: Per-group ratio index against a sibling group named by (Field, Value) on the SERIES host (×100).
type: reference
applies_to: process, compose
examples_tags: [overlay, comparison]
Overlays decorate the host; no Response.Components.
Use when
Each group’s value as an index value against one named group (group / sibling x 100), such as every store against the flagship.
Questions it answers:
- How does each store’s revenue compare with the flagship store’s?
- How does each region’s average rating compare with the home region’s?
Use something else:
OVERLAY_DELTA_VS_SIBLINGwhen you want the gap in the value’s own units.
Params
Scope must be group. Ref.Sibling.Field (string, required) — grouper field on the host. Ref.Sibling.Value (string, required) — axis-key value identifying the sibling group. Other Ref arms → PULSE_OVERLAY_REF_INCOMPATIBLE_WITH_SHAPE.
Host shape
SERIES — grouped Process host. Ratio twin of the sibling delta. Sibling resolved via internal/processing/overlay_sibling_resolver.go.
Output
SERIES — one SeriesEntry per host group, carrying index = group / sibling × 100 on Summary.Statistic. Sibling group itself emits 100.0 (self-vs-self). Layer Baseline = 100.
Gotchas
- Unknown
(Field, Value)pair → ONEPULSE_OVERLAY_REF_UNKNOWNper layer + NaN entries. - Zero sibling value → NaN entries + ONE
PULSE_OVERLAY_REF_ZEROper layer. The subtractive twin does not warn on zero. - Absent host group →
SeriesEntrywith unsetStatistic. - Buffered — the resolver needs materialised per-group accumulators (
ApplyOverlaysSeries). - Weighted host → weighted figure (reads the host payload).
See
op-overlay-index-vs-stage
name: op-overlay-index-vs-stage
kind: operator
category: OVERLAY
operator: OVERLAY_INDEX_VS_STAGE
description: Whole-chain ratio index of target stage's result against the reference stage's result (×100).
type: reference
applies_to: process
examples_tags: [overlay, comparison]
Lives on ChainRequest.Overlays (dual-slot host — ChainOverlaySpec). Decorates ChainResponse.Overlays; per-stage overlays untouched. Overlays emit no Response.Components.
Use when
Index value of a process-chain stage’s result against an earlier stage’s result at the same coordinate (target / reference x 100).
Questions it answers:
- What share of the starting figure is left after each filtering stage, scaled to 100?
- How does the refined stage’s result compare with the first stage’s?
Use something else:
OVERLAY_DELTA_VS_STAGEwhen you want the gap in the value’s own units.
Params
Scope required, echoed verbatim — the capability declares total (there is no chain scope). ChainOverlaySpec has no Level / Within. Ref (a StageRef) required — {Index: N} or {Name: "stage-id"}. Target — {Index} or {Name}, default the latest stage.
Host shape
CHAIN — ProcessChain; reference + target stages’ host shape (scalar / series / matrix). Consumes the StageRef reference family.
Output
Shape inherited from target stage. Per-coordinate index = target / ref × 100. Layer Baseline = 100.
Gotchas
- Zero
ref_val_k→ NaN at that coordinate + ONEPULSE_OVERLAY_REF_ZEROper layer. Targetdefaults to the latest stage whenIndexis nil andNameempty;Refhas no default.- Stage shape divergence →
PULSE_OVERLAY_CHAIN_STAGE_SHAPE_DIVERGENT(runtime only; predict cannot catch it). - Unknown stage →
PULSE_OVERLAY_REF_UNKNOWN(PULSE_OVERLAY_TARGET_UNKNOWNwhen it lands). - Buffered (whole-chain barrier post stage loop).
See
- Skills:
overlay-system,process-chain,op-overlay-delta-vs-stage.
op-overlay-index-vs-total
name: op-overlay-index-vs-total
kind: operator
category: OVERLAY
operator: OVERLAY_INDEX_VS_TOTAL
description: Per-group streamable ratio index against the SERIES host's grand total (×100).
type: reference
applies_to: process, compose
examples_tags: [overlay, comparison, streaming-friendly]
Overlays decorate the host; no Response.Components.
Use when
Each group’s value on a grouped result as an index value against the sum over all groups (group / total x 100).
Questions it answers:
- How big is each region relative to the whole, on a 0 to 100 scale?
- What part of total sales, scaled to 100, does each channel make up?
Use something else:
OVERLAY_SHARE_OF_TOTALwhen you want the raw share (0 to 1).
Params
Scope must be group. Ref (object, empty) — implicit-grand-total — leave empty. Level/Within must be 0.
Host shape
SERIES — grouped Process host. First streamable SERIES-host overlay with a streaming finalize hook. Sibling to the grand-total share (SERIES arm) — same accumulator, different scale.
Output
SERIES — one SeriesEntry per host group key in host order, carrying index = group_val / grand_total × 100 on Summary.Statistic. Layer Baseline = 100.
Gotchas
grand_total == 0→ NaN entries + ONEPULSE_OVERLAY_REF_ZEROper layer.- Absent host group →
SeriesEntrywith unsetStatisticand does NOT contribute to grand total. - Populated
Refarm →PULSE_OVERLAY_REF_INCOMPATIBLE_WITH_SHAPE. - Streamable — one
float64grand-total accumulator carried alongside per-group accumulators inside the streaming Process fold. Post-host finalize is the divide step. - Sum semantics — counts post-filter rows, not pre-filter row count.
- Weighted host → weighted figure (reads the host payload).
See
op-overlay-ks-vs-pop
name: op-overlay-ks-vs-pop
kind: operator
category: OVERLAY
operator: OVERLAY_KS_VS_POP
description: Kolmogorov-Smirnov distance + p-value comparing host Facet NUMERIC distribution against the resolved population.
type: reference
applies_to: facet
examples_tags: [overlay, facet, hypothesis-test, distribution-shape]
Rides on FacetRequest.Overlays. Overlays decorate the host; no Response.Components.
Use when
Kolmogorov-Smirnov test on a numeric facet: checks whether a subset’s distribution departs from a comparison population’s.
Questions it answers:
- Does this segment’s spend distribution differ from all customers’?
- Has the shape of response times in this region drifted from the fleet-wide shape?
Use something else:
OVERLAY_CHISQ_VS_POPwhen the field is categorical.TEST_KSwhen you have two separate groups of raw rows.
Params
Scope must be group. Ref.Population.Cohort required — the comparison-population cohort; other Ref arms → PULSE_OVERLAY_REF_INCOMPATIBLE_WITH_SHAPE. Level / Within must be 0.
multiplicity— optional{method, family, alpha}; addsp_adjustedfigures, raw p untouched (multiplicity-correction).
Host shape
FACET — numeric arm only. Categorical host → PULSE_OVERLAY_SCOPE_UNSUPPORTED (the χ²-vs-population sibling covers the discrete arm). Reuses kolmogorovSurvival, the KS test’s survival helper.
Output
SCALAR — Payload.Scalar carries KS D; OverlaySummary{Statistic, PValue, Parameters{"n_subset", "n_pop"}}. Layer Baseline unset (inferential).
Reading the output
scalar: D, also in summary.statistic: the largest vertical gap between the subset’s and the population’s cumulative distributions, from 0 (the same curve) to 1 (no overlap at all).summary.statistic: The same D as scalar.summary.p_value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.summary.parameters.n_subset: The number of rows behind the subset’s numeric facet; one of the two counts the p-value’s large-sample formula uses.
Gotchas
- Empirical-CDF path: histogram (preferred), else percentile-map, else Welford-only (degenerate → NaN +
PULSE_OVERLAY_REF_ZERO). - The resolver retains only Welford / histogram / percentiles; raw values are discarded. Set
IncludeHistogram=trueorNumericPercentiles=[...]on BOTH arms. - Empty host or pop (
n_subset == 0/n_pop == 0) → NaN +PULSE_OVERLAY_REF_ZERO. - Mismatched histogram edges fall to the percentile path, else
PULSE_OVERLAY_REF_ZERO. - Buffered (inferential).
See
- Skills:
overlay-system,facet-design,op-overlay-chisq-vs-pop,op-test-ks.
op-overlay-pairwise-probit-t
name: op-overlay-pairwise-probit-t
kind: operator
category: OVERLAY
operator: OVERLAY_PAIRWISE_PROBIT_T
description: Intra-matrix axis-pairwise probit t-test (Φ⁻¹ transform of each leg's proportion, Student-t tail).
type: reference
applies_to: process, compose
examples_tags: [overlay, cross-tabulation, hypothesis-test, pairwise]
Intra-matrix pairwise along one axis of the SAME crosstab: row scope pairs row indices per column, column pairs column indices per row. Sibling of the pairwise proportion z — same proportion + n inputs, probit transform instead. Overlays decorate the host; no Response.Components (this family READS them).
Use when
Pairwise t-tests on probit-transformed shares between rows (or columns) of one crosstab, matching a convention some survey tools use.
Questions it answers:
- Which segments differ in share when tested the way our survey tool does?
- Do the probit-scale shares of each region differ from each other?
Use something else:
OVERLAY_PAIRWISE_PROP_Zwhen you want the standard test of two shares.
Params
Scope (enum, required) — row or column. Ref (object, empty) — intra-matrix — leave empty. params.pair_along_dim (int, unset) — restrict pairs to same-bucket comparisons on the pair axis. params.n_source, params.n_within_depth and params.p_source are the family-wide vocabulary, identical to op-overlay-pairwise-prop-z: pairwise-n-sources.
multiplicity— optional{method, family, alpha}; addsp_adjustedfigures, raw p untouched (multiplicity-correction).
Host shape
MATRIX crosstab + Response.Components.Crosstab. Components-disabled host → PULSE_OVERLAY_COMPONENTS_REQUIRED.
Output
MATRIX — pair × opposite-axis grid of two-sided p-values, layout identical to op-overlay-pairwise-prop-z. Per pair: t = (Φ⁻¹(p_i) - Φ⁻¹(p_j)) / sqrt(1/n_i + 1/n_j) against Student-t with df = n_i + n_j - 2, two-sided; proportions clipped to [1e-10, 1-1e-10] before Φ⁻¹. Reuses the Student-t survival helper the t-tests use.
Reading the output
cells.value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.- Caveat: Each cell holds the two-sided p-value for one pair, from a t curve on n_i + n_j - 2 degrees of freedom after a probit transform of each share; it is absent when a leg is unreadable or the test is degenerate.
- Caveat: The probit simplification differs from the textbook two-proportion test (OVERLAY_PAIRWISE_PROP_Z), so the two give different p-values for the same cells.
Gotchas
- Never weighted (no reference weighted form): a row weight reaching the overlay slot (request, its own
weight, orOptions.DefaultWeight) isPULSE_WEIGHT_UNSUPPORTEDwithdetails.reason; set"weight": nullon the overlay to run it unweighted. df <= 0(n_i + n_j <= 2) or a non-finite t skips the pair (aggregatedPULSE_OVERLAY_REF_ZERO).- RAW p-values only — direction / thresholds / min-n are the embedder’s job.
p_sourcemismatch fails silently:cell_valueover a 0..100 percentage pushes proportions outside[0,1]and every pair skips.- Distinct-KEY n modes carry the same admission and the same slab partition refusal as on prop-Z — distinct-key cell aggregators only,
PULSE_OVERLAY_DISTINCT_SLAB_NOT_PARTITIONEDfor a fan-out grouper at a summed-across depth, margin modes never gated. Full rules:pairwise-n-sources. - Flagged buffered in
OverlayStreamability; the HOST crosstab still FUSES on a mergeable cell aggregator.
See
op-overlay-pairwise-prop-z
name: op-overlay-pairwise-prop-z
kind: operator
category: OVERLAY
operator: OVERLAY_PAIRWISE_PROP_Z
description: Intra-matrix axis-pairwise pooled-SE two-proportion z-test (row-vs-row or col-vs-col within one crosstab).
type: reference
applies_to: process, compose
examples_tags: [overlay, cross-tabulation, hypothesis-test, pairwise]
One host-matrix slot against another ALONG one axis of the SAME crosstab — the per-Request counterpart to Compose’s proportion-z panel. Overlays decorate the host; no Response.Components (this family READS them).
Use when
Two-proportion z-tests between every pair of rows (or columns) of one crosstab, per column (or row): which segments differ in share?
Questions it answers:
- Which age bands differ from each other in the share choosing each brand?
- Which pairs of regions differ in their share of late deliveries?
Use something else:
OVERLAY_PAIRWISE_WELCH_Twhen the cells are averages of a numeric measure rather than shares.
Params
Scope (enum, required) — row (pair rows per column) or column (pair columns per row). Ref (object, empty) — intra-matrix; any populated arm → PULSE_OVERLAY_REF_INCOMPATIBLE_WITH_SHAPE. params.pair_along_dim (int, unset) — restrict pairs to buckets agreeing on all pair-axis dims but this one; unset = every pair. params.n_source (default cell_n_unweighted), params.n_within_depth (default 0) and params.p_source (default cell_value_pct) are the family-wide vocabulary — nine sample-size modes, three of them distinct-KEY: pairwise-n-sources.
multiplicity— optional{method, family, alpha}; addsp_adjustedfigures, raw p untouched (multiplicity-correction).
Host shape
MATRIX crosstab (Response.Crosstab.Matrix) + Response.Components.Crosstab. Components-disabled host → PULSE_OVERLAY_COMPONENTS_REQUIRED.
Output
MATRIX (Payload.Shape = "matrix"). PAIR axis = one entry per evaluated (i, j) pair, key = the 2-tuple of the legs’ labels; OPPOSITE axis echoes the host’s other axis. Cell = the pair’s two-sided p-value, absent when a leg is unreadable or the test degenerate. row scope → rows = pairs; column transposes.
Reading the output
cells.value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.- Caveat: Each cell holds the two-sided pooled two-proportion z-test p-value for one pair of rows (or columns), named by the pair-axis key; it is absent when a leg is unreadable or the test is degenerate.
- Caveat: The normal approximation needs roughly 10 successes and 10 failures per leg, and an n_source or p_source that does not match the cell values skips pairs.
Gotchas
- Weighted host (both kinds, any source): p̂ = the weighted cell share (Σw_success/Σw_base); with
n_sourceomitted n = the cell’s N* (sum_weightsfrequency,n_effprobability). Unweighted-count modes (andcell_weight_sum/cell_value_weightedunder probability) arePROCESSING_CONFIGin predict AND runtime (pairwise-n-sources).Summary.Parametersaddssum_weights(+n_eff). - Reuses
twoProportionZ— byte-for-byte equal to every two-proportion z test and overlay on the same (success, n). - RAW p-values only — direction, thresholds and min-n flags are the embedder’s job.
- Degenerate pairs (n=0, pooled ∈ {0,1}, zero SE) fold into one aggregated
PULSE_OVERLAY_REF_ZEROper reason. p_sourcemismatch fails silently and totally.cell_valueover a real 0..100 percentage drives pooled p outside[0,1], so EVERY pair skips and the layer returns empty.- The three distinct-KEY n modes are admitted only on a distinct-key cell aggregator (sum-of-distinct or distinct count) — the modal aggregators spell a
distinct_counttoo, but theirs counts answer codes.n_within_distinctadditionally refuses a fan-out grouper at a summed-across depth (PULSE_OVERLAY_DISTINCT_SLAB_NOT_PARTITIONED, predict AND runtime); the margin modes are exact by construction and never gated. Null rules and the direct-caller bypass:pairwise-n-sources. - Flagged buffered in
OverlayStreamability, but the HOST crosstab still FUSES on a mergeable cell aggregator (a weighted mean included, even over a per-element set axis).
See
- Skills:
overlay-system,pairwise-n-sources,crosstab-guide,op-overlay-pairwise-probit-t,op-overlay-prop-z-cell,op-test-prop-z.
op-overlay-pairwise-two-means-z
name: op-overlay-pairwise-two-means-z
kind: operator
category: OVERLAY
operator: OVERLAY_PAIRWISE_TWO_MEANS_Z
description: Intra-matrix axis-pairwise two-means z-test on AGG_WELFORD cells (normal-CDF tail, no df).
type: reference
applies_to: process, compose
examples_tags: [overlay, cross-tabulation, hypothesis-test, pairwise, welford-triple]
Intra-matrix pairwise on MEANS along one axis of the SAME crosstab: row scope pairs row indices per column, column pairs column indices per row. Normal-CDF sibling of the pairwise Welch t — same standard error, no Satterthwaite df. Overlays decorate the host; no Response.Components (this family READS them).
Use when
Large-sample z-tests between every pair of rows (or columns) of one crosstab of averages: which segments differ in mean?
Questions it answers:
- With thousands of respondents per cell, which segments differ in average rating?
- Which pairs of high-volume stores differ in average basket size?
Use something else:
OVERLAY_PAIRWISE_WELCH_Twhen any cell is small; the t-based version is more honest there.
Params
Scope (enum, required) — row or column. Ref (object, empty) — intra-matrix — leave empty. params.pair_along_dim (int, unset) — restrict pairs to same-bucket comparisons on the pair axis.
n_source / p_source are NOT accepted — n, mean and variance all come from the Welford triple, so either would be a silent no-op. Predict refuses both (PULSE_OVERLAY_PARAM_MISSING), for EVERY mode and not just the distinct-key ones; runtime does not, the param being inert. n_within_depth stays accepted and inert. Detail: pairwise-n-sources.
multiplicity— optional{method, family, alpha}; addsp_adjustedfigures, raw p untouched (multiplicity-correction).
Host shape
MATRIX crosstab whose cell aggregator is AGG_WELFORD + Response.Components.Crosstab, from which it reads the {mean, variance, n} triple per cell. Non-Welford host → PULSE_OVERLAY_REF_INCOMPATIBLE_WITH_SHAPE; components-disabled → PULSE_OVERLAY_COMPONENTS_REQUIRED.
Output
MATRIX — pair × opposite-axis grid of two-sided p-values (layout as op-overlay-pairwise-prop-z). Per pair: a = v_i/n_i, b = v_j/n_j, se = sqrt(a + b), z = (m_i - m_j) / se, p = 2 * Φ(-|z|) via the normalTwoSidedP helper the two-sample z-test uses.
Reading the output
cells.value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.- Caveat: Each cell holds the two-sided z-test p-value for one pair of means, read from the normal curve; it is absent when a leg is unreadable or the test is degenerate.
- Caveat: With small cells the normal curve gives p-values that are too small; OVERLAY_PAIRWISE_WELCH_T is the safer reading there.
Gotchas
- Never weighted: a row weight reaching the overlay slot (request, its own
weight, orOptions.DefaultWeight) isPULSE_WEIGHT_UNSUPPORTEDwithdetails.reasonanddetails.alternative: useOVERLAY_PAIRWISE_WEIGHTED_TWO_MEANS_Zfor weighted means; set"weight": nullon the overlay to run it unweighted. - Either leg with
n <= 1skips the pair (aggregatedPULSE_OVERLAY_REF_ZERO). - Assumes the normal approximation: no small-sample df correction.
n_basishere is inert; predict refuses it.- RAW p-values only — direction / thresholds are the embedder’s job.
- Buffered (inferential) — and so is the HOST: the
AGG_WELFORDcell is non-mergeable, soCanFuseCrosstabrejects on the cell-aggregator arm. Expected (TestCrosstabWelfordCell_StaysBufferedWithCorrectOverlays).
See
- Skills:
overlay-system,pairwise-n-sources,crosstab-guide,op-overlay-pairwise-welch-t,op-agg-welford,op-test-z-two-sample.
op-overlay-pairwise-weighted-two-means-z
name: op-overlay-pairwise-weighted-two-means-z
kind: operator
category: OVERLAY
operator: OVERLAY_PAIRWISE_WEIGHTED_TWO_MEANS_Z
description: Intra-matrix axis-pairwise two-means z-test on weighted-mean cells under a required n_basis (weights | kish).
type: reference
applies_to: process, compose
examples_tags: [overlay, cross-tabulation, hypothesis-test, pairwise]
Weighted pairwise two-means z: pairs rows (row scope) or columns (column) on weighted means. Reads Response.Components.
Use when
Large-sample z-tests between every pair of rows (or columns) of one crosstab of weighted averages, with a sample-size basis you choose.
Questions it answers:
- Which weighted segments differ from each other in average satisfaction?
- Which regions differ in weighted average spend once survey weights are applied?
Use something else:
OVERLAY_PAIRWISE_WELCH_Twhen the cells are unweighted averages.
Params
Scope (required) — row or column. Ref empty. params.n_basis (REQUIRED, no default) — weights or kish. params.pair_along_dim (int) — same-bucket pairs. n_source / p_source / bad n_basis → PULSE_OVERLAY_PARAM_MISSING; weights on a probability-weighted cell (incl. weight_field) → PROCESSING_CONFIG.
multiplicity— optional{method, family, alpha}; addsp_adjustedfigures, raw p untouched (multiplicity-correction).
Host shape
MATRIX crosstab, weighted-moment cell: AGG_WEIGHTED_MEAN or a weighted AGG_AVERAGE. Reads weighted_mean, m2_weighted, sum_weights, sum_weights_sq — never floor n. Else PULSE_OVERLAY_REF_INCOMPATIBLE_WITH_SHAPE; components off → PULSE_OVERLAY_COMPONENTS_REQUIRED.
Output
Pair × opposite-axis two-sided p-values. weights: var = m2/(Σw−1), n = Σw. kish: var = m2/(Σw−Σw²/Σw), n = n_eff. z = (m_i−m_j)/sqrt(var_i/n_i + var_j/n_j), p = 2Φ(−|z|).
Reading the output
cells.value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.- Caveat: Each cell holds the two-sided z-test p-value for one pair of weighted means; params.n_basis sets the sample size (sum of weights, or Kish’s effective sample size).
- Caveat: It accounts for weighting only, not clustering or other design effects, so with such designs the p-values come out too small.
Gotchas
- Skips (
PULSE_OVERLAY_REF_ZERO):weightslegΣw ≤ 1;kishleg with one weighted row; zero SE. - Mergeable cell: stays fused.
n_basissets n:weightsneeds frequency weights (Σw is no sample size under probability); probability →kish.
See
op-overlay-pairwise-welch-t
name: op-overlay-pairwise-welch-t
kind: operator
category: OVERLAY
operator: OVERLAY_PAIRWISE_WELCH_T
description: Intra-matrix axis-pairwise Welch–Satterthwaite t-test on AGG_WELFORD cells.
type: reference
applies_to: process, compose
examples_tags: [overlay, cross-tabulation, hypothesis-test, pairwise, welford-triple]
Intra-matrix pairwise on MEANS along one axis of the SAME crosstab: row scope pairs row indices per column, column pairs column indices per row. Overlays decorate the host; no Response.Components (this family READS them).
Use when
Welch t-tests between every pair of rows (or columns) of one crosstab of averages: which segments differ in mean?
Questions it answers:
- Which age bands differ from each other in average spend, product by product?
- Which pairs of depots differ in average delivery time per month?
Use something else:
OVERLAY_PAIRWISE_TWO_MEANS_Zwhen every cell is large and you want the normal-curve version.
Params
Scope (enum, required) — row or column. Ref (object, empty) — intra-matrix — leave empty. params.pair_along_dim (int, unset) — restrict pairs to same-bucket comparisons on the pair axis.
n_source / p_source are NOT accepted — n, mean and variance all come from the Welford triple, so either would be a silent no-op. Predict refuses both (PULSE_OVERLAY_PARAM_MISSING), for EVERY mode and not just the distinct-key ones; runtime does not, the param being inert (weighted host: see Gotchas). n_within_depth stays accepted and inert. Detail: pairwise-n-sources.
multiplicity— optional{method, family, alpha}; addsp_adjustedfigures, raw p untouched (multiplicity-correction).
Host shape
MATRIX crosstab whose cell aggregator is AGG_WELFORD + Response.Components.Crosstab, from which it reads the {mean, variance, n} triple per cell. Non-Welford host → PULSE_OVERLAY_REF_INCOMPATIBLE_WITH_SHAPE; components-disabled → PULSE_OVERLAY_COMPONENTS_REQUIRED.
Output
MATRIX — pair × opposite-axis grid of two-sided p-values (layout as op-overlay-pairwise-prop-z). Per pair: a = v_i/n_i, b = v_j/n_j, se = sqrt(a + b), t = (m_i - m_j) / se, df = (a+b)² / (a²/(n_i-1) + b²/(n_j-1)) (Welch–Satterthwaite), two-sided via the studentTTwoSidedP helper the t-tests use.
Reading the output
cells.value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.- Caveat: Each cell holds the two-sided Welch t-test p-value for one pair of means, with Welch-Satterthwaite degrees of freedom; it is absent when a leg is unreadable or the test is degenerate.
- Caveat: Each mean should be roughly normal: safe for big cells, risky for small skewed ones.
Gotchas
- Weighted host (both kinds, any source): legs read N* =
sum_weights(frequency) /n_eff(probability), variance fromm2on w*, df on N*;Summary.Parametersaddssum_weights(+n_eff). There an unweighted-countn_source(and a weight-sum one under probability) isPROCESSING_CONFIGin predict AND runtime (pairwise-n-sources). - Either leg with
n <= 1skips the pair (aggregatedPULSE_OVERLAY_REF_ZERO). - RAW p-values only — direction / thresholds are the embedder’s job.
- Buffered (inferential) — and so is the HOST: the
AGG_WELFORDcell is non-mergeable, soCanFuseCrosstabrejects on the cell-aggregator arm. Expected (TestCrosstabWelfordCell_StaysBufferedWithCorrectOverlays).
See
- Skills:
overlay-system,pairwise-n-sources,crosstab-guide,op-overlay-pairwise-two-means-z,op-agg-welford,op-test-welch.
op-overlay-panel-index-vs-ref
name: op-overlay-panel-index-vs-ref
kind: operator
category: OVERLAY
operator: OVERLAY_PANEL_INDEX_VS_REF
description: Compose-host multi-reference index — indexes every target slot against a shared reference; emits one layer per target.
type: reference
applies_to: compose
examples_tags: [overlay, compose, comparison]
Compose-only multi-reference. Overlays decorate the host; no Response.Components.
Use when
Index values of several target Compose requests against one shared reference request, one layer per target (target / reference x 100).
Questions it answers:
- How do the last four waves each compare with the benchmark wave?
- How does each market compare with the reference market, cell by cell?
Use something else:
OVERLAY_INDEX_VS_REFwhen there is only one target.
Params
Scope required — cell (matrix host) or group (series host). Reference = shared reference slot. Targets — one layer per target. OverlayOptions.MaxPanelTargets int, default 16, caps len(Targets).
Host shape
COMPOSE dual-shape: MATRIX crosstab OR SERIES on reference and every target. Schema-match + key-alignment + dict-prefix gates per (ref, target). Sibling to the proportion-z panel.
Output
ONE layer per target, layers[i].Name = "<spec.Name>__<spec.Targets[i]>". Payload shape mirrors the reference slot’s. Byte-equal per layer to the two-slot reference index on each (reference, target[i]).
Gotchas
len(Targets) > MaxPanelTargets→PULSE_OVERLAY_PANEL_TARGETS_OVER_CAP.- Empty
spec.Name→ label becomesOVERLAY_PANEL_INDEX_VS_REF__<target_label>. - Streamable: SERIES fold-only; MATRIX forced buffered by the slot barrier.
- Shared coord space is enforced before dispatch.
- Layer slice order matches
spec.Targets, stable across re-runs. - Weighted slots → weighted figures (reads the slot payloads).
See
op-overlay-prop-z-cell
name: op-overlay-prop-z-cell
kind: operator
category: OVERLAY
operator: OVERLAY_PROP_Z_CELL
description: Compose-host per-cell two-proportion z-test against the reference slot's matching cell.
type: reference
applies_to: compose
examples_tags: [overlay, compose, hypothesis-test, proportion-analysis]
Compose-only. Overlays decorate the host; no Response.Components.
Use when
Two-proportion z-test for every crosstab cell in a Compose request: does the target’s share in that cell differ from the reference’s?
Questions it answers:
- Which answer shares changed between this wave and last wave?
- Where does the test market’s share differ from the control market’s?
Use something else:
OVERLAY_PROP_Z_PANELwhen you compare three or more requests at once.
Params
Scope must be cell. Reference (string, required) — reference slot label. Targets ([]string, required) — target slot labels.
multiplicity— optional{method, family, alpha}; addsp_adjustedfigures, raw p untouched (multiplicity-correction).
Host shape
COMPOSE — MATRIX crosstab on both reference + target slot. Cell value = success count; matching row margin = sample size. Sample reference reuses the two-proportion z-test’s pooled-SE recurrence.
Output
MATRIX — Cells[r][c].Value = two-sided p-value as float64. Mirrors reference matrix’s RowKeys / ColumnKeys. One layer per target. Layer Baseline unset (inferential).
Reading the output
cells.value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.- Caveat: Each cell holds the two-sided p-value comparing the target’s share (cell / row total) with the reference’s share in the same cell.
- Caveat: A cell whose row total is missing or zero on either side is NaN, with a warning (PULSE_OVERLAY_REF_ZERO): it gets no test.
Gotchas
- Weighted slots (both kinds): p̂ = cell/row margin (Σw); n = the row base’s N*: the margin Σw under frequency, its Kish
n_effunder probability (never Σw; no readable floor ⇒ missing margin). A probability slot with components disabled ⇒PROCESSING_CONFIG(predict AND runtime).Summary.Parametersaddssum_weights(+n_eff). - Pooled SE:
sqrt(pooled × (1-pooled) × (1/n_target + 1/n_ref))wherepooled = (target+ref) / (n_target+n_ref). ReusesnormalTwoSidedP— byte-equal to the two-proportion z-test on the same(success, n)pair. - Degenerate inputs (
pooled ∈ {0,1},se == 0, missing/zero row margin on EITHER side — never the cell value as n) → NaN cell + ONEPULSE_OVERLAY_REF_ZEROper affected cell. - Schema-match + key-alignment gates at the slot barrier.
- Buffered (inferential).
See
op-overlay-prop-z-panel
name: op-overlay-prop-z-panel
kind: operator
category: OVERLAY
operator: OVERLAY_PROP_Z_PANEL
description: Compose-host multi-reference per-cell pairwise two-proportion z-test across N+1 slots.
type: reference
applies_to: compose
examples_tags: [overlay, compose, hypothesis-test, proportion-analysis]
Compose-only multi-reference. Buffered.
Use when
Two-proportion z-tests between every pair of requests in a Compose panel, for each crosstab cell: which waves or markets differ in share?
Questions it answers:
- Across four waves, which pairs of waves differ in this answer’s share?
- Which of five markets differ from each other in brand share?
Use something else:
OVERLAY_PROP_Z_CELLwhen there is only one target.
Params
Scope = cell. Reference = panel 0, Targets = 1..N. MaxPanelTargets (OverlayOptions, default 16) caps len(Targets) and refuses FIRST (PULSE_OVERLAY_PANEL_TARGETS_OVER_CAP) — an Options knob, never a param.
Params → PanelOverlayParams; absent, {} and the explicit default stay byte-identical to the pre-params baseline. n_source picks each SLOT’s n at the tested coordinate (“row” = that slot’s own axis, never a pair axis):
row_margin_value(default) — the row-margin VALUE off the payload; a missing or zero margin reads as n = 0 (each pair with that slot ⇒ NaN +REF_ZERO), never the cell value.cell_n_unweighted— countednfrom the slot’sComponents.Crosstab, BY KEY. No fallback; unreadable ⇒ skip the cell.row_margin_value_within— the SAME payload row margin, optionally summed over a row-key prefix. No fallback.row_margin_distinct_within— that leg’s DISTINCT-KEY sibling: the row margin out ofRowMarginComponentsas the cell aggregator’s distinct-KEY cardinality. A different CARRIER, hence notrow_margin_value_distinct_within. Use it when one respondent contributes several records and n must be RESPONDENTS. No fallback.
n_within_depth (*int) is read by the two _within modes alone (types.PanelNSourcesUsingWithinDepth()), and the pointer is load-bearing: omitted ⇒ the exact per-slot row margin (no summing), d ⇒ margins summed over that slot’s rows agreeing on the first d+1 dims. Depth 0 ≠ omitted. Negative, or set with any other mode, ⇒ PULSE_OVERLAY_PARAM_MISSING on both arms; past a slot’s row depth ⇒ same code at RUNTIME only, naming that slot. Slots may declare DIFFERENT row depths and one out-of-range slot refuses the whole spec (dropping it would change M).
An explicit depth SUMS across rows, so they must partition the key set. A fan-out grouper (GroupType.FansOut() or an extension declaring it) on ANY slot’s row axis at depth > n_within_depth ⇒ PULSE_OVERLAY_DISTINCT_SLAB_NOT_PARTITIONED, both arms, Details panel_index/slot_index/slot_label/dim_index; one offending slot refuses the whole spec. Inside the prefix is fine (it multiplies slabs, not cells); omitted depth is never gated. Pairwise n_within is ungated (record counts are additive) — a panel row margin is whatever the slot’s cell aggregator emitted, so this host cannot claim that.
row_margin_n, n_within and n_within_distinct are OVERLAY_PAIRWISE_* spellings, NOT aliases: unknown here ⇒ PULSE_OVERLAY_PARAM_MISSING on both arms. There n_within sums CellCounts over a PAIR-axis slab at ONE fixed opposite index — one column; the panel’s leg sums a slot’s ROW margins across ALL columns, so the two differ by roughly the column count.
Admission (distinct mode)
row_margin_distinct_within is admitted UP FRONT, per slot, on the cell aggregator’s EXACT identity signature — the sum-of-distinct aggregator (distinct_count) and the distinct count (cardinality) only. The modal aggregators also emit a key spelled distinct_count, but theirs counts distinct answer CODES, so presence is never the test. Anything else ⇒ PULSE_OVERLAY_REF_INCOMPATIBLE_WITH_SHAPE naming the observed aggregator + admitted set + panel_index/slot_index/slot_label. RUNTIME only — predict cannot see materialised components.
Two WHOLESALE refusals, never a per-slot drop (dropping changes M): one unadmitted slot, AND slots that are each admitted but name DIFFERENT aggregators — keys-that-summed vs distinct non-null values are not the same UNIT, and a pair’s two legs must never be counted in different units.
multiplicity— optional{method, family, alpha}; addsp_adjustedfigures, raw p untouched (multiplicity-correction).
Host shape
COMPOSE — MATRIX crosstab per slot, order {Reference, Targets…}. cell_n_unweighted and row_margin_distinct_within need components on EVERY slot: disabled / non-crosstab ⇒ PULSE_OVERLAY_COMPONENTS_REQUIRED, unresolved ⇒ PULSE_OVERLAY_SLOT_NOT_CROSSTAB. Mode-scoped — the default never gates.
Output
MATRIX — Cells[r][c].Value is []float64: upper-triangular p-values (row-major, no diagonal), length M(M-1)/2, M = N+1. Pair index i*(2*M-i-1)/2 + (j-i-1). Baseline unset.
Reading the output
cells.value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.- Caveat: Each cell holds a list of two-sided p-values, one per pair of requests, in upper-triangle order with the reference at index 0: (0,1), (0,2), …, (1,2), …; M requests give M(M-1)/2 entries.
- Caveat: The normal approximation needs roughly 10 successes and 10 failures per side.
Gotchas
- Weighted slots (both kinds): the value modes read p̂ = cell/margin (Σw) and n = the base’s N* (margin or slab Σw under frequency, Kish
n_effunder probability);cell_n_unweighted/row_margin_distinct_withinon a weighted slot arePROCESSING_CONFIG(predict AND runtime).Summary.Parametersaddssum_weights(+n_eff). A probability slot it reads with components disabled ⇒PROCESSING_CONFIG(predict AND runtime). - Pairs byte-equal the cell proportion z (shared
twoProportionZ). - Degenerate
(n == 0, pooled ∈ {0,1}, se == 0)→ NaN + ONEPULSE_OVERLAY_REF_ZEROper (cell, pair). - Absent value → nil slice +
REF_ZEROref_missing; an unreadable counted n addsn_missing+slot_index. - An unemitted distinct MARGIN is not a zero-sized one: the coordinate skips (
n_missing) rather than testing against 0.
See
op-overlay-rank
name: op-overlay-rank
kind: operator
category: OVERLAY
operator: OVERLAY_RANK
description: Compose-host per-cell rank of each target cell within a configurable population (row / column / matrix).
type: reference
applies_to: compose
examples_tags: [overlay, compose, top-n]
Compose-only. Overlays decorate the host; no Response.Components.
Use when
Ranks each cell of a target Compose request’s crosstab (1 = largest) within its row, its column or the whole table.
Questions it answers:
- Which product is the top seller in each region?
- Where does each answer rank within its segment?
Use something else:
OVERLAY_SHARE_OF_TOTALwhen you want each cell’s share of the whole.OVERLAY_ZSCORE_VS_MARGINwhen you want how far each cell sits from its row or column.
Params
Scope must be cell. Reference required — anchor for resolution + key-set gates only. Targets required slot labels. params.population (string, default matrix) — row / column / matrix.
Host shape
COMPOSE — MATRIX crosstab. The reference slot anchors schema-match + key-alignment but its VALUES are not consumed: rank math reads target cells only. That asymmetry keeps RANK orthogonal to the comparison family (INDEX / DELTA / PROP_Z / T / CHISQ).
Output
MATRIX — Cells[r][c].Value = 1-based rank (1 = largest) within the selected population. Mirrors the target’s RowKeys / ColumnKeys. Baseline unset.
Gotchas
- Tie-breaking: average rank (matches
scipy.stats.rankdatadefault). - An absent target cell stays absent and is NOT in the population’s denominator — ranks cover PRESENT cells only.
population=row→ rank within the cell’s row;column→ within its column;matrix→ across all present cells.- Buffered (the slot barrier always buffers, and ranking needs the full materialised matrix).
See
op-overlay-share-of-col
name: op-overlay-share-of-col
kind: operator
category: OVERLAY
operator: OVERLAY_SHARE_OF_COL
description: Per-cell share-of-column ratio (cell / col_margin) — raw share, sums to 1.0 per column.
type: reference
applies_to: process, compose
examples_tags: [overlay, cross-tabulation, proportion-analysis]
Overlays decorate the host; no Response.Components.
Use when
Each crosstab cell as a share of its column margin (0 to 1): the mix of rows within each column, as a 100% stacked column shows it.
Questions it answers:
- Within each channel, what share of orders comes from each region?
- In each banner column, how are answers split across the options?
Use something else:
OVERLAY_SHARE_OF_ROWwhen you want the mix within each row.
Params
Scope must be cell. Ref.Margin.Axis (enum, required) — must be column. Level/Within must be 0. Other Ref arms → PULSE_OVERLAY_REF_INCOMPATIBLE_WITH_SHAPE.
Host shape
MATRIX crosstab (Response.Crosstab.Matrix). Structural twin of the row and grand-total shares. Compatible with any cell aggregator.
Output
MATRIX — Cells[r][c].Value = cell / col_margin (raw ratio, no ×100). Cells along a single column sum to 1.0 in the absence of missing cells. Renderers present as 100%-stacked vertical projection. Layer Baseline = 1 (raw-share centerpoint).
Gotchas
- A share, not an index (×100): the kind names are kept distinct so
shareis never read asindex/100. col_margin == 0→ NaN cell + ONEPULSE_OVERLAY_REF_ZEROper affected column.- Absent host cells stay absent on the overlay.
- Buffered (host crosstab always recomputes margins from raw rows).
- Weighted host → weighted figure (reads the host payload).
See
op-overlay-share-of-row
name: op-overlay-share-of-row
kind: operator
category: OVERLAY
operator: OVERLAY_SHARE_OF_ROW
description: Per-cell share-of-row ratio (cell / row_margin) — raw share, sums to 1.0 per row.
type: reference
applies_to: process, compose
examples_tags: [overlay, cross-tabulation, proportion-analysis]
Overlays decorate the host; no Response.Components.
Use when
Each crosstab cell as a share of its row margin (0 to 1): the mix of columns within each row, as a 100% stacked bar shows it.
Questions it answers:
- Within each region, what share of orders goes to each channel?
- For each age band, how are answers split across the options?
Use something else:
OVERLAY_SHARE_OF_COLwhen you want the mix within each column.
Params
Scope must be cell. Ref.Margin.Axis (enum, required) — must be row. Level/Within must be 0.
Host shape
MATRIX crosstab (Response.Crosstab.Matrix). Family: explicit-margin (Ref.Margin). Structural twin of the column and grand-total shares. Compatible with any cell aggregator.
Output
MATRIX — Cells[r][c].Value = cell / row_margin (raw ratio, no ×100). Cells along a single row sum to 1.0 in the absence of missing cells. Renderers present as 100%-stacked horizontal projection. Layer Baseline = 1.
Gotchas
- A share, not an index (×100): the kind names are kept distinct so
shareis never read asindex/100. row_margin == 0→ NaN cell + ONEPULSE_OVERLAY_REF_ZEROper affected row.- Absent host cells stay absent on the overlay.
- Scope MUST be
cell. EmptyRef.Marginor non-row Axis →PULSE_OVERLAY_REF_INCOMPATIBLE_WITH_SHAPE. - Buffered (host crosstab always recomputes margins from raw rows).
- Weighted host → weighted figure (reads the host payload).
See
op-overlay-share-of-total
name: op-overlay-share-of-total
kind: operator
category: OVERLAY
operator: OVERLAY_SHARE_OF_TOTAL
description: Share-of-grand-total ratio — dual-shape (matrix per-cell or series per-group); raw share sums to 1.0.
type: reference
applies_to: process, compose
examples_tags: [overlay, proportion-analysis, streaming-friendly]
Overlays decorate the host; no Response.Components.
Use when
Each crosstab cell or group of a grouped result as a share of the grand total (0 to 1): what part of the whole each piece makes up.
Questions it answers:
- What share of all revenue does each region bring in?
- What part of all responses falls in each segment-by-answer cell?
Use something else:
OVERLAY_SHARE_OF_ROWwhen you want the share within each row of a crosstab.
Params
Scope required — cell (MATRIX) or group (SERIES). Ref.Margin.Axis required on MATRIX dispatch (grand-axis-locked, value ignored), empty on SERIES. Level / Within must be 0.
Host shape
Dual-shape: MATRIX crosstab (Ref.Margin required, grand-axis) → cell / grand_total; SERIES grouped Process host (implicit grand-total, empty Ref) → group_val / grand_total.
Output
MATRIX or SERIES — raw share (no ×100). A whole matrix / complete partition sums to 1.0 within ULP. Layer Baseline = 1.
Gotchas
- Streamable via SERIES dispatch — same
computeSeriesGrandTotalaccumulator as the total index; MATRIX buffers. - Empty
Ref.Marginon MATRIX, or a populatedRefon SERIES →PULSE_OVERLAY_REF_INCOMPATIBLE_WITH_SHAPE. grand_total == 0→ NaN + ONEPULSE_OVERLAY_REF_ZEROper layer.- Absent host coordinate → unset entry, no contribution to the grand total.
- A share, not an index (×100); the kind names are kept distinct.
- Weighted host → weighted figure (reads the host payload).
See
op-overlay-t-cell
name: op-overlay-t-cell
kind: operator
category: OVERLAY
operator: OVERLAY_T_CELL
description: Compose-host per-cell Welch t-test against the reference slot's matching cell; parity overlay reading from CellComponents.
type: reference
applies_to: compose
examples_tags: [overlay, compose, hypothesis-test, welch, byte-equal-test]
Compose-only parity overlay. Overlays decorate the host; no Response.Components.
Use when
Welch t-test for every crosstab cell in a Compose request: does the target’s mean in that cell differ from the reference’s?
Questions it answers:
- Which segment-by-product cells changed in average rating since last wave?
- Where does the new site’s average handling time differ from the old site’s?
Use something else:
OVERLAY_Z_CELLwhen every cell is large and you want the normal-curve version.
Params
Scope required, must be cell. Reference / Targets required slot labels. Optional overrides params.variance_target/_ref (float, 1.0), params.sample_size_target/_ref (int, 2). Without AGG_WELFORD these ONE values per side apply to EVERY cell, so the p-values describe the supplied values (and the measure’s units), not each cell’s spread.
multiplicity— optional{method, family, alpha}; addsp_adjustedfigures, raw p untouched (multiplicity-correction).
Host shape
COMPOSE — MATRIX crosstab on both slots. Parity overlay — reads {n, mean, variance} from Response.Components.Crosstab.CellComponents[r][c] (populated by AGG_WELFORD via MetaAggregator), else the params triple.
Output
MATRIX — Cells[r][c].Value = two-sided p-value. One layer per target; Baseline unset.
Reading the output
cells.value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.- Caveat: Each cell holds the two-sided Welch t-test p-value comparing the target’s mean in that cell with the reference’s.
- Caveat: Without AGG_WELFORD values (or explicit variance and sample-size params) Pulse computes the p-value from placeholder inputs (variance 1, n 2), and it then describes those placeholders, not your data.
Gotchas
- Weighted (both kinds, any weight source): a cell carrying
sum_weightsreads N* =sum_weights(frequency) orn_eff(probability) — never the rawn— and its variance fromm2on w*.Summary.Parametersaddssum_weights(+n_eff) over the legs read. - Byte-equal to Welch’s t-test on the same inputs — both read
{n, mean, variance}via Welford and sharestudentTTwoSidedPand the df recurrence. - Legacy
processing.WelfordTriplesmuggle REMOVED v0.20.0 —MatrixCell.Valuecarries the scalar mean. - Canonical pairing:
AGG_WELFORD+OVERLAY_T_CELL. Buffered (inferential).
See
- Skills:
overlay-system,response-components,op-overlay-z-cell,op-overlay-t-vs-ref,op-agg-welford.
op-overlay-t-vs-ref
name: op-overlay-t-vs-ref
kind: operator
category: OVERLAY
operator: OVERLAY_T_VS_REF
description: Compose-host per-group Welch t-test against the reference slot's matching group (SERIES); parity overlay reading from CellComponents.
type: reference
applies_to: compose
examples_tags: [overlay, compose, hypothesis-test, welch, byte-equal-test]
Compose-only parity overlay. Series-shape sibling of the cell t overlay. Overlays decorate the host; no Response.Components.
Use when
Welch t-test for every group of a grouped result in a Compose request: does the target’s mean differ from the reference’s?
Questions it answers:
- Which regions’ average spend changed between this quarter and last?
- In which age bands does the test cohort’s mean score differ from the control’s?
Use something else:
OVERLAY_Z_VS_REFwhen every group is large and you want the normal-curve version.
Params
Scope required, must be group. Reference / Targets required slot labels. Optional params.variance_target/_ref (float, 1.0), params.sample_size_target/_ref (int, 2). Without AGG_WELFORD these apply to EVERY group: p-values then describe the supplied values, not each group.
multiplicity— optional{method, family, alpha}; addsp_adjustedfigures, raw p untouched (multiplicity-correction).
Host shape
COMPOSE — SERIES grouped Process on both slots. Parity overlay — reads {n, mean, variance} from Response.Components.Crosstab.CellComponents (matrix arms via AGG_WELFORD), else the params triple. Renders as a per-group strip.
Output
SERIES — one SeriesEntry per host group key carrying the p-value on Summary.Statistic. One layer per target; Baseline unset.
Reading the output
summary.statistic: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.- Caveat: Despite the field name, each series entry’s summary.statistic holds that group’s two-sided Welch t-test p-value, not a t value; Pulse does not emit t.
- Caveat: Without AGG_WELFORD values (or explicit variance and sample-size params) Pulse computes the p-value from placeholder inputs (variance 1, n 2), and it then describes those placeholders, not your data.
Gotchas
- Weighted (both kinds): a row triple carrying
sum_weightsreads N* =sum_weights(frequency) orn_eff(probability), never the rawn, and its variance fromm2on w*.Summary.Parametersaddssum_weights(+n_eff) over the legs read. - Byte-equal to Welch’s t-test on the same inputs — shares
studentTTwoSidedP+ the df recurrence with it and the cell t overlay. - Missing reference row, or degenerate inputs (
se == 0,n < 2) →PULSE_OVERLAY_REF_ZEROwithref_missing=true, entry NaN. - Unlike the streamable SERIES arm of INDEX/DELTA_VS_REF: inferential, buffered by policy.
See
op-overlay-yoy
name: op-overlay-yoy
kind: operator
category: OVERLAY
operator: OVERLAY_YOY
description: Per-point year-over-year ratio against the same period one year prior; requires GROUP_DATE host.
type: reference
applies_to: process, compose
examples_tags: [overlay, time-series, comparison, trend-detection]
Overlays decorate the host; no Response.Components.
Use when
Each period of a date-grouped series as an index value against the same period one year earlier (x 100): year-over-year change.
Questions it answers:
- How do this year’s monthly sales compare with the same months last year?
- Is this quarter’s volume up or down on the same quarter last year?
Use something else:
OVERLAY_INDEX_VS_PRIORwhen you want each period against the one before.
Params
Scope must be group. Ref.YoY (empty marker) — tags ref family. params.frequency (string, conditional) — annual/quarterly/monthly/weekly/daily/hourly.
Host shape
SERIES Process host whose single grouper is GROUP_DATE, else PULSE_OVERLAY_REF_INCOMPATIBLE_WITH_SHAPE. frequency from spec.Params, else req.Groups[0].Params. No tz — follows the host grouper’s zone (zone: following): keys are the host’s local labels.
Output
SERIES — one SeriesEntry per host key, yoy = point / prior × 100 on Summary.Statistic. Layer Baseline = 100.
Gotchas
- Strides: annual
-1, quarterly-4, monthly-12, weekly-52; daily/hourly exact-key lookup one year back. - Feb 29 with a non-leap prior year → NaN (no realignment).
- Missing
frequency→PULSE_OVERLAY_YOY_FREQUENCY_MISSING; unsupported →PULSE_OVERLAY_YOY_INCOMPATIBLE_FREQUENCY. - First year of data → NaN, no warning. Zero prior value → NaN + ONE
PULSE_OVERLAY_REF_ZEROper layer. - Buffered.
See
op-overlay-z-cell
name: op-overlay-z-cell
kind: operator
category: OVERLAY
operator: OVERLAY_Z_CELL
description: Compose-host per-cell two-sample z-test on the means against the reference slot's matching cell; parity overlay reading from CellComponents.
type: reference
applies_to: compose
examples_tags: [overlay, compose, hypothesis-test, z, byte-equal-test]
Compose-only parity overlay. Overlays decorate the host; no Response.Components.
Use when
Large-sample z-test for every crosstab cell in a Compose request: does the target’s mean in that cell differ from the reference’s?
Questions it answers:
- With large samples, which segment-by-question means moved since last wave?
- Which store-by-category average baskets differ between two high-traffic regions?
Use something else:
OVERLAY_T_CELLwhen any cell is small; the t-based version is more honest there.
Params
Scope required, must be cell. Reference / Targets required slot labels. Optional overrides params.variance_target/_ref (float, 1.0), params.sample_size_target/_ref (int, 2). Without AGG_WELFORD these ONE values per side apply to EVERY cell, so the p-values describe the supplied values (and the measure’s units), not each cell’s spread.
multiplicity— optional{method, family, alpha}; addsp_adjustedfigures, raw p untouched (multiplicity-correction).
Host shape
COMPOSE — MATRIX crosstab on both slots. Parity overlay — reads {n, mean, variance} from Response.Components.Crosstab.CellComponents[r][c] (populated by AGG_WELFORD via MetaAggregator), else the params triple.
Output
MATRIX — Cells[r][c].Value = two-sided p-value via standard normal survival. One layer per target; Baseline unset.
Reading the output
cells.value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.- Caveat: Each cell holds the two-sided z-test p-value comparing the target’s mean in that cell with the reference’s, read from the normal curve; with small cells it comes out too small.
- Caveat: The layer does not report an effect size: read the gap itself (for example OVERLAY_DELTA_VS_REF) for how big a difference is.
Gotchas
- Weighted (both kinds, any weight source): a cell carrying
sum_weightsreads N* =sum_weights(frequency) orn_eff(probability) — never the rawn— and its variance fromm2on w*.Summary.Parametersaddssum_weights(+n_eff) over the legs read. - Byte-equal to the two-sample z-test on the same inputs (shared
normalTwoSidedP). - Differs from the cell t overlay only by distribution (normal vs Student t); same SE
sqrt(var_t/n_t + var_r/n_r). - Legacy
processing.WelfordTriplesmuggle REMOVED v0.20.0;MatrixCell.Valueholds the scalar mean. - Buffered (inferential).
See
- Skills:
overlay-system,response-components,op-overlay-t-cell,op-overlay-z-vs-ref,op-agg-welford.
op-overlay-z-vs-ref
name: op-overlay-z-vs-ref
kind: operator
category: OVERLAY
operator: OVERLAY_Z_VS_REF
description: Compose-host per-group two-sample z-test on the means against the reference slot's matching group (SERIES); parity overlay reading from CellComponents.
type: reference
applies_to: compose
examples_tags: [overlay, compose, hypothesis-test, z, byte-equal-test]
Compose-only parity overlay. Series-shape sibling of the cell z overlay. Overlays decorate the host; no Response.Components.
Use when
Large-sample z-test for every group of a grouped result in a Compose request: does the target’s mean differ from the reference’s?
Questions it answers:
- With large samples, which regions’ average spend changed since last quarter?
- Which high-volume channels’ average basket differs between the two periods?
Use something else:
OVERLAY_T_VS_REFwhen any group is small; the t-based version is more honest there.
Params
Scope required, must be group. Reference / Targets required slot labels. Optional params.variance_target/_ref (float, 1.0), params.sample_size_target/_ref (int, 2). Without AGG_WELFORD these apply to EVERY group: p-values then describe the supplied values, not each group.
multiplicity— optional{method, family, alpha}; addsp_adjustedfigures, raw p untouched (multiplicity-correction).
Host shape
COMPOSE — SERIES grouped Process on both slots. Parity overlay — reads {n, mean, variance} from Response.Components.Crosstab.CellComponents (matrix arms via AGG_WELFORD), else the params triple. Renders as a per-group strip.
Output
SERIES — one SeriesEntry per host group key carrying the p-value on Summary.Statistic. One layer per target; Baseline unset.
Reading the output
summary.statistic: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.- Caveat: Despite the field name, each series entry’s summary.statistic holds that group’s two-sided z-test p-value, not a z value; Pulse does not emit z.
- Caveat: It is read from the normal curve, so with small groups it comes out too small.
Gotchas
- Weighted (both kinds): a row triple carrying
sum_weightsreads N* =sum_weights(frequency) orn_eff(probability), never the rawn, and its variance fromm2on w*.Summary.Parametersaddssum_weights(+n_eff) over the legs read. - Byte-equal to the two-sample z-test on the same inputs — shares
normalTwoSidedPwith it and the cell z overlay. - Missing reference row, or degenerate inputs (
se == 0,n < 2) →PULSE_OVERLAY_REF_ZEROwithref_missing=true, entry NaN. - Unlike the streamable SERIES arm of the reference index / delta: inferential, buffered.
See
op-overlay-zscore-vs-margin
name: op-overlay-zscore-vs-margin
kind: operator
category: OVERLAY
operator: OVERLAY_ZSCORE_VS_MARGIN
description: Per-cell standardized-margin z-score — (cell − margin) / sd where sd is the population SD within the same margin slice.
type: reference
applies_to: process, compose
examples_tags: [overlay, cross-tabulation, outlier-detection]
Overlays decorate the host; no Response.Components.
Use when
Each crosstab cell’s gap from its row, column or grand margin, in standard deviations of the cells in that slice: which cells stand out?
Questions it answers:
- Which segment-by-question averages sit unusually far from the question’s overall average?
- Which store-by-month cells stand out from their store’s typical month?
Use something else:
OVERLAY_DELTA_VS_MARGINwhen the gap in the cell’s own units is easier to explain.
Params
Scope must be cell. Ref.Margin.Axis (enum, required) — row / column / grand. Level/Within must be 0.
Host shape
MATRIX crosstab. First non-ratio overlay — output is unitless deviation, not ratio or percentage. Sibling of the margin index (ratio) and the margin delta (additive).
Output
MATRIX — Cells[r][c].Value = (cell - margin) / sd. Mirrors host RowKeys / ColumnKeys. Absent cells stay absent. Layer Baseline = 0 (z-score centerpoint).
Reading the output
cells.value: How far each cell sits from its row, column or grand margin figure, in standard deviations of the cell values across that slice (dividing by the number of cells). 0 means the cell equals the margin figure.- Sign: positive means the value sits above its centre; negative means the value sits below its centre.
- Caveat: The spread is that of the cells in the slice, not the sampling error of one cell, so a cell built from few rows can sit far out without being unusual.
Gotchas
- Per-slice population SD via Welford recurrence. Supports all three axes (
row/column/grand). sd == 0(constant slice) → NaN cell + ONEPULSE_OVERLAY_REF_ZEROper affected slice.- Empty
Ref.Margin→PULSE_OVERLAY_REF_INCOMPATIBLE_WITH_SHAPE. - Population SD convention (divide by N, not N-1) — like the row z-score attribute and the total z-score overlay.
- Buffered (host crosstab path + per-slice Welford recurrence both need materialised matrix).
See
op-overlay-zscore-vs-pop
name: op-overlay-zscore-vs-pop
kind: operator
category: OVERLAY
operator: OVERLAY_ZSCORE_VS_POP
description: Per-value population-comparison z-score for a Facet host — (subset_freq − pop_freq) / sd_pop.
type: reference
applies_to: facet
examples_tags: [overlay, facet, outlier-detection, streaming-friendly]
Rides on FacetRequest.Overlays. Overlays decorate the host; no Response.Components.
Use when
Puts each category’s share gap with a comparison population on a z-score scale; for numeric bins it says nothing about the subset.
Questions it answers:
- Which answers does this segment pick more or less often than the comparison population, relative to how much answer shares vary?
- Which categories stand out in this region compared with the whole business?
Params
Scope must be group. Ref.Population.Cohort (string, required) — comparison-population cohort name. Level/Within must be 0. Other Ref arms → PULSE_OVERLAY_REF_INCOMPATIBLE_WITH_SHAPE.
Host shape
FACET — FacetResult, discrete or numeric arm. Streamable sibling to the population index: the two streamable FACET kinds, one Facet pass.
Output
SERIES — one SeriesEntry per host value in payload order carrying the z-score on Summary.Statistic. Layer Baseline = 0.
Reading the output
summary.statistic: In each series entry, for a categorical field: the category’s share in the subset minus its share in the population, divided by the standard deviation of the population’s shares across categories. For a numeric field: a histogram bin’s centre against the population’s mean, in population standard deviations.- Sign: positive means the value sits above its centre; negative means the value sits below its centre.
Gotchas
- Discrete:
(subset_freq - pop_freq) / sd_pop,sd_pop = FacetPopulationView.DiscreteFrequencyStdev()(population Welford SD across per-category frequencies). - Numeric: per-bin centre standardised against the population’s Welford
(mean, sd)fromFacetPopulationView. sd_pop == 0→ ONEPULSE_OVERLAY_REF_ZEROper affected entry + SKIP it (Statisticunset). An absent population entry reads aspop_freq = 0and IS emitted, no warning.- Streamable — post-finalize fold over the materialised host + population view; carrier not widened.
See
op-overlay-zscore-vs-rolling
name: op-overlay-zscore-vs-rolling
kind: operator
category: OVERLAY
operator: OVERLAY_ZSCORE_VS_ROLLING
description: Per-point windowed z-score against the rolling-window mean + SAMPLE SD of the W preceding points.
type: reference
applies_to: process, compose
examples_tags: [overlay, time-series, outlier-detection, window-operator]
Overlays decorate the host; no Response.Components.
Use when
How many standard deviations each point sits from the rolling mean of the previous W points: a simple flag for unusual periods.
Questions it answers:
- Which days had unusually high or low orders compared with the previous two weeks?
- Which readings jumped far from their recent run?
Use something else:
TEST_TRENDwhen you want to test for a steady rise or fall across the whole series.
Params
Scope required, must be group. Ref.RollingMean empty marker tags the ref family. params.window required, positive width W. Level / Within must be 0.
Host shape
SERIES ordered grouped Process host. Shares the per-group ring buffer + Welford trio with the rolling-mean index, reading mean + M2.
Output
SERIES — one SeriesEntry per host group key carrying z = (point - rolling_mean) / rolling_sd on Summary.Statistic, rolling_sd = sqrt(M2 / (count - 1)) (SAMPLE SD, n-1). Layer Baseline = 0.
Reading the output
summary.statistic: In each series entry: how far the point sits from the mean of up to W points before it, in sample standard deviations of those points. 0 means the point equals its recent average.- Sign: positive means the value sits above its centre; negative means the value sits below its centre.
- Caveat: There are no sourced bands for reading this z-score: the familiar 1.96 / 2.58 cut-offs are critical values of a test statistic built from a standard error, and this value is built from a spread of values instead, so it has no p-value behind it.
Gotchas
- SAMPLE SD: the window IS a sample of the wider series → unbiased variance. Contrast the total z-score (population SD, ÷N).
- Missing
params.window→PULSE_OVERLAY_PARAM_MISSING;window <= 0→PULSE_OVERLAY_LEVEL_OUT_OF_RANGE. count < 2→ NaN, no warning (Welford needs ≥2). Zero rolling SD → NaN + ONEPULSE_OVERLAY_REF_ZERO.- Absent host point → NaN, and the ring does NOT advance.
- Buffered.
See
op-overlay-zscore-vs-total
name: op-overlay-zscore-vs-total
kind: operator
category: OVERLAY
operator: OVERLAY_ZSCORE_VS_TOTAL
description: Per-group streamable z-score against the SERIES host's grand-total distribution (population SD).
type: reference
applies_to: process, compose
examples_tags: [overlay, outlier-detection, streaming-friendly]
Overlays decorate the host; no Response.Components.
Use when
How many standard deviations each group’s value sits from the average of all groups on a grouped result: which groups stand out?
Questions it answers:
- Which stores’ sales are unusually high or low compared with the other stores?
- Which regions stand out from the rest on average rating?
Use something else:
TEST_ANOVA_WELCHwhen you want to test whether group averages differ, from raw rows.
Params
Scope required, must be group. Ref empty (implicit grand-total; populated → PULSE_OVERLAY_REF_INCOMPATIBLE_WITH_SHAPE). Level / Within must be 0.
Host shape
SERIES grouped Process host. Streamable alongside the total index and grand-total share.
Output
SERIES — one SeriesEntry per host group key in host order carrying z = (group_val - mean) / sd on Summary.Statistic, sd = sqrt(M2 / N) (POPULATION SD). Layer Baseline = 0.
Reading the output
summary.statistic: In each series entry: how far the group’s value sits from the average of all groups, in standard deviations of the group values (dividing by the number of groups). 0 means the group equals that average.- Sign: positive means the value sits above its centre; negative means the value sits below its centre.
- Caveat: The spread is between groups, not between rows within a group.
Gotchas
- POPULATION SD: the per-group set IS the standardisation target. Contrast the rolling z-score (sample SD, n-1); matches the margin z-score’s and the row z-score attribute’s denominator — but the variance is across GROUPS, not raw records.
sd == 0(all groups equal, all zero, single group) → NaN + ONEPULSE_OVERLAY_REF_ZEROper layer. Absent host group → unset entry, no Welford contribution.- Streamable — the Welford
(count, mean, M2)triple rides the streaming fold, byte-equal within ULP across every path.
See
op-reg-bayes-linear
name: op-reg-bayes-linear
description: Bayesian linear regression with a conjugate Normal-Inverse-Gamma prior; emits posterior means, std errors, and credible intervals. Streams the same sufficient statistics as an OLS fit.
kind: operator
category: REG
operator: REG_BAYES_LINEAR
type: reference
applies_to: process, compose, predict
examples_tags: [regression, bayesian, streaming-friendly]
Regression operators emit coefficient + diagnostics; no Response.Components. Fit summaries ride Response.Regressions[i].
Use when
Fits a straight-line model of a numeric outcome under a prior, reporting each coefficient’s posterior mean and credible interval.
Questions it answers:
- Given the data and our prior belief, what range of values is plausible for the price coefficient?
- How do the coefficients shift when last year’s estimates are used as the prior?
Use something else:
REG_GLMwhen the outcome is yes/no (0/1) or a count.
Params
| Name | Type | Default | Description |
|---|---|---|---|
target | field | required | Response variable (numeric). |
predictors | []field | required | ≥1 numeric predictor. |
prior | enum | nig | Only "nig" (conjugate Normal-Inverse-Gamma) in v1. |
prior_mu | []float | zero | Prior mean vector, length predictors + 1, intercept FIRST. |
prior_precision | float | 0.001 | Scalar ε, Λ₀ = ε·I, relative to σ²: prior SD = σ/√ε. |
prior_shape / prior_rate | float | engine | Inverse-gamma α₀ / β₀ on residual variance. |
credible_level | float | 0.95 | Posterior credible-interval mass. |
conf_level | float | credible_level | CILower / CIUpper mass, 0<c<1. |
weight | slot weight | inherited | null opts out; frequency only (= the expanded rows: X’WX, X’Wy, Σw in the posterior). Adds SumWeights; NObs raw rows. |
vcov | bool | false | Adds the POSTERIOR covariance b_n/(a_n−1)·Λ_n⁻¹ (null if a_n ≤ 1) + Correlation; diag = StdErrors²·a_n/(a_n−1). |
covariance | object | absent | {type, cluster, order_by, lags} as op-reg-ols. Keeps the posterior-mean βs but REPLACES the posterior covariance with Müller’s (2013) sandwich posterior N(μ_n, Λ_n⁻¹MΛ_n⁻¹): StdErrors, CredibleIntervals, CIs (normal, no DF) and Vcov (√diag = StdErrors) follow it. |
Inputs
target / predictors — numeric analytics set: u4/u8/u16/u32/u64, f32/f64, decimal128, date, packed_bool, plus nullable variants. Nullable rows drop from n_obs.
Output
RegressionResult: Coefficients["(intercept)"] + per-predictor posterior-mean βs; StdErrors = the Student-t marginal’s SCALE (not the posterior SD, which is larger by √(ν/(ν−2))); CredibleIntervals[name] = [lower, upper] at credible_level; CILower / CIUpper the same t interval at conf_level (equal when unset), DF = 2a_n, ConfLevel; R2, AdjR2, ResidualStdErr, NObs; a robust covariance adds the Covariance echo. No PValues — Bayesian inference reports credibility, not tail probability. Streams the same Welford stats as OLS; one finalize-time Cholesky on Λ_n applies the conjugate posterior.
Reading the output
coefficients.*: Posterior mean of the coefficient: the expected difference in the outcome between rows one unit apart on this predictor, holding the other predictors fixed, after combining the data with the prior.std_errors.*: Scale of the coefficient’s Student-t posterior: how uncertain the coefficient is given the data and the prior. With many rows it is close to the posterior standard deviation.conf_level: The coverage ci_lower and ci_upper were built at (the request’s conf_level, 0.95 by default).
Gotchas
- A probability weight in force (request, slot or
Options.DefaultWeight) →PULSE_WEIGHT_UNSUPPORTEDnaming the kind;"weight": nullruns it unweighted. penalty/alpha/l1_ratio/family/linkrejected — other engines’ knobs.resample/selection→PROCESSING_CONFIG(not advertised) — credible intervals already convey uncertainty.prior_mulength ≠ predictors + 1 →PROCESSING_CONFIG.- Robust
covarianceis a substitution, not a posterior: honest under heteroskedastic or dependent rows, but no longer the conjugate model’s probability statement. It forces the buffered path; < 2 clusters or a repeatedorder_by→PROCESSING_REGRESSION_VCOV_UNSUPPORTED. - Vague-prior limit reproduces the OLS point estimate; intervals and
ResidualStdErrrun below OLS with few rows per predictor (σ² ≈ RSS/n, not RSS/(n−p−1)). R2is on the posterior-mean fit, not averaged over posterior draws.
See
pulse_examples_search tags=[bayesian]- Skills:
regression-modeling,op-reg-ols,op-reg-glm
op-reg-glm
name: op-reg-glm
description: Generalized linear model via IRLS; supports binomial (logistic), poisson, and gamma families with family-specific link functions. Always buffered.
kind: operator
category: REG
operator: REG_GLM
type: reference
applies_to: process, compose, predict
examples_tags: [regression, glm, logistic, buffered-pipeline]
Regression operators emit coefficient + diagnostics; no Response.Components. Fit summaries ride Response.Regressions[i].
Use when
Fits a generalized linear model: logistic regression for yes/no outcomes, Poisson for counts, gamma for positive skewed amounts.
Questions it answers:
- Which customer traits are associated with the chance of churning, holding the others fixed?
- How does the expected number of support tickets change with account size?
Use something else:
TEST_PROP_Zwhen you only want to know whether a yes/no rate differs between two groups.
Params
| Name | Type | Default | Description |
|---|---|---|---|
target | field | required | Response variable (numeric). |
predictors | []field | required | ≥1 numeric predictor. |
family | enum | required | binomial, poisson, gamma. |
link | enum | family default | binomial→logit, poisson→log, gamma→inverse. identity, probit, cloglog, sqrt reserved. |
max_iters / tol | int / float | engine | IRLS caps. |
weight | slot weight | inherited | null opts out; both kinds. IRLS prior weights w* (R glm(weights = w*)); SEs, deviances on w*. |
conf_level | float | 0.95 | CILower / CIUpper coverage, 0<c<1. |
vcov | bool | false | Adds Vcov = (XᵀWX)⁻¹ at the converged weights (dispersion 1) + Correlation; √diag = StdErrors. Resample: replicate covariance; selection: final refit’s. |
covariance | object | absent | {type, cluster, order_by, lags} as op-reg-ols: hc0–hc3, cluster (CR1), hac (Newey–West). Sandwich over the IRLS scores at the converged weights (R sandwich on glm) behind StdErrors, PValues, CIs, Vcov; still Wald z (no DF, cluster too). Weighted: w* rule. |
resample / selection are top-level modifiers — see op-reg-mod-resample, op-reg-mod-selection.
Inputs
target / predictors — numeric analytics set: u4/u8/u16/u32/u64, f32/f64, decimal128, date, packed_bool, plus nullable variants. Binomial targets accept {0,1} or packed_bool. Nullable rows drop from n_obs.
Output
RegressionResult: Coefficients["(intercept)"] + per-predictor βs (link scale); StdErrors, PValues (Wald-z from Cov(β) = (XᵀWX)⁻¹); CILower / CIUpper = Wald β ∓ z·SE (R confint.default, not the profile confint), ConfLevel, no DF; Deviance, NullDeviance, PseudoR2 = 1 − D/D₀ (McFadden only for 0/1 binomial); Family, Link echoed; ConvergedIters = IRLS steps; NObs raw rows, weighted adds SumWeights (+ NEff, probability); a robust covariance adds the Covariance echo. Always buffered — IRLS needs multiple passes.
Reading the output
coefficients.*: The change in the linear predictor, on the link scale, for a one-unit increase in this predictor, holding the other predictors fixed: log-odds for binomial (logit link), the log of the expected count for Poisson (log link), the inverse of the expected value for gamma (inverse link).std_errors.*: How much the coefficient would vary from sample to sample, from the inverse of the fitted information matrix, with the dispersion fixed at 1.conf_level: The coverage ci_lower and ci_upper were built at (the request’s conf_level, 0.95 by default).
Gotchas
- Weighted:
resample/selection→PULSE_WEIGHT_UNSUPPORTED; n_eff < p+1 →PULSE_WEIGHT_LOW_NEFF. penalty/alpha/l1_ratiorejected →PROCESSING_CONFIG. Regularized GLM is a later phase.- Binomial separation diverges →
PROCESSING_REGRESSION_NO_CONVERGE. Raisemax_iters, drop the offender, or pre-bin. - Unsupported link →
PROCESSING_REGRESSION_INVALID_LINK. - Dispersion fixed at 1 for binomial / poisson, and gamma (weighted too) inherits it — be sceptical of gamma SEs and overdispersed counts;
covariancehc0–hc3is dispersion-free. - Robust
covariancebesideresample/selection, < 2 clusters or a repeatedorder_by→PROCESSING_REGRESSION_VCOV_UNSUPPORTED. A nullcluster/order_bydrops the row. - Collinearity →
PROCESSING_REGRESSION_RANK_DEFICIENT; drop a predictor.
See
pulse_examples_search tags=[glm]- Skills:
regression-modeling,op-reg-ols,op-reg-bayes-linear,op-reg-mod-resample,op-reg-mod-selection
op-reg-mod-resample
name: op-reg-mod-resample
description: Spec-level resampling modifier (jackknife / bootstrap) that composes with an OLS or GLM fit; replaces analytical std errors with empirical estimates. Non-empty value forces the buffered path.
kind: operator
category: REG
operator: REG_RESAMPLE
type: reference
applies_to: process, compose, predict
examples_tags: [regression, resampling, jackknife, buffered-pipeline]
Regression operators emit coefficient + diagnostics; no Response.Components. This modifier overwrites StdErrors / PValues in Response.Regressions[i]; the point estimate stays the full-data fit.
Params
Top-level resample on RegressionSpec plus two bootstrap knobs. A wrapper, not a fit: the host fit runs once per replicate (jackknife = n refits; bootstrap = bootstrap_iters) and β samples are aggregated.
| Name | Type | Default | Description |
|---|---|---|---|
resample | enum | "" | "", jackknife (leave-one-out), bootstrap (non-parametric). |
bootstrap_iters | int | 1000 | Replicate count; read only when resample == "bootstrap". |
rng_seed | int | 0 | 0 derives a deterministic per-request seed; non-zero is reproducible. |
Inputs
Inherits target / predictors from the host RegressionSpec. No additional field inputs.
| Host | Accepted? |
|---|---|
REG_OLS (any penalty) | yes |
REG_GLM | yes |
REG_BAYES_LINEAR | rejected — credible intervals already convey uncertainty |
Output
StdErrors[name]— jackknife√((n−1)/n · Σ(β⁽⁻ⁱ⁾ − β̄)²); bootstrap sample std of replicates.PValues[name]— jackknife pivotal; bootstrap percentile-method two-sided.Resampleechoes back;Coefficientsunchanged. Forl1/elasticnetOLS, bootstrap suppressesPROCESSING_REGRESSION_APPROXIMATE_SE.
Gotchas
- Any non-empty
resampleforces buffered —RegressionSpec.Streamable()→ false. Predict reports the downgrade. - Jackknife is
O(n)refits; on large cohorts prefer bootstrap with moderate replicates. - Composes with
selection— the resample wraps the selected-feature fit. rng_seed = 0is deterministic per request but cohort-dependent; pin non-zero for cross-cohort reproducibility.- Never weighted: any row weight in force on the regression (slot, request or
Options.DefaultWeight) isPULSE_WEIGHT_UNSUPPORTEDwithdetails.reason;"weight": nullon the slot runs it unweighted.
See
pulse_examples_search tags=[jackknife]- Skills:
regression-modeling,op-reg-ols,op-reg-glm,op-reg-mod-selection
op-reg-mod-selection
name: op-reg-mod-selection
description: Spec-level subset-selection modifier (forward / backward / stepwise) that composes with an OLS or GLM fit; drives a greedy search over predictors against an information criterion. Non-empty value forces the buffered path.
kind: operator
category: REG
operator: REG_SELECTION
type: reference
applies_to: process, compose, predict
examples_tags: [regression, selection, stepwise, buffered-pipeline]
Regression operators emit coefficient + diagnostics; no Response.Components. This modifier shrinks the active predictor set in Response.Regressions[i].SelectedFeatures and drops non-selected entries from Coefficients / StdErrors.
Params
Top-level selection on RegressionSpec plus its required criterion companion. A wrapper, not a fit: the engine refits the host model against candidate subsets and keeps the lowest-criterion winner.
selection— enum, default""."",forward(intercept-only, add best each step),backward(full, drop best each step),stepwise(bidirectional add/drop until no move improves).criterion— enum, default required when set.aicorbic.
Inputs
Inherits target / predictors from the host RegressionSpec. No additional field inputs.
| Host | Accepted? |
|---|---|
REG_OLS (penalty == "") | yes |
REG_OLS (penalty != "") | → PROCESSING_REGRESSION_REGULARIZED_SELECTION |
REG_GLM | yes |
REG_BAYES_LINEAR | rejected — stepwise on a NIG fit is out of scope |
Output
SelectedFeatures []string— chosen predictors in fit order.Coefficientsfor selected predictors plus(intercept)only. Absent ≠ zero — non-selected predictors were dropped entirely.StdErrors/PValuesfor selected predictors only.Selection/Criterionecho back.
Gotchas
- Any non-empty
selectionforces buffered —RegressionSpec.Streamable()→ false. Predict reports the downgrade. criterionmissing whenselectionset →SERVICE_VALIDATION.bicpenalizes complexity harder thanaic.- Worst-case
O(p²)fits (stepwise); on wide tables preferforward. - Composes with
resample— the resample wraps the selected-feature fit. - Selection inflates type-I error on retained predictors; treat the model as exploratory.
- Never weighted: any row weight in force on the regression (slot, request or
Options.DefaultWeight) isPULSE_WEIGHT_UNSUPPORTEDwithdetails.reason;"weight": nullon the slot runs it unweighted.
See
pulse_examples_search tags=[stepwise]- Skills:
regression-modeling,op-reg-ols,op-reg-glm,op-reg-mod-resample
op-reg-ols
name: op-reg-ols
description: Ordinary least squares with optional l1/l2/elasticnet penalty; covers simple, multiple, ridge, lasso, and elastic-net regression over streaming sufficient statistics.
kind: operator
category: REG
operator: REG_OLS
type: reference
applies_to: process, compose, predict
examples_tags: [regression, ols, streaming-friendly]
Regression operators emit coefficient + diagnostics; no Response.Components. Fit summaries ride Response.Regressions[i].
Use when
Fits a straight-line model that predicts a numeric outcome from one or more numeric predictors, by least squares.
Questions it answers:
- Which of price, discount and season are associated with weekly sales, holding the others fixed?
- How much does predicted order value change per extra visit, with the other predictors held fixed?
Use something else:
REG_GLMwhen the outcome is yes/no (0/1) or a count.
Params
| Name | Type | Default | Description |
|---|---|---|---|
target | field | required | Response variable (numeric). |
predictors | []field | required | ≥1 numeric predictor. |
penalty | enum | "" | "", l1, l2, elasticnet. |
alpha | float | 0 | Strength; >0 when penalty set. |
l1_ratio | float | 0 | Elastic-net mix in [0,1]. |
max_iters / tol | int / float | engine | Coordinate-descent caps for regularized fits. |
weight | slot weight | inherited | null opts out; both kinds. WLS β (penalty × Σw: kind-free); SE, df = N*−p−1 (fractional), AdjR2 on N* = Σw (frequency) / Kish n_eff (probability). |
conf_level | float | 0.95 | CILower / CIUpper coverage, 0<c<1. |
vcov | bool | false | Adds Vcov + Correlation (MatrixValues, keys (intercept) + predictors); √diag = StdErrors. Unpenalized σ̂²(XᵀX)⁻¹ / ridge sandwich, N* basis. Resample: replicate covariance; selection: final refit’s. l1 / elasticnet → PROCESSING_REGRESSION_VCOV_UNSUPPORTED. |
covariance | object | absent | {type, cluster, order_by, lags} — the estimator behind StdErrors, PValues, CIs, Vcov. classical (= absent); hc0–hc3; cluster (CR1 on cluster, categorical / unsigned int, DF = G−1); hac (Newey–West, order_by date / datetime / numeric, lags int or "auto" = ⌊4(n/100)^(2/9)⌋). Weighted: w* rule. |
resample / selection are top-level modifiers — see op-reg-mod-resample, op-reg-mod-selection.
Inputs
target / predictors — numeric analytics set: u4/u8/u16/u32/u64, f32/f64, decimal128, date, packed_bool, plus nullable variants. Nullable rows drop from n_obs via the per-record bitmap.
Output
RegressionResult: Coefficients["(intercept)"] + per-predictor βs; StdErrors, PValues (Student-t); CILower / CIUpper = β ∓ t(DF)·SE wherever an SE is (resample: z on its SE, no DF), DF, ConfLevel; R2, AdjR2, ResidualStdErr, NObs (raw rows); weighted adds SumWeights (+ NEff, probability). Penalized shrunk-to-zero βs drop from StdErrors. A robust covariance adds the Covariance echo (type, clusters, resolved lags). Streams Welford-Pébaÿ sufficient stats; the regularized solve runs once at finalize over the p×p Gram.
Reading the output
coefficients.*: The difference in the outcome’s expected value between rows one unit apart on this predictor, holding the other predictors fixed, in outcome units per predictor unit (an association, not the effect of changing it).std_errors.*: How much the coefficient would vary from sample to sample; smaller means a more precise estimate. It is the classic standard error, which assumes residuals of constant spread, unless the request’s covariance names a robust one.n_obs: Rows used in the fit, after rows with a null target or predictor were dropped.
Gotchas
- Weighted:
resample/selection→PULSE_WEIGHT_UNSUPPORTED; n_eff ≤ p+1 →PULSE_WEIGHT_LOW_NEFF; residual df ≤ 0 makes SEs, p,CILower/CIUpper,DF,AdjR2,ResidualStdErrnull. l1/elasticnetSE is plug-in over the active set — pair withop-reg-mod-resample; otherwisePROCESSING_REGRESSION_APPROXIMATE_SEwarns.- Robust
covarianceforces the buffered path (second pass for residuals / leverage); besidepenalty/resample/selection, < 2 clusters or a repeatedorder_by→PROCESSING_REGRESSION_VCOV_UNSUPPORTED. A nullcluster/order_bydrops the row. - Collinearity →
PROCESSING_REGRESSION_RANK_DEFICIENT/SINGULAR_GRAM; drop a predictor or addl2. penalty != ""+selection != ""→PROCESSING_REGRESSION_REGULARIZED_SELECTION.- Polynomial: stage
FEAT_POLYpolynomial columns upstream; degree gate[2,10]; standardize first. - Per-row residual / fitted / leverage live in
ATTR_REG_*— separate slot, separate prepass.
See
pulse_examples_search tags=[ols]- Skills:
regression-modeling,op-reg-mod-resample,op-reg-mod-selection,op-reg-glm,op-reg-bayes-linear
op-synth-bernoulli
name: op-synth-bernoulli
description: Bernoulli samples emitting 0 or 1 with probability p; pairs with packed_bool or unsigned int fields.
kind: operator
category: SYNTH
operator: bernoulli
type: reference
applies_to: inspect, predict, manifest
examples_tags: [synth, proportion-analysis]
Synth distributions emit per-row values; no Response.Components.
Use when
Draws a yes/no value per row: 1 with a chosen chance p and 0 otherwise, such as whether a customer churned.
Questions it answers:
- How do I mark 12% of synthetic customers as churned?
- How do I generate a random true/false flag?
Use something else:
weighted_categoricalwhen there are more than two outcomes.set_bernoulliwhen each row can tick several options.constantwhen every row should get the same value.
Params
p — float, default 0.5, probability of emitting 1; must lie in [0, 1].
Inputs
Field type: — packed_bool (1 bit per row), unsigned int (u4/u8/u16/u32/u64), or f32/f64.
Output
Per-row 1.0 with probability p, else 0.0; cast to the declared field type at write time. packed_bool packs into the bit-shared neighbour byte.
Gotchas
- The only honest marginal for a
packed_bool.profile createreconstructs everypacked_boolasbernoulliwithp= observed mean, ahead of--fit-shape. A continuous marginal cannot round-trip through one bit — the writer must threshold, and a thresholded clamped normal reproduces the wrong prevalence (measured on a 90-boolean cohort: mean error 0.47, 89 of 90 fields off by >0.05; an 11% attribute generated at 64%). - A modelled
bernoullitarget is a PROBIT.quantileFor’s stepQmakesvalue = Q(Φ(μ + σz))intoP(1 | row) = Φ((μ − Φ⁻¹(1−p)) / σ). Coefficients order rows and hold the marginal exactly; they are NOT probability changes. The fidelitymodelssection scores these on the interval-midpoint probit score, not a latent inversion. poutside[0, 1]→SERVICE_VALIDATIONat spec parse.pof exactly 0 or 1 is legal and exact but has zero variance, so a model on that target is dropped with a warning.- For
> 2experiment arms useweighted_categorical. - Observed
p_hatconverges atO(1/sqrt(n))— small cohorts diverge from declaredp. - Same
(spec, opts.Seed)⇒ identical bit pattern.
See
pulse_examples_search tags=[synth]- Skills:
synth-marginals,op-synth-weighted-categorical,op-synth-constant
op-synth-constant
name: op-synth-constant
description: Emit a constant value on every row; useful for unit testing, sentinel columns, and placeholder fields.
kind: operator
category: SYNTH
operator: constant
type: reference
applies_to: inspect, predict, manifest
examples_tags: [synth, data-quality]
Synth distributions emit per-row values; no Response.Components.
Use when
Puts the same value on every row, for a placeholder field or a fixed flag in a test fixture.
Questions it answers:
- How do I fill a column with the same country code?
- How do I add a fixed version field to test data?
Use something else:
monotonic_fromwhen each row needs a distinct ID.weighted_categoricalwhen the value should vary over a few labels.bernoulliwhen a flag should be set on only some rows.
Params
value— any, required. Per-row payload. Interpreted by the declared field type at write time.
value is checked and normalised to the field’s row shape once, at spec parse — never at write time.
Inputs
field type: — any .pulse field type. Categorical takes a string, set_* a list of option names, numeric/date a number or bool (decimal128 also a string).
Output
Same value returned on every row. RNG state untouched.
Gotchas
- Missing
valueparam →SERVICE_VALIDATION(“requires param value”). - A
valuethe field cannot hold (text on a number field) →SERVICE_VALIDATIONat spec parse. - Consumes NO RNG — like
monotonic_from, adding / removing a constant field preserves byte-equality of every other field’s stream. - A constant categorical still emits a one-entry dictionary block (one byte per row at
categorical_u8); for a packed-bool equivalent usebernoulliwithp=0/p=1.
See
pulse_examples_search tags=[synth]- Skills:
synthetic-data,op-synth-monotonic-from,op-synth-bernoulli
op-synth-discrete
name: op-synth-discrete
description: Exact per-level histogram for an integer column; what a profile reconstructs every u4/u8/u16/u32/u64 field from.
kind: operator
category: SYNTH
operator: discrete
type: reference
applies_to: inspect, predict, manifest
examples_tags: [synth, cohort-analysis]
Use when
Draws whole-number levels at set shares, such as a 1 to 7 rating scale, so each level appears about as often as declared.
Questions it answers:
- How do I generate answers on a 1 to 5 scale where most people choose 4?
- How do I reproduce the exact spread of a rating question?
Use something else:
normalwhen the field is a continuous measure.weighted_categoricalwhen the levels are text labels.
Params
values— list of numbers, required, non-empty, strictly ascending.weights— list of numbers, default uniform. Non-negative,len(values)long. Raw counts fine — normalised at parse.
Inputs
Field type: — u4/u8/u16/u32/u64 (the arm profile create targets); also legal on f32/f64.
Output
One declared level per row at its own share. Marginal exact — no threshold, no rounding bias.
Gotchas
- The only honest marginal for a coded scale.
profile createuses it for every integer column with ≤64 observed levels, ahead of--fit-shape. A clamped normal keeps the mean and flattens the shape: a 1–7 item at 25.26% on level 1 generated 13.73%. - Over 64 levels the capture ABANDONS it (never truncates) and the field falls back to the clamped normal. The missing
discretekey in the profile is the record — no warning, no knob. - A modelled target is an ORDERED PROBIT. The staircase
Qshifts the latent, not the value: direction and ordering carry, scale-point magnitude does not.latentForrefuses a staircase, so the fidelitymodelssection scores both sides on the interval-midpoint probit score rather than inverting. - Non-ascending or duplicate
values→SERVICE_VALIDATION; silent sorting would breakQ’s monotonicity and the copula’s rank order. - A rule reading the field needs no
round()guard — the row value already is the stored integer.
See
op-synth-exponential
name: op-synth-exponential
description: Exponential samples with rate parameter lambda; mean is 1/lambda.
kind: operator
category: SYNTH
operator: exponential
type: reference
applies_to: inspect, predict, manifest
examples_tags: [synth, time-series, distribution-shape]
Synth distributions emit per-row values; no Response.Components.
Use when
Draws positive waiting times where short waits are common and long ones rare, set by how often events happen.
Questions it answers:
- How do I simulate the time between customer arrivals?
- How do I generate realistic call durations for a load test?
Use something else:
poissonwhen you want whole-number counts of events per period.paretowhen a few values should be extremely large.
Params
| Name | Type | Default | Description |
|---|---|---|---|
lambda | float | 1.0 | Rate parameter; must be > 0. Mean = 1/lambda. |
Inputs
| Param | Accepted field types |
|---|---|
field type: | numeric: u4/u8/u16/u32/u64, f32/f64, decimal128. Output is always > 0. |
Output
Per-row float64 sample from Exp(lambda), computed as rng.ExpFloat64() / lambda. Tail is unbounded — heavy enough to inflate u4/u8 overflow risk under low lambda.
Gotchas
lambda <= 0→SERVICE_VALIDATIONat spec parse.- Memoryless property: hazard rate is constant. Pair with
monotonic_fromfor inter-arrival timestamps (cumulative sum captured downstream via a window operator). - No clamp param — bound via
constraints(e.g.delay_s < 86400) if a runaway tail breaks the host field’s cast. - Common pattern:
exponentiallifetimes / wait-times, log-transformed for distribution-shape work.
See
pulse_examples_search tags=[synth]- Skills:
synthetic-data,op-synth-poisson,op-synth-pareto
op-synth-lognormal
name: op-synth-lognormal
description: Log-normal samples parameterised by log-space mu and sigma; output strictly positive.
kind: operator
category: SYNTH
operator: lognormal
type: reference
applies_to: inspect, predict, manifest
examples_tags: [synth, distribution-shape, financial]
Synth distributions emit per-row values; no Response.Components.
Use when
Draws positive numbers with a long right tail, like incomes or order values: most are modest and a few are very large.
Questions it answers:
- How do I simulate skewed order values?
- How do I generate realistic household incomes?
Use something else:
normalwhen values are spread evenly around a centre.paretowhen the tail should follow a power law, as with wealth or city sizes.
Params
mu— float, default0.0. Log-space mean (mean ofln(X)).sigma— float, default1.0. Log-space standard deviation; must be> 0.
Spec defaults mirror the standard log-normal. Mean of the emitted distribution is exp(mu + sigma²/2).
Inputs
field type: — numeric: u4/u8/u16/u32/u64, f32/f64, decimal128. Output is always > 0, so signed-clamp casts are safe.
Output
Per-row float64 sample exp(mu + sigma * Z) where Z ~ N(0, 1). Cast to declared field type at write time.
Gotchas
- Right tail is heavy; larger
sigmaincreases dispersion exponentially. Useparetofor true power-law tails. sigma <= 0→SERVICE_VALIDATIONat spec parse.- No built-in clamp — pair with a
constraint(amount < 1_000_000) to bound outliers. - Common synthetic-money pattern:
lognormaloverf64, then cast todecimal128with a constraint enforcing the precision floor.
See
pulse_examples_search tags=[synth]- Skills:
synthetic-data,op-synth-normal,op-synth-pareto
op-synth-mixture
name: op-synth-mixture
description: Mixture-of-normals samples — per-row component draw by weight, then a Gaussian from that component; reproduces bimodal/multimodal shapes.
kind: operator
category: SYNTH
operator: mixture
type: reference
applies_to: inspect, predict, manifest
examples_tags: [synth, distribution-shape]
Synth distributions emit per-row values; no Response.Components.
Use when
Draws from two or more bell curves blended by weight, for data with several peaks, like weekday and weekend order sizes.
Questions it answers:
- How do I simulate a column with two separate peaks?
- How do I generate order sizes where small and bulk orders form distinct groups?
Use something else:
normalwhen one peak is enough.lognormalwhen there is one peak with a long right tail.
Params
| Name | Type | Default | Description |
|---|---|---|---|
means | list[float] | required | Per-component means; length sets the component count (>= 2). |
stds | list[float] | required | Per-component std devs; each > 0, length must match means. |
weights | list[float] | uniform | Mixing weights; length must match means, non-negative, sum > 0. |
Selection mirrors weighted_categorical: draw an index off the cumulative-weight table, then draw N(means[i], stds[i]²).
Inputs
Field type: — numeric u4/u8/u16/u32/u64, f32/f64, decimal128. Cast at write time.
Output
Per-row float64 from the selected component’s Gaussian. No min/max clamp — use constraints for hard bounds.
Gotchas
- Fewer than 2
means→SERVICE_VALIDATION(usenormalfor one component). stds/weightslength mismatch, anystd <= 0, or negative/all-zeroweights→SERVICE_VALIDATION.profile create --fit-shapefits and emitsmixtureautomatically, but fixed at 2 components via BIC-vs-normal selection (internal/synth/shape.go) — no sweep over component count. 3+ components are hand-written only.- Excluded from
correlations: unmodelled mixtures are pre-claimed before the copula bids, modelled ones refused there permanently; a modelled mixture correlates throughresidual_correlationsinstead. - Composes with
--fit-models: a shape-fitted target draws through its model with the mixture asQvia a fixed-count bisection inverse, so coefficients are LATENT-scale — non-linear in value space. Seesynthetic-data.
See
pulse_examples_search tags=[synth]- Skills:
synth-shape-fit,op-synth-normal,op-synth-weighted-categorical
op-synth-monotonic-from
name: op-synth-monotonic-from
description: Strictly increasing or decreasing integer counter; deterministic per row, ignores RNG. Synthetic primary keys.
kind: operator
category: SYNTH
operator: monotonic_from
type: reference
applies_to: inspect, predict, manifest
examples_tags: [synth, cohort-analysis]
Synth distributions emit per-row values; no Response.Components.
Use when
Counts up (or down) by a fixed step from a start value, one step per row, to make unique IDs or row numbers.
Questions it answers:
- How do I give each synthetic row a unique customer ID?
- How do I number rows from 1000 upward?
Use something else:
constantwhen every row should get the same value.uniformwhen the numbers should be random, not in order.regexwhen IDs should be text codes like AB-1234.
Params
start— int, default0. First emitted value.step— int, default1, non-zero. Negative ⇒ decreasing.
The sampler holds cur = start - step and adds step before emitting, so row 0 emits start.
Inputs
Field type: — numeric u4/u8/u16/u32/u64, f32/f64. u64 for primary keys.
Output
Per-row float64 start + i * step for row i. RNG state untouched, so seeded streams stay independent of monotonic columns.
Gotchas
step == 0→SERVICE_VALIDATIONat spec parse.- Consumes NO RNG — adding / removing a
monotonic_fromfield is the only spec edit preserving byte-equality of every other field’s stream. - Rejected rows still advance the counter, so a post-constraint cohort has gaps. For dense IDs, sanitize constraints.
- Casting overflow is SILENT —
start=250, step=10, row_count=10overflowsu8after row 1. Declare wide enough, or useu64. - Pairs with
pulse synth from-schemafor fixtures;Seedis irrelevant here.
See
pulse_examples_search tags=[synth]- Skills:
synthetic-data,op-synth-constant,op-synth-uniform-date
op-synth-normal
name: op-synth-normal
description: Gaussian samples with optional [min, max] clamp; default mean=0, std=1.
kind: operator
category: SYNTH
operator: normal
type: reference
applies_to: inspect, predict, manifest
examples_tags: [synth, distribution-shape]
Synth distributions emit per-row values; no Response.Components.
Use when
Draws numbers from a bell curve around a chosen centre and spread, optionally clipped to a low and a high bound.
Questions it answers:
- How do I generate realistic test scores that cluster around 70?
- How do I make a numeric column with a known average and spread?
Use something else:
lognormalwhen values are always positive with a long right tail, like income.mixturewhen the data has two or more peaks.
Params
mean— float, default0.0.std— float, default1.0; must be> 0.min/max— float, default-inf/+inf. Setting EITHER flips an internalclampedflag; once on, BOTH bounds apply.
Inputs
Field type: — numeric u4/u8/u16/u32/u64, f32/f64, decimal128, date (days-since-epoch); u4 writes one byte per row. Categorical / packed_bool unsupported — use weighted_categorical or bernoulli.
Output
Per-row float64 from N(mean, std²), cast/clamped to the declared type at write time; days-since-epoch on a date field.
Gotchas
- Heavy clamping distorts pairwise correlations —
|rho|_actual < |rho|_requestedwhen a partner field is clamp-narrow. std <= 0→SERVICE_VALIDATIONat spec parse.- Nullable fields draw the inner value first, then the null mask, so the seeded stream is invariant to which rows end up null.
- Same
(spec, opts.Seed)⇒ byte-identical output.Seed == 0is stable, not random.
See
pulse_examples_search tags=[synth]- Skills:
synthetic-data,op-synth-lognormal,op-synth-uniform
op-synth-pareto
name: op-synth-pareto
description: Heavy-tailed Pareto samples; xm is the scale (minimum), alpha the shape (tail weight).
kind: operator
category: SYNTH
operator: pareto
type: reference
applies_to: inspect, predict, manifest
examples_tags: [synth, distribution-shape, outlier-detection]
Synth distributions emit per-row values; no Response.Components.
Use when
Draws heavy-tailed values above a minimum, where a small share of rows holds most of the total, like wealth or file sizes.
Questions it answers:
- How do I simulate customers where the top fifth spend most of the money?
- How do I generate file sizes with a few huge files?
Use something else:
lognormalwhen the tail is long but values have a typical size.exponentialwhen the values are waiting times between events.
Params
xm— float, default1.0. Scale (minimum emitted value); must be> 0.alpha— float, default1.5. Shape; must be> 0. Larger alpha → lighter tail.
Inputs
field type: — numeric: u4/u8/u16/u32/u64, f32/f64, decimal128. Output is always >= xm.
Output
Per-row float64 sample from a Pareto Type-I distribution. Drawn via inverse CDF: xm / U^(1/alpha) where U = 1 - rng.Float64().
Gotchas
alpha <= 1→ no finite mean.alpha <= 2→ no finite variance. Document the choice if downstream tests assume moments.xm <= 0 || alpha <= 0→SERVICE_VALIDATIONat spec parse.- Tail dominates the buffered moments — the mean over Pareto cohorts converges slowly; prefer the median for centre estimates.
- Common synthetic-wealth pattern:
xm = 10_000,alpha = 1.16reproduces an 80/20 Pareto split. - No clamp param — use
constraintsto bound the tail when needed (e.g.wealth < 1e9).
See
pulse_examples_search tags=[synth]- Skills:
synthetic-data,op-synth-exponential,op-synth-lognormal
op-synth-poisson
name: op-synth-poisson
description: Poisson-distributed non-negative integer counts with mean lambda; Knuth multiplicative below 30, normal-approximation above.
kind: operator
category: SYNTH
operator: poisson
type: reference
applies_to: inspect, predict, manifest
examples_tags: [synth, cardinality-analysis]
Synth distributions emit per-row values; no Response.Components.
Use when
Draws whole-number counts of events per period, such as visits per day, around a chosen average.
Questions it answers:
- How many orders might a store get per hour?
- How do I simulate defect counts per batch?
Use something else:
exponentialwhen you want the time between events, not how many.bernoulliwhen each row is a yes/no outcome.discretewhen you know the exact share of each count.
Params
lambda— float, default1.0. Rate parameter; equals both the mean and the variance. Must be> 0.
Inputs
field type: — numeric: u4/u8/u16/u32/u64, f32/f64. Cast at write time — u4 overflows silently if lambda is large.
Output
Per-row non-negative integer (returned as float64). Two algorithms:
lambda < 30: Knuth’s multiplicative method — exact, drawskuniforms until product< exp(-lambda).lambda >= 30: normal approximationN(lambda, lambda)rounded to nearest non-negative integer.
Gotchas
- Knuth runtime is
O(lambda)per row — large lambda would dominate without the normal-approx switch. lambda <= 0→SERVICE_VALIDATIONat spec parse.- Variance equals mean; for over-dispersed counts pair with a
lognormalmixer or use rejection sampling viaconstraints. - Same
(spec, opts.Seed)is deterministic across both algorithm branches.
See
pulse_examples_search tags=[synth]- Skills:
synthetic-data,op-synth-exponential,op-synth-bernoulli
op-synth-regex
name: op-synth-regex
description: String samples generated from a Perl/RE2 regex pattern; walks regexp/syntax AST with bounded repetition.
kind: operator
category: SYNTH
operator: regex
type: reference
applies_to: inspect, predict, manifest
examples_tags: [synth, data-quality]
Synth distributions emit per-row values; no Response.Components.
Use when
Generates text matching a pattern, such as order codes like AB-1234, for realistic-looking IDs and labels.
Questions it answers:
- How do I generate postcodes that look real?
- How do I fill a SKU column with codes like SKU-00042?
Use something else:
weighted_categoricalwhen the values come from a short known list.monotonic_fromwhen IDs must be unique.constantwhen every row should carry the same text.
Params
pattern— string, required, non-empty. Perl/RE2 source viaregexp/syntax.Parse.max_repeat— int, default8, floored at 1. Bounds unbounded*/+/{m,}.
Inputs
Field type: — categorical_u8/u16/u32. Distinct generated strings become dictionary entries.
Output
Per-row string from the parsed AST: literals copy, char classes draw uniformly, alternations pick a branch, repeats expand under max_repeat. Stored as a dictionary index. Ops: OpLiteral, OpCharClass, OpAnyChar(NotNL), OpCapture, OpConcat, OpAlternate, OpStar, OpPlus, OpQuest, OpRepeat, anchors (emit nothing).
Gotchas
- No backreferences (
\1,(?P=name)); invalid pattern →SERVICE_VALIDATIONwith the syntax error. OpAnyCharemits printable ASCII (33–126), for cross-platform stability.- Unbounded
*/+clamps tomax_repeat; bounded{m,n}tomin(n, min + max_repeat). - An empty character class produces no character. Write-time dictionary cardinality must fit the declared categorical width — wide patterns + tall cohorts overflow
categorical_u8.
See
pulse_examples_search tags=[synth]- Skills:
synthetic-data,op-synth-weighted-categorical,op-synth-constant
op-synth-set-bernoulli
name: op-synth-set-bernoulli
description: Multi-select set_* bitmask generation — independent per-option Bernoulli draws, or a joint-structure resample when a paired field is declared.
kind: operator
category: SYNTH
operator: set_bernoulli
type: reference
applies_to: inspect, predict, manifest
examples_tags: [synth, distribution-shape]
Synth distributions emit per-row values; no Response.Components.
Use when
Fills a multi-select answer: each option is ticked on its own at its own rate, such as 40% picking email and 25% SMS.
Questions it answers:
- How do I simulate a tick-all-that-apply question?
- How do I generate which channels each customer has opted into?
Use something else:
weighted_categoricalwhen each row picks exactly one answer.bernoulliwhen there is only one yes/no flag.
Params
options— list[string], required. Dictionary entries in bit order (biti↔options[i]). Pre-registers the field’s dictionary at schema-build time — never lazily by first touch, so bit assignment is deterministic.frequencies— list[float], default0.5each. Per-optionP(bit set)in[0, 1]; length must matchoptions.
An option draws independently unless Spec.SetCategoricalPairs / SetNumericPairs / SetSetPairs names it as a captured joint pair’s target — then its bit is resampled from the pair’s conditional probability instead of frequencies[i].
Inputs
Field type: — any set rung, set_u8/u16/u32/u64/u128/u256; options length must not exceed the type’s MaxSetEntries() (8/16/32/64/128/256). The draw is width-agnostic — a member above bit 64 gets the same independent Bernoulli as one below it.
Output
Per-row map[string]bool (every declared option; true = selected), assembled into the bitmask at write time from each selected option’s pre-registered dictionary ID, never by first-encounter order.
Gotchas
- Empty
options,frequencieslength mismatch, or a frequency outside[0, 1]→SERVICE_VALIDATION. optionscount overMaxSetEntries()→PULSE_IMPORT_SET_OVERFLOWat schema build.SpecFromProfilefills this fromFieldProfile.Set.Optionsand wiresSetCategoricalPairs/SetNumericPairs/SetSetPairsfromConditional.Set*Pairsonly when the paired field also reconstructed to its expected distribution (weighted_categorical/normal/set_bernoulli).- A profile captured without
--conditionalhas no joint sections — every option draws its own marginal, byte-for-byte the pre-joint behaviour. SpecFromProfilecarries EVERY captured member, at every rung —FieldProfile.Set.Optionsis bit-ordered and uncapped belowMaxSetEntries(), so a 206-memberset_u256round-trips member-for-member. A translation that stopped at 64 would generate a plausible cohort whose high members are simply never selected.- Go map iteration order is never used for bit assignment (dictionary pre-populated in
optionsorder), so the seeded stream stays byte-identical.
See
pulse_examples_search tags=[synth]- Skills:
synth-set-fields,type-set-u8,op-synth-weighted-categorical,op-synth-bernoulli
op-synth-uniform
name: op-synth-uniform
description: Uniform real-valued samples in [min, max); default [0, 1).
kind: operator
category: SYNTH
operator: uniform
type: reference
applies_to: inspect, predict, manifest
examples_tags: [synth, distribution-shape]
Synth distributions emit per-row values; no Response.Components.
Use when
Draws numbers between a low and a high bound, every value equally likely, for flat filler columns or random noise.
Questions it answers:
- How do I fill a test column with random numbers between 0 and 100?
- How do I add flat random noise to a synthetic cohort?
Use something else:
normalwhen values should cluster around a typical value.uniform_datewhen the field is a calendar date.
Params
| Name | Type | Default | Description |
|---|---|---|---|
min | float | 0.0 | Lower bound (inclusive). |
max | float | 1.0 | Upper bound (exclusive before rounding; on u8–u64 fields rounding can produce it, and both ends come out half as often). Must be > min. |
Inputs
| Param | Accepted field types |
|---|---|
field type: | numeric: u4/u8/u16/u32/u64, f32/f64, decimal128, date (interpreted as days-since-epoch). |
For u8–u64 fields each sample rounds to the nearest integer at write time (floor(v+0.5)).
Output
Per-row float64 sample min + U * (max - min) where U ~ Uniform[0, 1). Half-open by construction.
Gotchas
max <= min→SERVICE_VALIDATIONat spec parse.- Half-open, but integer fields ROUND —
max=10overu8can store10(any draw ≥ 9.5). - For calendar dates use
uniform_date— it parses ISO-8601 strings and is inclusive both ends. - Default
[0, 1)is the lingua franca for noise injection / jitter columns.
See
pulse_examples_search tags=[synth]- Skills:
synthetic-data,op-synth-uniform-date,op-synth-normal
op-synth-uniform-date
name: op-synth-uniform-date
description: Uniform calendar-date samples in [start, end] inclusive; days-since-epoch internally.
kind: operator
category: SYNTH
operator: uniform_date
type: reference
applies_to: inspect, predict, manifest
examples_tags: [synth, time-series]
Synth distributions emit per-row values; no Response.Components.
Use when
Draws calendar dates between a start and an end date, every day equally likely, both ends included.
Questions it answers:
- How do I spread synthetic orders across 2024?
- How do I generate sign-up dates within the last quarter?
Use something else:
uniformwhen the field is a plain number, not a date.constantwhen every row shares one date.
Params
start/end— string, both required, ISO-8601YYYY-MM-DD.endmust not precedestart; equal is legal. Each parses viatime.Parse("2006-01-02", …)into days-since-1970-01-01.
Inputs
Field type: — date (32-bit days-since-epoch).
Output
Per-row float64 days-since-epoch; the writer stores it as the signed 32-bit date word. Draws off = rng.Int64N(span + 1) over span = endDays - startDays, so both endpoints are reachable.
Gotchas
- Both bounds inclusive —
uniform_date(2024-01-01, 2024-12-31)can emit either. - Unparseable dates →
SERVICE_VALIDATION(“invalid start date” / “invalid end date”).end == startis legal (every draw on that day); onlyend < start→SERVICE_VALIDATION(“end must not be before start”). - Epoch is 1970-01-01; pre-epoch dates are legal —
datestores signedint32days, so they are negative. - For sub-day granularity model the timestamp as
u64seconds-since-epoch viauniform.
See
pulse_examples_search tags=[synth]- Skills:
synthetic-data,op-synth-uniform,op-synth-monotonic-from
op-synth-weighted-categorical
name: op-synth-weighted-categorical
description: Discrete categorical draws from a values list with optional weights; uniform when weights absent.
kind: operator
category: SYNTH
operator: weighted_categorical
type: reference
applies_to: inspect, predict, manifest
examples_tags: [synth, cohort-analysis, cardinality-analysis]
Synth distributions emit per-row values; no Response.Components.
Use when
Picks one label per row from a list, each at its own share, such as 50% North, 30% South and 20% West.
Questions it answers:
- How do I give synthetic respondents a realistic region mix?
- How do I assign 70% of test users to plan A?
Use something else:
bernoulliwhen there are just two outcomes stored as 1 and 0 or true and false.set_bernoulliwhen each row can pick several options.
Params
values— list[string], required. Dictionary entries to draw from; non-empty.weights— list[float], default uniform. Per-value weight; length MUST matchvalues. Non-negative; sum> 0.
When weights absent the sampler installs equal weights internally.
Inputs
field type: — categorical_u8/u16/u32 (the writer registers values as the inline dictionary).
Output
Per-row sampled string from values, looked up via binary search over the cumulative-weight table. Encoded as a dictionary index in the host categorical_* field.
Gotchas
weightslength mismatch →SERVICE_VALIDATIONat spec parse.- Negative weight or zero-sum →
SERVICE_VALIDATION. - Empty
values→SERVICE_VALIDATION(“requires non-empty values”). - Dictionary cardinality at write time must fit the declared categorical width — over-
u8(>256 values) → cohesion check fails at encode. - For boolean-like coin flips use
bernoulliinstead — avoids the dictionary block.
See
pulse_examples_search tags=[synth]- Skills:
synthetic-data,op-synth-bernoulli,op-synth-regex
op-test-anova-f
name: op-test-anova-f
description: One-way ANOVA F-test comparing the means of a numeric Field across k groups defined by SplitBy.
kind: operator
category: TEST
operator: TEST_ANOVA_F
type: reference
applies_to: process, compose, predict
examples_tags: [hypothesis-test, tier-1-test, k-sample, parametric, comparison, streaming-friendly]
Tests emit statistic / p-value / effect size; no Response.Components.
Use when
Checks whether the average of a numeric measure differs across three or more groups.
Questions it answers:
- Does average spend differ across regions?
- Do the treatment arms produce different average outcomes?
Use something else:
TEST_ANOVA_WELCHwhen the groups have clearly unequal spread.TEST_KRUSKAL_WALLISwhen the measure is heavily skewed or only ordered.TEST_WELCHwhen there are only two groups.
Params
alpha— float, default0.05, in(0, 1).weight— slot weight (nullopts out), both kinds: SS on w*, each group on its own N*_g (Σw_g or Kish n_eff_g);df_within= ΣN*_g−k, may be fractional.Detailsaddsum_weights(+n_eff, probability) shaped liken(raw rows); n_eff < k+1 warnsPULSE_WEIGHT_LOW_NEFF.multiplicity— optional{method, family}; addsp_adjustedbeside the raw p (multiplicity-correction).
Inputs
Field (required) — numeric: u4/u8/u16/u32/u64, f32/f64, date. SplitBy (required, ≥ 2 groups) — categorical: categorical_u8/u16/u32, packed_bool.
Output
Statistic = F; DF = k−1; PValue via F survival. Details: groups, n, group_means, ss_between, ss_within, df_within, ms_within; effect_size.{eta_squared, omega_squared} (ω² clamped ≥0).
Reading the output
statistic: F compares how far apart the group averages are with how much rows vary inside each group; larger values are stronger evidence that the groups differ by more than within-group noise; the p-value says whether this F is large enough to be surprising.- Caveat: F says whether some groups differ, not which ones: run TEST_TUKEY_HSD to find the pairs.
p_value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.
Gotchas
- Streamable — per-group Welford feeds both SS terms.
- Rejects globally, not per pair — follow with tier-2
TEST_TUKEY_HSDviams_within/df_within. - Assumes equal spread; a large p from a spread test is not evidence of it.
- Normality: a normality test describes shape; it is not a gate.
- Tier-2
one_way_from_summaryreads group summaries; same keys.
See
pulse_examples_search tags=[k-sample]- Skills:
statistical-testing,op-test-tukey-hsd,op-test-anova-welch,op-test-kruskal-wallis
op-test-anova-rm
name: op-test-anova-rm
description: Repeated-measures one-way ANOVA; rows pivoted on SubjectField × SplitBy condition.
kind: operator
category: TEST
operator: TEST_ANOVA_RM
type: reference
applies_to: process, compose, predict
examples_tags: [hypothesis-test, tier-1-test, parametric, repeated-measures, k-sample, buffered-pipeline]
Tests emit statistic / p-value / effect size; no Response.Components.
Use when
Checks whether the average differs across conditions when every subject is measured under each condition.
Questions it answers:
- Do the same panel members rate the three ad concepts differently on average?
- Does each patient’s score change across the baseline, mid-point and final visits?
Use something else:
TEST_ANOVA_Fwhen each group holds different, unrelated subjects.
Params
alpha— float, default0.05, in(0, 1).multiplicity— optional{method, family}; addsp_adjustedbeside the raw p (multiplicity-correction).
Inputs
Field (required) — numeric: u4/u8/u16/u32/u64, f32/f64, date. SplitBy (required, the condition) — categorical: categorical_u8/u16/u32, packed_bool. SubjectField (required, the within-subject grouping) — categorical_u8/u16/u32.
Output
Statistic = F = MS_treatment / MS_error; DF = k-1 (Details.df_error = (n-1)(k-1)); PValue via F survival. Details.ss_between_subjects, Details.ss_treatment, Details.ss_error; effect_size.partial_eta_squared = SS_treatment / (SS_treatment + SS_error); unbanded.
Reading the output
statistic: F compares how far apart the condition averages are with the leftover noise once each subject’s own level is removed; larger values are stronger evidence that conditions differ by more than that noise; the p-value says whether this F is large enough to be surprising.- Caveat: It says whether some conditions differ, not which ones.
p_value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.
Gotchas
- Never weighted (a row weight has no meaning in the subject-by-condition table): any row weight in force on the slot (request, slot or
Options.DefaultWeight) isPULSE_WEIGHT_UNSUPPORTEDwithdetails.reason; set"weight": nullon the slot to run it unweighted. - Buffered — requires the full wide subject × condition table.
- Subjects missing a condition are dropped (
Details.dropped_subjects); < 2 left ->PULSE_TEST_INSUFFICIENT_N. - Sphericity violations inflate type-I; no Greenhouse-Geisser yet.
- Non-normal differences: Friedman not yet shipped.
See
pulse_examples_search tags=[repeated-measures]- Skills:
statistical-testing,op-test-anova-f,op-test-paired-t
op-test-anova-welch
name: op-test-anova-welch
description: Heteroscedasticity-robust one-way ANOVA with Welch-Satterthwaite df correction; streamable per-group Welford.
kind: operator
category: TEST
operator: TEST_ANOVA_WELCH
type: reference
applies_to: process, compose, predict
examples_tags: [hypothesis-test, tier-1-test, parametric, k-sample, comparison, streaming-friendly]
Tests emit statistic / p-value / effect size; no Response.Components.
Use when
Checks whether the average of a numeric measure differs across groups when the groups may have unequal spread.
Questions it answers:
- Does average response time differ across regions whose variability is very different?
- Do the store formats differ in average basket size, given some formats are far more variable?
Use something else:
TEST_KRUSKAL_WALLISwhen the measure is heavily skewed or only ordered.
Params
alpha— float, default0.05, in(0, 1).weight— slot weight (nullopts out), both kinds: per-group moments on w*,weights= N*_j/s²_j, df on N*_j−1 (Σw or Kish n_eff).Detailsaddsum_weights(+n_eff, probability) shaped liken(raw rows); n_eff < 2 warnsPULSE_WEIGHT_LOW_NEFF.multiplicity— optional{method, family}; addsp_adjustedbeside the raw p (multiplicity-correction).
Inputs
Field (required) — numeric: u4/u8/u16/u32/u64, f32/f64, date. SplitBy (required, ≥ 2 groups) — categorical: categorical_u8/u16/u32, packed_bool.
Output
Statistic = Welch F*; DF = k−1; PValue via F survival. Details: groups, n, group_means, group_variances, weights, weighted_mean, df_between, df_within (Welch df). effect_size.omega_squared = df_b(F*−1)/(df_b(F*−1)+N) clamped ≥ 0 (Lakens 2013).
Reading the output
statistic: Welch’s F compares the spread of the group averages with the noise inside groups, weighting each group by its own variance; larger values are stronger evidence that the averages differ by more than that noise; the p-value says whether this F is large enough to be surprising.- Caveat: It says whether some groups differ, not which ones.
p_value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.
Gotchas
- Streamable — same per-group Welford as classic one-way ANOVA; only the statistic + denominator change.
- Use when group spreads may differ.
- Tier-2
welch_one_way_postreads per-group{mean, variance, n}; same keys. - Post-hoc: Tukey HSD assumes equal variance; for unequal fall back to pairwise
TEST_WELCHwith amultiplicityblock. - Constant Field within a group →
PULSE_TEST_VARIANCE_ZERO.
See
pulse_examples_search tags=[k-sample]- Skills:
statistical-testing,op-test-anova-f,op-test-brown-forsythe,op-test-tukey-hsd
op-test-bartlett-sphericity
name: op-test-bartlett-sphericity
description: Bartlett's sphericity test on a vector's correlation matrix (is it the identity?); the factorability check, bit-equal to the PCA operator's Bartlett scalars.
kind: operator
category: TEST
operator: TEST_BARTLETT_SPHERICITY
type: reference
applies_to: process, compose, predict
examples_tags: [hypothesis-test, tier-1-test, parametric, multivariate, streaming-friendly]
Tests emit statistic / p-value; no Response.Components.
Use when
Checks whether a set of numeric fields are related at all, the first check before summarising them as a few underlying dimensions.
Questions it answers:
- Do the ten satisfaction items move together enough to be worth reducing to a few scores?
- Are these sensor readings related at all, or is each one independent noise?
Use something else:
MAT_CORRELATIONwhen you want how strongly each pair is related.
Params
alpha— float, default0.05.missing—listwiseonly.
Slot: exactly one of vector (a vectors entry) / fields, ≥ 2 members; no field, vector2 / fields2 or split_by.
weight— both kinds; N* = Σw or n_eff.
Inputs
Members: u4…u64, f32/f64.
Output
Statistic = χ² = −ln|R|·(N* − 1 − (2p + 5)/6), DF = p(p − 1)/2, PValue. Details: members, n, n_dropped, determinant (|R|).
Reading the output
statistic: A chi-square that grows as the fields’ correlations move away from all zero; a small p-value says the fields are related enough to summarise as fewer dimensions.p_value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.details.determinant: The determinant of the correlation matrix, 1 when the fields are uncorrelated and near 0 when they are strongly related; the statistic grows as it shrinks.
Gotchas
- Streamable. Same rows ⇒ same bits as the PCA operator’s Bartlett scalars (
MAT_PCA). - Singular R:
PULSE_MATRIX_SINGULAR; N* ≤ 1 + (2p + 5)/6:PULSE_MATRIX_INSUFFICIENT_N. Both warn, null figures. - Large n rejects almost always.
See
pulse_examples_search tags=[multivariate]- Skills:
multivariate-design,op-mat-pca,statistical-testing
op-test-box-m
name: op-test-box-m
description: Box's M test of one shared covariance across a SplitBy of 2+ levels; chi-square headline, F approximation in details. The MANOVA / Hotelling assumption check.
kind: operator
category: TEST
operator: TEST_BOX_M
type: reference
applies_to: process, compose, predict
examples_tags: [hypothesis-test, tier-1-test, parametric, multivariate, homogeneity-test, streaming-friendly]
Tests emit statistic / p-value; no Response.Components.
Use when
Checks whether several groups share one spread pattern across a set of numeric fields, the assumption behind comparing average profiles.
Questions it answers:
- Before comparing segment profiles, do the segments have similar spread across the metrics?
- Is it safe to pool the two groups’ covariance for a multivariate comparison?
Use something else:
TEST_BROWN_FORSYTHEwhen there is only one numeric field.
Params
alpha— float, default0.05.missing—listwiseonly.
Slot: exactly one of vector (a vectors entry) / fields; no field, vector2 / fields2. split_by required, categorical, ≥ 2 levels.
weight— both kinds; per-level ν_g = N*_g − 1.
Inputs
Members: u4…u64, f32/f64.
Output
Statistic = Box’s χ², DF = p(p + 1)/2·(k − 1), PValue. Details: m, f, df1, df2, f_p_value (F form), members, groups, n, n_dropped, log_det (per level), log_det_pooled.
Reading the output
statistic: A chi-square that grows as the groups’ spreads and correlations across the fields differ; a small p-value says the groups do not share one covariance.p_value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.details.m: Box’s M, the raw gap between the pooled and the per-group covariances before the small-sample correction; 0 when every group’s covariance is the same.
Gotchas
- Streamable. Levels need n ≥ 2; n ≤ p in a level is singular.
- Singular covariance: null figures +
PULSE_MATRIX_SINGULARwarning. - Very sensitive to non-normality; many read only p < 0.001.
See
pulse_examples_search tags=[multivariate]- Skills:
multivariate-design,op-test-manova,op-test-hotelling-t2
op-test-brown-forsythe
name: op-test-brown-forsythe
description: Homogeneity-of-variance test; one-way ANOVA on absolute deviations from per-group medians.
kind: operator
category: TEST
operator: TEST_BROWN_FORSYTHE
type: reference
applies_to: process, compose, predict
examples_tags: [hypothesis-test, tier-1-test, homogeneity-test, parametric, k-sample, buffered-pipeline]
Tests emit statistic / p-value / effect size; no Response.Components.
Use when
Checks whether the spread of a numeric field differs across groups: a robust test of equal variances.
Questions it answers:
- Is delivery time more variable at some warehouses than at others?
- How different is the spread of the measure across groups?
Use something else:
TEST_ANOVA_WELCHwhen you want to compare the group averages themselves.TEST_SHAPIRO_WILKwhen you want to check whether a measure is bell-shaped.
Params
alpha— float, default0.05, in(0, 1).weight— slot weight (nullopts out), kindfrequencyonly (a probability weight isPULSE_WEIGHT_UNSUPPORTEDnaming the kind): weighted group medians, ANOVA on Σw — equals the expanded rows.Detailsaddsum_weightsbesiden(raw rows).
Slot params: Field (required, numeric), SplitBy (required, categorical, ≥ 2 groups).
multiplicity— optional{method, family}; addsp_adjustedbeside the raw p (multiplicity-correction).
Inputs
Field — numeric: u4/u8/u16/u32/u64, f32/f64, date. SplitBy — categorical: categorical_u8/u16/u32, packed_bool.
Output
Statistic = F (ANOVA on absolute deviations from per-group medians); DF = k-1 (df_within = N-k in Details); PValue via F-distribution survival. Flat Details: groups, n, group_medians, abs_dev_means, ss_between, ss_within.
Reading the output
statistic: F on each row’s distance from its group median: larger values are stronger evidence that the groups differ in spread; the p-value says whether this F is large enough to be surprising.- Caveat: It tests spread only, not averages.
p_value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.
Gotchas
- Buffered — per-group medians require a sort.
- Not a pre-ANOVA gate: a large p is not evidence of equal spread.
- More robust than Levene (mean-based) under non-normality — that’s the whole point.
- Tier-2 variant
TEST_BROWN_FORSYTHE/median_postruns over result columns. - Tiny groups destabilize the median;
n_i < 2or N ≤ k →PULSE_TEST_INSUFFICIENT_N.
See
pulse_examples_search tags=[homogeneity-test]- Skills:
statistical-testing,op-test-anova-f,op-test-anova-welch
op-test-chisq
name: op-test-chisq
description: Chi-square independence test on a 2D Rows × Cols contingency table.
kind: operator
category: TEST
operator: TEST_CHISQ
type: reference
applies_to: process, compose, predict
examples_tags: [hypothesis-test, tier-1-test, cross-tabulation, proportion-analysis, streaming-friendly]
Tests emit statistic / p-value / effect size; no Response.Components.
Use when
Checks whether two categorical fields are associated by comparing a cross-tabulation with the counts expected if unrelated.
Questions it answers:
- Is preferred channel associated with age band?
- Is the mix of plan types different across regions?
Use something else:
TEST_FISHER_EXACTwhen the table is 2x2 and some expected counts are small.TEST_SPEARMAN_Rwhen both fields are numeric.
Params
alpha— float, default0.05, in(0, 1).weight— slot weight (nullopts out), both kinds: table of Σw per cell. Frequency: Pearson on the Σw table. Probability: Pearson on the table scaled to n_eff — first-order Kish approximation; not Rao-Scott (survey::svychisq).expected_min, the< 5guard, V, φ read the scaled table;Detailsadd scalarsum_weights(+n_eff);nstays raw rows.
Slot params: Rows (required, categorical), Cols (required, categorical). Field is ignored.
multiplicity— optional{method, family}; addsp_adjustedbeside the raw p (multiplicity-correction).
Inputs
Rows — categorical: categorical_u8/u16/u32, packed_bool. Cols — categorical: categorical_u8/u16/u32, packed_bool.
Output
Statistic = χ² (no Yates); DF = (rows−1)(cols−1); PValue via χ² survival. Details: contingency, row_labels/col_labels, row_totals/col_totals, n, expected_min. effect_size.cramers_v = √(χ²/(n·(min(r,c)−1))); effect_size.phi = √(χ²/n), 2×2 only.
Reading the output
statistic: Pearson’s chi-square adds up, over every cell, how far the observed count is from the count expected if the two fields were unrelated. Larger values mean the table departs more from that pattern.p_value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.details.expected_min: The smallest expected count across all cells.
Gotchas
- Any expected cell
< 5emitsPULSE_TEST_EXPECTED_COUNT_TOO_LOW. - Streamable — builds the contingency table during the row scan.
- Sparse high-cardinality axes blow memory; filter them first.
- Pairs with
Response.Crosstab;OVERLAY_CHISQ_VS_POPis the FACET-host equivalent.
See
pulse_examples_search tags=[cross-tabulation]- Skills:
statistical-testing,crosstab-guide,op-test-fisher-exact,op-test-prop-z
op-test-fisher-exact
name: op-test-fisher-exact
description: Exact two-sided p-value for a 2×2 Rows × Cols contingency table; small-sample CHISQ alternative.
kind: operator
category: TEST
operator: TEST_FISHER_EXACT
type: reference
applies_to: process, compose, predict
examples_tags: [hypothesis-test, tier-1-test, exact-test, cross-tabulation, proportion-analysis, small-sample, buffered-pipeline]
Tests emit statistic / p-value / effect size; no Response.Components.
Use when
Exact test of whether two yes/no fields are associated, built for small 2x2 tables.
Questions it answers:
- In a small pilot, did the treated group recover more often than the control group?
- With only a few dozen responses, is opting in associated with the sign-up channel?
Use something else:
TEST_CHISQwhen either field has more than two categories.
Params
alpha— float, default0.05, in(0, 1).weight— slot weight (nullopts out), kindfrequencyonly (a probability weight isPULSE_WEIGHT_UNSUPPORTEDnaming the kind): the exact test on the Σw table — equals the expanded rows.contingencybecomes the Σw table;nstays raw rows,sum_weightsbeside it.multiplicity— optional{method, family}; addsp_adjustedbeside the raw p (multiplicity-correction).
Inputs
Rows / Cols (both required, 2 levels each) — categorical: categorical_u8/u16/u32, packed_bool. Field ignored.
Output
Statistic = odds ratio; PValue = exact two-sided via hypergeometric tail (sum of tables with probability ≤ observed). Details.contingency = [[a, b], [c, d]] in first-seen order (row_labels / col_labels); odds_ratio = ad/bc, no Haldane fix: zero b or c -> +Inf, zero a or d -> 0.
Reading the output
statistic: The sample odds ratio (ad)/(bc) of the 2x2 table in details.contingency, also in details.odds_ratio: 1 means the odds of the first column are the same in both rows, above 1 higher in the first row, below 1 lower.- Caveat: Bands apply to max(OR, 1/OR), so an odds ratio of 0.25 reads like one of 4.
p_value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.
Gotchas
- Strictly 2×2 — other shapes →
PULSE_TEST_CONTINGENCY_DEGENERATE. - The canonical small-sample choice when any expected cell
< 5. - Buffered — needs the full contingency table.
- Two-sided p sums every table no more likely than observed; tools doubling one tail disagree slightly.
- Effect size = odds ratio (not Cramér’s V); take log for symmetry around 0.
See
pulse_examples_search tags=[exact-test]- Skills:
statistical-testing,op-test-chisq,op-test-prop-z
op-test-hotelling-t2
name: op-test-hotelling-t2
description: Hotelling's T² on a mean vector of p numeric members; one-sample or paired (difference vector) against params.mu, or two-sample over a 2-level SplitBy with pooled or unequal covariance.
kind: operator
category: TEST
operator: TEST_HOTELLING_T2
type: reference
applies_to: process, compose, predict
examples_tags: [hypothesis-test, tier-1-test, parametric, multivariate, two-sample, paired, streaming-friendly]
No Response.Components.
Use when
Checks whether several numeric fields, taken together as one profile, differ from a target, between two groups, or before vs after.
Questions it answers:
- Do the two app versions differ on the whole set of engagement metrics at once?
- Is our average satisfaction profile across five attributes different from the benchmark profile?
Use something else:
TEST_Twhen there is only one numeric field.
Params
alpha(0.05).mu— per member (default zeros).missing—listwiseonly.variance—pooled(default) orunequal(Krishnamoorthy–Yu); two-sample only.
Slot: one of vector (a vectors entry) / fields (globs ok); no field. split_by optional, exactly 2 levels. Paired: vector2 / fields2, same count, row-wise difference; never with split_by.
weight— both kinds; N* = Σw or n_eff (Detailsaddsum_weights).multiplicity—p_adjusted(multiplicity-correction).
Inputs
Members: u4…u64, f32/f64 (packed_bool: vector coerce: binary).
Output
Statistic = T², DF = p. Details: f, df1, df2, n, members, per-kind means (multivariate-outputs). Variant: one_sample, paired, two_sample_pooled, two_sample_unequal.
Reading the output
statistic: T² is the squared distance between the observed average profile and the target profile (one sample) or between the two groups’ average profiles, measured in units of the fields’ own spread and correlation; larger values are stronger evidence that the profiles differ; the p-value says whether it is large enough to be surprising.p_value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.
Gotchas
- Streamable. Needs n > p (n₁+n₂ > p+1 pooled);
unequalrefused on one-sample / paired;missing: pairwiserefused. - Singular covariance: null statistic +
PULSE_MATRIX_SINGULAR.
See
pulse_examples_search tags=[multivariate]- Skills:
statistical-testing,multivariate-outputs,op-test-t,op-test-welch
op-test-kendall-tau
name: op-test-kendall-tau
description: Concordance-based correlation between Field and Field2; buffered O(n²) pair count under tie correction.
kind: operator
category: TEST
operator: TEST_KENDALL_TAU
type: reference
applies_to: process, compose, predict
examples_tags: [hypothesis-test, tier-1-test, nonparametric, correlation-analysis, small-sample, buffered-pipeline]
Tests emit statistic / p-value / effect size; no Response.Components.
Use when
Measures how often pairs of rows agree in order on two numeric fields: a rank-based link suited to small samples and ties.
Questions it answers:
- In a small panel, do judges who score one entry higher also score the other higher?
- Do two coarse rating scales tend to agree?
Use something else:
TEST_SPEARMAN_Rwhen the data are large; its cost grows with the square of the row count.
Params
alpha— float, default0.05, in(0, 1).weight— slot weight (nullopts out), kindfrequencyonly (a probability weight isPULSE_WEIGHT_UNSUPPORTEDnaming the kind): a row pair counts w_i·w_j times — equals τ-b on the expanded pairs.Detailsaddsum_weightsbesiden(raw rows); pair counts become Σ w_i·w_j.
Slot params: Field (required, numeric), Field2 (required, numeric).
multiplicity— optional{method, family}; addsp_adjustedbeside the raw p (multiplicity-correction).
Inputs
Field — numeric: u4/u8/u16/u32/u64, f32/f64, date. Field2 — numeric (same set).
Output
Statistic = τ_b (Kendall tau-b, [-1, 1]); PValue two-sided via normal approximation with tie-variance adjustment. Details.concordant, Details.discordant, Details.ties_x, Details.ties_y, Details.s, Details.var_s, Details.z, Details.n.
Reading the output
statistic: Kendall’s tau-b, from -1 to +1: the share of row pairs that are ordered the same way on both fields minus the share ordered the opposite way, adjusted for ties.- Sign: positive means the two fields tend to rise together; negative means one field tends to fall as the other rises.
- Caveat: Correlation is not causation: a third factor may drive both fields.
p_value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.
Gotchas
- Buffered O(n²) pair enumeration — expensive on large n; filter or pre-sample first.
- Suits small samples and heavy ties (tau-b corrects for ties in either column).
- Distribution-free; no normality assumption.
- Tier-2 variant
TEST_KENDALL_TAU/tau_b_postruns over result columns. - For high-cardinality numeric fields the O(n²) cost dominates.
See
pulse_examples_search tags=[correlation-analysis]- Skills:
statistical-testing,op-test-spearman-r,op-test-pearson-r
op-test-kruskal-wallis
name: op-test-kruskal-wallis
description: Nonparametric k-group location test; ANOVA-like rank sums under tie correction.
kind: operator
category: TEST
operator: TEST_KRUSKAL_WALLIS
type: reference
applies_to: process, compose, predict
examples_tags: [hypothesis-test, tier-1-test, nonparametric, k-sample, comparison, buffered-pipeline]
Tests emit statistic / p-value / effect size; no Response.Components.
Use when
Rank-based check of whether values tend to be larger in some groups than in others, across two or more groups.
Questions it answers:
- Do satisfaction ratings tend to differ across the four regions?
- Do skewed repair times differ across product lines?
Use something else:
TEST_ANOVA_WELCHwhen the data are roughly normal and you want to compare averages.TEST_MANN_WHITNEY_Uwhen there are only two groups.
Params
alpha— float, default0.05, in(0, 1).weight— slot weight (nullopts out), kindfrequencyonly (a probability weight isPULSE_WEIGHT_UNSUPPORTEDnaming the kind): a row of weight w ranks as w identical rows — equals the test on the expanded rows.Detailsaddsum_weightsshaped liken;n/n_totalstay raw row counts.multiplicity— optional{method, family}; addsp_adjustedbeside the raw p (multiplicity-correction).
Inputs
Field (required) — numeric: u4/u8/u16/u32/u64, f32/f64, date. SplitBy (required, ≥ 2 groups) — categorical: categorical_u8/u16/u32, packed_bool.
Output
Statistic = tie-corrected H; DF = k − 1; PValue via χ² survival. Details: groups, n, rank_sums, n_total, tie_factor; effect_size.epsilon_squared = H·(n+1)/(n²−1), omitted when every value ties.
Reading the output
statistic: H measures how far each group’s average rank sits from the overall average rank, corrected for ties; larger values are stronger evidence that some groups tend to have higher values than others; the p-value says whether this H is large enough to be surprising.p_value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.
Gotchas
- Buffered — combined values ranked across all groups under tie correction.
- Nonparametric alternative to one-way ANOVA for skewed or heavy-tailed data.
- Global only — no Dunn / Conover post-hoc; use pairwise
TEST_MANN_WHITNEY_Uwith amultiplicityblock (Holm/Bonferroni). - Groups under ~5 rows: shaky p; only N < 2k is refused (
PULSE_TEST_INSUFFICIENT_N). ε² unbanded. - Tests stochastic equality, not equal medians — differing shapes can reject on shape alone.
See
pulse_examples_search tags=[k-sample]- Skills:
statistical-testing,op-test-anova-f,op-test-mann-whitney-u,op-test-anova-welch
op-test-ks
name: op-test-ks
description: Kolmogorov-Smirnov two-sample distribution test on Field partitioned by SplitBy.
kind: operator
category: TEST
operator: TEST_KS
type: reference
applies_to: process, compose, predict
examples_tags: [hypothesis-test, tier-1-test, nonparametric, two-sample, distribution-shape, buffered-pipeline]
Tests emit statistic / p-value / effect size; no Response.Components.
Use when
Checks whether a numeric field’s values follow the same distribution in two groups, comparing their whole shape.
Questions it answers:
- Do order values in the two regions have the same overall distribution?
- Has the shape of response times changed between the old and new system?
Use something else:
TEST_SHAPIRO_WILKwhen you want to check one field against the normal distribution.
Params
alpha— float, default0.05, in(0, 1). Always two-sided; there is no one-sided form.weight— slot weight (nullopts out), kindfrequencyonly (a probability weight isPULSE_WEIGHT_UNSUPPORTEDnaming the kind): ECDFs step by Σw, p on the Σw sizes — equals the expanded rows.Detailsaddsum_weightsbesiden(raw rows).
Slot params: Field (required, numeric), SplitBy (required, categorical, exactly 2 groups).
multiplicity— optional{method, family}; addsp_adjustedbeside the raw p (multiplicity-correction).
Inputs
Field — numeric: u4/u8/u16/u32/u64, f32/f64, date. SplitBy — categorical: categorical_u8/u16/u32, packed_bool.
Output
Statistic = D (sup |F₁(x) − F₂(x)|); PValue two-sided via Smirnov asymptotic distribution. Details.groups + per-arm Details.n.
Reading the output
statistic: D is the largest vertical gap between the two groups’ cumulative distributions, from 0 (identical) to 1 (no overlap at all).- Caveat: D is most sensitive to differences near the middle of the distributions and less to differences in the tails.
p_value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.
Gotchas
- Buffered — both ECDFs must materialize and sort before comparison.
- Sensitive to distribution shape, not just mean.
- Small-n approximation drifts; only n < 2 per group is refused (
PULSE_TEST_INSUFFICIENT_N). - Tier-2 variant
TEST_KS/two_sample_postruns between two output columns of the result set.
See
pulse_examples_search tags=[distribution-shape]- Skills:
statistical-testing,op-test-mann-whitney-u,op-test-shapiro-wilk
op-test-mann-whitney-u
name: op-test-mann-whitney-u
description: Nonparametric two-sample location test; buffered tie-corrected rank sum (Mann-Whitney U).
kind: operator
category: TEST
operator: TEST_MANN_WHITNEY_U
type: reference
applies_to: process, compose, predict
examples_tags: [hypothesis-test, tier-1-test, nonparametric, two-sample, comparison, buffered-pipeline]
Tests emit statistic / p-value / effect size; no Response.Components.
Use when
Rank-based check of whether values in one of two groups tend to be larger than in the other.
Questions it answers:
- Do customers on the new plan tend to rate the service higher?
- Do response times at one site tend to run longer than at the other?
Use something else:
TEST_WILCOXON_SRwhen the same subjects are measured twice.TEST_KRUSKAL_WALLISwhen there are three or more groups.
Params
alpha— float, default0.05, in(0, 1).weight— slot weight (nullopts out), kindfrequencyonly (a probability weight isPULSE_WEIGHT_UNSUPPORTEDnaming the kind): a row of weight w ranks as w identical rows — equals the test on the expanded rows.Detailsaddsum_weightsshaped liken(raw rows).multiplicity— optional{method, family}; addsp_adjustedbeside the raw p (multiplicity-correction).
Inputs
Field (required) — numeric: u4/u8/u16/u32/u64, f32/f64, date. SplitBy (required, exactly 2 groups) — categorical: categorical_u8/u16/u32, packed_bool.
Output
Statistic = U_min; PValue two-sided normal approx, tie-corrected. Details: groups (sorted), n, u_a, u_b, u_min, r_a, r_b, mu_u, var_u, z; effect_size.rank_biserial = (U_A − U_B)/(n_A·n_B) — > 0 ⇒ groups[0] tends larger (sign of z).
Reading the output
statistic: U counts, over every pair of rows from the two groups, how often one group’s value is larger (ties count half). Pulse reports the smaller of the two counts, from 0 to half of n_A times n_B: the smaller U, the more the groups separate.p_value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.details.z: The normal score the p-value is read from, with a continuity correction of one half.
Gotchas
- Buffered — combined values mid-ranked under tie correction.
- Robust alternative to a t-test when normality fails.
- Tests stochastic equality, not mean difference — disagreeing with a t-test is signal, not a bug.
- Asymptotic only (no exact p); n < 2 per group is
PULSE_TEST_INSUFFICIENT_N.
See
pulse_examples_search tags=[nonparametric]- Skills:
statistical-testing,op-test-kruskal-wallis,op-test-wilcoxon-sr,op-test-welch
op-test-manova
name: op-test-manova
description: One-way MANOVA of a mean vector of p numeric members across a SplitBy of 2+ levels; Pillai's trace headline, with Wilks, Hotelling–Lawley and Roy in details.
kind: operator
category: TEST
operator: TEST_MANOVA
type: reference
applies_to: process, compose, predict
examples_tags: [hypothesis-test, tier-1-test, parametric, multivariate, streaming-friendly]
No Response.Components.
Use when
Checks whether several numeric fields, taken together as one profile, differ across two or more groups.
Questions it answers:
- Do our customer segments differ on the whole set of spend, visit and basket metrics at once?
- Do the three store formats differ on their combined service ratings?
Use something else:
TEST_ANOVA_Fwhen there is only one numeric field.
Params
alpha— float, default0.05.missing—listwiseonly.
Slot: exactly one of vector (a vectors entry) / fields (inline, globs ok); no field, no vector2 / fields2. split_by required, categorical, ≥ 2 levels.
weight— both kinds (Detailsaddsum_weights).multiplicity— one site, Pillai’s p (multiplicity-correction).
Inputs
Members: u4…u64, f32/f64.
Output
Statistic = Pillai’s V, DF = its df1, PValue its p. Details: Pillai’s f, df1, df2, members, groups, n, means, eigenvalues, and pillai / wilks / hotelling_lawley / roy, each {statistic, f, df1, df2, p_value} (multivariate-outputs). Variant: one_way.
Reading the output
statistic: Pillai’s trace, between 0 and the smaller of the field count and the group count minus one, sums how much of the fields’ joint spread lies between the group average profiles; larger values are stronger evidence that the profiles differ; the p-value says whether it is large enough to be surprising.p_value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.
Gotchas
- Streamable. Needs n ≥ p + k rows; 1 level is
PULSE_TEST_SPLIT_GROUPS_LT_2. - Singular within SSCP: null figures +
PULSE_MATRIX_SINGULAR. - Roy’s p is a lower bound; a null
p_valuemeans R prints NA. - 2 levels: all four share one F (= pooled
TEST_HOTELLING_T2).
See
pulse_examples_search tags=[multivariate]- Skills:
statistical-testing,multivariate-outputs,op-test-hotelling-t2,op-test-anova-f
op-test-mardia
name: op-test-mardia
description: Mardia's multivariate normality test on a vector — skewness χ² and kurtosis z; buffered.
kind: operator
category: TEST
operator: TEST_MARDIA
type: reference
applies_to: process, compose, predict
examples_tags: [hypothesis-test, tier-1-test, normality-test, multivariate, buffered-pipeline]
Tests emit statistic / p-value; no Response.Components.
Use when
Checks whether a set of numeric fields looks jointly normal, through their combined skew and tail weight.
Questions it answers:
- Before comparing segment profiles, are the metrics close enough to jointly normal?
- Are the distance-based outlier flags safe to read as chi-square p-values?
Use something else:
TEST_SHAPIRO_WILKwhen there is only one numeric field and you want a sharper normality test.
Params
statistic—skewness(default) orkurtosis: the headline.alpha— default0.05.missing—listwiseonly.
Slot: one of vector (a vectors entry) / fields, ≥ 1 member; no split_by.
weight—frequencyonly (= expanded rows);sum_weightsbeside rawn.
Inputs
Members: u4…u64, f32/f64.
Output
Skewness: χ² = N·b1p/6 on p(p + 1)(p + 2)/6 df. Kurtosis: z, DF 0. Variant names the headline. Details: skewness (b1p), skewness_chisq / _df / _p_value, kurtosis (b2p), kurtosis_z / _p_value, small_sample, members, n, n_dropped.
Reading the output
statistic: The skewness chi-square (default) or the kurtosis z named by variant; a small p-value says the fields are not jointly normal.- Caveat: A large p only means no departure was detected, which is likely with few rows.
p_value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.details.skewness: Mardia’s multivariate skewness, 0 for jointly normal data; it grows as the joint shape leans to one side.
Gotchas
- Buffered: keeps n × p values.
- N < 20: skewness gets the small-sample factor.
- Singular covariance:
PULSE_MATRIX_SINGULAR, null figures.
See
pulse_examples_search tags=[normality-test]- Skills:
multivariate-design,statistical-testing,op-test-shapiro-wilk
op-test-paired-t
name: op-test-paired-t
description: Paired-sample t-test on the per-row difference d = Field − Field2; streamable via Welford on d.
kind: operator
category: TEST
operator: TEST_PAIRED_T
type: reference
applies_to: process, compose, predict
examples_tags: [hypothesis-test, tier-1-test, paired, parametric, before-after, comparison, streaming-friendly]
Tests emit statistic / p-value / effect size; no Response.Components.
Use when
Checks whether the average change between two measurements of the same rows, such as before and after, differs from zero.
Questions it answers:
- On average, did customers’ spend differ between the period before and after the loyalty programme started?
- Do patients’ scores differ between the first and second visit?
Use something else:
TEST_WELCHwhen the two sets of values come from different, unrelated subjects.
Params
alpha— float, default0.05, in(0, 1).
Slot params: Field (required, numeric), Field2 (required, numeric — the pre / before value).
weight— slot weight (nullopts out), both kinds: one weight per row = per pair (a null in either field drops the pair); one-sample t on d over w*,df= N*−1 (Σw or Kish n_eff).Detailsaddsum_weights(+n_eff, probability) shaped liken(raw rows); n_eff < 2 warnsPULSE_WEIGHT_LOW_NEFF.multiplicity— optional{method, family}; addsp_adjustedbeside the raw p (multiplicity-correction).
Inputs
Field — numeric: u4/u8/u16/u32/u64, f32/f64, date. Field2 — numeric (same set; same row pairs with Field).
Output
Statistic = t; DF = n − 1; PValue two-sided via Student-t. Details: mean_diff, variance (of d), n, ci_low, ci_high. Effect size Cohen’s d_z (unbanded).
Reading the output
statistic: t is the average of the per-row differences (Field - Field2) measured in standard errors of that average. Values far from 0 in either direction are unlikely if the true average difference is zero.p_value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.details.mean_diff: The average of Field - Field2 over complete pairs, in the field’s own units.
Gotchas
- Pairing is per-row: Field and Field2 must already encode the (post, pre) pair on the same record. If pairing is across rows, build a paired column upstream first.
- Streamable — Welford runs on d = Field − Field2 in a single pass.
- A null in Field or Field2 drops the pair;
ncounts complete pairs. - Tier-2 variant
TEST_PAIRED_T/paired_two_sided_postruns over two output columns of the result set.
See
pulse_examples_search tags=[paired]- Skills:
statistical-testing,op-test-wilcoxon-sr,op-test-t,op-test-pearson-r
op-test-pearson-r
name: op-test-pearson-r
description: Parametric Pearson correlation test between two numeric fields; streamable via online cross-product.
kind: operator
category: TEST
operator: TEST_PEARSON_R
type: reference
applies_to: process, compose, predict
examples_tags: [hypothesis-test, tier-1-test, parametric, correlation-analysis, streaming-friendly]
Tests emit statistic / p-value / effect size; no Response.Components.
Use when
Measures how strongly two numeric fields rise and fall together along a straight line.
Questions it answers:
- Do customers who spend more also visit more often?
- Does study time go with higher test scores?
Use something else:
TEST_SPEARMAN_Rwhen the link is consistent but curved, or the values are ranks.TEST_KENDALL_TAUwhen the sample is small or has many tied values.TEST_CHISQwhen both fields are categories.
Params
alpha— float, default0.05, in(0, 1).weight— slot weight (nullopts out), both kinds: r is scale-free (same under either kind); t anddf= N*−2 (Σw or Kish n_eff), Fisher CI on N*−3.Detailsaddsum_weights(+n_eff, probability) shaped liken(raw rows); n_eff < 3 warnsPULSE_WEIGHT_LOW_NEFF; n_eff ≤ 2 (df ≤ 0) isPULSE_TEST_INSUFFICIENT_N.multiplicity— optional{method, family}; addsp_adjustedbeside the raw p (multiplicity-correction).
Inputs
Field / Field2 (both required) — numeric: u4/u8/u16/u32/u64, f32/f64, date.
Output
Statistic = r (Pearson correlation, [-1, 1]); DF = n − 2; PValue two-sided via the t-statistic r·√((n−2)/(1−r²)). Details.n and Details.r² carry the sample size and coefficient of determination.
Reading the output
statistic: r measures how closely the two fields follow a straight line together, from -1 to +1; 0 means no straight-line link.p_value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.details.ci_low: Lower end of the confidence interval for r at the 1 - alpha level (95% by default).details.ci_high: Upper end of the confidence interval for r at the 1 - alpha level (95% by default).
Gotchas
- Streamable — extended Welford recurrence tracks the running cross-product alongside per-field moments.
- Linear-only sensitivity.
- Tier-1 (raw rows) vs tier-2 (
TEST_PEARSON_R/pearson_postover result columns) can disagree under Simpson’s paradox — pick the variant deliberately. - Constant Field or Field2 →
PULSE_TEST_VARIANCE_ZERO. - Outliers dominate r; pre-clip them or switch to a rank-based correlation.
See
pulse_examples_search tags=[correlation-analysis]- Skills:
statistical-testing,op-test-spearman-r,op-test-kendall-tau,op-test-paired-t
op-test-prop-z
name: op-test-prop-z
description: Two-proportion z-test on the success rate of Field across two SplitBy groups; streamable via success / total counts.
kind: operator
category: TEST
operator: TEST_PROP_Z
type: reference
applies_to: process, compose, predict
examples_tags: [hypothesis-test, tier-1-test, parametric, two-sample, proportion-analysis, experiment-analysis, streaming-friendly]
Tests emit statistic / p-value / effect size; no Response.Components.
Use when
Checks whether the rate of one outcome, such as conversion, differs between two groups.
Questions it answers:
- Does the new checkout convert at a different rate from the old one?
- Is the share of promoters different between the two regions?
Use something else:
TEST_FISHER_EXACTwhen some groups have only a handful of successes or failures.TEST_CHISQwhen there are more than two groups or more than two outcomes.
Params
alpha— float, default0.05, in(0, 1).weight— slot weight (nullopts out), both kinds: p̂_g = Σw_success/Σw_g, N*_g = Σw_g (frequency) or Kish n_eff_g (probability) for the pooled rate and SEs.successesbecomes Σw_success;Detailsaddsum_weights(+n_eff) shaped liken(raw rows).success— string, required. Dictionary value of Field treated as a “success”.multiplicity— optional{method, family}; addsp_adjustedbeside the raw p (multiplicity-correction).
Inputs
Field (required) — categorical: categorical_u8/u16/u32, packed_bool. SplitBy (required, exactly 2 groups) — same set.
Output
Statistic = z (pooled SE under H₀); PValue two-sided via Φ. Details: groups, n, successes, proportion (per arm), diff, pooled, Wald ci_low/ci_high. effect_size.cohens_h = 2·asin√p₁ − 2·asin√p₂ (sign of diff).
Reading the output
statistic: z: the first group’s success rate minus the second’s, in standard errors computed from the pooled rate. Values far from 0 in either direction are unlikely if the true rates are equal.p_value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.details.diff: The first group’s success rate minus the second’s, as a proportion (0.05 = 5 percentage points).details.ci_high: Upper end of the confidence interval for details.diff.
Gotchas
successmust match a dictionary value; otherwisePULSE_TEST_INVALID_SUCCESS.- Streamable — per-group counts feed both numerator and pooled denominator in one pass.
- More than two
SplitBygroups is a(SplitBy × Field)contingency-table question, not this test. - Pairs with
OVERLAY_PROP_Z_CELLfor crosstab cell-level proportion tests.
See
pulse_examples_search tags=[proportion-analysis]- Skills:
statistical-testing,op-test-chisq,op-test-fisher-exact
op-test-shapiro-wilk
name: op-test-shapiro-wilk
description: Shapiro-Francia (W′) normality test on Field; runs per-group when SplitBy is set; p advisory outside 5..5000.
kind: operator
category: TEST
operator: TEST_SHAPIRO_WILK
type: reference
applies_to: process, compose, predict
examples_tags: [hypothesis-test, tier-1-test, normality-test, distribution-shape, parametric, buffered-pipeline]
Tests emit statistic / p-value / effect size; no Response.Components.
Use when
Checks whether a numeric field looks normally distributed, overall or within each group.
Questions it answers:
- Is response time roughly bell-shaped, or clearly skewed or heavy-tailed?
- How far do scores in each group depart from a normal shape?
Use something else:
TEST_KSwhen you compare the distributions of two groups with each other.TEST_BROWN_FORSYTHEwhen you want to check whether groups have equal spread.
Params
alpha— float, default0.05, in(0, 1).multiplicity— optional{method, family}; addsp_adjustedbeside the raw p (multiplicity-correction).
Inputs
Field (required) — numeric: u4/u8/u16/u32/u64, f32/f64, date. SplitBy (optional — when set, runs per-group) — categorical: categorical_u8/u16/u32, packed_bool.
Output
Statistic = W’ (Shapiro-Francia, approximates Shapiro-Wilk W; (0, 1]); PValue (small p ⇒ reject normality). With SplitBy: Details.per_group carries per-arm W and p; headline Statistic / PValue track the worst-rejecting group.
Reading the output
statistic: W’ (Shapiro-Francia) measures how closely the sorted values follow the straight line expected of normal data, up to 1; values clearly below 1 point to skew, heavy tails or other departures from normal.- Caveat: With SplitBy, the headline W’ and p-value belong to the group with the smallest p; read details.per_group for every group.
p_value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.
Gotchas
- Never weighted (no standard weighted form exists): any row weight in force on the slot (request, slot or
Options.DefaultWeight) isPULSE_WEIGHT_UNSUPPORTEDwithdetails.reason; set"weight": nullon the slot to run it unweighted. - Buffered — requires the ordered values.
- p calibrated for 5..5000 rows; outside, it still runs and
per_group[].warningmarks p advisory. Check a QQ plot. - Not a gate: few rows miss real departures, many flag trivial ones.
- Tiny groups (
n < 3) →PULSE_TEST_INSUFFICIENT_N. - Tier-2 variant
TEST_SHAPIRO_WILK/shapiro_francia_postruns on a result column.
See
pulse_examples_search tags=[normality-test]- Skills:
statistical-testing,op-test-anova-f,op-test-kruskal-wallis,op-test-ks
op-test-spearman-r
name: op-test-spearman-r
description: Rank-based correlation between Field and Field2 (monotonic association); buffered mid-ranks then Pearson.
kind: operator
category: TEST
operator: TEST_SPEARMAN_R
type: reference
applies_to: process, compose, predict
examples_tags: [hypothesis-test, tier-1-test, nonparametric, correlation-analysis, buffered-pipeline]
Tests emit statistic / p-value / effect size; no Response.Components.
Use when
Measures how consistently one numeric field rises (or falls) as the other rises, using ranks, so the link need not be a straight line.
Questions it answers:
- Do higher-ranked products also tend to sell more, even if not in proportion?
- Does satisfaction tend to rise with tenure?
Use something else:
TEST_PEARSON_Rwhen you want the strength of a straight-line link.TEST_CHISQwhen both fields are categories.
Params
alpha— float, default0.05, in(0, 1).weight— slot weight (nullopts out), kindfrequencyonly (a probability weight isPULSE_WEIGHT_UNSUPPORTEDnaming the kind): a pair of weight w ranks as w identical pairs;DF= Σw − 2 — equals the test on the expanded pairs.Detailsaddsum_weightsbesiden(raw rows).
Slot params: Field (required, numeric), Field2 (required, numeric).
multiplicity— optional{method, family}; addsp_adjustedbeside the raw p (multiplicity-correction).
Inputs
Field — numeric: u4/u8/u16/u32/u64, f32/f64, date. Field2 — numeric (same set).
Output
Statistic = ρ (Spearman correlation, [-1, 1]); DF = n − 2; PValue two-sided via t = ρ·√((n−2)/(1−ρ²)). Details.n_ties_x and Details.n_ties_y surface tie counts.
Reading the output
statistic: Spearman’s rho is Pearson’s r computed on ranks, from -1 to +1: how consistently one field rises (or falls) as the other rises, whether or not in a straight line.- Sign: positive means the two fields tend to rise together; negative means one field tends to fall as the other rises.
- Caveat: Correlation is not causation: a third factor may drive both fields.
p_value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.
Gotchas
- Buffered — mid-ranks each column under tie correction before running Pearson on the ranks.
- Detects monotonic association; robust to outliers (rank transform).
- Tier-2 variant
TEST_SPEARMAN_R/rank_pearson_postruns over result columns. - Heavy ties degrade the asymptotic p-value.
See
pulse_examples_search tags=[correlation-analysis]- Skills:
statistical-testing,op-test-pearson-r,op-test-kendall-tau
op-test-t
name: op-test-t
description: One-sample or two-sample Welch t-test on a numeric field; SplitBy switches to the two-sample variant.
kind: operator
category: TEST
operator: TEST_T
type: reference
applies_to: process, compose, predict
examples_tags: [hypothesis-test, t-test, tier-1-test, parametric, two-sample, one-sample, streaming-friendly]
Tests emit statistic / p-value / effect size; no Response.Components.
Use when
Checks whether a numeric field’s average differs from a target value, or between two groups (Welch’s version).
Questions it answers:
- Is the average order value different from our target of 50?
- Do new and returning customers spend different amounts on average?
Use something else:
TEST_PAIRED_Twhen the same subjects are measured twice, such as before and after.TEST_ANOVA_WELCHwhen there are three or more groups.
Params
alpha— float, default0.05, in(0, 1).mu— float, default0.0. Hypothesized mean, one-sample only; ignored whenSplitByis set.weight— slot weight (nullopts out), both kinds: moments on w*, N* = Σw (frequency) or Kish n_eff (probability);df= N*−1 (two-sample: Welch df on N*_g), may be fractional.Detailsaddsum_weights(+n_eff, probability) shaped liken(raw rows); n_eff < 2 warnsPULSE_WEIGHT_LOW_NEFF.multiplicity— optional{method, family}; addsp_adjustedbeside the raw p (multiplicity-correction).
Inputs
Field (required) — numeric u4/u8/u16/u32/u64, f32/f64, date. SplitBy (optional → two-sample Welch) — categorical_u8/u16/u32, packed_bool.
Output
Statistic = t; DF; PValue two-sided. One-sample Details: mu, n, mean, variance, ci_low/ci_high, effect_size.cohens_d = (mean − mu)/sd. Two-sample: per-group n/mean/variance, diff, CI, pooled-SD cohens_d.
Reading the output
statistic: t is the gap between averages measured in standard errors: the sample mean minus the target value (one-sample), or the first group’s mean minus the second’s (two-sample, Welch). Values far from 0 in either direction are unlikely if the true gap is zero.p_value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.details.diff: Two-sample only: the first group’s mean minus the second’s, in the field’s own units.
Gotchas
- Two-sample needs exactly 2
SplitBygroups, elsePULSE_TEST_INVALID_SPLITBY. - Streamable — running Welford state per
SplitBygroup. - Constant Field within a group →
PULSE_TEST_VARIANCE_ZERO. - n < 2 (per group) →
PULSE_TEST_INSUFFICIENT_N.
See
pulse_examples_search tags=[t-test]- Skills:
statistical-testing,op-test-welch,op-test-z-two-sample,op-test-paired-t
op-test-trend
name: op-test-trend
description: Mann-Kendall trend test over an ordered numeric series; tier-2 over windowed result rows.
kind: operator
category: TEST
operator: TEST_TREND
type: reference
applies_to: process, compose, predict
examples_tags: [hypothesis-test, tier-2-test, nonparametric, trend-detection, time-series, buffered-pipeline]
Tests emit statistic / p-value / effect size; no Response.Components.
Use when
Checks whether an ordered series, such as monthly totals, tends to keep rising or keep falling.
Questions it answers:
- Is monthly churn creeping up?
- Has weekly average wait time been falling over the year?
Use something else:
REG_OLSwhen you want the size of a straight-line slope.TEST_WELCHwhen you compare the averages of two specific periods.
Params
alpha— float, default0.05, in(0, 1).variant— string, default"mann_kendall"(the only variant).
Field + OrderBy (≥ 1 key) both required. Tier-2 typical — list in Request.PostTests; tier-1 works when the raw field has an ordering key.
multiplicity— optional{method, family}; addsp_adjustedbeside the raw p (multiplicity-correction).
Inputs
Field — numeric output column (typically a smoothed window column or a grouped aggregate). OrderBy — numeric or date, defining order.
Output
Statistic = Mann-Kendall Z (S over its SE, continuity-corrected); PValue two-sided normal approx. Details: s, tau, var_s (tie-adjusted), n.
Reading the output
statistic: The Mann-Kendall Z score: the trend count S (details.s) in standard errors, with a continuity correction. Values far from 0 point to a consistent tendency to rise or fall (not necessarily at an even rate).p_value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.details.s: S counts every pair of points: +1 when the later point is higher, -1 when it is lower, 0 when tied.
Gotchas
- Never weighted (it reads aggregated result rows, which carry no row weights): any row weight in force on the slot (request, slot or
Options.DefaultWeight) isPULSE_WEIGHT_UNSUPPORTEDwithdetails.reason; set"weight": nullon the slot to run it unweighted. - Meaningful only over an ordered upstream series (a moving average over a date grouper is canonical); reads result rows, not raw cohort.
- Empty
OrderBy→PULSE_TEST_MISSING_ORDER_BY. Buffered. - n < 3 ->
PULSE_TEST_INSUFFICIENT_N; n < 8 warns (advisory; ~10+ advised). - Seasonality-sensitive — pre-deseasonalize (a smoothing window or month grouping).
See
pulse_examples_search tags=[trend-detection]- Skills:
statistical-testing,window-design,op-win-moving-avg
op-test-tukey-hsd
name: op-test-tukey-hsd
description: Tier-2 post-hoc pairwise comparison of group means using Tukey's Honestly Significant Difference (Tukey-Kramer).
kind: operator
category: TEST
operator: TEST_TUKEY_HSD
type: reference
applies_to: process, compose, predict
examples_tags: [hypothesis-test, tier-2-test, post-hoc, k-sample, parametric, comparison, buffered-pipeline]
Tests emit statistic / p-value / effect size; no Response.Components.
Use when
After an ANOVA, compares every pair of group averages to find which ones differ, keeping the overall false-alarm rate in check.
Questions it answers:
- Which regions differ from each other in average spend?
- Which treatment arms have different average outcomes?
Use something else:
TEST_ANOVA_Fwhen you only need to know whether any group differs.
Params
alpha— float, default0.05, family-wise, in(0, 1).ms_within/df_within— float, both required. Within-group mean square + df from a preceding one-way ANOVA.
Field + SplitBy both required. Tier-2 only — list in Request.PostTests.
Inputs
Field — per-group mean column (an average aggregate). SplitBy — the grouper column the upstream ANOVA partitioned by.
Output
Statistic = q (studentized range, worst pair); PValue via studentized-range CDF. Details.pairs = per-pair {label_a, label_b, mean_diff, q, p_value, reject}; α family-wise.
Reading the output
statistic: Not used: TEST_TUKEY_HSD reports no single statistic and leaves this slot at 0. Each pair’s studentized range q is in details.comparisons.p_value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.- Caveat: This is the smallest Tukey-adjusted p-value across all pairs, already corrected for multiple comparisons; read details.comparisons for which pairs it belongs to.
Gotchas
- Already family-wise: never joins a
multiplicityfamily; an explicit non-noneblock is refused, an inherited one skipped. - Never weighted (it reads aggregated result rows, which carry no row weights): any row weight in force on the slot (request, slot or
Options.DefaultWeight) isPULSE_WEIGHT_UNSUPPORTEDwithdetails.reason; set"weight": nullon the slot to run it unweighted. - Runs against materialized per-group result rows. Buffered (
Streamable=false). - Consumes tier-1 one-way ANOVA outputs: run as a follow-up Request, or compose inside a ProcessChain that exposes them.
- Assumes equal variances (as ANOVA); Games-Howell for unequal is not yet shipped.
- Headline tracks the worst pair — inspect
Details.pairsfor the full matrix.
See
pulse_examples_search tags=[post-hoc]- Skills:
statistical-testing,op-test-anova-f,op-test-anova-welch
op-test-welch
name: op-test-welch
description: Explicit two-sample Welch t-test on a numeric field across a categorical SplitBy partition.
kind: operator
category: TEST
operator: TEST_WELCH
type: reference
applies_to: process, compose, predict
examples_tags: [hypothesis-test, t-test, tier-1-test, parametric, two-sample, welch, streaming-friendly]
Tests emit statistic / p-value / effect size; no Response.Components.
Use when
Checks whether the average of a numeric field differs between two groups, without assuming they have equal spread.
Questions it answers:
- Do customers in the two pricing arms spend different amounts on average?
- Is average delivery time different between the two depots?
Use something else:
TEST_PAIRED_Twhen the same subjects are measured twice, such as before and after.
Params
alpha— float, default0.05, in(0, 1).
Slot params: Field (required, numeric), SplitBy (required, categorical, exactly 2 groups).
weight— slot weight (nullopts out), both kinds: per-group moments on w*, Welch–Satterthwaite df on N*_g (Σw frequency, Kish n_eff probability; fractional).Detailsaddsum_weights(+n_eff, probability) shaped liken(raw rows); n_eff < 2 warnsPULSE_WEIGHT_LOW_NEFF.multiplicity— optional{method, family}; addsp_adjustedbeside the raw p (multiplicity-correction).
Inputs
Field — numeric: u4/u8/u16/u32/u64, f32/f64, date. SplitBy — categorical: categorical_u8/u16/u32, packed_bool.
Output
Statistic = t; DF = Welch-Satterthwaite; PValue two-sided Student-t. Details.per_group = {n, mean, variance} per arm; effect size = mean diff / pooled SE.
Reading the output
statistic: Welch’s t: the first group’s mean minus the second’s, in standard errors computed from each group’s own variance. Values far from 0 in either direction are unlikely if the true gap is zero.p_value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.details.diff: The first group’s mean minus the second’s, in the field’s own units.
Gotchas
- Identical math to
TEST_TwithSplitBy; this alias documents intent. - Welch denominator never assumes equal variance — the safe default when spreads may differ.
- The Welford triple is byte-equal to the Welford aggregate’s on the same inputs — reuse via
OVERLAY_T_CELLon crosstabs. - Constant Field within a group →
PULSE_TEST_VARIANCE_ZERO.
See
pulse_examples_search tags=[welch]- Skills:
statistical-testing,op-test-t,op-test-z-two-sample,op-test-mann-whitney-u
op-test-wilcoxon-sr
name: op-test-wilcoxon-sr
description: Wilcoxon signed-rank test on per-row difference d = Field − Field2; buffered tie-corrected sign-rank.
kind: operator
category: TEST
operator: TEST_WILCOXON_SR
type: reference
applies_to: process, compose, predict
examples_tags: [hypothesis-test, tier-1-test, nonparametric, paired, before-after, buffered-pipeline]
Tests emit statistic / p-value / effect size; no Response.Components.
Use when
Rank-based check of whether paired before/after values tend to shift in one direction, without assuming a bell curve.
Questions it answers:
- Did each customer’s rating tend to go up after the redesign?
- Do patients’ skewed symptom scores tend to fall between visits?
Use something else:
TEST_MANN_WHITNEY_Uwhen the two sets of values come from different, unrelated subjects.
Params
alpha— float, default0.05, in(0, 1).weight— slot weight (nullopts out), kindfrequencyonly (a probability weight isPULSE_WEIGHT_UNSUPPORTEDnaming the kind): a pair of weight w ranks as w identical pairs — equals the test on the expanded pairs.Detailsaddsum_weightsbesiden;nandzero_diffsstay raw row counts.
Slot params: Field (required, numeric), Field2 (required, numeric — the pre value).
multiplicity— optional{method, family}; addsp_adjustedbeside the raw p (multiplicity-correction).
Inputs
Field — numeric: u4/u8/u16/u32/u64, f32/f64, date. Field2 — numeric (same set; paired per-row with Field).
Output
Statistic = min(W⁺, W⁻); PValue two-sided normal approx, tie-corrected. Details: n (non-zero pairs), w_plus, w_minus, mu_w, var_w, z, zero_diffs; effect_size.rank_biserial = (W⁺ − W⁻)/(W⁺ + W⁻) — > 0 ⇒ Field tends to exceed Field2 (sign of z).
Reading the output
statistic: The smaller of the two signed-rank sums: rank the non-zero differences (Field - Field2) by size, add the ranks of the positive ones and of the negative ones, and keep the smaller. The smaller it is, the more one direction dominates.p_value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.details.z: The normal score the p-value is read from, with a continuity correction of one half.
Gotchas
- Buffered — |d| ranked across the whole set.
- Zero-diff pairs are dropped (Wilcoxon convention); reported in
Details.zero_diffs. - Nonparametric alternative to a paired t-test when d is non-normal.
- Asymptotic only; < 6 non-zero pairs is
PULSE_TEST_INSUFFICIENT_N. - Tier-2 variant
asymptotic_postruns over two result-row columns. - Pairing is per row: both values sit on one record.
See
pulse_examples_search tags=[paired]- Skills:
statistical-testing,op-test-paired-t,op-test-mann-whitney-u
op-test-z-two-sample
name: op-test-z-two-sample
description: Two-sample z-test on Field means across two SplitBy groups; identical SE to Welch's t-test but p-value via standard normal Φ.
kind: operator
category: TEST
operator: TEST_Z_TWO_SAMPLE
type: reference
applies_to: process, compose, predict
examples_tags: [hypothesis-test, tier-1-test, parametric, two-sample, z, proportion-analysis, streaming-friendly]
Tests emit statistic / p-value / effect size; no Response.Components.
Use when
Large-sample check of whether two groups differ in average, with the p-value read from the normal distribution.
Questions it answers:
- In a large survey, do the two regions differ in average score?
- Across thousands of sessions, is average basket size different between the two app versions?
Use something else:
TEST_WELCHwhen either group is small; the t-distribution p-value is more honest there.
Params
alpha— float, default0.05, in(0, 1).
Slot params: Field (required, numeric), SplitBy (required, categorical, exactly 2 groups).
weight— slot weight (nullopts out), both kinds: per-group moments on w*, SE reads N*_g (Σw frequency, Kish n_eff probability).Detailsaddsum_weights(+n_eff, probability) shaped liken(raw rows); n_eff < 2 warnsPULSE_WEIGHT_LOW_NEFF.multiplicity— optional{method, family}; addsp_adjustedbeside the raw p (multiplicity-correction).
Inputs
Field — numeric: u4/u8/u16/u32/u64, f32/f64, date. SplitBy — categorical: categorical_u8/u16/u32, packed_bool.
Output
Statistic = z (= Welch t numerator over the same pooled SE); PValue two-sided via standard normal Φ; no DF. Details.per_group = {n, mean, variance} per arm.
Reading the output
statistic: z: the first group’s mean minus the second’s, in standard errors built from each group’s sample variance, read against the normal distribution. Values far from 0 in either direction are unlikely if the true gap is zero.p_value: a p-value. Below alpha (0.05 unless the request sets another) the result is called significant; that is not the same as important, so read the effect size for how big it is.details.diff: The first group’s mean minus the second’s, in the field’s own units.
Gotchas
- Streamable — reads the same per-group Welford buckets as the t-tests.
- Statistic + SE byte-equal to Welch’s t; p-value differs (Φ vs Student-t). For small n the divergence is non-trivial; predict surfaces no warning — choose intentionally.
- Use only when n is large per group AND survey conventions demand normal-CDF p.
See
pulse_examples_search tags=[z]- Skills:
statistical-testing,op-test-welch,op-test-prop-z,overlay-system
op-win-delta
name: op-win-delta
description: Point difference against the row `periods` positions earlier in the ordered partition — the subtraction counterpart of a percent change.
kind: operator
category: WIN
operator: WIN_DELTA
type: reference
applies_to: process, compose, predict
examples_tags: [time-series, window-operator, buffered-pipeline]
Row-level output; no Response.Components.
Use when
Adds the change from a set number of rows earlier in the field’s own units, such as orders this month minus last month.
Questions it answers:
- By how many orders did each month go up or down on the month before?
- How many points did satisfaction move between waves?
Use something else:
WIN_PCT_CHANGEwhen you want the change relative to the earlier value.
Params
periods (int, default 1) — lookback distance (≥ 1). Slots: field (required, numeric), order_by (≥1, numeric / date), partition_by, frame (forbidden).
Inputs
Field: numeric — u4…u64, f32/f64, date. Not decimal128, packed_bool, categorical.
Output
One float64 per row on Label (default WIN_DELTA_<field>). cur - prev at periods back — the field’s OWN units (points), never a ratio. Rows i < periods are null.
Reading the output
value: The change since the row periods rows earlier, in the field’s own units: current - earlier. A delta of 12 on an order count is 12 more orders than the earlier row.- Sign: positive means the current value is above the earlier one; negative means the current value is below the earlier one.
- Caveat: It is in the field’s units, so a change of 10 is large on a small field and trivial on a large one; read WIN_PCT_CHANGE for the change relative to the earlier value.
Gotchas
- Not weightable: a request
weightisPROCESSING_CONFIG(windows carry no slot weight);Options.DefaultWeightis skipped. - A zero prior is a REAL delta, not a null. A percent change nulls it because the division is undefined; subtraction has no such case, so
prev == 0emitscur. - Frame rejection happens at CONSTRUCTION, not just in predict — the factory returns
PROCESSING_CONFIGfor a non-nilframe. The rest of the family declares it in predict only, so a framed request skipping predict is ignored there but REFUSED here. periods <= 0rejected (PULSE_WINDOW_INVALID); either side null →null; rows NOT reordered (Request.Sort); buffered.
See
- Skills:
window-design,op-win-pct-change,op-win-lag
op-win-dense-rank
name: op-win-dense-rank
description: Dense rank with no gaps after ties (1, 2, 2, 3, ...) within the ordered partition.
kind: operator
category: WIN
operator: WIN_DENSE_RANK
type: reference
applies_to: process, compose, predict
examples_tags: [cohort-analysis, top-n, window-operator, buffered-pipeline]
Window operators emit row-level values; they do not produce Response.Components.
Use when
Ranks rows within each partition in the chosen order; rows with equal values share a rank and the next follows on: 1, 2, 2, 3.
Questions it answers:
- Which price tier is each product in, counting equal prices as one tier?
- With scores ordered desc, how many distinct scores sit above each respondent’s (the dense rank minus 1)?
Use something else:
WIN_RANKwhen the rank should skip past equal values, as in sports standings.
Params
None. partition_by (carve), order_by (≥1, required, numeric / date — tie comparison reads these keys), frame (forbidden), field (forbidden — no value read).
Inputs
| Param | Accepted field types |
|---|---|
Field | must be empty / omitted — reads ranking from order_by keys only |
Output
One int64 per row written to Label (default WIN_DENSE_RANK). Ties share a rank; next distinct row advances by exactly 1 — (1, 2, 2, 3, 4). Rank resets per partition.
Reading the output
value: The row’s position among the distinct order_by values within its partition, starting at 1. Rows equal on every order_by key share a rank, and the next rank follows on with no gap: 1, 2, 2, 3. The largest rank is the number of distinct values, not the number of rows.- Caveat: Rank 1 is the first row in order_by order: the smallest value with an ascending key, the largest with desc. Say which when reporting a ‘top’ rank.
Gotchas
- Not weightable: a request
weightisPROCESSING_CONFIG(windows carry no slot weight);Options.DefaultWeightis skipped. - Tie comparison uses every
order_bykey; tie only when ALL keys equal. WIN_DENSE_RANKdoes NOT distinguish row count from rank count.- Result rows are NOT reordered — use
Request.Sortfor response order. - Forces buffered execution (
Streamable=false).
See
pulse_examples_search tags=[top-n]- Skills:
window-design,op-win-rank,op-win-row-number
op-win-ewma
name: op-win-ewma
description: Exponentially weighted moving average; s_i = alpha*x_i + (1-alpha)*s_{i-1}.
kind: operator
category: WIN
operator: WIN_EWMA
type: reference
applies_to: process, compose, predict
examples_tags: [time-series, window-operator, buffered-pipeline]
Window operators emit row-level values; they do not produce Response.Components.
Use when
Adds a smoothed series: each new value counts for a fixed share w, the previous smoothed level for 1 - w, so older values fade.
Questions it answers:
- What is the underlying level of daily demand once day-to-day noise is damped?
- Is the smoothed defect rate drifting up over recent batches?
Use something else:
WIN_MOVING_AVGwhen you want every row in a fixed window to count equally.
Params
| Name | Type | Default | Description |
|---|---|---|---|
alpha | float | — | Smoothing factor in (0, 1]; higher = more weight on recent. |
decay | float | — | Carry-over share in [0, 1) (adstock decay); alpha = 1 − decay. |
Give EXACTLY ONE of alpha / decay (manifest exactly_one_of: smoothing; a Recommend draft names alpha).
partition_by (carve), order_by (≥1, required, numeric / date), frame (REQUIRED {mode: "rows"}; ignored by the recurrence — manifest ignores_frame). field (required, numeric).
Inputs
| Param | Accepted field types |
|---|---|
Field | numeric: u4/u8/u16/u32/u64, f32/f64, date (no decimal128, categorical, packed_bool) |
Output
One float64 per row written to Label (default WIN_EWMA_<field>). Recurrence seeds from the first non-null value in the partition.
Reading the output
value: A smoothed level of the field: s = alpha * current + (1 - alpha) * previous s, seeded with the partition’s first value present. A value k rows back carries weight alpha * (1 - alpha)^k, so alpha near 1 follows the latest value closely and alpha near 0 changes slowly.- Caveat: The seed enters at full weight, so the start of each partition leans on the first value: with alpha 0.1 it still carries about 35% of the weight ten rows later (0.9^10). Read the early rows with care.
Gotchas
- Not weightable: a request
weightisPROCESSING_CONFIG(windows carry no slot weight);Options.DefaultWeightis skipped. - Exactly one of
alpha/decay— both, neither,alphaoutside(0, 1]ordecayoutside[0, 1)→PULSE_WINDOW_INVALID(runtimePROCESSING_CONFIG). alpha = 1 − decayfrom the decay’s EXACT decimal:decay: 0.7smooths bit-identically toalpha: 0.3(not float1 − 0.7).decay: 0isalpha: 1(identity).- Rows preceding the first non-null emit
null(no seed). - Null values emit
nullbut state survives through to the next non-null row. - Result rows are NOT reordered — use
Request.Sort. - Forces buffered execution (
Streamable=false).
See
pulse_examples_search tags=[time-series]- Skills:
window-design,op-win-moving-avg,op-win-running-avg
op-win-lag
name: op-win-lag
description: Per-row value of Field from N rows earlier in the ordered partition.
kind: operator
category: WIN
operator: WIN_LAG
type: reference
applies_to: process, compose, predict
examples_tags: [time-series, window-operator, buffered-pipeline]
Window operators emit row-level values; they do not produce Response.Components.
Use when
Adds to every row the value of a field from a set number of rows earlier in the ordered series, such as last month’s sales.
Questions it answers:
- What were each store’s sales in the month before, on the same row as this month’s?
- What score did each respondent give in the previous wave?
Use something else:
WIN_DELTAwhen you want the difference from the earlier row, not its value.
Params
| Name | Type | Default | Description |
|---|---|---|---|
offset | int | 1 | Lookback distance (≥ 0). |
default | any | null | Substitute when offset crosses partition start. |
partition_by (carve), order_by (≥1, required, numeric / date), frame (forbidden).
Inputs
| Param | Accepted field types |
|---|---|
Field | numeric: u4/u8/u16/u32/u64, f32/f64, date (no decimal128, no packed_bool, no categorical) |
Output
One float64 per row written to Label (default WIN_LAG_<field>). When i - offset < 0 within the partition emits default if set, else null.
Gotchas
- Not weightable: a request
weightisPROCESSING_CONFIG(windows carry no slot weight);Options.DefaultWeightis skipped. order_byrequired — predict rejects empty slate (PULSE_WINDOW_INVALID).frameforbidden — set one and predict rejects.- Partitioning by the raw
datecollapses each row to its own partition. Pick a coarser partition (region, account) and order by date. - Forces buffered execution (
Streamable=false).
See
pulse_examples_search tags=[time-series]- Skills:
window-design,op-win-lead,op-win-pct-change
op-win-lead
name: op-win-lead
description: Per-row value of Field from N rows later in the ordered partition.
kind: operator
category: WIN
operator: WIN_LEAD
type: reference
applies_to: process, compose, predict
examples_tags: [time-series, window-operator, buffered-pipeline]
Window operators emit row-level values; they do not produce Response.Components.
Use when
Adds to every row the value of a field from a set number of rows later in the ordered series, such as next month’s sales.
Questions it answers:
- What did each customer spend in the following month, beside this month’s spend?
- What reading came next after each measurement?
Use something else:
WIN_LAGwhen you want the value from an earlier row.WIN_DELTAwhen you want how much the field changed between rows.
Params
| Name | Type | Default | Description |
|---|---|---|---|
offset | int | 1 | Lookahead distance (≥ 0). |
default | any | null | Substitute when offset crosses partition end. |
partition_by (carve), order_by (≥1, required, numeric / date), frame (forbidden).
Inputs
| Param | Accepted field types |
|---|---|
Field | numeric: u4/u8/u16/u32/u64, f32/f64, date (no decimal128, no packed_bool, no categorical) |
Output
One float64 per row written to Label (default WIN_LEAD_<field>). When i + offset >= partition_len emits default if set, else null.
Gotchas
- Not weightable: a request
weightisPROCESSING_CONFIG(windows carry no slot weight);Options.DefaultWeightis skipped. - Mirror of a lag — same partition / order / frame rules, opposite scan direction.
order_byrequired,frameforbidden.- Result rows are NOT reordered by
order_by— useRequest.Sortfor response order. - Forces buffered execution (
Streamable=false).
See
pulse_examples_search tags=[time-series]- Skills:
window-design,op-win-lag,op-win-pct-change
op-win-moving-avg
name: op-win-moving-avg
description: Moving average of Field over a bounded Frame; both ends must be set.
kind: operator
category: WIN
operator: WIN_MOVING_AVG
type: reference
applies_to: process, compose, predict
examples_tags: [time-series, window-operator, buffered-pipeline]
Window operators emit row-level values; they do not produce Response.Components.
Use when
Adds a moving average: the mean of a field over a fixed number of neighbouring rows, smoothing short-term swings in a series.
Questions it answers:
- What is the trailing seven-day average of daily orders?
- Is the trend in weekly sign-ups rising once the week-to-week noise is smoothed out?
Use something else:
WIN_RUNNING_AVGwhen you want the average of everything up to this row.
Params
None operator-level. partition_by (carve), order_by (≥1, required, numeric / date), frame (REQUIRED — mode "rows", preceding AND following BOTH bounded). field (required, numeric).
Inputs
| Param | Accepted field types |
|---|---|
Field | numeric: u4/u8/u16/u32/u64, f32/f64, date (no decimal128, no packed_bool, no categorical) |
Output
One float64 per row written to Label (default WIN_MOVING_AVG_<field>). Mean of non-null values inside [i - preceding, i + following] within the partition. Empty slice → null.
Reading the output
value: The mean of the field over the rows in the frame around this one: from preceding rows before it to following rows after it, within the partition and in order_by order. preceding 6, following 0 is a trailing average of 7 rows.- Caveat: Near the start and end of each partition the frame runs off the edge and the mean covers fewer rows, so the first values are noisier and not comparable with the rest.
- Caveat: Missing values are skipped, so the mean is over the values present, not the frame width; a frame with no value reads null.
Gotchas
- Not weightable: a request
weightisPROCESSING_CONFIG(windows carry no slot weight);Options.DefaultWeightis skipped. - Unbounded frame on either end is REJECTED at predict.
- Trailing 7-row window:
frame: {mode: "rows", preceding: 6, following: 0}. - Nulls skipped (not zero-filled); denominator is non-null count.
- Result rows are NOT reordered — use
Request.Sort. - Forces buffered execution (
Streamable=false).
See
pulse_examples_search tags=[time-series]- Skills:
window-design,op-win-running-avg,op-win-ewma
op-win-pct-change
name: op-win-pct-change
description: Fractional change (0.05 = 5%) against the row `periods` positions earlier in the ordered partition.
kind: operator
category: WIN
operator: WIN_PCT_CHANGE
type: reference
applies_to: process, compose, predict
examples_tags: [time-series, window-operator, buffered-pipeline]
Window operators emit row-level values; they do not produce Response.Components.
Use when
Adds the change from a set number of rows earlier as a fraction of the earlier value: 0.05 is a 5% rise on last month.
Questions it answers:
- By what percentage did each month’s revenue grow on the month before?
- Which regions grew fastest relative to their own previous quarter?
Use something else:
WIN_DELTAwhen the earlier value can be 0, near 0 or negative, or the field is itself a percentage.
Params
| Name | Type | Default | Description |
|---|---|---|---|
periods | int | 1 | Lookback distance (≥ 1) for the comparison row. |
partition_by (carve), order_by (≥1, required, numeric / date), frame (forbidden). field (required, numeric).
Inputs
| Param | Accepted field types |
|---|---|
Field | numeric: u4/u8/u16/u32/u64, f32/f64, date (no decimal128, no packed_bool, no categorical) |
Output
One float64 per row written to Label (default WIN_PCT_CHANGE_<field>). (cur - prev) / prev, prev being periods rows back: a FRACTION (0.05 = 5%), never ×100. Rows i < periods within the partition emit null.
Reading the output
value: The change since the row periods rows earlier, as a fraction of that earlier value: (current - earlier) / earlier. 0.05 is a 5% rise, -0.2 a 20% fall and 1 a doubling; the value is not multiplied by 100.- Sign: positive means the current value is above the earlier one (when the earlier value is positive); negative means the current value is below the earlier one (when the earlier value is positive).
- Caveat: On a field that can be negative the sign follows the division, not the direction: a rise from -10 to -5 reads -0.5. Use WIN_DELTA for such a field.
Gotchas
- Not weightable: a request
weightisPROCESSING_CONFIG(windows carry no slot weight);Options.DefaultWeightis skipped. periods <= 0REJECTED at predict (PULSE_WINDOW_INVALID).prev == 0emitsnull(no+Inf); a negativeprevflips the sign.- Either side null emits
null. - Result rows are NOT reordered — use
Request.Sort. - Forces buffered execution (
Streamable=false).
See
pulse_examples_search tags=[time-series]- Skills:
window-design,op-win-lag,overlay-system
op-win-rank
name: op-win-rank
description: Sparse rank with gaps after ties (1, 2, 2, 4, ...) within the ordered partition.
kind: operator
category: WIN
operator: WIN_RANK
type: reference
applies_to: process, compose, predict
examples_tags: [cohort-analysis, top-n, window-operator, buffered-pipeline]
Window operators emit row-level values; they do not produce Response.Components.
Use when
Ranks rows within each partition in the chosen order; rows with equal values share a rank and the next skips: 1, 2, 2, 4.
Questions it answers:
- Where does each store rank on revenue within its region?
- Which product came first in each month by units sold?
Use something else:
WIN_DENSE_RANKwhen equal values should share a rank with no gap after them.
Params
None. partition_by (carve), order_by (≥1, required, numeric / date — tie comparison reads these keys), frame (forbidden), field (forbidden — no value read).
Inputs
| Param | Accepted field types |
|---|---|
Field | must be empty / omitted — WIN_RANK reads ranking from order_by keys only |
Output
One int64 per row written to Label (default WIN_RANK). Ties (rows equal on every order_by key) share a rank; next distinct row advances by the tie count — (1, 2, 2, 4, 5). Rank resets per partition.
Reading the output
value: The row’s position in order_by order within its partition, starting at 1. Rows equal on every order_by key share a rank, and the next rank skips past them: 1, 2, 2, 4. A row’s rank is 1 plus the number of rows strictly before it.- Caveat: Rank 1 is the first row in order_by order: the smallest value with an ascending key, the largest with desc. Say which when reporting a ‘top’ rank.
- Caveat: Rows tie only when equal on every order_by key; rows with a missing order_by value sort last and tie with each other, so they share the last rank.
Gotchas
- Not weightable: a request
weightisPROCESSING_CONFIG(windows carry no slot weight);Options.DefaultWeightis skipped. order_bydefines BOTH scan order AND tie key — same field choice changes the answer.- Multiple
order_bykeys: tie only when ALL keys equal. - Result rows are NOT reordered — use
Request.Sortfor response order. - Forces buffered execution (
Streamable=false).
See
pulse_examples_search tags=[top-n]- Skills:
window-design,op-win-dense-rank,op-win-row-number
op-win-row-number
name: op-win-row-number
description: 1-based row index within the ordered partition; never ties.
kind: operator
category: WIN
operator: WIN_ROW_NUMBER
type: reference
applies_to: process, compose, predict
examples_tags: [top-n, window-operator, buffered-pipeline]
Window operators emit row-level values; they do not produce Response.Components.
Use when
Numbers the rows 1, 2, 3 within each partition in the chosen order, for picking the first few rows of every group.
Questions it answers:
- Which are the top three products by revenue in each region?
- What is each customer’s first order, by date?
Use something else:
WIN_RANKwhen rows with equal values should share a position.WIN_DENSE_RANKwhen you want ranks without gaps after equal values.
Params
None. partition_by (carve), order_by (≥1, required, numeric / date), frame (forbidden), field (forbidden — no value read).
Inputs
| Param | Accepted field types |
|---|---|
Field | must be empty / omitted — no value field is read |
Output
One int64 per row written to Label (default WIN_ROW_NUMBER). Counts 1, 2, 3, ... in scan order, resets per partition. NEVER ties — row order is stable (nulls last) on the order_by comparator.
Gotchas
- Not weightable: a request
weightisPROCESSING_CONFIG(windows carry no slot weight);Options.DefaultWeightis skipped. - Ties on
order_by: ROW_NUMBER picks a deterministic but arbitrary order. - Common top-N idiom:
WIN_ROW_NUMBERpartitioned by group, then a range filter on the label[1, N]. Filter runs BEFORE windows in the pipeline — stage via Compose / ProcessChain. - Result rows are NOT reordered — use
Request.Sortfor response order. - Forces buffered execution (
Streamable=false).
See
pulse_examples_search tags=[top-n]- Skills:
window-design,op-win-rank,op-win-dense-rank
op-win-running-avg
name: op-win-running-avg
description: Running average of Field over the configured Frame within the ordered partition.
kind: operator
category: WIN
operator: WIN_RUNNING_AVG
type: reference
applies_to: process, compose, predict
examples_tags: [time-series, window-operator, buffered-pipeline]
Window operators emit row-level values; they do not produce Response.Components.
Use when
Adds the average of a field over the rows so far in the ordered series, such as the average order value to date.
Questions it answers:
- What is the average monthly revenue for the year so far, at each month?
- How has the cumulative average score settled as more waves came in?
Use something else:
WIN_MOVING_AVGwhen you want the average of a fixed number of recent rows.
Params
None operator-level. partition_by (carve), order_by (≥1, required, numeric / date), frame (REQUIRED — mode "rows"; typical {preceding: null, following: 0} for cumulative-to-current-row). field (required, numeric).
Inputs
| Param | Accepted field types |
|---|---|
Field | numeric: u4/u8/u16/u32/u64, f32/f64, date (no decimal128, no packed_bool, no categorical) |
Output
One float64 per row written to Label (default WIN_RUNNING_AVG_<field>). Arithmetic mean of non-null values inside the resolved frame. Empty slice → null.
Reading the output
value: The mean of the field over the rows in the frame. With the usual frame (no preceding bound, following 0) it is the cumulative average: the mean of every row from the partition’s start up to and including this one.- Caveat: Early values rest on few rows and swing; later ones rest on the whole history and barely move, so a late cumulative average says little about the recent level (use WIN_MOVING_AVG for that).
- Caveat: A frame with a following bound above 0, or none, takes in later rows too.
Gotchas
- Not weightable: a request
weightisPROCESSING_CONFIG(windows carry no slot weight);Options.DefaultWeightis skipped. - Mechanically identical to a moving average; the differentiator is FRAME —
MOVING_AVGrequires bounded both sides,RUNNING_AVGaccepts unbounded preceding (cumulative). - Nulls skipped (not zero-filled); denominator is non-null count.
- Result rows are NOT reordered — use
Request.Sortfor response order. - Forces buffered execution (
Streamable=false).
See
pulse_examples_search tags=[time-series]- Skills:
window-design,op-win-moving-avg,op-win-running-sum
op-win-running-sum
name: op-win-running-sum
description: Running total of Field over the configured Frame within the ordered partition.
kind: operator
category: WIN
operator: WIN_RUNNING_SUM
type: reference
applies_to: process, compose, predict
examples_tags: [time-series, window-operator, buffered-pipeline]
Window operators emit row-level values; they do not produce Response.Components.
Use when
Adds a running total of a field down the ordered rows, such as revenue for the year to date.
Questions it answers:
- What is the year-to-date revenue at the end of each month?
- How many responses had come in by each day of fieldwork?
Use something else:
AGG_SUMwhen you want one total per group, not a running one.WIN_RUNNING_AVGwhen you want the running average instead of the total.
Params
None operator-level. partition_by (carve), order_by (≥1, required, numeric / date), frame (REQUIRED — mode "rows"; typical {preceding: null, following: 0} for cumulative-to-current-row). field (required, numeric).
Inputs
| Param | Accepted field types |
|---|---|
Field | numeric: u4/u8/u16/u32/u64, f32/f64, date (no decimal128, no packed_bool, no categorical) |
Output
One float64 per row written to Label (default WIN_RUNNING_SUM_<field>). Sum of non-null values inside the resolved frame within the partition. Empty slice → null.
Gotchas
- Not weightable: a request
weightisPROCESSING_CONFIG(windows carry no slot weight);Options.DefaultWeightis skipped. - Frame REQUIRED —
nullpreceding = UNBOUNDED PRECEDING (full cumulative). - Bounded frames produce a windowed sum, not cumulative.
- Nulls skipped (not zero-filled).
- Result rows are NOT reordered — use
Request.Sort. - Forces buffered execution (
Streamable=false).
See
pulse_examples_search tags=[time-series]- Skills:
window-design,op-win-running-avg,op-win-moving-avg
name: overlay-system
description: Overlay framework — OverlaySpec composition, six reference families, three payload shapes, host-arm wiring (SERIES / FACET / CHAIN / FORMULA), parity overlays + Welford migration. Per-kind detail lives in op-overlay-* atomics.
type: guide
kind: design
applies_to: process, compose, facet
covers: [OVERLAY, OverlaySpec, OverlayLayer]
Overlays
Additive, read-only decorations on a result that is already computed. Specs ride Request.Overlays; each produces one layer in Response.Overlays[i], in spec order. Overlays NEVER mutate the base payload — they are siblings keyed to host coordinates, so the base numbers are byte-identical with or without them.
Use an overlay for a figure DERIVED from the result — a share, an index against a reference, a delta, a z-score, a significance test between cells. Base figures stay aggregations.
{"overlays": [{"kind": "<overlay kind>", "scope": "cell", "ref": {"margin": {"axis": "row"}}}]}
Choosing an overlay
- Name the question, then read the matching intent (
pulse_skills_get intents):composition(shares),benchmark(index / delta against a reference),compare_groups(cell and pairwise tests),change_over_time(prior period, rolling, baseline). - Filter manifest
overlays[]on that intent, then on the HOST you have — each entry listsshapes,scopes,ref_kinds,bufferedandinferential. - Read the chosen kind’s atomic skill for its math, params and warnings. The authoritative catalog is
overlays[]itself; never hardcode a count.
OverlaySpec composition
kind, scope, ref, optional name / level / within / params; Compose-only reference / targets. scope ∈ cell|row|column|group|matrix|total. level / within mirror the crosstab’s normalize_level / normalize_within (same-axis rollup, opposite-axis prefix).
Predict and runtime refuse a misshapen spec under the SAME code (PULSE_OVERLAY_REF_INCOMPATIBLE_WITH_SHAPE, _LEVEL_OUT_OF_RANGE, _SCOPE_UNSUPPORTED, …), carried as errors[0].code so pulse_errors_lookup resolves it.
Six reference families
ref is a discriminated union — exactly one family populated:
| Family | Compares against | Host |
|---|---|---|
margin{axis} | the row / column / grand margin | crosstab |
sibling{field, value} | another group’s value | grouped Process |
baseline_index{position}, prior, rolling_mean, yoy | an earlier point in the same series | grouped Process |
population{cohort} | a reference cohort | facet |
stage{index or name} | an earlier chain stage | process chain |
reference + targets (spec top level) | another Compose slot | compose |
Implicit-margin kinds (the χ² and Fisher cell tests) leave ref empty.
Three payload shapes
payload.shape ∈ scalar|series|matrix. Scalar — payload.scalar + optional summary. Series — payload.series.entries[i].{key, value, summary}, aligned to host keys. Matrix — payload.matrix.cells[r][c], mirroring host cells. baseline is the centre point (100 for an index, 0 for a delta / z) and is absent for inferential kinds (manifest inferential: true).
Host-arm wiring
Which request carries the spec decides the host, and the host decides which kinds are legal:
- MATRIX — a crosstab (
Request.Crosstab+Request.Overlays): share, margin compare, cell tests, intra-matrix pairwise. Pairwise n:pairwise-n-sources. - SERIES — a grouped Process with no crosstab: per-group self-compare (sibling, baseline, prior, rolling, year-over-year).
- FACET —
FacetRequest.Overlays(NOTRequest.Overlays): population comparisons, layers onFacetResult.Overlays(facet-design). - CHAIN — whole-chain
ChainRequest.Overlaysagainst an earlier stage; layers on the chain response, per-stage overlays untouched. Stages of divergent shape ⇒PULSE_OVERLAY_CHAIN_STAGE_SHAPE_DIVERGENT(process-chain). - FORMULA — an expression over earlier layers, referenced by
name. - COMPOSE — the post-slot fold compares slots (
referencevstargets); slots must align on keys, schema and dictionaries. Layers onComposedResponse.Overlays[i](top-levelreturnprunes post-fold), buffered only (compose-requests).
Per-layer warnings (OverlayLayer.Warnings)
Additive warnings: [{code, message, details}] on each layer, omitempty — overlay-free responses stay byte-identical. Canonical: PULSE_OVERLAY_REF_ZERO (a reference value of zero, so the ratio is undefined), PULSE_OVERLAY_EXPECTED_LOW (χ² expected count below 5). A warning lands on the layer that raised it; on Compose and chain hosts it rides Overlays[i].Warnings of the matching layer. Warnings never fail the request — read them before reporting a figure.
Multiplicity
Inferential layers take multiplicity (layer / row / column / request / compose): adds p_adjusted figures and a layer echo, base payload untouched (multiplicity-correction).
Streamability
Manifest overlays[].buffered. Descriptive SERIES kinds stream; inferential kinds buffer. MATRIX kinds fold after the matrix is finished, so an overlay never decides whether a crosstab fuses — the cell aggregator does (crosstab-guide). Mixing streamable and buffered kinds on one request prices it as buffered.
Weighted hosts
A weighted host yields a weighted payload, so every descriptive kind (shares, indices, deltas) reads weighted figures with no overlay setting. An inferential kind refuses a weight that reaches it (request, instance default, its own weight) with PULSE_WEIGHT_UNSUPPORTED; "weight": null on the spec opts out. The weighted pairwise two-means z is exempt and takes a weighted-mean or weighted-average cell.
Parity overlays — Welford migration
The cell t / z kinds and their Compose vs-ref twins read {n, mean, variance} from Response.Components.Crosstab.CellComponents[r][c], so the cell aggregator must emit that triple; with no triple at the coordinate they fall back to params (variance_*, sample_size_*). The cell’s own MatrixCell.Value is the scalar mean. P-values equal the matching row-level two-sample tests on the same inputs.
See
response-components,crosstab-guide,facet-design,pairwise-n-sources,compose-requests,process-chain.
name: pairwise-n-sources
description: The sample-size vocabulary shared by every OVERLAY_PAIRWISE_* kind — the nine n_source modes, the three distinct-key modes and their cell-aggregator admission, the slab partition gate, the null rules, and p_source.
type: guide
kind: design
applies_to: process, compose, predict
covers: [OVERLAY, PairwiseOverlayParams, n_source, p_source]
requires: [capability:crosstab]
Pairwise sample-size sources
Every OVERLAY_PAIRWISE_* kind decodes one shared params blob (types.PairwiseOverlayParams): pair_along_dim, n_source, n_within_depth, p_source, n_basis. This skill is the vocabulary; per-kind math and param acceptance live in each kind’s atomic skill (manifest overlays[] entries serving compare_groups).
Every mode reads Response.Components.Crosstab, so a components-disabled host is refused first (PULSE_OVERLAY_COMPONENTS_REQUIRED). An unreadable leg SKIPS the pair (aggregated PULSE_OVERLAY_REF_ZERO), never a zero n.
Choosing n
The question is: what does one observation mean here?
- One record per respondent, unweighted → the default,
cell_n_unweighted. - Weighted host (a weight on the cell, any source) → omit
n_source: n is N* (sum_weightsunder kind frequency, Kishn_effunder probability). Unweighted counts (cell_n_unweighted, margin_n,n_within, every distinct mode) arePROCESSING_CONFIGthere, andcell_weight_sum/cell_value_weightedstand under frequency only. - The pair compares slices of a wider population (the share of a region WITHIN an issuer) →
n_within, holding the firstn_within_depth+1 dims fixed. - Several records per respondent and n must be respondents → a DISTINCT mode (below).
- Margin bases →
row_margin_n/column_margin_n, or their distinct twins.
The nine n_source modes
n_source | Leg |
|---|---|
cell_n_unweighted (default; empty means this) | CellCounts[r][c] |
cell_value_weighted | the cell VALUE, truncated |
cell_weight_sum | CellComponents[r][c]["sum_weights"] |
row_margin_n / column_margin_n | RowMarginCounts[r] / ColumnMarginCounts[c] |
n_within | the SLAB: CellCounts summed across the pair axis, first n_within_depth+1 dims held fixed |
n_within_distinct | that slab, in distinct KEYS |
row_margin_distinct / column_margin_distinct | RowMarginComponents[r] / ColumnMarginComponents[c], in distinct KEYS |
n_within_depth applies to the two within modes only (margin modes ignore it); a depth >= the pair-axis dim count is refused. The moment-based kinds (two-means z, Welch t, weighted two-means z) read n and both moments off their cell aggregator, so they REFUSE n_source and p_source outright — a selector there would be a silent no-op; the weighted kind instead REQUIRES n_basis (weights | kish). Refusals are PULSE_OVERLAY_PARAM_MISSING.
Distinct keys versus records
Use a distinct mode when one respondent contributes several records. n_within_distinct SUMS per-cell cardinalities; the margin modes read ONE accumulated figure.
Admission is decided on the cell aggregator’s IDENTITY, up front, not per pair — an exact component-key-set match against its ComponentSchema. Two are admitted: the sum-of-distinct aggregator (figure on distinct_count) and the distinct count (on cardinality). Everything else is PULSE_OVERLAY_REF_INCOMPATIBLE_WITH_SHAPE, naming the observed aggregator and the admitted set — including the modal aggregators, which ALSO emit a key spelled distinct_count but count distinct VALUES of the measure (answer codes), not respondents. A key-presence probe would read an answer-code count as a sample size; that is why identity, not presence, is the test.
Null rules
The n leg counts exactly what the CELL counted, so the two admitted aggregators differ: sum-of-distinct registers a key only when the key AND the value are non-null; distinct count counts distinct non-null values.
A slab cell no record reached contributes zero — a true zero, not an unreadable leg. A nil or absent MARGIN entry is the opposite: components off, or that margin’s display flag off, means the leg was never emitted, so the pair skips.
The slab partition rule
Summing per-cell distinct cardinalities equals the slab’s true count only when its cells PARTITION the key set. A fan-out grouper (one row in several buckets, e.g. per-element grouping of a set_* field) at a depth the slab sums across puts one key in two cells: n too big, every p-value too small, silently. So it is refused: PULSE_OVERLAY_DISTINCT_SLAB_NOT_PARTITIONED, at predict and again at runtime (pulse.Process does not run predict).
Two shapes are deliberately ACCEPTED: a fan-out grouper at depth <= n_within_depth (inside the FIXED prefix, it multiplies slabs, not cells — the shape the mode exists for), and one on the OPPOSITE axis, which the slab never sums across. Fix a refusal by raising n_within_depth past the offending dim, or switch to n_within — record counts ARE additive. The margin distinct modes are exact by construction (a margin accumulates each record once) and not gated. Extension groupers declare fan-out, so are gated alike.
p_source
Proportion-input kinds only. cell_value_pct (default) divides the cell value by 100; cell_value takes it as already 0..1. A mismatch fails silently: cell_value over a real 0..100 percentage puts every proportion out of range and the layer returns empty.
Compose-host panel
The multi-slot panel overlay reads the same distinct-KEY quantity under the same admission rule and null rules, but under its OWN mode spellings — none of row_margin_n, n_within, n_within_distinct is accepted there — and it refuses the whole spec when two slots name different admitted aggregators (op-overlay-prop-z-panel).
See
crosstab-guide (cell aggregators, margins, display flags) · response-components · overlay-system · op-overlay-pairwise-prop-z · op-agg-distinct-sum · op-agg-distinct-count · op-group-set-per-element.
Runnable distinct-n slab over a fan-out axis: pulse_examples_get crosstab-pairwise-distinct-n.
name: predict-advisories
description: Predict advisories — coded, non-blocking notes in a predict result that the chosen analysis may not fit the data (a two-group test over many groups, many uncorrected p-values, an SPSS nominal or ordinal field treated as a quantity, an unused SPSS weight); how they differ from warnings, what each code means, what it suggests, and how an embedder suppresses one.
kind: design
type: guide
applies_to: predict
covers: [advisories, PULSE_ADVISORY, analysis fit, suppress advisories]
Predict advisories
Predict answers “will this run” (errors, warnings). An advisory answers a different question: “does this analysis fit the data”. pulse_predict / pulse api predict --json carry them in data.advisories, one {code, message, details} per finding, omitted when none fires.
- Not a warning. Advisories ride their own slot, never the envelope
warnings.--strictnever turns one into an error andvalidnever changes. - Never changes execution. The request runs exactly as written; acting on an advisory is the caller’s choice.
- Authoritative metadata only. A rule reads the request, the schema and its dictionaries, and figures predict already computed. No field-name or value-shape guessing, so a rule that cannot see the data stays silent.
details.suggestedis a replacement operator name or a request patch to merge — never a rewritten request — and only ever names something this instance offers.pulse_errors_lookup CODEreturns the fixup text.
Codes
| Code | Fires when | details |
|---|---|---|
PULSE_ADVISORY_TWO_GROUP_TEST_MANY_GROUPS | a two-sample t-test’s split_by is a categorical field whose dictionary holds more than two entries (exactly two never fires) | slot, operator, field, split_by, groups, suggested (the many-group ANOVA; absent when none is offered) |
PULSE_ADVISORY_MANY_TESTS | p_values.uncorrected ≥ threshold (10) and no multiplicity block anywhere — request, slot or instance default | total, uncorrected, basis, threshold, suggested: {"multiplicity": {"method": "holm"}} |
PULSE_ADVISORY_CATEGORICAL_AS_NUMERIC | the SPSS sidecar records a numeric field nominal and a mean-family aggregator (average, stddev, variance, moments, CI bounds) or a parametric test (t, z, paired t, ANOVA, Pearson) reads it | slot, operator, field, measure, suggested (a frequency count or chi-square) |
PULSE_ADVISORY_ORDINAL_PARAMETRIC | the SPSS sidecar records a numeric field ordinal and a parametric test reads it | slot, operator, field, measure, suggested (the rank-based test from the operator’s not_for; absent when it names none) |
PULSE_ADVISORY_WEIGHT_AVAILABLE_UNUSED | predict echoes suggested_weight (an SPSS weight variable, no slot resolving a weight) | field, source, suggested: {"weight": {"field": F}} |
PULSE_ADVISORY_EXTRA_PASS | an ATTR_FORMULA calls total(expr), so the run adds a pre-pass over the filtered rows (one per such formula) | slot, operator, label, totals (distinct arguments), added_scans (1; formulas in one pre-pass layer share it) |
The many-tests message says “at least N” when p_values.basis is lower_bound and “an estimated N” when it is dictionary; any multiplicity block — method: none included — silences it.
The two-group rule reads the dictionary, not the data: it fires even when a filter leaves only two groups.
The three SPSS rules need the cohort’s SPSS metadata sidecar: a cohort imported from any other format, or one whose sidecar is missing or stale, never fires them, and a labelled SPSS numeric (imported as categorical) is not a numeric field.
Roots
Request predict runs every rule. Facet predict runs them too, but a facet request has no test, aggregation or weight slot, so only the many-tests note can fire: one p-value per inferential population-comparison overlay (chi-square or Kolmogorov-Smirnov), counted in its own p_values. Compose predict forwards each slot’s advisories in slot order, attributed by details.request (the slot index) and a requests[i]. prefix on slot, then adds a many-tests note over its own overlays. Their p_values count one per inferential overlay on a lower_bound basis, because the extent depends on slot results. Chain predict carries no advisories.
Suppression
Embedders set Options.SuppressAdvisories (a list of codes) for questions their product answers itself. pulse.New refuses an unknown code with PULSE_SUPPRESS_ADVISORY_UNKNOWN. There is no per-request or CLI knob.
See
tool-predict · multiplicity-correction · weighting · spss-metadata-sidecar · statistical-testing · pulse_errors_lookup for PULSE_ADVISORY_*.
name: process-chain
description: ChainRequest source-rooted linear pipeline — mergeable-only v1 gate, per-stage Response.Components, dual-slot overlays (per-stage + whole-chain), StageRef resolution, shape-divergence warning.
type: guide
kind: design
applies_to: process-chain, predict
covers: [ChainRequest, pulse_process_chain, OVERLAY_CHAIN_STAGE_SHAPE_DIVERGENT, OVERLAY_DELTA_VS_STAGE, OVERLAY_INDEX_VS_STAGE]
requires: [capability:process_chain]
Process chain
ChainRequest runs a linear pipeline rooted at one cohort: stage 0 reads the source; each later stage reads its predecessor’s OUTPUT rows as a synthesised cohort. CLI pulse api process-chain; library pulse.ProcessChain; MCP pulse_process_chain.
When to chain
Chain when a question is “aggregate, then aggregate the aggregates” — revenue per store, then the spread of store revenue per region; counts per respondent, then the distribution of those counts. It collapses N round-trips into one open + N stage validations. If the steps are independent of each other, batch them instead (compose-requests).
{
"cohort": {"filename": "sales-2025.pulse"},
"stages": [
{"name": "per_store",
"request": {"groups": [{"type": "<grouper>", "field": "store"}],
"aggregations": [{"type": "<aggregator>", "field": "revenue", "label": "store_rev"}]}},
{"name": "spread",
"request": {"aggregations": [{"type": "<aggregator>", "field": "store_rev"}],
"sort": [{"field": "store_rev", "desc": true}]}}
]
}
<…> = any operator the stage gate admits (below). Only stage 0 names a cohort (the chain-level cohort); later stages’ cohort is ignored. A later stage addresses earlier outputs by their LABEL (store_rev) or default output name.
The stage gate (v1: mergeable only)
The bridge between stages is a synthesised cohort: grouper keys become categorical_u32 columns, aggregator outputs become f64. A stage passes only if everything it runs fits that bridge — mergeable aggregators and groupers, row-local attributes, streamable filterers, no built-in aggregator over decimal128. Read the admitted set off the manifest process_chain block — mergeable_aggregators, mergeable_groupers, row_local_attributes, and rejection_rules for what is refused (windows, features, tests, regressions, two-pass attributes, order statistics). An extension operator passes iff declared Mergeable.
- Smart defaults run first.
{"field": "n"}on a numeric field is gated as its default aggregator, and the next stage sees that default’s output name. - A modal value on a categorical field chains its DICTIONARY INDEX — the next stage sees a number, not the label.
- Only stage 0 may join. A later stage’s
joins⇒PULSE_CHAIN_STAGE_JOIN {count, stage, stage_name}(join-design). - Failure:
PULSE_CHAIN_NOT_MERGEABLE"chain stage is not mergeable: <reason>"{stage_index, stage_name}. Stages are gated as they run, so a refusal in stage 2 comes after stages 0–1 executed; a zone or join-count refusal carriesdetails.stage.
When the gate refuses, run stage 0 as an ordinary request (pulse_process) and finish the later steps client-side.
A later step that must READ a window, feature or attribute column (a regression on a window) cannot chain: write the column first with pulse_materialize (derived-cohorts), then run that step on the new cohort.
Per-stage Response.Components
Each stage emits its own components on ChainResponse.stages[i] — the universal floor ({n, n_null} per aggregator, {total_n, n_null} per grouper, {n_in, n_out, n_null_input} per filterer) plus operator keys. ChainResponse.final is the last stage’s response. A stage’s return shapes it after the whole chain ran. Overlays read these as inputs and never rewrite them (response-components).
Dual-slot overlay design
Two independent overlay slots:
- Per-stage (
stages[i].request.overlays) — the ordinaryRequest.Overlays, run at that stage’s exit BEFORE the next stage reads its rows. Lands onstages[i].overlays. Nothing chain-specific. - Whole-chain (
ChainRequest.overlays,ChainOverlaySpec) — runs AFTER every stage finalises and compares two stage results: one kind per manifestprocess_chain.overlay_kindsentry, an index (target / ref × 100) and a delta (target − ref). Each spec needskind,scope,ref,target. Lands onChainResponse.overlaysin declared order — never onstages[i].overlays.
A complete three-stage request with a whole-chain index: pulse_examples_get chain-whole-chain-index-vs-stage.
Multiplicity
Each stage corrects its own request; no family spans stages, and ChainOverlaySpec takes no block (multiplicity-correction).
StageRef resolution
ref and target each take exactly one of index (zero-based) or name (matches stages[i].name); both or neither is a configuration error. In Go, Index is a *int so stage 0 is distinguishable from “unset” — pass &zero.
An empty target defaults to the LAST stage. ref has no default — every spec names its baseline.
Shape divergence
A whole-chain overlay fires only when ref and target produce the same host shape: crosstab ⇒ MATRIX, aggregations + groups ⇒ SERIES, aggregations alone ⇒ SCALAR. On a mismatch the runtime emits ONE PULSE_OVERLAY_CHAIN_STAGE_SHAPE_DIVERGENT warning per spec (details {target_shape, ref_shape, target_index, ref_index}) and an empty layer in the target’s shape — not fatal. Keep compared stages shape-identical: same groups, same aggregation count. Other whole-chain refusals: PULSE_OVERLAY_KIND_UNKNOWN (a kind outside overlay_kinds), PULSE_OVERLAY_REFERENCE_UNKNOWN / PULSE_OVERLAY_TARGET_UNKNOWN (index out of range, unmatched name, both or neither set).
Hashing & echo
ChainRequest.Hash() is a 32-char canonical-JSON digest; overlays is omitempty, so an overlay-free chain hashes as it always did. --echo-request puts each stage’s post-defaults form on envelope.request.
See
compose-requests (independent slots) · overlay-system (overlay hosts) · response-components · streaming-and-watching (ChainRequest.Hash() cache keys) · docs/src/internals/adding-chain-predicate.md (contributors).
name: regression-inference
description: Coefficient confidence intervals (ci_lower / ci_upper at conf_level) and the coefficient covariance and correlation on a regression — the per-family critical value, the opt-in vcov flag, where each engine's and modifier's matrix comes from, how it relates to the standard errors, weighted fits, the refusals, and what R's vcov does differently. Topical design; per-model params in atomic op-reg-* skills.
type: guide
kind: design
applies_to: process, compose, predict
covers: [ci_lower, ci_upper, conf_level, confidence interval, df, vcov, Vcov, Correlation, covariance, coefficient correlation, standard errors, std_errors, regressions]
Regression coefficient intervals and covariance
Confidence intervals
Every fit with standard errors carries ci_lower / ci_upper (keyed like coefficients), conf_level and, for a t interval, df: bound = β ∓ q·SE. Set "conf_level": 0.9 (flat, 0<c<1, default 0.95).
| Fit | q | df |
|---|---|---|
| OLS, ridge | Student t | residual N* − p − 1 (fractional weighted) |
| GLM | normal (Wald, R confint.default) | absent |
| Bayesian linear | posterior t, = credible_intervals at one level | 2a_N |
| Resample | normal on the resampled SE | absent |
Present exactly where std_errors is: lasso / elastic net only on kept predictors (plug-in, approximate), selection keyed by selected_features. An undefined bound (df ≤ 0) is null. t on df, so it differs from a pasted ±1.96·SE in about the third digit on small samples.
Covariance
Set "vcov": true on a regression slot (flat spec field, NOT under params) to get the coefficient covariance and its correlation. Off by default: Vcov / Correlation are then absent and the response is byte-identical.
{"regressions": [{"type": "REG_OLS", "target": "mpg", "predictors": ["wt", "hp"], "vcov": true}]}
Shape
Response.Regressions[i].Vcov and .Correlation are square-symmetric tables in the full encoding, keyed (intercept) first, then the predictors in spec order. Correlation is cov2cor (unit diagonal); a zero or undefined variance gives null in that row and column. Under selection the keys are (intercept) + the retained predictors in selected_features order; selected-out predictors are absent.
Where the matrix comes from
| Engine | Matrix |
|---|---|
| OLS | σ̂²(XᵀX)⁻¹, intercept cross terms included (−μᵀCov) |
Ridge (l2) | the sandwich estimator |
| GLM | (XᵀWX)⁻¹ at the converged IRLS weights, dispersion fixed at 1 |
| Bayesian linear | the posterior covariance b_N/(a_N−1)·Λ⁻¹; null when a_N ≤ 1 |
| Resample jackknife | (n−1)/n·Σ(β₋ᵢ−β̄)(β₋ᵢ−β̄)ᵀ over the leave-one-out refits |
| Resample bootstrap | sample covariance (B−1 divisor) of the successful replicates; seeded = deterministic |
| Selection | the engine’s matrix from the final refit on the selected predictors |
For OLS, ridge, GLM and both modifiers, √diag(Vcov) equals StdErrors exactly; selection + resample follows the resample SEs over the selected set. The Bayesian diagonal is StdErrors² · a_N/(a_N−1), not equal: StdErrors there is the scale of the marginal t, not the posterior variance.
Weighted fits (frequency or probability) use the N* basis the weighted SE uses; see weighting.
Robust standard errors
"covariance": {"type": "hc3"} on REG_OLS swaps the estimator behind std_errors, p_values, the intervals and vcov (√diag still = StdErrors): hc0–hc3, cluster (CR1, OLS df = clusters − 1) or hac (Newey–West over order_by). Absent = classical, byte-identical. Matches R sandwich + lmtest::coeftest.
REG_GLM takes it too: the sandwich over the IRLS scores (R sandwich on glm), still Wald z.
REG_BAYES_LINEAR: Müller’s sandwich posterior REPLACES the posterior covariance (mean kept; normal intervals, no df).
Refusals
PROCESSING_REGRESSION_VCOV_UNSUPPORTED, details.reason penalty: l1 / elasticnet has no sampling covariance of its data-dependent active set, never approximated — refused under resample or selection too. A robust covariance is refused beside any penalty, resample or selection.
Predict and runtime apply the same rule, so predict reports it before any fit. pulse_errors_lookup carries the recovery.
Against R
vcov(lm)andvcov(ridge)agree to rounding.vcov(glm)uses weights one IRLS iteration behind its coefficients; Pulse inverts at the converged fixed point, matching R refined to that point to 1e-11 (rawvcov(glm)only to about 1e-6).- Gamma GLM: Pulse fixes dispersion at 1, R estimates it, so the covariances differ by that factor. No oracle case covers gamma.
Response shaping
vcov, correlation, ci_lower, ci_upper, df, conf_level ride the standard preset (the MCP default) and are absent from minimal. Exclude with regressions[*].<key> paths in return.exclude; return.precision rounds them like any figure. See response-shaping.
See
regression-modeling— choosing a model and modifiers.op-reg-ols— thevcovParam for OLS and ridge.op-reg-glm— thevcovParam for GLM.op-reg-bayes-linear— thevcovParam for the Bayesian linear model.weighting— the N* basis.response-shaping— presets and exclude paths.matrix-results— the same matrix encodings.
name: regression-modeling
description: Choosing and composing a regression — outcome type, priors, penalties, the resampling and stepwise modifiers, polynomial terms built upstream, and how textbook regression names map to specs. Topical design; per-model detail in atomic op-reg-* skills.
type: guide
kind: design
applies_to: process, compose, predict
covers: [REG, REG_OLS, REG_GLM, REG_BAYES_LINEAR, Resample, Selection, FEAT_POLY, regressions, modifiers]
Regression modeling
Model operators, two spec-level modifiers and an upstream feature transform cover the textbook regression names. Per-model params, outputs, inference and errors: atomic op-reg-* skills.
Regressions do not emit Response.Components. Fit summaries ride Response.Regressions[i].
Choosing a model
Start from the question: pulse_skills_get intents → drivers (which predictors move the outcome, and by how much) or relationship (do two fields move together). In pulse_manifest, the regressions[] entries carrying that intent are the models this instance offers; each states streamable. Each model’s guidance names its alternatives — a correlation or group-mean test often answers without a model.
| Decide | Pushes toward |
|---|---|
| Outcome numeric with roughly symmetric, constant-spread residuals? | the least-squares (OLS) fit |
| Outcome yes/no (0/1) or a count? | the generalised linear (GLM) fit with a binomial / poisson family |
| Prior knowledge about the coefficients, or want credible intervals? | the Bayesian linear fit (no p-values) |
| Many correlated predictors? | a penalty (l2 keeps all, l1 / elasticnet zero some) |
| Distrust the analytical SE? | the Resample modifier |
| Unsure which predictors belong? | the Selection modifier — or a penalty, never both |
| A curved relationship? | polynomial columns built upstream, then a linear fit |
Target / predictor types follow encoding.FieldType.IsNumericForAnalytics (integer / float / decimal plus u4, packed_bool, date); a null drops the row from the observation count.
Streaming: the OLS and Bayesian linear fits share one Welford-Pébaÿ accumulator (n, μ_x, μ_y, M2_xx, M2_xy, M2_yy) and stream; the GLM’s iteratively reweighted fit buffers. Either modifier forces buffered execution.
Modifiers
Both compose with the OLS and GLM fits; the Bayesian fit rejects both — credible intervals already convey uncertainty, and stepwise search on a conjugate fit is out of scope.
Resample (jackknife / bootstrap)
| Value | Behavior |
|---|---|
"" | closed-form / asymptotic SE |
"jackknife" | LOO refit; SE = √((n−1)/n · Σ(β⁽⁻ⁱ⁾ − β̄)²) |
"bootstrap" | non-parametric (bootstrap_iters, rng_seed); SE = sample std of replicates; percentile-method p |
Overwrites StdErrors / PValues; point estimate stays at the full-data fit. BootstrapIters defaults to 1000; RNGSeed = 0 time-seeds, non-zero reproducible. For l1 / elasticnet, Resample is the rigorous SE answer and suppresses PROCESSING_REGRESSION_APPROXIMATE_SE.
Selection (stepwise)
| Value | Behavior |
|---|---|
"" | fit on all predictors |
"forward" | start intercept-only; add the predictor that lowers Criterion most |
"backward" | start full; remove the predictor whose absence lowers Criterion most |
"stepwise" | bidirectional sweep |
Criterion ∈ {aic, bic} required. BIC rejects noise predictors more reliably at moderate n. SelectedFeatures lists chosen predictors; non-selected ones are DROPPED from Coefficients — absent ≠ zero.
Selection + Resample together is sane. Penalty != "" + Selection != "" emits PROCESSING_REGRESSION_REGULARIZED_SELECTION — regularization already shrinks / selects.
Textbook names → spec
| Name | Spec |
|---|---|
| Simple / Linear / Multiple | REG_OLS, one or more predictors |
| Ridge | REG_OLS{Penalty:"l2", Alpha:λ} |
| Lasso | REG_OLS{Penalty:"l1", Alpha:λ} |
| Elastic Net | REG_OLS{Penalty:"elasticnet", Alpha, L1Ratio} |
| Logistic | REG_GLM{Family:"binomial", Link:"logit"} |
| Polynomial | FEAT_POLY upstream → REG_OLS |
| Bayesian Linear | REG_BAYES_LINEAR{Prior:"nig"} |
| Jackknife | an OLS / GLM spec with Resample:"jackknife" |
| Stepwise | an OLS / GLM spec with Selection:"stepwise", Criterion:"aic"|"bic" |
| Ecological | a grouper + AGG_AVERAGE upstream → REG_OLS over per-group means (composed) |
Runnable JSON: internal/examples/regression/.
Polynomial terms
Polynomial columns are built in features, before the fit: the transform emits Degree − 1 derived columns (<label>_2 … <label>_<Degree>) and keeps the original. Degree gate [2, 10]. Standardize predictors first — x^10 overflows f64 past |x| ≈ a few hundred. Parameter table and naming: feature-engineering.
Ecological caveat
A significant group-level slope does NOT imply individual-level association (Robinson 1950, Simpson). Use ecological fits only when the question is about groups or individual data is unavailable (census, precincts). Pulse cannot enforce this — annotate consumer prose.
Inference
Per model (atomic skill): Student-t p for unpenalized / l2 OLS, plug-in SE over the active set for l1 / elasticnet (PROCESSING_REGRESSION_APPROXIMATE_SE unless Resample), Wald-z p for the GLM, credible intervals and no p_values for the Bayesian fit.
Gotchas
n_obs≠ raw record count when nullable fields appear.PROCESSING_REGRESSION_RANK_DEFICIENT/SINGULAR_GRAM: drop a predictor or add regularization.PROCESSING_REGRESSION_NO_CONVERGE: raiseMaxIters/Tol, or reduceAlpha.- Knobs belong to their engine: a penalty on the GLM, or
Penalty/Family/Linkon the Bayesian fit, is rejected (PROCESSING_CONFIG).
See
- Recipes:
pulse_examples_search tags=["regression"]plus atomicop-reg-<name>. feature-engineering— polynomial parameter table + column naming.statistical-testing— Wald-z vs Student-t.request-envelope— slot keys, streamability.regression-inference— vcovpulse_errors_lookup—PROCESSING_REGRESSION_*recovery.
name: request-envelope
description: Request shapes, envelope contract, slot keys, smart defaults, streamability and time zones. Use when authoring or adapting any Pulse Request.
type: guide
kind: design
applies_to: process, compose, sample, facet, inspect, predict, manifest
covers: [Request, ComposedRequest, ChainRequest, FacetRequest, SampleRequest, Envelope]
Request envelope
Envelope (response side)
Every --json output and facade response uses descriptor.Envelope:
{"format_version": "1.1", "data": {...}, "request": {...}, "errors": [], "warnings": []}
format_version—"1.1"; additivedatafields never bump it.data— the operation’s payload.request— opt-in echo of the normalized request (--echo-request); streaming skips it.errors/warnings— always arrays of{code, message, details}; resolve viapulse_errors_lookup.
Request shapes (per command)
| Operation (CLI) | Wire type | Top-level keys |
|---|---|---|
process, predict (pulse api process) | Request | cohort, time_zone, filterers, features, attributes, groups, aggregations, windows, sort, tests, post_tests, joins, crosstab, overlays, outputs, return, multiplicity, vectors, matrices |
compose (pulse api compose) | ComposedRequest | requests[] (each = Request), return (overlays only) |
process chain (pulse api process-chain) | ChainRequest | cohort, stages[], overlays (each stage: {request: Request}) |
facet (pulse api facet) | FacetRequest | cohort, time_zone, fields[], top_k, percentiles, histogram, additive, overlays |
sample (pulse api sample) | SampleRequest | cohort, count, offset |
Each operation’s MCP tool is listed in manifest mcp_tools.
Row weight: Request.weight {field, kind}; per-slot weight — a field, {field, kind} or null (null opts out, absent inherits); pulse.Options.DefaultWeight. See weighting.
Request slot order is pipeline order: features → filterers → attributes → groups → aggregations → windows → sort; sort shares the window comparator (nulls last both ways). A derived name (feature output, attribute / aggregation / window label) exists only DOWNSTREAM of its producer. Runtime and predict refuse (SERVICE_VALIDATION) a name nothing produces, a label shadowing a column, and an aggregation label equal to a group field or another aggregation label.
Canonical process Request
{
"cohort": {"filename": "sales.pulse"},
"filterers": [{"type": "FILTER_INCLUDE", "field": "status", "values": ["active"]}],
"groups": [{"type": "GROUP_CATEGORY", "field": "region"}],
"aggregations": [
{"type": "AGG_COUNT", "field": "id", "label": "n"},
{"type": "AGG_SUM", "field": "revenue", "label": "revenue"}
]
}
Every type is an operator constant from pulse_manifest components.<category>; runnable requests: pulse_examples_search.
Slot-key gotchas
Unknown keys drop silently on decode.
| Wrong | Right |
|---|---|
groupers | groups |
aggregators | aggregations |
filters | filterers |
tests_post | post_tests |
output | outputs |
request (Compose top-level) | requests |
Per-operator slot: type, field, label, optional params (keys per operator: its atomic skill).
Smart defaults
When a slot names field but omits type, the engine infers from schema type. Predict reports filled slots under data.defaults_applied.
| Field type | Default agg | Default grouper |
|---|---|---|
numeric (u4, u8..u64, f32/f64, decimal128) | AGG_SUM | GROUP_RANGE (interval 10) |
categorical_*, packed_bool | AGG_MODE_COUNT | GROUP_CATEGORY |
date, datetime (truncated) | (explicit only) | GROUP_DATE ("day") |
Never override explicit type or cross categories; tests / filterers / attrs / features / windows never defaulted. Disable via pulse.Options{DisableDefaults: true} / --no-defaults.
Streamability
pulse_predict returns data.streamable: bool + data.streamable_reasons: []string.
- Streams: online aggregators (manifest
streamable: true), ungrouped or under a streamable grouper; row-local attributes; whole-cohort-statistic attributes (z-scores and the like) in two passes. - Buffers: order-statistic aggregators (median, percentile); percentile-rank attributes; groupers with
streamable: false; windows; decimal aggregations; two-pass attributes combined with features or groups; tier-2 post tests.
streamable_reasons is authoritative.
Time zones
time_zone sits on Request and FacetRequest (Compose / Chain: per inner Request; SampleRequest: none). Slot tz sits on groups, filterers, attributes, features and crosstab.rows/columns entries — a slot key, never inside params. Names are UTC or IANA (Europe/Berlin); EST, Local, +05:00 → PULSE_TIMEZONE_UNKNOWN.
Precedence per slot: tz → time_zone → pulse.Options.DefaultTimeZone → UTC. Only manifest zone: "capable" operators take tz; a zone: "following" operator (the year-over-year overlay) inherits its host grouper’s zone; extension operators are never capable.
On a datetime field instants read on their local day (DST-correct). Refused with PROCESSING_CONFIG: tz on a non-capable operator; explicit tz on a date field (even "UTC"); a non-UTC zone on a derived field. An inherited zone on a date field is not applied. UTC (and aliases like Etc/UTC) is byte-identical to no zone.
Predict-specific data fields
streamable, streamable_reasons, defaults_applied, weights / suggested_weight (weighting), resolved_vectors, time_zones (per zone-capable slot: {slot, operator, field_type, tz, source}, source ∈ slot|request|options|default), suggestions (on_invalid="suggest"), per-slot buffered_components (non-mergeable).
Cross-links
response-components (the Response.Components block) · response-shaping (return: trim the response, wire precision) · session-bootstrap (MCP session order) · aggregation-design / grouper-design / attribute-composition (slot shapes) · compose-requests · facet-design · streaming-and-watching (stream chunks, request hashing) · multiplicity-correction.
name: request-templating
description: Stored parameterised JSON requests — the {target, variables, body} wrapper, $var / {{}} / $when substitution, the nine variable types, directory discovery, hot reload, and the PULSE_TEMPLATE_* family. Not expr-lang.
kind: design
type: guide
applies_to: process, compose, sample, facet, predict
covers: [templates, variables, substitution, PULSE_TEMPLATES_DIR]
requires: [capability:templates]
Request templating
Stored JSON that renders into a validated typed request — shape fixed once, caller supplies only variables. Execution-free.
Not expr-lang. $var / {{}} / $when are request-authoring parameters substituted before decode; the formula attribute and expression filter run expr-lang over row fields at execution time (expression-language). A formula string in a body is inert text to the renderer.
File shape
{"name"?, "description"?, "target", "variables"[], "body"}; unknown top-level keys rejected. target ∈ request | composed | chain | facet | sample (lowercase) picks the strict-decode request root — required, never inferred. name derives from the file path; a disagreeing name key is rejected. body is a non-empty object, deliberately not runnable as-is.
Substitution — three forms
| Form | Rule |
|---|---|
{"$var":"bucket"} | Marker iff $var is the only key and its value a non-empty string; replaced whole, type-preserving — {"interval":{"$var":"bucket"}} → {"interval":10}, never "10". {"$var":"x","other":1} is literal data. |
"{{name}}" | Natural text, inside a string value only; {{ name }} works, {{{{ escapes a literal {{. list/period have no text form → _VAR_TYPE. {{ in an object key is rejected. |
{"$when":"segs",…} | Survives iff the variable resolved — presence, not truthiness: drops only when unsupplied AND undefaulted; supplied ""/[]/0/false KEEPS it. Key stripped either way. Guards run before descent, so markers in a dropped block never raise. Array drops compact the slice; root-level $when errors. |
A for_each item’s own axis placeholders pass through to its expansion (item-fan-out).
Variable types (nine)
string, number, integer (1.0 yes, 1.5 no), boolean, field (a string), enum (+values, case-sensitive), list (+items, scalar only — no nesting), date, period.
date: onlyYYYY-MM-DD(03/04/2024is ambiguous).period: exactly one ofrangesortable, mirroring the labeled date-range operators’Paramsshape; unknown keys in it or in a{label,start,end}range are rejected.
Worked example
<root>/finance/revenue.json → finance/revenue:
{"target":"request",
"variables":[{"name":"metric","type":"field","required":true},
{"name":"bucket","type":"integer","default":10},
{"name":"segs","type":"list","items":"string"}],
"body":{"label":"total {{metric}}","cohort":{"filename":"sales.pulse"},
"aggregations":[{"type":"<aggregator>","field":{"$var":"metric"}}],
"groups":[{"type":"<binning grouper>","field":{"$var":"metric"},"interval":{"$var":"bucket"}},
{"$when":"segs","type":"<category grouper>","field":"region","include":{"$var":"segs"}}]}}
<…> = any operator the manifest lists; render never inspects type.
RenderTemplateRequest("finance/revenue", map[string]any{"metric":"revenue"}) →
{"label":"total revenue","cohort":{"filename":"sales.pulse"},
"aggregations":[{"type":"<aggregator>","field":"revenue"}],
"groups":[{"type":"<binning grouper>","field":"revenue","interval":10}]}
bucket defaulted, splicing the number 10; the segs-guarded grouper dropped, the array compacted.
Discovery + lifecycle
Options.TemplateDirs []string, else PULSE_TEMPLATES_DIR split on os.PathListSeparator Name = path relative to its root minus .json, forward-slash separated. Roots are ordered precedence — first root wins; losers land on the winner’s Summary.Shadows, never an error. Filesystem faults are DATA_FILE, outside the PULSE_TEMPLATE_* family.
| Malformed file | Behaviour |
|---|---|
at pulse.New() | hard-fails startup, path named — a boot break is a deploy error |
| after startup, parsed before | serves last-good parse; ListTemplates marks Broken + Error |
| after startup, never parsed | listed to be seen, not fetchable; GetTemplate → _INVALID |
Lookups rescan after 1s (unchanged size+mtime is not re-parsed). ReloadTemplates() forces the walk (a deploy step that writes then renders); it returns nil for one broken file, only whole-walk faults return.
Facade: ListTemplates (non-nil []Summary), GetTemplate, RenderTemplate (all five targets), RenderTemplateRequest (95% path), ReloadTemplates. Rendered.Target picks the populated pointer — compare it to pulse.TemplateTarget*, a variable’s type to pulse.TemplateVar*, never .String(); echo Rendered.JSON — re-marshaling drops explicit zeros from omitempty structs.
Errors — ten codes, by provenance
A bad declared default is an author error → PULSE_TEMPLATE_INVALID (checked semantically at declaration: enum membership, date parse, period XOR); a bad caller value → _VAR_TYPE / _VAR_ENUM. Same split on names: $var/{{}}/$when naming an undeclared variable is an author error caught at validation (_INVALID); a declared but unresolved reference is render-time _UNRESOLVED.
Also _NOT_FOUND, _TARGET_UNKNOWN (absent/unknown target, or RenderTemplateRequest on a non-request template), _VAR_MISSING (required, nothing resolved), _VAR_UNKNOWN (supplied, undeclared — never ignored), _RENDER_INVALID (strict decode failed, incl. a top-level slot this instance does not offer), _AXIS_COLLISION (a variable shares an item axis’s name). Details carry template + variable; pulse errors lookup CODE is authoritative.
What render does NOT do
Render never opens a cohort: it checks the request shape only; field existence, types, operator applicability and streamability stay Predict’s job. Strict decode is harsher than elsewhere in Pulse: a body pasted from an internal/examples/ file with its _meta block attached fails _RENDER_INVALID.
See
request-envelope (body slot map) · expression-language (the other axis: expr-lang over row fields).
name: response-components
description: How Response.Components carries the constituent parts of every aggregation, grouper, filterer, and crosstab cell
kind: design
type: guide
applies_to: process, compose, predict, sample, facet
Response.Components
The constituent parts behind every emitted value. Reach for it when a figure must be checked or re-based: “what n is this mean over?”. This skill is the shared shape; per-operator keys live in each atomic skill’s ## Components and in manifest.components_schemas.
Additive omitempty — a run producing nothing components-shaped emits no components key. Nothing here moves format_version ("1.1").
Universal floor
Filled by the ENGINE, not the operator — every slot carries it, even a floor-only operator.
| Shell | JSON keys |
|---|---|
AggregationComponents | n, n_null |
GrouperComponents | total_n, n_null |
FiltererComponents | n_in, n_out, n_null_input |
crosstab cell (CellComponents[r][c]) | n, n_null |
Weighted slots (crosstab cells and margins too) add sum_weights / n_eff / n_weight_invalid. An aggregator’s n counts NON-NULL inputs (a paired-input slot: of field alone); a grouper’s total_n counts every post-filter record it partitioned and n_null those that took the null / skip path. Operator keys (mean, mode_count, …) ride operator on the aggregation / grouper shells, or the cell map directly.
Sub-blocks
ResponseComponents = aggregations · groupers · crosstab · filterers · run · matrices — every slot omitempty.
| Block | Cardinality · identity | Carries |
|---|---|---|
aggregations | one per Request.Aggregations, declared order · label | floor + operator; grouped: cohort-wide floor + groups (below) |
groupers | one per Request.Groups, declared order · field (+ label when a field repeats) | floor + operator (bucket edges, dictionary mappings, buckets) |
crosstab | only when the Request carried crosstab | cell / margin / axis-key components (below) |
filterers | one per Request.Filterers, declared order · label | floor only |
run | always on a successful run | total_records (pre-filter), filtered_records, null_records, shard_count (0 = single file), partial_cohort_reason (a shard failed to open) |
matrices | one per Response.Matrices result, same order · name | n (weight-0 rows count), n_null, n_listwise_dropped; pairwise min_pair_n / max_pair_n; weighted floor; operator |
- Grouped runs (
Request.Groups, no crosstab): eachaggregations[i]carries the COHORT-WIDE floor (every filter-passing record, nooperator) andgroups[]— one{group_key, n, n_null, weighted floor, operator}perDatarow, inDataorder (sortincluded), equal to an ungrouped run over that bucket. A stream carriesgroups[]on its terminal chunk only. - A fan-out grouper (one record → several buckets, e.g. each option of a multi-select) has a bucket sum EXCEEDING
total_n— correct. runcoexists withResponse.Metadata:Metadata.TotalRows == Run.TotalRecords;Metadatakeeps non-numerical run facts (cohort filename),runthe typed counters.
Crosstab block
Mirrors the matrix coordinate-for-coordinate (cells, row / column margins, grand total, axis keys); CellComponents[r][c] is nil for an empty cell. Indexing: crosstab-guide.
CellCounts[r][c] is a RECORD count; the floor’s n counts NON-NULL observations — CellCounts[r][c] == n + n_null. Never read CellCounts as the aggregator’s sample size (it stays raw under a weight; Σw is sum_weights). Same split on every margin counterpart.
Auxiliary margin-only figures land BESIDE the margin components, and a record reaches an auxiliary margin only if it reached a CELL: crosstab-margin-aggregations.
Opting out
| Knob | Scope |
|---|---|
pulse.Options.DisableComponents bool | engine default for every request the instance runs |
types.Request.DisableComponents *bool | per request — nil inherits, true forces off, false forces ON even on an engine shipping them off |
--no-components | CLI, on pulse api process / process-chain / compose; request JSON "disable_components" wins over it |
Disabled ⇒ Response.Components stays nil (wire form byte-identical to the pre-Components baseline; the work is skipped). Streaming consumers MUST tolerate a nil block. Compose: each requests[i] carries its own override. MCP tools do not surface the knob.
Manifest declaration
manifest.components_schemas holds operator-name-keyed maps — aggregators, groupers, filterers, matrices. Each value is a ComponentSchema: keys (each {name, type, description}) in emission order, plus mergeability. Aggregators and matrices list their floor; empty keys is a valid floor-only operator.
Mergeability
mergeability classifies how an operator’s components fold across streaming chunks and parallel partitions.
| Class | Wire | Meaning for a consumer |
|---|---|---|
Mergeable | "mergeable" | constant-space running state; every chunk renders |
Partial | "partial" | grows (maps / sets); terminal authoritative |
None | "none" | needs the full sorted input; keys only at terminal flush |
Predict flags each None slot with buffered_components: true — check it once when planning a streaming request. Per-chunk behaviour: streaming-and-watching.
Worker counts never change the answer — parallel runs emit the blocks a serial run emits. Examples: pulse_examples_search tags=["welford-triple"].
Extensions
An extension operator declares a ComponentSchema and supplies the keys it emits; pulse.New probes declaration against emission and refuses a mismatch. Recipe and codes: docs/src/internals/extension-points.md.
See
aggregation-design (per-aggregator keys) · grouper-design (grouper floor) · crosstab-guide (crosstab indexing) · crosstab-margin-aggregations (auxiliary margin figures) · streaming-and-watching (per-chunk components) · overlay-system (overlays that read cell components).
name: response-shaping
description: The request `return` block — choose which parts of a response come back (presets, include / exclude paths) and how many significant digits floats carry on the wire; what is projected, what stays exact, and how it composes with streaming and instance defaults.
kind: design
type: guide
applies_to: process, compose, predict, manifest
covers: [return, presets, include, exclude, precision, returned]
Response shaping (return)
return trims a response to what the caller reads, saving tokens. Absent return (or preset: full) the response is byte-identical to before and hashes identically. Predict validates it before any record is read; pulse_predict echoes data.return ({preset, include, exclude, keep, precision?, identity, digest, unresolved_includes?}) and, on every request, data.sizes (per section {section, full_bytes, shaped_bytes, basis}; shaped = full without return).
{"return": {"preset": "standard", "exclude": ["warnings"], "precision": 4}}
Resolution
Order: preset, then include adds, then exclude removes — exclude wins. include with no preset starts from an EMPTY base (an allowlist). exclude / precision alone shape the full response. Top-level warnings, and every nested warnings whose parent is emitted, are kept unless excluded.
Presets (closed enum; the manifest return_presets lists each one’s paths, already narrowed to what this instance offers):
full— the identity.standard— data, warnings, metadata, whole crosstab / overlays, trimmed tests and regressions; nocomponents.minimal— the PRIMARY RESULT only:data,warnings, crosstab matrix, matrix primaries, test statistic and p-value, regression coefficients. Nometadata, nocomponents.
Path grammar
Paths use the Response JSON names at any depth: . between keys, [*] into every array element, a trailing * on a map-key segment as a prefix glob (tests[*].details.effect_*). data[*].<column> selects output columns; a column the request cannot produce is refused.
Errors
PULSE_RETURN_INVALID— unknown preset, precision outside 1–17, malformed path, namingreturned, or a non-overlay path in a Compose-level block.PULSE_RETURN_PATH_UNKNOWN— a path this instance’s Response does not carry (a hidden feature’s path counts).PULSE_RETURN_PATH_UNMATCHED— WARNING, buffered runs only: an include through an open map matched nothing in the executed response. Never raised on streams; predict lists the includes that may go unmatched asunresolved_includes.
Precision
precision is significant digits, wire-only: Go values stay full float64, JSON floats are written at N digits (Go g format, so tiny or huge values use exponent form). Untouched: integers, decimal128 decimal strings, NaN (still null), and exact counts — count-aggregation columns, count crosstab cells and margins, matrices[*].auxiliary.n. Extension aggregators are never count-exempt.
Absent, not null
An excluded part is ABSENT on the wire — never null, even a required key such as metadata.total_rows — and zero / nil on the Go value. A shaped response is a PROJECTION: it is not schema-valid against the full payload schema, so do not validate it against pulse://schema or re-decode it as a full Response. A response whose plan changes something carries returned {preset, digest, precision?} (preset is custom for include / exclude alone); the marker is unexcludable and its digest equals predict’s.
Defaults and precedence
Instance default: Options.DefaultReturn, else the feature profile’s return, else full. A request’s return REPLACES the default entirely (include-only over a standard default gives an empty base) — blocks never merge.
MCP tools default to standard: request return > host pulse mcp --return / DefaultReturn > instance default > standard. Send "return": {"preset": "full"} when you need components or everything.
disable_components is shorthand: alone it only skips computing components (byte-identical, no marker). Combined with any return layer it adds exclude: ["components"]. An engine that disabled components stays disabled under a request return: the components are not computed and the block gains exclude: ["components"]. Only an explicit request disable_components: false re-opens them.
Excluded means not computed
An excluded components part (aggregations incl. groups, groupers, filterers, run) is never computed — the run does less work, not just the wire. An excluded matrices slot is never accumulated (unless components.matrices is kept), and an excluded auxiliary, scalars or vectors is never built. An excluded overlay layer, test or post-test is never computed, unless a multiplicity family claims it; excluded regressions are never fitted. Not computed means not validated: an excluded part raises no refusal or warning (PULSE_OVERLAY_COMPONENTS_REQUIRED, PULSE_TEST_*, PROCESSING_REGRESSION_*) — predict stays the validator, so predict first. A kept overlay that reads components keeps them computed, still pruned from the wire. Kept figures never change, and the execution path is chosen from the full request.
Surfaces
- Process: shapes the one response.
- Compose: each slot’s
return(else the instance default) shapesresponses[i];ComposedRequest.returnshapes top-level overlays only (overlays…paths),responsesstays whole. - Chain: each stage has its own
return, applied AFTER the whole chain, so a stage excludingdatastill feeds the next stage;finalfollows the last stage. - Streaming (CLI
--stream, library iterator): rows carry only selected columns,returnedrides the terminal chunk only, excludingdataemits no rows; noPULSE_RETURN_PATH_UNMATCHED(predictunresolved_includesinstead). MCP has no streaming. Precision is wire-only:StreamChunkvalues stay full float64. Details:streaming-and-watching.
See
request-envelope (slot map) · response-components (what components holds, per-group groups[]) · streaming-and-watching · pulse_manifest return_presets.
name: session-bootstrap
description: Canonical MCP session order — manifest once, then examples → predict → process → errors_lookup. Skill-name derivation from operator names. Use first on every new MCP session.
type: guide
kind: design
applies_to: process, compose, sample, facet, inspect, predict, manifest
covers: [pulse_manifest, pulse_examples_search, pulse_examples_get, pulse_skills_list, pulse_skills_get, pulse_inspect, pulse_predict, pulse_errors_lookup]
Session bootstrap
Canonical MCP call order. Steps 1–2 once, cached; re-enter from step 3 per user question.
| # | Call | Cadence | Returns / effect |
|---|---|---|---|
| 1 | pulse_manifest (intent scopes it to one question kind) | once per session | operator catalogs (each entry’s intents = the question kinds it answers), field types, error codes, MCP tool list, skills index, extensions, capability blocks. Deterministic per binary version |
| 2 | pulse_inspect | once per cohort | schema (fields, types, descriptions, dictionaries). Side-effect: binds schema-aware enums into the field-name arguments of pulse_predict / pulse_process / pulse_compose / pulse_sample / pulse_facet, constraining them to schema-resident values |
| 3 | pulse_examples_search | per question | name + summary, by query + tags + category; or pulse_recommend by intents ID |
| 4 | pulse_examples_get | per candidate | runnable Request JSON (body, _meta stripped). Adapt cohort filename / fields / labels — do not invent |
| 5 | pulse_skills_get | on demand | shape, gotchas, contract; pick via session-skill-routing. Runnable JSON comes from examples, NOT skills |
| 6 | pulse_predict (bare request, or composed, or facet, or chain) | until clean | errors, warnings, data.suggestions, data.defaults_applied, data.streamable, data.streamable_reasons |
| 7 | the execute tool the manifest’s mcp_tools lists for the operation — pulse_process / pulse_compose / pulse_process_chain / pulse_facet / pulse_facet_schema / pulse_sample / pulse_lookup | execute | {format_version, data, errors, warnings} + additive data.components; format_version is "1.1"; read via pulse_explain |
| 8 | pulse_errors_lookup | per unique code | canonical message + structured fixups[]. Authoritative — never paraphrase from memory |
Step 7 Compose return shape. pulse_compose’s data is a ComposedResponse {responses[], overlays[]} — NOT the legacy []*Response. Per-slot Components ride each responses[i]; overlay diagnostics ride overlays[i].warnings.
Which skill answers what
| Question | Skill |
|---|---|
Which skill to fetch for an operator prefix, manifest entry, request / response slot or CLI leaf; reading data.components on first sight | session-skill-routing |
Excel / SPSS read flags, parent groups on the managed import path, .sav write flags | session-format-flags |
pulse profile create and pulse synth from-profile flags | session-synth-flags |
Authoring layers
Manifest = the contract. pulse_inspect = field names + values. pulse_examples_* = runnable JSON. Skills = gotchas, slot-key naming, rationale. pulse_errors_lookup = per-code prose. Prompts:
pulse-bootstrap: this order.pulse-author-request: question → intent prompt, tool or request.pulse-<intent>(per analytic intent): inspect, recommend, confirm, process, explain.
On failure
Read every errors[] / warnings[] entry ({code, message, details}) → pulse_errors_lookup each unique code (cache) → apply fixups[] → re-pulse_predict → re-execute only when predict is clean.
Environment
Roots auto-loaded at pulse.New:
PULSE_LABEL_TABLES_DIR— output-time label tables. Give it its own directory. Every*.jsonbeneath it is parsed as a label table and an unparseable file hard-failspulse.New; Pulse’s own.spss.json/.meta.jsonsidecars are excluded by suffix, nothing else is.PULSE_RANGE_TABLES_DIR— named labeled-date-range tables ({label,start,end}sets referenced by the date-range grouper and filter). Same sidecar exclusion.PULSE_TEMPLATES_DIR— parameterised request templates;os.PathListSeparator-separated roots in precedence order, first root wins. Render viaRenderTemplate/RenderTemplateRequest, then predict the rendered request. Seerequest-templating.
Managed imports (PULSE_IMPORTS_DIR) persist each handle’s read settings in a sidecar (format, source_path, ttl_seconds, column_type_overrides, source_tz, column_source_tz, dst_policy); a handle read in one zone is never reused for another — re-import with overwrite: true. See time-zones.
Server-side (pulse mcp / an embedder’s mcpserve): PULSE_MCP_NO_COHORT_SCAN (flag --no-cohort-scan) suppresses the startup walk that enumerates pulse://<path> resources. On such a server resources/list names no cohorts and that is not evidence there are none — a resources/read pulse://<path> and pulse_inspect both still resolve, so ask the user for the cohort path instead of concluding the data root is empty. Tool responses default to the standard preset (no components; host flag --return); send "return": {"preset": "full"} for everything — see response-shaping. The host caps resources (flag --limit name=value); read the effective caps from manifest.limits (-1 = none), plan within them, and re-fetch the manifest when limits_digest changes — a breach is PULSE_LIMIT_EXCEEDED. Host diagnostics (--log-level, --metrics-addr) never reach tool responses.
Both table kinds surface under manifest.extensions.{label_tables,range_tables} — check there before assuming a named table exists. Templates are NOT manifest-projected; enumerate with ListTemplates (Summary.Broken flags a file that has gone malformed since load).
Cross-links
request-envelope (envelope shape, slot keys, smart defaults, streamability) · synthetic-data (synth modes, multi-predictor models, correlations, determinism) · synth-structural-rules (rules[] / constraints[]: gating, masking, derived fields) · response-components · response-shaping (return: trim a response, cap float digits) · tool-* (one atomic skill per MCP tool, full argument shape) · docs/src/internals/debugging-predict.md · docs/src/getting-started/ (cold-start fallback) · spss-cohorts.
name: session-format-flags
description: Per-format CLI flags an agent must know — source-side read knobs (Excel sheet, SPSS missing-value mode and charset), parent-group declaration on the managed import path, the import source zone, the export render zone, and the four .sav write knobs with their refusal defaults.
type: guide
kind: design
applies_to: process, compose, sample, facet, inspect, predict, manifest
covers: [CLI flags, --ignore-extra, --float, --sheet, --spss-missing, --charset, --group, --source-tz, --dst-policy, --tz, --ignore-sidecar, --uncompressed, --sanitize-names]
Format CLI flags
Part of session-bootstrap. Every leaf and flag: docs/src/cli/flags.md.
Source-format CLI flags
Per-format READ knobs the file cannot always answer itself; all ride io.ReaderOptions (one sub-struct per format) and every other format ignores them. Full SPSS model: spss-cohorts, docs/src/cli/import-spss.md.
| Flag | Format | Leaves | Must know |
|---|---|---|---|
--sheet | Excel | pulse import excel, import predict, import schema-template, pulse import auto; pulse_import as sheet | — |
--spss-missing (auto | null, default auto) | SPSS .sav / .zsav | pulse import spss, import predict, import schema-template, convert, convert predict | auto nulls each numeric user-missing value (so sums and means never meet a refusal code) AND adds a <var>_missing sibling carrying WHY (sysmis, the value label, or the code); null = same nulls, reason gone. A .sav import can therefore yield MORE columns than the file has variables — count from ReadHeader / the returned schema, never the SPSS variable count. Bad value ⇒ PULSE_SPSS_MISSING_MODE_INVALID, never a silent default. Deliberately NOT on pulse import auto or the MCP import tool |
--charset | SPSS .sav / .zsav | the --spss-missing leaves plus pulse import auto and the MCP import tool (as charset) | overrides the encoding the file declares about itself; decoding only. Reach for it on PULSE_SPSS_CHARSET_INVALID / PULSE_SPSS_CHARSET_UNSUPPORTED |
Parent groups on the managed path. pulse import auto --group KEY[,KEY]:MEMBER[,MEMBER] (repeatable) and pulse_import groups: [{key, members}] declare them; suggest_groups returns measured group_candidates whose key/members paste straight back. Default ratio floor 2; findings are warnings (group_warnings / envelope warnings), never failures. No floor, strict or elision knob there — pulse import <fmt> carries those. Detail: tool-import, cohort-parent-groups.
Source zone (import <fmt>, import auto, import predict, convert, convert predict; MCP source_tz). Naive datetimes import as UTC unless --source-tz Zone (IANA, UTC or ±HH:MM; repeatable col=Zone wins per column; date columns skipped, col= on one is CLI_INPUT) says where they were recorded. Offset/Z literals keep their instant. DST gap/overlap is FATAL (PULSE_IMPORT_DST_AMBIGUOUS / _NONEXISTENT, naming the row) unless --dst-policy earlier|later, which warns PULSE_IMPORT_DST_RESOLVED with counts. import auto persists the zone on the managed sidecar; convert writes zoned naive values as UTC …Z literals.
Explicit schema (import <fmt>, import predict, convert with --schema). Fields match source columns by exact NAME, in any order; a field with no column, a column no field names, a repeated name, a late NDJSON / JSON-array key or an unknown type (never read as f64) is a coded refusal (PULSE_IMPORT_SCHEMA_*) before any row. --ignore-extra drops unnamed columns and late keys instead. Detail: tool-import.
Float width (same leaves + import schema-template). Inferred floats are f64; --float f32 halves the bytes but rounds to ~7 digits.
Export zone (export <fmt>, export predict; io.ExportJob.TimeZone). --tz Zone (IANA or UTC only, else PULSE_TIMEZONE_UNKNOWN) renders datetime cells as local literals with offset — 2026-03-29T08:00:00+05:30 — that re-import to the same instant; date never moves. Absent / UTC-equivalent is byte-identical …Z. Arrow/Parquet carry it as UTF8 strings.
Target-format CLI flags
Four .sav WRITE knobs, one per io.SPSSWriterOptions field (io.WriterOptions.SPSS). All on pulse export spss; --ignore-sidecar, --uncompressed, --sanitize-names also on convert / convert predict (convert’s --charset is the SOURCE charset, so the write charset is export-only). Full model: spss-export, docs/src/cli/export-spss.md.
| Flag | Must know |
|---|---|
--ignore-sidecar | synthesise the dictionary from the .pulse schema alone. Suppresses the sidecar read, not just the staleness verdict — a healthy sidecar is ignored too (PULSE_SPSS_SIDECAR_IGNORED, which cannot say which refusal it silenced). Cannot round-trip a cohort still carrying a derived MD set_* column ⇒ PULSE_SPSS_NAME_COLLISION; export without it |
--uncompressed | flat 8-byte elements instead of SPSS bytecode; losslessly equivalent. Does not select ZSAV — emitting that is PULSE_SPSS_COMPRESSION_UNSUPPORTED |
--charset (export leaf only) | charset written AND declared. Default: the source’s own declared spelling; UTF-8 with no SPSS provenance. Set it when the cohort holds text that codepage cannot express (else PULSE_SPSS_CHARSET_UNENCODABLE) |
--sanitize-names | rewrite names a .sav cannot carry (space, bracket, hyphen, leading digit) instead of refusing. Refusal is the default on purpose; this is the opt-in for the synthesised path. Deterministic, collision-safe, every rename reported as PULSE_SPSS_NAME_SANITIZED (full field → name list). Inert on the sidecar path |
pulse export spss refuses --include, --labels and a non-UTC --tz rather than ignoring them (PULSE_SPSS_EXPORT_UNSUPPORTED): the writer encodes from raw cohort storage, not the rendered row stream those transform (a .sav DATETIME has no offset slot). Narrow or relabel into a cohort first.
name: session-skill-routing
description: Which skill to fetch for a given trigger — operator family prefixes, manifest catalog entries, request and response slots, request roots, CLI leaves and plain-language questions — plus how to read data.components on first sight.
type: guide
kind: design
applies_to: process, compose, sample, facet, inspect, predict, manifest
covers: [skill-name derivation, skills, routing, components]
Skill routing
Step 5 of session-bootstrap (pulse_skills_get on demand). Skills carry no runnable JSON; examples do.
Skill-name derivation
Lowercase the operator family prefix and map through this table.
| Trigger | Skill to fetch |
|---|---|
AGG_* | aggregation-design |
ATTR_* | attribute-composition |
FILTER_* | aggregation-design |
GROUP_* | grouper-design |
WIN_* | window-design |
FEAT_* | feature-engineering |
TEST_* (tier-1 or tier-2) | statistical-testing |
REG_* | regression-modeling |
OVERLAY_* | overlay-system |
synth_distributions[i].kind | synthetic-data |
cohort_types[i].name (field type) | cohort-schema-design |
a .sav / .zsav source, or a cohort carrying a .spss.json sidecar | spss-cohorts |
mcp_tools[i].name | tool-<name minus pulse_> — one atomic skill per tool |
pulse_lookup / pulse index build / pulse index list / pulse index verify / pulse index drop / pulse api lookup | tool-lookup (MCP surface), cohort-sidecar-index (sidecar format) |
a join-shaped cohort already on disk — pulse_dedup (suggest_groups alone is read-only; groups + out converts without touching the original) / pulse dedup COHORT --group KEY:MEMBER,… [--out P] [--suggest-groups] | tool-dedup, cohort-parent-groups |
a window, feature or attribute column a later request must regress, group, filter or join on — pulse_materialize / pulse api materialize -r REQ -o OUT [--overwrite] writes it as a field of a new cohort | tool-materialize, derived-cohorts |
a set column out of option headroom — pulse widen COHORT --field F --to set_u128|set_u256 rewrites it in place (single-file cohorts only; no MCP tool) | cohort-schema-design (set rungs), cohort-sharding (archives auto-widen) |
| a plain-language question with no operator in mind | intents (pulse-skill://intents) — question kinds, then the manifest entries whose intents match |
a question kind known, request not — pulse_recommend / pulse recommend --intent ID [--cohort C --field F …] returns ranked drafts (bound to the cohort’s fields and predict-checked when given one) | tool-recommend |
what a request will do, or what a result found, in plain words — pulse_explain / pulse explain --request F | --response F [--request F] returns findings with verdicts and caveats | tool-explain |
| a statistical term in a result or skill | glossary (pulse-skill://glossary) |
error_codes[i] | pulse_errors_lookup — the tool is the surface, not a skill |
Request slot Joins | join-design |
Request slot Crosstab | crosstab-guide |
Request slot Overlays | overlay-system |
Request / per-slot weight, Options.DefaultWeight, predict weights, suggested_weight, PULSE_WEIGHT_* | weighting |
Response slot data.components (first sight) | response-components |
Response slot Metadata.LabelBindings | label-display |
ComposedRequest | compose-requests |
ChainRequest | process-chain |
FacetRequest / FacetSchemaRequest | facet-design |
| streaming, watch, request hashing | streaming-and-watching |
extension operator (anything in manifest.extensions) | docs/src/internals/extension-points.md |
| predict failed — fix loop | docs/src/internals/debugging-predict.md |
Components on first sight
Response.Components / data.components first sight. Fetch response-components — canonical for the per-family shape (aggregations[], groupers[], crosstab, filterers[], run), the orchestrator-filled universal floor ({n, n_null}, or {total_n, n_null} / {n_in, n_out, n_null_input} by family — NOT enumerated in manifest.components_schemas[*].keys), and the per-operator keys inside Operator map[string]any (or the cell map for crosstab cells), looked up under manifest.components_schemas.aggregators[name].keys / .groupers[name].keys / .filterers[name].keys. Additive-only; never bumps format_version. Never infer the shape from memory — it is bound to the binary version.
name: session-synth-flags
description: The profile-capture and synth-generation CLI flags an agent must know — the five additive profile create sections, emitting the generating spec, supplying and suggesting structural rules — and why a model coefficient is a latent-scale quantity.
type: guide
kind: design
applies_to: process, compose, sample, facet, inspect, predict, manifest
covers: [CLI flags, profile create, synth from-profile, --fit-models, --emit-spec, --rules, --suggest-rules]
requires: [capability:synth]
Synth CLI flags
Part of session-bootstrap. The synth surface: synthetic-data.
Profile-capture CLI flags
Five independent, additive pulse profile create knobs; each adds an omitempty section, none implies another, all omitted reproduces the pre-flag document byte-for-byte. The leaf profiles a single file or a whole shard archive. Detail: synthetic-data, docs/src/cli/profile-create.md.
| Flag | Adds |
|---|---|
--conditional | pick-one conditional pair sections (numeric-numeric, categorical-categorical, categorical-numeric, three set_* arms) |
--fit-shape | 2-component Gaussian mixture per numeric on a BIC win; generates as mixture, not normal |
--fit-models | one linear model per numeric, regressed on admitted categorical levels + set options. How several drivers condition ONE numeric at once. RETIRES the numeric-target conditional pairs for the targets it lands on — per target, never per document; the three non-numeric arms are untouched, so --conditional + --fit-models keeps both halves |
--residual-correlations (needs --fit-models) | full correlation submatrix among fitted residuals, so a numeric can be both conditioned and correlated with a sibling |
--run-continuation | run_continuation: per-field fraction of adjacent row pairs whose bytes + null bit repeat — exactly the run-skip decode’s hit rate — plus overall, high_fields (≥0.75) and advice. Low overall (<0.5) ⇒ sort the source by its parent key upstream. Pairs never span a shard. Not read by synth |
Synth-generation CLI flags
Two additive pulse synth from-profile knobs (both absent reproduces pre-flag output byte-for-byte) plus one on pulse profile create. Detail: synth-structural-rules, docs/src/cli/synth-from-profile.md, docs/src/cli/profile-create.md.
| Flag | Must know |
|---|---|
--emit-spec <path> | the spec that actually generated (AFTER any --rules merge), indented JSON, not a rendering — fed to synth from-schema at the same seed it reproduces the same rows. The only way to see which captured models survived translation, which distribution each field reconstructed to, and which conditional pairs were retired, and how you learn the field names, types and floors a rule must be written against. Written before generation, so a failing run still leaves it |
--rules <path> | the only way to reach the rules[] layer from the profile path. A bare JSON array of rule objects — the rules key’s own value, so a rule moves between spec and file by cut and paste; {"rules": […]} is refused, not read as zero rules. REPLACES Spec.Rules (a derived spec carries none). Eager validation naming the FILE — e.g. PULSE_SYNTH_RULE_FIELD_UNKNOWN with details.path, never a bare parse error |
--suggest-rules <path> (on pulse profile create) | DETECTS rules on the same scan (no extra cohort read) and writes the bare array --rules consumes unmodified: GATING (set_null), CO-MISSING blocks (null_together, admitted on identical null PATTERN — an identical null RATE is never enough) and EXACT DEPENDENCIES (set_expr; packed_bool/u4 targets, categorical_*/packed_bool/u4 source of ≤16 levels, band edges DISCOVERED from the data). Near misses, almost-determined pairs, always-null and constant columns land in warnings, never proposed. Gating first, dependency LAST — declaration order is applied order. PROPOSED, never applied: detection finds the STATISTICAL gate, a human knows the SEMANTIC one. Measurements ride _evidence, a rule’s one INERT slot (an evidence-only rule is still PULSE_SYNTH_RULE_EMPTY). The profile document gains no section. Numeric gates emit through round() — a bare == reads the pre-rounding float and under-fires |
A model coefficient is a LATENT-scale quantity, not data units: it shifts the standard-normal μ in value = Q(Φ(μ + σ·z)), so it is non-linear in value space for every non-normal Q. Never report one as “this many points on the scale”. Detail: synth-models.
name: spss-cohorts
description: Entry point for SPSS .sav / .zsav cohorts — the three surprises (extra derived columns, codes-not-labels dictionaries, the label-tables directory trap), why dictionaries hold codes, and which focused SPSS skill answers each import, missing-value, sidecar and .sav-writing question. Read this when a cohort came from SPSS or when emitting a .sav.
type: guide
kind: design
applies_to: inspect, predict, process, compose, sample, facet
covers: [SPSS, sav, zsav, import, export, derived columns, user-missing values, multiple-response sets, metadata sidecar]
requires: [io_format:spss]
SPSS cohorts
The one import source whose schema Pulse does not infer, and the one producing a cohort wider than its source dictionary. Read and write belong together — the write half is defined by the read half’s derived columns, sidecar and charset. General .pulse schema surface: cohort-schema-design.
Three things that surprise people first
- Columns the
.savnever declared —<var>_missingsiblings, oneset_*per multiple-dichotomy question. Count columns frompulse_inspect/ReadHeader, never from the SPSS variable count. - Categorical columns hold codes —
"1"/"2", notMale/Female. - Never point
PULSE_LABEL_TABLES_DIRat a cohort directory — it parses every.jsonbeneath it.
SPSS import — what it changes about a .pulse schema
- The schema is not inferred. An SPSS dictionary DECLARES every column, so
internal/io/spssimplementsio.SchemaAwareReaderandinternal/io/infer.go’s sample-and-vote pass is skipped:SampleRows,SetInferenceMinPct,SetDelimitersare inert,ColumnTypeOverridesis refused (PULSE_IMPORT_OVERRIDE_INVALID), and there is no null promotion — declared nullability is a contract, so an unexpected null isPULSE_IMPORT_ROW_ERROR. An explicitImportJob.Schemastill wins. - The cohort can be wider than the source. Two derived kinds: a
<var>_missingcategorical_*sibling per numeric variable declaring user-missing values (the null bitmap is one bit and cannot say why), and oneset_*column per multiple-dichotomy response set, emitted beside its constituents.--spss-missing=nullsuppresses the siblings; theset_*column has no opt-out.
Writable. An import also writes cohort.pulse.spss.json, the JSON metadata sidecar holding what the .pulse header cannot: value labels, measure levels, missing-value specs, response-set definitions, and which columns were derived. pulse export spss reproduces that dictionary and folds the derived columns away at every rung (the fold is keyed on the recorded kind, so a 206-constituent set_u256 drops and rebuilds its constituents exactly as a set_u8 does); a cohort that never came from SPSS exports on a synthesised one (PULSE_SPSS_SIDECAR_ABSENT, a warning), a stale sidecar is an error, and --include / --labels are refused rather than ignored because the writer encodes from raw storage. pulse convert x.sav out.sav builds no cohort on disk, so it carries the source’s declared schema AND its sidecar into the writer’s in-memory intermediate (pio.ConvertSource) — without them the rebuilt cohort is re-inferred from rendered text, which loses a set_* rung down to the options actually ticked and made every multiple-dichotomy convert refuse with PULSE_SPSS_NAME_COLLISION.
Which skill answers what
| Question | Skill |
|---|---|
| How does each SPSS type map; why no inference; bytecode / ZSAV / charset / byte order; which damage is fatal? | spss-import-schema |
Where do refused / don't know codes go; the <var>_missing sibling; --spss-missing; filtering on a missing code | spss-missing-values |
Multiple-dichotomy vs multiple-category sets; the derived registry and its closed kind vocabulary | spss-response-sets |
What cohort.pulse.spss.json holds; absent vs stale vs invalid vs ignored | spss-metadata-sidecar |
Writing .sav: the writer, the four flags, convert, export predict | spss-export |
Variable-name policy, set_* masks, nulls, codes vs positions, byte order on the way out | spss-export-values |
| What the round-trip claim rests on; the write-side charset rules | spss-export-fidelity |
Dictionaries hold codes, not labels
A labelled variable’s categorical_* dictionary holds "1", "2", … — source codes in source order, because entry order IS the on-wire encoding. Two SPSS codes may legitimately share one value label, so a label-keyed dictionary collapses them and destroys the code. Labels arrive at output time via a LabelTable (label-display), never from the cohort; the code ↔ label triple lives in the metadata sidecar (spss-metadata-sidecar). Text that is a null sentinel ("", NA, N/A, NULL) imports as null + PULSE_SPSS_NULL_TOKEN_COLLISION.
PULSE_LABEL_TABLES_DIR skips our sidecar. It parses every *.json there as a label table, excluding Pulse’s own sidecars by suffix FIRST — .spss.json i.e. cohort.pulse.spss.json (the .spss.json suffix) and the managed-import cohort.pulse.meta.json — so a skipped sidecar registers no table and no longer fails pulse.New. Exclusion by name, not tolerance: any other unparseable *.json still hard-fails naming its path.
Cross-links
cohort-schema-design—.pulsefield-type matrix, nullability bitmap, sidecar index.label-display— resolving SPSS value labels from the codes the cohort stores.tool-import— thepulse_importMCP surface and its SPSS caveats.session-bootstrap—--spss-missing/--charsetflag coverage per CLI leaf.docs/src/cli/import-spss.md— exhaustive user-facing READ reference (worked examples, byte-level detail, R cross-checks).docs/src/cli/export-spss.md— user-facing WRITE reference (the four flags, sidecar verdicts, derived fold, write-side diagnostic table).
name: spss-export
description: Writing .sav — the dictionary + data-section writer, bytecode compression, the four write flags, why the writer takes the whole cohort rather than rendered rows, how a .sav-to-.sav convert carries the source through, and asking export predict first.
type: guide
kind: design
applies_to: inspect, predict, process, compose, sample, facet
covers: [SPSS, sav, export, convert, export predict, CohortWriter]
requires: [io_format:spss]
SPSS export
Part of the SPSS surface; entry skill spss-cohorts. Names, nulls and value rules on the way out: spss-export-values; fidelity evidence and charset: spss-export-fidelity.
Writing .sav — pulse export spss
BuildDictionary(DictionaryRequest{Schema, Sidecar, Cases, Compression, Options}) (internal/io/spss) emits the dictionary section — header, record 2 variable records, records 3/4 value labels, the 7/* subtypes, the 999 terminator — returning a DictionaryPlan. NewDataEncoder(plan, schema) writes the data section (WriteCohort(r) drains a .pulse record stream, WriteCase takes one record, Finish() returns bytes). File = plan.Bytes + those.
- Bytecode compression is the default (what SPSS’s own SAVE writes).
WriterOptions{Uncompressed: true}(.Compression()resolves the header flag) writes flat 8-byte elements; losslessly equivalent, so the knob trades size for a readable hex dump. - ZSAV emission is not implemented —
PULSE_SPSS_COMPRESSION_UNSUPPORTED. Cases: -1when the count is unknown up front:Finishpatches it viaDictionaryPlan.SetCaseCount, which writes the header int32 and record7/16together — two disagreeing counts are a file no reader can adjudicate. A-1plan emits no7/16, carrying the header count alone.
The CLI surface, and the one contract mismatch behind it
pulse export spss -i cohort.pulse -o out.sav; pulse convert data.csv out.sav reaches the same writer. Four flags, one per io.SPSSWriterOptions field: --ignore-sidecar, --uncompressed, --charset, --sanitize-names. Per-flag detail: session-bootstrap, docs/src/cli/export-spss.md.
The writer is a pio.CohortWriter, not a row writer. A .sav value derives from a categorical’s dictionary ID, a set_*’s mask bits and the null bitmap — all three gone once ExportJob rendered a row (a categorical resolves to label text and two codes may share one label; a null renders "", which a string categorical can legitimately hold). So ExportJob.Run hands over the cohort path and skips its row loop; WriteRow is never called. Hence:
--include/--labels/ non-UTC--tzrefused withPULSE_SPSS_EXPORT_UNSUPPORTED, not silently ignored — project or relabel into a narrowed cohort, then export that; a.savDATETIME has no offset slot, so it stays UTC.- Overlays warn-and-skip, like CSV.
- A
converthas no cohort: the writer buffers rows, builds an intermediate in-memory cohort through the ordinary import path, exports that. From a TEXT source that cohort is inferred and sidecar-less, and it says so (PULSE_SPSS_SIDECAR_ABSENT, namingconverted-rows.pulse).
pulse convert survey.sav out.sav carries the source through — the pio.SourceAwareWriter / pio.ConvertSource channel, SetConvertSource before WriteHeader. The rebuilt cohort takes the source’s declared schema instead of inferring one, and the source’s sidecar is written against it (the source Reader’s own WriteSidecar, which re-fingerprints — the source’s copy describes the source cohort and would read back PULSE_SPSS_SIDECAR_STALE). Without both, a .sav → .sav convert was strictly worse than import-then-export: the derived MD column was unrecognisable as derived, so synthesis minted indicator variables colliding with the real constituents (PULSE_SPSS_NAME_COLLISION — every multiple-dichotomy cohort, at every width), a set_* rung re-inferred only as wide as the options somebody ticked, and a value-labelled numeric came back a STRING of bare numerals. Carried only when the emitted rows are faithful to the schema — one cell per field, in field order, so --include or a label binding carries nothing and the target infers as before. A text source declares nothing, so inference stays its fallback.
Ask first — pulse export predict --format spss. The .sav writer is the first Pulse writer that can REFUSE, so predict consults the target through the optional pio.CohortValidator contract (the SPSS writer’s ValidateCohort): it runs the writer’s own non-data pass (sidecar resolution, dictionary build, name policy, charset transcode, derived fold) and discards it, so a predicted refusal is the export’s own check code for code — PULSE_SPSS_SIDECAR_ABSENT predicts as a warning on PredictReport.TargetWarnings exactly as it exports as one. Pass the flags you will export with; --sanitize-names flips a PULSE_SPSS_NAME_INVALID refusal into a warning. Sound but INCOMPLETE: anything needing a record — a value past a declared width, an unformable character, a dictionary ID with no source code — is unreachable without the data pass, so a pass means no SCHEMA-level refusal was found, never that the export cannot fail. Writes no file; no --format ⇒ target-blind as before.
name: spss-export-fidelity
description: How far the .sav round-trip claim is proven — what CI gates, what was checked only locally against independent readers, what nothing corroborates — and the write-side charset rules (source charset kept, no replacement characters, no silent truncation, encode-measure-segment).
type: guide
kind: design
applies_to: inspect, predict, process, compose, sample, facet
covers: [SPSS, sav, export, round trip, fidelity, charset, --charset]
requires: [io_format:spss]
SPSS export — fidelity and charset
Part of the SPSS surface; entry skill spss-cohorts. The writer and its CLI surface: spss-export.
What the fidelity claim rests on
Fidelity is this adapter’s whole justification, so be precise about PROVEN vs. PSPP-specification-only.
- Gated in CI.
TestRoundTrip_*runs import → export → import over a matrix covering all three source encodings, both MR flavours, all three missing-spec shapes, a non-UTF-8 charset, very long strings, both endiannesses. Asserts: re-imported.pulsebyte-identical to the first (cohort identity —.savbyte-identity is unreachable for the reasons inspss-export-values); emitted.sava fixed point under a second export; exactly the source’s own variables declared.TestRoundTrip_MatrixCoversFR62fails if an axis loses its last fixture. - Local only, not CI.
internal/io/spss/dict_ecosystem_test.go(incl.TestRoundTrippedFile_ReadsIdenticallyInReadStatAndForeign) hands emitted and cycled files to haven 2.5.5 (ReadStat — the C reader behind haven, pyreadstat and most of what opens a.sav) and to independentforeign0.8.91, requiring each reader’s cycled read to match its own source read. Both pass — but theyt.Skipwithout R / haven / foreign, which is CI’s state. A recorded result, not a gate. - Corroborated by no independent reader. MR set subtypes
7/5/7/19— neither haven norforeignexposes MR metadata, so nothing outside Pulse has read our set definitions back; the round trip proves only that Pulse reads what Pulse writes. Same for the two-int32form of record7/11: Pulse READS two- and three-int32shapes, always WRITES three, so that branch rests on synthetic fixtures. - Not implemented. ZSAV emission — read-only, no partial or degraded path.
Charset on the way out — the same two hard rules, mirrored
Written in the charset its source declared, in the source’s own spelling. A cp1252 source goes out as cp1252 bytes under a cp1252 record 7/20, and 7/3 re-emits the source’s own character code — including a stale one, because the disagreement is real information. No sidecar ⇒ UTF-8. UTF-8 under a windows-1252 header would corrupt every non-ASCII label: the declaration follows the bytes, the bytes follow the source.
Never a replacement character. An unformable character ⇒ PULSE_SPSS_CHARSET_UNENCODABLE naming variable, value, code point — never ?, 0x1A or U+FFFD. Usual cause: a cohort edited since import, since text a Pulse operation produced is UTF-8. Every encode is decoded back and compared, so a character that encodes but returns as a different one (GB18030 does this across the Private Use Area) is refused too.
Never a silent truncation. SPSS widths are BYTE counts, so transcoding moves them (Zürich = 6 bytes windows-1252, 7 UTF-8). Widths recompute from the ENCODED bytes and a source-recorded width only ever widens — SPSS pads, the read path trims, so widening loses nothing while narrowing changes a declaration the source made. Where the format fixes the width: PULSE_SPSS_WIDTH_OVERFLOW (string past 32767 bytes, 8-byte short name, 255-byte value label, 64-byte file label, 80-byte document line).
Order is the crux: encode, measure, segment. A >255-byte string re-segments per 7/14 on a fixed 252-byte stride, so a multi-byte character can straddle a boundary — the reader joins pieces before decoding, exactly as the writer encodes the whole value before slicing. Segmenting the UTF-8 form first would put a partial character on the wire.
--charset / io.SPSSWriterOptions{Charset} overrides the target — the answer to a cohort whose text outgrew its source codepage. io.SPSSReaderOptions{Charset} is read-side decoding only, not consulted here. An unwritable name ⇒ PULSE_SPSS_CHARSET_UNSUPPORTED, never a silent fall back. Records 7/10, 7/17, 7/18 pass through verbatim (the reader never decodes them) — the one case where overriding the target is lossy, so a non-ASCII payload rides PULSE_SPSS_CHARSET_MISMATCH as a warning rather than being guessed at.
name: spss-export-values
description: What a .sav export writes for each value — the variable-name policy and --sanitize-names, set_* masks at every width, the missing state each null takes, original codes versus dictionary positions, byte order, and labels that live only on user-missing codes.
type: guide
kind: design
applies_to: inspect, predict, process, compose, sample, facet
covers: [SPSS, sav, export, variable names, --sanitize-names, nulls, set fields, value labels]
requires: [io_format:spss]
SPSS export — names, nulls and values
Part of the SPSS surface; entry skill spss-cohorts. The writer and its CLI surface: spss-export.
Writing .sav — names, nulls and dictionary rules
Names are policed and the default is a refusal. A .pulse field name is any UTF-8 string; an SPSS variable name is ≤64 bytes, opens with a letter, unique ignoring case. All three ways an illegal name fails are quiet — records 7/13, 7/7 and the case-fold rule each produce a well-formed file saying something else — so an offender is PULSE_SPSS_NAME_INVALID / PULSE_SPSS_NAME_COLLISION. --sanitize-names is the opt-in escape hatch for the synthesised path, where a CSV header’s spaces and brackets are ordinary: deterministic, collision-safe against other renames and against already-legal names (which never move), every rename reported as PULSE_SPSS_NAME_SANITIZED with the full field → name list. Inert on the sidecar path — those names came from SPSS.
--ignore-sidecar cannot round-trip a cohort still carrying a derived MD set_* column. Its dictionary entries are its constituents’ field names, so with the registry suppressed, synthesis mints indicator variables Q1A/Q1B beside the real Q1A/Q1B → PULSE_SPSS_NAME_COLLISION. Export without the flag so the registry folds the column away.
The mask is width-blind on the way out, and the bit bound is a PLAN-time check. The encoder carries a set value as an encoding.SetMask at every rung, not a uint64: the wide rungs (set_u128/set_u256) are read through encoding.ReadSetMask because ReadFieldValue refuses them outright, so a 206-option battery exports its bits above 63 instead of failing the whole file. Two consequences worth stating. A member variable’s bit is bounded against its own rung inside NewDataEncoder’s column check, not per case — a per-case bound is a refusal ValidateCohort cannot see, so export predict would pass a cohort the export fails on record 1 (a set_u8 declaring ten dictionary entries has no honest .sav form and says so before a record is read). And folding a derived MD column away is decided by Derived.Kind, never by width: a 206-constituent set_u256 is dropped and its 206 constituents rebuilt exactly as a 3-constituent set_u8 is.
Nulls take the missing state the SPSS type has: numeric → sysmis sentinel; string → blanks (no string sentinel exists, and blank reads back as null); every member of a null set_* → sysmis, keeping null apart from an empty mask on the way back. No honest form ⇒ PULSE_SPSS_EXPORT_UNSUPPORTED naming the variable, never a quiet substitution.
Two dictionary rules. Original SPSS codes, never dictionary positions — the sidecar triple supplies them, and IF q1 EQ 5 addresses a value, so renumbering re-points every reference. No sidecar ⇒ nothing invented: a categorical becomes a STRING variable holding the dictionary text; a set_* expands to one indicator variable per entry (named for that entry, so the mask round-trips) plus a 7/7 dichotomy definition; CategoryCode.Known stays false so the plan says which it is.
Two things deliberately do NOT reproduce the source: byte order is always little-endian (7/3 agrees), and prod_name identifies pulse. Easily-missed corollary — a NUMERIC missing-value slot is a flt64, so the sidecar’s verbatim slots are byte-reversed when the source was big-endian; re-emitted as read they declare eight bytes decoding here as an unrelated subnormal, and the variable silently stops declaring anything missing. A string slot is characters, never reversed. Records 7/21/7/22 carry each variable’s FINAL name (ReadStat refuses a file spelling the short name there). MOYR/QYR/WKYR keep both raw seconds and format code.
Value labels declared only on user-missing codes come back from the derived registry, not the categories. An income column labelled at 97/98 and nowhere else is not coded, so import maps it to f64 and moves those labels into the sibling’s Derived.Reasons — the only place they survive. Write re-emits them as ordinary records 3/4; a categories-only export would return the code while losing what it MEANT, and the re-imported reason column would read as bare numerals.
name: spss-import-schema
description: How a .sav / .zsav file imports — the declared dictionary replaces inference, the SPSS-to-Pulse type mapping, the three data encodings, charset and byte-order rules, and the fatal-vs-warning damage diagnostics. Read this when an SPSS import types a column unexpectedly or refuses a file.
type: guide
kind: design
applies_to: inspect, predict, process, compose, sample, facet
covers: [SPSS, sav, zsav, import, type mapping, charset, byte order, bytecode, diagnostics]
requires: [io_format:spss]
SPSS import — schema and file reading
Part of the SPSS surface; entry skill spss-cohorts. Missing values: spss-missing-values; response sets and derived columns: spss-response-sets.
Schema-authoritative import
.sav declares every column ⇒ internal/io/spss implements io.SchemaAwareReader, internal/io/infer.go’s sample-and-vote pass skipped for pulse import spss / pulse import auto / pulse_import / pulse convert alike. Hence:
- Inference-steering slots inert:
SampleRows,SetInferenceMinPct,SetDelimiters.ColumnTypeOverridesis refused (PULSE_IMPORT_OVERRIDE_INVALID) — re-type via an explicitImportJob.Schema. - No null promotion — declared nullability is a contract; an unexpected null is
PULSE_IMPORT_ROW_ERROR, never a silent widening.promoted_fieldsalways empty. - Explicit
ImportJob.Schemastill wins.
| SPSS | Pulse | Why |
|---|---|---|
| numeric (F/E/COMMA/DOT/PCT…) | f64 | no range-probe narrowing — a probe types two identical files differently |
| numeric with value labels | categorical_u8/u16/u32 | width from distinct count; past u32 → PULSE_SPSS_CATEGORICAL_OVERFLOW (hard; dropping values is worse) |
| string (A*) | categorical_* | near-unique → PULSE_SPSS_CARDINALITY_HIGH (free-text signature), still imports |
| very long string (>255 bytes) | one categorical_* | record 7/14 segments it; Pulse rejoins RAW bytes, decodes once |
| DATE/ADATE/EDATE/SDATE/JDATE | date, else datetime + PULSE_SPSS_DATE_WIDENED | widens only on a time-of-day value; pre-1970 stays date (signed epoch days) |
| DATETIME | datetime (epoch seconds; naive wall clock, --source-tz applies) | fractional-second / non-finite / out-of-int64 demotes to f64 raw SPSS seconds + PULSE_SPSS_TEMPORAL_PRECISION |
| TIME/DTIME | f64 seconds | durations, never a datetime |
| system-missing (sysmis) | null (bitmap bit) | the one missing state with a format sentinel |
| numeric user-missing | null + <var>_missing sibling | spss-missing-values |
| categorical/string user-missing | dictionary entries, verbatim + FLAGGED | spss-missing-values |
multiple-DICHOTOMY set (7/5, 7/7, 7/19) | every constituent PLUS a derived set_* | additive, never a replacement |
| multiple-CATEGORY set | N categorical_*; definition sidecar-only | positional, duplicate-tolerant ⇒ not a set |
Labels naming only missing codes do NOT make a variable categorical — INCOME labelled solely on 97/98/99 is continuous. Labels code the variable iff one names a value that is neither user-missing nor sysmis.
Reading real files
All three data encodings read, identical cohorts from identical content: uncompressed; bytecode (SPSS’s save default — bias from the header, not a hardcoded 100); ZSAV (zlib blocks inflating to a bytecode stream — two layers, not a third encoding; .zsav carries $FL3 not $FL2). Pulse never writes ZSAV.
Text decodes out of the file’s charset — record 7/20 (a NAME), else 7/3 (numeric code), else UTF-8; on disagreement 7/20 wins. Spellings fold (windows-1252 = cp1252 = 1252), never approximately (1250 ≠ 1252). Two hard rules: an undecodable byte errors naming variable and value, never a U+FFFD substitution; declared widths are BYTE counts, so padding is trimmed on raw bytes BEFORE decoding. --charset / io.SPSSReaderOptions{Charset} overrides a self-mislabelling file — not yet on pulse import auto or pulse_import.
Either byte order reads. The header layout code decides; record 7/3 corroborates only. A contradiction is FATAL — unlike the charset cross-check one field away — because byte order governs every count, offset and double: the wrong reading yields a whole file of plausible wrong numbers.
Damage has distinct codes because it has distinct fixes.
- Fatal:
PULSE_SPSS_FILE_EMPTY,_DICT_TRUNCATED,_DATA_TRUNCATED,_DICT_INVALID(four damage shapes);_COMPRESSION_INVALID(a bytecode command landed where it cannot apply — stream lost sync with the dictionary);_ZSAV_INVALID(self-inconsistentZHEADER/ZTRAILERindex, naming the block) /_ZSAV_BLOCK_CORRUPT;_ENDIANNESS_MISMATCH;_CHARSET_INVALID/_CHARSET_UNSUPPORTED. - Warnings, because declining loses nothing:
_CHARSET_MISMATCH,_MAGIC_FLAG_MISMATCH,_VALUE_LABELS_DROPPED(unbindable record3/4set — a label is display metadata, so refusing the file would cost the data to save the labels),_VERY_LONG_STRING_INVALID(unusable7/14; segments import as the columns the dictionary literally declares). - No input panics.
Warnings are load-bearing. Every non-fatal PULSE_SPSS_* diagnostic changes what the cohort MEANS. They ride ImportReport.SourceWarnings / ConvertReport.SourceWarnings (the io.SourceWarningEmitter interface), the --json envelope’s warnings array, and Warning [CODE] lines on the text path. pulse errors lookup CODE carries the fixup.
name: spss-metadata-sidecar
description: The cohort.pulse.spss.json sidecar an SPSS import writes — what it records that the .pulse format cannot, the code-label-dictionary-ID triple labels come from, its fingerprint, and the absent / stale / invalid / ignored read verdicts.
type: guide
kind: design
applies_to: inspect, predict, process, compose, sample, facet
covers: [SPSS, metadata sidecar, spss.json, value labels, staleness, --ignore-sidecar]
requires: [io_format:spss]
SPSS metadata sidecar
Part of the SPSS surface; entry skill spss-cohorts. What the export does with it: spss-export.
The metadata sidecar
An import writes cohort.pulse.spss.json beside the cohort (the .spss.json suffix, the pulse.ImportSidecar convention — NOT .meta.json, which a managed import writes for the same cohort). It holds every dictionary element the .pulse format has no slot for: measure levels, print/write formats, records 7/17 file and 7/18 variable attributes (kept distinct), record 6 documents, weight variable, compression bias, nominal_case_size, original short names, declared BYTE widths + 7/14 segmentation, MR/MC set and 7/5 variable-set definitions, the declared charset in the file’s own spelling, product name, missing-value specs in all three shapes. EVERY response set is recorded — MC sets and MD sets that refused to derive included — because the block records DEFINITIONS, a different question from which columns are synthetic.
Load-bearing payload: the code ↔ label ↔ Pulse dictionary ID triple per categorical column. Pulse IDs are positional, SPSS codes arbitrary ⇒ the only place the LABELS live. Per-entry flags labelled / observed / missing keep a declared-but-unused code, an appended unlabelled code and a user-missing code all representable. Build a LabelTable from this file.
Document {format_version, kind, fingerprint, payload}; payload flat and self-contained so it can later be lifted verbatim into a .pulse schema metadata block (deferred, not rejected — needs a FormatVersion bump). fingerprint = SHA-256 + size + mtime over the .pulse cohort, not the source .sav, mirroring the sidecar index’s O(1) staleness check. Written via the optional io.SidecarEmitter, called by ImportJob.Run after the cohort write; a source not implementing it ⇒ byte-identical import, no sidecar.
Reading it back — and why absent and stale are not the same answer
LoadSidecar(fs, cohort, opts) (internal/io/spss) — read path and the write side’s first act (pulse export spss reaches it for you; no leaf reads the sidecar alone). Inspect and predict read it too, only for payload.weight → suggested_weight (tool-inspect) and, in predict, the measure levels behind the predict-advisories sidecar codes: every refusal below is SILENT there — no suggestion, no advisory, no warning. Returns a SidecarResolution; resolution.Synthesise() is the single question — must I build a default dictionary from the .pulse schema alone?
| State | Verdict | Code | Then |
|---|---|---|---|
| no file | warning | PULSE_SPSS_SIDECAR_ABSENT | synthesise a default; the normal case for synth / CSV output |
| size or mtime moved | error | PULSE_SPSS_SIDECAR_STALE | nothing — no resolution returned at all |
not JSON / foreign kind / unknown format_version / bad digest | error | PULSE_SPSS_SIDECAR_INVALID | nothing |
IgnoreSidecar set, file present | warning | PULSE_SPSS_SIDECAR_IGNORED | synthesise a default |
- The split overrides a flatter “a lost sidecar is a warning”. Absent is benign — the cohort never had source metadata. Stale is the highest-fidelity-risk state there is: a complete, plausible dictionary over changed data yields a
.savwhereIF q1 EQ 5addresses a category that moved — authoritative-looking, wrong, undetectable downstream. So a refusal returns no resolution object; no shape exists in which a caller holds the stale document and writes it by accident. - Size + mtime, never a hash, for
PULSE_INDEX_STALE’s reason: hashing a multi-GB cohort per export costs more than the export. Same residual gap (an in-place edit preserving both);Document.VerifyDigest(fs, cohort)is the full SHA-256 recompute. WriterOptions{IgnoreSidecar: true}(--ignore-sidecaronpulse export spss/pulse convert) suppresses the read, not the verdict: a healthy sidecar is ignored too (the flag never flips with an mtime), an unreadable one cannot block, both refusals downgrade to the warning path, and the warning deliberately cannot say which refusal it silenced. No option applies a stale dictionary — recorded metadata or synthesised default, never fresh-or-stale.- Load normalises
multiple_response_sets[].fields: additive underomitemptywith noSidecarFormatVersionbump, so an ABSENTfieldskey means “written before the slot existed” ⇒ back-filled fromvariables[].short_name(case-insensitive, first declaration wins, unknown member →""). WRONG LENGTH ⇒ rejected — index-for-index withvariables, and a repair would bind members to the wrong columns.
name: spss-missing-values
description: How SPSS user-missing and system-missing values land in a cohort — the numeric <var>_missing sibling versus the flagged categorical code, why the two arms deliberately differ, --spss-missing, and filtering on the code rather than the label.
type: guide
kind: design
applies_to: inspect, predict, process, compose, sample, facet
covers: [SPSS, user-missing values, sysmis, missing siblings, --spss-missing]
requires: [io_format:spss]
SPSS missing values
Part of the SPSS surface; entry skill spss-cohorts. The sibling column is registered as a derived column: spss-response-sets (Derived columns and the derived registry).
Missing values — and why the two arms differ
SPSS separates refused / don't know / not applicable / sysmis; the Pulse bitmap is ONE bit — that a value is absent, never why. Both naive mappings lose: codes-as-data makes a sum aggregator add 99999 per refusal; all-to-null destroys the item-non-response distinction that nonresponse adjustment needs. Split by substrate:
| Numeric | Categorical / string | |
|---|---|---|
| where can the reason live? | nowhere — f64 has no dictionary, bitmap is one bit | already there — the code IS a dictionary entry |
| mapping | column nulled + generated <var>_missing sibling | code verbatim, entry FLAGGED |
| cost of the other choice | reason lost outright | a redundant sibling on EVERY item — 200 questions → 400 columns, no new information |
--spss-missing | auto (sibling) / null (none) | no effect — no sibling to suppress |
The asymmetry is deliberate; do not “fix” it. Both arms preserve rather than degrade; only the substrate differs.
Numeric sibling <var>_missing — categorical_u8 (widens), immediately after its source.
- Dict ID
0="sysmis", then DECLARED discrete codes in spec order, then further observed missing values first-seen. Ranges are never enumerated — observed members only, which drives the widening. - Reason text = the file’s value label for the code, else the code. Label colliding with another reason loses to the code +
PULSE_SPSS_VALUE_COLLISION. - PRESENT value ⇒ sibling null: the empty reason is the bitmap bit, not an entry.
--spss-missing=null/io.ReaderOptions{SPSS: io.SPSSReaderOptions{MissingMode: io.SPSSMissingNull}}drops siblings — same nulls, reason no longer per-row (the specification still rides the sidecar). Unrecognised mode ⇒PULSE_SPSS_MISSING_MODE_INVALID, never a default.
Categorical flag — Q1: 1=Yes, 2=No, 9=Refused → categorical_u8 holding "1", "2", "9"; the refused row still says 9. Record 7/22 long-string missing values bind to the same variable.missing slot a record type 2 spec does, so strings need no branch. Which entries are missing-coded is recorded twice:
- sidecar
variables[].categories[].missing— additiveomitempty; no such codes ⇒ byte-identical document,SidecarFormatVersionunmoved. PULSE_SPSS_CATEGORICAL_USER_MISSING— one informational diagnostic per FILE, never per variable; prose names the first few,Details["missing_categories"]carries every field → entries pair uncapped. Nothing is wrong when it fires; the loss is downstream, where a percentage base silently includes the refusal category.
Exclude over the CODE: a value filter names "9", never "Refused" — a value outside the dictionary is a loud PROCESSING_CONFIG, not a filter matching nothing.
{"type":"FILTER_EXCLUDE","field":"Q1","values":["9"]}
name: spss-response-sets
description: SPSS multiple-response sets and the columns an import synthesises — the derived set_* mask beside a multiple-dichotomy set's constituents, why multiple-category sets derive nothing, and the closed derived registry the export folds by. Read this when a cohort has more columns than its .sav had variables.
type: guide
kind: design
applies_to: inspect, predict, process, compose, sample, facet
covers: [SPSS, multiple-response sets, derived columns, derived registry, set fields]
requires: [io_format:spss]
SPSS response sets and derived columns
Part of the SPSS surface; entry skill spss-cohorts. The other derived kind, the numeric <var>_missing sibling: spss-missing-values.
Multiple-response sets
SPSS is the one source that DECLARES a multi-select; every other path guesses from delimited strings. A multiple-dichotomy set gets a derived set_* mask beside its constituents, never instead — a bit cannot separate Q1B=0 (“shown, not picked”) from Q1B=. (“never asked”). MD set ⇒ N + 1 columns; each constituent keeps its own null bit and <var>_missing sibling; the derived column follows the LAST constituent (a summary must not precede its parts).
- Dictionary holds constituent field names, not option labels — cohort-unique, so injective for free. Labels stay on
variables[].label. - Mask uses the declared counted value, never a guessed
1. A user-missing code sets no bit, and is not evidence of an answer. - Set name loses its
$($media→media) — a sigil is no legal expr-lang identifier. Full name onderived[].set_name. - Three row states: option(s) selected → bits set; answered, nothing selected → empty mask, a real “none of these”, NOT null; all constituents missing → null.
PULSE_SPSS_MR_SET_NOT_DERIVED— WARNING, import succeeds — on >256 constituents (set_u256is the widest rung; a 206-option battery derives), an undeclared or duplicated member, a counted value that will not compare against a numeric member, or a constituent whose field name holds the set delimiter|or IS a null token. The additive design paying out: a set that does not derive costs ergonomics, never data.
Multiple-CATEGORY sets derive nothing — a fidelity call. N answer SLOTS over a shared value-label set, so slot ORDER (“first choice” vs “third”) and a REPEATED code (two slots both 2) are real; a bitmask is unordered and idempotent. Members import as ordinary categorical_* as if the definition were absent; only the definition rides the sidecar.
Derived columns and the derived registry
payload.derived names every SYNTHESISED column ⇒ a column absent from it is a source variable by construction. Name-matching cannot substitute: _missing is a legal SPSS suffix (income_missing is not hypothetical) and a set column matches no pattern.
kind | Fold action | Opt out |
|---|---|---|
numeric_missing | CONSUMED — its per-row ID decides what its one source variable writes wherever that variable is null | --spss-missing=null |
multiple_dichotomy | DROPPED — every bit re-reads a constituent still in the cohort | none by design; one column per set |
kindis a CLOSED vocabulary (DerivedKinds()ininternal/io/spss), one action each viaDerivedFoldFor, which reportsfalseotherwise — an older binary meeting a newer document stops rather than defaults.- Entries are self-sufficient (
Derived.Complete()): a reason sibling carries its reason dictionary (ID ↔ reason ↔ SPSS code ↔ label), the only record of which state each row was in; a set column carriesset_name+sourcesin BIT order. - Derived columns INTERLEAVE ⇒
variables[].positionis a cohort position, not a source ordinal. - Nothing derived ⇒
"derived": [], never a missing key — “nothing derived” and “cannot tell you” are different answers.
Export-transparent by fold, not by name. foldDerived (internal/io/spss/dict_fold.go) consumes the registry at plan time, so an emitted .sav carries exactly the source’s own variables. restore binds a sibling to its variable; the encoder then writes the recorded SPSS code into every null instead of sysmis, from Derived.Reasons, never re-derived. drop releases a set column after checking its constituents are emitted. The encoder is driven by DictionaryPlan.Columns alone, so an unbound field is decoded (the record stride demands it) and written nowhere — a derived column cannot leak out as a variable even if the fold missed it.
The audit is worth more than the fold. DictionaryPlan.UnboundFields = every cohort field no emitted variable is written from; on the sidecar path, exactly the derived columns. Unaccounted ⇒ PULSE_SPSS_COLUMN_UNMAPPED: a column leaving the export silently, the outcome this path exists to refuse. Checked on the synthesised path too, where the registry is empty and every field must bind. An entry the binary cannot honour ⇒ PULSE_SPSS_DERIVED_UNFOLDABLE, four shapes — unknown kind from a newer import; numeric_missing missing its reasons; an entry naming an unemitted source column; an entry naming a column also emitted as a variable. A refusal: both available guesses are invisible data faults.
Sibling name colliding with a real variable (case-insensitively, as SPSS names are) ⇒ PULSE_SPSS_DERIVED_NAME_COLLISION naming both sides — hard ERROR for a sibling, warning on the MD-set arm (pure convenience).
name: statistical-testing
description: Tier-1 (`tests`) vs tier-2 (`post_tests`) pairing, assumption gates, ANOVA + post-hoc composition, p-value conventions. Topical design; per-TEST detail in atomic op-test-* skills.
type: guide
kind: design
applies_to: process, compose, predict
covers: [TEST, tier-1, tier-2, tests, post_tests, ANOVA, Tukey, assumption-gates, p-value]
Statistical testing
Hypothesis tests run through two independent slots. This file teaches choosing and chaining; per-test detail lives in atomic op-test-<name> skills. Tests emit no Response.Components; results ride Response.Tests / Response.PostTests.
Finding the right test
Start from the question:
pulse_skills_get intents— pick the question kind:compare_groups(do averages / rates / mixes differ),relationship(do two fields move together),distribution_shape(is it normal, do two shapes differ),change_over_time(is an ordered series trending),benchmark(one group against a fixed target).- In
pulse_manifest, keep thetests[]entries whoseintentscarry that ID. Each entry also states itstierandstreamable. - Read the chosen
op-test-<name>skill; narrow by data shape (below), not by catalogue.
Two tiers, one shape
| Slot | Stage | Consumes | Cost |
|---|---|---|---|
tests (tier 1) | during row scan | raw records | near-zero when reusing aggregator Welford state |
post_tests (tier 2) | after windows | materialized result rows | one pass over Response.Data |
Both share the Test shape ({type, field, field2?, split_by?, params?}) and TestResult output; the split is what they read.
Picking the tier
Tier 1 — raw distribution questions (A/B revenue, churn × region independence, normality). Tier 2 — per-group summaries / windowed columns (do per-region averages differ, trend on moving_avg).
Raw-row and per-group correlations can disagree (Simpson’s paradox); the Variant (pearson vs pearson_post) says which you got.
Decision criteria
Four questions narrow a comparison:
| Question | Pushes toward |
|---|---|
| How many groups? | two → a two-sample test; three or more → an ANOVA-family test |
| Same subjects measured more than once? | yes → a paired / repeated-measures test |
| Roughly normal, or skewed / ranked / extreme? | normal → mean-based; otherwise rank-based |
| Similar spread across groups? | no → the Welch forms |
Outcome type routes first: a numeric measure → mean or rank tests; a yes/no rate → the two-proportion z or a contingency test; two categorical fields → χ² (2×2 with any expected cell < 5 → Fisher’s exact, PULSE_TEST_EXPECTED_COUNT_TOO_LOW); two numeric fields → a correlation (Kendall’s cost grows with n²).
A whole battery of numeric fields (mean profiles, covariance, sphericity, joint normality) takes a vector test: multivariate-design.
Assumption gates before an ANOVA
Before a one-way ANOVA:
- Per-group normality — the Shapiro-Wilk test with
split_by(n ≤ 5000 per group; larger →PULSE_TEST_SHAPIRO_N_BOUND). - Equal spread — the Brown-Forsythe test (median-based, robust under non-normality).
A failed spread gate → Welch’s ANOVA (same Welford state); badly failed normality → rank-based Kruskal-Wallis. Gates are evidence, not proof: small n misses departures, huge n flags trivial ones.
ANOVA alone vs ANOVA + post-hoc
The F test rejects the global null but not which pairs differ. For pairwise localization chain two requests:
- Tier-1 one-way ANOVA; read
ms_within/df_withinfromDetails. - Tier-2 Tukey HSD with those two as
params(Tukey-Kramer p, family-wise α controlled).
After Welch’s ANOVA, Tukey’s pooled ms_within is wrong: run pairwise Welch tests with a multiplicity block (holm).
P-value conventions
alphadefaults to 0.05, range(0, 1)(elsePULSE_TEST_INVALID_ALPHA).- Two-sided by default; one-sided needs caller-side post-processing.
- Regression inference uses Wald-z, not Student-t;
TEST_*never mix the two. - Multi-group headline tracks the worst group; detail in
Details.per_group. - Many tests:
multiplicityaddsp_adjustedbeside raw p (multiplicity-correction).
Streamability
A tier-1 test streams when it can run on online state (mean / variance / n, or contingency counts); rank-, sort- and permutation-based tests are forced buffered, as is TEST_MARDIA (needs the fitted covariance first). Read the answer from the manifest tests[].streamable or pulse_predict, never from memory. Tier 2 is always buffered; some families exist on tier 2 only (one manifest entry per tier).
Composition with aggregators
Declare the Welford aggregator on the same (field, split_by); a mean-based tier-1 test reads its running (mean, variance, n) free, byte-equal to the standalone Welch numerators. See aggregation-design.
Gotchas
- Tier-2
fieldnames match the aggregator’s projected column (AGG_<TYPE>_<field>). - Tier-2 Welch’s ANOVA needs
params.n_col+params.variance_col; Tukey HSD needsparams.ms_within+params.df_withinfrom a tier-1 ANOVA. - Tiny groups → unstable p (
PULSE_TEST_INSUFFICIENT_Nfloor). PULSE_TEST_VARIANCE_ZERO: a constant field within a split group breaks correlation and t-tests.
See
- Recipes:
pulse_examples_search tags=["ab-test"],tags=["anova"],tags=["correlation"],tags=["nonparametric"]plus atomicop-test-<name>. aggregation-design— Welford-triple reuse +MetaAggregatorcontract.regression-modeling— Wald-z vs Student-t inference inside regressions.overlay-system— crosstab cell stat overlays.request-envelope— slot keys.pulse_errors_lookup—PULSE_TEST_*recovery.
name: streaming-and-watching
description: Reactive Pulse primitives — Request.Hash() cache keys, StreamResult[T] incremental output with per-chunk + terminal Components, Watch / WatchDir mutation observation, FilterToFileWithRequest deterministic derived cohorts, manifest CommandAnnotations caching policy.
type: guide
kind: design
applies_to: process, predict, manifest
covers: [Request.Hash, StreamResult, Watch, WatchDir, FilterToFileWithRequest, CommandAnnotations]
Streaming & watching
Primitives for deterministic identity, incremental output and reactive observation.
Request.Hash()
Hash() string returns a 32-char hex digest of canonical-JSON form. Supported: Request, ComposedRequest, FacetRequest, ChainRequest, synth.Spec. Lower-level: types.CanonicalHash(tag, v).
Guarantees: stable across processes/versions; field-order invariant; default-normalising (Limit: 0 ≡ omitted); -0.0 ≡ 0.0; a namespace tag separates shapes. return is part of the hash.
key := req.Hash()
if v, ok := cache.Get(key); ok { return v }
resp, _ := p.Process(ctx, req); cache.Set(key, resp)
StreamResult[T]
type StreamResult[T any] struct {
Header StreamHeader // RequestHash, EstimatedTotal (-1 unknown), StartedAt
Chunks <-chan StreamChunk[T] // 4-deep; producer never drops
Done <-chan StreamTerminator // CompletedAt, TotalRows, Status, Error
}
type StreamChunk[T any] struct {
Sequence int; Data T; Progress float64
Components *types.ResponseComponents
}
Variants: ProcessStreamResult wraps ProcessStream; SynthStream wraps synth.SynthBytes.
Per-chunk + terminal Components
What a chunk’s Components holds depends on each operator’s mergeability class — read it from manifest.components_schemas, never from the operator’s name; predict flags every none slot buffered_components: true before you run.
mergeable—operatorcarries running state on every chunk. Rendering mid-stream is safe; the terminal chunk is authoritative.partial— folds like mergeable but the state grows (maps / sets); a consumer may union chunk partials the way the engine does, or just wait for the terminal chunk.none— the operator needs the whole sorted input (medians, percentiles, quantile bands): non-terminal chunks carry the floor only (n,n_null; grouperfield,label,total_n,n_null) withoperatornil. MUST NOT merge non-terminal chunks.
Identity: the terminal chunk’s Components is DeepEqual to the buffered Process result’s. With components disabled every chunk carries nil.
A return block shapes streamed rows (selected columns, wire precision via returnshape.MarshalRow); returned rides the terminal chunk only. See response-shaping.
res, _ := p.ProcessStreamResult(ctx, req)
var terminal *types.ResponseComponents
for chunk := range res.Chunks {
terminal = chunk.Components
if chunk.Components != nil { renderRunning(chunk.Components) }
}
<-res.Done; finalise(terminal)
ctx cancel closes both channels with StreamCancelled + ctx.Err(). Mid-stream errors deliver StreamErrored.
Two-pass attributes
Attributes needing a whole-cohort statistic first (z-score, regression fit, declared two-pass extension) stream in two passes in declared order; each dependent layer adds a scan.
Watch / WatchDir
type ChangeEvent struct {
Path string; Kind ChangeKind // Created|Modified|Removed|Renamed
Hash string; Timestamp time.Time // Hash empty for Removed
}
ch := p.WatchDir(ctx, "cohorts/", true)
for ev := range ch { handle(ev.Kind, ev.Path, ev.Hash) }
No pre-seed — pre-existing files surface as ChangeCreated on the first tick.
WatchOptions: PollInterval (250 ms), CoalesceWindow (100 ms; 0 disables), HashPrefixBytes (64 KiB; < 0 = whole file), Recursive, Suffix (.pulse for WatchDir). Network filesystems should raise PollInterval to ~30 s.
Atomic-write coalescing: Removed(temp) + Created(target) folds into ChangeRenamed(target) when both share a directory, the temp looks like an editor/atomic-write temp name, and both fall inside CoalesceWindow.
FilterToFileWithRequest
res, err := p.FilterToFileWithRequest(ctx, &pulse.FilterToFileRequest{
SourcePath: "cohort-2025-Q3.pulse",
Expression: "age >= 18 && state in [\"CA\", \"NY\"]",
OutputDir: "derived/",
})
Guarantees: deterministic name {source-hash[:16]}_{predicate-hash[:16]}.pulse when OutputName empty; atomic write via OutputDir/.<name>.partial + rename; dedup by pre-existence (re-reads hash + row count, returns Reused: true).
Predicates: Expression (an expression-filter string, expression-language) or structured Filterers (translated, AND-combined). Exactly one set — both empty/both set rejects so the predicate hash is unambiguous.
Manifest CommandAnnotations
Every Manifest.Commands (CLI leaves) and Manifest.Operations (library-only: filter_to_file, process_stream, synth_stream, watch) carries:
- name: process_stream
annotations: {streamable: true, deterministic: true, expensive: true}
- name: synth_stream
annotations: {streamable: true, deterministic: false, expensive: true}
- name: watch
annotations: {streamable: true, deterministic: false, expensive: false}
streamable — has a *Stream variant. deterministic — same inputs ⇒ byte-identical output; cache keyed by req.Hash() + source hash. Non-deterministic entries MUST NOT be cached as stable. expensive — hint.
A buffered overlay downgrades a streamable Process to buffered — price it as buffered.
Compose streaming vs. overlays
pulse api compose --stream emits per-row NDJSON {index, row} and bypasses the envelope. The Compose-host overlay fold runs only at terminal flush — ComposedResponse.Overlays[i] + per-layer Warnings appear under --json (data.overlays) but are absent under --stream. Consumers needing Compose overlays MUST run buffered; Pulse.Compose / Pulse.ComposeParallel callers see overlays on the returned *ComposedResponse, never on row events.
See
response-components (shape, mergeability classes) · response-shaping (return) · overlay-system (overlay streamability) · compose-requests (per-slot Components) · process-chain (per-stage Components).
name: synth-conflicts
description: Synth conditional relationship conflicts — the shared claim space, the fixed priority order `resolveConflicts` applies once per spec, how rule / model / shape pre-claims compose, and where the dropped-relationship warnings surface.
type: guide
kind: design
applies_to: inspect, predict, manifest
covers: [synth from-schema, synth from-profile, conflicts, resolveConflicts]
requires: [capability:synth]
Synth relationship conflicts
Conditional relationship conflicts
Every relationship writes into one shared claim space: a field name for scalar targets (categorical-pair / categorical-numeric B side, set-numeric’s numeric side, correlation participants), or (field, option) for a set_* bit. Two options on the SAME set field are two DIFFERENT targets, never a conflict. A whole-field claim subsumes that field’s options; an option claim never reaches the whole field.
resolveConflicts (internal/synth/conflict.go) runs ONCE per Spec at generation setup, never per row, in the fixed drawRow priority order:
structural-rule pre-claim → linear-model pre-claim → shape-fit pre-claim
→ catPairs → catNumPairs → setSetPairs → setCatPairs → setNumPairs
→ correlations
A Spec.Rules entry that DETERMINES a field claims it first, because the rule pass runs last in drawRow and wins outright; which rule shapes qualify (and the four exclusions, each silent if got backwards) is in synth-rule-claims.
First claim wins; every later relationship naming that target is dropped and REPORTED, one warning each (conditional relationship conflict: … is already claimed by …), rather than resolving to “whichever stage runs last”. Spec.Correlations is one joint claimant across all participants (single Cholesky draw) — losing one participant excludes only that field and buildCorrelator rebuilds from the survivors.
The model and shape pre-claims compose; they do not compete. The model claims first (one additive expression already accounts for every predictor, and a later overwrite would discard the whole account rather than layer onto it); a --fit-shape field claims second (its sampler already drew the value). A shape-fitted field carrying a model is claimed by the model, draws through the model, and gets its fitted mixture as Q — nothing dropped, no warning. The shape pre-claim still owns every DistMixture field no model took, and excludes a pair or correlation naming it with a captured shape (--fit-shape) warning.
A profile-derived spec never declares both a model and a numeric-target pair for one field (SpecFromProfile retires the pair per target); a HAND-AUTHORED spec that does is arbitrated here, the model winning. A value-scale correlation naming a modelled field is reported as not applied (capture residual correlations instead), not as a conflict — no pair claimed it.
Where the warnings surface
generate() returns them on Result.Warnings; for --fidelity-report, SpecFromProfile reruns the pass at spec-composition time and merges with capture-time warnings into FidelityWarnings. Both always agree — same Spec, same order. The terminal summary counts an arbitrated claim as an EXPECTED outcome, not an attention item.
The arbitration is also why the fidelity report scores only relationships generation actually applied (synth-fidelity-report).
See
name: synth-correlations
description: Synth spec `correlations` — the Gaussian-copula construction, which marginals and field types may participate, how rank and Pearson correlation survive, and how unmeasured matrix entries are completed and reported.
type: guide
kind: design
applies_to: inspect, predict, manifest
covers: [synth from-schema, synth from-profile, correlations, copula]
requires: [capability:synth]
Synth correlations
Pairwise correlations
correlations is a list of {a, b, rho} realized by a Gaussian copula (internal/synth/copula.go): correlated standard normals u via Cholesky → p_i = Φ(u_i) → each field’s OWN quantile Q_i(p_i). It runs after every pair stage. A profile supplies it from conditional.numeric_pairs when captured, else from the top-K pairwise list (synth-profile-capture).
- Supported marginals:
normal/uniform/lognormal/exponential/mixture/bernoulli/discrete(fieldMomentsvalidates,quantileForbuildsQ_i). Anything else incorrelations→SERVICE_VALIDATION.|rho| ≥ 1rejected at validation. - A participant must also be a SCALAR field TYPE, and that predicate is DERIVED, never transcribed.
isNumericFieldTypereadsfieldTypeFromName: every declarable non-categorical_*, non-set_*type qualifies (u4…u64,f32/f64,date,decimal128,packed_bool) and nothing else. A hand-written list silently dropped a whole type out ofSpec.Correlationsonce — do not reintroduce one. The boundary is TYPE, not distribution: adate’suniform_dateis refused one call later, NAMING the distribution. normalreduces exactly tomean + std*u. Otherwise the copula targets RANK correlation exactly and preserves each field’s own marginal (mean, std AND skew); realized PEARSON attenuates for a wide-variance non-normal marginal, andmin/maxclamping distorts further. Keep sigma modest if Pearson must land tightly.- A step/staircase participant (
bernoulli,discrete) holds its marginal EXACTLY and attenuates the realised correlation, costing rank as well as Pearson once ties dominate (synth-marginals). - The
mixturearm exists for the MODEL draw, not forcorrelations: a--fit-shapefield is excluded from the value-scale matrix either way (pre-claimed when unmodelled, permanently refused when modelled — its structure ridesresidual_correlations,synth-models). - A modelled field never joins
correlations. Realizing it would overwrite the model’s output; capture residual correlations instead. The value-scale and residual-scale arms therefore fire on DISJOINT field sets. - Unmeasured entries: ASSUME AND RECORD.
correlationsis a LIST, the Cholesky needs a MATRIX, so every unnamed pair is filled with 0 (drawn independent) andbuildCorrelatorWARNS with the count. Refusal was rejected — an incomplete list is the normal input.cholesky’s ridge also reports its accumulated diagonal jitter, which pulls realized correlations toward zero. Neither warning changes a number. The residual correlator shares this one factorization and completion policy.
Spec.Correlations is ONE joint claimant across all participants (a single Cholesky draw): losing one participant to an earlier claim excludes only that field, and the correlator rebuilds from the survivors (synth-conflicts).
Gates: TestSynth_CorrelationReconstructionWithinTolerance, TestSynth_CopulaPreservesLognormalMarginal.
See
synthetic-data— spec shape and distribution registry.synth-models— residual-scale correlation for modelled fields.synth-fidelity-report— thepairwiserecovery section.
name: synth-determinism
description: The synth determinism contract — byte-identical `.pulse` output per spec and seed, byte-identical profile documents per input and seed, and the two non-RNG threats to capture (map iteration order, float fusion).
type: guide
kind: design
applies_to: inspect, predict, manifest
covers: [synth from-schema, synth from-profile, profile create, determinism, seed]
requires: [capability:synth]
Synth determinism
Determinism contract
Same (spec, opts.Seed) MUST produce a byte-identical .pulse file. Any sampler change breaking that is a contract break.
Seed splitting uses a 64-bit avalanche; seeds differing by 1 give uncorrelated streams. Seed == 0 is stable, not “random”. nullableSampler always draws the inner value FIRST, then the null mask — the stream is invariant to which rows are null.
Every per-row stage consumes a FIXED number of draws whatever it decides: each model drawer takes exactly one normal per row unconditionally (even at zero residual std), in SCHEMA field order, never in models array order; the rule pass consumes NO RNG; the mixture quantile is a fixed-count bisection, never a tolerance-terminated search. A spec declaring no models, residual correlations or rules is byte-identical to pre-slot output. The stream legitimately moves only for a reason a warning names (a rule that pre-claims a field retires its model, and that model’s draw).
Capture is held to the same bar: same (--input, --seed) MUST produce a byte-identical profile document, or the pipeline is only deterministic downstream of a spec that itself drifts. Two threats, neither the RNG:
- Map iteration order. Anywhere capture folds several accumulators into one shared bucket — canonically the top-K collapse folding out-of-top-K categories into
"other"— the fold MUST walk SORTED keys: float addition is not associative and Go randomizes map iteration. The(sumSq - mean*sum)/(n-1)variance form amplifies rather than absorbs the last-bit difference, so a map-order fold surfaces as a ~1e-10 relative drift in the emittedstd. Signature: only"other"entries move, because only"other"has more than one source. Sort — do NOT switch to compensated summation, which is still order-dependent in principle. - Float fusion. Go may contract
a + b*cinto one FMA; arm64 does, amd64 does not, and the contraction is permitted ACROSS statements. Every product in a capture or generation formula therefore carries an explicitfloat64(...)conversion — the only construct that forbids contraction — andinternal/synth/moments_internal_test.gofails if one is dropped (it can only DETECT on a contracting architecture, so it is silent on CI by construction).math.FMAis the wrong lever: it forces fusion everywhere. Test fixtures computing float columns are part of the contract — accumulate in integer units and divide once. Residual, stated not hidden:--fit-shape(math.Exp/math.Log) and--fit-models(the regression engine, not yet fusion-free) can still differ in last bits across architectures.
Ordering anywhere in capture or generation is spec-derived or sorted, never map-derived — a document’s array order is a serialization artifact and must not choose a draw.
See
synthetic-data·synth-models·synth-set-fields(dictionary pre-registration).docs/src/cli/profile-create.md— reproducibility.
name: synth-fidelity-report
description: The `pulse synth from-profile` tagged top-up contract (`_synthetic` column, new output path, `--rows` counts new rows) and the `--fidelity-report` sections — marginals, pairs, set fields — scored only over relationships generation actually applied.
type: guide
kind: design
applies_to: inspect, predict, manifest
covers: [synth from-profile, fidelity report, _synthetic, top-up]
requires: [capability:synth]
Synth top-up and fidelity report
Tagged top-up contract
synth from-profile (SynthOptions.SourceCohort / --source) always: (1) appends one _synthetic packed_bool field, false on copied rows, true on generated ones; (2) writes a new output path, distinct from --source, which is opened read-only and never mutated; (3) treats --rows as a count of NEW rows, never “top up to N total” (--rows 500 against 200 source rows → 700 rows). Source records are re-encoded straight through, so structural rules reach GENERATED rows only. SourceCohort empty (synth from-schema) is the plain path — no tag column. Refusals: PULSE_SYNTH_SOURCE_REQUIRED, PULSE_SYNTH_OUTPUT_REQUIRED, PULSE_SYNTH_OUTPUT_COLLISION, PULSE_SYNTH_ALREADY_TAGGED, PULSE_SYNTH_PROFILE_SCHEMA_MISMATCH.
Fidelity report
--fidelity-report <path.json> (only with SourceCohort set) writes a synth.FidelityReport after generation. Marginals: numeric via the KS test operator (split_by: _synthetic), categorical via the χ² test operator — existing operators, no new stat math. _synthetic is on-wire packed_bool and both operators need categorical, so the bridge presents it through a categorical_u8 VIEW schema for that call only. That bridge lives in pulse.go (writeSynthFidelityReport), not internal/synth/, to avoid an import cycle: the internal BuildFidelityReport takes an injected TestRunner. A failed field test reports error, not result, without aborting the rest. Sections omitempty throughout, so a section whose capture flag was absent leaves the report byte-identical to before it existed.
Every section scores ONLY relationships generation ACTUALLY APPLIED. spec carries the full captured cross product; generate() applies the subset conflict resolution leaves. Scoring a conflict-dropped pair computes a delta against a relationship the generator never modelled — a number that LOOKS like evidence. internal/synth.ResolveConflicts re-runs the identical arbitration against the identical *Spec, so a dropped pair has no entry and its own conflict warning explains the absence (synth-conflicts).
pairwise is one {a, b, source_rho, synthetic_rho, delta, n} per surviving numeric-numeric pair, via the same pearson helper capture uses. It scores VALUE-scale Spec.Correlations only; residual correlations have their own section.
A modelled numeric has no numeric-target pair entry, ever. categorical_numeric_pairwise / set_numeric_pairwise score the pick-one pair sampler, which does not run for a modelled target; an entry would be a delta for a mechanism that never executed. An unmodelled numeric in the same profile keeps its pair and is scored there. The non-numeric-target pair sections are untouched by models.
Set fields have their own sections and are never omitted. set_fields is one {field, options: [{value, source_frequency, synthetic_frequency, delta}]} per set_* column, computed directly (no TestRunner: the KS test wants a scalar and the χ² test a partition, and a multi-select is neither), one entry per dictionary member in BIT order up to MaxSetEntries(). set_categorical_pairwise / set_numeric_pairwise / set_set_pairwise score the surviving conditional pairs. All four read masks the same way capture does — a wide column read through a uint64 assertion returns an empty mask on BOTH partitions, so every delta comes back exactly 0.0 and the section certifies a generation it never measured. A silently-omitted wide column reads the same way: as nothing wrong (synth-set-fields).
The two structure-recovery sections — models and model_residual_correlations, asking whether the captured CONDITIONING survived rather than whether the rows look alike — are in synth-models.
Warnings in the report
The report’s warnings carry capture, translation and generation warnings, deduplicated — including generation-only facts such as which captured relationship a --rules run retired and which rule never fired. The CLI prints a counted summary to stderr (attention kinds first, expected outcomes counted separately) and, with --fidelity-report, the recovery headline N model(s) checked, M flagged.
See
synthetic-data·synth-conflicts·synth-models·synth-set-fields.docs/src/cli/synth-from-profile.md— flags and a worked report.
name: synth-marginals
description: Why a profiled `packed_bool` reconstructs as `bernoulli` and a small integer as `discrete` rather than a clamped normal — the three writers each arm covers, probit and ordered-probit model draws, and what that does to recovery and correlation.
type: guide
kind: design
applies_to: inspect, predict, manifest
covers: [synth from-profile, profile create, bernoulli, discrete, packed_bool, u4, marginals]
requires: [capability:synth]
Synth marginals: booleans and small integers
A boolean or an integer column lands in the profiler’s NUMERIC accumulator, and the obvious reading of that summary — normal(mean, std) clamped to the observed range — is wrong on the wire, severely and silently, because the writer quantizes it. Both arms below replace that reading, and share one shape that is the part to get right:
- The arm runs AHEAD of
--fit-shape— a mixture fits such a column happily and reproduces its shares no better, at a bisection per draw. - Capture is UNCONDITIONAL (no flag): this is the DEFAULT reconstruction being wrong, not an enhancement.
- All THREE writers that can reach the field are covered — fixing one leaves the others wrong with no signal: its own sampler, a conditional pair, and the model draw. A pair’s target marginal is resolved from the target’s own
FieldSpec, never from a wire flag, so pair and marginal cannot disagree.
Boolean marginals
A packed_bool reconstructs as bernoulli with p = the observed mean. The field holds ONE BIT, the writer must reduce a continuous draw to 0/1, and no threshold over a clamped normal lands right (a 20% boolean generated at 69%, a 50% one at 84%). bernoulli needs no threshold — the mean IS p.
Three writers: its own sampler draws bernoulli; a conditional pair draws each cell’s captured Mean as a PREVALENCE, not a location (Std unused); a model draws through the step Q, making it a probit — P(1 | row) = Φ((μ − Φ⁻¹(1−p)) / σ), so coefficients order rows, not probabilities. A pair’s old bernoulli wire flag is RETIRED: still parsed, read by nothing, and warned when it disagrees with the target’s marginal.
Consequences: p of exactly 0 or 1 has zero variance, so a model on it is DROPPED with a warning rather than producing an infinite 1/std (the constant is deliberately not floored). A bernoulli target has no POINT latent inverse, so latentFor refuses it and recovery runs on the calibrated probit score (synth-models). The continuous arm survives only for a hand-authored normal on a packed_bool: biased, not correct, and toBool rounds at 0.5 there so it is at least not inverted.
A MODELLED boolean’s prevalence is held at p only while the generated latent is standard normal. Φ(u) is uniform only when Var(u) == 1: true at FIT time by construction, not at GENERATION time, where predictors come from their own reconstructed marginals. The realised prevalence therefore carries a small systematic bias — not the sampler and not the write path, both measured exact. The same mechanism compresses a normal target’s SD and shifts a discrete target’s level shares, and a gated field inherits it: a null_together block lands on the modelled field’s REALISED rate, not its captured one. Gate: TestSynthModel_BooleanPrevalenceFollowsTheGeneratedLatentVariance.
Small-integer marginals
An integer column with at most 64 observed levels reconstructs as discrete — its own exact per-level histogram. The boolean defect one type wider: the writer stores floor(v+0.5), so a clamped normal is QUANTIZED on the way to the file — the bell flattens the scale’s real shape and the clamp piles asymmetric mass on the near bound, while the MEAN comes back roughly right, which is how it survived. A level’s observed count IS its weight.
Which types claim the arm is not a taste judgement. isIntegerQuantizedFieldType is exactly the set of writeFieldValueForField arms applying Floor(f+0.5) — so f32/f64 (the float is stored), decimal128 (exact at its scale), date (owns uniform_date) and packed_bool (bernoulli runs ahead) are excluded. A type list ALONE is insufficient (a u16 share-of-wallet is in the defect, a u64 ID must not be), so the observed-level cap separates them.
maxDiscreteLevels (64, package constant, no flag) is where capture ABANDONS the histogram, never truncates it — a top-64-of-3,000 histogram makes every share a share of an arbitrary subset — and the ABSENCE of the discrete key IS that record, structurally rather than conventionally. Above the cap the field keeps the clamped normal. The boundary is PRAGMATIC, not a fidelity cliff: the clamped normal’s per-level RELATIVE error is scale-invariant; only its absolute error shrinks (~1/K).
Three writers: its own sampler draws the staircase; a conditional pair locates the cell’s captured moments ON that staircase — value = Q(Φ((cellMean−fieldMean)/fieldStd + (cellStd/fieldStd)·z)), the SAME construction the model stage uses; a model draws through the staircase Q, an ordered probit (predictors shift the latent, the histogram holds exactly, direction and ordering carry, scale-point magnitude does not).
Consequences: no POINT latent inverse, so latentFor refuses it as it refuses bernoulli and recovery runs on the probit score. Value-scale correlation on a discrete participant attenuates (synth-correlations). And the pre-rounding gotcha DISAPPEARS for these fields — the row value already IS the stored integer, so {"set_expr": {"nps": "round(nps)"}} is a no-op. That advice stays live only for f32/f64, an integer column over the cap, and a hand-authored continuous distribution on an integer field (synth-rule-expressions).
See
op-synth-bernoulli·op-synth-discrete·type-packed-bool.synth-profile-capture— the full reconstruction table.synth-models— the composed model draw and the probit-score recovery.
name: synth-model-draw
description: How synth generates a modelled numeric — the composed latent draw, firing terms and the "other" catch-all, the single clamp, draw ordering, and why a coefficient is latent-scale and non-linear in value space.
type: guide
kind: design
applies_to: inspect, predict, manifest
covers: [synth from-profile, models, latent scale]
requires: [capability:synth]
Synth model draw
The composed draw
A numeric carrying a Spec.Models entry is NOT drawn from its marginal and overwritten. It is composed in one step at the END of drawRow (internal/synth/model_draw.go):
value = Q( Φ( μ(row) + σ·z ) ) μ = intercept + Σ fired coefficients (raw data scale,
standardised by the field's own mean/std)
σ = residual_std / std, z ~ N(0,1)
Φ / Q are copula.go’s own phi / quantileFor — the functions the correlation stage uses, so a modelled and a correlated field agree on what the marginal means. Standardising first is load-bearing: an OLS fit is in DATA units, Φ takes only a standard normal. For a plain normal target the round trip collapses to prediction + residual_std·z, the ordinary OLS draw; routing through Φ/Q anyway is what lets a uniform/lognormal/mixture/discrete/bernoulli target keep its own marginal while the predictor drives it. z comes from the residual correlator when the field participates (synth-residual-correlations), else a fresh normal.
A term fires on an exact match only; a non-firing term contributes ZERO. The dummy-coded reading, not a convenience default: predictors are read against a dropped reference folded into the intercept, so “nothing fired for this field” IS “this row sits at the baseline”. A level the fit never saw reads as the reference; a NULL predictor contributes zero too, since listwise deletion means the coefficients say nothing about such rows.
The top-K catch-all is NOT an unseen level — it has its own firing term. predictors[].kind is a three-value wire vocabulary (categorical_level, set_option, numeric); the catch-all is a categorical_level whose level is the literal "other". Those rows are real and often DOMINANT: --conditional collapses out-of-top-K values to that spelling, categoricalPairSampler resamples straight out of them, and the model stage runs last so it reads what the row carries. The field’s own marginal cannot produce it (SpecFromProfile builds a categorical from Categorical.Top, which truncates and RENORMALISES rather than appending a bucket), so the pair stage is the whole path.
Getting that kind wrong is SILENT and costs the WHOLE model — modelSpecFromProfile refuses a model wholesale on one unusable predictor rather than leaving survivors read against a vanished baseline. A default: arm mapping the catch-all to numeric is how most captured models went silently unapplied for a whole development cycle, with a plausible cohort generated either way; modelPredictorKind now names every dummyColumnKind and TestModelPredictorKind_CoversEveryDummyColumnKind pins the enum’s cardinality. numeric stays REFUSED at translation (a genuine scalar predictor has no generation-time term); a document carrying the old spelling is not rehabilitated on read — re-capture is the answer, since read-side tolerance would freeze the bug into the file format.
Exactly ONE clamp, on the final data-scale value: the model’s own min/max (the target’s observed bounds, on FieldModelSpec) win, the field’s marginal clamp is the fallback — so model stage and copula stage cannot disagree about the admissible range.
Ordering and determinism. The model stage runs LAST among the draw stages, after every pair stage and the correlator, because its predictors are categorical levels and set bits those stages can still rewrite. Drawers sort into schema field order at setup (never models array order, never map order) and each consumes exactly one normal per row unconditionally, before any branch, so the stream depends only on the number of surviving models. residual_std of 0 still consumes its draw and multiplies by zero. A target whose marginal std is 0 is dropped with a warning (never an infinite 1/std). No models ⇒ zero drawers ⇒ byte-identical to pre-model output.
Latent-scale effects — read this before reading a coefficient
A coefficient shifts the LATENT Gaussian, not the value. μ lives on the standard-normal scale; the value is Q of the shifted latent. Only an affine Q — i.e. normal — carries a coefficient to the data scale unchanged.
Under a mixture, lognormal or any non-normal Q the same coefficient moves the value by an amount depending on where the row landed: near a bimodal trough it moves MASS between modes and barely moves values inside either; in a tail it moves the value a lot. bernoulli is the sharpest case (a step, so the draw is a probit and the coefficient is neither a probability change nor a value change), discrete’s staircase the same (synth-marginals). Neither has a point inverse, so recovery uses the calibrated probit score.
Direction and monotonicity hold; magnitude in data units does not. Do not multiply a coefficient into data units; do not compare two across targets with different Qs as one currency. latent_scale on a fidelity entry lets a reader multiply back; the unit making one tolerance meaningful across every field is one target standard deviation.
The value-space alternative — draw the marginal, then ADD the prediction — destroys the fitted marginal it was meant to protect. Rejected; do not quietly reintroduce it.
A --fit-shape mixture target draws through this same construction with the mixture as Q: synth-shape-fit.
See
synth-models·synth-model-selection·synth-residual-correlations·synth-model-recovery.synth-shape-fit·synth-determinism.
name: synth-model-recovery
description: The synth fidelity report's `models` section — refitting each applied model on the generated rows, comparing on the latent scale (or a calibrated probit score for step targets), flagging bands, and how to read a flag on a quantized target.
type: guide
kind: design
applies_to: inspect, predict, manifest
covers: [synth from-profile, fidelity report, models, recovery]
requires: [capability:synth]
Synth model recovery
Both recovery sections (this one and synth-residual-recovery) belong to --fidelity-report (its framing, marginals and pairwise section: synth-fidelity-report), are omitempty — a report for a profile captured without --fit-models is byte-identical — and score ONLY relationships generation actually applied.
Model recovery (models)
For every applied linear model, a refit on the _synthetic partition: captured coefficient beside recovered, per predictor. The only section asking whether captured structure survived rather than whether rows look like the source — the instrument for the silent structure-loss class this feature has shipped twice, each time with every marginal healthy while the conditioning was gone. It also settles the compression question: a modelled numeric’s mean spread across a predictor’s levels is visibly compressed against source, and recovered ≈ captured means that gap is the legitimate partial-effect-vs-marginal-contrast one (widened because predictors draw from their own marginals, so source confounding is absent by design), while recovered itself attenuated means a real fault. Nothing else tells them apart.
- Latent scale, both sides. Each generated value is inverted through
latentFor(the algebraic inverse of thequantileForthe draw used) before the refit, and the captured coefficient is divided by the target’s marginal std — precisely the divisionbuildModelDrawersperforms. The two switches MUST move together (round-trip-gated per distribution) or a distribution silently drops out of the section. Regressing raw values would compare a value-space effect against a latent coefficient and report every--fit-shapetarget as badly recovered while generation was exactly correct.latent_scalerides each entry;scalesays"latent". - A STAIRCASE target (
bernoulli,discrete) recovers through a CALIBRATED probit score, markedscale: "probit_score". A stepQhas no point inverse, so the score isΦ⁻¹of each level’s cumulative midpoint — a monotone re-expression, not an estimate — and systematically ATTENUATED. Honest because the SAME score is computed TWICE: once from the generated values, once as the conditional mean the CAPTURED model implies per row (E[score | m], same residual). OLS is linear in its response ⇒E[b_observed | X] == b_expectedEXACTLY under faithful generation, so dividing byb_expected / capturedREMOVES the attenuation rather than estimating it. That ratio ships asscore_retentionand divides the standard error too — low retention is a WIDE BAND, not a suspect number. No K gate (K = 2is not degenerate, merely the case retaining least); belowminScoreRetention(0.05) the entry ships anerror. A staircase entry reports no recovered intercept — its cut points come from the captured marginal, which generation holds exactly. - Only applied models.
BuildModelFidelityre-runsresolveConflictsANDbuildModelDrawersagainst the same*Specgenerate()was given — both pure functions of the Spec — reproducing generation’s surviving set including the drawer compiler’s warn-and-skip refusals. A field with no model has NO entry, not an entry with empty values. A rule that determines a field retires its model, so the entry disappears too. - Unidentified terms are
error, never a computed-looking delta. A predictor whose level the output cohort never carries cannot be checked. Not the"other"catch-all, which fires: the design’s top-K andCategorical.Top’s rank on DIFFERENT bases (design = frequency within the rows the fit listwise-admitted for that target; marginal = the whole cohort), both keep the same count, and they disagree. - Zero-predictor models are
marginal: true, not failures — a complete model, so nopredictorsarray (an empty one would read as failure) and the intercept comparison only. - Flagging band.
flaggedfires only when the gap exceeds bothinternal/synth.ModelRecoveryTolerance(0.10 latent sd) and twice the refit’s own standard error.n_firedseparates “fired thousands of times and recovered nothing” (a fault) from “never fired” (nothing to recover). - Same engine, same adapter, unpenalized.
internal/processing/regressionthroughdummyRecord, exactly as capture did — a second estimator makes every gap ambiguous between “generation lost structure” and “two fitters disagree” — and unpenalized even when capture shrank its own, since the captured coefficient being compared against is the shrunken one. An ordered-probit refit would be more EFFICIENT, not more correct. - A flag on a QUANTIZED target is about the COHORT, not the instrument. The composed draw holds the marginal only while the generated latent is standard normal (
synth-marginals, Boolean marginals), so a modelled target’s realised conditioning is systematically a little weaker than the captured model asked for. Survey data is mostly binary and small-integer, so this is the common case.
Cost. The synthetic partition is decoded ONCE (decodeSyntheticRows) and every section rides that slice — a per-entry decode reintroduces the O(pairs × records) blowup that cache exists to fix. Refits consume at most modelRecoveryRowCap (10,000) rows, capture’s own snapshot size, so neither side is estimated from more rows than the other.
Gates: TestBuildModelFidelity_DiscreteTargetRecoversItsCapturedCoefficients, TestBuildModelFidelity_DiscreteTargetFlagsBrokenGeneration, TestBuildModelFidelity_BooleanTargetRecoversAtKEqualsTwo, TestBuildModelFidelity_InvertibleTargetKeepsTheLatentScale.
See
name: synth-model-selection
description: How synth `--fit-models` chooses predictors — the top-K collapse, the variance-explained floor, marginal scoring and refit-on-rank-deficiency, main effects only, zero-predictor models — and how thin levels are shrunk by ridge rather than dropped.
type: guide
kind: design
applies_to: inspect, predict, manifest
covers: [profile create, models, predictor selection, shrinkage, top-k]
requires: [capability:synth]
Synth model predictor selection
Predictor selection — automatic, absolute, no knob
Two mechanisms, this order (internal/synth/profile_models_select.go).
(1) Top-K collapse — structural. A candidate categorical contributes one column per top---top-k level plus ONE catch-all carried as an ordinary level named "other" (otherCategoryLabel) — the same collapse Categorical.Top and categorical_pairs apply, so collapsed buckets mean one thing throughout a document. This is what makes the flag WORK, not a refinement: unbounded, a wide survey cohort blows past maxModelColumns, every target is skipped, and --fit-models produces NOTHING on real data. The catch-all is never the reference level.
(2) Variance-explained floor — statistical, over the collapsed set. A candidate FIELD enters iff the adjusted share of the target’s total sum of squares between the candidate’s groups reaches synth.minVarianceExplained (0.01, Cohen’s small-effect boundary). Package constant, not a ProfileOptions field (TestModelSelection_NoUserFacingOverride — no capture flag but --top-k moves an admission). NOT significance — at large n everything is significant, so a p-value gate is “all predictors enter” in disguise, silently, since every coefficient it admits is real and merely negligible. An absolute floor is n-invariant by construction (TestModelSelection_AdmissionInvariantToRowCount). Adjusted, not raw, η², so a wide collapsed field is not admitted for its width alone.
Scoring is MARGINAL — one candidate at a time against the RAW target, never against another’s residual — so two collinear candidates (a banding beside the exact value; a region nested in a DMA) both clear and both enter; incremental scoring would make the admitted set depend on candidate order. Redundancy is the solver’s job: a design refused as rank-deficient is refitted with its weakest-scoring admitted predictor dropped, up to maxModelRefitRounds (4) times, so the survivor of a nesting is the one explaining more. A functional-dependency probe is NOT attempted — the dependency that bites is LINEAR, not functional.
Selection also prunes columns degenerate ON THE ROWS THE FIT ADMITS: selection scores pairwise, the fit deletes listwise, so a level with pairwise support but no listwise rows is an identically-zero column the solver refuses. A single-level candidate drops by the same arithmetic, not a special case.
MAIN EFFECTS only — no interactions. One coefficient per (field, level), summed; cannot express “brand X matters only in region Y”. A real limit: one pairwise interaction between two collapsed 33-column fields is 1,089 columns, and the scoring rule, the wire format and the recovery refit all assume one term per (field, level).
A target no candidate clears is NOT skipped — it gets a zero-predictor model (own mean + spread, r2: 0), warned carries no predictors: …, never skipped: …: a complete model and a failed capture mean opposite things. maxModelColumns (256) is a backstop against a pathological schema, not a working limit (the accumulator is O(p²) per row).
Thin-level shrinkage
Selection picks FIELDS; this decides how far to trust their individual LEVELS. Top-K bounds RANK, not SUPPORT; listwise deletion cuts again; a coefficient from 4 rows is noise generation reproduces as a confident invented offset.
Trigger: a design column whose listwise support (rows the fit ADMITS, not cohort frequency) falls below internal/synth.minLevelObservations (50) ⇒ the whole fit switches to the engine’s ridge, Penalty: "l2", alpha = 50 / n_obs — never a hand-rolled penalty. Its own constant, NOT MinPairObservations (30): that asks “enough co-occurrences for a correlation to mean anything”, this asks “enough rows for a free coefficient to describe the level rather than the sample”.
alpha is a pseudo-count — the engine adds n·alpha to each Gram diagonal, so a level keeps n_j/(n_j + 50) of its free coefficient (3 rows keep 6%, 50 keep half, 500 keep 91%). It SCALES with thinness rather than switching on at the threshold, which is why one global alpha suffices. Shrinkage is toward the field’s reference level (what a dummy coefficient measures); a thin level collapsing onto the baseline is the conservative reading. Never refused, never dropped. shrinkage_alpha rides the wire because a shrunk and a free coefficient are different kinds of number and nothing else says which you hold; alpha × n_obs recovers the pseudo-count.
Three invisible consequences, none fixable by a different alpha:
- Well-supported columns move too, by ≈
50/n_j— bounded, small, not zero. - GATED on a thin column being present ⇒ clean designs stay exact OLS, at the cost of a bounded discontinuity at the boundary.
- A penalized Gram is positive-definite ⇒ the rank-deficiency refusal the refit uses as its redundancy signal does NOT fire; collinear predictors are jointly shrunk instead of one being dropped.
Warnings reuse thinSupportWarning, doubly bounded: aggregated by (field, level) across targets (thinness is a LEVEL property, so per-(target, level) emission restates one finding once per model), reporting the thinnest support and the model count, then maxThinLevelWarnings (20) thinnest-first plus a counted summary. Lower --top-k to fold rare levels into "other".
See
synth-models— capture and wire shape.synth-model-draw— how the"other"catch-all fires at generation.
name: synth-models
description: Entry point for synth `--fit-models` — per-numeric linear model capture and its wire shape, the latent-scale reading of a coefficient, what a model retires, and which focused skill covers selection, the draw, residual correlations and recovery.
type: guide
kind: design
applies_to: inspect, predict, manifest
covers: [synth, models, residual_correlations, fidelity, latent scale, predictor selection]
requires: [capability:synth]
Multi-predictor synthetic models
--fit-models = several drivers conditioning one numeric at once. The conditional-pair arms (synth-profile-capture) are pick-one: each overwrites its target, so on a wide cohort every categorical claims the same numeric and all but the first are dropped. One additive model accounts for all of them in one expression.
Contract surface for that machinery. Calibration figures and closed design questions: docs/src/cli/synth-calibration.md.
| Question | Skill |
|---|---|
| Which predictors enter; how thin levels are shrunk | synth-model-selection |
| How a modelled value is drawn | synth-model-draw |
A --fit-shape mixture target under a model | synth-shape-fit |
Correlating modelled fields’ residuals (--residual-correlations) | synth-residual-correlations |
Did captured coefficients survive? (models fidelity section) | synth-model-recovery |
Did residual correlations survive? (model_residual_correlations) | synth-residual-recovery |
| Claim order; a rule that retires a model | synth-conflicts · synth-rule-claims |
Capture (--fit-models)
One least-squares linear model per numeric field, regressed on the categorical levels and set_* options selection admitted, through internal/processing/regression’s streaming OLS engine.
Cohort read ONCE; fits do NOT run per row. The scan only retains a bounded Algorithm-R snapshot (modelResidualCap, 10,000, on its own RNG stream off --seed so --conditional’s reservoir is never perturbed); selection, fitting and residuals run over that snapshot afterwards — forced, since a frequency ranking does not exist until rows have been seen. Wire consequence: n_obs is the fit’s support WITHIN the snapshot (≤10,000), not the cohort’s row count (Profile.RowCount is that).
One reference level per categorical drops into the intercept (full level set + intercept is rank-deficient). Set options are all kept — a multi-select is not a partition.
A set_* predictor is read through setMaskFromWideEntry, at EVERY rung — the snapshot holds an encoding.SetMask, and a .(uint64) assertion on a wide rung reads present == false: MISSING, so every row is deleted listwise and the target reports carries no predictors, an ordinary outcome that carries no signal. The design-column reader (dummyRecord) and the recovery refit’s reader (recoveryIndicator) share the helper (synth-set-fields).
{field, intercept, predictors, references, n_obs, r2, residual_std, shrinkage_alpha}
predictors[] = {kind, field, level, coefficient} kind ∈ categorical_level | set_option | numeric
references[] = the level each categorical dropped into the intercept
- Coefficients are addressed by (field, level). The solver’s internal design-column name is
json:"-"— serialising it would freeze that encoding into the file format. references[]is why a reader holding k−1 of k levels need not guess the baseline. Set-only model ⇒ none.shrinkage_alphaomitempty; absent ⇔ unpenalized OLS (synth-model-selection).- Fitted residual reservoir (
FieldModel.Residuals/ResidualPresent) alsojson:"-"— reach it viasynth.Profile.FittedModels(), never a re-read document. - No flag ⇒ no
modelskey ⇒ byte-identical to a pre-flag document. An unsolvable design, or a fit that would serialise a non-finite float, loses only ITS model, named inProfile.Warnings. Never a refusal.
Latent-scale effects
A coefficient shifts the LATENT Gaussian, not the value — the draw is value = Q(Φ(μ + σ·z)). Only an affine Q (normal) carries it to the data scale unchanged. Direction and monotonicity hold; magnitude in data units does not: a coefficient of 0.556 on a 0–10 NPS field is NOT “0.556 points”. Never multiply one into data units or compare coefficients across targets with different Qs. Restate this wherever a coefficient is surfaced. Full reading: synth-model-draw.
What models retire
Numeric-target conditional pairs retire PER TARGET, never per document. A numeric landing a model on the Spec is left out of Spec.CategoricalNumericPairs / SetNumericPairs — one additive model already accounts for every predictor, whereas those arms each overwrite the same numeric and all but the first are dropped as conflicts. A model also PRE-CLAIMS its target in resolveConflicts, so a hand-authored spec declaring both loses the pair and is told which.
Those two guards make models and the two numeric-target fidelity sections disjoint by construction: a modelled numeric has NO entry in categorical_numeric_pairwise / set_numeric_pairwise, ever, since the pick-one sampler they score never runs for it. categorical_pairwise / set_categorical_pairwise / set_set_pairwise are untouched.
A model dropped in translation (unknown predictor kind, or a zero-predictor model) leaves its field’s pair STANDING and warns model for numeric field %q not applied: …. Under a per-document rule such a field lost the pair too and was reconstructed from nothing at all — strictly worse than before the flag existed.
The three non-numeric-target arms (CategoricalPairs, SetCategoricalPairs, SetSetPairs) still arbitrate pick-one; a linear model targets a numeric and subsumes nothing they describe. --conditional + --fit-models keeps both halves. Absent models, every arm populates exactly as before.
See
- The focused skills in the table above.
synthetic-data— modes, distributions, the determinism rule;synth-fidelity-report— the rest of the report.regression-modeling— the OLS engine both capture and the recovery refit drive.docs/src/cli/profile-create.md/docs/src/cli/synth-from-profile.md— the CLI surface.
name: synth-profile-capture
description: What `pulse profile create` captures per field and per pair (conditional pairs, thin-pair warning, run-continuation, shape fitting), how `SpecFromProfile` reconstructs each field, and how categorical joint structure is generated.
type: guide
kind: design
applies_to: inspect, predict, manifest
covers: [profile create, synth from-profile, conditional, fit-shape, run-continuation, SpecFromProfile]
requires: [capability:synth]
Synth profile capture
Capture via pulse profile create (Pulse.Profile); synth via pulse synth from-profile. Every section below is additive and omitempty: a capture without its flag emits no key and is byte-identical to a pre-flag document.
Profile mode
Per field:
- Numeric: mean, std, min, max, optional percentiles, null-rate, plus
discrete(per-level histogram) for a capped integer column (synth-marginals). Categorical: top-K + frequencies, cardinality, null-rate. Date: range, weekday histogram, null-rate.set_*:synth-set-fields. - Pairwise: strongest
|rho|(capped by--correlation-top-k). --conditional(all underconditional.):conditional.numeric_pairs{a,b,rho,n}row-aligned,nthe TRUE co-occurrence count (both fields non-null on one row) — preferred overpairwisewhen present, falling back unchanged when absent, so no older document is invalidated.conditional.categorical_pairs{a,b,cells,n}over at most 10,000 rows by genuine Algorithm-R reservoir sampling (--seed), NOT first-N, so a block-ordered source is unbiased; two caps compose — each field’s--top-kcollapses to"other"FIRST, thenContingencyCellCap(128) folds the tail into one merged("other","other").conditional.categorical_numeric_pairs{a,b,categories,n}, one online{category,mean,std,n}per category (no reservoir cap, no second joint cap).- Thin-pair warning:
n < 30(internal/synth.MinPairObservations) still SHIPS — never refused — with a warning naming the pair (thinPairWarning). Every pair kind reuses this one mechanism, and thin model levels share its line shape. --fit-models(models) and--residual-correlations(residual_correlations, requires--fit-models):synth-models.--suggest-rules <path>(rule candidates, written to their own file, never into the profile):synth-rules-from-profile.--run-continuation(run_continuation): per-field share of adjacent row pairs whose on-wire bytes + null bit repeat — exactly the run-skip decode’s hit rate — plusoverall,high_fields(rate ≥ 0.75) andadvice(low:below 0.5, pointing at an upstreamORDER BYof the parent key). A ROW-ORDER fact, not a distribution:SpecFromProfilenever reads it. It needs every record, so it is not inpulse inspect. Pairs never span a shard; a shard archive profiles as one stream against the canonical schema (anarchive.pulse#shard.pulseanchor is not resolved).--fit-shape(numeric.shape, numeric only): a 2-component Gaussian mixture kept only on a genuine BIC improvement;SpecFromProfilethen emitsmixtureinstead ofnormal. Fit rules and how it composes with a model:synth-shape-fit.
Capture rides ONE cohort scan; no flag adds a read.
Reconstruction (SpecFromProfile)
| Captured field | Rebuilt as |
|---|---|
packed_bool | bernoulli (synth-marginals) |
| integer with ≤64 observed levels | discrete (synth-marginals) |
| other numeric | normal clamped to observed min/max, or mixture when shape is present |
| categorical | weighted_categorical |
| date | uniform_date |
set_* | set_bernoulli |
| nothing summarised (always-null) | a typed constant placeholder with null_rate 1.0 — every row null |
Unsupported → PULSE_PROFILE_FIELD_UNSUPPORTED. Captured pairs land on Spec.CategoricalPairs, Spec.CategoricalNumericPairs, the set pair slots and Spec.Correlations; which of them actually run is arbitrated once (synth-conflicts). A numeric that lands a model keeps NO numeric-target conditional pair — the model replaces it, per target (synth-models).
synth from-profile --emit-spec <path> writes the spec that actually generated — the one place to read which distribution each field reconstructed to (synth-rules-from-profile).
Categorical joint structure (generation)
Spec.CategoricalPairs / CategoricalNumericPairs drive two resample steps after every field’s own draw and before the correlator: categorical-categorical resamples B from the captured per-a_value distribution over B’s cells (falling back to B’s POOLED marginal for an unseen A value); categorical-numeric resamples numeric B keyed on A’s drawn value, clamped to B’s observed [min, max]. Both are no-ops when Conditional was absent at capture — a plain profile reproduces independent-marginal generation byte-for-byte. Each pair is PICK-ONE: it overwrites its target, so several drivers conditioning one numeric is a model’s job, not a pair’s.
See
synthetic-data— modes, spec shape, distribution registry.synth-marginals·synth-set-fields·synth-shape-fit·synth-correlations·synth-conflicts·synth-models·synth-determinism.docs/src/cli/profile-create.md— every capture flag, with examples.
name: synth-residual-correlations
description: Synth `--residual-correlations` — capturing the correlation among fitted model residuals (full submatrix, measured vs unmeasured), the correlated residual draw that lets a numeric be both conditioned and correlated, and its zero-predictor boundary.
type: guide
kind: design
applies_to: inspect, predict, manifest
covers: [profile create, synth from-profile, residual_correlations, models]
requires: [capability:synth]
Synth residual correlations
Capture (--residual-correlations)
Requires --fit-models. The correlation structure among fitted model RESIDUALS. Once a numeric’s systematic variation is explained by its predictors, what is free to move at generation time is the residual — and a raw correlation double-counts every predictor two targets share (two fields both driven by region correlate on raw values while their residuals may be independent). Computed off FieldModel.Residuals in memory after the fits: no extra cohort read.
Shape {fields, pairs, unmeasured} — sorted participant set, {a,b,rho,n} per MEASURED pair, {a,b,n,reason} per unmeasurable pair; exhaustive, non-overlapping. Full submatrix, never top-K: the consumer is a joint draw over every participant at once and a matrix is not a ranked list — an omitted pair is a hole the factorization must fill, not a weak pair. --include-correlations / --correlation-top-k keep their meaning (top-K pairs of RAW values); this shares neither flag and is NOT implied by --fit-models, being quadratic in modelled fields.
Measured-zero and unmeasured are structurally different, never conventionally different. A dense N×N array has one slot per pair, so “unmeasured” would have nowhere to live but a sentinel — and 0 is a perfectly ordinary measurement. Two lists keep the distinction across a JSON round trip, and an unmeasured entry carries no rho key at all. Two closed reasons: insufficient_overlap (fewer than minResidualPairObservations = 3 rows carry both residuals; two points always give ±1) and no_variance (overlapped, one side’s residual constant — 0/0 is not 0). That floor is NOT MinPairObservations (30): 30 answers “stable enough to trust” and its answer is a WARNING; this answers “is there a measurement at all” and its answer is a GAP. They compose. Gaps get one summary warning; thin measured pairs warn thinnest-first, capped at 20. Capture is byte-reproducible (sorted participants, no map-order float folds).
Participants = every model carrying a residual vector, INCLUDING zero-predictor models — such a target still has a residual (its whole deviation from its own mean), and excluding it drops real structure for an unrelated reason. A model with a NIL residual vector (a Profile decoded from a document) is not a participant.
The correlated residual draw
SpecFromProfile translates the MEASURED pairs onto Spec.ResidualCorrelations; the unmeasured list becomes NOTHING (an absent pair is completed-and-counted; a written zero would present a gap as a measurement). A modelled field named there reads its own component of ONE correlated standard-normal vector drawn per row (internal/synth/residual_draw.go) as the z of Q(Φ(μ + σ·z)); every other drawer takes its own fresh normal. That is the whole of how a field is both conditioned and correlated — predictors move μ, the shared vector correlates the residual, neither overwrites the other. Cholesky, assume-and-record policy and ridge report are the SAME machinery the value-scale matrix uses (factorCorrelations): one construction, two consumers, so a policy cannot land on one scale and miss the other.
Component order is drawer order (= schema field order), never residual_correlations array order, or the cohort would depend on how the request was serialized. The vector consumes exactly one normal per participant, in component order, BEFORE the first drawer runs; a non-participant still consumes its own draw where it always did. Fewer than two surviving participants ⇒ no correlator, no draw, so a spec declaring none stays byte-identical. Participation is decided from the COMPILED drawers: a pair naming a field whose model was dropped is dropped with one aggregated warning. validateSpec refuses a hand-authored residual correlation naming an unmodelled field.
For a normal target the latent→value map is affine ⇒ realized value-scale residual correlation is the captured figure exactly. Under lognormal / mixture / discrete / bernoulli the RANK correlation is exact and Pearson attenuates.
A value-scale correlations entry naming a modelled field is refused, permanently. Realizing it would overwrite the model’s output; rerouting it into the residual would apply every shared predictor a second time. resolveConflicts words it is not applied … capture residual correlations (profile create --residual-correlations) to correlate a modelled field — deliberately NOT a conditional relationship conflict, because no pair claimed the field.
The zero-predictor target is the feature’s real boundary. Selection emits a zero-predictor model for a target nothing explained; capture admits it as a participant; modelSpecFromProfile DROPS it at translation, because a field nothing explains is better served by its captured conditional pair than by an empty model — so its captured residual pairs reach the spec as nothing. Reversing the drop would retire those fields’ pairs in exchange, a worse trade, so the boundary is pinned by test rather than closed.
See
synth-models·synth-model-draw·synth-correlations.synth-residual-recovery— whether the correlations survived generation.
name: synth-residual-recovery
description: The synth fidelity report's `model_residual_correlations` section — captured vs recovered residual correlation per applied pair, why it is not `pairwise`, its bounded worst-first listing, and why step-target endpoints stay unmeasured.
type: guide
kind: design
applies_to: inspect, predict, manifest
covers: [synth from-profile, fidelity report, residual_correlations, recovery]
requires: [capability:synth]
Synth residual-correlation recovery
omitempty — a report for a profile captured without --residual-correlations is byte-identical — and, like every section, scores ONLY relationships generation actually applied (synth-fidelity-report).
Residual-correlation recovery (model_residual_correlations)
The models question (synth-model-recovery) asked BETWEEN fields: per applied residual correlation, captured rho beside recovered. Deliberately not folded into pairwise, and the two section names are the load-bearing distinction — pairwise scores Spec.Correlations (value-scale, realized by the copula), this scores Spec.ResidualCorrelations (residual-scale, realized by the residual correlator). Different numbers over the same two fields, and resolveConflicts excludes a modelled field from Spec.Correlations precisely so the two arms fire on DISJOINT field sets.
The recovered side uses the refit’s residuals, not residuals against the captured coefficients — those fold every coefficient gap models already reports into a correlation and state one finding twice. Scale is latent for the reason the coefficients are: generation correlates the zs. The model refits produce the residuals as a by-product, so both sections ride one decode of the synthetic partition.
Bounded because quadratic: compared / flagged / mean_delta / max_delta cover EVERY pair; pairs lists only the worst maxResidualRecoveryPairs (20) by absolute delta with omitted counting the rest, so an unlisted pair recovered at least as well as the last listed. flagged fires on ResidualRecoveryTolerance (0.10) ALONE — a correlation is unit-free and its SE is under 0.02 over thousands of rows, so a noise-scaled band would collapse onto the floor. Unmeasured pairs ride a separate list carrying captured_rho and no recovered slot (insufficient_overlap / no_variance, plus a recovery-only no_model_fit).
A STAIRCASE endpoint lands in no_model_fit, deliberately. A coefficient can be calibrated because OLS is linear in its response; a correlation cannot — the factor relating two score residuals’ correlation to the correlation of the residuals that drove the draws depends on the PAIR’s joint distribution rather than either marginal, and no projection removes it. On a survey cohort of staircase targets that leaves compared at 1, the rest counted under no_model_fit — counted, never silent. Quote the participant count with the figure: the total is C(applied models, 2), so the same finding reads as a different fraction on a cohort with a different applied-model count. Closing it needs a polychoric-style bivariate calibration or an ordered-probit refit; internal/processing/regression has neither (binomial+probit is a reserved, unimplemented link).
See
name: synth-rule-claims
description: Which synth rules pre-claim a field ahead of its linear model, conditional pairs and residual correlations, the four exclusions that are silent if got backwards, and why the fidelity report is the reason.
type: guide
kind: design
applies_to: inspect, predict, manifest
covers: [synth, rules, conflicts, models, pre-claim]
requires: [capability:synth]
Synth rule claims
What a rule retires
A rule that DETERMINES a field pre-claims it at priority 0 in resolveConflicts, ahead of the linear-model pre-claim — the rule pass runs last and an unconditional write wins outright, so nothing upstream should produce a value it discards. Three things stop for that field: its models entry, any conditional pair naming it, its place in residual_correlations. Each loss is warned in the ordinary wording (conditional relationship conflict: field "promoter" is already claimed by structural rule 0 (set_expr); dropping linear model).
A whole-field claim SUBSUMES that field’s set_* OPTIONS, so an unconditional rule writing a whole multi-select retires the per-option set-set / set-categorical pairs driving it. One-directional: an option-level claim does NOT reach the whole field, since a pair driving one option has said nothing about the others.
The report is the reason, not the wasted work. FidelityReport.Models asks generation’s own compiler which models ran, so without the claim a rule-overwritten modelled field ships a captured-versus-recovered coefficient delta for a value nothing kept — every number rendering, nothing saying it describes something that did not happen.
Four exclusions, each SILENT if got backwards:
| Rule shape | Claims? | Why |
|---|---|---|
set / set_expr, no when | yes | writes every row from inputs the field’s own generation does not supply |
carries a when | no | writes only some rows; claiming strips the model from the rest, leaving a plausible bare marginal |
set_null (any conditionality), owns_nulls or not | no | removes a value rather than supplying one — if gate then null else inferred needs the model to produce what non-gated rows keep. Ownership suppresses the NULL DRAW and claims nothing; the value is still the model’s |
null_together | no | copies one null decision; supplies no value |
set_expr reading its OWN target | no | transforms what generation produced rather than determining it |
The last is why the pre-rounding remedy {"set_expr": {"nps": "round(nps)"}} is safe: claiming there would leave the rounding applied to a bare marginal draw — the documented fix for one silent fault causing a larger one. Self-reference is detected on the PARSED expression, never by substring (nps_reason is not nps), and an unparseable expression answers “reads”, the direction that cannot lose structure.
One contract narrows. The pass still consumes no RNG, but a CLAIMING rule retires a model and a retired model stops drawing its own per-row normal, so the stream shifts — for a reason a warning names. No rules, or rules claiming nothing ⇒ byte-identical.
See
synth-conflicts— the full claim order;synth-models·synth-model-recovery.synth-rule-expressions— the pre-rounding remedy that must not claim.
name: synth-rule-detectors
description: The `--suggest-rules` detector rules that are silent if got backwards — rounded gates, co-missingness, near and always-null blocks, the narrow dependency search and discovered band edges — and why emitted candidate order is applied order.
type: guide
kind: design
applies_to: inspect, predict, manifest
covers: [profile create, suggest-rules, rules, detectors]
requires: [capability:synth]
Synth rule detectors
Detector rules that are silent if got backwards
- Numeric gates emit through
round(), uniformly, includingpacked_bool— a bare==reads the pre-rounding float and under-fires silently, and a file mixing bare and rounded gates teaches a reader that bare is sometimes fine with nothing saying which. Every emittedwhenis COMPILED against the rule layer’s own environment before it is written. gated_share(1 − P(gate)) is a CHECKING aid, not the ranking signal — the split test arithmetically implies it. Ranking is target count, then rows affected.- A gate whose ONLY gated level is the null pseudo-level is CO-MISSINGNESS, not value gating: counted, not proposed, or it would restate one finding once per block member.
- A NEAR block is REPORTED with its agreement and disagreeing row count, never emitted —
null_togetherhas no dial for “almost” and, unlike a gating candidate’swhen, nothing in it can be corrected. Measure is the null-SET overlap (nearBlockAgreement0.95), never row-level agreement: two independent fields each null at 1% agree on 98% of ROWS. - An ALWAYS-NULL column is its own finding and gets NO rule — it reconstructs as a typed
constantwithnull_rate1.0, so generation already reproduces it. Check the source or the slice profiled. - The dependency search is NARROW and the bounds ride the file: targets
packed_bool/u4only; sourcescategorical_*/packed_bool/u4with at mostmaxDepLevels(16) levels; exactly ONE source. A field missing from the file was not cleared, it was not examined. - Band edges are DISCOVERED, never encoded. A THRESHOLD form is preferred wherever the target’s value regions are contiguous in the source’s own order, because it is a TOTAL function — an unobserved source value lands in the nearest band rather than off the end of an enumeration.
formnames the reading:threshold/threshold_chaintotal,membership/membership_complement/enumeration_chainnot. - ONE dependency candidate per SOURCE, carrying its
null_togetherin the SAME rule (split out, the derivation runs afterwards and un-nulls every member) and every numeric term insideround()— so nothing writes the source and no!isnullguard is needed. Nowhen, so its targets are pre-claim-eligible: accepting one RETIRES their captured model, pairs and residual correlations. - Reported rather than proposed: an ALMOST-determined pair (1..
maxDepExceptions= 8 contradicting rows, counted order-independently; over 8 dropped AND unreported, being not “almost” anything), a CONSTANT column, a mutually-determining pair, and a target contested by two sources (written once, by the strongest). - Caps are PER DETECTOR, so twenty gating candidates cannot hide that the cohort has question blocks at all.
Emitted order is applied order
A candidate WRITING a field another candidate READS is emitted BEFORE it (ruleCandidateOrder). The detector preference — gating, blocks, dependencies — is a READABILITY choice kept wherever nothing forces a move, and a candidate is HOISTED only just before the one it must precede.
Gate-before-block is arithmetic rather than luck for the fields a gate WRITES (identical patterns classify identically at every level of every gate, so a gate takes a WHOLE block or none) and is chosen for the file’s actual purpose — being EDITED, where a hand-narrowed set_null over PART of a block is repaired by a block that follows it. The equivalence is FALSE for what a gate READS: a block member that is a gate’s when FIELD moves the gate’s own INPUT after the gate read it, so the gate fires on the drawn value, the block then nulls the field it was reading, and the target it left present becomes an answer on a row whose screener the same file says was never asked. The trade is deliberately asymmetric — the repair property protects an edit that may never be made; the ordering fault corrupts rows unconditionally.
Reordering by hand: keep writers before readers. A mutual pair (each writing a field the other reads) has no satisfying order — the closing edge is dropped, the survivor decides the pair (possibly inverting the detector preference, because an edge is a constraint and the order a preference), and the drop is WARNED about rather than resolved silently.
See
synth-rules-from-profile— the detector table, evidence and thresholds.synth-rule-nulls·synth-rule-claims.
name: synth-rule-expressions
description: Synth `constraints[]` and the expression side of `rules[]` — the row environment's types, `isnull`, the `set_expr` coercion matrix, set-option addressing, and the pre-rounding gotcha with its `round` (not `int`) remedy.
type: guide
kind: design
applies_to: inspect, predict, manifest
covers: [synth, constraints, set_expr, isnull, coercion, pre-rounding]
requires: [capability:synth]
Synth rule expressions
Constraints
constraints[] reuse expr-lang/expr. Any constraint returning false rejects the row; the generator redraws until row_count. Reject cap 50% (max_rejection_rate); beyond it PULSE_SYNTH_CONSTRAINT_INFEASIBLE. Same-row reach only; no cross-row in v1.
Env types are the ROW’s types (sentinelFor, derived from fieldTypeFromName so the two cannot drift): every scalar — u4/u8…u64, f32/f64, date, decimal128 AND packed_bool — is float64; categorical_* is string; set_* is map[string]bool (flags["opt"]). So a boolean is tested flag == 1, never bare flag (a compile-time refusal, not a boolean). Typing packed_bool as Go bool was issue #258: compiled, then failed every row with PROCESSING_RUNTIME: invalid operation: bool(float64).
isnull(field) reads the per-record null mask — the only way to test absence, since a nulled field still carries its drawn value. Takes the field NAME: isnull(x) (rewritten to a literal) and isnull("x") are equivalent, and an undeclared name in EITHER form is refused before a row exists — bare by expr’s unknown-name check, string form by a parse pass (isnullUnknownField) applied to rules and constraints[] alike, never answered false. Only an argument that is a string at RUN time (isnull(region + "!")) reaches the loud run-time refusal.
set_expr and the coercion matrix
set_expr assigns an expression’s RESULT, writing the value and clearing the mask as set does — collapsing three when-gated rules into one. A wrong cell is a SILENT wrong value:
| result | scalar target | categorical_* | set_* |
|---|---|---|---|
| bool | 1 / 0 | refused | refused |
number (float64/int) | the number, RANGE-CHECKED | refused | refused |
| string | refused (see decimal) | must be a DECLARED value | refused |
selection (map[string]bool, option list, split()) | refused | refused | the selection |
Scalar = u4 u8 u16 u32 u64 f32 f64 date packed_bool decimal128. Range is the bound a set literal obeys (u4 0–15) — ONE shared matrix; NaN/Inf refused. Declared = weighted_categorical’s values / a set_*’s options; a regex or constant categorical is unbounded, so any string lands.
Set targets are addressed by option NAME, never by bit — the row value is a map[string]bool keyed by the field’s declared options, so every rule is width-agnostic and a 206-member set_u256 needs no new syntax; an option the spec does not declare is PULSE_SYNTH_RULE_VALUE_INVALID at whatever rung. What a rule cannot express is a partial edit or a cardinality constraint. set_expr assigns a WHOLE selection (it replaces the mask), and the rule vocabulary has no bit-count, no “add this member” and no “these two are exclusive” primitive. To build on the current selection, read the field back off the row — the coercion accepts a map[string]bool taken straight from it — and return the complete result. A constraint you cannot write that way is UNSTATED; do not reach for a bit index instead.
One asymmetry: a decimal128 takes an exact string only from a set LITERAL. set_expr refuses one — expr already reduced the value to float64, so exactness is gone, and a string left in a float64-typed row slot breaks the NEXT rule reading it, on gated rows only.
Two timings, one code. A fault the RETURN TYPE settles is refused at SPEC PARSE; one only a VALUE settles (range, category) at ROW time. Both PULSE_SYNTH_RULE_VALUE_INVALID, naming rule, slot, field, value. The static side owns no table — it probes the same matrix with the type’s zero and refuses only on a TYPE fault, so the two timings cannot disagree.
Pre-rounding
Pre-rounding. when / set_expr read the row’s PRE-ROUNDING float, not the wire value, so == k fires only on draws landing exactly on k. Use comparisons (>= 9). It bites set_expr hardest because the result still LOOKS right — a three-band NPS classification off raw nps sets exactly one flag per row and merely disagrees with the score beside it. Normalise in an EARLIER rule (snapshot semantics means the same rule will not do), with round, NOT int: an integer field is stored as floor(v+0.5), so round(v) reproduces the value the FILE will hold and moves the FLAGS onto the right side of the edge, while int(v) truncates and reaches the same band agreement by moving the SCORE down instead. Guard with when whenever the target is nullable — set_expr clears the null flag:
{"when": "!isnull(nps)", "set_expr": {"nps": "round(nps)"}}
LIVE only where row value and stored value differ — f32/f64, an integer column over 64 observed levels (a clamped normal), a hand-authored continuous distribution on an integer field. A small integer reconstructs as discrete and a packed_bool as bernoulli, so for those the row value already IS the stored integer and the normalisation is a no-op.
See
synth-structural-rules— slots and order;synth-rule-validation— the refusal codes.synth-marginals— why a small integer orpacked_boolis already exact.expression-language— the general expr environment the formula attribute and expression filter share (synth rules use their own row env).
name: synth-rule-nulls
description: Synth rule null handling — `null_together` as one null decision per question block (first field wins), and `owns_nulls` making a gate the field's only source of absence, with the divergence warnings each emits.
type: guide
kind: design
applies_to: inspect, predict, manifest
covers: [synth, rules, null_together, owns_nulls, set_null]
requires: [capability:synth]
Synth rule null handling
null_together
One null decision per block; the FIRST named field wins. A question block is asked or skipped as a unit; independent per-field null draws make it a lottery (four fields sharing null_rate 0.826 come back all-present at 0.174⁴, not 0.174). The rule COPIES nullMask[first] onto the rest — every field has already taken its null draw, so a copy is the only resolution adding no randomness.
- Cost: every other member’s
null_rateis IGNORED. A member more than 0.02 from the gate’s (nullRateDivergenceThreshold; ABSOLUTE, measured against the GATE not the block’s spread, because what the copy discards is a number of ROWS) warns as an ATTENTION kind, never a refusal — a real block whose fields drifted slightly would otherwise be unusable. - The gate is never reported by either warning: the copy READS it, so a non-nullable gate is not a field the block failed to null — and a never-null gate is the IDIOM, clearing the members’ own MCAR nulls so a later
set_nullbecomes the block’s only absence. - The copy moves the DECISION, never a value: a member the gate UN-nulls publishes what it drew, which is what makes the block share a RATE rather than only share its nulls.
- Within one rule it is the LAST write, after
set_null/set/set_expr:{"set_null": ["nps"], "null_together": ["nps", …]}nulls the gate and the block follows, so aset_nullover a NON-gate member of the same rule is overridden. Across rules, ordinary last-write-wins — and the case that bites is a laterset_expr(it clears the mask on every row it writes, un-nulling every member). Put both slots in ONE rule rather than trusting declaration order.
owns_nulls
The rule is the field’s ONLY source of absence. set_null states WHICH rows are absent and nothing about HOW MANY, so each target’s own null_rate keeps firing beneath the gate — and that rate is a MARGINAL the profiler captured, already inclusive of what the gate removed. The two compose as g + (1−g)·r: a gate exactly right about every gated row is still wrong about the marginal. "owns_nulls": true DISCARDS those fields’ own null draw.
{"when": "aware == 0", "set_null": ["regard", "charming", …], "owns_nulls": true}
- Scoped to
set_nulland nothing else — declaring it with an emptyset_nullisPULSE_SYNTH_RULE_OWNERSHIP_INVALID, because an ignored claim is invisible. - It zeroes the draw rather than applying the residual
(r − g)/(1 − g), becauseg— the rule’s FIRING PROBABILITY — is a property of the data no spec knows. The gap is therefore MEASURED: any owned field whose realised rate misses its discardednull_rateby more than 0.02 and two standard errors warns as an ATTENTION kind naming both rates, the row count and every claiming rule. The noise term is load-bearing on small runs. - Ownership is a UNION, not a
claim(): two gates may each own one field (the draw is suppressed once, either gate nulls it), sinceset_nullremoves a value rather than supplying one and never claims a field away from its own generation. - Suppression is a WRAPPER discarding the verdict (
ruleOwnedNullSampler), never a rebuild without the nullable wrapper —nullableSamplerconsumes one inner draw PLUS onerng.Float64()whatever the rate, so a rebuild shifts the whole seeded stream. null_togetheralready does this for its non-gate members, by COPY, so the gated-block idiom is ownership as a side effect.owns_nullssays it directly; the block stays REQUIRED for a co-missing block with no gate at all, where one member’s own draw is the block’s only absence.
See
synth-structural-rules·synth-rule-validation(non-nullable members) ·synth-rules-from-profile(detected blocks and gates).
name: synth-rule-validation
description: Synth rule validation and diagnostics — the eight eager `PULSE_SYNTH_RULE_*` refusals, the non-nullable-target refusal, and the never-fired warning with its three cause arms.
type: guide
kind: design
applies_to: inspect, predict, manifest
covers: [synth, rules, PULSE_SYNTH_RULE, validation, never fired]
requires: [capability:synth]
Synth rule validation
Eager validation
At spec parse, never at row time. A rule naming a mistyped field, or carrying an uncompilable expression, is invisible at run time: the run succeeds and the gate is simply absent. Eight codes, each naming the rule INDEX (rule_index — a rule has no name) plus slot and field where one exists:
| Code | Fires on |
|---|---|
PULSE_SYNTH_RULE_EMPTY | no action slot (_evidence alone is still empty) |
_FIELD_UNKNOWN | a slot’s name, AND an undeclared name inside isnull("…") — that expression COMPILES, so it is an unknown FIELD, not an _EXPR_INVALID |
_FIELD_NOT_NULLABLE | set_null only |
_EXPR_INVALID | uncompilable, or a when not returning bool |
_VALUE_INVALID | the coercion matrix, either timing |
_CONFLICT | one rule naming a field in two slots that DISAGREE (set_null + null_together do not disagree and are ordered instead) |
_BLOCK_INVALID | null_together naming fewer than two DISTINCT fields |
_OWNERSHIP_INVALID | owns_nulls with an empty set_null |
Faults report in a fixed slot order with SORTED map keys, so a rule with two faults refuses the same one every run. A when failing at RUN time stays PROCESSING_RUNTIME — the remaining causes are generic expr faults (a dynamic isnull argument, int() of a non-numeric string), not “a malformed rule”, and the coded error already carries rule index, slot and expression. pulse errors lookup CODE is authoritative.
Nulling a non-nullable field
A field not declared "nullable": true gets the type’s zero written as an ordinary value with NO null bit — the rule fires and the file cannot show it. set_null over such a field is REFUSED (PULSE_SYNTH_RULE_FIELD_NOT_NULLABLE); the fix is on the FIELD, and on a profile-derived run a field is nullable only if the source had nulls (--emit-spec, add the flag, from-schema). A null_together MEMBER only warns, because that slot copies the gate’s decision — which may be “present” on every row — rather than claiming a null. Do NOT “finish the job” by promoting it: the gated-block idiom names a never-null field first on purpose.
A rule that never fires
Eager validation cannot catch a when that is simply NEVER TRUE: the rule validates, compiles, applies to nothing, and the cohort generates cleanly with the structural fact still missing — the same silent-inertness class as a scored pair generation never applied. So firings are COUNTED during generation and a rule applying to ZERO rows warns, naming its index, its when verbatim and the row count the zero is out of. Kind ATTENTION (rule never fired), so it leads the stderr summary.
The count is over rows that reached the FILE. A constraint-rejected row was re-drawn and left no trace, so it is not a firing — which keeps the figure divisible by the row count and makes a rule firing only on rejected rows report the truth (zero). Firing on every row, and on some, are both silent. Many dead rules list at most 20 plus a counted +N further rule(s) never fired. Counting consumes no RNG and allocates nothing per row.
The message names the cause that applies to THAT rule, in three arms:
- CONSTRAINT — the rule DID select rows and a constraint rejected every one. Recorded during the run (attempt remembered, acceptance counted) rather than inferred, so the line blames the constraint and says nothing about rounding.
- PRE-ROUNDING — the predicate reads a field that rounds on write AND draws continuous values; remedy
round(field)in an earlier rule, neverint(). - NEUTRAL — neither. Names each field the predicate reads with its type and distribution, and RULES PRE-ROUNDING OUT for a field whose row value already IS the stored value.
Arm 3 is why the split exists: a field that rounds on write still draws exact values under discrete, bernoulli, uniform_date, poisson or monotonic_from, and a small integer reconstructs as discrete automatically — so on a survey cohort the pre-rounding advice is wrong for most columns, and sending an author to normalise an already-exact gate is worse than saying nothing.
See
synth-structural-rules·synth-rule-expressions(pre-rounding) ·synth-rule-nulls.tool-errors-lookup—pulse errors lookup CODEis authoritative.
name: synth-rules-from-profile
description: Reaching synth rules from the profile path — `--rules` (replace semantics, load-time validation) and `--emit-spec` (the spec that generated) — and `profile create --suggest-rules`: proposed candidates, inert evidence, fixed thresholds and the three detectors.
type: guide
kind: design
applies_to: inspect, predict, manifest
covers: [synth from-profile, profile create, rules, suggest-rules, emit-spec]
requires: [capability:synth]
Synth rules from a profile
Reaching rules from a profile
SpecFromProfile derives a spec, generates from it in one breath, and emits no rules, so pulse synth from-profile carries two flags that make the layer reachable.
--rules <path>loads a standalone rules document (the barerulesarray — cut and paste between spec and file) and REPLACESSpec.Rules. No append mode: a derived spec has nothing to append to, and one would only create an ordering question.{"rules": […]}refused rather than read as zero rules;[]accepted, meaning “no rules”. Validation runs at the LOAD against a COPY (a profile-derived spec bypassesvalidateSpecby design), so a refused file leavesSpec.Rulesuntouched.--emit-spec <path>writes the derived spec AFTER the merge as indented JSON. The spec that GENERATED, not a rendering: fed tosynth from-schemaat the same seed it reproduces the rows byte for byte. Authoring aid (field names, types, floors awhenmust be written against) and diagnostic (which models survived translation, which distribution each field reconstructed to, which pairs were retired). Written BEFORE generation, so a failing run still leaves it.
Generation-time warnings — POST-merge arbitration, rule-compilation, never-fired reports — also land in --fidelity-report’s warnings array, deduped against the translation channel.
Refusals name the FILE as well as the rule index (details.path on every PULSE_SYNTH_RULE_* raised by the load), so an analyst holding a rules file, a spec and a profile knows which document is wrong. Missing file DATA_FILE, malformed one SERVICE_VALIDATION; both name the path.
Detecting rules from the data (--suggest-rules)
Coverage bounds the layer’s value and rules get written from RECALL. pulse profile create --suggest-rules <path> runs three detectors on the SAME scan (no extra byte read) and writes the bare rules array --rules consumes UNMODIFIED. Per-detector treatment: docs/src/cli/profile-create.md.
Proposed, never applied. A detected pattern can be a coincidence of the sample, and detection finds the STATISTICAL gate while a human knows the SEMANTIC one — on a real cohort two different predicates gated the identical rows with identical evidence to sixteen digits, so nothing in the data could separate them. Read each candidate’s evidence, correct the when, delete the rest.
Evidence rides the rule on _evidence — RuleSpec’s ONE inert slot. Generation never reads it, an evidence-only rule is still PULSE_SYNTH_RULE_EMPTY, hand-authoring it is harmless. There rather than in a sibling block because the file must stay a bare ARRAY, and because evidence in a second document drifts the first time a candidate is deleted. Handles inside it are CONTENT-derived, never an index — the file is edited by deletion and every index below a deleted line shifts.
Thresholds are package CONSTANTS, never ProfileOptions fields — a capture flag that moved one would make two documents over the same cohort disagree about what the field IS. TIGHT because skip logic is EXACT in the source; a looser pair finds ordinary ASSOCIATION and proposes it as structure.
Over-cap input is ABANDONED, never truncated — a partial level map, field set or histogram makes every share below the cut a share of an arbitrary subset, the defect the detectors exist to remove. Caps: maxGateLevels (16 observed levels on a gate candidate), maxBlockFields (256 nullable fields), maxDepFields (256 dependency participants). Block accumulation is a per-field bitset over blockChunkRows folded by popcount, so memory is flat in the row count. Each abandonment is COUNTED in Profile.Warnings.
| Detector | Proposes | Admission rule (the non-guessable part) |
|---|---|---|
gating (detector: "gating") | set_null + "owns_nulls": true | a low-cardinality field (categorical_*, packed_bool, u4) whose levels split a target’s null rate into ≥gateHighNullRate (0.98) and ≤gateLowNullRate (0.02). P(null | open) IS the residual owns_nulls zeroes, so the flag is within 0.02 of exact BY CONSTRUCTION — without it the gate repairs CO-MISSINGNESS ONLY |
co-missing (detector: "co_missing", evidence on _evidence.block) | null_together | IDENTICAL NULL PATTERN, never an identical null_rate — two unrelated fields can share a rate to sixteen digits and overlap by chance, and the rule would then make the claim TRUE in output. Every emitted member carries agreement: 1 and max_null_rate_deviation: 0, so a block can never trip the divergence warning and member order is arbitrary rather than a ranking |
dependency (detector: "dependency", evidence on _evidence.dependency) | set_expr (+ its own null_together, same rule) | a field that IS a function of ONE other on every co-present row, AND identical null patterns — read from the co-missing accumulator rather than a second one, because a set_expr clears the null mask and would un-null the target wherever the source is absent |
Detector-specific rules and the emitted order: synth-rule-detectors.
See
synth-structural-rules·synth-rule-detectors·synth-rule-nulls.docs/src/cli/profile-create.md/docs/src/cli/synth-from-profile.md— flags and worked files.
name: synth-set-fields
description: How `set_*` (multi-select) fields are profiled per option, captured jointly per option pair, read safely on the wide rungs, and generated deterministically with a pre-registered dictionary.
type: guide
kind: design
applies_to: inspect, predict, manifest
covers: [profile create, synth from-profile, set_bernoulli, set_u128, set_u256, conditional]
requires: [capability:synth]
Synth set fields
Set (multi-select) field profiling
set_* fields are “select all that apply” bitmasks over one shared dictionary — bit i ↔ dictionary entry i. Each entry profiles as an independent Bernoulli sub-field for MARGINAL purposes: set.options[i].frequency is P(bit i set | field non-null).
profile create always captures set = {n, options: [{value, count, frequency}]} (no flag). options lists EVERY dictionary entry in bit order — never sorted by frequency like categorical.top — so two profiles of one schema compare position-for-position. n is the non-null row count. SpecFromProfile reconstructs the field as set_bernoulli from options, bounded only by MaxSetEntries().
Joint capture. --conditional extends to set_* pairs by running the per-pair-type machinery ONCE PER OPTION:
| Section | One entry per | Shape |
|---|---|---|
conditional.set_categorical_pairs | (set option, categorical field) | {set, option, categorical, cells, n}; a_value fixed "selected"/"not_selected", b_value the categorical value (own --top-k collapse) |
conditional.set_numeric_pairs | (set option, numeric field) | {set, option, numeric, categories, n}; categories keyed "selected"/"not_selected" |
conditional.set_set_pairs | option pair between two DIFFERENT set fields (never two options of one field) | {set_a, option_a, set_b, option_b, cells, n}, a 2x2 table |
The option axis never needs a top-K collapse (fixed two-value domain). Bounded by FieldType.MaxSetEntries() (8/16/32/64/128/256) and ContingencyCellCap (128). Thin cells reuse thinPairWarning.
Wide rungs
set_u128 / set_u256 are captured member-for-member, and the read is the whole risk. A narrow rung arrives in the decoder’s wide map as a uint64; a wide one arrives as an encoding.SetMask. Every site — the marginal, all three conditional pair kinds, the --fit-models row snapshot and its dummy design columns, the recovery refit’s reader and all four set fidelity sections — resolves it through the shared setMaskFromWide / setMaskFromWideEntry helpers and NEVER type-asserts .(uint64). A failed assertion yields the zero value, which for a set field is a perfectly plausible “selected nothing”: every member captures frequency: 0.0, every conditional cell lands in the not_selected arm, every model row is deleted listwise (the target reads as carries no predictors), every fidelity delta reads exactly 0.0, and nothing errors. setMaskFromWideEntry is the presence-reporting half — ok == false for a nil map, an absent key (the decoder deletes the entry for a null set field) or a non-mask value, i.e. MISSING rather than empty, which is what a model fit’s listwise deletion needs and a marginal does not.
A dictionary wider than its own rung’s mask is reported, never repaired. Members past MaxSetEntries() get no marginal, no pair capture and no fidelity comparison, which is indistinguishable in the document from an option nobody selects — so capture appends set field "x": dictionary carries N option(s) but the set_uK mask addresses K … to Profile.Warnings, grouped as its own ATTENTION kind (set options beyond the mask width) in the terminal summary. Widening is pulse widen’s job; the profiler states the loss and moves on.
Rules are width-agnostic — a rule addresses a member by option NAME (the row holds a map[string]bool), never by bit. But set_expr assigns a WHOLE selection: there is no bit-count, single-member edit or mutual-exclusion primitive, so a cardinality constraint over many members is unstated rather than expressible.
Generation
Each pair resamples ONE option’s bit conditioned on the paired field’s already-drawn value, in the fixed order set-set → set-categorical → set-numeric, after categorical joint structure and before the correlator. Two options on the SAME set field are two DIFFERENT claim targets, never a conflict (synth-conflicts).
A set_* dictionary is pre-populated ONCE from params.options’ declared order at schema-build time, never lazily during generation — the row value is a map[string]bool and Go randomizes map iteration per process, so pre-registration is what keeps bit assignment (hence determinism) independent of it. AugmentFromProfile requires both dictionaries to name the identical option universe in the identical ORDER (PULSE_SYNTH_PROFILE_SCHEMA_MISMATCH). A marginal-only profile still generates correctly — joint structure is additive, never required.
See
op-synth-set-bernoulli·type-set-u8·type-set-u256.synth-fidelity-report— theset_fieldsand set pair sections.synth-profile-capture— the other conditional pair kinds.
name: synth-shape-fit
description: Synth `--fit-shape` — the two-component mixture capture and its keep rule, and how a shape-fitted numeric still accepts model conditioning through its mixture quantile, with a fixed-count bisection for determinism.
type: guide
kind: design
applies_to: inspect, predict, manifest
covers: [profile create, synth from-profile, fit-shape, mixture, models]
requires: [capability:synth]
Synth shape fitting
Capture (--fit-shape)
--fit-shape (numeric.shape, numeric only): a 2-component Gaussian mixture (internal/synth/shape.go) kept only when it beats plain normal on BIC AND the means are ≥ 0.75*avgStd apart. fitTwoComponentEM runs a FIXED 50 iterations from a deterministic percentile init (no RNG) with a std floor at 5% of overall std. Fixed at 2 components, no sweep. SpecFromProfile then emits mixture instead of normal.
A packed_bool or a capped small integer never reaches this arm: their own bernoulli / discrete arms run AHEAD of it (synth-marginals).
Shape-fitted targets accept conditioning
--fit-shape and conditioning are NOT mutually exclusive, and FOUR independent sites must agree on that (the shape pre-claim in resolveConflicts, modelSpecFromProfile, fieldMoments, quantileFor) — any one alone silently strips the relationship, which is why a change to one looks like it works and then fails at the next.
The construction always admitted it: Q(Φ(μ+σ·z)) takes an ARBITRARY marginal in Q. A captured mixture carries exact moments (law of total variance) and a quantile function (internal/synth/mixture_quantile.go); the fitted mixture becomes Q, the predictors shift the latent, the two compose with nothing dropped and no warning. Effects are latent-scale, hence non-linear in value space — synth-model-draw (Latent-scale effects).
The quantile inverse is a FIXED-COUNT bisection, deliberately. mixtureQuantileBisections (64) steps over a bracket min(μ_i) − 40·max(σ_i) .. max(μ_i) + 40·max(σ_i), where Φ has saturated at exactly 0 and 1 so no root escapes. No convergence test, no early exit — a tolerance-terminated inverse makes the answer depend on how many steps a given p needed, and byte-determinism is not negotiable. Bisection over Newton additionally because the density underflows between well-separated modes and a Newton step there explodes.
An UNMODELLED --fit-shape field is untouched: draws its own mixture, holds its pre-claim, still excludes a pair or correlation naming it with the captured shape (--fit-shape) warning.
See
name: synth-structural-rules
description: Entry point for synth spec `rules[]` and `constraints[]` — the five rule slots, declaration order and last-write-wins, why the rule pass runs last, and which focused skill covers expressions, null handling, validation, claims and profile-derived rules.
type: guide
kind: design
applies_to: inspect, predict, manifest
covers: [synth, rules, constraints, set_null, set_expr, null_together, PULSE_SYNTH_RULE]
requires: [capability:synth]
Synth structural rules
Two synth-spec slots act ON a drawn row rather than describing its variation: constraints[] rejects a row and makes the generator redraw; rules[] masks or overwrites named fields on the rows a predicate selects. Both compile against the SAME expr-lang row environment (rowExprEnv) — a second hand-rolled env is what produced issue #258.
Every measurement behind a figure here, and the closed design questions: docs/src/cli/synth-calibration.md.
| Question | Skill |
|---|---|
constraints[], the row expression environment, set_expr coercion, pre-rounding | synth-rule-expressions |
null_together blocks and owns_nulls | synth-rule-nulls |
| Refusal codes, non-nullable targets, a rule that never fires | synth-rule-validation |
| Which rules retire a field’s model / pairs / residual correlations | synth-rule-claims |
--rules / --emit-spec, and detecting rules with --suggest-rules | synth-rules-from-profile |
| Detector-specific rules and the emitted candidate order | synth-rule-detectors |
Structural rules
rules[] (additive, omitempty) state what no statistical summary can — and what therefore comes back from marginals as soft noise with a plausible rate and no gate: a question block not ASKED of a respondent who failed a screener, a flag that is a band of another column.
"rules": [
{"when": "aware == 0", "set_null": ["perception_1", "perception_2"], "set": {"segment": "unaware"}},
{"set_expr": {"promoter": "nps >= 9"}},
{"null_together": ["nps", "nps_reason"]}
]
Five slots plus one modifier: when optional predicate (absent means EVERY row, not “never”); set assigns LITERALS; set_expr expression RESULTS; null_together nulls a block as one decision; owns_nulls changes what a set_null MEANS rather than adding an action. when must return bool.
set and set_expr are sibling keys, never one map with a {"$expr": …} marker — one map leaves {"set": {"region": "west"}} undecidable between a literal and an identifier.
The pass consumes no RNG and allocates nothing per row, so a rules-free spec is byte-identical to pre-slot output. The standalone rules-file format is this array itself — an inline declaration and a --rules file are the same JSON.
Order and placement
Declaration order, sequentially, LAST WRITE WINS, in ONE pass that is drawRow’s last act, after the model stage — and the placement IS the semantics: if gate then null else inferred. A gated field is still drawn through its own sampler, model and pairs; the rule masks or replaces it only where when selects, so non-gated rows keep the INFERRED value. NOT topologically sorted — declaration order is the one ordering an author can read off the document.
Accepted consequence: a field a rule NULLS still contributed its DRAWN value to any model using it as a PREDICTOR on that row. The two-phase alternative (evaluate gates, re-run dependent stages with gated fields withheld) is rejected; the single-phase reading is that the respondent’s propensity existed, the question was not asked.
| Slot | Value | Null mask |
|---|---|---|
set_null | LEFT as drawn (the shape nullableSampler produces; the wire gets the type’s zero, never the masked value) | set |
set / set_expr | written | CLEARED — a rule stating a field’s value states the field HAS one |
A set literal is coerced to the row’s Go shape once at compile time via constantRowValue, which turns a set_* target’s ["tv","radio"] into the row’s map[string]bool.
ACROSS rules an expression sees EARLIER writes, not LATER ones. WITHIN one rule order is UNOBSERVABLE: every expression, when included, reads the row as it stood BEFORE the rule ran, and all its writes land after — so {"set_expr": {"a": "b", "b": "a"}} swaps, like SQL UPDATE. A sibling set literal is invisible to a sibling set_expr; self-reference ({"nps": "nps + 1"}) works and does NOT pre-claim the field. The alternative exposes an order nobody can SEE: a rule’s slots are Go maps, JSON key order is gone after decode, and the surviving order is ALPHABETICAL, so renaming a column would silently change a number.
Rules reach GENERATED rows only — synth from-profile --source re-encodes the real partition straight through.
See
- The focused skills in the table above.
synthetic-data— modes, distributions, the determinism rule;synth-models— what a claiming rule retires from--fit-models.tool-errors-lookup—PULSE_SYNTH_RULE_*/PULSE_SYNTH_CONSTRAINT_*recovery;pulse errors lookup CODEis authoritative.docs/src/cli/synth-from-schema.md— CLI surface; therulesarray is also the standalone rules-file format.
name: synthetic-data
description: Entry point for synthetic data — `pulse synth from-schema` vs `pulse synth from-profile` (CLI + library; no MCP tool), the spec shape, the distribution registry, the determinism rule, and which focused synth skill answers each question.
type: guide
kind: design
applies_to: inspect, predict, manifest
covers: [synth from-schema, synth from-profile, profile create, synth, distributions, correlations, models, determinism]
requires: [capability:synth]
Synthetic data
Pulse synthesizes deterministic .pulse cohorts via the CLI leaves pulse synth from-schema / pulse synth from-profile (library Pulse.Synth; no MCP tool).
This is the CONTRACT surface — rules not inferable from the code you are editing, whose violation is SILENT. The measurements behind each rule and every closed design question: docs/src/cli/synth-calibration.md.
Synth does not emit Response.Components — it writes a .pulse file.
Which skill answers what
| Question | Skill |
|---|---|
What does profile create capture; what does each field rebuild as? | synth-profile-capture |
Why packed_bool → bernoulli, small integer → discrete? | synth-marginals |
set_* (multi-select) profiling, pairs, generation | synth-set-fields |
correlations: which marginals / types may join | synth-correlations |
| Two relationships name one field — which wins? | synth-conflicts |
Several drivers conditioning one numeric (--fit-models) | synth-models |
Gating, masking, deriving a field (rules[], constraints[]) | synth-structural-rules |
The _synthetic top-up and --fidelity-report | synth-fidelity-report |
| Byte-identical output; why a profile can drift | synth-determinism |
--fit-shape mixtures | synth-shape-fit |
| One distribution’s params | atomic op-synth-<kind> |
Two modes
| Mode | Input | When |
|---|---|---|
pulse synth from-schema | hand-written JSON spec | caller knows desired shape — fixtures, CI seeds, demos |
pulse synth from-profile | profile JSON + the source cohort it was captured from | tagged top-up: add rows matching a real cohort’s marginals, source untouched |
Privacy. Synth does NOT preserve privacy. A profile without DP noise leaks the empirical distribution — top-K categoricals reveal rare values, percentiles reveal ranges, coefficients expose structure. Add a calibrated noise mechanism if the source is sensitive.
Schema-mode spec
{
"row_count": 100000,
"fields": [
{"name": "user_id", "type": "u64", "distribution": "monotonic_from", "params": {"start": 1}},
{"name": "age", "type": "u8", "distribution": "normal", "params": {"mean": 35, "std": 12}},
{"name": "country", "type": "categorical_u8", "distribution": "weighted_categorical", "params": {"values": ["US","UK"], "weights": [0.6,0.4]}},
{"name": "amount", "type": "f64", "distribution": "lognormal", "params": {"mu": 4.2, "sigma": 0.8}}
],
"constraints": [{"expr": "amount >= 0"}],
"max_rejection_rate": 0.5
}
Verify with pulse_inspect. constraints[] (reject-and-redraw) and rules[] (gate, mask, derive): synth-structural-rules — read it before a spec gates, masks or derives a field rather than merely distributing one.
Distribution registry
Registry: the manifest’s synth_distributions; params and clamp semantics per kind in op-synth-<kind>.
uniform [min,max) · normal (optional min/max clamp) · lognormal · exponential · poisson · pareto · bernoulli · monotonic_from (ignores RNG — primary keys) · weighted_categorical (uniform when weights absent) · uniform_date (inclusive both ends) · regex (no backreferences) · mixture (≥2 components; bimodal or skewed shapes a single normal collapses).
Three carry rules you cannot guess:
discrete—valuesstrictly ASCENDING + optionalweights. The exact per-level histogram of an integer column; whatprofile createreconstructs every capped integer field from (synth-marginals).constant— coerced ONCE at spec parse to the field’s ROW shape: bool → 1/0 on a scalar, option-name array or object → aset_*mask, string REQUIRED for a categorical, verbatim exact string fordecimal128(ParseDecimal128). The only sampler valued from the document, hence the only one that could put a Goboolwhere the expr environment promisesfloat64; a shape the row cannot hold is refused at parse.set_bernoulli—optionsalso PRE-REGISTERS the field’s dictionary at schema-build time. A determinism requirement, not an optimisation (synth-set-fields).
19 of the 20 field types are reachable — datetime is NOT: fieldTypeFromName (internal/synth/writer.go) has no case for it, so "type": "datetime" refuses with unknown field type. Use date (epoch days) or u64 epoch seconds. decimal128 needs params.scale matching the declared scale (banker’s rounding). Bit-packed (u4, packed_bool) use one byte per row in the writer. nullable: true opts into the null bitmap; nulls NEVER ride an inline sentinel.
Determinism contract
Same (spec, opts.Seed) MUST produce a byte-identical .pulse file; same (--input, --seed) MUST produce a byte-identical profile document. Any change breaking either is a contract break. Seed splitting, Seed == 0, null-draw order, map-order folds and float fusion: synth-determinism.
Library embedding
pulse.Pulse.Synth / pulse.Pulse.Profile route through the embedded filesystem — pulse.New(pulse.Options{FS: afero.NewMemMapFs()}) for hermetic tests.
Gotchas
- Constraints +
monotonic_from: monotonic ignores RNG, so a rejected row still increments the counter. - A model coefficient is latent-scale (data units only when
Qisnormal); a quantized modelled target attenuates at write time —synth-models. --fit-modelsdoes not imply--residual-correlations, and neither implies--conditional.--fit-shapecomposes with all of them.weighted_categoricalweights normalize at sample time; absent weights default to uniform.
See
- The focused skills in the table above, plus atomic
op-synth-<kind>. cohort-schema-design— field types, dictionaries, null bitmap.regression-modeling— the OLS engine both model capture and the recovery refit drive.pulse_errors_lookup—PULSE_SYNTH_*/PULSE_PROFILE_*recovery.
name: time-zones
description: Time zones end to end — which zone a request uses, how a datetime buckets by local calendar day, import in a source zone with DST policy, native timestamps, local-offset export and the tz database version. Topical design.
type: guide
kind: design
applies_to: process, compose, predict, inspect
covers: [time_zone, tz, datetime, week_start, hour, source_tz, dst_policy]
Time zones
Storage is always UTC. datetime = epoch SECONDS, date = epoch DAYS (never interchangeable). A zone only changes how an instant is READ (calendar day, wall clock) or RENDERED (export). Names are UTC or an exact IANA Area/Location from Pulse’s own embedded tz database (never the host’s); EST, Local, +05:00, "" fail PULSE_TIMEZONE_UNKNOWN. The manifest carries the release as tzdata_version; a slot-level zone key marks which operators take a zone (capable) or follow their host (following).
Which zone applies
Per slot: slot tz -> request time_zone -> Options.DefaultTimeZone -> UTC. Predict resolves the same way and echoes time_zones[] (slot, tz, source = slot|request|options|default). tz on a date field, or on an operator that is not zone-capable, is PROCESSING_CONFIG. An inherited zone on a date field is simply not applied (tz: null). A non-UTC zone on a derived field (absent from the schema, e.g. a chain stage output) is refused: “a zone cannot be applied to a derived field”.
UTC, Etc/UTC and absent are byte-identical to the pre-zone output.
Local-day semantics
A datetime is read on its LOCAL calendar day in every mode (process, stream, compose, parallel, shards, crosstab, join, chain, facet); a Berlin request’s days are Berlin calendar days, DST changes included. Say so in explanations: “days are Berlin calendar days”.
GROUP_DATEkeys are local labels.component: hourkeysYYYY-MM-DDTHHlocal wall clock (datetime only; the skipped DST hour has no bucket, the repeated hour is one bucket).week_start(week only) defaults tomonday(ISOYYYY-Www); any other day keys by the week’s first local date.- Range literals (inline or
RangeTables) are LOCAL calendar days in the slot’s zone; a range table has no zone of its own. ATTR_DATE_PARTandFEAT_DATE_FEATURESacceptdatetimeand read the zone’s wall clock; parthour(and the sixth<prefix>_hourfeature column) is datetime-only. Featuredowstays Sunday = 0 and ignoresweek_start.OVERLAY_YOYhas no zone slot; it follows its host grouper, so an hourly host gives hourly prior-year alignment.
Import in a source zone
A naive literal (no Z, no offset) is UTC unless the import names a source zone: CLI --source-tz Zone (or col=Zone, wins over the bare form), library ImportJob.SourceTZ / ColumnSourceTZ, MCP source_tz / column_source_tz. Offset and Z literals never move. Also on import auto, import predict, convert, convert predict; managed imports persist the zone, so changing it means re-import with overwrite.
--dst-policy error|earlier|later (default error): a wall time the zone skips is PULSE_IMPORT_DST_NONEXISTENT, one it shows twice is PULSE_IMPORT_DST_AMBIGUOUS; both name row, column, value and zone and write no file. earlier/later resolve it and warn PULSE_IMPORT_DST_RESOLVED with counts. Predict reports the same refusal as the run.
Native timestamps: Arrow and Parquet timestamp columns type datetime. Zoned Arrow types and UTC-adjusted Parquet are instants (the source zone never moves them); zone-less Arrow, non-adjusted Parquet and legacy INT96 are wall clocks (source zone applies). Sub-second values floor toward the past with PULSE_IMPORT_TIMESTAMP_TRUNCATED. SPSS DATETIME is a wall clock (source zone applies); SPSS TIME/DTIME are durations, f64 seconds, never a datetime.
Export in a zone
export --tz Zone renders each datetime with its local offset (2024-03-10T08:00:00+05:30); the stored instant is unchanged. Without it, output is canonical UTC Z. Sub-minute historical offsets render as Z. convert has no --tz.
SPSS export refuses a non-UTC --tz (PULSE_SPSS_EXPORT_UNSUPPORTED).
Gotchas
- A DST day has 23 or 25 hours; local-day counts differ from UTC-day counts only for records near midnight.
- Zone names are case-sensitive; echoes keep your spelling.
- Zones never change stored bytes; only import interpretation of NAIVE input does.
See
grouper-design, type-datetime, type-date, request-envelope, session-format-flags, overlay-system.
tool-compose
name: tool-compose
kind: tool
description: Execute a batch of processing requests in one round-trip.
type: reference
applies_to: compose, process, mcp
When to use
CALL TO RUN SEVERAL REQUESTS IN ONE ROUND-TRIP.
Distinct types.Request payloads against the same or different cohorts: ecological regression (slot 1 aggregates, slot 2 regresses over aggregates) or any multi-question loop that would otherwise take N round-trips.
Input
request (string): JSON-encoded types.ComposedRequest. Carries requests (the slot list) plus optional Compose-level overlays that fold across slots (see compose-requests). An optional sweep expands one request body over axes into more slots (compose-sweeps); a sweep body’s missing return defaults to standard. Parallel execution: Options.MaxWorkers, PerRequestTimeout, FailFast. Each slot’s return shapes its response; a top-level return (overlays… paths only) shapes data.overlays after the fold.
Output
descriptor.Envelope (format_version: "1.1") wrapping a pulse.ComposedResponse on data:
data.responses[]— fullResponseper slot in input order; each carries its ownData,Metadata,Components, per-RequestOverlays.data.overlays[]—OverlayLayerper Composeoverlays[i]spec viainternal/service/compose_overlay.go. Omitted when no Compose overlays declared.data.overlays[i].warnings[]— per-layerOverlayWarning{code, message, details}diagnostics (cohesion, missing coordinates, panel overflow). Omitted when empty, so overlay-free Compose stays byte-identical (TestComposedResponse_OverlayFreeByteIdentical).data.warnings[]— batch-level diagnostics no slot owns (a fit save’s overwrite,details.fit); omitted when empty.
Gotchas
- Compose-overlay multi-slot probes use byte-equal schema match by default. Enable
OverlaySpec.Options.DictPrefixFastwhen slots share dictionary-prefix-equal categoricals. - Panel overlays cap Targets at
MaxPanelTargets(default 16); overflow →PULSE_OVERLAY_PANEL_TARGETS_OVER_CAP. - Per-slot errors surface in that slot’s envelope; Compose still returns unless
FailFast: true.
See
compose-requests— multi-slot patterns, parallel knobs, overlay fold.response-components— emitted per slot.overlay-system— Compose-level overlay kinds.
tool-dedup
name: tool-dedup
kind: tool
description: Deduplicate an existing cohort's repeated parent blocks into parent groups (format 0x02), or suggest groups read-only.
type: reference
applies_to: mcp
When to use
CALL WHEN A COHORT REPEATS PARENT ATTRIBUTES ON EVERY CHILD ROW.
A denormalized join: pulse_dedup stores each parent tuple once plus a 4-byte row index: smaller file, byte-identical answers. The existing-cohort twin of pulse_import groups.
Input
path(string, required): the single-file.pulsecohort.suggest_groups(bool): detect candidates over the cohort’s records. Alone the call is READ-ONLY.groups(array):[{key: [...], members: [...]}], thepulse_importshape. An unknown entry key →PULSE_GROUP_DECLARATION_INVALID.out(string): write to this NEW path (must not exist). Omit to rewrite IN PLACE.
Output
rewritten, in_place, records, *_before/after (format version, bytes, stride), groups (per-group verdict, ratio, dictionary_bytes, byte_delta), group_warnings, group_candidates (with the suggested set) and invalidated_sidecars (in place only, each with its rebuild command).
Gotchas
- Suggest, then declare. Call with
suggest_groupsonly, then pass thesuggestedcandidates’key/membersasgroups. Never guess. - Prefer
outunless the user asked for in place. In place is atomic: a failure leaves the cohort byte-identical. - A member that varies within its key fails
PULSE_GROUP_MEMBER_NOT_CONSTANTand nothing is written. - Weak groups warn:
PULSE_GROUP_TOO_NARROW(dropped),PULSE_DEDUP_LOW_RATIO(written; floor 2). Floor, strict and constant elision arepulse dedupflags only. - A grouped cohort is regrouped from scratch.
- Sidecars are reported, never rebuilt; run each
rebuildbefore the next point lookup. - Shard archives →
SERVICE_VALIDATION: dedup each shard, thenpulse shard create(it union-merges grouped shards). Output is format 0x02 (older binaries cannot read it).
See
tool-import— declaring groups at import time.cohort-schema-design(Parent groups) — when grouping pays off.
tool-drop
name: tool-drop
kind: tool
description: Remove a managed-import handle — delete its .pulse file and sidecar.
type: reference
applies_to: mcp
When to use
CALL TO DELETE A MANAGED IMPORT YOU NO LONGER NEED.
Cleaning up after a one-off analysis, or freeing a name for re-import. Sidecar expired=true entries can stay until swept; pulse_drop is the explicit eviction.
Input
handle(string, required): managed handle name (frompulse_imports_listor the name you passed topulse_import).
Output
descriptor.Envelope wrapping a small drop record (handle, dropped: true). Unknown handle → PULSE_IMPORT_SOURCE_MISSING.
Gotchas
- Pulse-format passthroughs (
managed=false) are NOT in the pool —pulse_dropreturnsPULSE_IMPORT_SOURCE_MISSINGfor them. They were never managed. - Drop deletes both the
.pulsefile under$PULSE_DATA_DIR/imports/AND the TTL sidecar. There is no undo. - After drop, any cached session binding pointing at the handle path is stale; the next inspect/import rebinds.
See
tool-import— create / refresh a handle.tool-imports-list— enumerate before drop.
tool-errors-lookup
name: tool-errors-lookup
kind: tool
description: Look up Pulse error code metadata — message and fixup hints.
type: reference
applies_to: mcp
When to use
CALL WHEN A TOOL RETURNS AN ERROR CODE YOU NEED EXPLAINED. Also to triage a known code or enumerate every code in one domain. The manifest carries only the slim code-name list — fetch detail here on demand to keep session context lean.
Input
code(string, optional): exact code identifier (e.g.PULSE_AGG_NOT_MEANINGFUL_FOR_CATEGORICAL). Returns 1-element array on hit, empty on miss.domain(string, optional): domain prefix (PULSE,ENCODING,PROCESSING,SERVICE,DATA,CLI). Case-insensitive; enumerates every code in that domain.query(string, optional): case-insensitive substring across descriptions and fixup hints; ranks message hits above fixup hits.
Output
descriptor.Envelope wrapping []perr.LookupResult — each entry: Code, Domain, Message, Fixups[] (Title + Body). Empty array when nothing matches (never null). Multiple axes set → intersection of candidate sets.
Gotchas
- At least one of
code/domain/queryMUST be set; all empty → errorspecify at least one of code, domain, query. - Every code carries at least one
Fixuptemplate ORFixupNotApplicable: true. Enforced byTestCodesHaveFixups. - Intersection preserves the first-supplied axis’s ordering (Search ranking, ByDomain alphabetical, or single-code).
See
- Error-code list in
pulse_manifest(error_codesslim slice). session-bootstrap— error-handling role in the workflow.tool-manifest— companion catalog for domain prefixes.
tool-examples-get
name: tool-examples-get
kind: tool
description: Fetch one runnable request example from the embedded library by name.
type: reference
applies_to: mcp
When to use
CALL AFTER A SEARCH FINDS A MATCHING EXAMPLE, TO GET ITS RUNNABLE BODY.
The full record’s body has the _meta annotation block already stripped — hand it straight to pulse_predict or pulse_process after renaming fields for the target cohort.
Input
name(string, required): example name from the_meta.namefield (e.g.t_test_one_sample).
Output
descriptor.Envelope wrapping the example record: name, category, tags, operators, description, body (runnable types.Request JSON). Missing example → MCP error example "<name>" not found.
Gotchas
bodyistypes.Request— forComposedRequest/ChainRequest/FacetRequestexamples the library category encodes the target endpoint; the body is still a single-shape JSON, not a tagged union.- Field names in the body match the example’s seed cohort. Rename them for your target cohort before calling
pulse_predict/pulse_process. - The
_metablock is stripped on emit so the body is directly runnable; the surrounding record carries the same metadata externally.
See
tool-examples-search— discovery sibling.tool-process/tool-predict— downstream execution.request-envelope— slot map for rebinding to a new cohort.
tool-examples-search
name: tool-examples-search
kind: tool
description: Search the runnable request-example library for templates matching a question.
type: reference
applies_to: mcp
When to use
CALL BEFORE AUTHORING A REQUEST.
Find a runnable template that matches the user’s question; clone its body via pulse_examples_get and rename fields for the target cohort. The example library is curated, runnable, and stays in lockstep with the operator surface — prefer it over inferring request shapes from documentation or source code.
Input
query(string, optional): plain words. One word is a case-insensitive substring of name, description or operators. Several words must each match a whole word (lower-cased, no stemming) of name, description, operators, intents or tags. An operator alias (anova,chisq.test,pearson) or a phrase from an intent’s sounds (move together) also finds that operator’s or intent’s examples when nothing matches literally.intent(string, optional): an intent-taxonomy ID (compare_groups,relationship, …); results must declare it. Unknown:PULSE_RECOMMEND_INTENT_UNKNOWN,details.validlists the IDs.tags(string[], optional): ANDed list of canonical taxonomy tags (e.g.time-series,experiment-analysis,tier-1-test,regression,ols,logistic).category(string, optional): exact directory —aggregations,attributes,compose,crosstab,facet,features,filterers,groupers,matrices,overlays,regression,tests,windows.
Output
descriptor.Envelope wrapping lightweight summaries: name, category, tags, operators (sorted), description, intents (absent when none). Ranked by distinct matched fields, then name. Empty filter returns the full library list. Use the name to fetch the runnable body via pulse_examples_get.
Gotchas
- Tags are ANDed, not ORed. Two tags → results carrying both.
categoryis an exact directory match, not a substring.- Literal matches win: synonyms are consulted only when no example matches the words themselves.
- A deployment may add examples for its own operators, under its own categories; they search, read and prune exactly like the built-in ones.
- The summary does NOT include the runnable body; you MUST follow up with
pulse_examples_get.
See
tool-examples-get— fetch runnable body for one named example.session-bootstrap— example-library role in the request-authoring workflow.tool-manifest— operator catalog companion.
tool-explain
name: tool-explain
kind: tool
description: Say in plain words what a request will do, or what a result found — closed verdicts, conventional strength bands and caveats.
type: reference
applies_to: mcp
When to use
CALL TO SAY IN PLAIN WORDS WHAT A REQUEST WILL DO, OR WHAT A RESULT FOUND — instead of reading p-values yourself.
CLI twin: pulse explain --request F | --response F [--request F] [--root R] [--detail full] [--json].
Input
Exactly ONE root, as an object:
- Request mode:
request(a process request body),composed,chain,facetorsample. Never run; a root naming a cohort is predict-checked. - Response mode:
response,composed_response,chain_responseorfacet_result, exactly as Pulse returned it, plus its own request beside it (request/composed/chain/facet). detail:terse(default) orfull.
Output
mode, root, summary (one sentence), findings[] (slot, subject, operator, verdict, strength_band + convention, numbers), caveats. Request mode adds valid, steps[], defaults_applied, advisories, refusals. full adds sentences, glossary_refs, follow_ups.
Gotchas
- Relay
caveats; never drop them — uncorrected p-values, an assumed alpha, a partial reading. - A
no_evidence_of_*verdict is not evidence of none; say “no evidence of a difference”, never “no difference”. - Pass results unedited: a
nullfigure is undefined and readsnot_computable. - Without the request companion an aggregation is described by count only.
- Zero or several roots, or an unknown
detail→SERVICE_VALIDATION; unreadable cohort →DATA_FILE.
See
glossary— terms inglossary_refs.tool-predict— the check request mode runs.tool-recommend— drafts to explain before running.
tool-facet
name: tool-facet
kind: tool
description: Return distinct values for one field in a cohort.
type: reference
applies_to: facet, mcp
When to use
CALL TO LIST THE DISTINCT VALUES OF ONE FIELD.
Diagnostic / discovery — before authoring a value filter, to know which categorical labels exist. For multi-field summaries with counts, null tallies, numeric stats, percentiles, histograms, or additive-contribution counts, use pulse_facet_schema instead.
Input
path(string, required): filesystem path to the.pulsefile.field(string, required): field name to facet.
Output
descriptor.Envelope wrapping a slice of distinct values typed per the field’s schema. For categorical fields, dictionary labels surface as strings; numeric fields surface the raw values; date fields surface ISO-8601 strings. Response.Components populates the universal floor (total_n, n_null) for the facet operation.
Gotchas
- Single-field, no counts. Use
pulse_facet_schemawhen you need histograms, percentiles, null tallies, or multi-field rollups. - Categorical dictionaries are truncated only when the field uses
DefaultDictionaryLimitsemantics elsewhere; facet always returns the full distinct set encountered in records. - Unknown field →
PULSE_REQUEST_UNKNOWN_FIELD.
See
facet-design— facet endpoint contract.tool-facet-schema— rich multi-field variant.response-components— universal floor reported.
tool-facet-schema
name: tool-facet-schema
kind: tool
description: Multi-field rich facet — counts, nulls, percentiles, histograms, additive contributions.
type: reference
applies_to: facet, mcp
When to use
CALL TO SUMMARISE SEVERAL FIELDS AT ONCE WITH COUNTS, NULLS, STATS OR HISTOGRAMS.
Also top-K truncation and additive-contribution counts: the faceted-UI / discovery sibling to pulse_facet. Reusable for “if I added value V to my filter, what is the surviving population” — that’s AdditiveFields.
Input
request (string): JSON-encoded pulse.FacetRequest. Fields: cohort.filename, fields (string[]), filterers (Filterer[]), additive_fields (string[]), discrete_top_k (int), numeric_percentiles (float[]), include_histogram (bool), histogram_bins (int), histogram_range ([min, max]). FACET-host overlays ride FacetRequest.Overlays.
Output
descriptor.Envelope wrapping per-field summaries: discrete value/count lists with null tallies for categorical/boolean/geo fields; streaming count/sum/min/max/mean/stddev for numeric fields; optional Percentiles, Histogram, AdditiveContributions. Response.Components reports the facet floor + per-field operator state. FACET-host overlays appear in Response.Overlays.
Gotchas
numeric_percentilesforces a buffered per-field sort — not streaming.additive_fieldsstrips that field’s own filter clauses before counting — designed for UI panels that surface “what else could you add”.- FACET-host overlays (the
*_VS_POPkinds) wire viaservice.applyFacetOverlays(FacetSchema-buffered-exit hook).
See
facet-design— full endpoint contract + overlay wiring.response-components— emitted floor + per-field state.overlay-system— FACET-host overlay kinds.
tool-import
name: tool-import
kind: tool
description: Import a tabular source file (or pass through .pulse) into a managed handle.
type: reference
applies_to: mcp
When to use
CALL TO BRING AN EXTERNAL DATA FILE INTO PULSE AS A COHORT.
CSV, TSV, NDJSON, JSON array, Parquet, Arrow, Excel, SPSS. Managed handles live under $PULSE_DATA_DIR/imports/ with a TTL sidecar; every inspect/predict/process/sample/facet slides expiry. .pulse passes through (managed=false).
Input
| Field | Notes |
|---|---|
source (req) | path relative to PULSE_DATA_DIR |
format | csv/tsv/ndjson/jsonarray/parquet/arrow/excel/spss/pulse; default extension-detected (.sav/.zsav → spss) |
handle | default = source basename |
ttl | Go duration (24h) or day form (7d); pin = no expiry (the only way). Default 7d |
sheet | Excel sheet name; inert elsewhere |
charset | SPSS-only decode override (windows-1252/cp1252/1252 fold); empty keeps the file’s declaration |
overwrite | default false → PULSE_IMPORT_HANDLE_EXISTS |
groups | parent groups, [{key: [...], members: [...]}] (omit key for a plain tuple group); unknown entry key → PULSE_GROUP_DECLARATION_INVALID |
suggest_groups | also return measured group_candidates; one extra full pass, declares nothing |
schema | explicit schema [{name, type, nullable, description}] (precision/scale for decimal128) — the CLI --schema document; omit to infer |
ignore_extra | with schema: drop columns / late keys it does not name instead of refusing |
float | inferred float width: f64 (default) or f32; else PULSE_IMPORT_FLOAT_WIDTH_INVALID |
source_tz / column_source_tz / dst_policy | zone naive datetimes were recorded in (IANA, UTC, ±HH:MM), per-column {col: zone} override, DST policy error/earlier/later; kept on the sidecar |
Output
descriptor.Envelope over handle, managed_path, format, row_count, expires_at, managed, plus promoted_fields, width_warnings and source_warnings (coded source-parse diagnostics: PULSE_SPSS_*, PULSE_IMPORT_TIMESTAMP_TRUNCATED), groups (per-group verdict, ratio, resident dictionary bytes, byte delta), group_warnings (coded {code, message, details}), group_candidates and zone_warnings — all omitted when empty. Description >1000 bytes → PULSE_IMPORT_DESCRIPTION_TOO_LONG; low-quality → PULSE_FIELD_DESCRIPTION_LOW_QUALITY (error under --strict).
Gotchas
- SPSS is schema-authoritative and import-only here. Its dictionary declares type, nullability and dictionary order ⇒ inference skipped, sampling knobs inert,
promoted_fieldsalways empty. Dictionaries hold SPSS codes, not labels (two codes may share one label) — resolve labels at output via a LabelTable. Writing.savis CLI-only:pulse export spss,pulse convert x.csv out.sav. - Non-fatal
PULSE_SPSS_*ridesource_warnings/ envelopewarnings. Read them — they change what the cohort MEANS. Damage and self-contradiction (byte order, compression, charset) are FATAL coded refusals, never silent wrong numbers. charsetis the recourse when a.savis wrong about itself (stale record7/20; no declaration, failing the strict UTF-8 default). Decode-side only — the declaration is retained for export.- No missing-value mode here. Default is the fidelity split: numeric user-missing → null + a
<var>_missingsibling recording why. The only alternative discards those siblings; ask for it explicitly withpulse import spss --spss-missing=null. - A blank categorical cell is not always a null.
ndjson/jsonarray/arrow/parquetcarry the source’s own null channel, so JSON""(or a set validity bit) imports as an empty-string VALUE and onlynullimports as null.csv/tsv/excelhave one spelling for both and import a blank as NULL — a documented gap, and the collapse always goes toward null. A blank in a numeric / date column is a null on every format. - An explicit schema matches columns BY NAME, never position (
schemahere; CLI--schemaonimport,import predict,convert; libraryImportJob/ConvertJob/ImportSpecSchema): exact spelling, any order; the array order is the cohort’s. Refused before any row is read: a field with no columnPULSE_IMPORT_SCHEMA_FIELD_MISSING; a column no field namesPULSE_IMPORT_SCHEMA_COLUMN_UNMAPPED; a repeated namePULSE_IMPORT_SCHEMA_DUPLICATE; an NDJSON / JSON-array key first seen after the first object (whose keys are the columns)PULSE_IMPORT_SCHEMA_KEY_LATE. Unknown type namePULSE_IMPORT_SCHEMA_TYPE_UNKNOWN; an unknown entry key, an empty array or a schema on a.pulsesourcePULSE_IMPORT_SCHEMA_INVALID.ignore_extra(--ignore-extra) drops unnamed columns and late keys instead. A narrowed output (a projected--stream) is never re-imported with its columns silently shifted. Not kept on the sidecar — the cohort header records the schema. - Derived columns are not an import job. To make a window, feature or attribute column a field,
pulse_materializeit (derived-cohorts) — the supported route, exact and atomic; never re-import a streamed result. - Inferred floats are
f64— decimals stay exact (money sums do not drift).float: "f32"(CLI--float f32) halves the bytes for a whole import, rounding to ~7 digits. - Nullability is inferred from the first N rows. A later null promotes the field (
promoted_fields+PULSE_IMPORT_NULL_PROMOTED); an explicit schema raisesPULSE_IMPORT_ROW_ERRORinstead. - Widths too. A value past the sample-inferred width (a 257th parent name in a
categorical_u8, a larger or non-integer number) promotes the field to the narrowest type that holds it — never a dropped row — with onewidth_warningsentry (PULSE_IMPORT_WIDTH_PROMOTED:field,from,to,source_row). - Naive datetimes import as UTC. A source recorded in local time needs
source_tz(CLI--source-tz; libraryImportJob/ImportSpecSourceTZ). DST gap/overlap is FATALPULSE_IMPORT_DST_AMBIGUOUS/_NONEXISTENTby default;dst_policy: earlier|laterresolves and warnsPULSE_IMPORT_DST_RESOLVED(zone_warnings).Z/offset literals never move; nor do zoned Arrow / UTC-adjusted Parquet timestamps, while naive ones, INT96 and SPSS datetimes are wall clocks. A handle read in one zone is never reused for another: re-import withoverwrite: true. - Zero rows out of a non-empty source is a FATAL
PULSE_IMPORT_ROW_ERROR, not an empty import — details carryrows_read/rows_failed/first_row/first_error, and no cohort is written. Export and convert mirror it withPULSE_EXPORT_ROW_ERROR(convert’s row errors all come from the TARGET writer), and convert writes no--keep-pulseintermediate. PARTIAL failure is unchanged (rows land, the rest riderow_errors, no error), and a source with no data rows stays a legitimate empty cohort. - Parent groups: suggest, then apply. Import with
suggest_groups: true, readgroup_candidates.candidates(suggested: true= the non-overlapping viable set), then re-import with those{key, members}asgroupsandoverwrite: true. Groups write a smaller0x02cohort older binaries cannot open; a member varying within its key is FATALPULSE_GROUP_MEMBER_NOT_CONSTANT. Weak groups WARN at the default floor 2 (PULSE_GROUP_TOO_NARROWdropped,PULSE_DEDUP_LOW_RATIOwritten) — there is no floor/strict knob because neither changes the bytes. Refused on a.pulsepassthrough. No constant elision here — its value is at-rest bytes and a managed import is a TTL’d cache; usepulse import <fmt> --elide-constants. - One dispatch. Detection and reader construction use the
iofactory (io.FormatFromPath→io.NewReader), the same one the CLI and embedders use. No extension match and noformat→PULSE_IMPORT_FORMAT_UNKNOWN; an explicitformatoutside the list →PULSE_IO_FORMAT_UNSUPPORTED. - A
.pulse.zst(zstd transfer artifact) is refused withPULSE_COHORT_COMPRESSED, even renamed.pulse. Decompressing is an operator act:pulse import transfer(CLI) — no MCP slot. - Passthrough skips copy + sidecar (
pulse_dropno-ops). MCP success rebinds session-scoped tools from the new cohort (astool-inspect).
See
tool-drop,tool-imports-list,cohort-schema-design.spss-cohorts— type mapping, derived columns, missing-value split, metadata sidecar, per-code prose.pulse_errors_lookup.
tool-imports-list
name: tool-imports-list
kind: tool
description: Enumerate every managed-import handle with sidecar metadata.
type: reference
applies_to: mcp
When to use
CALL TO SEE WHICH MANAGED IMPORTS EXIST AND WHICH HAVE EXPIRED.
Render a UI of imports, find expired entries before pulse_drop, or check a handle name is free before pulse_import. Sweep is NOT invoked — expired entries are flagged via Expired so callers decide to drop or extend.
Input
No arguments.
Output
descriptor.Envelope wrapping []ImportInfo: handle, source_path, source_format, imported_at, expires_at, ttl, expired (bool), pinned (bool). Empty array when no managed handles (never null).
Gotchas
- Pulse-format passthroughs are NOT in the list — they are not managed.
expired=trueentries are still openable (Pulse never garbage-collects the underlying.pulse); usepulse_dropto evict.pinned=true⇒ TTL ispin;expires_atis reported as zero-value.
See
tool-import— create a managed handle.tool-drop— explicit eviction.
tool-inspect
name: tool-inspect
kind: tool
description: Read header + schema of a .pulse file without touching record data.
type: reference
applies_to: inspect, mcp
When to use
CALL TO SEE A COHORT’S FIELDS AND TYPES WITHOUT RUNNING A REQUEST. Listing fields, debugging dictionaries, checking a type. On MCP a successful inspect rebinds session tool variants to the field names (best-effort).
Input
path(string, required): the.pulsefile or shard archive;archive.pulse#shard.pulseopens one shard.
Output
descriptor.Envelope wrapping InspectResult: fields (name, type, description, categorical dictionary), record_count, shards, suggested_weight {field, source: "spss_sidecar", kind: "probability"} from the SPSS sidecar’s weight variable. Dictionaries truncated to 100 unless FullDict: true. A 0x02 cohort adds layout (physical/logical stride), groups (fields, entry_count, resident dictionary_bytes, ratio, byte_delta, verdict) and a per-field group marker (kind: constant = elided); a grouped archive reports canonical groups over its whole record_count.
MCP (pulse_inspect) returns those keys at top level plus a warnings array of coded {code, message, details} entries, omitted on a clean read.
Gotchas
- Header + schema + sidecar metadata, never a record; group
ratio=record_count ÷ entry_count(import report’s;verdictat floor 2). suggested_weightis NEVER applied — name it as a requestweight; SPSSWEIGHT BYreplicates cases (kind: "frequency"). Absent, stale or bad sidecar ⇒ none.record_countis DERIVED (payload bytes / stride); a torn tail reports the FLOOR, signalled only by anENCODING_INVALIDwarning (record_stride,trailing_bytes). Library callers usePulse.InspectEnvelope—Pulse.Inspectdrops it.FullDict: true(--full-dict): full label list.- Unknown magic / format-version mismatch →
ENCODING_INVALID.
See
cohort-schema-design—.pulsebyte layout and field-type matrix.tool-predict— schema validation companion.tool-import— the other leaf rebinding session tools.
tool-label-resolve
name: tool-label-resolve
kind: tool
description: Reverse-resolve a human-readable name (typo-tolerant) to raw categorical key(s).
type: reference
applies_to: mcp
When to use
CALL WHEN THE USER NAMES A CATEGORY VALUE BY ITS DISPLAY LABEL. Resolve it BEFORE authoring a value filter or category grouper to get the raw key they expect. Labels are output-only; filter / group / sort keys operate on the raw categorical value.
Input
table(string, required): label table name (frompulse_label_tables, e.g.brand).query(string, optional): name to search; typo-tolerant. Empty returns the first rows in browse mode (score 0).limit(number, optional): maximum matches. Default10.
Output
descriptor.Envelope wrapping []LabelMatch: key (raw categorical value), value (display label), score (confidence in [0, 1]). 1.0 = exact (or exact-key); ~0.9+ = prefix or near-typo; lower = fuzzy (edit-distance + trigram). Matching normalizes case and punctuation.
Gotchas
- Decision rule: top score
>=0.9AND clearly ahead of next → use it. Low or several close scores → present the top names to the user and ask which they meant rather than guessing. - Non-enumerable tables (forward-only) → error. Check
enumerableviapulse_label_tablesfirst. - Empty
queryis valid — browse mode returns the first rows up tolimitwith score0.
See
tool-label-tables— table discovery companion.label-display— label binding contract.
tool-label-tables
name: tool-label-tables
kind: tool
description: List registered label tables — ID→display-name dictionaries for categorical fields.
type: reference
applies_to: mcp
When to use
CALL TO FIND WHICH CATEGORICAL FIELDS CAN BE RESOLVED BY DISPLAY NAME.
Run it before pulse_label_resolve. Output surfaces already render these labels automatically — this tool and pulse_label_resolve are for the INPUT direction: turning a user-supplied name into the raw categorical key a filter / grouper needs.
Input
No arguments.
Output
descriptor.Envelope wrapping []LabelTableInfo: name, row_count, enumerable (bool — whether reverse-searchable). Empty array when no tables registered (never null). Tables are loaded from $PULSE_LABEL_TABLES_DIR/*.json at pulse.New time, or supplied programmatically via Options.LabelTables.
Gotchas
enumerable=falsetables are forward-only (key→label);pulse_label_resolverejects them with a clear error.- Labels are OUTPUT-only — filter / group / sort keys still operate on the raw categorical value. The reverse resolution is for translating user-supplied names into keys.
- Table names are the JSON filename stem (e.g.
brand.json→"brand").
See
tool-label-resolve— reverse-resolve a name to keys.label-display— label binding contract end-to-end.
tool-lookup
name: tool-lookup
kind: tool
description: Resolve a point lookup against a cohort's prebuilt sidecar index.
type: reference
applies_to: mcp
When to use
CALL TO FETCH ROWS BY EXACT KEY FROM A COHORT WITH A PREBUILT INDEX.
O(1) row addressing, not a scan — needs a sidecar index already built (pulse index build / Service.BuildIndex); this tool never builds one.
Input
types.LookupRequest:
cohort(required).field/value: single-key convenience path.keys([{field, value}]): ordered composite-key path, wins overfield/value. Order MUST match the index’s build-time key order —[region, date]!=[date, region].return_columns(string[], optional): project these fields; empty = all.multiplicity(defaultassert_unique): errors on >1 match;first= lowest row-id;all= every match, ascending row-id.
Keyable types: every field type except set_* (ambiguous mask equality — use FILTER_SET instead). Per-type equality caveats (f32/f64 bit-pattern, decimal128 mantissa, categorical_* dictionary ID, datetime literal form) are in cohort-schema-design’s field-type matrix.
Output
types.LookupResult: rows — matched row(s), field→value maps projected per return_columns; warnings — reserved, empty in v1.
Gotchas
PULSE_INDEX_MISSING— no sidecar for the key field(s); build one. To discover which tuples DO exist, readpulse index list/Pulse.ListIndexes— a sidecar’s filename is a hash of its key tuple, so globbing.idxtells you nothing and is unavailable on object storage; the listing is served from the keyless<cohort>.indexes.jsonmanifest.PULSE_INDEX_STALE— the cohort really changed: a different size, or a content hash that disagrees. An mtime that moved while the size held is NOT stale — it falls through to the fingerprint (memoised per stat pair), so an index read through a bucket still serves.details.fingerprint_checkedsays which arm refused. Rebuild.PULSE_INDEX_MANIFEST_INVALID/_STALE— the discovery manifest is unreadable, or names a sidecar that is gone. Rebuild or drop that tuple.PULSE_INDEX_UNSUPPORTED_SHARDED— shard archives unsupported;archive.pulse#shard.pulseanchor works around it.PULSE_LOOKUP_NOT_FOUND— fresh index, no matching record.PULSE_LOOKUP_AMBIGUOUS— defaultassert_uniquerejects >1-row matches; opt intofirst/allfor duplicates.- Perf: indexed lookup is flat O(1) (~5µs, 10k→1M rows); a scan is linear.
See
request-envelope— cohort slot shape shared across request types.cohort-schema-design— sidecar byte format (v3), field-type matrix (keyable types + equality caveats), staleness.
tool-manifest
name: tool-manifest
kind: tool
description: Bootstrap blob — call once per session for the operator + capability catalog.
type: reference
applies_to: manifest, mcp
When to use
CALL FIRST IN EVERY SESSION.
Cache the result; reference it for every request-authoring decision. Source of truth for every operator, tool and error code THIS deployment offers. Pair with pulse_examples_search for runnable templates.
Input
Optional intent (an intent-taxonomy ID, e.g. compare_groups): the manifest scoped to it — only operators serving it, its ranked skills, the intent record in scope.intent. Fixed sections (commands, error codes, cohort types, tool list, limits, components_schemas, …) are absent and named in elided. Cache per intent. Unknown: PULSE_RECOMMEND_INTENT_UNKNOWN. (CLI: pulse manifest --json with --slim = the MCP payload, --intent ID.)
Output
descriptor.Envelope; data = commands, components, tests + post_tests, synth_distributions, regressions, error_codes*, mcp_tools, cohort_types, skills, extensions, capability blocks (facet, join, process_chain, crosstab, export, import, overlays; absent when not offered), limits ({name, value, default, unit}, -1 = none), feature_set_digest + limits_digest — the cache key with the build version (and the intent). Sort-stable.
Gotchas
- MCP always serves the slim payload (no prose). Fetch per-operator prose via
pulse_skills_getand per-error prose viapulse_errors_lookupon demand. - Operator names in
components.aggregators[]/components.groupers[]are the manifest catalog, NOT the request slot keys — request bodies use"aggregations"/"groups"(seetool-processGotchas). - Each operator entry carries its
component_schema; the unscoped manifest also keys them by name undercomponents_schemas.
See
session-bootstrap— bootstrap workflow and field cross-reference.request-envelope—format_versionsemantics and the standard envelope shape.response-components—ComponentSchemacontract returned for emitting operators.
tool-materialize
name: tool-materialize
kind: tool
description: Write a request's derived columns (features, attributes, windows) as ordinary fields of a new cohort that later requests read.
type: reference
applies_to: mcp
When to use
CALL WHEN A LATER REQUEST MUST READ A WINDOW, FEATURE OR ATTRIBUTE COLUMN AS AN ORDINARY FIELD.
A derived value lives only inside the request that computes it: a regression, grouper, filter or join cannot name it. pulse_materialize writes it to a new cohort; point the next request at out.
Input
request(object, required): a request body (thepulse_processshape) withcohort. Onlycohort,label,features,filterers,attributes,windows,sort,weight,time_zone,disable_components.out(string, required): the NEW.pulsepath, relative to the data dir.overwrite(bool): replace an existing cohort atout.
Output
target, records (one per filter-passing source record; source order, or sort order), derived_columns (output order: feature outputs, attributes, windows), warnings, invalidated_sidecars (overwrite only, each with its rebuild command).
The cohort holds the source fields verbatim, then one nullable f64 per derived column, described by operator, field, params and (when the operator reads it) frame.
Gotchas
- Nest the request. A slot beside
out→PULSE_REQUEST_UNKNOWN_FIELD; a slot outside the list →PULSE_MATERIALIZE_SLOT_UNSUPPORTED(details.slot); nothing derived →PULSE_MATERIALIZE_NO_DERIVED. - Label the outputs you will reference; the column name is the label (or the operator’s default output name).
- Integer window outputs (row number, rank) are stored as
f64; a non-numeric output →PULSE_MATERIALIZE_OUTPUT_NOT_NUMERIC. - Existing
outwithoutoverwrite→SERVICE_VALIDATION. Every refusal happens before anything is written; the write is atomic. sortorders the ROWS only (keys: source fields or derived columns; Process’s comparator, nulls last); window values follow each window’s ownorder_by.- Output is always a single-file cohort; source sidecars are not carried.
See
derived-cohorts— when to materialise, and the two-step recipe.request-envelope— the request slots.
tool-predict
name: tool-predict
kind: tool
description: Validate a request against a cohort schema without executing.
type: reference
applies_to: predict, process, mcp
When to use
CALL BEFORE STORING OR EXECUTING A HAND-AUTHORED OR GENERATED REQUEST. Cheap — reads only the cohort header + schema, never records. Returns the shape errors execution would emit, plus normalization metadata.
Input
A bare types.Request at the root, as execution takes it; or ONE alternative root alone (any key beside it is SERVICE_VALIDATION):
composed— a Compose batch: each slot over its cohort, then the batch overlays.facet— a rich facet request.chain— a chain: each stage over the schema the one before produces.
Output
An alternative root answers under its own key (valid, the echoed request, its per-shape verdict) beside errors / warnings. A bare request answers with PredictResult at the root: Streamable (bool — matches runtime via processing.CanStreamRequest), CrosstabFusable + CrosstabFusionReasons (crosstab requests only: will the grid build on the fused one-pass, O(cells + margins) arm — and why not), LimitFindings (limit_findings, omitted when none: each instance resource limit a predicted figure exceeds — {limit, configured, estimated, grade}; certain = exact, refused with PULSE_LIMIT_EXCEEDED and valid: false; possible = an upper bound, a warning only), DefaultsApplied (slot-level inference summary), Normalized (the engine-canonical request after defaults), and suggested_weight — inspect’s SPSS suggestion, echoed as data (never a warning or applied) while no weight resolves, and advisories (omitted when none) — coded {code, message, details} notes that the analysis may not fit the data; never warnings, never escalated by --strict (predict-advisories).
Gotchas
- Predict is no-execute:
internal/descriptor/predict.goMUST NOT importinternal/service/orinternal/processing/(enforced byTestPredictNoExecutionImports). Header + schema only. Streamablereflects per-operatorStreamable()plus schema gates (decimal128 forces buffered).CrosstabFusableis absent (nil) without a crosstab; it runs the engine’s own fusion rule on the defaulted request and honoursOptions.DisableCrosstabFusion(false + reason). Output is identical either way — only memory differs. Predict cannot see a grouper factory rejecting its params: it may say fusable where the runtime refuses the request anyway.DefaultsAppliedis always computed — disabling defaults at request time via--no-defaultsdoes NOT suppress the report.- Unknown top-level keys are rejected with
PULSE_REQUEST_UNKNOWN_FIELDand a “did you mean” suggestion.
See
request-envelope— slot map and smart-default inference rules.tool-process— runtime sibling; identical input shape.streaming-and-watching— streamability semantics.predict-advisories— the advisory codes and suppression.
tool-process
name: tool-process
kind: tool
description: Execute one pre-built processing request against a cohort.
type: reference
applies_to: process, compose, predict, mcp
When to use
CALL TO RUN ONE VALIDATED REQUEST AGAINST A COHORT.
The body comes from pulse_examples_get (or is hand-authored and run through pulse_predict). For multi-question batches use pulse_compose. For stage-chained workflows use pulse_process_chain.
Input
request (string): JSON-encoded types.Request. Top-level keys: cohort, aggregations, groups, attributes, filterers, windows, features, regressions, crosstab, joins, overlays, post_tests.
Output
descriptor.Envelope wrapping pulse.Response. data carries Data (rows or matrix), Metadata (cohort filename, partial-cohort reasons), and Components (universal floor + per-operator state — see response-components). errors / warnings use coded entries.
Gotchas
- Slot keys differ from manifest catalog names: groupings go under
"groups"(NOT"groupers"); aggregations under"aggregations"(NOT"aggregators"). Unknown top-level keys →PULSE_REQUEST_UNKNOWN_FIELDwith a “did you mean” suggestion. - DO NOT author bodies from external docs or source code — they may be out of date. Always seed from
pulse_examples_search+pulse_examples_getorpulse_manifest. - Smart defaults: omitting
Typeon a slot that names a field auto-infers from schema type. Disable viaOptions.DisableDefaultsor CLI--no-defaults. models: [{name, path}]loads saved model files by name for scoring references; seecompose-fits(Loading).
See
request-envelope— slot map, smart defaults, normalization.response-components— universal floor + per-operator components emitted inResponse.Components.aggregation-design,grouper-design,attribute-composition— operator catalogs.
tool-process-chain
name: tool-process-chain
kind: tool
description: Linear chain of stages; stage N+1 consumes stage N's output rows.
type: reference
applies_to: process-chain, process, mcp
When to use
CALL WHEN EACH STAGE SHOULD READ THE PREVIOUS STAGE’S OUTPUT ROWS. Collapses N round-trips into one open + N stage validations. Common: “group-then-regress”, “filter-then-aggregate-then-attribute”. Each stage’s synthesized output schema is the next stage’s cohort.
Input
request (string): JSON-encoded pulse.ChainRequest. Fields: cohort.filename (source for stage 0), stages ([]ChainStage with name + request). Stage 0 supplies the source cohort; later stages ignore their inner cohort field. A stage’s request.return shapes that stage only after the whole chain ran (the next stage still reads full rows); final follows the last stage’s.
Output
descriptor.Envelope wrapping per-stage Responses plus a whole-chain summary. Chain-host overlays fold at the post-chain barrier.
Gotchas
- MERGEABLE-ONLY at v1. Permitted: exactly the manifest
process_chainblock’smergeable_aggregators,mergeable_groupersandrow_local_attributes. Windows, features, statistical tests, regressions, two-pass attributes and non-mergeable aggregators/groupers →PULSE_CHAIN_NOT_MERGEABLE. Fall back to per-stage requests (batched bypulse_compose). - Synthesized schema between stages: grouper keys →
categorical_u32columns; aggregator outputs →f64.
See
process-chain— full mergeability matrix and recipe library.response-components— per-stage emission contract.overlay-system— CHAIN-host overlays.
tool-range-tables
name: tool-range-tables
kind: tool
description: List registered range tables — named, reusable sets of labeled date ranges.
type: reference
applies_to: mcp
When to use
CALL TO FIND WHICH NAMED DATE-RANGE SETS EXIST BEFORE GROUPING OR FILTERING BY ONE.
Fiscal quarters, marketing campaigns, product-launch windows. This is the INPUT direction: turning a table name into the usable {label, start, end} range set an operator resolves.
Input
No arguments.
Output
descriptor.Envelope wrapping {tables: []RangeTableInfo}, each RangeTableInfo: name, range_count, and ranges (the ordered {label, start, end} entries; start/end are ISO date literals, omitted for an open bound). Empty array when none registered (never null). Tables are supplied programmatically via Options.Extensions.RangeTables or loaded from $PULSE_RANGE_TABLES_DIR/*.json at pulse.New time.
Gotchas
- Empty registry returns an empty list, NOT an error.
- Ranges are validated at
pulse.Newtime (non-overlapping, unique labels, non-empty, parseable bounds) — a listed table is always safe to reference. - Both bounds are inclusive; an empty/absent
startorendis an open bound. - Entries are calendar days with no zone of their own: over a
datetimethey match the local day in the referencing slot’s resolved zone, exactly like inline ranges. - Reference a table by name from the grouper’s
tablefield or the filter’sParams.table— do not re-inline the ranges.
See
op-group-date-ranges— bucket a date field into labeled ranges.op-filter-date-ranges— keep/drop rows by labeled date range.tool-label-tables— the parallel discovery tool for categorical label tables.
tool-recommend
name: tool-recommend
kind: tool
description: Turn a question kind (an intent ID) into ranked draft requests — placeholder skeletons, or predict-checked drafts bound to a cohort's fields.
type: reference
applies_to: mcp
When to use
CALL WHEN YOU KNOW THE KIND OF QUESTION BUT NOT THE REQUEST.
Get ranked drafts instead of JSON built from memory. CLI twin: pulse recommend --intent ID [--cohort C] [--field F …] [--level L] [--limit N] [--json].
Input
intent(string, required): an ID from theintentsskill. Unknown or empty →PULSE_RECOMMEND_INTENT_UNKNOWN;details.validlists every ID.cohort(string): a.pulsepath. Set → drafts BOUND to its fields, each predict-checked. Omit → UNBOUND skeletons.fields(array, cohort only): hints; each pins the first role of the intent it fits. Not a field, or fits no role →SERVICE_VALIDATION.level(basic/intermediate/advanced): rank that level first. Default: simplest first.limit(int): cap, default 10.
Output
intent, bound, shapes (the roles a request’s fields fill), recommendations[], routes_to, truncated, candidates_considered. Each recommendation: operator, category, level, why, bound, request (the draft), placeholders, needs ({param, why}), advisories (predict’s), alternatives, follow_ups.
Gotchas
- Unbound drafts never run as-is. Every
"<placeholder>"string (named after its wire key, listed inplaceholders) must be replaced; thenpulse_predictit. bound: falseon a cohort draft means itneedsa value only you can choose — a success value, a reference mean, a model family. Fill eachneeds[].parambefore running.bound: truedrafts passed predict; still read theiradvisories.- Empty
recommendations+routes_tois not an error: tooling answers it, or nothing serves it yet. - Reads header, schema and sidecar only. Unreadable cohort →
DATA_FILE. - Ranking: hints, fully bound, level, fewest advisories.
See
intents— every intent ID and its field shapes.tool-predict— re-check a draft after filling placeholders.session-bootstrap— where recommend sits in a session.
tool-sample
name: tool-sample
kind: tool
description: Return up to N rows from a cohort for eyeball / preview.
type: reference
applies_to: sample, mcp
When to use
CALL TO EYEBALL A FEW RAW ROWS OF A COHORT. After running a request, to inspect the underlying data, or before authoring a filter, to see typical row shapes. NOT a query interface: selection and aggregation belong to request execution.
Input
path(string, required): filesystem path to the.pulsefile.count(number, optional): maximum rows to return. Default10.
Output
descriptor.Envelope wrapping a row slice — each row is a map keyed by field name with values decoded according to the schema. Categorical fields surface display labels by default, decimal128 a decimal string at the column scale; a null field is omitted from its row. Response.Components.Run reports total_records and filtered_records (always equal under sample). See response-components.
Gotchas
- Reads from the head of the cohort — NOT random sampling. For randomized sampling run a request with a sampling filter.
- Shard archives:
pathmay be either the archive orarchive.pulse#shard.pulseanchor. Sample reads from the resolved cohort’s first records. count <= 0or non-numeric returns the default 10.
See
request-envelope— envelope shape.response-components—Runcounters surfaced even on sample.tool-facet/tool-facet-schema— per-field discovery alternatives.
tool-skills-get
name: tool-skills-get
kind: tool
description: Fetch the markdown body of one named skill.
type: reference
applies_to: mcp
When to use
CALL TO READ ONE SKILL’S FULL GUIDE ONCE YOU KNOW ITS NAME.
Usually after pulse_skills_list: an operator guide, type reference, tool reference, or design doc. The skill pack is the authoritative reference for operator semantics, gotchas, and recipes in this deployment.
Input
name(string, required): skill name without.mdextension (e.g.aggregation-design,type-categorical-u32,op-agg-welford,tool-process).
Output
Plain-text markdown body — full frontmatter block included. NOT wrapped in descriptor.Envelope; returned as MCP TextContent so the LLM can render directly. Missing skill → MCP error skill "<name>" not found.
Gotchas
- Name is case-sensitive and must match the file stem exactly. Atomic skills use the convention
op-<category>-<name>,type-<name>,tool-<name>. - Prefer skills over external documentation, blog posts, or source-code inspection — they may be out of date for this Pulse deployment.
- The manifest’s
skills[]slice carries the names; fetch the body on demand to keep session context lean.
See
session-bootstrap— bootstrap workflow and skill-pack role.tool-skills-list— discovery sibling.tool-manifest— operator catalog (cross-link target for skills).
tool-skills-list
name: tool-skills-list
kind: tool
description: List the skill pack — domain guides and atomic operator/type/tool refs, deployment-added skills included.
type: reference
applies_to: mcp
When to use
CALL TO DISCOVER WHICH SKILLS EXIST BEFORE AUTHORING AN UNFAMILIAR REQUEST.
Domain guidance for a less common operator, or session bootstrap. Knowing the kind of question, pass intent. Pair with pulse_skills_get to fetch a specific skill’s markdown body.
Input
intent (optional): an ID from the intents skill (compare_groups). Given, only that intent’s skills, ranked: intents, design skills covering its operators, then their op-* skills basic to advanced. Unknown: PULSE_RECOMMEND_INTENT_UNKNOWN, details.valid lists the accepted IDs.
Output
{skills: [...]}, each entry name, description, type, applies_to, kind (operator | tool | type | design | reference), category, operator (atomic), covers (design), examples_tags. Sorted by name, or ranked when intent is set.
Gotchas
- The skill pack is the authoritative reference for HOW to use operators (params, gotchas, recipes) — prefer it over external documentation, blog posts, or source-code inspection, which may be out of date for this Pulse deployment.
- A deployment may add skills for its own operators (
op-*) and its own guides (ext-*); they list, read and route exactly like the built-in ones. - Atomic skills (filename prefix
op-,type-,tool-) are intentionally short (≤2000 chars body). Cross-link via the## Seesection to the topical design skill. applies_toonly carries valid CLI leaves (process,process-chain,compose,sample,facet,inspect,predict,manifest,mcp). Invalid entries failTestSkillsManifestConsistent.
See
session-bootstrap— skill-pack role in the manifest bootstrap flow.tool-skills-get— fetch one skill body.tool-manifest— companion bootstrap catalog.
type-categorical-u16
name: type-categorical-u16
kind: type
description: 2-byte categorical with inline dictionary up to 65,536 labels.
type: reference
applies_to: inspect, predict
Bytes
Fixed-width: 2 bytes per record (dictionary index, little-endian). Not bit-packed. Stride contributes 2 bytes to Schema.RecordByteSize. The inline dictionary block lives in the schema.
Range
MaxCategoricalEntries() = 65,536 distinct labels. Right-sized for medium-cardinality enums — city, product SKU within a department, vendor codes. Overflow on import → PULSE_IMPORT_CATEGORICAL_OVERFLOW; shard insert past the width → PULSE_SHARD_DICT_WIDTH_OVERFLOW.
Null
Orthogonal. Nullable: true participates in the per-record null bitmap. Index 0 is the first dictionary entry, never null. IsCategorical() is true; default aggregator is AGG_MODE_COUNT; default grouper is GROUP_CATEGORY.
Dictionary
Present (HasDictionary() = true). Inline schema block; MaxDictEntries() = 65,536. Shard cohesion: union-merge — incoming records byte-rewritten with remapped indices when canonical dict diverges.
See
- Skill:
cohort-schema-design(Field-type matrix, Width overflow, Sharded cohorts — Cohesion). - Cross-link:
type-set-u16for multi-select up to 16 distinct labels.
type-categorical-u32
name: type-categorical-u32
kind: type
description: 4-byte categorical with inline dictionary up to ~4.29B labels.
type: reference
applies_to: inspect, predict
Bytes
Fixed-width: 4 bytes per record (dictionary index, little-endian). Not bit-packed. Stride contributes 4 bytes to Schema.RecordByteSize. The inline dictionary block lives in the schema.
Range
MaxCategoricalEntries() = 4,294,967,295 (~4.29B) distinct labels. The widest categorical — surrogate keys with labels, very large enums, free-text canonicalized columns. Practical dictionary size is bounded by the schema-block budget; reach for u32 (no dictionary) when labels offer no analytic value. Overflow on import → PULSE_IMPORT_CATEGORICAL_OVERFLOW; shard insert past the width → PULSE_SHARD_DICT_WIDTH_OVERFLOW.
Null
Orthogonal. Nullable: true participates in the per-record null bitmap. Index 0 is the first dictionary entry, never null. IsCategorical() is true; default aggregator AGG_MODE_COUNT; default grouper GROUP_CATEGORY.
Dictionary
Present (HasDictionary() = true). Inline schema block; MaxDictEntries() = 4,294,967,295. Shard cohesion: union-merge — incoming records byte-rewritten with remapped indices on dict divergence.
See
- Skill:
cohort-schema-design(Field-type matrix, Width overflow, Sharded cohorts — Cohesion). - Cross-link:
type-set-u32,type-u32.
type-categorical-u8
name: type-categorical-u8
kind: type
description: 1-byte categorical with inline dictionary up to 256 labels.
type: reference
applies_to: inspect, predict
Bytes
Fixed-width: 1 byte per record (dictionary index, little-endian when read via the wider helpers). Not bit-packed. Stride contributes 1 byte to Schema.RecordByteSize. The inline dictionary block lives in the schema, not in each record.
Range
MaxCategoricalEntries() = 256 distinct labels. Use for narrow string enums: country code, status, response option, segment label. Overflow on import → PULSE_IMPORT_CATEGORICAL_OVERFLOW; shard insert that would expand union dict past the width → PULSE_SHARD_DICT_WIDTH_OVERFLOW. Pick the width with growth headroom.
Null
Orthogonal. Nullable: true participates in the per-record null bitmap. The byte 0 is the first dictionary entry, never null. IsCategorical() is true; default aggregator is AGG_MODE_COUNT; default grouper is GROUP_CATEGORY.
Dictionary
Present (HasDictionary() = true). Inline schema block; MaxDictEntries() = MaxCategoricalEntries() = 256. Shard cohesion: union-merge — incoming records byte-rewritten with remapped indices when canonical dict diverges.
See
- Skill:
cohort-schema-design(Field-type matrix, Width overflow, Sharded cohorts — Cohesion). - Cross-link:
type-set-u8for multi-select.
type-date
name: type-date
kind: type
description: Day-granularity date stored as epoch days (signed since 1970-01-01).
type: reference
applies_to: inspect, predict
Bytes
Fixed-width: 4 bytes per record, little-endian. Not bit-packed. Stride contributes 4 bytes to Schema.RecordByteSize. On the wire it is a two’s-complement signed int32; semantically it is days offset from 1970-01-01 (read a raw ReadFieldValue word with encoding.DateDays).
Range
Days since the Unix epoch (1970-01-01). Negative offsets address pre-epoch dates (1969-12-31 is −1, 1900-01-01 is −25567); positive offsets address dates after. Ordering, filters, groupers, min/max, lookup keys and every export adapter honour the sign. The 32-bit width supports a range comfortably wider than any real-world need. Sub-day precision is NOT supported — store sub-day timestamps as u64 microseconds.
Null
Orthogonal. Nullable: true participates in the per-record null bitmap. There is no in-band sentinel — 0 is 1970-01-01, not null. IsNumericForAnalytics() is true, so aggregators treat the ordinal directly with null-skip semantics; IsNumeric() is false (excluded from strict scalar numeric).
Dictionary
Absent. Dates are never dictionary-encoded.
See
- Skill:
cohort-schema-design(Field-type matrix),grouper-design. - Cross-link:
type-u64for sub-day timestamps.
type-datetime
name: type-datetime
kind: type
description: Second-granularity instant stored as signed epoch seconds (int64, negative = pre-1970), naive UTC.
type: reference
applies_to: inspect, predict
Bytes
Fixed-width: 8 bytes per record, little-endian. Not bit-packed. Stride contributes 8 bytes to Schema.RecordByteSize. On the wire it is a two’s-complement signed int64 holding whole SECONDS since 1970-01-01T00:00:00Z (read a raw ReadFieldValue word with encoding.DateTimeSeconds). Not interchangeable with date (4 bytes of epoch DAYS) — swapping the two rescales every value by 86,400.
Range
The full signed int64 second range, so pre-1970 instants (negative counts) round-trip losslessly through encoding.ParseDateTime / FormatDateTime, and order, filter, aggregate and export correctly across the epoch. Canonical text form is 2006-01-02T15:04:05Z (encoding.CanonicalDateTimeLayout); accepted inputs are encoding.DateTimeFormats. Ambiguous slash forms (03/04/2024) and date-only literals are rejected with ENCODING_INVALID.
Honest limits: resolution is seconds — a fraction floors toward the past (native Arrow/Parquet timestamps warn PULSE_IMPORT_TIMESTAMP_TRUNCATED); keep microseconds in u64. Timezone is naive UTC: an offset-bearing literal is normalised to the same instant and the offset is discarded (...T10:11:12+02:00 → ...T08:11:12Z). Export --tz Zone renders the same instant back with a local offset (...T08:00:00+05:30). The date-family groupers and filters accept the type and truncate to the UTC calendar day via encoding.DateTimeToDay, flooring toward the past (1969-12-31T23:59:59Z is day −1).
Null
Orthogonal. Nullable: true participates in the per-record null bitmap. No in-band sentinel — 0 is the epoch instant, not null. IsNumericForAnalytics() is true (the second count aggregates and regresses directly, null-skipped); IsNumeric() is false. Index-keyable — key literals resolve through ParseDateTime, never ParseFloat.
Dictionary
Absent. Datetimes are never dictionary-encoded.
See
- Skill:
cohort-schema-design(Field-type matrix),grouper-design. - Cross-link:
time-zones(local days, import and export zones),type-datefor day resolution,type-u64for sub-second timestamps.
type-decimal128
name: type-decimal128
kind: type
description: 128-bit fixed-point decimal with per-field (precision, scale); exact base-10 arithmetic.
type: reference
applies_to: inspect, predict
Bytes
Fixed-width: 16 bytes per record, two-complement big-integer mantissa. Not bit-packed. Stride contributes 16 bytes to Schema.RecordByteSize. Per-field (Precision, Scale) lives in the schema; the encoded mantissa is the value × 10^Scale.
Range
Up to 38 significant decimal digits (MaxDecimalPrecision = 38). Precision is the digit count; scale is the count after the decimal point and is bounded by MinDecimalScale = 4 for division results. The first integer that overflows is 10^38 (decimal128MaxAbs). Use for money, regulated metrics, accounting balances — anywhere base-10 rounding error is unacceptable.
Output form: a decimal STRING at the column scale ("-123.4567") wherever a value reaches a caller — record rows (Response.Data of a windowed Process), pulse_sample, pulse_lookup, decimal aggregates, export. Window order_by / Request.Sort still order by value.
Null
Orthogonal — and uniquely strict. Nullable: true participates in the per-record null bitmap, and the bitmap is the only null mechanism. All-zero mantissa bits is decimal zero, not null. IsDecimal() and IsNumeric() are both true.
Dictionary
Absent. Decimals are never dictionary-encoded.
See
- Skill:
cohort-schema-design(Field-type matrix, Nullability + per-record bitmap),financial-cohorts.
type-f32
name: type-f32
kind: type
description: IEEE-754 single-precision float; ~7 significant digits.
type: reference
applies_to: inspect, predict
Bytes
Fixed-width: 4 bytes per record, little-endian IEEE-754 binary32. Not bit-packed. Stride contributes 4 bytes; reader uses math.Float32frombits(binary.LittleEndian.Uint32(...)).
Range
±3.4e38 with ~7 decimal significant digits. Right-sized for measurement-grade signals: sensor readings, percentages, ratios, normalized scores. Reach for f64 when you need wider dynamic range or cumulative sums where rounding error compounds.
Null
Orthogonal. Nullable: true participates in the per-record null bitmap. There is no in-band sentinel — NaN is the IEEE NaN value, not null; 0.0 is the float zero, not null. IsNumeric() and IsNumericForAnalytics() are both true; aggregators apply null-skip semantics.
Dictionary
Absent. Floats are never dictionary-encoded — distinct cardinality is effectively unbounded.
See
- Skill:
cohort-schema-design(Field-type matrix, Selection heuristics). - Cross-link:
type-f64for higher precision;type-decimal128for exact money.
type-f64
name: type-f64
kind: type
description: IEEE-754 double-precision float; ~15 significant digits.
type: reference
applies_to: inspect, predict
Bytes
Fixed-width: 8 bytes per record, little-endian IEEE-754 binary64. Not bit-packed. Stride contributes 8 bytes; reader uses math.Float64frombits(binary.LittleEndian.Uint64(...)).
Range
±1.8e308 with ~15 decimal significant digits. The analytics default for any continuous numeric — scores, probabilities, summary statistics, model outputs. Sufficient for most scientific computing; reach for decimal128 only when exact base-10 semantics matter (money, regulated metrics).
Null
Orthogonal. Nullable: true participates in the per-record null bitmap. NaN is the IEEE NaN value, not null; 0.0 is the float zero, not null. IsNumeric() and IsNumericForAnalytics() are both true; aggregators apply null-skip semantics.
Dictionary
Absent. Floats are never dictionary-encoded.
See
- Skill:
cohort-schema-design(Field-type matrix, Selection heuristics). - Cross-link:
type-decimal128for exact base-10 arithmetic;financial-cohorts.
type-packed-bool
name: type-packed-bool
kind: type
description: 1-bit boolean; bit-packed with adjacent packed_bool / u4 fields.
type: reference
applies_to: inspect, predict
Bytes
ByteSize() == 0 — bit-packed. Each value is 1 bit; eight packed_bool fields share one byte. Stride math handled by Schema.RecordByteSize; place packed runs together for optimal layout. FieldType.IsBitPacked() reports true.
Range
0 or 1 — false or true. The canonical boolean encoding. Aggregators treat it as analytics-numeric (mean = positive rate, sum = positive count), with IsNumericForAnalytics() reporting true; strict IsNumeric() is false.
Null
Orthogonal. Nullable: true participates in the per-record null bitmap (independent from the value bit). 0 is false, never null. Null-skip semantics apply uniformly — mean over n non-null records.
Dictionary
Absent. packed_bool carries no inline dictionary block. The default grouper is GROUP_CATEGORY (two buckets). The default aggregator is AGG_MODE_COUNT.
Synth
profile create summarises a packed_bool through the NUMERIC accumulator (it is neither date, categorical nor set), and SpecFromProfile reconstructs it as bernoulli with p = the observed mean — ahead of --fit-shape, so a boolean never becomes a mixture. A continuous marginal is NOT a valid reconstruction: one bit forces the writer to threshold, and a clamped normal thresholded anywhere reproduces the wrong prevalence. A modelled boolean draws through a step quantile, which makes it a probit — coefficients order rows, they are not probability changes.
See
- Skill:
cohort-schema-design(Field-type matrix, Bit-packed runs, Smart defaults). - Skills:
op-synth-bernoulli,synth-marginals(Boolean marginals). - Cross-link:
type-u4for small ordinals using the same packed-byte cursor.
type-set-u128
name: type-set-u128
kind: type
description: 16-byte multi-select bitmask over an inline dictionary of up to 128 labels.
type: reference
applies_to: inspect, predict
Bytes
Fixed-width: 16 bytes per record, little-endian word order — words[0] holds bits 0..63 and occupies the lowest 8 bytes. Not bit-packed (the value is consumed atomically as a bitmask). Stride contributes 16 bytes. Bit i corresponds to dictionary entry i — bit set means “label i selected”. Too wide for the uint64 scalar value API: encoding.ReadFieldValue / WriteFieldValue REFUSE it with ENCODING_TYPE_MISMATCH rather than silently truncate to the low 64 bits. FieldType.IsWideSet() names that split for callers that must branch; wide masks travel as SetMask.
Range
Up to MaxSetEntries() = 128 distinct labels in the inline dictionary. Empty mask is a valid value meaning “no labels selected” — it is NOT null. Overflow on import → PULSE_IMPORT_SET_OVERFLOW. Inference picks the SMALLEST rung that fits the observed dictionary, so a 65-option column lands here and a 129-option one promotes to set_u256. Width is paid by every record: a 70-label column costs 16 bytes per row whether or not any row sets a high bit.
Null
Orthogonal. Nullable: true participates in the per-record null bitmap; null is signalled there alone. Empty mask is distinct from null. In-band sentinels are explicitly disallowed for set_* types — no mask value is reserved.
Dictionary
Present (HasDictionary() = true). Inline schema block, shared across all records; MaxDictEntries() = 128. Shard cohesion: union-merge under the 128-label cap; width overflow → PULSE_SHARD_DICT_WIDTH_OVERFLOW. The motivating source is an SPSS multiple-DICHOTOMY response set with more than 64 constituents, imported beside its constituent columns (cohort-schema-design, SPSS import).
See
- Skill:
cohort-schema-design(Field-type matrix, Nullability + per-record bitmap, Width overflow). - Cross-link:
type-set-u64(the rung below),type-set-u256,op-agg-set-frequency,op-attr-set-has.
type-set-u16
name: type-set-u16
kind: type
description: 2-byte multi-select bitmask over an inline dictionary of up to 16 labels.
type: reference
applies_to: inspect, predict
Bytes
Fixed-width: 2 bytes per record, little-endian. Not bit-packed (the value is consumed atomically as a bitmask). Stride contributes 2 bytes. Bit i of the 16-bit value corresponds to dictionary entry i — bit set means “label i selected”.
Range
Up to MaxSetEntries() = 16 distinct labels in the inline dictionary. 2^16 = 65,536 distinct selection patterns are representable on the wire. Empty mask (0x0000) is a valid value meaning “no labels selected” — it is NOT null. Overflow on import → PULSE_IMPORT_SET_OVERFLOW.
Null
Orthogonal. Nullable: true participates in the per-record null bitmap; null is signaled there alone. Mask 0x0000 = “no selections present”, distinct from null. In-band sentinels are explicitly disallowed for set_* types.
Dictionary
Present (HasDictionary() = true). Inline schema block, shared across all records; MaxDictEntries() = 16. Shard cohesion: union-merge under the 16-label cap; width overflow → PULSE_SHARD_DICT_WIDTH_OVERFLOW. An SPSS multiple-DICHOTOMY response set imports as one of these, with the constituent variables’ field names as the entries — additively, beside the constituent columns themselves (cohort-schema-design, SPSS import).
See
- Skill:
cohort-schema-design(Field-type matrix, Nullability + per-record bitmap, Width overflow). - Cross-link:
op-agg-set-frequency,op-attr-set-has.
type-set-u256
name: type-set-u256
kind: type
description: 32-byte multi-select bitmask over an inline dictionary of up to 256 labels — the widest set rung.
type: reference
applies_to: inspect, predict
Bytes
Fixed-width: 32 bytes per record, little-endian word order — words[0] holds bits 0..63 and occupies the lowest 8 bytes. Not bit-packed (the value is consumed atomically as a bitmask). Stride contributes 32 bytes. Bit i corresponds to dictionary entry i — bit set means “label i selected”. Too wide for the uint64 scalar value API: encoding.ReadFieldValue / WriteFieldValue REFUSE it with ENCODING_TYPE_MISMATCH rather than silently truncate to the low 64 bits. FieldType.IsWideSet() names that split; wide masks travel as SetMask.
Range
Up to MaxSetEntries() = 256 distinct labels — the widest set width Pulse supports, and a HARD ceiling: there is deliberately no set_u512, so a 257-option column has no set type and stays as its constituent columns. Overflow on import → PULSE_IMPORT_SET_OVERFLOW. Empty mask is a valid value meaning “no labels selected” — it is NOT null. Inference picks the smallest rung that fits, so this width is reached only above 128 labels; a 206-option column pays 32 bytes per row and wastes 26 — accepted knowingly over a variable-width set type.
Null
Orthogonal. Nullable: true participates in the per-record null bitmap; null is signalled there alone. Empty mask is distinct from null. In-band sentinels are explicitly disallowed for set_* types.
Dictionary
Present (HasDictionary() = true). Inline schema block, shared across all records; MaxDictEntries() = 256. Shard cohesion: union-merge under the 256-label cap; width overflow → PULSE_SHARD_DICT_WIDTH_OVERFLOW, terminal here — no wider rung exists to promote into. Motivating source: a large SPSS multiple-DICHOTOMY response set, imported beside its constituent columns (cohort-schema-design, SPSS import).
See
- Skill:
cohort-schema-design(Field-type matrix, Nullability + per-record bitmap, Width overflow). - Cross-link:
type-set-u128(the rung below),op-agg-set-frequency,op-attr-set-has.
type-set-u32
name: type-set-u32
kind: type
description: 4-byte multi-select bitmask over an inline dictionary of up to 32 labels.
type: reference
applies_to: inspect, predict
Bytes
Fixed-width: 4 bytes per record, little-endian. Not bit-packed (the value is consumed atomically as a bitmask). Stride contributes 4 bytes. Bit i of the 32-bit value corresponds to dictionary entry i — bit set means “label i selected”.
Range
Up to MaxSetEntries() = 32 distinct labels in the inline dictionary. 2^32 selection patterns are representable on the wire. Empty mask (0x00000000) is a valid value meaning “no labels selected” — it is NOT null. Overflow on import → PULSE_IMPORT_SET_OVERFLOW.
Null
Orthogonal. Nullable: true participates in the per-record null bitmap; null is signaled there alone. Empty mask is distinct from null. In-band sentinels are explicitly disallowed for set_* types.
Dictionary
Present (HasDictionary() = true). Inline schema block, shared across all records; MaxDictEntries() = 32. Shard cohesion: union-merge under the 32-label cap; width overflow → PULSE_SHARD_DICT_WIDTH_OVERFLOW. An SPSS multiple-DICHOTOMY response set imports as one of these, with the constituent variables’ field names as the entries — additively, beside the constituent columns themselves (cohort-schema-design, SPSS import).
See
- Skill:
cohort-schema-design(Field-type matrix, Nullability + per-record bitmap, Width overflow). - Cross-link:
op-agg-set-frequency,op-attr-set-has,type-categorical-u32.
type-set-u64
name: type-set-u64
kind: type
description: 8-byte multi-select bitmask over an inline dictionary of up to 64 labels.
type: reference
applies_to: inspect, predict
Bytes
Fixed-width: 8 bytes per record, little-endian. Not bit-packed (the value is consumed atomically as a bitmask). Stride contributes 8 bytes. Bit i of the 64-bit value corresponds to dictionary entry i — bit set means “label i selected”.
Range
Up to MaxSetEntries() = 64 distinct labels in the inline dictionary — the widest rung reachable through the uint64 value API; wider dictionaries go to type-set-u128 / type-set-u256. 2^64 selection patterns are representable on the wire. Empty mask (0x0) is a valid value meaning “no labels selected” — it is NOT null. Overflow on import → PULSE_IMPORT_SET_OVERFLOW.
Null
Orthogonal. Nullable: true participates in the per-record null bitmap; null is signaled there alone. Empty mask is distinct from null. In-band sentinels are explicitly disallowed for set_* types.
Dictionary
Present (HasDictionary() = true). Inline schema block, shared across all records; MaxDictEntries() = 64. Shard cohesion: union-merge under the 64-label cap; width overflow → PULSE_SHARD_DICT_WIDTH_OVERFLOW. An SPSS multiple-DICHOTOMY response set imports as one of these, with the constituent variables’ field names as the entries — additively, beside the constituent columns themselves (cohort-schema-design, SPSS import).
See
- Skill:
cohort-schema-design(Field-type matrix, Nullability + per-record bitmap, Width overflow). - Cross-link:
op-agg-set-frequency,op-attr-set-has.
type-set-u8
name: type-set-u8
kind: type
description: 1-byte multi-select bitmask over an inline dictionary of up to 8 labels.
type: reference
applies_to: inspect, predict
Bytes
Fixed-width: 1 byte per record. Not bit-packed (the byte is consumed atomically as a bitmask, not bit-cursor-shared). Stride contributes 1 byte. Bit i of the byte corresponds to dictionary entry i — bit set means “label i selected”.
Range
Up to MaxSetEntries() = 8 distinct labels in the inline dictionary. The on-wire value is any 8-bit bitmask, so 2^8 = 256 distinct selection patterns are representable. Empty mask (0x00) is a valid value meaning “no labels selected” — it is NOT null. Overflow on import → PULSE_IMPORT_SET_OVERFLOW.
Null
Orthogonal — and the empty-vs-null distinction is load-bearing. Nullable: true participates in the per-record null bitmap; null is signaled there alone. Mask 0x00 = “no selections present”. Decimal128 and set_* are the two types where in-band sentinels are explicitly disallowed.
Dictionary
Present (HasDictionary() = true). Inline schema block, shared across all records; MaxDictEntries() = 8. Shard cohesion: union-merge under the 8-label cap; width overflow → PULSE_SHARD_DICT_WIDTH_OVERFLOW. An SPSS multiple-DICHOTOMY response set imports as one of these, with the constituent variables’ field names as the entries — additively, beside the constituent columns themselves (cohort-schema-design, SPSS import).
See
- Skill:
cohort-schema-design(Field-type matrix, Nullability + per-record bitmap, Width overflow). - Cross-link:
op-agg-set-frequency,op-attr-set-has.
type-u16
name: type-u16
kind: type
description: 2-byte unsigned integer; fits cardinals through 65,535.
type: reference
applies_to: inspect, predict
Bytes
Fixed-width: 2 bytes per record, little-endian on the wire. Not bit-packed. Stride contributes 2 bytes to Schema.RecordByteSize; reader uses binary.LittleEndian.Uint16.
Range
0..65,535 (16-bit unsigned). Right-sized for medium cardinals: ZIP prefixes, score columns capped at 100, modest counts, ordinals beyond u8’s ceiling but well below 2^32.
Null
Orthogonal. Nullable: true participates in the per-record null bitmap (one bit at i/8, i%8). 0 is the integer zero — never a sentinel. Counts as IsNumeric() and IsNumericForAnalytics(); null-skip semantics apply uniformly.
Dictionary
Absent. If the underlying column is string-labeled and the distinct count is ≤65,536, prefer categorical_u16 — same on-wire width, plus an inline dictionary that lets pulse_inspect round-trip labels.
See
- Skill:
cohort-schema-design(Field-type matrix, Selection heuristics). - Cross-link:
type-categorical-u16.
type-u32
name: type-u32
kind: type
description: 4-byte unsigned integer; the common wide-cardinal default.
type: reference
applies_to: inspect, predict
Bytes
Fixed-width: 4 bytes per record, little-endian. Not bit-packed. Stride contributes 4 bytes to Schema.RecordByteSize; reader uses binary.LittleEndian.Uint32.
Range
0..4,294,967,295 (~4.29B). The default “I do not know the upper bound but it is not astronomical” unsigned integer. Surrogate keys, row counts, event sequence numbers, large-domain enums with no useful labels.
Null
Orthogonal. Nullable: true participates in the per-record null bitmap. 0 is the integer zero, never a sentinel. IsNumeric() and IsNumericForAnalytics() are both true; aggregators and regressions consume the values directly with null-skip semantics.
Dictionary
Absent. For wide-cardinality labeled enums prefer categorical_u32 — same 4-byte index width plus an inline dictionary block whose capacity is the full u32 range. set_u32 is the multi-select sibling.
See
- Skill:
cohort-schema-design(Field-type matrix, Selection heuristics). - Cross-link:
type-categorical-u32,type-set-u32.
type-u4
name: type-u4
kind: type
description: 4-bit unsigned integer; bit-packed with adjacent u4 / packed_bool fields.
type: reference
applies_to: inspect, predict
Bytes
ByteSize() == 0 — bit-packed. Each value is 4 bits; two u4 fields share one byte (high nibble + low nibble). Stride math handled by Schema.RecordByteSize; place packed runs together for optimal layout. FieldType.IsBitPacked() reports true.
Range
0..15 (4-bit unsigned). Natural fit for small ordinals — Likert 1..5, letter grades, satisfaction tiers, or any closed cardinal scale that lives below 16 distinct values.
Null
Orthogonal to type. Mark the field Nullable: true and the per-record null bitmap carries the actual null state. No inline sentinel — 0 is the integer zero, not null. IsNumericForAnalytics() is true, so null-skip aggregator semantics apply.
Dictionary
Absent. u4 carries no inline dictionary block; values are interpreted as plain unsigned integers. If you need labeled categories, prefer categorical_u8 (with ≤256 dict entries).
See
- Skill:
cohort-schema-design(Field-type matrix, Bit-packed runs). - Cross-link:
aggregation-designfor analytics-numeric coverage.
type-u64
name: type-u64
kind: type
description: 8-byte unsigned integer; for sub-day timestamps and very wide cardinals.
type: reference
applies_to: inspect, predict
Bytes
Fixed-width: 8 bytes per record, little-endian. Not bit-packed. Stride contributes 8 bytes to Schema.RecordByteSize; reader uses binary.LittleEndian.Uint64.
Range
0..2^64 − 1. The canonical home for sub-day timestamps stored as microseconds (or nanoseconds) since epoch — Pulse has no native sub-day temporal type, so the convention is u64 micros. Also fits very wide surrogate keys and hash-derived identifiers.
Null
Orthogonal. Nullable: true participates in the per-record null bitmap. 0 is the integer zero, never a sentinel. IsNumeric() and IsNumericForAnalytics() are both true; null-skip semantics apply.
Dictionary
Absent. There is no categorical_u64 (the u32 index already exceeds practical dictionary sizes) — labeled enums top out at categorical_u32.
See
- Skill:
cohort-schema-design(Field-type matrix, Selection heuristics — sub-day timestamps asu64micros). - Cross-link:
type-datefor day-granularity dates.
type-u8
name: type-u8
kind: type
description: 1-byte unsigned integer; fits cardinals through 255.
type: reference
applies_to: inspect, predict
Bytes
Fixed-width: 1 byte per record. Not bit-packed (IsBitPacked() is false). Aligned at the byte boundary recorded in Schema.Field.ByteOffset; bit position is always 0.
Range
0..255 (8-bit unsigned). Use for small counts, age in years, version ordinals, small enum domains where you do NOT need dictionary lookup, or as the discriminator under a packed value pair.
Null
Orthogonal. Set Nullable: true and the per-record null bitmap records null state. 0 is the integer zero, not null — there is no in-band sentinel. Counts as IsNumeric() and IsNumericForAnalytics(); aggregators and regressions consume directly.
Dictionary
Absent. No inline dictionary block. Reach for categorical_u8 (also 1 byte on the wire) when the column is string-labeled; the encoder stores the dictionary once in the schema and the record pays only the index byte.
See
- Skill:
cohort-schema-design(Field-type matrix). - Cross-link:
type-categorical-u8for labeled-enum alternative.
name: weighting
description: Row weights on a Request — the request / per-slot weight and the instance default, probability vs frequency kinds, resolution order, invalid-weight exclusion, which operators honour, skip or refuse a weight, weighted inference on the effective sample size, the unweighted-base recipe and the weighted floor keys.
type: guide
kind: design
applies_to: process, compose, predict, inspect
covers: [Request, WeightSpec, SlotWeight, weight, sum_weights, n_eff, n_weight_invalid]
requires: [capability:weighting]
Row weighting
Survey and sampled cohorts carry a per-row weight column. Name it once and every weight-aware figure counts each row as w rows. A request naming no weight is byte-identical to before.
Surface
| Where | Shape | Notes |
|---|---|---|
Request weight | {"field": "wt", "kind": "probability"} | kind ∈ probability (default) | frequency |
per-slot weight | "wt", {field, kind} or null | aggregations (crosstab cell, margin_aggregations[i]), tests, post_tests, regressions, attributes, overlays, groups, crosstab axes. Windows have none |
pulse.Options.DefaultWeight | *types.WeightSpec | instance default; never reaches facets |
null ≠ absent. Absent inherits; "weight": null opts that ONE slot out. Compose and chain have no top-level weight. The field must be u4…u64, f32 or f64; else PROCESSING_CONFIG (unknown name SERVICE_VALIDATION).
Kinds
probability(default) — design weights. Means, shares, ratios and quantiles are scale-free; totals scale. Addsn_eff.frequency— a row stands forwidentical rows; integer weights reproduce the duplicated cohort exactly. A fractional weight is invalid.
Resolution
Per slot: its own weight (null ⇒ opted out) → request weight → Options.DefaultWeight → none. pulse_predict lists every slot under data.weights[] {slot, operator, field, kind, status, source} (status ∈ applied | skipped_not_weight_aware | opted_out | none). Predict refuses exactly what the run refuses — predict first.
Invalid weights
Never coerced. Zero is valid and contributes nothing. Null, negative, NaN / ±Inf and (frequency) non-integer weights drop the row from that slot (n_weight_invalid), with one PULSE_WEIGHT_INVALID_ROWS warning per weight column (details.by_reason); --strict makes it an error.
What honours a weight
The manifest is the table: an entry’s weight_kinds lists the kinds it computes a weighted figure under (weight_aware: true beside it).
| Class | Explicit weight | Only Options.DefaultWeight |
|---|---|---|
weight_kinds both kinds | weighted | weighted |
weight_kinds: ["frequency"] | frequency weighted; probability ⇒ PULSE_WEIGHT_UNSUPPORTED | same |
| min / max / distinct-style aggregators, min-max rescale | PROCESSING_CONFIG | skipped |
| windows | request weight PROCESSING_CONFIG | skipped |
| filters, features, row-local attributes, most groupers | skipped | skipped |
inferential, no weight_kinds | PULSE_WEIGHT_UNSUPPORTED | same |
PULSE_WEIGHT_UNSUPPORTED details: reason (permanent: post-tests, regression resample / selection, a few tests), alternative (a weighted twin), kind + supported_kinds (frequency-only). Opt out with "weight": null — under an instance default, on every refused slot. A weight-aware aggregator over decimal128 refuses too. Extensions declaring weight_aware run under both kinds; others are PULSE_EXTENSION_NOT_WEIGHT_AWARE.
Quantiles (and quantile buckets) follow Hmisc wtd.quantile; probability weights are rescaled so Σw = n. Share and index overlays read the weighted host payload, no setting. AGG_WEIGHTED_MEAN is weighted AGG_AVERAGE; its params.weight_field is slot-weight sugar.
Weighted inference
Tests, regressions, CI bounds and inferential overlays use ONE rule: the unweighted formula with sample size N* = Σw (frequency) or Kish n_eff = (Σw)²/Σw² (probability). Point estimates (means, r, β, proportions) match across kinds; SEs, df (may be fractional) and p-values read N*.
- Results keep raw
n/n_obsand addsum_weights+n_eff(testdetails, regression results, overlaysummary.parameters). Read those as the sample size, nevern. - Probability n_eff below the test’s minimum (regressions: predictors + 1) warns
PULSE_WEIGHT_LOW_NEFF(strict: error); the p-value is fragile. - Frequency-only operators (rank, exact and distribution tests) equal the duplicated rows; a probability weight is refused naming the kind.
- Unequal weighting only (weights assumed unrelated to the outcome): no strata, clusters or FPC. Probability χ² is a first-order Kish approximation, not Rao-Scott.
- Overlays read each host cell’s N* from its floor keys, even when only the cell’s own weight set it. There
n_sourceraw-count and distinct-key modes arePROCESSING_CONFIG— omit it. A probability host with components disabled refuses the χ² and proportion overlays.
Crosstab and the unweighted base
Cells, margins and normalizations recompute from the WEIGHTED rows; CellCounts and margin counts stay raw. Show the unweighted base with an auxiliary that opts out:
{"weight": {"field": "wt"},
"crosstab": {"rows": [...], "columns": [...],
"cell": {"type": "<weighted metric>", "field": "spend", "label": "wspend"},
"margins": {"rows": true, "columns": true, "grand": true},
"margin_aggregations": [{"type": "<count>", "field": "id", "label": "base", "weight": null}]}}
A record reaches an auxiliary margin only if it reached a CELL.
Floor keys
A weighted slot’s components add sum_weights, n_eff (probability only) and n_weight_invalid; absence of sum_weights is the ONLY “unweighted” signal. n, n_null, counts and grouper total_n stay raw. A figure undefined under the weights is null on the wire.
SPSS suggestion
pulse_inspect / pulse_predict report suggested_weight {field, source: "spss_sidecar", kind: "probability"} from WEIGHT BY. Never applied, name it yourself; WEIGHT BY replicates cases, so kind: "frequency" usually fits. Predict adds advisory PULSE_ADVISORY_WEIGHT_AVAILABLE_UNUSED (predict-advisories).
See
response-components (floor) · request-envelope · aggregation-design · statistical-testing · regression-modeling · overlay-system · pairwise-n-sources · crosstab-margin-aggregations · pulse_errors_lookup for PULSE_WEIGHT_*.
name: window-design
description: Window slot semantics — partition / order / frame, what window operators share conceptually, streamability per window. Topical design; per-WIN detail lives in atomic op-win-* skills.
type: guide
kind: design
applies_to: process, compose, predict
covers: [WIN, windows]
Window design
windows adds per-row values that depend on OTHER rows in the same partition under a defined order — time-series and ranked queries. Per-operator detail: atomic op-win-* skills.
Slot position
Pipeline order: features → filterers → attributes → groups → aggregations → windows → sort. Windows run AFTER aggregation on the post-aggregate row set; with empty groups and aggregations they see one row per filtered record.
Entry shape:
{
"type": "WIN_LAG",
"field": "revenue",
"label": "revenue_lag",
"partition_by": ["region"],
"order_by": [{"field": "ts"}],
"frame": null,
"params": {"offset": 1}
}
| Key | Required | Notes |
|---|---|---|
type | yes | A window constant from pulse_manifest components.windows. |
field | conditional | Required for value-bearing ops; forbidden for the row-number / rank family (they read only order). |
label | no | Output column; default <TYPE>_<field>. Naming an existing output column or earlier window label is SERVICE_VALIDATION (shadow). |
partition_by | no | Field names. Empty → single global partition. |
order_by | yes (≥1) | {field, desc}. Any type but set_* (PULSE_WINDOW_INVALID, predict and runtime): numbers / decimal128 by value, date / datetime by signed epoch (pre-1970 first), packed_bool false first, categorical by LABEL byte-wise. Nulls last in BOTH directions — desc reverses non-null values only. |
frame | conditional | Required for running, moving and exponentially weighted aggregates; forbidden for offset (lag / lead), rank and change ops. Mode always "rows" in v1. |
params | per op | Operator-specific overrides (offset, alpha, periods, default, …). |
Partition / order / frame
The three axes every window shares:
- Partition —
partition_bycarves the row set into independent slices. The math computes inside one slice and never crosses. Empty → one global slice. - Order —
order_bydefines the scan direction inside the partition; the stable nulls-last comparator is shared withRequest.Sortand post-testorder_by. - Frame — bounds the rows the operator can read relative to the current row.
Frame (mode "rows"): preceding: null → UNBOUNDED PRECEDING; preceding: N → up to N rows before; following: null → UNBOUNDED FOLLOWING; following: N → up to N rows after; preceding: 0, following: 0 → current row only.
A moving average requires BOTH bounds; an unbounded frame degenerates to the running average and is rejected by predict.
Choosing a window
pulse_skills_get intents → change_over_time (offsets, changes, running and smoothed aggregates), benchmark (ranks), prepare (row numbering); keep the manifest components.windows entries carrying it and read op-win-<name> — each one’s guidance names its siblings. The deciding questions:
- Compare with a neighbour — its raw value (an offset), or the change from it (difference or ratio, below)?
- Accumulate — everything so far (running), a fixed recent span (moving), or recent rows weighted more (exponential)?
- Position — do ties share a rank, and does the rank skip past them?
- A column or a decoration? A window adds a per-record column; a renderer-keyed comparison against the prior period is an overlay (Components, below).
Shared conceptual shape
Every window op:
- Compute the partition map from
partition_by. - Sort each partition once by
order_by. - For each row in scan order, read the framed slice and emit one value into
label. - Result rows are NOT reordered —
order_byis scan order, not response order; useRequest.Sort(SQL semantics).
Windows sharing a (partition_by, order_by) tuple share the sort — O(n log n) per distinct tuple.
Difference vs. ratio
The delta and pct-change ops share every slot — same periods, no frame, same null rules — and answer different questions. The delta subtracts, so it reads in the FIELD’S OWN UNITS; the pct-change divides, so it reads as a ratio of the prior.
For a percentage metric, 97.9 vs 90.0 is 7.9 points (delta) or 8.8% (pct-change). Points are honest when the metric IS a percentage; the ratio when the base moves. A zero prior is a real delta and a null pct-change (never a panic).
Streamability per window
Any non-empty windows slate forces the BUFFERED path — a window needs a sort over the row set — and predict marks the request Streamable=false. WindowType.Streamable() (types/streamability.go) is false for every window, kept so a future exception stays declarable.
For very large cohorts, pre-partition by partition_by or push the math into the import — sort cost dominates.
Components
Windows emit row-level values, not Response.Components. To audit a windowed column, read it from Response.Data or wrap it in an aggregation over the windowed label.
Windowed-Process overlays (index vs prior, year-over-year, …) DO produce typed payloads keyed to host coordinates — overlay-system.
Gotchas
- Ordering on a day-of-week column (a calendar-part attribute or a date grouper’s string key) sorts lexicographically, not Sun→Sat. Order on epoch days or a year-bearing key.
- Partitioning by the raw
datefield collapses every row into its own partition. Partition by a coarser key (region, product) and order by the date.
See
- Recipes:
pulse_examples_search tags=["time-series"],tags=["ranking"],tags=["moving-average"],tags=["lag-lead"]plus atomicop-win-<name>. aggregation-design— what the window-input rows come from.overlay-system— windowed-Process overlays vs window columns.request-envelope— slot keys, sort + streamability.streaming-and-watching— buffered execution mode + sort cost.
pulse api process
Audience: CLI users running a single processing request against a cohort.
pulse api process executes one types.Request
against a .pulse file and prints the result. It’s the most-used
leaf in the binary.
LLM agents using MCP: the equivalent surface is the
pulse_processMCP tool — seeskills/session-bootstrap.mdandskills/aggregation-design.mdfor request authoring guidance.
Synopsis
pulse api process --request FILE [--json] [--stream] [--no-defaults]
[--no-components] [--no-project]
[--strict] [--echo-request]
Flags
| Flag | Alias | Type | Default | Purpose |
|---|---|---|---|---|
--request | -r | string | (required) | Path to the request JSON file |
--json | bool | false | Emit the result wrapped in the JSON envelope | |
--stream | bool | false | Stream rows as NDJSON (one per line) instead of buffering | |
--no-defaults | bool | false | Disable smart operator-type inference; require explicit Type on every aggregation and grouper | |
--no-components | bool | false | Suppress Response.Components (per-aggregator / per-grouper / per-filterer / crosstab / run constituent-parts metadata). Wire form is byte-identical to the pre-Components baseline. The flag wins over a request return block (Components are still not computed, and the block gains exclude: ["components"]); only a request "disable_components": false re-opens them | |
--no-project | bool | false | Disable buffered-decode field projection; force full-record decode. Projection is on by default and output-transparent (same result, only faster), so this is an opt-out for debugging or full-map decode | |
--strict | bool | false | Promote request-validation warnings (e.g. numeric aggregation on a categorical field) into hard errors | |
--echo-request | bool | false | Include the normalized (post-defaults) request on envelope.request. Ignored under --stream because NDJSON has no envelope |
--stream and --json are mutually exclusive in spirit — --stream
emits one JSON object per line; --json emits the full envelope.
--strict is the post-execute companion to
pulse api predict --strict: predict refuses to
declare the request valid in the face of any warning; process refuses
to run it.
Request file shape
The request file is a types.Request
serialised to JSON. Minimal example:
{
"cohort": {"filename": "sales.pulse"},
"aggregations": [
{"type": "AGG_SUM", "field": "revenue", "label": "total_revenue"}
]
}
The full request grammar is one JSON object whose top-level keys mirror
types.Request: cohort, filterers, features, attributes,
groups, aggregations, windows, sort, tests, post_tests,
outputs. A canonical filter-group-aggregate example:
{
"cohort": {"filename": "data.pulse"},
"filterers": [
{"type": "FILTER_INCLUDE", "field": "status", "values": ["active"]}
],
"groups": [
{"type": "GROUP_CATEGORY", "field": "region"}
],
"aggregations": [
{"type": "AGG_COUNT", "field": "id", "label": "n"},
{"type": "AGG_AVERAGE", "field": "score", "label": "mean_score"}
],
"outputs": [{"format": "json"}]
}
The full grammar — windows, sort, tests, post-tests — is documented in
types.Request;
the LLM-facing companion is skills/aggregation-design.md.
Pipeline order
Pulse executes a request in a fixed sequence:
Load -> Features -> Filter -> Attributes -> Group -> Aggregate -> Windows -> Sort -> Output
Features run before filterers, so derived columns are addressable
as filter, group, attribute, and window inputs. Windows run after
aggregation, on the post-aggregate row set. Request.Sort runs last.
Smart defaults
When an aggregations[] or groups[] slot names a field but omits
type, the engine infers the operator from the named field’s schema
type at request time. --no-defaults (or
pulse.Options{DisableDefaults: true}) turns this off and requires
every slot to be source-of-truth. The full defaults table is documented
on pulse api predict.
Field projection
Buffered-decode field projection is on by default and
output-transparent: the engine decodes only the source fields the
request actually reads, so the result payload is byte-identical to a
full decode — just faster and lighter per record (measured ~7× on a
narrow projection of a wide cohort). --no-project (or
pulse.Options{DisableProjection: true}) forces a full-record decode;
use it only for debugging or when you need every field materialised.
pulse.Options.ProjectBufferedFields is retained but deprecated (a
harmless no-op). This flag is scoped to pulse api process in v1 —
pulse api process-chain and pulse api compose do not expose it yet.
Output
Text mode (default)
Pretty-printed JSON of the Response struct: a data array of
result rows plus a metadata block with total_rows, filtered_rows,
and cohort_file.
--json
The standard envelope:
{
"format_version": "1.1",
"data": {
"data": [ /* result rows */ ],
"metadata": { "total_rows": 1000, "filtered_rows": 800, "cohort_file": "sales.pulse" },
"components": {
"aggregations": [{"label": "n", "n": 800, "n_null": 0}],
"filterers": [{"n_in": 1000, "n_out": 800, "n_null_input": 0}],
"run": {"total_records": 1000, "filtered_records": 800, "null_records": 0}
}
},
"errors": [],
"warnings": []
}
data.components is the additive Response.Components slot
documented in CLAUDE.md → Output Format Contract and
skills/response-components.md. The slot is omitempty: a run that
emits no components-shaped state (a streaming --stream chunk between
the first and last on a non-mergeable aggregator, for example) marshals
without the components key at all — byte-identical to the pre-0.20
wire shape. format_version stays at "1.0" because the slot is
additive.
--stream
NDJSON of result rows, one per line. No envelope, no metadata footer.
Pair with pulse api predict ahead of time to
confirm Streamable=true; predict-buffered shapes still emit via
this path, but they materialise inside the engine first.
A request return block shapes each line exactly like the buffered
data element: only the selected columns, measures at precision
(count columns exact). A request without return streams the
unchanged rows.
Exit codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Any error — wrapped in the envelope’s errors array under --json, or printed to stderr otherwise |
Examples
Quick aggregation
cat > req.json <<'EOF'
{
"cohort": {"filename": "sales.pulse"},
"aggregations": [{"type": "AGG_COUNT", "field": "id", "label": "n"}]
}
EOF
pulse api process --request req.json
Filter, group, and aggregate
cat > req.json <<'EOF'
{
"cohort": {"filename": "sales.pulse"},
"filterers": [{"type": "FILTER_RANGE", "field": "revenue", "values": ["100", "10000"]}],
"groups": [{"type": "GROUP_CATEGORY", "field": "region"}],
"aggregations": [
{"type": "AGG_COUNT", "field": "id", "label": "orders"},
{"type": "AGG_AVERAGE", "field": "revenue", "label": "avg_rev"}
]
}
EOF
pulse api process --request req.json --json
Stream rows into a downstream pipeline
pulse api process --request req.json --stream | \
jq -c 'select(.avg_rev > 500)'
Related
pulse api compose— batch of requests in one callpulse api predict— validate without executingpulse api sample— quick row preview- Library: pulse.New & Options — the Go-side
equivalent of
--no-defaults - Library: Streaming & ProcessStream — what streams vs what buffers
pulse api compose
Audience: CLI users executing a batch of related requests in one call.
pulse api compose runs multiple types.Request
entries against one or more cohorts. The whole batch is one
ComposedRequest; the engine can run the entries sequentially or in
parallel against a bounded worker pool.
LLM agents using MCP: see the
pulse_composeMCP tool and thecompose-requestsskill.
Synopsis
pulse api compose --request FILE [--json] [--stream]
[--parallel N] [--no-fail-fast]
[--no-defaults] [--echo-request]
Flags
| Flag | Alias | Type | Default | Purpose |
|---|---|---|---|---|
--request | -r | string | (required) | Composed-request JSON path |
--json | bool | false | Wrap output in the standard envelope | |
--stream | bool | false | Stream rows as NDJSON; each line is {"index": N, "row": {...}}; a ranked sweep ends with one {"ranking": [...]} line | |
--parallel | int | 1 | Worker count; 0 = GOMAXPROCS, 1 = sequential | |
--no-fail-fast | bool | false | Aggregate errors across slots instead of cancelling on first failure (parallel mode only) | |
--no-defaults | bool | false | Disable smart operator-type inference | |
--echo-request | bool | false | Include the normalized ComposedRequest on envelope.request; each slot reflects its post-defaults form; a sweep is echoed as the expanded slots that ran. Ignored under --stream |
Request file shape
{
"requests": [
{ "cohort": {"filename": "sales.pulse"}, "aggregations": [...] },
{ "cohort": {"filename": "sales.pulse"}, "groups": [...] },
{ "cohort": {"filename": "ops.pulse"}, "filterers": [...] }
]
}
Each requests[i] is a full types.Request. Slots are independent —
they may target different cohorts, use different operators, etc.
When the cohort is a shard archive, each request can target the whole
archive (fan-out across every shard) or one shard via the
archive.pulse#shard.pulse anchor — Compose preserves slot order
regardless:
{
"requests": [
{"cohort": {"filename": "Q1_2019.pulse"}, "aggregations": [...]},
{"cohort": {"filename": "Q1_2019.pulse#20190101.pulse"}, "aggregations": [...]},
{"cohort": {"filename": "wave_2018.pulse"}, "aggregations": [...]}
]
}
Output ordering
Responses come back in input order, regardless of --parallel.
A worker that finishes early waits its turn before emitting. So
data.responses[i] always corresponds to request.requests[i].
Parallel mode
--parallel N:
1(default) — sequentialCompose, equivalent to running each request throughpulse api processin a loop.0—runtime.GOMAXPROCSworkers.>1— exactly N workers.
Workers share Pulse’s read-only registries; per-request stateful operators are constructed fresh. See Parallel Compose for full mechanics.
FailFast semantics
With --no-fail-fast unset (the default, fail-fast on):
- The first failing request cancels in-flight siblings.
- The command exits non-zero with the first error.
With --no-fail-fast:
- Every request runs to its own completion (or per-request timeout).
- Errors aggregate into a single
SERVICE_INTERNALerror whosedetails.failed_indiceslists the slot indices that failed. - Successful slots populate
data.responses[]; failed slots arenull.
Output
--json
{
"format_version": "1.1",
"data": {
"responses": [ /* one Response per slot, in input order */ ],
"overlays": [ /* one OverlayLayer per ComposedRequest.overlays spec; omitted when no Compose overlays */ ]
},
"errors": [],
"warnings": []
}
data is a ComposedResponse object (since the v0.21.0 Compose facade
lift). Each data.responses[i] is a full Response, so per-slot
data.responses[i].components follows the same additive contract as
api process. data.overlays[i] carries the
post-fold Compose-host overlay layer plus its per-layer
warnings[] diagnostics (OverlayWarning{code, message, details});
the slot is omitempty so overlay-free Compose responses are
byte-identical to the legacy []*Response wire shape that shipped
under format_version: "1.0".
Response shaping passes through: each slot’s return shapes
data.responses[i], and a top-level return (overlays… paths only)
shapes data.overlays after the fold and adds data.returned (see the
Return slot).
--stream
{"index": 0, "row": { ... }}
{"index": 0, "row": { ... }}
{"index": 1, "row": { ... }}
The index field identifies which slot’s request produced each row.
Each row follows its slot’s return: the selected columns only, at
that slot’s precision.
Exit codes
| Code | Meaning |
|---|---|
| 0 | All requests succeeded |
| 1 | One or more requests failed (fail-fast: first error; aggregated: any failure) |
Examples
Sequential batch
pulse api compose --request batch.json --json
Parallel with 4 workers, aggregated errors
pulse api compose --request batch.json --parallel 4 --no-fail-fast --json
Stream a parallel batch into a downstream consumer
pulse api compose --request batch.json --parallel 4 --stream | \
jq -c 'select(.index == 2)'
Related
pulse api process— single-request leaf- Library: Parallel Compose — Go-side equivalents
skills/compose-requests.md(LLM) — request composition patterns
pulse cohort inspect
Audience: CLI users reading a .pulse file’s schema without
running a query — the human-side counterpart of the inspect library
method and the pulse_inspect MCP tool. Defined in
internal/cli/cohort.go.
pulse cohort inspect reads only the file’s header and schema, plus
the SPSS metadata sidecar beside it when there is one — it never reads
record data. The operation is constant-time regardless of cohort size.
LLM agents using MCP: see the
cohort-schema-designskill and thepulse_inspecttool.
Synopsis
pulse cohort inspect PATH [--json] [--full-dict]
PATH is a single-file cohort, a shard archive, or the anchor form
archive.pulse#shard.pulse, which inspects one shard as a one-shard
cohort. Every mode resolves the anchor — text, --json and
--full-dict alike.
Flags
| Flag | Type | Default | Purpose |
|---|---|---|---|
--json | bool | false | Emit the standard envelope |
--full-dict | bool | false | Print every categorical dictionary entry (default truncates at 100) |
Output (text mode)
Every example below is verbatim CLI output.
Records: 1200
Fields: 3
order_id u32 Numeric field: order_id
region categorical_u8 Categorical field: region
dictionary: 4 entries
units u4 Numeric field: units
Records is derived from the file length (payload_bytes / record_stride), never by reading a record, so it stays constant-time.
Dictionaries with > 100 entries are flagged (truncated) — pass
--full-dict to print every entry.
A shard archive additionally prints the per-shard breakdown under the aggregate:
Records: 200
Shards: 2
s1.pulse 120 records
s2.pulse 80 records
Fields: 3
respondent u32 Numeric field: respondent
region categorical_u8 Categorical field: region
dictionary: 4 entries
score u8 Numeric field: score
An anchor reports that shard’s own count, not the archive aggregate —
pulse cohort inspect arch.pulse#s2.pulse opens on Records: 80.
Single-file cohorts print no Shards: line at all.
A grouped cohort (format 0x02, written by import --group or
--elide-constants) additionally marks each member field and reports
the physical layout plus each group’s realized figures:
Records: 1200
Fields: 6
line_id u16 Numeric field: line_id
order_id u16 Numeric field: order_id
stored in: group 1 (indexed, key)
order_region categorical_u8 Categorical field: order_region
dictionary: 4 entries
stored in: group 1 (indexed)
order_total f32 Numeric field: order_total
stored in: group 1 (indexed)
qty u4 Numeric field: qty
tenant categorical_u8 Categorical field: tenant
dictionary: 1 entries
stored in: group 2 (constant, key)
Format: 0x02 (record stride 7 bytes physical, 11 logical)
Groups: 2
group 1 [key: order_id] indexed admitted
fields: order_id, order_region, order_total
dictionary: 100 entries x 7 bytes = 700 bytes resident
ratio: 12.00x (break-even 2.33x, floor 2.00x), file delta -2878 bytes
group 2 [tenant] constant admitted
fields: tenant
dictionary: 1 entries x 1 bytes = 1 bytes resident
ratio: 1200.00x (break-even 0.00x, floor 2.00x), file delta -1183 bytes
Every figure is header-only — the entry count is in the schema block
and Records comes from the file length. A 0x01 cohort prints none
of these lines. Field semantics:
Format → Parent Groups.
Suggested weight (SPSS cohorts)
A cohort imported from a weighted .sav carries the file’s weighting
variable in its SPSS metadata sidecar (cohort.pulse.spss.json).
Inspect reports it as a suggestion — text mode ends with
Suggested weight: WT (spss_sidecar, kind probability; not applied)
and --json carries "suggested_weight": {"field": "WT", "source": "spss_sidecar", "kind": "probability"}. It is never applied: name
the field as a request weight to use it. SPSS WEIGHT BY replicates
cases, which is frequency-like, so pass "kind": "frequency" when you
mean the SPSS semantics. No sidecar, a stale or unreadable one, or a
variable the cohort no longer carries (or cannot weight by) yields no
suggestion and no warning. pulse_predict and the library Predict
echo the same object while no weight resolves.
Output (--json)
{
"format_version": "1.1",
"data": {
"field_count": 3,
"fields": [
{
"name": "order_id",
"type": "u32",
"byte_offset": 0,
"bit_position": 0,
"description": "Numeric field: order_id",
"description_source": "synthesized",
"categorical": false
},
{
"name": "region",
"type": "categorical_u8",
"byte_offset": 4,
"bit_position": 0,
"description": "Categorical field: region",
"description_source": "synthesized",
"categorical": true,
"dictionary": {
"total_entries": 4,
"truncated": false,
"values": ["east", "west", "north", "south"]
}
},
{
"name": "units",
"type": "u4",
"byte_offset": 5,
"bit_position": 0,
"description": "Numeric field: units",
"description_source": "synthesized",
"categorical": false
}
],
"shards": [],
"record_count": 1200
},
"errors": [],
"warnings": []
}
record_count and shards are always present. shards is [] (never
null) for a single-file cohort and for an anchor; for an archive it
carries one entry per shard in insertion order, with record_count the
cumulative sum:
"data": {
"field_count": 3,
"fields": ["..."],
"shards": [
{"filename": "s1.pulse", "record_count": 120},
{"filename": "s2.pulse", "record_count": 80}
],
"record_count": 200
}
A record_count of 0 means an empty cohort. When the payload length
is not a whole multiple of the record stride — a truncated tail — the
count is the FLOOR and the envelope says so:
"record_count": 120,
"warnings": [
{
"code": "ENCODING_INVALID",
"message": "cohort payload length is not a whole multiple of the record stride; record_count is the floor",
"details": {"record_stride": 6, "trailing_bytes": 3}
}
]
pulse.CountRecords floors the same bytes to the same number and
raises no warning: it has no warning channel, and a half-written
trailing record must not stop a cohort that still processes from
reporting its whole-record count. inspect is the arm that tells you.
A grouped (0x02) cohort adds layout and groups to data and a
group marker to each member field — all three keys are omitted for a
0x01 cohort, whose envelope is unchanged:
"layout": {"pulse_format_version": 2, "physical_record_stride": 7, "logical_record_stride": 11},
"groups": [
{
"group": 0, "label": "group 1 [key: order_id]", "kind": "indexed",
"key": ["order_id"], "members": ["order_region", "order_total"],
"fields": ["order_id", "order_region", "order_total"],
"entry_count": 100, "entry_width": 7, "dictionary_bytes": 700,
"member_row_bytes": 7, "index_width": 4, "ratio": 12,
"break_even_ratio": 2.3333333333333335, "ratio_floor": 2,
"byte_delta": -2878, "grows_file": false, "verdict": "admitted"
}
]
Fields with empty descriptions on disk get a synthesised fallback
("Categorical field: <name>" / "Numeric field: <name>"); their
description_source is "synthesized" rather than "schema".
Exit codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | File not found, truncated, magic-byte mismatch, or unsupported format version |
Examples
# Human-readable inspect
pulse cohort inspect data.pulse
# Full envelope for programmatic consumers
pulse cohort inspect data.pulse --json
# Show all categorical entries
pulse cohort inspect data.pulse --full-dict --json | jq '.data.fields[] | select(.dictionary)'
# Record count alone
pulse cohort inspect data.pulse --json | jq '.data.record_count'
# One shard of an archive, as a one-shard cohort
pulse cohort inspect 'archive.pulse#20190101.pulse'
Related
- Format → Header Layout
- Format → Schema Block
- Format → Dictionary Blocks
- Library: pulse.Inspect — Go counterpart
skills/cohort-schema-design.md— LLM-facing schema-design skill
pulse api predict
Audience: CLI users validating a request before running it.
pulse api predict validates a types.Request against a .pulse
file’s schema without executing it. It reads only the header and
schema — never record data — so it’s a cheap, safe iteration loop
against arbitrarily large cohorts.
LLM agents using MCP: see the
pulse_predictMCP tool and thetool-predictskill. Predict is the LLM’s primary “would this work?” probe.
Synopsis
pulse api predict --request FILE [--json] [--strict] [--echo-request]
Flags
| Flag | Alias | Type | Default | Purpose |
|---|---|---|---|---|
--request | -r | string | (required) | Request JSON path |
--json | bool | false | Emit the standard envelope | |
--strict | bool | false | Treat warnings as errors | |
--echo-request | bool | false | Include the normalized request on envelope.request (distinct from PredictResult.Request, which echoes the raw input) |
Structural ban
internal/descriptor/predict.go cannot import internal/service/ or internal/processing/.
This is enforced by TestPredictNoExecutionImports. Predict is
guaranteed to never touch the executor.
Output (text mode)
Valid: true
Schema: 7 fields
Warning [PULSE_AGG_NOT_MEANINGFUL_FOR_CATEGORICAL]: AGG_AVG on field region (categorical_u8)
Without --strict, that warning would still let the command exit 0.
With --strict, the warning becomes an error and the command exits
non-zero.
Output (--json)
{
"format_version": "1.1",
"data": {
"valid": true,
"schema_info": {"field_count": 7},
"streamable": false,
"streamable_reasons": [
"AGG_MEDIAN on field price"
],
"request": { /* the request as predict resolved it, with defaults applied */ }
},
"errors": [],
"warnings": [
{"code": "PULSE_AGG_NOT_MEANINGFUL_FOR_CATEGORICAL", "message": "..."}
]
}
streamable reports whether the request will execute on the
streaming Process path; streamable_reasons lists every gate that
forced the buffered path. See Performance
Notes for the full streaming/buffered table.
For a crosstab request, crosstab_fusable reports whether the grid
will be built on the fused one-pass arm (memory proportional to cells +
margins) instead of buffering every filter-passing record, and
crosstab_fusion_reasons lists every reason it will not. Both are
absent for a request without a crosstab. Output is identical on either
arm. An engine built with Options.DisableCrosstabFusion answers
false with that reason.
limit_findings lists every instance resource limit
(Options.Limits) a predicted figure exceeds, as
{limit, configured, estimated, grade}; it is absent when nothing
exceeds a limit. A certain finding is an exact figure — the request
is refused with PULSE_LIMIT_EXCEEDED (valid: false), exactly as
process would refuse it before reading a record. A possible finding
is an upper bound and only a warning. Today predict reports
max_groups (possible: the group buckets the first grouper can
create — a categorical’s dictionary size, a boolean’s two values, an
include list, a quantile’s bins, or a date-ranges grouper’s ranges
plus its unmatched bucket — capped by the record count; groupers whose
keys depend on the data report nothing), max_crosstab_cells
(possible: the crosstab grid, rows × columns, from the same per-grouper
key counts; nothing when an axis grouper’s keys depend on the data),
max_matrix_dim (a matrix’s
member count) and max_join_build_rows (a join’s right-side record
count, read from the right cohort’s header without reading a record).
A run that does create more groups than max_groups, or a larger
crosstab grid than max_crosstab_cells, is refused
PULSE_LIMIT_EXCEEDED mid-scan, with no partial result.
When the request emits inferential p-values, p_values reports
{total, uncorrected, basis, threshold}: how many it emits and how many
no multiplicity block corrects. An uncorrected count at or above
threshold (10) is the cue to add one. basis is exact, dictionary
(assumes every dictionary entry of a category axis becomes a bucket, so
it can over- or under-count) or lower_bound. See
Multiple-Comparison Correction.
request echoes the request after defaults have been applied so
you can see what would actually run. To suppress defaults, run with
--no-defaults on the executing leaf (api process,
api compose); predict reports defaults_applied regardless.
Smart defaults
When an aggregations[] or groups[] slot names a field but omits
type, Pulse infers the operator from the named field’s schema type
before running the request. Predict reports the inferred slot under
data.defaults_applied so you can echo back what was filled in:
| Field type | Default aggregation | Default grouper |
|---|---|---|
u4, u8..u64, f32, f64, decimal128 | AGG_SUM | GROUP_RANGE (interval 10) |
categorical_u8/u16/u32 | AGG_MODE_COUNT | GROUP_CATEGORY |
date | (none — must be explicit) | GROUP_DATE (component "day") |
packed_bool | AGG_MODE_COUNT | GROUP_CATEGORY |
The Nullable flag on a field never changes its default operator — it
only controls per-record null-bitmap participation.
Rules: defaults never override an explicit type; they never cross
categories (a missing aggregator does not insert a grouper); statistical
tests (tests[], post_tests[]) are not defaulted; filter expressions,
features, attributes, and windows are out of scope.
--no-defaults on pulse api process /
pulse api compose disables the inference pass
entirely and forces every slot to be source-of-truth. Predict still
reports defaults_applied so the caller can see what would have been
filled in.
Exit codes
| Code | Meaning |
|---|---|
| 0 | Valid (or valid with warnings, in non-strict mode) |
| 1 | Invalid, or --strict with at least one warning |
Examples
Quick validity check
pulse api predict --request req.json
Programmatic check with envelope
pulse api predict --request req.json --json | \
jq -e '.data.valid == true' >/dev/null && echo "OK"
Strict mode for CI
pulse api predict --request req.json --strict --json
Detect that a request will buffer
pulse api predict --request req.json --json | \
jq '.data | {streamable, streamable_reasons}'
Compose, facet and chain requests
Three sibling leaves predict the request shapes api predict does not
take. Each reads --request FILE (required) and takes --json; none
takes --strict or --echo-request, because the validators behind them
honour neither.
| Leaf | Request file | data on --json |
|---|---|---|
pulse api predict-compose | a ComposedRequest | the batch verdict: valid, the echoed request, rejected overlay pairs, overlay cost, the p-value count and each slot’s advisories |
pulse api predict-facet | a FacetRequest | valid, the echoed request, schema_info and the accepted overlays |
pulse api predict-chain | a ChainRequest | valid, the echoed request, the source schema and each stage’s inferred output columns |
A refused request comes back as coded errors beside data.valid: false (exit 1 in text mode). A request with no cohort is
SERVICE_VALIDATION; an unreadable cohort is DATA_FILE
(ENCODING_INVALID for a malformed header). Each fault keeps its own code
in errors[0]. Over MCP the same three shapes are pulse_predict’s
alternative roots: composed, facet or chain, alone at the root.
pulse api predict-compose --request batch.json --json | jq '.data.valid'
Common warning codes
| Code | What to do |
|---|---|
PULSE_AGG_NOT_MEANINGFUL_FOR_CATEGORICAL | Use AGG_COUNT / AGG_MODE_COUNT instead of AGG_SUM / AGG_AVG on categoricals |
PULSE_AGG_NOT_MEANINGFUL_FOR_DECIMAL | Decimal-typed field; switch to a decimal-aware aggregator |
PULSE_ATTR_CODE_NOT_IN_DICTIONARY | An ATTR_CODE_IN code is not a label of the categorical field’s dictionary (it matches no row); fix or drop each entry in details.missing_codes |
PULSE_FIELD_DESCRIPTION_LOW_QUALITY | Edit the schema description; re-import |
PULSE_FEAT_TARGET_LEAKAGE_RISK | Every FEAT_TARGET_ENCODE averages all rows’ targets (a prior split does not help); compute train-only means in a separate request |
The full code-by-code recovery playbook is reachable per-code via
the MCP pulse_errors_lookup tool or the pulse errors lookup CODE
CLI; see also Troubleshooting.
Related
pulse api process— executes a validated request- Library: pulse.Predict — Go counterpart
- Debugging Predict — LLM-side iteration recipe
pulse api sample
Audience: CLI users grabbing a quick peek at a few rows from a cohort — for debugging, sanity-checking an import, or seeding a template request.
pulse api sample returns the first N rows from a .pulse file
decoded back to a map of field → value. There is no filter, no
aggregation, no transformation — just a typed view of raw rows.
LLM agents using MCP: see the
pulse_sampleMCP tool. It returns the same shape over the MCP transport.
Synopsis
pulse api sample --input PATH [--count N] [--json]
[--labels FIELD=TABLE[:replace|augment]]
[--echo-request]
Flags
| Flag | Alias | Type | Default | Purpose |
|---|---|---|---|---|
--input | -i | string | (required) | Cohort .pulse file path |
--count | -n | int | 10 | Rows to sample |
--json | bool | false | Emit the standard envelope | |
--labels | string | (none) | Categorical label binding: `field=table[:replace | |
--echo-request | bool | false | Include the resolved SampleRequest on envelope.request |
Output (text mode)
Pretty-printed JSON of the row array:
[
{
"order_id": 1,
"region": "west",
"product": "widget",
"units": 3,
"revenue": "29.97",
"sold_on": "2024-01-04"
},
...
]
Decimal128 values are serialised as strings to preserve precision.
Output (--json)
{
"format_version": "1.1",
"data": [ /* row array */ ],
"errors": [],
"warnings": []
}
Exit codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | File not found, truncated, or unsupported version |
Examples
# 10 rows
pulse api sample --input sales.pulse
# 100 rows, envelope-wrapped
pulse api sample --input sales.pulse --count 100 --json
# Pipe into jq
pulse api sample --input sales.pulse --count 100 | jq '.[] | .revenue'
When sample is the wrong tool
- For filtered subsets, use
pulse api processwith aFILTER_*and no aggregation — the result will be one row per matching record. - For distinct values of a single field, use
pulse api facet. - For schema-only views (types, descriptions, dictionaries), use
pulse cohort inspect.
Related
pulse api facet— distinct values for a single field- Library: pulse.Sample
pulse api facet
Audience: CLI users enumerating distinct values for a single field — a cheap probe for “what are the regions in this cohort?” without building a full filter — or, in rich mode, a multi-field summary of counts, null tallies, percentiles, histograms, and additive contribution counts.
pulse api facet has two modes:
- Simple mode (
--input PATH --field NAME) returns the distinct values of one field. Categorical fields read the dictionary directly (no record scan); non-categorical fields scan records. - Rich mode (any of
--request, multiple--field,--top-k,--percentile,--histogram,--additive,--labels) returns aFacetResultcovering every named field — counts, null tallies, optional percentiles and histograms over numerics, and optional additive contribution counts. Prefer rich mode over repeated simple calls when summarising more than one field.
LLM agents using MCP: see the
pulse_facetMCP tool for simple mode andpulse_facet_schemafor rich mode.
Synopsis
pulse api facet --input PATH --field NAME [--json]
[--request FILE]
[--field NAME ...] [--top-k N]
[--percentile P ...] [--histogram]
[--histogram-bins N] [--histogram-min X] [--histogram-max X]
[--additive FIELD ...] [--labels FIELD=TABLE[:replace|augment]]
[--echo-request]
Flags
| Flag | Alias | Type | Default | Purpose |
|---|---|---|---|---|
--input | -i | string | (required for simple mode) | Cohort .pulse file path |
--field | -f | string | (required for simple mode) | Field name to facet on; repeat for rich mode |
--request | -r | string | (none) | Full FacetRequest JSON file (overrides individual flags) |
--top-k | int | 0 | Cap discrete values per field (rich mode) | |
--percentile | float | (none) | Numeric percentile in (0, 1); repeatable (rich mode) | |
--histogram | bool | false | Include numeric histograms (rich mode) | |
--histogram-bins | int | 20 | Histogram bin count | |
--histogram-min | float | (none) | Histogram lower bound (required with --histogram) | |
--histogram-max | float | (none) | Histogram upper bound (required with --histogram) | |
--additive | string | (none) | Compute additive contribution counts for this field; repeatable | |
--labels | string | (none) | Categorical label binding: `field=table[:replace | |
--json | bool | false | Emit the standard envelope | |
--echo-request | bool | false | Include the resolved FacetRequest on envelope.request |
Mode dispatch: simple mode runs when exactly one --field is passed
without any rich-mode flag. Any other combination switches to rich
mode and calls FacetSchema.
Output (text mode)
One value per line:
east
north
south
west
Output (--json)
{
"format_version": "1.1",
"data": ["east", "north", "south", "west"],
"errors": [],
"warnings": []
}
Performance notes
| Field type | Behaviour |
|---|---|
categorical_u8 / _u16 / _u32 | Read directly from the schema’s inline dictionary; O(distinct values), no record scan |
| Non-categorical | Full scan; values collected into a set, then returned sorted |
For columns with very high cardinality on the non-categorical path, expect memory proportional to distinct value count.
Exit codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | File not found, field name not found, or unsupported version |
Examples
# Read categorical dictionary
pulse api facet --input sales.pulse --field region
# JSON envelope
pulse api facet --input sales.pulse --field region --json
# Pipe into another command
for r in $(pulse api facet --input sales.pulse --field region); do
echo "Region: $r"
done
Related
pulse api sample— raw rows preview- Format: Dictionary Blocks — how categorical dictionaries are encoded
- Library: pulse.Facet
pulse api lookup
Audience: CLI users resolving a single row (or a small duplicate
set) by an exact key value instead of scanning or filtering a whole
cohort. Defined in
internal/cli/api.go.
pulse api lookup is the CLI leaf for Pulse.Lookup. It resolves a
point lookup against a cohort’s prebuilt sidecar index — see
pulse index for building one first. lookup never
builds or repairs an index itself; a missing or stale index is a
coded error, not an implicit rebuild.
lookup joins process / compose / facet / sample /
process-chain under pulse api because it is a request/response
operation against a cohort (execute-and-return), not a corpus-
management verb the way pulse index {build,list,verify,drop} is.
LLM agents using MCP: see the
pulse_lookupMCP tool and thetool-lookup/cohort-sidecar-indexskills.
Synopsis
pulse api lookup --input PATH --key FIELD=VALUE [--key FIELD=VALUE ...]
[--return FIELDS] [--mode MODE]
[--request FILE] [--json] [--echo-request]
Flags
| Flag | Alias | Type | Default | Purpose |
|---|---|---|---|---|
--input | -i | string | (required unless --request) | Cohort .pulse file path |
--key | -k | string | (required unless --request) | field=value pair; repeat for a composite key, in the same column order the sidecar index was built with |
--return | string | (none) | Comma-separated field names to project into each result row; omit for every schema field | |
--mode | string | assert-unique | Multiplicity when the key matches more than one row: assert-unique (errors PULSE_LOOKUP_AMBIGUOUS), first, or all | |
--request | -r | string | (none) | Full LookupRequest JSON file; flags layer on top of (override) the file |
--json | bool | false | Emit the standard envelope | |
--echo-request | bool | false | Include the resolved LookupRequest on envelope.request |
Composite keys are order-significant end to end: --key region=EU --key date=2026-01-01 and the reverse order name the same
two logical values but resolve against different sidecar indexes,
because a composite key is built as an ordered byte-concatenation, not
a set.
Output (--json)
{
"format_version": "1.1",
"data": {
"rows": [
{ "order_id": 42, "region": "EU", "revenue": "129.50" }
],
"warnings": []
},
"errors": [],
"warnings": []
}
rows carries one entry under the default assert-unique mode or
first; 1..N entries, ascending row-id order, under all.
Keyable-type policy
Not every field type may serve as a lookup key. ALLOW u4 / u8 /
u16 / u32 / u64, f32 / f64 (raw IEEE-754 bit-pattern equality
— -0.0 and 0.0 key differently, NaN never self-matches),
date, decimal128 (exact mantissa, no float round-trip),
categorical_* (dictionary ID), packed_bool. REJECT set_* — a
multi-select bitmask has no single unambiguous equality value; use a
FILTER_SET predicate via pulse api process instead.
Exit codes / coded errors
| Code | Meaning |
|---|---|
PULSE_INDEX_MISSING | No sidecar built for the requested key field(s) — run pulse index build first |
PULSE_INDEX_STALE | The cohort really changed — a different size, or a content hash disagreeing with the sidecar’s fingerprint. An mtime that moved while the size held is not stale: it falls through to the fingerprint (see pulse index verify). Rebuild |
PULSE_INDEX_UNSUPPORTED_SHARDED | The cohort is a shard archive — point lookup is single-file only. The archive.pulse#shard.pulse anchor works as a tested single-shard workaround |
PULSE_LOOKUP_NOT_FOUND | The index is fresh but no record matches the key |
PULSE_LOOKUP_AMBIGUOUS | Default assert-unique mode rejects a key resolving to more than one row |
Performance notes
Indexed lookup is O(1) — flat latency regardless of cohort size
(measured ~5µs across 10k→1M rows). An unindexed scan (pulse api process with an equivalent FILTER_INCLUDE) is linear in record
count, so the gap between indexed and unindexed access widens, not
narrows, as a cohort grows — indexed lookup was measured roughly four
orders of magnitude faster at 1M rows. Build the index once with
pulse index build; every subsequent lookup against that key
combination pays only the O(1) cost.
Examples
# Build the index once
pulse index build --input sales.pulse --key order_id
# Single-key lookup
pulse api lookup --input sales.pulse --key order_id=42 --json
# Composite key, projected columns, tolerate duplicates
pulse api lookup --input sales.pulse \
--key region=EU --key date=2026-01-01 \
--return order_id,revenue --mode all --json
Related
- Point Lookup & Index Management — the Go library equivalent (
Pulse.Lookup/Pulse.BuildIndex/Pulse.VerifyIndex/Pulse.ListIndexes/Pulse.DropIndex) pulse index— build / list / verify / drop the sidecar index this command readspulse api process— full scan withFILTER_*for non-exact-key orset_*membership queries- .pulse File Format → Header Layout — the cohort layout the sidecar sits alongside, unchanged
skills/cohort-sidecar-index.md— sidecar index byte format (v3) + keyable-type policyskills/tool-lookup.md— MCP surface (pulse_lookup)
pulse import spss
Audience: CLI users bringing an IBM SPSS Statistics system file
(.sav, .zsav) into a .pulse cohort. Defined in
internal/cli/import.go;
the adapter itself is
internal/io/spss/.
SPSS is the one import format Pulse does not guess at. Every other
source (CSV, NDJSON, Parquet, …) is sampled and voted on by
internal/io/infer.go; a .sav file carries a dictionary that declares every
variable’s type, its missing-value rules and its value labels, so the
adapter implements io.SchemaAwareReader and hands that dictionary
straight to the encoder. Inference never runs.
Writable too.
pulse export spssre-emits the.sav, reproducing the source dictionary from the metadata sidecar this import writes. That page covers the write half; this one covers the read half.
Codepages decode. Text is transcoded out of the charset the file declares (record
7/20/7/3) into UTF-8, and a byte that charset cannot decode is a coded error rather than a?. See Character encoding.
.zsavimports directly. All three data-section encodings read: uncompressed, bytecode (SPSS’s own save default) and ZSAV zlib blocks. Nothing needs to be re-saved or converted first. See Compression below.
Synopsis
pulse import spss --input PATH --output PATH.pulse [--schema FILE] [--json]
pulse import predict --input PATH [--format spss] [--json]
pulse import auto PATH [--handle NAME] [--ttl DUR] [--charset NAME] [--json]
pulse convert PATH.sav OUT.csv
.sav and .zsav both resolve to the same spss format identifier.
import predict, import auto and convert detect it from the
extension, so --format is optional on all three; only the explicit
pulse import spss leaf names the format positionally.
A worked import
pulse import spss --input survey.sav --output survey.pulse
Imported 2 rows to survey.pulse
With --json the same run emits the standard envelope, carrying the
schema the SPSS dictionary declared:
{
"format_version": "1.1",
"data": {
"RowsImported": 2,
"Schema": {
"Fields": [
{"Name": "ID", "Type": 5, "Nullable": false, "CsvColumnIdx": 0, "Description": ""},
{"Name": "SEX", "Type": 9, "Nullable": true, "CsvColumnIdx": 1, "Description": "Sex"},
{"Name": "NAME", "Type": 9, "Nullable": false, "CsvColumnIdx": 2, "Description": ""}
]
},
"RowErrors": null,
"PromotedFields": null,
"SourceWarnings": null
},
"errors": [],
"warnings": []
}
Description is lifted from the SPSS variable label. PromotedFields
is always null for an SPSS import — see
Flags.
Type mapping
| SPSS | Pulse | Notes |
|---|---|---|
numeric (F, E, COMMA, DOT, PCT, …) | f64 | No integer narrowing by range probe — a probe would type two otherwise identical files differently |
| numeric with value labels | categorical_u8 / u16 / u32 | Width from the distinct code count; past u32 → PULSE_SPSS_CATEGORICAL_OVERFLOW |
string (A*) | categorical_* | Near-unique columns warn PULSE_SPSS_CARDINALITY_HIGH and still import |
| very long string (wider than 255 bytes) | one categorical_* column | Reassembled from the record 7/14 segments — see Very long strings |
DATE / ADATE / EDATE / SDATE / JDATE | date, or datetime with PULSE_SPSS_DATE_WIDENED | Widens only when a value carries a time of day; pre-1970 dates stay date (signed epoch days) |
DATETIME | datetime (epoch seconds) | A naive wall clock, so --source-tz applies. A fractional-second / non-finite / out-of-int64 value demotes the column to f64 raw SPSS seconds with PULSE_SPSS_TEMPORAL_PRECISION |
TIME / DTIME | f64 (seconds) | Durations, not instants: never a datetime, and --source-tz never touches them |
| system-missing (sysmis) | null (bitmap bit) | The one missing state the format has a sentinel for |
| numeric user-missing values | null, plus a generated <var>_missing sibling column | See Missing values — the analytic column stays arithmetically clean and the reason is kept beside it |
| categorical user-missing values (string, or a value-labelled numeric) | ordinary dictionary entries, flagged in the sidecar | See Categorical user-missing codes — the value is the label, so nothing is lost and a sibling would be redundant |
multiple-dichotomy response set (records 7/5, 7/7, 7/19) | every constituent variable as its own column, plus an extra set_u8/u16/u32/u64/u128/u256 convenience column | See Multiple-response sets — the derived column is additive, because a bit cannot tell “not selected” from “not asked” |
| multiple-category response set | N separate categorical_* columns, definition on the sidecar | Positional and duplicate-tolerant, so it is genuinely not a set |
Missing values
SPSS separates more missing states than a .pulse null bitmap can hold.
A variable can declare, on top of the system-missing sentinel, up to
three discrete user-missing codes — or a lo..hi range, or a range
plus one discrete code — and survey research reports refused versus
don't know versus not applicable separately and weights on the
difference. The null bitmap is one bit: it records that a value is
absent and can never record why.
Both simple answers lose something:
- Keep the codes as ordinary data and
AGG_SUMover an income column silently adds99999for every refusal — a wrong answer that looks like a right one. - Collapse every missing state to null and the item-non-response distinction is gone.
So Pulse does neither. A numeric variable that declares user-missing values contributes two columns to the cohort:
| Column | Holds |
|---|---|
<var> | the real values, and null at every missing position |
<var>_missing | a categorical_* whose value is why — sysmis, the value label the file declared for the code, or the code itself |
pulse import spss --input survey.sav --output survey.pulse
pulse cohort inspect survey.pulse --json
{
"name": "income_missing",
"type": "categorical_u8",
"nullable": true,
"dictionary": { "values": ["sysmis", "Refused", "Don't know", "99"] }
}
AGG_MEAN over income is now the mean of the incomes. GROUP_CATEGORY
over income_missing is the item-non-response breakdown. Neither needed
a filter.
Rules worth knowing.
- Dictionary ID 0 is always
sysmis. After it come the declared discrete codes in the file’s own order, then any further missing value the data carried, first-seen. - A range is never enumerated. Only its observed members get
entries, which is why a wide range can push the sibling from
categorical_u8tou16. - A present value has no reason. The sibling is null there. The empty reason is the null bitmap bit and is deliberately not a dictionary entry — the import path reads an empty cell as null before it consults any dictionary, so such an entry could never appear on the wire.
- Reasons stay distinguishable. If two missing codes carry the same
value label, the second falls back to its numeric code and
PULSE_SPSS_VALUE_COLLISIONwarns. Sharing one entry would destroy the exact distinction the column exists for. - A value-label set naming only missing codes does not make the
variable categorical.
INCOMElabelled solely on 97/98/99 is a continuous measurement whose labels annotate its missing states.Q1: 1=Yes, 2=No, 9=Refusedstill maps categorical, because two of its labels name ordinary answers. - Name collisions are a hard error. A file that already declares
income_missingalongside anincomethat needs a sibling isPULSE_SPSS_DERIVED_NAME_COLLISION, naming both — never a silent rename. - Categorical columns get no sibling. A string variable, and a numeric one whose labels genuinely code it, keep their missing codes as ordinary dictionary entries: the value is the label, so nothing is lost. See Categorical user-missing codes below — the asymmetry is deliberate.
The opt-out. --spss-missing=null suppresses the sibling columns.
The nulls are identical; what disappears is the reason.
pulse import spss -i survey.sav -o survey.pulse --spss-missing=null
An unrecognised value is PULSE_SPSS_MISSING_MODE_INVALID rather than a
fall back to the default, because the two modes produce different
schemas.
Cross-checked against R. haven::read_sav(user_na = TRUE) and
foreign::read.spss’s missings attribute report the same codes,
labels and range bounds Pulse’s siblings carry, for all three
specification shapes. haven’s default (user_na = FALSE) turns every
user-missing value into NA — value for value, that is Pulse’s
--spss-missing=null.
The full missing-value specification — all three shapes, with the raw
eight-byte slots verbatim — rides the metadata sidecar
(survey.pulse.spss.json) under both modes, alongside a derived
registry naming each generated column, its cohort position, its source
variable and its reason dictionary: reason ID ↔ text ↔ original SPSS code
↔ label. That registry is what lets an export drop the derived column and
write the original codes back, rather than re-deriving the mapping and
hoping it lands on the same answer. It is shared with the other derived
kind — the multiple-dichotomy set_* columns of
Multiple-response sets, which need no reason
dictionary because nothing they show is absent from the cohort.
Categorical user-missing codes
A categorical column takes the opposite treatment, and the asymmetry with the numeric case above is deliberate rather than an oversight.
A numeric variable needs a <var>_missing sibling because there is
nowhere else for the reason to live: f64 has no dictionary, and the
null bitmap is one bit that can say a value is absent but never why. A
categorical variable already stores the SPSS code as a dictionary entry,
so Q1: 1=Yes, 2=No, 9=Refused imports as a categorical_u8 whose
dictionary holds "1", "2", "9" — the refusal is preserved
losslessly by the ordinary mapping and the row that was refused still
says 9. A sibling here would restate a value that is already present,
and it would do it on every variable of an all-categorical survey: a
200-question questionnaire whose items each carry a Refused code would
double to 400 columns to carry no new information.
So the codes stay, and Pulse records which entries are missing-coded
instead. String variables — including record 7/22 long-string missing
values — take exactly the same path, because a string maps to
categorical_* where the value is the dictionary entry.
Two surfaces, answering different questions.
The metadata sidecar is the persistent, exact, per-entry record. Each
flagged dictionary entry carries "missing": true alongside its code,
label and Pulse ID:
{
"name": "Q1",
"pulse_type": "categorical_u8",
"categories": [
{ "id": 0, "value": "1", "code": 1, "label": "Yes", "labelled": true, "observed": true },
{ "id": 1, "value": "2", "code": 2, "label": "No", "labelled": true, "observed": true },
{ "id": 2, "value": "9", "code": 9, "label": "Refused", "labelled": true, "observed": true, "missing": true }
]
}
The flag is additive and omitempty, so a file declaring no categorical
user-missing codes writes a document byte-identical to the shape before
it existed; the sidecar’s own format_version does not move.
The import raises PULSE_SPSS_CATEGORICAL_USER_MISSING as the
import-time signal — one informational diagnostic for the whole file,
never one per variable, because an all-categorical survey would otherwise
emit hundreds of lines and bury the thing they are meant to carry. It
rides SourceWarnings, so it appears on the --json envelope’s
warnings array and as a Warning [PULSE_SPSS_CATEGORICAL_USER_MISSING]
line on the text path. Its prose names the first few variables and their
codes; details.missing_categories carries every one of them as a field
name to flagged-entry map, uncapped.
Nothing is wrong when it fires. It exists because the loss it prevents is downstream: a percentage base computed over a coded question silently includes its refusal category unless somebody excludes it.
The exclusion is over the CODE, not the label. The cohort dictionary
holds SPSS codes (see Value labels),
so "Refused" appears nowhere in it:
{ "type": "FILTER_EXCLUDE", "field": "Q1", "values": ["9"] }
values: ["9"] — the dictionary entry, which is exactly what
details.missing_categories hands you. A value not in the dictionary is
a PROCESSING_CONFIG error, so a label typed there fails loudly rather
than filtering nothing.
--spss-missing does not reach this arm. The flag governs sibling
columns, and a categorical column has none, so its codes, its dictionary
and its flags are identical under auto and null — two imports of one
file must never disagree about what a field is.
Cross-checked against R. The shape matches what an independent reader
produces. haven::read_sav(user_na = TRUE) returns a
haven_labelled_spss vector that keeps the code in the data and
reports the missingness separately, on the na_values / na_range
attributes, alongside the value labels — value kept, missingness stated
beside it. foreign::read.spss(use.value.labels = TRUE) does the same by
a different route: the missing code comes back as an ordinary factor
level, with the codes on the missings attribute. That is exactly the
division Pulse reproduces — the code is an ordinary dictionary entry, and
categories[].missing is the separate statement about it. The contrast
with the numeric arm holds on their side too: neither reader manufactures
a second column, because neither is constrained to a one-bit null flag.
The one deliberate divergence is the default: haven’s default
(user_na = FALSE) turns every user-missing value into NA, dropping
the distinction. Pulse has no equivalent default for categoricals —
--spss-missing=null governs sibling columns only, and a categorical
column has none — because for a categorical, nulling the code destroys
the value itself.
Multiple-response sets
A survey’s “select all that apply” question is stored in SPSS as a multiple-response set: a named definition listing N member variables. There are two flavours and they are not equivalent.
- Multiple dichotomy (MD). N binary indicator variables. A member holding the declared counted value means that option was selected.
- Multiple category (MC). N variables each holding a code from a shared value-label set. Positional, duplicate-tolerant, order-bearing — genuinely N categorical columns and not a set. It imports as exactly that; only its definition rides the sidecar.
This is the one mapping in the whole adapter where SPSS declares what
every other ingest path has to guess. A CSV importer looking at
"tv|radio" runs internal/io/infer.go’s delimited-token heuristic and votes; a
.sav states the set outright. And Pulse has a type built for the shape:
set_u8/u16/u32/u64/u128/u256, a fixed-width bitmask over an
inline dictionary, with FILTER_SET_*, GROUP_SET_PER_ELEMENT and
AGG_SET_FREQUENCY over it. The narrowest rung with a bit per
constituent is chosen, so a three-option battery costs one byte a record
and a 206-option one costs thirty-two.
The derived column is additive — that is the whole design
The obvious mapping is to collapse: replace the N indicators with one bitmask. Pulse does not do that, because it is lossy, and not in a corner case:
Q1B (Radio) | Means | Bit 1 |
|---|---|---|
0 | shown the option, did not pick it | clear |
. (sysmis) | never asked — skipped or filtered past | clear |
Both are the same bit. Item non-response is not a rounding error in survey work — it drives weighting and it is reported — and once the constituents were gone nothing downstream could recover it.
So an MD set produces N + 1 columns. Every constituent variable is
imported as its own ordinary column, with its own null bitmap bit and its
own <var>_missing sibling where it declares one; the derived set_*
column sits beside them, immediately after the last of its
constituents.
pulse import spss -i survey.sav -o survey.pulse
pulse cohort inspect survey.pulse --json
{
"fields": [
{ "name": "RESPID", "type": "f64" },
{ "name": "Q1A", "type": "f64" },
{ "name": "Q1B", "type": "f64" },
{ "name": "Q1C", "type": "f64" },
{ "name": "media", "type": "set_u8",
"dictionary": { "values": ["Q1A", "Q1B", "Q1C"] } }
]
}
The constituents carry the fidelity; the derived column carries the ergonomics. The cost is schema width, which is the only currency the trade could have been paid in.
{ "type": "FILTER_SET_CONTAINS_ANY", "field": "media", "values": ["Q1C"] }
{ "type": "GROUP_SET_PER_ELEMENT", "field": "media" }
One request slot for the per-option base, instead of one aggregation per
option. And when the answer raises a question the mask cannot settle —
“did the people without Radio decline it, or were they never asked?” —
Q1B is still right there.
Rules worth knowing
- The dictionary holds constituent field NAMES, not option labels.
Bit
i↔ entryi↔Sources[i]on the sidecar. Field names are unique in a cohort, so the dictionary is injective for free; a variable label may be empty, duplicated or contain a|. It also means a bit names the column holding its own fidelity, which is the round trip the additive design exists to make one step. The option labels are each constituent’s ownvariables[].labelin the sidecar. - The mask uses the DECLARED counted value. Nothing assumes
1. A set declaring2, or a string set declaring"YES", produces the mask the file describes. - A user-missing code is not a selection. A refusal on a constituent sets no bit and is not evidence the row was answered.
- The
$is dropped from the field name.$mediabecomesmedia, because a leading sigil is not a legal expr-lang identifier and a field named$mediawould be unreachable fromATTR_FORMULA/FILTER_EXPRESSION. The full name is kept on the sidecar asderived[].set_nameand inmultiple_response_sets[].name. - Three row states, and the middle one has its own spelling. A row selecting nothing but having answered the battery is an empty mask — a real “none of these” answer, distinct from null. A row whose every constituent is missing is null: nothing is known.
- Over 256 constituents, no derived column.
set_u256is the widest set type Pulse has, so a battery naming more constituents than that has no honest mask. The import emits the constituents and warnsPULSE_SPSS_MR_SET_NOT_DERIVEDnaming the set. The ceiling was 64 untilset_u128/set_u256landed; a 206-option battery derives now, and the importer reads the ceiling off the same width ladderinternal/io/infer.gouses rather than carrying a copy.
When a set does not derive
Every refusal is a warning and the import succeeds, because the
derived column is additive: a set that does not derive costs ergonomics
and never data. Failing an otherwise-readable file to protect a
convenience column would be the wrong trade — which is why the same
PULSE_SPSS_DERIVED_NAME_COLLISION code that is a hard error for a
<var>_missing sibling is only a warning here.
| Reason | Code |
|---|---|
more than 256 constituents (the set_u256 ceiling) | PULSE_SPSS_MR_SET_NOT_DERIVED |
| a member no record type 2 declares, or one named twice | PULSE_SPSS_MR_SET_NOT_DERIVED |
| a counted value that will not compare against a numeric member | PULSE_SPSS_MR_SET_NOT_DERIVED |
a constituent whose name contains | or is a null token (NA, N/A, NULL) | PULSE_SPSS_MR_SET_NOT_DERIVED |
| the derived name is one a real variable already holds | PULSE_SPSS_DERIVED_NAME_COLLISION |
Multiple-category sets stay N categorical columns
An MC set gets no derived column, and that is a fidelity decision rather than an omission. Its N members are slots, and two facts about them survive only as separate columns:
| MC set | set_* bitmask | |
|---|---|---|
| slot order | first choice ≠ third choice | unordered |
| a repeated code | two slots may both hold 2 | idempotent — one bit |
Collapsing an MC set would lose both. So each member imports as its own
ordinary categorical_* column over the shared value-label set, exactly
as it would if the set definition were not there, and only the
definition rides the sidecar.
{ "R1": "2", "R2": "2", "R3": "1" }
Both 2s are still there, in the slots they arrived in.
On the sidecar
The set definitions ride payload.multiple_response_sets verbatim —
name, kind (dichotomy / category), label, subtype, member short
names, and (dichotomy only) counted_value. fields sits beside
variables and resolves each member, index for index, to the Pulse
field name that member became — "" for a member no record type 2
declares. Duplicate members are kept, because for an MC set a repeated
member is meaningful.
Every set is recorded, including the MC sets that derive nothing by design and the MD sets that refused to derive. The block is the write-back record for the definitions; the derived registry below answers the different question of which cohort columns are synthetic, and a set that produced no column must still be written back.
Each derived column gets a payload.derived entry of kind
multiple_dichotomy carrying its cohort position, its set_name and its
sources in bit order. It needs no reason dictionary, unlike a
<var>_missing sibling: nothing it shows is absent from the cohort, so
an export drops the column outright and re-emits the constituents.
Cross-checked against R — and there is nothing to check against.
Neither haven (ReadStat) nor foreign::read.spss exposes
multiple-response set metadata at all, from subtype 7 or 19; both
read a fixture carrying both records cleanly and report only the member
variables as ordinary columns. So Pulse’s reading of record 7/19’s
extended E grammar rests on the PSPP specification alone. The residual
risk is bounded by the additive design: a misread definition can only
mis-derive or fail to derive a convenience column, and can never touch
a constituent.
Derived columns
Two of the mappings above add columns the .sav never declared. They
are the only two, they are always additive, and they are the reason
an imported cohort can be wider than the file it came from.
| Kind | Where it comes from | What it holds |
|---|---|---|
numeric_missing | a numeric variable declaring user-missing values | <var>_missing, the reason each null had — see Missing values |
multiple_dichotomy | a multiple-dichotomy response set | a set_* bitmask over the constituents — see Multiple-response sets |
Both are interleaved, not appended. A reason sibling sits
immediately after its source variable; a set column sits immediately
after the last of its constituents, because a summary must not
precede its parts. So a cohort position is a cohort position — never an
ordinal into the source dictionary — which is why the sidecar records
variables[].position explicitly.
They are export-transparent
Neither kind is ever written back as an SPSS variable. An export folds them away and reconstructs exactly the variables the source declared:
- a
multiple_dichotomycolumn is dropped. Every bit it shows is a second reading of a constituent that is still in the cohort under its own name, so there is nothing to reconstruct. - a
numeric_missingcolumn is consumed. Its per-row value decides what its source variable writes wherever that variable is null — the original SPSS code, or the system-missing sentinel.
That fold is driven by the sidecar’s payload.derived registry, never by
matching on names. _missing is a legal SPSS name suffix and a survey
that genuinely declares income_missing is not hypothetical; a set
column’s name is whatever the set was called minus its $, which matches
no pattern at all. Guessing wrong is silent in both directions — a
phantom variable that was never in the source, or a real column dropped.
{
"derived": [
{
"name": "income_missing",
"kind": "numeric_missing",
"sources": ["income"],
"position": 1,
"reasons": [
{ "id": 0, "reason": "sysmis", "sysmis": true, "observed": true },
{ "id": 1, "reason": "Refused", "code": 97, "label": "Refused",
"declared": true, "observed": true }
]
},
{
"name": "media",
"kind": "multiple_dichotomy",
"set_name": "$media",
"sources": ["Q1A", "Q1B", "Q1C"],
"position": 4
}
]
}
kind is a closed vocabulary — those two values today. A consumer
meeting a third must refuse rather than skip it: a column whose fold-back
is unknown has no safe default.
A cohort with no derived columns writes "derived": [], not a
missing key. “Nothing was derived” and “this document cannot tell you”
are different answers, and the second is the one an export has to stop
on.
Opting out
| Kind | Opt out | Cost |
|---|---|---|
numeric_missing | --spss-missing=null / io.SPSSReaderOptions{MissingMode: io.SPSSMissingNull} | Identical nulls in the analytic column; the reason is no longer in the cohort. The full specification still rides the sidecar, so a re-import recovers the vocabulary — but not which row had which reason. |
multiple_dichotomy | no flag | It is the ergonomic half of the additive design and costs one column per set. The constituents carry the fidelity either way, so suppressing it would remove convenience and change nothing else. |
Neither knob changes the categorical arm: a categorical_* column keeps
its user-missing codes in its own dictionary and never gets a sibling
(see Categorical user-missing codes).
Value labels: the cohort stores codes
A labelled SPSS variable becomes a categorical_* column whose
dictionary holds the numeric codes, not the labels:
pulse cohort inspect survey.pulse --json
{
"name": "SEX",
"type": "categorical_u8",
"description": "Sex",
"categorical": true,
"dictionary": { "total_entries": 2, "values": ["1", "2"] }
}
"1" / "2" — not "Male" / "Female". This is deliberate. SPSS
permits two distinct codes to share one value label, so a label-keyed
dictionary would silently collapse them and the original code could not
be recovered. Dictionary entry order also is the on-wire encoding, so
preserving the source’s own code order is what preserves the round trip.
To see labels in output, register a LabelTable mapping code → label
(pulse.Options.LabelTables, or a directory of JSON files pointed at by
PULSE_LABEL_TABLES_DIR) and bind it per request. Labels are an
output-time projection, never a property of the stored cohort; the
label-display skill is the full surface. The sidecar’s categories[]
block is where the code → label pairs came from, so it is the natural
place to generate a label table from.
PULSE_LABEL_TABLES_DIRmay safely point at a directory of cohorts. The loader parses every*.jsonbeneath that root as a label table, but it excludes Pulse’s own sidecars by suffix before reading them — the SPSS metadata sidecar written next to every imported cohort (cohort.pulse.spss.json,.spss.jsonsuffix) and the managed-import sidecar (cohort.pulse.meta.json,internal/imports.SidecarSuffix). A skipped sidecar registers no label table under any name.That is an exclusion of files Pulse knows are its own, not tolerance of unparseable JSON. Any other
*.jsonthat fails to parse still failspulse.Newoutright, naming the offending path:pulse: parsing label table /data/cohorts/regions.json: json: cannot unmarshal number into Go value of type stringA typo’d label table must surface as an error, not as a table that is quietly not there. A dedicated label-tables directory remains the tidier habit, but it is no longer a requirement.
Flags and what SPSS ignores
pulse import spss takes the shared import flags plus one of its own,
--charset.
| Flag | Alias | Effect on an SPSS import |
|---|---|---|
--input | -i | required — the .sav / .zsav path |
--output | -o | required — the .pulse path to write |
--schema | wins outright. An explicit schema file overrides the SPSS dictionary and the adapter’s PulseSchema() is never called | |
--charset | override the encoding the file declares about itself — see Character encoding | |
--spss-missing | auto (default) or null — how numeric user-missing values are represented; see Missing values | |
--sample-rows | inert. Nothing is sampled | |
--json | standard envelope |
--charset and --spss-missing are also on pulse import predict,
pulse import schema-template, pulse convert and pulse convert predict, since a .sav can arrive through any of them. Every other
format ignores both.
--charset — and only --charset — additionally reaches pulse import auto and the pulse_import MCP tool, where it is spelled charset:
$ pulse import auto legacy.sav --charset windows-1252
The asymmetry is deliberate. --charset is the only recourse for a
file that is wrong about itself, so without it a legacy .sav is
unimportable through the managed pool — and over MCP, unimportable full
stop, since pulse_import is the only import path MCP exposes.
--spss-missing is the opposite shape: its default (auto) is the
fidelity-preserving mode, and its only other value suppresses the
<var>_missing siblings that record why each numeric value is
missing. A knob whose sole effect is to discard information does not
belong on an auto-detect convenience path or on a general-purpose tool;
pulse import spss --spss-missing=null is where asking for it is an
explicit act. Both surfaces send no missing-mode at all, and an unset
mode means “leave the default in force” — never an override with the
empty string.
The same inertness applies to the library and managed-import knobs that
exist only to steer inference — ImportJob.SampleRows,
SetInferenceMinPct and SetDelimiters. The force_type column
overrides (ColumnTypeOverrides) are a type instruction rather than a
hint, so an SPSS import that carries them is refused with
PULSE_IMPORT_OVERRIDE_INVALID instead of silently ignoring them:
forcing a type onto a dictionary-carrying column would discard the
source’s category IDs and rebuild them in first-seen order, which is
exactly what an authoritative schema exists to prevent. Re-type a column
with an explicit schema.
There is also no null promotion. For inferred formats a null found
past the sample window widens the field to nullable and reports it in
promoted_fields. A declared schema is a contract instead: a null in a
column SPSS declares non-nullable stays a PULSE_IMPORT_ROW_ERROR.
Diagnostics
Non-fatal parse findings are coded PULSE_SPSS_* warnings. They do not
stop the import, but they change what the resulting cohort means, so
read them. On the text path they print one per line:
Warning [PULSE_SPSS_CARDINALITY_HIGH]: spss: variable "COMMENT": the variable has
150 distinct value(s) across 150 case(s), which maps to a categorical_u8 dictionary
of one entry per 1.0 case(s); a near-unique categorical is the free-text signature
and its inline dictionary block is read on every open [record type 2 at byte offset 208]
With --json they are lifted onto the envelope’s warnings array —
not buried inside data, so a generic envelope consumer sees them:
{
"code": "PULSE_SPSS_CARDINALITY_HIGH",
"message": "spss: variable \"COMMENT\": the variable has 150 distinct value(s) ...",
"details": {
"variable": "COMMENT",
"distinct": 150,
"actual": 150,
"record_type": "2",
"offset": 208
}
}
pulse errors lookup CODE carries the canonical prose and fixups for
each.
| Code | Raised when |
|---|---|
PULSE_SPSS_CARDINALITY_HIGH | A string column is near-unique — a free-text signature |
PULSE_SPSS_DATE_WIDENED | A date column widened to datetime |
PULSE_SPSS_TEMPORAL_PRECISION | A temporal column demoted to f64 raw seconds |
PULSE_SPSS_VALUE_COLLISION | Two distinct SPSS values resolve to one dictionary entry (reachable causes: the shared import path trims cells, so " X" and "X" merge; or two user-missing codes share a value label, in which case the second reason falls back to its code — see Missing values) |
PULSE_SPSS_MEASURE_LEVEL_MISMATCH | A scale-level variable carries value labels, so it mapped to a categorical whose smart defaults are AGG_MODE_COUNT / GROUP_CATEGORY rather than AGG_SUM / GROUP_RANGE |
PULSE_SPSS_NULL_TOKEN_COLLISION | A cell’s text is a null sentinel ("", NA, N/A, NULL) and imports as null |
PULSE_SPSS_CATEGORICAL_USER_MISSING | Informational, once per file: categorical columns carry user-missing codes as ordinary dictionary entries — see Categorical user-missing codes |
PULSE_SPSS_EXTENSION_UNKNOWN | A record type 7 extension subtype this reader does not interpret; its bytes are retained verbatim |
PULSE_SPSS_EXTENSION_INVALID | An interpreted subtype carried a payload of the wrong shape; framing stayed sound, only the interpretation is dropped |
PULSE_SPSS_VERY_LONG_STRING_INVALID | A record 7/14 segmentation could not be reassembled; the segments import as separate columns — see Very long strings |
PULSE_SPSS_DATA_CASE_COUNT_MISMATCH | The header’s declared case count disagrees with the cases present |
PULSE_SPSS_CHARSET_MISMATCH | The file states its character encoding twice and the two disagree — see Character encoding |
PULSE_SPSS_MAGIC_FLAG_MISMATCH | The 4-byte magic and the compression flag disagree about whether the file is a ZSAV — see Byte order and damaged files |
PULSE_SPSS_VALUE_LABELS_DROPPED | A record 3/4 value-label set names variables it cannot be bound to; that set is dropped and the data imports — see Byte order and damaged files |
Character encoding
Pre-Unicode .sav files hold text in a codepage — windows-1252,
ISO-8859-1, Shift_JIS and the rest — not in UTF-8. Pulse decodes every
string it reads into UTF-8, so a French or German survey saved in 2004
imports as Zürich and Männlich, not as Z?rich and M?nnlich.
Decoding is done with golang.org/x/text,
which this feature promoted from an indirect to a direct module
dependency — it was already in the graph via the Arrow and Excel
adapters. No cgo, so the CGO_ENABLED=0 build is unaffected.
Where the charset comes from, highest precedence first:
- the record
7/20character-encoding name (windows-1252,UTF-8, …), which is what PSPP and modern SPSS write; - the record
7/3character code — the legacy numeric field (2/3ASCII,1252,65001, …); - UTF-8, for a file that declares neither.
Spelling is forgiving on purpose: windows-1252, Windows_1252,
cp1252, CP-1252 and 1252 are the same request. It is not loose —
1250 never resolves to windows-1252 no matter how it is punctuated.
When both are present and they disagree, the 7/20 name wins and
PULSE_SPSS_CHARSET_MISMATCH is raised as a warning. The name is
strictly the more informative statement — code 3 (“8-bit ASCII”) is
what a writer emits for ISO-8859-1, windows-1252 and half a dozen
national codepages alike — and writers routinely leave the numeric field
at an ASCII default while filling in 7/20 correctly. A 7/3 code of
2 or 3 is therefore never treated as a disagreement.
A byte the declared charset cannot decode is an error, never a replacement character. The usual behaviour of a text decoder is to substitute U+FFFD and carry on, which would fill a cohort with replacement characters no later stage could tell from data:
error: PULSE_SPSS_CHARSET_INVALID: spss: a data value of variable "CITY":
byte 0x81 at position 1 of the value "Z\x81rich" is not decodable in the
declared character encoding windows-1252
Almost always the file is wrong about itself rather than the bytes being
wrong — a dictionary transcoded by one tool and re-saved by another keeps
its old 7/20 name. Only the caller can say which is right, so the
library reader takes an override:
$ pulse import spss -i survey.sav -o survey.pulse --charset windows-1252
r, err := io.NewReader(io.FormatSPSS, fs, "survey.sav", io.ReaderOptions{
SPSS: io.SPSSReaderOptions{Charset: "windows-1252"},
})
It changes decoding only; the file’s own declaration is still
retained, so a future export re-encodes into what the source said. The
name goes through the same forgiving lookup the file’s own declaration
does, so windows-1252, cp1252 and 1252 are one request; a name
with no decoder behind it is PULSE_SPSS_CHARSET_UNSUPPORTED naming it.
The case that most needs the flag is the file that declares nothing at
all — no 7/20, no 7/3 — and carries an 8-bit byte. It falls to the
UTF-8 default, which is strict, so it fails on the first such byte. The
file has no further evidence to offer and only you can supply the
answer.
A charset with no decoder is refused rather than read as UTF-8:
PULSE_SPSS_CHARSET_UNSUPPORTED names it. That covers an unregistered
name, a registered one with no implementation (EBCDIC codepages), and
any encoding that is not an ASCII superset — UTF-16 among them, because
a .sav pads its fields with the byte 0x20 and delimits its record
7/5 and 7/13 payloads with ASCII, so an encoding that does not write
ASCII as itself cannot express the format at all.
Very long strings
SPSS cannot state a string width above 255 in a variable record: the
type field is one byte’s worth of range. A wider string is therefore
stored as several physical variables, and a record 7/14 extension
says how to put them back together.
That is a second segmentation, stacked on one that is already there. Every string over 8 bytes is spread across 8-byte data elements (continuation records); a very long string is spread across physical variables, each of which is itself spread across elements. A 600-byte string is three physical variables of 255, 255 and 96 declared bytes, occupying 32 + 32 + 12 = 76 elements.
The number that decides everything is 252, not 255. A non-final segment declares 255 bytes but only its first 252 carry the value; the remaining three are unused. The clearest proof is a 256-byte string: it is two segments declaring 255 and 4, whose declared widths sum to 259 — three more bytes than the variable can hold. Only a 252-byte stride reproduces a 256-byte value.
Pulse folds all of that away. pulse inspect and the imported cohort
show one column, under the variable’s own name; the generated
segment names (COMMENT0, COMMENT1, …) never appear:
$ pulse import spss -i survey.sav -o survey.pulse
Imported 2 rows to survey.pulse
$ pulse cohort inspect survey.pulse
Fields: 2
ID f64 Numeric field: ID
Comments categorical_u8 Free text
dictionary: 2 entries
Two properties are worth knowing:
- Bytes are joined before they are decoded. A segment boundary falls at a fixed byte offset that knows nothing about characters, so a multi-byte character can straddle it. Pulse concatenates the raw bytes first and runs the charset decoder once over the result. Decoding each segment separately would cut such a character in half.
- The layout is kept, not discarded. The number of physical variables, their names and their declared widths survive the fold, so an export can re-segment the value the way the source had it. The retained width is the logical total (600), not the 255 any one segment declares.
A record 7/14 that cannot be applied is
PULSE_SPSS_VERY_LONG_STRING_INVALID, and it is a warning. The
record only says how to join columns that are already in the file, so
declining to join loses no bytes at all: the segments import as the
separate columns the dictionary literally declares, under their own
names, and the warning says which variable and why.
Two sibling records decorate wide strings, and both apply to any string over 8 bytes — not only very long ones, because the 8-byte value slot in a variable record is what they exist to get around:
| Record | Carries | Where it lands |
|---|---|---|
7/21 | value labels for a wide string | The column’s categorical_* dictionary, in record order, exactly as records 3/4 do for narrow ones |
7/22 | up to three missing values for a wide string | The variable’s missing-value specification. The slot is fixed at 8 bytes because SPSS itself compares only a long string’s first 8 |
Both name their variable by its long name (record 7/13) in every
file the wider ecosystem will read; Pulse falls back to the short name
for writers that used it.
Cross-check note. R’s
foreignreads7/14files but deliberately does not reassemble — it imports each segment as its own variable and says so in a warning. R’shaven(ReadStat) does reassemble, but concatenates each non-final segment’s full 255 declared bytes rather than its 252 content bytes, so it returns a 600-byte value as 606 bytes with three spurious spaces at every segment boundary, and a 256-byte value as 259. The divergence is invisible for the common case — a wide field holding a short value, where trailing-space trimming hides it — and only appears once a value actually exceeds 252 bytes.
Compression
A .sav data section arrives in one of three encodings, and the file
header says which. All three import.
| Encoding | Header flag | Header magic | Status |
|---|---|---|---|
| Uncompressed | 0 | $FL2 | Read |
| Bytecode | 1 | $FL2 | Read — this is what SPSS writes by default |
| ZSAV (zlib blocks) | 2 | $FL3 | Read — this is what a .zsav carries |
Nothing needs to be passed to select one. The flag is read from the header and the right decoder runs:
pulse import spss --input survey.sav --output survey.pulse
A compressed and an uncompressed copy of the same data produce identical cohorts, whichever of the three encodings was used.
How bytecode compression works
The data section becomes a stream of blocks. Each block is eight command bytes followed immediately by the eight-byte payloads that those commands asked for:
| Command | Means |
|---|---|
0 | Padding — occupies a command slot, produces no value. Fills out the final block. |
1..251 | The whole number command - bias. The bias is read from the header; it is conventionally 100, giving the range −99…151. |
252 | End of the data section. |
253 | The next eight bytes of the stream are the value, verbatim. |
254 | An all-spaces eight-byte string segment. |
255 | System-missing. |
The saving comes from survey data being mostly small whole numbers: one
byte instead of eight. It is lossless — anything the commands cannot
express falls through to 253 unchanged.
When a compressed file will not read
error: reading authoritative source schema: PULSE_SPSS_COMPRESSION_INVALID:
spss: data section: the compressed stream asks for an all-spaces string
segment (command 254) at element 1 of a case, where the dictionary declares
a numeric element; the stream has lost sync with the dictionary
[at byte offset 372 (0x174)]
PULSE_SPSS_COMPRESSION_INVALID means the command stream and the
dictionary disagree — a command landed on an element position it cannot
apply to. Every element after that point would be read against the wrong
variable, so the import stops rather than emitting plausible numbers.
Re-export the file from SPSS or PSPP; a desynchronised stream cannot be
repaired by hand.
A stream cut short — mid-case, or with a 253 whose eight bytes never
arrived — is PULSE_SPSS_DATA_TRUNCATED instead.
How ZSAV works
ZSAV is two layers, not a third encoding. The zlib blocks do not
hold case data — they inflate to a bytecode command stream, exactly the
one described above, which is then decoded exactly as it would be in a
plain .sav. A reader that treated the inflated bytes as values would
produce plausible numbers from every file, which is why the layering is
spelled out rather than assumed.
The blocks are described by an index at both ends of the data section:
| Structure | Where | Carries |
|---|---|---|
ZHEADER | first 24 bytes of the data section | its own offset, the trailer’s offset, the trailer’s length |
| compressed blocks | after the ZHEADER | one independent zlib stream each |
ZTRAILER | end of the file | the bias (negated), a reserved zero, the uncompressed block size, the block count, then one 24-byte entry per block |
Each entry gives its block’s offset and size in two coordinate spaces — where the block actually sits in the file, and where it would sit if the file were not compressed. That redundancy is the point: the entries must tile the compressed region exactly, each block starting where the previous one ended, and Pulse checks every one of them before inflating anything. Inflating from an offset no writer ever wrote a stream at either fails or, worse, succeeds on something.
When a .zsav will not read
error: reading authoritative source schema: PULSE_SPSS_ZSAV_INVALID:
spss: data section: ZSAV block 3 of 40 declares compressed offset 1857,
but the block before it ends at 1856; the compressed offsets must run on
without a gap [at byte offset 4218 (0x107A)]
PULSE_SPSS_ZSAV_INVALID means the block index does not describe the
file it sits in. The message names the block, and the same 1-based
number is carried structurally under details.block, so a fault in one
block of a thousand is actionable rather than a shrug. Re-export the
file; a block index cannot be repaired by hand.
PULSE_SPSS_ZSAV_BLOCK_CORRUPT is the other half: the index was
coherent, the offsets were right, and the bytes at them are damaged — a
block that will not inflate, fails its zlib checksum, or inflates to a
size other than the one its entry declares. A short or long block is as
fatal as one that fails outright, because the blocks concatenate into a
single command stream and a wrong-length block shifts every later value
onto the wrong variable. That one is usually a truncated download:
compare the file’s byte length against the source.
A .zsav whose inflated stream disagrees with the dictionary raises
the bytecode codes above (PULSE_SPSS_COMPRESSION_INVALID,
PULSE_SPSS_DATA_TRUNCATED), not a ZSAV code — the zlib layer was
intact, so pointing at it would send you to the wrong place.
Read-only by design. Pulse never writes ZSAV, and there is no
pulse export spssat all.
Fatal errors
| Code | Meaning |
|---|---|
PULSE_SPSS_DICT_INVALID | The dictionary is malformed |
PULSE_SPSS_DICT_TRUNCATED | The file ends mid-dictionary |
PULSE_SPSS_FILE_EMPTY | The source has no bytes at all — a zero-length file, not a truncated one |
PULSE_SPSS_ENDIANNESS_MISMATCH | The header layout code and record 7/3 contradict each other about byte order — see Byte order and damaged files |
PULSE_SPSS_COMPRESSION_UNSUPPORTED | A compression flag the format does not define — all three defined encodings are read |
PULSE_SPSS_COMPRESSION_INVALID | A bytecode stream that disagrees with its own dictionary, or an unusable compression bias — see above |
PULSE_SPSS_ZSAV_INVALID | A ZSAV block index that does not describe its file — names the block — see above |
PULSE_SPSS_ZSAV_BLOCK_CORRUPT | A ZSAV zlib block that will not inflate, or inflates to the wrong length — names the block — see above |
PULSE_SPSS_DATA_TRUNCATED | The data section ends mid-case, or a 253 command’s value is missing |
PULSE_SPSS_CATEGORICAL_OVERFLOW | A labelled variable has more distinct codes than categorical_u32 holds |
PULSE_SPSS_DERIVED_NAME_COLLISION | A generated column has the same name as a real variable — names both sides. A hard error for a <var>_missing sibling (see Missing values); a warning for a multiple-dichotomy set_* column, which is additive (see Multiple-response sets) |
PULSE_SPSS_MR_SET_NOT_DERIVED | A multiple-dichotomy set got no set_* convenience column — names the set and why. Warning only: every constituent is imported regardless — see Multiple-response sets |
PULSE_SPSS_MISSING_MODE_INVALID | --spss-missing was given a value other than auto or null |
PULSE_SPSS_CATEGORICAL_USER_MISSING | Informational: one or more categorical columns carry user-missing codes as ordinary dictionary entries — one diagnostic per file, naming every flagged variable and entry under details.missing_categories — see Categorical user-missing codes |
PULSE_SPSS_CHARSET_UNSUPPORTED | The declared character encoding resolves to no decoder — see Character encoding |
PULSE_SPSS_CHARSET_INVALID | A byte sequence is not decodable in the declared character encoding — names the variable and the value |
PULSE_SPSS_EXPORT_UNSUPPORTED | Something in the cohort has no honest .sav form — an empty set_* dictionary, a value with no recorded SPSS code, or a rendered row stream with no cohort behind it. Raised on the WRITE side |
Parse-stage diagnostics (dictionary walk, data pass) carry record_type
and offset details pinpointing where in the file they were raised, and
the PULSE_SPSS_ZSAV_* pair additionally carries block;
schema-mapping diagnostics carry variable.
On the
--jsonpath a fatal import error is currently reported under the generic envelope codeIMPORT_ERROR(CLI_ERRORfor a convert), with thePULSE_SPSS_*code carried inside themessagestring. Non-fatalPULSE_SPSS_*warnings do reachwarnings[].codestructurally.
Byte order and damaged files
Either byte order reads
A .sav is written in the byte order of the machine that wrote it, and
Pulse reads both. The header layout code decides: it always holds
2 or 3, and neither value byte-swaps into the other or into anything
in range, so reading those four bytes both ways identifies the file’s
order with no residual doubt. That is what the field is for.
Record 7/3 states the byte order a second time, as a numeric field
(1 big-endian, 2 little-endian). It is a corroboration and never a
source — reading it at all already requires knowing the order. When the
two contradict each other the import stops with
PULSE_SPSS_ENDIANNESS_MISMATCH.
That is a deliberately harsher answer than the sibling charset cross-check one field away, which warns and carries on. Byte order governs every multi-byte field in the file — every count, every offset, every double — so there is no partial damage to weigh: one reading yields a coherent file and the other yields numbers that are plausible and wrong. When the file’s own two statements disagree, no evidence remains about which the writer meant.
Shapes that are not a contradiction, and read normally: a file with
no record 7/3 at all, an endianness field left at 0, and any value
outside {1, 2} (which raises PULSE_SPSS_EXTENSION_INVALID and is
otherwise ignored — an unfilled field is not a claim).
Magic versus compression flag
$FL2 marks an ordinary system file and $FL3 marks a ZSAV, but the
compression flag is what actually decides how the data section is
decoded. When the two disagree — $FL3 with flag 0 or 1, or $FL2
with flag 2 — the flag wins, the file reads, and
PULSE_SPSS_MAGIC_FLAG_MISMATCH warns.
Warning rather than error, on the same reasoning as the charset cross-check: the flag describes the bytes, while the magic is a coarse generation label a re-saving tool can leave stale. And the failure mode is safe either way — the wrong decoder fails loudly on the first case rather than quietly succeeding.
Value labels that cannot be bound
A record 3/4 pair can name variables it cannot legitimately attach
to: a set mixing a numeric with a string, a set attached to a string
wider than the 8 bytes a record 3 value slot holds (those belong in the
record 7/21 extension), or an element index landing on a string
continuation rather than on a variable. Each of those drops that one
label set with PULSE_SPSS_VALUE_LABELS_DROPPED naming the variable,
and imports everything else.
The reasoning is that a value label is display metadata. Refusing the file would cost the data to save the labels; binding the set anyway would attach labels to values they do not describe — an 8-byte slot matched against a 20-byte string labels everything sharing a prefix — without saying so. Dropping loudly is the only option that loses nothing silently.
Genuinely corrupt indices are still fatal. An element index below
1, or past the end of the dictionary, cannot come from a writer reading
the format differently, and it puts the record’s framing in doubt:
PULSE_SPSS_DICT_INVALID.
Damage has distinct codes
Four different things can be wrong with a file, and they have four different fixes, so they get four different codes:
| Code | What happened | What to do |
|---|---|---|
PULSE_SPSS_FILE_EMPTY | zero bytes | the target was created and never written — check the export or the redirection |
PULSE_SPSS_DICT_TRUNCATED | stops mid-dictionary | re-transfer; compare byte sizes |
PULSE_SPSS_DATA_TRUNCATED | stops mid-case | re-transfer |
PULSE_SPSS_DICT_INVALID | structurally wrong — bad magic, unidentifiable byte order, unknown record tag, out-of-range field | this is not the file you think it is, or it is corrupt beyond a truncation |
No input panics. Every read is bounds-checked before it happens, and
that is verified rather than asserted: a corruption sweep overwrites
every byte of the dictionary with all 256 values across all three
compression modes, a truncation sweep cuts the file at every offset in
each mode, and two fuzz targets (FuzzParseDictionary, FuzzReadRows)
carry the invariant beyond single-byte edits. Every failure is a coded
error a caller can switch on.
Converting out
pulse convert reads .sav and writes any format Pulse can write:
pulse convert survey.sav survey.csv
Converted 2 rows: survey.sav -> survey.csv
The reverse direction writes a .sav:
pulse convert data.csv out.sav
A text source has no cohort behind it, so the writer buffers the rows,
builds an intermediate cohort in memory through the ordinary import path
and exports that — an inferred schema and no metadata sidecar, which
means synthesised SPSS names. If the CSV’s header carries names a .sav
cannot express — a space, a bracket, a hyphen, a leading digit — it stops
with PULSE_SPSS_NAME_INVALID rather than mangling them; add
--sanitize-names to rewrite them and have every rename reported:
error: PULSE_SPSS_NAME_INVALID: spss: "household income (gross)" is not a
name a .sav can carry as a variable: it contains ' ' at byte 9
Related
pulse cohort inspect— read the schema and dictionaries the import producedpulse manifest—import.formats[]declaresspsswithschema_source: "authoritative"andexport: truepulse export spss— the write half: the sidecar read, the derived-column fold, the name boundary, the four write flags- Adding an I/O Format — the
SchemaAwareReader/SourceWarningEmitter/SidecarEmitter/CohortWriter/TargetWarningEmittercontracts this adapter implements skills/spss-cohorts.md— the agent-facing SPSS surface: mapping table, derived columns, missing-value split, metadata sidecar, diagnosticsskills/cohort-schema-design.md— the.pulsefield-type matrixskills/label-display.md— turning the stored codes into labels at output timeskills/tool-import.md— thepulse_importMCP surface
pulse export spss
Audience: CLI users writing a .pulse cohort back out as an IBM SPSS
Statistics system file (.sav). Defined in
internal/cli/export.go;
the adapter is
internal/io/spss/.
This is the write half. pulse import spss is the read
half, and the two are designed as one round trip: an import writes a
metadata sidecar carrying every dictionary element the .pulse byte
format has no slot for, and this leaf reads it back to reproduce the
source dictionary rather than inventing one.
.savonly — ZSAV is read-only. All three data encodings import (uncompressed, bytecode, ZSAV zlib blocks). Only the first two are emitted. Asking for ZSAV output isPULSE_SPSS_COMPRESSION_UNSUPPORTED, not a silent downgrade.
--include,--labelsand a non-UTC--tzare refused, not ignored. The.savwriter encodes from the cohort’s raw storage, not from the rendered row stream those transform. See What this leaf refuses.
Illegal variable names stop the export by default.
--sanitize-namesis the opt-in escape hatch. See The name boundary.
Synopsis
pulse export spss --input PATH.pulse --output PATH.sav
[--ignore-sidecar] [--uncompressed]
[--charset NAME] [--sanitize-names] [--json]
pulse export predict --input PATH.pulse --format spss
[--ignore-sidecar] [--uncompressed]
[--charset NAME] [--sanitize-names] [--json]
pulse convert PATH.pulse OUT.sav
pulse convert PATH.csv OUT.sav
pulse convert reaches the same writer and detects the format from the
.sav extension.
A worked export
pulse export spss --input survey.pulse --output survey.sav
Exported 2 rows to survey.sav
A cohort with no SPSS provenance warns instead, because the dictionary it gets is a reconstruction rather than a reproduction:
pulse export spss --input d.pulse --output out.sav
Exported 2 rows to out.sav
Warning [PULSE_SPSS_SIDECAR_ABSENT]: spss: no metadata sidecar found at
d.pulse.spss.json; the export will synthesise a default SPSS dictionary
from the .pulse schema alone, so value labels, measure levels, print
formats and missing-value specifications will not be restated. This is the
normal state for a cohort that was never SPSS-derived
This is the correct state for a cohort produced by pulse synth or
imported from CSV. It is a warning and not an error precisely because
nothing was lost — there was never any source metadata to lose.
With --json, the same run emits the standard envelope. Note that the
encode-side diagnostics appear twice: once on data.TargetWarnings,
which is the adapter’s own report slot, and once on the envelope’s
top-level warnings array, which is where a generic client should read
them:
{
"format_version": "1.1",
"data": {
"RowsExported": 2,
"RowErrors": null,
"LabelWarnings": null,
"OverlayWarnings": null,
"TargetWarnings": [
{
"code": "PULSE_SPSS_SIDECAR_ABSENT",
"message": "spss: no metadata sidecar found at d.pulse.spss.json; ...",
"details": {
"cohort": "d.pulse",
"sidecar": "d.pulse.spss.json"
}
}
]
},
"errors": [],
"warnings": [
{
"code": "PULSE_SPSS_SIDECAR_ABSENT",
"message": "spss: no metadata sidecar found at d.pulse.spss.json; ...",
"details": {
"cohort": "d.pulse",
"sidecar": "d.pulse.spss.json"
}
}
]
}
ExportReport carries no json: struct tags, so its data keys are the
Go field names in Go casing — unlike the errors / warnings entries,
which are the standard {code, message, details} shape.
pulse export predictdoes not validate the.savwrite.ExportJob.Predictreads only the source cohort’s header and schema and reports the field count and estimated rows; the target writer is never constructed, so--format spssis accepted and unused there. Nothing short of a real export exercises the name boundary, the sidecar verdict or the charset encode.
The metadata sidecar: absent, stale, ignored
The writer’s first act is loading the sidecar (LoadSidecar in internal/io/spss) against
<cohort>.pulse.spss.json. Four outcomes, and the split between them is
the single most load-bearing design decision on this page:
| State | Verdict | Code | What the export does |
|---|---|---|---|
| No sidecar file | warning | PULSE_SPSS_SIDECAR_ABSENT | synthesises a default dictionary from the .pulse schema |
| Cohort size or mtime moved since the sidecar was written | error | PULSE_SPSS_SIDECAR_STALE | nothing — the export stops |
Not JSON / foreign kind / unknown format_version / bad digest | error | PULSE_SPSS_SIDECAR_INVALID | nothing — the export stops |
--ignore-sidecar given, file present or not | warning | PULSE_SPSS_SIDECAR_IGNORED | synthesises a default dictionary |
Absent is benign; stale is the dangerous one. An absent sidecar means
the cohort never had source metadata, so nothing is being discarded. A
stale sidecar is the highest-fidelity-risk state available: the dictionary
is complete and plausible, so applying it to changed data yields a .sav
in which IF q1 EQ 5 addresses a category that has since moved. It looks
authoritative, it is wrong, and no downstream reader can tell. So the
refusal returns no resolution object at all — there is no shape in
which a caller ends up holding the stale document and writing it by
accident.
The staleness check is a cheap O(1) size + mtime comparison, the same
model PULSE_INDEX_STALE uses, with the same residual gap: an in-place
edit that preserves both is not caught. Document.VerifyDigest recomputes
the authoritative full SHA-256 for a verify-style pass; hashing a
multi-gigabyte cohort on every export would cost more than the export.
No option applies a stale dictionary. The choice is always recorded metadata or a synthesised default — never fresh-or-stale.
Flags
Four flags, each exactly one field of io.SPSSWriterOptions (io.WriterOptions.SPSS).
--ignore-sidecar, --uncompressed and --sanitize-names are also on
pulse convert / convert predict; --charset on convert names the
source charset, so the write charset is settable only on this leaf.
--ignore-sidecar
Do not read the metadata sidecar; synthesise the dictionary from the
.pulse schema alone.
It suppresses the read, not merely the staleness verdict. A healthy
sidecar is ignored too, so the flag’s effect never flips with an mtime,
and an unreadable one cannot block you. The resulting
PULSE_SPSS_SIDECAR_IGNORED warning deliberately cannot say which refusal
(if any) it silenced — nothing on that path read the file.
It cannot round-trip a cohort whose derived
set_*column is still present. That column’s dictionary entries are its constituents’ field names, so with the derived registry suppressed, synthesis mints indicator variablesQ1A/Q1Bbeside the realQ1A/Q1Band stops onPULSE_SPSS_NAME_COLLISION. Export without the flag so the registry folds the column away.
--uncompressed
Write flat 8-byte data elements instead of SPSS’s bytecode compression.
The two are losslessly equivalent, so this trades file size for a readable
hex dump. Bytecode is the default because it is what SPSS’s own SAVE
writes. It does not select ZSAV.
--charset NAME
The charset the emitted file is written in and declares.
Default: whatever the source declared, in the source’s own spelling — a
cohort imported from a cp1252 file goes back out as cp1252 bytes under
a cp1252 record 7/20. A cohort with no SPSS provenance defaults to
UTF-8. Set it when the cohort now holds text the source’s codepage cannot
express, which is otherwise PULSE_SPSS_CHARSET_UNENCODABLE.
io.SPSSReaderOptions{Charset} on the read side changes decoding only and is
deliberately not consulted here.
--sanitize-names
Rewrite cohort field names a .sav cannot carry, instead of refusing.
See below — the refusal is the default on purpose.
The name boundary
A .pulse field name is any UTF-8 string. An SPSS variable name is at
most 64 bytes, opens with a letter, and is unique without regard to
case. All three ways an illegal name fails are quiet — records 7/13,
7/7 and the case-fold rule each produce a well-formed file that says
something other than what was meant — so the export refuses instead:
pulse convert bad.csv bad.sav
error: PULSE_SPSS_NAME_INVALID: spss: "household income (gross)" is not a
name a .sav can carry as a variable: it contains ' ' at byte 9; an SPSS
name may carry only letters, digits and '.', '_', '$', '#', '@'
pulse convert bad.csv bad2.sav --sanitize-names
Converted 1 rows: bad.csv -> bad2.sav
Warning [PULSE_SPSS_SIDECAR_ABSENT]: spss: no metadata sidecar found at
converted-rows.pulse.spss.json; ...
Warning [PULSE_SPSS_NAME_SANITIZED]: spss: --sanitize-names rewrote 2
name(s) that a .sav cannot carry: "household income (gross)" ->
"household_income__gross_", "2024 total" -> "V2024_total". The full list
is under "renames"; the emitted variables carry the rewritten names.
The rewrite is deterministic and collision-safe — against other renames
and against names that were already legal, which never move. The prose
caps the list at three; the renames detail never truncates. A leading
digit is prefixed rather than dropped (2024 total → V2024_total),
because dropping it would collide 2024 total with total.
Note the sidecar path in that transcript: a convert from a text source
builds an intermediate cohort named converted-rows.pulse, so the absent
sidecar it reports is that intermediate’s, not any file on disk.
--sanitize-names is inert on the sidecar path: those names came from
SPSS and are already legal by construction. It exists for the synthesised
path, where a CSV header’s spaces and brackets are perfectly ordinary.
Derived columns are export-transparent
An SPSS import can produce a cohort wider than the source dictionary:
a <var>_missing reason sibling per numeric variable declaring
user-missing values, and one set_* bitmask per multiple-dichotomy
response set. Both are additive by design.
On the way out they are consumed, never emitted. An emitted .sav
carries exactly the source’s own variables and no _missing or set_*
artefacts. The fold reads the sidecar’s derived registry and nothing
else — never the column name, because income_missing is a perfectly
legal SPSS variable name and a survey that declares one is not
hypothetical:
Registry kind | Fold | Why |
|---|---|---|
numeric_missing | restore — the encoder writes the recorded SPSS code back into every null, instead of the sysmis sentinel | the .pulse null bitmap is one bit, so which missing state each row was in exists nowhere else |
multiple_dichotomy | drop — after checking the constituents really are being emitted | every bit is a second reading of a constituent still in the cohort under its own name |
Two refusals guard the fold, and both are about silence:
PULSE_SPSS_COLUMN_UNMAPPED— a cohort column no emitted variable is written from that the registry does not account for. It is data about to leave the export invisibly. Checked on the synthesised path too, where the registry is empty and every field must bind.PULSE_SPSS_DERIVED_UNFOLDABLE— a registry entry this binary cannot honour: akindoutside its vocabulary (a document from a newer import), anumeric_missingentry with noreasonsdictionary, an entry naming an unemitted source column, or an entry naming a column the export is also emitting as a variable. Both available guesses — emit it, or drop it — are invisible data faults, so it refuses.
Value labels declared only on user-missing codes come back from the
registry, not from the categories. An income column labelled at 97 /
98 and nowhere else is not a coded variable, so the import maps it to
plain f64 and moves those labels into the sibling’s Derived.Reasons —
the only place they survive. The write path re-emits them as ordinary
records 3 / 4.
What this leaf refuses
--include, --labels and a non-UTC --tz are refused with
PULSE_SPSS_EXPORT_UNSUPPORTED, not silently dropped (details.option
names the flag). --tz is refused because a .sav DATETIME is a naive
wall-clock second count with no offset slot: the local reading would lose
the offset that tells a fall-back hour’s two instants apart. The .sav
always carries UTC wall-clock values; --tz UTC / Etc/UTC is accepted
and byte-identical. For offset-bearing local literals export a text
format (pulse export csv --tz ...).
pulse export spss -i d.pulse -o out.sav --include age
error: PULSE_SPSS_EXPORT_UNSUPPORTED: spss: --include is not available on
a .sav export (age). The .sav writer encodes from the cohort's raw storage
rather than from the rendered row stream those options transform, so
honouring them would mean silently emitting something other than what was
asked for. Project or relabel first — `pulse api process` writing a
narrowed cohort — then export that.
The reason is structural. A .sav value is derived from a categorical’s
dictionary ID, a set_*’s mask bits and the null bitmap, and
all three are gone by the time ExportJob has rendered a row: a
categorical resolves to its label text (and two SPSS codes may share one
label), and a null renders as "", which a string categorical can hold
legitimately. So the writer implements io.CohortWriter — the one
optional writer interface that replaces the row loop rather than
decorating it — and WriteRow is never called on the cohort path.
Overlays are warn-and-skip, the same posture as CSV.
Anything else with no honest .sav form is
PULSE_SPSS_EXPORT_UNSUPPORTED naming the variable, never a quiet
substitution: a dictionary ID the plan records no SPSS code for, an ID two
source values collapsed onto, a set_* column with an empty dictionary.
Most of these are answerable before a single byte is written — see Checking first.
Checking first: pulse export predict --format spss
pulse export predict --input survey.pulse --format spss --json
{
"format_version": "1.1",
"data": { "Schema": { "Fields": [ ... ] }, "EstimatedRows": 1200,
"TargetWarnings": [ ... ] },
"errors": [],
"warnings": [
{ "code": "PULSE_SPSS_SIDECAR_ABSENT",
"message": "spss: no metadata sidecar beside survey.pulse; the .sav dictionary was synthesised from the .pulse schema" }
]
}
predict writes nothing. It declares no --output, and the throwaway
writer it builds to ask the question is pointed at an in-memory
filesystem it never flushes.
Pass the flags you will export with. The four write knobs are mounted
on this leaf too, and the answer depends on them: --sanitize-names in
particular turns a PULSE_SPSS_NAME_INVALID refusal into a
PULSE_SPSS_NAME_SANITIZED warning, so a predict that could not be told
about it would refuse an export that would have succeeded.
A refusal carries the export’s own code, not a PREDICT_ERROR
placeholder, so it is usable with pulse errors lookup:
pulse export predict -i survey.pulse --format spss --json
{ "format_version": "1.1", "data": null,
"errors": [ { "code": "PULSE_SPSS_NAME_INVALID",
"message": "spss: the cohort field \"household income\" cannot be an SPSS variable name (' ')",
"details": { "spss_field": "household income" } } ],
"warnings": [] }
Running the export against that cohort fails with the same code. That parity is the point, and it is asserted end to end rather than assumed.
What predict can and cannot see
It runs the writer’s own non-data pass — the sidecar resolution, the
dictionary build, the name policy, the charset transcode of the
dictionary text, the derived fold and the encoder’s column checks — and
throws the result away. It never reads a record. That covers most of the
refusal set, because a .pulse cohort’s records are fixed-width numerics
and every string lives in the schema block’s dictionaries.
| Reachable without records | Needs the data pass |
|---|---|
PULSE_SPSS_NAME_INVALID / _COLLISION / _SANITIZED | PULSE_SPSS_WIDTH_OVERFLOW on a value |
PULSE_SPSS_SIDECAR_ABSENT / _STALE / _INVALID / _IGNORED | PULSE_SPSS_CHARSET_UNENCODABLE in cell text |
PULSE_SPSS_CHARSET_UNSUPPORTED / _UNENCODABLE in dictionary text | PULSE_SPSS_EXPORT_UNSUPPORTED for a dictionary ID with no source code |
PULSE_SPSS_COLUMN_UNMAPPED, PULSE_SPSS_DERIVED_UNFOLDABLE | |
PULSE_SPSS_EXPORT_UNSUPPORTED for --include / --labels / non-UTC --tz | |
PULSE_SPSS_COMPRESSION_UNSUPPORTED / _INVALID |
So predict is a sound but incomplete filter. A refusal is real; a pass means no schema-level refusal was found, not that the export cannot fail. It deliberately does not guess at the right-hand column — a false refusal would block an export that would have worked, with no way to appeal it.
Without --format the leaf is target-blind exactly as it was before, and
says so:
Schema: 12 fields
Estimated rows: 1200
Target: not checked (pass --format to validate against the format you will export to)
Predict against a non-.sav target is also unchanged: no other writer
implements io.CohortValidator today, so --format csv reports the same
schema and row estimate it always did.
Converting from a text source
pulse convert data.csv out.sav
A text source has no cohort behind it, so the writer buffers every row,
builds an intermediate cohort in memory through the ordinary import path,
and exports that. Consequences worth expecting: an inferred schema, no
metadata sidecar (so PULSE_SPSS_SIDECAR_ABSENT), synthesised SPSS names
(so --sanitize-names is live), and a memory profile proportional to the
input, because an intermediate cohort cannot be written until the last row
has been seen. A row path with no rows at all is a refusal, not an empty
file.
Nulls, and what a synthesised dictionary will not invent
Nulls take the missing state the SPSS type actually has: numeric → the
sysmis sentinel; string → blanks (no string sentinel exists, and
blank reads back as null); every member of a null set_* → sysmis, which
is what keeps null apart from an empty mask on the way back.
Two rules govern the dictionary:
- Original SPSS codes, never dictionary positions. The sidecar’s
code ↔ label ↔ Pulse dictionary IDtriple supplies them.IF q1 EQ 5addresses a value, so renumbering would silently re-point every reference in every downstream syntax file. - With no sidecar, nothing is invented. A categorical becomes a
string variable holding the dictionary text, rather than a numeric
with codes taken from positions. A
set_*expands into one indicator variable per entry (named for that entry, so the mask round-trips) plus a7/7dichotomy definition.CategoryCode.Knownstaysfalse, so the plan itself records which of the two it is.
Two things deliberately do not reproduce the source, because re-emitting them would be wrong rather than merely less faithful:
- Byte order is always little-endian (record
7/3agrees). One corollary is easy to miss: a numeric missing-value slot is aflt64, so the sidecar’s verbatim slots are byte-reversed when the source was big-endian. Re-emitting them as read would declare eight bytes that decode here as an unrelated subnormal, and the variable would silently stop declaring anything missing at all. A string slot is characters and is never reversed. prod_nameidentifies Pulse, not the originating SPSS build.
Records 7/21 and 7/22 carry each variable’s final name; ReadStat
(haven, pyreadstat) refuses a file that spells the short name there.
MOYR / QYR / WKYR keep both their raw seconds and their format code, so
such a column still renders as a month or a quarter.
Charset on the way out
Two hard rules, mirroring the read side.
Never a replacement character. A character the target charset has no
form for is PULSE_SPSS_CHARSET_UNENCODABLE naming the variable, the
value and the code point — never ?, 0x1A or U+FFFD. The usual cause is
a cohort edited since import: text a Pulse operation produced is UTF-8 and
need not fit a legacy codepage. Every encode is also decoded back and
compared, so a character that encodes but returns as a different one
(GB18030 does this across the Private Use Area) is refused too.
Never a silent truncation. SPSS widths are BYTE counts, so transcoding
moves them — Zürich is 6 bytes in windows-1252 and 7 in UTF-8.
Declared widths are recomputed from the encoded bytes: a synthesised
width is computed exactly, and a width the source recorded is only ever
widened to fit. (SPSS pads to the declared width and the read path
trims it back, so widening loses nothing; narrowing would change a
declaration the source made.) Where the format fixes the width there is
nothing to widen and it is PULSE_SPSS_WIDTH_OVERFLOW — a string past
32767 bytes, an 8-byte short name, a value label past the 255 its
one-byte length field counts, the 64-byte file label, an 80-byte document
line.
Order is the crux: encode, then measure, then segment. A string past
255 bytes is re-segmented across physical variables per record 7/14 on a
fixed 252-byte stride, so a multi-byte character can straddle a
boundary — the reader joins the pieces before decoding, exactly as the
writer encodes the whole value before slicing.
Records 7/10, 7/17 and 7/18 pass through verbatim: the reader
never decodes them, so their bytes are the only record of what the source
said. That is correct whenever the target charset is the source’s, and it
is the only case where overriding the target is lossy — a non-ASCII
payload then rides PULSE_SPSS_CHARSET_MISMATCH as a warning rather than
being guessed at or dropped.
Diagnostics raised by the write side
Encode-side diagnostics ride the io.TargetWarningEmitter optional
interface onto ExportReport.TargetWarnings / ConvertReport.TargetWarnings,
and the CLI lifts them onto the --json envelope’s warnings array or
onto Warning [CODE] lines on the text path. The CLI re-reads them
after Close, because a writer that encodes at Close — which the
row path does — has raised none of them when the job builds its report.
| Code | Severity | Meaning |
|---|---|---|
PULSE_SPSS_SIDECAR_ABSENT | warning | No sidecar; dictionary synthesised from the .pulse schema |
PULSE_SPSS_SIDECAR_IGNORED | warning | --ignore-sidecar given; the read was skipped entirely |
PULSE_SPSS_SIDECAR_STALE | error | Cohort size/mtime moved since the sidecar was written |
PULSE_SPSS_SIDECAR_INVALID | error | Not JSON, foreign kind, unknown format_version, bad digest, or a broken multiple_response_sets[].fields array |
PULSE_SPSS_NAME_INVALID | error | A cohort field name a .sav cannot carry as a variable |
PULSE_SPSS_NAME_COLLISION | error | Two cohort fields resolve to one SPSS variable name (case-insensitively) |
PULSE_SPSS_NAME_SANITIZED | warning | --sanitize-names rewrote names; details carry the full field → name list |
PULSE_SPSS_COLUMN_UNMAPPED | error | A cohort column would leave the export silently, and the registry does not account for it |
PULSE_SPSS_DERIVED_UNFOLDABLE | error | A registry entry this binary cannot fold back |
PULSE_SPSS_EXPORT_UNSUPPORTED | error | --include / --labels / non-UTC --tz on a .sav export, or a value with no honest .sav form |
PULSE_SPSS_COMPRESSION_UNSUPPORTED | error | ZSAV emission — read-only, not implemented |
PULSE_SPSS_CHARSET_UNENCODABLE | error | A character the target charset cannot represent |
PULSE_SPSS_CHARSET_UNSUPPORTED | error | A --charset name Pulse cannot write |
PULSE_SPSS_CHARSET_MISMATCH | warning | A verbatim 7/10 / 7/17 / 7/18 payload under an overridden target charset |
PULSE_SPSS_WIDTH_OVERFLOW | error | An encoded value exceeds a width the format fixes |
pulse errors lookup CODE is authoritative for the per-code fixups.
What the fidelity claim rests on
The effort’s justification was fidelity, so it is worth being precise about which parts are proven and which rest on the PSPP specification alone.
Gated in CI. TestRoundTrip_* runs import → export → import over a
matrix covering all three source encodings, both multiple-response
flavours, all three missing-specification shapes, a non-UTF-8 charset,
very long strings and both endiannesses. It asserts that the re-imported
.pulse is byte-identical to the first — cohort identity, not .sav
byte-identity, which is unreachable for the correct reasons above — that
the emitted .sav is a fixed point under a second export, and that it
declares exactly the source’s own variables.
TestRoundTrip_MatrixCoversFR62 fails if any axis loses its last fixture,
so nothing can be quietly skipped.
Verified locally, not in CI. The R cross-checks
(internal/io/spss/dict_ecosystem_test.go) hand emitted and round-tripped files to
haven 2.5.5 (ReadStat — the C reader behind haven, pyreadstat and most of
what else opens a .sav) and to the independent implementation in
foreign 0.8.91, and require each reader’s own reading of the cycled file
to match its own reading of the source. Both read every value, value
label, variable label and measure level back exactly, in both write modes.
These tests t.Skip when R, haven or foreign is absent, which is the
state on CI — they are a recorded local result, not a standing gate.
Corroborated by no independent reader. Two parts of the write path are implemented from the PSPP specification and nothing else has confirmed them:
- Multiple-response set subtypes
7/5and7/19. Neither haven norforeignexposes MR metadata at all, so there is no third party that can read our set definitions back. The round-trip gate proves only that Pulse reads what Pulse writes. - The two-
int32form of record7/11. Pulse reads both the two-int32 and three-int32 shapes; it always writes the three-int32 shape. The two-int32 branch is exercised only by synthetic fixtures.
Not implemented. ZSAV emission. Pulse reads .zsav and does not write
it; there is no partial or degraded path.
Related
pulse import spss— the read half, in full detailpulse cohort inspect— see the cohort’s schema and dictionaries before exportingpulse manifest—import.formats[]declaresspsswithexport: true;export.formats[]declares itswarn_and_skipoverlay support- Adding an I/O Format — the
CohortWriter/CohortValidator/TargetWarningEmittercontracts this adapter implements skills/spss-cohorts.md— the agent-facing SPSS surface, read and writeskills/session-format-flags.md— the four write flags in the CLI flag map
pulse index
Audience: CLI users managing the sidecar point-lookup index for a
cohort — the corpus-management counterpart to
pulse api lookup. Defined in
internal/cli/index.go.
pulse index mirrors the pulse shard subcommand tree’s shape: one
command group, four verbs (build, list, verify, drop) acting
on a cohort’s sidecar index file(s). An index is a separate file
next to the cohort — cohort.pulse.<keyhash>.idx — the .pulse file
itself is never modified by any index subcommand.
build additionally maintains a keyless catalog beside the cohort,
cohort.pulse.indexes.json, recording each index’s ordered key tuple.
It exists because <keyhash> is a hash of the key tuple, so a
sidecar cannot be opened without already knowing its key columns — and
object storage cannot list a directory, so globbing .idx files is not
an option where cohorts are usually served from. list reads the
catalog first.
LLM agents using MCP: there is no dedicated MCP tool for index management in v1 —
pulse_lookupassumes an index already exists. Build indexes via this CLI orpulse.BuildIndex/pulse.VerifyIndex/pulse.ListIndexes/pulse.DropIndexin the Go library.
Synopsis
pulse index build --input PATH --key FIELD[,FIELD...] [--json]
pulse index list --input PATH [--json]
pulse index verify --input PATH --key FIELD[,FIELD...] [--json]
pulse index drop --input PATH --key FIELD[,FIELD...] [--json]
PATH may also be given as the first positional argument in place of
--input on every subcommand.
pulse index build
Scans the cohort once and writes a sidecar index keyed on --key
(comma-separated for a composite key; order is significant and is
what pulse api lookup --key must match). Idempotent — rebuilding
with the same key columns over an unchanged cohort produces a
byte-identical sidecar.
pulse index build --input sales.pulse --key region,date
{
"format_version": "1.1",
"data": {
"cohort": "sales.pulse",
"index_path": "sales.pulse.a1b2c3d4.idx",
"manifest_path": "sales.pulse.indexes.json",
"keys": ["region", "date"],
"distinct_keys": 480,
"indexed_records": 50000
},
"errors": [],
"warnings": []
}
manifest_path is the keyless catalog this build upserted its entry
into — the one path that can be read to recover this index’s key tuple
without knowing the tuple already.
pulse index list
Enumerates every sidecar index built for a cohort, with each one’s ordered key columns. An empty result is not an error.
pulse index list --input sales.pulse
Two sources, catalog first:
cohort.pulse.indexes.jsonwhen it exists. It carries every entry’s key tuple and its counts, so this path reads no sidecar at all — one read of the catalog plus one existence check per entry. That is what makes the listing work on object storage.- The directory listing (
cohort.pulse.<hash>.idxbeside the source file), reading each match’s own key spec.
The two are unioned when both are available, so a sidecar built by
a Pulse that predates the catalog does not disappear the day one
appears beside it — and build seeds a catalog it is creating from the
sidecars already on disk, closing the same gap from the write side.
When the catalog exists, a directory that cannot be listed is not an
error.
A catalog that exists but cannot be parsed is
PULSE_INDEX_MANIFEST_INVALID rather than a silent fall back to the
directory listing: that fallback succeeds on a local disk and answers
“no indexes” on a bucket, which is the backend-dependent divergence the
catalog exists to remove. A catalog naming a sidecar that is not
present is PULSE_INDEX_MANIFEST_STALE rather than a skipped entry —
reporting two indexes for a catalog claiming three is indistinguishable
from a correct answer. Repair either with pulse index build for that
key tuple, or pulse index drop, which prunes an orphaned entry even
though the file is already gone.
pulse index verify
Reports whether an index is still fresh relative to its source
cohort. The read path used by lookup accepts a matching size+mtime
pair without hashing; verify instead recomputes the authoritative
full SHA-256 fingerprint whenever the size matches, so it catches the
residual case an in-place edit that happens to preserve file size and
mtime would otherwise miss. It also never reuses the digest cached by
the read path — its whole job is the verdict that does not rest on a
stat pair.
pulse index verify --input sales.pulse --key region,date --json
{
"format_version": "1.1",
"data": {
"cohort": "sales.pulse",
"index_path": "sales.pulse.a1b2c3d4.idx",
"keys": ["region", "date"],
"fresh": true,
"reason": "fingerprint_match",
"fast_path": false
},
"errors": [],
"warnings": []
}
reason is one of stat_mismatch (the size alone was conclusive —
fast_path: true), fingerprint_match, or fingerprint_mismatch.
An additive modtime_drift: true (omitted when false) means the
cohort’s modification time no longer matches the snapshot taken at
build while its size does, and the content hash settled the verdict.
That is not staleness on its own: SourceModTime is recorded as
Unix nanoseconds, an S3 LastModified is whole seconds, and every
copy, restore and cache hop truncates somewhere — so drift is not
evidence of mutation, and lookup escalates to the fingerprint there
rather than refusing. Seeing fingerprint_match with
modtime_drift: true means the index is good and the filesystem
reporting the cohort simply does not preserve the timestamp
resolution.
pulse index drop
Removes a sidecar index immediately — destructive, non-interactive,
no confirmation prompt. The sidecar is a cheap rebuild artifact
(pulse index build regenerates it byte-identically), so no undo is
offered.
pulse index drop --input sales.pulse --key region,date
Flags (common to build / verify / drop)
| Flag | Alias | Type | Default | Purpose |
|---|---|---|---|---|
--input | -i | string | (required) | Cohort .pulse file path (or the positional argument) |
--key | -k | string | (required for build/verify/drop) | Comma-separated key column(s), in build-time order |
--json | bool | false | Emit the standard envelope |
Coded errors
| Code | Meaning |
|---|---|
PULSE_INDEX_MISSING | verify / drop targeted a key combination with no built sidecar (and, for drop, no catalog entry either) |
PULSE_INDEX_UNSUPPORTED_SHARDED | The cohort is a shard archive — point-lookup indexing is single-file only |
PULSE_INDEX_MANIFEST_INVALID | cohort.pulse.indexes.json exists but is not a readable catalog (malformed JSON, foreign kind, unknown format_version) |
PULSE_INDEX_MANIFEST_STALE | The catalog names an index file that is not present — rebuild or drop that key tuple |
PROCESSING_CONFIG | --key names a field whose type is not index-keyable (set_*), or another field-resolution failure |
Constraints
- Single-file cohorts only. Shard archives reject with
PULSE_INDEX_UNSUPPORTED_SHARDED; thearchive.pulse#shard.pulseanchor works as a tested single-shard workaround. - Equality-only, full-key required. No range or partial-key lookups in v1.
set_*fields cannot be key columns — see the keyable-type policy onpulse api lookup.
Related
pulse api lookup— read path that consumes the index this group managesskills/cohort-sidecar-index.md— sidecar index byte format (v3) + keyable-type policypulse shard— the subcommand-group shape this mirrors
pulse manifest
Audience: CLI users (and orchestration agents) discovering Pulse’s self-description — what commands exist, which aggregators are registered, which field types are supported, and what skills the binary ships with.
The manifest is the bare-pulse invocation with --json. It is
deterministic and process-wide: it never depends on cohort data or
the filesystem.
LLM agents using MCP: the manifest is also available via the
pulse_manifestMCP tool. Agents typically call this once per session and cache the result.
Synopsis
pulse --json [--slim] [--intent ID]
(There is no pulse manifest subcommand — the manifest is the root
command’s --json output.)
Flags
| Flag | Type | Default | Purpose |
|---|---|---|---|
--json | bool | false | Emit the manifest as a JSON envelope |
--slim | bool | false | Drop prose descriptions from the manifest payload (smaller for size-sensitive clients) |
--intent | string | “” | Scope the manifest to one intent-taxonomy ID (see below) |
Manifest shape
From internal/descriptor/manifest.go:
{
"format_version": "1.1",
"data": {
"commands": [ /* every CLI leaf with a usage line */ ],
"operators": [ /* every aggregator / attribute / filterer / grouper / window / feature */ ],
"tests": [ /* every tier-1 statistical test */ ],
"post_tests": [ /* every tier-2 post-test variant */ ],
"distributions": [ /* every synth distribution kind */ ],
"errors": [ /* every registered error code with a description */ ],
"mcp_tools": [ /* every MCP tool name + description */ ],
"field_types":[ /* every .pulse field type */ ],
"skills": [ /* every embedded skill with metadata */ ]
},
"errors": [],
"warnings": []
}
Every list is sorted deterministically (alphabetical or category +
alphabetical). The same Pulse binary always emits the same manifest
bytes (modulo --slim).
Intent-scoped manifest
--intent ID (MCP: pulse_manifest {"intent": ID}; library:
Pulse.ManifestForIntent) narrows the manifest to one intent-taxonomy
ID, for an agent that already knows the kind of question it is
answering. The operator sections (components, tests, post_tests,
regressions, matrices, overlays) keep only operators whose purpose
names the intent, skills becomes the intent’s ranked skill list, and
crosstab / matrix stay only when a remaining operator can use them.
The fixed sections — commands, operations, the error-code lists,
cohort_types, mcp_tools, synth_distributions, extensions,
limits, components_schemas (each operator keeps its own
component_schema) and the facet / process_chain / join /
export / import blocks — are absent, never empty, and listed in
elided. scope.intent carries the full intent record (label, sample
phrasings, data shapes). Versions, digests, intents and
return_presets are kept, so a client caches a scoped manifest under
(pulse_version, feature_set_digest, limits_digest, scope.intent.id).
A scoped slim manifest is gated at no more than 15% of the full slim
manifest for every analytic intent; describe (served by every summary
aggregator) is the one named exception, with a measured ceiling. The
unscoped manifest is unchanged.
Determinism gates
Several CI tests enforce manifest completeness — see Testing Conventions. Notably:
TestManifestOperatorsComplete— every registered operator appears in the manifest.TestManifestTestsComplete/TestManifestPostTestsComplete— every registered statistical test appears.TestManifestDistributionsComplete,TestManifestErrorCodesComplete,TestManifestMCPToolsComplete— same for distributions, error codes, and MCP tools.TestManifestStreamableMatchesTypes— every operator’sstreamableflag mirrors the per-type method.
When to use the manifest
| Use case | Reach for |
|---|---|
| Discover what’s available | pulse --json |
| Confirm a specific operator’s params and emit type | `jq ’.data.operators[] |
List embedded skills with their applies_to | jq '.data.skills[]' |
| Generate documentation or client stubs | Parse the full manifest once at boot |
| Quick “is this name a real operator?” | `pulse –json –slim |
Exit codes
| Code | Meaning |
|---|---|
| 0 | Always (the manifest is in-memory, deterministic, never errors) |
Examples
Print the manifest
pulse --json | jq '.data | keys'
Slim variant for embedding in an agent’s system prompt
pulse --json --slim > manifest.slim.json
List every aggregator with its emitted type
pulse --json | jq '.data.operators[] | select(.category == "aggregation") | {name, emits_type}'
Confirm a feature operator’s parameters
pulse --json | jq '.data.operators[] | select(.name == "FEAT_BUCKETIZE")'
Related
- How LLMs Use Pulse — the manifest is one of the agent discovery primitives
- Library: pulse.Manifest — Go counterpart
- Internals: Architecture — why the
manifest cannot import
internal/service/orinternal/processing/
pulse schema
Print the JSON Schema (draft 2020-12) describing every public Pulse request and response payload.
pulse schema
The output is a self-contained JSON Schema document — it carries its own
$schema and $id — so it is emitted raw, not wrapped in the
standard --json envelope. It is byte-identical to the file published at
the schema’s $id
(https://frankbardon.github.io/pulse/payload-schema.json) and to the
pulse://schema MCP resource.
Typical use — validate a request body offline:
pulse schema > payload-schema.json
# validate ./request.json against payload-schema.json#/$defs/Request
# with any draft-2020-12 validator
See the Payload Contract chapter for the schema’s structure, the three access surfaces, how it stays in sync with the engine, and its v1 strictness boundaries.
pulse synth from-schema
Audience: CLI users generating a synthetic .pulse cohort from a
declarative spec — for testing, demos, and bootstrapping fixtures.
pulse synth from-schema reads a JSON synth spec (field-by-field
distributions, row count, optional pairwise correlations) and writes
a deterministic .pulse file. Same (spec, seed) pair produces a
byte-identical output.
No MCP tool. Synthesis is CLI and library (
Pulse.Synth) only. Thesynthetic-dataskill covers spec authoring, the supported distributions (manifestsynth_distributions), and constraint patterns.
Synopsis
pulse synth from-schema --spec FILE --output FILE
[--rows N] [--seed N] [--json]
Flags
| Flag | Alias | Type | Default | Purpose |
|---|---|---|---|---|
--spec | -s | string | (required) | Synth spec JSON path |
--output | -o | string | (required) | Output .pulse file path |
--rows | int | from spec | Override row_count in the spec | |
--seed | int | 0 | Deterministic RNG seed | |
--json | bool | false | Emit the standard envelope |
Spec shape (sketch)
{
"row_count": 10000,
"fields": [
{"name": "id", "type": "u64", "distribution": "monotonic_from", "from": 1},
{"name": "region", "type": "categorical_u8", "distribution": "weighted_categorical",
"weights": {"east": 0.4, "west": 0.4, "north": 0.1, "south": 0.1}},
{"name": "revenue", "type": "f64", "distribution": "lognormal", "mu": 4.0, "sigma": 0.8},
{"name": "sold_on", "type": "date", "distribution": "uniform_date",
"from": "2024-01-01", "to": "2024-12-31"}
]
}
Full spec grammar (correlations, regex, …) lives in
skills/synthetic-data.md and the synth package; constraints[] and the
rules[] structural surface live in skills/synth-structural-rules.md.
Categorical / numeric / set joint structure (advanced)
Beyond independent per-field distributions and correlations, a
hand-authored spec can also declare conditional joint structure
between fields directly — the same Spec fields synth.SpecFromProfile
populates from a captured profile’s conditional.* sections (see
pulse profile create), reachable in from-schema
JSON without ever running profile create --conditional:
| Spec field | JSON key | Reconstructs |
|---|---|---|
CategoricalPairs | categorical_pairs | field B resampled from a captured contingency table conditioned on field A’s drawn value |
CategoricalNumericPairs | categorical_numeric_pairs | numeric field B resampled from Normal(mean, std) per observed category of categorical field A |
SetCategoricalPairs | set_categorical_pairs | a set_* field’s option (bit) resampled from Bernoulli(P(selected | categorical A's value)) |
SetNumericPairs | set_numeric_pairs | numeric field B resampled from Normal(mean, std) conditioned on a set_* field’s option state |
SetSetPairs | set_set_pairs | one set_* field’s option resampled conditioned on a DIFFERENT set_* field’s option state |
Each entry runs as a generation-time resample step, applied after
every field’s own independent draw and before the numeric-numeric
correlator — the field still needs its own distribution declared (a
categorical_pairs entry’s b still needs weighted_categorical, a
categorical_numeric_pairs entry’s b still needs normal); the
pair only overrides the drawn VALUE, never the field’s declared shape.
Omitting all five keys reproduces plain independent-marginal generation
exactly, unchanged from a spec that never mentions them.
Two further keys are hand-authorable on the same terms. models (the
Spec.Models slot profile create --fit-models populates) declares one
additive linear predictor per numeric field — a field it names is drawn
from that model rather than from its own marginal, and any pair or
correlations entry naming the same field is dropped with a warning.
residual_correlations declares correlations between two modelled
fields’ residuals, {a, b, correlation} exactly like correlations but
read on the residual scale; both endpoints must carry a models entry
or the spec is refused. Omitting either key is the zero state — every
residual is drawn independently, exactly as before they existed. The
construction and the latent-scale caveat for non-normal targets are in
skills/synth-model-draw.md; the determinism rules in
skills/synth-determinism.md.
Minimal example — a categorical-categorical pair (region → tier)
alongside a categorical-numeric pair (region → revenue):
{
"row_count": 10000,
"fields": [
{"name": "region", "type": "categorical_u8", "distribution": "weighted_categorical",
"weights": {"east": 0.5, "west": 0.5}},
{"name": "tier", "type": "categorical_u8", "distribution": "weighted_categorical",
"weights": {"gold": 0.5, "silver": 0.5}},
{"name": "revenue", "type": "f64", "distribution": "normal", "mean": 500, "std": 100}
],
"categorical_pairs": [
{"a": "region", "b": "tier", "cells": [
{"a_value": "east", "b_value": "gold", "count": 80},
{"a_value": "east", "b_value": "silver", "count": 20},
{"a_value": "west", "b_value": "gold", "count": 30},
{"a_value": "west", "b_value": "silver", "count": 70}
]}
],
"categorical_numeric_pairs": [
{"a": "region", "b": "revenue", "categories": [
{"category": "east", "mean": 650, "std": 90},
{"category": "west", "mean": 350, "std": 80}
]}
]
}
Full cell/category shapes: internal/synth/spec.go
(CategoricalPairCellSpec, CategoricalNumericCategorySpec, and the
Set*PairSpec family) and skills/synth-profile-capture.md (“Categorical
joint structure (generation)”); claim arbitration is skills/synth-conflicts.md. A field named by two or more of these
relationships — or by both a relationship and that field’s own
--fit-shape-captured distribution when the spec came from
SpecFromProfile — is a conflict, not an error: one relationship
wins by a fixed priority order and every other claimant on that field
is dropped with a warning on Result.Warnings
(data.warnings under --json); see pulse profile create (“--fit-shape” section) for the full
priority order.
Structural rules
Beyond distributions, correlations and joint structure — all of which describe variation — a spec can declare structural rules: facts about which fields a row may carry a value for at all, and what that value is. A skip-logic gate (“the perception block is not asked of a respondent who has never heard of the brand”) is not a rate, and a profile reconstructs it as soft noise with a plausible null rate and no gate.
{
"row_count": 40000,
"fields": [ "..." ],
"rules": [
{"when": "aware == 0",
"set_null": ["perception_1", "perception_2"],
"set": {"segment": "unaware"}},
{"set_expr": {"promoter": "nps >= 9"}},
{"null_together": ["nps", "nps_reason"]}
]
}
| Slot | Type | Meaning |
|---|---|---|
when | expr string | the rule applies to rows satisfying it; absent means every row |
set_null | array | null these fields |
set | object | assign these literal values |
set_expr | object | assign the result of these expressions |
null_together | array | null this block as one decision (needs ≥ 2 distinct fields) |
owns_nulls | bool | modifier, not an action: this rule is the only source of absence for the fields it names in set_null, so their own null_rate draw is discarded |
set and set_expr are separate keys on purpose: with one map,
{"set": {"region": "west"}} would be undecidable between a categorical
literal and a bare identifier.
Rules apply in declaration order, sequentially, last write wins, in
one pass at the very end of the row — after every distribution, pair,
correlation and model has settled. So a gated field is still generated
normally and the rule then masks or replaces it on the rows when
selects; rows the rule does not gate keep the value generation inferred.
An expression reads the row as it stood when its rule started, and a
later rule may still change it. Rules are deliberately not reordered
by dependency: declaration order is the one ordering you can read off the
file.
Running the pass last is what makes if gate then null else inferred
true; running it earlier would let a model overwrite its own rule-gated
target. The accepted consequence is that a field a rule nulls still
contributed its drawn value to any model that used it as a predictor
on that row — the propensity existed, the question was not asked.
set_null sets the null flag and leaves the drawn value in the row; the
file gets the type’s zero, so a masked value never reaches the wire.
set and set_expr write the value and clear the null flag, because a
rule stating a field’s value is stating the field has one. So set_null
then set yields the value, and set then set_null yields the null.
The pass consumes no randomness: a spec carrying rules draws exactly the same per-row sequence as the same spec without them, and a spec declaring no rules generates byte-identical output to one written before the slot existed.
owns_nulls: the rule owns the field’s absence
set_null states which rows a field is absent on. It says nothing
about how many, and each target’s own null_rate keeps firing
underneath the gate. That rate is a marginal the profiler captured, so
it already includes every row the gate removed, and the two compose as
g + (1 - g) * r: a gate that is exactly right about every gated row is
still wrong about the marginal.
Measured on the 381,324-row survey profile behind this feature, a
50-field {"when": "aware == 0"} gate took regard from a captured
0.2526 to a generated 0.4369, and the five *Aware gates took
people from 0.4273 to 0.7506.
{"when": "aware == 0",
"set_null": ["regard", "meaningfulness", "uniqueness"],
"owns_nulls": true}
With the flag, all fifty land on 0.2455 — the gate’s own firing rate, which is the number the capture was measuring — while the gate stays exact on 491,200 of 491,200 gated cells.
The flag is scoped to set_null and to nothing else; declaring it with
an empty set_null is PULSE_SYNTH_RULE_OWNERSHIP_INVALID, because a
claim that is merely ignored is invisible.
It zeroes the field’s own draw rather than applying the residual
(r - g) / (1 - g). The residual needs g, the rule’s firing
probability, and a when is an arbitrary predicate over a row — no
spec knows how often it will be true, and estimating it would need a
generation pass whose own rows are drawn from the rates being corrected.
So the gap is measured instead of guessed: an owned field whose
realised null rate misses its discarded null_rate by more than 0.02
and by two standard errors is reported after generation, naming both
rates, the row count and every rule that claimed it. The second term is
not decoration — at 200 rows a correct claim on a 0.25 field misses by
more than 0.02 about half the time.
Two rules may own one field. Ownership is a union, not an exclusive
claim: set_null removes a value rather than supplying one, so it never
takes a field away from its own generation, and “two gates can each
account for this field’s absence” needs no arbitration. The draw is
suppressed once and either gate nulls the field.
The field still draws its null and only the verdict is discarded, so the seeded stream does not move: on the motivating cohort the other 72 fields are cell-identical to the un-owned run at the same seed, and the only change is 381,351 null flags removed on non-gated rows — none added, no value moved.
null_together already does this for its non-gate members, by copying
the gate’s decision rather than by suppression, which is why naming a
never-null field first is an idiom. owns_nulls states it directly, on
the rule that makes it true. The block is still required for the shape
it alone expresses: a co-missing block with no gating field at all,
where one member’s own draw is the block’s only source of absence.
null_together: one null decision for a block
A survey question block is asked or skipped as a unit, so its fields are
present or absent together. Generation draws each field’s null
independently from its own null_rate, which turns a block into a
lottery: on a real 122-field survey profile the nps / promoter /
passive / detractor block shares null_rate 0.8260 exactly, and all
four came back present in 45 of 40,000 generated rows against the
~6,960 the block actually has — because 0.174⁴ is not 0.174. With
the rule, 6,809–6,994 across five seeds, and no partial block at all.
{"null_together": ["nps", "promoter", "passive", "detractor"]}
The first named field’s null state is copied to the rest. Every field has already taken its own null draw by the time the rule pass runs, so copying an existing decision is the only resolution that adds no randomness — and the pass consuming none is a hard contract, not a preference. Order the block so the field you mean as the gate comes first; it is normally the question the block hangs off.
So every other member’s own null_rate is ignored. That is the cost
of the copy, it is deliberate, and the run says so out loud rather than
leaving you to find it: when a member’s declared rate sits more than
0.02 from the gate’s, a warning names the rule, the gate and every
divergent field with its rate. It is a warning and not a refusal,
because a real block whose fields drifted a little — a coding
difference, a partial re-ask, a rate read back off a rounded table — is
still a block, and the copy is still the right answer for it. The
comparison is absolute and each member is measured against the gate,
not against the block’s spread: what the copy discards is a number of
rows, so 0.80 against 0.84 matters and 0.001 against 0.002 does not.
The copy moves the null decision, never a value. Each member keeps
what its own sampler drew; a member the block nulls keeps it in the row
exactly as set_null does (the file gets the type’s zero), and a member
the block un-nulls — the gate carried a value, the member had drawn
a null — publishes the value it drew. That second direction is what
makes the block share a rate rather than merely share its nulls.
Inside one rule the block is the last write, after set_null, set
and set_expr. The slots of a single rule have no order you can read
off the document, so the order is fixed: the block goes last because
that is the composition that does what it looks like —
{"when": "aware == 0", "set_null": ["nps"],
"null_together": ["nps", "promoter", "passive", "detractor"]}
nulls the gate and the whole block follows it. The same fixed order
means a set_null naming a non-gate member of the same rule is
overridden by the block. Across rules there is a real order to appeal
to and the ordinary last-write-wins applies instead: a later rule’s
set_null over any member does break the block apart, and a later
null_together re-decides it from whatever the gate holds by then.
A block member that is not declared "nullable": true raises a warning
— where set_null over such a field is refused. The difference is
what each slot claims: set_null states the field is null on a
matching row, which a non-nullable field can never record, while
null_together states the members carry the gate’s decision, and that
decision may be “present” on every row. The block’s gate is never
reported at all: the copy reads it, so a non-nullable gate is not a field
the block failed to null — and a never-null gate is an idiom rather than
a mistake, since it clears every member’s own null and leaves a later
set_null as the block’s only source of absence. See below.
set_expr and the coercion matrix
set_expr assigns what an expression computes from the row, which
collapses a derived-field relationship from several when-gated rules
into one unconditional rule:
{"set_expr": {"promoter": "nps >= 9",
"passive": "nps >= 7 && nps < 9",
"detractor": "nps < 7"}}
It writes the value and clears the null flag exactly as set does.
An expression returns a Go value and the target field is one of the
seventeen declarable types, so the interesting surface is which
results can land on which targets. Getting a cell of that wrong is a
silent wrong value — a true reaching a u8 as a formatted string
would encode as 0 — so the whole matrix is written down:
| expression result | scalar target | categorical_* | set_* |
|---|---|---|---|
true / false | 1 / 0 | refused | refused |
| a number | the number, range-checked | refused | refused |
| a string | refused (see below) | must be a declared value | refused |
a selection (another set_* field, a list of option names, split()) | refused | refused | the selection |
Scalar means u4 u8 u16 u32 u64 f32 f64 date
packed_bool decimal128. The range is the same one a set literal
obeys — u4 is 0–15, packed_bool is 0–1 — because both go through one
implementation; NaN and infinities are refused. Declared means an
entry in a weighted_categorical’s params.values, or in a set_*’s
params.options; a regex or constant categorical has no declarable
domain, so any string is accepted there.
A decimal128 takes an exact string only from set, never from
set_expr. A literal like "1.25" is parsed exactly, with no float
round trip. An expression cannot offer that — expr sees the field as
the float64 the row holds, so anything computed already lost the
digits — and a string left in the row for a field the expression
environment types as a number would break the next rule that reads it,
on gated rows only. Write the literal with set.
Two timings, one error. A result type that could never land is
refused when the spec is parsed, before a single row is generated:
{"set_expr": {"region": "nps * 2"}} is a number aimed at a
categorical, and expr knows that from the types. A fault only a
value can settle — a number outside the target’s range, a computed
category outside the declared domain — is refused at row time, because
that is the first moment it exists. Both are
PULSE_SYNTH_RULE_VALUE_INVALID and both name the rule index, the slot,
the field, the expression and the value.
Inside one rule, order does not exist. Every expression in a rule —
its when included — reads the row as it was before the rule ran, and
all of that rule’s writes land afterwards. {"set_expr": {"a": "b", "b": "a"}} swaps the two, the way a SQL UPDATE does. A set literal in
the same rule is not visible to a sibling set_expr. Reading the
target’s own drawn value ({"set_expr": {"nps": "nps + 1"}}) works.
The alternative would have been alphabetical key order, which is not
something you can read off a JSON document and which would make renaming
a column change a number. Across rules nothing changed: an expression
sees what an earlier rule wrote and not what a later one will.
Expressions are the same expr-lang environment constraints[] uses —
every scalar including a boolean is a number (flag == 1, never bare
flag), a categorical is a string, a set_* is a map
(flags["opt"]), and isnull(field) tests absence. when must return
a bool. isnull takes a field NAME, bare or quoted, and a name the spec
does not declare is refused when the spec is parsed in either spelling —
the quoted form compiles, so it is reported as the unknown FIELD it is
(PULSE_SYNTH_RULE_FIELD_UNKNOWN), not as an expression that will not
compile.
The same array is also the standalone rules file format that
synth from-profile --rules loads, so an
inline declaration and a file are the same JSON.
Every malformed rule is refused when the spec is parsed, not at row
400,000: a rule naming a mistyped field, or carrying an expression that
does not compile, would otherwise generate a full cohort with the gate
silently missing. Each refusal names the rule’s index (rule_index) —
rules have no names of their own — plus the offending slot and field.
Two things that are silent if you get them wrong
Nulling needs a nullable field. The per-record null bitmap only
carries a bit for a field declared "nullable": true. Nulling any other
field writes the type’s zero as an ordinary value with no null flag,
indistinguishable from a real 0 on a u4 or a packed_bool: the rule
fires and the file cannot show it.
A set_null over such a field is therefore refused when the spec is
parsed (PULSE_SYNTH_RULE_FIELD_NOT_NULLABLE, naming the rule index,
the slot and the field). The fix is on the field, not the rule —
declare it "nullable": true. On a profile-derived run a field is
nullable only if the source cohort had nulls in it, so the route is
--emit-spec, add the flag, and generate with
synth from-schema. Dropping the field from set_null is the other
answer, if a real 0 is what the cohort should carry.
A null_together member only warns, for the reason above: that slot
copies a decision rather than making one.
when and set_expr see the pre-rounding value — when the field’s
reconstruction is continuous. A numeric drawn from a continuous
distribution is a float in the row and floor(v+0.5) in the file, so
familiarity == 1 tests the drawn value, not the 1 you read back: for
a clamped normal it fires on the clamp’s point mass, not on the whole
wire-value-1 bucket. Use comparisons (nps >= 9) for those.
Two reconstructions are exact and need no normalisation at all. A
packed_bool reconstructs as bernoulli, whose row value is exactly 1.0
or 0.0, so aware == 0 selects precisely the rows the file shows as 0.
And an integer column (u4/u8/u16/u32/u64) with at most 64
observed levels reconstructs as discrete — its own captured histogram —
whose row value already is the stored integer, so round(nps) == 9 and
nps == 9 select the same rows and a round() normalisation rule over it
is a no-op.
The gap survives for three shapes, and the advice below is written for
them: an f32/f64 field, an integer column too wide for the 64-level cap
(see profile create), and a hand-authored spec that
puts a continuous distribution on an integer field.
It bites set_expr harder than when, because the result still looks
right. The three-band NPS rule above, written straight off nps, sets
exactly one flag on every scored row — it simply disagrees with the
score printed beside it whenever the float and its rounding fall on
opposite sides of a band edge. Measured on a real 122-field survey
profile at 20,000 rows: 476 of 3,486 scored rows, 13.7%. Normalise
first, in an earlier rule, because snapshot semantics means the same
rule will not do:
"rules": [
{"when": "!isnull(nps)", "set_expr": {"nps": "round(nps)"}},
{"set_expr": {"promoter": "nps >= 9",
"passive": "nps >= 7 && nps < 9",
"detractor": "nps < 7"},
"null_together": ["nps", "promoter", "passive", "detractor"]}
]
Three things in that snippet are load-bearing, and each was measured wrong before it was measured right.
round, not int. An integer field is stored as floor(v+0.5), so
round(v) is the expression that reproduces the value the file will
hold: the flags move onto the right side of the band edge and the stored
score does not move at all. int(v) truncates, and band(v) equals
band(floor(v)) for integer thresholds — so truncation moves no flag and
reaches the same agreement by pulling the SCORE down instead. On the
survey profile at 40,000 rows round repaired 1,029 band disagreements
and left the nps column byte-identical to the un-normalised run, while
int repaired none and rewrote 3,018 of 6,944 scored rows.
The when on the normalisation rule. A set_expr clears the
target’s null flag, so an ungated {"set_expr": {"nps": "round(nps)"}}
un-nulls every row the score was missing from — and a null_together
keyed on nps then copies that decision to the whole block. Measured:
the block goes from present on 17.4% of rows to present on 100% of them,
with nothing refusing it.
The classification and the block share ONE rule. Within a rule
null_together is the last write, so the flags are computed and then take
the score’s null decision. Declared as two rules with null_together
first, the set_expr runs afterwards and un-nulls all three flags on
every unscored row — 33,056 of 40,000 on the same measurement. The second
rule needs no when of its own precisely because the block resolves
after it; and having no when is also what makes the three flags
eligible for the pre-claim, so they are not modelled upstream only to be
overwritten.
Rules apply to generated rows only. synth from-profile --source
copies the real cohort through unchanged and tags it _synthetic=false;
no rule ever rewrites one of those rows.
Supported distributions
bernoulli, constant, discrete, exponential, lognormal,
mixture, monotonic_from, normal, pareto, poisson, regex,
set_bernoulli, uniform, uniform_date, weighted_categorical.
discrete takes values (strictly ascending) plus optional weights and
emits one declared level per row at its own share — the exact marginal of a
coded integer scale, and what profile create reconstructs every narrow
integer column from. Its quantile function is a staircase, so a discrete
field used as a model target or a correlation participant carries
latent-scale effects: ordering holds, magnitude in scale points does not.
The full catalog is listed in skills/synthetic-data.md (Distribution
registry), with parameters per kind in the atomic skills/op-synth-<kind>.md
skills, and pulse manifest --json | jq '.data.synth_distributions'.
Determinism
Same (spec, seed) → byte-identical output. The seed is a int64;
default 0. Use a fixed seed for fixtures and a random seed for
load-testing variation.
Output
Text mode
Generated 10000 rows -> sales.pulse (rejected 0)
rejected counts rows that failed user-defined constraints
(PULSE_SYNTH_CONSTRAINT_INFEASIBLE when the rejection rate is too
high to make progress).
Generation warnings — conflict arbitration, correlation matrices
completed by assumption or ridge-regularized, models that could not be
compiled — are summarised on stderr in the same grouped, counted,
capped shape profile create uses.
Nothing is printed when the run raised none, and nothing is printed on
the --json path, where data.warnings carries them in full.
--json
{
"format_version": "1.1",
"data": {
"output_path": "sales.pulse",
"rows_generated": 10000,
"rows_rejected": 0,
"seed": 0
},
"errors": [],
"warnings": []
}
Exit codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Spec parse error, unknown distribution, infeasible constraints, or output write failure |
Common error codes
| Code | Cause |
|---|---|
PULSE_SYNTH_DISTRIBUTION_UNKNOWN | Spec references a distribution name not in the catalog |
PULSE_SYNTH_CONSTRAINT_INFEASIBLE | Constraints reject too high a fraction of generated rows |
PULSE_SYNTH_RULE_EMPTY | A rule declares no action slot (set / set_expr / set_null / null_together) |
PULSE_SYNTH_RULE_FIELD_UNKNOWN | A rule names a field the spec does not declare — in a slot, or inside isnull("…") |
PULSE_SYNTH_RULE_FIELD_NOT_NULLABLE | A rule’s set_null names a field that is not declared "nullable": true, so the null could not be recorded |
PULSE_SYNTH_RULE_EXPR_INVALID | A rule’s when or a set_expr value does not compile (when must return a bool) |
PULSE_SYNTH_RULE_VALUE_INVALID | A set literal is the wrong shape for its target field, outside the type’s range, or outside the declared values / options domain |
PULSE_SYNTH_RULE_CONFLICT | One rule names the same field in two slots that disagree about it |
PULSE_SYNTH_RULE_BLOCK_INVALID | A null_together block names fewer than two distinct fields |
PULSE_SYNTH_RULE_OWNERSHIP_INVALID | A rule declares "owns_nulls": true but names no field in set_null, so the claim has nothing to apply to |
Examples
# Build sales.pulse from a spec
pulse synth from-schema --spec sales.spec.json --output sales.pulse --seed 42
# Override row count without editing the spec
pulse synth from-schema --spec sales.spec.json --output sales.pulse --rows 1000
# Programmatic envelope
pulse synth from-schema --spec sales.spec.json --output sales.pulse --json
Related
pulse synth from-profile— generate from a captured profile of an existing cohortpulse profile create— capture the profileskills/synthetic-data.md— spec grammar, distribution table, and the index of focusedsynth-*skillsskills/synth-correlations.md/skills/synth-conflicts.md—correlationsand claim arbitration- Synth calibration figures and design rationale
skills/synth-structural-rules.md—rules[]andconstraints[]entry skill; the focusedskills/synth-rule-*.mdskills carry expressions, null handling, validation and claims- Library: pulse.Synth
pulse synth from-profile
Audience: CLI users topping up a real cohort with synthetic rows whose per-field distributions match it — a tagged, source-preserving augmentation, not a full anonymised replica.
pulse synth from-profile reads a profile JSON captured by
pulse profile create, reads the --source
cohort the profile was captured from, and writes a new .pulse
file combining every real row from --source with --rows newly
generated rows. The profile itself retains no individual rows
from the source; only summary statistics drive generation.
LLM agents using MCP: see the
synthetic-dataskill.
Synopsis
pulse synth from-profile --profile FILE --source FILE --output FILE --rows N
[--seed N] [--fidelity-report FILE]
[--rules FILE] [--emit-spec FILE] [--json]
Flags
| Flag | Alias | Type | Default | Purpose |
|---|---|---|---|---|
--profile | -p | string | (required) | Profile JSON path |
--source | string | (required) | Source .pulse cohort the profile was captured from — copied into the output, never opened for write | |
--output | -o | string | (required) | Output .pulse file path — must be a distinct path from --source |
--rows | int | (required) | Number of new rows to generate | |
--seed | int | 0 | Deterministic RNG seed | |
--fidelity-report | string | (none) | Write a JSON fidelity report to this path after generation completes | |
--rules | string | (none) | Load structural rules from a standalone JSON file and apply them to the derived spec | |
--emit-spec | string | (none) | Write the derived spec (after any --rules merge) to this path as indented JSON | |
--json | bool | false | Emit the standard envelope |
--rows is required (unlike from-schema, which can pull it from
the spec) because the profile does not carry a generation count of
its own. --rows is always an explicit count of new rows to
generate, never a “top up to N total” target — --rows 500 against
a 200-row --source cohort produces a 700-row output (200 real +
500 synthetic), not a 500-row output.
Provenance tagging and the new-file-only contract
Every output cohort gains one appended field, _synthetic
(packed_bool): false on every row copied from --source, true
on every newly generated row. This is the only schema difference
between --source and the output — every other field is carried
through unchanged, in order.
--output must always be a path distinct from --source.
synth from-profile never opens --source for write and never
mutates it in place — an --output that resolves to the same file
as --source is refused outright, and the source cohort’s bytes and
modification time are unchanged by a run.
Inspecting and modifying the derived spec
synth from-profile derives a synth.Spec from the profile and
generates from it in one breath. Two additive flags open that step up.
Both absent, behaviour and output bytes are unchanged.
--emit-spec <path>
Writes the spec that actually generated as indented JSON. It is the
real spec, not a summary of it: fed to
synth from-schema at the same --seed it
reproduces the same rows.
pulse synth from-profile -p cohort.json --source cohort.pulse \
-o out.pulse --rows 20000 --seed 7 --emit-spec derived.json
It is the authoring aid — writing {"when": "familiarity == 1", …}
requires knowing the field is called familiarity, that it is a u4
and that its floor is 1, and nothing else prints any of that — and
equally the diagnostic. The emitted document is the only place a
reader can see:
- which captured
modelssurvived translation onto the spec, - which distribution each field reconstructed to (
normal,bernoulli,mixture,weighted_categorical,constantfor a column the profiler could not summarise), - which conditional pairs were retired, and which survived.
Each of those has been lost silently in this package before, every time leaving a plausible-looking cohort behind. The spec is written before generation runs, so a run that then fails still leaves the document.
--rules <path>
Loads a standalone rules document and applies it to the derived spec. Without it the whole structural-rules layer is unreachable from the profile path.
The file is the rules array itself — a bare JSON array of rule
objects, identical to the rules key of a from-schema spec, so a rule
moves between the two by cut and paste:
[
{"when": "aware == 0", "set_null": ["perception_1", "perception_2"],
"owns_nulls": true},
{"null_together": ["nps", "nps_reason"]}
]
A gate applied to a PROFILE-DERIVED spec almost always wants
owns_nulls.
SpecFromProfile carries each field’s CAPTURED null_rate, which is a
marginal already inclusive of every row the gate removed, so without the
flag the gate’s absences and the field’s own draw compose and the
generated rate lands well above the captured one — measured on the
motivating profile at 0.4369 against a captured 0.2526. The flag
discards the field’s own draw; profile create --suggest-rules writes
it on every gating candidate for the same reason.
pulse synth from-profile -p cohort.json --source cohort.pulse \
-o out.pulse --rows 20000 --seed 7 --rules rules.json
Merge semantics are REPLACE, not append. SpecFromProfile derives no
rules, so there is nothing to append to; an append mode would only create
an ordering question no caller has asked. An empty array ([]) is a legal
document meaning “no rules”; a {"rules": [...]} wrapper is refused
rather than read as zero rules, because a run with every gate silently
missing is the exact failure eager validation exists to remove.
Validation is eager and runs before generation. Every refusal names the
file in details.path alongside the rule index, because an analyst
editing a rules file beside a spec beside a profile needs to know which
document is wrong:
| Fault | Code |
|---|---|
| A rule names a field the derived spec does not carry | PULSE_SYNTH_RULE_FIELD_UNKNOWN |
A when or set_expr does not compile | PULSE_SYNTH_RULE_EXPR_INVALID |
A set literal the target cannot hold | PULSE_SYNTH_RULE_VALUE_INVALID |
| The file is not readable | DATA_FILE |
| The file is not a JSON array of rule objects | SERVICE_VALIDATION |
The rule-specific codes are E1’s own, not a leaf placeholder, so
pulse errors lookup PULSE_SYNTH_RULE_FIELD_UNKNOWN carries the recovery
prose. A refused file generates nothing.
What a rule retires from the derived spec
A rule that determines a field takes it away from everything
upstream that was going to produce a value the rule overwrites. The
field’s captured linear model stops being applied, any conditional
pair naming it is dropped, and it stops being a
residual_correlations participant. Each loss is warned:
conditional relationship conflict: field "promoter" is already claimed by
structural rule 0 (set_expr); dropping linear model
residual correlation(s) naming [promoter] dropped: the field carries no
surviving linear model, so it has no residual to correlate; the
remaining participants still correlate
That is not primarily about saving work. The fidelity report’s
models
section asks generation’s own compiler which models ran, so without the
retirement a rule-overwritten field would ship a captured-versus-recovered
coefficient delta for a value nothing kept — every number rendering,
and nothing on the report saying it describes something that did not
happen.
Which rule shapes retire a field, and which deliberately do not:
| Rule | Retires? | Why |
|---|---|---|
set / set_expr with no when | yes | writes every row |
any rule carrying a when | no | writes only some rows; the model still produces the value the rest keep |
set_null, at any conditionality (with or without owns_nulls) | no | it removes a value rather than supplying one — if gate then null else inferred needs the model to produce what the non-gated rows carry. owns_nulls suppresses the field’s own NULL DRAW and claims nothing: the value is still the model’s |
null_together | no | copies one null decision; supplies no value |
set_expr reading its own target | no | it transforms what generation produced rather than determining it |
The last row is why the documented pre-rounding remedy
{"set_expr": {"nps": "round(nps)"}} is safe — retiring nps there would
leave the rounding applied to a bare marginal draw. The reference is
detected on the parsed expression, so a field named nps_reason
elsewhere in the expression is not mistaken for one.
Measured on the 381,324-row survey cohort: an unconditional
{"set_expr": {"promoter": "nps >= 9"}} takes the applied models from 55
to 54, drops promoter’s 54 residual-correlation pairs, and removes its
models fidelity entry. A set_null over the same field changes none of
the three.
One consequence worth knowing: a rule that retires a model also removes that model’s per-row random draw, so a spec with such a rule generates a different row sequence from the same spec without it. The determinism contract is unaffected — same spec and seed still reproduce the same bytes — and the change is always announced by the warning above.
The two compose
pulse synth from-profile -p cohort.json --source cohort.pulse \
-o out.pulse --rows 20000 --seed 7 \
--rules rules.json --emit-spec derived.json
The emitted spec carries the merged rules, so the loop is emit → read →
write rules → apply → emit again to check what they became. --emit-spec
never changes the generated cohort; it is a pure diagnostic.
A rule that never fires says so
A rule naming a mistyped field or carrying an uncompilable expression is
refused at spec parse. A rule whose when is simply never true is
refused by nothing: it validates, it compiles, it applies to no row, and
the cohort generates cleanly with the structural fact still absent.
Every rule’s firings are counted during generation, and a rule that applied to zero generated rows is reported — as a kind needing attention, so it leads the stderr summary however many expected-outcome lines sit below it:
! rule never fired (1)
rule 2 never fired: when "respondent == 0.5" was true on 0 of 20000
generated row(s), so the rule applied to nothing; it compares
"respondent" (u64, normal), whose row value is PRE-ROUNDING — the
file holds round(v) — so normalise first (an earlier rule setting
round(field)) or compare a range
The count is over rows that reached the file: a row a constraint rejected was re-drawn and left no trace, so it is not a firing. A rule that fires on every row, and one that fires on some, are silent.
The message names the cause that applies to THAT rule
There are three arms, and the line picks between them from what the
compiled spec says about the fields the predicate reads. A single message
naming one cause was wrong for most rules once the discrete and
bernoulli marginals shipped — measured on the 381,324-row survey
profile, 103 of its 123 fields round on the way to the file and
draw exact values, so a dead rule gating on any one of them was told to
fix a gate that was already exact. Only three genuinely carry the
hazard.
| Arm | Reached when | What it says |
|---|---|---|
| constraint | the rule DID select rows and a constraint rejected every one (recorded, not inferred) | the cause is the constraint; relax it or widen the rule |
| pre-rounding | the predicate reads a field that rounds on write and draws continuous values | normalise with round(field) in an earlier rule, or compare a range |
| neutral | neither | names each field it reads with its type and distribution, and explicitly rules pre-rounding OUT for a field whose row value already is the stored value |
A field rounds on write when it is u4/u8/u16/u32/u64, date or
packed_bool. It still draws exact values — so pre-rounding cannot be
the cause — when its distribution is discrete, bernoulli,
uniform_date, poisson or monotonic_from. A small-integer column
reconstructs as discrete and a packed_bool as bernoulli
automatically, so on a survey cohort the neutral arm is the common one:
rule 0 never fired: when "familiarity == 99" was true on 0 of 2000 generated
row(s), so the rule applied to nothing; it reads "familiarity" (u4, discrete),
and only the values those fields actually generate can match it — check the
predicate against each field's own reconstruction; pre-rounding is NOT the
cause for "familiarity", whose row value already is the stored value
When pre-rounding IS the cause, the remedy is round, not int
The row holds the sampler’s float and the wire holds round(f), so a
<= k gate selects only the draws whose float is already at or below k.
An integer field is stored as floor(v+0.5), so round(v) is the one
expression that reproduces the value the file holds, and int(v) widens
the gate by moving VALUES down instead.
Measured on the current tree, at 20,000 rows and --seed 7, against the
smallest spec that still carries the hazard — a hand-authored continuous
distribution on an integer field, which is one of the three live cases
listed below:
{"row_count": 20000,
"fields": [
{"name": "score", "type": "u8", "nullable": true, "distribution": "normal",
"params": {"mean": 5, "std": 2.5, "min": 0, "max": 10}, "null_rate": 0.1}],
"rules": [{"when": "!isnull(score) && score <= 1", "set": {"marker": "fired"}}]}
| gate | rows it fires on | wire score <= 1 | stored level 0 / 1 |
|---|---|---|---|
score <= 1, un-normalised | 1,000 | 1,480 | 659 / 821 |
behind {"set_expr": {"score": "round(score)"}} | 1,480 | 1,480 | 659 / 821 — unchanged |
behind {"set_expr": {"score": "int(score)"}} | 2,140 | 2,140 | 1,000 / 1,140 — rewritten |
round reaches exactly the population a reader sees in the file and
stores the same column it would have stored anyway; int gets its extra
660 rows by rewriting the score of every respondent whose draw had a
fractional part. All three run silently; only the fully-empty case is a
warning.
How big the gap is depends on the field’s SPREAD, not just its type.
The divergence band is the half unit either side of the threshold, so a
WIDE integer column is live in principle and immaterial in practice: on
the same tree, catSpend (u32, 0–150,000, std 11,672 — over the
discrete cap, so it reconstructs as a clamped normal) fired on 7,872
rows un-normalised, 7,872 behind round() and 7,872 behind int(), all
three matching the wire count exactly. The three cases where it bites are
an f32/f64 column, a NARROW integer column drawn from a continuous
distribution, and any gate whose threshold sits where the density is high.
The original worked example for this section used familiarity from the
381,324-row survey profile; it is now a no-op there, because a
small-integer column reconstructs as discrete and its row value already
IS the stored level. round(familiarity) changes nothing, which is the
outcome the discrete marginal
was added to produce.
If many rules never fire the listing is capped at 20 with a counted
+N further rule(s) never fired line, the same bound the thin-level and
thin-residual-pair listings use.
Determinism
Same (profile, source, seed, rows) tuple → byte-identical output.
Seeds are int64; default 0.
Fidelity report
--fidelity-report <path.json> writes a JSON document after
generation completes, comparing the newly generated rows against the
source cohort’s rows inside the one tagged output. It reuses the
existing statistical-test operators rather than new comparison math:
every numeric field is compared with TEST_KS (split_by: _synthetic), and every categorical field with TEST_CHISQ
(contingency against _synthetic). Fields of any other on-wire type
(date, datetime, packed_bool, u4, set_*) are out of scope for
this section today.
Shape:
{
"source_rows": 200,
"synthetic_rows": 500,
"fields": [
{"field": "score", "test": "TEST_KS", "result": {"type": "TEST_KS", "statistic": 0.04, "p_value": 0.87, "alpha": 0.05, "reject_null": false}},
{"field": "country", "test": "TEST_CHISQ", "result": {"type": "TEST_CHISQ", "statistic": 1.2, "df": 2, "p_value": 0.55, "alpha": 0.05, "reject_null": false}}
],
"pairwise": [
{"a": "income", "b": "spend", "source_rho": 0.80, "synthetic_rho": 0.79, "delta": 0.01, "n": 20000}
],
"warnings": [
"thin numeric pair income x spend: only 12 supporting observation(s) (below 30) — reconstructed correlation may be unstable"
]
}
A field whose test could not run (e.g. a categorical column with only
one distinct value in one partition) gets "error" instead of
"result" — that single field’s failure never blocks the report for
every other field, nor the generated cohort itself.
pairwise reports, for every numeric-numeric correlation the profile
captured (--conditional’s Conditional.NumericPairs, or the plain
--include-correlations stats as a fallback — whichever
SpecFromProfile used to populate spec.Correlations), the delta
between that pair’s captured/target correlation (source_rho — the
same figure the copula reconstruction targeted) and its REALIZED
Pearson correlation over just the newly generated rows
(synthetic_rho); delta is abs(source_rho - synthetic_rho). A pair
whose synthetic partition cannot produce a defined correlation (fewer
than two co-occurring non-null observations) gets "error" instead of
synthetic_rho/delta, following the same non-fatal-per-entry
contract as fields. pairwise is entirely absent — never an empty
array — when the spec carried no correlations at all (e.g. neither
--conditional nor --include-correlations was passed at profile
time).
models — did generation reproduce the captured structure?
Every other section asks whether the generated rows look like the
source. models asks whether the captured structure survived
generation: for each linear model generation actually applied
(profile create --fit-models), it refits that same model on the
_synthetic partition and reports the captured coefficient beside the
recovered one, per predictor.
{
"models": [
{
"field": "spend",
"latent_scale": 25.0,
"captured_intercept": 0.0,
"recovered_intercept": 0.004,
"intercept_delta": 0.004,
"n_obs": 10000,
"r2": 0.61,
"max_delta": 0.031,
"predictors": [
{"kind": "categorical_level", "field": "region", "level": "east",
"captured_coefficient": 1.2, "recovered_coefficient": 1.187,
"std_error": 0.012, "delta": 0.013, "n_fired": 3341},
{"kind": "categorical_level", "field": "region", "level": "yukon",
"captured_coefficient": 1.76, "n_fired": 0,
"error": "predictor level is absent from the output cohort's dictionary, so no row can carry it"}
]
}
]
}
Both coefficients are on the latent scale — units of one standard
deviation of the target’s own reconstructed marginal, which is
latent_scale. That is not a presentation choice: a modelled numeric
is drawn as value = Q(Φ(μ + σz)), so its coefficients live on the
latent scale, and for a --fit-shape target the map from latent to
value is non-linear. Regressing the raw generated values would compare
a value-space effect against a latent coefficient and report a large
gap on a generation path that is exactly correct. Multiply by
latent_scale to return to the units profile create --fit-models
writes into the profile document.
flagged marks a gap large enough to indicate a real generation
fault: it fires only when delta exceeds both 0.10 latent standard
deviations and twice the refit’s own std_error. A gap smaller
than the estimator’s own noise cannot be evidence of anything.
n_fired — how many admitted synthetic rows the term’s indicator was
1 on — is what separates the two ways a recovered coefficient reaches
zero: a term that fired thousands of times and recovered nothing is a
fault; a term that never fired had nothing to recover.
A field a --rules rule determines has no entry here at all. The
section reports only the models generation applied, and an unconditional
set / set_expr retires its target’s model before generation runs —
see What a rule retires from the derived
spec. An absent field is
therefore an answer, not a gap; the conflict warning on stderr names it.
Reading a flag without concluding the feature is broken
Expect flags. On the 381,324-row survey cohort behind this feature, 53 models were applied, all 53 are comparable and 40 of them flag. That is not a generation failure.
A target whose Q is a step or a staircase — every packed_bool and
every small integer, which on a survey cohort is nearly all of them —
has no point latent inverse, so its entry carries
"scale": "probit_score" and was recovered through a calibrated score
rather than a direct inversion. That calibration is exact in
expectation, so a flag on such an entry is a statement about the rows,
not about the instrument. (Before that score existed the section
reported only 2 comparable targets of the same 53, and
model_residual_correlations only 1 pair; the instrument had stopped
covering the field types the feature mostly applies to.)
The rows do carry slightly less conditioning than the captured model
asked for, for two reasons that compose. Quantization attenuation: a
u4 field is stored rounded to a whole number and a packed_bool as 0
or 1, so a latent effect survives the write only as a change in how many
rows crossed a boundary. And the composed draw holds a target’s marginal
exactly only while the generated latent is standard normal — that holds
by construction at fit time, but at generation time the predictors
come from their own reconstructed marginals, so the linear predictor’s
variance is whatever that distribution gives (measured on aware, 0.045
against a captured R² of 0.078, with 9 of its 64 terms never firing on a
generated row). Both are real properties of the generated rows, not
artefacts of the instrument.
What a flag on a continuous, unrounded target means is different, and that is the case worth acting on. Separate the two:
| Look at | Quantization attenuation | A real generation fault |
|---|---|---|
| Target’s field type | packed_bool, u4, or another narrow integer ("scale": "probit_score") | f32/f64, or a wide integer (no scale key) |
n_fired | Healthy — thousands of rows | Healthy — thousands of rows (a zero here is neither case: nothing fired, so there was nothing to recover) |
recovered_coefficient | Same sign, systematically smaller | Near zero, or the wrong sign |
| Peers | Most models of the same target type flag alike | An outlier among similar targets |
n_fired is what stops the third row of that table being ambiguous. A
term that fired thousands of times and recovered nothing is a fault
worth reporting. A term that never fired had nothing to recover — the
level exists in the model but the field’s reconstructed marginal does
not generate it, which is the absence case described above, and those
terms carry an error rather than a delta.
A quick triage over a report:
# how many models flagged, and of what target type
jq '[.models[] | select(.flagged)] | length' out.fidelity.json
jq -r '[.models[] | select(.flagged) | .field] | .[]' out.fidelity.json
# the terms actually driving the flags: fired a lot, recovered little
jq '[.models[].predictors[]?
| select(.flagged and .n_fired > 500)
| {field, level, captured_coefficient, recovered_coefficient, n_fired}]
| sort_by(.n_fired) | reverse | .[:10]' out.fidelity.json
For calibration on that same cohort: 2,499 predictor entries, 2,343
compared and 97 flagged; 156 unestimable, of which 143 are the
different-ranking-basis case described below, 7 constant columns and 6
terms whose captured effect falls below the staircase score’s own
resolution (those carry an error, never a divided-by-nothing number).
A ratio near zero on a wide numeric target with a healthy n_fired is
the signature that something in the generation path is not applying the
model, and is worth reporting.
On a "scale": "probit_score" entry each predictor also carries
score_retention — the fraction of the captured effect that survives
onto the score, which both the recovered coefficient and its
std_error have already been divided by. Read a small retention as a
wide confidence band, not as a suspect number. Measured across 2,193
calibrated terms on that cohort: minimum 0.075, median 0.456, upper
quartile 0.814. A staircase entry reports no
recovered_intercept: its cut points come from the captured marginal,
which generation holds exactly, so that intercept carries no evidence
about the coefficients.
Three absence rules, all deliberate:
- A field carrying no model has no entry here, rather than an entry with empty values.
- A captured model generation did not apply — one whose target lost a conflict, or which generation refused — likewise has no entry. The section asks generation’s own compiler which models ran; reporting a computed-looking delta for a relationship that was never applied is worse than reporting nothing, because the number looks like evidence.
- A predictor whose level the generated cohort never carries is listed
with an
errorand no delta, for the same reason.
That last case has one cause worth naming, because it looks like a bug
and is not. A model’s retained level set and the field’s own captured
marginal are both cut at --top-k, but they are ranked on different
bases: the model ranks levels by frequency within the rows its fit
admitted for that one target, while the marginal ranks them across the
whole cohort. Both keep 32 and they need not agree, so a level the model
retained can be absent from the marginal that generates the field — on
the survey cohort behind this feature, 9 of brand’s 32 design levels,
4 of ageExact’s and 2 of category’s, 138 such terms out of 2,235.
The "other" catch-all is not one of them: it is a design column
like any other, it is generated through the categorical-pair stage, and
on that cohort all 54 catch-all terms fired, between 3,992 and 9,046
rows each.
A model whose predictor selection admitted nothing carries
"marginal": true and no predictors array. That is a complete
model — the field’s own mean and spread — not a failure; its intercept
comparison is still a real check, since a drifted intercept there means
the marginal itself did not survive.
models is entirely absent when the profile carried no models
section, so a report for a profile captured without --fit-models is
unchanged.
models and the two numeric-target pair sections
(categorical_numeric_pairwise, set_numeric_pairwise) cover disjoint
sets of fields, and always will. A numeric reached by a linear model
draws from that model, so the pick-one conditional sampler those
sections score never runs for it and it gets no entry there — reporting
a delta for a mechanism that did not execute is the same mistake as
reporting one for a conflict-dropped pair. The retirement is per
target, not per profile: a numeric whose model was captured but not
applied keeps its conditional pair and is still scored in those
sections, because the alternative is a field reconstructed from nothing
at all. categorical_pairwise, set_categorical_pairwise and
set_set_pairwise are unaffected — a linear model targets a numeric and
subsumes nothing they describe.
model_residual_correlations — did the structure BETWEEN models survive?
models asks whether each field’s own captured structure survived.
This section asks whether the structure between modelled fields did:
for every residual correlation generation applied (profile create --residual-correlations), it reports the captured rho beside the rho
recovered from the generated partition.
{
"model_residual_correlations": {
"fields": ["spend", "tenure"],
"compared": 1,
"mean_delta": 0.012,
"max_delta": 0.012,
"pairs": [
{"a": "spend", "b": "tenure", "captured_rho": 0.70, "recovered_rho": 0.688, "delta": 0.012, "n": 10000}
]
}
}
This is not pairwise under another name. pairwise scores a
correlation between two fields’ values, realized by the copula;
this scores a correlation between two models’ residuals, realized by
the residual correlator. They are different numbers over the same two
fields — two fields both driven by region correlate strongly on raw
values while their residuals may be independent — and a modelled field
is excluded from the value-scale arm precisely so the two never both
fire on one field. The section names are how a reader tells which
mechanism a given number describes.
The residuals compared are the refit’s own, on the latent scale.
Capture measured the residuals of a model fitted on its own rows, so
recovery measures the residuals of a model fitted on the synthetic rows
— the symmetric definition. Taking them against the captured
coefficients instead would fold the coefficient gaps models already
reports into a correlation and state one finding twice.
The listing is bounded, because the section is quadratic in
participants: 55 applied models is 1,485 pairs. compared, flagged,
mean_delta and max_delta describe every compared pair; pairs
carries the worst 20 by absolute delta, largest first, and omitted
counts the rest. A pair that did not make the listing recovered at
least as well as the last one that did. flagged fires at a delta
above 0.10 — a correlation is already unit-free, so unlike the
coefficient band it needs no standard-error term.
A pair generation applied but the synthetic partition cannot produce a
correlation for is listed under unmeasured with a reason
(insufficient_overlap, no_variance, or no_model_fit when one
endpoint’s model could not be refitted). Those entries carry
captured_rho — so a reader can see how much structure went unverified
— and no recovered-rho key at all, so an unmeasured pair can never
be mistaken for one recovered at zero.
A pair with a packed_bool or small-integer endpoint is always
no_model_fit, and that is deliberate. The models section can
calibrate a staircase target’s coefficient because least squares is
linear in its response, so the same score projected twice divides the
attenuation out exactly. A correlation has no such projection: the
factor relating two score residuals’ correlation to the correlation of
the residuals that drove the draws depends on the pair’s joint
distribution, not on either marginal. Shipping the attenuated figure
would put a number that looks measured beside one that is, so it is
counted as a gap instead — on the survey cohort 1 compared pair and
1,377 unmeasured. Closing it needs a polychoric-style bivariate
calibration or an ordered-probit refit.
For calibration again: on a cohort whose modelled targets are all
continuous, 55 applied models produce 1,485 compared pairs, of which
147 flag, at a mean delta of 0.055 and a worst of 0.490. On the
survey cohort, whose targets are 51 of 53 staircase, the same section
compares 1 pair and reports 1,377 as no_model_fit — see the paragraph
above.
Because a modelled field is deliberately excluded from the value-scale
pairwise arm, this section is the only place the report scores
structure between two modelled fields. If you want the raw-value
correlation between them as well, compute it over the output cohort’s
_synthetic partition yourself — on the survey cohort a source pair at
0.835 realizes at 0.791 that way, which is the residual mechanism
working as intended.
model_residual_correlations is entirely absent when the profile
carried no residual_correlations section, so a report for a profile
captured without that flag is unchanged.
warnings mirrors any thin-pair warnings the source profile carried
(Profile.Warnings — see pulse profile create’s --conditional section) verbatim,
letting a reader see “how well did it match” (pairwise) and “which
parts were built on thin data” (warnings) in the one document.
Absent, not an empty array, when the profile carried no warnings.
What the report costs
The report is a footnote to generation, but on a wide cohort it is not a
small file and it is not instant. Every model is refitted, so the work
scales with the number of applied models and the width of their designs,
and model_residual_correlations is quadratic in applied models. On the
survey cohort — 105 captured models, 55 applied, designs up to 126
columns — the report is about 1.65 MB and adds a few seconds to a
run that takes roughly two minutes in total. The refits are capped
at the first 10,000 synthetic rows regardless of --rows, because the
captured coefficients they are compared against were themselves
estimated from a 10,000-row sample and sharpening one side of a
comparison does not sharpen the comparison.
Budget for it in a loop, and skip the flag when you are only iterating
on --seed.
Omitting --fidelity-report writes no report at all and changes no
other behavior. The flag only has an effect on the tagged top-up path
(--source set); it is a no-op on plain synth from-schema (see
pulse synth from-schema), which has no
_synthetic partition to compare against.
Profile shape
The profile is a synth.Profile JSON object produced by
pulse profile create. It carries per-field type, descriptive
statistics, top-K categorical entries (default K = 32), optional
pairwise correlations (when --include-correlations was passed at
profile-creation time), an optional row-aligned conditional section
(when --conditional was passed — preferred over the plain pairwise
stats when both are present, since its n is the true co-occurrence
count rather than an approximation), an optional per-numeric-field
shape (when --fit-shape was passed AND that field’s fit was a
genuine improvement over normal — see
pulse profile create’s --fit-shape section),
an optional per-numeric-field linear model (models, when
--fit-models was passed) with an optional correlation submatrix over
those models’ residuals (residual_correlations, when
--residual-correlations was), and a row count. A field carrying
shape regenerates via the mixture distribution instead of normal,
and a field carrying a model is drawn through that model with its own
marginal as the shape — no separate flag here selects either; it is
decided entirely by the profile document’s own contents.
See pulse profile create for how to capture
one, and the synth package for the underlying Go types.
Output
Text mode
Generated 1000 rows -> sales.synth.pulse (rejected 0)
Fidelity report -> sales.fidelity.json
Those lines are the whole of stdout, so a redirect stays exactly the bytes it was.
Warning summary and recovery headline (stderr)
Diagnostics go to stderr, in the same grouped, counted, capped shape
profile create uses — kinds
needing attention first and marked !, expected outcomes counted
separately and marked -, three examples per kind. With
--fidelity-report, two headline lines precede it:
Model recovery: 55 model(s) checked, 33 flagged — see sales.fidelity.json (.models)
Residual recovery: 1485 pair(s) compared, 154 flagged — see sales.fidelity.json (.model_residual_correlations)
Warnings: 3846 in 6 kind(s) — 1 needing attention, 3845 expected
! residual correlation pairs unmeasured (1)
residual correlations: 104 of 5460 pair(s) among 105 modelled field(s) could not be measured …
- thin categorical pair (2931)
…
+2928 more of this kind
- conditional relationship conflict (791)
conditional relationship conflict: field "dma" is already claimed by categorical pair (wave -> dma); …
…
+788 more of this kind
…
Full list: sales.fidelity.json (.warnings)
The recovery lines exist because the sections they summarise live inside
a document that is 1.65 MB on the motivating cohort — a reader has to
learn that flags are there before deciding to open it. Both lines are
omitted when the report carries no such section, which is every spec
predating profile create --fit-models.
The summary spans all three warning channels this command touches, because each carries findings the others do not:
| Channel | Produced by | Also written to |
|---|---|---|
| Capture-time | profile create, read back off the document | the profile document’s warnings |
| Translation | SpecFromProfile — model drops and conditional conflicts | --fidelity-report’s warnings |
| Generation | generate() — the post---rules arbitration, rule compilation, rules that never fired, correlation completion, model compilation | --json’s data.warnings and --fidelity-report’s warnings |
The middle channel is the one that carries model for numeric field "x" not applied: …, and until the summary existed it reached no terminal at
all: a spec that silently applied a fraction of its captured models
generated a plausible cohort and printed Generated 50000 rows.
Translation and generation both derive their conflict lines from the same arbitration, so byte-identical duplicates are dropped before counting — 791 conflicts are reported as 791, not 1,582.
The generation channel reaches the report as well as the terminal,
and that is what makes the summary’s Full list: footer true. Until it
did, the report held the two channels computed before the run, so a
--rules run’s post-merge arbitration — precisely the record of which
captured relationships the rules retired — existed only on a terminal
nobody keeps. Measured on the 381,324-row profile with
{"set_expr": {"promoter": "nps >= 9"}} plus one never-firing rule: the
report went from 3,846 warnings that mentioned neither fact to 3,849
carrying the retired model, the dropped residual participant, and the
dead rule. The overlap with the translation channel is deduped at the
fold, so 791 shared lines are written once.
Without --fidelity-report no document holds the merged list, so the
footer names the flag that would produce one rather than a path that
does not exist.
--json
Same envelope shape as
synth from-schema. The stderr summary is
not printed on this path — data.warnings already carries the
generation channel, and the report carries every channel. The envelope
shape is unchanged: warnings is the same []string slot it has always
been.
Exit codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Profile parse error, infeasible constraints, missing/colliding --source/--output, or output write failure |
See pulse errors lookup PULSE_SYNTH_SOURCE_REQUIRED /
PULSE_SYNTH_OUTPUT_REQUIRED / PULSE_SYNTH_OUTPUT_COLLISION /
PULSE_SYNTH_ALREADY_TAGGED / PULSE_SYNTH_PROFILE_SCHEMA_MISMATCH
for the specific top-up refusal codes.
Examples
# Capture once
pulse profile create --input sales.pulse --output sales.profile.json
# Top up with different seeds — sales.pulse is read but never written
pulse synth from-profile --profile sales.profile.json --source sales.pulse --output sales.s42.pulse --rows 10000 --seed 42
pulse synth from-profile --profile sales.profile.json --source sales.pulse --output sales.s43.pulse --rows 10000 --seed 43
# Also write a per-field marginal fidelity report
pulse synth from-profile --profile sales.profile.json --source sales.pulse --output sales.s42.pulse --rows 10000 --seed 42 --fidelity-report sales.s42.fidelity.json
Generating a numeric conditioned by several categoricals
The companion to pulse profile create’s
worked example. Nothing here opts in — whether spend draws from a
model is decided entirely by whether the profile carries one for it.
pulse profile create --input survey.pulse --output survey.profile.json \
--include-stats --conditional --fit-models --residual-correlations
pulse synth from-profile --profile survey.profile.json --source survey.pulse \
--output survey.synth.pulse --rows 50000 --seed 7 \
--fidelity-report survey.fidelity.json
Check that the model was applied at all — a captured model that
generation did not run has no entry in models, so presence is the
check:
jq '.models[] | select(.field == "spend")
| {latent_scale, n_obs, r2, flagged,
fields: [.predictors[].field] | unique}' survey.fidelity.json
{
"latent_scale": 412.6,
"n_obs": 10000,
"r2": 0.29,
"flagged": null,
"fields": ["region", "segment", "tier"]
}
flagged is omitted when nothing exceeded the band, which is the null
the projection shows. All three conditioning fields are present and the
model did not flag, so
the generated rows carry region, segment and tier effects
simultaneously — which the pick-one conditional sampler could not do.
Because spend is modelled it has no categorical_numeric_pairwise
entry, by design:
jq '[.categorical_numeric_pairwise[]? | select(.b == "spend")] | length' \
survey.fidelity.json # 0
And because the model expresses all three relationships at once, none of
them had to lose a conflict on the way in. Generation-time warnings —
the conflicts, and any model the spec could not apply — land in this
report’s warnings array, after the capture-time ones the profile
carried:
jq -r '.warnings[] | select(startswith("conditional relationship conflict"))' \
survey.fidelity.json # empty for the numeric arms
jq -r '.warnings[] | select(contains("not applied"))' survey.fidelity.json
A not applied line is the one to read closely: that field fell back to
its --conditional pair, so it is still conditioned, but by one
categorical rather than by all of them. It is also one of the kinds the
stderr summary marks ! and lists first, so it does not need finding.
Limitations
-
Categorical tails: anything past the captured top-K is dropped, not preserved as an
"other"bucket — the retained levels’ weights are renormalised, so the tail’s share is redistributed across them. A 1,900-level field therefore generates as--top-klevels. (The"other"catch-all that appears in a--fit-modelsdesign and inconditional.*tables is a different, per-section collapse and is generated normally.) -
Correlations: pairwise only, and only between SCALAR fields — every declarable type that is not
categorical_*orset_*, sou4…u64,f32/f64,date,decimal128andpacked_boolall qualify. The profile capture flag--include-correlations(or the more accurate--conditional) opts in; without either, fields are generated independently. Reconstruction uses a conditional-Gaussian construction (internal/synth/copula.go) that exactly targets the captured Pearsonrhofor jointly-normal fields — seeskills/synth-correlations.mdfor the technique and its trade-offs. A small-integer or boolean participant reconstructs as a staircase (discrete/bernoulli), which holds its own per-level shares exactly and attenuates the realised correlation: measured on two real 7-levelu4columns at a capturedrhoof +0.8400, Pearson came back +0.8072 and Spearman +0.8051 with both marginals within 0.003 per level. A participant already claimed by an earlier stage — a linear model, or a categorical-numeric conditional pair — is not correlated at all, and says so in the generation warnings. -
Boolean (
packed_bool) fields: reconstructed asbernoulliwithp= the captured mean, which holds the prevalence exactly. Two consequences. A modelled boolean is drawn as a probit, so its coefficients order rows but are not probability changes; and a 0/1 value pins the latent to an interval rather than to a point, so themodelssection of a fidelity report recovers it through a calibrated probit score (markedscale: "probit_score") instead of a direct inverse. A boolean observed at prevalence exactly 0 or 1 has no variance, so a model on it is dropped with a warning. A hand-authored schema-mode spec that puts a continuous distribution on apacked_boolis still biased (the writer rounds at 0.5); declarebernoullithere instead.One caveat worth knowing when you compare a generated prevalence to the captured one: a MODELLED boolean holds its marginal exactly only while the generated latent is standard normal. The residual scale on the wire is a FIT-time quantity, and at generation the predictors come from their own reconstructed marginals, so the latent’s variance is whatever that distribution gives. On the survey cohort
awarewas captured atp= 0.7473906704010238 and generated withP(aware == 0)= 0.247193 over 20 seeds against the exact 0.252609 — a −0.0054 drift. It is the composed draw, not the sampler: a lonebernoullifield over 200 seeds is exact to +0.000046, and the same spec with every model removed is exact to +0.000324. A rule-gated field inherits the drift from the field it gates on. -
Small-integer (
u4/u8/u16/u32/u64) fields: reconstructed from the captured per-level histogram asdiscretewhen the column carried at most 64 distinct values, which holds every level’s share exactly; wider columns keep the clamped normal (seeprofile createfor the cap and why it abandons rather than truncates). The consequences mirror the boolean arm above. A modelled integer target is drawn as an ordered probit, so its coefficients order rows but are not scale points; and like a boolean it is recovered through the calibrated probit score rather than a direct latent inverse, because a level pins the latent to an interval. On the survey cohort themodelssection reports 53 comparable targets of 53 entries (it reported 2 before that score existed) and 2,343 of 2,499 predictor terms compared.model_residual_correlationsstill reports 1 compared pair with 1,377 unmeasured: a coefficient can be calibrated because least squares is linear in its response, a correlation cannot, so a staircase endpoint stays in the unmeasured list underno_model_fitrather than shipping an attenuated rho that looks measured. -
Decimal and geo fields: regenerated within the same type family but with synthetic value distributions; downstream uses that depend on exact field values (e.g. joinable identifiers) need the schema-driven path instead.
-
Shape fitting (
--fit-shapeat profile-creation time): fixed at exactly 2 mixture components, and no min/max clamp on the field’s own independent draw. A field carrying a capturedshapedoes not participate in--conditional’s categorical-numeric conditioning even when both were captured for it; it does compose with--fit-models, drawing through its captured linear model with the fitted mixture as the marginal — with the caveat that such a model’s coefficients are on the latent scale and are not readable in data units. Seepulse profile create’s--fit-shapesection.
Related
pulse profile createpulse synth from-schemaskills/synthetic-data.md— the spec grammar and the index of focusedsynth-*skillsskills/synth-fidelity-report.md— the tagged top-up contract and the fidelity sectionsskills/synth-models.md—--fit-modelsentry skill;skills/synth-residual-correlations.md,skills/synth-model-recovery.mdandskills/synth-residual-recovery.mdcarry residual correlations and the two structure-recovery fidelity sections- Synth calibration figures and design rationale
pulse profile create
Audience: CLI users capturing a statistical profile of an
existing cohort — typically to feed into
pulse synth from-profile.
pulse profile create reads a .pulse file — a single-file cohort
or a whole shard archive, whose shards are profiled as one stream in
archive order against the canonical schema — and writes a JSON
profile: per-field type, descriptive statistics, top-K categorical
entries, optional pairwise correlations. The profile retains no
individual rows from the source.
No MCP tool. Profile capture is CLI (
pulse profile create) and library (Pulse.Profile) only; see thesynthetic-dataskill.
Synopsis
pulse profile create --input PATH --output PATH
[--top-k N] [--include-stats]
[--include-correlations] [--correlation-top-k N]
[--conditional] [--fit-shape] [--fit-models]
[--residual-correlations] [--suggest-rules PATH]
[--run-continuation]
[--sample-limit N] [--seed N] [--json]
Flags
| Flag | Alias | Type | Default | Purpose |
|---|---|---|---|---|
--input | -i | string | (required) | Source .pulse cohort |
--output | -o | string | (required) | Output profile JSON path |
--top-k | int | 32 | Top-K categorical entries to retain per field | |
--include-stats | bool | true | Include percentile / std stats | |
--include-correlations | bool | false | Capture pairwise numeric correlations | |
--correlation-top-k | int | 16 | Cap on retained correlation pairs | |
--conditional | bool | false | Capture row-aligned numeric-numeric pair structure (conditional.numeric_pairs), categorical-categorical contingency tables (conditional.categorical_pairs), categorical-numeric conditional means (conditional.categorical_numeric_pairs), and — per option of any set_* field — the same three pair kinds against categorical/numeric/other-set fields (conditional.set_categorical_pairs, conditional.set_numeric_pairs, conditional.set_set_pairs) | |
--fit-shape | bool | false | Fit a 2-component Gaussian mixture per numeric field, kept as numeric.shape only when it’s a genuine improvement over plain normal (BIC) | |
--fit-models | bool | false | Fit one linear model per numeric field — the field regressed on the categorical levels and set options automatic predictor selection admits — capturing coefficients, residual scale and fitted residuals (see below) | |
--residual-correlations | bool | false | Capture the full correlation submatrix among --fit-models’ fitted residuals (residual_correlations); every pair, measured or explicitly unmeasured — never a top-K sample and never a fabricated zero (see below). Requires --fit-models | |
--suggest-rules | string | (off) | Detect structural rules on the same scan — GATING relationships (set_null) and CO-MISSING question blocks (null_together) — and write them to this path as a standalone rules file, the bare JSON array synth from-profile --rules consumes unmodified. PROPOSED, never applied; the profile document itself gains no section (see below) | |
--run-continuation | bool | false | Measure per-field run-continuation — the fraction of adjacent row pairs whose on-wire bytes and null bit repeat, i.e. the run-skip decode’s hit rate — into the additive run_continuation section, with the high-continuation fields and advice on sorting the source upstream (see below) | |
--sample-limit | int | 0 (unlimited) | Cap rows ingested for the profile (0 disables) | |
--seed | int | 0 | Deterministic RNG seed for --conditional‘s categorical-categorical reservoir sampling and --fit-models’ residual reservoir (see below); each draws from its own stream, so the two flags never perturb each other, and the same (--input, --seed) produces byte-identical captured output | |
--json | bool | false | Also print the envelope to stdout |
What the profile captures
| Field type | What is recorded |
|---|---|
Numeric (u*, f*, decimal128) | Count, min, max, mean, stddev; percentiles if --include-stats |
Integer (u4, u8, u16, u32, u64) | The same numeric summary, plus an exact per-level histogram (numeric.discrete) when the column carries at most 64 distinct values — and reconstructed from that histogram, not as a clamped normal. See “Small-integer fields” below |
packed_bool | The same numeric summary (a boolean falls to the numeric accumulator), but reconstructed as bernoulli, not as a clamped normal — see “Boolean fields” below |
| Categorical | Cardinality, plus the top-K most-frequent values with their observed weights. The tail below the cut is not retained as an "other" bucket — synth from-profile renormalises the retained weights, so a 1,900-level field regenerates as --top-k levels and the tail’s share is redistributed across them. (The "other" spelling that does appear in conditional.* tables and in --fit-models designs is a different, per-section collapse.) |
date | Min, max, count |
Any nullable field (Field.Nullable, any type) | Null count alongside the above. Nullability is a per-field flag, not a type — there are no nullable_* types |
Boolean fields
A packed_bool is summarised by the numeric accumulator — it is neither
date, categorical nor set — so its profile entry looks exactly like any
other numeric’s: mean, std, min: 0, max: 1. The obvious reading of
that entry is normal(mean, std) clamped to [0, 1], and it is wrong on
the wire.
The field holds one bit. Generation therefore has to reduce a
continuous draw to 0 or 1, and no threshold over a clamped normal lands in
the right place — a clamped normal is exactly 0 only where the underlying
draw fell below zero, so the historical value != 0 threshold gave
P(false) = Φ(−p/σ):
| Source prevalence | Generated (before) |
|---|---|
| 0.20 | 0.691 |
| 0.50 | 0.842 |
| 0.80 | 0.977 |
On a real 381,324-row survey cohort with 90 packed_bool fields, the mean
prevalence error was 0.47 and 89 of 90 fields were off by more than
0.05 — every 11–14% brand-attribute item generated at around 64%, which
turns a rare attribute into the majority answer. The marginals looked
plausible in isolation; nothing compared them to the source.
synth from-profile now reconstructs a packed_bool as bernoulli
with p = the observed mean. No threshold is involved, so the prevalence
is exact by construction (same cohort after the fix: mean error 0.0032,
max 0.0116, nothing off by more than 0.05). Two details follow from it:
- The boolean arm outranks
--fit-shape. A two-component mixture fits a 0/1 column very well by BIC — two near-zero-variance spikes — and reproduces the prevalence no better while costing a numeric inversion per draw. A boolean’s shape is one number, not something to discover. - A modelled boolean is a probit.
--fit-modelson a boolean target still works, and its coefficients still order rows, but the composed draw isP(1 | row) = Φ((μ − Φ⁻¹(1−p)) / σ). A coefficient is not a change in probability. See “Reading a coefficient” below; the fidelity report reports these targets as unidentified rather than fabricating a recovered figure, because a 0/1 value does not determine the latent that produced it.
Small-integer fields
Same defect class as “Boolean fields” above, one type wider, and it is the
one that bites a survey cohort hardest: a u4/u8/u16/u32/u64
column falls to the numeric accumulator too, so its entry is mean, std,
min, max — and the writer stores floor(v + 0.5), so a clamped-normal
reconstruction is quantized on the way to the file. What comes back is a
bell where the source had a U, a J or a spike, and the mean is roughly
right, which is why it went unnoticed.
Measured on the same 381,324-row survey cohort, generating 40,000 rows:
| Field | Level | Source share | Generated (before) |
|---|---|---|---|
familiarity (u4, 1–7) | 1 | 0.2526 | 0.1373 |
familiarity | 7 | 0.2198 | 0.1323 |
nps (u4, 0–10) | 10 | 0.3200 | 0.2253 |
nps | 0 | 0.0284 | 0.0020 |
useCon (u4, 1–6) | 6 | 0.3143 | 0.1806 |
sow (u16, 0–15) | 0 | 0.6469 | 0.3804 |
sow‘s levels 10 and 12–15 were never generated at all. Every one of those
fields’ means came back within about 3%.
The capture therefore records the exact per-level histogram on
numeric.discrete — one {value, count, frequency} entry per observed
level, in ascending order — and synth from-profile reconstructs the field
as the discrete distribution from it. A level’s observed count is its
weight, so no threshold is involved and the marginal is exact by
construction. Same cohort after the fix: familiarity level 1 at 0.2493,
nps level 10 at 0.3310 and level 0 at 0.0269, useCon level 6 at 0.3154,
sow level 0 at 0.6460, every level present.
"nps": {
"min": 0, "max": 10, "mean": 7.5489, "std": 2.6561,
"discrete": {
"levels": [
{"value": 0, "count": 1883, "frequency": 0.02838},
{"value": 1, "count": 1176, "frequency": 0.01772}
]
}
}
Four details follow from it.
- Above 64 distinct levels the histogram is ABANDONED, not truncated.
A top-64 view of a 3,000-level column would make every share a share of
an arbitrary subset, so such a column keeps the clamped normal instead.
The absence of the
discretekey on an integer field is the record that this happened — there is no warning and no flag, because presence or absence of the key is the answer to “which reconstruction will this field get”. On the motivating cohort 12 of 14 integer columns qualified;respondent(a u64 ID) andcatSpend(a u32 currency amount) abandoned. The cap is a package constant, not a flag, matching every other tuned threshold insynth. - The boundary is pragmatic, not a fidelity cliff. A clamped normal’s per-level relative error does not shrink as levels are added — its central levels come out around 1.4× their true share at any number of levels. What shrinks is the absolute error (roughly 1/K). So a column just over the cap is wrong by under about 1.5 percentage points per level while one just under it can be wrong by 12.
- The integer arm outranks
--fit-shape, for the same reason the boolean arm does: a mixture fits a 7-level column well by BIC and reproduces the per-level shares no better, while costing a numeric inversion per draw. A coded scale’s shape is its histogram. - A modelled integer target is an ordered probit.
--fit-modelson one still works and its coefficients still order rows, but the staircaseQmeans they shift the latent, not the value: a coefficient is not “this many points on the scale”. The fidelity report reports these targets as unidentified rather than fabricating a recovered figure, because a level pins the latent to an interval rather than a point.
The capture is unconditional — there is no flag — because this is the
default reconstruction being wrong rather than an enhancement. It rides the
existing single scan (one bounded map per eligible column) and adds one
omitempty key: a document captured before the key existed still
reconstructs exactly as it did, through the clamped normal.
What the profile does NOT capture
- Individual rows.
- The full categorical dictionary beyond
--top-k. - Correlations unless
--include-correlationsor--conditionalis set.
This is by design — profiles are intended to be safe to share with parties who shouldn’t see the underlying data.
--conditional: row-aligned pair reconstruction
--include-correlations computes each numeric field’s Pairwise entry
from independently-capped per-field reservoirs; once any field carries
nulls those reservoirs can drift out of row alignment, and the reported
observation count is only an approximation of true co-occurrence.
--conditional instead keeps a row-aligned joint snapshot (capped at
10,000 rows) and writes a conditional.numeric_pairs section: each
entry’s rho and n are computed only from rows where BOTH fields were
simultaneously non-null. synth.SpecFromProfile prefers
conditional.numeric_pairs over pairwise when both are present, and
internal/synth/copula.go’s conditional-Gaussian construction (the mechanism
that actually reconstructs the requested correlation, replacing the old
approximate ±5%·std blend) consumes either shape identically. A pair
whose n falls below 30 (internal/synth.MinPairObservations) is still
shipped — never refused — but appends a warning to warnings naming the
pair. The section is entirely absent (not empty) when --conditional
is not passed, and profiles captured before this flag existed remain
valid synth from-profile input.
--conditional: categorical-categorical contingency capture
--conditional also builds a bounded contingency table for every
categorical-categorical field pair, written to
conditional.categorical_pairs: each entry is {a, b, cells, n}, where
cells is a list of {a_value, b_value, count} co-occurrences and n
is the pair’s true co-occurrence count (rows where both fields were
simultaneously non-null), capped at 10,000 captured rows the same way
conditional.numeric_pairs’ row-aligned snapshot is. A source cohort
over that cap is captured via genuine Algorithm R reservoir sampling
(--seed, above) rather than the first 10,000 rows, so a cohort sorted
or otherwise ordered by block (e.g. by region) does not bias the
captured table toward whichever block was read first. Two caps compose
to keep the table bounded regardless of either field’s raw cardinality:
each field’s own
--top-k cap (default 32) first collapses any value outside that
field’s top-K into "other", then internal/synth.ContingencyCellCap (128)
bounds the resulting joint table itself — a pair of two 50+-category
fields can still produce over a thousand joint combinations after the
per-field collapse, and the joint cap is what keeps that bounded. When
the raw cell count exceeds the cap, the top ContingencyCellCap - 1
cells by co-occurrence count are kept and everything else — the ranked
tail plus any pre-existing ("other","other") cell from the per-field
collapse — folds into one merged ("other","other") catch-all cell,
never a second competing entry, so the table always saturates to
exactly the cap (never more, and never silently fewer) whenever the
raw cardinality exceeds it. A cell whose count falls below 30
(internal/synth.MinPairObservations) still ships — never dropped — but appends
the same thin-pair warning numeric pairs use, to warnings. This story
only captures the contingency table; synth from-profile does not yet
sample from it (a later addition).
--conditional: categorical-numeric conditional-mean capture
--conditional also captures, for every categorical-numeric field
pair, the numeric field’s conditional mean/std broken out per observed
category of the categorical field, written to
conditional.categorical_numeric_pairs: each entry is {a, b, categories, n}, where a is the categorical field, b the numeric
field, categories a list of {category, mean, std, n} per-category
summaries, and n the pair’s true co-occurrence count (rows where both
fields were simultaneously non-null) summed across every category.
Unlike conditional.numeric_pairs and conditional.categorical_pairs,
this section is computed online from an exact running sum /
sum-of-squares per category as the cohort streams past — no row-aligned
10,000-row reservoir snapshot and no dependency on --seed, because a
conditional mean/std needs one pass of running statistics, not a stored
sample.
Only one cap applies here, unlike categorical-categorical’s two:
the categorical field’s own --top-k (default 32) collapses any value
outside its top-K into "other" before that category’s conditional
mean/std is computed. There is no second, joint-cardinality cap the way
conditional.categorical_pairs layers internal/synth.ContingencyCellCap on
top of the per-field top-K — this section emits one numeric summary
per already-capped category rather than a joint table over two
categorical axes, so the retained categories count is already
bounded by --top-k (plus one "other" bucket) with nothing further
to saturate.
A category whose n falls below 30 (internal/synth.MinPairObservations)
still ships — never dropped — but appends the same thin-pair warning
the other two pair kinds use, to warnings. The section is entirely
absent (not empty) when --conditional is not passed.
synth.SpecFromProfile consumes it into Spec.CategoricalNumericPairs;
at generation time, numeric field B is resampled from Normal(mean, std) parameterized by categorical field A’s already-drawn value,
clamped to B’s own observed [min, max] exactly as B’s unconditional
normal reconstruction clamps.
--conditional: set_* pair capture (set-categorical / set-numeric / set-set)
Every set_* field is profiled marginally as N independent Bernoulli
sub-fields, one per dictionary option (bit position) — see
skills/synth-set-fields.md (“Set (multi-select) field profiling”).
--conditional extends the pair-reconstruction sections above to any
pair involving a set_* field by running the SAME machinery once PER
OPTION rather than once per field:
conditional.set_categorical_pairs: one{set, option, categorical, cells, n}entry per (option, categorical field) combination —cellsis the same{a_value, b_value, count}shape asconditional.categorical_pairs, witha_valuealways"selected"/"not_selected"andb_valuethe categorical field’s value (subject to that field’s own--top-kcollapse). The sameContingencyCellCap(128) bounds each option’s cell table.conditional.set_numeric_pairs: one{set, option, numeric, categories, n}entry per (option, numeric field) combination —categoriesholds the numeric field’s conditional mean/std for"selected"and"not_selected"rows, the same shapeconditional.categorical_numeric_pairsuses per category.conditional.set_set_pairs: one{set_a, option_a, set_b, option_b, cells, n}entry per option pair between two DIFFERENTset_*fields — a bounded 2x2 contingency table. Never captured between two options of the same field.
Cardinality stays bounded without a new, separate cap: a set field’s
option count is capped by its own type (8/16/32/64/128/256 for
set_u8/u16/u32/u64/u128/u256), and ContingencyCellCap
still bounds every individual cell table exactly as it does for two
plain categorical fields. Thin combinations (n below
internal/synth.MinPairObservations, 30) still ship — never dropped —
appending the same warning shape every other pair kind uses. synth from-profile does not yet sample from any of these three sections — a
later addition.
--fit-shape: mixture-of-normals shape fitting for numeric fields
By default every numeric field is summarized as a single normal
(mean, std, min, max) regardless of its actual shape — a
strongly bimodal or skewed source distribution collapses to that one
bell curve. --fit-shape attempts, for every numeric field, a
2-component Gaussian mixture fit (reusing the mixture distribution
registered by synth.DistMixture) and keeps it — written as
numeric.shape: {means, stds, weights} — only when it is a genuine
improvement over the plain normal, decided by comparing BIC
(Bayesian Information Criterion) between the two models: a 2-component
mixture can always fit the training sample at least as well as a single
normal in raw likelihood terms, so BIC’s extra-parameter penalty
(3*log(n) more free parameters) is what keeps a field that is already
close to normal from being force-fit into a needlessly complex shape. A
second guard rejects a fit whose two components are not meaningfully
separated (at least 0.75 combined-std apart) even when BIC alone would
accept it, since GMM likelihood surfaces have spurious local optima
near the single-component case.
numeric.shape is additive and omitempty: absent from every profile
document captured without --fit-shape, and absent for any individual
numeric field whose fit was not kept — synth.SpecFromProfile falls
back to the ordinary normal reconstruction for that field either way.
No new flag is needed at synth from-profile time — whether a field
regenerates from normal or mixture is decided entirely by whether
the profile document carries a captured shape for it.
Limitations (see internal/synth/shape.go for the full algorithm and its
documented trade-offs): fixed at exactly 2 components (no component-
count search); a single deterministic EM run per field, not
multi-start, so a genuinely trimodal source fits a 2-component
approximation rather than an optimal one. A captured shape does compose with --fit-models: a
shape-fitted numeric that lands a linear model draws through that
model with its fitted mixture as the marginal, keeping both halves.
See “--fit-shape and --fit-models compose” below.
A field’s captured shape and --conditional’s categorical-numeric
conditional structure (or a numeric-numeric correlation) for that same
field still do not compose, but the conflict is not resolved silently:
at synth from-profile generation time a --fit-shape-captured field
that carries no model is pre-claimed under
"captured shape (--fit-shape)" before any conditional-pairing or
correlation stage runs (synth.resolveConflicts,
internal/synth/conflict.go), so the shape fit wins that field — but the
excluded relationship (the dropped categorical-numeric pair, or that
field’s exclusion from a correlation whose other participants still
correlate) is reported as one warning naming both the field and which
relationship lost. That warning lands
on synth.Result.Warnings (data.warnings under --json) for every
synth from-schema / synth from-profile run, and additionally folds
into --fidelity-report’s own warnings array for a synth from-profile run, alongside profile create’s own capture-time
thin-pair warnings. The same priority-ordered claim mechanism resolves
every other conditional-relationship collision too (e.g. two
categorical-numeric pairs both naming the same numeric field) — a
shape fit is simply a high-priority claimant, not a special case. The
one claimant above it is a linear model, which does not displace the
shape fit but composes with it.
--fit-models: per-numeric linear models
By default a numeric field’s relationship to the cohort’s categorical
structure is captured one pair at a time — --conditional records a
separate conditional mean per category, per categorical field — and
generation applies those pairs by overwriting the drawn value once per
claimed pair. --fit-models captures the relationship as a single
additive linear model instead: for each numeric field, one
least-squares fit of that field on the cohort’s categorical levels
and set_* options, so region, tier and a multi-select all
contribute to the same prediction rather than competing to be the last
writer.
The cohort is read once. What the scan itself does is retain a
bounded, uniformly sampled row snapshot; the predictors are then
selected, the models fitted and the residuals computed over that
snapshot after the scan finishes. That ordering is not an optimisation —
both halves of predictor selection are measured, and neither a level
frequency ranking nor a variance share exists before any row has been
read. One visible consequence: n_obs reports the fit’s own support
within the snapshot, so on a cohort larger than the snapshot it is
smaller than row_count.
Which predictors enter
Selection is fully automatic. There is no way to declare, override or weight a predictor, and no threshold flag.
Levels are collapsed by --top-k. A categorical contributes one
column per top---top-k level by frequency, plus a single catch-all
column labelled "other" standing for everything below the cut — the
same collapse --top-k already applies to captured marginals and
contingency tables. This is what keeps the flag usable at all: a
survey cohort with a 1,900-level brand column expands to thousands of
design columns unbounded, past any sane width limit, and every numeric
field would be skipped. It is also more honest — 1,900 levels cannot
support 1,900 free coefficients, however many rows there are.
Fields are admitted by variance explained. A candidate categorical
or set_* field enters a numeric field’s model only if it accounts for
at least 1% of that field’s variance, adjusted for the degrees of
freedom its groups spend. The criterion is deliberately not
statistical significance: on a large cohort every candidate is
significant, including ones explaining a thousandth of the variance, so
a significance test would admit everything while looking selective. An
absolute floor decides the same way regardless of how many rows the
cohort has.
Two consequences worth knowing. A field like wave on a single-wave
cohort explains nothing by construction and is dropped by the same
arithmetic that drops a real but negligible predictor — no special case.
And two overlapping candidates (age is a banding of ageExact;
region is nested inside dma) are scored independently, so both
enter. Sorting out the redundancy is the fit’s job, not the selection
rule’s: when the solver refuses the design as rank-deficient, the model
is refitted with its weakest-scoring predictor dropped — up to four
times — so the candidate that survives a nesting is the one that
explains more of the target. That narrowing applies to unpenalized
designs only: a ridge penalty makes a collinear design solvable, so a
model that carries a thin level (below) keeps both overlapping
candidates and shrinks them together instead.
Each admitted categorical contributes one column per retained level
except one reference level, whose effect is absorbed into the
model’s intercept; every other coefficient is read relative to it. A
full level set alongside an intercept is rank-deficient by construction,
which is why one has to go. The "other" catch-all is never chosen as
the reference — a baseline meaning “one of the remaining 1,874 brands”
is not something a reader can interpret. set_* options are not
subject to reference-dropping at all: a multi-select is not a partition,
so each option is an independent indicator and all of them are kept.
Thin levels are shrunk, not dropped. Choosing which fields enter a
model is one question; how much to trust the individual levels of a
field that already entered is another. Retaining a field’s top 32 levels
bounds how many there are, not how many rows each has — the 32nd most
common brand is still rare, and rows are dropped again for every model
whose target or predictors are null on them. A coefficient fitted from
four rows is mostly sampling noise, and generation would reproduce that
noise as a confident offset for every row it draws at that level.
So a model with any design column supported by fewer than 50 of the
rows it admits is fitted with a ridge penalty instead of plain least
squares. The penalty is worth 50 observations at zero, which means a
level keeps n / (n + 50) of the coefficient it would otherwise get:
three rows keep 6% of it, thirty keep 38%, fifty keep half, five hundred
keep 91%. The shrinkage therefore scales with how thin the level is
rather than switching on at the threshold, and it pulls the level toward
the model’s reference level — the honest reading of “too few rows to say
this level differs”. A model fitted this way records the penalty as
shrinkage_alpha; one whose levels all clear 50 records nothing and is
plain least squares.
Each shrunk level is named once in the profile’s warnings, with its
support and the model it was thinnest in. Thinness is a property of the
level rather than of the target, so the lines are aggregated across
models and then capped at twenty, thinnest first, with a counted summary
for the rest — lowering --top-k folds rare levels into "other" and
is the actual fix. A thin level is never refused and never silently
dropped.
Alongside the coefficients the capture keeps each model’s residual scale and its fitted residuals (observed minus predicted) over a bounded, row-aligned sample of the scan. Residuals are what remains once a field’s systematic variation is explained, and they are the honest input to any later measurement of how two numeric fields co-move.
A numeric field that no candidate explains is not skipped. It gets a
model with zero predictors — its own mean as the intercept, its own
spread as the residual scale, r2 of 0 — and a warning saying it
carries no predictors. That is a finding about the cohort, and it is
deliberately worded differently from a skip.
Skipping is never a refusal either. A field whose design stays
unresolvable after those refits loses its own model, is named in the
profile’s warnings as skipped — with the reason and the predictors
it was carrying — and costs the rest of the document nothing. It also
keeps its --conditional pair, so it is reconstructed from measured
structure rather than from nothing.
What selection admits in practice
Selection is calibrated for cohorts with enough rows to support a
per-level coefficient — a survey panel or an operational extract, not a
few hundred responses. A few numbers from a real 381,324-row, 122-field
survey cohort give the shape of a typical result — the same cohort every
measured figure on this page and on
synth from-profile comes from:
| Numeric fields, all of which got a model | 105 |
| …of those, carrying at least one predictor | 55 |
| Distinct (predictor field → target) relationships admitted | 113 |
| Targets skipped | 0 |
Capture time, --include-stats --conditional --fit-shape --fit-models | ~37s |
Roughly half the models carrying no predictor is a normal result, not
a breakage. Those 50 fields are ones where no categorical or set_*
in the cohort explained 1% of their variance — a finding about the
cohort. They are reported as carrying no predictors, never as
skipped, and the two words mean different things throughout the
document: a zero-predictor model is complete and usable (it is the
field’s own mean and spread), while a skip means the field lost its
model and fell back to whatever --conditional measured for it.
On a small cohort expect the opposite pressure: with a few hundred
rows most levels fall under the 50-observation mark, so most models are
ridge-penalized, shrinkage_alpha appears widely, and the coefficients
are pulled hard toward the reference level. That is the flag behaving
correctly — it is refusing to state a level difference that a handful of
rows cannot support — but it does mean the capture is not telling you
much. Read the warnings before trusting a small-sample capture.
What lands in the document
The fits are written as an additive models section — one entry per
fitted numeric field:
"models": [
{
"field": "spend",
"intercept": 41.87,
"predictors": [
{"kind": "categorical_level", "field": "region", "level": "west", "coefficient": 11.02},
{"kind": "set_option", "field": "features", "level": "premium", "coefficient": 24.91}
],
"references": [{"field": "region", "level": "east"}],
"n_obs": 1160,
"r2": 0.94,
"residual_std": 1.41,
"shrinkage_alpha": 0.0431
}
]
shrinkage_alpha appears only when the fit was penalized (see thin
levels above); its absence means plain least squares. A shrunk
coefficient and a free one are not the same kind of number, and this key
is the only thing on the wire that says which one you are holding —
shrinkage_alpha × n_obs gives back the 50-observation pseudo-count.
A coefficient is addressed by (field, level) — never by the
solver’s internal design-column name, which is an implementation detail
and is deliberately absent, so the encoding stays free to change without
invalidating documents already on disk. references names the level
each categorical DROPPED into the intercept, so a reader holding k−1
of k levels does not have to guess which one is the zero baseline (a
model with only set_* predictors carries no references key — a
multi-select is not a partition and has no baseline arm).
kind is a closed three-value vocabulary — categorical_level,
set_option, numeric — and it is on the wire because it is not
derivable from (field, level): a categorical level is one arm of a
partition, read against the dropped reference the same field names in
references, while a set option is an independent indicator with no
reference at all. The --top-k catch-all column is a
categorical_level whose level is the literal string "other", not
a kind of its own — the same spelling the collapsed buckets in
categorical_pairs and the captured marginals use, so a consumer
applies it exactly as it applies region=west. numeric is reserved
for a genuine scalar predictor and synth from-profile refuses a model
carrying one, since there is no generation-time term for it. That
refusal is wholesale — one unusable predictor drops the entire model,
because dropping a single term would leave the surviving coefficients
read against a baseline that no longer exists — so a document written by
a build that mislabelled its catch-alls loses those models outright and
must be re-captured rather than repaired.
The fitted residuals are not written. They are a bounded row-aligned
sample — thousands of numbers per numeric field — and their consumer
runs in the same process as the capture, so they stay reachable through
synth.Profile.FittedModels() and are absent from a document read back
off disk.
A capture without --fit-models emits no models key at all and is
byte-for-byte the document it has always been; a capture with it
moves nothing but that one key. --conditional,
--include-correlations, --correlation-top-k and --fit-shape all
keep their exact meaning, flag present or absent.
Reading a coefficient — it is not “points on the scale”
Every coefficient is read against the model’s reference level for
that field: region=west 11.02 beside references: [{region, east}]
means west relative to east, not west relative to zero. A set_*
option has no reference — it is read against not selecting the option.
The subtler point is the units. Generation does not add a coefficient to a drawn value; it adds it to the field’s latent — an internal standard-normal position — and the field’s own captured marginal then turns that position into a value:
value = Q( Φ( μ(row) + σ·z ) )
That indirection is invisible for the simple case and unavoidable for the rest:
- A continuous field reconstructed as a plain normal has an affine
marginal, so the round trip cancels: a coefficient of
+30on such a field really does move the drawn value by+30units, and reading it as “thirty dollars of spend” is correct. - A field carrying a
--fit-shapemixture does not. The same coefficient shifts mass between the fitted modes rather than translating the distribution, so how far a row moves depends on where in the distribution it landed. Direction and ordering hold; magnitude in data units does not. - A field whose on-wire type is coarse does not either, and this is
the case most survey cohorts are made of. A
u4field storing a 0–10 scale is written to the nearest whole number; apacked_boolfield is written as 0 or 1 and nothing else. A latent shift of a quarter of a standard deviation moves most rows by nothing at all and a few rows by a whole scale point — it changes the proportion of rows that cross each boundary, which is a real effect but not a per-row offset. On a boolean target almost the whole latent effect disappears at write time; seesynth from-profile’s guidance on reading a flagged model, where the same property shows up as a measured recovery gap.
So: read a coefficient’s sign and its size relative to the model’s other coefficients freely. Read it as an amount of the field only when the field is continuous, unrounded and reconstructed as a plain normal.
What models replaces at generation time
synth from-profile reads the section instead of --conditional’s
numeric-target pairs, field by field: a numeric field that lands a
model has its entries in conditional.categorical_numeric_pairs and
conditional.set_numeric_pairs dropped. They exist to
resample a numeric field from one paired field’s conditional moments, so
on a cohort with several categorical and set_* fields every one of
them claims the same numeric target and all but the first are dropped as
conflicts — one warning each, thousands on a wide cohort. A model
accounts for all of those predictors at once, so the pairs have nothing
left to add.
The three non-numeric-target arms —
conditional.categorical_pairs, conditional.set_categorical_pairs and
conditional.set_set_pairs — are unaffected. A model predicts a numeric
FROM categorical and set structure; it says nothing about how that
structure co-varies with itself, so passing --conditional and
--fit-models together is supported and keeps both halves.
A model that cannot be applied to the reconstructed spec — it names a
field the profile does not carry, or it is the zero-predictor model
above — is dropped with a
model for numeric field "x" not applied: … warning, and that field
keeps its captured conditional pair. Retirement is per field, not
per document: a field that gains no model must not also lose the
structure --conditional measured for it.
Without the section — every document written before this flag existed included — nothing changes: all five arms populate exactly as before.
--fit-shape and --fit-models compose
A --fit-shape target used to be a drop reason: its mixture had no
quantile function a linear predictor could ride, so synth from-profile kept the fitted shape and discarded the model. On a real
cohort that removed every measured conditioning relationship from
exactly the numerics whose distributions had been interesting enough
to earn a shape fit.
They now compose. A modelled numeric is drawn as
value = Q(Φ(μ(row) + σ·z)), and Q is the field’s own marginal — so
a fitted mixture simply becomes Q while the predictors shift the
latent, the same way a hand-authored lognormal target already keeps
its skew under a linear predictor. Nothing is dropped and nothing is
warned. A shape-fitted field that lands no model is untouched: it
draws its own mixture and still holds the pre-claim described above.
Two consequences worth knowing before reading such a model’s numbers:
- Coefficients are on the latent scale. For a plain
normaltarget the mapping is affine and a coefficient of+30moves the drawn value by+30. For a fitted mixture it does not — the same coefficient moves values by an amount that depends on where in the distribution the row landed, because it shifts mass between the modes rather than translating them. Direction and monotonicity hold; magnitude in data units does not. Do not read such a coefficient as “this many units of the field”. - The marginal is preserved exactly. The alternative — drawing from the mixture and then adding the linear prediction — would make coefficients read in data units, at the cost of smearing the fitted modes across the level offsets. That destroys the shape the flag exists to capture, so it is not what happens.
How a modelled numeric is drawn
synth from-profile composes the value in one step rather than drawing
a marginal and overwriting it:
value = Q( Φ( μ(row) + σ·z ) )
μ(row) is the model’s prediction for that row — the intercept plus the
coefficient of every predictor that fires — standardised by the field’s
own captured mean and standard deviation; σ is residual_std on that
same scale, and z is a fresh standard-normal draw. Φ and Q are the
same normal CDF and per-field quantile function the correlation
reconstruction uses, so a modelled field and a correlated field agree on
what the field’s own shape is. For a numeric reconstructed as normal —
which is every numeric a profile carries a model for — this reduces
exactly to prediction + residual_std × z.
A coefficient fires only on an exact match. A categorical level the fit
never saw, and a null predictor, both contribute nothing — they read
as the reference level, which is what the dropped-baseline encoding
means. The drawn value is clamped once to the field’s observed
[min, max].
Generation stays deterministic: the same (profile, --rows, --seed)
still produces a byte-identical cohort. Each model consumes exactly one
residual draw per row, in schema field order, whether or not any
predictor fired.
A correlations entry naming a modelled field is not applied, and
permanently so: a correlations figure is a correlation between two
fields’ values, and the only way to realize one is to draw both values
from a shared copula — which would delete the model’s whole account of
the field. Rerouting it into the residual instead would apply every
predictor the two fields share a second time. The run reports the
refusal and names the surface that does correlate a modelled field:
--residual-correlations, below.
--residual-correlations: the residual submatrix
--include-correlations and --conditional both measure the
correlation between two fields’ raw values, which is the right
quantity when each field is reconstructed from its own marginal and
nothing else. It stops being the right quantity the moment a field
carries a model: once a numeric’s systematic variation is explained by
its predictors, the part still free to move at generation time is the
residual. Two fields both driven by region correlate strongly on
raw values while their residuals may be independent — imposing the raw
figure on the residual draw would apply region’s effect twice.
--residual-correlations (which requires --fit-models) measures the
correlation between every pair of fitted residuals and writes it as the
additive residual_correlations section. It reads no extra bytes from
the cohort: the residual reservoir is already in memory when the fits
finish.
{
"residual_correlations": {
"fields": ["alpha", "beta", "gamma"],
"pairs": [
{ "a": "alpha", "b": "beta", "rho": 0.81, "n": 600 },
{ "a": "alpha", "b": "gamma", "rho": 0.02, "n": 600 }
],
"unmeasured": [
{ "a": "beta", "b": "gamma", "n": 1, "reason": "insufficient_overlap" }
]
}
}
The full submatrix, not a top-K sample
--correlation-top-k retains the strongest K pairs because its
consumer applies one pair at a time. This section’s eventual consumer is
a joint draw over every participant at once — a Cholesky factor of the
whole matrix — and a matrix is not a ranked list. A pair left out of it
is not “weak”, it is a hole the factorization has to fill with
something. So every pair among participants is visited.
--include-correlations and --correlation-top-k are untouched and
keep their exact meaning. This is a different measurement (residuals,
not raw values) with a different shape (a submatrix, not a ranked list),
and sharing either flag would silently change what those two already
mean. It is likewise not implied by --fit-models: the section is
quadratic in modelled fields (105 targets is 5,460 pairs), so switching
it on for free would grow every such document without notice.
Measured zero is not the same as unmeasured
fields + pairs + unmeasured is exhaustive and non-overlapping:
every unordered pair among fields appears in exactly one of the two
lists.
A dense N×N array would have one slot per pair and every slot would
have to hold a number, so “unmeasured” would have nowhere to live except
a sentinel — and the sentinel that reads most naturally is 0, which is
also a perfectly ordinary measurement. Two lists make the two states
structurally different rather than conventionally different, and a
JSON round trip preserves a structural difference for free. An
unmeasured entry carries no rho key at all — there is no slot for a
zero to be written into.
Two reasons, a closed vocabulary:
reason | Means |
|---|---|
insufficient_overlap | Fewer than three rows carry both residuals. Two points always produce a Pearson coefficient of exactly ±1 — a line through two points is a perfect fit — so a figure from a handful of rows is an artifact of the arithmetic, not a weak measurement |
no_variance | The rows overlapped but one side’s residual is constant across them, so the correlation is 0/0. An exactly fitted model on the overlap is the ordinary cause |
That floor of three is deliberately not the 30-observation thin-pair threshold. Thirty answers “is this stable enough to trust”, and its answer is a warning — a shaky measurement of something real beats no measurement. Three answers “is there a measurement here at all”, and below it there is not. The two compose: a pair at or above three but below thirty is measured and warned.
Warnings are bounded. One summary line names how many pairs are unmeasured (the per-pair reasons are already in the document, so the warning’s job is to make sure the gap is noticed, not to re-list it); thin measured pairs are named individually up to twenty, thinnest-first, then collapse into one counted summary.
Who participates
Every model carrying a residual vector, including a model with zero predictors. Selection can legitimately leave a target with nothing admitted, and such a target still has a residual: its whole deviation from its own mean. Excluding it would drop real structure for a reason unrelated to that structure, and would do it asymmetrically, since the same field paired against a predictor-carrying target is exactly as measurable. Its residual correlation simply coincides with its raw correlation, which is the correct answer for a field nothing predicts.
A model whose residual vector is absent — a Profile decoded from a
document, where the reservoir is deliberately not serialised — is not a
participant. Treating it as one would produce a submatrix in which every
pair is unmeasured, which describes the reader’s situation rather than
the cohort’s.
How generation uses it
synth from-profile translates the measured pairs onto the spec and
draws every participating model’s residual from one shared correlated
standard-normal vector per row — the same Cholesky, the same completion
policy and the same ridge report the value-scale correlations matrix
uses, with a different consumer rather than a second construction. The
predictors move the prediction, the shared vector correlates the
residual, and neither overwrites the other, so a field is finally able
to be both conditioned and correlated.
The unmeasured list is translated into nothing at all. An absent
pair is completed as independent by the generator, which counts and
names the assumption (see below); writing it out as a zero here would
present a gap as a measurement and would silence that count as well.
A pair whose endpoint lost its model in translation is dropped — the model drop was already reported by field, with its reason — and the run names the consequence once rather than per pair.
The commonest such endpoint is a zero-predictor target. Selection
emits a model for a target nothing explained, and it participates in the
capture above — it still has a residual, its whole deviation from its own
mean — but translation drops it, because a field nothing explains is
better served by its captured conditional pair than by an empty model.
Its residual pairs therefore have nothing to attach to. On a wide cohort
roughly half the modelled targets carry no predictor, so
residual_correlations reaches the predictor-carrying half.
What the generator does with an incomplete matrix
Both correlation surfaces — correlations on the value scale and
residual_correlations on the residual scale — now say out loud what
they had to invent, in the same words and through the same code. A
correlation surface is a list and the Cholesky needs a matrix, so
every pair the list does not name is filled with zero — those fields are
drawn independent — and the run emits a warning counting how many pairs
were completed by assumption. Refusing was considered and rejected:
an incomplete list is the normal input (a spec correlating a–b and
b–c has said nothing about a–c, and a profile-derived list is
capped by --correlation-top-k by construction), and zero is the only
completion that adds no structure the caller did not ask for. What was
not acceptable is doing it silently.
The ridge fallback likewise stays — it is the safety net for a matrix whose supplied entries are not jointly realizable — but now reports the diagonal jitter it had to add. A regularized matrix means the realized correlations will be pulled toward zero, which a caller previously could only infer from the output.
--suggest-rules: proposing structural rules from the data
The rules[] layer (docs/src/cli/synth-from-schema.md) can state the
facts no statistical summary can — a question block not ASKED of a
respondent who failed a screener, a flag that is a band of another
column — but its value is bounded entirely by rule COVERAGE, and rules
get written from memory. On the motivating 122-field survey the analyst
supplied exactly one; a second and larger one was found by accident
while verifying something else. An unstated rule is invisible in the
output because every marginal inside it is individually correct.
--suggest-rules <path> runs three detectors on the scan profile create
already makes — no additional cohort read — and writes their
candidates to one file:
- Gating. For every low-cardinality field (
categorical_*,packed_bool,u4) and every other field it measuresP(target null | gate = level), and proposes aset_nullrule for each field whose levels split a target’s null rate into ~1 and ~0. - Co-missing. It proposes a
null_togetherrule for each set of fields null on EXACTLY the same rows, and reports separately the near misses and the always-null columns. - Exact dependency. It proposes a
set_exprrule for each field that IS a function of one other field on every row where both are present —promoterisnps >= 9— and reports separately the almost-determined pairs and the constant columns.
The first two read NULL STATE only; the third reads VALUES.
pulse profile create -i cohort.pulse -o profile.json --suggest-rules candidates.json
pulse synth from-profile -p profile.json --source cohort.pulse -o out.pulse \
--rows 20000 --rules candidates.json
Proposed, never applied
A detected pattern can be a coincidence of the sample, and a rule applied
without review is a silent structural claim about the data. Nothing is
auto-applied and the profile DOCUMENT is untouched: absent the flag it is
byte-identical, and with the flag the only key that moves is warnings.
The candidates are never a profile section — an unreviewed structural
claim must not ride inside the document that drives generation.
Detection finds the STATISTICAL gate, not the SEMANTIC cause
This is measured, not hypothetical. On the motivating cohort detection
proposes BOTH round(aware) == 0 and round(familiarity) == 1, with
identical evidence to sixteen digits — the two select the same 96,326
rows with zero exceptions either way, so nothing in the data can
separate them, and the analyst’s own rule is the second. Each candidate
says so in its _evidence.note and invites correction rather than
asserting cause.
The _evidence block
Each candidate carries its own supporting measurement on _evidence —
the ONE INERT slot of a rule. Generation never reads it, a rule carrying
only _evidence is still refused as PULSE_SYNTH_RULE_EMPTY, and
deleting a candidate takes its evidence with it. The leading underscore
follows the repo’s _meta convention for a block that is documentation
rather than payload.
[
{
"when": "round(useCon) == 3 || round(useCon) == 4 || round(useCon) == 5 || round(useCon) == 6 || isnull(useCon)",
"set_null": ["nps", "detractor", "passive", "promoter"],
"owns_nulls": true,
"_evidence": {
"detector": "gating",
"note": "measured, not asserted: \"useCon\" is the field whose levels split these 4 target(s) …",
"gate_field": "useCon",
"gated_levels": ["3", "4", "5", "6", "(null)"],
"open_levels": ["1", "2"],
"levels": [{"level": "1", "side": "open", "n": 40517, "mean_target_null_rate": 0}, "…"],
"targets": [{"field": "nps", "null_rate": 0.826, "gated_null_rate": 1, "open_null_rate": 0}, "…"],
"rows_observed": 381324,
"rows_affected": 314981,
"gated_share": 0.826019343130776,
"max_null_rate_deviation": 0,
"min_level_support": 19313
}
}
]
gated_share is the figure to compare against each target’s own
null_rate in the profile document — that agreement is how the
relationship was first found by hand. It is a CHECKING aid rather than
the ranking signal: the split test arithmetically implies it, so every
admitted candidate scores well on it. Ranking is target count, then rows
affected.
Predicates are emitted through round()
Uniformly, including for a packed_bool whose row value is already
exactly 0.0 or 1.0. A generated row holds the sampler’s float and
the file holds the stored integer, so a bare familiarity == 1 selects
only the draws that landed exactly on 1 — measured at 531 of 901 rows in
the committed regression. The uniformity is deliberate: a file where one
numeric gate is spelled bare and another through round() teaches a
reader that bare is sometimes fine, and nothing in the file says which.
What it will NOT propose, and says so
Both are COUNTED in warnings rather than dropped silently.
- A field with more than 16 observed levels. A gate is a branch, and a branch with more arms than that is an attribute. The candidate is ABANDONED, never truncated — a partial level map makes every rate below it a rate over an arbitrary subset of the cohort.
- A gate whose ONLY gated level is the null pseudo-level. That is co-missingness between fields rather than value gating, and every member of an N-field co-missing block reports the other N−1 that way; proposing them restates one finding N times and buries everything else. On the motivating cohort that is 3,932 relationships — and the co-missing detector below proposes those blocks as ONE candidate each, so the finding is counted here and resolved there.
Candidates resting on THIN levels are not suppressed — they ship with
thin_support: true and their min_level_support, warned thinnest-first
with a counted remainder. The analyst is better placed than a threshold
to judge a twelve-row level.
Thresholds are constants, not flags
gateHighNullRate (0.98) and gateLowNullRate (0.02) are package
constants with their reasoning at the declaration in
internal/synth/profile_gating.go, matching the minVarianceExplained /
minLevelObservations precedent: a threshold whose purpose is to mean
the same thing across cohorts must not be tunable per run. They are tight
because skip logic is EXACT in the source. A looser pair would not find
more gates; it would find ordinary ASSOCIATION and propose it as
structure, and a rule built from a 10%/30%/50% null rate is applied
unconditionally and wrong on most of the rows it selects.
Two caveats that survive detection
Candidates compose in declaration order. They are not independent
statements. On the motivating cohort aware nulls the five *Aware
flags, and the five later candidates — each gated partly on
isnull(<x>Aware) — then fire on those nulls too. That is the
declaration-order contract working, and it is why the file’s order is
the applied order. The file is ordered so that a candidate WRITING a
field another candidate READS comes before it; see Emitted order is
applied order below.
Every gating candidate carries "owns_nulls": true, and the
detector’s own admission thresholds are what make that correct. Without
it a set_null gate repairs co-missingness ONLY: each target’s own
null_rate keeps firing beneath the gate, and because that rate is a
marginal already inclusive of everything the gate removed, the two
compose as g + (1 - g) * r and the generated rate rises well above the
captured one. owns_nulls discards the target’s own null draw, making
the gate the single source of its absence (see
synth from-schema).
That would be an optimistic default if it were not bounded: a
(gate, target) pair is admitted only when the target is null on at least
gateHighNullRate (0.98) of gated rows and at most gateLowNullRate
(0.02) of OPEN rows — and P(null | open) is the residual the flag
zeroes. So every candidate this detector can emit is within 0.02 of
exact, which is the distance the divergence warning already calls
immaterial; a claim that misses by more is reported after generation
rather than left silent.
Measured on the motivating cohort’s accepted 11-candidate file at 40,000
generated rows: the 50 aware-gated fields moved from 0.4320 to
0.2455 against a captured 0.2526, and people from 0.7506 to 0.4208
against a captured 0.4273, with the coherence table, the emitted
ordering and the zero orphan count all unchanged and no claim diverging
enough to warn. A co-missing block candidate never carries the flag: it
already discards its non-gate members’ rates by copying the gate’s
decision.
Measured on the motivating cohort
381,324 rows, 122 fields. Detection proposes 8 candidates and adds
no cohort read (+13% CPU against a full
--conditional --fit-models --residual-correlations capture; the output
is byte-identical with those flags and without them).
| gate | targets | gated_share | deviation |
|---|---|---|---|
round(aware) == 0 | 63 | 0.2526093295989762 | 1.2e-02 |
round(familiarity) == 1 | 63 | 0.2526093295989762 | 1.2e-02 |
round(useCon) ∈ {3,4,5,6} ∨ isnull | 4 | 0.8260193431307760 | 0 |
round(peopleAware) == 0 ∨ isnull | 1 (people) | 0.427288 | 0 |
round(promotionAware) == 0 ∨ isnull | 1 (promotion) | 0.321231 | 0 |
round(placementAware) == 0 ∨ isnull | 1 (placement) | 0.304429 | 0 |
round(productAware) == 0 ∨ isnull | 1 (product) | 0.293158 | 0 |
round(priceAware) == 0 ∨ isnull | 1 (price) | 0.288857 | 0 |
The first two are the proxy pair above. The third is the four-field NPS
block that was found by ACCIDENT during research — recovered here
automatically, with rate exactly 1 at every gated level and exactly 0 at
every open one. aware’s 63 targets are a strict SUPERSET of the 50
fields whose null_rate is exactly 0.2526093295989762: the extra 13
are the 0.2649 cluster that research recorded as “no matching single
boolean gate — UNEXPLAINED”. They are gated by aware too, with a
further ~1.2% of missingness on the open side. The 0.3564 cluster (26
fields) has no single low-cardinality gate either — it is EXPLAINED by
the co-missing detector below, as an exact 26-field block.
Fed back unmodified through synth from-profile --rules, all 8 fire on
100% of their gated rows over 20,000 generated rows; the same seed
without the rules leaves aware coherent on 0 of 4,964 and the NPS block
on 7,819 of 16,806.
That figure is about the rows each gate SELECTS, and it predates the orphan measurement. Five of these eight gates read a field that is itself a member of an emitted co-missing block, so under detector preference alone they fired on a drawn value the block then nulled — 8,693 of 20,000 generated rows carried an answer to a question the same file said was never asked, with every gate still at 100% on its own gated rows. The writer-before-reader ordering in Emitted order is applied order takes that to 0; see Ordering has teeth.
Co-missing blocks (null_together candidates)
A survey question block is asked or skipped as a unit, so its fields are
present or absent together. Generation draws each field’s null
independently from its own scalar null_rate, which turns the block into
a lottery — the defect null_together exists to fix
(docs/src/cli/synth-from-schema.md). This detector finds the blocks.
An identical null_rate is never enough
This is the whole subtlety of the detector, and getting it wrong is
silent. Two entirely independent fields can share a null rate to sixteen
digits and overlap only by chance; grouping by rate proposes them as one
question block, null_together then makes the claim TRUE in generated
output, and the cohort acquires a structural fact the source does not
have.
So the rate is only the grouping key, and admission is identical null
PATTERN — null on exactly the same rows. Every member of an emitted
block therefore carries agreement: 1, an identical null_count, and
max_null_rate_deviation: 0.
That last figure is exactly the measure null_together itself warns on:
the rule copies the FIRST named member’s null decision and ignores every
other member’s declared null_rate, warning above a 0.02 divergence. For
an emitted block the divergence is zero, so no declared rate is
discarded — and the member ORDER is arbitrary by construction rather than
a ranking someone has to get right.
{
"null_together": ["nps", "detractor", "passive", "promoter"],
"_evidence": {
"detector": "co_missing",
"note": "measured, not asserted: these 4 field(s) are null on exactly the same 314981 row(s) …",
"block": [
{"field": "nps", "type": "u4", "null_count": 314981, "null_rate": 0.826019343130776, "agreement": 1},
{"field": "detractor", "type": "packed_bool", "null_count": 314981, "null_rate": 0.826019343130776, "agreement": 1}
],
"rows_observed": 381324,
"rows_affected": 314981,
"gated_share": 0.826019343130776,
"max_null_rate_deviation": 0,
"min_level_support": 66343
}
}
The candidate carries no when: an absent when means EVERY ROW, which
is the true statement — the members are null together on the rows where
the block is null and present together on the rows where it is not.
A near block is reported, never proposed
Fields that agree on MOST rows but not all are reported in warnings
with their agreement and their disagreeing row count:
rule suggestion: "regard" (a block of 50 — its candidate is the null_together
beginning "regard") and "appropriate" (a block of 13 — its candidate is the
null_together beginning "appropriate") are null together on 0.9537 of the rows
where either is null (4676 row(s) disagree) but not on exactly the same rows …
Each block side carries the handle that finds it in the emitted file:
the candidate is the null_together whose first entry is that field.
The handle is derived from the candidate’s own content rather than from
its position, because the file exists to be edited by DELETION and every
index below a deleted line shifts — an index would be a handle that goes
wrong silently. The first entry is also load-bearing rather than
incidental (it is the block’s gate, the only member whose own null
decision survives), so it cannot be reordered away without changing what
the rule means. A side that produced no candidate — a singleton, or a
class beyond the candidate cap — carries no handle and says so, rather
than pointing at a line that is not there.
They are not emitted, and the asymmetry with the gating detector’s thin
candidates is deliberate: a thin gate’s RELATIONSHIP is exact and only
its support is thin, whereas a near block’s relationship is measurably
false on some rows. null_together has no dial for “almost” — applying
one silently rewrites the rows that disagreed — and unlike a gating
candidate, whose when an analyst can correct, there is nothing in it
to fix.
The agreement is the overlap of the two null SETS (of the rows where either is null, the share where both are), not row-level agreement. Two independent fields each null at 1% agree on 98% of ROWS, so a row-level threshold anywhere near 1 would flood the report with unrelated pairs.
Always-null columns
A column null on every row is its own finding, named with its type:
always-null column "lgbt" (categorical_u8): null on all 381324 row(s) profiled,
so the profile summarises no marginal for it and generation reproduces it
exactly — null on every row, from null_rate 1.0; no rule is proposed because
an unconditional set_null would be redundant. If the column is meant to carry
values, the gap is in the source or in the slice profiled
It is deliberately kept out of every block and every gate — it is not
co-missing with anything, it is simply absent — and no rule is
proposed for it because one would change nothing. A column the
profiler summarised nothing for is reconstructed as a typed constant
placeholder with null_rate 1.0, so every row is nulled and the type’s
zero is written behind a set null bit. Measured: the motivating cohort’s
lgbt came back null on 20,000 of 20,000 generated rows.
An earlier version of this line claimed generation “fabricates a distribution” for such a column. It does not — the no-marginal arm is reached precisely BECAUSE there is nothing to fabricate from. The finding stays worth an analyst’s eye, but the thing to check is the source or the slice profiled, not the rules file.
Emitted order is applied order
A candidate that WRITES a field another candidate READS is emitted BEFORE it. Within that constraint the preference order — gating, then co-missing blocks, then exact dependencies — is preserved as closely as possible: candidates are walked in that order and a candidate a walked one depends on is hoisted to just before it, never further.
The preference is the readable order and is kept wherever nothing forces
a move. For a block holding a gate’s TARGETS the two orders are
equivalent, and that is arithmetic rather than luck: block members are
admitted only when their null patterns are identical, so they carry
identical conditional null rates at every level of every gate and the
gating detector classifies them identically. A gate takes a WHOLE block
or none of one. Gate-first is then chosen for what the file is FOR —
being edited: a hand-narrowed set_null over part of a block is repaired
by a block that follows it and broken by one that precedes it.
That equivalence governs the fields a gate WRITES, and is false for the
field a gate READS. When a block member is also a gate’s when field,
a block placed after that gate moves the gate’s own INPUT after the gate
has read it: the gate fires on the drawn value, the block then nulls the
field the gate was reading, and a target it left present is now an answer
on a row whose screener is absent. On the motivating cohort five gates
sit in exactly that shape (peopleAware, promotionAware,
placementAware, productAware, priceAware are all members of the
50-field block) and a sixth reads a field a set_expr rewrites
(aware = round(familiarity) >= 2).
| orphan rows of 20,000 | |
|---|---|
| preference order alone | 8,693 |
| writer-before-reader | 0 |
An orphan row is one carrying a value for a gate’s target while the gate’s own field is absent.
What it costs. A block hoisted ahead of a gate loses, for that gate, the repair property above: narrow that gate’s target list by hand and the hoisted block no longer follows the edit. The trade is not symmetric — the repair property protects an edit that may never be made, the ordering fault corrupts every generated row unconditionally. Only the candidates that MUST move, move: on the motivating cohort the headline 63-target gate stays first and one block is hoisted ahead of the five gates that read it.
If you reorder the file by hand, keep writers before readers. A mutual pair — each writing a field the other reads — has no satisfying order; one edge is dropped, the surviving one decides the pair, and the drop is reported as a warning naming both. It does not arise on the motivating cohort.
Bounded, on the same scan
The accumulator is a per-field bitset over a fixed chunk of rows, folded into a pairwise co-null matrix by popcount and cleared. Memory is flat in the row count and bounded by the field count; the per-row cost is one bit write per null field. Over 256 nullable fields the detector abandons rather than truncating — a truncated field set yields blocks that are exact among the fields it kept and silently missing the rest, which is the defect this detector exists to remove.
Blocks rank largest-first, capped at 20 with a counted remainder, and each detector bounds its own listing rather than sharing one budget.
Measured on the motivating cohort
Same 381,324 rows. Four blocks, one always-null column and four near
misses, alongside the eight gating candidates — 12 candidates in one
file, consumed by synth from-profile --rules unmodified.
| members | gated_share | first members |
|---|---|---|
| 50 | 0.2526093295989762 | regard, meaningfulness, uniqueness, … |
| 26 | 0.3564055763602606 | funcAppearance, attentionToDetail, brandAssets, … |
| 13 | 0.2648718674932603 | appropriate, beliefsValues, clarity, … |
| 4 | 0.8260193431307760 | nps, detractor, passive, promoter |
The 26-field 0.3564 cluster is the one the gating detector could not
explain and research recorded as unexplained. It has no single
low-cardinality gate, and it is an EXACT co-missing block. The 50-field
and 13-field clusters are the aware targets, split apart here by their
patterns: they overlap on 0.9537 and are reported as a near miss rather
than merged.
One always-null column: lgbt. Near misses: the 50/13 pair above,
sow against the 26-field block (0.9989, 145 rows), and useCon
against sow (0.9798) and the 26-field block (0.9788).
Block detection costs +2% CPU on a full
--conditional --fit-models --residual-correlations capture (37.8s →
38.5s against a 34.0s baseline) and reads no additional byte. The
gating half of the candidate file is byte-identical with and without it.
Fed back unmodified, all four blocks are all-or-nothing on 20,000 of 20,000 generated rows. At the same seed without the rules: partial on 20,000 / 20,000 / 19,660 / 10,761.
Exact dependencies (set_expr candidates)
promoter IS nps >= 9. Generation samples it from its own marginal and
gives it a captured linear model, so the synthetic cohort contains
promoters with a score of 3 — and the profile document has been carrying
the proof the whole time (promoter + passive + detractor = 1.0000,
exactly) with nothing reading it. This detector reads it.
The search is NARROW, deliberately, and the bounds are published
A general functional-dependency search over 122 fields is quadratic, mostly finds noise, and would have to be believed on the strength of a claim nobody can check. What is searched:
| Admitted | |
|---|---|
| Target | packed_bool, u4 — a boolean flag or a small integer |
| Source | categorical_*, packed_bool, u4, with at most 16 observed levels |
| Arity | exactly ONE source |
Everything else is out: wider numerics (u8+, f32/f64,
decimal128), date, set_*, categorical-VALUED targets (a derived
category needs the declared domain and a wrong arm silently grows the
dictionary), and joint dependencies on two fields at once.
A field missing from the candidate file was not cleared — it was not
examined. The bound is stated in every candidate’s _evidence.note, on
stderr and here, because an analyst who believes a field was checked
stops looking.
Band edges are DISCOVERED, never assumed
The measurement is a LOOKUP — source level to target value. Rendering it
as round(nps) >= 9 rather than a nine-way disjunction is a separate
judgement, and the edges come from the data. The standard NPS 9-10 / 7-8
/ 0-6 definition is this detector’s OUTPUT on the motivating cohort; it
is nowhere in its input.
A threshold form is preferred wherever the target’s value regions are
contiguous in the source’s own order, because it is a total function
of the source: a value generation produces that the cohort never carried
lands in the nearest band instead of falling off the end of an
enumeration. _evidence.dependency[].form names which reading was
chosen — threshold / threshold_chain are total, while membership /
membership_complement / enumeration_chain (a categorical source has
no order to be contiguous in) fall to a default arm.
One candidate per SOURCE, with its null_together in the SAME rule
A partition is three measurements against one field. Three separate rules would have to be kept consistent by hand — delete one and the remaining two silently stop partitioning — so every target a source determines rides ONE rule:
{
"set_expr": {
"detractor": "round(nps) <= 6",
"passive": "round(nps) >= 7 && round(nps) <= 8",
"promoter": "round(nps) >= 9"
},
"null_together": ["nps", "detractor", "passive", "promoter"],
"_evidence": {
"detector": "dependency",
"source_field": "nps",
"source_levels": 11,
"dependency": [
{"field": "promoter", "type": "packed_bool", "form": "threshold",
"mapping": [{"level": "0", "value": false, "n": 6272}, "…"],
"exceptions": 0}
],
"rows_observed": 381324,
"rows_affected": 66343,
"gated_share": 0.17398119,
"max_null_rate_deviation": 0,
"min_level_support": 1176
}
}
Three properties of that snippet are load-bearing, and each was measured wrong before it was measured right (see synth from-schema):
round(), uniformly. A generated row holds the sampler’s float and the file holds the stored integer, so a barenps >= 9classifies against a value the file does not show. Measured on this story’s own suite: 838 of 4,495 scored rows disagree without it.- No normalisation rule. The rounding is INSIDE each band predicate
rather than in a separate
{"set_expr": {"nps": "round(nps)"}}rule, so nothing writes to the source and nothing can un-null it — the!isnull()guard that remedy needs is unnecessary because the write does not happen. null_togetherin THIS rule, naming the SOURCE first. Aset_exprclears its target’s null mask; within a rulenull_togetheris the last write, so the flags are computed and then take the score’s null decision. Split into two rules the derivation runs afterwards and un-nulls every member: 1,505 orphan rows on the same suite.
The rule carries no when, which is the true statement (the target
is a function of the source on every co-present row) and which makes its
targets pre-claim-eligible: accepting one RETIRES the target’s
captured linear model, conditional pairs and residual correlations rather
than computing them and overwriting the result. The note says so, because
that is the most valuable consequence of accepting a candidate.
Admission: identical null patterns, or nothing
A candidate is emitted only when the source and every target are null on
EXACTLY the same rows — the same test the co-missing detector applies,
served from that detector’s own co-null accumulator rather than a second
one. When the patterns differ, the dependency is REPORTED and not
emitted: a set_expr clears the target’s null mask, so the rule would
un-null the target on every row its source is absent from. When the
accumulator is unavailable (abandoned over 256 nullable fields) the
answer is “unknown” and the candidate is reported, never guessed.
What it reports rather than proposes
- An almost-determined pair — at least one and at most 8
contradicting rows — with its exception count.
set_exprhas no dial for “almost” and would rewrite exactly the rows that disagreed, and unlike a gating candidate’swhenthere is nothing in it to correct. Over 8 the pair is dropped entirely and not reported, because it is not “almost” anything. The count is taken against the two most common values at each source level, so it does not depend on the order the rows arrived in. - A constant column: determined by every other field and by none of them. Excluded from both roles and named once.
- A mutually-determining pair: both directions are proposed and both are consistent, but either alone is sufficient and the pair is usually one column under two names.
- A contested target determined by two sources: written once, by the strongest candidate, because two rules writing one field leaves the earlier one firing with no effect.
Thin candidates are not suppressed — they ship with
thin_support: true and their min_level_support.
Emitted LAST, and the order has teeth
Gating candidates, then co-missing blocks, then dependencies. A
dependency rule writes VALUES and carries its own null_together, so
placed last it re-resolves that block from the source AFTER every
null-state rule has decided the source’s own null state. Measured on this
story’s suite with a hand-narrowed set_null over the source alone:
dependency-last leaves 0 orphan rows, dependency-first leaves 940.
The one exception is the writer-before-reader rule above: a dependency
whose set_expr writes a field a GATE reads is hoisted ahead of that
gate, because otherwise the gate fires on a value the dependency rewrites
afterwards. aware = round(familiarity) >= 2 against a gate on aware
is exactly that shape on the motivating cohort.
Measured on the motivating cohort
Same 381,324 rows, 122 fields. Two dependency candidates, alongside the eight gating candidates and four blocks — 14 in one file.
| source | targets | expression | form | co-present |
|---|---|---|---|---|
nps (11 levels) | detractor | round(nps) <= 6 | threshold | 66,343 |
passive | round(nps) >= 7 && round(nps) <= 8 | threshold | ||
promoter | round(nps) >= 9 | threshold | ||
familiarity (7 levels) | aware | round(familiarity) >= 2 | threshold | 381,324 |
The NPS partition comes back as ONE candidate with the standard
9-10 / 7-8 / 0-6 band edges, discovered rather than assumed, carrying
null_together: ["nps", "detractor", "passive", "promoter"] — the same
four fields the co-missing detector proposes as a block, restated inside
the rule that derives them because that is where it has to be.
The second candidate answers the proxy question the gating detector
could only report. aware and familiarity == 1 select the same
96,326 rows with identical evidence to sixteen digits, and nothing in a
null-state measurement can separate them. Reading VALUES does: aware is
an exact function of familiarity on all 381,324 rows, so they are not
two independent gates — one is DERIVED from the other, and the analyst’s
own rule names the source.
Reported, not proposed: three constant columns (wave, country,
_synthetic — the last an artefact of taking the real partition), and
five fields over the level cap (ageExact, brand, category, dma,
region). No almost-determined pair and no null-shape mismatch survive
on this cohort.
Fed back unmodified through synth from-profile --rules, both candidates
reproduce the source mapping on 100% of co-present rows with zero
orphan rows. At the same seed without them: detractor 86.3%, promoter
76.8%, passive 56.1% agreement, each with ~5,700 rows carrying a band
flag and no score, and aware 81.8%.
Detection costs +7.5% CPU against the two-detector figure (38.6s →
41.5s on a full --conditional --fit-models --residual-correlations
capture, against a 34.6s no-detection baseline) and reads no additional
byte. The candidate output is byte-identical with those flags and
without them, and the eight gating candidates and four blocks are
byte-identical to the file the previous two detectors wrote.
--run-continuation: is the cohort still sorted?
Scans of a .pulse cohort skip re-decoding any field whose on-wire bytes
did not change since the previous row (the run-skip decode). On a
denormalised parent/child join sorted by the parent key, the whole parent
block repeats across each parent’s child rows, so most of a row’s fields
are skipped. That win is a property of the DATA, not the format: a cohort
re-imported without its upstream ORDER BY, or a shard appended out of
order, loses it with nothing on the wire saying so.
--run-continuation makes it visible. On the same scan the profile
already makes (no second read), it compares each row’s raw bytes with the
previous row’s and writes the additive run_continuation section:
| Key | Meaning |
|---|---|
pairs | adjacent row pairs compared — rows − 1 per shard |
shards | payloads scanned (1 for a single-file cohort) |
overall | mean of the per-field rates: the share of field writes the run-skip decode avoids on a full-row scan |
high_threshold | 0.75 — the rate at or above which a field is listed in high_fields |
high_fields | schema-order list of the high-continuation block (on a sorted join, the parent block) |
advice | one-line reading of overall |
fields | {name, rate} for every schema field, in schema order |
A field “continues” on a pair when its on-wire bytes are identical AND,
for a nullable field, its null bit is too: a null followed by a non-null
zero carries the same zero bytes and still counts as a change. A
bit-packed field is compared as its one on-wire byte. That is the exact
comparison the decoder makes, so overall is its hit rate, not an
estimate of it.
Reading it. Below 0.5 overall the decoder’s backoff (it stops
comparing when more than half of a probe window’s fields changed) keeps
it on full decode — the advice says low: and, if the cohort is a
denormalised join, to sort the source by the parent key upstream and
re-import. At or above 0.5 it says high: and to keep the ordering
when re-importing or appending shards. On a synthetic 95-field join at
12.5x fanout the sorted cohort measures 0.71 with the 66-field parent
block as high_fields; the same rows shuffled measure 0.21 with none.
Shards. Pairs never span a shard boundary: each shard is a separately written payload and the decoder restarts its comparison at each one, so a pair across two shards measures nothing the optimisation can use. An archive of many small shards therefore reports fewer pairs than rows − 1, and a shard appended in a different order shows up as a lower overall.
--sample-limit bounds the measurement like every other section. Absent
the flag the document is byte-identical and the scan pays nothing.
synth from-profile ignores the section — it describes the source’s row
ORDER, which generation does not reproduce.
Output
The profile JSON is always written to --output. With --json, the
envelope is also written to stdout (typically piped or jq-d).
Profile schema lives in internal/synth/profile.go and is documented in
skills/synth-profile-capture.md.
Reproducibility — across machines, not just across runs
The same (--input, --seed, flags) produces a byte-identical profile
document, and that now holds across CPU architectures as well as
across runs on one machine. A profile document is something you
commit, review in a diff and hand to a colleague, so a guarantee that
only held per-machine was not much of a guarantee: two engineers, one on
an arm64 laptop and one on an amd64 build box, captured the same cohort
and got documents that differed in the last bits of every std and
rho.
Two causes, both closed:
- Fold order. Anywhere capture folds several accumulators into one
shared bucket — canonically the
--top-kcollapse folding every out-of-top-K category into"other"— the fold walks sorted keys. Float addition is not associative and Go randomizes map iteration, so a map-order fold gave a different answer per process. - Float fusion. Go permits
a + b*cto be contracted into a single fused multiply-add; arm64 does it, amd64 does not. Every product in a capture formula now carries an explicitfloat64(...)conversion, which the language defines as forbidding contraction.
Two residuals remain, and they are stated rather than papered over:
--fit-shaperuns an EM fit throughmath.Exp/math.Log, whose standard-library implementations are architecture-specific. Ashapesection’s last bits can therefore still differ between machines. Every other section is architecture-independent.--fit-modelssolves its least squares ininternal/processing/regression, which has not been made fusion-free. Amodelscoefficient’s last bits can differ between machines.
Neither affects a value you would read or report — the divergence is at
the 1e-15 relative level — but a byte-for-byte diff of two documents
captured with those flags on different CPUs may still show movement.
Text mode summary
Profiled 50000 rows from sales.pulse -> sales.profile.json
That single line is the whole of stdout, so `pulse profile create …
log
and any pipeline over it keep exactly the bytes they always had.–run-continuation` adds exactly one more stdout line, and only when it is passed:
Run continuation: 0.7100 overall over 399 pair(s) in 1 shard(s) — high: …
Warning summary
Diagnostics go to stderr, grouped by kind, counted, and capped at three examples per kind. On the 381,324-row cohort that produces 3,005 warnings, the terminal shows twenty-one lines:
Warnings: 3005 in 5 kind(s) — 1 needing attention, 3004 expected
! residual correlation pairs unmeasured (1)
residual correlations: 104 of 5460 pair(s) among 105 modelled field(s) could not be measured …
- thin categorical pair (2931)
thin categorical pair wave=20230220 x ethnicity=1005: only 12 supporting observation(s) (below 30) …
thin categorical pair wave=20230220 x income=1012: only 23 supporting observation(s) (below 30) …
thin categorical pair wave=20230220 x brand=11: only 29 supporting observation(s) (below 30) …
+2928 more of this kind
- model carries no predictors (50)
model for numeric field "placementAware" carries no predictors: no candidate explained at least 1.0% …
+47 more of this kind
…
Full list: sales.profile.json (.warnings)
Three properties are load-bearing:
- Kinds needing attention come first, marked
!, whatever their count. A single model that could not be applied stays visible above two thousand nine hundred thin-pair lines; sorting by volume would bury it, which is exactly how a defect that disabled most of--fit-modelssurvived undetected. - Expected outcomes are counted separately and marked
-. A zero-predictor model is a complete model, a thin pair still ships, and an arbitrated pair claim is the design working; none of them inflate theneeding attentionfigure. - The cap is a screen budget, not a data limit. Three examples per
kind (
maxWarningExamples,internal/cli/warnings.go) is smaller than the twenty synth’s ownwarningsarray uses for its internal roll-ups, because every kind shares one terminal. The document keeps every line.
With --json the summary is not printed at all: the envelope already
carries data.warnings in full.
Where the warnings are
Every diagnostic this command raises — thin pairs, shrunk levels,
zero-predictor models, skipped models, unmeasured residual pairs — lands
in the profile document’s own warnings array, and a grouped summary
of it is printed to stderr (see Warning summary
above). The summary is bounded on purpose and names only three examples
per kind, so the document is still where the full list lives:
# how many, and what kind
jq '.warnings | length' sales.profile.json
jq -r '.warnings[]' sales.profile.json | sed 's/[0-9][0-9]*/N/g' | sort | uniq -c | sort -rn
# the two that change what you can trust
jq -r '.warnings[] | select(contains("skipped"))' sales.profile.json
jq -r '.warnings[] | select(contains("no predictors"))' sales.profile.json
Volume is expected and is bounded on purpose: --conditional on a wide
cohort emits a thin-pair line per thin pair (thousands is normal), while
thin levels are aggregated per level and capped at twenty with a
counted summary. A large count is not itself a problem — the two lines
worth grepping for are the ones above, since a skipped model is a
field that lost its fit and a no predictors model is a field the
cohort does not explain.
Two related warning kinds are not here, because they are not
produced here: conditional relationship conflict: … and
model for numeric field "x" not applied: … arise when the profile is
translated into a generation spec, which happens inside
pulse synth from-profile. That command’s own
stderr summary covers them — it spans all three channels — and the full
list is in its --fidelity-report, where they are appended after this
document’s own warnings.
Exit codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Read error, unsupported field type (PULSE_PROFILE_FIELD_UNSUPPORTED), or write failure |
Examples
Minimal profile
pulse profile create --input sales.pulse --output sales.profile.json
Rich profile with correlations
pulse profile create --input sales.pulse --output sales.profile.json \
--include-stats --include-correlations --top-k 64 --correlation-top-k 32
One numeric conditioned by several categoricals
This is what --fit-models adds that no earlier flag could express.
--conditional measures a numeric field against one categorical at
a time, and at generation time only one of those pairs can claim the
field — the rest are dropped as conflicts, one warning each. A linear
model carries all of them at once.
pulse profile create --input survey.pulse --output survey.profile.json \
--include-stats --conditional --fit-models
Ask the document which fields condition spend:
jq '.models[] | select(.field == "spend")
| {n_obs, r2, shrinkage_alpha,
fields: [.predictors[].field] | unique,
terms: (.predictors | length),
references}' survey.profile.json
{
"n_obs": 9840,
"r2": 0.31,
"shrinkage_alpha": null,
"fields": ["region", "segment", "tier"],
"terms": 27,
"references": [
{"field": "region", "level": "east"},
{"field": "segment", "level": "consumer"},
{"field": "tier", "level": "bronze"}
]
}
Three separate categoricals now condition spend additively: a west
row in the enterprise segment on the gold tier gets all three
offsets, not whichever pair happened to claim the field first. The
"shrinkage_alpha": null is jq making an absent key explicit — every
level in this design cleared 50 rows, so the fit is plain least squares.
The --conditional pairs for spend are still in the document — the
capture keeps everything it measured — and it is
synth from-profile
that stands them down for a modelled target. The retirement is per
target: a numeric that got no usable model keeps its pair, so the
fallback is measured structure rather than nothing.
On the 381k-row survey cohort behind this feature the same query against
nps returns seven conditioning fields — age, ageExact, brand,
category, educationLevel, ethnicity and income — over a
126-column design. Under --conditional alone, six of those seven
relationships were unrepresentable.
Sample-limited profile for a huge cohort
pulse profile create --input ops.pulse --output ops.profile.json --sample-limit 1000000
Round-trip with synth
pulse profile create --input sales.pulse --output sales.profile.json
pulse synth from-profile --profile sales.profile.json --output sales.synth.pulse --rows 10000 --seed 1
pulse cohort inspect sales.synth.pulse
Related
pulse synth from-profile— the consumer of profile JSONpulse synth from-schema— the alternative spec-driven pathskills/synthetic-data.md— spec grammar and the index of focusedsynth-*skillsskills/synth-profile-capture.md— every capture section and the per-field reconstructionskills/synth-marginals.md— boolean and small-integer reconstructionskills/synth-models.md—--fit-modelscapture;skills/synth-model-selection.md— selection and shrinkage- Synth calibration figures and design rationale
- Library: pulse.Profile
Synth calibration figures and design rationale
Companion to profile create, synth from-profile
and synth from-schema.
This page is the archive for two things the synthetic-data and
synth-structural-rules skills deliberately do not carry:
- Calibration figures — what each design decision measured, on the cohort it was measured on. A skill is read by an agent that needs the rules it cannot guess; a figure from one cohort is evidence for a past decision, not a rule.
- Rejected alternatives — the design roads not taken, kept so a later reader does not re-open a closed question, or quietly re-introduce a defect that was already paid for once.
Unless a paragraph says otherwise, “the motivating cohort” is one 381,324-row,
122-field consumer-survey cohort (90 packed_bool fields, 14 small-integer
fields, 16 categoricals), and every figure is as measured at the time of the
change it justifies. Figures are not re-measured on every release; treat
them as dated evidence, and re-measure before quoting one as current.
Marginals
The boolean defect (bernoulli marginals)
A packed_bool falls to the profiler’s numeric accumulator, and reconstructing
it as normal(mean, std) clamped to [0, 1] is wrong on the wire: under the
historical value != 0 threshold P(false) is Φ(−p/σ), so a 20%-prevalence
boolean generated at 69.1%, a 50% one at 84.2%, an 80% one at 97.7%.
On the motivating cohort, where 90 of 122 fields are packed_bool and 90 of 105
captured models target one: mean prevalence error 0.4676, with 89 of 90
fields off by more than 0.05 — every 11–14% brand-attribute item generating at
~64%. Post-fix on the same profile: mean error 0.0032, max 0.0116, zero
fields off by more than 0.05.
Per-cell, the conditional-pair arm recurred once per cell and was worst exactly
where the signal is: a 5% cell generated at 41%. A hand-authored continuous
distribution on a packed_bool is still biased (22.7% for a 0.2 field, because
clamping piles asymmetric mass on the near bound) but no longer inverted, since
toBool rounds at 0.5.
The small-integer defect (discrete marginals)
Measured on the motivating cohort at 40,000 generated rows, source share → generated share under the clamped normal:
| Field | Type / range | Level | Source | Generated |
|---|---|---|---|---|
familiarity | u4, 1–7 (U-shaped) | 1 | 0.2526 | 0.1373 |
familiarity | 7 | 0.2198 | 0.1323 | |
nps | u4, 0–10 | 10 | 0.3200 | 0.2253 |
nps | 0 | 0.0284 | 0.0020 | |
useCon | u4, 1–6 | 6 | 0.3143 | 0.1806 |
useCon | 4 | 0.0778 | 0.2121 | |
sow | u16, 0–15 | 0 | 0.6469 | 0.3804 |
sow levels 10 and 12–15 were never drawn at all. nps’s mean came back
7.3463 against 7.5489 — 3% off while its top level was off by 28% of its own
size, which is how the defect survived. Post-fix on the same profile: 0.2493 /
0.2165, 0.3310 / 0.0269, 0.3154 / 0.0792, 0.6460 with every level present;
worst per-level error across the four fields 0.1153 → 0.0110.
An identity-cell conditional pair without the same treatment measured a worst per-level error of 0.0903.
maxDiscreteLevels (64) admits every coded scale by a wide margin (a u4’s
entire domain is 16, an NPS is 11, a 1–7 Likert is 7, sow is 16) and bounds
the document addition at 64 levels × 2 numbers per field — +0.22% on the
motivating cohort.
Generated-latent variance (a modelled boolean’s prevalence)
Φ(u) is uniform only when Var(u) == 1. At fit time it is by construction; at
generation time the predictors come from their own reconstructed marginals.
Measured on aware (captured p 0.7473906704010238):
Var(prediction)/std² is 0.045270 against the captured R² of 0.077878 — nine
of its 64 terms never fire on a generated row — so the latent variance is
0.970721 rather than 1.0033, and P(aware == 0) returns 0.247193 over 20 seeds
instead of 0.252609: a −0.0054 bias at ten standard errors.
It is not the sampler or the write path, both of which are exact: 200 seeds of a lone bernoulli field give +0.000046 (z = +0.28), and the same 122-field spec with every model removed gives +0.000324 (z = +0.69).
Correlations
A step/staircase copula participant holds its own marginal exactly and
attenuates the realised correlation. Measured on two real 7-level u4 columns
(regard × meaningfulness) at a captured rho of +0.8400 over 200,000 rows:
Pearson +0.8072, Spearman +0.8051, both marginals within 0.003 per level.
isNumericFieldType was a hand-written list until it was derived from
fieldTypeFromName. It carried three nullable_* spellings no spec can declare
and omitted u4, which silently dropped every small-integer pair from
Spec.Correlations: on the motivating cohort that was all 16 captured
numeric pairs and 11 of 14 integer columns, leaving the reconstructed
correlation structure empty with nothing saying so. On that cohort the fix is
byte-identical output — all 16 pairs enter and resolveConflicts drops all 16,
because every participant is already claimed by a model or a conditional pair —
so the visible change is 16 silent absences becoming 10 aggregated arbitration
warnings.
Assume-and-record: on a ten-field matrix built from a top-16 pair list, 29 of 45 pairs were asserted independent with nothing saying they had been asserted rather than measured.
Multi-predictor models
Top-K collapse
On the motivating cohort the unbounded dummy expansion of 16 categoricals was
2,420 columns (brand alone 1,906 levels, 79% of the width) and all 105
numeric targets were skipped as too wide — --fit-models produced nothing at
all on real data. Collapsed, the same cohort is under 200 columns before any
statistical criterion runs.
Rank-deficiency refit
The weakest-predictor refit (maxModelRefitRounds, 4) is the difference between
23 skipped targets and 2 on the motivating cohort.
End state after selection and shrinkage
| Figure | Before selection | After selection | After shrinkage |
|---|---|---|---|
| numeric targets modelled | 0 (all skipped at 2,420 columns) | 103 | 105 |
| carrying predictors | 0 | 53 | 55 |
| admitted (field, target) relationships | — | 76 | 113 |
| skipped targets | 105 | 2 | 0 |
The predecessor pick-one arms applied 86 pairs on the same cohort. The shrinkage interaction, not a change to the variance floor, is the difference between the last two columns: a ridge-augmented Gram is positive-definite, so the two irreducibly-redundant targets fit with both nested candidates jointly shrunk.
Thin-level warning volume
Per-(target, level) emission restated 89 distinct levels in 477 lines on the
motivating cohort, on a warnings slice --conditional alone already puts ~2,900
thin-pair lines on. Aggregated by (field, level) and capped at
maxThinLevelWarnings (20) thinnest-first, with a counted remainder.
Shrinkage retention as a pseudo-count (alpha = 50 / n_obs): 3 observations
keep 6% of the free coefficient, 30 keep 38%, 50 keep half, 500 keep 91%. A
clean design stays exact OLS, recovering an exactly-linear cohort to 1e-6.
The "other" catch-all
Serialising the collapsed catch-all column as numeric rather than as a
categorical_level named "other" cost 105 captured models applied as 20,
with a plausible cohort generated either way and the only signal a warning line
the CLI did not print at the time. Corrected, 55 apply — exactly the 55
capture reports as predictor-carrying, the other 50 being zero-predictor models
dropped on purpose — and Spec.ResidualCorrelations grows from 190 pairs to
1,485 (= C(55,2)).
On a 20,000-row generation from that profile 18,198 rows carry
brand = "other": the dominant arm, not an edge case.
Shape-fitted targets
On the motivating cohort 15 of 19 shape-fitted fields carry a model with
predictors once the exclusivity was removed, including all four motivating ones
(nps, detractor, promoter, catSpend). The mixture quantile inverse costs
~128 math.Erf calls per row, paid only by fields that are both shape-fitted
and modelled.
Residual correlations
Capture is quadratic in modelled fields: 105 targets is 5,460 pairs (= C(105,2)). Translation drops the ~50 zero-predictor models, so 1,485 (= C(55,2)) reach the spec.
Measured source vs generated at 50,000 rows: regard ~ meaningfulness 0.791
against a source 0.835; people ~ promotion 0.794 / 0.835; price ~ promotion
0.783 / 0.830. Before the catch-all fix these three sat at ~0.00 —
annihilation, not attenuation, with every marginal in the fidelity report
looking healthy throughout.
Fidelity report
Probit-score restoration
Refusing a staircase target outright (rather than scoring both sides on the calibrated interval-midpoint probit score) measured as an instrumentation loss of:
| Figure | Refused | Calibrated |
|---|---|---|
| comparable targets | 2 of 53 | 53 of 53 |
| compared predictor terms | 150 of 2,499 | 2,343 of 2,499 |
| step-quantile errors | 51 | 0 |
| flagged terms | 7 | 97 |
Measured score_retention across 2,193 calibrated terms: min 0.075, median
0.456, p75 0.814; ≈0.54 at K = 2 against 0.83–0.92 at K = 7.
Report-wide on the motivating cohort at 40,000 rows: 53 model entries, 53 comparable, 40 flagged; 2,499 predictor entries, 2,343 compared, 97 flagged, 156 unestimable — 143 of those the top-K ranking mismatch, 7 constant columns, 6 below the score’s resolution.
model_residual_correlations is not restored by the same move and stays at 1
compared pair, the rest counted under no_model_fit. The total is C(P, 2) over
P applied models, so the participant count has to be quoted with the figure:
1 of 1,378 at the 53-applied-model capture the table above comes from, and 1
of 1,653 re-measured on a later tree at 58 applied models. Same finding,
different participant count.
Unidentified terms
Not the "other" catch-all — all 54 catch-all terms fire, 3,992–9,046 rows
each. The design’s top-K and Categorical.Top’s rank on different bases (the
design by frequency within the rows the fit listwise-admitted for that target,
the marginal over the whole cohort). Both keep 32 and they disagree: 9 of
brand’s design levels, 4 of ageExact’s and 2 of category’s are absent from
the marginal that generates them — 138 of 2,235 entries in one run, 168 of
2,769 in another.
Quantized-target attenuation
Every modelled target on the motivating cohort is packed_bool or a small
integer, and 30 of 55 models flag; 18 of those are packed_bool and 9 are u4.
peopleAware recovers 0.11 against a captured 0.556 on 1,447 firing rows at an
SE of 0.021 — measured, not noise. That is a property of the generated cohort,
not of the instrument.
Disjointness and cost
55 fields in models, 46 in categorical_numeric_pairwise, intersection 0.
model_residual_correlations on 55 participants: 1,485 compared, 147 flagged,
mean delta 0.055, max 0.490, 0 unmeasured, +6 KB and ~5s on an existing 1.65 MB
/ ~2 min report. The largest gaps sit on packed_bool and small-integer targets
(aware × familiarity 0.738 → 0.248) — the same quantization attenuation the
models section reports on the same fields.
A modelled numeric’s mean spread across a predictor’s levels is visibly
compressed against source (nps across educationLevel: source 1.85,
synthetic 0.73). Recovered ≈ captured means that compression is the legitimate
partial-effect-vs-marginal-contrast gap; recovered itself attenuated means a
real fault.
Structural rules
null_together
The motivating profile’s nps / promoter / passive / detractor block
shares null_rate 0.8260193431307760 exactly and came back all-present in 45
of 40,000 rows against the ~6,960 the block actually has, because
0.174⁴ ≠ 0.174. With the rule: 6,809–6,994 across five seeds, and zero
partial blocks.
owns_nulls
A 50-field {"when": "aware == 0"} gate that is exactly right about every gated
row took regard from a captured 0.2526 to 0.4369, and the five *Aware
gates took people from 0.4273 to 0.7506 — every gated cell correct, every
rate wrong, because the marginal null_rate keeps firing beneath the gate and
the two compose as g + (1−g)·r. With "owns_nulls": true all fifty land on
0.2455 (the gate’s own firing rate) with the gate still exact on 491,200 of
491,200 gated cells; people lands on 0.4208 against a captured 0.4273.
Suppression is a wrapper that discards the null verdict, never a rebuild without the nullable wrapper: against an un-owned run at one seed the other 72 fields are cell-identical and the only change is 381,351 null flags removed on non-gated rows, 0 added, 0 values moved.
Divergence noise term: at 200 rows a correct claim on a 0.25 field misses by 0.02 about half the time, which is why the warning needs both the 0.02 threshold and two standard errors.
Pre-rounding
A three-band NPS classification written straight off the pre-rounding float sets exactly one flag per row and merely disagrees with the number beside it — 476 of 3,486 scored rows at 20,000 rows.
round versus int, measured on the motivating profile at 40,000 rows:
| Normalisation | nps rows moved | Band disagreements repaired |
|---|---|---|
| none | — | 0 of 1,029 |
round(nps) | 0 | 1,029 |
int(nps) | 3,018 of 6,944 scored | 0 |
int reaches band agreement by dropping respondents a point. Same shape on a
gate: familiarity <= 1 fires on 1,843 rows raw, 2,739 behind round() with the
column unchanged, and 3,824 behind int(), which gets there by dropping 1,085
respondents a point.
An ungated set_expr clears the null mask on every row: the four-field NPS
block goes from 6,944 present to 40,000 of 40,000.
The never-fired message’s third arm exists because the pre-rounding advice is wrong for most survey columns: on the motivating cohort 103 of 123 fields round on write and draw exact values, against 3 that carry the hazard.
Rule claims
An unconditional set_expr over promoter takes the applied models from 55 to
54, drops its 54 residual-correlation pairs, and removes its models fidelity
entry. A set_null over the same field changes none of the three. The
documented pre-rounding remedy {"set_expr": {"nps": "round(nps)"}} reads its
own target, so it claims nothing — claiming there would strip nps’s model and
all 54 of its residual correlations.
--suggest-rules
On the motivating cohort the three detectors emit 14 candidates in one file
(--rules consumes it unmodified) for +7.5% CPU over the two-detector figure and
no additional cohort read:
- Gating (8).
awaregating 63 fields — a strict superset of the 50-field block, the extra 13 being the 0.2649 cluster. It also proposesround(familiarity) == 1with evidence identical to sixteen digits: both gate the same 96,326 rows with zero exceptions either way, so nothing in the data separates them.gated_share0.2526093295989762 matched 50 fields’null_rateto ten digits — the figure that identified this cohort’s gate by hand. A bare==under-fires silently: 531 of 901 rows in the committed regression. - Co-missing blocks (4). 50 fields at 0.2526093295989762, 26 at
0.3564055763602606 (the cluster no single gate could explain), 13 at
0.2648718674932603, and the four-field NPS block at 0.8260193431307760 — plus
one always-null column (
lgbt, null on 20,000 of 20,000 generated rows) and four near misses at 0.9989 / 0.9798 / 0.9788 / 0.9537. The 27th field at 0.3564, which research had counted into the cluster by rate, is not null on the same rows (145 disagree) — the exact case an identical-rate test would have fabricated. - Dependencies (2).
nps -> {detractor <= 6, passive 7..8, promoter >= 9}on 66,343 co-present rows, with the standard 9-10 / 7-8 / 0-6 NPS definition as the detector’s output rather than its input; andaware = round(familiarity) >= 2on all 381,324 rows, which answers the proxy question the gating detector could only report.
Eleven further findings are reported rather than proposed: 5 fields too wide to gate, 3,932 co-missing relationships restating the blocks, 4 near blocks, 5 fields too wide for dependency, 3 constant columns.
Ordering has teeth
Five *Aware gates sit inside the 50-field block and one set_expr writes a
sixth gate’s field. Under detector preference alone, 8,693 of 20,000
generated rows carried an answer to a question the same file said was never
asked; 0 after the writer-before-reader reorder, with the coherence table
unmoved either way (which is why the orphan count is its own gate). A dependency
emitted first rather than last leaves 940 orphan rows against 0.
The accepted candidate file, fed back
Edited only by deletion (the familiarity proxy gate, the useCon gate and the
familiarity -> aware dependency removed) and fed back through
synth from-profile --rules at 40,000 rows:
| Coherence check | Without rules | With the candidate file |
|---|---|---|
| all four NPS fields present together | 39 | 6,968 (target ~6,959) |
| all four null together | 18,560 | 33,032 |
nps present, flag null | 6,845 | 0 |
nps null, flag present | 14,556 | 0 |
| mixed blocks | 21,401 | 0 |
| exactly one flag set | 113 | 6,968 |
| band agreement | 16 | 6,968 (100%) |
| non-gated rows carrying all 50 fields | 0 | 22,572 |
All 50 gated fields are null on 9,878 of 9,878 aware == 0 rows with none
present. Every candidate fires on a non-zero row count (lowest 5,491 of 40,000)
and no rule never fired line is raised.
Two deletions are themselves findings. Accepting familiarity -> aware moves
aware’s generated marginal from 0.2469 to 0.1373, because an exactly-determined
dependency inherits its source’s reconstruction error. Keeping the useCon
gate over the NPS block double-counts against the block’s own null draw,
collapsing nps presence from 6,884 to 1,098 — a gate repairs co-missingness
only, which is what owns_nulls exists for.
Rejected alternatives
Each of these was considered and closed. Re-opening one needs a reason the original did not have.
| Alternative | Why not |
|---|---|
| Value-space model draw (draw the marginal, then add the prediction) | destroys the fitted marginal it was meant to protect. Must not be quietly re-introduced |
| Statistical significance as the predictor-admission gate | at 381k rows every candidate is significant, including ones explaining a thousandth of the variance — “all predictors enter” in disguise, and silently, since every coefficient it admits is real |
| Incremental (residual-on-residual) candidate scoring | makes the admitted set depend on candidate order; redundancy is resolved by the solver’s refit instead |
| A functional-dependency probe for collinear predictors | the dependency that bites is LINEAR, not functional, so the probe misses it while claiming to have handled it |
Sharing MinPairObservations (30) for thin LEVELS | different questions (“enough co-occurrences to mean anything” vs. “enough rows for a free coefficient”); one constant would couple two unrelated tunings forever |
Refusing an incomplete correlations list | an incomplete list is the normal input; zero is the only completion that adds no structure the caller did not ask for. Silence was the defect, not the completion |
| Compensated (Kahan) summation for reproducible capture | still order-dependent in principle; sort the keys instead |
math.FMA for architecture-independent floats | forces fusion everywhere — it changes amd64’s values rather than preserving them |
| Inverse-Mills recovery for a step target | reports a large false attenuation for a generation path that is exactly correct |
| An ordered-probit refit for the recovery | more efficient, not more correct; internal/processing/regression has binomial+probit as a reserved, unimplemented link |
| A tolerance-terminated mixture quantile inverse | makes the answer depend on how many steps a given p needed, breaking byte-determinism; Newton additionally explodes where the density underflows between modes |
One set map with a {"$expr": …} marker | leaves {"set": {"region": "west"}} undecidable between a literal and an identifier |
| A two-phase rule pass (evaluate gates, re-run dependent stages) | doubles the stage surface and needs a rule↔model dependency order no document declares |
Append mode for --rules | a derived spec carries nothing to append to; it would only invent an ordering question |
Auto-applying --suggest-rules candidates | a detected pattern can be a coincidence of the sample, and detection finds the statistical gate while a human knows the semantic cause |
Grouping co-missing blocks by identical null_rate | two unrelated fields can share a rate to sixteen digits and overlap by chance; admission is identical null PATTERN |
| A per-field null-pattern hash | answers only the yes/no question, so a near block is invisible and indistinguishable from an unrelated field |
| Truncating (rather than abandoning) an over-cap histogram, level map or field set | every share below the cut becomes a share of an arbitrary subset — the defect the detector exists to remove |
Index-based handles in _evidence | the file is edited by deletion and every index below a deleted line shifts |
Read-side rehabilitation of the old numeric catch-all spelling | would freeze the bug into the file format; re-capture is the answer |
Topologically sorting rules[] | declaration order is the one ordering an author can read off the document |
Refusing a null_together member that is not nullable | the gated-block idiom names a never-null field first on purpose |
Related
profile create— capture flags and the full document shape.synth from-profile— generation,--rules,--emit-spec, the fidelity report.synth from-schema— the hand-authored spec grammar.skills/synthetic-data.md— the contract statements an agent must not guess.skills/synth-structural-rules.md— therules[]/constraints[]contract (entry skill; focusedsynth-rule-*/synth-rules-from-profilechildren).
pulse mcp
Audience: operators wiring Pulse into an MCP-aware AI client (Claude Desktop, Claude Code, generic MCP clients).
pulse mcp runs the Model Context Protocol server over stdio. The AI
client launches pulse mcp as a subprocess, speaks MCP over its
stdio streams, and shuts it down on session close.
LLM agents using MCP: the agent-side guide is the
session-bootstrapskill — fetch it viapulse_skills_getfor the tool catalog and request shapes. This page is for the human setting the server up.
Synopsis
pulse mcp [--data-dir PATH] [--feature-profile FILE] [--return PRESET] [--limit NAME=VALUE]... [--bind-on-open] [--no-cohort-scan] [--metrics-addr HOST:PORT] [--log-level LEVEL] [--log-format text|json]
The command reads stdin, writes MCP responses on stdout, and writes a
one-line startup notice (and any subsequent diagnostics, including
--log-level records) on stderr. Nothing but JSON-RPC is ever written
to stdout.
Flags
| Flag | Type | Default | Purpose |
|---|---|---|---|
--data-dir | string | from PULSE_DATA_DIR env var | Cohort base directory |
--bind-on-open | bool | true | Register session-scoped JSON-schema-bound tool variants on successful pulse_inspect |
--no-cohort-scan | bool | false | Skip the startup walk that enumerates .pulse files as pulse:// resources (env: PULSE_MCP_NO_COHORT_SCAN) |
--feature-profile | string | from PULSE_FEATURE_PROFILE env var | Feature profile JSON file — an OS path, not resolved under the data dir. An invalid profile fails startup |
--return | string | unset (instance / profile default, else standard) | return preset (full, standard, minimal) for a tool request without its own return block. An unknown preset fails startup (PULSE_RETURN_INVALID) |
--limit | string, repeatable | unset (profile limits, else the built-in defaults) | Instance resource limit as name=value (e.g. max_groups=1000000, request_timeout=30s). An unknown name or bad value fails startup (CLI_INPUT) |
--metrics-addr | string | unset (no port opened) | Serve Prometheus text-format metrics on GET /metrics at host:port (:0 picks a free port). A bad or busy address fails startup (CLI_INPUT) |
--log-level / --log-format | string | off / text | Root persistent flags (Global flags): structured logs on stderr |
--data-dir is required in one of its two forms (env var or
flag). The MCP server fails to start otherwise:
data directory required: set PULSE_DATA_DIR or pass --data-dir
–feature-profile
Loads a feature profile into the served instance. The value is a host
OS path — absolute or relative to the process working directory, never
resolved under --data-dir — read and strictly parsed before
pulse.New. The flag wins over PULSE_FEATURE_PROFILE; the env var is
read by this leaf only, so every other pulse command stays
unprofiled. Any invalid profile (unreadable file, malformed JSON, an
unknown key or feature, an unmet dependency) aborts startup with a
PULSE_FEATURE_PROFILE_* error. The server then registers only the
tools and prompts the profile offers; a hidden tool fails like a
nonexistent one. A profile whose behaviour sets
disable_cohort_scan, or that omits mcp_extra:cohort_resources,
skips the startup scan as if --no-cohort-scan were passed.
The stderr startup notice reports the EFFECTIVE settings, so a profile
that turned the scan off reads cohort-scan: false even without the
flag, and a loaded profile is named by its profile label
((unnamed) when it has none):
pulse mcp: serving over stdio (data dir: /mnt/cohorts, bind-on-open: true, cohort-scan: false, return: standard, feature-profile: self-serve)
Embedders get the same effective view from
mcpserve.Describe(p, opts), which returns a mcpserve.ServeInfo
(CohortScan, FeatureProfileLoaded, FeatureProfile, DefaultReturn,
Limits). The library
contract is Feature Profiles.
–return
MCP agents pay tokens for every key they receive, so the request-carrying
tools (pulse_process, pulse_predict, every pulse_compose slot, every
pulse_process_chain stage) shape a request WITHOUT its own return block
by the standard preset: the primary result plus metadata, tests, crosstab
and overlays, never components. The library default stays full.
Precedence, highest first:
- the request’s own
returnblock (a slot’s, a stage’s); --return/gosdk.Config.DefaultReturn/mcpserve.Options.DefaultReturn;- the instance default —
pulse.Options.DefaultReturn, else the feature profile’sreturn; - the built-in
standard.
--return full restores the unshaped (pre-default) output byte for byte.
The startup notice always names the effective preset (return: standard;
(custom) when the instance default is a preset-less include/exclude
selection), and ServeInfo.DefaultReturn carries the same value.
An engine that disables components keeps them off under any return;
only a request disable_components: false re-opens them. The full contract
is Response shaping.
–limit
Sets the served instance’s resource limits — the
pulse.Options.Limits layer — one name=value per flag, repeatable:
pulse mcp --limit max_groups=1000000 --limit request_timeout=30s
Names are the snake_case limit keys the feature profile limits
section and every PULSE_LIMIT_* error’s limit detail use:
request_timeout, max_groups, max_crosstab_cells,
max_estimated_memory, max_matrix_dim, max_compose_slots,
max_chain_stages, max_join_build_rows, max_derived_columns. A
value is a positive integer
(underscores allowed, 10_000_000), a Go duration for request_timeout
(30s, 2m), unlimited (or -1) for no limit, or 0 for the
default. Per key, --limit wins over the feature profile’s limits,
which wins over the built-in default; a later flag for the same name
wins. A malformed pair, an unknown name or a bad value aborts startup
with CLI_INPUT (details {flag, value, limit?}).
The startup notice echoes the limits that differ from the defaults, in the same grammar:
pulse mcp: serving over stdio (data dir: /mnt/cohorts, bind-on-open: true, cohort-scan: true, return: standard, limits: request_timeout=30s max_groups=1000000)
Agents read the effective values from the manifest’s limits block
(cache key (pulse_version, feature_set_digest, limits_digest));
embedders read the same block from ServeInfo.Limits.
–metrics-addr
Opt-in Prometheus exporter. Unset (the default), pulse mcp opens no
port and records no metrics. Set, it binds host:port after the tool
surface registers — a bad or busy address fails startup with
CLI_INPUT — and serves the Prometheus text format on GET /metrics
(every other path is 404) until the server exits. The startup notice
names the bound address, so :0 reveals the port it picked:
pulse mcp --log-level info --log-format json --metrics-addr 127.0.0.1:9090
# Stderr: pulse mcp: serving over stdio (data dir: /mnt/cohorts, bind-on-open: true, cohort-scan: true, return: standard, log-level: info, metrics: 127.0.0.1:9090)
Bind a loopback or otherwise private address: the endpoint carries no authentication. Metric labels are operation kinds, scopes, phases, error codes, limit names and hook names — never cohort paths or data.
–bind-on-open
When a session calls pulse_inspect successfully, the server can
register session-scoped tool variants whose JSON Schemas constrain
field-name parameters to the cohort’s actual fields. This narrows
the LLM’s choices and prevents typos at parameter-binding time.
Default: true. Pass --bind-on-open=false if your client binds
tool schemas itself.
The binding logic lives in the SDK-free core
internal/mcp/bind.go
(the per-session server mutation that consumes it lives in the go-sdk
adapter, mcp/gosdk/bind.go); see
Adding an MCP tool for the
LLM-facing implications.
–no-cohort-scan
At startup the server walks the data directory once and registers every
.pulse file it finds as an exact-match pulse://<path> resource, so
resources/list enumerates the available cohorts. On a large, remote or
volatile data root that walk is the most expensive thing startup does,
and the enumeration is not always worth it — a client that already knows
which cohort it wants never reads the list.
--no-cohort-scan (or PULSE_MCP_NO_COHORT_SCAN=1) skips the walk. The
pulse://{+path} resource template is registered either way, so
every cohort stays readable by URI:
resources/read pulse://surveys/2026.pulse # works with or without the scan
resources/list # lists cohorts only WITH the scan
Nothing else changes: the static pulse://schema resource, the
pulse-skill:// resources, every tool, and pulse_inspect (the tool
route to the same header+schema JSON) are unaffected.
Embedders reach the same knob as gosdk.Config{DisableCohortScan: true}
or mcpserve.Options{DisableCohortScan: true}; both default to scanning.
Wiring it into Claude Desktop
~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"pulse": {
"command": "/usr/local/bin/pulse",
"args": ["mcp"],
"env": {
"PULSE_DATA_DIR": "/var/data/pulse"
}
}
}
}
Restart the client. The Pulse tools (pulse_manifest, pulse_inspect,
pulse_predict, pulse_process, pulse_compose, pulse_sample,
pulse_facet, pulse_import, pulse_dedup, pulse_materialize, pulse_recommend, pulse_explain, pulse_drop, pulse_imports_list,
pulse_examples_search, pulse_examples_get, pulse_errors_lookup,
pulse_skills_list, pulse_skills_get) and resources
(pulse://*.pulse, pulse-skill://*) appear in the tool/resource
list. The per-cohort pulse://*.pulse entries come from the startup
scan — see --no-cohort-scan.
Wiring it into Claude Code
~/.claude.json (or per-project .claude.json):
{
"mcpServers": {
"pulse": {
"command": "/usr/local/bin/pulse",
"args": ["mcp"],
"env": { "PULSE_DATA_DIR": "/var/data/pulse" }
}
}
}
The full LLM-side recipe (including resource URIs and the schema binding details) is in Adding an MCP tool and Wiring an MCP client.
Exit codes
pulse mcp is a long-running process. It exits non-zero only on
fatal startup failure (missing data dir, transport error). Once
serving, an MCP client controls the lifecycle.
Examples
Foreground run for debugging
PULSE_DATA_DIR=/tmp/pulse-data ./bin/pulse mcp
# Stderr: pulse mcp: serving over stdio (data dir: /tmp/pulse-data, bind-on-open: true, cohort-scan: true, return: standard)
Disable schema binding
PULSE_DATA_DIR=/tmp/pulse-data ./bin/pulse mcp --bind-on-open=false
Skip the cohort scan on a large data root
PULSE_DATA_DIR=/mnt/cohorts ./bin/pulse mcp --no-cohort-scan
# Stderr: pulse mcp: serving over stdio (data dir: /mnt/cohorts, bind-on-open: true, cohort-scan: false, return: standard)
Or from an MCP client config, which sets env rather than args:
"env": { "PULSE_DATA_DIR": "/mnt/cohorts", "PULSE_MCP_NO_COHORT_SCAN": "1" }
Inspect what the server registers
# Manifest exposes the MCP tool list
pulse --json | jq '.data.mcp_tools[]'
Related
- How LLMs Use Pulse — the pointer table from this site into the skill pack
- Adding an MCP tool — LLM-side wiring, tool catalog, resource schemes, schema binding
- Deployment — production hardening notes
- Troubleshooting — common MCP failure modes
Flag Reference
Audience: CLI users who want one page that lists every flag and every environment variable in scope across the binary.
The per-command pages list each command’s full flag set; this page is the cross-cutting reference for flags that appear on multiple commands and for the environment variables Pulse reads.
LLM agents using MCP: there is no LLM-facing skill for the CLI surface. Agents go via MCP tools (
pulse_process,pulse_inspect, …) — seepulse mcpand Adding an MCP tool.
Global flags
Available on the bare pulse invocation:
| Flag | Effect |
|---|---|
--json | Print the root manifest as JSON (envelope-wrapped) |
--slim | With --json, drop prose descriptions for size-sensitive clients |
--intent ID | With --json, scope the manifest to one intent-taxonomy ID (e.g. compare_groups): only the operators serving it and its skills, the fixed sections dropped and listed in elided. An unknown ID is a PULSE_RECOMMEND_INTENT_UNKNOWN error envelope |
All default to off. pulse --json is the discovery entry point — it
emits the manifest documented at pulse manifest.
Persistent on every leaf (accepted before or after the leaf name):
| Flag | Effect |
|---|---|
--log-level off|debug|info|warn|error | Structured log records on stderr at or above the level (default off: nothing). debug adds the per-operation plan record (execution arm, workers, shards, projection) |
--log-format text|json | Log record encoding (default text); ignored while --log-level is off |
Logs never reach stdout, so --json output and the pulse mcp
JSON-RPC transport stay byte-identical with or without them. Records
carry identifiers, counts, timings, enums and error codes only — never
row data, filter values or error messages. An unknown value is
CLI_INPUT. There are no environment-variable forms. Leaves that never build a Pulse instance (import, convert, skills, errors, version, features) accept the flags but emit nothing. pulse mcp --metrics-addr is documented under pulse mcp; see also Observability.
Environment variables
| Variable | Used by | Required | Purpose |
|---|---|---|---|
PULSE_DATA_DIR | All commands when no path override is given; required by pulse mcp | conditionally | Base directory for cohort files. Relative cohort paths resolve against it |
PULSE_IMPORTS_DIR | pulse import auto / list / drop | no | Managed-imports subdir under the data root. Defaults to imports |
PULSE_IMPORT_TTL | pulse import auto | no | Default TTL for managed imports. Go duration (24h, 30m), day form (7d, 30d), or pin. Defaults to 7d |
PULSE_LABEL_TABLES_DIR | pulse api sample --labels, pulse api facet --labels | no | Directory of JSON files auto-loaded as label tables at pulse.New time; each *.json becomes one table keyed by its filename |
PULSE_MCP_NO_COHORT_SCAN | pulse mcp | no | Skip the startup walk that enumerates .pulse files as pulse:// resources. Same as --no-cohort-scan; cohorts stay readable by URI, only resources/list loses them |
PULSE_FEATURE_PROFILE | pulse mcp only | no | OS path (cwd-relative or absolute) to a feature profile JSON file. Same as --feature-profile, which wins when both are set. Every other leaf ignores it |
PULSE_DATA_DIR is the only required PULSE_* environment variable.
The Makefile auto-loads a repo-root .env file so you can keep these
(and any future env vars) there for development.
When embedding the library, you can bypass the env vars entirely by
passing pulse.Options{DataDir: "/path"}, pulse.Options{ImportsDir, ImportTTL, LabelTablesDir, FS: myFs} etc. — see
pulse.New & Options.
--json envelope
Almost every leaf command accepts --json, which switches output
from human prose to a structured envelope. The envelope shape is
fixed and documented in CLAUDE.md → Output Format Contract:
{
"format_version": "1.1",
"data": { /* operation-specific result */ },
"request": { /* normalized request, omitted unless --echo-request was passed */ },
"errors": [ /* {"code": "...", "message": "...", "details": {...}} */ ],
"warnings": [ /* same shape */ ]
}
format_version is currently "1.1". errors and warnings are
always arrays (never null) so JSON consumers can index without
nullable-check overhead. request is opt-in (see
--echo-request below); data.components is the
additive Response.Components slot documented per leaf — see
api process → --json.
Shared per-command flags
Several flags appear on multiple commands with identical semantics.
--no-defaults
Available on: api process, api compose.
Disable the runtime smart-defaults pass that infers operator Type
from the named field’s schema type when the caller omits it. Forces
the request to be source-of-truth. See pulse.New &
Options for the underlying library option.
--stream
Available on: api process, api compose.
Stream result rows as NDJSON (one row per line) instead of buffering
the full result. For compose, each line carries an {"index": N, "row": {...}} shape so consumers know which sub-request produced
each row. See Streaming & ProcessStream.
--strict
Available on: api process, api predict.
Treat request-validation warnings (e.g. numeric aggregation on a
categorical field, low-quality field description) as errors. On
api predict this fails validation; on api process it refuses to
execute. Useful in CI gates that want the strictest possible
validation.
import <format> also takes --strict, with a narrower meaning: it
escalates the parent-group viability warnings only — see
--strict (import).
--echo-request
Available on: api process, api process-chain, api compose,
api predict, api sample, api facet.
Include the normalized request — smart defaults resolved, label
bindings expanded — on envelope.request. Absent (and omitted from
JSON) by default so the envelope shape is unchanged for callers that
do not need it. Streaming output (--stream) skips the echo because
NDJSON has no envelope.
--full-dict
Available on: cohort inspect.
Print full categorical dictionaries instead of truncating after 100
entries. Pair with --json for programmatic consumption.
--strict / --seed / --rows
synth from-schema and synth from-profile use --seed (for
deterministic RNG) and --rows (override the spec’s row count). See
the per-command pages.
--emit-spec / --rules
Available on: synth from-profile.
--emit-spec <path> writes the profile-derived spec (after any
--rules merge) as indented JSON. It is the spec that actually
generated, so feeding it to synth from-schema at the same --seed
reproduces the same rows — and it is the only way to see which captured
models survived translation, which distribution each field reconstructed
to, and which conditional pairs were retired.
--rules <path> loads structural rules from a standalone JSON file — a
bare array of rule objects, the same shape as a spec’s rules key — and
replaces the derived spec’s rules with them. Refusals carry E1’s own
PULSE_SYNTH_RULE_* codes with the file path in details.path. See
synth from-profile.
--suggest-rules
Available on: profile create.
--suggest-rules <path> detects structural rules on the same scan the
profile already makes — no additional cohort read — and writes them to
<path> as the bare rules array synth from-profile --rules consumes
unmodified. Three detectors, one file: GATING relationships (a
low-cardinality field whose levels split a target’s null rate into ~1 and
~0, proposed as set_null), CO-MISSING blocks (fields null on exactly
the same rows, proposed as null_together) and EXACT DEPENDENCIES (a
field that IS a function of one other, proposed as set_expr). An
identical null RATE is never enough for a block — two unrelated fields
can share one — so admission is identical null PATTERN; near misses,
almost-determined pairs, always-null columns and constant columns are
reported in warnings rather than proposed.
Dependency detection is bounded, deliberately, and the bounds are
published — in every candidate’s _evidence.note, on stderr and in
profile create. TARGETS are packed_bool and u4
only; SOURCES are categorical_* / packed_bool / u4 with at most 16
observed levels; the dependency is on exactly ONE source. A field missing
from the file was not cleared, it was not examined.
PROPOSED, never applied. Detection finds the STATISTICAL gate; a human
knows the SEMANTIC one, and the two are routinely different fields that
move together. Each candidate carries its own measurement on _evidence
— the one inert slot of a rule, which generation never reads — so a
candidate the analyst deletes takes its evidence with it. Gating
candidates are emitted first and dependency candidates LAST, because
declaration order is applied order: a block that FOLLOWS a hand-narrowed
set_null repairs it, and a set_expr derivation must re-resolve its
own block after every null-state rule has run. The profile document
itself gains no section: absent the flag it is byte-identical, and with
it only warnings moves. See profile create.
--run-continuation
Available on: profile create.
--run-continuation measures, on the same scan the profile already
makes, each field’s run-continuation: the fraction of adjacent row pairs
whose on-wire bytes (and, for a nullable field, null bit) repeat. That is
exactly the hit rate of the run-skip decode, which rewrites only the
fields that changed since the previous row — so a cohort that was sorted
by its parent key upstream scans faster, and one re-imported without
that ORDER BY silently loses the win. The additive run_continuation
section carries the per-field rates, an overall figure, the
high-continuation fields and a one-line advice; pairs never span a shard
boundary. Absent the flag the document is byte-identical. See
profile create.
--schema / --ignore-extra
Available on: import csv, import tsv, import ndjson,
import jsonarray, import parquet, import arrow, import excel,
import spss, import predict, convert.
--schema FILE supplies an explicit schema (a JSON array of
{name, type, description, nullable}, plus precision / scale on a
decimal128 field; import schema-template INPUT emits an editable one,
decimal precision and scale included). The file carries no byte
offsets — the layout is derived from field order and types exactly as
inference derives it, so a file mixing u4 / packed_bool with
byte-aligned fields writes the same schema block an inferred import of
the same fields does. Its fields are matched to the source’s columns
by exact name — case and whitespace included — never by position, so
the schema may list them in any order and that order is the cohort’s
field order. For NDJSON and JSON-array sources the columns are the keys
of the first object. Arrow, Parquet and SPSS sources, whose own schema
an explicit one overrides, are matched the same way. Every mismatch is
refused before a row is read, under its own code:
| Code | When | Details |
|---|---|---|
PULSE_IMPORT_SCHEMA_FIELD_MISSING | a schema field has no column of that name | field, columns |
PULSE_IMPORT_SCHEMA_COLUMN_UNMAPPED | a source column no schema field names | column, fields |
PULSE_IMPORT_SCHEMA_DUPLICATE | a field name declared twice, or a repeated header name | field or column, side |
PULSE_IMPORT_SCHEMA_KEY_LATE | an NDJSON / JSON-array key first seen after the first object | column, row, columns |
PULSE_IMPORT_SCHEMA_TYPE_UNKNOWN | a field’s type is not a registered field type (never read as f64) | field, type, valid |
PULSE_IMPORT_SCHEMA_INVALID | the file is not a JSON array of field objects, or lists no field | reason, error |
--ignore-extra drops source columns, and late keys, that the schema
does not name instead of refusing (library: ImportJob.IgnoreExtra /
ConvertJob.IgnoreExtra). A schema field with no column is still
refused. Without --schema the flag is inert and inference is
unchanged.
The MCP pulse_import tool takes the same document inline as its
schema argument, with ignore_extra and float beside it, and
refuses with the same codes (plus PULSE_IMPORT_SCHEMA_INVALID for an
unknown entry key or a schema on a .pulse source).
--float
Available on: import csv, import tsv, import ndjson,
import jsonarray, import parquet, import arrow, import excel,
import spss, import predict, import schema-template, convert.
The width schema inference gives an all-float column: f64 (the
default) keeps every decimal the source wrote exact, so 0.1 and
123456.789012 round-trip and a money column’s sum matches the f64
reference; f32 halves the column’s bytes but rounds every value to
about 7 significant digits. Under f32 a column whose sample holds a
value past f32’s range still infers f64, and a later such value
promotes the field (PULSE_IMPORT_WIDTH_PROMOTED, below). Any other
value refuses the run with PULSE_IMPORT_FLOAT_WIDTH_INVALID (value,
valid), with or without --schema. Inert with --schema or a source
that carries its own schema (Arrow, Parquet, SPSS); a managed import’s
column_type_overrides still forces a column’s type either way.
Library: ImportJob.FloatWidth / ConvertJob.FloatWidth ("f64",
"f32"; "" is f64). import auto and pulse_import take no
float knob and infer f64.
--elide-constants
Available on: import csv, import tsv, import ndjson,
import jsonarray, import parquet, import arrow, import excel,
import spss, import predict (reports what would be elided and the
bytes saved; writes nothing).
--elide-constants stores every field that holds exactly one value — or
is null — on every imported row once, in the schema block, and drops it
from each record. Constancy is measured over the full row pass, never
the inference sample. The output is a format 0x02 cohort that binaries
predating 0x02 cannot open, which is why it is opt-in. Nothing is
elided (and the cohort stays byte-identical 0x01) for fewer than two
rows or when the saving would not repay the schema-block growth; when
every field is constant the lowest-index field stays in the row. The
elided fields are printed, and reported as ElidedConstants under
--json. See parent groups.
--group
Available on: import csv, import tsv, import ndjson,
import jsonarray, import parquet, import arrow, import excel,
import spss, import predict, import auto. Repeatable — one parent
group per flag. On import auto the group is judged at the default
ratio floor and its findings are always warnings (--json puts them in
warnings, the per-group figures under data.groups); the floor and
--strict stay on the per-format leaves, since neither changes the
bytes written.
On import predict the declaration is evaluated, not applied: see
--suggest-groups.
--group KEY[,KEY...]:MEMBER[,MEMBER...] declares a parent group:
the key fields identify a parent (a customer ID, say) and the members
are the fields that key determines (its name, region, tier). Each
distinct tuple is stored once in the schema block and every record
carries a 4-byte index instead of the members, so a denormalised join
shrinks by its repeated parent block. --group F1,F2,... (no colon) is
a plain tuple group: each distinct combination is one entry, with no
key check.
pulse import csv -i lines.csv -o lines.pulse \
--group cust_id:cust_name,cust_region \
--group prod_id:prod_cat
A keyed declaration is checked, not trusted: if two rows share a key
tuple but disagree on a member (value or null state), the import fails
with PULSE_GROUP_MEMBER_NOT_CONSTANT naming the member, the record
index and the source row — a wrong declaration is refused, never
silently turned into a larger dictionary. A field in two groups is
PULSE_GROUP_FIELD_CONFLICT (naming both groups), an unknown field
PULSE_GROUP_FIELD_UNKNOWN, a malformed value
PULSE_GROUP_DECLARATION_INVALID; all three fail before the row pass.
More than 2^32 distinct tuples is PULSE_GROUP_ENTRIES_EXHAUSTED.
Every declared group passes a per-group viability gate. A group
whose members are no wider than the 4-byte index can never save space:
it is dropped (its members stay in the row) with a
PULSE_GROUP_TOO_NARROW warning naming both widths. A group whose
dedup ratio (rows per distinct tuple) is below --dedup-ratio-floor,
or that makes the file no smaller, is still written, with a
PULSE_DEDUP_LOW_RATIO warning carrying the ratio, the resident
dictionary bytes and the byte delta. Other groups on the same import
are unaffected. The text output prints each group’s ratio, resident
dictionary bytes and byte delta; --json reports them per group under
Groups and the findings in warnings.
--suggest-groups
Available on: import predict.
Finds candidate parent groups before you import, and measures each one.
A candidate is a single key field plus every field it determines (the
same value on every row sharing a key value). Each is reported with its
fields, verdict, distinct-tuple count, ratio, resident dictionary bytes,
byte delta and the exact file size with that group declared, next to
the flat cohort’s size. You also get a ready-to-paste --group value
and the structured {key, members} form. A candidate below the width
or ratio floor is listed with its verdict and reason, not hidden.
suggested lists only the admitted candidates that do not share a field
with a better one. A source with no viable group suggests none.
pulse import predict -i lines.csv --suggest-groups
pulse import predict -i lines.csv --group cust_id:cust_name,cust_tier
It suggests only. Nothing is declared until you pass --group to
the import. Candidates are nominated over the first rows, up to
10,000 (fewer on a wide schema; window_rows / window_bound). The
500-row inference sample is too small at typical fanouts: it holds a few
dozen parents, and a column that happens to be constant across so few
looks like a parent attribute. Candidates are then confirmed and
measured over every row. A member that varies within its key later in
the file is dropped and listed under rejected_members. The figures
therefore come from the same conversion and the same internal/encoding.DedupGate
arithmetic the import uses, so they are not a sample estimate. They
equal what import --group reports and what cohort inspect shows
afterwards. A dependency can still break in a later re-export of the
same source; the import catches that with
PULSE_GROUP_MEMBER_NOT_CONSTANT.
Detection has these limits:
- Keys are single fields; composite keys are not detected. Evaluate one
with
--group A,B:MEMBERS. - Up to 64 keys are evaluated, highest cardinality first
(
keys_over_bound), and 16 candidates are confirmed (candidates_over_bound). - A field constant across the window, or declared in a
--group, is never nominated. - Candidate dictionaries share a 512 MiB budget. A candidate that
outgrows its share is reported
unmeasured.
Cost: import predict normally reads rows without converting them.
With --suggest-groups, --group or --elide-constants it converts
every row as the import would. That measured roughly 1.2x-1.4x the
import’s own time on synthetic 12- and 102-column sources, against
about 0.15x for plain predict. It does not spool the rows, so memory is
bounded by the window and the candidate dictionaries, not the file.
When a field outgrows its sample-inferred width mid-file (see
PULSE_IMPORT_WIDTH_PROMOTED below) the measured pass stops measuring,
finishes the file to find every promotion, then rewinds and measures
again at the final widths, so such a source is read twice. Only the
measured pass sees the full-file widths: plain import predict reports
the sample-inferred schema.
--json adds Projection, Groups, GroupWarnings, ElidedConstants,
GroupCandidates and WidthWarnings to data; format_version stays
"1.1".
Width promotion (inferred imports and converts)
Not a flag: every inferred import <format>, import auto,
pulse_import and pulse convert does it. Inference sizes categorical_* rungs and integer
widths from the first --sample-rows rows. A later value that outgrows
them promotes the field to the narrowest type that holds it instead of
dropping the row: categorical_u8 → u16 → u32; u4 → u8 → u16
→ u32 → u64 for a larger non-negative integer; u4..u32 → f64
for a negative, fractional or signed number; f32 → f64 for a value
outside f32’s range — past MaxFloat32, or a non-zero magnitude f32
would flush to zero. Floats only infer f32 under --float f32 (see
below), and by that same range test, so a value f32 merely rounds
(0.1) never promotes. Every step is lossless
for every value already read. Each promoted field draws one PULSE_IMPORT_WIDTH_PROMOTED
warning (field, from, to, source_row) in the envelope warnings
and data.WidthWarnings (width_warnings on import auto /
pulse_import). A --schema or an authoritative source schema (SPSS,
Arrow, Parquet) never promotes: its overflow stays a
PULSE_IMPORT_ROW_ERROR. A column_type_overrides column never promotes
either, and any value it cannot hold refuses the whole import with
PULSE_IMPORT_OVERRIDE_INVALID. So does a non-number, a
non-integer in a u64 column, a non-boolean in a packed_bool column
(no type holds both losslessly), and a dictionary past categorical_u32.
pulse convert copies cell text, so a promotion never changes what the
target receives. It reports the promoted types in data.Schema and the
warnings in data.WidthWarnings and the envelope warnings, and a
--keep-pulse intermediate is byte-identical to a plain import of the
source. On a declared schema (--schema, or an SPSS / Arrow / Parquet
source’s own) a full categorical rung still stops the convert with the
fatal PULSE_IMPORT_CATEGORICAL_OVERFLOW, and no intermediate is written.
--dedup-ratio-floor
Available on: every import <format> leaf, and import predict.
Default 2.
The rows-per-distinct-tuple floor below which a --group draws
PULSE_DEDUP_LOW_RATIO. A floor of 1 disables it, leaving only the
check that the group actually makes the file smaller. The group is
written either way unless --strict is set.
--strict (import)
Available on: every import <format> leaf, and import predict (which
then fails where the import would).
Turns the parent-group viability warnings (PULSE_GROUP_TOO_NARROW,
PULSE_DEDUP_LOW_RATIO) into errors: the import fails with that code
(errors[0].code under --json) and writes nothing. Other import
warnings are unaffected.
Groups are named in errors and output by position and key
(group 1 [key: cust_id]) — the format stores no group name. Each group
is printed with its distinct-tuple count, and reported under Groups
with --json (entry count, entry width, member bytes per row, index
width). Declared members are never constant-elided, so --group
composes with --elide-constants. Field names containing , or :
cannot be declared here; use io.ImportJob.Groups. The output is a
format 0x02 cohort that binaries predating 0x02 cannot open; with no
--group the import is unchanged. pulse_import (MCP) takes the same
declarations as structured groups: [{key, members}] entries, plus
suggest_groups to return measured candidates in the same shape. See
parent groups.
--source-tz / --dst-policy
Available on: every import <format> leaf, import auto, import predict,
convert and convert predict.
--source-tz Zone reads every naive datetime value (no Z, no
±HH:MM) as a wall clock in Zone and stores the UTC instant it names;
storage is UTC epoch seconds either way, so the .pulse format is
unchanged. Zone is an IANA name (America/New_York), UTC, or — for
imports only — a fixed offset +HH:MM / -HH:MM. Repeatable
--source-tz col=Zone sets one column and wins over the bare form; it is
CLI_INPUT when col is not a column, or is not datetime (a date is
a calendar day and has no zone). The bare form skips date columns. A
value carrying its own Z or offset names that instant and ignores the
zone. Without --source-tz the cohort is byte-identical to before.
--dst-policy decides a naive value a DST transition makes ambiguous (a
fall-back repeats the hour) or nonexistent (a spring-forward skips it):
error (default) fails the import at the first such row with
PULSE_IMPORT_DST_AMBIGUOUS / PULSE_IMPORT_DST_NONEXISTENT, naming
row, column, value and zone, and writes nothing; earlier uses
the pre-transition offset (an overlap’s first occurrence; Berlin
2026-03-29 02:30 → 01:30Z), later the post-transition offset
(second occurrence; → 00:30Z). A resolved value is never silent: one
PULSE_IMPORT_DST_RESOLVED warning carries ambiguous_n /
nonexistent_n (warnings and data.ZoneWarnings under --json). The
policy is inert without a zone and under a fixed offset.
On import auto the zone, per-column map and policy are persisted onto
the managed sidecar (source_tz, column_source_tz, dst_policy); a
handle is never reused across zones — re-importing in another zone needs
--overwrite. import predict and convert predict raise the refusal
the real run would and report the resolved count. convert is a text
pass, so a zoned naive value is written to the target as the canonical
UTC literal of the instant it names (2026-07-01T16:00:00Z); a value
with its own Z / offset, an unparseable cell and every other column
pass through verbatim, and a --keep-pulse cohort is imported in the
same zone. pulse_import (MCP) takes source_tz, column_source_tz
({column: zone}) and dst_policy.
pulse import csv -i orders.csv -o orders.pulse \
--source-tz America/New_York --source-tz shipped_at=Europe/London \
--dst-policy later
--tz (export)
Available on: every export <format> leaf and export predict.
--tz Zone renders every datetime cell on Zone’s wall clock with
its numeric offset — an RFC 3339 literal such as
2026-03-29T08:00:00+05:30 under Asia/Kolkata, +01:00 / +02:00
either side of a Europe/Berlin transition (a fall-back hour’s two
readings differ only by offset) — instead of the canonical UTC
2026-03-29T02:30:00Z. The literal names the same instant, so
re-importing the export (offset literals keep their instant) round-trips
every value exactly. date columns are calendar days and never move.
Zone is an IANA name or UTC — no fixed offsets, abbreviations or
Local (PULSE_TIMEZONE_UNKNOWN, from export predict too). Absent,
UTC or a UTC-equivalent zone (Etc/UTC) is byte-identical to today’s
output. An instant whose historical offset is not a whole minute (a
pre-1900 local mean time) keeps the …Z form: RFC 3339 cannot spell it.
Text formats carry the literal as text, and so do arrow / parquet,
whose datetime columns are UTF8 strings. export spss refuses a
non-UTC zone (PULSE_SPSS_EXPORT_UNSUPPORTED, details.option =
--tz), and export predict --format spss --tz ... predicts that: a
.sav DATETIME is a naive wall-clock count with no offset slot. The
library knob is io.ExportJob.TimeZone; the manifest’s
export.formats[].time_zone says per format (local_offset /
refused).
pulse export csv -i orders.pulse -o orders.csv --tz Asia/Kolkata
Command index
Every runnable leaf the binary exposes, with the page that documents it
in depth. A leaf whose “Documented in” column names no dedicated page is
covered by the surrounding topic page or by --help.
The list itself is the contract, and it is enforced in both
directions by TestSkillsCoverAllCliLeaves: a leaf added to
buildApp() without a row here fails the gate, and a row here that names
a command the binary does not actually mount fails it too. The second
direction is the one that matters when a registration is deleted — the
documented command would otherwise stay listed while answering “command
not found”. Group nodes that carry no action of their own (pulse api,
pulse shard, pulse index) are not leaves and are deliberately absent.
| Leaf | Purpose | Documented in |
|---|---|---|
pulse api compose | Execute multiple processing requests in batch | api compose |
pulse api facet | Distinct values for a field, or a rich multi-field summary | api facet |
pulse api lookup | Key-exact row read against a prebuilt sidecar index | api lookup |
pulse api materialize | Write a request’s derived columns (features, attributes, windows) as fields of a new cohort: -r REQ.json -o OUT.pulse [--overwrite] [--strict] [--json]; refusals keep their own code | Materialize |
pulse api predict | Validate a request against a cohort without executing | api predict |
pulse api predict-chain | Validate a process-chain request without executing | api predict |
pulse api predict-compose | Validate a Compose batch without executing | api predict |
pulse api predict-facet | Validate a facet request without executing | api predict |
pulse api process | Execute a processing request against a cohort | api process |
pulse api process-chain | Source-rooted linear chain of mergeable stages | api process |
pulse api sample | Return sample rows from a cohort | api sample |
pulse cohort filter | Filter a .pulse file to a new .pulse file | --help |
pulse cohort inspect | Inspect a .pulse header and schema | cohort inspect |
pulse convert | Convert between tabular formats, auto-detected from extensions | import spss, export spss |
pulse convert predict | Validate a conversion without writing output | export spss |
pulse dedup | Deduplicate an existing single-file cohort’s repeated parent blocks into parent groups (format 0x02) — the existing-cohort twin of pulse import --group, taking the same --group, --elide-constants, --dedup-ratio-floor and --strict. Rewrites in place (destructive, non-interactive, atomic: temp file, fsync, rename, so a refusal or failure leaves the cohort byte-identical) or, with --out PATH, writes a new file that must not exist. --suggest-groups detects candidates over the cohort’s records; alone it writes nothing. An already-grouped cohort is regrouped from scratch. Refuses a shard archive (SERVICE_VALIDATION). An in-place rewrite names each invalidated sidecar and its rebuild command (data.invalidated_sidecars) and rebuilds nothing | parent groups |
pulse recommend | Turn a question kind into ranked draft requests: --intent ID (required; an intent-taxonomy ID, pulse skills show intents), --cohort PATH to bind the drafts to the cohort’s fields and predict-validate each one (omit for <placeholder> skeletons), --field F (repeatable, cohort only) to pin a field to the first role it fits, --level basic|intermediate|advanced to rank that level first, --limit N (default 10). Reads the cohort’s header, schema and sidecar only. Refusals keep their own code: PULSE_RECOMMEND_INTENT_UNKNOWN, SERVICE_VALIDATION (bad hint, level or limit), DATA_FILE | --help; skill tool-recommend |
pulse explain | Say in plain words what a request will do, or what a result found. --request FILE alone describes the request before it runs (never run; predict-checked over its cohort — header, schema and sidecar only — when it names one). --response FILE reads a result into findings (closed verdicts at the result’s own alpha, strength bands with their convention, caveats); a --json envelope is unwrapped to its data, and a null figure stays undefined. --request beside --response is the request that produced it (without it an aggregation is described by count only). --root request|composed|chain|facet|sample (default request) names which root the files hold; sample has no response. --detail terse|full (full adds narrative sentences, glossary refs, follow-ups and assumptions). Refusals keep their own code: CLI_INPUT (no file, bad --root, unreadable JSON), SERVICE_VALIDATION (bad --detail), DATA_FILE | --help; skill tool-explain |
pulse docs export | Write the analysis reference — operator catalog, “Reading your results” pages, glossary, skills and an mdBook SUMMARY.md fragment — as deterministic Markdown under --out DIR (required). Needs no data directory; --feature-profile PATH (host OS path; PULSE_FEATURE_PROFILE is not read) scopes it to a feature profile, --no-skills leaves the skill pages out. An unmarked non-empty --out is refused with PULSE_DOCS_EXPORT_DIR_NOT_EMPTY | guided analysis |
pulse errors list | List error codes by domain and/or substring | --help |
pulse errors lookup | Message + fixups for one error code | --help |
pulse examples search | Search the embedded request-example library | --help |
pulse examples show | Print a named example’s runnable request JSON | --help |
pulse export arrow | Export .pulse to Arrow IPC | --help |
pulse export csv | Export .pulse to CSV | --help |
pulse export excel | Export .pulse to Excel | --help |
pulse export jsonarray | Export .pulse to a JSON array | --help |
pulse export ndjson | Export .pulse to NDJSON | --help |
pulse export parquet | Export .pulse to Parquet | --help |
pulse export predict | Validate an export without writing output | export spss |
pulse export spss | Export .pulse to SPSS .sav | export spss |
pulse export transfer | Compress a cohort or shard archive (any format version) into a zstd transfer artifact, <input>.zst by default; --level 1..22 (default 3). Transport only — the artifact is never opened as a cohort | transfer compression |
pulse export tsv | Export .pulse to TSV | --help |
pulse features check | Validate a feature profile file (host OS path) against this build exactly as pulse.New would; offline, so an unregistered extension-like name is a PULSE_FEATURE_PROFILE_UNKNOWN warning (unverified_extension), and any failure exits non-zero | feature profiles |
pulse features diff | List the features this build offers that a feature profile does not list (flagged new past its written_with) and the names it does not resolve | feature profiles |
pulse features init | Print strict feature-profile JSON listing every feature this build offers, or --from NAME seeded from an example feature profile | feature profiles |
pulse features show | Describe every feature a feature profile lists: kind, category, source, since and dependencies | feature profiles |
pulse import arrow | Import Arrow IPC into .pulse | --help |
pulse import auto | Auto-detect a source format into the managed pool; carries the per-format read knobs --sheet (Excel) and --charset (SPSS), --group parent-group declarations, --source-tz / --dst-policy, and deliberately not --spss-missing | import spss |
pulse import csv | Import CSV into .pulse | --help |
pulse import drop | Remove a managed-import handle | --help |
pulse import excel | Import Excel into .pulse | --help |
pulse import jsonarray | Import a JSON array into .pulse | --help |
pulse import list | List managed-import handles and TTL status | --help |
pulse import ndjson | Import NDJSON into .pulse | --help |
pulse import parquet | Import Parquet into .pulse | --help |
pulse import predict | Validate an import without writing output | import spss |
pulse import schema-template | Emit an editable schema template from input data | import spss |
pulse import spss | Import SPSS .sav / .zsav into .pulse | import spss |
pulse import transfer | Decompress a pulse export transfer artifact into a byte-identical .pulse at rest (temp file, fsync, rename); refuses an existing --output without --overwrite | transfer compression |
pulse import tsv | Import TSV into .pulse | --help |
pulse index build | Build a point-lookup sidecar index | index |
pulse index drop | Remove a cohort’s sidecar index (destructive, no prompt) | index |
pulse index list | List every sidecar index built for a cohort | index |
pulse index verify | Report whether a cohort’s sidecar index is fresh | index |
pulse mcp | Run the MCP server over stdio; --metrics-addr HOST:PORT serves Prometheus metrics on GET /metrics (unset opens no port); --return PRESET sets the response preset for a tool request without its own return (default standard); repeatable --limit NAME=VALUE sets an instance resource limit (unlimited / Go duration for request_timeout; a bad key or value is CLI_INPUT) | mcp |
pulse profile create | Create a profile JSON for an existing cohort; carries the capture flags --include-correlations, --conditional, --fit-shape, --fit-models (one linear model per numeric field, so several categoricals can condition the same field) and --residual-correlations (the correlation submatrix among those models’ residuals; requires --fit-models), plus --suggest-rules <path>, which detects structural gating relationships, co-missing question blocks and exact single-source dependencies on the same scan and writes them as a standalone rules file for review, and --run-continuation, which measures per-field run-continuation (the run-skip decode’s hit rate) to show whether the cohort still has the sort order that makes scans fast. Profiles a single-file cohort or a whole shard archive | profile create |
pulse schema | Print the payload JSON Schema (raw, not envelope-wrapped) | schema |
pulse shard add | Append a shard to an existing archive | --help |
pulse shard compact | Rewrite an archive to reclaim orphan bytes | --help |
pulse shard create | Create a shard archive from single-file shards | --help |
pulse shard extract | Write a shard’s standalone .pulse bytes to stdout | --help |
pulse shard list | List shards inside an archive | --help |
pulse shard remove | Remove a shard from an archive by basename | --help |
pulse shard verify | Re-validate every shard against the canonical schema | --help |
pulse skills list | List every embedded skill | --help |
pulse skills show | Print one skill’s markdown | --help |
pulse synth from-profile | Generate a synthetic cohort from a captured profile; --emit-spec writes the derived spec (the one that actually generated) and --rules applies a standalone structural-rules file to it; --fidelity-report additionally scores how much of the captured structure survived, including the models and model_residual_correlations sections a --fit-models profile earns | synth from-profile |
pulse synth from-schema | Generate a cohort from a JSON schema/spec | synth from-schema |
pulse version | Print the build version as pulse <version>; --json emits the standard envelope whose data carries the build version, Go version, VCS commit and commit time (both omitted when the binary carries no VCS metadata) and the envelope format_version. Same build version as pulse --version and the root manifest | --help |
pulse widen | Widen a set column of a single-file cohort to a wider set rung (--field, --to), rewriting the cohort in place. Destructive and non-interactive, but atomic — temp file, fsync, rename — so a refusal or a failure leaves the cohort byte-identical. Refuses a narrower or equal target, a non-set column, a column already at set_u256, and a shard archive (whose widen spans the canonical schema plus every shard payload, so it is not this leaf’s operation); each refusal carries its own error code, usable with pulse errors lookup. On success it names any sidecar the rewrite invalidated — the point-lookup index, the SPSS metadata sidecar — and the exact command that rebuilds it (data.invalidated_sidecars under --json); it rebuilds nothing, and prints nothing when there are none | --help |
Help
Every command supports --help:
pulse --help
pulse api --help
pulse api process --help
pulse mcp --help
--help output is the urfave/cli v3 default — a usage block,
description, flag list, and an examples block where applicable.
Cross-references
| If you need… | Go to |
|---|---|
| Per-command synopsis & examples | CLI Tour and each cli/ page |
| Library-side equivalents | Library Embedding |
| MCP-side equivalents | How LLMs Use Pulse |
| Envelope and error code semantics | Troubleshooting and the pulse_errors_lookup MCP tool / pulse errors lookup CODE CLI |
Go API Overview
Audience: Go developers embedding Pulse in a binary or a service.
Pulse is library-first. The CLI in cmd/pulse/ is a thin adapter
around the package documented here. If you’re reaching for os/exec
to shell out to the binary from Go, stop and use the library directly
— you’ll skip a process boundary and gain typed responses.
LLM agents using MCP: there is no LLM-facing skill that covers Go embedding directly. Agents speak MCP; this page is for the programs that host them.
Module path
import "github.com/frankbardon/pulse"
Sub-packages you’ll commonly touch:
| Package | Purpose |
|---|---|
github.com/frankbardon/pulse | Public facade (Pulse, Options, Request, Response, …) |
github.com/frankbardon/pulse/types | Request/response structs, component-type constants (AGG_*, …) |
github.com/frankbardon/pulse/io | Jobs (ImportJob, ExportJob, ConvertJob, …), Reader / Writer, and the factory: io.NewReader(io.FormatCSV, fs, path, io.ReaderOptions{}), NewReaderFromBytes, NewWriter, NewWriterToBuffer, FormatFromPath |
github.com/frankbardon/pulse/errors | Typed CodedError system and code constants |
github.com/frankbardon/pulse/descriptor | Result and envelope types for manifest, predict, inspect (Envelope, Manifest, PredictResult, InspectResult) |
github.com/frankbardon/pulse/synth | Synthetic data specs, profiles, Synth / SynthBytes for fixtures |
github.com/frankbardon/pulse/encoding | Schema nouns and the ungrouped raw-byte primitives |
github.com/frankbardon/pulse/mcp/gosdk, .../mcpserve | Mount Pulse’s MCP tools on your server / run a ready-made one |
Everything under internal/ — the engine, the per-format adapters, the
MCP core, the skill pack, the filesystem config — is not importable from
outside the module. The skill pack is reachable through the MCP skill
tools, pulse skills, or the manifest’s skills block. Upgrading from
the earlier, flatter layout: see the embedder migration guide in the
repository roadmap (docs/roadmap/v1.0.0-api-and-release/03-embedder-migration.md).
The facade
Construct a Pulse once per process (or per filesystem boundary) and
re-use it:
p, err := pulse.New(pulse.Options{
DataDir: "/var/data/pulse",
})
if err != nil {
return err
}
The full Options shape (custom afero.Fs, smart-default toggling)
is documented at pulse.New & Options.
Public methods
From pulse.go:
| Method | Purpose |
|---|---|
Open(ctx, path) (*Cohort, error) | Read header + schema, return a typed Cohort handle |
Process(ctx, req) (*Response, error) | Execute one request |
ProcessStream(ctx, req) (RowIter, error) | Same, pull-based iterator over result rows |
Compose(ctx, req) ([]*Response, error) | Execute a batch sequentially |
ComposeParallel(ctx, req, opts) ([]*Response, error) | Execute a batch in parallel with a worker pool |
Import(ctx, job) (*ImportReport, error) | Tabular → .pulse |
Export(ctx, job) (*ExportReport, error) | .pulse → tabular |
Convert(ctx, job) (*ConvertReport, error) | Tabular → tabular, with .pulse as the transparent middle |
Inspect(ctx, path) (*InspectResult, error) | Read header + schema only (no record data) |
InspectEnvelope(ctx, path, opts) (*Envelope, error) | Same read, envelope returned whole — carries the warnings Inspect drops (e.g. a truncated payload tail) and takes descriptor.InspectOptions |
InspectBytes(ctx, data, opts) (*Envelope, error) | Inspect an in-memory cohort (single file or shard archive); envelope returned whole, warnings included |
Predict(ctx, req) (*PredictResult, error) | Validate a request without executing |
PredictBytes(ctx, data, req) (*Envelope, error) | Validate a request against an in-memory cohort; the extension snapshot, Options.Strict and Options.EchoRequest come from the instance |
PredictCompose(ctx, req) / PredictFacet(ctx, req) / PredictChain(ctx, req) (*Envelope, error) | Validate a Compose batch, facet request or ProcessChain without executing it; the whole envelope comes back, Data a *ComposePredictResult / *FacetPredictResult / *ChainPredictResult with each refusal a coded Errors entry |
CohortArtifacts(ctx, cohort) ([]string, error) | Every existing Pulse-owned sidecar of a cohort (.<keyhash>.idx, .indexes.json, .spss.json, .meta.json), sorted — what must move with it (keep the cohort’s mtime: both sidecars fingerprint it) |
Sample(ctx, path, n) ([]Record, error) | Up to n rows |
Facet(ctx, path, field) ([]string, error) | Distinct values of a field |
Synth(ctx, spec, out, opts) (*SynthResult, error) | Generate a synthetic cohort |
Materialize(ctx, req, out, opts) (*MaterializeResult, error) | Write a new cohort: the filtered source records plus one nullable f64 column per derived output (features, attributes, windows) — see Materializing Derived Columns |
Profile(ctx, path, opts) (*Profile, error) | Statistical summary suitable for from-profile synthesis |
Manifest(ctx) *Manifest | Deterministic root self-description |
Fs() afero.Fs | The underlying filesystem (used by pulse mcp and other embedders) |
Re-exported type aliases let you write pulse.Request instead of
types.Request:
type (
Request = types.Request
Response = types.Response
ComposedRequest = types.ComposedRequest
SynthSpec = synth.Spec
Profile = synth.Profile
// … and so on
)
Minimum viable embed
package main
import (
"context"
"fmt"
"log"
"github.com/frankbardon/pulse"
"github.com/frankbardon/pulse/types"
)
func main() {
ctx := context.Background()
p, err := pulse.New(pulse.Options{DataDir: "/var/data/pulse"})
if err != nil {
log.Fatal(err)
}
resp, err := p.Process(ctx, &pulse.Request{
Cohort: &types.Cohort{Filename: "sales.pulse"},
Aggregations: []*types.Aggregation{
{Type: types.AGG_AVERAGE, Field: "revenue", Label: "avg_revenue"},
},
})
if err != nil {
log.Fatal(err)
}
fmt.Println(resp.Data)
}
Where to go from here
- pulse.New & Options — full
Optionsreference. - Custom Filesystems — in-memory testing pattern, custom storage backends.
- Streaming & ProcessStream — pull-based iteration, what streams vs what buffers.
- Parallel Compose — worker pool, fail-fast, per-request timeout.
- Feature Profiles — declare the instance’s feature set; validation codes and the MCP server entry point.
pulse.New & Options
Audience: Go embedders constructing a Pulse instance.
pulse.New(pulse.Options{...}) is the single entry point. There is no
config file, no init function, no global state. Every option is
declared in code (or comes from PULSE_DATA_DIR when the field is
left empty).
LLM agents using MCP: the MCP server constructs its own
Pulseinstance from CLI flags. Agents don’t see this surface.
The Options struct
From pulse.go:
type Options struct {
// DataDir is the base directory for cohort files.
// Defaults to PULSE_DATA_DIR if empty and FS is not set.
DataDir string
// FS is an optional custom filesystem.
// When set, DataDir is ignored for filesystem construction.
FS afero.Fs
// DisableDefaults turns off the smart-defaults pass that infers
// operator Type from the named field's schema type when the caller
// omits it. Defaults to false (defaults enabled). Predict still
// computes and reports DefaultsApplied independently — this flag
// governs only what the runtime mutates on the live request.
DisableDefaults bool
// DisableCrosstabFusion forces every crosstab request onto the
// buffered path, skipping the fused in-decode streaming arm.
DisableCrosstabFusion bool
// DefaultTimeZone is the engine-wide IANA zone ("UTC" or an
// Area/Location name) a zone-capable slot falls back to. Empty = UTC.
DefaultTimeZone string
// DefaultWeight is the row weight a weight-bearing slot inherits
// when neither the slot nor the request names one. Nil = none.
DefaultWeight *types.WeightSpec
// FeatureProfile / FeatureProfileFile declare the instance's
// feature set (at most one of the two). Nil / empty = no profile.
FeatureProfile *FeatureProfile
FeatureProfileFile string
// Limits bounds runaway requests (groups, crosstab cells, memory
// estimate, matrix dim, compose/chain fan-out, join build rows,
// request timeout). Zero field = default; pulse.Unlimited (-1) = none.
Limits Limits
// ... further fields (concurrency, projection, imports, labels,
// templates) are documented on the Options type in pulse.go.
}
Field reference
DataDir string
The base directory for .pulse files. Relative cohort paths
({"filename": "data.pulse"}) resolve against this directory.
| Source | Result |
|---|---|
Non-empty Options.DataDir | Used directly |
Empty + FS non-nil | DataDir is ignored — the FS is the trust boundary |
Empty + FS nil | Pulse falls back to its default OS filesystem, rooted at PULSE_DATA_DIR |
Example:
p, err := pulse.New(pulse.Options{DataDir: "/var/data/pulse"})
FS afero.Fs
A custom afero.Fs implementation. When set, it fully overrides the
filesystem layer — DataDir is unused, and PULSE_DATA_DIR is not
consulted. Use this for tests (afero.NewMemMapFs()) or non-local
backends (S3-backed afero.Fs, encrypted overlays, …).
Example:
import "github.com/spf13/afero"
p, err := pulse.New(pulse.Options{
FS: afero.NewMemMapFs(),
})
See Custom Filesystems for in-depth usage and the hermetic-test pattern.
DisableDefaults bool
The runtime smart-defaults pass infers an operator’s Type from the
named field’s schema type when the caller omits it (e.g. AGG_SUM on
a numeric field defaults appropriately; categorical fields default
toward AGG_COUNT). Set DisableDefaults = true to require an
explicit Type on every aggregation and grouper — useful when you
want the request to be source-of-truth and never be silently
re-typed.
This option only governs the runtime mutation. predict independently
computes and reports DefaultsApplied in its result envelope, so
callers can see what would have been inferred even when defaults are
disabled.
CLI parity: pulse api process --no-defaults, pulse api compose --no-defaults.
DisableCrosstabFusion bool
A crosstab request the fusion gate admits normally runs on the fused
arm: the row × column grid is built in-decode, without materialising
the filter-passing record set. Set DisableCrosstabFusion = true to
force every crosstab onto the buffered path instead. Output is
identical either way — fusion is a peak-heap optimisation — so this is
a diagnostic and benchmarking knob, for example to compare fused and
buffered memory on the same request. It is fixed at pulse.New time;
there is no per-request override. Predict / PredictBytes honour it:
PredictResult.CrosstabFusable answers false, with the knob named in
CrosstabFusionReasons. Without it, those fields report the fusion
gate’s own answer and every reason it declines.
p, err := pulse.New(pulse.Options{DisableCrosstabFusion: true})
DefaultTimeZone string
The zone a zone-capable slot falls back to when neither its own tz
nor the request’s time_zone names one — precedence is slot tz →
time_zone → DefaultTimeZone → UTC. The name must be exactly UTC
or an IANA Area/Location zone (Europe/Berlin); anything else
(EST, Local, +05:00) fails pulse.New with
PULSE_TIMEZONE_UNKNOWN. Names resolve only from Pulse’s own embedded
copy of the tz database (never $ZONEINFO or the host’s zoneinfo
files), so the accepted set and every offset are identical on every
host, and the lookup is case-sensitive everywhere.
Where it reaches a datetime field, the zone-aware operators read each
instant on its local calendar day in this zone (DST-correct). A request
in which a non-UTC default reaches a derived field (absent from the
schema) is refused with PROCESSING_CONFIG rather than silently
ignored. It does not apply to date fields at all. Predict reports the
resolved zone per slot under time_zones, with source: "options" when
this field supplied it. There is no env var or CLI flag for it. Full
guide: Row Weighting.
p, err := pulse.New(pulse.Options{DefaultTimeZone: "UTC"})
DefaultWeight *types.WeightSpec
The row weight a weight-bearing slot (aggregations, the crosstab cell
and margin aggregations, tests, regressions, attributes, overlays)
falls back to when neither its own weight nor the request’s weight
names one — precedence is slot weight → request weight →
DefaultWeight → none, and a slot’s weight: null opts that slot out.
pulse.New refuses a spec with no Field or a Kind other than
probability (the default when empty) / frequency with
PROCESSING_CONFIG. The field is judged per request, only where the
default actually applies: there it must exist in the cohort and be an
unsigned-integer or float column. It reaches Process, Compose slots and
ProcessChain stages, never Facet / FacetSchema. Predict reports the
resolved weight per slot under weights, with source: "options" when
this field supplied it. There is no env var or CLI flag for it. Full
guide: Row Weighting.
p, err := pulse.New(pulse.Options{DefaultWeight: &types.WeightSpec{Field: "wt"}})
DefaultMultiplicity *types.Multiplicity
The multiple-comparison correction a test or inferential overlay falls
back to when neither its own multiplicity block nor its request’s
(nor, in Compose, the ComposedRequest’s) names one. Each of method,
family and alpha falls through on its own: slot, request,
ComposedRequest, DefaultMultiplicity, none. pulse.New validates it
(PULSE_MULTIPLICITY_INVALID); a family the surface does not offer
falls back to the surface default rather than failing. It reaches
Process, Compose slots, ProcessChain stages and Facet overlays. Unset
(the default) corrects nothing and leaves every response
byte-identical. There is no env var or CLI flag for it. Full guide:
Multiple-Comparison Correction.
p, err := pulse.New(pulse.Options{DefaultMultiplicity: &types.Multiplicity{Method: types.MultiplicityMethodHolm}})
DefaultReturn *types.Return
The response selection (preset, include, exclude, precision) a
request without its own return block is shaped by. A request block
replaces it entirely, never merging. Precedence: the request’s return,
then DefaultReturn, then the feature profile’s return, then full
(the whole response, unchanged). pulse.New refuses a bad preset,
precision or path (PULSE_RETURN_INVALID / PULSE_RETURN_PATH_UNKNOWN),
including a path only a feature the instance hides has. Data-column paths
(data[*].<column>) are not checked at New; one that matches nothing
draws a PULSE_RETURN_PATH_UNMATCHED warning. DisableComponents folds
into this layer: while components are off for a request, the selection
also excludes components. A request return replaces the default
but never the engine switch: with DisableComponents on, components stay
off unless the request sets disable_components: false. Applies to Process, each Compose slot and each chain stage. There is no env var
or CLI flag for it.
p, err := pulse.New(pulse.Options{DefaultReturn: &types.Return{Preset: types.ReturnPresetStandard}})
FeatureProfile *FeatureProfile / FeatureProfileFile string
Declare the instance’s feature set: a Go value, or a JSON file read
through the instance filesystem (relative to DataDir or FS). Set at
most one; both is PULSE_FEATURE_PROFILE_INVALID. pulse.New validates
the profile and stores it. Its behaviour switches take effect now, ORed
into the matching Disable* fields; the feature list hides nothing yet.
pulse.New never reads the PULSE_FEATURE_PROFILE env var — only
pulse mcp / mcpserve.NewPulse do. Full contract:
Feature Profiles.
p, err := pulse.New(pulse.Options{
DataDir: "/var/data/pulse",
FeatureProfileFile: "profiles/self-serve.json",
})
Limits Limits
The instance resource limits: RequestTimeout, MaxGroups,
MaxCrosstabCells, MaxEstimatedMemory, MaxMatrixDim,
MaxComposeSlots, MaxChainStages, MaxJoinBuildRows,
MaxDerivedColumns. Per field, 0
means the default (pulse.DefaultMaxGroups, …), pulse.Unlimited (-1)
means no limit, any other negative fails pulse.New with
PULSE_LIMIT_INVALID. Precedence per field: a non-zero Options.Limits
value, then the feature profile’s limits, then the default. Read the
effective values with p.Limits(). A breach is PULSE_LIMIT_EXCEEDED
with {limit, configured, observed, option}. Defaults, checks and
reading a breach: Tuning Limits.
p, err := pulse.New(pulse.Options{Limits: pulse.Limits{MaxGroups: 1_000_000}})
Defaults at a glance
Field omitted from Options | Effective behaviour |
|---|---|
DataDir and FS both empty | Pulse builds its default filesystem → reads PULSE_DATA_DIR env var. Errors if unset and the operation needs filesystem access. |
DataDir only | Uses an afero.NewOsFs() rooted at DataDir. |
FS only | Uses the provided FS verbatim. |
| Both | FS wins; DataDir is ignored. |
DisableDefaults omitted | Defaults enabled. |
DisableCrosstabFusion omitted | Fusion engages whenever the gate admits the crosstab. |
DefaultTimeZone omitted | Zone-capable slots without tz / time_zone resolve to UTC. |
Limits omitted | Count limits at their high defaults; RequestTimeout and MaxEstimatedMemory unlimited. |
FeatureProfile / FeatureProfileFile omitted | No profile: every feature, byte-identical to a profile-free build. |
Re-using a Pulse instance
Pulse is safe for concurrent use across goroutines once constructed.
The internal registries are read-only after New; each Process
call constructs fresh stateful operators per request, so multiple
goroutines can call Process/ProcessStream/Compose in parallel
against the same Pulse.
For batch parallelism, prefer
ComposeParallel — it shares the read-only
registries and bounds concurrency for you.
Tearing down
There is no explicit Close() method on Pulse. The filesystem is a
borrowed handle; if you supply a custom FS, the embedder is
responsible for any cleanup that FS requires. Streaming consumers
should still call RowIter.Close() so that the underlying readers
release their buffers.
Custom Filesystems
Audience: Go embedders running Pulse in tests (hermetic, no disk), in cloud-storage-backed environments (S3, GCS, Azure Blob via afero), or behind a custom storage layer.
Pulse routes all file I/O through afero.Fs. Pass any
afero.Fs-conformant filesystem to pulse.New(pulse.Options{FS: ...}) and Pulse never touches the OS filesystem directly.
LLM agents using MCP: the MCP server’s filesystem is fixed at startup via
PULSE_DATA_DIRor--data-dir. Agents don’t swap filesystems mid-session.
In-memory testing pattern
The single most common reason to override the filesystem is hermetic tests. Pass an in-memory afero filesystem directly:
import (
"github.com/frankbardon/pulse"
"github.com/spf13/afero"
)
func TestSomething(t *testing.T) {
p, err := pulse.New(pulse.Options{FS: afero.NewMemMapFs()})
if err != nil {
t.Fatal(err)
}
// Write a .pulse file into the in-memory FS, then process it.
// ...
}
The in-memory FS persists for the life of the FS reference. Create a fresh one per test for isolation.
Custom storage backends
Anything that implements afero.Fs works. Common patterns:
- S3 / GCS / Azure Blob — via community afero adapters
(
afero/gcsfs,afero/s3). - Encrypted overlays — wrap a base FS with envelope encryption per file.
- Read-only mounts —
afero.NewReadOnlyFs(base)for production cohort serving where mutation is by accident, not policy.
Example with a hypothetical S3 wrapper:
import (
"github.com/frankbardon/pulse"
"example.com/myorg/aferos3"
)
func main() {
s3fs := aferos3.New(aferos3.Config{
Bucket: "my-pulse-cohorts",
Region: "us-east-1",
})
p, _ := pulse.New(pulse.Options{FS: s3fs})
// p reads and writes cohort files from S3 transparently.
}
Filesystem configuration
pulse.Options is the only filesystem knob an embedder needs:
FS (any afero.Fs) and DataDir (the base directory; when both are
empty, PULSE_DATA_DIR is read from the environment). The lower-level
config constructors live in internal/fs/ and are not importable from
outside the module. (*pulse.Pulse).Fs() returns the filesystem the
instance resolved, which is what to hand the io factory
(io.NewReader(format, p.Fs(), path, opts)).
Path resolution
Pulse resolves a Cohort to a path with this rule (see
resolveCohortPath in pulse.go):
if cohort.DataDir != "" → "<DataDir>/<Filename>"
else → "<Filename>"
The custom FS is then asked to open that path. For an
afero.MemMapFs, an absolute-looking path like
/var/data/sales.pulse is just a key in the in-memory map — no need
to mirror the OS layout.
What custom filesystems do NOT do
- Pulse never falls back to
os.Openif the custom FS fails. The custom FS is the only filesystem; if it errors, that error propagates verbatim. - The MCP server (
pulse mcp) currently usesafero.NewOsFs()only. Custom filesystems are a library-side capability today. - The Go race detector and
go test -racework normally with in-memory filesystems; tests can run highly concurrent without fighting over a real directory.
Streaming & ProcessStream
Audience: Go embedders feeding rows into an HTTP response, an NDJSON pipeline, or any consumer that wants result rows one at a time instead of buffering the full set.
pulse.ProcessStream returns a pull-based iterator. The API is
stable regardless of whether the underlying request shape streams
inside the engine — non-streamable requests return the same iterator,
they just buffer once internally before yielding.
LLM agents using MCP: the MCP-side streaming surface is
pulse_processwith the streaming option. The Streamable predicate is the same on both surfaces.
The iterator API
type RowIter = internal/service.RowIter
// In service:
type RowIter interface {
Next(ctx context.Context) (Row, bool, error)
Close() error
Metadata() *ResponseMetadata
}
type Row = internal/service.Row // map[string]any
Usage:
iter, err := p.ProcessStream(ctx, req)
if err != nil {
return err
}
defer iter.Close()
for {
row, ok, err := iter.Next(ctx)
if err != nil {
return err
}
if !ok {
break
}
// … emit row …
}
meta := iter.Metadata() // available after drain
Metadata() returns the full ResponseMetadata (total rows,
filtered rows, cohort file) once the iterator has been drained.
Response shaping
A return block on the request (or the instance default) shapes the
stream the way it shapes a buffered Process response. Each row is a
pruned copy equal to the shaped data element. When data is
excluded, the iterator yields no rows. Components() and Metadata()
return shaped copies, and the run’s own state is never pruned. Once
the iterator is drained, it reports the returned marker through a
Returned() *types.ReturnedMarker method. ProcessStreamResult puts
that marker on the terminal chunk only (StreamChunk.Returned).
Precision applies only on the wire, so rows stay full float64. To
write a row at the block’s precision, assert
interface{ MarshalRow(pulse.Row) ([]byte, error) } on the iterator;
pulse api process --stream does exactly that. With no effective
block, the iterator is the unshaped one, byte-identical to before.
What actually streams
ProcessStream always returns an iterator, but the engine only
avoids the buffered intermediate row set for a subset of request
shapes. Run pulse api predict (or Predict from the library) and
check the Streamable flag in the result:
pred, err := p.Predict(ctx, req)
if !pred.Streamable {
for _, reason := range pred.StreamableReasons {
log.Printf("buffered because: %s", reason)
}
}
The streaming-eligible request shapes are listed in Performance Notes → Streaming path.
The complement — the request shapes that force the buffered path — is at Performance Notes → Buffered path.
Streamable=false doesn’t mean the iterator is broken; it just
means rows materialise inside the engine before Next yields them.
The output API is identical either way.
CLI parity
pulse api process --stream writes NDJSON to stdout, one row per
line. pulse api compose --stream does the same with an index
field per row identifying which sub-request produced it.
Cancellation
Every Next call accepts a context. Cancellation propagates to the
underlying reader; rows that are already in flight may still be
returned before Next returns (_, false, ctx.Err()). Close()
releases any reader resources and is safe to call multiple times.
Backpressure
The iterator is pull-based: the engine produces rows only as fast as
the consumer calls Next. For HTTP responders that flush periodically,
this means you can stream a multi-GB result set through a
constant-memory buffer.
For pipelines that want to fan rows out across goroutines, copy each
row into your own struct before processing — Row is
map[string]any and the engine may re-use the backing data after
Next returns. Treat it as borrowed.
Inside the engine
Under the hood, ProcessStream calls one of four orchestrator modes
depending on the request shape: single-pass streaming, grouped
streaming, two-pass streaming, or the buffered fallback. The choice
is made by the engine’s internal streamability gate, which is the
same predicate Predict.Streamable reports — call Predict to learn
the path before you run — this parity is
enforced by TestPredict_Streamable_MatchesRuntime.
If you find a request that predict says is streamable but Next
materialises something large, that’s a parity drift and a bug —
please report it with the request JSON.
StreamResult[T]
Audience: Go embedders that want a structured streaming shape with header, chunks, terminator, and built-in cancellation semantics.
StreamResult[T] is Pulse’s canonical streaming shape. Every operation
that produces incremental data exposes a *Stream variant that returns
StreamResult[T]. The shape’s contract — request hash in the header,
sequence-numbered chunks, terminator on a separate channel — is
generic across operators, so a consumer can write one row-handling loop
that drives any streamable Pulse call.
Shape
type StreamResult[T any] struct {
Header StreamHeader
Chunks <-chan StreamChunk[T]
Done <-chan StreamTerminator
}
type StreamHeader struct {
RequestHash string // From req.Hash()
EstimatedTotal int64 // Best-effort; -1 if unknown
StartedAt time.Time
}
type StreamChunk[T any] struct {
Sequence int // Monotonic 0-based
Data T
Progress float64 // 0.0–1.0, or -1 if unknown
}
type StreamTerminator struct {
CompletedAt time.Time
TotalRows int64
Status StreamStatus // Completed | Cancelled | Errored
Error error
}
Available variants
Pulse.ProcessStreamResult(ctx, req) (StreamResult[Row], error)— wraps the existingProcessStreamengine.Pulse.SynthStream(ctx, spec, opts) (StreamResult[Row], error)— generates synth respondents and yields one row per chunk.
Receiver pattern
res, err := p.ProcessStreamResult(ctx, req)
if err != nil {
return err
}
for chunk := range res.Chunks {
handle(chunk.Sequence, chunk.Data, chunk.Progress)
}
term := <-res.Done
if term.Status != pulse.StreamCompleted {
return fmt.Errorf("stream %s: %w", term.Status, term.Error)
}
fmt.Printf("delivered %d rows in %s\n",
term.TotalRows, term.CompletedAt.Sub(res.Header.StartedAt))
The Chunks channel closes when the operation finishes. The Done
channel delivers exactly one StreamTerminator and then closes — a
receiver that selects on Done sees one value followed by
channel-closed.
Backpressure
Chunks carries a 4-deep buffer. Slow consumers slow the producer;
the producer never drops chunks. This makes StreamResult safe to
hand to a downstream HTTP response writer or SSE relay without
worrying about chunk loss under pressure.
Cancellation
Cancelling the context cancels the operation:
ctx, cancel := context.WithCancel(parent)
res, _ := p.ProcessStreamResult(ctx, req)
go func() {
time.Sleep(5 * time.Second)
cancel()
}()
for chunk := range res.Chunks { ... }
term := <-res.Done
// term.Status == pulse.StreamCancelled, term.Error == context.Canceled
Errors
A mid-stream error closes Chunks early and delivers a
StreamTerminator{Status: StreamErrored, Error: err} on Done.
Consumers should always check the terminator status — a closed
Chunks channel alone does not distinguish success from error.
Non-streaming variants
The non-streaming entry points (Process, Synth) remain — they are
convenience wrappers that drain the stream and return the full
result. Use the streaming variants when you need incremental output,
progress reporting, or early cancellation; use the buffered variants
when you want one materialised result and don’t care about the
intermediate chunks.
Relationship to ProcessStream
Pulse.ProcessStream (returning RowIter) remains the lower-level
pull-iterator API for callers that want the simpler shape. See
Streaming & ProcessStream. ProcessStreamResult
adds the structured header, terminator, and cancellation semantics
on top of the same engine.
Parallel Compose
Audience: Go embedders running multiple requests concurrently against the same cohort or set of cohorts.
pulse.ComposeParallel fans a ComposedRequest across a bounded
worker pool. Workers share the engine’s read-only registries; each
Process call constructs fresh stateful operators per request, so
concurrent execution is safe.
LLM agents using MCP: the MCP server today exposes
pulse_composeas a sequential operation. Parallelism is a library-side capability.
When to use
| Goal | Reach for |
|---|---|
| Single request, single result | Process |
| Single request, pulled as rows | ProcessStream |
| Batch of independent requests, in order, sequential | Compose |
| Batch of independent requests, in parallel, with bounded workers | ComposeParallel |
Order of results is preserved regardless of completion order — a
worker that finishes early is held until its slot’s index is the
next to emit. So callers can index responses[i] against
req.Requests[i] directly.
ComposeOptions
From internal/service/compose_parallel.go,
re-exported as pulse.ComposeOptions:
type ComposeOptions struct {
// MaxWorkers caps concurrent in-flight Process calls. Zero means
// runtime.GOMAXPROCS; negatives clamp to 1.
MaxWorkers int
// PerRequestTimeout, if positive, derives a context.WithTimeout for
// each request.
PerRequestTimeout time.Duration
// FailFast cancels in-flight siblings on the first request error.
// Defaults to true. Set false to aggregate all errors instead.
FailFast bool
}
| Field | Default | Notes |
|---|---|---|
MaxWorkers | runtime.GOMAXPROCS(0) | 0 resolves to GOMAXPROCS; <1 clamps to 1 |
PerRequestTimeout | unlimited | When positive, each worker derives context.WithTimeout |
FailFast | true | First error cancels siblings and returns immediately |
Example
ctx := context.Background()
composed := &pulse.ComposedRequest{
Requests: []*pulse.Request{req1, req2, req3, req4},
}
resps, err := p.ComposeParallel(ctx, composed, pulse.ComposeOptions{
MaxWorkers: 4,
PerRequestTimeout: 30 * time.Second,
FailFast: true,
})
if err != nil {
return err
}
for i, resp := range resps {
fmt.Printf("request %d: %d rows\n", i, len(resp.Data))
}
FailFast semantics
With FailFast = true (the default):
- The first request to return an error cancels the shared context.
- In-flight siblings observe cancellation via
ctx.Err()(every serial per-record loop polls it every 4,096 rows) and return early withcontext.Canceled. ComposeParallelreturns(nil, err)whereerris the lowest-index failure that is not such a sibling cancellation, so the error that tripped FailFast is the one reported. When the caller’s own ctx is done, the lowest-index failure is reported as is.
With FailFast = false:
- Every request runs to completion (or its own per-request timeout).
- Errors are aggregated into a single
SERVICE_INTERNALerror whosedetailsmap carriesfailed_indices(a list of slot indices that errored). - The call returns
(nil, err): no partial response array is returned, so the successful slots’ results are discarded along with the failed ones. The error isSERVICE_INTERNAL(compose parallel: N/M requests failed);details.first_errorholds the lowest-index failure’s text. A caller that needs the slots that succeeded must callProcessper request.
A ctx that is already done before every slot launched returns the
ctx error (with no slot error to report), never a response with
unrun nil slots.
Timeouts
PerRequestTimeout bounds each slot; a slot that runs out returns
context.DeadlineExceeded, unchanged. The instance-wide
Options.Limits.RequestTimeout, when set, bounds the whole
ComposeParallel (or Compose) call instead: when it fires, the call
returns PULSE_LIMIT_EXCEEDED with
{limit: "request_timeout", configured, observed} (nanoseconds) and no
partial result. A deadline or cancel on the caller’s own ctx always
passes through as context.DeadlineExceeded / context.Canceled.
CLI parity
pulse api compose --request batch.json --parallel 4
pulse api compose --request batch.json --parallel 4 --no-fail-fast
--parallel N:
1(default) → sequentialCompose.0→runtime.GOMAXPROCS.> 1→ exactly that many workers.
--no-fail-fast mirrors FailFast = false.
Performance considerations
- Each worker performs its own filesystem reads. If your cohort lives on slow remote storage, parallelism amortises latency well; on local SSD the gain is smaller and CPU-bound.
- Streaming aggregations are CPU-friendly —
ComposeParallelover a pool of streaming requests scales near-linearly to the worker count. - Buffered request shapes (window operators, median, …) hold
memory per request. Watch
MaxWorkers × per_request_peak_memory. - The internal registries are read-only and shared across workers with no locking; only the per-request operator instances are fresh allocations.
Safety
Pulseis safe for concurrent use afterNew.- Per-request operator state (running sums, dictionaries, sorted
buffers) is allocated fresh inside each
Processcall. - The
afero.Fsyou supply must itself be safe for concurrent reads — every shipped backend (OsFs,MemMapFs) is.
Compose Sweeps
Audience: Go embedders, CLI users and agents who run one question over a grid of parameters.
ComposedRequest.Sweep (types.SweepSpec) is one request body plus
axes of values. Before any slot runs, Pulse expands it into ordinary
Compose slots appended after the explicit Requests. Each expanded slot
then runs exactly like a hand-written one: same limits, same labels,
same overlay fold, serial or parallel. The same payload works through
Compose, ComposeParallel, PredictCompose, pulse api compose,
pulse api predict-compose, pulse_compose and pulse_predict
(composed). It is gated by capability:compose_sweep.
{
"sweep": {
"axes": [
{"name": "region", "values": ["north", "south"]},
{"name": "cut", "values": [10, 20, 30]}
],
"mode": "grid",
"label": "{{region}}_{{cut}}",
"request": {
"cohort": {"filename": "sales.pulse"},
"filterers": [{"type": "FILTER_INCLUDE", "field": "region", "values": [{"$var": "region"}]}],
"aggregations": [{"type": "AGG_SUM", "field": "revenue", "label": "rev_{{cut}}"}]
}
}
}
Axes and modes
An axis has a unique identifier name and a non-empty list of scalar
values (string, number or boolean; numbers keep the digits as
written). mode grid (default) is the Cartesian product, first axis
slowest, so a 2-value and a 3-value axis give 6 slots. zip walks the
axes in lockstep and every axis must be the same length.
Placeholders
{{axis}} inside a string splices the value as text; {"$var": "axis"} as a whole value keeps its JSON type. A $when guard may name
an axis. Every axis must be referenced by the body, an authored label
pattern or the overlays, and a placeholder that names no axis is
refused. This is a purpose-built substitution, not an expression
language and not the stored-template surface: it runs before the body
is decoded.
The substituted body decodes strictly into a Request: an unknown
key or a wrong-typed value fails with PULSE_SWEEP_INVALID, reason
request_decode, even where a plain compose body would ignore it.
Labels
Without label, a slot is named by its axis=value pairs joined with
_. An authored pattern may use any axis. The body must not set its own
label, and a pattern that renders empty is refused. Labels stay unique
across explicit and sweep slots (PULSE_COMPOSE_LABEL_COLLISION).
Compose overlays name sweep slots by these labels.
Overlays
sweep.overlays is an array of compose overlay specs with
placeholders. It is substituted per expanded slot and appended after the
request’s own Overlays, in expansion order.
Limits and predict
MaxComposeSlots counts the expanded total (explicit plus sweep); an
over-limit batch is PULSE_LIMIT_EXCEEDED before any slot runs
(Tuning Limits). PredictCompose expands too and
applies the same limit, slot-gate and label checks as the runtime. It
adds a sweep summary to the result: axes (name and value count),
mode, expanded_count (sweep slots only) and labels. A malformed
sweep is PULSE_SWEEP_INVALID with details.field and
details.reason.
Ranking
sweep.rank is {by, order, top}. by is a dot path into each sweep
slot’s unshaped response (an object key, or on a list the element whose
name or label equals the segment, never an index; for example
components.aggregations.rev.n); order is asc (default) or desc; top
keeps the first N. ComposedResponse.Ranking lists {label, value, rank}
in rank order. Explicit slots are never ranked. A null value, or a path
missing in one slot only, excludes that slot with a PULSE_SWEEP_RANK_PATH
warning on its own response; a non-number, ambiguous or unresolved path
refuses the batch with the same code (details.reason syntax, missing,
not_number, ambiguous). A ranked slot always computes the ranked part
even when its return excludes it, and ranking is never trimmed or
rounded by the Compose return. Under --stream one trailing
{"ranking": [...]} line follows the rows.
Known gap: a figure serialised with omitempty disappears at exactly 0
(for example residual_std_err), so that slot reads as missing and is
excluded rather than ranked first (TODO #300).
Echo and return shaping
With Options.EchoRequest / --echo-request the echo carries the
expanded slots the service ran (ComposedResponse.NormalizedRequest,
not on the wire). The MCP pulse_compose default return of standard
also fills a sweep body that sets no return; an authored one is never
overwritten. The library default is unchanged.
See also: Parallel Compose, the payload schema.
Request Hashing
Audience: Go embedders building caches, deduplication layers, or materialization stores on top of Pulse.
Every Pulse request type implements Hash() string — a 32-character
hex digest of the request’s canonical JSON form. Same logical request
produces the same hash, across processes and across Pulse versions
where the request’s semantic meaning is unchanged.
Supported types
| Request shape | Method |
|---|---|
pulse.Request | req.Hash() |
pulse.ComposedRequest | req.Hash() |
pulse.FacetRequest | req.Hash() |
pulse.ChainRequest | req.Hash() |
pulse.SynthSpec (synth.Spec) | spec.Hash() |
The lower-level helper types.CanonicalHash(tag, v) hashes any
JSON-serializable value with a caller-chosen namespace tag.
Guarantees
- Deterministic. Same in, same out — every call, every process.
- Round-trip stable.
json.Marshal→json.Unmarshal→Hash()returns the original digest. - Field-order invariant. Struct field order normalised by the canonical encoder; map keys sorted.
- Default-normalising. Explicit zero (
Limit: 0) and the omitted equivalent hash identically because the request struct tags carryomitempty. - Negative-zero collapsing.
-0.0hashes identically to0.0. - Type-namespaced. A
Requestand aComposedRequestwith identical wire bytes never collide — the hash function prefixes a namespace tag per request shape. - Schema-free.
Hash()never reads a cohort, so avectorsentry hashes AS WRITTEN: apatternand the explicitfieldslist it resolves to hash differently. Resolved-member identity needs the schema; inside the moduleinternal/vectors.Normalize(req, schema)rewrites every vector to its resolvedfields(order = axis order,patterncleared), after which equivalent requests hash alike. A request with no vectors hashes exactly as before the slot existed.
Use cases
Cache key
key := req.Hash()
if hit, ok := cache.Get(key); ok {
return hit
}
resp, err := p.Process(ctx, req)
if err == nil {
cache.Set(key, resp)
}
Filename suffix
out := fmt.Sprintf("derived_%s.pulse", req.Hash())
Idempotency token
// Reject duplicate work in a queue / job system.
if seen.Add(req.Hash()) == false {
return errAlreadyQueued
}
Dedup across consumers
Independent processes can build the same Request (programmatically
or from JSON) and reach the same hash without coordination. Combined
with Deterministic FilterToFile, this is the
foundation for shared materialization caches.
Algorithm
json.Marshal(v)— produces canonical-ordered struct fields and sorted map keys.- Walk the resulting JSON tree, dropping representational variants
(collapse
-0.0→0.0, integer-valued floats keep integer form). - Re-emit with sorted map keys and no whitespace.
SHA-256(tag + 0x00 + canonical).- First 16 bytes as 32-character lowercase hex.
The implementation lives in types/hash.go; tests covering the
guarantees above are in types/hash_test.go.
Watch & WatchDir
Audience: Go embedders building reactive systems on top of Pulse — caches that invalidate on source change, UI layers that re-fetch when the data updates, batch jobs that fire on file appearance.
Pulse.Watch and Pulse.WatchDir observe .pulse files for changes
and emit ChangeEvent records on a channel. The polling watcher
coalesces atomic-write patterns (write-temp + rename) into single
events so consumers don’t see spurious intermediate states.
Shape
type ChangeEvent struct {
Path string
Kind ChangeKind // Created | Modified | Removed | Renamed
Hash string // SHA-256 hex of leading bytes; empty for Removed
Timestamp time.Time
}
type ChangeKind int
const (
ChangeCreated ChangeKind = iota
ChangeModified
ChangeRemoved
ChangeRenamed
)
Default usage
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Single-file watch.
ch := p.Watch(ctx, "cohort.pulse")
// Directory watch (recursive, .pulse files only).
ch := p.WatchDir(ctx, "cohorts/", true)
for ev := range ch {
switch ev.Kind {
case pulse.ChangeCreated:
log.Printf("new cohort %s (hash=%s)", ev.Path, ev.Hash)
case pulse.ChangeModified, pulse.ChangeRenamed:
invalidateCache(ev.Path)
case pulse.ChangeRemoved:
cache.Delete(ev.Path)
}
}
Cancelling the context closes the channel and releases the watcher.
WatchOptions
type WatchOptions struct {
PollInterval time.Duration // Default 250ms
CoalesceWindow time.Duration // Default 100ms (0 disables coalescing)
HashPrefixBytes int // Default 64 KiB (< 0 hashes entire file)
Recursive bool // WatchDir-only
Suffix string // Default ".pulse" for WatchDir, "" for Watch
}
ch := p.WatchDirWithOptions(ctx, "cohorts/", pulse.WatchOptions{
PollInterval: 1 * time.Second,
HashPrefixBytes: -1, // hash full file
Recursive: true,
Suffix: ".pulse",
})
Tuning notes
PollInterval— stat-poll cadence. The default 250ms is fine for local filesystems. Network filesystems should raise this to ~30s; on remote storage the stat call dominates over any latency fsnotify-style edge events would save.CoalesceWindow— events inside this window collapse to one. Critical for atomic-write patterns (see below).HashPrefixBytes— how much of the file’s content participates in the hash. Default 64 KiB covers a.pulsefile’s header + schema + first dictionary block, enough to detect any structural change. Use-1when you need full-file hashing for small files where prefix collisions matter.Suffix— filter directory contents to files ending in this suffix.WatchDirdefaults to.pulse. Set to""to watch every file in the directory.
Atomic-write coalescing
Most tools that write .pulse files do so atomically — write to a
temp path, then rename into place. Without coalescing, a naive
watcher would see two events: a Removed(temp) and a
Created(target). WatchDir folds these into a single
ChangeRenamed(target) event when:
- Both paths share a directory.
- The temp basename matches a canonical scratch pattern (
<target>.tmp,<target>.partial,<target>.swp,~<target>, hidden dotfile variants, or<target>.NNNN). - The two raw events fall inside
CoalesceWindowof each other.
Pulse.FilterToFileWithRequest uses this pattern internally; a
consumer watching the output directory sees a clean ChangeRenamed
or ChangeCreated event without intermediate states.
Hash semantics
ChangeEvent.Hash is the SHA-256 hex of the file’s first
HashPrefixBytes bytes. The hash is informational — two events with
the same Hash value describe identical leading-byte content. The
watcher itself uses the hash internally to detect content changes the
mtime/size signals miss (e.g. an in-place write that preserves both).
For ChangeRemoved events the Hash field is empty.
First-tick behaviour
The watcher does not pre-seed its state map. Files that already
exist when the watcher starts surface as ChangeCreated on the first
tick. If you only want subsequent mutations, drain the first batch:
ch := p.WatchDir(ctx, dir, true)
// Drain initial Created events.
first := time.After(500 * time.Millisecond)
draining := true
for draining {
select {
case <-ch:
case <-first:
draining = false
}
}
// Now treat subsequent events as actionable.
Limitations
- Polling-based — no fsnotify dependency. The default 250ms cadence
is the floor for change visibility; tune
PollIntervalfor your latency budget. - Symlinks are followed implicitly (the underlying
afero.Fs.Statresolves them) but the watcher does not re-resolve a link target that changes after watch-start. - The afero abstraction means the watcher works against any custom
filesystem (in-memory, encrypted, remote) supplied via
pulse.Options.FS.
Deterministic FilterToFile
Audience: Go embedders that materialize filtered subsets of a canonical cohort and want to dedup the work across consumers.
Pulse.FilterToFileWithRequest reads a source .pulse cohort,
applies a predicate, and writes the surviving records to a new
.pulse file. Three guarantees make the operation dedup-safe:
- Deterministic output naming — same source + same predicate resolves to the same output path.
- Atomic write — the engine never leaves a partially-written
.pulsefile at the target path. - Pre-existence dedup — if the deterministic output already exists, the engine returns it without re-running the filter.
Shape
type FilterToFileRequest struct {
SourcePath string // Path to the source .pulse file
Expression string // FILTER_EXPRESSION-style predicate
Filterers []*types.Filterer // Structured-predicate alternative
OutputDir string // Where to write the result
OutputName string // Optional override; default: {srcHash}_{predHash}.pulse
}
type FilterToFileResult struct {
OutputPath string
OutputHash string
RowCount int64
ElapsedMs int64
Reused bool // true when a pre-existing output was returned
}
Exactly one of Expression or Filterers must be set — both empty
or both set is a configuration error so the predicate hash is
unambiguous.
Basic usage
res, err := p.FilterToFileWithRequest(ctx, &pulse.FilterToFileRequest{
SourcePath: "cohort-2025-Q3.pulse",
Expression: "age >= 18 && state in [\"CA\", \"NY\"]",
OutputDir: "derived/",
})
if err != nil {
return err
}
fmt.Printf("%s (%s) %d rows reused=%v in %dms\n",
res.OutputPath, res.OutputHash, res.RowCount, res.Reused, res.ElapsedMs)
On a fresh OutputDir the first call writes the file and reports
Reused: false. A subsequent identical call hits the pre-existence
check, reports Reused: true, and skips the filter work.
Predicate shapes
Expression
A string evaluated under the FILTER_EXPRESSION engine — same expr-
lang semantics as Request.Filterers[*].Expression:
Expression: "age >= 18 && (region == \"NA\" || region == \"EU\")",
Filterers
A slice of structured types.Filterer entries translated into the
equivalent expression and AND-combined. Useful when the predicate is
built programmatically:
Filterers: []*types.Filterer{
{Type: types.FILTER_RANGE, Field: "age", Values: []string{"18", "65"}},
{Type: types.FILTER_INCLUDE, Field: "state", Values: []string{"CA", "NY"}},
{Type: types.FILTER_NULL, Field: "email", Values: []string{"is_not_null"}},
},
Supported types: FILTER_EXPRESSION (pass-through), FILTER_INCLUDE,
FILTER_EXCLUDE, FILTER_RANGE, FILTER_NULL.
Each filterer’s Field is judged by the same field rule Process and
predict use, against the cohort schema (a shard archive’s canonical
schema) and before the translated expression compiles: an unknown or
empty Field is SERVICE_VALIDATION filter references unknown field: <f> with details {field, filter}, exactly as Process refuses it.
Deterministic output naming
When OutputName is empty, the basename is computed as:
{sourceHash[:16]}_{predicateHash[:16]}.pulse
sourceHashis the SHA-256 of the source file’s bytes.predicateHashistypes.CanonicalHashof the predicate payload (Expression or Filterers slice, normalised to the same canonical JSON form used byRequest.Hash).
Independent consumers building the same request reach the same path
without coordination. Combined with a Watch on OutputDir, this is
the foundation for shared materialization caches: many callers ask
for the same derived cohort, only the first request pays the filter
cost, every subsequent caller hits the pre-existence dedup.
Atomic write
The engine stages writes to OutputDir/.<name>.partial and renames
into place only after the filter completes. A failed run removes the
temp file and never publishes a partial result at the target path.
Combined with the Watch API’s rename coalescing, downstream
observers see a single ChangeCreated or ChangeRenamed event for
the completed file — no spurious intermediate states.
Deduped (parent-group) sources
A grouped (0x02) source produces a grouped output. The header,
schema, group descriptors and group dictionaries are copied
byte-for-byte and the surviving physical rows are copied as-is, so
every row’s dictionary index stays valid. The output opens, inspects
and processes exactly like the same surviving rows deduped afresh
with the same groups.
The dictionary is carried, not pruned. After a selective filter
it still holds entries no surviving row references, so pulse inspect reports a group ratio of surviving rows ÷ carried entries.
A low ratio there is expected and is not a defect. Pruning would cost
a second pass and renumber indices, so v1 defers it. To prune,
re-dedup the output with the same groups (pulse dedup OUT --group ...). It regroups from scratch.
A filter that keeps no rows writes a valid, empty 0x02 cohort (the
preamble alone). The predicate’s field projection narrows only what
is DECODED to evaluate it. Output rows always carry every field,
group members included. A filter over one group’s members is
evaluated once per dictionary entry, not once per row.
Manifest annotation
filter_to_file appears in Manifest.Operations with annotations
{streamable: false, deterministic: true, expensive: true}. The
expensive: true hint is the signal to embedders that this is the
operation worth caching aggressively — which the function itself
already does via pre-existence dedup, so the typical consumer pattern
is to simply call FilterToFileWithRequest repeatedly and trust the
result.
See also
- Request Hashing — the hashing primitives underlying the deterministic naming.
- Watch & WatchDir — observe derived-output changes.
Reading & Building Cohorts
Audience: Go embedders who need the stored records of a cohort
themselves — to copy, verify, re-encode or feed them elsewhere — rather
than an aggregate computed by Process.
Cohort.Reader opens an index-addressed, record-by-record reader over
any cohort Pulse.Open returns: a single-file cohort (ungrouped format
0x01 or grouped 0x02), a whole shard archive, or one anchored shard
(archive.pulse#shard.pulse). The builder half of the round trip is
described further down.
Read records by index
c, err := p.Open(ctx, "survey.pulse")
if err != nil {
log.Fatal(err)
}
r, err := c.Reader()
if err != nil {
log.Fatal(err)
}
defer r.Close()
fields := r.Schema().Fields
for i := int64(0); i < r.Len(); i++ {
row, err := r.RecordAt(i) // pulse.CohortRow, one slot per field
if err != nil {
log.Fatal(err)
}
for f, v := range row {
fmt.Println(fields[f].Name, v)
}
}
Schema()is the schema the reader decodes against.CohortRowslotiholdsSchema().Fields[i], in schema LOGICAL order, so a grouped cohort reads exactly like its ungrouped twin.Len()is the number of addressable records. A trailing partial record is never addressable (the count floors, asCountRecordsdoes).RecordAt(i)is 0-based. Every returned row is freshly allocated and owned by the caller.Close()releases the file handle, waits for in-flight reads and is idempotent.
Exact values
RecordAt returns the EXACT stored value, never a float64 echo:
| Field type | Go value |
|---|---|
u4, u8, u16, u32, u64 | uint64 |
f32 / f64 | float32 / float64 |
date | int32 epoch days |
datetime | int64 epoch seconds |
decimal128 | encoding.Decimal128 |
categorical_* | string (the dictionary label) |
set_* (every rung, set_u128 / set_u256 included) | []string labels in dictionary order; an empty non-nil slice is “no selection” |
packed_bool | bool |
| null (any type) | nil |
Shard archives and anchors
Over a whole shard archive the index is global: shards are
concatenated in archive order (the order Cohort.Shards lists them),
Len() is the sum of their record counts — unlike
Cohort.RecordCount, which counts single-file cohorts only — and
RecordAt locates the record’s shard by binary search over the prefix
sums. Every record decodes against the archive’s canonical schema,
the one Cohort.Schema returns for the archive.
An anchored shard (archive.pulse#shard.pulse) addresses that one
shard, again against the canonical schema, so its rows equal the
whole-archive reader’s rows for that shard. A stored shard’s own
header schema is structurally identical to the canonical one — same
fields, types, set rungs, offsets and group layout — but its
dictionaries (categorical, set and group entries) may be a prefix
of the canonical ones: the seed shard is stored untouched while later
shards carry the union. The values decode identically either way;
the visible difference is that the anchored reader’s Schema() can
carry longer dictionaries than the anchored Cohort.Schema().
At open the reader checks every shard it addresses with the rules
pulse shard verify applies. A shard that is not decode-compatible is
refused with the same code — PULSE_SHARD_SCHEMA_MISMATCH or
PULSE_SHARD_DICT_DIVERGENCE — never decoded wrongly.
Errors
| Situation | Code |
|---|---|
index outside [0, Len()) | SERVICE_VALIDATION |
RecordAt after Close | SERVICE_RESOURCE |
| a record cut short | ENCODING_INVALID |
| a shard not decode-compatible with the canonical schema | PULSE_SHARD_SCHEMA_MISMATCH / PULSE_SHARD_DICT_DIVERGENCE |
Concurrency
RecordAt is safe for concurrent use from many goroutines, across
shard boundaries included: every call reads through its own
section reader, so no seek cursor is shared. On-disk files are read
with positional reads; other afero files are serialised behind a
mutex.
Building a single-file cohort
Pulse.NewCohortBuilder writes a new cohort from rows appended one at
a time — the write half of the round trip. The schema is the contract,
exactly as an explicit-schema import’s is.
schema := encoding.Schema{Fields: []encoding.Field{
{Name: "order", Type: encoding.FieldTypeU32, Description: "Order number."},
{Name: "cust", Type: encoding.FieldTypeU32, Description: "Customer number."},
{Name: "tier", Type: encoding.FieldTypeCategoricalU8, Description: "Customer tier."},
{Name: "amount", Type: encoding.FieldTypeF64, Nullable: true, Description: "Order value."},
}}
b, err := p.NewCohortBuilder(ctx, "orders.pulse", schema, pulse.CohortBuilderOptions{
Groups: []pio.GroupDecl{{Key: []string{"cust"}, Members: []string{"tier"}}}, // optional
ElideConstants: true, // optional
})
if err != nil {
log.Fatal(err)
}
for _, o := range orders {
if err := b.Append(pulse.CohortRow{o.ID, o.Cust, o.Tier, o.Amount}); err != nil {
log.Println(err) // PULSE_IMPORT_ROW_ERROR: the row is skipped, the builder stays usable
}
}
res, err := b.Close() // or b.Abort() to discard
- Rows use the same Go types
RecordAtreturns (table above), so a row read from one cohort can be appended to another unchanged. No coercion is applied. A row that does not fit — wrong arity or Go type,nilin a non-nullable field, a value past its type, a new label past the dictionary’s rung — isPULSE_IMPORT_ROW_ERRORwithrow(the 1-basedAppendcall),fieldandreasondetails; it leaves no trace, not even a dictionary entry. - Schema. Field order, names, types, nullability, descriptions,
decimal precision / scale and optional pre-seeded dictionaries are
honoured. Layout (
ByteOffset,BitPosition,CsvColumnIdx) is recomputed, and the caller’s schema is never mutated. Dictionaries grow from the pre-seeded entries in first-seen order; the declared rung is a ceiling, never auto-promoted. Parent groups go inGroups, never in the schema. - Groups and elision.
GroupsandElideConstantsbehave exactly aspulse import --group/--elide-constants: the samePULSE_GROUP_*declaration checks (atNewCohortBuilder), the same viability gate (a group no wider than its index is dropped withPULSE_GROUP_TOO_NARROW; one belowRatioFloorrows per tuple is still written, withPULSE_DEDUP_LOW_RATIO) and the same encoder. A key that does not determine its members failsClosewithPULSE_GROUP_MEMBER_NOT_CONSTANT(source_row= theAppendcall). The header is0x02only when a group is written — a build whose every group was dropped is a plain0x01file. StrictturnsPULSE_FIELD_DESCRIPTION_LOW_QUALITYand the gate’s findings into errors; none leaves anything on disk.- Close writes atomically: rows spool to a temp file beside the
target (the preamble’s dictionaries are only final after the last
row), then preamble + rows go to a second temp file that is fsynced
and renamed over the target. A failed
Closeor anAbortleaves no target and no temp files.CohortBuildResultcarriesRecords, theFormatVersionwritten, the finalSchema,Warnings, the per-groupGroupsverdicts andElidedConstants. - Overwrite. An existing target is refused unless
Overwrite: true; replacing a cohort reports the sidecars it invalidated (InvalidatedSidecars), asPulse.Dedupdoes. Replacing a managed import (a cohort under the imports directory with its.meta.jsonsidecar) also slides its expiry by its TTL — the same refresh an in-placeDedupor a read gives it; a pinned import stays pinned. A fresh build never creates or touches that sidecar, so a new cohort written into the imports directory is not a managed import.
Parity with import. A builder cohort with schema S is
byte-identical to the same rows imported with explicit schema S and the
same Groups / ElideConstants — both run one dictionary-assignment,
row-encoding, group-gate and encoder path. Shared checks are the
description length (PULSE_IMPORT_DESCRIPTION_TOO_LONG) and the row
conversion; the builder additionally refuses a malformed schema (no
fields, empty or duplicate names, unknown type, bad decimal precision)
with SERVICE_VALIDATION, which import does not check.
Building a shard archive
Set Shards and the same builder writes a new shard archive, split
automatically:
b, err := p.NewCohortBuilder(ctx, "orders.pulse", schema, pulse.CohortBuilderOptions{
Shards: &pulse.ShardSplit{MaxRecords: 1_000_000},
Groups: []pio.GroupDecl{{Key: []string{"cust"}, Members: []string{"tier"}}}, // optional
})
// ... Append exactly as above ...
res, err := b.Close() // res.Shards: part-00001.pulse, part-00002.pulse, ...
- Split. Rows are cut, in append order, into consecutive shards of
MaxRecordsrows namedpart-00001.pulse,part-00002.pulse, …; the last shard is partial, and an empty build is one empty shard. The archive reads back throughCohortReaderas the appended rows, in order.MaxRecordsmust be positive (SERVICE_VALIDATION), and a build holds at most 65,535 shards — the row that would open one more is refused withSERVICE_VALIDATION(reason: shard_limit). - One layout.
GroupsandElideConstantsare decided once over all rows, then applied to every shard: each shard carries the same schema, dictionaries and group layout. A group the gate drops is dropped in every shard, and a field constant within each shard but not across the build is not elided. - All or nothing.
Closestages the shard files in a directory beside the target and publishes them with onePulse.CreateShardArchivecall, which writes the archive atomically. The staging directory is removed whatever the outcome, so a failedCloseleaves no archive, no spool and no shard file — and an existing archive being replaced (Overwrite: true) stays as it was. - Result.
CohortBuildResult.Shardslists the shard entries in archive order.Warningsends with any warningsCreateShardArchiveraised (PULSE_SHARD_*), with their codes and details unchanged.
Appending a shard to an archive
An anchored target — archive.pulse#name.pulse — makes the builder
write one new shard and append it to an existing archive, the
monthly-drop pattern:
b, err := p.NewCohortBuilder(ctx, "orders.pulse#2026-10.pulse", schema, pulse.CohortBuilderOptions{})
// ... Append this month's rows ...
res, err := b.Close() // runs Pulse.AddShard once; res.Shards == ["2026-10.pulse"]
- Rules, checked at
NewCohortBuilder. The archive must already exist and be a shard archive — an append never creates one (build it withShardSplit):SERVICE_VALIDATIONwithreasonarchive_not_found/not_an_archive. The shard name must be a plain file name (invalid_shard_name) the archive does not hold yet (PULSE_SHARD_NAME_COLLISION), and never_schema.pulse(PULSE_SHARD_RESERVED_NAME).Groups,ElideConstantsandShardsare refused — the archive’s group layout wins — and so isOverwrite, since an append replaces nothing:SERVICE_VALIDATION,reason: append_option,details.optionnaming the option. - Cohesion, checked up front too.
NewCohortBuilderreads the archive’s canonical schema and runsAddShard’s own merge rules over your schema, so a shardAddShardis certain to refuse fails at construction — before any row is spooled — with the same code, message and details:PULSE_SHARD_SCHEMA_MISMATCHfor a field count, name, order, numeric type, categorical width or set-vs-non-set that differs,PULSE_SHARD_DICT_WIDTH_OVERFLOWwhen your pre-seeded dictionaries alone overflow the union. EverythingAddShardaccepts passes: a set rung it widens, labels it union-merges, a grouped archive it re-encodes into. What only the rows decide (a union the appended labels overflow) is still checked atClose, andClosere-runs the full check, since the archive can change in between. - Close. The rows are written as one shard file staged beside the
archive and handed to one
Pulse.AddShardcall, which rewrites the archive atomically and reconciles the shard to it: categorical dictionaries union-merge, a set field that outgrows the archive’s rung widens the archive (PULSE_SHARD_SET_WIDENED), and a shard whose group layout differs is re-encoded to the archive’s (PULSE_SHARD_GROUPS_REWRITTEN,reason: incoming_regrouped). Those mandatory warnings endCohortBuildResult.Warnings, codes and details unchanged. - Failures pass through.
AddShard’s errors areClose’s, unchanged — a cohesion failure the rows caused or that an archive changed while the build ran introduced (PULSE_SHARD_SCHEMA_MISMATCH,PULSE_SHARD_DICT_WIDTH_OVERFLOW), or a name another writer added while the build ran (PULSE_SHARD_NAME_COLLISION). On any failure the archive is byte-for-byte as it was, and no spool or staging is left. - Result.
Targetis the anchored path,Shardsthe one entry added.Schema/FormatVersiondescribe the shard as built, beforeAddShardreconciled it; open the archive (or the anchor) to read the stored layout. The new shard reads back throughCohortReaderover the archive (after the existing shards) and over the anchor.
Materializing derived columns
Pulse.Materialize writes a request’s derived columns (features,
attributes, windows) as ordinary fields of a new cohort, through
NewCohortBuilder and with source cells read by CohortReader.RecordAt.
The full contract — accepted slots, rows, schema, refusals, the CLI
leaf and the MCP tool — is on its own page:
Materializing Derived Columns.
Materializing Derived Columns
A derived column — a window, a feature output, an attribute — exists
only inside the request that computes it. Regressions, groupers,
filters, joins and tests read cohort FIELDS, so none of them can name
one. Pulse.Materialize writes the derived columns as ordinary fields
of a NEW cohort; the next request reads them like any other field.
Library
Pulse.Materialize(ctx, req, out, opts) turns a request’s derived
columns into ordinary fields of a NEW single-file cohort, so later
requests can filter, group or join on them:
res, err := p.Materialize(ctx, &pulse.Request{
Cohort: &types.Cohort{Filename: "sales.pulse"},
Features: []*types.Feature{{Type: types.FEAT_LOG, Field: "spend"}},
Windows: []*types.Window{{Type: types.WIN_LAG, Field: "spend",
PartitionBy: []string{"region"}, OrderBy: []types.OrderKey{{Field: "week"}}}},
}, "sales_derived.pulse", pulse.MaterializeOptions{})
// res.Records, res.DerivedColumns ("LOG_spend", "WIN_LAG_spend")
- Accepted slots. Features, filterers, attributes, windows,
sort, weight andtime_zone. Any other slot is refused withPULSE_MATERIALIZE_SLOT_UNSUPPORTED(details.slot); a request with no feature, attribute or window withPULSE_MATERIALIZE_NO_DERIVED; an embedder-registered feature (its outputs are unknown before it runs) withPULSE_MATERIALIZE_OUTPUT_UNDECLARED. Process’s own pre-flight runs first, so a requestProcessrefuses is refused with the same error. Every refusal happens before anything is created. - Rows. One per filter-passing source record, in source order (an
archive’s shards concatenated in archive order). Features see every
record, attributes and windows the survivors — exactly as
Processcomputes them. Withsort, the rows follow the orderRequest.SortgivesProcess’s row set — the engine’s own comparator, ties in source order, nulls last in both directions — on keys naming source fields or derived columns (feature outputs, attribute and window labels). Sorting moves rows, never values: every window has already run under its ownpartition_by/order_by. Only the permutation and each survivor’s source index are held beside the buffered records, and the memory pre-flight isProcess’s (PULSE_LIMIT_EXCEEDEDbefore anything is created). Source cells are the exact stored values (CohortReader.RecordAt). - Schema. The source’s logical schema verbatim (order, types,
nullability, dictionaries, descriptions), then one nullable
f64per derived output in pipeline order — features (every output of a multi-output feature), attributes, windows — each with a generated description such asWIN_LAG of spend (partition region; order week) — derived from sales.pulse. A window’s frame is stated only when the operator reads it:WIN_EWMAis cumulative whatever its required frame says (manifestignores_frame), so its description has no frame clause. An operator whose input is the slot’sexpression(manifestreads_expression) is described by that expression, verbatim —ATTR_FORMULA = tv_spend_k * 0.80866 — derived from panel.pulse— and a regression attribute names itspenalty/alpha/l1_ratio. Every description is capped at 1,000 bytes, cut on a rune boundary with an ellipsis. Integer window outputs (row number, rank) are stored asf64; a non-numeric value (a lag copying a categorical label) fails the run withPULSE_MATERIALIZE_OUTPUT_NOT_NUMERIC. - Writing. Always an ungrouped (
0x01) single file, written atomically throughNewCohortBuilder; source sidecars are not carried. An existing target needsOverwrite; replacing one reports its stale sidecars inInvalidatedSidecarsplus aPULSE_MATERIALIZE_SIDECARS_INVALIDATEDwarning.Strictescalates a weak description on the output schema (the generated ones always pass; an undescribed source field does not). A limit breach, a refusal or a write failure leaves nothing at the target.
The two-step recipe: a regression on a window
Step 1 materialises the window (only derived columns and filters):
zero := 0 // a cumulative frame: unbounded preceding through the current row
res, err := p.Materialize(ctx, &pulse.Request{
Cohort: &types.Cohort{Filename: "weekly.pulse"},
Windows: []*types.Window{{Type: types.WIN_RUNNING_SUM, Field: "x", Label: "cum_x",
OrderBy: []types.OrderKey{{Field: "week"}},
Frame: &types.FrameSpec{Mode: "rows", Following: &zero}}},
}, "weekly_derived.pulse", pulse.MaterializeOptions{})
Step 2 points the next request at the output and names the column by its label:
resp, err := p.Process(ctx, &pulse.Request{
Cohort: &types.Cohort{Filename: "weekly_derived.pulse"},
Regressions: []*types.RegressionSpec{{Type: types.REG_OLS, Target: "y",
Predictors: []string{"cum_x"}}},
})
Label every output a later request references; an unlabelled one takes
the operator’s default output name (res.DerivedColumns lists each).
The output is a snapshot: re-materialise after the source changes.
CLI
pulse api materialize -r req.json -o weekly_derived.pulse [--overwrite] [--strict] [--json]
req.json is an ordinary request body. --json wraps the
MaterializeResult (target, records, derived_columns,
warnings, invalidated_sidecars) in the standard envelope and lifts
its warnings onto the envelope’s warnings; a refusal keeps its own
code in errors[0].code (pulse errors lookup CODE). The leaf is a
thin adapter over Pulse.Materialize.
MCP
pulse_materialize takes {request, out, overwrite} — the request
NESTED under request, out relative to the data dir — and returns
the same result object. A request slot spread beside out (or a
misspelled argument) is PULSE_REQUEST_UNKNOWN_FIELD with a
suggestion; an unknown key inside request is refused as
pulse_process refuses it. Description-quality strict mode is CLI and
library only.
Feature profiles
capability:materialize (which needs capability:process) gates the
tool and the CLI leaf; a profile without it does not mount
pulse_materialize and lists no materialize command. The library
method itself is never hidden — an embedder calling it holds the
instance — but the instance’s operator set still applies: a hidden
operator in the request is refused exactly as an unknown one.
Point Lookup & Index Management
Audience: Go embedders resolving a single row (or a small duplicate set) by an exact key value instead of scanning or filtering a whole cohort.
Pulse.Lookup is a keyed point-lookup store: O(1) — flat latency
regardless of cohort size — against a prebuilt sidecar index
(Pulse.BuildIndex). It only works against a single-file cohort
(shard archives are rejected with PULSE_INDEX_UNSUPPORTED_SHARDED;
the archive.pulse#shard.pulse anchor is a tested workaround), it is
equality-only (no ranges, no expressions), and it always resolves
the full key — no prefix or partial-key matching. For anything
else (ranges, expressions, unindexed ad hoc queries), use
Process with FILTER_INCLUDE / FILTER_EXPRESSION
instead.
Build then look up
package main
import (
"context"
"fmt"
"log"
"github.com/frankbardon/pulse"
"github.com/frankbardon/pulse/types"
)
func main() {
ctx := context.Background()
p, err := pulse.New(pulse.Options{DataDir: "/var/data/pulse"})
if err != nil {
log.Fatal(err)
}
// Build the sidecar index once — cheap to re-run (idempotent,
// byte-identical sidecar for an unchanged cohort), but there's no
// need to call it on every lookup.
if _, err := p.BuildIndex(ctx, "sales.pulse", []string{"order_id"}); err != nil {
log.Fatal(err)
}
result, err := p.Lookup(ctx, &pulse.LookupRequest{
Cohort: &types.Cohort{Filename: "sales.pulse"},
Field: "order_id",
Value: "42",
})
if err != nil {
log.Fatal(err)
}
for _, row := range result.Rows {
fmt.Println(row)
}
}
BuildIndex returns a *pulse.BuildIndexResult{IndexPath, ManifestPath, Index} —
IndexPath is the derived sidecar path, Index is the in-memory
*pulse.SidecarIndex that was serialized there, handed back in case you
want to inspect it without a round-trip read. Every type inside it has a
root spelling: Keys []pulse.SidecarIndexKeySpec (ordered key columns),
Buckets []pulse.SidecarIndexBucket whose Entries are
pulse.SidecarIndexEntry{Key, RowIDs}, and Fingerprint, a
pulse.CohortFingerprint (the SHA-256 of the cohort’s bytes).
Lookup’s single-key convenience path — Field + Value, both
strings — is the E1 shape and stays the ergonomic default. Value is
always text, the same string-literal convention Filterer.Values
uses for FILTER_INCLUDE/FILTER_EXCLUDE.
Composite keys
A multi-column key is an ordered tuple — column order matters end
to end, from the BuildIndex call that wrote the sidecar through
every Lookup that probes it. Use Keys instead of Field/Value:
if _, err := p.BuildIndex(ctx, "sales.pulse", []string{"region", "date"}); err != nil {
log.Fatal(err)
}
result, err := p.Lookup(ctx, &pulse.LookupRequest{
Cohort: &types.Cohort{Filename: "sales.pulse"},
Keys: []pulse.LookupKey{
{Field: "region", Value: "EU"},
{Field: "date", Value: "2026-01-01"},
},
ReturnColumns: []string{"order_id", "revenue"},
Multiplicity: pulse.LookupMultiplicityAll,
})
if err != nil {
log.Fatal(err)
}
Keys takes precedence over Field/Value when both are set.
{region, date} and {date, region} name the same two logical
values but resolve against two different sidecar indexes — build the
index with the same column order you’ll query with.
ReturnColumns projects the result down to named fields; empty means
every schema field (LookupResult.Rows[i] is a full
map[string]any), matching Sample’s no-projection default.
Multiplicity
Multiplicity controls what happens when the matched key resolves to
more than one row-id (a duplicate key value in the source cohort):
The mode type and its constants are re-exported on the pulse package,
so you never need to import types to set LookupRequest.Multiplicity:
| Constant | Wire value | Behavior |
|---|---|---|
pulse.LookupMultiplicityAssertUnique | assert_unique | Default (zero value). Fails with PULSE_LOOKUP_AMBIGUOUS on more than one match; a single match succeeds normally. |
pulse.LookupMultiplicityFirst | first | Takes the lowest row-id, never errors on a multi-row match. |
pulse.LookupMultiplicityAll | all | Returns every matched row in Rows, ascending row-id order. |
result, err := p.Lookup(ctx, &pulse.LookupRequest{
Cohort: &types.Cohort{Filename: "sales.pulse"},
Field: "region",
Value: "EU",
})
if err != nil {
if errors.HasCode(err, errors.PULSE_LOOKUP_AMBIGUOUS) {
// region isn't unique — rebuild the index on a real key, or
// opt into LookupMultiplicityFirst / LookupMultiplicityAll.
}
log.Fatal(err)
}
errors.HasCode(err, errors.CODE) (github.com/frankbardon/pulse/errors)
is the coded-error check used across the codebase — the same pattern
covers PULSE_INDEX_MISSING, PULSE_INDEX_STALE,
PULSE_INDEX_UNSUPPORTED_SHARDED, PULSE_INDEX_MANIFEST_INVALID,
PULSE_INDEX_MANIFEST_STALE, and PULSE_LOOKUP_NOT_FOUND.
Index lifecycle
Three more facade methods manage sidecars without a lookup:
// Freshness check — size fast-path before an authoritative
// full-content SHA-256 recompute.
verify, err := p.VerifyIndex(ctx, "sales.pulse", []string{"order_id"})
// verify.Fresh, verify.Reason ("stat_mismatch" | "fingerprint_match" |
// "fingerprint_mismatch"), verify.FastPath, verify.ModTimeDrift
// Enumerate every sidecar built against a cohort, with each one's
// ordered key tuple. Served from the keyless manifest when present, so
// it needs no directory listing.
indexes, err := p.ListIndexes(ctx, "sales.pulse")
// []pulse.IndexInfo{IndexPath, Keys, DistinctKeys, IndexedRecords}
// Remove a sidecar. Non-interactive — no confirmation prompt. Prunes
// the manifest entry in the same call.
err = p.DropIndex(ctx, "sales.pulse", []string{"order_id"})
Staleness has three outcomes, not two
- Size mismatch →
PULSE_INDEX_STALEimmediately, with no hash. This is conclusive rather than merely cheap: the sidecar’s fingerprint covers the whole file, so a file of a different length cannot produce the recorded digest. - Size and mtime both match → served hash-free. That is the O(1) property; a full hash per lookup would defeat the point of the store.
- Mtime moved while the size held → not staleness. It escalates to the sidecar’s own SHA-256 and refuses only if the content really differs.
That third case is the one worth knowing about. SourceModTime is
recorded as Unix nanoseconds; an S3 LastModified is whole
seconds, and every copy, restore and cache hop truncates somewhere
of its own choosing. Treating drift as proof of mutation refused
byte-identical indexes permanently — not transiently — so a cohort
read through an object-storage backend could not be looked up at all.
The escalation is memoised per (cohort path, size, mtime), so a
backend that never preserves nanoseconds pays one whole-file hash per
distinct stat, not one per lookup. The memo assumes exactly what the
stat fast-path already assumes (same path, size and mtime means the
same bytes), so it adds no freshness gap of its own.
VerifyIndex is the authoritative path: a matching size alone is
inconclusive (a same-size rewrite is possible), so it always falls
through to a full content-hash recompute before reporting
Fresh: true, and it never reads that memo — its whole job is the
verdict that does not rest on a stat pair. ModTimeDrift reports that
the mtime disagreed and the hash overruled it, on both a fresh and a
stale verdict. Run VerifyIndex explicitly wherever you need a
stronger guarantee than Lookup gives you for free — before a batch of
lookups against a cohort you don’t fully trust, or in a periodic health
check.
The one residual gap is unchanged and deliberate: an in-place edit that
preserves both size and mtime passes Lookup and is caught only by
VerifyIndex.
Discovering which indexes exist
A sidecar’s filename embeds a hash of its ordered key tuple
(cohort.pulse.<keyhash>.idx), so the file cannot be opened without
already knowing the tuple — and key order is significant, so guessing
is a permutation search. On a local disk you could glob
<cohort>.*.idx and read each match’s key spec; object storage cannot
list a directory, so that escape is unavailable exactly where cohorts
are usually served from.
BuildIndex therefore also writes a keyless catalog beside the cohort
at a deterministic path — sales.pulse.indexes.json — holding every
index’s ordered key tuple with its column types and counts:
{
"format_version": "1",
"kind": "pulse.index-manifest",
"cohort": "sales.pulse",
"indexes": [
{
"index_path": "sales.pulse.42fdb66c2f329c5c.idx",
"key_hash": "42fdb66c2f329c5c",
"keys": [
{"name": "category_id", "type": "u16"},
{"name": "brand_id", "type": "u32"}
],
"distinct_keys": 480,
"indexed_records": 50000
}
]
}
ListIndexes reads it first, which is why discovery costs one read of
that document plus one existence probe per entry and no read of any
sidecar. BuildIndexResult.ManifestPath names the file. Its
format_version is the manifest’s own, independent of the .pulse
format and the sidecar index format, so a field can be added to it
without any rebuild.
Behaviour at the edges:
| State | Result |
|---|---|
| No manifest (indexes built by an older Pulse) | Falls back to the directory listing; unchanged behaviour |
| Manifest plus a sidecar it does not name | Both are listed — the union, so an older sidecar does not vanish |
| Manifest present, directory not listable | Manifest answers; not an error |
| Manifest unreadable | PULSE_INDEX_MANIFEST_INVALID — never a silent fall back to the listing, which would succeed locally and answer “no indexes” on a bucket |
| Manifest names a sidecar that is gone | PULSE_INDEX_MANIFEST_STALE; repair with BuildIndex or DropIndex (which prunes an orphaned entry) |
The manifest is one of Pulse’s own *.json sidecars, so a
PULSE_LABEL_TABLES_DIR or PULSE_RANGE_TABLES_DIR pointed at a data
directory skips it by suffix.
Keyable-type policy
Not every field type can serve as a lookup key — set_* is rejected
(a multi-select bitmask has no single unambiguous equality value; use
FILTER_SET via Process instead). See the full allow/reject table
in pulse api lookup or
skills/cohort-sidecar-index.md.
When to use this vs Process + FILTER_INCLUDE
Reach for Lookup when you have an exact key and want the fastest
possible single/few-row read; reach for Process with FILTER_*
for anything that isn’t a full-key equality match (ranges,
expressions, set_* membership, or querying a column you haven’t
built an index for). The performance shape is the load-bearing claim,
not any particular absolute number: indexed lookup is flat
regardless of cohort size, while an unindexed scan is linear in
record count — the gap widens, not narrows, as a cohort grows. Build
the index once with BuildIndex; every subsequent Lookup against
that key combination pays only the flat O(1) cost.
See also
pulse api lookup— the CLI leaf forLookup, pluspulse index build/list/verify/dropfor the lifecycle methods above.- Go API Overview — the rest of the public facade.
Request Templating
Audience: Go embedders and operators who want a request shape fixed once — reviewed, stored, version-controlled — and callers who supply only the parts that legitimately vary.
A template is a JSON document at rest that renders into a
validated, typed Pulse request. The analytical shape (which
aggregations, which groupers, which filters, which cohort) is
authored once; a caller passes a small map of variables and gets back
a *types.Request — or one of the four other request roots — ready
to hand to Process.
Rendering is execution-free. It never opens a cohort, never reads a record, and never touches the filesystem beyond the template file itself.
Not
expr-lang. Template variables and Pulse’s expression runtime are different axes that happen to both use braces.$var,{{name}}and$whenare request-authoring parameters, substituted before the JSON is decoded into a request.ATTR_FORMULAandFILTER_EXPRESSIONareexpr-langexpressions over row fields, evaluated during execution. They do not interoperate: a formula cannot see a template variable, and a formula string sitting in a template body is just text as far as the renderer is concerned.
Why the wrapper
A template file is not a bare request with holes punched in it. It is an explicit four-part wrapper:
{
"name": "finance/revenue",
"description": "Revenue bucketed by size, optionally segmented",
"target": "request",
"variables": [],
"body": {}
}
| Key | Required | Why it exists |
|---|---|---|
target | yes | Names the request root the rendered body strict-decodes into. It cannot be inferred — the five roots overlap enough in shape that guessing would silently pick the wrong one. |
variables | no | Declares every parameter a caller may (or must) supply. Declaration-first is what lets a UI build a form, or an agent build a prompt, from the template alone — without rendering it first to discover what it wants. |
body | yes | The parameterised request, markers still in place. Deliberately not runnable as-is: it becomes a request only once rendered. |
name | no | Optional, and the file path is authoritative. A name that disagrees with the derived path name is rejected rather than silently renaming the author’s template. |
description | no | Human-facing prose, surfaced in listings. |
Unknown top-level keys are rejected. A typo’d "varaibles" that
silently yielded zero variables is precisely the class of silent
failure this feature exists to eliminate.
target is one of five lowercase values, each selecting a public
request root:
target | Decodes into | Execute with |
|---|---|---|
request | types.Request | Process, Predict, ProcessStream |
composed | types.ComposedRequest | Compose, ComposeParallel |
chain | types.ChainRequest | ProcessChain |
facet | types.FacetRequest | FacetSchema |
sample | types.SampleRequest | SampleWithRequest |
A worked example
Put this at <root>/finance/revenue.json, which names it
finance/revenue:
{
"target": "request",
"variables": [
{"name": "metric", "type": "field", "required": true},
{"name": "bucket", "type": "integer", "default": 10},
{"name": "segs", "type": "list", "items": "string"}
],
"body": {
"label": "total {{metric}}",
"cohort": {"filename": "sales.pulse"},
"aggregations": [
{"type": "AGG_SUM", "field": {"$var": "metric"}}
],
"groups": [
{"type": "GROUP_RANGE", "field": {"$var": "metric"}, "interval": {"$var": "bucket"}},
{"$when": "segs", "type": "GROUP_CATEGORY", "field": "region", "include": {"$var": "segs"}}
]
}
}
Point an engine at the directory and render it:
p, err := pulse.New(pulse.Options{
DataDir: "/var/data/pulse",
TemplateDirs: []string{"/etc/pulse/templates"},
})
if err != nil {
log.Fatal(err)
}
req, err := p.RenderTemplateRequest("finance/revenue", map[string]any{
"metric": "revenue",
})
if err != nil {
log.Fatal(err)
}
resp, err := p.Process(ctx, req)
The rendered request is:
{
"label": "total revenue",
"cohort": {"filename": "sales.pulse"},
"aggregations": [{"type": "AGG_SUM", "field": "revenue"}],
"groups": [{"type": "GROUP_RANGE", "field": "revenue", "interval": 10}]
}
Three things happened. metric was substituted into both a string
("total {{metric}}") and a slot ({"$var": "metric"}). bucket
fell back to its declared default and spliced in as the number
10, not the string "10". And the segs-guarded grouper — no
value supplied, no default — was dropped entirely, with the array
compacted so no null hole was left behind.
Substitution — three forms, and nothing else
There is no expression language in a template body. A body is a request with holes in it, not a program. The whole grammar is three forms.
Slot markers — {"$var": "name"}
An object is a marker if and only if $var is its single key and
its value is a non-empty string. The whole object is replaced by the
resolved value, and the substitution is type-preserving:
{"interval": {"$var": "bucket"}}
renders to {"interval": 10} — never {"interval": "10"}. This is
what lets a marker fill a numeric, boolean, array or object slot.
{"$var": "x", "other": 1} is not a marker; it is literal data and
passes through untouched. A lone $var whose value is not a
non-empty string is rejected at validation rather than passed through
as data.
Substituted values are spliced as data and are not re-walked. A
caller who supplies the literal string "{{metric}}" gets that string
verbatim in the rendered request, not a second round of interpolation.
String sugar — "{{name}}"
Inside a string value, {{name}} interpolates the variable’s
natural text: strings verbatim, numbers as their exact literal,
booleans as true/false, dates as their ISO string. Whitespace is
tolerated — {{ name }} works. {{{{ is the escape for a literal
{{.
Two types have no natural text form: a list and a period inside a
string are PULSE_TEMPLATE_VAR_TYPE. Splice them with a marker
instead.
Object keys are not interpolated. {{ appearing in a key is
rejected outright at validation, so this is never a silent no-op.
Optional blocks — {"$when": "name", ...}
A block carrying $when survives if and only if the named variable
resolved. The $when key is stripped either way, so it never reaches
the decoder.
$when is presence, not truthiness. It drops only when the
variable was neither supplied nor defaulted. A caller who supplies
"", [], 0, or false has supplied a value, and the block
stays — a legitimate zero has to be templatable.
Guards evaluate before the walk descends, so an unresolved marker inside a dropped block never raises. That ordering is the entire point of the guard: it is how an author says “this slot is optional”.
A dropped object is removed from its parent array with the slice
compacted (a null hole would decode to a nil operator slot), or its
key is removed from its parent object. $when on the root body is an
error.
A surviving unguarded marker naming a variable that resolved to
nothing is PULSE_TEMPLATE_UNRESOLVED. A marker never silently
vanishes — optionality is spelled $when, always.
Items that fan out — for_each
A window, feature or attribute item may carry a for_each, which fans
it out over parameter axes after the template renders, using the same
two placeholder spellings ({{axis}} in a string, {"$var": "axis"}
as a whole value inside params). The renderer recognises such an item
structurally — an object whose for_each lists axes with literal
names — and leaves that item’s own axis placeholders untouched for
the expansion. No escape is needed.
Template variables keep working everywhere else, including beside the
axis placeholders and inside the for_each value itself, so a template
variable can supply an axis’s values:
{"target": "request",
"variables": [{"name": "metric", "type": "field", "required": true},
{"name": "decays", "type": "list", "items": "number", "required": true}],
"body": {"cohort": {"filename": "media.pulse"},
"windows": [{"type": "<smoothing window>", "field": {"$var": "metric"},
"label": "{{metric}}_ad{{d}}", "order_by": [{"field": "week"}],
"params": {"decay": {"$var": "decay"}},
"for_each": {"axes": [{"name": "d", "values": [30, 50, 70]},
{"name": "decay", "values": {"$var": "decays"}}],
"mode": "zip"}}]}}
{{metric}} and both {"$var": "metric"} / {"$var": "decays"}
substitute at render; {{d}} and {"$var": "decay"} reach the
rendered request as written and resolve when the request runs or is
predicted.
Two rules keep this unambiguous, both checked at declaration time:
- An axis
namemust be literal. Written as{{…}}or as a$varmarker it isPULSE_TEMPLATE_INVALID— the renderer has to know the axis names before any variable resolves. - A template variable may not share a name with an axis of a
for_eachitem in the body: inside that item the placeholder would mean two things. That isPULSE_TEMPLATE_AXIS_COLLISION; rename one of them.
An axis is scoped to its own item: the same {{d}} in a sibling item
without that for_each is an undeclared reference
(PULSE_TEMPLATE_INVALID), as is an axis placeholder inside the
for_each value.
The nine variable types
| Type | Accepts | Extra declaration slot | Notes |
|---|---|---|---|
string | any JSON string | — | |
number | any JSON number | — | integral or fractional |
integer | a JSON number with no fractional part | — | 1 and 1.0 qualify; 1.5 does not |
boolean | a JSON bool | — | |
field | a JSON string naming a cohort field | — | shape-identical to string; a distinct type so cohort-bound field-name constraining can be layered on later without a wire change |
enum | a string in the declared set | values (required) | membership is exact and case-sensitive |
list | a JSON array | items (required) | items must be a scalar type — lists do not nest |
date | a string parsing as YYYY-MM-DD | — | a strict subset of encoding.DateFormats; 03/04/2024 is ambiguous and rejected |
period | an object carrying exactly one of ranges or table | — | mirrors the GROUP_DATE_RANGES / FILTER_DATE_RANGES Params shape |
The scalar types — string, number, integer, boolean, field,
date — are the six legal items values. The three non-scalars are
excluded for distinct reasons: list would nest and a nested list has
no element-type slot to declare; period is a JSON object rather than
a scalar; enum is scalar on the wire but draws its membership set
from the variable-level values slot, which a list declaration cannot
express per-element unambiguously.
Every declaration also carries:
required— the variable must resolve at render.requiredtogether with adefaultis legal and not a contradiction: the default resolves the variable, so a required variable carrying a default can never go missing.default— the value used when the caller supplies none, held as raw JSON so integer fidelity survives to the render walk. An explicit JSONnullmeans “no default”, identical to omitting it.description— optional prose, so a caller can build a form or a prompt from the declaration alone.
Unknown keys inside a period object, and inside each
{label, start, end} range, are rejected — a "startt" typo must not
silently become an open bound.
Layered template directories
Options.TemplateDirs []string is the ordered list of directory
roots. When it is empty, PULSE_TEMPLATES_DIR applies instead, split
on os.PathListSeparator (: on Unix, ; on Windows) — the same
PATH-style shape, so a deployment can layer a site-override directory
ahead of a shipped default without a second variable. The
programmatic option wins outright: when TemplateDirs is non-empty
the environment is never consulted.
Every *.json file beneath a root is a template. Its name is its
path relative to its own root, minus the .json extension,
forward-slash separated regardless of host OS:
/etc/pulse/templates/finance/revenue.json → finance/revenue
Subdirectories namespace for free, and the root’s own location never leaks into the name.
Roots are a precedence list and the first root wins. The same name
under a later root is shadowed, not rejected — that is what makes an
override layer possible. The losing entries are not discarded: their
source paths are recorded on the winner’s Summary.Shadows, so “why
is my override not taking effect?” is answerable from
ListTemplates() alone. A shadowed entry deliberately gets no summary
of its own; a listing whose entries cannot all be fetched would be a
trap.
Root edge cases:
| Root | Behaviour |
|---|---|
| blank / whitespace-only | skipped — an empty element in a PATH-style list is not a fault |
| does not exist | skipped — an absent optional override layer is not a fault |
| exists but is a regular file | error, naming the path — a root pointed at a file is a genuine misconfiguration |
| exists and is a directory | walked |
A file whose derived name would be empty (.json, finance/.json) is
an error naming the path at startup, not a silent skip.
Filesystem faults use DATA_FILE, not the PULSE_TEMPLATE_*
family. That split is deliberate: PULSE_TEMPLATE_* describes a fault
in a document or a render, while an unreadable directory, an
unreadable file, or a root that is a regular file is an ordinary
filesystem problem and reads better under the code every other
filesystem fault in Pulse uses.
Template directories are process configuration, not cohort data,
so they are read through the os package rather than afero — the
same sanctioned exception the label-table and range-table loaders use.
The hot-reload lifecycle
Templates reload without a restart. A lookup whose cached snapshot has
aged past the store’s rescan interval (one second, a package constant
rather than an Option — a dial nobody can set better than the store
can is a permanent public surface bought for nothing) re-walks the
roots first. A file dropped into a scanned directory becomes
renderable, a changed file starts serving its new content, and a
deleted one stops resolving.
A rescan is a directory walk plus one stat per candidate. A file whose size and modification time both match the copy already parsed is carried over rather than re-read, so rescanning an unchanged directory costs syscalls and no JSON parsing.
ReloadTemplates() forces that walk immediately. It is an escape
hatch, not the mechanism — reach for it when you need determinism
rather than eventual visibility: a deployment step that writes a
template and must render it on the next line, or a test that would
otherwise have to sleep.
What a malformed file does depends on when it breaks
This is the load-bearing policy, and the phase split is intentional:
| Phase | A malformed template file | Why |
|---|---|---|
At pulse.New() | hard-fails startup, naming the offending path | At boot, a broken document is a deploy error the operator must see immediately. |
| After startup, parsed successfully before | keeps serving its last-good parse; marked Broken in ListTemplates() with the fault in Error | A partially-written file is the normal transient state when a human edits in place. Killing the catalog over a half-finished save is worse than briefly-stale content. |
| After startup, never parsed successfully | Listed with Broken: true and an empty Target; GetTemplate / RenderTemplate return PULSE_TEMPLATE_INVALID naming the path | There is no last-good copy to fall back to. The entry is listed to be seen, not fetched. |
Two consequences follow, and both are deliberate:
ReloadTemplates() returns nil for a broken file. An error there
would tell a caller its whole catalog failed over one half-written
editor save, masking every otherwise-healthy template. Brokenness is
observable through the listing, not through the reload return value.
An unreadable file degrades the same way — it is also one file.
A root that is not a directory is still a hard error from
ReloadTemplates(). That is a misconfiguration rather than a
transient edit, so it must not be swallowed. A failed walk leaves the
previously loaded templates entirely in place.
Repairing a file clears its broken state on the next rescan.
Summary.Broken and Summary.Error are both omitempty, so a
healthy listing marshals byte-identically to the shape that predates
per-file degradation.
One scoping note: a broken file in a lower-precedence root that is
shadowed by a healthy override is tracked internally but not surfaced
in ListTemplates(). The entry an operator can actually reach is
fine.
The facade
Five methods, all on *pulse.Pulse:
summaries := p.ListTemplates() // []pulse.TemplateSummary
tmpl, err := p.GetTemplate("finance/revenue") // *pulse.Template
rendered, err := p.RenderTemplate(name, vars) // *pulse.RenderedTemplate — all five targets
req, err := p.RenderTemplateRequest(name, vars) // *types.Request — the 95% path
err = p.ReloadTemplates() // force a rescan now
ListTemplates is sorted by name and always returns a non-nil
slice (possibly empty) so it marshals as [] rather than null. An
engine with no template directories configured lists nothing; that is
an ordinary deployment, not a fault, though the other three lookup
methods will answer PULSE_TEMPLATE_NOT_FOUND.
RenderTemplate is the general form. Exactly one of Rendered’s
typed pointers is populated, selected by Rendered.Target — read that
pointer (or Rendered.Typed()) and hand it to the matching execution
method. Every target and variable type has a root constant, so switch
on them directly: pulse.TemplateTargetRequest, TemplateTargetComposed,
TemplateTargetChain, TemplateTargetFacet, TemplateTargetSample for
Template.Target / TemplateSummary.Target / Rendered.Target, and
pulse.TemplateVarString … TemplateVarPeriod (one per variable type)
for TemplateVariable.Type / Items. There are deliberately no per-execution-mode convenience
wrappers: N execution modes would mean N wrappers to keep in sync
forever, for no capability gain.
Rendered.JSON is the rendered body before decode, retained alongside
the typed value on purpose. Re-marshaling the typed request would
not reproduce it — every request struct is dense with omitempty,
so any slot that rendered to an explicit zero would silently vanish
from a round trip. If you echo, log, or diff a render, use
Rendered.JSON.
The returned template from GetTemplate is the engine’s own copy and
must be treated as read-only. Rendering never mutates it.
The error family
Ten codes, all in the PULSE domain. Every one carries the template
name under the template detail key; the variable-scoped members also
carry variable. pulse errors lookup <CODE> is authoritative for
the message and fixups.
The organising rule is provenance, not detection time:
- A bad declared default is an author error →
PULSE_TEMPLATE_INVALID, always. Default checking is fully semantic at declaration time — enum membership, date parseability, theperiodranges-XOR-table rule — which is what keeps the fail-fast-at-pulse.New()promise real. A template with an unparseable date default must not lie in wait until someone renders it. - A bad caller-supplied value is a caller error →
PULSE_TEMPLATE_VAR_TYPE/PULSE_TEMPLATE_VAR_ENUM.
The same split governs variable names. A $var, {{name}} or
$when naming an undeclared variable is an author error caught at
validation time — a typo’d {"$var": "metrc"} fails at pulse.New(),
not on first render. A declared but unresolved reference is a
render-time PULSE_TEMPLATE_UNRESOLVED.
| Code | Raised when | What the author or caller should do |
|---|---|---|
PULSE_TEMPLATE_NOT_FOUND | the requested name is not registered — including every name on an engine with no template directories | Check the derived name (path relative to its own root, minus .json); check the roots are configured and reachable. |
PULSE_TEMPLATE_INVALID | a document failed declaration validation: malformed JSON, unknown wrapper key, non-object body, duplicate variable name, uninterpretable declaration, bad declared default, marker naming an undeclared variable, or name disagreeing with the path | Fix the document. This is always an author fault, and at startup it is fatal. |
PULSE_TEMPLATE_TARGET_UNKNOWN | target is absent or not one of the five roots — and also when RenderTemplateRequest is called on a template declaring some other (perfectly valid) target | Two meanings, so read the details. expected_target present means the target is fine and the method is the mismatch: call RenderTemplate and read the pointer named in the message. |
PULSE_TEMPLATE_VAR_MISSING | a required variable resolved to nothing — no supplied value, no default | Supply the variable, or give the declaration a default. |
PULSE_TEMPLATE_VAR_UNKNOWN | the caller supplied a variable the template does not declare | Fix the caller’s key. Unknown variables are rejected, never ignored — silently dropping one produces a request that looks parameterised but is not. |
PULSE_TEMPLATE_VAR_TYPE | a supplied value does not match the declared type — including a list or period used inside a {{}} string, which has no text form | Correct the supplied JSON type, or splice with a {"$var": ...} marker instead of string sugar. |
PULSE_TEMPLATE_VAR_ENUM | a supplied value is not a member of an enum’s values set | Membership is exact and case-sensitive; the details carry the permitted set. |
PULSE_TEMPLATE_UNRESOLVED | an unguarded marker survived substitution — declared, but nothing resolved | Supply the variable, give it a default, or wrap the block in $when. Rendering hard-fails rather than emit a request carrying a literal marker string. |
PULSE_TEMPLATE_RENDER_INVALID | substitution succeeded but the rendered JSON failed strict decode into the target request type | Usually an unknown field, or a substituted value landing in a slot whose type it does not fit. |
PULSE_TEMPLATE_AXIS_COLLISION | a declared variable shares its name with an axis of a for_each item in the body | Rename the variable or the axis. An author fault, raised at declaration validation like PULSE_TEMPLATE_INVALID. |
Strict decode is harsher than the rest of Pulse, and that is worth
knowing before it bites: a body copied out of an internal/examples/ file
with its _meta block still attached will fail
PULSE_TEMPLATE_RENDER_INVALID. The message names the offending field
and calls out the _meta case explicitly.
What rendering does not do
Render never opens a cohort. A template that renders is well-formed
against the request shape — nothing more. Field existence, type
compatibility, operator applicability and streamability are all
Predict’s job, and stay there. A render
that succeeds and a Predict that fails is the expected division of
labour, not a bug.
The one engine fact render does consult is the feature profile. On
an engine whose profile hides a request slot (crosstab, joins,
overlays — see Feature profiles), a rendered
body that sets it fails PULSE_TEMPLATE_RENDER_INVALID exactly as a
misspelled key would: same message, same {template, target, field}
details, at the same point in the body, even when the slot’s value is
empty. An operator name is not a slot — a template naming a hidden
operator renders, and fails at execution just as one naming an operator
that never existed.
There is also no CLI surface for templating. It is a library and
embedding feature: Options.TemplateDirs /
PULSE_TEMPLATES_DIR plus the five facade methods.
See also
pulse.New& Options — the rest of the construction surface.- Go API Overview — the facade methods a rendered request is handed to.
pulse api predict— the cohort-aware validation a render deliberately leaves undone.skills/request-templating.md— the dense, agent-facing version of this chapter, reachable at runtime through the MCP skill tools andpulse skills show.
Response Shaping
A return block on a request chooses which parts of the response come
back and how many significant digits floats carry on the wire. It exists
to keep responses small for callers (agents especially) that read a
fraction of them. A request with no return is byte-identical to before,
hashes identically, and format_version stays "1.1".
resp, err := p.Process(ctx, &types.Request{
// Cohort, Aggregations, ... as for any request.
Return: &types.Return{
Preset: types.ReturnPresetMinimal,
Include: []string{"metadata.total_rows"},
Precision: 4,
},
})
Resolution
Preset, then include adds, then exclude removes; exclude wins. An
include with no preset starts from an empty base. exclude or
precision alone shape the full response. Top-level warnings and every
nested warnings whose parent is emitted stay unless excluded.
Presets are full (identity), standard (data, warnings, metadata,
trimmed tests and regressions, no components) and minimal (the primary
result only). The manifest return_presets block lists each preset’s
paths expanded against the instance.
Paths use the Response JSON names at any depth: . between keys, [*]
into array elements, a trailing * on a map-key segment as a prefix
glob. data[*].<column> selects output columns. The full grammar and
the preset table are in Payload JSON Schema
(Return slot).
What the caller sees
- An excluded part is absent on the wire, never
null, and zero or nil on the Go value. - The shaped response is a projection: it is not schema-valid
against the full payload schema. Do not validate it against
pulse://schema. - A response whose plan changes something carries
returned {preset, digest, precision?}. It cannot be excluded and itsdigestequals the one predict returns. precision(1 to 17 significant digits) is wire-only. Integers,decimal128strings and NaN (null) are untouched, and counts stay exact: count-aggregation columns, count crosstab cells, andmatrices[*].auxiliary.n. Extension aggregators are never exempt.
Errors
| Code | When |
|---|---|
PULSE_RETURN_INVALID | unknown preset, precision outside 1 to 17, malformed path, naming returned, a non-overlay path in a Compose-level block |
PULSE_RETURN_PATH_UNKNOWN | a path this instance’s Response does not carry, including a hidden feature’s path |
PULSE_RETURN_PATH_UNMATCHED | warning, buffered runs only: an include through an open map matched nothing |
PredictResult.Return resolves and validates the block before any
record is read.
Defaults and precedence
Options.DefaultReturn, else the feature profile’s return, else
full, applies to every request that has no block. A request block
replaces the default entirely. See pulse.New & Options
and Feature Profiles.
The MCP tools add one layer on top: a request without a block is shaped
by gosdk.Config.DefaultReturn / mcpserve.Options.DefaultReturn /
pulse mcp --return, else the instance default above, else the
built-in standard preset — never full. A request block still wins;
full on the host restores the unshaped output. See
pulse mcp --return.
disable_components is shorthand: alone it only skips computing
components (byte-identical, no marker); with a return layer it adds
exclude: ["components"]. An engine DisableComponents sticks: a
request return never re-opens it, so components are not computed and
the block gains exclude: ["components"]. Only an explicit request
disable_components: false turns them back on.
Excluded parts are not computed
An excluded part is skipped at execution, not only on the wire: the
run does less work, and a kept figure never changes. An excluded
overlay layer, test or post-test runs no handler, and an excluded
regressions slot fits nothing, so none raises its refusals or
warnings (a rank-deficient fit, a low effective sample size);
Predict still validates them. A layer, test or post-test that
belongs to a multiplicity family is computed anyway, so every kept
p_adjusted matches the full run. The execution path is chosen from
the full request: excluding a test never moves a request onto the
parallel or shard path, and a chain stage carrying tests is still
refused.
Surfaces
Process: shapes the one response.Compose: each slot’sreturnshapesresponses[i];ComposedRequest.Returnshapes top-level overlays only.ProcessChain: each stage’sreturnis applied after the whole chain, so a stage that excludesdatastill feeds the next stage.- Streaming: rows carry only the selected columns;
returnedrides the terminal chunk only; excludingdatayields no rows. See Streaming & ProcessStream. A stream never raisesPULSE_RETURN_PATH_UNMATCHED(it has no warnings slot);Predictlists the includes that may match nothing asreturn.unresolved_includes. Precision is wire-only: the libraryStreamChunkkeeps full float64, and only the writers (api process --stream,api compose --stream) round. The MCP tools have no streaming option.
Predict size estimates
Predict reports sizes on every request, with or without a return
block: per top-level section {section, full_bytes, shaped_bytes, basis} with basis one of exact, upper_bound or heuristic.
Without an effective return block shaped_bytes equals full_bytes;
under one, an excluded section is 0. A refused block reports no sizes,
and a section whose size depends
on the data is omitted rather than guessed. The figures are guidance
for choosing a preset before running, not a byte-accuracy contract.
Row Weighting
Audience: analysts and embedders running survey or sampled data, where each row stands for a different number of people, accounts or events.
Name a weight column once and every weight-aware figure in the request
counts each row as w rows: counts become Σw, sums Σw·x, means
Σw·x / Σw, and crosstab cells, margins and shares follow. A request
that names no weight produces exactly the output it always did, byte
for byte, and format_version stays "1.1".
Where a weight comes from
There are three places to name one. For each slot Pulse takes the first that applies:
- the slot’s own
weight(an explicitnullopts that slot out, and resolution stops there); - the request’s
weight; - the engine default,
pulse.Options.DefaultWeight; - none.
{
"cohort": {"filename": "survey.pulse"},
"weight": {"field": "wt", "kind": "probability"},
"groups": [{"type": "GROUP_CATEGORY", "field": "region"}],
"aggregations": [
{"type": "AGG_COUNT", "field": "id", "label": "weighted_n"},
{"type": "AGG_COUNT", "field": "id", "label": "respondents", "weight": null},
{"type": "AGG_AVERAGE", "field": "spend", "label": "mean_spend"}
]
}
Here weighted_n and mean_spend are weighted and respondents is
the raw count of respondents.
A per-slot weight is a field name ("wt"), a {field, kind} object
or null. The slot is on aggregations (which covers the crosstab
cell and each margin_aggregations entry), tests and post-tests,
regressions, attributes, overlays, groups and both crosstab axes.
Windows have no slot. Compose and process chains have no top-level
weight: each inner request carries its own. Facets are never weighted,
and DefaultWeight does not reach them.
From Go:
req.Weight = &types.WeightSpec{Field: "wt"} // request weight
req.Aggregations[1].Weight = types.NullSlotWeight() // opt one slot out
req.Aggregations[2].Weight = types.SlotWeightField("w2") // a different column
p, err := pulse.New(pulse.Options{
DefaultWeight: &types.WeightSpec{Field: "wt", Kind: types.WeightKindFrequency},
})
types.SlotWeight’s zero value means absent (inherit).
NullSlotWeight(), SlotWeightField(f) and SlotWeightOf(spec) build
the other two states. The distinction survives JSON, request hashing,
MCP and the payload schema.
The weight column must be an unsigned-integer (u4 to u64) or float
(f32, f64) field of the cohort. Derived columns are not accepted,
but a joined, prefixed name is. Any other type, an empty field or an
unknown kind is PROCESSING_CONFIG; an unknown name is
SERVICE_VALIDATION. pulse.New checks DefaultWeight up front, but
judges its field only on a request where it applies, so an instance
default never breaks a cohort that lacks the column for a request that
does not use it.
Probability or frequency
kind | Meaning | Consequence |
|---|---|---|
probability (default) | design / sampling weights | Ratios, means, shares and quantiles are scale-free: multiplying every weight by a constant changes nothing. Totals scale. Components report Kish n_eff. |
frequency | each row stands for w identical rows | Integer weights reproduce the physically duplicated cohort exactly. A fractional weight is invalid. |
Weighted median and percentile follow Hmisc wtd.quantile. Frequency
weights give R’s / numpy’s type 7 (linear) on the duplicated rows.
Probability weights are first rescaled to sum to the row count
(normwt = TRUE), which makes the figure scale-invariant. Cumulative
weights within 1e-9 of an integer are snapped to it, so the answer
does not depend on float summation order.
Invalid weights
Invalid weights are never coerced:
| Weight | Treatment |
|---|---|
| positive, finite | used |
| zero | valid; contributes nothing |
| null, negative, NaN / ±Inf | row excluded from that figure and counted |
non-integer under frequency | row excluded and counted |
Each weighted slot reports its exclusions as n_weight_invalid. Each
response carries one PULSE_WEIGHT_INVALID_ROWS warning per weight
column, with details.by_reason counting null, negative, nan_inf
and non_integer_frequency. Under Options.Strict (--strict) that
warning is an error.
What a weight applies to
Check the manifest rather than this page: every operator that computes
a weighted figure carries weight_kinds — ["frequency", "probability"]
when it is weighted under both kinds, ["frequency"] when only
frequency weights have a standard weighted form — with
weight_aware: true beside it. Extension operators declare
weight_aware (they have no weight_kinds; see the last section).
| Operator class | Explicit weight (slot or request) | DefaultWeight only |
|---|---|---|
| weight-aware aggregators | weighted | weighted |
| other aggregators (min, max, range, distinct-style, null count, set union / intersection) | PROCESSING_CONFIG | skipped |
| windows | request weight: PROCESSING_CONFIG | skipped |
| filters, features, row-local attributes, other groupers | skipped | skipped |
| the moment tests — t (one- and two-sample), Welch t, paired t, two-sample z, one-way ANOVA F, Welch ANOVA, Pearson r — plus the two-proportion z-test and the χ² independence test | weighted (both kinds) | weighted |
| the rank tests — Mann-Whitney U, Wilcoxon signed-rank, Kruskal-Wallis, Spearman ρ, Kendall τ-b — plus Fisher’s exact test, the two-sample Kolmogorov-Smirnov test and Brown-Forsythe | kind: frequency: weighted; kind: probability: PULSE_WEIGHT_UNSUPPORTED | same, by the default’s kind |
| the mean-comparison overlays — cell and reference t and z, pairwise Welch t — and the χ² (row, column, matrix, vs reference) and proportion z (cell, panel, pairwise) overlays | weighted (both kinds) | weighted |
| the Fisher exact cell overlay | kind: frequency: weighted; kind: probability: PULSE_WEIGHT_UNSUPPORTED | same, by the default’s kind |
| ordinary least squares (plain, ridge, lasso, elastic net), the GLM (binomial, poisson, gamma), the regression attributes (fitted value, residual, leverage) | weighted (both kinds) | weighted |
the z-score and t-score attributes, the quantile grouper (on groups and both crosstab axes) | weighted (both kinds) | weighted |
| Bayesian linear regression | kind: frequency: weighted; kind: probability: PULSE_WEIGHT_UNSUPPORTED | same, by the default’s kind |
Shapiro-Wilk, Tukey HSD, repeated-measures ANOVA, the trend test, every post-test, any regression with resample or selection, the percentile-rank attribute, the pairwise two-means z and probit t overlays | PULSE_WEIGHT_UNSUPPORTED (permanent) | PULSE_WEIGHT_UNSUPPORTED (permanent) |
the min-max rescale attribute (ATTR_NORMALIZED) | PROCESSING_CONFIG | skipped |
Weighted tests. A weighted moment test uses the frequency formula
with its sample size read as Σw under kind: frequency (a weight of 3
is three identical rows) or as Kish’s effective n,
n_eff = (Σw)²/Σw², under kind: probability. Means and r are the same
under either kind; standard errors and degrees of freedom (which may be
fractional) read that effective size. The result’s details.n stays
the raw row count; sum_weights and, for probability weights, n_eff
appear beside it in the same shape. A group whose n_eff falls below
the test’s minimum warns PULSE_WEIGHT_LOW_NEFF (an error under
strict mode). A probability-weighted Pearson correlation test whose
n_eff is 2 or less has no degrees of freedom left and is refused with
PULSE_TEST_INSUFFICIENT_N, its details naming n_eff. This is not design-based (strata / cluster) variance.
The two-proportion z-test reads each group’s rate as Σw of successes
over Σw, with the group’s sample size read as above; successes
becomes the weighted success total. The χ² independence test builds
its table from Σw per cell: under kind: frequency it is the ordinary
Pearson test on that table (identical to the expanded rows); under
kind: probability the cell proportions are scaled to the table’s
n_eff before the ordinary Pearson test, and the expected-count check,
Cramér’s V and φ read that scaled table. This is a first-order Kish
approximation, not the Rao-Scott correction survey software
(survey::svychisq) applies. It matches Rao-Scott only when the
weights are unrelated to both classifications; when the weights are
constant within each category of one margin (a sample stratified on
the row variable, say) it is conservative.
The one-way ANOVA F test credits each group with its own n_eff under
probability weights (its own Σw under frequency weights), like the
split t and Welch tests, so weights that differ between the groups do
not move effective size from one group to another; df_within is the
sum of the groups’ n_eff minus the number of groups.
The rank tests take frequency weights only. A row of weight w ranks as
w identical rows (a run of tied values shares the mid-rank of its
total weight), the tie corrections read those expanded tie sizes and
Kendall’s τ-b counts a row pair w_i·w_j times, so each answer equals
the unweighted test on the physically expanded rows. details.n and
the other row counts stay raw; sum_weights appears beside n.
Because every weighted row is itself a tie on the expansion, a
frequency-weighted rank test often warns PULSE_TEST_TIES_DOMINATE.
Pulse has no probability-weighted rank test: the design-based
Mann-Whitney and Kruskal-Wallis tests of survey software
(survey::svyranktest) need design information (strata, clusters)
Pulse does not carry, and Spearman and Kendall have no standard
probability-weighted form. A probability weight is refused with a
message naming the kind; switch the weight to
kind: frequency (if the weights are replication counts) or set the
slot’s weight: null.
Fisher’s exact test, the Kolmogorov-Smirnov test and Brown-Forsythe
take frequency weights only, for the same reason. Fisher’s exact test
runs on the Σw table (details.contingency reports it); KS steps each
empirical CDF by a row’s weight and reads its asymptotic p-value on the
Σw group sizes; Brown-Forsythe takes each group’s median of the
expanded rows (the frequency weighted median of AGG_MEDIAN) and runs
the one-way ANOVA on the weighted absolute deviations. Each equals the
unweighted test on the expanded rows. The KS statistic compares the two
CDFs only after every row tied at a value is counted on both sides,
which is also the unweighted rule (ties inside one sample no longer
inflate D).
Weighted regressions. Ordinary least squares fits weighted least
squares: the coefficients minimise Σw·(y − ŷ)² and are the same under
either kind. Standard errors, degrees of freedom (N* − p − 1, possibly
fractional), p-values, adjusted R² and the residual standard error use
the effective size N* — Σw under kind: frequency, Kish’s n_eff under
kind: probability. Under frequency weights the fit equals the one on
the expanded rows. R’s lm(weights = w) gives the same coefficients but
counts the rows, not Σw or n_eff, for its residual degrees of freedom,
so its standard errors differ. Ridge, lasso and elastic net scale the
penalty by Σw, so their coefficients do not change when every weight is
multiplied by the same constant, and they match glmnet(weights = w)
under frequency weights. Their standard errors stay plug-in
approximations, now on N*. Bayesian linear regression takes frequency
weights only: X’WX, X’Wy and Σw enter the conjugate posterior, which
equals the posterior on the expanded rows. n_obs stays the raw row
count. A weighted result adds sum_weights and, for probability
weights, n_eff. A probability-weighted fit whose n_eff is at or
below the predictor count plus one warns PULSE_WEIGHT_LOW_NEFF: its
residual degrees of freedom are zero or negative, so the standard
errors, p-values, residual standard error and adjusted R² are reported
as null (the coefficients and R² stay defined). A
regression with resample or selection refuses any weight, because
neither has a standard weighted form.
The GLM runs iteratively reweighted least squares with the row weights
as prior weights on w* = w·N*/Σw, so its fit is R’s
glm(weights = w*): the coefficients are the same under either kind,
the standard errors come from (XᵀWX)⁻¹ and the deviance and null
deviance sum w·d. Under frequency weights that is glm(weights = w)
and equals the fit on the expanded rows. The dispersion stays fixed at
1 for every family, gamma included, so treat gamma standard errors with
the same caution as unweighted ones. The regression attributes refit
ordinary least squares with the slot’s weight and accept both kinds:
the fitted value comes from the weighted coefficients, the residual is
the raw y − ŷ, and the leverage is R’s hatvalues() on the weighted
lm, the diagonal of W½X(XᵀWX)⁻¹XᵀW½. A row whose weight is zero or
invalid stays out of the refit and gets leverage 0.
Weighted confidence bounds. AGG_CI_LOWER and AGG_CI_UPPER are
weight-aware aggregators under either kind. The bound is the weighted
mean ∓ z·√(s²/N*): s² is the sample variance on w* = w·N*/Σw, N* is Σw
under kind: frequency (the bound on the expanded rows) or Kish’s
n_eff under kind: probability, and z is the same normal critical
value the unweighted bound uses (qnorm(1 − α/2), reported as
t_critical), not a t on N* − 1. They stay mergeable, so they run on
every streaming, shard and parallel-decode path.
Weighted scores and quantile buckets. ATTR_ZSCORE and
ATTR_TSCORE score each row against the weighted mean and the weighted
population standard deviation √(Σw(x − mean)²/Σw), so frequency and
probability weights give the same scores. A row whose weight is zero or
invalid adds nothing to the mean and spread but is still scored.
GROUP_QUANTILE cuts its buckets at the weighted order statistics, by
the same rule as the weighted percentile (probability weights rescaled
to the row count, as in Hmisc wtd.quantile(normwt = TRUE)): each
bucket holds as close to an equal share of the weight as whole rows
allow. A heavy row is never split, so it lands whole in the higher
bucket when it spans a cut. Bucket count, total_n and crosstab
counts stay raw row counts. A row with no usable weight is bucketed by
its value. The slot weight on a groups[i] or crosstab-axis quantile
grouper opts it in or out like any other slot.
Weighted k-means. GROUP_KMEANS takes a frequency weight only: a
row of weight w is w coincident points, so the k-means++ draws, the
centroids and the within-cluster sums of squares are those of the
expanded rows, and a cluster’s size is its total weight (count stays
the raw row count). A row with a zero or invalid weight is assigned to
its nearest centroid but does not move the fit. A probability weight is
refused permanently (PULSE_WEIGHT_UNSUPPORTED, with details.reason).
Refused operators. The permanently refused row has no standard
weighted form (or, for the post-tests, Tukey HSD and the trend test,
reads aggregated result rows that carry no row weights), so it is
refused rather than silently computed unweighted beside weighted
figures. To run one of those operators in a weighted request, give its
slot "weight": null; on an instance with a DefaultWeight, each such
slot needs that opt-out. PULSE_WEIGHT_UNSUPPORTED says why in its
details: reason for a permanent refusal, alternative when a
weighted twin exists (the pairwise two-means z points at
OVERLAY_PAIRWISE_WEIGHTED_TWO_MEANS_Z), and kind with
supported_kinds: ["frequency"] when a frequency-only operator meets a
probability weight. A weight-aware aggregator over a decimal128 field
is also PULSE_WEIGHT_UNSUPPORTED, because the decimal path has no
weighted form.
What the effective sample size does and does not do. Every weighted
test, regression, confidence bound and inferential overlay reads one
effective size: Σw for frequency weights, Kish’s n_eff for probability
weights. Kish’s n_eff corrects for unequal weights only, and assumes the
weights are unrelated to the outcome and to the comparison; for
slopes, correlations, group contrasts and tables it is a rule of
thumb. It knows nothing of strata, clusters or a finite population, so on a complex
survey design the p-values and intervals are narrower than a
design-based analysis (R survey, Stata svy) would give. Use Pulse’s
figures to screen and describe, and a design-based tool when the design
matters. In every weighted result, read n_eff (or sum_weights under
frequency weights) as the sample size, never n.
pulse predict reports the outcome for every slot under
data.weights[]: {slot, operator, field, kind, status, source}.
status is applied, skipped_not_weight_aware, opted_out or
none. source is slot, request, options or none. Predict
refuses exactly what the run would refuse.
AGG_WEIGHTED_MEAN is the weighted AGG_AVERAGE. Its
params.weight_field is shorthand for a slot weight with
kind: probability and is now optional: without it, the slot inherits
the request weight or the default.
Crosstabs and the unweighted base
A weighted crosstab recomputes cells, row, column and grand margins,
and every normalization from the weighted rows, on both execution
arms. CellCounts and the margin counts stay raw row counts. The
standard survey table shows a weighted figure beside an unweighted
base, and an auxiliary margin aggregation that opts out gives you
that base:
{
"weight": {"field": "wt"},
"crosstab": {
"rows": [{"type": "GROUP_CATEGORY", "field": "region"}],
"columns": [{"type": "GROUP_CATEGORY", "field": "segment"}],
"cell": {"type": "AGG_COUNT", "field": "id", "label": "weighted_n"},
"margins": {"rows": true, "columns": true, "grand": true},
"margin_aggregations": [
{"type": "AGG_COUNT", "field": "id", "label": "base", "weight": null}
]
}
}
A record reaches an auxiliary margin only if it reached a cell. So the base counts exactly the respondents the weighted cells beside it counted.
Overlays
Share and index overlays (share of row / column / total, index vs
margin, total, prior, baseline, rolling mean, sibling, reference) read
the host’s figures. On a weighted host they are therefore weighted,
with no setting needed. The pairwise two-means z and probit t
overlays refuse when a weight reaches the overlay itself: its own
weight, the request’s, or the default. Its "weight": null opts it
out. A host weighted only by its own slot weight does not trigger the
refusal.
OVERLAY_PAIRWISE_WEIGHTED_TWO_MEANS_Z is the weighted twin, built for
weighted cells: its host is an AGG_WEIGHTED_MEAN cell or a weighted
AGG_AVERAGE cell. It accepts both weight kinds, and its required
n_basis param (weights for Σw, kish for n_eff) decides the sample
size. weights needs frequency weights: on a cell weighted under
kind: probability (an AGG_WEIGHTED_MEAN weight_field included)
the weight sum is not a sample size, so n_basis: "weights" is refused
with PROCESSING_CONFIG; use kish there.
The mean-comparison overlays (OVERLAY_T_CELL, OVERLAY_Z_CELL,
OVERLAY_T_VS_REF, OVERLAY_Z_VS_REF, OVERLAY_PAIRWISE_WELCH_T) run
weighted under both kinds. A host cell is weighted when its components
carry sum_weights, whatever set the weight — including the cell’s own
slot weight. Such a cell’s sample size is Σw (frequency) or n_eff
(probability), never its raw row count, and its variance is
recomputed from m2 on the rescaled weights; degrees of freedom may be
fractional. The layer summary’s parameters add sum_weights (and
n_eff under probability) over the cells the layer read. Before
this release a host weighted only by its own slot weight ran these
overlays on raw row counts, which gave standard errors that were too
small; that was a bug and is fixed. On a weighted host the pairwise
Welch t refuses an n_source that reads raw row counts
(cell_n_unweighted, row_margin_n, column_margin_n), and
cell_weight_sum under probability, with PROCESSING_CONFIG.
The χ² and proportion overlays also run weighted under both kinds. A
weighted host’s cells and margins are weight sums (Σw). Under
frequency the χ² overlays run Pearson’s test on the Σw table, which
equals the test on the expanded rows. Under probability the table
(for OVERLAY_CHISQ_MATRIX), each row (_ROW), each column (_COL)
or the target table (_VS_REF) is first scaled to its Kish n_eff.
This is a first-order Kish approximation, not the Rao-Scott correction
survey packages apply, so expect it to differ from svychisq. The
low-expected-count warning reads the scaled table. The proportion
overlays read p̂ = Σw of the successes over Σw of the base, with the
base’s n_eff (or Σw under frequency) as the sample size, never the
payload’s Σw under probability. On a weighted host the pairwise
proportion z omits n_source to read that sample size. Every
unweighted count (raw rows, n_within, the distinct-key modes) is
refused with PROCESSING_CONFIG, and cell_weight_sum /
cell_value_weighted are accepted under frequency only. This is a
change for requests that used cell_weight_sum over an
AGG_WEIGHTED_MEAN cell with params.weight_field, which is
probability. The Fisher exact cell overlay runs on the Σw table
under frequency only. A probability weight on its host cell is
refused with PULSE_WEIGHT_UNSUPPORTED, even when the overlay itself
carries no weight.
Under probability the χ² and Compose proportion overlays read each
host’s n_eff from its components. A host built with components
disabled (disable_components, --no-components or
Options.DisableComponents) has none to read, so those overlays are
refused on it with PROCESSING_CONFIG naming the host, in predict and
at run time. Before, they read Σw as the sample size without a
warning. Turn components back on for that host, or use a frequency
weight, where Σw is the sample size.
Reading weighted components
A weighted slot’s components add three floor keys beside {n, n_null}:
sum_weights: Σw over the rows that contributed;n_eff: Kish effective sample size, (Σw)² / Σw², forprobabilityweights only;n_weight_invalid: rows excluded for an invalid weight.
They appear only on weighted slots, so sum_weights being absent is
the per-slot sign that a figure is unweighted. n, n_null,
CellCounts, margin counts and grouper total_n stay raw counts. For
the effective sample size of a probability-weighted figure, use
n_eff, not n. A figure that is undefined under the weights (0/0
when every weight in a cell is zero) is written as null on the wire.
See JSON Schema for both rules.
SPSS weights
A cohort imported from a .sav file with WEIGHT BY keeps that
variable in its metadata sidecar. pulse cohort inspect,
pulse_inspect and pulse_predict then report
suggested_weight {field, source: "spss_sidecar", kind: "probability"}.
The suggestion is never applied: name the field as weight
yourself. SPSS WEIGHT BY replicates cases, so kind: "frequency" is
usually the faithful reading. See
cohort inspect.
Feature profiles
Row weighting is the feature capability:weighting. A profile that
omits it removes the whole weight surface: every weight key is
refused as unknown, the schema and manifest drop it, and pulse.New
refuses Options.DefaultWeight with PULSE_FEATURE_PROFILE_DEPENDENCY.
AGG_WEIGHTED_MEAN’s own weight_field keeps working. See
Feature Profiles.
Extension operators
Aggregators, attributes and tests registered through
pulse.Options.Extensions declare WeightAware and read the row
weight with extend.Record.Weight(). A WeightAware extension runs under
both weight kinds: there is no per-kind declaration, so an extension
with no probability-weighted form reads the kind off its slot spec and
refuses itself. Undeclared ones are skipped or refused
(PULSE_EXTENSION_NOT_WEIGHT_AWARE). The recipe is in
Extension Points.
Multiple-Comparison Correction
Run many tests and some will come out significant by luck. A
multiplicity block adds corrected p-values beside the raw ones, so a
reader can tell a real hit from one the number of tests made likely.
Raw p_value / reject_null never change, and a request with no block
(the default) is byte-identical to before; format_version stays "1.1".
{
"cohort": "survey",
"tests": [
{"type": "TEST_T", "field": "score", "split_by": "arm"},
{"type": "TEST_PROP_Z", "field": "converted", "split_by": "arm"}
],
"multiplicity": {"method": "holm"}
}
The block
multiplicity: {method, family, alpha}, every key optional.
| Key | Values | Meaning |
|---|---|---|
method | none, bonferroni, holm, bh, by | the procedure; matches R p.adjust (bh / by are R’s BH / BY) |
family | layer, row, column, request, compose | which p-values are corrected together |
alpha | a number in (0, 1), default 0.05 | the level an overlay’s significant_adjusted reads (a test uses its own alpha) |
bonferroni and holm control the family-wise error rate (the chance
of any false positive in the family); bh and by control the false
discovery rate (the expected share of false positives among the hits),
by under any dependence. {"method": "none"} is the explicit opt-out.
It rides on the Request, each tests[] / post_tests[] entry, each
overlay spec, the ComposedRequest and its overlay specs, and
engine-wide as Options.DefaultMultiplicity. Each key falls through on
its own: slot, request, ComposedRequest, engine default, none.
Families
| Family | Corrects together | Where |
|---|---|---|
layer | one overlay layer | overlays (the overlay default) |
row / column | each row / column of one layer’s matrix | overlays with a MATRIX payload |
request | the request’s tests, post-tests and request-family overlays | tests (the test default), request overlays |
compose | every compose member across all slots and Compose-host layers | inside Compose |
An inherited family the surface does not offer takes the surface
default; an explicit one is refused (PULSE_MULTIPLICITY_INVALID):
row / column on a non-matrix kind, compose outside Compose,
request on a Compose-host overlay, request / compose on a facet
overlay. Members of one request / compose family that resolve to
different methods are PULSE_MULTIPLICITY_CONFLICT. alpha on a test’s
own block is refused (the test’s alpha is used), and so is an explicit
correction on Tukey HSD, which is already family-wise and never joins a
family.
row and column build one family per row or column index of the
layer’s own matrix, purely by coordinate: a pairwise overlay’s rows are
the compared groups, and every element of a panel cell joins its cell’s
family. Families never cross layers.
Outputs
All additive, present only when a correction ran:
- Tests:
p_adjusted,significant_adjustedandmultiplicity {method, family, alpha, m}besidep_value. - Overlay summaries:
p_adjusted/significant_adjustedbeside the raw figure (p_value, or thestatisticthat the t / z-versus-reference kinds carry their p in). - Overlay matrices: parallel
payload.p_adjusted/payload.significant_adjustedmatrices on identical headers and coordinates; a panel cell’s vector maps element for element. - Overlay layers: a
multiplicityecho, withm_perforrow/column(each family’s size, aligned with the rows or columns).
m is the number of defined p-values corrected together. An undefined
(NaN) p is left out of m and shows p_adjusted: null. Field shapes:
Payload JSON Schema.
Where it runs
- Process: every arm (serial, streaming, fused or buffered crosstab, join, shard, parallel decode) corrects in one place after the result is built.
- Compose: after all slots and Compose-host overlays finish. Serial
and
ComposeParallelagree byte for byte;--streamemits slot rows only, with no corrections; a failed slot aborts before the fold. - ProcessChain: each stage corrects its own request; no family spans stages, and the whole-chain overlays take no block.
- Facet: each overlay layer corrects on its own.
Predict
pulse predict applies the same rules to a request without running it
and reports p_values {total, uncorrected, basis, threshold}: how many
inferential p-values the request emits and how many no block reaches.
threshold is 10 (descriptor.MultiplicityTriggerThreshold); an
uncorrected count at or above it is the cue to add a block. basis is
exact, dictionary (assumes every dictionary entry of a category axis
becomes a bucket, so it can over- or under-count) or lower_bound (an
axis it cannot size counts as one). Request predict reports it; facet
predict counts its inferential overlays (one p-value each) and compose
predict counts its own overlays, always as a lower_bound, beside each
slot’s advisories. Chain predict reports none.
Feature profiles
The block is the feature capability:multiplicity. On an instance that
hides it, the key is refused as an unknown field, it is absent from the
payload schema, and predict omits p_values. See
Feature Profiles.
Not corrected
Regression coefficient p-values are out of scope. An extension TEST_*
joins families like a built-in
(Extension Points).
Time Zones
Storage is always UTC: a datetime is epoch seconds, a date is epoch days. A time zone only changes how an instant is read (local calendar day, wall clock) or rendered (export). Zone names are UTC or an exact IANA Area/Location from Pulse’s own embedded tz database, so results never depend on the host; anything else is PULSE_TIMEZONE_UNKNOWN. The manifest reports the release as tzdata_version (see Refreshing the Time-Zone Database).
Choosing a zone
Per zone-capable slot: slot tz, then request time_zone, then Options.DefaultTimeZone, then UTC. There is no environment variable or default-zone CLI flag. Predict echoes the resolved zone per slot in time_zones[]. An explicit tz on a date field or a non-zone-capable operator is PROCESSING_CONFIG; a non-UTC zone on a derived field is refused. UTC or no zone is byte-identical to the pre-zone output.
Local days in every mode
A datetime buckets and filters on its local calendar day in process, stream, compose, shards, crosstab, join, chain and facet, DST changes included. GROUP_DATE also takes component: "hour" (local wall-clock keys; the skipped DST hour has no bucket, the repeated hour is one) and week_start. ATTR_DATE_PART and FEAT_DATE_FEATURES read the zone’s wall clock over a datetime. Range literals are local calendar days. OVERLAY_YOY follows its host grouper’s zone.
Import and export
--source-tz [col=]Zone reads naive datetimes in a source zone on every import path; --dst-policy error|earlier|later decides what happens to a skipped or repeated wall time (PULSE_IMPORT_DST_NONEXISTENT, PULSE_IMPORT_DST_AMBIGUOUS, PULSE_IMPORT_DST_RESOLVED). Offset and Z literals never move. Native Arrow and Parquet timestamps type datetime; SPSS DATETIME is a wall clock while TIME / DTIME are f64 durations. export --tz Zone renders local offsets; SPSS export refuses a non-UTC zone. Flag detail: CLI flags. Agent-facing form: skill time-zones.
Feature Profiles
Audience: Go embedders, and operators who run pulse mcp.
A feature profile declares exactly which features one *pulse.Pulse
instance offers: which capabilities, operators, I/O formats and
MCP-only surfaces exist for it. A profile is a complete, closed
allowlist. It never means “everything minus X”, and a feature added in a
later Pulse release is not in an existing profile until you add it.
With no profile, an instance offers everything, including features added in later releases. Its output is byte-identical to an instance built before profiles existed.
Status (v1.0.0 alphas). An instance enforces its profile. Everything the profile omits is hidden:
- An extension operator the profile omits is not registered.
- A built-in aggregator, attribute, filter, grouper, window, feature, statistical test, regression or overlay kind the profile omits behaves at run time exactly as a name Pulse has never heard of: same result or error, on every entry point and every overlay host, predict included. Smart defaults never pick it.
- A request that sets a slot the profile omits (
crosstab,joins, or anoverlaysslot with no enabled host and kind) is refused withPULSE_REQUEST_UNKNOWN_FIELD, exactly as an unrecognised JSON key, andvalid_keyslists only the slots the instance offers. A request template that renders such a slot failsPULSE_TEMPLATE_RENDER_INVALID.p.Manifest,p.PayloadSchema()and the errors lookup describe only what the instance offers (see below).Library methods are not gated:
p.Process,p.Compose,p.Facet,p.Lookup,p.Importand the rest stay callable on every instance. A profile hides names and slots, not methods. The MCP server registers only the tools, prompts, cohort listing, skills and examples the profile offers (see below).
The name is “feature profile” because “profile” already means synthetic
data profiling (Pulse.Profile, pulse profile create).
A profile file
{
"profile": "survey-self-serve",
"written_with": "1.0.0",
"features": [
"capability:process",
"capability:crosstab",
"capability:facet",
"AGG_COUNT",
"AGG_MODE_COUNT",
"AGG_SUM",
"GROUP_CATEGORY",
"GROUP_DATE",
"FILTER_INCLUDE",
"TEST_CHISQ",
"OVERLAY_SHARE_OF_ROW",
"io_format:csv",
"io_format:spss"
],
"behaviour": { "disable_projection": true }
}
| Key | Required | Meaning |
|---|---|---|
profile | no | Your own label. Informational; the pulse mcp startup line prints it |
written_with | no | The Pulse version you wrote the profile against. Stored as-is, never compared, and never an error |
features | yes | The exact feature names the instance offers. May be empty ([]), and must not repeat a name |
behaviour | no | Engine switches the profile turns on (below) |
return | no | The instance’s default response selection — the return block a request without its own is shaped by ({"preset": "standard"}, include, exclude, precision) |
limits | no | The instance’s resource limits (below) |
Any other key is refused, and so is an unknown key inside behaviour,
return or limits. Trailing data after the JSON object is refused too.
return takes the request block’s shape. A request return replaces it
entirely, and Options.DefaultReturn wins over it. pulse.New checks it
against the profile’s own feature set: a path only a feature the profile
leaves out has (for example matrices without capability:matrices), an
unknown preset or a precision outside 1–17 is refused with
PULSE_FEATURE_PROFILE_INVALID, reason invalid_return.
limits sets the instance resource limits with the same snake_case
names pulse mcp --limit and every PULSE_LIMIT_* error’s limit
detail use: request_timeout, max_groups, max_crosstab_cells,
max_estimated_memory, max_matrix_dim, max_compose_slots,
max_chain_stages, max_join_build_rows, max_derived_columns.
{
"features": [],
"limits": { "max_groups": 1000000, "request_timeout": "30s", "max_matrix_dim": -1 }
}
The encoding matches Options.Limits: an omitted key or 0 keeps the
built-in default, -1 means no limit, and any other negative value is
refused with PULSE_FEATURE_PROFILE_INVALID, reason invalid_limits
(details.limit names the key, details.value echoes it). Counts are
JSON integers; request_timeout is a Go duration string ("30s",
"2m"), with "-1" or "unlimited" for no timeout. Per limit, a
non-zero Options.Limits field wins over the profile’s value, so an
embedder can still tighten or relax one limit on top of a shared
profile. p.Limits() reports the effective values. In Go the section
is FeatureProfile.Limits (*pulse.FeatureProfileLimits). Limits are
behaviour, not features: they never change feature_set_digest.
Full guide: Tuning Limits.
Feature names
| Kind | Spelling | Examples |
|---|---|---|
| operator | bare registered name | AGG_SUM, GROUP_DATE, TEST_T, REG_OLS, OVERLAY_YOY, and your extension operators |
| capability | capability:<name> | capability:process, capability:compose, capability:compose_sweep, capability:compose_fits, capability:model_save, capability:models, capability:for_each, capability:process_chain, capability:facet, capability:sample, capability:crosstab, capability:joins, capability:stream, capability:watch, capability:filter_to_file, capability:lookup, capability:index, capability:shard, capability:import, capability:export, capability:dedup, capability:materialize, capability:widen, capability:templates, capability:synth, capability:labels, capability:range_tables, capability:weighting, capability:multiplicity, capability:matrices, capability:recommend, capability:explain |
| I/O format | io_format:<name> | io_format:csv, io_format:parquet, io_format:spss. One name covers both import and export |
| MCP extra | mcp_extra:<name> | mcp_extra:cohort_resources, mcp_extra:prompt_bootstrap, mcp_extra:prompt_author_request, and one mcp_extra:prompt_<intent> per analytic intent (mcp_extra:prompt_describe, mcp_extra:prompt_compare_groups, …) for the pulse-<intent> prompts |
pulse manifest --json lists every operator name the running build
registers.
Rules:
- Exact names only.
TEST_*,capability:*and other patterns are refused, never expanded. - One spelling per feature.
operator:AGG_SUMandprocess(without its kind) are refused, and the error suggests the right spelling. - A statistical test is one name.
TEST_Tcovers both its row-test and post-test forms. - Synth distributions are not features.
capability:synthgates all of them. - The core is always present and cannot be listed. Open, inspect, predict, record counts, the manifest, the payload schema, skills, examples, errors lookup and cohort artifacts exist on every instance. Listing one is an error.
- Extension operators are features. List them by their registered
name, like a built-in. An extension operator the profile omits is
hidden:
pulse.Newstill validates its registration and itsDependsOn, then does not register it.
Dependencies
Some features need others. A dependency is a list of groups, and at least one name in every group must be enabled:
- Every operator, and
capability:crosstab/capability:joins/capability:weighting/capability:matrices/capability:for_each/capability:model_save, needs a host that runs requests:capability:process,capability:composeorcapability:process_chain. capability:modelsneeds a host that runs requests orcapability:facet(a facet’smodelsfeed its filters’coef()).capability:multiplicityneeds nothing: its blocks ride every request host and facet overlays alike.capability:stream,capability:watch,capability:filter_to_fileandcapability:materializeneedcapability:process.- An intent prompt (
mcp_extra:prompt_<intent>) needs every tool its script calls:capability:recommend,capability:explainandcapability:process(all three;pulse_inspectis always mounted). - An overlay kind needs a host that can run it:
capability:crosstabfor crosstab overlays,capability:composefor compose and series overlays,capability:process_chainfor stage overlays,capability:facetfor facet overlays. - Some operators read another’s results, and filter-to-file runs on the filter-expression engine:
| Feature | Also needs |
|---|---|
OVERLAY_T_CELL, OVERLAY_Z_CELL, OVERLAY_T_VS_REF, OVERLAY_Z_VS_REF, OVERLAY_PAIRWISE_WELCH_T, OVERLAY_PAIRWISE_TWO_MEANS_Z | AGG_WELFORD |
OVERLAY_PAIRWISE_WEIGHTED_TWO_MEANS_Z | AGG_WEIGHTED_MEAN or AGG_AVERAGE (either one) |
ATTR_REG_FITTED, ATTR_REG_LEVERAGE, ATTR_REG_RESIDUAL | REG_OLS |
ATTR_REG_PREDICT | REG_BAYES_LINEAR, REG_GLM or REG_OLS (any one) |
OVERLAY_YOY | GROUP_DATE |
capability:filter_to_file | FILTER_EXPRESSION (filter-to-file compiles every filterer into one filter expression) |
capability:model_save | REG_BAYES_LINEAR, REG_GLM or REG_OLS (any one; a save writes a fitted regression as a model file) |
capability:models | ATTR_REG_PREDICT, ATTR_FORMULA or FILTER_EXPRESSION (any one; a loaded model is read only by a scoring reference) |
TEST_TUKEY_HSD does not require TEST_ANOVA_F.
Extension dependencies
Every extension operator registration has an optional DependsOn. Each
entry is a feature the operator needs, and all of them must be enabled
whenever the operator is:
pulse.AggregatorRegistration{
Name: "AGG_ACME_SPREAD",
DependsOn: []string{"AGG_WELFORD"},
Factory: newSpread,
}
Every DependsOn entry must name a feature this build knows: a built-in,
or another registered extension operator. This is checked at every
pulse.New, with or without a profile, so a typo fails at construction.
An enabled extension operator also needs a request host, like a built-in.
Using a profile from Go
As a value:
p, err := pulse.New(pulse.Options{
DataDir: "/var/data/pulse",
FeatureProfile: &pulse.FeatureProfile{
Profile: "survey-self-serve",
Features: []string{"capability:process", "AGG_SUM", "GROUP_CATEGORY"},
},
})
Features must be non-nil. Use []string{} for an empty profile; a nil
slice is refused as a missing features key. pulse.New copies the
value, so changing it afterwards has no effect.
From a file read through the instance filesystem:
p, err := pulse.New(pulse.Options{
DataDir: "/var/data/pulse",
FeatureProfileFile: "profiles/self-serve.json", // relative to DataDir (or Options.FS)
})
Set at most one of FeatureProfile and FeatureProfileFile. For bytes
you hold yourself (a config service, an embedded file),
pulse.ParseFeatureProfile(data) applies the same strict decode and
returns a *pulse.FeatureProfile for Options.FeatureProfile. It only
decodes: names and dependencies are checked by pulse.New.
pulse.New never reads PULSE_FEATURE_PROFILE. Only the MCP server
entry point does (below).
Behaviour switches
| Key | Turns on |
|---|---|
disable_defaults | Options.DisableDefaults |
disable_components | Options.DisableComponents |
disable_projection | Options.DisableProjection |
disable_cohort_scan | the MCP server’s DisableCohortScan (no effect on the engine) |
Each switch is combined with Options by OR. A profile can turn a switch
on, and Options cannot turn it back off. Omitting a switch, or setting
it false, leaves it to Options.
Errors
Validation runs in three classes, in order. It stops at the first class
that fails and reports every problem in that class, sorted. Look any
code up with pulse errors lookup CODE.
| Code | When | Read |
|---|---|---|
PULSE_FEATURE_PROFILE_INVALID | The profile cannot be used as written | details.reason: both_options_set, file_unreadable, malformed_json, unknown_key, missing_features, duplicate_feature (the names are under duplicates), unknown_example (from pulse.ExampleFeatureProfile). details.path names the file when one was read |
PULSE_FEATURE_PROFILE_UNKNOWN | A name does not resolve | details.unknown[], each with name and reason: unregistered, pattern, wrong_kind (see did_you_mean), core_surface, newer_than_running (see since; the running build is under details.version). An unknown extension DependsOn entry also carries extension and category |
PULSE_FEATURE_PROFILE_DEPENDENCY | An enabled feature is missing what it needs, or an Options value needs a feature the profile omits | details.unmet[], each with feature (or option, for an Options value — listed under details.options) and requires_any_of. Add one name from the group, or remove the feature / leave the option unset. Two options are checked: Options.DefaultWeight needs capability:weighting, Options.DefaultMultiplicity needs capability:multiplicity (both unmet: one unmet entry each) |
Versions
Every built-in feature records the release that introduced it. A profile
naming a feature newer than the running Pulse fails with
newer_than_running, so a profile written for a newer release cannot
silently lose features on an older one. Only the running version’s
major.minor.patch is compared, so 1.0.0-alpha.2 offers every 1.0.0
feature. Development builds (devel, untagged builds) offer everything.
pulse.Version() reports Pulse’s own version, even inside your binary.
Reading an instance’s feature set
p.FeatureProfile()returns a copy of the profile the instance was built with, ornil, falsewithout one. Changing the copy changes nothing on the instance.p.FeatureSetDigest()returns the instance’s feature-set digest,"fs1:"plus the hex SHA-256 of its sorted enabled feature names and its effectivebehaviourswitches (the profile’s ORed withOptions). Every instance has one. It is the same across processes for the same features and switches, and any difference in either changes it, so it can key a cache of an instance’s self-description. It hides the list but not its identity: anyone running the same Pulse version can tell an unprofiled instance’s digest from a profiled one’s.
What a profiled instance describes
- Manifest.
p.Manifest(ctx)lists only enabled operators, tests, regressions, overlays, commands, MCP tools, synth distributions and I/O formats. Thefacet,process_chain,join,crosstab,exportandimportcapability blocks are omitted when their capability is off. This is a public type change:descriptor.Manifest.Facet,ProcessChain,Join,Crosstab,ExportandImportare now pointers (nil when hidden), so code that read them as values must check for nil. Prose that names a hidden feature is dropped from descriptions and hints. The manifest’sskillslist and examples count, categories and tags cover only the skills and examples the instance serves (see the MCP section).extensions.label_tablesis empty ([]) whencapability:labelsis off andextensions.range_tableswhencapability:range_tablesis off, exactly as on an instance that registered no tables, and requests resolve them the same way: a label binding naming one failsPULSE_LABEL_TABLE_UNKNOWN, anAutoLabelsdefault naming one is not applied, and a date-rangetable:failsPULSE_RANGE_TABLE_UNKNOWN.p.LabelTables()andp.RangeTables()still return them: facade methods are not gated. Lookup tables (the expressionlookup()function) have no capability and always resolve.p.ManifestForIntent(ctx, intent)narrows this same instance view to one intent, so it never lists a hidden operator either; an intent the profile prunes (every operator serving it hidden) isPULSE_RECOMMEND_INTENT_UNKNOWN. - Payload schema.
p.PayloadSchema()returns the JSON Schema for the instance: enums keep only enabled names, hidden slots are not properties, and a request root whose capability is off (compose, process chain, facet, sample, lookup) is not offered. A profile-free instance’s schema is byte-identical topulse schema’s published golden apart from the digest comment.pulse schemaserves the default instance; the MCPpulse://schemaresource serves the mounted instance’sp.PayloadSchema(). - Virtual vectors and matrices. Without
capability:matricesthe requestvectorsandmatricesslots failPULSE_REQUEST_UNKNOWN_FIELDon every entry point, the payload schema drops them with theVectorSpec/VectorCoerce/MatrixSpecdefinitions and theResponse.matricesresult slot, and the manifest drops thematrixblock. EveryMAT_*operator depends oncapability:matrices. - Item fan-out. Without
capability:for_eachafor_eachon a window, feature or attribute item (inside Compose requests and chain stages too) failsPULSE_REQUEST_UNKNOWN_FIELD, naming the item underdetails.path(windows[1]); the payload schema and the MCP tool input schemas drop the key and theForEachdefinition. A hand-expanded request runs unchanged. - Row weighting. Without
capability:weightingthe instance has no weight surface. The requestweightand every per-slotweight(aggregations, the crosstab cell, margin aggregations and axes, groups, tests, post-tests, regressions, attributes, overlays — inside Compose requests and chain stages too, and on facet overlays) failPULSE_REQUEST_UNKNOWN_FIELD, even when set tonull. A nested one names its object underdetails.path(aggregations[0]) and lists that object’s keys undervalid_keys. The payload schema and the MCP tool input schemas drop everyweightproperty, the manifest dropsweight_aware(built-in and extension) and the weightedsum_weights/n_eff/n_weight_invalidcomponent keys, predict reports noweights, inspect and predict report no SPSSsuggested_weight, andpulse.NewrefusesOptions.DefaultWeight(PULSE_FEATURE_PROFILE_DEPENDENCY).AGG_WEIGHTED_MEANand itsparams.weight_fieldkeep working: they are the operator’s own parameters, not the weight surface, so its invalid-weight warning and decimal refusal stay listed whileAGG_WEIGHTED_MEANis enabled. - Multiple-comparison correction. Without
capability:multiplicityeverymultiplicityblock — on the request, the composed request, a test or post-test, a request, facet or Compose overlay, inside Compose requests and chain stages too — failsPULSE_REQUEST_UNKNOWN_FIELD(a nested one underdetails.path), the payload schema drops everymultiplicityproperty with theMultiplicitydefs,PULSE_MULTIPLICITY_*leave the error lists, andpulse.NewrefusesOptions.DefaultMultiplicity(PULSE_FEATURE_PROFILE_DEPENDENCY). - Errors.
p.ErrorLookup,p.ErrorsByDomainandp.ErrorsSearch, and the manifest’s error lists, show only codes the instance can raise. A hidden code looks up as unknown.pulse errors lookupon the CLI stays unscoped. - Wording follows the instance. Some refusals and hints that used to
name built-ins now name only what the instance offers: the
zone-capable operator list, the
ATTR_RANKtoWIN_RANKhint (the generic unknown-attribute error whenWIN_RANKis hidden), pairwise overlay advice, the facet overlay kind list, and the categorical and decimal suggestion reasons. Runtime refusals follow the same rule: one that recommends other operators (for example “use GROUP_SET_VALUE or GROUP_SET_PER_ELEMENT” or “use TEST_ANOVA_F for k>2”) names only the ones the instance offers, keeping its error code. An instance without a profile reads byte-identically to before. - Digest. Manifest and payload schema carry
feature_set_digest(the schema as its root$comment), equal top.FeatureSetDigest(). The field is additive; the envelopeformat_versionstays"1.1". Do not read the digest as hiding the profile: a same-version observer can tell a profiled digest from the default.
Upgrading an existing profiled instance
An instance built with a profile before this release hid nothing. It now
hides everything the profile omits, including extension operators. A
request that used an omitted name or slot fails as if the name never
existed. Add the names you still need to features.
The MCP server
pulse mcp --feature-profile FILE loads a profile into the served
instance. Without the flag it reads the PULSE_FEATURE_PROFILE
environment variable. Both are host OS paths, absolute or relative to
the working directory, and never resolved under --data-dir. An invalid
profile fails startup. Every other pulse command ignores the variable,
because the CLI is an admin tool and is not profiled.
The server mounts only what the profile offers. A tool tied to a
feature (pulse_process to capability:process, pulse_facet to
capability:facet, and so on) is registered only when that feature is
listed; the discovery tools (pulse_inspect, pulse_predict,
pulse_manifest, the skills, examples and errors lookups) are always
there. Calling a tool the profile hides fails exactly like calling one
that does not exist. Each prompt needs its own mcp_extra:prompt_*
feature. A pulse-<intent> prompt also disappears, feature or not, when
the profile hides every operator that answers its intent, and
pulse-author-request then routes no question to it: its intent list
names only the prompts and route tools the instance mounts. Without mcp_extra:cohort_resources the server skips the
startup walk and does not list cohorts under resources/list, as if
--no-cohort-scan were set; every cohort stays readable by its
pulse:// URI.
Tool input schemas follow the profile too. With BindOnInspect on,
the schemas re-bound after an inspect list only the operators, tests
and overlay kinds the profile enables (an operator family with none
enabled carries no list at all), offer the labels slot only with
capability:labels, and leave out any request slot the profile hides.
A request naming a hidden slot (say crosstab without
capability:crosstab) is refused as PULSE_REQUEST_UNKNOWN_FIELD,
exactly like a misspelt key, and the valid-key list and suggestions
name only the slots the profile offers.
Prose follows the profile the same way the manifest’s does. Tool
descriptions, every description in a tool’s input schema (at
registration and after a re-bind), prompt descriptions and prompt
bodies drop each sentence that names a hidden operator or a tool the
profile does not mount; a numbered step dropped from a prompt
renumbers the rest of its list. A sentence about an enabled operator
that also names a hidden one goes too. The same goes for the wire
names of a hidden request slot: without capability:multiplicity no
sentence naming multiplicity, p_adjusted or significant_adjusted
is served (the pulse_lookup duplicate-key multiplicity setting is a
different slot and stays), without capability:matrices none naming
vectors or matrices, and likewise for the weighting and crosstab
slots’ own keys. The text a caller supplies to
pulse-author-request is never touched.
Skills and examples follow the profile as well. The reference skill for
a hidden operator, overlay kind, regression or synth distribution, and
for a tool the server does not mount, is left out of
pulse_skills_list, pulse_skills_get, the pulse-skill:// resources
and the manifest’s skills list. Field-type skills, the design
guides and the glossary / intents reference skills stay, and a design guide is served as written even where it
mentions an operator the profile hides. An example is left out of
pulse_examples_search, pulse_examples_get, p.ExamplesSearch,
p.ExampleGet and the manifest’s examples count, categories and tags
when it uses a hidden operator or overlay kind, or needs a hidden
facet, crosstab, joins or compose capability. Asking for a
hidden skill or example by name fails exactly like asking for one that
does not exist. A visible skill’s listed description drops each
sentence that names a hidden operator or tool, in pulse_skills_list
and the manifest alike, and the manifest’s top-level intents list
leaves out an intent when every operator that serves it is hidden (an
intent no operator serves, such as lookup, always stays).
Embedders building their own discovery UI read the same instance view through three facade methods. Like every facade method they are callable on any instance; only what they return follows the profile.
g := p.Ontology() // descriptor.Ontology: nodes + typed edges, no prose
for _, n := range g.Nodes {
if n.Kind == descriptor.OntologyNodeSkill {
body, ok := p.Skill(n.Name) // markdown body as MCP serves it
_, _ = body, ok
}
}
list := p.Skills() // []pulse.SkillMetadata, as pulse_skills_list returns it
p.Ontology()returns the instance’s skill ontology: intents, operators, skills, examples, glossary terms, capabilities, MCP tools and named tables, joined by typed edges (serves_intent,documented_by,exemplified_by,not_for,follow_up,uses_term,routes_to,requires_capability). It is always the pruned view — every node for a hidden feature, and every skill, example, intent or term left with nothing to describe, is absent with its edges. Each call returns a fresh copy you may modify.p.Skills()lists the skills the instance serves, with the same rendered metadata aspulse_skills_listand thepulse-skill://resource list.p.Skill(name)returns the bodypulse_skills_getand apulse-skill://<name>read return, or("", false)for a hidden or unknown name.
Bodies of examples come from p.ExamplesSearch and p.ExampleGet, and
glossary text from pulse.Glossary(). On an instance with no profile all
three return the full skill pack and graph.
Embedders serving MCP themselves use mcpserve.NewPulse:
p, err := mcpserve.NewPulse(
pulse.Options{DataDir: "/var/data/pulse"},
mcpserve.Options{FeatureProfileFile: "/etc/pulse/self-serve.json"},
)
if err != nil {
return err
}
return mcpserve.ServeStdio(p, mcpserve.Options{BindOnOpen: true})
Its sources, highest first: mcpserve.Options.FeatureProfileFile, a
profile already on the pulse.Options, then PULSE_FEATURE_PROFILE.
Setting the field and a pulse.Options profile together is
both_options_set. mcpserve.Serve and ServeStdio ignore
FeatureProfileFile: a profile is applied when the instance is built.
mcpserve.Describe(p, opts) returns the effective serving settings: the
cohort-scan setting after the profile’s disable_cohort_scan and
mcp_extra:cohort_resources, and the
loaded profile’s label. The pulse mcp startup line prints the same
thing:
pulse mcp: serving over stdio (data dir: /var/data/pulse, bind-on-open: true, cohort-scan: false, return: standard, feature-profile: survey-self-serve)
gosdk.RegisteredTools() and gosdk.RegisteredPrompts() are global:
they list every canonical tool and prompt whatever the instance hides.
To learn what a profiled server offers, ask the server (tools/list,
prompts/list) or read p.Manifest(ctx).
Example profiles
Pulse publishes example profiles to copy, in the repository’s
examples/profiles/ directory and embedded in the library:
| Name | Offers |
|---|---|
minimal | capability:process plus core aggregators and the default groupers |
survey-crosstab | Process, Compose, crosstab, facet, labels, row weighting and multiple-comparison correction, with survey tests and the crosstab, compose and facet overlays they feed, closed over their dependencies |
read-only-analyst | Every analytic capability and operator; nothing that writes data (import, export, filter_to_file, dedup, materialize, widen, shard, index, synth, model_save) and no I/O formats |
names := pulse.ExampleFeatureProfiles() // sorted: minimal, read-only-analyst, survey-crosstab
fp, err := pulse.ExampleFeatureProfile("minimal") // a fresh *pulse.FeatureProfile you own
p, err := pulse.New(pulse.Options{FeatureProfile: fp})
Each example passes pulse.New validation. An unknown name fails with
PULSE_FEATURE_PROFILE_INVALID, details.reason unknown_example, and
the available names under details.examples.
Published examples are never edited in place. Each lists exact
feature names as of the release in its written_with, so upgrading Pulse
never grows the feature set of a profile copied from one. A changed
example ships under a new name. Copy one and own the copy.
Writing, checking and upgrading profiles
Four root functions work on a profile without building a *pulse.Pulse,
so a CI job can keep a profile honest. Each optional ext argument is the
same pulse.Extensions value you hand to pulse.New. Every result type
is JSON-tagged, with list fields that are never null.
// Every feature this build offers (plus your extension operators), in
// canonical order, stamped with the running release.
fp, err := pulse.InitFeatureProfile("", ext)
// Or start from a published example.
fp, err = pulse.InitFeatureProfile("survey-crosstab")
// Exactly the validation pulse.New runs: INVALID, then UNKNOWN, then
// DEPENDENCY, stopping at the first failing class.
report, err := pulse.CheckFeatureProfile(fp, pulse.FeatureProfileCheckOptions{Extensions: ext})
// What this build offers that the profile does not list, and what the
// profile lists that this build does not know.
diff, err := pulse.DiffFeatureProfile(fp, ext)
// Kind, category, Since and dependency groups of every listed feature.
desc, err := pulse.DescribeFeatureProfile(fp, ext)
InitFeatureProfile(from, ext...)lists every built-in feature the running build has reached plus the given extension operators, by exact name, ordered by kind, then operator category, then table position (the order the examples use). A non-emptyfromseeds the profile from that example instead (label, features, behaviour; no extension names are added).written_withis the running release core:1.0.0-alpha.2stamps1.0.0; a development build with no release core stamps the newest release its feature table knows. The result always passesCheckFeatureProfilewith the same extensions.CheckFeatureProfile(fp, opts)returns a report and the coded errorpulse.Newwould return for the same profile and extensions (nilwhen valid). Withopts.Offline— for checking without your extensions in hand — a name that is not registered but follows the extension naming policy (AGG_ACME_THING: an operator category other thanSYNTH, a namespace other thanBUILTIN/STANDARD/CORE/PULSE) becomes a warning, “unverified extension name, check in-process”, codedPULSE_FEATURE_PROFILE_UNKNOWNwithdetails.reasonunverified_extension, instead of an error. It still needs a request host. Every other unknown name stays an error. Run the check in-process, with your extensions, before shipping.DiffFeatureProfile(fp, ext...)reportsmissing— features the running build offers that the profile omits, each flaggednewwhen itsSinceis later than the profile’swritten_with— andunknown— names the build does not resolve, with the same reasonsPULSE_FEATURE_PROFILE_UNKNOWNuses. Unknown names are reported, not fatal. A missing or unparseablewritten_withflags nothingnew.DescribeFeatureProfile(fp, ext...)describes every listed name:kind,category(operators only),source(builtin,extensionorunknown),since(built-ins) anddepends_on, an AND of any-of groups. A name the build does not resolve carries anunknownexplanation instead of failing.
Diff and describe fail only on a missing profile or a nil feature list
(PULSE_FEATURE_PROFILE_INVALID).
From the command line
pulse features wraps the same four functions, with no extensions and no
data directory (the leaves describe the binary). A profile path is a host
OS path, relative to the working directory.
pulse features init > profile.json # or: init --from minimal
pulse features check profile.json # exits non-zero on any failure
pulse features diff profile.json # missing (flagged [new]) + unknown
pulse features show profile.json
Every leaf takes --json for the standard envelope: data is the
profile, check report, diff or description; pulse features check --json
also lifts each unverified-extension warning onto the envelope’s
warnings, and a fatal error carries its own code in errors[0] (the
report stays in data) before the command exits non-zero. check is
always offline (above), so check in-process before shipping a profile that
lists your extension operators.
Related
- pulse.New & Options
- Extension Points for
DependsOn pulse mcp
Tuning Limits
An instance carries a closed set of resource limits. They exist so a pathological request (a group-by on a unique ID, a thousand-slot Compose, a join whose right side will not fit) fails fast with a message that names the knob to turn, instead of exhausting the host. The defaults are high enough that no legitimate request trips them, so most embedders set nothing. Tighten them on shared hosts (an MCP server, a multi-tenant service).
A request can never override an instance limit. A breach is a single
coded error, PULSE_LIMIT_EXCEEDED, with no partial result.
The limits
pulse.Limits (the Options.Limits field):
| Field | JSON / --limit key | Default | Unit | Guards against | Checked |
|---|---|---|---|---|---|
RequestTimeout | request_timeout | none (Unlimited) | duration | a call that runs far longer than intended | runtime, an inner deadline |
MaxGroups | max_groups | 10,000,000 | distinct groups | grouping on a high-cardinality field | predict (possible) + every bucket mint at runtime |
MaxCrosstabCells | max_crosstab_cells | 10,000,000 | rows x columns | a huge crosstab grid | predict (possible) + both crosstab arms |
MaxEstimatedMemory | max_estimated_memory | none (Unlimited) | bytes | a run whose buffered state cannot fit | predict (certain) and the same pre-flight before any record decodes |
MaxMatrixDim | max_matrix_dim | 2,048 | columns p | a quadratic matrix output | predict (certain) + pre-flight |
MaxComposeSlots | max_compose_slots | 1,000 | requests | fan-out through Compose; a sweep counts its EXPANDED slots plus the explicit ones | up front, serial and parallel, and in PredictCompose |
MaxChainStages | max_chain_stages | 1,000 | stages | fan-out through ProcessChain | up front |
MaxJoinBuildRows | max_join_build_rows | 100,000,000 | records | a join’s in-memory build side | predict (certain, from the right side’s header) + the build loop |
MaxDerivedColumns | max_derived_columns | 1,000 | window + feature + attribute items | fan-out through an item for_each; counts every plain item plus every EXPANDED one (a multi-output feature counts once) | predict (certain) + before any for_each item renders, on every request host (Process, each Compose slot, each chain stage, Materialize) |
RequestTimeout and MaxEstimatedMemory are opt-in: their default is
Unlimited. There is no output row cap; output volume is the embedder’s
to manage (see Response Shaping for what is
returned).
Read the effective values with p.Limits(), or pulse manifest --json
(limits array {name, value, default, unit}). The Go constants
pulse.DefaultMaxGroups, pulse.DefaultMaxCrosstabCells, … are the
defaults.
The encoding: 0 and -1
The same on every input layer (Go, feature profile, --limit):
0, or absent: use the default.-1(pulse.Unlimited;unlimitedas text): no limit.- any other negative value: refused with
PULSE_LIMIT_INVALID(PULSE_FEATURE_PROFILE_INVALID, reasoninvalid_limits, in a profile).
The effective limits Pulse.Limits() returns never hold 0.
Precedence
Per field: a non-zero Options.Limits value, else the feature profile’s
limits value, else the built-in default.
p, err := pulse.New(pulse.Options{
DataDir: "/var/data/pulse",
Limits: pulse.Limits{
MaxGroups: 1_000_000,
RequestTimeout: 30 * time.Second,
MaxMatrixDim: pulse.Unlimited,
},
})
A feature-profile file carries the same keys in snake_case;
request_timeout is a Go duration string ("30s", "-1", "unlimited"):
{
"profile": "shared-host",
"written_with": "1.0.0",
"features": [],
"limits": { "max_groups": 1000000, "request_timeout": "30s", "max_matrix_dim": -1 }
}
Limits are behaviour, not features: they are not in feature_set_digest
and do not hide anything. See Feature Profiles.
For an MCP server, pass --limit once per limit. A bad key or value is a
CLI_INPUT startup error:
pulse mcp --limit max_groups=1000000 --limit request_timeout=30s --limit max_estimated_memory=4_000_000_000
Manifest and the cache key
manifest.limits reports the effective values (-1 = none) and
manifest.limits_digest (lim1: + SHA-256 over the name=value lines)
changes exactly when an effective limit changes. The manifest cache key
stays (pulse_version, feature_set_digest); a client that plans around
limits re-fetches when limits_digest moves.
Predict findings: certain and possible
Predict reports PredictResult.LimitFindings
({limit, configured, estimated, grade}) for every limit a figure
computed from the request and schema exceeds.
certain: the exact figure the runtime would see. The finding also adds thePULSE_LIMIT_EXCEEDEDerror, sovalidis false, andProcessrefuses with the identical code, message and details before any record decodes.possible: an upper bound (a group count estimated from dictionary sizes, a crosstab grid). Reported only; the request staysvalidand the runtime decides.MaxGroupsandMaxCrosstabCellsarepossible.
Predict reports a Request’s findings (a Compose slot’s, a chain’s first
stage). Slot and stage counts, RequestTimeout and a rich facet’s group
count are knowable only at runtime.
Reading PULSE_LIMIT_EXCEEDED
details is {limit, configured, observed, option}; the message names the
three places to raise it (Options.Limits.<Field>, the profile key,
--limit). Look the code up with pulse errors lookup PULSE_LIMIT_EXCEEDED.
- A parallel run (shard or decode workers,
ComposeParallel) stops at the first worker to breach, soobservedis NONDETERMINISTIC there: compare{code, limit, configured}, neverobserved. A merge-only breach (every shard under the limit, the union over it) reports the merged count. - Fused crosstab
observedis the first grid product past the limit; a buffered crosstab reports the final grid. The fused count is the axis grid (rows x columns of interned keys), not populated cells. - For
request_timeout,configuredandobservedare nanoseconds (observed= elapsed time from the outermost call). - A trip is a bare coded error, never wrapped in a slot or stage locator.
The memory estimate
MaxEstimatedMemory compares against an UPPER bound, never a measurement:
no filter selectivity is assumed, and the model carries a x3 collector
headroom. Calibration against measured peak heap lands the estimate at
roughly 1.3x to 4.9x the peak depending on the arm. Not modelled: a 1:N
fan-out join (it can exceed the estimate), a GOGC above 100, and
extension aggregators. There is no GOMEMLIMIT coupling: the limit is
opt-in and independent of the Go runtime’s own limit. Set it
a little under the memory you can spare.
An unknown record count produces no estimate and no finding. With the
default (Unlimited) no count is read at all.
RequestTimeout and the caller’s context
RequestTimeout is an inner deadline in addition to the caller’s ctx. It
covers Process, ProcessStream / ProcessStreamResult (the run and the
drain, so a stalled consumer trips it), Facet / FacetSchema, and the
WHOLE Compose / ComposeParallel / ProcessChain call (nested entries
share one budget). Only the inner deadline becomes
PULSE_LIMIT_EXCEEDED:
| Who ended the call | Error |
|---|---|
RequestTimeout | PULSE_LIMIT_EXCEEDED {limit: "request_timeout"} |
| the caller’s deadline | context.DeadlineExceeded, unchanged |
| the caller cancelled | context.Canceled, unchanged |
Serial per-record loops (streaming, buffered materialize, fused and
buffered crosstab, the join build, facet) poll the ctx on the first row and
then every 4,096 rows, so a cancel or timeout lands within 4,096 rows.
Pulse.ProcessStream (a RowIter) bounds only the run; draining with
Next(ctx) is governed by the caller’s ctx. ComposeOptions.PerRequestTimeout
still bounds each slot and still surfaces context.DeadlineExceeded. See
Parallel Compose.
Choosing values
- Shared MCP host: set
request_timeout(30s to a few minutes) andmax_estimated_memoryfirst; the count limits are rarely the binding one. - Tighten
max_groupswhen users group freely and a unique-ID group-by would be the common mistake. - Never lower a limit below what existing saved requests need. Run
Predictover them and look atlimit_findings.
Observability
Pulse gives an embedder three independent, optional windows into a running instance. All three default to off, and the off path allocates exactly what it did before they existed.
pulse.Options field | What it is | Cost when nil |
|---|---|---|
Logger *slog.Logger | structured records (stdlib log/slog) | nothing written |
Hooks *observe.Hooks | callbacks around every operation and phase | nothing called |
Metrics observe.Metrics | an instrument factory for counters, histograms, gauges | nothing recorded |
Pulse never reads slog.Default(), adds no environment variable, and does not change format_version. The vocabulary (Hooks, OperationInfo, the OperationKind / Phase / Arm / Scope enums, the instrument interfaces) lives in the small public package github.com/frankbardon/pulse/observe, which imports only the standard library.
Which setup do I need?
| Host shape | Wire it with |
|---|---|
| Logs only (slog) | Options.Logger = slog.New(handler) |
| Custom callbacks (audit, tracing of your own) | Options.Hooks = &observe.Hooks{...} |
| OpenTelemetry traces and metrics | contrib/otelpulse: Hooks(tp) + Metrics(mp) |
Prometheus via client_golang | contrib/prompulse: Metrics(reg) |
| The CLI MCP server | pulse mcp --log-level info --metrics-addr 127.0.0.1:9090 |
p, err := pulse.New(pulse.Options{
Logger: slog.New(slog.NewJSONHandler(os.Stderr, nil)),
Hooks: otelpulse.Hooks(tracerProvider),
Metrics: prompulse.Metrics(prometheus.DefaultRegisterer),
})
The core module depends on neither OpenTelemetry nor Prometheus. The two adapters are separate modules (go get github.com/frankbardon/pulse/contrib/otelpulse, .../contrib/prompulse) released at the same version as Pulse itself.
What counts as an operation
Every operation-bearing *Pulse method has an OperationKind: open, process, process_stream, compose, compose_parallel, process_chain, facet, facet_schema, lookup, count_records, inspect, predict, manifest, payload_schema, import, export, convert, imports_sweep, drop, sample, filter_to_file, synth, synth_stream, profile, dedup, materialize, widen, the index_* and shard_* kinds, template_render and template_reload. observe.AllOperationKinds() is the authoritative list. Several methods share a kind: every predict entry point (Predict, PredictBytes, PredictCompose, PredictFacet, PredictChain) reports predict, every inspect entry point inspect. A Compose predict fires one predict operation, never a child per slot.
Pure in-memory getters (Skills, Limits, LabelTables, …) are not operations, and neither are handles whose real work reaches an instrumented call (NewCohortBuilder) or a few filesystem helpers (CohortArtifacts, Imports, ResolveImport, Watch*, ExportReference, …): nothing useful to time, and each extra kind widens metric cardinality.
A Compose slot or ProcessChain stage is a child operation (Scope child, its parent’s Kind, Parent set, Index = slot or stage). A parent’s result sums its children’s rows and bytes, takes the maximum of workers and shards, and reports an Arm only when every child ran the same one.
Logs
Attribute keys are stable; message text is not. Records: op, cohort, request_hash, duration_ms, rows_scanned (when positive), code. At Debug, an extra plan record adds arm, workers, shards, projected_fields.
- Error: an operation failed (code only). Warn: one record per result warning (code only) and one per resource-limit trip (
limit,configured,observed). Info:pulse.Newready; index build/drop and shard create/add/remove/compact completed; imports sweeps and template reloads. - Privacy: logs never carry an error message, expression, filter value, dictionary entry or record. Warnings that exist only as free text are skipped for the same reason.
Hooks
Hooks: &observe.Hooks{
OnOperationStart: func(ctx context.Context, i observe.OperationInfo) context.Context { return ctx },
OnPhase: func(ctx context.Context, i observe.OperationInfo, p observe.PhaseTiming) {},
OnOperationEnd: func(ctx context.Context, i observe.OperationInfo, r observe.OperationResult) {},
}
- Hooks run synchronously on the operation’s goroutine; keep them fast. A panic is recovered, the operation is unaffected, and it is counted in
pulse_hook_panics_total. - The context
OnOperationStartreturns is the one the work,OnPhaseandOnOperationEndsee. OnPhasefires when the operation ends, once per phase that ran, in execution order, just beforeOnOperationEnd(plan,open,decode,scan,reduce,post,overlay,shape). It is a summary, not a live feed. Streaming operations have noshapephase.- A streaming operation ends when its stream is drained, closed, errors or its context is cancelled. A stream you abandon without closing never ends, and its in-flight gauge stays up. Always
Closean iterator. OperationResult.Codeisok, an error code, oruncoded.
Metrics
| Metric | Type | Labels |
|---|---|---|
pulse_operations_total | counter | op, code, scope |
pulse_operation_duration_seconds | histogram | op, scope |
pulse_phase_duration_seconds | histogram | op, phase |
pulse_rows_scanned_total | counter | op |
pulse_bytes_read_total | counter | op |
pulse_limit_trips_total | counter | limit |
pulse_hook_panics_total | counter | hook |
pulse_operations_in_flight | up-down counter | op |
Operations count and duration cover top-level and child operations (split by scope). The rest count top-level operations only, because a parent already carries its children’s totals. Labels come from closed sets, so cardinality is bounded.
Writing your own observe.Metrics: Pulse calls your factory at most once per name and label combination and caches the result. Most instruments are resolved while New runs, but pulse_operations_total for an error code is resolved on first use, so the factory can be called after New, from any goroutine. It must be safe for concurrent use. Histogram buckets are yours to choose.
OpenTelemetry (otelpulse)
Hooks(tp) starts one span per operation named pulse.<kind>. A child span is named pulse.process with the parent’s kind in pulse.op, pulse.scope=child and pulse.index. Result attributes (pulse.code, pulse.arm, pulse.rows_*, pulse.bytes_read, …) are set at the end, zero counters omitted; a failure sets the span status and pulse.error_code. Phases become span events, timestamped from the span start by the preceding phases’ durations, so their placement is approximate. Metrics(mp) maps the table above onto a MeterProvider.
Prometheus (prompulse)
Metrics(reg, ...) registers on any prometheus.Registerer; WithBuckets overrides the default 0.5 ms to 60 s histogram buckets. A series appears on its first write.
CLI and pulse mcp
--log-level off|debug|info|warn|error and --log-format text|json are accepted by every leaf and write to stderr only (stdout stays clean for --json and for MCP’s JSON-RPC). Leaves that never build a Pulse instance (import, convert, skills, errors, version, features) accept the flags but emit nothing. See Global flags.
pulse mcp --metrics-addr HOST:PORT serves GET /metrics in Prometheus text format from the standard library alone. The endpoint has no authentication: bind a loopback address or put it behind your own access control. No port opens unless the flag is given. See pulse mcp.
Contributors
The full contract (attribute keys, phase lists per arm, gates, adapter release procedure) is in .claude/reference/observability.md; make contrib builds and tests the adapter modules.
Guided-Analysis Vocabulary
Audience: Go embedders and agent-harness authors.
Pulse ships plain-language guidance metadata alongside its operators: what
kind of question each one answers, how to read the numbers it returns,
and a glossary of the statistical terms involved. The metadata is
pulled on demand. It never appears in a default Response,
PredictResult or manifest, apart from short intent IDs.
Status (v1.0.0 alphas). Every operator carries a
Purpose, and every statistical test, overlay and regression anInterpretation. Pulse renders them for you: the book’s Analysis Guide (operator catalog, “Reading your results”, glossary), the## Use whenand## Reading the outputsections of every operator skill, andp.ExportReference/pulse docs exportfor your own instance. The prose is still pulled, never pushed: it appears in none of the defaultResponse,PredictResultor manifest.Recommend,Explainand predict advisories (below) turn that metadata into answers; the MCP guidance tools beyond them come in a later unit.
Intents: what kind of question
An intent is a kind of question a non-statistician would recognise,
such as “compare groups”, “relationship between measures” or “change
over time”. The taxonomy is closed, and pulse.Intents() returns all of
it on every instance.
for _, in := range pulse.Intents() {
fmt.Println(in.ID, in.Label, in.Analytic)
// in.Sounds: example phrasings; in.Shapes: the field roles it needs
}
Twelve intents are analytic, meaning operators answer them. Three
(prepare, simulate, lookup) are not: they point at tooling.
Intent.Shapes describes the data each intent needs as named roles,
for example one outcome field plus one categorical group field for
“compare groups”.
The manifest carries the intents compactly:
- top-level
intentsis the sorted list of IDs only. On an instance with a feature profile it leaves out an intent that only hidden operators serve; an intent no operator serves always stays - each operator, test, regression, distribution and overlay-kind entry
carries
intents, the IDs its guidance declares. The key is omitted when an entry declares none.
To find candidate operators for a question, pick its intent ID and
filter manifest entries whose intents contain it.
Glossary
for _, t := range pulse.Glossary() {
fmt.Println(t.ID, "—", t.Short)
}
Each descriptor.Term has a one-sentence Short, a WhyCare, and
SeeAlso links. Both functions return fresh deep copies in declaration
order, so changing the result cannot affect Pulse.
Effect sizes
Statistical tests report standardised effect sizes under
details.effect_size (for example cramers_v on TEST_CHISQ,
omega_squared on TEST_ANOVA_F, rank_biserial on
TEST_MANN_WHITNEY_U). A key is omitted when it is undefined for
the data, for instance when every value ties. Each test’s skill
(op-test-*) lists its keys and formulas.
Reading bands
Where an effect size has a published benchmark, its interpretation
labels value ranges as very small, small, medium or large, and
names the convention the labels come from. Every built-in band set comes
from one sourced registry of conventions, for example Cohen (1988) for
cohens_d (0.2 / 0.5 / 0.8) and for eta_squared / omega_squared
(0.01 / 0.06 / 0.14). Odds-ratio and R² bands are Cohen’s benchmarks
converted to that scale, and the citation says so.
Bands are a labelled convention, not a verdict: what counts as a large
effect depends on the field. Effect sizes with no published convention,
such as rank_biserial or cramers_v, are deliberately left unbanded,
and their interpretation says why.
Recommend: from a question to a request
p.Recommend takes an intent and returns ranked draft requests. Without
a cohort it is cohort-free (bound: false): each draft carries
"<key>" placeholders you fill in, and nothing is validated against
data. With a cohort it reads only the header and schema, matches your
Fields hints to roles, and predict-validates every draft, so a bound
draft that is fully filled in is a request you can run. A draft that
still needs a choice from you says so in needs. A fully bound draft
ranks above one that needs something.
res, err := p.Recommend(ctx, descriptor.RecommendRequest{
Intent: "compare_groups",
Cohort: &types.Cohort{Filename: "survey.pulse"},
Fields: []string{"score", "region"},
})
for _, r := range res.Recommendations {
fmt.Println(r.Operator, r.Bound, r.Why)
}
Each recommendation carries why in plain words, alternatives (what
not to use it for) and follow_ups (what to ask next). Only operators
the instance offers appear. CLI: pulse recommend --intent ID; MCP:
pulse_recommend.
Explain: from a request or a result to plain language
p.Explain takes exactly one root: a request (request, composed,
chain, facet, sample) or a result (response, composed_response,
chain_response, facet_result, each with its own request as an
optional companion). Given a request it describes what each slot will
do, which defaults apply and what predict says. Given a result it
returns findings[], each with a verdict from a closed set, a
strength band that names the convention it uses, and the figures behind
it. Output is terse by default; Detail: "full" adds the sentences,
glossary terms and follow-ups.
Explain never says “no difference” for a non-significant result: a
p-value above the threshold is reported as no evidence of an effect,
which is not evidence of none. An undefined figure stays null in the
result, and a result you decode from JSON keeps its nulls as undefined
rather than reading them as zero. Regressions and overlays without a
multiplicity correction are read against an alpha of 0.05, and the
result says so. CLI: pulse explain --request FILE or --response FILE;
MCP: pulse_explain.
Predict advisories
A predict result can carry advisories[]: coded, non-blocking notes
that the analysis may not fit the data. Each has a code, a message and a
suggested fix. They never change execution, valid or --strict.
| Code | Fires when |
|---|---|
PULSE_ADVISORY_TWO_GROUP_TEST_MANY_GROUPS | a two-sample test splits on a field with more than two groups |
PULSE_ADVISORY_MANY_TESTS | ten or more uncorrected p-values and no multiplicity |
PULSE_ADVISORY_CATEGORICAL_AS_NUMERIC | an SPSS nominal field is averaged as a number |
PULSE_ADVISORY_ORDINAL_PARAMETRIC | a parametric test runs on an SPSS ordinal field |
PULSE_ADVISORY_WEIGHT_AVAILABLE_UNUSED | the cohort names a weight variable and the request uses none |
PULSE_ADVISORY_EXTRA_PASS | an ATTR_FORMULA calls total(expr), which adds a pre-pass over the filtered rows |
Silence a code for the whole instance with Options.SuppressAdvisories;
an unknown code is refused at pulse.New with
PULSE_SUPPRESS_ADVISORY_UNKNOWN. Compose and Facet predict carry
advisories too. Facet has no test slot, so only the many-tests code can
fire there.
Over MCP and the CLI
The glossary and the taxonomy are also served as two skills with no
file behind them, glossary and intents. They work everywhere skills
do:
pulse skills show glossary/pulse skills show intentspulse_skills_getwithname: "glossary"/"intents", andp.Skill("glossary")/p.Skill("intents")in Go- the resources
pulse-skill://glossaryandpulse-skill://intents - the manifest’s
skillslist
Both skills are listed on every instance. On an instance with a feature profile, the bodies leave out what the instance cannot use: an intent that only hidden operators serve, and a glossary term that only hidden operators cite. “See also” links to a removed term are dropped too. An intent no operator serves, and a term no operator cites, always stay.
Exporting the reference
p.ExportReference(fs, dir, pulse.ExportReferenceOptions{}) writes
the instance’s whole analysis reference as Markdown under dir on an
afero.Fs (a nil fs writes through the instance filesystem): the
operator catalog with each operator’s purpose, the “Reading your
results” pages, the glossary, every skill as the instance serves it,
and an mdBook SUMMARY.md fragment. OmitSkills: true leaves the
skill pages out. The tree is deterministic, so two exports of the same
instance are byte-identical, and it is scoped like every other
surface: under a feature profile no page names a hidden operator, MCP
tool or request slot.
The export writes a .pulse-docs-export marker that lists the files
it wrote. An empty or missing directory, or one carrying the marker,
is written in place, and the files a previous export listed but this
one no longer renders are deleted; nothing else is touched. A
non-empty directory without the marker is refused with
PULSE_DOCS_EXPORT_DIR_NOT_EMPTY before anything is written. A
filesystem failure is DATA_FILE, with the path under
details.path.
From the command line, pulse docs export --out DIR does the same for
a default instance built over an in-memory filesystem, so it needs no
PULSE_DATA_DIR. --feature-profile PATH scopes the export to the
feature profile at that host OS path, --no-skills sets OmitSkills,
and --json wraps the result in the standard envelope, where a refusal
keeps its own code (errors[0].code).
Guidance on your own operators
Extension registrations accept an optional Purpose, and test
registrations an optional Interpretation. pulse.New validates them
and refuses an invalid one with PULSE_EXTENSION_PURPOSE_INVALID. See
Extension Points.
Linear Algebra (linalg)
github.com/frankbardon/pulse/linalg is Pulse’s linear-algebra core. Pulse’s own synthetic-data generator and regression engine use it, and embedders can import it directly. It is a leaf package: it imports only the standard library, gonum and Pulse’s errors. It exposes no gonum type, so a gonum upgrade can never break your code.
import "github.com/frankbardon/pulse/linalg"
Types
| Type | Shape | Build with |
|---|---|---|
Matrix | dense, row-major | NewMatrix(rows, cols, data), NewMatrixFromRows([][]float64) |
Sym | symmetric; stores the upper triangle, packed | NewSym(n, packed), NewSymFromRows([][]float64) (reads the lower triangle) |
Vec | vector | NewVec([]float64) |
Accessors: At, Set, Rows / Cols / N / Len, ToRows, RowMajor, Slice. Constructors copy their input, and a malformed shape returns an error, never a panic. An out-of-range At / Set panics like a slice index.
Two backends: which routine to pick
| Need | Routine | Same bits on every architecture? |
|---|---|---|
| Factor, solve or invert a symmetric positive-definite matrix, with reproducible output | Cholesky, CholeskyRidge, SolveSPD, InverseSPD | yes: pure Go, FMA-free |
| Running means and covariances, mergeable | CoMoment, MergeTree | yes: pure Go, FMA-free |
| Factor once, then solve many right-hand sides, with a condition check | FactorSPD → SPDFactor.Solve / Inverse / ConditionNumber; Mul | no; gonum-backed, but identical bits run to run on one machine |
| Eigen, singular value, QR, rank, condition number | SymEigen, SVD, QR, Rank, ConditionNumber | values may differ in the last ulps; order, sign and rank decisions are fixed |
The reference routines write every product that feeds an addition as an explicit float64(a*b) conversion, which stops the compiler from fusing it into a fused multiply-add. That is why arm64 and amd64 agree bit for bit. Use them when “same input, same seed, same bytes” matters to you.
Cholesky with a ridge
s, _ := linalg.NewSymFromRows([][]float64{{1, 0.9}, {0.9, 1}})
L, err := linalg.Cholesky(s) // lower triangular, s = L·Lᵀ
// Nearly singular? Retry with a growing diagonal ridge.
L, ridge, err := linalg.CholeskyRidge(s, nil) // nil = DefaultRidgeSchedule()
if ridge > 0 {
// s was not positive definite; ridge is the TOTAL added to the diagonal
}
DefaultRidgeSchedule() makes eight attempts. Before each retry it adds the next increment, 1e-6, 1e-5, …, 1e1. The returned ridge is the sum of the increments actually added. Pass your own RidgeSchedule to change the steps.
Eigen, SVD, QR and rank
eig, err := linalg.SymEigen(s) // eig.Values (descending), eig.Vectors (columns)
sv, err := linalg.SVD(m) // sv.U, sv.Values, sv.V
qr, err := linalg.QR(m) // qr.Q, qr.R
r, err := linalg.Rank(m, 0) // 0 = the default tolerance
k, err := linalg.ConditionNumber(m)
gonum’s raw output is canonicalised so every decision reads the same everywhere:
- Order. Values are descending. Values tied within the rank tolerance keep the original variable order.
- Sign. Each eigenvector and right singular vector is flipped so its largest-magnitude component is positive. A tie within
DominanceTolerancegoes to the lowest index.QRmakesR’s diagonal non-negative. - Rank. One tolerance,
RankTolerance(rows, cols, σmax) = max(rows, cols)·Epsilon·σmax, the same default as LAPACK and NumPy.ConditionNumberis+Infwhen the matrix is rank-deficient under it.
Co-moments
CoMoment accumulates weighted means and the covariance matrix of p variables in one pass, and merges exactly.
acc, _ := linalg.NewCoMoment(3, linalg.Listwise) // or linalg.Pairwise
for _, row := range rows {
acc.Add(row.x, row.w) // len(row.x) == 3
}
cov := acc.Cov(1) // sample covariance (frequency weights)
corr := acc.Corr() // C/√(M2_x·M2_y), TEST_PEARSON_R's arithmetic
mean := acc.Mean()
- Missing values. A
NaNinxis missing.Listwisedrops the row.Pairwisedrops it only for the pairs it touches;PairN(i, j)/PairW(i, j)report each pair’s count and weight. - Weights.
- A
NaN,±Infor negative weight skips the row and is counted inNWeightInvalid(). - A weight of
0is valid: the row counts inN()but carries no mass. W()is the weight sum, andNEff()is Kish’s effective sample size. Use weight1for unweighted data.
- A
- Undefined figures. These are
NaN, never 0:- a covariance entry whose
W − ddof ≤ 0; - every correlation touching a constant variable;
- the mean of a variable with no mass.
- a covariance entry whose
- Concurrency. A
CoMomentis not safe for concurrent use. Give each goroutine its own accumulator andMergethem.
Merging with identical bits regardless of worker count
a.Merge(b) is exact algebra, but floating-point addition is not associative. So a different split of the same rows agrees only to about 1e-10 relative. To get the same bits however the work was divided:
- Fold the row at absolute index
rinto a per-block accumulator for blockr / linalg.MergeBlockSize(4096 rows). Each block’s rows must be added in order. - Combine the blocks, ordered by block index, with
linalg.MergeTree(blocks). It uses a fixed binary tree, so the result depends only on the block sequence.
Pulse’s engine uses exactly this scheme internally, so its parallel and serial runs agree bit for bit.
Errors
Every failure is an *errors.CodedError. Look up its prose with pulse errors lookup CODE.
-
PULSE_MATRIX_SHAPE_MISMATCH: dimensions that do not fit, a nil operand, or merging accumulators of different size or mode. A wrong-lengthxpassed toCoMoment.Addpanics instead. -
PULSE_MATRIX_SINGULAR: the matrix cannot be factored, solved, inverted or decomposed.details.reasonsays why where the routine knows:not_positive_definiteorill_conditioned(withcondition_number);non_finite(aNaN/Infelement in a decomposition);no_convergence.
A reference Cholesky failure carries
pivot; an exhausted ridge schedule carriesattemptsandridge. A failedCholesky(soSolveSPD/InverseSPD) orFactorSPDon a finite matrix also carriesrank,condition_number(+Inf,nullon the wire, when rank-deficient) and, for a rank-deficient one,dependent_indices: the axis indices in the linear dependency. A matrix operator names them asdependent_fields.
Vectors and Matrices
A vector names a battery of numeric fields once; a matrix slot
turns a vector into a result in which every member is compared with
every member. Eight operators ship: MAT_COVARIANCE,
MAT_CORRELATION (Pearson by default; Spearman or Kendall tau-b under
params.method), MAT_PARTIAL_CORRELATION, MAT_RELIABILITY,
MAT_PCA, MAT_FACTOR, MAT_COLLINEARITY and MAT_DISTANCE (which
compares bucket centroids instead of members). All are additive: a request without
vectors / matrices is byte-identical to before, and format_version
stays "1.1". The slots are gated by the capability:matrices feature.
{
"cohort": {"filename": "training_data.pulse"},
"vectors": [{"name": "money", "fields": ["price", "income", "label"]}],
"matrices": [
{"type": "MAT_CORRELATION", "vector": "money",
"params": {"missing": "pairwise", "summary": {"top_pairs": 3}}}
]
}
Vectors
vectors: [{name, fields | pattern, labels?, coerce?}], exactly one of
fields / pattern. A literal field keeps the caller’s order; a glob
(q*) expands in place in schema order; pattern is an unanchored
regexp in schema order. Members are integer and float fields;
packed_bool needs coerce: "binary"; categorical, set_*, date,
datetime and decimal128 are refused with PULSE_VECTOR_MEMBER_TYPE.
Other codes: PULSE_VECTOR_EMPTY, _DUPLICATE, _LABELS_MISMATCH,
_UNKNOWN, _INVALID, and _NAME_COLLISION for a name equal to a
schema field or to a feature output, attribute or window label of the
request (an expression reads a vector by name); a vector no operator
slot or expression (formula, expression filter, total() argument)
names warns PULSE_VECTOR_UNREFERENCED. Pulse.Predict echoes the resolved members
as resolved_vectors.
Matrices
matrices: [{name?, type, vector | fields, params?, weight?, encoding?}]
returns Response.Matrices, one types.MatrixResult per spec in
request order:
| Key | Meaning |
|---|---|
primary | the matrix, a types.MatrixValues {kind, encoding, row_keys, column_keys, labels?, values} |
auxiliary | pairwise only: n, each pair’s row count, same shape |
vectors | top_pairs ([{row, col, r, n}]) for MAT_CORRELATION with summary.top_pairs |
scalars | determinant, null unless the matrix is positive definite |
warnings | PULSE_MATRIX_* data-quality findings |
group_key, group_header | set on a per-bucket result |
encoding is full (default) or upper (row r carries the p - r cells
from the diagonal). Undefined cells are null, keys kept.
Missing data and weights
missing: "listwise"(default) drops a row with any null member;"pairwise"uses, per cell, the rows where both members are present. A pairwise matrix of three or more members may fail to be positive semi-definite (PULSE_MATRIX_NOT_PSD: detection only on the covariance and correlation operators, fatal on a decomposition operator — see Partial correlation).max_drop_share(listwise) warnsPULSE_MATRIX_LISTWISE_HEAVY_DROP.PULSE_MATRIX_INSUFFICIENT_NandPULSE_MATRIX_ZERO_VARIANCEflag thin or constant members. A constant member nulls its correlation row and column; the covariance keeps an exact 0.- Weights follow Row Weighting; a weight-0 row counts
toward
nand adds no mass.
Rank correlation
MAT_CORRELATION params.method: pearson (default), spearman or
kendall (tau-b). A rank method ranks each member first (pairwise:
each pair re-ranked over its own rows, as R’s
cor(use = "pairwise.complete.obs")), and each cell equals
TEST_SPEARMAN_R / TEST_KENDALL_TAU over the pair’s rows. It keeps
every admitted row until finalize, so the request runs buffered and
serially (predict: streamable and mergeable false,
row_buffer_bytes); it takes frequency weights only (a probability
weight is PULSE_WEIGHT_UNSUPPORTED) and emits no p-values.
Partial correlation
MAT_PARTIAL_CORRELATION reads each pair’s Pearson correlation with
other fields held fixed, off the precision matrix of the members’
correlation (the same co-moments as MAT_CORRELATION, so it streams,
merges and takes both weight kinds). params.control is "all"
(default: every pair controls for every other member, as
ppcor::pcor) or a list of numeric fields: each output pair then
controls for exactly those (ppcor::pcor.test(x, y, Z)), a listed
member leaves the output axis, and a listed field outside the members
joins the fold and its missing-data mode (projection and
Components.Matrices count it). No p-values, no scalars; pairwise adds
auxiliary.n. If any input correlation is undefined every cell is
null.
It is a decomposition operator, so its input must be usable: a
pairwise input that is not positive semi-definite is refused with the
fatal PULSE_MATRIX_NOT_PSD (predict flags the risk as
pairwise_psd_risk) unless params.repair: "nearest", which replaces
it by the nearest correlation matrix — Higham’s alternating projections
with Dykstra’s correction, Matrix::nearPD(corr = TRUE)’s settings
(at most 100 iterations, convergence 1e-7, eigenvalue floor 1e-8 of the
largest) — and keeps the code as a warning carrying
frobenius_adjustment, iterations and converged. A member or
control that is a linear mix of the others is PULSE_MATRIX_SINGULAR
with rank, condition_number and dependent_fields.
Scale reliability
MAT_RELIABILITY checks a battery of items before it is summed into a
score. Its primary is the inter-item correlation matrix; scalars
carry Cronbach’s alpha (on the covariance, psych::alpha raw_alpha),
alpha_standardized (from the mean inter-item r), mean_inter_item_r
and McDonald’s omega; vectors carry, per item in axis order, the
corrected item_total_r (the item against the sum of the others),
alpha_if_deleted, item_mean and item_sd.
Reverse-worded items are named in params.reverse together with the
battery’s scale_min / scale_max: each value becomes
scale_min + scale_max − x before the fold, so the operator streams and
merges like MAT_CORRELATION and takes both weight kinds. A reverse
list without the range, or any item value outside a declared range, is
PROCESSING_CONFIG — values are never clamped and the range is never
read off the data.
omega is (Σλ)² / ((Σλ)² + Σψ) from a one-factor minimum-residual fit
of the inter-item correlation (psych::fa(nfactors = 1, fm = "minres"));
components.matrices[i].operator reports the fit’s iterations and
converged. It is null with a warning when the battery has 2 items
(PULSE_MATRIX_NOT_IDENTIFIED), when the fit is a Heywood case
(PULSE_MATRIX_HEYWOOD), or when a pairwise table is not positive
semi-definite and params.repair is not "nearest"
(PULSE_MATRIX_NOT_PSD as a warning — alpha needs no such input and is
always reported). Alpha is read against George & Mallery’s (2003) bands;
omega is not banded.
Principal components
MAT_PCA decomposes the members’ correlation (params.on
"correlation", the default) or covariance ("covariance"). Its
primary is the loadings — eigenvector × √eigenvalue — as a
rectangular matrix (kind: "rectangular"): row_keys are the
members, column_keys the kept components PC1 … PCk, always written
in full. auxiliary.eigenvectors has the same shape. vectors carry
every eigenvalue (descending), its explained_variance share and the
cumulative share, the communalities (each member’s summed squared
loadings over the kept components) and kmo_msa; scalars carry the
overall kmo, Bartlett’s sphericity test (bartlett_chisq,
bartlett_df, bartlett_p) and components_retained.
params.components chooses k: an integer (at most the member count),
"kaiser" (every eigenvalue above 1 — the default on a correlation,
refused on a covariance, whose eigenvalues are in the members’ units),
or {"variance": share} (the fewest leading components whose cumulative
share reaches it). On a covariance it is required. Sign and order follow
linalg.SymEigen: eigenvalues descending, each eigenvector’s
largest-magnitude entry positive — portable across architectures, with
values agreeing to the eigensolver’s last digits.
KMO (psych::KMO) and Bartlett (psych::cortest.bartlett) always read
the correlation. Bartlett’s sample size follows the weighted-inference
rule: the rows listwise, Σw under frequency weights, Kish n_eff under
probability weights, and under pairwise deletion the smallest pair’s
(PULSE_MATRIX_PAIRWISE_N_STAR says so). A singular correlation nulls
KMO and Bartlett with a PULSE_MATRIX_SINGULAR warning, the components
still reported. Like the partial correlation, a non-PSD pairwise input
is refused unless params.repair is "nearest". KMO is read against
Kaiser’s (1974) bands.
params.rotation: "varimax" (default "none") rotates the kept
components orthogonally toward simple structure, exactly as R’s
stats::varimax with Kaiser normalization does. The rotated loadings
become the primary — columns ordered by the variance each accounts
for, each signed so its loadings sum positive, keys still PC1 …
PCk — the unrotated loadings move to auxiliary.unrotated, and
vectors.rotated_variance lists each rotated column’s sum of squared
loadings. Eigenvalues, the explained and cumulative shares, the
communalities (unchanged by an orthogonal rotation), the eigenvectors,
KMO and Bartlett are never rotated. A single kept component is left as
is. Without the knob the output is byte-identical to before.
Centroid distances
MAT_DISTANCE answers “how far apart are the segments?”: a grouper in
params.between cuts the rows into buckets — GROUP_CATEGORY,
GROUP_KMEANS, any other built-in or a registered extension grouper —
each bucket’s
centroid is its (weighted) mean of every member, and the primary is the
k × k distance matrix between centroids, keyed by the bucket labels in the
grouper’s own order (cluster_1 … by size for k-means), zero on the
diagonal. A date-family between (GROUP_DATE, GROUP_DATE_RANGES) reads
local calendar days in its own tz, else the request time_zone, exactly
like a groups slot; predict echoes the zone at slot
matrices[i].params.between. An extension between buckets with its
buffered Group, so the spec runs buffered and serial.
{"type": "MAT_DISTANCE", "fields": ["age", "income"],
"params": {"between": {"type": "GROUP_CATEGORY", "field": "region"},
"metric": "euclidean", "standardize": true}}
params.metric is euclidean (default, stats::dist), sqeuclidean,
manhattan, chebyshev, cosine (1 − cos, null against a zero-length
centroid) or mahalanobis — over the pooled within-bucket covariance,
which allows for how the members vary together (it reads the raw
centroids; it is scale-free, so standardize changes nothing).
params.standardize z-scores every member over the bucketed rows first
(their mean and N* − 1 spread, R scale()). The metric list is local to
MAT_DISTANCE; the shared vector-metric registry will absorb it later.
auxiliary.centroids holds the k × p centroids (rows the buckets),
centroids_standardized and pooled_covariance ride their options, and
vectors.sizes / counts give each bucket’s weight mass and rows. Rows
the grouper places in no bucket (a null key, an include rejection, a
k-means row with a null member) are excluded and counted in
components.matrices[].operator.n_null_key; missing data is listwise
only. With a streaming grouper the matrix streams and merges (one
co-moment per bucket); GROUP_KMEANS and GROUP_QUANTILE keep the rows
for their whole-set pass and run serially. Weighted centroids under
frequency and probability weights; a k-means between takes frequency
weights only.
Factor analysis
MAT_FACTOR is an exploratory factor analysis of the members’
correlation: where PCA summarises all of the spread, a factor model
explains only the spread the members share, leaving each its own
uniqueness. params.factors is the number of factors — an integer (at
most the member count) or "kaiser", the default (the correlation’s
eigenvalues above 1). params.extraction picks the solver, each
starting from the squared multiple correlations: "minres" (the
default) minimises the off-diagonal residuals, as psych::fa(fm = "minres") does — Pulse solves to the exact optimum, where psych’s
optimiser stops about 1e-5 short — and "principal_axis" iterates the
communalities on the diagonal exactly as psych::fa(fm = "pa") does
(until their sum moves by at most 0.001, at most 50 iterations), and
"ml" is Jöreskog’s maximum likelihood over the uniquenesses, each held
in [0.005, 1], as stats::factanal and psych::fa(fm = "ml") fit it —
again solved to the exact optimum where their optimiser stops short.
The primary is the p × k loadings, a rectangular matrix with
column_keys F1 … Fk, each column signed so its loadings sum
positive. params.rotation rotates them with the same engine as
MAT_PCA: "varimax" (orthogonal, stats::varimax), "promax"
(oblique, stats::promax with power params.kappa, default 4) or
"oblimin" (oblique, GPArotation::oblimin with params.gamma,
default 0). Rotated, the columns are ordered by the variance each
accounts for, the unrotated loadings move to auxiliary.unrotated, and
an oblique rotation adds auxiliary.factor_correlation — the k × k
correlation of the factors, a square matrix keyed F1 … Fk.
vectors carry each member’s communalities (unchanged by any
rotation) and uniquenesses (1 − communality) and each factor’s
variance_accounted; scalars carry rmsr (the root-mean-square of
the correlations the factors leave unexplained), the empirical residual
chi-square (empirical_chisq, df, empirical_p — psych’s residual
size, not a likelihood test; empirical_p is null when df ≤ 0), kmo
and Bartlett’s test as on MAT_PCA, the fit’s iterations and
converged (1 or 0) and factors_retained. Maximum likelihood adds
the likelihood test and fit indices psych reports: likelihood_chisq
(the Bartlett-corrected likelihood-ratio chi-square on df) and its
likelihood_p, the Tucker-Lewis index tli, the RMSEA point estimate
rmsea with its confidence interval rmsea_lower / rmsea_upper
(psych’s RMSEA lower / upper: the noncentral chi-square inverted at
coverage params.conf_level, 0.9 by default as in psych::fa, and
rescaled by (N* − 1)·df; a bound psych’s search range cannot reach, or
one on the wrong side of the point estimate, is null) and bic — all
but the chi-square null when df ≤ 0. They
need a positive-definite correlation (a singular one nulls the ML fit
with PULSE_MATRIX_SINGULAR) and an inference size above 1 + (2p +
5)/6 + 2k/3 (below it they are null with PULSE_MATRIX_INSUFFICIENT_N).
A count the members cannot identify — (p − k)² < p + k, so one factor
needs 3 members, two need 5 and three need 6 — returns null loadings
and fit figures with PULSE_MATRIX_NOT_IDENTIFIED (KMO and Bartlett
still compute). A member whose uniqueness falls to 0 or below (a
Heywood case; under "ml", held at the 0.005 floor) is reported as
fitted with PULSE_MATRIX_HEYWOOD, and an
extraction or rotation that stops at its iteration cap warns
PULSE_MATRIX_NOT_CONVERGED. Like the partial correlation, a non-PSD
pairwise input is refused unless params.repair is "nearest".
Weighted under frequency and probability weights. KMO is read against
Kaiser’s (1974) bands; the loadings, rmsr, tli and rmsea are
unbanded.
Collinearity
MAT_COLLINEARITY checks candidate regression predictors before a
model is fitted. Its members are the predictors only — there is no
response, so the figures are the same whatever outcome is later
modelled. vectors.vif is each predictor’s variance inflation factor,
the diagonal of the inverse correlation matrix (car::vif on an lm
over the same predictors), and vectors.tolerance its reciprocal; the
primary is the predictors’ correlation matrix and scalars.max_vif
the largest VIF.
Belsley’s diagnostics (perturb::colldiag) ride beside them:
vectors.condition_indices (ascending, the first exactly 1),
scalars.condition_number (the largest) and
auxiliary.variance_decomposition, a rectangular matrix with a row
per variable and a column per dimension D1 … Dq in condition-index
order — each row is that variable’s coefficient variance split across
the dimensions, summing to 1. By default they are computed on the
scaled, uncentered predictors with an intercept column (Belsley’s
recommendation, which also shows collinearity with the intercept; the
first row is (intercept)), rebuilt from the slot’s co-moments as
W(Σ + μμᵀ), so the operator stays streamable and mergeable.
params.center: true computes them on the centered predictors (their
correlation) instead.
An exactly collinear set has no inverse and is refused with
PULSE_MATRIX_SINGULAR, whose dependent_fields name the members in
the dependency. Like the partial correlation, a non-PSD pairwise input
is refused unless params.repair is "nearest". Weighted under
frequency and probability weights (the figures are scale-free, so the
two kinds agree). Neither VIF nor the condition index is banded: 5 or 10
for VIF and 30 for the condition index are rules of thumb only
(O’Brien 2007).
Groups, cost and refusals
With groups, a matrix is computed per bucket, in Response.Data order
(sort included). Pulse.Predict reports matrices[]: shape,
accumulator_bytes, streamable, mergeable, row_buffer_bytes (a
rank method), pairwise_psd_risk, and for a grouped request
bucket_basis, estimated_buckets, estimated_cells and
estimated_bytes (an upper bound; omitted when the bucket count depends
on the data). estimated_bytes is the run’s co-moment state: merge
blocks (ceil(records / 4096), counted per shard for an archive) ×
buckets × accumulator_bytes, plus a rank method’s row buffer. Matrix dimension is capped by
Options.Limits.MaxMatrixDim, and the opt-in
Options.Limits.MaxEstimatedMemory counts estimated_bytes in the
memory estimate it checks before any record is read.
Refused identically by predict and the run: matrices with joins or on a
chain stage after the first (PULSE_MATRIX_UNSUPPORTED_SOURCE), and with
crosstab (PULSE_MATRIX_HOST_CONFLICT).
Determinism
Serial, parallel-decode and sharded runs return bit-identical matrices
for a cohort file; a multi-shard archive matches its single-file twin
within rounding. A correlation cell agrees with the corresponding
TEST_PEARSON_R within 1e-12 + 1e-12 * |r|.
Contract: .claude/reference/matrix-and-vectors.md; payload shapes:
Payload Schema; agent guide: the
matrix-results skill; kernels: Linear Algebra.
Header Layout
Audience: anyone reading or writing .pulse files by hand (forensics,
custom readers, debugging a truncated file). The Go library handles all of
this for you; this page documents the wire format.
The header is fixed-size: 9 bytes, consisting of an 8-byte magic identifier and a 1-byte format version.
LLM agents using MCP: see the
cohort-schema-designskill viapulse_skills_get. It speaks in field-type semantics rather than byte layout; this page covers the bytes.
Constants
These live in encoding/header.go:
| Name | Value | Purpose |
|---|---|---|
MagicBytes | []byte{'P','U','L','S','E', 0x00, 0x00, 0x00} | 8-byte identifier; rejects non-Pulse files |
FormatVersionV1 | 0x01 | Original layout — every file written before 0x02 existed |
FormatVersionV2 | 0x02 | Adds a length-prefixed schema extension block carrying parent groups (see Schema Block) |
FormatVersion | 0x01 | Baseline: what writers emit for a schema that uses no 0x02 feature |
MaxFormatVersion | 0x02 | Newest version this binary reads and writes |
HeaderSize | 9 | Total header byte count |
Byte layout
Offset Length Field
------ ------ -----
0 8 Magic: "PULSE\0\0\0"
8 1 Format version: 0x01 or 0x02
9 — Schema block begins here
That’s the entire fixed header. The schema block immediately follows; see Schema Block.
Version semantics
The format version is single-byte. The accepted set is
{0x01, 0x02}. encoding.ReadHeader returns the version it read, and
the caller must hand it to encoding.ReadSchema — the schema block’s
layout depends on it, and records begin immediately after the schema
block with no terminator (internal/encoding.ReadPreamble does both in one call).
Any other version byte is rejected with ENCODING_INVALID:
ENCODING_INVALID: unsupported pulse format version: ...
{"version": <byte>, "supported_versions": [1, 2]}
This is the fail-loud guard against silently mis-decoding a file written
by a newer binary. A forward-incompatible change bumps the version; the
older reader stops at header parse instead of producing wrong rows. A
binary that predates 0x02 refuses every 0x02 file this way.
Old cohorts stay readable forever. A 0x01 file parses exactly as it
always has, and a checked-in 0x01 golden (encoding/testdata/format_v1.pulse)
is read by every build in CI.
The version written is a function of schema content, never a global
flag (Schema.RequiredFormatVersion, internal/encoding.WritePreamble). A schema
that uses no 0x02 feature is written at 0x01, byte-identical to
every file written before 0x02 existed. Only a schema that declares a
parent group is written at 0x02.
The envelope format_version ("1.1") that all CLI --json output
carries is unrelated — it tracks the JSON output schema, not the binary
file format.
Hexdump sanity check
A freshly-written .pulse file starts with:
00000000 50 55 4c 53 45 00 00 00 01 .. .. .. .. ..
|P U L S E \0 \0 \0|ver| schema starts here (ver 01 or 02)
If file path/to/data.pulse reports “data” (rather than something
plausible) and the first nine bytes don’t match the above, the file is
either truncated or corrupted — see
Troubleshooting.
What comes next
The schema block follows the header. Read it as documented in Schema Block; it carries per-field descriptors, inline categorical dictionaries, and decimal/H3 metadata. After the schema, fixed-width records start — see Record Layout.
Field Types
Audience: anyone designing a cohort schema, decoding a .pulse
file by hand, or trying to understand which type to pick for a column.
Every field type has a fixed type byte, a fixed (or bit-packed) byte
size, and well-defined semantics. The registry is
encoding/field_type.go
— the FieldType const block gives the byte values (iota order),
ByteSize() the widths and String() the names below. For the live
list at runtime, read cohort_types out of pulse manifest --json
rather than counting rows in this page.
LLM agents using MCP: see the
cohort-schema-designskill viapulse_skills_getfor the same matrix plus “which type to pick”, andtype-<name>(e.g.type-set-u256) for one type in depth.
Nullability is orthogonal to type
There are no nullable_* types. Any field of any type may be
marked nullable (encoding.Field.Nullable), which sets the schema’s
per-field nullable flag byte and enrols the field in the per-record
null bitmap described in Record Layout.
Nullability never changes a field’s type byte, its width, or the
operator the engine infers for it.
The catalog
Type bytes are the FieldType iota order. Note that it is not
grouped by family — datetime (17) and the two wide set rungs (18, 19)
were appended after the original block, and appending is the only way
to add a type without invalidating every existing file.
| Type | Byte | ByteSize | Notes |
|---|---|---|---|
u8 | 0 | 1 | Unsigned 8-bit integer |
u16 | 1 | 2 | Unsigned 16-bit integer |
u32 | 2 | 4 | Unsigned 32-bit integer |
u64 | 3 | 8 | Unsigned 64-bit integer |
f32 | 4 | 4 | 32-bit IEEE 754 float |
f64 | 5 | 8 | 64-bit IEEE 754 float |
u4 | 6 | 0 | Bit-packed 4-bit unsigned (0–15); shares a byte with a neighbour |
date | 7 | 4 | Epoch DAYS as signed int32 |
packed_bool | 8 | 0 | Bit-packed boolean; up to 8 share one byte |
categorical_u8 | 9 | 1 | Dictionary-backed single value; ≤ 256 entries |
categorical_u16 | 10 | 2 | ≤ 65,536 entries |
categorical_u32 | 11 | 4 | ≤ 4,294,967,295 entries |
decimal128 | 12 | 16 | Fixed-point exact decimal; per-field (precision, scale), both ≤ 38 |
set_u8 | 13 | 1 | Multi-select bitmask over an inline dictionary of ≤ 8 labels |
set_u16 | 14 | 2 | ≤ 16 labels |
set_u32 | 15 | 4 | ≤ 32 labels |
set_u64 | 16 | 8 | ≤ 64 labels |
datetime | 17 | 8 | Epoch SECONDS as signed int64 |
set_u128 | 18 | 16 | ≤ 128 labels; two little-endian 64-bit words, words[0] = bits 0–63 |
set_u256 | 19 | 32 | ≤ 256 labels; four little-endian words. Widest set rung — a hard ceiling |
Type families
Plain integers and floats
u8, u16, u32, u64, f32, f64. Standard little-endian
encoding, full range, no in-band null sentinel. Mark the field nullable
if the column carries missing values.
Bit-packed types
u4 (a 4-bit unsigned) and packed_bool (one bit) both report
ByteSize() == 0 and share bytes with adjacent packed fields; the
schema’s BitPosition locates them. ByteSize() == 0 is the reader’s
signal that a field shares bytes — a non-zero ByteSize field never
does. See Record Layout → Bit-packing.
Temporal types
date is a signed (two’s-complement int32) count of days since the
Unix epoch. datetime is a signed (two’s-complement int64) count of
seconds since the same epoch. Pre-1970 values are negative and
supported end to end; encoding.DateDays / encoding.DateTimeSeconds
interpret a raw ReadFieldValue word.
They are never interchangeable: swapping them rescales every value by
86,400. Everything downstream of the operator boundary speaks epoch
days only, so a datetime column is day-truncated exactly once there
(encoding.DateTimeToDay, toward the past, naive UTC).
Categoricals
categorical_u8, categorical_u16, categorical_u32 store a single
dictionary ID per row and carry the string-to-ID mapping inline as a
dictionary block immediately after the field’s schema entry. Pick the
smallest variant that fits your cardinality (import inference does this
for you). See Dictionary Blocks.
Sets (multi-select bitmasks)
set_u8, set_u16, set_u32, set_u64, set_u128, set_u256 carry
the same inline dictionary block as a categorical, but the on-wire
payload is a fixed-width bitmask: bit i set means dictionary entry
i is selected. One row can select any number of labels, which is what
makes these the landing type for an SPSS multiple-dichotomy battery.
- Inference picks the smallest rung that fits the option count; a column with more than 256 options has no set type at all and stays as its constituent columns.
- An empty mask is a valid value meaning “answered, selected nothing” — it is NOT null, and the two must stay distinct because “ticked none of these” and “skipped the question” give different denominators.
- The wide rungs (
set_u128,set_u256) exceed theuint64value API:ReadFieldValue/WriteFieldValuerefuse them withENCODING_TYPE_MISMATCHrather than truncating to the low 64 bits (FieldType.IsWideSet()). Wide masks travel asencoding.SetMask.
Decimal128
decimal128 is a 16-byte fixed-point decimal. Each field carries a
per-field (precision, scale) pair written into the schema after the
description; both top out at 38 (PULSE_DECIMAL_OVERFLOW,
PULSE_DECIMAL_PRECISION_LOSS).
Use it for currency and any other column where IEEE-754 rounding is
not acceptable. See the financial-cohorts skill for full semantics
including banker’s rounding and divide-by-zero policy. decimal128
nulls ride the bitmap only — there is no in-band sentinel.
Unknown type bytes
The schema reader rejects unknown FieldType bytes at parse time
with ENCODING_INVALID. This is the same fail-loud strategy as the
header version check: a file written by a future binary that
introduced a new type fails immediately at schema parse, not later
during row decode where the corruption could go unnoticed.
What you can do with each type
| Concern | Source |
|---|---|
| Which aggregators are meaningful on which types | skills/aggregation-design.md (LLM) / api process (CLI) |
| Per-type depth, one file per type | skills/type-<name>.md (LLM) |
| Decimal arithmetic semantics | skills/financial-cohorts.md (LLM) |
| Categorical and set dictionary limits | Dictionary Blocks |
| Multiple-response set import | import spss |
Schema Block
Audience: anyone decoding a .pulse file by hand or writing a
non-Go reader. The schema block follows the 9-byte
header and carries one descriptor per column.
From CLAUDE.md, byte-layout invariants for
.pulsefiles, plus the on-disk format documented inencoding/schema.go.
Top-level shape
u16 field_count
field_record × field_count
[format 0x02 only] u64 extension_length
[format 0x02 only] extension_length bytes of extension payload (tagged sections)
The layout depends on the header’s format version:
encoding.ReadSchema(r, version) takes the version ReadHeader
returned. A 0x01 block ends at the last field record. A 0x02 block
appends a length-prefixed schema extension block, so the start of the
record region is always derivable without understanding the payload. The
payload is a list of tagged sections; the only one defined is GROUPS,
the parent-group descriptors and their dictionaries.
A schema is written at 0x02 exactly when it declares a group. An
unknown REQUIRED section is refused with ENCODING_INVALID rather than
skipped, because its bytes change how records decode.
Each field_record is variable-width (it includes UTF-8 name and
description strings, and may include an inline dictionary or decimal
(precision, scale) metadata). The reader walks them sequentially.
Per-field record
In write order — see WriteSchema / ReadSchema in
encoding/schema.go:
| # | Field | Size | Encoding |
|---|---|---|---|
| 1 | type | 1 byte | FieldType byte (see Field Types) |
| 2 | nullable | 1 byte | 1 = the field participates in the per-record null bitmap, 0 = it does not. Immediately after the type byte |
| 3 | name_length | 2 bytes | u16 little-endian |
| 4 | name | name_length bytes | UTF-8 |
| 5 | byte_offset | 4 bytes | u32 LE — offset within a record |
| 6 | bit_position | 1 byte | u8 — slot within the field’s byte (bit-packed types only; 0 otherwise) |
| 7 | csv_column_idx | 2 bytes | u16 LE — source column index at import time |
| 8 | description | 2 bytes length + UTF-8 | Capped at 1000 bytes (PULSE_IMPORT_DESCRIPTION_TOO_LONG) |
| 9 | (decimal only) precision | 1 byte | decimal128 only |
| 10 | (decimal only) scale | 1 byte | same |
| 11 | (dictionary types only) dictionary | variable | categorical_* and set_*. See Dictionary Blocks |
Order matters: every reader walks these in the listed order, so a
malformed record stops the parse with ENCODING_INVALID.
The nullable flag is not a type variant. There are no nullable_*
field types — any type may carry the flag, and the flag alone decides
whether the schema has a null bitmap at all
(Record Layout → The null bitmap).
Byte offsets and bit positions
byte_offset is the offset of this field’s first byte within a
record. For the two bit-packed types (u4, packed_bool),
byte_offset plus bit_position together locate the field’s bits
within its own byte — a bit-packed field still consumes one whole byte
of the record stride.
For every other type, bit_position is always 0.
byte_offset is a reader convenience, not the authority: record stride
is derived from the type bytes alone (Schema.RecordByteSize).
Record layout mechanics — the bit-packing rule, record-size computation and the trailing null bitmap — are in Record Layout.
Conditional trailers
Two trailers attach only to specific field types:
decimal128gets a(precision, scale)pair (u8,u8). Both ≤ 38.- Dictionary-bearing types (
FieldType.HasDictionary()— the threecategorical_*and all sixset_*rungs) get a full dictionary block inline; see Dictionary Blocks. A dictionary-bearing field with no dictionary still writes an empty one (au32zero count), so the trailer’s presence is decided by the TYPE, never by whether values were seen.
A field with none of the above writes nothing after the description.
Field descriptions
The description string is UTF-8 with a 2-byte length prefix. The
import path rejects descriptions longer than 1000 bytes
(PULSE_IMPORT_DESCRIPTION_TOO_LONG) and warns on low-quality
descriptions (empty, under 10 characters, or generic words like
"n/a", "tbd", "unknown", "field", "data", "value",
"column") — that warning is PULSE_FIELD_DESCRIPTION_LOW_QUALITY,
upgraded to an error under --strict.
When the description is empty, pulse cohort inspect synthesises a
fallback string (Categorical field: <name> or Numeric field: <name>) with description_source = "synthesized". The original
bytes on disk remain empty.
Reader behaviour
encoding.ReadSchema is intentionally strict:
- Field count limit comes from the u16 prefix (max 65,535 fields).
- Unknown type bytes fail loud (
ENCODING_INVALID), under every format version. - An unsupported version, a truncated extension block, an unknown
required section, or a malformed group descriptor fail loud
(
ENCODING_INVALID). - Truncated records fail loud at the first short read.
- The reader produces a
*encoding.Schemawith oneencoding.Fieldper record;Schema.Field(name)looks fields up by name.
After the schema block (including a 0x02 extension block), record
data starts at the file’s first byte past the schema. The record layout is documented in
Record Layout.
Dictionary Blocks
Audience: anyone decoding categorical or set fields, sizing one of those types during import, or chasing a dictionary-overflow error.
Dictionary-bearing fields — the three categorical_* types and all six
set_* rungs (FieldType.HasDictionary()) — store their string-to-ID
mapping inline, immediately after the field’s schema entry. The
dictionary is part of the schema block, not the record data, and the
BLOCK is byte-identical for both families; only what the record stores
against it differs.
LLM agents using MCP: the
cohort-schema-designskill covers when to pick which categorical width; thetool-importskill covers fail-closed semantics on overflow.
On-disk layout
From encoding/dictionary.go:
u32 count
(u16 strlen + utf8 bytes) × count
Sizes are little-endian. Each entry’s ID is its insertion index
(0..count-1); ID lookups during decode use the ID found in the
record byte(s) and resolve to the string at that index.
Sizing the type
| Type | Max entries | Bytes per record value |
|---|---|---|
categorical_u8 | 256 | 1 |
categorical_u16 | 65,536 | 2 |
categorical_u32 | 4,294,967,295 | 4 |
The import path samples the source (--sample-rows, default 500) to
estimate cardinality and picks the smallest width that fits. You can
also force a width by editing the schema template (pulse import schema-template SOURCE).
Set fields share the block, not the capacity
A categorical_* record value is one dictionary ID; a set_*
record value is a fixed-width bitmask where bit i means entry i
is selected. So a set’s dictionary capacity is its mask width, not an
ID range (FieldType.MaxSetEntries()):
| Type | Max entries | Bytes per record value |
|---|---|---|
set_u8 | 8 | 1 |
set_u16 | 16 | 2 |
set_u32 | 32 | 4 |
set_u64 | 64 | 8 |
set_u128 | 128 | 16 |
set_u256 | 256 | 32 |
set_u256 is a hard ceiling — there is deliberately no wider rung, so
a 257-option column has no set type and stays as its constituent
columns. Overflow on import is PULSE_IMPORT_SET_OVERFLOW (not the
categorical code below). Inference picks the smallest rung that fits,
so a dictionary can be narrower than its rung’s capacity — a 206-option
set_u256 wastes 50 bit slots, accepted knowingly over a
variable-width set type.
Overflow and unbounded errors
AddWithLimit enforces the per-type cap and returns
PULSE_IMPORT_CATEGORICAL_OVERFLOW when the source has more distinct
values than the dictionary can hold:
{
"code": "PULSE_IMPORT_CATEGORICAL_OVERFLOW",
"message": "categorical dictionary overflow: max 256 entries",
"details": {"max_entries": 256, "value": "the_257th_distinct_string"}
}
The companion code PULSE_IMPORT_CATEGORICAL_UNBOUNDED fires when the
import path detects an effectively unbounded categorical column (the
schema declared categorical_u32 and the column still grew past the
caller-provided guardrails). Both errors halt the import — fail-closed,
no partial output.
Recovery options, in order of preference:
- Re-import with a wider categorical type
(
categorical_u8→categorical_u16→categorical_u32). - Drop the categorical encoding (treat the column as a plain string field — but Pulse has no native variable-string type; you’d add a pre-import transform to bucket values).
- Pre-filter the source to a smaller distinct set and re-import.
Inspect behaviour
pulse cohort inspect --json reports each categorical field’s
dictionary entry count and sample values. By default the inline list
is capped at 100 entries (DefaultDictionaryLimit); pass --full-dict
to print the full dictionary:
pulse cohort inspect data.pulse --full-dict --json
Both forms include a truncated: true|false flag and a total_entries
count for programmatic consumers.
Performance notes
Dictionary reads are amortised: the reader allocates one shared byte
buffer for all string payloads, then does one string(...) copy per
entry. This avoids the “one allocation per entry” overhead that
naively reading length-prefixed strings would produce. The dictionary
itself is held in memory for the life of the cohort’s schema parse.
For very large dictionaries, the categorical_u32 path is still O(N)
to deserialise; if you find yourself near the 32-bit cap, you almost
certainly want a different model (a separate lookup table, or a
plain integer column with the strings stored externally).
Record Layout
Audience: anyone hand-decoding row data or implementing a non-Go reader. The schema block ends; record data starts immediately after.
Records are fixed-width. Every row in a cohort occupies the same number of bytes, computed from the schema’s field types. Variable-width data (strings) lives in the schema (as categorical dictionaries) or is not directly supported.
LLM agents using MCP: the record byte layout is an implementation detail the MCP surface hides — there is no LLM-facing skill for it. The MCP tools operate on the inspect / process / sample abstractions.
Computing record size
Record size (Schema.RecordByteSize) is the sum of
FieldType.ByteSize() over all schema fields, plus one byte for
each bit-packed field, plus the null bitmap when the schema
declares any nullable field. For non-packed types ByteSize() returns
the obvious value (u32 = 4, f64 = 8, decimal128 = 16,
set_u256 = 32); the bit-packed types (u4, packed_bool) return
0 and are counted as one byte each.
A cohort with parent groups (format 0x02) stores
member fields in the schema block instead: its record size counts a
4-byte index per indexed group, the non-member fields, and a bitmap
narrowed to the non-member fields. It is still fixed and still derived
from the schema alone.
Stride is a pure function of the type bytes. The per-field
ByteOffset stored in the schema is a convenience for readers, not
the source of truth — a type byte with the wrong width silently
corrupts every downstream offset.
The writer (encoding/record.go) lays out fields in the order they
appear in the schema; the reader walks the same order.
Encoding per type
From WriteFieldValue / ReadFieldValue in
encoding/record.go:
| Type family | Encoding |
|---|---|
u8 / categorical_u8 / set_u8 | 1 byte, unsigned |
u16 / categorical_u16 / set_u16 | 2 bytes, little-endian unsigned |
u32 / categorical_u32 / set_u32 | 4 bytes, little-endian unsigned |
date | 4 bytes, little-endian two’s-complement signed int32 (epoch days) |
u64 / set_u64 | 8 bytes, little-endian unsigned |
datetime | 8 bytes, little-endian two’s-complement signed int64 (epoch seconds) |
f32 | 4 bytes, little-endian IEEE 754 |
f64 | 8 bytes, little-endian IEEE 754 |
decimal128 | 16 bytes, little-endian two’s-complement integer (scaled by 10^scale). Exceeds the uint64 API — use the 16-byte accessors |
set_u128 / set_u256 | 16 / 32 bytes, little-endian 64-bit words, words[0] = bits 0–63. Exceeds the uint64 API — Read/WriteFieldValue return ENCODING_TYPE_MISMATCH rather than truncating; use encoding.SetMaskFromBytes / PutSetMask |
u4 / packed_bool | Bit-packed — see below |
A set_* payload is a membership bitmask, not a number: bit i set
means dictionary entry i is selected, and an all-zero mask is a valid
“selected nothing” value distinct from null.
Bit-packing
The two bit-packed types carry fewer than 8 bits of payload. The
schema records both ByteOffset and BitPosition (which slot within
that byte the value occupies).
packed_bool— 1 bit. Value is(b >> BitPosition) & 1.u4— 4 bits.BitPosition > 0selects the HIGH nibble (b >> 4);BitPosition == 0selects the low nibble (b & 0x0F).
Each bit-packed field still occupies one whole byte of the record
stride. RecordByteSize adds one byte per bit-packed field and the
decoder advances its cursor by one byte for each — the bits are packed
within a field’s own byte, not across fields. ByteSize() == 0 is
the reader’s signal to use the bit-level API (ReadBit / ReadNibble)
rather than ReadFieldValue, not a signal that the field is free.
The null bitmap
There are no nullable_* types and no in-band null sentinels.
Nullability is orthogonal to type: any field may set its schema
nullable flag byte, and nulls then ride a per-record bitmap.
The bitmap is present iff the schema has at least one nullable
field (Schema.HasBitmap()). When present, every record carries a
trailing ceil(field_count / 8) bytes (Schema.BitmapByteSize()),
and those bytes are part of the stride.
- Field index
i→ bytei/8, biti%8, LSB-first. - Bit set (
1) means null. - The bitmap covers every field position, not only the nullable ones; bits for non-nullable fields are simply never set.
- Helpers:
encoding.ReadBitmap/WriteBitmap/BitmapIsNull/BitmapSetNull.
decimal128 and set_* nulls ride the bitmap only. For a set_*
field in particular the bitmap is the ONLY null signal — an all-zero
mask means “selected nothing”, which is data, not absence.
Reading a record
The Go decoder is internal (internal/encoding, RecordReader); an
embedder decodes a single-file ungrouped record with the public
encoding.ReadFieldValue over the schema geometry
(Schema.RecordByteSize, each field’s byte offset and bit position).
A non-Go reader can follow the same recipe:
- Compute record size from the schema (field widths + one byte per bit-packed field + the bitmap, if any).
- Read
record_sizebytes. - Walk the schema fields in declaration order, advancing a cursor:
- If
ByteSize() > 0, decodeByteSize()bytes at the cursor. - If
ByteSize() == 0(u4,packed_bool), consume ONE byte and decode the bit slot atBitPositionwithin it.
- If
- If the schema has a bitmap, the last
BitmapByteSize()bytes of the record are it; mark fieldinull when biti%8of bytei/8is set.
Forward compatibility
Records carry no type tag — they’re a packed binary blob whose interpretation comes entirely from the schema block. That’s why the file’s format version (in the header) and unknown field-type bytes (in the schema block) both fail loud at parse time: the records themselves cannot self-correct, so the format gates everything before record data is observed.
Parent Groups (format 0x02)
Audience: anyone decoding a grouped .pulse file by hand, writing a
non-Go reader, or building on the group descriptor. Source of truth:
encoding/group.go,
group_wire.go,
group_stream.go.
A denormalised join repeats its parent block on every child row. A
parent group stores each distinct tuple of its member fields ONCE,
in a dictionary inside the schema block, and every row carries a
fixed-width u32 index into it. A cohort may declare N independent
groups; groups never reference each other. A constant group holds
exactly one entry and no per-row index — how a column with one value
across the whole cohort is stored once. A cohort with at least one
group is written at format 0x02; one without is 0x01,
byte-identical to every file written before groups existed.
Logical vs physical
| Term | Meaning |
|---|---|
| Logical schema | Schema.Fields — every field, original order. Field indices, records, operators, dictionaries and inspect all use it. |
| Logical row | The 0x01 row of the logical schema: each field’s on-wire bytes in order (bit-packed = one whole byte), then a ceil(field_count/8) null bitmap when any field is nullable. |
| Physical row | What a grouped cohort stores: one u32 index per indexed group (group order), then the row fields (fields in no group) in logical order, then a narrowed null bitmap over the row fields only. |
| Entry | One dictionary tuple: each member’s logical on-wire bytes in member order, then a ceil(member_count/8) member null bitmap when any member is nullable. |
Schema.RecordByteSize(), BitmapByteSize() and HasBitmap() describe
the PHYSICAL row, so stride is still fixed and derivable from the schema
alone, and RecordCountForPayload, the record locator, parallel
segment splitting and lookup offsets are unchanged in form.
Schema.Logical() is the ungrouped view (same Fields slice).
A cohort whose every nullable field is a group member has no per-row
bitmap at all.
Schema extension block
After the 0x01 field descriptors:
u64 extension_length
u16 section_count
per section: u16 tag, u8 flags (bit0 REQUIRED), u64 length, bytes
An unknown REQUIRED section is refused (ENCODING_INVALID); an unknown
optional one is skipped. Reserved flag bits, a duplicated tag, or a
payload not consumed exactly are refused. Section tag 1 is GROUPS
(always REQUIRED):
u16 group_count (>= 1)
per group:
u8 kind 0 = indexed, 1 = constant
u8 index_width 4 for indexed, 0 for constant
u8 flags reserved, 0
u16 member_count (>= 1)
per member: u16 field_index (logical, strictly ascending), u8 flags (bit0 = key)
u32 entry_width must equal the members' widths (+ member bitmap)
u32 entry_count constant: exactly 1
entry_count x entry_width bytes
Entries hold raw encoded values, never strings: a categorical member’s
bytes are its own dictionary ID, and every member keeps its own inline
dictionary exactly as in the ungrouped cohort. A bit-packed member
(u4, packed_bool) stores its one whole on-wire byte verbatim — the
row never shares a byte between fields, so splitting a bit-packed run
across a group boundary needs no repacking.
Decode
A grouped cohort decodes to exactly what its ungrouped twin decodes to; there are two routes to that, chosen by the decoder:
- Reuse / buffered scans (the streaming and buffered
Processpaths, parallel decode, shard iteration) read the physical row as it is and write each field at its LOGICAL position — row fields from their bytes, group members from the group’s dictionary entry. A group whose index equals the index the record already holds is not touched at all, and a changed index copies the members from a per-entry cache of decoded values (derived at read time, never stored, bounded per reader). Per-row cost therefore tracks the row fields plus the groups that CHANGED, not the width of the parent block; a projection that retains no member of a group never decodes it. - Whole-row consumers (the map decoder, point lookup, export, the
SPSS writer, profile run-continuation) read the logical stream: each
physical row expanded into its logical row — row-field bytes copied,
each group’s entry spliced in by index, null bits merged from the
narrowed bitmap and the entries — and then the ordinary
0x01decoder runs over it.
An index past its dictionary is ENCODING_INVALID on both routes. A
physical stride of zero (every field constant-grouped) is refused at
write and read.
Global-constant elision
pulse import <format> --elide-constants (library:
io.ImportJob.ElideConstants, default off) folds every field that holds
one value on every imported row into a single constant group. Constancy
is decided over the FULL row pass (internal/encoding.ConstantDetector), never
the bounded inference sample — a column constant across the first 500
rows may vary later. Two cells are the same value iff their on-wire
bytes and null bits match: a column null on every row is a null
constant; a value plus some nulls is not constant. The plan
(internal/encoding.PlanConstantElision) elides nothing below two rows, nothing
when the schema-block growth would exceed the per-row saving (the file
is then byte-identical 0x01), and keeps the lowest-index field in the
row when every field is constant. The elided fields are reported in
ImportReport.ElidedConstants.
Declaring groups at import
pulse import <format> --group KEY[,KEY...]:MEMBER[,MEMBER...]
(repeatable; library: io.ImportJob.Groups []io.GroupDecl{Key, Members})
declares one indexed group per flag. The group’s members are the key
fields plus the fields they determine; the key fields carry the member
KEY flag in the descriptor. A declaration without a colon is a tuple
group whose every member is key.
- Key semantics. Non-key members must be functionally determined by
the key: two rows with the same key tuple must agree on every other
member, byte for byte and null bit for null bit. The encoder checks
every row and fails with
PULSE_GROUP_MEMBER_NOT_CONSTANT(details:group,group_label,field,row— the 0-based record — andsource_row— the 1-based source data row) rather than store a second entry for the same key. - Names. The format has no group-name slot. Errors and reports name a
group by its 1-based declaration position and its key
(
group 2 [key: prod_id]), or its members when it has no key. - When it is checked. Field names are resolved against the import’s
final schema — inferred, authoritative (
SchemaAwareReader: SPSS, Arrow, Parquet) or explicit alike — before the row pass:PULSE_GROUP_FIELD_UNKNOWN,PULSE_GROUP_FIELD_CONFLICT(detailsgroup_labelsnames both),PULSE_GROUP_DECLARATION_INVALID. - One pass. The source is read once. Each group’s dictionary is
built in a single pass over the imported rows: a row’s tuple is looked
up in the group’s dictionary, or appended.
u32exhaustion isPULSE_GROUP_ENTRIES_EXHAUSTED, never a wraparound. - Composition. Declared groups come first in the descriptor, in
declaration order; with
--elide-constantsthe constant group follows them, and declared members are never elided. - Report.
ImportReport.Groupslists each declared group’s label, fields, verdict,entry_count,entry_width,member_row_bytes,index_width,dictionary_bytes,ratio,break_even_ratio,ratio_floorandbyte_delta— see the viability gate below. - Zero groups write the
0x01cohort byte for byte.
Viability gate
A badly chosen group does not fail — it quietly makes the file bigger
(every row still pays the 4-byte index, and the dictionary holds one
entry per distinct tuple) and holds its whole dictionary in memory
whenever the cohort is open. Each declared group is therefore judged on
its own numbers (internal/encoding.DedupGate, shared by import and
retro-dedup); one group can be admitted while another on the same
import is not.
| Check | When | Outcome |
|---|---|---|
| Width floor: members’ bytes per row ≤ the 4-byte index | before the row pass | group dropped (members stay in the row), PULSE_GROUP_TOO_NARROW with both widths |
| Ratio floor: rows ÷ distinct tuples below the floor (default 2) | after the dictionary is built | group written, PULSE_DEDUP_LOW_RATIO |
| Grows the file: deduped bytes ≥ undeduped bytes, at any floor | after the dictionary is built | group written, PULSE_DEDUP_LOW_RATIO with grows_file: true |
Per group, deduped = rows × 4 + entry_count × entry_width + (13 + 3 × members) and undeduped = rows × member_row_bytes; byte_delta is
their difference (negative is a saving) and dictionary_bytes = entry_count × entry_width is the resident cost. The warning carries
those, the ratio, the floor and the break-even ratio
(entry_width ÷ (member_row_bytes − 4)).
Ratio alone never refuses a group: a deliberately deduped low-ratio
group is a legitimate choice. The floor is a judgement — at 2, each
stored tuple is shared by two rows on average and a wide parent block
at least halves; below it most of the parent data becomes resident for
a shrinking saving — and it is overridable with --dedup-ratio-floor
(ImportJob.DedupRatioFloor; a floor of 1 leaves only the grows-the-file
check). --strict (ImportJob.StrictDedup) turns either finding into
a fatal error carrying the same code, and nothing is written. Constant
groups from --elide-constants are not gated.
Set-field widening (pulse widen) and the byte-level shard categorical
rewrite do not accept grouped cohorts: they refuse with a coded error
rather than reinterpret physical bytes. Shard archives DO carry grouped
shards — see Shard archives below.
Finding and sizing groups before import
pulse import predict --suggest-groups (library:
io.ImportJob.SuggestGroups, result PredictReport.GroupCandidates)
detects candidate groups. It takes single-field keys and the fields each
one determines, nominates them over a bounded window of leading rows,
then confirms and measures them over every row. import predict --group
evaluates a declaration you already have, and
import predict --elide-constants reports the constant plan. Any of the
three makes predict convert every row through the import’s own
converter. Each group’s dictionary is built exactly as GroupEncoder
would build it and judged by DedupGate, so the figures, verdicts and
projected file sizes are the ones the import would report. A declaration
the import would refuse (PULSE_GROUP_MEMBER_NOT_CONSTANT, --strict)
fails predict the same way. Detection only suggests, and Run ignores
SuggestGroups. Bounds, cost and limits:
--suggest-groups.
Converting an existing cohort (retro-dedup)
pulse dedup COHORT --group KEY:MEMBER,... (library Pulse.Dedup,
MCP pulse_dedup) converts a cohort that is already on disk, so a
grouped cohort no longer needs the original source. It takes the same
declarations, --elide-constants, --dedup-ratio-floor and --strict
as import (elision is CLI + library only, not on pulse_dedup). The
byte work is io.DedupJob. The source is read through its LOGICAL
record stream, so a 0x02 cohort is regrouped from scratch: its
existing groups are replaced, not extended. The rows go through the same
GroupEncoder and are judged by the same DedupGate, so retro-dedup of
a flat cohort is byte-identical to importing its source with the same
flags. The physical rows are spooled to a temp file (memory stays
O(dictionaries)). The ratio floor runs after the pass and before
anything is written. The cohort is then assembled into a second temp
file beside the target, fsynced and renamed over it, so any failure or
refusal leaves the original byte-identical. --out PATH writes a new
file (which must not exist) instead of rewriting in place.
--suggest-groups runs the import-predict detector over the cohort’s
decoded records; alone it writes nothing. An in-place rewrite changes
the file’s length, so the point-lookup index and SPSS sidecar
self-invalidate. data.invalidated_sidecars names each one with its
rebuild command, and nothing is rebuilt. Shard archives, and an
anchored shard inside one, are refused with SERVICE_VALIDATION: an
archive-wide dedup would have to decide groups over every shard at once.
Dedup each source shard instead and pulse shard create the results.
Inspecting a grouped cohort
pulse cohort inspect (Pulse.InspectEnvelope, pulse_inspect)
reports what an import actually produced, header-only: a layout
object (pulse_format_version, physical_record_stride,
logical_record_stride) and one groups entry per group in file order
— label, kind (indexed / constant), key / members / fields
(the --group KEY:MEMBER shape), entry_count, entry_width,
dictionary_bytes (the resident dictionary), member_row_bytes,
index_width, ratio, break_even_ratio, ratio_floor, byte_delta,
grows_file and verdict. Each member field also carries a
group: {group, key, kind} marker; kind: "constant" is an elided
field with no per-row bytes.
The ratio needs no scan: the entry count is in the schema block and the
record count comes from the file length (RecordCountForPayload), so
ratio = record_count ÷ entry_count. The figures come from the same
internal/encoding.AssessGroup import uses, so they equal ImportReport.Groups
for the same file. verdict re-applies the gate at the DEFAULT floor
of 2 — a group imported under a different --dedup-ratio-floor can
read differently — and is a figure, never an envelope warning. The file
stores no group name or declaration ordinal, so a label numbers the
group by its position in the file (a group dropped as too narrow at
import shifts later labels down). A 0x01 cohort emits neither key.
A grouped shard archive reports its CANONICAL groups — the dictionaries
every shard’s indices address — over the archive-wide record count, so
ratio = total records ÷ canonical entries.
Shard archives
Every shard of an archive is decoded with the archive’s canonical
schema, so a grouped archive has exactly one parent-group layout (group
count, kinds, members, key flags) and every shard’s row indices address
the canonical group dictionaries. pulse shard create and
pulse shard add apply one merge rule:
- The archive’s layout wins. A grouped shard added to an ungrouped archive is stored as its ungrouped twin; a shard with other groups (or none) added to a grouped archive is re-encoded into the archive’s groups. Only the arriving shard is rewritten.
- Group dictionaries union-merge canonical-first, like categorical
dictionaries: stored shards are untouched and keep their indices; the
arriving shard’s rows are renumbered into the union. A union past the
u32 index space is
PULSE_SHARD_DICT_WIDTH_OVERFLOW. - A constant group (from
--elide-constants) that the arriving shard disagrees with is promoted to an indexed group across the whole archive — every shard is re-encoded and each row gains a 4-byte index. - A declared key that the arriving shard violates (the same key
with a different non-key member) is refused with
PULSE_GROUP_MEMBER_NOT_CONSTANT, exactly as import refuses it.
Every layout change emits the mandatory PULSE_SHARD_GROUPS_REWRITTEN
warning (details.reason: incoming_flattened, incoming_regrouped,
constant_promoted; details.archive_rewritten), and the rewrite is
atomic. A plain dictionary union is the ordinary cost of an add and is
not reported. pulse shard verify refuses a shard whose group layout
differs from the canonical one, checks the prefix rule on group
dictionaries, and reports group_index_headroom per group. To keep
appends cheap, import every period with the same --group flags and do
not elide a column that varies between periods.
Transfer Compression (.pulse.zst)
Audience: anyone moving cohorts between machines. Source of truth:
internal/io/transfer.go.
Compression in Pulse is transport-only. A cohort is compressed to
move it, and decompressed back to a byte-identical .pulse before it is
used. A compressed file is never opened as a cohort.
# sender
pulse export transfer --input cohort.pulse # writes cohort.pulse.zst
# receiver
pulse import transfer --input cohort.pulse.zst --output cohort.pulse
Library: Pulse.ExportTransfer(ctx, *pio.TransferExportJob) and
Pulse.ImportTransfer(ctx, *pio.TransferImportJob), each returning a
*pio.TransferReport.
Why not compress at rest
Every fast read path depends on the uncompressed bytes being on disk:
fixed-stride record offsets (record_at, point Lookup), stride-aligned
segments (parallel decode), the record count derived from file length
(Inspect, CountRecords), whole-file mmap during a scan, and shard
archive entries stored uncompressed (Method 0) so Archive.OpenAt can
return a byte range straight into the file. A compressed stream supports
none of these. So Pulse adds no third magic-byte variant, and archive
entries stay store-only.
The artifact
A transfer artifact is one standard zstd stream (RFC 8878, with the
frame content checksum on). Decompressed, it is the exact byte image of
the source. Anything a .pulse path can name can be sent this way: a
single-file cohort at format 0x01 or 0x02 (grouped, elided) or a
whole shard archive. Pulse does not wrap the stream in a container of
its own, so the stock zstd -d gives the same bytes, and
pulse import transfer accepts a .pulse compressed with the stock
zstd.
| Flag | Leaf | Meaning |
|---|---|---|
--input / -i | both | the source file |
--output / -o | export | the artifact; default <input>.zst; overwritten |
--output / -o | import | the cohort to write; must not exist |
--level | export | zstd level 1..22, default 3. The encoder maps levels onto four speed classes: 1-2 fastest, 3-5 default, 6-9 better, 10-22 best |
--overwrite | import | allow replacing an existing output cohort |
--json | both | the result as a descriptor.Envelope |
The report (data under --json) carries source, output, codec,
level (on export only), layout (single_file / shard_archive),
cohort_bytes, compressed_bytes, ratio and sha256. sha256 is
always the digest of the UNCOMPRESSED cohort, so the sender’s and the
receiver’s figures can be compared directly.
Guarantees
- Byte identity. Decompressing gives a file whose SHA-256 equals the
source’s. This is tested for
0x01,0x02grouped,0x02elided, and shard archives of each. - Atomic arrival.
import transferwrites to a temp file next to the output, fsyncs it, and renames it into place. This happens only after the whole stream has decoded, the checksum has matched and the first decoded bytes have proved to be a Pulse magic. A truncated or damaged artifact leaves nothing behind. - Bounded memory. Both directions stream. Peak heap is the codec window plus fixed buffers, and does not grow with the size of the cohort.
- Opt-in. Nothing else changes.
export <format>, the read path and the archive layout are untouched.
Refusals
| Code | When |
|---|---|
PULSE_COHORT_COMPRESSED | A transfer artifact was passed where a cohort goes: Open, Process, CountRecords, Inspect, Predict and every other read surface, every pulse shard leaf that takes an archive (verify, compact, remove, add, list, extract — all open it through internal/encoding.OpenArchive), and pulse_import, which refuses it even when it has been renamed to .pulse. The fix is to decompress it first. |
PULSE_TRANSFER_INVALID | details.reason is one of: level (outside 1..22); not_a_cohort (the export source, or the decompressed bytes, are not a Pulse cohort); already_compressed; not_zstd (the import source is not a zstd stream — an uncompressed .pulse needs no import); corrupt_stream (truncated or damaged in transit); output_exists. |
Compression and parent groups do overlapping work
Parent groups (0x02) and zstd both remove the same redundancy: a parent
block repeated on every child row. Once a cohort is grouped, zstd has
much less left to find. Figures from the synthetic join-shaped bench,
go test ./internal/io/ -run '^$' -bench BenchmarkTransfer, at 240K rows, fanout
12, a 12-field parent block, and random-valued child columns:
| Shape | Cohort | Artifact | Ratio |
|---|---|---|---|
| flat, sorted by parent | 9.16 MB | 3.53 MB | 2.60x |
| flat, scattered | 9.39 MB | 5.59 MB | 1.68x |
| grouped, sorted | 5.02 MB | 3.38 MB | 1.48x |
| grouped, scattered | 5.04 MB | 3.48 MB | 1.45x |
- Sorted: a flat cohort sent as
.zstcomes within about 5% of a grouped one sent as.zst. For transfer alone, grouping adds little. - Scattered: grouping still saves about 38% over the wire, because it removes repeats that sit too far apart for zstd to exploit cheaply.
- Grouping also pays off at rest. It speeds up scans and lowers memory, which compression never does.
The two choices are independent, and it is fine to use both.
MCP Integration
Audience: operators wiring Pulse into an MCP-aware AI client (Claude Desktop, Claude Code, Cursor, Zed, custom hosts), and embedders who want to expose Pulse to an LLM agent.
This page is the human-facing guide: what the server does, how to wire it up, what the LLM sees, and how to debug a misbehaving session. Agent-facing guidance ships inside the binary as the session-bootstrap skill — fetch it via pulse_skills_get (or pulse skills show session-bootstrap).
What pulse mcp is
pulse mcp runs the Pulse library as a Model Context Protocol (MCP) server. The host (Claude Desktop, Claude Code, etc.) launches it as a subprocess, speaks JSON-RPC over its stdio streams, and shuts it down on session close. The LLM sees Pulse as a set of tools (callable functions), resources (browseable URIs), and prompts (canned slash commands).
┌─────────────┐ stdio JSON-RPC ┌────────────┐ Go calls ┌─────────────┐
│ AI client │ ───────────────→ │ pulse mcp │ ─────────→ │ pulse.Pulse │
│ (host) │ ←─────────────── │ (this bin) │ ←───────── │ (library) │
└─────────────┘ └────────────┘ └─────────────┘
│
└── stderr ─→ host log pane
The server is a thin translator. Every tool wraps a public method on pulse.Pulse; the same code path powers the CLI.
Quickstart
# 1. Build and place on PATH
make build && cp ./bin/pulse /usr/local/bin/
# 2. Pick a data directory
mkdir -p /var/data/pulse
# 3. Wire into your host (see below) and restart it
# 4. From the LLM session, call:
# pulse_manifest → cache once
# pulse_inspect → open a cohort
# pulse_predict → validate a request
# pulse_process → execute
Wiring into a host
Claude Desktop
Edit ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows):
{
"mcpServers": {
"pulse": {
"command": "/usr/local/bin/pulse",
"args": ["mcp"],
"env": {
"PULSE_DATA_DIR": "/var/data/pulse"
}
}
}
}
Restart Claude Desktop. Pulse tools appear in the tool picker.
Claude Code
claude mcp add pulse --env PULSE_DATA_DIR=/var/data/pulse -- pulse mcp
Or by hand in ~/.claude.json (or per-project .claude.json):
{
"mcpServers": {
"pulse": {
"command": "/usr/local/bin/pulse",
"args": ["mcp"],
"env": { "PULSE_DATA_DIR": "/var/data/pulse" }
}
}
}
Cursor / Zed / generic stdio hosts
Any host that speaks the MCP stdio transport can launch pulse mcp the same way — provide the binary path, the mcp argument, and the PULSE_DATA_DIR env var.
What the LLM sees
Tool surface
The core tools below are registered at server start (the manifest’s mcp_tools is the authoritative list for an instance). Names and order match internal/mcp/toolmeta/meta.go.
| Tool | Purpose |
|---|---|
pulse_manifest | Call first. Self-description: commands, operators (with accepted types + streamability), tier-1/tier-2 tests, regressions, synth distributions, error code list, MCP tool list, cohort field types with operator cross-references. Cache once per session. |
pulse_inspect | Read .pulse header + schema (no record bytes). Carries an additive warnings array beside the result — a payload length that is not a whole multiple of the record stride floors record_count and says so there. Side effect: registers session-scoped schema-bound tool variants (see below). |
pulse_predict | Validate a request against the schema without executing. Returns errors, warnings, applied defaults, streamability reasons. |
pulse_process | Execute one pre-built request. |
pulse_compose | Execute a batch of requests against the same cohort in one round trip. |
pulse_sample | Return up to N rows for preview / diagnostics. |
pulse_facet | Distinct values for a single field. |
pulse_import | Convert a tabular source (csv, tsv, ndjson, jsonarray, parquet, arrow, excel) into a managed .pulse handle under internal/imports/, with TTL-tracked sidecar. Pulse-format inputs pass through. |
pulse_dedup | Deduplicate an existing single-file cohort into parent groups (format 0x02), in place or to out; suggest_groups alone is read-only candidate detection. |
pulse_materialize | Write a request’s derived columns (features, attributes, windows) as ordinary fields of a NEW cohort at out — {request, out, overwrite}, the request nested — so a later request can regress, group, filter or join on them. Returns target, records, derived_columns, warnings. |
pulse_recommend | Turn a question kind (an intent ID) into ranked draft requests: <placeholder> skeletons without cohort, drafts bound to the cohort’s fields and predict-validated with it. Non-analytic intents return routes_to instead. |
pulse_explain | Say in plain words what a request will do (never run; predict-checked when it names a cohort) or what a result found: findings with closed verdicts read at the result’s own alpha, strength bands with their convention, and caveats. Pass a result exactly as returned, with its request beside it. |
pulse_drop | Delete a managed-import handle and its sidecar. |
pulse_imports_list | Enumerate managed handles with sidecar metadata (source, format, imported_at, expires_at, ttl, expired flag, pinned flag). |
pulse_examples_search | Search the embedded request-example library by query, taxonomy tags (ANDed), or category. |
pulse_examples_get | Fetch one runnable example body by name. |
pulse_errors_lookup | Per-code Message + Fixup detail (kept out of the manifest for context economy). |
pulse_skills_list | Embedded skill metadata; the optional intent filter returns that intent’s skills. |
pulse_skills_get | Fetch one skill body by name. |
Resources
| URI scheme | Yields |
|---|---|
pulse://<path> | One resource per .pulse file under the data directory. Read returns descriptor.InspectResult JSON (header + schema only — no record bytes). The per-file entries are produced by a startup scan the operator can disable (pulse mcp --no-cohort-scan); a read by URI resolves either way. |
pulse-skill://<name> | One per skill — every embedded skill plus the two rendered from Go registries, glossary and intents. Read returns the markdown body. |
Resources are registered once at server start. Files added afterwards do not appear until the server restarts. Listing is cheap because the server only reads header bytes.
Prompts
| Name | Args | Returns |
|---|---|---|
pulse-bootstrap | none | A short instructions block telling the assistant what to call (and in what order) before authoring any request, and where the authoritative references live. Inject at session start. |
pulse-author-request | question | Classifies the question against the intents (label + example phrasings) and hands it to the matching mounted pulse-<intent> prompt, or — for prepare and lookup — straight to the mounted route tool (pulse_import / pulse_dedup, pulse_lookup; simulate has no MCP tool). It lists only what this server mounts. Otherwise a guided tool-call sequence: manifest → examples search → inspect → predict → process. |
pulse-describe | cohort, measure | The guided intent workflow for describe (below). |
pulse-compare-groups | cohort, outcome, group | The guided intent workflow for compare_groups (below). |
pulse-relationship | cohort, measures, categories | The guided intent workflow for relationship (below). |
pulse-drivers | cohort, outcome, predictors | The guided intent workflow for drivers (below). |
pulse-change-over-time | cohort, time, measure | The guided intent workflow for change_over_time (below). |
pulse-composition | cohort, category, by | The guided intent workflow for composition (below). |
pulse-benchmark | cohort, measure, group | The guided intent workflow for benchmark (below). |
pulse-distribution-shape | cohort, measure | The guided intent workflow for distribution_shape (below). |
pulse-segment | cohort, measures | The guided intent workflow for segment (below). |
pulse-measure-construct | cohort, items | The guided intent workflow for measure_construct (below). |
pulse-flows | cohort, from, to | The guided intent workflow for flows (below). |
pulse-data-quality | cohort, fields | The guided intent workflow for data_quality (below). |
Intent prompts. One pulse-<intent> prompt per analytic intent (the twelve above; the tooling intents prepare, simulate and lookup route to tools and get none), generated from the intent registry. Every argument is optional: cohort plus one field hint per role of the intent’s shapes, comma-separated field names. All share one script: pulse_inspect the cohort → pulse_recommend {intent, fields} (the role arguments become fields hints) → pulse_explain the draft request and confirm it with the user → pulse_process → pulse_explain the response. Under a feature profile each prompt is its own mcp_extra:prompt_<intent> feature (it needs capability:recommend, capability:explain and capability:process), and it is not mounted when every operator serving its intent is hidden.
Hosts that surface prompts as slash commands let users trigger these directly.
Recommended session flow
The default sequence for nearly every user request:
pulse_manifestonce at session start. No arguments. Cache the payload — it is deterministic for a binary version and carries every fact needed to author a valid request.pulse_importwhen the user hands the LLM a raw tabular file (CSV/TSV/NDJSON/JSONArray/Parquet/Arrow/Excel). Returns a managed handle that subsequent tools address as if it were a.pulse. Skip when the cohort already exists as a managed handle or.pulsefile inPULSE_DATA_DIR.pulse_inspecton the handle (or path). Reads header + schema only — no record bytes — and registers session-scoped, schema-bound variants of the action tools (see below).pulse_predictwith the authored request. Validates against the schema, surfaces applied defaults, streamability, and any structural errors / warnings. Each error code carries Fixup metadata so the LLM can repair the request without another round trip.pulse_processto execute. Usepulse_composeto batch multiple requests against the same cohort in one round trip.
Cheaper probes are available without going through pulse_process:
pulse_samplefor a row preview.pulse_facetfor distinct values of a single field.pulse_examples_search/pulse_examples_getto crib a runnable request template from the embedded library.
Managed imports + TTL
pulse_import lets the LLM hand the server any tabular file and address it from then on as if it were a .pulse.
- Convertible formats (csv, tsv, ndjson, jsonarray, parquet, arrow, excel) are imported into
$PULSE_DATA_DIR/imports/<handle>.pulsewith a sidecar<handle>.pulse.meta.jsoncarryingimported_at,expires_at,ttl_seconds, source path, source format, and row count — plussource_tz/column_source_tz/dst_policywhen the import read naive datetimes in a source zone (thepulse_importfields of the same names; omitted for a UTC import).result.managed=true. - Pulse passthroughs (
.pulseextension) underPULSE_DATA_DIRare not copied — the server returns the relative path verbatim withmanaged=false. A.pulseoutsidePULSE_DATA_DIRis copied into the managed pool.
Source path resolution. Relative source paths resolve against PULSE_DATA_DIR. Absolute paths read from the host filesystem through a separate “source fs.”
Import jail. Absolute source paths are confined to a single directory tree (the jail root). Default: the working directory the MCP server was launched from. Paths that escape the jail (including ..) return PULSE_IMPORT_SOURCE_FORBIDDEN. Override via pulse.Options.ImportSourceJailRoot when embedding.
Sliding TTL. Default lifetime is 7d (overridable via PULSE_IMPORT_TTL, or per-import via the ttl field — accepts Go duration like "24h", day form like "7d", or "pin" for never-expire). Every subsequent inspect/predict/process/sample/facet against the handle slides expires_at forward. The pool self-sweeps on every pulse_import call — no daemon required. Inspect with pulse_imports_list; evict manually with pulse_drop.
Schema-bound enums
After a successful pulse_inspect, the server registers session-scoped variants of the action tools (pulse_process, pulse_predict, pulse_compose, pulse_sample, pulse_facet) whose JSON Schemas embed enum constraints on field-name parameters. The LLM picks field names from a typed list rather than free-texting and discovering on predict that the name was wrong.
What gets constrained on bound pulse_process / pulse_predict / pulse_compose schemas:
| Path | Enum |
|---|---|
aggregations[].field | All cohort field names |
aggregations[].type | Full aggregator catalogue (AGG_*) |
attributes[].field | Numeric fields only (includes decimal) |
attributes[].type | Full attribute catalogue (ATTR_*) |
filterers[].field | All cohort field names |
filterers[].type | Full filterer catalogue (FILTER_*) |
groups[].field | All cohort field names |
groups[].type | Full grouper catalogue (GROUP_*) |
windows[].field, windows[].partition_by[] | All cohort field names |
windows[].order_by[].field | Numeric and date fields |
windows[].type | Full window catalogue (WIN_*) |
tests[].field, tests[].field2 | Numeric fields only |
tests[].split_by / rows / cols / subject_field | All cohort field names |
tests[].type | Full test catalogue (TEST_*) |
attributes[].target, attributes[].predictors[] | Numeric fields only (regression attributes) |
regressions[].target, regressions[].predictors[] | Numeric fields only |
regressions[].type | Full regression catalogue (REG_*) |
matrices[].type | Full matrix catalogue (MAT_*) |
joins[].on[].left_field | All cohort field names (the right cohort is not bound) |
pulse_facet field arg | All cohort field names |
Beyond the enums, a bound schema carries every key the payload schema declares on the request roots it describes (Request, ComposedRequest, ChainRequest, FacetRequest, and pulse_predict’s alternative roots), nested slot objects included, and no key the instance’s payload schema lacks. The only deliberate omissions are a test’s multiplicity.alpha (the engine refuses it: a test reads its own alpha), a facet overlay’s weight (facets are never weighted) and the labels slot on an instance offering no label table. TestBindForInstance_PayloadSchemaParity holds the two in step, profile-free and under feature profiles.
Items that fan out (for_each). On a features[], attributes[] or windows[] item that carries a for_each, every enum-bound string above (the operator type, field, partition_by[], order_by[].field, a regression attribute’s target / predictors[], the window frame.mode, a weight field) also accepts an axis placeholder: the property is {"type": "string", "anyOf": [{"enum": [...]}, {"pattern": "\\{\\{\\s*[A-Za-z_][A-Za-z0-9_]*\\s*\\}\\}"}]}, and the item carries "if": {"required": ["for_each"]} with an else that forbids the placeholder form at the same sites — so an item without for_each keeps the strict enum (a typo is still rejected) and a {"field": "{{ch}}_spend", "for_each": …} validates. Plain draft 2020-12 keywords, pinned against a real validator by TestBind_ForEach* (internal/mcp). An instance hiding capability:for_each binds neither the key nor the placeholder branch. A reader looking for a property’s enum finds it at enum or, on these items, as the first anyOf branch.
Trigger and lifecycle. Binding fires on a successful pulse_inspect. The go-sdk server auto-fires notifications/tools/list_changed on the post-serve AddTool / RemoveTools swap; the host refreshes its tool list and picks up the bound schemas on the next list. Bound tools share names with the global tools — the session-scoped variants override globals for that session.
Limitations.
- Multi-file sessions: the latest inspect wins. Track multiple cohorts client-side.
- No per-element type ↔ field correlation: JSON Schema can’t easily express “if
aggregations[i].type == AGG_SUMthenaggregations[i].fieldmust be numeric.” Operator–type compatibility lives in thetypeproperty description; strict validation remainspulse_predict’s job. - Transport support: binding mutates the single stdio-session server post-serve (
AddTool/RemoveToolsauto-emitlist_changed), so stdio — the transportpulse mcpships — honours it. There is no per-session schema override for a shared HTTP server, a documented limitation. The manifest’saccepts_typestable is still authoritative, so authoring is never blocked regardless of transport. - Empty enums omitted: when the cohort has zero fields in a category (e.g. no geo fields), the enum is omitted entirely rather than emitted as
[].
Disable binding entirely with --bind-on-open=false.
Lean responses by default
pulse_process, pulse_predict, every pulse_compose slot and every
pulse_process_chain stage shape a request WITHOUT its own return
block by the standard preset — the primary result plus metadata,
tests, crosstab and overlays, never components — and stamp
returned {preset: "standard", digest}. Precedence: request return >
host (pulse mcp --return, gosdk.Config.DefaultReturn,
mcpserve.Options.DefaultReturn) > instance default
(pulse.Options.DefaultReturn, else the feature profile’s return) >
standard. An agent that needs everything sends
"return": {"preset": "full"}; an operator restores the pre-default
output with pulse mcp --return full. The library default is
unchanged (full). Contract: Response shaping.
Configuration
| Env var | Purpose | Default |
|---|---|---|
PULSE_DATA_DIR | Cohort base directory. Required. | (none — server fails to start without it) |
PULSE_IMPORTS_DIR | Subdirectory for managed-import handles. | imports |
PULSE_IMPORT_TTL | Default TTL for managed handles. Accepts Go duration (24h, 30m), day form (7d, 30d), or pin. | 7d |
Embedders can override per-instance via pulse.Options{DataDir, ImportsDir, ImportTTL, ImportSourceJailRoot, FS, ImportSourceFS, BindOnOpen} — see pulse.go.
Transport caveats
- Stdio. The default and only transport
pulse mcpships today. Schema bind-on-inspect works on the single stdio session (see Limitations). Stdout is the JSON-RPC channel; stderr is the log channel — never write structured output to stdout outside the protocol. - Streamable HTTP. Not exposed by the
mcpCLI leaf yet. The underlying go-sdk server supports it; embedders build their owngo-sdkserver and callgosdk.Register(server, p, cfg)(or usemcpserve.Serve), then serve over the transport of their choice. Bind-on-inspect has no per-session override on a shared HTTP server (see Limitations).
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
data directory required: set PULSE_DATA_DIR or pass --data-dir | Neither env var nor flag set | Pass PULSE_DATA_DIR in the host’s env block, or --data-dir in args |
| Tools don’t appear in the host UI after editing config | Host caches tool list | Restart the host fully (not just the conversation) |
pulse_import returns PULSE_IMPORT_SOURCE_FORBIDDEN for an absolute path | Path escapes the import jail (default = server’s working dir) | Either move the file under the jail, launch the server from a higher-level directory, or set pulse.Options.ImportSourceJailRoot when embedding |
pulse_inspect succeeds but bound enums never fire | Stdio session — binding is a no-op there | Use pulse_predict for validation; the manifest’s accepts_types lists give the LLM the same information |
| Tool calls hang | Host wrote non-protocol bytes to the server’s stdin, or server wrote non-protocol bytes to stdout | Check server stderr; restart the session. pulse mcp itself only writes a one-line startup notice to stderr at boot |
To see what the server registers without launching the host:
pulse --json | jq '.data.mcp_tools[]'
pulse manifest --json | jq '.data.skills[]'
Skill cross-reference for LLM agents
If you are writing a system prompt for an LLM agent that uses Pulse, point it at these skills rather than at this site:
| LLM task | Skill |
|---|---|
| MCP wiring, tool surface, schema binding | session-bootstrap |
Author a Process request | request-envelope, aggregation-design |
| Compose multiple sub-requests in one call | compose-requests |
Iterate on a request with pulse_predict | tool-predict |
| Look up an error code or warning | tool-errors-lookup |
| Pick an aggregator / filterer | aggregation-design |
| Pick an attribute (z-score, percentile, formula, …) | attribute-composition |
| Design a grouper | grouper-design |
Use a window operator (WIN_*) | window-design |
Use a feature engineer (FEAT_*) | feature-engineering |
| Run a statistical test (tier-1 or tier-2) | statistical-testing |
| Fit a regression (OLS, GLM, Bayesian) | regression-modeling |
| Generate synthetic data | synthetic-data |
Gate, mask or derive a synthetic field (rules[]) | synth-structural-rules |
| Understand a cohort’s schema layout | cohort-schema-design |
Import a tabular source into .pulse | tool-import |
| Pick an export format | pulse manifest --json (Export capability block) |
Work with decimal128 (currency, precise arithmetic) | financial-cohorts |
| Get started end-to-end (LLM walkthrough) | session-bootstrap |
The agent should call pulse_skills_list once at session start to enumerate the catalog, then pulse_skills_get on demand. The returned text is authoritative; this site does not duplicate it and may lag.
Related
mcp(CLI leaf) — flag reference and exit codes for the server binary- Deployment — production hardening notes
- Troubleshooting — non-MCP failure modes
Payload JSON Schema
Pulse publishes a single machine-readable JSON Schema (draft 2020-12)
describing every public payload — the request envelopes and the universal
--json output envelope. Use it to validate requests before sending them,
to generate client types, or to drive editor autocompletion.
Where to get it
The schema is reachable three ways, all backed by the same generator
(BuildPayloadSchema in internal/descriptor):
| Surface | How |
|---|---|
| Docs URL | https://frankbardon.github.io/pulse/payload-schema.json — the file’s own $id. |
| CLI | pulse schema prints it to stdout (offline; no cohort needed). |
| MCP resource | Read pulse://schema (MIME application/json) alongside pulse_manifest. |
| Go library | p.PayloadSchema() returns the schema of instance p as raw JSON. |
The CLI and MCP surfaces emit byte-identical output to the published file.
Per-instance schema
The root $comment carries the describing instance’s feature-set digest,
"feature_set_digest: fs1:…" — the same value as the manifest’s
feature_set_digest and p.FeatureSetDigest(), so both self-descriptions
can be cached under one key. $id never changes.
p.PayloadSchema() describes only what instance p offers. Without a
feature profile it is the published file byte for byte. An instance built
with a feature profile gets a narrower document:
- the operator, overlay-kind and regression enums list only the enabled names;
- a request slot the instance does not offer (
crosstab,joins,overlays,vectors,matrices,weight) is not a property of its request root (a hiddenvectorstakesVectorSpecandVectorCoercewith it; hiddenmatricestakesMatrixSpec, theResponse.matricesandResponseComponents.matricesslots andMatrixResult/MatrixComponentswith it —MatrixValuesstays, shared with the regressionvcov/correlation); withoutcapability:weightingno per-slotweightis a property either (soSlotWeightandWeightSpecare absent), and withoutcapability:multiplicitynomultiplicityblock is a property of any root or slot (soMultiplicity,MultiplicityMethodandMultiplicityFamilyare absent); - a root whose capability is not offered is absent —
ComposedRequest/ComposedResponse(compose),ChainRequest/ChainResponse(process-chain),FacetRequest/FacetResult(facet),SampleRequest(sample),LookupRequest/LookupResult(lookup),RecommendRequest/RecommendResult(recommend),ExplainRequest/ExplainResult(explain); on an instance that offers explain,ExplainRequestdrops the slots of every hidden root (composed/composed_response,chain/chain_response,facet/facet_result,sample); - every def reachable only through an omitted part is dropped, so the
document stays a valid draft 2020-12 schema with no dangling
$ref.
Request, Response and Envelope are always present. pulse schema
serves the CLI’s default instance; the pulse://schema MCP resource
serves the mounted instance’s schema (p.PayloadSchema()).
Structure
The document is a $defs bundle. The root oneOf lists the entry points:
- Requests —
#/$defs/Request(process / predict),ComposedRequest(compose),ChainRequest(process-chain),FacetRequest,SampleRequest,LookupRequest(point lookup),RecommendRequest(recommend),ExplainRequest(explain: one request root, or one result root with its request companion, plusdetail). - Results —
#/$defs/Response,ComposedResponse,ChainResponse,FacetResult,LookupResult,RecommendResult(its draftrequestis an open object: a"<placeholder>"may sit whereRequesttakes a number),ExplainResult(itsfindings[].numbersvalues are["number", "null"],verdictthe closedVerdictenum). - Advisory —
#/$defs/Advisory, one entry of a predict result’sadvisories({code, message, details}): a coded, non-blocking note that the analysis may not fit the data. Never a warning. - Envelope —
#/$defs/Envelope, the universal--jsonwrapper. Itsdataslot is intentionally open: it carries whatever the operation returned (aResponse, the manifest, a predict result, an inspect result, …). To validate a wrapped result strictly, validate the unwrappeddatavalue against its own def (e.g.#/$defs/Response).
Example — validate a request body with any draft-2020-12 validator:
pulse schema > payload-schema.json
# then point your validator at payload-schema.json#/$defs/Request
How it stays in sync
The schema is generated, never hand-maintained, so it cannot silently drift from the engine:
- Reflection over the Go payload structs — a new or renamed field changes the output.
- Registry-injected enums — the operator, overlay-kind, and
regression discriminants draw their value lists from the same
types.All*Types()/AllOverlayKinds()/AllRegressionTypes()registries the engine executes against, so registering a new operator changes the schema. - Hand-tuned strict unions for the two shapes reflection cannot
express:
OverlayRef(at most one arm populated) andOverlayPayload(shape-discriminatedscalar/series/matrix).
A golden test (TestPayloadSchemaGolden) pins the output and an
enum-parity test (TestPayloadSchema_EnumsMatchRegistry) fails CI on
drift; the schema’s format_version is held equal to the envelope’s
(TestPayloadSchema_VersionMatchesEnvelope). Regenerate after an
intentional payload change with:
go test ./descriptor/ -run TestPayloadSchemaGolden -update
v1 boundaries
The schema is faithful but not maximally strict in two places, by design:
- Operator
params(thejson.RawMessageslot on aggregations, groupers, overlays, etc.) is an open object. There is no central declarative source for per-operator input parameters — each operator’s param schema lives alongside its processor — so encoding it here would duplicate that surface and rot. Consult the operator’s skill / manifest entry for its accepted params. - Small closed mode enums (
OverlayScope,OverlayShape,CrosstabNormalize,CrosstabShape,LabelMode,MarginAxis) are typed as plain strings rather thanenums — they have no registry helper, and a hardcoded list would drift silently.
One shape reads oddly against the rest and is deliberate rather than an
oversight: MarginAggregationFigure.present (on the crosstab’s auxiliary
margin figures — row_margin_aggregations / column_margin_aggregations
/ grand_total_aggregations) is required, so a false is on the wire,
while its sibling value is optional and open. A margin slot that
admitted no record has no defined aggregate; emitting 0 there would be
indistinguishable from a genuine zero, so the figure carries no value
at all and present is what says so. Dropping present when false would
put that statement back into an absence — exactly what it exists to
avoid. Every other field of every response sub-shape stays omitempty.
Cross-slot validation rules that depend on more than one field (e.g. a
crosstab requires at least one row and one column, mutually exclusive
with top-level groups; every crosstab.margin_aggregations entry needs
a type and a label distinct from every other entry’s and from the cell’s)
are enforced by pulse predict at request time, not by the schema.
Time-zone slots
Two additive, optional string slots name a time zone (format_version
stays "1.1"; omitting them is byte-identical to the earlier wire form):
time_zoneonRequestandFacetRequest— the request-level zone.ComposedRequestandChainRequestcarry none of their own (each innerRequestdoes);SampleRequesthas none.tzon everyGroup(so alsocrosstab.rows[]/crosstab.columns[]),Filterer,AttributeandFeatureentry — the per-slot override. It is a slot key besidetype/field, never a key insideparams.
Both are typed as plain strings. The schema does not encode the zone
name set or which operators accept tz; three rules are enforced by
pulse predict and at run time instead:
- a name must be exactly
UTCor an IANAArea/Locationzone (Europe/Berlin,Etc/GMT-5) — anything else isPULSE_TIMEZONE_UNKNOWN; tzis accepted only on operators whose manifest entry carries"zone": "capable", and never on adatefield — otherwisePROCESSING_CONFIG;- precedence is slot
tz→time_zone→ the engine’sDefaultTimeZone→UTC; a resolved zone on adatetimefield reads each instant on its local calendar day, and a non-UTC zone reaching a derived field (absent from the schema) is refused withPROCESSING_CONFIG.
The resolved zone per slot is echoed by predict as
data.time_zones[] — {slot, operator, field_type, tz, source} —
which is a predict result field, not part of this schema. See
skills/request-envelope.md (Time zones).
Multiplicity slots
Multiple-comparison correction adds one additive block shape,
#/$defs/Multiplicity {method, family, alpha} (every key optional;
format_version stays "1.1"; a request that names no block is
byte-identical to the earlier wire form and hashes identically). It rides
as multiplicity on Request, Test (tests[] and post_tests[]),
OverlaySpec (request and facet overlays), ComposeOverlaySpec and
ComposedRequest. method is the closed enum MultiplicityMethod
(none, bonferroni, holm, bh, by) and family the closed enum
MultiplicityFamily (layer, row, column, request, compose);
both are full vocabularies on every instance (not features). alpha is a
number in (0, 1).
Each key falls through on its own: slot → request (a Compose slot: then
the ComposedRequest) → the engine’s DefaultMultiplicity → none, and an
omitted family takes the surface default (request for tests, layer
for overlays). {"method": "none"} is the explicit opt-out. What the
schema cannot say is enforced identically by pulse predict and the
runtime (PULSE_MULTIPLICITY_INVALID): the families each surface offers
(row / column only on a MATRIX-payload overlay kind, compose only
inside Compose, never request on a Compose overlay or anything but
layer / row / column on a facet overlay), no alpha on a test’s
own block, and no explicit correction on the Tukey HSD post-test. Members
of one request or compose family that resolve to different methods
are refused PULSE_MULTIPLICITY_CONFLICT.
When a correction runs, each corrected TestResult (in tests[] and
post_tests[]) gains three additive omitempty keys BESIDE its raw
p_value / reject_null, which never change: p_adjusted (number, or
null when the raw p is undefined — such a p is left out of the family
size), significant_adjusted (p_adjusted < the test’s own alpha;
absent when p_adjusted is null) and multiplicity
(#/$defs/AppliedMultiplicity {method, family, alpha, m}, m the
number of defined p-values corrected together). Each test contributes
one headline p; the Tukey HSD post-test never joins a family. No block,
or a method resolving to none, emits none of them — the response is
byte-identical. On an instance that hides capability:multiplicity the
three keys and AppliedMultiplicity are absent from the schema too.
An inferential overlay layer is corrected the same way, its adjusted
figures riding the layer’s own additive slots — the base payload (the
matrix cells, scalar, each summary’s p_value / statistic) never
changes. Where they land follows where the kind carries its p-values:
an OverlaySummary (the layer’s, for the χ² / KS kinds whose p is
summary.p_value; each SERIES entry’s, for OVERLAY_CHISQ_ROW /
_COL, and for OVERLAY_T_VS_REF / OVERLAY_Z_VS_REF whose p is the
entry’s statistic) gains p_adjusted and significant_adjusted; a
MATRIX payload whose cells are p-values (the pairwise, Fisher and
*_CELL kinds) gains two parallel MatrixPayloads, payload.p_adjusted
and payload.significant_adjusted, on identical headers, keys and
[row][column] coordinates (a panel kind’s cell vector maps element for
element: []number|null and []boolean|null); a base cell that is
absent stays absent, an undefined adjusted p is null and its
significance cell absent. The layer gains a multiplicity echo
(AppliedMultiplicity), present only when a correction ran.
significant_adjusted compares against the resolved
multiplicity.alpha (default 0.05). A layer family never mixes
layers; a request-family layer pools with the request’s tests and
post-tests, so its echo’s m is the pooled count. A row (column)
family is one family per row (column) index of the layer’s own MATRIX
payload — purely coordinate-based, never crossing rows (columns) or
layers — and every element of a panel cell joins its cell’s row
(column); its echo adds m_per (array of integers, omitempty), each
family’s size index-aligned with the matrix rows (columns), 0 for an
index with no defined p-value, and m is their sum. A facet overlay
(FacetResult.overlays[]) is corrected per layer with the same slots
and the engine’s DefaultMultiplicity applying. These keys ride
capability:multiplicity like the test outputs.
Weight slots
Row weighting adds two additive slot shapes (format_version stays
"1.1"; a request that names no weight is byte-identical to the earlier
wire form and hashes identically):
weightonRequest— aWeightSpecobject{field, kind},kind∈probability(default when omitted) |frequency.ComposedRequestandChainRequestcarry none of their own (each innerRequestdoes);FacetRequestandSampleRequesthave none.weighton everyAggregation(so alsocrosstab.celland eachcrosstab.margin_aggregations[]entry),Test(tests[]andpost_tests[]),RegressionSpec,Attribute,OverlaySpecandGroup(groups[]and both crosstab axes) — the per-slotSlotWeightunion: a field-name string, aWeightSpecobject, ornull. Inferential slots (tests, regressions, reference-distribution attributes, the quantile grouper, inferential overlays) are refusedPULSE_WEIGHT_UNSUPPORTEDwhile a weight is in force, so on themnullis the way to run unweighted.
null is not absence. An absent per-slot weight inherits the
request’s weight, then the engine’s DefaultWeight; an explicit
null opts that one slot out, so it runs unweighted while the rest of
the request stays weighted. The schema says so with
oneOf [string, WeightSpec, null] on #/$defs/SlotWeight.
The field set a weight may name is not in the schema; pulse predict
and the runtime enforce it identically: the field must be a cohort
column (a joined, prefixed name included — never a derived column) of
an unsigned-integer or float type, otherwise SERVICE_VALIDATION
(unknown field) or PROCESSING_CONFIG ({slot, field, type}). The
resolved weight per slot is echoed by predict as data.weights[] —
{slot, operator, field, kind, status, source}, omitted when nothing
names a weight — a predict result field, not part of this schema.
A weighted aggregation slot reports three optional floor fields on its
Response.components.aggregations[i] entry — sum_weights, n_eff
(probability weights only) and n_weight_invalid — each omitempty
and present only when a weight was applied to that slot, so an
unweighted response is unchanged. n and n_null keep their
value-presence meaning.
Which operators honour, skip or refuse a weight, the invalid-weight rules and the unweighted-base recipe: Row Weighting.
Vector slot
vectors on Request is an additive array of VectorSpec
{name, fields | pattern, labels?, coerce?} (format_version stays
"1.1"; a request with no vectors is byte-identical and hashes
identically). coerce is the closed VectorCoerce enum (binary).
What the schema cannot say, predict and the runtime enforce
identically before any record is read: exactly one of fields /
pattern; literal fields entries keep the caller’s order while glob
entries and pattern (a Go regular expression) expand in schema order;
members must be integer or float columns (packed_bool only with
coerce: "binary"); labels count equals the member count; names and
members are unique — refusals carry the PULSE_VECTOR_* codes.
Predict echoes the resolved members as data.resolved_vectors
({name: [members]}, a predict result field, not part of this schema).
Request.Hash() hashes a vector as written (see
Request hashing).
Test vector inputs
Test carries four additive slots, the MatrixSpec mirror:
vector / fields (the member set) and vector2 / fields2 (a
paired second set). A vector-input test (TEST_HOTELLING_T2, TEST_MANOVA,
TEST_BOX_M, TEST_BARTLETT_SPHERICITY, TEST_MARDIA) sets
exactly one of vector (a vectors entry) and fields (inline
names and globs, the VectorSpec.fields rules) and no field; a
second set must resolve to the same member count and is accepted only
by a test that reads one (TEST_HOTELLING_T2’s paired form, which
refuses split_by beside it); every other test refuses all four.
TEST_MANOVA and TEST_BOX_M require split_by (details.reason
split_by_required); TEST_BARTLETT_SPHERICITY and TEST_MARDIA
refuse it (split_by_not_accepted). Missing
data is listwise only (params.missing other than "listwise" is
refused). Shape refusals are SERVICE_VALIDATION with
details.reason; an undefined vector is PULSE_VECTOR_UNKNOWN.
format_version stays "1.1"; a test without them is byte-identical.
Attribute vector inputs
Attribute carries two additive slots, the same mirror: vector /
fields. A vector-input (fitted) attribute (ATTR_MAHALANOBIS,
ATTR_PC_SCORE, ATTR_FACTOR_SCORE) sets exactly one of them and no
field; every other attribute refuses both.
Members are source columns only. Its params share fit_on —
"filtered" (the default) or an inline array of Filterer objects
choosing the fit rows among the filtered ones (every filtered row is
still scored) — and missing ("listwise" only). The default label is
<TYPE>_<vector>, or <TYPE>_<first fields entry> (ATTR_PC_SCORE
appends _PC<component>, ATTR_FACTOR_SCORE _F<factor>). Shape refusals
are SERVICE_VALIDATION with details.attribute and details.reason;
an undefined vector is PULSE_VECTOR_UNKNOWN. A fit that is singular,
or has N* ≤ p, nulls every score and adds a PULSE_MATRIX_SINGULAR /
PULSE_MATRIX_INSUFFICIENT_N entry to Response.warnings (details
scope: "fit"); ATTR_FACTOR_SCORE also nulls every score with
PULSE_MATRIX_NOT_IDENTIFIED (an unidentified or Kaiser-short factor
count) or PULSE_MATRIX_HEYWOOD (Bartlett over a uniqueness ≤ 0), and
scores with a PULSE_MATRIX_HEYWOOD / PULSE_MATRIX_NOT_CONVERGED
entry otherwise. format_version stays "1.1"; an attribute without
them is byte-identical.
Grouper vector inputs
Group carries the same two additive slots, vector / fields, on
groups and both crosstab axes. A vector-input grouper (GROUP_KMEANS)
sets exactly one of them and no field; every other grouper refuses
both. Members are source columns only. Its key column (the Data row key,
the crosstab axis field) is <TYPE>_<vector> or <TYPE>_<first fields entry>. Shape refusals are SERVICE_VALIDATION with details.group
and details.reason; an undefined vector is PULSE_VECTOR_UNKNOWN. A
best start stopped at params.max_iter adds a
PULSE_MATRIX_NOT_CONVERGED entry to Response.warnings (solver
kmeans_lloyd). Predict’s groups[i].row_buffer_bytes reports its point
store. format_version stays "1.1"; a grouper without them is
byte-identical.
Matrix slots
matrices on Request is an additive array of MatrixSpec
{name, type, vector | fields, params, weight, encoding} and
matrices on Response the matching array of MatrixResult
{name, type, group_key?, group_header?, primary, auxiliary, vectors, scalars, warnings}, one per spec in request order (format_version
stays "1.1"; a matrix-free request and response are byte-identical).
type is the registry-backed MatrixType enum (MAT_COVARIANCE,
MAT_CORRELATION, MAT_PARTIAL_CORRELATION, MAT_PCA, MAT_FACTOR,
MAT_COLLINEARITY, MAT_RELIABILITY, MAT_DISTANCE); encoding is full (default) or upper. Every
matrix is a dedicated MatrixValues {kind, encoding, row_keys, column_keys, labels?, values} — not the crosstab MatrixPayload —
whose values rows hold p cells (full) or p − r cells from the
diagonal (upper). kind is square_symmetric or rectangular
(MAT_PCA’s p × k loadings and eigenvectors: row_keys the members,
column_keys PC1 … PCk, k possibly 0, always written full;
MAT_FACTOR’s p × k loadings: column_keys F1 … Fk;
MAT_COLLINEARITY’s variance decomposition: a row per variable —
(intercept) first unless center — and column_keys D1 … Dq;
MAT_DISTANCE’s primary is square_symmetric but keyed by its
between grouper’s bucket labels, and its auxiliary.centroids is k × p:
row_keys the buckets, column_keys the members).
Undefined cells and scalars (no mass, too few rows, a determinant of a
matrix that is not positive definite) are null, keys kept. What the
schema cannot say, predict and the runtime enforce identically before
any record is read: exactly one of vector / fields; a vector a
vectors entry defines (else PULSE_VECTOR_UNKNOWN); inline fields
follow the vector rules; result names are unique; params are the
operator’s own (MAT_COVARIANCE: ddof 0 or 1; both: missing
listwise (default) or pairwise, max_drop_share in [0, 1],
listwise only; MAT_CORRELATION only: method pearson (default),
spearman or kendall — a rank method takes frequency weights only —
and summary {top_pairs: k}, k a positive integer;
MAT_PARTIAL_CORRELATION only: control "all" (default) or a list of
numeric fields — a listed member leaves the output axis, so row_keys
are the non-control members — and repair "nearest";
MAT_RELIABILITY only: reverse (item names), scale_min /
scale_max (required with reverse; a missing range or an out-of-range
value is PROCESSING_CONFIG) and repair "nearest"; at least 2
items; MAT_PCA only: on correlation (default) or covariance,
components an integer k ≤ p, "kaiser" (default on a correlation,
refused on a covariance) or {"variance": share} — required on a
covariance — and repair "nearest"; MAT_COLLINEARITY only:
center (boolean, default false: Belsley uncentered with the
intercept) and repair "nearest"; MAT_FACTOR only: factors an
integer k ≤ p or "kaiser" (default), extraction minres (default),
principal_axis or ml, rotation none (default), varimax, promax
or oblimin, kappa (promax only) and gamma (oblimin only), and
repair "nearest"; MAT_DISTANCE only: between (required) a grouper
spec as in groups — any built-in but GROUP_DATE / GROUP_DATE_RANGES,
no own weight or tz — metric euclidean (default), sqeuclidean,
manhattan, chebyshev, cosine or mahalanobis, standardize
(boolean, default false), listwise only). Under pairwise, auxiliary.n is a MatrixValues of
the same shape and encoding holding each pair’s row count. vectors is an
open object: with summary.top_pairs, vectors.top_pairs is
[{row, col, r, n}] — the k off-diagonal pairs with the largest |r|,
ties in axis order, undefined pairs skipped, n the pair’s row count;
MAT_RELIABILITY adds vectors.item_total_r / alpha_if_deleted /
item_mean / item_sd (one number per item, axis order) and scalars
alpha, alpha_standardized, mean_inter_item_r, omega;
MAT_PCA adds auxiliary.eigenvectors, vectors.eigenvalues /
explained_variance / cumulative / communalities / kmo_msa (one
number per member or component) and scalars kmo, bartlett_chisq,
bartlett_df, bartlett_p, components_retained (the two integer
scalars are never rounded by return.precision); MAT_COLLINEARITY
adds auxiliary.variance_decomposition (rows sum to 1),
vectors.vif / tolerance (one per member) and condition_indices
(one per dimension, ascending) and scalars condition_number,
max_vif; MAT_FACTOR adds auxiliary.unrotated (rotated runs) and
auxiliary.factor_correlation (promax / oblimin: square_symmetric
keyed F1 … Fk, always full), vectors.communalities /
uniquenesses (one per member) and variance_accounted (one per
factor) and scalars rmsr, empirical_chisq, df, empirical_p,
kmo, bartlett_chisq, bartlett_df, bartlett_p, iterations,
converged, factors_retained (df, iterations, converged and
factors_retained are never rounded by return.precision), plus, under
extraction ml only, likelihood_chisq, likelihood_p, tli,
rmsea, rmsea_lower, rmsea_upper (the interval at
params.conf_level, default 0.9) and bic (all but likelihood_chisq
null at df ≤ 0); MAT_DISTANCE adds
auxiliary.centroids (always), centroids_standardized (standardize)
and pooled_covariance (mahalanobis), and vectors.sizes (each bucket’s
weight mass) / counts (its rows, integers), one per bucket.
warnings are {code, message, details} entries
(PULSE_MATRIX_INSUFFICIENT_N, _ZERO_VARIANCE,
_LISTWISE_HEAVY_DROP, _NOT_PSD — fatal, not a warning, on
MAT_PARTIAL_CORRELATION without repair: "nearest"; on
MAT_RELIABILITY a warning that nulls omega, beside _HEYWOOD and
_NOT_IDENTIFIED; MAT_PCA fatal like the partial correlation, plus
_PAIRWISE_N_STAR and a _SINGULAR warning that nulls KMO and
Bartlett; MAT_COLLINEARITY fatal like the partial correlation, and a
singular correlation is a fatal _SINGULAR naming dependent_fields;
MAT_FACTOR fatal like the partial correlation, plus _PAIRWISE_N_STAR,
_NOT_IDENTIFIED (null fit), _HEYWOOD (fit kept), _NOT_CONVERGED,
a _SINGULAR warning that nulls KMO and Bartlett (and, under ml, the
whole fit) and, under ml, _INSUFFICIENT_N scope likelihood that
nulls the likelihood figures; MAT_DISTANCE _INSUFFICIENT_N scope
buckets (a massless bucket) or pooled (mahalanobis with no more rows
than buckets) and a _SINGULAR warning that nulls the mahalanobis
distances). components.matrices carries one
MatrixComponents per result, in the same order: {name, type, group_key?, n, n_null, n_listwise_dropped, min_pair_n?, max_pair_n?, sum_weights?, n_eff?, n_weight_invalid?, operator?} (pairwise and
weighted keys only when they apply; MAT_RELIABILITY’s operator
carries the omega fit’s iterations / converged; MAT_DISTANCE’s
metric, standardize, buckets and n_null_key — the rows its
between grouper placed in no bucket). Contract:
.claude/reference/matrix-and-vectors.md.
Regression coefficient covariance
RegressionSpec takes an additive vcov boolean (default false) and
RegressionResult two additive omitempty MatrixValues slots,
vcov and correlation, present only when the spec set vcov: true
(format_version stays "1.1"; a fit without it is byte-identical).
Both are square_symmetric, full, with row_keys = column_keys =
(intercept) then the predictors in spec order. vcov is the
coefficient covariance — REG_OLS σ̂²·(XᵀX)⁻¹ (ridge: the sandwich),
REG_GLM (XᵀWX)⁻¹ at the converged working weights (dispersion 1),
whose square-rooted diagonal is exactly std_errors; REG_BAYES_LINEAR
the POSTERIOR covariance b_n/(a_n−1)·Λ_n⁻¹ (null cells when
a_n ≤ 1), whose diagonal is std_errors²·a_n/(a_n−1). A weighted fit
reads the inference size N* (Σw frequency, n_eff probability), as its
standard errors do. correlation is vcov scaled to a unit diagonal
(R’s cov2cor); an entry with a zero or undefined variance is null.
Under resample it is the replicate covariance (jackknife
(n−1)/n·Σ(β₋ᵢ−β̄)(β₋ᵢ−β̄)ᵀ, bootstrap the B−1 sample covariance of the
successful replicates), under selection the final refit’s covariance
keyed (intercept) + the selected predictors — so its diagonal is
std_errors² there too. Lasso / elastic net refuse vcov, with or
without a modifier, with PROCESSING_REGRESSION_VCOV_UNSUPPORTED
(details.reason penalty) in predict and at runtime alike. Both slots are in the standard preset (not
minimal) and every float in them is rounded by return.precision.
Regression robust covariance
RegressionSpec takes an additive covariance object {type, cluster, order_by, lags} and RegressionResult an additive omitempty
covariance echo {type, cluster, clusters, order_by, lags}
(format_version stays "1.1"). type is the CovarianceType enum
(classical, hc0, hc1, hc2, hc3, cluster, hac); lags is a
non-negative integer or "auto". Absent or classical is
byte-identical to a request without the block and echoes nothing. Any
other type on REG_OLS or REG_GLM replaces the estimator behind
std_errors, p_values, ci_lower / ci_upper and vcov (so
√diag(vcov) is still exactly std_errors); under cluster, an OLS
df is the cluster count minus 1 (GLM stays Wald z). On
REG_BAYES_LINEAR it keeps the posterior-mean coefficients and
REPLACES the posterior covariance with Müller’s sandwich posterior:
std_errors, credible_intervals, ci_lower / ci_upper (normal, no
df) and vcov follow it. Shape faults are PROCESSING_CONFIG; a
robust type beside a penalty, resample or selection, or on another
regression type, is
PROCESSING_REGRESSION_VCOV_UNSUPPORTED (details.reason), in predict
and at runtime alike. The echo is in the standard preset.
Regression coefficient intervals
RegressionSpec takes an additive conf_level float (0 < c < 1,
zero or absent = 0.95; out of range is PROCESSING_CONFIG in predict
and at runtime) and RegressionResult four additive omitempty
slots: ci_lower / ci_upper (maps keyed like coefficients, a key
present exactly when std_errors carries it), df and conf_level
(format_version stays "1.1"). Each bound is β ∓ q·SE: REG_OLS a
Student-t q on df = the residual N* − p − 1 (fractional under
probability weights), REG_GLM a Wald normal q (no df),
REG_BAYES_LINEAR the posterior t on df = 2·a_n — equal to
credible_intervals when conf_level is unset, since it then falls
back to credible_level — and any fit under resample a normal q on
the resampled standard errors (no df). An undefined bound or df
(residual df ≤ 0) is null. All four are in the standard preset (not
minimal) and rounded by return.precision.
Per-group aggregation components
groups on AggregationComponents is an additive omitempty array of
AggregationGroupComponents {group_key, n, n_null, sum_weights?, n_eff?, n_weight_invalid?, operator?} (format_version stays "1.1";
an ungrouped or crosstab response has no groups key and is
byte-identical). On a grouped, non-crosstab run each
components.aggregations[i] carries the cohort-wide floor (no
operator) and one groups entry per data row, in data order
(sort included), each equal to an ungrouped run over that bucket’s
records. What the schema cannot say: entry i describes data[i]; every
execution arm (buffered, streaming, parallel) emits the same figures, and
a stream carries them on its terminal chunk only. Contract:
.claude/reference/response-components.md (Per-group aggregation
components).
Return slot
return on Request is an additive Return object
{preset?, include?, exclude?, precision?} (format_version stays
"1.1"; a request without one is byte-identical and hashes
identically). preset is the closed ReturnPreset enum (full,
standard, minimal); precision is significant digits, 1–17 (0 or
absent: unlimited). include / exclude are paths over this schema’s
Response JSON names at any depth — . between keys, [*] into every
array element, a trailing * on a map-key segment as a prefix glob
(tests[*].details.effect_*). What the schema cannot say, predict and
the runtime enforce identically before any record is read: resolution
runs preset, then include adds, then exclude removes (exclude wins);
include without a preset starts from an empty base; top-level
warnings, and every nested warnings whose parent is emitted, stay
unless excluded. A path this instance’s Response does not carry — a
hidden feature’s included — is PULSE_RETURN_PATH_UNKNOWN, as is a
data[*].<column> the request cannot produce (data under a join or a
crosstab, test details and operator component maps accept any key); an
unknown preset, a precision out of range or a malformed path is
PULSE_RETURN_INVALID. Predict echoes the resolved plan as
data.return ({preset, include, exclude, keep, precision?, identity, digest, unresolved_includes?}, a predict result field, not part of
this schema); equivalent spellings share a digest, and
unresolved_includes lists the open-path includes predict cannot
resolve without data. On every request, return block or not, predict
also reports data.sizes: each top-level section as {section, full_bytes, shaped_bytes, basis} (shaped_bytes equals full_bytes
with no return, 0 when excluded; a section whose size depends on the
data is omitted) — guidance only.
At runtime the plan is applied to the finished Response: an excluded
slot is absent on the wire — never null, required keys such as
metadata.total_rows or tests[*].p_value included — and zero (nil
when nillable) on the Go value. A shaped response carries returned
(ReturnedMarker {preset, digest, precision?} — preset is custom
for an explicit include / exclude without one; digest equals
predict’s); it is emitted only when the plan changes something and is
not itself a selectable path (naming it is PULSE_RETURN_INVALID). An
include through a map key or an open value that matched nothing in the
executed response adds a PULSE_RETURN_PATH_UNMATCHED entry to
warnings (details path) unless warnings is excluded.
precision is wire-only: the Go response keeps full float64, and on
the wire every float (data cells, tests, regressions, overlays, matrices
incl. top_pairs[*].r and scalars.determinant, component maps) is
strconv.FormatFloat(v, 'g', precision, 64) — so 3e-9 at 4 digits is
3e-09 and 123456.789 is 1.235e+05. NaN / ±Inf stay null; ints
are never touched. Exempt (written exact): integer-semantics floats —
matrices[*].auxiliary.n on every request, and, derived from the
request, the data column of a count aggregation (AGG_COUNT,
AGG_DISTINCT_COUNT, AGG_NULL_COUNT, AGG_MODE_COUNT,
AGG_FREQUENCY, AGG_SET_FREQUENCY, AGG_SET_CARDINALITY_SUM,
AGG_SET_DISTINCT_VALUES), a count crosstab cell under normalize
none (cells, margins, grand total, long-form column) and a count
auxiliary margin aggregation’s figures — plus decimal128 values, which
are decimal strings. The upper matrix encoding keeps its shape.
A shaped response is a projection: it is not schema-valid against
the full payload schema (required keys may be absent), so validate the
request and the plan, not the shaped output. A request without return
stays byte-identical. Agent-facing summary: skills/response-shaping.md;
library walkthrough: library/response-shaping.md.
Presets
The presets are defined once (internal/descriptor/return_resolve.go,
returnPresetPaths) and listed in the manifest as return_presets
([{name, paths}], in full / standard / minimal order) with each
preset’s paths expanded against the instance: a path a hidden
feature owns (e.g. matrices[*] without capability:matrices,
tests[*].p_adjusted without capability:multiplicity) is absent from
the listing and from the selection, never an error. A preset is an
include allowlist, so a caller’s include can always add a part back.
Nested *.warnings are not listed: each is kept wherever its parent is
emitted. Every listed path must resolve against this schema
(TestReturnPathsMatchSchema).
| Slot | minimal (primary result) | standard |
|---|---|---|
data, warnings | yes | yes |
metadata | no | yes |
crosstab | shape, matrix | whole |
matrices[*] | name, type, group_key, primary | all but auxiliary |
tests[*] / post_tests[*] | label, type, statistic, p_value, reject_null, p_adjusted, significant_adjusted | minimal’s keys + variant, df, alpha, multiplicity, details.effect_size (no other details key) |
regressions[*] | name, type, coefficients, p_values | all but credible_intervals and selection (so ci_lower / ci_upper / df / conf_level, and vcov / correlation / covariance when asked for) |
overlays[*] | name, kind, ref, summary (no payload) | whole |
components | no | no |
full is the identity: every visible top-level key, byte-identical to
a request without return. standard and minimal stamp returned
with their preset name.
Compose and chain
Every Compose slot and every chain stage is a Request, so its own
return (else the instance default) shapes responses[i] /
stages[i] exactly as a single Process would; a refusal carries
details.request / details.stage. ComposedRequest adds its own
return (same Return def) whose paths root at ComposedResponse:
only overlays… paths are valid (any other is PULSE_RETURN_INVALID,
details root: "compose"), responses is always kept whole, presets
expand to their overlay paths, and no instance default applies at that
level. A non-identity Compose-level plan stamps
ComposedResponse.returned. Shaping runs after the Compose overlay and
multiplicity folds — layers are computed from unshaped slots — and after
the whole chain, so a stage that excludes data still feeds the next
stage its rows. final is the last stage, shaped by its return;
there is no chain-level block.
Compose sweep
ComposedRequest.sweep (SweepSpec) is the one slot that expands into
ordinary Compose slots, so a sweep adds no response shape of its own:
responses carries explicit slots first, then one entry per expansion.
SweepSpec is {axes, mode, label, request, overlays, rank}: axes
are SweepAxis {name, values} (values a non-empty list of scalars),
mode the closed enum SweepMode (grid default, zip), request an
object in the Request shape that may carry {{axis}} / {"$var": "axis"} placeholders (the schema types it as object; the substituted
body is decoded strictly at expansion), overlays an array of
ComposeOverlaySpec objects with placeholders, and rank a
SweepRank {by, order, top} (SweepRankOrder: asc, desc). The slot
is omitempty, so a sweep-free request is byte-identical on the wire.
It is gated by capability:compose_sweep: on an instance that hides it
the sweep property and the Sweep* defs are absent. A malformed sweep
is PULSE_SWEEP_INVALID with details.field (a sweep-rooted path)
and details.reason. pulse_predict with composed reports the
expansion as sweep {axes, mode, expanded_count, labels} on the predict
result; that summary is not payload-reachable and is not in this schema.
A sweep with rank adds ComposedResponse.ranking, an omitempty array
of RankEntry {label, value, rank} (1-based, in rank order, value
always finite). Only sweep slots are ranked. A slot whose value at
rank.by is null, or whose path is missing there but resolves elsewhere,
is left out with a PULSE_SWEEP_RANK_PATH warning on its own response;
a non-number, ambiguous or nowhere-resolving path refuses the batch with
the same code. ranking is pinned outside the Compose-level return:
always emitted, never rounded by precision.
Compose fits
ComposedRequest.fits is an omitempty array of FitSpec {name, request}: request is an ordinary Request holding exactly one
regression and no groups / crosstab. Every fit runs once, before any
slot (sweep slots included), over its own cohort, filters, features and
weight; slots read the fitted model by name (one namespace,
PULSE_MODEL_NAME_COLLISION on a duplicate, PULSE_MODEL_UNKNOWN for an
unbound reference). A malformed entry is PULSE_FIT_INVALID with
details.fit (the entry index) and details.reason (name_missing,
request_missing, regression_count, grouped); a fit request’s own
refusal keeps its code and gains details.fit. A fit request’s return
is ignored. The fits surface as ComposedResponse.fits, an omitempty
object of RegressionResult keyed by fit name, pinned outside the
Compose-level return like ranking (always emitted, never rounded).
Both slots are gated by capability:compose_fits. pulse_predict with
composed lists them as fits [{name, type, target, predictors, cohort}] on the predict result (not payload-reachable).
Model files (save)
RegressionSpec.save and FitSpec.save are omitempty strings: a path,
relative to the instance filesystem root, where the finished fit is
written as a model file (model_format_version 1, or 2 — adding the
covariance echo — when a robust covariance produced the fit’s standard
errors; .pulse-model.json;
the format is in .claude/reference/byte-layout.md, “Model file”). The
path must end .pulse-model.json and stay under the root
(PULSE_MODEL_SAVE_INVALID with details.reason and details.regression
or details.fit, refused by predict and the runtime alike before any
record is read); a failed write is PULSE_MODEL_WRITE_FAILED. Replacing a
file succeeds with the warning PULSE_MODEL_OVERWRITTEN: on the
Response.warnings of the request whose regression saved, or on
ComposedResponse.warnings — an omitempty array of ResponseWarning
for batch-level diagnostics, always emitted (a warnings keep path the
Compose-level return cannot exclude) — for a fits save
(details.fit). Both save keys are
gated by capability:model_save; the file itself is not payload-reachable,
so format_version stays "1.1". Two saves naming one file in a request
or a Compose batch are refused (reason: duplicate, details.sites
naming both writers), and a batch judges every save path — fits and
slots — before any fit or slot runs (a slot’s fault gains
details.request). Materialize and ProcessChain admit no regression, so
neither ever writes a model file.
Loading models (models)
Request.models and FacetRequest.models are omitempty arrays of
ModelRef {name, path}: each loads a model file a save wrote, under
name, into the namespace a Compose fits list binds, so
ATTR_REG_PREDICT fit and coef() resolve a loaded name exactly like
a fit (bit-for-bit the same scores). Every file is read, version-checked
and decoded before any record is read — in a Compose batch or a chain,
before any fit, slot or stage runs, so a load always sees the file as it
was before the run. Refusals, predict and runtime alike:
PULSE_MODEL_REF_INVALID (no name, or a path the save-path rule
refuses; details.model, reason), PULSE_MODEL_FILE_UNREADABLE
(missing / unreadable), PULSE_MODEL_FILE_INVALID /
PULSE_MODEL_VERSION_UNSUPPORTED (with details.model and name),
PULSE_MODEL_NAME_COLLISION (a name a fit or an earlier entry binds;
details.sources names both), then the scoring rules
(PULSE_MODEL_PREDICTOR_MISSING / _NOT_NUMERIC against the scoring
frame). Provenance is informational: a model fit on one cohort scores any
cohort holding its predictor columns. A fit request’s own models are
all it sees (fits run before the batch binds); each chain stage loads its
own. Gated by capability:models; format_version stays "1.1".
Item fan-out (for_each)
Window, Feature and Attribute each carry an optional for_each
(ForEach {axes, mode} — the sweep’s SweepAxis list and SweepMode).
Before any other check the item is replaced IN PLACE by one ordinary
item per axis combination (grid: first axis slowest; zip: lockstep),
each rendered from the item’s own body with the sweep substitution —
{{axis}} in any string (label, field, expression), {"$var": "axis"} as a whole value inside params (opaque JSON; a typed
non-string key cannot hold the marker) — and decoded strictly. Plain
items are untouched, so a list may mix both, and no response shape
changes: each expanded item produces its own column. The key is
omitempty, so a for_each-free request is byte-identical on the wire
and under CanonicalHash. It is gated by capability:for_each: on an
instance that hides it the for_each properties and the ForEach def
are absent (SweepAxis stays while a compose sweep is visible). A
malformed for_each is PULSE_FOR_EACH_INVALID with details.field (a
request-rooted path such as windows[0].for_each.axes[1]) and
details.reason; a for_each inside a sweep body is refused with
reason nested_in_sweep. Limits.MaxDerivedColumns (default 1,000)
bounds plain plus expanded window, feature and attribute items, checked
before any item renders. Predict reports each fan-out as for_each [{path, axes, mode, expanded_count}] on the predict result (not
payload-reachable, so not in this schema).
Undefined figures
A result figure can be undefined even when every input is present — a
ratio over an all-zero denominator, a confidence bound under two rows,
the first entry of an index-vs-prior series, a rolling window that has
not filled. The engine’s Go results carry NaN there; JSON has no NaN,
so every JSON surface writes the figure as null in place, key
kept (types.MarshalFinite), and the rest of the response serialises
normally. null means “reported, undefined here”; an absent optional
key keeps meaning “not reported for this kind”.
The schema says so: every float slot on a result-only def —
TestResult, RegressionResult, OverlaySummary, the
OverlayPayload.scalar arm, FacetNumeric, FacetHistogram and the
weighted floor’s sum_weights / n_eff on AggregationComponents and
AggregationGroupComponents —
is "type": ["number", "null"]. The open slots (data rows,
components operator maps, matrix cell value) already admit null.
Request floats stay "number": a decoded request never carries a
non-finite float. ExplainRequest is not a request root for this rule:
the results it carries keep their nullable floats.
Reading a result back is the inverse: plain encoding/json decodes a
null into a Go float as 0, which a reader would take for a real
figure. pulse explain --response and MCP pulse_explain decode the
result they are handed so that null in a float slot is NaN again —
undefined stays undefined (a not_computable verdict, a null number),
never a confident 0.
Widening those slots did not move format_version (still "1.1"):
every document the schema accepted before it still accepts, and output
that serialised before is byte-identical — the only outputs that
change are ones that previously failed to serialise at all.
Whether a crosstab fuses is a predict answer
The schema cannot tell you which arm the engine will build a crosstab
on — the fused one-pass arm (memory O(cells + margins)) or the buffered
arm (holds every filter-passing record). Output is identical; only
memory differs. pulse predict reports it as
data.crosstab_fusable (absent when the request has no crosstab) plus
data.crosstab_fusion_reasons (every reason it will not fuse, in rule
order). Both are predict result fields, not part of this schema, so
adding them did not move format_version (still "1.1"). They honour
the instance: an engine built with Options.DisableCrosstabFusion
answers false with a reason. See skills/crosstab-guide.md.
What the schema cannot say about margin_aggregations
The auxiliary margin-only slot is fully described structurally — it is an
array of the same Aggregation shape the cell slot takes — but two of
its contracts are semantic, so validating a request against the schema
tells you nothing about either. Both are reported by pulse predict.
Whether the figures will be emitted at all. The auxiliary figures
land only on components.crosstab.row_margin_aggregations /
column_margin_aggregations / grand_total_aggregations, and each rides
the matching DISPLAY flag (crosstab.margins.rows / .columns /
.grand). A schema-valid request with every display flag false produces
no auxiliary figure at all. Setting a normalize direction does not
help: it makes its margin required as a normalization denominator, and
an auxiliary is never a denominator. That shape warns
PULSE_CROSSTAB_MARGIN_AGG_UNOBSERVED.
Which records each figure is built from. An auxiliary observes the
same record admission as the CELL aggregator — a record contributes only
if it contributed to a cell — so a record whose cell field is null, and a
record whose axis key a grouper include excluded, are absent from every
auxiliary figure while the cell’s own row_margin_components beside them
still count both. Nothing in the schema distinguishes the two, and the
consequence is silent: a consumer reading an auxiliary’s components.n
as a cohort count gets a well-formed, plausible, wrong number. See
skills/crosstab-guide.md and skills/response-components.md.
Request Example Library
Pulse ships a searchable, embedded catalogue of runnable request JSON files
spanning every operator category. They are checked into the repo
under internal/examples/, mounted into the binary at compile time via //go:embed,
and surfaced through three peer access paths:
| Access path | Best for |
|---|---|
pulse_examples_search / pulse_examples_get (MCP tools) | LLM agents authoring requests against a running Pulse server |
pulse examples search / pulse examples show (CLI) | Developers exploring at a shell |
pulse.ExamplesSearch / pulse.ExampleGet (Go API) | Embedders building higher-level UIs |
What the library contains
Every example is a complete types.Request JSON body — the same shape you
hand to pulse_process. Each file is annotated with a structured _meta
block describing the example. Pulse’s JSON unmarshaller ignores unknown
fields by default, so the _meta block is invisible at execution time;
the file remains runnable verbatim.
{
"_meta": {
"name": "t_test_one_sample",
"category": "tests",
"tags": ["hypothesis-test", "t-test", "tier-1-test", "parametric", "one-sample", "streaming-friendly"],
"operators": ["AGG_AVERAGE", "AGG_COUNT", "TEST_T"],
"description": "One-sample t-test comparing revenue mean against the hypothesized mu=100."
},
"cohort": {...},
...
}
Fetching via pulse_examples_get returns the request body with the _meta
block already stripped, so you can pass it straight to
pulse_process / pulse_predict.
Searching the library
Three filter dimensions, all optional and combined with AND:
| Filter | Behaviour |
|---|---|
query | Case-insensitive substring across the example’s name, description, and operator list |
tags | An example must carry every requested tag |
category | Exact match against the example’s directory (aggregations, attributes, features, filterers, groupers, regression, tests, windows) |
CLI
pulse examples search --query welch # find Welch-related examples
pulse examples search --tag time-series --tag tier-2-test # AND tag filter
pulse examples search --category tests --json # JSON envelope
pulse examples show t_test_one_sample # print runnable JSON
pulse examples show t_test_one_sample --json # full record (with _meta)
MCP
// arguments to pulse_examples_search
{"query": "welch"}
{"tags": ["time-series", "tier-2-test"]}
{"category": "features"}
Go API
p, _ := pulse.New(pulse.Options{DataDir: "/data"})
// Search:
hits := p.ExamplesSearch("welch", []string{"experiment-analysis"}, "")
for _, h := range hits {
fmt.Println(h.Name, "—", h.Description)
}
// Fetch and run:
ex, ok := p.ExampleGet("t_test_one_sample")
if ok {
var req pulse.Request
_ = json.Unmarshal(ex.Body, &req)
resp, _ := p.Process(ctx, &req)
_ = resp
}
Tag taxonomy
Tags are curated and validated by a CI gate (TestExamples_TagsFromTaxonomy).
The taxonomy spans four dimensions:
| Dimension | Tags |
|---|---|
| Domain / use case | time-series, cohort-analysis, experiment-analysis, correlation-analysis, comparison, before-after, top-n, distribution-shape, cross-tabulation, proportion-analysis, trend-detection, outlier-detection, cardinality-analysis, data-quality, geo-analysis, financial, feature-engineering |
| Statistical method | hypothesis-test, t-test, parametric, nonparametric, paired, one-sample, two-sample, k-sample, repeated-measures, post-hoc, normality-test, homogeneity-test, exact-test |
| Regression / modeling | regression, ecological, ols, glm, logistic, bayesian, regularization, ridge, lasso, elasticnet, polynomial, resampling, jackknife, selection, stepwise |
| Pipeline machinery | tier-1-test, tier-2-test, composed, pre-filter, feature-pipeline, window-operator, streaming-friendly, buffered-pipeline |
| Matrices and multivariate tests | matrix, covariance, multivariate |
| Risk / edge | leakage-risk, small-sample |
| Cohort shape | sharded, anchor |
| Result decoration | overlay, byte-equal-test, compose, crosstab, welch, welford-triple, z |
The category (directory name) is not repeated in the tags — _meta.category
carries that.
Adding a new example
- Write the request JSON under
internal/examples/<category>/. Use existing files as shape templates. Keepcohort.data_dir = ".data"and reference one of the fixture cohorts. - Add a
_metablock at the top of the file:name— kebab-case-with-underscores, unique across the whole library.category— must match the parent directory.tags— pick 3-6 from the taxonomy above.operators— the list ofAGG_* / ATTR_* / FILTER_* / GROUP_* / WIN_* / FEAT_* / TEST_*types appearing in the body, alphabetized and deduped.description— one-sentence, present-tense summary.
- Re-run
go test ./internal/examples/... ./internal/descriptor/...to confirm the new file passes:TestExamples_AllParseAsRequestTestExamples_UniqueNamesTestExamples_TagsFromTaxonomyTestExamples_OperatorsMatchBodyTestExamples_CategoryMatchesDirectoryTestManifestExamplesPopulated
- Keep the
_metablock in sync by hand when the body changes —TestExamples_OperatorsMatchBodyfails the momentoperatorsdrifts from the types the request actually uses.
Regression Modeling
Pulse exposes regression through a compact, composable surface. A small set of REG_* operators (pulse manifest --json lists them under regressions), two orthogonal modifiers, and one upstream feature transform together cover every textbook regression variant. This chapter is the human-facing counterpart to skills/regression-modeling.md; agents should fetch the skill via pulse_skills_get rather than read this page.
Overview
| Operator | Engine | Streaming |
|---|---|---|
REG_OLS | Ordinary least squares + optional regularization | Streams sufficient statistics (Phase 1 + 2) |
REG_GLM | Generalized linear model via IRLS | Always buffered (Newton-Raphson refit) |
REG_BAYES_LINEAR | Bayesian linear regression (conjugate NIG) | Streams sufficient statistics (Phase 4) |
Two spec-level modifiers compose with any of the three:
Resample ∈ {jackknife, bootstrap}— replaces analytical SE / p-values with resample-based estimates. Forces buffered.Selection ∈ {forward, backward, stepwise}— drives AIC- or BIC-based greedy subset search. RequiresCriterion. Forces buffered.
One upstream feature operator (FEAT_POLY) extends the linear core to polynomial regression. Per-row attributes (ATTR_REG_FITTED, ATTR_REG_RESIDUAL, ATTR_REG_LEVERAGE) attach per-record diagnostics in the output row stream.
The 13 textbook names → Pulse specs
The Indeed regression taxonomy double-counts (Simple ≡ Linear univariate, Multiple ≡ Multiple Linear) and treats orthogonal wrappers (Jackknife, Stepwise) as families. Pulse does not. The table below maps each textbook name onto the corresponding Pulse spec and links to a runnable example file under internal/examples/regression/.
| # | Indeed name | Pulse expression | Example |
|---|---|---|---|
| 1 | Simple | REG_OLS with one predictor | internal/examples/regression/02_simple_linear.json |
| 2 | Multiple | REG_OLS with multiple predictors | internal/examples/regression/03_multiple_linear.json |
| 3 | Linear | = #1 | internal/examples/regression/02_simple_linear.json |
| 4 | Multiple Linear | = #2 | internal/examples/regression/03_multiple_linear.json |
| 5 | Logistic | REG_GLM{Family:"binomial", Link:"logit"} | internal/examples/regression/04_logistic.json |
| 6 | Ridge | REG_OLS{Penalty:"l2", Alpha:λ} | internal/examples/regression/05_ridge.json |
| 7 | Lasso | REG_OLS{Penalty:"l1", Alpha:λ} | internal/examples/regression/06_lasso.json |
| 8 | Polynomial | FEAT_POLY{Field:x, Degree:n} upstream → REG_OLS | internal/examples/regression/07_polynomial.json |
| 9 | Bayesian Linear | REG_BAYES_LINEAR{Prior:"nig"} | internal/examples/regression/08_bayesian_linear.json |
| 10 | Jackknife | any regression with Resample:"jackknife" | internal/examples/regression/09_jackknife.json |
| 11 | Elastic Net | REG_OLS{Penalty:"elasticnet", Alpha, L1Ratio} | internal/examples/regression/10_elasticnet.json |
| 12 | Ecological | GROUP_* upstream → REG_OLS over group means (composed request) | internal/examples/regression/01_ecological_fallacy.json |
| 13 | Stepwise | any regression with Selection:"stepwise", Criterion:"aic"|"bic" | internal/examples/regression/11_stepwise.json |
Streamability matrix
| Spec | Streamable | Memory | Notes |
|---|---|---|---|
REG_OLS no penalty | yes | O(p²) | sufficient stats: n, Σx, Σy, XᵀX, Xᵀy, Σy² |
REG_OLS + l1 / l2 / elasticnet | yes | O(p²) | streaming Gram; regularized solve at finalize |
REG_BAYES_LINEAR (conjugate NIG) | yes | O(p²) | streaming sufficient stats + closed-form posterior update |
REG_GLM (binomial / poisson / gamma) | no | O(n·p) | IRLS / Newton requires multiple passes |
Any regression with Resample != "" | no | O(n·p) | LOO / bootstrap refit |
Any regression with Selection != "" | no | O(n·p) | refit per candidate subset |
REG_OLS / REG_BAYES_LINEAR with a robust covariance (not classical) | no | O(n·p) | second pass for residuals / leverage |
pulse_predict reports per-request streamability on PredictResult.Streamable, mirroring the runtime gate.
Operator reference
REG_OLS
Ordinary least squares with optional regularization.
| Param | Required | Notes |
|---|---|---|
target | yes | Numeric response field. |
predictors | yes | One or more numeric predictor fields. |
penalty | no | "" (default), "l1", "l2", or "elasticnet". |
alpha | conditional | Required and > 0 when penalty != "". |
l1_ratio | conditional | Required and in [0, 1] when penalty == "elasticnet". |
max_iters | no | Coordinate-descent cap (default 1000). |
tol | no | Convergence tolerance (default 1e-6). |
resample | no | "jackknife" or "bootstrap". Downgrades streaming. |
selection | no | "forward", "backward", or "stepwise". Requires criterion. Downgrades streaming. |
conf_level | no | Coverage of ci_lower / ci_upper, 0 < c < 1. Default 0.95. Student t on the residual df. |
covariance | no | {type, cluster, order_by, lags}: classical (default), hc0–hc3, cluster (CR1 on cluster’s levels, df = clusters − 1) or hac (Newey–West over order_by, lags an integer or "auto"). Drives std_errors, p_values, the intervals and vcov; refused beside a penalty, resample or selection (PROCESSING_REGRESSION_VCOV_UNSUPPORTED). Downgrades streaming. |
Modifier compatibility: Resample and Selection may be combined; Selection runs first, Resample re-fits the selected subset.
Error codes: PROCESSING_REGRESSION_RANK_DEFICIENT, PROCESSING_REGRESSION_SINGULAR_GRAM, PROCESSING_REGRESSION_NO_CONVERGE, PROCESSING_REGRESSION_INSUFFICIENT_DATA, PROCESSING_REGRESSION_APPROXIMATE_SE (warning, l1/elasticnet without resample), PROCESSING_REGRESSION_REGULARIZED_SELECTION (warning, penalty + selection), PROCESSING_CONFIG.
REG_GLM
Generalized linear model via iteratively-reweighted least squares.
| Param | Required | Notes |
|---|---|---|
target | yes | Numeric response. |
predictors | yes | One or more numeric predictor fields. |
family | yes | "binomial", "poisson", or "gamma". |
link | no | Family-specific default when empty (binomial→logit, poisson→log, gamma→inverse). |
max_iters | no | IRLS iteration cap (default 50). |
tol | no | Convergence tolerance (default 1e-8). |
resample | no | "jackknife" or "bootstrap". |
selection | no | Subset-selection wrapper; requires criterion. |
conf_level | no | Coverage of the Wald ci_lower / ci_upper, 0 < c < 1. Default 0.95. |
covariance | no | As REG_OLS: the sandwich over the IRLS scores at the converged weights (R sandwich on glm) behind std_errors, p_values, the intervals and vcov. Inference stays Wald z, cluster included. |
Always buffered. Setting penalty / alpha / l1_ratio on a REG_GLM spec is rejected with PROCESSING_CONFIG; regularized GLM is reserved for a later phase.
Error codes: PROCESSING_REGRESSION_INVALID_FAMILY, PROCESSING_REGRESSION_INVALID_LINK, PROCESSING_REGRESSION_NO_CONVERGE, PROCESSING_REGRESSION_INSUFFICIENT_DATA, PROCESSING_CONFIG.
REG_BAYES_LINEAR
Bayesian linear regression with a conjugate Normal-Inverse-Gamma prior.
| Param | Required | Notes |
|---|---|---|
target | yes | Numeric response. |
predictors | yes | One or more numeric predictor fields. |
prior | no | Only "nig" accepted in v1. Default "nig". |
prior_mu | no | Length p+1 mean vector (intercept first); defaults to zero. |
prior_precision | no | Scalar ε ≥ 0 on the precision matrix ε·I. Default 1e-3. |
prior_shape | no | Inverse-gamma shape a₀. Default 1e-3. |
prior_rate | no | Inverse-gamma rate b₀. Default 1e-3. |
credible_level | no | Posterior interval mass. Default 0.95. |
conf_level | no | Mass of ci_lower / ci_upper (the same posterior t interval). Defaults to credible_level. |
covariance | no | As REG_OLS. Keeps the posterior-mean coefficients but REPLACES the posterior covariance with Müller’s (2013) sandwich posterior N(μ_n, Λ_n⁻¹·M·Λ_n⁻¹): std_errors, credible_intervals, ci_lower / ci_upper (normal, no df) and vcov follow it. A substitution, not the conjugate posterior. Downgrades streaming. |
Every fit with standard errors reports ci_lower / ci_upper (keyed like coefficients), conf_level and, for a t interval, df: OLS t on the residual df (N* − p − 1 on a weighted fit), GLM Wald normal (R’s confint.default), Bayes the posterior t on 2·a_n, and any fit under resample normal on the resampled standard errors. An interval exists exactly where a standard error does; an undefined bound is null.
Modifier compatibility: Resample and Selection are rejected for REG_BAYES_LINEAR at spec validation — the posterior already conveys uncertainty via credible intervals, and stepwise feature selection on a Bayesian model is a posterior-based question the conjugate-NIG engine doesn’t support.
Setting penalty / alpha / l1_ratio / family / link on a Bayes spec is rejected with PROCESSING_CONFIG.
Error codes: PROCESSING_REGRESSION_RANK_DEFICIENT, PROCESSING_REGRESSION_INSUFFICIENT_DATA, PROCESSING_CONFIG.
Modifiers
Resample
Layered on top of any base operator (except REG_BAYES_LINEAR).
| Value | Behavior |
|---|---|
"" | No resampling. Closed-form / asymptotic standard errors. |
"jackknife" | Leave-one-out resampling. SE = sqrt((n−1)/n · Σᵢ (β⁽⁻ⁱ⁾ − β̄)²). |
"bootstrap" | Non-parametric bootstrap. bootstrap_iters (default 1000), rng_seed (0 → time-seeded; non-zero → reproducible). |
For l1 / elasticnet OLS, setting Resample is the rigorous answer for standard errors: it suppresses the PROCESSING_REGRESSION_APPROXIMATE_SE warning (the SEs are now resample-based, not plug-in over the active set).
Selection
Layered on top of any base operator (except REG_BAYES_LINEAR).
| Value | Behavior |
|---|---|
"" | No subset selection. |
"forward" | Start from intercept-only; add the predictor that lowers the criterion most. |
"backward" | Start from full model; remove the predictor whose absence lowers the criterion most. |
"stepwise" | Bidirectional sweep; try every add and every remove per cycle. |
Requires Criterion ∈ {"aic", "bic"}.
- AIC =
-2·logL + 2·k. Lighter penalty; may retain weak predictors at moderaten. - BIC =
-2·logL + log(n)·k. Heavier per-parameter penalty; rejects noise predictors more reliably at moderaten.
SelectedFeatures lists the chosen subset; Coefficients drops non-selected predictors entirely (absence ≠ zero — selection’s contract is stronger). Selection may be combined with Resample: Selection picks the active subset, then Resample replaces SE / p-values on the selected model.
Compositional patterns
Polynomial regression — FEAT_POLY + REG_OLS
Polynomial regression is linear in the coefficients; the non-linearity lives in the feature space. Use FEAT_POLY upstream to materialize x_2, x_3, …, x_<degree> derived columns, then list them alongside the original x in predictors:
{
"features": [
{"type": "FEAT_POLY", "field": "x", "label": "x", "params": {"degree": 3}}
],
"regressions": [
{"type": "REG_OLS", "name": "polyfit", "target": "y",
"predictors": ["x", "x_2", "x_3"]}
]
}
Degree is gated at [2, 10]. Numerical stability is the caller’s responsibility: x^10 overflows f64 once |x| clears a few hundred, and the Gram matrix conditions poorly long before that. Centre or standardize predictors before requesting FEAT_POLY.
Ecological regression — group → regress
“Ecological regression” is a regression fit on aggregated group-level statistics — per-precinct means, per-county sums, per-region rates — rather than individual-level rows. Use pulse_compose with two slots: slot 1 produces per-group means via GROUP_* + AGG_AVERAGE, slot 2 fits REG_OLS over the aggregate output (or, in practice, over a pre-aggregated .pulse file).
The two slots are intentionally independent; Pulse does not pipe slot-1 results into slot-2 as cohort input. Either (a) materialize slot 1’s aggregate as its own .pulse cohort upstream, or (b) treat slot 1 as the audit trail (per-group means visible in the composed response) and run slot 2 over a pre-aggregated fixture.
Caution — the ecological fallacy. A significant group-level slope does not imply an individual-level association. Robinson (1950) showed that ecological correlations and individual correlations can take opposite signs in the same data: a per-state regression of literacy on race might suggest a strong relationship that vanishes (or reverses) at the per-person level. Aggregation collapses within-group variation, leaving only between-group structure that frequently encodes confounders.
When ecological regression is the right tool: aggregate-only data (census output, public-health summary tables); genuinely group-level research questions (“do counties with higher median income have higher turnout?”). When it is the wrong tool: individual-level claims; causal claims. Annotate consumer-facing prose with this caveat; Pulse cannot enforce it.
Robinson, W.S. (1950). “Ecological Correlations and the Behavior of Individuals.” American Sociological Review 15(3): 351–357.
Per-row regression attributes
Three attribute operators emit per-record diagnostics from a fitted regression onto the row stream.
| Attribute | Emits per row |
|---|---|
ATTR_REG_FITTED | ŷ_i = Xᵢ β — the model’s prediction at each row. |
ATTR_REG_RESIDUAL | y_i − ŷ_i — the per-row residual. |
ATTR_REG_LEVERAGE | h_ii = Xᵢ (XᵀX)⁻¹ Xᵢᵀ — the i-th diagonal of the hat matrix. |
Each attribute references a sibling regression spec by regression_name. See skills/attribute-composition.md for the parameter table.
Error codes
Look up full prose via pulse_errors_lookup or pulse errors lookup CODE.
| Code | Meaning (one-liner) |
|---|---|
PROCESSING_REGRESSION_NOT_IMPLEMENTED | Reserved as of Phase 8; no engine returns this today. |
PROCESSING_REGRESSION_RANK_DEFICIENT | XᵀX is singular; add regularization or drop a predictor. |
PROCESSING_REGRESSION_NO_CONVERGE | IRLS or coordinate descent failed within MaxIters. |
PROCESSING_REGRESSION_SINGULAR_GRAM | XᵀX non-invertible even after regularization; increase alpha. |
PROCESSING_REGRESSION_INVALID_FAMILY | REG_GLM Family outside {binomial, poisson, gamma}. |
PROCESSING_REGRESSION_INVALID_LINK | Link incompatible with the chosen Family. |
PROCESSING_REGRESSION_INSUFFICIENT_DATA | Filtered set has fewer rows than predictors + 1, or below resample minimum. |
PROCESSING_REGRESSION_APPROXIMATE_SE | Warning: l1 / elasticnet SE is a plug-in approximation; set resample for rigor. |
PROCESSING_REGRESSION_REGULARIZED_SELECTION | Warning: penalty != "" plus selection != "" is unusual. |
PROCESSING_CONFIG | Invalid spec combination (e.g. Bayes + Resample, GLM + Penalty). |
Worked examples
Every Indeed name has a runnable JSON file under internal/examples/regression/. Fetch via pulse_examples_get or read directly:
- 01_ecological_fallacy.json — per-region aggregation + ecological caveat (#12).
- 02_simple_linear.json — univariate OLS (#1, #3).
- 03_multiple_linear.json — multivariate OLS (#2, #4).
- 04_logistic.json — binary classification (#5).
- 05_ridge.json — l2 penalty (#6).
- 06_lasso.json — l1 penalty (#7).
- 07_polynomial.json —
FEAT_POLY+ OLS (#8). - 08_bayesian_linear.json — conjugate NIG (#9).
- 09_jackknife.json — leave-one-out resampling (#10).
- 10_elasticnet.json — combined l1 / l2 penalty (#11).
- 11_stepwise.json — BIC-driven stepwise selection (#13).
Architecture Overview
Source of truth: the canonical architectural contract is
CLAUDE.mdat the repository root, with its long form in.claude/reference/architecture.md. This chapter restates its design principles for human readers; if they ever disagree, CLAUDE.md is authoritative.
Pulse is a high-performance, self-describing tabular data processing engine. It
ships as a Go library (github.com/frankbardon/pulse) and as a CLI binary
(cmd/pulse/). The library is the primary deliverable; the CLI is a thin
adapter over it.
Design principles
- Library-first. The
pulse.gofacade (pulse.New,pulse.Optionsand the methods on*pulse.Pulse—Process,Compose,Import,Export,Convert,Inspect,InspectBytes,Predict,PredictBytes,Sample,Facet,CohortArtifacts, …) is the public API. The CLI calls the library; it never contains business logic. - A deliberate public surface. Only the root package and a small set of
“noun” packages (
types,errors,encoding,descriptor,io,synth,mcp/gosdk,mcpserve,extend) are importable. The engine, the adapters, the MCP core and every support package live underinternal/.TestPublicAPIGoldenfreezes the exported shape; see Package Layout. - Self-describing. Every
.pulsefile carries its schema in the header. The no-executemanifest,predictandinspectoperations (implemented ininternal/descriptor/, result types in the publicdescriptor/) expose the system’s capabilities and validate requests without executing them. - Skill-augmented.
internal/skills/embeds the markdown skill pack into the binary via//go:embed. Agents load it through the MCP skill tools and thepulse-skill://resources, and discover the rest of Pulse’s capabilities viapulse manifest --json. - Embedder-first, consumer-agnostic. Pulse is a standalone engine with no dependency on any consumer. Library embedders are a first-class audience; harnesses discover Pulse through the manifest and the embedded skills.
The next chapter, Package Layout, shows where each of these concerns lives in the source tree.
Package Layout
Source of truth: this tree is mirrored from
.claude/reference/architecture.md, which CLAUDE.md’s “Architecture” section points at. If the project structure changes, that file is updated first; this page follows.
Every package outside internal/ and cmd/ is public Go API: tagging
v1.0.0 freezes each of its exported identifiers. The public set is
deliberately small; everything else lives under internal/.
pulse/
├── pulse.go, *.go # PUBLIC facade — pulse.New, pulse.Options, *Pulse methods, root aliases
├── types/ # PUBLIC request/response structs; Go AND JSON names frozen
├── errors/ # PUBLIC typed error codes (CodedError, every code, Lookup/Search/ByDomain)
├── encoding/ # PUBLIC schema nouns + ungrouped (0x01) raw-byte primitives
├── descriptor/ # PUBLIC result + envelope types (Envelope, Manifest, PredictResult, InspectResult)
├── io/ # PUBLIC alias facade: jobs, Reader/Writer, io.Format + factory
├── synth/ # PUBLIC alias facade: Spec, Profile, Synth, SynthBytes, ProfileFile, …
├── mcp/
│ └── gosdk/ # PUBLIC go-sdk adapter — the only package importing the MCP SDK; gosdk.Register
├── mcpserve/ # PUBLIC ready-made MCP server (Serve, ServeStdio)
├── extend/ # PUBLIC extension-authoring contract: Record, Rows, operator factories + interfaces
├── cmd/
│ └── pulse/ # CLI binary (the only binary)
├── docs/ # mdBook source for this site (published to GitHub Pages)
└── internal/
├── cli/ # CLI leaves, flags, envelopes (no business logic)
├── service/ # Orchestration layer; wires processing to encoding
├── descriptor/ # manifest / predict / inspect / payload-schema builders, capabilities_*.go
├── encoding/ # codec remainder: shard archives, groups, decode plans, sidecar index
├── encodingbridge/ # init-installed hooks from public encoding to its internal twin
├── temporal/ # epoch-day / calendar / zone math, leaf over stdlib + errors (TestNoZoneMathOutsideTemporal)
├── iocore/ # Reader / Writer contracts the public io aliases
├── io/ # import/export/convert jobs, inference, transfer
│ ├── csv/ tsv/ ndjson/ jsonarray/ jsonshared/
│ ├── arrow/ parquet/ excel/ spss/
│ └── exportoverlay/ nullcell/ settristate/ setwide/
├── synth/ # synthetic generator, profile capture, structural rules, fidelity
├── template/ # request templating
├── mcp/ # SDK-free MCP core (typed In/Out, reflected schemas, handlers, bind)
│ └── toolmeta/ # leaf metadata (tool names + descriptions) shared by descriptor + core
├── skills/ # embedded markdown skill pack (//go:embed *.md; no index file)
├── examples/ # embedded runnable request library
├── fs/ # afero-based filesystem config (fs.Default, fs.NewMemMap)
├── imports/ # managed-imports manager
├── daterange/ # compiled labeled-date-range model
├── spsssidecar/ # SPSS sidecar path helpers
├── facadebridge/ # root -> mcp/gosdk hooks
├── processing/ # operator engine: operators, crosstab, joins, registry (NOT importable by embedders; extension authors use public `extend/`)
│ ├── window/ # WIN_* operators (LAG, LEAD, RANK, RUNNING_*, EWMA, ...)
│ ├── feature/ # FEAT_* pre-filter feature engineers (LOG, SQRT, BUCKETIZE, ...)
│ └── regression/ arena/ # REG_* engine, arena allocator
├── buildinfo/ # version injected by ldflags
├── apigolden/ # TestPublicAPIGolden + testdata/public_api.txt
└── embeddersmoke/ # nested module compiled by `make smoke` against public spellings only
How the narrowed packages are built
- Split in place —
encodinganddescriptorkeep their public subset at the public path; the remainder lives in aninternal/twin with the same package name. - Alias facade —
ioandsynthhold only type aliases, re-declared constants and thin wrappers overinternal/io(+internal/iocore) andinternal/synth. Nothing underinternal/io/**may import the publicio(its factory imports every adapter);TestIOImportBoundaryenforces it. - Root aliases — facade-returned types from internal packages keep their
pulse.Xspelling as aliases (ComposeOptions,RowIter, the shard and index result types,ImportSpec,Example,Template, …).
Embedders adapting from the earlier layout: see the embedder migration guide
in the repository roadmap (docs/roadmap/v1.0.0-api-and-release/03-embedder-migration.md).
Extension Points
Audience: Pulse embedders writing Go code that calls
pulse.New(pulse.Options{Extensions: ...}) to inject domain-specific
operators or expression-runtime extensions. If you are adding a new
built-in operator to Pulse itself, see Adding an
Aggregator (and its sibling recipes) instead.
The extension surface is Go-native. There is no plugin loader, no .so
files, no hot reload. Registration happens once when the embedding
binary constructs its Pulse instance, and the registration set is fixed
for the lifetime of that instance — restart to change it. Registered
extensions are first-class participants in Predict / Inspect / Process
/ Compose / Manifest: the runtime treats them identically to built-ins,
the manifest advertises them, and the schema-bound MCP tools include
their names in per-category enums.
You author operators against the public extend package — the
interfaces (extend.Aggregator, extend.Grouper, …), the read-only
extend.Record / extend.Rows views and the per-category factory
types. The operator engine itself (internal/processing/...) is not
importable by embedders; extend imports only encoding, types and
errors (TestExtendImportBoundary,
TestExtendImportBoundary_NoTransitiveEngine), and no public root
signature names an engine type (TestRootSurfaceNamesNoProcessing).
Engine-only capabilities — MetaWindow, ExtensionAware, KeyFor
and the engine’s merge hooks (MergeOnline, MergeGrouperState) —
are deliberately absent from extend
(TestExtendOmitsEngineOnlyCapabilities); merging is public through
extend.MergeableAggregator / extend.MergeableGrouper, behind an
explicit Mergeable declaration.
The implementation lives in the repository root: extensions.go
(public registration types), extensions_adapt.go (the adapters that
lift each extend operator onto the engine at pulse.New),
extensions_validate.go (name + registration shape checks),
extensions_probe.go (factory probe and components parity),
extensions_runtime.go (built-in/extension fold into the runtime
registry), extensions_snapshot.go (descriptor-side read-only
projection), and internal/processing/extensions.go (the runtime
overlay the engine consults).
The extend contract: Record, Rows and reuse
extend.Record exposes Schema, IsNull, NumericValue,
StringValue, SetMaskValue, DecimalValue and Weight; extend.Rows exposes
Len and At(i). Accessors never return an error — every “no value”
answer is a false second return. The silent cases: NumericValue is
false for a null, projected-out or missing field AND for every
set_* field (read those with SetMaskValue); a categorical field’s
NumericValue is its dictionary INDEX; date is epoch days and
datetime epoch seconds; u64 above 2^53 loses precision through the
float echo; decimal128 needs DecimalValue for exactness.
Reuse contract. The engine decodes into reusable buffers, so a
Record or Rows is valid ONLY for the call that received it. Never
retain one (copy out values; encoding.SetMask and
encoding.Decimal128 are plain values), never mutate anything reachable
from a Record including its *encoding.Schema. Record and Rows
are consumer-only: Pulse implements them, embedders call them, and
methods may be added in a minor release.
Row weights: WeightAware and Record.Weight()
AggregatorRegistration, AttributeRegistration and
TestRegistration carry WeightAware bool, projected as the manifest
extension entry’s weight_aware (omitted when false). A WeightAware
operator reads the row weight the engine resolved for its slot — slot
weight → request weight → pulse.Options.DefaultWeight — through
extend.Record.Weight() (float64, bool):
ok == falsemeans no weight is in force on the slot (or the operator is not WeightAware — every other operator, and every filterer, grouper, window and feature, always sees(0, false)).- Only a VALID weight is ever reported: finite and non-negative (zero
included), and an integer under
kind: frequency. A row whose weight is invalid never reaches a WeightAware aggregator or tier-1 row test — it is excluded and counted (n_weight_invalid, onePULSE_WEIGHT_INVALID_ROWSwarning per weight column). A WeightAware attribute owes every row a value, so it still receives such a row, withWeight()reporting false. - The factory sees the resolved weight on its spec (
spec.Weight.Spec()gives{field, kind}with the kind spelled out);nilwhen no weight applies. - The orchestrator stamps the weighted floor keys (
sum_weights,n_eff,n_weight_invalid) on a weighted aggregator slot, exactly as for a built-in. The operator never emits them, and they are not declared in itsComponentSchema.
A registration that does NOT declare WeightAware is classed like the
built-ins: an aggregator is skipped under the instance default
(predict reports skipped_not_weight_aware, the slot runs unweighted)
and refused with PULSE_EXTENSION_NOT_WEIGHT_AWARE under an explicit
slot or request weight; an attribute or test is refused under ANY
weight in force, the instance default included (it may read the whole
population, like the built-in tests and reference attributes). Every
refusal is opted out of with "weight": null on the slot. Predict,
manifest and runtime read the same declaration, so they always agree.
A WeightAware extension test is applied, not refused — unlike a
built-in TEST_*, the registration has declared its weighted form.
func (a *wsum) UpdateRow(rec extend.Record, field string) error {
x, ok := rec.NumericValue(field)
if !ok {
return nil
}
w, weighted := rec.Weight()
if !weighted {
w = 1
}
a.sum += w * x
return nil
}
Contract long form: .claude/reference/weighting.md (Extension
contract).
When to register vs use a built-in
Use a built-in operator when the semantics already ship with Pulse,
or when an ATTR_FORMULA plus a custom ExprFunction covers the
behaviour. Use a registered extension when you need:
- An operator that encodes proprietary business logic that should not live in Pulse core (e.g. a domain-specific composite score).
- A closed-form function call from within
ATTR_FORMULA/FILTER_EXPRESSION(e.g.rank_familiarity(value, total_pop)). - A static keyed lookup table that drives multipliers or calibration
factors (e.g. per-
(study, wave)adjustment). - Manifest + MCP visibility so LLM agents can discover and invoke your operators alongside Pulse built-ins.
The Extensions struct
The full public surface is a single struct on pulse.Options:
import "github.com/frankbardon/pulse"
ext := pulse.Extensions{
Aggregators: []pulse.AggregatorRegistration{...},
Attributes: []pulse.AttributeRegistration{...},
Filterers: []pulse.FiltererRegistration{...},
Groupers: []pulse.GrouperRegistration{...},
Windows: []pulse.WindowRegistration{...},
Features: []pulse.FeatureRegistration{...},
Tests: []pulse.TestRegistration{...},
SynthDistributions: []pulse.DistributionRegistration{...},
ExprFunctions: []pulse.ExprFunction{...},
LookupTables: map[string]pulse.LookupTable{...},
Skills: skillsFS, // fs.FS of embedder skill files — see "Embedder skills"
Examples: examplesFS, // fs.FS of embedder request examples — see "Embedder examples"
}
p, err := pulse.New(pulse.Options{
FS: myFs,
Extensions: ext,
})
A zero-value Extensions is the no-op case — pulse.New behaves
exactly as the unmodified binary. All registrations are validated
together; the first failure short-circuits pulse.New with a typed
CodedError.
Naming policy
Embedder registrations MUST use a three-segment namespaced form:
<CATEGORY>_<NAMESPACE>_<NAME>
The regex enforced by extensions_validate.go is:
^(AGG|ATTR|FILTER|GROUP|WIN|FEAT|TEST|SYNTH)_[A-Z][A-Z0-9]+_[A-Z](?:[A-Z0-9_]*[A-Z0-9])?$
| Example | Category | Namespace | Name |
|---|---|---|---|
AGG_ACME_BRAND_SCORE | aggregator | ACME | BRAND_SCORE |
ATTR_ACME_ADJUSTMENT | attribute | ACME | ADJUSTMENT |
FILTER_ACME_GEO_FENCE | filterer | ACME | GEO_FENCE |
TEST_FINANCE_VAR | test | FINANCE | VAR |
Failure modes raised at registration time:
- Name fails the regex →
PULSE_EXTENSION_NAME_INVALID. - Namespace is one of the reserved values
BUILTIN,STANDARD,CORE,PULSE→PULSE_EXTENSION_NAME_RESERVED. - Name collides with a built-in (e.g. registering
AGG_COUNT) →PULSE_EXTENSION_NAME_COLLISION. - The same name appears twice in one
pulse.Newcall →PULSE_EXTENSION_DUPLICATE.
Two embedders in the same process must use disjoint namespaces.
Per-category registration shapes
The eight operator categories plus expression functions and lookup
tables cover everything pulse.Options.Extensions accepts. Field-input
introspection and component-schema emission attach to operator
registrations as additional optional fields — see the dedicated
sections below.
Aggregator
{
Name: "AGG_ACME_BRAND_SCORE",
Description: "ACME brand composite (0-100).",
Factory: acme.NewBrandScoreAggregator, // extend.AggregatorFactory
Streamable: true, // factory MUST return extend.OnlineAggregator
Mergeable: true, // factory MUST return extend.MergeableAggregator; needs Streamable
MarginReducibility: types.MarginSummable, // optional: fused crosstab cell; needs Mergeable
Accepts: []encoding.FieldType{encoding.FieldTypeF64},
Params: []pulse.ParamMeta{{Name: "weights", JSONType: "array"}},
ComponentSchema: descriptor.ComponentSchema{ /* see below */ },
ComponentsFunc: func(instance extend.Aggregator) (map[string]any, error) { /* ... */ },
}
One-of-several params. When the factory takes exactly one of
several params (an EWMA’s span or halflife), tag each with the same
ParamMeta.ExactlyOneOf group name. The manifest shows it as
exactly_one_of, and Recommend drafts the group’s first member as a
need, naming the others as alternatives. A group needs two or more
members, none Required and none with a Default; anything else is
PULSE_EXTENSION_PARAM_INVALID. The factory still enforces the rule.
When Streamable=true, the probe at pulse.New time asserts that the
factory’s returned value implements extend.OnlineAggregator.
Mismatch surfaces as PULSE_EXTENSION_STREAMABLE_MISMATCH. The
declaration is authoritative at run time: a Streamable=false
aggregator runs buffered even when its value also implements
extend.OnlineAggregator, so PredictResult.Streamable (which reads
the declaration) and the engine never disagree.
Aggregators are authored against the public extend package:
Aggregate(rows extend.Rows, field) receives a zero-copy view of the
buffered rows, UpdateRow(rec extend.Record, field) one row at a time.
A Record / Rows is valid only for the call that received it — never
retain one. The adapter installed at pulse.New forwards each optional
sibling (extend.OnlineAggregator, extend.RichAggregator,
extend.MergeableAggregator) explicitly, so a streamable aggregator
that also supplies ComponentsFunc still streams and merges. Read semantics (null, set, categorical, date/datetime, u64,
decimal128) are on each extend.Record method’s godoc.
Decimal128 targets. The built-in decimal table (exact AGG_SUM,
AGG_AVERAGE, … — see skills/financial-cohorts.md) governs built-ins
only: a built-in outside it is refused on a decimal128 field
(PROCESSING_CONFIG at run time, PULSE_AGG_NOT_MEANINGFUL_FOR_DECIMAL
from predict). A registered extension aggregator is never refused — its
factory runs and the extension decides what a decimal field means, reading
the exact value through extend.Record.DecimalValue (NumericValue is a
rounded echo) and typically rendering it via extend.RichAggregator.
Path selection follows the declaration, not the field type: a built-in
over a decimal target always runs buffered (the exact
AggregateDecimalField path), but an extension aggregator declaring
Streamable: true streams it — UpdateRow sees decimal fields via
DecimalValue, exactly as the buffered Aggregate does — and
predict reports the same (Streamable from the snapshot, no decimal
reason). The built-in decimal merge refusal does not apply either: a
Mergeable extension aggregator merges a decimal target under shard /
decode parallelism like any other field.
Mergeable. Mergeable: true admits the aggregator to the parallel
reducers — Options.ShardWorkers over a shard archive and
Options.DecodeWorkers over a large single-file cohort — and to
ProcessChain stages. Each worker folds its partition through
UpdateRow on a fresh instance; the orchestrator then combines the
partials in a deterministic order with
extend.MergeableAggregator.Merge(other) and calls Finalize,
Rich and ComponentsFunc once, on the merged receiver. other is
the embedder’s own value built by the same factory from the same spec,
so other.(*myAgg) succeeds. Merge must be associative; how the
cohort is partitioned depends on the worker count, so a floating-point
fold may differ from the serial answer in the last ULP. The
declaration, not the method set, routes the request: an aggregator
that implements Merge but omits Mergeable runs serially (and a
chain refuses it). Probe-validation refuses Mergeable with
PULSE_EXTENSION_MERGEABLE_MISMATCH when the registration is not also
Streamable (merge folds online state), when the value does not
implement extend.MergeableAggregator, or when ComponentSchema
declares keys with Mergeability: None — the reducers read
Components() off the MERGED instance, so a figure that needs the full
input would be silently wrong. The manifest projects the flag as
extensions.aggregators[].mergeable.
MarginReducibility (fused crosstab cells). A crosstab whose cell is
an extension aggregator takes the fused in-decode arm
(processing.CanFuseCrosstab) only when the registration declares a
margin class — the embedder-side sibling of
types.AggregationType.MarginReducibility(): types.MarginSummable
(the margin is the sum of the cells), types.MarginMeanReducible
(derivable from per-cell value + count) or types.MarginIndependent
(set-valued state whose margin is neither a sum nor a re-scan). Any of
the three admits the cell; omitted or types.MarginRecompute keeps the
crosstab on the buffered arm, which is what every undeclared extension
cell did before. The class gates ADMISSION only: the fused walk gives
every row, column, grand and cross-axis margin its own instance fed
record by record through UpdateRow, exactly as it does for a built-in,
so the class never changes a margin figure — an honest class is still
required, because the manifest and predict read it. A fusable class
requires Mergeable: true (and so Streamable: true) — the fused gate
holds an extension cell to the built-in cell’s mergeable bar — and the
usual fused-gate conditions apply (a FieldInputs hook, keyable
groupers, no tests / features / two-pass attributes). Like the decimal
rule above, a decimal128 extension cell fuses on its declaration while a
built-in decimal cell stays buffered. Probe-validation refuses an
unknown class, or a fusable class without Mergeable, with
PULSE_EXTENSION_MARGIN_REDUCIBILITY_MISMATCH. An extension used as a
margin_aggregations AUXILIARY needs no class — an auxiliary is
independent in role — only Mergeable. The manifest projects the class
as extensions.aggregators[].margin_reducibility, and predict’s
PULSE_CROSSTAB_NORMALIZE_UNSATISFIABLE advisory reads it from the
snapshot.
Attribute
{
Name: "ATTR_ACME_ADJUSTMENT",
Description: "Per-(study, wave) multiplier.",
Factory: newAdjustmentAttribute, // extend.AttributeFactory
Mode: pulse.AttributeModeRowLocal, // row_local | two_pass | buffered
Accepts: []encoding.FieldType{encoding.FieldTypeF64},
Emits: pulse.AttributeEmitFloat64,
}
Mode drives streaming-tier validation: row_local requires
extend.RowLocalAttribute (Row(rec extend.Record, field)),
two_pass requires extend.TwoPassAttribute (adds PrePass +
Finalize), buffered requires only the base
extend.AttributeComputer (Compute(rows extend.Rows, field) ([]float64, error), one value per row, aligned with rows). The probe
asserts the extend sibling on the factory’s own value; the adapter
forwards exactly the tier it implements. Attributes do NOT declare a
ComponentSchema (see the table in the Component schemas section).
Mode also picks the run-time drive on Process: row_local streams
through Row, two_pass takes the streaming PrePass → Finalize →
Row drive, buffered runs Compute over the materialised rows. Like
the built-in two-pass attributes (ATTR_ZSCORE, …), a two_pass
extension runs buffered when the request also carries a grouper,
feature, regression or tier-1 test.
On the streaming drive every attribute runs in declared order, so a
two_pass attribute can read an earlier attribute’s label: the engine
learns which earlier labels it reads from Field plus its
FieldInputs hook. Without the hook it assumes the attribute reads
every earlier attribute — still correct, but each earlier two_pass
attribute then costs an extra scan.
Filterer, Grouper, Window, Feature
All four follow the same envelope: name, description, factory, accepted
types, params metadata. FiltererRegistration and GrouperRegistration
additionally carry ComponentSchema + ComponentsFunc on the same
contract as AggregatorRegistration. Filterers are always row-local
streamable; windows always run buffered.
GrouperRegistration additionally carries FansOut bool — the
embedder-side sibling of types.GroupType.FansOut(), which knows
built-in constants only. Set it true when the factory returns a
value that also implements extend.MultiKeyStreamingGrouper
(KeysForRow), i.e. when one record can land in more than one bucket.
Consumers that reason about per-record denominators need the fact:
under a fan-out grouper the bucket counts SUM to more than the record
total, so an n taken from a slab total double-counts records. The
probe verifies the claim in BOTH directions
(PULSE_EXTENSION_FANOUT_MISMATCH), and omitting the field defaults it
to false — so a multi-key factory is refused rather than silently
admitted as single-key.
A registered grouper is also a MAT_DISTANCE params.between, judged
like a built-in (its vector / fields by the member rules and handed
to the factory as literal Fields; a field must name a schema column;
no own weight or tz). No extra hook is needed: the engine buckets
the rows with your buffered Group in one whole-set pass at finalize,
so the spec is buffered and serial whatever Streamable declares, its
bucket keys alphabetical (or the include order). Projection reads the
FieldInputs hook exactly as for a groups slot.
Groupers and filterers are authored against extend too. A grouper’s
buffered Group(rows extend.Rows, field) returns
map[string][]int — row INDICES into rows, which the adapter maps
back to the engine’s records (an out-of-range index is a
PROCESSING_INTERNAL coded error, never a panic). The streaming
siblings are extend.StreamingGrouper (KeyForRow) and
extend.MultiKeyStreamingGrouper (KeysForRow); returning
extend.ErrGrouperKeyNull from either is the same as ok=false — the
row lands in no bucket. A filterer is an extend.FiltererFactory
returning an extend.FiltererBuilder whose Build compiles an
extend.FilterFunc func(extend.Record) (bool, error). The adapter
forwards each keying sibling explicitly, so a fan-out grouper that also
supplies ComponentsFunc stays multi-key (and fuses in a crosstab).
A single-key extend.StreamingGrouper fuses too: the adapter binds the
types.Group.Field the factory was built for and synthesizes the
engine-only field-bound KeyFor from your KeyForRow, so your method
always receives the real field name. The fused gate reads the
DECLARATION: a crosstab axis grouper fuses iff its registration
declares Streamable=true or FansOut=true (both probe-validated, so
the keying sibling is guaranteed). This is a deliberate change — the
gate used to probe the constructed value’s interface, so a
Streamable=false grouper implementing KeyForRow fused; it now runs
the buffered crosstab arm, with identical output and only peak heap
differing, because pulse predict (PredictResult.CrosstabFusable)
can read only the declaration and both sides run one rule. Declare
Streamable=true to keep such a grouper on the fused arm.
Streamable=true routes the grouped Process request onto the
streaming path, driving KeyForRow / KeysForRow per row; the probe
refuses a Streamable=true registration whose value implements
neither keying sibling (PULSE_EXTENSION_STREAMABLE_MISMATCH). A
Streamable=false grouper runs buffered through Group even when it
can key per row.
Mergeable groupers. Mergeable: true (with Streamable: true)
admits the grouper — single-key or fan-out — to the parallel reducers
(ShardWorkers, DecodeWorkers) and to ProcessChain stages. Each
partition gets its own instance keyed through KeyForRow /
KeysForRow; the engine merges the per-key aggregator buckets and
sums the (record, bucket) assignment count behind the
{total_n, n_null} floor itself. What it cannot merge is your
components state, so a grouper that EMITS components (ComponentsFunc,
or its own Components() method) must implement
extend.MergeableGrouper.MergeState(other): fold other’s counters
into the receiver — other is your own value from the same factory
and spec — and ComponentsFunc then runs once, on the merged receiver.
A grouper that emits no components needs no method; the adapter folds
nothing. Probe-validation refuses Mergeable without Streamable, an
emitting value without MergeState, and ComponentSchema keys
classified None (PULSE_EXTENSION_MERGEABLE_MISMATCH). The flag
reaches the manifest as extensions.groupers[].mergeable. Omitted,
grouped requests naming the grouper run serially and a chain refuses
it.
A window is an extend.WindowFactory taking the spec and an empty
extend.WindowOptions and returning an extend.WindowComputer
(Compute(rows []map[string]any, partitions [][]int, label string));
it writes its column into the materialised result rows in place.
A feature is an extend.FeatureFactory returning an
extend.FeatureComputer: Compute(rows extend.Rows, field) returns
map[string]extend.FeatureOutput keyed by output column, each
{Values, Nulls} aligned with rows. The optional streaming sibling
extend.StreamingFeatureComputer (PrePass, Finalize, EmitRow)
returns single-row outputs from EmitRow; any other shape is a
PROCESSING_INTERNAL coded error. Features only READ rows — the engine
writes the derived columns. Feature streamability is decided on the
returned value (the adapter exposes the streaming sibling iff the
embedder’s value implements it), so a streaming extension feature does
stream on Process.
Test (tier-1 / tier-2)
// Tier-1 (folds during streaming aggregation pass):
{
Name: "TEST_ACME_PROXY",
Tier: pulse.TestTierRow,
RowFactory: newProxyRowTest, // extend.RowTestFactory
Streamable: true,
}
// Tier-2 (runs over materialised result rows after windows):
{
Name: "TEST_ACME_AGGREGATE_CHECK",
Tier: pulse.TestTierPost,
PostFactory: newAggregateCheckPostTest, // extend.PostTestFactory
}
Exactly one of RowFactory / PostFactory must be non-nil and match
Tier. A tier-1 extend.RowTest folds UpdateRow(rec extend.Record)
then Finalize() (*types.TestResult, error); a tier-2 extend.PostTest
runs Run(rows []map[string]any) once over the result rows. Tier-2
tests always run buffered; Streamable on a tier-2 registration is
ignored. A tier-1 test declared Streamable=true co-streams with
online aggregators (UpdateRow folds during the single pass);
Streamable=false forces the request buffered.
An extension TEST_* joins multiple-comparison families exactly like a
built-in: its headline PValue is one member of the request (or
compose) family under the resolved multiplicity block, and the fold
adds p_adjusted, significant_adjusted and multiplicity to its
TestResult. The registration declares nothing for it.
Synth distribution
Reserved for embedders shipping bespoke samplers. There is no extend
factory shape for it yet; the registration validates name + duplicates
and reserves the namespace.
Expression functions
Custom Go functions become callable from ATTR_FORMULA and
FILTER_EXPRESSION:
{
Name: "rank_familiarity",
Description: "ACME brand familiarity rank.",
Signature: "rank_familiarity(value float64, total_pop bool) float64",
Fn: acme.RankFamiliarity,
Pure: true, // declares side-effect-free; reserved for future memoisation
}
Pulse passes Fn to expr-lang’s expr.Function(name, fn). expr-lang
accepts typed functions via reflection — func(v float64) float64 and
func(args ...any) (any, error) both work. Use the variadic shape
when zero-allocation calling matters.
Lookup tables
Static keyed tables exposed via the built-in lookup(table, keys...)
function:
LookupTables: map[string]pulse.LookupTable{
"adjustments": {
Description: "Per-(study, wave-date) calibration multipliers.",
// Rows is the simple path — caller-joined composite key.
Rows: map[string]float64{
"study_a|2025-01-01": 1.07,
"study_a|2025-02-01": 1.12,
},
// OR — Lookup is the escape hatch:
// Lookup: func(keys ...string) (float64, bool, error) { ... }
},
},
Exactly one of Rows / Lookup must be non-nil; validation at
pulse.New returns PULSE_EXTENSION_PARAM_INVALID otherwise.
Rows-backed tables join keys with | before indexing. The Lookup
function-backed path receives the key slice directly — compose keys
however you want, perform partial-match fallback, or pull from an
external store.
At evaluation time lookup() raises PULSE_LOOKUP_TABLE_UNKNOWN for
an unregistered table and PULSE_LOOKUP_MISS for a missing key. Both
wrap into PROCESSING_RUNTIME when surfaced through ATTR_FORMULA /
FILTER_EXPRESSION — use errors.HasCode(err, errors.PULSE_LOOKUP_MISS)
to detect inside the chain.
Component schemas (v0.20.0)
Response.Components is the operator-keyed sibling payload that runs
alongside Response.Data. Every aggregator, grouper, and filterer in
a request lands a typed map under
Response.Components.Aggregations[i].Operator, .Groupers[i].Operator,
or .Filterers[i].Operator. Embedder-registered operators participate
via two coupled optional fields on AggregatorRegistration,
GrouperRegistration, and FiltererRegistration. The universal
contract — typed shells, floor keys, additive omitempty shape —
lives in
skills/response-components.md;
this section covers only the extension-side wiring.
Which categories declare a ComponentSchema?
| Category | Declares ComponentSchema? | Notes |
|---|---|---|
| Aggregator | Yes | Universal floor {n, n_null} filled by orchestrator. |
| Grouper | Yes | Universal floor {total_n, n_null} filled by orchestrator. |
| Filterer | Yes (floor-only valid) | Universal floor {n_in, n_out, n_null_input} filled by orchestrator; in v1 no built-in filterer adds operator-specific keys. |
| Attribute | No | Attributes do not flow into Response.Components. |
| Window | No | Window outputs land in Response.Data rows. |
| Feature | No | Pre-filter; no components surface. |
| Test | No | Test results carry their own typed shape. |
| Synth | No | Generators, not aggregations. |
A registration in a category that does not declare a ComponentSchema
silently ignores the field if you set it.
Declaration shape
type ComponentSchema struct {
Keys []ComponentKey
Mergeability ComponentsMergeability // Mergeable | Partial | None
}
type ComponentKey struct {
Name string // snake_case, matches the runtime emission key
Type string // "int" | "float64" | "string" | "map" | "array" | "object"
Description string // surfaces in manifest + MCP schema
}
Keys is the canonical declared set. Names use snake_case (matches
every other Pulse JSON key — n_null, mode_count, range_min).
Type is a JSON-shape declaration that flows through to the manifest
and schema-bound MCP tools; Description is the one-liner shown in
LLM-bootstrap output.
The orchestrator owns the universal floor keys; the embedder’s
ComponentsFunc returns ONLY operator-specific keys. Two conventions
for what goes in Keys are tolerated by the probe — either list both
floor and operator-specific keys (so a manifest reader sees the full
payload shape), or list only operator-specific keys (the convention
used by built-ins, where readers compose with the per-category floor
table). The runtime contract is the same either way.
Mergeability axis
ComponentsMergeability declares how the components state composes
when multiple chunks (streaming) or shards (parallel) flow into a
single aggregator:
| Value | Meaning | Examples |
|---|---|---|
Mergeable | Stream-safe. State composes through the same MergeOnline path as the scalar value. Streaming chunks carry ComponentsDelta; consumers reconcile. | Welford-family (sums, sums-of-squares), running counts, set masks, weighted accumulators. |
Partial | Unions across chunks but at non-trivial allocation cost — map / set unions where the merge is associative but not constant-space. The orchestrator may stage the merge at terminal flush. | AGG_MODE_COUNT, AGG_MODE, AGG_DISTINCT_COUNT, AGG_DISTINCT_SUM. |
None | Terminal-only. Needs sorted full input. Streaming chunks omit components entirely; only the terminal buffered flush emits. Predict declares the slot buffered-components-only. | AGG_MEDIAN, AGG_PERCENTILE. |
Choose the axis that matches the math, not the convenience of the
registration site. Declaring Mergeable on an operator whose state
cannot actually fold produces silently-wrong components in parallel
shard processing — there is no runtime gate for the math, only the
streaming-tier wiring. For an aggregator or grouper the components
class is separate from the registration’s Mergeable flag (which
decides whether the operator merges at all), but the two must agree: a
Mergeable aggregator or grouper whose components class is None is
refused at pulse.New (PULSE_EXTENSION_MERGEABLE_MISMATCH).
Two emission paths: ComponentsFunc and Components()
There are two equivalent ways to surface operator-specific keys at runtime; pick whichever fits your operator type.
ComponentsFunc closure (the registration-level path). Supply a
closure on the registration; the runtime wraps the factory return
value so the orchestrator can find it:
pulse.AggregatorRegistration{
Name: "AGG_ACME_BRAND_SCORE",
Factory: acme.NewBrandScoreAggregator,
Streamable: true,
Accepts: []encoding.FieldType{encoding.FieldTypeF64},
ComponentSchema: descriptor.ComponentSchema{
Keys: []descriptor.ComponentKey{
{Name: "weighted_sum", Type: "float64", Description: "Running weighted sum."},
{Name: "weights_applied", Type: "int", Description: "Weight multipliers fired."},
},
Mergeability: descriptor.Mergeable,
},
ComponentsFunc: func(instance extend.Aggregator) (map[string]any, error) {
a := instance.(*brandScoreAggregator)
return map[string]any{
"weighted_sum": a.WeightedSum(),
"weights_applied": a.WeightsApplied(),
}, nil
},
}
The closure signatures, defined in extensions.go, are:
type AggregatorComponentsFunc func(instance extend.Aggregator) (map[string]any, error)
type GrouperComponentsFunc func(instance extend.Grouper) (map[string]any, error)
type FiltererComponentsFunc func(instance extend.FiltererBuilder) (map[string]any, error)
The orchestrator invokes each func ONCE after the operator’s terminal
pass (post-Aggregate/Finalize for aggregators; post-partition for
groupers; post-eval for filterers). Returning (nil, nil) is the
canonical signal for “no operator-specific keys; the orchestrator’s
universal floor is the entire payload” — the floor-only shape.
Self-emitting operators (the type-level path). If the value your
factory returns already has a Components() (map[string]any, error)
method, you may leave ComponentsFunc nil — the adapter adopts the
method as the emitter, for aggregators, groupers and filterers alike.
An explicit ComponentsFunc wins over the method. This holds for every
emitting category, and it is an intentional, documented deviation from a
strict “emission only through ComponentsFunc” reading: an operator’s
own Components() method still emits when no ComponentsFunc is
registered.
Probe-validation asserts emitted keys against ComponentSchema.Keys
only for an explicit ComponentsFunc; a self-emitting Components()
method is NOT probe-validated (it is not invoked at pulse.New), so
prefer ComponentsFunc when you want that check. Use ComponentsFunc too when the factory returns a third-party
type you cannot extend.
Floor-only registrations
If both ComponentSchema.Keys is empty and ComponentsFunc is nil
(and the factory’s return value does NOT implement the sibling
interface), the registration is floor-only: the orchestrator emits
the universal floor keys for that category and nothing else. This is
valid and supported, and it is the most common shape for filterer
extensions today — no built-in filterer adds operator-specific keys.
For aggregators it suits simple counter-style operators that do not
need to surface internal state. The probe does NOT reject this shape.
pulse.AggregatorRegistration{
Name: "AGG_ACME_COUNT_NONNEG",
Factory: newNonNegCountAgg,
// No ComponentSchema, no ComponentsFunc — Response.Components carries
// only {"n": ..., "n_null": ...}.
}
Probe-validation parity check
The probe at pulse.New (see extensions_probe.go) exercises each
factory once against a minimal synthetic schema, then asserts the
components contract for AGG / GROUP / FILTER registrations:
| Condition | Error code |
|---|---|
ComponentsFunc set (or the value’s own Components() method adopted) but ComponentSchema.Keys empty | PULSE_EXTENSION_MISSING_COMPONENT_SCHEMA |
ComponentsFunc returns a key NOT present in ComponentSchema.Keys (after the floor-tolerance carve-out), or order diverges from the declared order | PULSE_EXTENSION_COMPONENT_SCHEMA_MISMATCH |
ComponentsFunc returns a universal-floor key the orchestrator owns | PULSE_EXTENSION_COMPONENT_SCHEMA_MISMATCH |
Fetch the Message + Fixup template via pulse errors lookup <CODE>
(CLI) or call pulse_errors_lookup from an MCP session.
Probe validation (full surface)
The probe (extensions_probe.go) runs after schema + name validation
and before pulse.New returns. For every aggregator, attribute,
grouper, and filterer registration it constructs the factory once
against a minimal synthetic schema and asserts:
flowchart TD
A[pulse.New] --> B[validate names + reserved namespaces]
B --> C[probe each factory]
C -->|panic / nil return| F1[PULSE_EXTENSION_FACTORY_PANIC]
C --> D{Streamable declared?}
D -->|yes, interface missing| F2[PULSE_EXTENSION_STREAMABLE_MISMATCH]
D -->|no, or interface satisfied| D2{Grouper FansOut matches MultiKeyStreamingGrouper?}
D2 -->|either direction disagrees| F5[PULSE_EXTENSION_FANOUT_MISMATCH]
D2 -->|agrees, or not a grouper| E{Components contract?}
E -->|emitter, no schema| F3[PULSE_EXTENSION_MISSING_COMPONENT_SCHEMA]
E -->|emitter, key divergence| F4[PULSE_EXTENSION_COMPONENT_SCHEMA_MISMATCH]
E -->|all clear| G[snapshot + runtime overlay]
Factory contract for the probe (documented in extensions_probe.go):
embedder factories MUST tolerate a nil/empty Schema and a spec
carrying only the operator Name. The probe never feeds real records.
A WeightAware aggregator, attribute or test factory is constructed a
second time with a row weight on its spec (a field the empty schema
does not carry) and must pass the same checks; a failure of that
construction reports the same code with details.weighted: true.
Tests are probed only when they declare WeightAware.
Factory panics or nil returns surface as
PULSE_EXTENSION_FACTORY_PANIC. Streamability declarations that do
not match the returned interface surface as
PULSE_EXTENSION_STREAMABLE_MISMATCH. A grouper whose FansOut
declaration disagrees with whether its factory returns
extend.MultiKeyStreamingGrouper — in EITHER direction — surfaces
as PULSE_EXTENSION_FANOUT_MISMATCH; a factory panic is caught first,
so a panicking factory never reports a fan-out mismatch. The
components-contract failures are listed in the table above.
Manifest visibility and the extensions snapshot
pulse manifest --json and pulse_manifest include a top-level
extensions block whenever the host registered anything:
{
"format_version": "1.1",
"components": { ... built-ins ... },
"extensions": {
"aggregators": [
{"name": "AGG_ACME_BRAND_SCORE", "namespace": "ACME", "streamable": true, "...": "..."}
],
"groupers": [
{"name": "GROUP_ACME_PANEL", "namespace": "ACME", "streamable": true, "fans_out": true}
],
"expr_functions": [
{"name": "rank_familiarity", "signature": "rank_familiarity(value float64, total_pop bool) float64"}
],
"lookup_tables": [
{"name": "adjustments", "has_rows_data": true}
]
}
}
The plumbing that fills that block is the extensions snapshot
(extensions_snapshot.go). buildExtensionsSnapshot(ext) translates
the public Extensions struct into the read-only
internal/descriptor.ExtensionsSnapshot projection (internal/descriptor; not
importable by embedders — the facade fills it). The snapshot is passed
into internal/descriptor.PredictOptions.Extensions and into
mcp.BindWithExtensions (internal/mcp; mcp/gosdk reaches the
instance’s snapshot through the internal/facadebridge hook) so the descriptor layer stays
free of internal/service/ and internal/processing/ imports — the no-execute
contract for internal/descriptor/ remains intact, and predict / manifest
treat custom operators identically to built-ins.
LLM agents that call pulse_manifest see both the built-in set and
the embedder additions in one fetch. The schema-bound MCP tools (after
pulse_inspect) also include custom operator names in their enum
lists.
The snapshot carries fans_out and mergeable
OperatorMeta.Mergeable (json:"mergeable,omitempty") projects the
aggregator / grouper Mergeable declaration the same way, and
OperatorMeta.MarginReducibility (json:"margin_reducibility,omitempty")
the aggregator’s declared crosstab margin class (predict’s normalize
advisory reads it through
ExtensionsSnapshot.AggregatorMarginReducibility). The
predict-side chain gate reads it:
internal/descriptor.ValidateChainWithExtensions admits an extension
aggregator or grouper on its declared Mergeable flag and a
row_local extension attribute as row-local and an extension filterer
on its Streamable flag — the same internal/mergegate.ChainRefusal
call the runtime gate makes over the ExtensionRegistry, so the answers
cannot drift — while ValidateChain (the nil-snapshot case) knows
built-ins only.
descriptor.OperatorMeta carries FansOut bool
(json:"fans_out,omitempty") alongside Streamable, and
buildExtensionsSnapshot fills it from
GrouperRegistration.FansOut. It is grouper-only and omitted
everywhere else; absent reads as false, which is also the
registration default.
This is not cosmetic manifest detail — it is the only route the fact
has into the no-execute layer. internal/descriptor/ may not import
internal/processing/ (TestPredictNoExecutionImports), so predict cannot
assert MultiKeyStreamingGrouper on a constructed grouper the way the
probe does. Without the projection, a predict-time rule that reasons
about per-record denominators sees every extension grouper as
single-key.
The consumer today is the distinct-key slab partition gate on the
OVERLAY_PAIRWISE_* family (params.n_source = "n_within_distinct"),
which refuses a pair axis whose summed-across dims include a fan-out
grouper, because summing per-cell DISTINCT counts over cells that do
not partition the key set over-states n. Both arms of that gate
resolve a grouper the same way:
- a built-in constant answers from
types.GroupType.FansOut(); - anything else asks the extension side — the snapshot at predict
(
ExtensionsSnapshot.GrouperFanOut), the live registry at runtime (the engine’s extension registry, fed by the registration’sFansOut); - a name in NEITHER passes. It cannot execute — the runtime refuses to build an unknown group type — so no wrong number can come of it, and refusing here would bury the accurate unknown-operator error under a partition diagnostic about a grouper that does not exist.
Two sources, one order: types.CheckPairwiseSlabPartitionWith owns
steps 1–3 and each arm supplies only its own step-2 resolver, so
predict and runtime cannot drift on the same request. The
runtime registry’s fan-out map holds an entry for every registered
grouper including the false ones, so a missing key means
“registered nowhere” rather than “declared single-key”.
Because the probe already verified the declaration against the factory
at pulse.New (PULSE_EXTENSION_FANOUT_MISMATCH), both arms trust it
without reconstructing the grouper — which matters for the runtime
arm, whose overlay hook has no schema in reach.
DependsOn (feature-profile dependencies)
Every operator registration (aggregator, attribute, filterer, grouper,
window, feature, test) accepts an optional DependsOn []string: the
features the operator needs. Each entry is an AND edge — a
feature profile that enables the
operator must enable every named feature too, or pulse.New fails with
PULSE_FEATURE_PROFILE_DEPENDENCY. Every enabled extension operator
also needs a request host (capability:process, capability:compose
or capability:process_chain), exactly like a built-in.
pulse.AggregatorRegistration{
Name: "AGG_ACME_SPREAD",
DependsOn: []string{"AGG_WELFORD"}, // reads AGG_WELFORD's components
Factory: newSpread,
}
Entries use profile spelling: operators bare, everything else
<kind>:<name> (capability:crosstab, io_format:csv). Each must name
a feature this build knows — a built-in or another registered extension
operator — and that is checked at every pulse.New, with or
without a profile, so a typo fails at construction with
PULSE_FEATURE_PROFILE_UNKNOWN (each entry carries extension and
category). Extension operators carry no Since and are listed in a
profile by their registered name. Synth-distribution registrations have
no DependsOn: distributions are not features (capability:synth
gates them all).
Purpose and Interpretation (guidance metadata)
Every registration — all seven operator categories plus synth
distributions — accepts an optional Purpose *descriptor.Purpose: the
same plain-language guidance built-ins declare (what the operator is
for, the intents it answers, when to reach for something else, its
assumptions). A TestRegistration additionally accepts
Interpretation []descriptor.Interpretation, saying how to read each
output field.
pulse.AggregatorRegistration{
Name: "AGG_ACME_BRAND",
Factory: newBrand,
Purpose: &descriptor.Purpose{
Plain: "Composite brand health score for a set of respondents.",
Intents: []string{"measure_construct"},
Questions: []string{"How healthy is our brand this wave?", "Which segment rates it highest?"},
KnownAs: []string{"brand health index", "BHI"},
UseCases: map[descriptor.Domain]string{descriptor.DomainSurvey: "Brand tracker across waves."},
NotFor: []descriptor.Alternative{{When: "you need one rating's plain average", Use: "AGG_AVERAGE"}},
Level: descriptor.LevelIntermediate,
},
}
Validation. At every pulse.New (and CheckFeatureProfile), after
the probe and the DependsOn check, a present Purpose runs the exact
rules the built-in tier does: Plain non-empty and at most 140
characters; at least one intent, each in the taxonomy (pulse.Intents())
and listed once; at least two Questions; at least one NotFor entry,
each with a When and a Use that is not the operator itself and
resolves against the instance registry — a built-in operator, any
other registered extension, or a <kind>:<name> feature-table row;
an optional FollowUps list (the natural next steps once the operator
has run) whose entries follow the same When / Use rules as NotFor
and land in p.Ontology() as follow_up edges;
UseCases keyed by survey / ops / science / harness; Level
one of basic / intermediate / advanced; Glossary IDs from
pulse.Glossary(), listing every jargon term Plain uses; an
optional KnownAs list of the names analysts already use for the
operator (an SPSS, R or textbook spelling — only a real alias, never a
paraphrase), each non-empty, at most 48 characters, listed once, not
the same as any intent’s example phrasing (Intent.Sounds), and —
compared case-insensitively with whitespace collapsed — not already
declared by a built-in or an earlier extension. An
Interpretation is checked for structure only — path syntax,
Means or a known Shared rule set, Bands with a Convention,
Sign keys + / - — since an extension declares no output keys to
probe. The first registration that breaks a rule (category order, then
slice index; Purpose before Interpretation) fails with
PULSE_EXTENSION_PURPOSE_INVALID; details carry category, name,
index, part (purpose / interpretation), the first failing
rule, and every rules / violations entry.
Projection. Only a Purpose’s sorted intent IDs reach the manifest,
as the extension entry’s intents list; the prose never rides a
default payload. An absent Purpose projects no intents key and leaves
the operator out of guidance coverage. The extensions snapshot carries
the validated Purpose and Interpretation for later surfaces, and an
extension a feature profile hides drops its guidance with it. KnownAs
aliases are identifiers, not prose: they never reach the manifest, but
they join the instance’s synonym index beside the built-in aliases, so
discovery that matches plain words (example search, skill ranking)
finds the operator by them; a hidden operator’s aliases drop out.
FieldInputs hook (buffered-projection introspection)
Every operator registration accepts an optional FieldInputs
callback:
type FieldInputsFunc func(raw json.RawMessage) []string
Buffered-decode projection is default-on (output-transparent —
same result, only faster; opt out with pulse.Options{DisableProjection: true}
or the --no-project CLI flag on pulse api process only —
process-chain / compose do not expose it in v1). pulse.Options.ProjectBufferedFields
is retained but deprecated (a harmless no-op). While projection is
active, the runtime walks each request before opening the streaming
iterator and calls
the engine’s field-needs extractor (internal/processing, not
embedder-reachable) to compute the set of source fields the operators actually read. Built-in operators are
fully introspectable from their spec (Field, Field2,
PartitionBy, OrderBy, Target, Predictors, plus expr-AST
identifiers for ATTR_FORMULA / FILTER_EXPRESSION). Custom
operators registered through this surface are opaque by default —
without a FieldInputs hook, the projection extractor widens the
retained set to “every field” so the runtime stays correct.
A built-in whose Params name a schema field is the one shape
introspection does not get for free, because the name lives nowhere on
the operator’s own struct. addAggParamFields is the built-in
counterpart of FieldInputs and must gain an arm on the same day such
an operator does — AGG_DISTINCT_SUM’s distinct_by,
AGG_WEIGHTED_MEAN’s weight_field, AGG_RATIO’s
numerator_field / denominator_field, FEAT_TRAIN_TEST_SPLIT’s
stratify and FEAT_TARGET_ENCODE’s target are the whole set
today, and the field-reference rule (aggParamFieldKeys in
internal/descriptor/field_refs.go) must gain the same arm so an
unknown name there is refused rather than read as all-null. Forgetting one fails silently, not loudly: the field is
decoded onto no Record, Record.NumericValue answers ok=false for
it on every row, and the operator reads that as a missing input rather
than as a broken request — so the request succeeds and publishes a
confident zero, with n_null equal to the full admitted record count
as the only signature.
The extractor must also walk every request slot that can carry an
operator, not merely the well-known ones. Crosstab.MarginAggregations
is the slot that has been missed once: auxiliary margin-only
aggregations read source records exactly as Crosstab.Cell does and
contribute to the projection exactly as it does, both their own Field
and their Params fields, and an opaque extension aggregator sitting
there widens the set exactly as one sitting in Crosstab.Cell would.
To let projection narrow past your custom operator (instead of
widening to “every field”), register FieldInputs and return every
schema field the operator reads beyond its spec’s explicit
references:
pulse.AggregatorRegistration{
Name: "AGG_ACME_BLENDED_SCORE",
Factory: blendedScoreFactory,
Params: []pulse.ParamMeta{{Name: "weight_field", JSONType: "string"}},
// Spec.Field carries the value field; weight_field names a second
// numeric field. Both must be in the projected map for the
// aggregator to compute correctly.
FieldInputs: func(raw json.RawMessage) []string {
var p struct {
WeightField string `json:"weight_field"`
}
_ = json.Unmarshal(raw, &p)
if p.WeightField == "" {
return nil
}
return []string{p.WeightField}
},
}
Return-value semantics:
nilor empty slice: no extra fields beyond the spec’sField.- Every returned name is also validated: the hook rides the
read-only extensions snapshot, and the shared field-reference rule
(the one predict, the Compose / chain validators and the runtime all
apply before a record is read) judges each declared name against the
columns available at the operator’s pipeline point — schema fields,
earlier feature outputs and attribute labels, or, for a window, the
output row columns. A name nothing produces is refused with
SERVICE_VALIDATION"<category> <NAME>: FieldInputs references unknown field <f>", details{field, <slot>, field_inputs: true}, identically on both sides. So return exactly the names the operator reads — never a speculative superset. A hook that panics is treated as undeclared (nothing judged), not as a crash. The projection extractor itself still drops non-schema names, so a declared derived column costs nothing there. - Errors are not part of the signature on purpose —
FieldInputsruns on the hot path and should be allocation-free. Anything that needs decoding belongs in the factory.
For filterers the callback receives nil as raw (filterers do not
carry a Params block today). Tier-2 post-tests do not decode source
records and should leave FieldInputs nil.
The hook is plumbed via buildRuntimeExtensions into the engine’s
internal extension registry, keyed by category and name. The lookup
reports “callback ran” when the callback is registered and “none” when
the operator is custom but has no callback — that second case is what
triggers the extractor to widen.
The retained set the extractor returns feeds
internal/encoding.BuildDecodePlan(schema, retained). A registration with FieldInputs
participates normally — its contributed fields land in the retained
set and the plan emits SkipBytes segments for every contiguous
unprojected run, so unread byte ranges advance with a single Seek.
A registration without FieldInputs widens the retained set to
*, producing a full-coverage plan with no SkipBytes segments.
Both shapes are correct; only the plan-driven one elides byte
ranges.
Vector inputs (vector / fields)
An extension test, attribute or grouper may read a member set from the
slot’s vector (a Request.Vectors name) or fields (inline names
and globs), and a test also a second set from vector2 / fields2.
Predict and the runtime judge those member sets exactly as a
built-in’s: vector and fields together are refused
(reason vector_and_fields, vector2_and_fields2), a second set
needs a first, an undefined vector is PULSE_VECTOR_UNKNOWN, and an
inline member must exist, be numeric (PULSE_VECTOR_MEMBER_TYPE) and
appear once (PULSE_VECTOR_DUPLICATE). The factory then receives a
copy of the spec whose Fields / Fields2 are the resolved literal
member names and whose Vector / Vector2 are empty, so it never
resolves a vector itself. Everything else is the operator’s: its
params, field / field2 beside the vector, split_by and whether
the two sets must match in length. A test or attribute that reads a
vector may leave field empty; a grouper keeps its field, which
names its key column.
Streamability contract
Embedders declare streamability at registration time; the runtime
routes on that declaration — never on the built-in per-type
Streamable() tables (which know no extension name) nor on whichever
optional interface the value happens to carry — and predict reads the
same declaration from the extensions snapshot, so
PredictResult.Streamable agrees with the path taken
(the engine’s internal streamability gate is the runtime parity hook;
TestExtensions_StreamabilityFollowsDeclaration holds all three equal).
Use Predict and read PredictResult.Streamable to learn the path
before running. Probe-validation guarantees every streamable declaration is
backed by the interface in the table below
(PULSE_EXTENSION_STREAMABLE_MISMATCH); for a tier-1 test the flag
alone decides, since every extend.RowTest folds per row. Feature
streamability is the exception: it is decided on the returned value.
| Category | Streamable means | Required interface |
|---|---|---|
| Aggregator | one-pass online | extend.OnlineAggregator |
Attribute (row_local) | per-row eval, no PrePass | extend.RowLocalAttribute |
Attribute (two_pass) | PrePass + Finalize + Row | extend.TwoPassAttribute |
| Grouper | derive key from a single row | extend.StreamingGrouper (fan-out: extend.MultiKeyStreamingGrouper) |
| Feature | StreamingComputer pipeline | extend.StreamingFeatureComputer |
| Test (tier-1) | folds with online aggregators | extend.RowTest |
Filterers are always row-local streamable; windows always run buffered.
Limits of extension operators
State these plainly to users rather than discovering them at run time:
- Merging is opt-in. An extension aggregator or grouper merges
only when its registration declares
Mergeable(see Aggregator and Grouper above); extension filterers androw_localattributes merge as row-local operators;two_pass/bufferedattributes, features, windows and tests never merge (as for built-ins), so a request naming one runs the parallel shard and parallel bufferedProcessarms serially. DeclaringMergeablein aComponentSchemadescribes the components shape; it does not make the operator fold across workers. - Crosstab cells fuse only on a declared margin class. An
extension cell aggregator takes the fused crosstab arm when its
registration declares
MarginReducibility(summable, mean_reducible or independent) alongsideMergeable; undeclared, the crosstab runs the buffered arm with the same result. An axis grouper fuses only on a declaredStreamableorFansOut. Predict reports the answer ascrosstab_fusable+crosstab_fusion_reasons. - Grouped Components: grouper and per-group aggregator figures, on
every path. An extension grouper’s
ComponentsFuncoutput lands onComponents.Groupers[i].Operatoron every grouped path (streaming, buffered, and the merged parallel arms viaMergeState), exactly as a built-in grouper’s does. An extension aggregator under a grouper gets the per-group floor automatically and itsoperatormap off each per-bucket instance’s own components hook inComponents.Aggregations[i].Groups— buffered, streaming (terminal flush) and merged parallel arms alike, with no new API. Ungrouped runs emit an extension aggregator’s figures on every path. - Two-pass attributes keep a crosstab buffered. A
two_passextension attribute declines the fused crosstab exactly as the built-inATTR_ZSCOREdoes — the fused walk never runs aPrePass. - Decimal targets follow the declarations. Extension aggregators
are admitted on
decimal128and readDecimalValue; they stream there per their declaredStreamableflag and merge perMergeable(built-ins over decimal stay buffered and serial). - Extensions are never zone-capable. There is no registration
field for time-zone participation, so an extension operator carries
no manifest
zonekey and an explicit slottzon it is refused withPROCESSING_CONFIG(details{slot, operator, tz}), in runtime and predict alike. A request-leveltime_zonesimply does not reach it. Only the built-in date-family operators declared ininternal/descriptor/capabilities_zone.goresolve a zone.
Migration recipe — pre-processing → registration
Before extensions, the canonical pattern was for an embedder to rewrite the request before submitting it:
// Old: rewrite "adjustment" attribute into a formula with the
// multiplier inlined.
req.Attributes = append(req.Attributes, &types.Attribute{
Type: types.ATTR_FORMULA,
Field: "score",
Expression: fmt.Sprintf("score * %f", adjustmentFor(study, wave)),
})
With the extension API the request stays domain-named and the engine resolves the value at runtime:
// New: register the lookup once at startup.
pulse.New(pulse.Options{
Extensions: pulse.Extensions{
LookupTables: map[string]pulse.LookupTable{
"adjustments": {Lookup: acme.LookupAdjustment},
},
},
})
// Request stays declarative:
req.Attributes = append(req.Attributes, &types.Attribute{
Type: types.ATTR_FORMULA,
Field: "score",
Expression: "score * lookup(\"adjustments\", study, wave_date)",
})
Benefits: the manifest advertises adjustments, the schema-bound MCP
tool surfaces it, predict can typecheck the expression, and the
lookup runs without pre-processing every request.
Embedder skills (Extensions.Skills)
Agents learn HOW to use an operator from the skill pack
(pulse_skills_list / pulse_skills_get). Extensions.Skills lets an
embedder document its own operators the same way: an fs.FS of
top-level .md files, read once at pulse.New and then served through
p.Skills() / p.Skill(name), pulse_skills_list /
pulse_skills_get, the pulse-skill:// resources, the manifest
skills list and p.Ontology() exactly like the shipped pack.
//go:embed skills/*.md
var skillFiles embed.FS
sub, _ := fs.Sub(skillFiles, "skills")
ext := pulse.Extensions{
Aggregators: []pulse.AggregatorRegistration{{Name: "AGG_ACME_TRIM", ...}},
Skills: sub,
}
Shapes
| Stem | Frontmatter | Documents |
|---|---|---|
op-<category>-<kebab>.md | kind: operator, category: = the operator’s prefix (AGG, ATTR, FILTER, GROUP, WIN, FEAT, TEST, SYNTH), operator: = the registered name | ONE extension operator registered in the same Extensions; the stem is op- plus the name lowercased with _ → - (AGG_ACME_TRIM → op-agg-acme-trim) |
ext-<kebab>.md | kind: design, no operator: / category:, optional requires: | a topic — how your operators fit a workflow |
The frontmatter keys and the required ## sections are the built-in
pack’s (.claude/reference/skill-pack.md in the repo): an atomic skill
carries ## Params, ## Inputs, ## Output, ## Gotchas, ## See
(plus ## Components for AGG / GROUP / FILTER). Only an ext-*
skill may carry requires: — an atomic skill already follows its
operator.
Validation at pulse.New
Every file is validated HARD — against every registration, before a
feature profile hides any, so validity never depends on the profile.
The first failure is PULSE_EXTENSION_SKILL_INVALID (details carry
skill and reason) or PULSE_EXTENSION_SKILL_COLLISION:
reason | Rule |
|---|---|
layout | the root reads; every entry is a regular top-level .md file |
builtin / duplicate (COLLISION) | the stem is not a built-in (or virtual glossary / intents) skill and is shipped once across every merged Extensions value — embedders never override or shadow the shipped pack |
stem | op-<category>-<kebab> naming its operator, or ext-<kebab> |
frontmatter / name / description | a --- block; name equals the stem; a description |
kind / operator / category / requires | atomic: kind: operator, a registered extension operator, its prefix as category; topical: kind: design; each requires: entry a feature (capability:crosstab) or registered operator |
sections | the family’s required ## headings |
budget | body (frontmatter, fence markers and generated-section markers stripped) ≤ 1200 bytes for op-*, ≤ 6000 for ext-* — hard, unlike the built-in atomic budget |
fence | every <!-- feature: … --> fence closes and names a feature or registered operator |
fence_coverage | every operator, <kind>:<name> feature or feature-owned pulse_* tool the body names — your own other operators included — sits in a fence naming it, unless the skill goes whenever it is hidden (its own operator and that operator’s DependsOn, transitively; a topical skill’s requires:). The description may name none |
see | every backticked stem in ## See is a skill the instance carries (built-in, virtual or yours) |
Served and pruned like built-ins
Each skill is a skill:<stem> node of the instance ontology with the
edges a built-in of its shape gets (operator documented_by skill,
## See → routes_to, requires: → requires_capability, topical
fence names → routes_to). A feature profile that hides your operator
hides its atomic skill, and an ext-* skill whose requires: names a
hidden feature: either then reads exactly like a name that never
existed on every surface. A visible skill’s fences render against the
instance (a span naming a hidden feature is cut), so mention a feature
the profile may hide only inside a fence. Registering skills never
changes a built-in skill’s listing or body.
Generated sections (opt-in)
An atomic skill may carry the marker lines
<!-- generated: use-when --> (after the lead sentence, before
## Params) and <!-- generated: reading-the-output --> (after
## Output). Every read replaces them with ## Use when, rendered
from the operator’s registration Purpose (plain line, up to two
questions, the not-for alternatives the instance can see), and
## Reading the output, rendered from a test registration’s
Interpretation. A marker whose operator declares no such metadata is
removed silently; a skill without markers is served unchanged; markers
never cause a pulse.New failure and never count toward the budget.
Embedder examples (Extensions.Examples)
Agents copy request shapes from the example library
(pulse_examples_search / pulse_examples_get). Extensions.Examples
lets an embedder ship runnable examples for its own operators the same
way: an fs.FS of top-level .json files, read once at pulse.New and
then served through p.ExamplesSearch / p.ExampleGet,
pulse_examples_search / pulse_examples_get, the manifest
examples_count / example_categories / example_tags and
p.Ontology() exactly like the shipped library.
//go:embed examples/*.json
var exampleFiles embed.FS
sub, _ := fs.Sub(exampleFiles, "examples")
ext := pulse.Extensions{
Aggregators: []pulse.AggregatorRegistration{{Name: "AGG_ACME_TRIM", ...}},
Examples: sub,
}
Shape
Each file is a request body plus the _meta block every built-in
example carries; _meta is stripped before the body is served, so the
body hands straight to Process / Predict:
{
"_meta": {
"name": "acme-trimmed-revenue",
"category": "acme",
"description": "Trimmed mean revenue per region.",
"tags": ["financial"],
"intents": ["describe"],
"operators": ["AGG_ACME_TRIM", "GROUP_CATEGORY"]
},
"cohort": {"filename": "sales.pulse"},
"aggregations": [{"type": "AGG_ACME_TRIM", "field": "revenue"}],
"groups": [{"type": "GROUP_CATEGORY", "field": "region"}]
}
category is yours to choose (acme above) — it is what
category= filters on. intents and capabilities are optional;
declare a capability (capability:stream) only when the body carries no
structural sign of it — crosstab, joins, a requests (compose) or
stages (process chain) root and a facet category or tag are
detected from the body.
Validation at pulse.New
Every file is validated HARD — against every registration, before a
feature profile hides any, so validity never depends on the profile. The
rules are the shipped library’s own CI rules. The first failure is
PULSE_EXTENSION_EXAMPLE_INVALID (details carry example and reason)
or PULSE_EXTENSION_EXAMPLE_COLLISION:
reason | Rule |
|---|---|
layout | the root reads; every entry is a regular top-level .json file |
meta | the file is a JSON object with a _meta block holding only name, category, description, tags, operators, intents, capabilities |
name | lowercase letters and digits joined by - or _ |
builtin / duplicate (COLLISION) | the name is not a built-in example and is shipped once across every merged Extensions value — embedders never override or shadow the shipped library; prefixing names with your namespace avoids both |
category / description | a lowercase category token; a description |
tags | every tag from the canonical example taxonomy |
intents | every intent an intent-taxonomy ID (pulse.Intents()) |
capabilities | every entry a non-operator feature name (capability:stream); operators belong in operators |
body | the body decodes STRICTLY — an unknown key is refused — as its request root: requests → compose, stages → process chain, fields → facet, otherwise process |
operators_body | operators equals the operators the body names as a "type" value |
operators | every operators entry is a built-in operator or one registered in the same Extensions |
edge_coverage | every operator the body or description names is tied to the example (a "type", an overlays[].kind, or a description mention of an operator), and no declared capability contradicts its structural detector |
Served and pruned like built-ins
Each example is an example:<name> node of the instance ontology with
the edges a built-in example gets: operator exemplified_by example
for every operator and overlay kind, serves_intent per intent,
requires_capability per declared or detected capability, routes_to
for an operator the description names, and an exemplified_by edge
from every atomic skill whose ## See tags=[…] search it matches. A
feature profile that hides your operator — or a built-in operator or
capability the example uses — hides it: absent from every search and
from the manifest counts, and ExampleGet / pulse_examples_get answer
exactly as for a name that never existed. Search ranks your examples
together with the built-ins by the same rules, and registering examples
never changes a built-in example’s record. The pulse examples CLI
leaf lists the shipped library only.
Error codes raised by this surface
Fetch the Message + Fixup template for any of these via
pulse errors lookup <CODE> (CLI) or pulse_errors_lookup (MCP).
| Code | Trigger |
|---|---|
PULSE_EXTENSION_NAME_INVALID | name fails the registration regex |
PULSE_EXTENSION_NAME_RESERVED | namespace is BUILTIN/STANDARD/CORE/PULSE, or an ExprFunction is named total (engine-owned) |
PULSE_EXTENSION_NAME_COLLISION | name matches a built-in |
PULSE_EXTENSION_DUPLICATE | same name registered twice |
PULSE_EXTENSION_STREAMABLE_MISMATCH | declared streaming tier does not match factory interface |
PULSE_EXTENSION_FANOUT_MISMATCH | grouper FansOut disagrees with extend.MultiKeyStreamingGrouper, either direction |
PULSE_EXTENSION_PURPOSE_INVALID | a Purpose breaks a guidance validity rule, or a test Interpretation is structurally invalid |
PULSE_EXTENSION_MERGEABLE_MISMATCH | aggregator / grouper Mergeable without Streamable, value lacks extend.MergeableAggregator (or, for a grouper that emits components, extend.MergeableGrouper), or ComponentSchema keys classified None |
PULSE_EXTENSION_MARGIN_REDUCIBILITY_MISMATCH | aggregator MarginReducibility is not a known class, or is a fusable class (summable / mean_reducible / independent) without Mergeable |
PULSE_EXTENSION_FACTORY_PANIC | factory panicked or returned nil during probe |
PULSE_EXTENSION_PARAM_INVALID | bad ParamMeta, missing Mode/Tier, lookup table with neither Rows nor Lookup, etc. |
PULSE_EXTENSION_MISSING_COMPONENT_SCHEMA | emitter wired (closure or sibling interface) but ComponentSchema.Keys empty |
PULSE_EXTENSION_COMPONENT_SCHEMA_MISMATCH | emitter returned a key not in ComponentSchema.Keys, or re-emitted a floor key |
PULSE_EXTENSION_SKILL_INVALID | an Extensions.Skills file breaks a rule in “Embedder skills” — details.reason names which |
PULSE_EXTENSION_SKILL_COLLISION | an Extensions.Skills stem is a built-in skill or is shipped twice |
PULSE_EXTENSION_EXAMPLE_INVALID | an Extensions.Examples file breaks a rule in “Embedder examples” — details.reason names which |
PULSE_EXTENSION_EXAMPLE_COLLISION | an Extensions.Examples name is a built-in example or is shipped twice |
PULSE_FEATURE_PROFILE_UNKNOWN | a DependsOn entry names no built-in feature or registered extension operator |
PULSE_EXTENSION_NOT_WEIGHT_AWARE | a row weight reached a registration without WeightAware — explicit on an aggregator, or any weight in force on an attribute or test |
PULSE_LOOKUP_TABLE_UNKNOWN | expression referenced an unregistered table |
PULSE_LOOKUP_MISS | lookup key not present |
Naming-policy violations and probe failures are also enforced by the
gates listed in The Update Demand — any change to
an extension registration’s ComponentSchema (adding a registration,
renaming a key, changing mergeability) MUST update this page and the
CLAUDE.md Update Demand table in the same PR.
Related pages
- Adding an Aggregator — the in-tree recipe
for built-in operators; mirrors the same
ComponentSchema+ mergeability contract described here. - The Update Demand — enforced gates for
extension registration
ComponentSchemachanges and naming-policy drift. skills/response-components.md— universalResponse.Componentscontract (typed shells, floor keys, additiveomitemptyshape).
Adding an Aggregator
Embedding Pulse, not contributing to it? This recipe adds a built-in in
internal/processing/, which embedders cannot import. To add your own operator, implement the matching contract in the publicextendpackage instead — see Extension Points.
Audience: Pulse internals contributors adding a new AGG_*
operator.
This page is a step-by-step recipe. The binding list of companions
is CLAUDE.md’s Update Demand table (aggregator row) and its long form,
.claude/reference/update-demand.md; this is the human-readable
walkthrough.
From CLAUDE.md, The Update Demand (registered aggregator row) and
.claude/reference/update-demand.md.
1. Declare the type constant
Add the new constant to types/types.go and the slice returned by
types.AllAggregationTypes(). Example, for a hypothetical AGG_GINI:
const (
// ... existing constants ...
AGG_GINI AggregationType = "AGG_GINI"
)
func AllAggregationTypes() []AggregationType {
return []AggregationType{
// ... existing entries, alphabetised ...
AGG_GINI,
}
}
The exhaustiveness tests (TestStreamability_AggregationsKnown and
friends) will fail until you add the streamability case in step 4.
2. Implement the aggregator and register it
The operator implementation lives in internal/processing/. Write the factory
function (newGini(...) returning the aggregator interface) and
register it in aggregatorRegistry in internal/processing/registry.go.
If the aggregator can update one row at a time, also implement the
OnlineAggregator interface so it joins the streaming Process path.
Sort-based or sum-of-deviation aggregators (like AGG_MEDIAN,
AGG_ZSCORE) skip this interface and run in the buffered path.
3. Tests
Tests come first: write them in internal/processing/aggregator_test.go
before the implementation, run the suite, confirm they fail
informatively, then port the implementation until green. See
Testing Conventions.
4. Declare streamability
Add a case for the new type in types/streamability.go:
func (t AggregationType) Streamable() bool {
switch t {
// ...
case AGG_GINI:
return false // sort-based
}
}
Add the same row to the table in types/streamability_test.go.
If the aggregator is online, also expect
TestRegistryStreamabilityMatchesTypes to compare your
OnlineAggregator implementation against the
AggregationType.Streamable() return value — they must agree.
5. Update the skill pack
Add a section for the new aggregator in
skills/aggregation-design.md. Cover when to use it, what its inputs
and outputs look like, and any caveats (sort cost, memory, supported
field types).
The CI gate TestSkillsCoverAllComponents parses the skill body for
the operator name; the section can live anywhere in the file as long
as the name appears.
6. Declare the capability metadata
Add a row to internal/descriptor/capabilities_aggregators.go describing the
operator’s params, accepted field types, emitted type, and the
streamable hint. TestManifestOperatorsComplete enforces that every
registered aggregator has a capability row.
The capability row also carries the operator’s ComponentSchema and
Mergeability — both required for every registered aggregator since
v0.20.0. The next section walks through both.
6a. Declare the ComponentSchema (Response.Components contract)
Every registered aggregator MUST declare a ComponentSchema on its
capability row in internal/descriptor/capabilities_aggregators.go. The schema
enumerates the operator-specific keys the aggregator emits into
Response.Components.Aggregations[i].Operator and tags the operator
with one of three mergeability classes:
| Class | Wire value | When to use |
|---|---|---|
Mergeable | "mergeable" | Components fold via the same associative / commutative path as the scalar value. Constant-space online merge works across streaming chunks and parallel shards. Used by AGG_SUM, AGG_COUNT, AGG_AVERAGE, AGG_MIN, AGG_MAX, AGG_WELFORD. |
Partial | "partial" | Components fold across chunks but at non-trivial allocation cost — map / set unions where the merge is associative but not constant-space. The orchestrator may stage the merge at terminal flush. Used by AGG_MODE_COUNT, AGG_MODE, AGG_DISTINCT_COUNT, AGG_DISTINCT_SUM. |
None | "none" | Components cannot be computed from a per-chunk partial. The operator needs a sorted view of the full input. Streaming chunks omit components; emission lands only on the terminal buffered flush. Used by AGG_MEDIAN, AGG_PERCENTILE. |
The aggSchema helper in capabilities_aggregators.go prepends the
universal floor ({"n", "n_null"}) automatically — do not list those
keys in your extra slice, the helper adds them:
{
Name: string(types.AGG_GINI),
Category: "aggregator",
Description: "Gini coefficient of the field across the input set.",
AcceptsTypes: numericFieldTypesAnalyticsNoDecimal,
EmitsTypeNote: "scalar float64 in [0, 1]",
Streamable: false,
ComponentSchema: aggSchema(None,
ComponentKey{Name: "sorted_n", Type: "int", Description: "Number of non-null values seen before the final sort."},
),
},
A floor-only aggregator (operator-specific keys empty) is valid — pass
no extra arguments and the schema declares only {n, n_null} under
the chosen mergeability class. AGG_COUNT is the canonical floor-only
operator.
6b. Emit per-operator component values at runtime
Two equivalent paths exist for emitting the operator-specific keys at runtime; pick whichever fits your aggregator type.
Sibling interface (preferred for built-in operators). Implement
MetaAggregator (internal/processing) on your aggregator type and add a
compile-time assertion in internal/processing/aggregator.go:
type giniAggregator struct {
// ... operator state ...
}
func (g *giniAggregator) Components() (map[string]any, error) {
return map[string]any{
"sorted_n": len(g.values),
}, nil
}
// In internal/processing/aggregator.go's compile-time assertion block:
var _ MetaAggregator = (*giniAggregator)(nil)
The assertion list near the bottom of internal/processing/aggregator.go is the
grep-discoverable record of which operators emit components. Add the
new entry so interface drift is caught at build time.
ComponentsFunc closure. Used primarily by pulse.Options.Extensions
registrations where you cannot extend the aggregator type. The closure
receives the constructed aggregator instance and returns the
operator-specific keys map. See
Extension Points
for the full extension recipe and the probe-validation contract
(PULSE_EXTENSION_MISSING_COMPONENT_SCHEMA,
PULSE_EXTENSION_COMPONENT_SCHEMA_MISMATCH).
In both paths the orchestrator owns the universal floor {n, n_null}
unconditionally — your Components() MUST NOT re-emit those keys.
Return (nil, nil) to signal “no operator-specific keys; let the
orchestrator fill the floor only” — the canonical AGG_COUNT shape.
The full Response.Components contract (per-family typed shape, streaming behaviour by mergeability class, overlay parity reads) lives in the response-components skill — that file remains the authoritative contract document.
6c. Mergeable-aggregator rule
If your aggregator is mergeable, implement the engine’s
MergeableAggregator.MergeOnline(other) and declare it as Mergeable()
in AggregationType.Mergeable() (types/streamability.go) so it
composes correctly under parallel decode
(internal/service/parallel_reduce.go) and shard reduce (internal/service/shard_reduce.go).
Both surfaces fold per-worker / per-shard partials in deterministic
index order via mergeShardPartials + finalizeMergedPartial.
An aggregator registered but not Mergeable() silently forces the request
down the serial scanIter / shardIter path; both parallel paths gate on
internal/processing’s CanMergeRequestWithExtensions (the built-in-only
CanMergeRequest is its nil-registry case) and fall through cleanly when
an entry is not flagged.
An extension aggregator takes the same paths through the public
contract instead: it implements extend.MergeableAggregator.Merge(other)
and its pulse.AggregatorRegistration declares Streamable: true and
Mergeable: true; probe-validation at pulse.New refuses a declaration
the value cannot honour (PULSE_EXTENSION_MERGEABLE_MISMATCH). See
Extension points — Aggregator.
Crosstab margin class. A built-in cell aggregator is admitted to the
fused crosstab arm by AggregationType.MarginReducibility()
(types/streamability.go) — classify every new aggregator there (the
default is MarginRecompute, which keeps its crosstabs buffered). An
extension aggregator declares the same class on its registration’s
MarginReducibility field (needs Mergeable; refused otherwise with
PULSE_EXTENSION_MARGIN_REDUCIBILITY_MISMATCH); undeclared, its
crosstab cells run buffered.
Associative + commutative aggregators (count, sum, min, max, frequency,
distinct_count, mode) produce byte-equal merge output; Welford-Pébaÿ
aggregators (mean, variance, stddev) use Chan-Welford and stay within ULP
of serial. If your aggregator’s online state cannot be folded associatively,
leave Mergeable() returning false. See Cohort schema design — Parallel
decode
for the gate composition and observed perf characteristics.
7. CLAUDE.md and registered-component lists
There is no registered-aggregator count or list in CLAUDE.md to update —
CLAUDE.md never hardcodes registered counts; the manifest is the source of
truth. The Update Demand operator row (.claude/reference/update-demand.md)
requires instead the atomic skill skills/op-agg-<kebab>.md, the capability declaration in
internal/descriptor/capabilities_aggregators.go — and an internal/examples/<dir>/*.json example whose _meta.operators
names the operator (TestEveryOperatorHasAnExampleTag). Edit CLAUDE.md only if the
operator introduces a contract it states directly, and mind
TestClaudeMdSizeBudget — long-form prose belongs in .claude/reference/.
If the operator interacts with categorical fields in a special way,
also update internal/descriptor/predict.go’s numericAggregations map.
8. Run the gates
go test ./internal/skills/ -run TestSkillsCoverAllComponents
go test ./descriptor/ ./internal/descriptor/ -run 'TestManifest|TestPredict'
go test ./internal/processing/ -run TestRegistryStreamability
go test ./...
The full Update Demand row for aggregators says: atomic skill + capability declaration + an example tag + the existing test coverage. All of them ride in the same PR. See The Update Demand.
Adding an Attribute
Embedding Pulse, not contributing to it? This recipe adds a built-in in
internal/processing/, which embedders cannot import. To add your own operator, implement the matching contract in the publicextendpackage instead — see Extension Points.
Audience: Pulse internals contributors adding a new ATTR_*
operator — a per-record derived value computed from one or more cohort
fields (z-score, formula, lookup, etc.).
The recipe is the aggregator recipe with three swaps: a different type constant, a different registry, and a different skill file. The shape is otherwise identical.
1. Declare the type constant
Add the new constant to types/types.go and the slice returned by
types.AllAttributeTypes():
const (
// ... existing constants ...
ATTR_GINI_BUCKET AttributeType = "ATTR_GINI_BUCKET"
)
func AllAttributeTypes() []AttributeType {
return []AttributeType{
// ... existing entries, alphabetised ...
ATTR_GINI_BUCKET,
}
}
2. Implement and register
Implement the attribute in internal/processing/. Each attribute is a factory
function registered in attributeRegistry (internal/processing/registry.go).
Attribute factories return a closure with the signature
func(record encoding.RecordView) (any, error).
If the attribute can be evaluated row-at-a-time (the common case), that is all the implementation needs; the streaming Process path invokes the closure on every record.
3. Tests
Write tests first in internal/processing/attribute_test.go. Run the suite,
confirm informative failure, port the implementation until green.
4. Declare the capability metadata
Add a row to internal/descriptor/capabilities_attributes.go with the
attribute’s params, the field types it accepts as input, the type
it emits, and any documentation strings.
TestManifestOperatorsComplete enforces a capability row per registered
attribute.
5. Update the attribute-composition skill
Add a section in skills/attribute-composition.md covering the new
attribute’s params, output column naming convention, and any caveats
(NaN propagation, integer underflow, expr-runtime cost). The
TestSkillsCoverAllComponents gate parses the skill body for the
operator name.
6. Update CLAUDE.md
There is no registered-attribute count or list in CLAUDE.md to update —
CLAUDE.md never hardcodes registered counts; the manifest is the source of
truth. The Update Demand operator row (.claude/reference/update-demand.md)
requires instead the atomic skill skills/op-attr-<kebab>.md, the capability declaration in
internal/descriptor/capabilities_attributes.go — and an internal/examples/<dir>/*.json example whose _meta.operators
names the operator (TestEveryOperatorHasAnExampleTag). Edit CLAUDE.md only if the
operator introduces a contract it states directly, and mind
TestClaudeMdSizeBudget — long-form prose belongs in .claude/reference/.
7. Run the gates
go test ./internal/skills/ -run TestSkillsCoverAllComponents
go test ./internal/descriptor/ -run TestManifestOperatorsComplete
go test ./internal/processing/ -run TestAttribute
The Update Demand row for attributes covers all of these in one PR; see The Update Demand.
Adding a Filterer
Embedding Pulse, not contributing to it? This recipe adds a built-in in
internal/processing/, which embedders cannot import. To add your own operator, implement the matching contract in the publicextendpackage instead — see Extension Points.
Audience: Pulse internals contributors adding a new FILTER_*
operator — a per-record predicate that decides whether the row
participates in the downstream aggregation / grouper / crosstab.
The recipe mirrors the aggregator recipe; the filterer-specific moving
parts are the predicate factory, the filter registry, and the universal
floor for Response.Components.Filterers.
1. Declare the type constant
Add the new constant to types/types.go and the slice returned by
types.AllFiltererTypes():
const (
// ... existing constants ...
FILTER_REGEX FiltererType = "FILTER_REGEX"
)
func AllFiltererTypes() []FiltererType {
return []FiltererType{
// ... existing entries, alphabetised ...
FILTER_REGEX,
}
}
2. Implement and register
Implement the filterer in internal/processing/. Each filterer is a factory
that returns a FiltererBuilder — register it in filtererRegistry
(internal/processing/registry.go).
The builder produces a per-record FilterFunc that returns
(keep bool, err error). The streaming Process path invokes the
filter func once per row; the buffered path invokes it once per
materialised record.
3. Tests
Add tests in internal/processing/filterer_test.go before the implementation.
Cover both the include and exclude branches, the null-handling
contract, and any error path.
4. Declare the capability metadata
Add a row to internal/descriptor/capabilities_filterers.go with the
filterer’s params, accepted field types, and the
ComponentSchema
that follows.
TestManifestOperatorsComplete enforces a capability row per
registered filterer.
5. Declare the ComponentSchema (Response.Components contract)
Every registered filterer MUST declare a ComponentSchema on its
capability row, with a Mergeability class
(Mergeable / Partial / None).
In v1 every built-in filterer is Mergeable with an empty
operator-specific keys slice — the orchestrator fills the universal
floor {n_in, n_out, n_null_input} from the filter pass’s
record-walker counters, and no per-filter extras are emitted today:
{
Name: string(types.FILTER_REGEX),
Category: "filterer",
Description: "Keep records whose field matches the supplied regular expression.",
AcceptsTypes: stringFieldTypes,
ComponentSchema: filterSchema(Mergeable),
},
The filterSchema helper prepends the universal floor automatically —
do not list those keys in your extra slice. The interface
MetaFilterer (internal/processing) exists for parity with
MetaAggregator / MetaGrouper and leaves room for future per-filter
specifics (n_below / n_above for FILTER_RANGE, per-value n for
FILTER_INCLUDE / FILTER_EXCLUDE). When a built-in eventually
implements it, declare the extra keys in filterSchema(...) and add
the Components() method on the filterer struct plus a compile-time
sentinel:
var _ MetaFilterer = (*regexFilterer)(nil)
The full Response.Components contract — universal floor semantics, streaming behaviour, parity overlay reads — lives in the response-components skill; extension-side parity lives in Extension Points.
6. Update the aggregation-design skill
Add a section in the filtering portion of skills/aggregation-design.md
covering the new filterer’s semantics, parameter shape, and the null-
input contract.
7. Update CLAUDE.md
There is no registered-filterer count or list in CLAUDE.md to update —
CLAUDE.md never hardcodes registered counts; the manifest is the source of
truth. The Update Demand operator row (.claude/reference/update-demand.md)
requires instead the atomic skill skills/op-filter-<kebab>.md, the capability declaration in
internal/descriptor/capabilities_filterers.go — and an internal/examples/<dir>/*.json example whose _meta.operators
names the operator (TestEveryOperatorHasAnExampleTag). Edit CLAUDE.md only if the
operator introduces a contract it states directly, and mind
TestClaudeMdSizeBudget — long-form prose belongs in .claude/reference/.
8. Run the gates
go test ./internal/skills/ -run TestSkillsCoverAllComponents
go test ./internal/descriptor/ -run TestManifestOperatorsComplete
go test ./internal/processing/ -run TestFilter
The Update Demand row for filterers covers all of these in one PR; see The Update Demand.
Adding a Grouper
Embedding Pulse, not contributing to it? This recipe adds a built-in in
internal/processing/, which embedders cannot import. To add your own operator, implement the matching contract in the publicextendpackage instead — see Extension Points.
Audience: Pulse internals contributors adding a new GROUP_*
operator — a bucketing function that maps each record to a group key
the orchestrator uses to partition the aggregation.
The recipe mirrors the aggregator recipe; the grouper-specific moving
parts are the per-bucket key contract, the grouper registry, and the
v0.20.0 ComponentSchema / MetaGrouper emission path.
1. Declare the type constant
Add the new constant to types/types.go and the slice returned by
types.AllGroupTypes():
const (
// ... existing constants ...
GROUP_DECILE GroupType = "GROUP_DECILE"
)
func AllGroupTypes() []GroupType {
return []GroupType{
// ... existing entries, alphabetised ...
GROUP_DECILE,
}
}
2. Implement and register
Implement the grouper in internal/processing/. Register the factory in
grouperRegistry (internal/processing/registry.go). The interface choice
depends on whether the grouper can run in the streaming path:
Grouper— buffered-only.Group(rows)returns a slice of bucket assignments. Used by quantile-class groupers that need the full sorted view.StreamingGrouper/StreamableGrouper— streaming. The per-recordKeyFor(record)/KeyForRow(record)returns a bucket key as the iterator walks rows; the orchestrator accumulates per key.MultiKeyStreamingGrouper— multi-emit streaming.KeysForRowreturns N keys per row; used by set-typed groupers (GROUP_SET_PER_ELEMENT).
3. Tests
Add tests in internal/processing/grouper_test.go (or the per-grouper test
file) before the implementation. Cover empty input, single-value
input, null-bearing input, the Include filter slot if your grouper
honours it, and the streaming-vs-buffered parity assertions where
applicable.
4. Declare the capability metadata
Add a row to internal/descriptor/capabilities_groupers.go with the grouper’s
params, accepted field types, and the
ComponentSchema
that follows. TestManifestOperatorsComplete enforces a capability
row per registered grouper.
5. Declare the ComponentSchema (Response.Components contract)
Every registered grouper MUST declare a ComponentSchema on its
capability row in internal/descriptor/capabilities_groupers.go, tagged with
one of three mergeability classes:
| Class | Wire value | When to use |
|---|---|---|
Mergeable | "mergeable" | Per-bucket counts and bucket-key state fold associatively across chunks and shards. Used by GROUP_CATEGORY, GROUP_RANGE, GROUP_ROUNDED, GROUP_DATE. |
Partial | "partial" | Counts fold trivially but per-bucket key state is dict-bearing (e.g. growing label sets). The orchestrator may stage the merge at terminal flush. |
None | "none" | Bucket definitions cannot be known until the full input is seen — quantile-class groupers (GROUP_QUANTILE) need the full sorted view. Streaming chunks omit components; emission lands on terminal buffered flush only. |
The groupSchema helper in capabilities_groupers.go prepends the
universal floor ({"total_n", "n_null"}) automatically — do not list
those keys in your extra slice:
{
Name: string(types.GROUP_DECILE),
Category: "grouper",
Description: "Partition the numeric field into ten equally-sized quantile buckets.",
AcceptsTypes: numericFieldTypesAnalyticsNoDecimal,
ComponentSchema: groupSchema(None,
ComponentKey{Name: "edges", Type: "[]float64", Description: "Decile boundary values (length 9)."},
ComponentKey{Name: "buckets", Type: "map[string]int", Description: "Per-decile record counts keyed by label."},
),
},
6. Emit per-operator component values at runtime
Implement MetaGrouper (internal/processing) on the grouper type and add a
compile-time assertion in the grouper implementation file:
type decileGrouper struct {
edges []float64
buckets map[string]int
// ...
}
func (g *decileGrouper) Components() (map[string]any, error) {
return map[string]any{
"edges": g.edges,
"buckets": g.buckets,
}, nil
}
var _ MetaGrouper = (*decileGrouper)(nil)
The orchestrator owns the universal floor {total_n, n_null} — the
post-filter record walker fills it unconditionally. Your Components()
MUST NOT re-emit those keys.
total_n is the number of (record, bucket) assignments: the sum of
your buckets[].count when Components() returns a buckets list of
[]map[string]any with int counts (as GROUP_CATEGORY and GROUP_SET_PER_ELEMENT do), otherwise
the orchestrator’s own assignment count. For single-key groupers that
is the number of keyed records; for multi-key streaming groupers
(GROUP_SET_PER_ELEMENT) a record contributes once per selected label,
so total_n can exceed the record count.
Merging under the parallel reducers
The parallel reducers (ShardWorkers over a shard archive,
DecodeWorkers over a large single file) build one grouper per
partition and fold the per-key aggregator buckets themselves. Your
grouper’s own components state (the counters behind Components())
is folded through MergeableGrouper.MergeGrouperState(other)
(internal/processing/grouper_merge.go); other is a fresh instance
of the same concrete type from the same spec. Implement it and return
true from GroupType.Mergeable() (types/streamability.go) only when
the fold is associative — a sum, or the same first/last-write rule the
serial tracker applies — so the merged Components() equals a serial
instance’s. The reducers sum the assignment count across partitions
and hand it to processing.FinalizeGroupedStream, so total_n /
n_null match the serial path even for a bucket-less grouper. A
grouper not Mergeable() runs those requests serially
(processing.CanMergeRequestWithExtensions).
An extension grouper takes the same paths through the public
contract: declare Streamable: true and Mergeable: true on its
pulse.GrouperRegistration and, if it emits components, implement
extend.MergeableGrouper.MergeState(other); the adapter hands
MergeState your own value. A grouper that emits no components needs
no method (the adapter folds nothing). Probe-validation refuses a
declaration the value cannot honour
(PULSE_EXTENSION_MERGEABLE_MISMATCH). See
Extension points — Grouper.
The full Response.Components contract for groupers — streaming
behaviour by mergeability class, the orchestrator-owned floor, the
extension-side parity contract — lives in the response-components
skill.
Embedder extensions implement the same MetaGrouper interface via
ComponentsFunc or directly; see
Extension Points.
7. The Include inclusion-list slot
If your grouper honours an Include whitelist of allowed bucket
labels, update internal/processing/grouper.go + internal/processing/grouper_set.go
so the include filter is applied at key-emission time, not on the
output table after the fact. The contract is enforced by the
following gates:
TestGroupCategory_IncludeFiltersLabelsTestGroupSetValue_IncludeFiltersCompositeKeyTestGroupSetPerElement_IncludeFilters
8. Update the grouper-design skill
Add a section in skills/grouper-design.md covering the new
grouper’s bucket-naming convention, the params it accepts, the
streamability classification, and any cardinality bound.
9. Update CLAUDE.md
There is no registered-grouper count or list in CLAUDE.md to update —
CLAUDE.md never hardcodes registered counts; the manifest is the source of
truth. The Update Demand operator row (.claude/reference/update-demand.md)
requires instead the atomic skill skills/op-group-<kebab>.md, the capability declaration in
internal/descriptor/capabilities_groupers.go — and an internal/examples/<dir>/*.json example whose _meta.operators
names the operator (TestEveryOperatorHasAnExampleTag). Edit CLAUDE.md only if the
operator introduces a contract it states directly, and mind
TestClaudeMdSizeBudget — long-form prose belongs in .claude/reference/.
10. Run the gates
go test ./internal/skills/ -run TestSkillsCoverAllComponents
go test ./internal/descriptor/ -run TestManifestOperatorsComplete
go test ./internal/processing/ -run TestGroup
The Update Demand row for groupers (and the Group.Include slot) covers
all of these in one PR; see The Update Demand.
Adding a Window Operator
Embedding Pulse, not contributing to it? This recipe adds a built-in in
internal/processing/, which embedders cannot import. To add your own operator, implement the matching contract in the publicextendpackage instead — see Extension Points.
Audience: Pulse internals contributors adding a new WIN_*
operator — a window-frame function that emits a per-row value derived
from a sliding or anchored frame around the current record
(WIN_LAG, WIN_LEAD, WIN_RANK, WIN_RUNNING_*, WIN_EWMA, …).
The recipe mirrors the aggregator recipe; the window-specific moving parts are the frame contract and the windows registry. Streamability is not one of them — see step 3.
1. Declare the type constant
Add the new constant to types/types.go and the slice returned by
types.AllWindowTypes():
const (
// ... existing constants ...
WIN_PERCENT_RANK WindowType = "WIN_PERCENT_RANK"
)
func AllWindowTypes() []WindowType {
return []WindowType{
// ... existing entries, alphabetised ...
WIN_PERCENT_RANK,
}
}
2. Implement and register
Window operators live under internal/processing/window/. Each file is one
operator; register the factory in the package’s init() via the
register(types.WIN_X, newX) call shape used by sibling files.
The frame semantics — anchored vs sliding, lookahead vs lookback — are part of the operator’s contract and ride in the operator’s struct parameters; the orchestrator’s window-pass invokes the implementation per row in declared order.
3. Streamability — nothing to do
No window operator streams today. WindowType.Streamable() in
types/streamability.go is an unconditional return false, because every
WIN_* operator needs a sort over the row set:
func (t WindowType) Streamable() bool {
return false
}
There is no per-type case to add and no table row to write.
TestStreamability_WindowsKnown iterates types.AllWindowTypes() and asserts
every constant answers false, so a new operator is covered the moment step 1
lands. If a future window operator genuinely streams, that is when the method
grows a switch and the test grows a table — not before.
4. Capability declaration
Add a row to internal/descriptor/capabilities_window_ops.go with the operator’s
params, accepted field types, and streamable hint.
TestManifestOperatorsComplete enforces a capability row per registered
window operator.
5. Tests
Add tests in internal/processing/window/<name>_test.go. Cover the empty-frame,
single-row, null-bearing, and order-sensitive cases.
6. Write the atomic skill
The gated target is the atomic skill skills/op-win-<kebab>.md, not a
section in skills/window-design.md — TestSkillsCoverAllWindowTypes and
TestOperatorHasAtomicSkill both key off that stem. Frontmatter name: must
equal the file stem; category: WIN; operator: the full constant. The
required ## sections are ## Params, ## Inputs, ## Output,
## Gotchas, ## See, and the body budget is 1,200 characters
(TestAtomicSkillHasRequiredSections, TestSkillTokenBudget).
Add the operator to skills/window-design.md only where the topical prose
would otherwise be wrong (the frame-forbidden list, a family gotcha) — per-op
detail belongs in the atomic file.
7. Update CLAUDE.md
There is no registered-window count to bump — CLAUDE.md forbids hardcoded
component counts because the manifest is the source of truth. CLAUDE.md’s
Update Demand row for windows is generic over internal/descriptor/capabilities_*.go,
so a new window operator normally needs no CLAUDE.md edit at all. Edit it only
if the operator introduces a contract CLAUDE.md states directly, and mind
TestClaudeMdSizeBudget — long-form prose belongs in .claude/reference/.
8. Run the gates
go test ./internal/skills/ -run TestSkillsCoverAllWindowTypes
go test ./internal/descriptor/ -run TestManifestOperatorsComplete
go test ./internal/processing/window/...
go test ./types/ -run TestStreamability_WindowsKnown
The Update Demand row for windows covers all of these in one PR; see The Update Demand.
Adding a Feature Operator
Embedding Pulse, not contributing to it? This recipe adds a built-in in
internal/processing/, which embedders cannot import. To add your own operator, implement the matching contract in the publicextendpackage instead — see Extension Points.
Audience: Pulse internals contributors adding a new FEAT_*
operator — a pre-filter feature engineer that runs before the
aggregation / window pass and emits one or more derived columns
(FEAT_LOG, FEAT_SQRT, FEAT_BUCKETIZE, …).
The recipe mirrors the aggregator recipe; the feature-specific moving
parts are the StreamingComputer (internal/processing/feature; embedders implement extend.StreamingFeatureComputer) interface, the output-label
emitter, and the predict-side label projection.
1. Declare the type constant
Add the new constant to types/types.go and the slice returned by
types.AllFeatureTypes():
const (
// ... existing constants ...
FEAT_BOX_COX FeatureType = "FEAT_BOX_COX"
)
func AllFeatureTypes() []FeatureType {
return []FeatureType{
// ... existing entries, alphabetised ...
FEAT_BOX_COX,
}
}
2. Implement in internal/processing/feature/
Each feature operator lives in internal/processing/feature/<name>.go.
Register via the package’s init() calling
register(types.FEAT_X, newX).
If the operator is streaming-eligible, implement the
feature.StreamingComputer interface — a three-method shape:
PrePass(rows)— accumulate any whole-cohort statistics needed (mean, stddev) on a first pass.Finalize()— close out the pre-pass, compute coefficients.EmitRow(row)— emit the per-row feature value(s) on the second pass.
Operators without whole-cohort statistics skip PrePass and run as
single-pass row transforms.
3. Tests
Write tests in internal/processing/feature/<name>_test.go before the
implementation. Cover the empty-input, single-row, null-bearing, and
boundary cases.
4. Capability declaration
Add a row to internal/descriptor/capabilities_features.go with the operator’s
params, accepted field types, and any emit shape.
TestManifestOperatorsComplete enforces a row per registered feature.
5. Predict-side label projection
Update internal/descriptor/predict_feature.go:
- Validate the operator’s params (raise the appropriate
PROCESSING_CONFIG/SERVICE_VALIDATIONerror code on invalid input). - Emit the operator’s output column labels in
featureOutputLabelsso predict can show the LLM client what columns the request will materialise.TestPredict_Featureenforces parity.
6. Update the feature-engineering skill
Add a section in skills/feature-engineering.md covering the
operator’s params and output column naming convention. The
TestSkillsCoverAllComponents gate enforces presence by name.
7. Update CLAUDE.md
There is no registered-feature count or list in CLAUDE.md to update —
CLAUDE.md never hardcodes registered counts; the manifest is the source of
truth. The Update Demand operator row (.claude/reference/update-demand.md)
requires instead the atomic skill skills/op-feat-<kebab>.md, the capability declaration in
internal/descriptor/capabilities_features.go — and an internal/examples/<dir>/*.json example whose _meta.operators
names the operator (TestEveryOperatorHasAnExampleTag). Edit CLAUDE.md only if the
operator introduces a contract it states directly, and mind
TestClaudeMdSizeBudget — long-form prose belongs in .claude/reference/.
8. Run the gates
go test ./internal/skills/ -run TestSkillsCoverAllComponents
go test ./internal/descriptor/ -run 'TestManifestOperatorsComplete|TestPredict_Feature'
go test ./internal/processing/feature/...
The Update Demand row for feature operators covers all of these in one PR; see The Update Demand.
Adding a Statistical Test
Embedding Pulse, not contributing to it? This recipe adds a built-in in
internal/processing/, which embedders cannot import. To add your own operator, implement the matching contract in the publicextendpackage instead — see Extension Points.
Audience: internals contributors adding a new TEST_* operator —
tier-1 (row-stream) or tier-2 (post-test on the materialised result
set).
The recipe mirrors the aggregator and feature recipes; the test-specific moving parts are streamability, the test catalog, and the registered-test capability table.
From CLAUDE.md, “Update Demand” rows for statistical tests and tier-2 post-test variants.
1. Decide tier
- Tier 1. Runs against the raw row stream, alongside aggregators.
Online-moments tests (
TEST_T,TEST_WELCH,TEST_CHISQ,TEST_ANOVA_F) stay in the streaming Process path. Sort-required tests (TEST_KS) force the buffered path. - Tier 2. Runs after the result set is materialised, in
req.PostTests. Always buffered.
2. Declare the type constant
Add to types/types.go:
const (
// ... existing constants ...
TEST_GINI_TREND TestType = "TEST_GINI_TREND"
)
Add it to types.AllTestTypes().
3. Implement and register
Tests live in internal/processing/test_*.go. Existing examples to mirror:
internal/processing/test_t.go— online tier-1 test.internal/processing/test_anova.go— tier-1 ANOVA with grouper support.internal/processing/test_post.goandinternal/processing/test_post_more.go— tier-2 post-tests.internal/processing/test_studentized.go— numerical integration utilities (used byTEST_TUKEY_HSD).
Register the test in internal/processing/test.go (the registry construction
calls). For tier-2 variants, declare both the base type and the
variant identifier the post-test surface uses.
4. Streamability
Add a case in types/streamability.go for the new TestType:
func (t TestType) Streamable() bool {
switch t {
// ...
case TEST_GINI_TREND:
return false // sort-based
}
}
Add the matching row in types/streamability_test.go so
TestStreamability_TestsKnown passes.
5. Capability declaration
Add a row to internal/descriptor/capabilities_tests.go:
- For a tier-1 test, declare it in the tier-1 catalog (
testCapabilities). - For a tier-2 post-test, declare it in
postTestCapabilities.
TestManifestTestsComplete and TestManifestPostTestsComplete
enforce that the manifest enumerates every registered test.
6. Skill update
Add an entry to skills/statistical-testing.md under “Operator
catalog”. Describe the test’s family, inputs, outputs (statistic, p,
df, effect size, …), and any preconditions (PULSE_TEST_* error
codes it can raise). For tier-2 variants, also document the variant
field shape since the post-test API exposes it.
7. Tests
Use the same TDD pattern as for aggregators. The processing package
has rich existing test files to model new cases against:
processor_test_pipeline_test.go, test_parametric_test.go,
test_nonparametric_test.go, test_post_more_test.go. Add hermetic
fixtures that exercise the streaming and buffered paths.
8. Error codes
If your test introduces a new failure mode, add a code to
errors/codes.go (mirror the existing PULSE_TEST_* family),
register its description row in internal/descriptor/capabilities_errors.go,
and document recovery in errors/fixup_metadata.go (codeMetadata
Message + Fixups, surfaced per-code via pulse_errors_lookup /
pulse errors lookup CODE). See the
Adding an Aggregator recipe for the same
pattern at the aggregator layer.
9. CLAUDE.md
There is no registered-test count or list in CLAUDE.md to update —
CLAUDE.md never hardcodes registered counts; the manifest is the source of
truth. The Update Demand operator row (.claude/reference/update-demand.md)
requires instead the atomic skill skills/op-test-<kebab>.md, the types/streamability.go case (§4), the capability declaration in
internal/descriptor/capabilities_tests.go — and an internal/examples/<dir>/*.json example whose _meta.operators
names the operator (TestEveryOperatorHasAnExampleTag). Edit CLAUDE.md only if the
operator introduces a contract it states directly, and mind
TestClaudeMdSizeBudget — long-form prose belongs in .claude/reference/.
A new preconditions class (e.g. paired sample, repeated measures)
belongs in skills/statistical-testing.md, not CLAUDE.md.
10. Run the gates
go test ./types/ -run TestStreamability_TestsKnown # declared TestType.Streamable() table
go test ./internal/processing/ -run 'TestProcessor_Tier1|TestProcessor_PostTest' # tier-1 streaming vs buffered, post-test dispatch
go test ./descriptor/ ./internal/descriptor/ -run TestManifest
go test ./internal/skills/ -run TestSkillsCoverAll
go test ./...
See The Update Demand for the full row that governs statistical-test changes.
Adding a Synth Distribution
Audience: Pulse internals contributors adding a new synthetic-data
distribution kind to the internal/synth/ package — Normal, Uniform,
Categorical, ZipfMandelbrot, etc.
The synth surface generates .pulse cohorts from either a declared
schema (pulse synth from-schema) or a profile sampled from an
existing cohort (pulse synth from-profile). Distributions plug into
both code paths via the AllDistributions() registry in internal/synth.
1. Implement in internal/synth/
Add the distribution implementation in internal/synth/ (the public synth package is an alias facade; re-declare a new Dist* constant there if embedders should spell it). Each distribution
satisfies the package’s distribution interface (per-field RNG +
parameter validation + JSON marshalling). Register it in the
AllDistributions() slice (internal/synth) so the generator surface enumerates
it.
2. Capability declaration
Add a row to internal/descriptor/capabilities_distributions.go so the manifest
exposes the new distribution to LLM clients.
TestManifestDistributionsComplete enforces a capability row per
registered distribution kind.
3. Write the atomic skill and list it
Create skills/op-synth-<kebab>.md covering the parameter shape, the
distribution’s family (continuous, discrete, heavy-tailed,
categorical-like), and any sampling caveats;
TestSkillsCoverAllSynthDistributions enforces the file exists at that stem.
Add the kind’s name to the “Distribution registry” list in
skills/synthetic-data.md, and — if it changes reconstruction, correlation
or model participation — the focused skills/synth-*.md skill that owns that
topic (synth-marginals, synth-correlations, synth-models).
4. Update CLAUDE.md
There is no registered-synth-distribution count or list in CLAUDE.md to update —
CLAUDE.md never hardcodes registered counts; the manifest is the source of
truth. The Update Demand operator row (.claude/reference/update-demand.md)
requires instead the atomic skill skills/op-synth-<kebab>.md, the capability declaration in
internal/descriptor/capabilities_distributions.go. Edit CLAUDE.md only if the
operator introduces a contract it states directly, and mind
TestClaudeMdSizeBudget — long-form prose belongs in .claude/reference/.
5. Run the gates
go test ./internal/skills/ -run TestSkillsCoverAllSynthDistributions
go test ./internal/descriptor/ -run TestManifestDistributionsComplete
go test ./internal/synth/...
The Update Demand row for synth distributions covers all of these in one PR; see The Update Demand.
Adding an I/O Format
Audience: internals contributors adding a new bidirectional
tabular format (a peer to the existing csv/, tsv/, ndjson/,
jsonarray/, arrow/, parquet/, excel/, spss/ sub-packages under
internal/io/).
From CLAUDE.md, The Update Demand (registered I/O format row) and
.claude/reference/update-demand.md.
1. Create the sub-package
Each format is a sub-package under internal/io/. Create
internal/io/<format>/<format>.go with both a reader and a writer.
The two interfaces to implement live in the internal/iocore leaf
(the public io package aliases them as io.Reader / io.Writer).
An adapter imports internal/iocore for the contracts and, only if it
must drive a job itself (the SPSS writer’s row path runs an
ImportJob), internal/io for the jobs. It must never import the
public io package: io’s factory imports every adapter, so that
would be a cycle. TestIOImportBoundary enforces it — add the new
package to its belowFacade list.
// Reader
type Reader interface {
ReadHeader() ([]string, error)
ReadRows(ctx context.Context, fn func(row []string) error) error
Close() error
}
// Writer
type Writer interface {
WriteHeader(columns []string) error
WriteRow(values []any) error
Close() error
}
Offer all four constructors — NewReader(fs, path),
NewReaderFromBytes(data), NewWriter(fs, path) and
NewWriterToBuffer() plus a Bytes() []byte method on the writer —
because the factory’s NewReaderFromBytes / NewWriterToBuffer must
cover every format it advertises.
If the reader needs schema inference (header sample, then full
import), also implement io.ResetReader.Reset() so the import job
can rewind after sampling.
Headerless formats: the first record is still a record
Some formats carry no header line at all — NDJSON and a top-level JSON
array both derive their column names from the keys of the first
object. That object is a RECORD as well as a column declaration, so
ReadHeader must buffer it and ReadRows must replay it as row one.
Getting this wrong is invisible: no error, no warning, every import
one row short and a single-record file importing nothing. internal/io/ndjson
and internal/io/jsonarray both keep the decoded first object in a pending
field and emit it before resuming the scan; copy that shape.
Only the first object’s KEYS define the column set. Replaying the record does not widen the columns, and a key that first appears in a later object is still not a column.
2. Tests
Add internal/io/<format>/<format>_test.go with the standard round-trip
checks: write rows, read them back, verify equality. Hermetic tests
should use afero.NewMemMapFs() — see Testing
Conventions.
For a headerless format, assert the record COUNT on a one-record and a many-record source, and assert the first record’s VALUES — a count alone passes on a reader that drops record one and duplicates another.
3. Register and wire it up
Registration is spread across four places, and a format wired into only some of them is reachable by one verb and mysteriously absent from another.
io/format.go + io/factory.go — the one dispatch every surface
(CLI import / export / convert, imports.Manager, embedders) shares:
- a
Formatconstant and its entry in theformatslist behindFormats()(which also drivesFormat.CanRead/CanWrite); - an
ext → Formatcase inFormatFromPath(this is what makespulse convert,pulse import autoandpulse_importdetect the file at all); - a case in each of
NewReader,NewReaderFromBytes,NewWriterandNewWriterToBuffer, returning the adapter pointer itself — never a wrapper, or every optional interface the jobs discover by type assertion is lost (TestFactory_ReturnsAdapterUnwrapped); - a per-format options sub-struct on
ReaderOptions/WriterOptionsonly when the CLI orimports.Specactually reaches a knob. Sub-structs for other formats are ignored silently, so one options value serves every leaf. Validate anything that can be wrong at construction and return a coded error.
Formats() is what documentation and help output enumerate, so a
format with no entry there is reachable only by accident — and an entry
with a missing factory case advertises an adapter the engine cannot
build. TestFactory_EveryAdvertisedFormatConstructs closes that loop;
TestFromExt_Matrix pins the extension mapping and
TestFactory_RoundTripEveryFormat writes and reads a table through all
four constructors. An unknown, empty or native pulse format is
PULSE_IO_FORMAT_UNSUPPORTED. A read-only format would make
CanWrite false and return its OWN coded error from the writer
constructors naming the format, not the generic code — the extension is
recognised, so “unsupported” says the wrong thing. (SPSS was the worked
example until its writer landed; PULSE_SPSS_EXPORT_UNSUPPORTED has
since been repurposed to mean “this cohort has no honest .sav form”.)
internal/cli/import.go — an importFormatCmd("yourformat") line
in the Commands: slice on ImportCommand(). Reader construction
(makeImportReader) and the per-format flags (readerOptionsFrom)
already route through the factory; a new reader knob gets a flag here
and a field in readerOptionsFrom.
internal/cli/export.go — an exportFormatCmd("yourformat") line
in ExportCommand(), when a writer exists; a new writer knob gets a
flag and a field in writerOptionsFrom.
internal/descriptor/capabilities_export.go — the manifest capability
blocks. importCapability() gains an ImportFormatCapability
(name, extensions, SchemaSource — inferred or authoritative —
and whether the same format can be written); exportCapability()
gains an ExportFormatCapability only when a writer actually exists.
Both slices are alphabetised so the golden manifest stays stable, and
TestManifestImportCapability_MatchesFormatRegistry pins the
hand-declared table against io.Formats(), FormatFromPath and
Format.CanWrite.
Two prose surfaces hardcode the format list and are easy to miss:
internal/mcp/contract.go (the ImportIn.Format jsonschema description) and
internal/mcp/toolmeta/meta.go (DescImport).
Regenerate the manifest golden afterwards — never hand-edit it:
go test ./descriptor/ -run 'Test.*Golden' -update
4. Schema mapping
If the new format has a native type system (Arrow / Parquet do, CSV
does not), share the type map with neighbouring formats via the
internal/io/arrow package the way Parquet already does. CSV / TSV / NDJSON
/ JSON-array share internal/io/jsonshared for value coercion.
Explicit schemas are matched by header NAME
A caller-authored schema (ImportJob.Schema / ConvertJob.Schema,
CLI --schema) is matched to your reader’s columns by the exact names
ReadHeader returns — resolveExplicitSchema in
internal/io/explicit_schema.go, the one resolver ImportJob.Run,
ImportJob.Predict (and its measured pass) and ConvertJob.Run /
Predict all run. It rewrites CsvColumnIdx on a copy of the schema
and refuses a field with no column (PULSE_IMPORT_SCHEMA_FIELD_MISSING),
a column no field names (PULSE_IMPORT_SCHEMA_COLUMN_UNMAPPED, unless
IgnoreExtra) and a repeated name (PULSE_IMPORT_SCHEMA_DUPLICATE)
before any row is read. Your adapter needs no code for it, but two
things must hold: ReadHeader must be idempotent and must not consume
a row the next ReadRows would emit, and the header names must
correspond position-for-position to the cells of every row.
A keyed format whose column set is the first record’s keys (NDJSON,
JSON array) should also implement the optional iocore.LateKeyRefuser
(RefuseLateKeys(on bool), kept across Reset): the resolver arms it
when extra columns are not ignored, and the reader then fails ReadRows
with PULSE_IMPORT_SCHEMA_KEY_LATE (column, row, columns) at the
first record carrying a key the header lacks, instead of dropping it.
jsonshared.LateKeys holds the shared state and refusal.
Native datetimes: instant or wall clock
The row path is text, and an import source zone (ImportJob.SourceTZ)
applies only to a datetime literal with no Z and no offset. A reader
with a native datetime type must therefore decide, per column, which of
two literals it emits — and the choice IS the zone contract:
- A value that names an instant (an Arrow timestamp with a
TimeZone, a ParquetTIMESTAMPwithisAdjustedToUTC=true) renders as the canonical UTC literal (encoding.FormatDateTime,…Z), which no source zone reinterprets. - A wall clock (a zone-less Arrow timestamp,
isAdjustedToUTC=false, legacy ParquetINT96, every SPSSDATETIME) renders as a naive literal (2006-01-02T15:04:05), so the source zone and DST policy apply; with no zone it reads as UTC, the same instant as before.
Floor sub-second values toward the past (also before 1970) and report
any floored fraction through SourceWarningEmitter as one
PULSE_IMPORT_TIMESTAMP_TRUNCATED per pass — never per row, never
silently. Reset the tally at the top of each ReadRows so an inference
sample followed by the row pass does not double it. The Arrow helpers
(internal/io/arrow FormatTimestamp, TimestampTally) are shared
with Parquet.
The external form of a set_* cell
A set column’s external form is its selected labels, never the
bitmask. ExportJob.Run hands a Writer one string per set cell:
the selected dictionary labels joined with iocore.DefaultSetDelimiter
("|"), in ascending bit order — which is dictionary-index
order, not the order the tokens appeared in the source. This is the
exact inverse of the import obligation above, which is what makes a
set column survive cohort → format → cohort.
The three states, and the one marker that carries them
A set cell is not two-valued. It has null (no answer), an empty mask (answered, selected nothing) and a selection, and all three are distinct inside the cohort: null rides the per-record null bitmap, an empty mask is a real zero-width selection. For survey data the middle state is load-bearing — “ticked none of these” and “skipped the question” give different denominators — so an adapter that collapses them corrupts an analysis without failing anything.
The external forms, identical at every rung from set_u8 to
set_u256:
| State | Flat text (csv / tsv / excel) | JSON (ndjson / jsonarray) | arrow / parquet |
|---|---|---|---|
| null | "" (any isNullToken spelling) | null | validity bit clear |
| empty mask | iocore.EmptySetCell — a bare `“ | “`, no token | `“ |
| selection | `A | B` | `“A |
iocore.EmptySetCell is one marker for every format, so the convention
cannot drift between adapters, and it survives a round trip through a
third-party tool because it is ordinary cell text: unlike CSV’s
,, versus ,"",, a spreadsheet or a generic CSV writer has nothing
to normalise away. It costs nothing at the other two states — a null
cell is still "" and a non-empty cell is still its joined
tokens, byte for byte.
It works by the composition of two documented behaviours in the
shared import path, and both are part of the contract:
isNullToken recognises exactly "", na, n/a and null, so
"|" reaches value conversion instead of being read as null; and
splitSetTokens trims each part and drops the empty ones, so "|"
yields zero tokens — mask 0, with no dictionary mutation. Widening
the null-token set to cover a lone delimiter, or making
splitSetTokens retain empty tokens, re-collapses the two states
SILENTLY: both spellings keep importing and only the meaning changes.
Implementer obligations.
- A Writer that stringifies (
csv,tsv,excel, and the JSON adapters viajsonshared.CoerceValue) needs no set-specific code: the marker is already in the stringExportJob.Runhands over. - A Writer with a native list type MUST route set cells through
parrow.AppendSetList(or reproduce it):niland""are the null cell and take the validity bit; anything else opens a list, empty for the marker. Reading""as an empty list is the bug this replaced. - A Reader with a native list type must render a present zero-length
list as
iocore.EmptySetCell, not""—""is a null token and the null cell already has its own channel one level up.internal/io/arrow’sformatStringListSliceis the reference. - A Reader over JSON must map
[]to the marker (jsonshared.ValueToString), so a producer that does not know the convention and simply emits[]still imports as an empty selection.
The matrix that pins all of this for every adapter at a narrow rung
and a wide rung is internal/io/settristate/.
categorical_* has the same empty-string-vs-null collapse and it
does have a real out-of-band null channel now — see “The categorical
null cell” below. The two mechanisms do not overlap: a set cell’s
present-but-empty state stays on the iocore.EmptySetCell marker at
every adapter, including the four with a null channel.
The form is width-agnostic. Every rung from set_u8 to
set_u256 externalizes identically; a 206-label cell is simply a
longer token list. A Writer therefore never branches on the rung —
but it MUST branch on Type.IsSet() if its native type for a set is
a list (internal/io/arrow’s LIST<UTF8>), because the joined string has to
be split back into elements before it reaches a list builder.
Handing the joined string to a list builder’s generic
AppendValueFromString makes it parse as JSON, fail on every row,
and produce an export that reports success having written no rows.
On the decode side the wide rungs (set_u128 / set_u256) do not
fit the uint64 value API at all: encoding.ReadFieldValue refuses
them with ENCODING_TYPE_MISMATCH, so the export loop reads them
with encoding.ReadSetMask instead. A format that walks cohort
bytes itself (io.CohortWriter, below) has to make the same split —
branch on FieldType.IsWideSet().
The categorical null cell: io.NullAwareWriter / io.NullAwareReader
categorical_* has the same two-states-that-look-alike problem and it
cannot be solved the same way. A set’s "|" marker works because no
legal selection can spell it; any text is a legal categorical
value, so every candidate sentinel makes some real value
unrepresentable — a worse bug than the one it would fix. The .pulse
format already keeps the pair apart (null rides the bitmap, "" is an
ordinary dictionary entry), so the distinction has to ride out of
band, beside the cell rather than inside it.
| State | export row ([]any) | JSON | arrow / parquet | flat text |
|---|---|---|---|---|
| value | "red" | "red" | "red" | red |
| empty-string value | "" | "" | present "" | (collapses) |
| null | nil | null | validity bit clear | (collapses) |
Write side. ExportJob.Run spells a bitmap-null cell as an
untyped nil and an empty-string value as "", then declares the
convention with io.NullAwareWriter.SetExplicitNulls(true).
ConvertJob never makes that call, because its rows are source TEXT
in which "" is the null token isNullToken recognises — so a
writer’s null policy follows the caller’s provenance, never the cell’s
spelling. Use iocore.IsNullCell(v, explicit) rather than testing the
flag by hand. A Writer that ignores the interface entirely keeps the
text convention and is unaffected; internal/io/csv and internal/io/tsv already
render nil as "", so their bytes did not move.
Read side. Reader.ReadRows yields []string, which has no null
channel at all — a JSON null, an Arrow validity bit and an empty JSON
string all arrive as "". A source whose format HAS the channel
implements io.NullAwareReader.RowNulls() []bool, valid for the row
currently in the callback, and ImportJob believes it over the text:
a declared null is null whatever the text says, and a cell the source
declares PRESENT keeps "" as a value only where the type can
hold it — a dictionary-bearing non-set type, and nothing else. ""
in a u32 or a date column is still a null, because reading it as a
value would turn a recoverable null into a per-row import error.
set_* is excluded on purpose: its present-but-empty state already
has the iocore.EmptySetCell spelling and must behave identically on
every adapter.
The adapter matrix, gaps included.
| Adapter | Carries null vs "" | How |
|---|---|---|
ndjson, jsonarray | yes | JSON null vs "", via jsonshared.CoerceValueExplicitNull on write and key-presence on read |
arrow, parquet | yes | AppendNull vs Append(""); Array.IsNull on read |
csv, tsv | no | RFC 4180 has one spelling for an absent value, and Go’s encoding/csv does not preserve unquoted-empty vs quoted-empty on read |
excel | no (write side only) | the writer already emits a genuinely blank cell for nil and an empty-string cell for "", so the artefact carries it; excelize’s row API (GetRows / Rows.Columns) drops it, and the per-cell GetCellType probe that recovers it is O(rows) per call |
spss | n/a | does not ride the row path — io.CohortWriter reads the bitmap itself |
A gap is documented and asserted, never silent, and its direction is
part of the contract: an adapter that cannot carry the distinction
reads BOTH states back as null. Losing an empty string into null
is the pre-existing behaviour and is recoverable from the source;
inventing an empty-string value where the cohort held a null would be
new data. internal/io/nullcell/ pins every row of that table, including the
three gaps — flipping an entry fails the matrix.
Authoritative schemas: io.SchemaAwareReader
By default ImportJob.Run samples up to SampleRows rows and votes
on each column’s type in internal/io/infer.go — a guess made from
stringified cells. A format that carries its own dictionary (SPSS
.sav; in principle Parquet and Arrow) should not be guessed at.
Such a reader implements the optional io.SchemaAwareReader:
type SchemaAwareReader interface {
Reader
PulseSchema() (*encoding.Schema, error)
}
It is the read-side mirror of io.SchemaAwareWriter: ExportJob.Run
pushes the .pulse schema into a writer via SetPulseSchema;
ImportJob pulls the .pulse schema out of a reader via
PulseSchema. Both ImportJob.Run and ImportJob.Predict
type-assert it, and a reader that does not implement it takes the
unchanged sample-then-infer path byte-for-byte.
Implementer obligations. The returned schema is written verbatim,
so every field must be complete: Name, Type, Nullable,
CsvColumnIdx (the index into the []string row ReadRows yields —
fields are not positionally matched to the header), Precision /
Scale for decimal128, and a pre-populated Dictionary for
every type where Type.HasDictionary() is true. That dictionary is
the load-bearing slot: entry order is the on-wire encoding —
position i is the categorical_* ID and the set_* mask bit — so
handing over the source’s own ordering is what preserves the source
codes. Leave it nil and the importer fills it in first-seen order
from cell text, which is exactly the re-guessing the interface exists
to prevent. Cell text still flows through the normal conversion, so
the dictionary is pre-seeded rather than sealed: a value absent from
it is appended, subject to the type’s width limit. set_* cells must
arrive as tokens joined with iocore.DefaultSetDelimiter ("|").
Precedence. ImportJob carries four slots that exist only to
steer inference. With an authoritative schema there is nothing to
steer, so three are inert and the fourth is refused:
| Slot | With an authoritative schema |
|---|---|
SampleRows | inert — nothing to sample |
SetInferenceMinPct | inert — no delimited-cell heuristic runs |
SetDelimiters | inert — DefaultSetDelimiter is the fixed contract |
ColumnTypeOverrides | refused — PULSE_IMPORT_OVERRIDE_INVALID, see below |
ColumnTypeOverrides is the managed-import force_type escape
hatch, and it loses deliberately — loudly. An override is applied
exactly or refused, never ignored, and it is refused whenever a schema is
supplied, which a reader-supplied schema is: forcing a type onto a
dictionary-carrying column would discard the source’s category IDs /
mask bit positions and silently rebuild them in first-seen order. A
text-inference adapter needs no override code of its own — the override
rule (exact header-name match, every present value checked at the forced
type on every row, no promotion) lives in internal/io/import_override.go
and runs over whatever ReadHeader / ReadRows yield. A
caller who genuinely needs a different type sets ImportJob.Schema
explicitly — an explicit schema wins outright and PulseSchema is
not even called; it is matched to ReadHeader’s names like any other
explicit schema (see “Explicit schemas are matched by header NAME”).
No null promotion. ImportJob.InferredSchema’s out-of-sample-null
tolerance does not apply. An authoritative schema is not
inference-originated, so a null in a field the source declares
non-nullable is a genuine data error: it stays a
PULSE_IMPORT_ROW_ERROR and the field is never widened.
ImportReport.PromotedFields is always empty for such an import, and
setting InferredSchema alongside a SchemaAwareReader does not
re-enable promotion.
Failure modes. A non-nil error fails the import — never a quiet
fallback to inference, because a source that has a dictionary and
could not read it must not produce a differently-typed cohort from
re-guessed text. A (nil, nil) return is the deliberate opt-out
(“this source carries no authoritative schema”) and falls back to
inference, which then requires a ResetReader as usual. A schema
with no fields, or a field with a negative CsvColumnIdx, is a
malformed contract and fails the import.
ConvertJob consults this interface through the same precedence, via
the shared readerSchema resolver: an explicit ConvertJob.Schema
wins outright, otherwise an authoritative source schema is adopted
before inference is considered, and the intermediate .pulse file
KeepPulseAt writes is built from it. That matters because
registering an extension on FormatFromPath immediately makes
pulse convert source.ext out.csv reachable — and a convert that
re-inferred types from the text the reader rendered would throw the
source dictionary away through a command the registration itself
created.
Non-fatal diagnostics: io.SourceWarningEmitter
A reader whose parse can raise warnings that do not stop an import —
an unrecognised metadata record, a column mapped to a wider type than
the ideal, a declared row count that disagrees with the rows present —
implements the read-side peer of OverlayWarningEmitter:
type SourceWarningEmitter interface {
Reader
Warnings() []*errors.CodedError
}
ImportJob.Run, ImportJob.Predict and ConvertJob.Run type-assert
it after the row pass (so progressively-discovered warnings are all
knowable) and lift the result onto ImportReport.SourceWarnings /
PredictReport.SourceWarnings / ConvertReport.SourceWarnings. The
CLI then routes those onto the --json envelope’s warnings array and
prints Warning [CODE]: message lines on the text path.
Implementations must be pure accessors: calling Warnings() must not
itself trigger a parse, and calling it twice must not double the set.
Readers that do not implement the interface contribute nil, keeping
every pre-existing report byte-identical.
Total failure is fatal, partial failure is not
A per-row refusal — your reader cannot convert a cell, or your writer
refuses a value — lands on ImportReport.RowErrors /
ExportReport.RowErrors and the job carries on. That is the PARTIAL
contract and it is unchanged.
What is not partial is a pass that read rows and got none of them
through. ImportJob.Run, ExportJob.Run and ConvertJob.Run classify
that as total failure and return a coded error with no report:
PULSE_IMPORT_ROW_ERROR / PULSE_EXPORT_ROW_ERROR (or the first row
error’s own code when it carries one), with rows_read, rows_failed,
first_row and first_error in Details. Import additionally writes
no .pulse file, and convert writes no KeepPulseAt intermediate. A
source or cohort with no rows at all is an empty cohort and stays a
success.
Convert falls back to the EXPORT code by provenance: its row loop hands
raw cell text through and never objects to it, so every RowError it
can record came out of your WriteRow.
Convert has one refusal of its own, and it is not row-shaped. A
categorical dictionary that fills its rung mid-pass raises
PULSE_IMPORT_CATEGORICAL_OVERFLOW — the IMPORT-half code, by the same
provenance rule, since the overflow happens reading the source — and
ConvertJob.Run stops there with no report and no KeepPulseAt
intermediate. It is fatal rather than partial because capacity is not a
property of the row: past the limit every further unseen category is
dropped for the rest of the file, which used to leave a ConvertReport
whose schema carried a silently short dictionary.
This matters when you write an adapter because a format-wide mistake — a column type your writer has no mapping for, a cell shape your reader never parses — fails identically on every row. Before the verdict existed, three such bugs shipped looking like successful zero-row runs. Your adapter needs no code for this; just do not swallow the per-row error, because the verdict is computed from the row-error slice.
Source metadata with no Pulse home: io.SidecarEmitter
A source format may declare things a .pulse file has nowhere to put.
An SPSS dictionary is the motivating case: measure levels, print
formats, arbitrary value codes, missing-value specifications, declared
string widths, multiple-response sets, document records and a source
charset are all load-bearing for a round trip, and none of them fits a
9-byte header, a schema block or a one-bit-per-field null bitmap.
Such a reader implements the third optional interface:
type SidecarEmitter interface {
Reader
WriteSidecar(fs afero.Fs, cohortPath string) error
}
ImportJob.Run type-asserts it and calls it after the cohort has
been written. The ordering is load-bearing: an implementation is
expected to fingerprint the cohort it describes, which requires the
cohort’s bytes to exist. fs is ImportJob.FS and cohortPath is
ImportJob.Target, so the sidecar lands on the same filesystem as the
cohort and fs.NewMemMap() stays hermetic — never reach for os.
ConvertJob’s KeepPulseAt path delegates to ImportJob.Run, so it
inherits the hook with no extra wiring.
Four conventions an implementation should follow:
- Name it by suffix, per the
imports.Sidecarconvention — append to the cohort filename rather than replacing its extension, so cohort and sidecar stay adjacent and sort together. Pick a suffix that does not collide withimports.SidecarSuffix(.meta.json), which a managed import writes for the same cohort. - Version the document and carry a
kind, so a reader can reject one written by something else before trusting its shape. - Carry a fingerprint block modelled on
encoding.Index: a 32-byte SHA-256 plusSourceSize(u64) andSourceModTime(i64 Unix ns), over the cohort — never over the original source file, which may be long gone. The read path then does an O(1) size+mtime stat and reserves the full hash for a verify pass. The policy that pairs with it: an absent sidecar is a warning (a cohort that was never derived from that format correctly has none), a stale one is an error (a stale dictionary over changed data yields a file that looks authoritative and is wrong). - Keep the metadata payload flat and self-contained — no
filesystem paths, no byte offsets into the source, no references to
anything outside itself — and hold it in a slot separate from the
fingerprint. A
.pulseschema metadata block would delete the sidecar entirely but needs aFormatVersionbump; a payload shaped this way can be lifted into one verbatim if that ever lands.
Build the document with encoding/json — never fmt.Sprintf. A
returned error fails the import: the cohort write on the same
filesystem has just succeeded, so a sidecar write that then fails is a
genuine fault, and a cohort silently missing the only surviving record
of its source dictionary is exactly the quiet degradation the sidecar
exists to prevent. Readers that do not implement the interface write no
extra file and take no extra stat, keeping every other format’s import
byte-identical.
Encoding from raw storage: io.CohortWriter
ExportJob.Run decodes a cohort, renders each record into []any, and
hands the row to Writer.WriteRow. For most targets that is exactly
right. For one class of target it is not, and the mismatch is not close
enough to fake.
internal/io/spss is the case that forced the interface. A .sav variable’s
on-wire value is derived from a categorical’s dictionary ID, a
set_*’s mask bits and the null bitmap — and every one of those
is gone by the time a row has been rendered. formatFieldValue resolves
a categorical to its label text (and two SPSS codes may legitimately
share one label), and a null renders as "", which a string categorical
can hold as a real value. Rebuilding the storage from the text would be
a guess in exactly the places fidelity is decided.
type CohortSource struct {
FS afero.Fs // ExportJob.FS — a hermetic MemMapFs export stays hermetic
Path string // the `.pulse` cohort path; also where a format sidecar rides
Includes []string // ExportJob.Includes verbatim
Labelled bool // a label resolver is rewriting or augmenting cells
TimeZone string // ExportJob.TimeZone when NOT UTC-equivalent, else ""
}
type CohortWriter interface {
Writer
WriteCohort(ctx context.Context, src CohortSource) (int, error)
}
Dispatch. ExportJob.Run type-asserts the interface after
SetPulseSchema and after WriteHeader — so a cohort writer still
receives both, and the header is still the projection-aware column list —
and then calls WriteCohort instead of its own row loop. Nothing is
double-decoded: the row loop does not run, and WriteRow is never
called on that path. Writers that do not implement it take the unchanged
row path, byte for byte.
Includes, Labelled and TimeZone are carried so you can REFUSE them, not
because a cohort writer is expected to implement them. A writer that
silently ignored Includes would answer pulse export spss --include age
with a file carrying every column, which is the quiet wrong answer this
whole surface exists to avoid. Return a coded error naming the option
instead. The returned count becomes ExportReport.RowsExported.
TimeZone is set only when the row stream would have rendered
datetime cells off UTC (--tz); a writer whose format cannot carry an
offset-bearing instant refuses it — internal/io/spss does, because a
.sav DATETIME is a naive wall-clock count.
A writer can implement both paths. internal/io/spss does: WriteCohort for
an export whose source is a cohort, and a buffering WriteRow for
pulse convert data.csv out.sav, where no cohort exists — it collects
the rows, builds an intermediate cohort in memory through the ordinary
import path, and encodes that. Buffering rather than streaming is
inherent: an intermediate cohort cannot be written until the last row has
been seen.
What the row path lost: io.SourceAwareWriter
A target that rebuilds a cohort from the row stream re-derives a schema
by inferring it from the text the source just rendered, and inference
cannot recover what the source declared. Two things went missing on
pulse convert survey.sav out.sav because of it, and neither was
recoverable downstream:
- the declared schema. A
set_*column round-trips as"Q1A|Q1C"tokens, so a re-inferred rung is only as wide as the options somebody actually ticked; a value-labelled numeric came back as a string column of bare numerals. - the metadata sidecar. It is the only home of the code / label /
dictionary-ID triple and of the derived-column registry, so the
rebuilt cohort could not tell a synthesised multiple-dichotomy
set_*column from a real one.internal/io/spssthen expanded it into member variables that collided by name with the constituents already in the cohort —PULSE_SPSS_NAME_COLLISION, which refused every multiple-dichotomy convert outright.
type ConvertSource struct {
Schema *encoding.Schema // the source's DECLARED schema, or nil
Sidecar SidecarEmitter // the source Reader itself, or nil
}
type SourceAwareWriter interface {
Writer
SetConvertSource(src ConvertSource)
}
ConvertJob.Run type-asserts it and calls SetConvertSource before
WriteHeader; a target that does not implement it is never consulted and
its convert output is byte-identical to the pre-interface shape.
What you may assume, and what you may not. The facts are offered only
when the row stream is faithful to the schema — one cell per field, in
field order, with no Includes projection and no label binding inserting
or rewriting cells — so Schema.Fields[i] describes emitted column i
and CsvColumnIdx is i. Schema is nil when convert inferred the
schema itself: there is nothing declared to preserve, and re-inferring is
the same guess over the same values. Sidecar is the source Reader, so
write it against the cohort you just built (WriteSidecar(fs, path)
re-fingerprints the document); the source’s own copy is fingerprinted
over the source cohort and a read path would correctly refuse it as
stale.
This is deliberately not SchemaAwareWriter. SetPulseSchema means
“here is the cohort you are exporting from” and the typed-column adapters
answer it by changing what they emit; this one means “here is what your
source declared, for the cohort you are about to rebuild”.
Encode-side diagnostics: io.TargetWarningEmitter
The write-side mirror of SourceWarningEmitter, and distinct from
OverlayWarningEmitter, which answers only the narrower question “were
overlay layers dropped?”:
type TargetWarningEmitter interface {
Writer
Warnings() []*errors.CodedError
}
ExportJob.Run and ConvertJob.Run collect after the write pass and
lift the result onto ExportReport.TargetWarnings /
ConvertReport.TargetWarnings. Implementations must be pure accessors —
calling Warnings() must not itself trigger work, and calling it twice
must not double the set. Writers implementing neither contribute nil,
so every pre-existing report stays byte-identical.
The canonical user is again internal/io/spss, whose encode raises diagnostics
that do not stop an export but change what the file MEANS: a metadata
sidecar that was absent or deliberately ignored (so the dictionary was
synthesised rather than reproduced), and every variable rename
--sanitize-names performed. A user who never sees those cannot tell a
faithful re-emission from a reconstruction.
Re-read after
Close.internal/cli/export.godeliberately callsWarnings()again afterwriter.Close()rather than trustingreport.TargetWarnings. A writer that buffers and encodes atClose— which the.savrow path does — has raised none of its diagnostics by the time the job builds its report. Because the accessor is pure, asking twice cannot double the set.
Releasing without emitting: io.DiscardableWriter
Most adapters need nothing here, and the absent implementation is the correct answer.
type DiscardableWriter interface {
Writer
Discard() error
}
The io CLI leaves deliberately do not call writer.Close() when the
job returns an error. Every adapter buffers its output and emits it
exactly once inside Close (afero.WriteFile, not an incrementally
written handle), so skipping Close writes nothing — the correct
outcome for a hard failure. Adding a defer writer.Close() would put a
zero-row target next to the error instead, which is precisely the
silent-success trap the total-failure verdict exists to close.
TestExportTargets_EmitNothingBeforeClose pins that property across every
export adapter, header and rows.
That reasoning is about DATA. Resources are a separate question, and one
adapter answers it differently: internal/io/excel drives an excelize
StreamWriter whose buffer spills to an os.CreateTemp file past
excelize.StreamChunkSize (16 MiB), and only excelize.File.Close
removes those files. Discard is the release-without-emitting half of
Close — drop the buffers and any temp files, write nothing, and leave
the writer inert so a later Close also writes nothing.
internal/cli/export.go and internal/cli/convert.go run
iio.DiscardWriter(writer) (internal/io, forwarding to iocore.DiscardWriter) on every error return. It is a no-op for a
writer that does not implement the interface, and it never falls back to
Close. Implement Discard only if your writer holds something the Go
GC cannot reclaim; if you buffer on the heap, do nothing.
Predicting a refusal: io.CohortValidator
pulse export predict was target-blind. ExportJob.Predict read the
source header and schema, estimated the row count, and answered “this
export is fine” regardless of the target — which was harmless only for
as long as every writer was infallible at the target boundary. The text
adapters stringify anything handed to them. internal/io/spss is the first
writer that can REFUSE, so predict was saying yes to exports that then
failed.
type CohortValidator interface {
Writer
ValidateCohort(ctx context.Context, src CohortSource) ([]*errors.CodedError, error)
}
The returned slice is the non-fatal diagnostics the real export would
raise; they land on PredictReport.TargetWarnings, the symmetric peer of
ExportReport.TargetWarnings. A non-nil error is the refusal the real
export would return, and it must carry the same code the export
itself carries — a predicted PULSE_SPSS_NAME_INVALID that exported as
something else would be worse than no prediction at all.
Dispatch. ExportJob.Predict type-asserts the interface. A Target
that is nil, or one that does not implement it, is predicted exactly
as it was before the interface existed: no extra read, no new failure
mode, the same PredictReport. That is the whole compatibility contract
and TestExportJob_Predict_NonValidatingTargetUnchanged pins it.
Unlike CohortWriter, a validator is reached without SetPulseSchema
and without WriteHeader — predict starts no write lifecycle on a
writer it will never Close. Everything a validator needs rides
CohortSource: FS + Path locate the cohort and any format sidecar
beside it, and Includes / Labelled / TimeZone carry the row-stream
transformations so they can be refused here on the same terms
WriteCohort refuses them.
A validator may never refuse something the real export would accept. A false refusal blocks work that would have succeeded and the caller has no way to appeal it. Where a verdict needs a record — a value whose width overflows, a character the target charset cannot encode, a dictionary ID with no source code behind it — warn, or stay silent. Never guess. Predict is therefore a sound but incomplete filter.
The refusal set is mostly reachable without records precisely because a
.pulse cohort’s records are fixed-width numerics: every string lives in
the schema block’s dictionaries. Name legality, charset encodability of
the dictionary text, sidecar state, derived-column foldability and the
Includes / Labels refusals are all schema + sidecar facts.
Implement it by re-running the write path’s own checks, not by
re-stating them. internal/io/spss splits its encode at the last point before
the first record is read — planCohort returns the sidecar resolution,
the built dictionary and a bound encoder — so WriteCohort goes on to
the data pass and ValidateCohort closes the file and reports. One
implementation, two callers; a check that moves cannot move in only one
of them. Validation must also have no observable side effect: no output
file, no mutation of the writer’s own encode state, and safe to call
before, after or instead of a write pass.
CLI wiring. internal/cli/export.go’s predict leaf builds the target
through the io.NewWriter factory against a MemMapFs and a throwaway path,
and never Closes it, so no adapter’s bytes can reach any filesystem.
It mounts the target format’s write flags too (--sanitize-names turns a
.sav name refusal into a warning, so a predict that could not be told
about it would refuse an export that would have succeeded), and with no
--format it keeps the old target-blind behaviour and says so in the
output.
5. Skill and doc update
Add or update a skill that points users at the new format. Cohort-
schema considerations (field-type round-trip, dictionary behaviour,
null markers) belong in skills/cohort-schema-design.md and its
focused children (skills/cohort-null-states.md for null / empty
markers, skills/cohort-width-overflow.md for width promotion).
skills/tool-import.md carries the format enum the MCP tool accepts
and must list the new identifier.
Add a user-facing mdBook page under docs/src/cli/ for the new leaf
(docs/src/cli/import-spss.md is the worked model — synopsis, type
mapping, flags and which of them the format ignores, the coded warning
and error tables, a real failure transcript) and register it in
docs/src/SUMMARY.md. Nothing gates this; it is missed unless written
deliberately.
skills/session-format-flags.md (a child of skills/session-bootstrap.md)
only needs touching if the format adds a CLI flag (as --sheet did
for Excel, and as the four .sav write knobs did for SPSS). Registering
a new pulse import <fmt> subcommand alone does not: that file carries
per-format flags, not a format list.
A new leaf does need a row in the command index in
docs/src/cli/flags.md. TestSkillsCoverAllCliLeaves
(cmd/pulse/cli_leaves_test.go) walks the real buildApp() command tree
and fails if any runnable command path is named nowhere under skills/
or docs/src/. It checks naming only — whether the prose is any good
stays the author’s judgement.
6. Convert and orchestration plumbing
Make sure both directions flow through pio.ImportJob and
pio.ExportJob. The orchestration layer is format-agnostic; you
should not need to touch internal/service/ unless the new format requires
special metadata (e.g., Parquet’s per-column statistics).
7. Run the gates
go test ./internal/io/<format>/...
go test ./internal/skills/ -run TestSkillsCoverAll
go test ./...
For format-specific perf, add benchmarks (Benchmark<Format>...) in
the sub-package. There’s no required perf gate today, but neighbouring
formats have benchmarks you can mirror as a baseline.
Adding a Field Type
Audience: Pulse internals contributors adding a new .pulse field
type — a new on-wire encoding the schema block can describe, the
record codec can read / write, and the operator surface can accept.
Adding a field type touches more layers than any other recipe — the
binary format, the schema reader, the operator-accept tables, the
predict routing layer, and the cohort-schema skill. It is also one of
the highest-impact extensions: every operator that touches the new
type either accepts it via its AcceptsTypes list or is excluded
from it explicitly.
1. Declare the FieldType constant
Add the FieldType constant and its ByteSize() method. The byte
size is the wire footprint per record; bit-packed types
(packed_bool, u4) return 0 and signal sharing via
FieldType.IsBitPacked().
2. Schema reader
Wire the type byte into the schema reader case in encoding/ (the public schema nouns; the decode-plan and group remainder is internal/encoding/). The
unknown-type-byte branch in the reader is what surfaces
ENCODING_INVALID for an unknown type — when you add the new constant,
add the corresponding decode arm.
If the new type carries a dictionary block (categoricals, sets), follow the inline-dictionary contract documented in CLAUDE.md’s “Byte-layout invariants” — the dictionary lives between the schema block and the record data, keyed by the position of the bearing field.
3. Update the operator accept tables
Every operator that should accept the new field type needs its row in
internal/descriptor/capabilities_*.go extended. The accept tables are shared
slices declared near the top of capabilities_aggregators.go
(numericFieldTypes, numericFieldTypesAnalytics, allCohortFieldTypes,
setFieldTypes, …) — extend the right slice rather than adding the
new type to each operator individually.
4. Predict-side routing
If the new type changes how an operator behaves (categorical-vs-numeric
routing, nullable-vs-non-nullable handling), update
internal/descriptor/predict.go’s routing tables (numericAggregations,
categoricalAggregations, …).
5. Update the cohort-schema-design skill
Add the new type to the “All field types” table in
skills/cohort-schema-design.md. TestSkillsCoverAllFieldTypes
enforces presence by name.
6. Update CLAUDE.md
The CLAUDE.md “Byte-layout invariants” section enumerates the canonical field-type count and the bit-packed vs dictionary-bearing distinctions. Update it to reflect the new type.
7. Run the gates
go test ./internal/skills/ -run TestSkillsCoverAllFieldTypes
go test ./encoding/... ./internal/encoding/...
go test ./descriptor/ ./internal/descriptor/ -run 'TestManifest|TestPredict'
The Update Demand row for .pulse format changes (header / field
type) covers all of these in one PR; see The Update
Demand.
Adding an Error Code
Audience: Pulse internals contributors adding a new PULSE_* /
SERVICE_* / ENCODING_* / PROCESSING_* / DATA_* / CLI_* error
code, or renaming / removing one.
The Pulse error system runs under six domains. Every code carries a
typed CodedError, a human-readable Message, and at least one
Fixup template explaining how to recover. The full taxonomy is in
The Update Demand.
1. Declare the code constant
Add the constant in errors/codes.go and append it to the allCodes
slice. Pick the domain prefix:
| Domain | When to use |
|---|---|
CLI_* | Bad invocation, missing flag, ambiguous subcommand |
DATA_* | Source file unreadable, encoding mismatch, IO failure |
ENCODING_* | .pulse magic / version / schema parse failure |
PROCESSING_* | Operator-level configuration or runtime error |
PULSE_* | Top-level Pulse-API error visible to embedders and the MCP surface (most common for new codes) |
SERVICE_* | Orchestration / validation error caught before execution |
2. Declare the message + fixup template
Add an entry to the codeMetadata map in errors/fixup_metadata.go.
Every code MUST have either:
- A
Messageplus one or moreFixuptemplates explaining how to recover (the canonical case), or FixupNotApplicable: truewhen there is truly no remediation the caller can apply (rare — most error codes are recoverable in principle).
TestCodesHaveFixups enforces that every code has either fixups or
the explicit FixupNotApplicable: true flag. The fixup templates are
surfaced to MCP clients via pulse_errors_lookup and to CLI users via
pulse errors lookup CODE — no separate skill-file edit is required.
3. Wire any test that emits the new code
If your change introduces the code as part of a new failure mode, make sure the test that exercises that mode asserts on the code constant rather than the error message.
4. Run the gates
go test ./errors/ -run 'TestCodesHaveFixups|TestErrorsLookup'
go test ./internal/descriptor/ -run 'TestManifestErrorCodesComplete|TestManifest_ErrorCodesSlim'
The Update Demand row for error codes covers all of these in one PR; see The Update Demand.
Adding an MCP Tool
Audience: Pulse internals contributors adding a new tool to the
embedded MCP server. The MCP layer is split into the SDK-free core
(internal/mcp/), the go-sdk adapter (mcp/gosdk/, the only package importing
the MCP SDK), and the leaf metadata package (internal/mcp/toolmeta/).
Each MCP tool wraps one slice of pulse.Pulse and surfaces it over
stdio / Streamable HTTP transports. The catalog covers one tool per
facade method (pulse_inspect, pulse_predict, pulse_process,
pulse_compose, pulse_sample, pulse_facet, pulse_facet_schema,
pulse_manifest, pulse_errors_lookup, …) plus the skills / examples /
import / label tools, and the two resource schemes (pulse://*.pulse,
pulse-skill://*). Treat the manifest (pulse manifest) as the
source-of-truth count — never hardcode a number. Adding a new tool
means extending the core catalog, registering the metadata, optionally
binding a field-aware JSON Schema, and updating the MCP-integration
skill.
1. Implement the handler
Implement the new tool handler in the SDK-free core (internal/mcp/handlers.go).
The handler is a typed function over *pulse.Pulse: accept the typed
In struct, call the facade method, return the typed Out (coded
errors surface verbatim as {code, message, details}). Add the tool’s
ToolDescriptor to the core catalog (internal/mcp/tools.go) so Tools(cfg)
emits it; the go-sdk adapter (mcp/gosdk/) mounts whatever the catalog
returns via gosdk.Register.
2. Register tool metadata
Add the tool’s name + description in internal/mcp/toolmeta/meta.go.
The description opens with one CALL … sentence saying when to call the
tool, and that sentence is the first line of the tool skill’s
## When to use section, word for word
(TestToolDescriptionsLeadWithWhenToUse).
The internal/mcp/toolmeta package is imported by internal/descriptor/ (which assembles
the manifest) and by the core, so this is the leaf-metadata package that
lets the descriptor surface the tool without importing the MCP layer or
the SDK.
3. Field-name parameters (optional)
If the new tool has field-name parameters (e.g. a field: string
argument that takes a cohort field name), add a per-tool JSON Schema
builder in internal/mcp/bind.go + an entry in Bind. After pulse_inspect
succeeds against a cohort the adapter binds session-scoped variants of
every schema-aware tool whose JSON Schema constrains field-name
parameters to the inspected cohort’s actual fields. internal/mcp/bind.go is
pure (no MCP SDK); the per-session server mutation that consumes the
schemas lives in the adapter (mcp/gosdk/bind.go).
Schema-binding parity is enforced by:
TestMCPSchemaBinding_RemovesInvalidFieldsTestMCPSchemaBinding_AllFieldsInFiltererEnumTestMCPSchemaBinding_SampleAndFacetFieldEnumTestMCPSchemaBinding_DedupAndSortTestMCPSchemaBinding_NilSchemaTestBindForInstance_PayloadSchemaParity— every payload-schema key under a bound request root is in the bound schema (allowlisted omissions carry a reason), and no bound key is absent from the instance’s payload schema
The transport caveat: bind-on-inspect works on the single stdio session
(post-serve AddTool/RemoveTools auto-emits list_changed); there is
no per-session override for shared HTTP servers — a documented
limitation. See the MCP integration skill for the configuration recipe.
4. Update the session-bootstrap skill
Add a section to skills/session-bootstrap.md covering the new
tool’s purpose, request shape, response shape, and (if applicable)
the Schema-bound enums it exposes after pulse_inspect.
TestSkillsCoverAllMCPTools enforces presence by name.
5. Run the gates
go test ./internal/skills/ -run TestSkillsCoverAllMCPTools
go test ./internal/descriptor/ -run TestManifestMCPToolsComplete
go test ./internal/mcp/ -run TestMCPSchemaBinding
The Update Demand row for MCP tools covers all of these in one PR; see The Update Demand.
Adding a Facet Capability Variant
Audience: Pulse internals contributors extending the
pulse.FacetSchema endpoint with a new top-level facet behaviour — a
streaming auto-range histogram, a new aggregation kind on numeric
fields, a new contribution-style accumulator.
The facet endpoint sits behind descriptor.FacetCapability and runs
through internal/service/facet_rich.go. New behaviours land in five files in
lockstep — request type, accumulator dispatch, capability flag,
validator, MCP JSON Schema builder — plus the facet-design skill.
1. Extend the request / result types
Add the new fields to types.FacetRequest and types.FacetResult in
types/facet.go. Keep JSON tags backward-compatible — additive only.
Renames or removals trigger a format_version bump per the Output
Format Contract;
new fields use omitempty and do not bump.
2. Implement per-row accumulation
Add the per-row accumulation in internal/service/facet_rich.go, dispatching
off the schema field type via newKindAccumulator. The accumulator
contract — Add(value), Finalize(), Result() — runs per-row
during the facet pass.
3. Capability flag
Add the capability flag in internal/descriptor/capabilities_facet.go so the
manifest exposes the new behaviour to LLM agents.
TestManifestFacetCapability enforces parity between the capability
block and the runtime surface.
4. Validator
internal/descriptor/facet.go::ValidateFacet runs without importing
service / processing (it is structurally no-execute, governed by
TestPredictNoExecutionImports). Any new structural rule lands here
as a SERVICE_VALIDATION error or an advisory warning.
5. MCP JSON Schema builder
Update the JSON Schema builder in internal/mcp/bind.go —
the buildFacetSchemaRequestSchema function — so the LLM sees the
new fields in the pulse_facet_schema tool surface.
TestMCPSchemaBinding_SampleAndFacetFieldEnum enforces parity.
6. Update the facet-design skill
Update skills/facet-design.md with the new behaviour’s request
shape, output shape, and any worked example. When the behaviour
warrants a runnable fixture, add it under internal/examples/facet/ and
update the example metadata. TestExamples_* enforces the fixture
contract.
7. Run the gates
go test ./internal/skills/ ./internal/examples/ ./descriptor/ ./internal/descriptor/
go test ./internal/service/ -run TestFacet
go test ./internal/mcp/ -run TestMCPSchemaBinding
The Update Demand row for facet-capability changes covers all of these in one PR; see The Update Demand.
Adding a Chain-Stage Predicate
Audience: Pulse internals contributors tightening or relaxing the
gate that decides whether an operator is allowed in a ProcessChain
stage.
ProcessChain (pulse.ProcessChain, pulse_process_chain,
pulse api process-chain) executes a linear pipeline whose stages all
pass one shared gate, internal/mergegate.ChainRefusal. The gate enforces that each stage
emits a shape the next stage can consume; v1 admits mergeable operators
only, and every mergeable built-in aggregator emits one float64 per row.
1. Edit the shared gate
Edit internal/mergegate/gate.go. ChainRefusal runs MergeRefusal
(the merge rule processing.CanMergeRequestWithExtensions also
delegates to) and, today, nothing else — no mergeable built-in emits a
shape the next stage cannot read. Add a chain-specific branch after
MergeRefusal when an operator is mergeable but its emit shape would
break the synthesised f64 / categorical_u32 schema the next stage
expects; return a reason string naming the operator.
2. Both sides pick it up
There is nothing to mirror. The runtime calls it as
processing.ChainRefusal (over the ExtensionRegistry) and
internal/descriptor/chain.go calls mergegate.ChainRefusal directly
(over the ExtensionsSnapshot), so code, message and details
{stage_index, stage_name} are identical by construction. The package
imports only types, encoding and errors: a fact only the engine
holds must reach it through the mergegate.Extensions interface, which
both adapters implement — never by importing internal/processing.
3. Update the capability surface
Edit internal/descriptor/capabilities_chain.go. processChainCapability()
carries the manifest-facing allowlists and RejectionRules strings.
After editing, regenerate descriptor/testdata/manifest.json:
go test ./descriptor/ -update -run Golden
Then verify the new hash sticks:
go test ./descriptor/ -run TestGoldensNotHandEdited
4. Tests
Add the rule’s case to internal/mergegate/gate_test.go and a row to
TestChainGate_ValidatorMatchesRuntime (root chain_gate_parity_test.go),
which runs each stage through ProcessChain and ValidateChainWithOptions
and requires the same code, message and details.
5. Allowlist skim
Skim skills/session-bootstrap.md and skills/process-chain.md for
any operator allowlist that needs adjustment in prose.
6. Whole-chain overlays
ChainRequest.Overlays []*ChainOverlaySpec is the whole-chain overlay
surface — overlays here execute AFTER every stage finalises (NOT
between stages). Per-stage overlays continue to ride the universal
ChainStage.Request.Overlays []OverlaySpec slot.
ChainOverlaySpec(intypes/chain.go):Name string,Kind OverlayKind,Ref StageRef,Target StageRef,Scope OverlayScope,Params map[string]any.StageRef(intypes/chain.go): XOR{Index *int, Name string}.Indexis a pointer so0is meaningful — the canonical “stage 0” call site setsIndex = &zero, notIndexunset. The downstream validator enforces “exactly one of Index / Name”.OverlayRef.Stage(intypes/overlay.go) is the same*StageRef— there is exactly oneStageRefdeclaration in the codebase. The legacyOverlayStageRefidentifier is a type alias toStageRefand is deprecated.ChainResponse.Overlays []*OverlayLayerreuses the universalOverlayLayerwrapper fromtypes/overlay.go(one entry perChainRequest.Overlaysspec in matching index order).
Canonical-hash coverage is data-driven (types/hash.go): the slot’s
omitempty tag means overlay-free chain requests hash byte-identically
to the overlay-free baseline; populated overlays fold into the hash
automatically. Locked by TestChainCanonicalHash_OverlayFreeByteIdentity
and TestChainCanonicalHash_OverlaysIncluded.
Whole-chain handler dispatch lives in internal/processing/overlay_chain_dispatch.go.
Predict-time validation lives in internal/descriptor/chain_overlay.go —
ValidateChain walks ChainRequest.Overlays after the per-stage
gate and emits:
PULSE_OVERLAY_KIND_UNKNOWN— anything outsideOVERLAY_INDEX_VS_STAGE/OVERLAY_DELTA_VS_STAGEis rejected today.PULSE_OVERLAY_REFERENCE_UNKNOWN/PULSE_OVERLAY_TARGET_UNKNOWN— StageRef resolution failures. Same Index / Name / XOR / latest-stage-default contract the runtimeresolveChainStageRefuses.PULSE_OVERLAY_CHAIN_STAGE_SHAPE_DIVERGENT— Ref and Target stages produce different host shapes perinferChainStageShape:req.Crosstab != nil ⇒ MATRIX,req.Aggregations + req.Groups ⇒ SERIES,req.Aggregations only ⇒ SCALAR.
Every rejected (Ref, Target) pair also lands in
ChainValidationResult.OverlaysSchemaDivergence so LLM planners can
budget reshapes without re-parsing envelope details.
7. Run the gates
go test ./internal/service/ -run TestChain
go test ./internal/descriptor/ -run 'TestValidateChain|TestProcessChain'
go test ./descriptor/ -run TestGoldensNotHandEdited
The Update Demand row for ProcessChain capability changes covers
all of these in one PR; see The Update Demand.
Adding an afero.Fs Implementation
Audience: Pulse internals contributors and embedders wiring a new
filesystem backend (a copy-on-read overlay caching remote objects, a
base-path wrapper, any prefix-translating shim) under pulse.Options.FS.
The Pulse iterator engages an mmap fast path when the cohort path
ultimately resolves to a real on-disk file. The eligibility probe
lives in internal/service/fs_probe.go and is documented inline; the gist:
implement the service.RealPather capability interface so the probe
can ask your fs “where is this file on disk?” without opening it.
The RealPather contract
// service.RealPather — defined in internal/service/fs_probe.go.
type RealPather interface {
RealPath(name string) (string, error)
}
The returned path MUST be os.Open-able at the moment of the call,
and the bytes it returns MUST be identical to what
fs.Open(name).Read would yield. The signature deliberately matches
*afero.BasePathFs.RealPath so that wrapper is detected
automatically.
Why this matters
service.resolveRealPath is the eligibility probe that decides
whether the streaming iterator engages the mmap fast path. Without
RealPather, the probe falls through to the *afero.OsFs check and
then to the afero.ReadFile slow path.
Failure to implement this interface silently disables the mmap
optimisation — no error, no warning, just a regression in scan
throughput on cold-cache wide cohorts. The mmap policy and probe
order are documented in the Cohort schema design
skill
(“Iterator mmap policy”); the rationale for omitting an open-and-
inspect fallback is inline at internal/service/fs_probe.go.
The regression gate
The countingFs test family in internal/service/ (e.g. TestCountingFs_*)
is the regression gate. It wraps an fs and fails the test if Process
calls afero.ReadFile on a single-file cohort path when the fs
advertises a real path. If you add a new wrapper and the gate flips
red, the wrapper is almost certainly missing RealPather.
Lazy-materialising backends
For caches that materialise the file lazily (copy-on-read overlays),
advertise RealPath only after the local copy is on disk. Return a
non-nil error during the in-flight download window so the probe
declines and the iterator falls back to afero.ReadFile for that
call.
Hermetic tests that need to exercise the non-mmap path keep using
fs.NewMemMap() — MemMapFs does not satisfy RealPather and the
probe correctly declines.
Run the gate
go test ./internal/service/ -run TestCountingFs
Managing a Shard Archive
Audience: Pulse internals contributors and embedders who manage a
multi-shard .pulse archive — a Zip64 store-only cohort that fans
out across N standalone .pulse shards under union semantics.
A shard archive is byte-distinct from a single-file .pulse cohort:
the magic-byte dispatch at pulse.Open looks at the first four bytes
and chooses single-file vs archive based on PULSE vs PK\x03\x04.
Read-side commands (pulse api process, pulse api compose,
pulse api sample, pulse api facet, pulse inspect, pulse predict)
accept either transparently.
For the union semantics, per-shard cohesion, and the memory multiplier of read paths see the Cohort schema design skill (Sharded cohorts).
1. Create the archive
The first include seeds the canonical schema; remaining includes are validated against it via structural cohesion + the dict prefix rule. Atomic temp + rename:
pulse shard create q1_2019.pulse \
--include 20190101.pulse \
--include 20190108.pulse \
--include 20190115.pulse
2. Append a shard
Validates cohesion + dict prefix, grows the canonical dict if needed
(rewriting _schema.pulse before placing the new shard), then in-place
appends the payload:
pulse shard add q1_2019.pulse 20190122.pulse
Go embedders building shards from rows skip the intermediate files:
Pulse.NewCohortBuilder with ShardSplit writes a new archive through
one CreateShardArchive call, and an anchored target
(q1_2019.pulse#20190122.pulse) appends one shard through one
AddShard call, with the same cohesion checks and mandatory warnings.
See Reading & Building Cohorts.
Set-width auto-widen
Two conditions trigger it, and they compose:
- The dictionary union outgrows the bitmask. The field is promoted to the narrowest rung that holds the union.
- The arriving shard declares a different rung. A re-import of a
new period infers the rung its own data needs, so a
set_u128shard legitimately meets aset_u64archive with no dictionary overflow in sight.
Either way the add does not fail. The field is promoted across the
canonical _schema.pulse and every shard payload already in the
archive, and the merge proceeds. The alternative, refusing, forces a
full re-import to reach a layout a mechanical re-stride already
produces.
The promotion is only ever wider. A shard arriving at a narrower
rung never narrows the archive — narrowing would drop every selection
above the target’s ceiling, and it would do so silently, with the record
still decoding to a smaller and entirely plausible selection. The
arriving shard is promoted to the archive’s rung instead, and it is the
only payload rewritten. SetWidening.From != To is the test for “the
archive moved”; the warning’s details.archive_widened says the same.
Only the rung dimension relaxes, and it relaxes by order, not by
loosening a validator: encoding.PlanSetWidening now runs before
encoding.ValidateStructuralCohesion, reconciles the rungs, and
cohesion then runs over the reconciled schemas as strictly as ever. That
matters because pulse shard verify calls the same validator, and an
archive whose shards genuinely carry different strides is a corruption
signature that must stay fatal there.
pulse shard create widens on the same conditions. Refusing at seed
while widening at add was an asymmetry with no defence: the same two
files produced an archive when fed one after the other and an error when
fed together. Pulse.CreateShardArchive therefore returns a
*CreateShardArchiveResult, not a bare error, for the same reason
AddShard returns a result — so the mandatory warning has nowhere to be
dropped.
The rewrite is atomic (temp file + fsync + rename), so a failure
mid-rewrite leaves the original archive byte-identical and openable at
its old rung. It is also never silent: every widen emits a
mandatory PULSE_SHARD_SET_WIDENED warning naming the field, the old
rung, the new rung and the number of shards rewritten, on the --json
envelope’s warnings array and on the text output.
Added 20190122.pulse to q1_2019.pulse (now 4 shard(s))
WARN [PULSE_SHARD_SET_WIDENED] set field "media" was widened from set_u64 to set_u128 ...
A union above set_u256 has nowhere left to widen to and still fails
with PULSE_SHARD_DICT_WIDTH_OVERFLOW. Categorical widths are
untouched — a categorical’s width is fixed at folder creation.
pulse shard verify reports per-set-field width headroom so a widen is
foreseeable before you pay for it.
Grouped (0x02) shards
Shards may be deduped cohorts (pulse import --group, pulse dedup).
Every shard is decoded with the canonical schema, so an archive has ONE
parent-group layout, and create / add share one merge rule
(internal/service/shard_groups.go, mergeShard):
- The arriving shard is flattened to its ungrouped twin and reconciled exactly as an ungrouped shard — set-rung plan, strict field cohesion, categorical union and remap (null placeholders keep their bytes, since they are part of a group entry’s identity).
- The archive’s layout wins. An ungrouped archive stores a grouped arrival flattened; a grouped archive re-encodes an arrival with other groups (or none) into its own.
- Group dictionaries union-merge canonical-first: the encoder is seeded
with the canonical dictionaries, so stored shards are untouched and
only the arrival is renumbered. A union past the u32 index space is
PULSE_SHARD_DICT_WIDTH_OVERFLOW. - A constant group the arrival disagrees with is promoted to an indexed
group across every shard (a whole-archive rewrite); a declared key the
arrival violates is refused with
PULSE_GROUP_MEMBER_NOT_CONSTANT. - Strict cohesion runs over the reconciled grouped schemas.
Layout changes emit the mandatory PULSE_SHARD_GROUPS_REWRITTEN
warning (details.reason = incoming_flattened / incoming_regrouped /
constant_promoted, details.archive_rewritten) and a regrouped
entry in --json data. pulse shard verify refuses a shard whose
group layout differs from the canonical one, applies the prefix rule to
group dictionaries, and reports group_index_headroom.
3. List shards
Reads _schema.pulse + central directory, prints basenames + per-shard
record counts:
pulse shard list q1_2019.pulse
4. Verify
Re-validates every shard’s header + cohesion against the canonical schema. Useful after manual archive surgery or when a build pipeline appends shards from multiple producers:
pulse shard verify q1_2019.pulse
5. Compact
Reclaims orphan bytes (e.g. after pulse shard remove) and refreshes
canonical metadata (aggregate_record_count, shard_count):
pulse shard compact q1_2019.pulse
6. Anchor syntax
Anchor syntax archive.pulse#shard.pulse opens a single shard inside
an archive as a one-shard cohort — useful for diagnostics, debugging,
and tests that exercise the cohesion path against a known-good shard.
Concurrency caveat
Pulse does not provide writer locking. Two processes running
pulse shard add against the same archive race; the last writer wins
and the earlier writer’s shard is lost. Sharding is single-writer by
design — the caller owns concurrency control (orchestrator coordination,
an external advisory lock, or a single-writer architecture).
Implementation surface
For maintainers extending the sharding internals, the surface lives in:
| File | Role |
|---|---|
internal/encoding/archive.go | Zip64 read / write + EOCD |
internal/encoding/schema_doc.go | _schema.pulse parser / writer |
internal/encoding/cohesion.go | Structural + dict-prefix validators |
internal/service/shard_iter.go | Multi-shard row iterator |
internal/service/shard_reduce.go | Parallel reducer for mergeable ops |
internal/service/shard_admin.go | create / add / remove / list / extract; both create and add plan set-rung reconciliation before strict cohesion |
internal/service/shard_widen.go | Set-width auto-widen on create + add + the mandatory warning |
internal/service/shard_groups.go | The shared create / add merge (mergeShard), grouped-shard reconciliation + PULSE_SHARD_GROUPS_REWRITTEN |
internal/encoding/shard_groups.go | FlattenCohortBytes, ValidateGroupCohesion, group prefix rule, GroupIndexHeadroomFor |
internal/encoding/widen.go | The re-stride engine (WidenSetFieldBytes / WidenSchemaSetField) |
internal/service/shard_compact.go | compact |
internal/service/shard_verify.go | verify |
internal/service/anchor_overlay.go | Anchor-syntax overlay |
internal/cli/shard.go | CLI thin adapter |
Width overflow on a categorical dictionary grown by an append surfaces
as PULSE_SHARD_DICT_WIDTH_OVERFLOW; the stricter prefix-only
validator (PULSE_SHARD_DICT_DIVERGENCE) is retained for the
pulse shard verify strict path. The set-width auto-widen planner is
encoding.PlanSetWidening and its executor is internal/service/shard_widen.go
over E4-S1’s internal/encoding/widen.go engine; encoding.SetWidthHeadroomFor
feeds verify’s headroom report.
Run the gates
go test ./internal/service/ -run 'TestShardArchive|TestShardGroups'
go test ./internal/encoding/ -run TestShardArchive
go test ./internal/encoding/ -run Cohesion
Wiring Pulse into an MCP Client
Audience: Pulse internals contributors and integrators wiring the
pulse mcp server into an MCP-aware client (Claude Desktop, Claude
Code, any custom MCP host).
Pulse ships an embedded MCP server that exposes the public facade
through one tool per facade method plus the skills, examples, errors,
import and label tools (the manifest’s mcp_tools block is the live
list — never count it by hand) and two resource schemes (pulse://*.pulse, pulse-skill://*).
Wiring it into a client is a three-step process: build, configure,
restart.
1. Build the binary
make build
The resulting bin/pulse must be on the client’s PATH (or
referenced by absolute path in the client’s configuration).
2. Configure the client
Add an mcpServers.pulse entry to the client’s configuration file
(claude_desktop_config.json for Claude Desktop, ~/.claude.json
for Claude Code) running pulse mcp and exporting PULSE_DATA_DIR:
{
"mcpServers": {
"pulse": {
"command": "/abs/path/to/bin/pulse",
"args": ["mcp"],
"env": {
"PULSE_DATA_DIR": "/abs/path/to/your/cohorts"
}
}
}
}
The --bind-on-open flag (default true) controls whether successful
pulse_inspect calls trigger registration of session-scoped tool
variants whose JSON Schemas constrain field-name parameters to the
cohort’s actual fields. Pass --bind-on-open=false for clients that
bind themselves:
{
"command": "/abs/path/to/bin/pulse",
"args": ["mcp", "--bind-on-open=false"]
}
3. Restart the client
After the client reloads its MCP configuration the Pulse tools
(pulse_inspect, pulse_predict, pulse_process, …) and resources
(pulse://*.pulse, pulse-skill://*) appear in the tool / resource
list.
Schema-bound enums
The full configuration recipe — including the schema-bound enums
section that describes the inspect trigger, the multi-file limitation
(latest inspect wins), and the transport-support caveat (bind-on-inspect
works on the single stdio session via post-serve AddTool /
RemoveTools; there is no per-session override for a shared HTTP
server) — lives in Adding an MCP tool.
Verification
bin/pulse mcp --help
bin/pulse manifest --json | jq .data.mcp_tools
If the client supports an MCP tool inspector, point it at the
configured server and confirm it exposes every tool the manifest’s
mcp_tools block lists, plus the two resource schemes.
Debugging a Predict Mismatch
Audience: Pulse internals contributors and embedders triaging a
case where pulse predict says a request is valid but execution
fails, or vice versa.
pulse predict reads only the .pulse header and schema; it never
touches record data. That makes the predict path fast and safe to
call from an LLM agent, but it also means predict can only catch
structural and shape errors — type mismatches, unknown field names,
operators that cannot accept a given field type. Anything that
depends on the actual record values (e.g. a divide-by-zero in
ATTR_FORMULA) is a runtime concern.
The triage sequence
Run predict against the request first:
pulse predict --json < request.json
Read the envelope’s errors and warnings arrays — predict returns
both. Errors are structural failures; warnings are advisories that
identify low-quality input but do not block execution.
The most common issues:
| Symptom | Likely code |
|---|---|
| Field-name typo | SERVICE_VALIDATION |
| Numeric aggregation on a categorical field | PULSE_AGG_NOT_MEANINGFUL_FOR_CATEGORICAL |
| Description below the quality threshold | PULSE_FIELD_DESCRIPTION_LOW_QUALITY |
ATTR_CODE_IN code absent from a categorical dictionary | PULSE_ATTR_CODE_NOT_IN_DICTIONARY |
Operator’s AcceptsTypes excludes the field | SERVICE_VALIDATION |
Filterer referenced from Aggregation not declared | SERVICE_VALIDATION |
Resolve the typo / mismatch and re-run predict.
Cross-check against the actual schema
If predict’s error mentions a field that you believe should exist,
read the schema with pulse inspect:
pulse inspect --json <file.pulse>
The inspect output lists every field’s name, type, nullability flag, and (if present) description. A mismatch between what you think the schema says and what predict / inspect actually reads almost always traces to a stale cohort file or a request crafted against a different schema generation.
When predict passes but runtime fails
If predict reports the request as valid but execution fails, the bug is in the processing layer — not predict.
internal/descriptor/predict.go has a structural ban on importing internal/service/
and internal/processing/ (TestPredictNoExecutionImports). The predict path
runs against encoding.ReadHeader + encoding.ReadSchema only,
never against records. A divergence between predict’s verdict and
runtime’s verdict is therefore always either:
- A processing-layer bug (the runtime rejects something predict had no way to know was bad), or
- A capability declaration that drifted from the registry (the capability row says the operator accepts a type the runtime rejects, or vice versa).
For the second case, the registry-vs-capability parity gates are:
TestManifestOperatorsCompleteTestStreamability_AggregationsKnownTestPredict_Streamable_MatchesRuntimeTestCanStreamRequest_RegressionMatrix
Re-run them locally and inspect any failure — they catch capability drift before it ships.
Streamability mismatch
Predict surfaces a per-slot Streamable flag derived from the
per-type Streamable() methods plus schema gates (decimal); an
extension operator answers from its declared registration flag on the
extensions snapshot. The runtime parity check is
internal/processing’s CanStreamRequest(req, schema) —
CanStreamRequestWithExtensions(req, schema, exts) for a
request naming an extension operator (internal; embedders see the
answer through PredictResult.Streamable). A divergence means either:
Streamable()returned a value that doesn’t match theOnlineAggregatorcapability of the registered operator (caught byTestRegistryStreamabilityMatchesTypes), or- The schema gate (decimal) was missed in one of the two computations.
Both surfaces share helpers in types/streamability.go; the
drift is almost always in the table at the top of that file.
Regenerating Goldens
Audience: Pulse internals contributors after a legitimate change to a descriptor / manifest generator.
Golden files live in descriptor/testdata/. Each file ends with a
// golden-hash: <sha256> line; TestGoldensNotHandEdited verifies
the hash against the file’s body. Hand-editing a golden file flips
the hash and fails the gate.
When regeneration is justified
You should regenerate after:
- Adding or removing a registered operator (the manifest enumerates every entry).
- Changing the capability shape for an existing operator.
- Renaming an error code (the manifest carries the canonical list).
- Updating the
format_version(rare — additive changes do not bump the version). - Any other change that legitimately changes the deterministic manifest output.
If you cannot articulate which deterministic output changed, the golden update is probably wrong and the underlying generator is emitting non-deterministic content (e.g. iterating a map without sorting). Fix that first.
The regenerate flow
go test ./descriptor/ -run 'Test.*Golden' -update
The -update flag asks the test runner to rewrite the goldens with
the current generator output. The generator stamps a fresh
// golden-hash: <sha256> line at the end of every regenerated
file.
Then verify the gate accepts the new hashes:
go test ./descriptor/ -run TestGoldensNotHandEdited
If the gate still fails after -update, the cause is usually one of:
- The hash trailer is missing from a golden you added by hand — the generator only stamps files it owns.
- An extra blank line or comment was added by editor configuration before the final hash line — the hash is computed over the body before the trailer.
- The generator emits map iteration without a sort — non-determinism itself.
Per-feature-profile goldens
Beside the full-registry manifest.json and payload-schema.json, each
fixture feature profile under descriptor/testdata/profiles/ (minimal,
survey-crosstab, empty) pins its instance’s self-description:
manifest.<fixture>.json (the --json envelope around
Pulse.Manifest) and payload-schema.<fixture>.json (Pulse.PayloadSchema
byte for byte), written by TestProfileManifestGolden and
TestProfilePayloadSchemaGolden. Both match Test.*Golden, so the
regenerate command above rewrites them too, and they sit at the top of
testdata/ where TestGoldensNotHandEdited checks their hashes. The
profile inputs themselves live in the profiles/ subdirectory, which the
gate skips because they are hand-written.
Regenerate them whenever a change moves what an instance offers or how it
describes it: a feature added to or removed from the feature table, a
change to a fixture profile, a capability block or schema root that
gating omits, or prose the hidden-name scrub redacts. The manifest
goldens also carry the skill list, so a skill-pack edit churns them like
manifest.json.
The generated Analysis Guide
The book’s “Analysis Guide” part — every page under docs/src/guide/
plus the docs/src/SUMMARY.md entries between the
<!-- docgen:summary begin --> and <!-- docgen:summary end --> lines —
is generated by pulse docs export from the default instance: the
Purpose and Interpretation registries, the glossary and intents, every
operator’s ComponentSchema, the embedded skills, and the Components
intro in internal/docgen/components_intro.md. It is committed so the
published book needs no Go toolchain, and TestDocsGeneratedCurrent
(root package) fails the moment a source edit lands without it.
Regenerate after any edit to those sources:
make docs
make docs runs make docs-generate — go run ./cmd/pulse docs export --out docs/src/guide with PULSE_LABEL_TABLES_DIR,
PULSE_RANGE_TABLES_DIR, PULSE_TEMPLATES_DIR and
PULSE_FEATURE_PROFILE cleared (so a local .env cannot change the
output), then go run ./internal/tools/docsummary, which splices the
export’s SUMMARY.md into the book’s (title line dropped, paths
prefixed guide/) — and then mdbook build docs. On a current tree it
changes nothing. go test . -run TestDocsGeneratedCurrent -update
rewrites the same files without building the book.
Commit everything the export wrote, the .pulse-docs-export marker
included: it lists the files the export owns, so the next export
deletes a page that no longer renders. Never hand-edit
docs/src/guide/ — the next make docs overwrites it and the gate
fails until it does. Edit the source and regenerate.
Never hand-edit a golden
The gate exists to catch the case where a contributor edits a golden to make a test pass instead of fixing the underlying drift in the generator. If a golden diff surprises you in code review, that is a red flag — ask the contributor to show the generator change that justifies the new hash.
The 0x01 format golden is a compatibility fixture, not a snapshot
encoding/testdata/format_v1.pulse is a synthetic 0x01 cohort that
every build must still read — and that the current writer must still
reproduce byte-for-byte for the same schema and rows. It exists because
old cohorts stay readable forever. A failure there is a
backward-compatibility break, not a stale golden: do not run
go test ./internal/encoding/ -run TestFormatV1Golden -update to make it pass.
The -update flag exists only to create the file; to cover more of the
0x01 surface, add a second fixture rather than rewriting this one, so
the bytes an earlier binary wrote stay under test.
Shard-archive fixtures are generator-pinned snapshots
testdata/sharding/*.pulse are produced by internal/shardfixtures
(go run ./testdata/sharding/build_fixtures.go writes them) and
TestShardFixtures_MatchGenerator fails when the committed bytes and the
generator disagree. Unlike the 0x01 golden these are snapshots of the
CURRENT writer: they once went stale across the nullable-flag
schema-block change (a declared clean break) and stopped opening, with
nothing noticing. Regenerate with the command above or
go test ./internal/shardfixtures/ -update, then git add -f them
(*.pulse is gitignored). The two JSON envelopes beside them are pinned
to live CLI output by TestShardingSnapshots_MatchCLIOutput
(go test ./internal/cli/ -run TestShardingSnapshots -update).
R reference goldens are an external oracle, not a snapshot
internal/processing/testdata/reference/*.json hold R’s values for the
shared distribution primitives (Student t, chi-square, the noncentral
chi-square CDF, F, normal CDF and
quantile, the limiting Kolmogorov survival, the studentized range). They
are written by scripts/reference/gen_reference.R and carry the R and
package versions that produced them; make reference runs the script
(needs Rscript plus the jsonlite package) and appends the
// golden-hash: footer that the processing package’s
TestGoldensNotHandEdited checks. CI never runs R.
The TestReferenceOracle_* tests in internal/processing and
internal/statdist (the Student-t family’s one implementation) hold every primitive to a relative
1e-10 of those values. A failure there means the Go primitive is
wrong, not that the golden is stale: fix the primitive, never
regenerate to match it. Regenerate only to change the grid (edit the
script, run make reference, commit script and goldens together). The
studentized-range and Student-t quantile references are refined in R
beyond the stock ptukey() / qtukey() / qt() because those are less
accurate than the oracle needs; the script’s header explains why.
Multivariate goldens and their fixtures
The same make reference run also sources
scripts/reference/gen_multivariate.R, which writes the matrix-operator
oracle as mv_*.json beside the primitives: rank correlation
(Spearman, Kendall τ-b), partial correlation (ppcor::pcor,
corpcor::cor2pcor), reliability (psych::alpha, ω from
psych::fa(nfactors = 1, fm = "minres")), the one-factor minres solve,
PCA (eigen / prcomp) with psych::KMO and psych::cortest.bartlett,
collinearity (car::vif, perturb::colldiag uncentered-with-intercept
and centered), vcov(lm) / vcov(glm) (binomial, poisson) and
Matrix::nearPD(corr = TRUE). Each case is unweighted and, where the
method admits it, frequency- and probability-weighted under Pulse’s one
weighting rule (stats::cov.wt, frequency asserted equal to the
rep()-expanded rows). Eigenvectors carry the linalg sign convention.
Extra packages, PINNED — the script refuses any other version, so a CRAN
update cannot move a golden silently: psych, car, ppcor,
perturb, corpcor and Matrix (exact versions and the install line
are in the script header).
Fixtures live in internal/processing/testdata/reference/fixtures/ as a
CSV (citation header, %.17g cells, empty = null) plus a .pulse twin
that make reference writes with go run ./internal/tools/refcohorts:
the vendored attitude (Chatterjee & Price 1977) and mtcars
(Henderson & Velleman 1981) from R’s datasets package, each with two
seeded weight columns appended, and seeded synthetic fixtures
likert_ties, nulls (listwise vs pairwise), weighted (zero-weight
rows), nonpsd_pairwise, heywood, reversed_items and two_factor
(a proper two-factor fit under every weight kind, so weighted Bartlett
factor scores are pinned on real values). No
GPL-package data. mv_fixtures.json records each fixture’s source,
citation, seed and CSV md5; the internal/tools/refcohorts tests fail
when a CSV drifts from that record or a .pulse drifts from its CSV.
The R toolchain
make reference refuses any R but 4.6.1 (2026-06-24) and any package
version but the pinned one, so every package below must be installed
at exactly that version. Every script header repeats its own pins; this
is the whole list in one place.
| Package | Version | Used by |
|---|---|---|
jsonlite | 2.0.0 | every golden (gen_reference.R) |
Matrix | 1.7-5 | nearest-PD repair, gen_singularity.R rank (recommended package, ships with R) |
psych | 2.6.9 | reliability, KMO / Bartlett, minres / principal-axis / ML factor analysis, factor scores |
car | 3.1-5 | VIF |
ppcor | 1.1 | partial correlation |
perturb | 2.10 | collinearity diagnostics (archived on CRAN; needs gdata) |
corpcor | 1.6.10 | cor2pcor; a Hotelling dependency |
Hotelling | 1.0-8 | TEST_HOTELLING_T2 (gen_multivariate_tests.R) |
biotools | 4.3 | TEST_BOX_M |
GPArotation | 2026.8-2 | oblimin rotation (gen_rotation.R, gen_factor.R) |
sandwich | 3.1-3 | robust covariance (gen_robust_vcov*.R) |
lmtest | 0.9-40 | robust covariance tests |
ICSNP, MVTests and MVN do NOT build on R 4.6.1; nothing needs
them. The scripts compute what they would (the unequal-covariance
Hotelling test, Mardia’s statistics) as closed forms in base R, each
cross-checked as its comment says. The weighted-reference generator
(internal/service/testdata/weight_reference/) needs only base R plus
Hmisc 5.3.0, installed into a private library; its README covers it,
along with its Python pins and the --check mode.
Install the pins into the user library (remotes::install_version
fetches the exact version even after CRAN moves on):
Rscript -e 'r <- "https://cloud.r-project.org"; l <- Sys.getenv("R_LIBS_USER");
dir.create(l, recursive = TRUE, showWarnings = FALSE)
install.packages("remotes", lib = l, repos = r)
pins <- c(psych = "2.6.9", car = "3.1-5", ppcor = "1.1", corpcor = "1.6.10",
gdata = "3.0.1", Hotelling = "1.0-8", biotools = "4.3",
GPArotation = "2026.8-2", sandwich = "3.1-3", lmtest = "0.9-40")
for (p in names(pins)) remotes::install_version(p, pins[[p]], lib = l, repos = r, upgrade = "never")
install.packages(paste0(r, "/src/contrib/Archive/perturb/perturb_2.10.tar.gz"),
lib = l, repos = NULL, type = "source")'
Homebrew Fortran workaround. Packages with Fortran code (Hmisc, and
any dependency that compiles Fortran) fail to link on Homebrew R 4.6.1
with library 'emutls_w' not found: R’s recorded FLIBS names a gcc
target directory that does not exist (R CMD config FLIBS reports
aarch64-apple-darwin27; Homebrew gcc 16 installs
aarch64-apple-darwin23). Write a TEMPORARY Makevars naming the real
directory and point R_MAKEVARS_USER at it for the install only —
never commit it, and keep it out of ~/.R/Makevars:
GCC=$(brew --prefix gcc)/lib/gcc/current
TRIPLE=$(ls "$GCC/gcc"); VER=$(ls "$GCC/gcc/$TRIPLE")
printf 'FLIBS = -L%s/gcc/%s/%s -L%s/gcc -L%s -lemutls_w -lheapt_w -lgfortran -lquadmath\n' \
"$GCC" "$TRIPLE" "$VER" "$GCC" "$GCC" > /tmp/Makevars.pulse
R_MAKEVARS_USER=/tmp/Makevars.pulse Rscript -e '...the install line...'
A rerun of make reference on this toolchain is byte-identical (no
clock, no unseeded RNG): git status must show only the goldens your
change meant to move.
Refreshing the Time-Zone Database
Audience: Pulse internals contributors picking up a newer IANA tz database release.
Pulse resolves every time-zone name from its own embedded copy of the
tz database, internal/temporal/zoneinfo.zip — the uncompressed zip the
Go toolchain ships at $(go env GOROOT)/lib/time/zoneinfo.zip.
temporal.LoadZone looks the exact, case-sensitive name up in that zip
and builds the location with time.LoadLocationFromTZData. It never
consults $ZONEINFO, the host’s zoneinfo files or time/tzdata, so the
accepted set of names and every offset are identical on every host
(TestLoadZone_IgnoresHostZoneinfo).
Go’s zip carries no version entry, so the release is recorded by hand as
temporal.TZDataVersion and pinned to the file’s bytes by the constant
tzdataSHA256 in internal/temporal/tzdata.go.
TestTZDataVersion_MatchesEmbeddedZip fails whenever the zip changes
without both constants changing with it.
When to refresh
After a Go toolchain upgrade whose lib/time/update.bash names a newer
DATA= release, or when a tz change affecting users has shipped. The
file never refreshes on its own: a toolchain upgrade alone changes
nothing.
Recipe
- With the toolchain you want to take the data from active, run
make tzdata. It copies the zip intointernal/temporal/and prints the release (DATA=fromlib/time/update.bash) and the new SHA-256. - Set
TZDataVersionandtzdataSHA256ininternal/temporal/tzdata.goto the printed values. - Run
go test -race ./internal/temporal/. The property tests compare against the stdlib over the same embedded locations, so they hold across releases; a changed zone shows up only if a pinned example (for instance a DST-at-midnight zone) changed its rules. - The manifest reports the release as top-level
tzdata_version, so regenerate the manifest goldens:go test ./descriptor/ -run 'Test.*Golden' -update. - Commit the zip,
tzdata.goand the regenerated goldens together.
The Update Demand
Source of truth: this chapter is mirrored from the “Update Demand” section of
CLAUDE.md. Both files are kept in lock-step;CLAUDE.mdis authoritative if they ever diverge (aTestUpdateDemandTableCoversCI gate enforces table coverage against the registries).
Any change to Pulse code, configuration, file format, or public surface MUST update the corresponding skill file(s) and CLAUDE.md in the same PR. This is not a courtesy. It is a non-skippable CI failure if any of the trigger conditions below is met without the corresponding doc update.
Trigger → required update
| If you change… | You MUST also update… | Enforced by |
|---|---|---|
| A registered aggregator | skills/op-agg-<kebab>.md (one atomic skill per operator) | TestOperatorHasAtomicSkill, TestAtomicSkillHasRequiredSections, TestSkillTokenBudget, TestSkillsCoverAllComponents |
| A registered attribute | skills/op-attr-<kebab>.md | TestOperatorHasAtomicSkill, TestAtomicSkillHasRequiredSections, TestSkillTokenBudget, TestSkillsCoverAllComponents |
| A registered filterer | skills/op-filter-<kebab>.md | TestOperatorHasAtomicSkill, TestAtomicSkillHasRequiredSections, TestSkillTokenBudget, TestSkillsCoverAllComponents |
| A registered grouper | skills/op-group-<kebab>.md | TestOperatorHasAtomicSkill, TestAtomicSkillHasRequiredSections, TestSkillTokenBudget, TestSkillsCoverAllComponents |
| A registered window operator | skills/op-win-<kebab>.md | TestOperatorHasAtomicSkill, TestAtomicSkillHasRequiredSections, TestSkillTokenBudget, TestSkillsCoverAllWindowTypes |
| An error code (added/removed/renamed) | errors/fixup_metadata.go (codeMetadata Message + Fixups; surfaced per-code via pulse_errors_lookup / pulse errors lookup CODE) | TestCodesHaveFixups, TestManifestErrorCodesComplete |
| A CLI leaf (added/removed/flag added) | the command index in docs/src/cli/flags.md + skills/session-bootstrap.md if the leaf carries a user-facing flag | TestSkillsCoverAllCliLeaves (checks the leaf path is NAMED under skills/ or docs/src/; it cannot check that the prose is adequate) |
A --json envelope or format_version | CLAUDE.md “Output Format Contract” | TestClaudeMdMentionsFormatVersion |
A .pulse file format change (header layout, new field type) | CLAUDE.md “Byte-layout invariants” + skills/type-<kebab>.md (one atomic skill per FieldType) + skills/cohort-schema-design.md | TestOperatorHasAtomicSkill, TestAtomicSkillHasRequiredSections, TestSkillTokenBudget, TestClaudeMdMentionsFormatVersion, TestSkillsCoverAllFieldTypes |
| A new non-skippable CI gate | CLAUDE.md “Non-Skippable CI Gates” (gate listed by name) | TestClaudeMdMentionsAllNonSkippableGates |
Purpose / Interpretation / glossary / intent / ComponentSchema / skill-body prose (anything internal/docgen renders) | make docs (regenerates the committed Analysis Guide under docs/src/guide and the docs/src/SUMMARY.md span; never hand-edit them) | TestDocsGeneratedCurrent, TestSkillPurposeSectionsCurrent |
| A new architectural decision | CLAUDE.md (relevant section) + PRD if applicable | reviewer enforcement |
| An environment variable | CLAUDE.md “Build / Env” + skills/session-bootstrap.md | TestClaudeMdMentionsAllEnvVars |
| A registered MCP tool (added/removed) | docs/src/internals/adding-mcp-tool.md + internal/mcp/toolmeta/meta.go (name + description) | TestSkillsCoverAllMCPTools, TestManifestMCPToolsComplete |
| A new MCP action tool with field-name parameters | internal/mcp/bind.go (add a per-tool JSON Schema builder + entry in Bind) + docs/src/internals/adding-mcp-tool.md (Schema-bound enums section) | TestMCPSchemaBinding_RemovesInvalidFields, TestMCPSchemaBinding_AllFieldsInFiltererEnum, TestMCPSchemaBinding_SampleAndFacetFieldEnum, TestMCPSchemaBinding_DedupAndSort, TestMCPSchemaBinding_NilSchema |
| A registered feature operator | skills/op-feat-<kebab>.md + capability declaration in internal/descriptor/capabilities_features.go | TestOperatorHasAtomicSkill, TestAtomicSkillHasRequiredSections, TestSkillTokenBudget, TestSkillsCoverAllComponents |
| A registered synth distribution kind | skills/op-synth-<kebab>.md + capability declaration in internal/descriptor/capabilities_distributions.go | TestOperatorHasAtomicSkill, TestAtomicSkillHasRequiredSections, TestSkillTokenBudget, TestSkillsCoverAllSynthDistributions, TestManifestDistributionsComplete |
A registered statistical test (TEST_*) | skills/op-test-<kebab>.md + types/streamability.go + types/streamability_test.go + capability declaration in internal/descriptor/capabilities_tests.go | TestOperatorHasAtomicSkill, TestAtomicSkillHasRequiredSections, TestSkillTokenBudget, TestStreamability_TestsKnown, TestManifestTestsComplete |
| A registered tier-2 post-test variant | Capability declaration in internal/descriptor/capabilities_tests.go (postTestCapabilities) | TestManifestPostTestsComplete |
| A registered aggregator/attribute/filterer/grouper/window capability metadata | Capability declaration in internal/descriptor/capabilities_<category>.go (params, accepts_types, emits_type, streamable_hint) | TestManifestOperatorsComplete |
| A new error code | Description row in internal/descriptor/capabilities_errors.go (errorMetaTable) | TestManifestErrorCodesComplete |
| An error code’s fixup template | Entry in errors/fixup_metadata.go (codeMetadata) — surfaced per-code via pulse_errors_lookup / pulse errors lookup CODE | TestCodesHaveFixups |
| A new operator’s streaming capability | types/streamability.go (case for the new type) + table in types/streamability_test.go | TestRegistryStreamabilityMatchesTypes, TestStreamability_*Known, TestManifestStreamableMatchesTypes |
| The default operator table | CLAUDE.md “Code Conventions → Smart defaults” + skills/request-envelope.md (“Smart defaults” section) | TestDefaults_Applied + reviewer enforcement |
The Update Demand applies recursively to itself: when a new trigger row is added (e.g., a new component category, a new contract), this table MUST be updated in the same PR. TestUpdateDemandTableCovers (non-skippable) parses this table and asserts every registered component category and contract type has a row.
If you find yourself wanting to defer the doc/skill update to “a follow-up PR,” stop. The follow-up PR will not happen, and the next Claude Code session will read a stale CLAUDE.md and produce wrong code. Update in the same PR or do not merge.
Deployment
Audience: operators standing up Pulse as a CLI server, an MCP process under an AI client, or an embedded Go library inside a larger binary.
Pulse is a single static Go binary. There is no install command, no
config file, and no daemon — every deployment story is some shape of
“put the binary somewhere, set PULSE_DATA_DIR, run it”.
LLM agents using MCP: see the
session-bootstrapskill viapulse_skills_getfor the MCP-side wiring details. This page covers the operator side.
Mode 1: Standalone CLI
go install github.com/frankbardon/pulse/cmd/pulse@latest
export PULSE_DATA_DIR=/var/data/pulse
pulse --version
That’s the full install. The CLI tree is mapped in the CLI Tour.
Mode 2: MCP stdio server (Claude Desktop, Claude Code, generic MCP clients)
pulse mcp runs the Model Context Protocol over stdio. AI clients
launch the process, speak MCP over its standard streams, and shut it
down on session close.
The full wiring guide is in the session-bootstrap skill. Quick
reference for Claude Desktop:
// ~/Library/Application Support/Claude/claude_desktop_config.json
{
"mcpServers": {
"pulse": {
"command": "/usr/local/bin/pulse",
"args": ["mcp"],
"env": {
"PULSE_DATA_DIR": "/var/data/pulse"
}
}
}
}
For Claude Code (~/.claude.json) and other clients the shape is the
same — see the session-bootstrap skill (pulse skills show session-bootstrap) for the canonical recipes.
Flags worth knowing:
| Flag | Default | Purpose |
|---|---|---|
--data-dir | from PULSE_DATA_DIR | Override the cohort base directory |
--bind-on-open | true | Register session-scoped JSON-schema-bound tool variants on successful pulse_inspect. Disable for clients that bind tool schemas themselves. |
See pulse mcp for the full command page.
Mode 3: Embedded Go library
import "github.com/frankbardon/pulse"
p, err := pulse.New(pulse.Options{
DataDir: "/var/data/pulse",
})
When embedding, you can bypass PULSE_DATA_DIR entirely by passing
DataDir (as above) or a custom afero.Fs. See Library
Embedding for the full surface.
Production hardening
- Filesystem permissions.
pulse mcpreads everything underPULSE_DATA_DIR. Treat the directory as the trust boundary — run the process as a user that can only read what it should serve. - Stdio plumbing. MCP transports stderr too. Pulse writes a
one-line startup notice (
pulse mcp: serving over stdio...) on stderr and never logs request/response payloads, so MCP clients can surface stderr without leaking data.--log-levelrecords also go to stderr only and carry no row data; stdout stays pure JSON-RPC. - Metrics port.
--metrics-addr host:portis the only waypulse mcpopens a port; the/metricsendpoint is unauthenticated, so bind loopback or a private interface. - Resource limits. Streaming aggregations stay memory-bounded;
buffered request shapes (window operators, median/percentile,
decimal/geo paths) can materialise large intermediate row sets.
Use
pulse api predictto checkStreamablebefore running an unfamiliar request — see Performance Notes. - No mutating background state. Pulse never writes to a cohort
during
process/compose. The only write paths areimport,export,synth,profile, andcohort filter— explicit by flag.
Upgrades
Drop in a new binary and restart the MCP process (or the calling
client). The .pulse file format carries a one-byte version field
(0x01 or 0x02; every current writer still emits 0x01 unless a
schema needs a 0x02 feature). Old files stay readable forever; files
written by a newer binary that introduces a version this one does not
know are rejected loud at parse time, not silent at row decode — so a
binary that predates 0x02 refuses 0x02 cohorts, and must be upgraded
before it reads cohorts written by a 0x02-writing release. See Header Layout.
Performance Notes
Audience: operators sizing a Pulse deployment, and library users debugging memory or latency surprises.
Pulse is built to keep “the streaming path” the default for most
analytical requests. When the engine has to leave that path it says so
— via the Streamable flag in
pulse api predict — and falls back to a
buffered execution. This page tells you what stays streaming, what
buffers, and how to read predict’s diagnostics.
LLM agents using MCP: there is no direct skill counterpart for this page —
tool-predictcovers how to drive predict; this page tells operators what predict’s answers imply.
Streaming path: what stays out of memory
The streaming Process path covers four orchestrator modes (from
.claude/reference/execution-modes.md → Streaming Process):
- Single-pass streaming. No-group requests with online aggregators
(
COUNT,SUM,AVG,STDDEV,VARIANCE,RANGE,FREQUENCY,MODE,SKEWNESS,KURTOSIS,DISTINCT_COUNT) on numeric (non-decimal) fields. Row-local attributes (FORMULA,DATE_PART) apply inline. - Grouped streaming. Groupers implementing the streaming key path
(
GROUP_CATEGORY,GROUP_RANGE,GROUP_ROUNDED) drive per-key online aggregator buckets. Memory isO(distinct_groups × per-aggregator-state). - Two-pass streaming. Two-pass attributes (
ATTR_ZSCORE,ATTR_TSCORE,ATTR_NORMALIZED) compute population stats via Welford-Pébaÿ pass 1, then emit per-row values in pass 2. - Streaming features. Every registered
FEAT_*operator implements the streaming computer interface and composes with the three modes above.
These paths benefit from three optimisations landed during the streaming
refactor (commit cdd72d5): record reuse (the same record buffer flows
through the pipeline), zero-allocation decoding into reused buffers,
and an mmap reader for .pulse files large enough to benefit from
demand paging.
Sorted cohorts decode faster, automatically. On the record-reuse
paths the decoder compares each field’s bytes against the previous
row’s and skips rewriting the ones that did not change. A cohort that
is a denormalised join — a parent block repeated across each parent’s
child rows — pays off when it is stored SORTED by the parent key: on a
synthetic 95-field, 12.5x-fanout join the full-row reuse decode runs
about 1.5x faster sorted. There is nothing to declare and no option;
on unsorted data the decoder notices the low repeat rate and stops
comparing, so the cost there stays within a few percent. Results are
identical either way. If you control the import, ORDER BY the parent
key.
Parent-grouped cohorts decode in time independent of the parent
block’s width. A cohort whose parent block is stored once per parent
(a 0x02 parent group) is scanned without rebuilding each row: a row
whose parent did not change costs nothing for the parent block, and a
changed parent is copied in from decoded values cached per parent
(bounded to 8 MiB per reader). On a synthetic 12.5x-fanout join with a
29-field child block, the full-row streaming scan runs 1.2x (10 parent
fields) to 1.4x (80) faster than the ungrouped file when sorted, and up
to 2.2x faster unsorted; a four-field projection costs the same at every
parent width, 2.5–3.3x under the ungrouped file. Results are identical.
Filters on grouped cohorts
A filter whose fields all belong to ONE parent group is evaluated once
per distinct parent tuple, not once per row: its verdict per dictionary
entry is computed the first time a row carrying that entry reaches it,
then every later row is a bit test on its entry index. Every built-in
filterer qualifies, and so does FILTER_EXPRESSION when it calls only
pure functions; a filter mixing a parent field with a child field (or
two groups) is evaluated per row as before. Results and
Response.Components filterer counts are identical either way.
On the synthetic 12.5x-fanout join (66 parent / 29 child fields,
100,000 rows, 8,000 parents) the filter does 8,000 evaluations instead
of 100,000. A cheap FILTER_INCLUDE on a parent categorical goes from
~35 to ~22 ns/row for the filter pass (sorted; 43 → 30 scattered) —
invisible end to end, where decode dominates. A FILTER_EXPRESSION on a
parent field goes from ~95 to ~19 ns/row for the filter pass (sorted;
~110 → ~24 scattered) and ~640 → ~580 ns/row end to end. (Before
expressions compiled once — below — the same filter went from ~7 µs to
~0.6 µs/row.) The verdict table costs two bits per dictionary entry per filter (27 KB
for a 109,000-entry group). The ungrouped (0x01) cohort and child-field
filters are unchanged.
Expressions compile once
FILTER_EXPRESSION and ATTR_FORMULA compile their expr-lang program
once per request build — against a prototype of the schema, typed as the
row values are — and run the cached program per row, with an env holding
only the fields the expression names. They used to compile per row
against the whole row, which cost more the wider the schema. On the
synthetic 12.5x-fanout join (95 fields, 100,000 rows, filter precompute
off), p_10 > 1.5 && c_u64_00 < 549755813888 went from ~8.7 µs to ~0.14
µs/row for the filter pass, and ~24 µs to ~2.1 µs/row end to end (0x01;
~24 → ~1.1 on 0x02), against ~1.9 / ~1.0 µs/row for the same request
with a FILTER_RANGE instead. ATTR_FORMULA p_10 * 2 + c_u64_00 / 1024: ~9.1 µs → ~0.3 µs/row through the Processor, ~24 → ~2.2 µs/row end
to end. Results are identical. Bench: BenchmarkExprCompileOnce.
Buffered path: when Pulse has to materialise
pulse api predict reports Streamable=false and lists every
buffering reason. The current set, from .claude/reference/execution-modes.md
(Streaming Process):
AGG_MEDIAN,AGG_PERCENTILE, andAGG_ZSCORE— require sorts or summed deviations.ATTR_PERCENTILE— sorted view of every value; no streaming algorithm preserves exact rank.GROUP_QUANTILE,GROUP_DATE— finalize-time work over the full set.- Window operators (
WIN_*) — operate on a sorted post-aggregate row set. - Decimal-typed field aggregations — precision-preserving path.
- Two-pass attributes combined with features or groups — orchestration matrix not yet extended.
- Tier-1 statistical tests combined with groupers, features, or two-pass attributes — same orchestration limit.
- Tier-2 post-tests (
req.PostTests) — always run after the result set is materialised, regardless ofTestType.
Reading predict output
pulse api predict --request request.json --json | jq '.data | {streamable, streamable_reasons}'
{
"streamable": false,
"streamable_reasons": [
"AGG_MEDIAN on field price"
]
}
If streamable_reasons is empty and streamable=true, the request
executes without buffering. Each reason is a one-line gate that pushed
the request to the buffered path; you can drop or substitute the
offending operator (e.g., AGG_AVG instead of AGG_MEDIAN) and
re-run predict.
Memory rules of thumb
| Path | Memory profile |
|---|---|
| Single-pass streaming | Constant — O(aggregator state) |
| Grouped streaming | O(distinct_groups × per-aggregator state) |
| Two-pass streaming | Constant; cost is 2× iter scan (typically OS-page-cached) |
| Buffered | O(filtered_rows × output_width) for the working set, plus per-operator state |
Concurrency
pulse.ComposeParallel (CLI: pulse api compose --parallel N)
fans ComposedRequest slots over a bounded worker pool. Workers share
the engine’s read-only registries; each Process call constructs
fresh stateful operators per request, so concurrent execution is
safe. Defaults: MaxWorkers = GOMAXPROCS, FailFast = true. See
Parallel Compose.
When to embed vs shell out
For high-throughput pipelines, embed Pulse directly via the Go library
— you avoid one process boundary per request and can stream rows
through your own writer with ProcessStream. For ad-hoc analysis,
JSON-in/JSON-out via pulse api process --json is faster to write
and easier to debug.
Components emission baselines
Always-on Response.Components emission baselines (Apple M1 Max, go1.x,
hermetic afero.NewMemMapFs() cohorts). The buffered/streaming pair drives a
100K-record single-f64 cohort through a five-aggregator mix covering both
mergeable (AGG_SUM / AGG_COUNT / AGG_AVERAGE / AGG_VARIANCE) and
non-mergeable (AGG_MEDIAN) paths. The crosstab fused/buffered pair drives a
200-field × 10K-row wide cohort with a region × segment crosstab over
AGG_COUNT(value) and full margins. Each cell is the median of three
b.Loop() runs.
| Bench | ms/op | MB/op | allocs/op |
|---|---|---|---|
BenchmarkProcess_BufferedComponents (/) | 30.16 | 49.25 | 600,122 |
BenchmarkProcessStream_WithComponents (/) | 30.44 | 49.38 | 600,487 |
BenchmarkCrosstabWideCohort_Fused (/service/) | 8.69 | 21.07 | 143,903 |
BenchmarkCrosstabWideCohort_Buffered (/service/) | 9.08 | 22.03 | 83,978 |
These are the canonical post-Components-always-on baselines and the regression
frontier: a sustained > +5% regression on any line warrants investigation.
A relative “+5% vs no-Components” gate (BenchmarkProcess_NoComponents against
_WithComponents, plus the matching crosstab pair) would need paired
_NoComponents sub-cases that do not exist today; add them and flip the gate
from absolute to relative if the pairing lands.
Reproduce with:
go test -bench=BenchmarkProcessStream_WithComponents -run=^$ -count=3 -benchmem ./
go test -bench=BenchmarkProcess_BufferedComponents -run=^$ -count=3 -benchmem ./
go test -bench=BenchmarkCrosstabWideCohort_Fused -run=^$ -count=3 -benchmem ./internal/service/
go test -bench=BenchmarkCrosstabWideCohort_Buffered -run=^$ -count=3 -benchmem ./internal/service/
Troubleshooting
Audience: operators chasing a specific failure mode in production (file not found, permission errors, MCP transport issues, common error codes).
This page is organised by symptom. For per-code recovery detail
(Message + Fixup templates), fetch metadata via the
pulse_errors_lookup MCP tool ({"code": "PULSE_XXX"}) or
pulse errors lookup CODE on the command line. The
tool-errors-lookup skill explains the envelope shape, the
DOMAIN_CATEGORY naming convention, and the repair workflow that
chains predict-side suggestions into structured fixups.
LLM agents using MCP: call
pulse_errors_lookupfor per-code detail —code=PULSE_XXXfor one code,domain=PULSEto enumerate,query="..."for keyword search. The skill is the orientation; the tool is the catalog. This page focuses on operational symptoms that don’t reduce to a single error code.
“data directory required: set PULSE_DATA_DIR or pass –data-dir”
pulse mcp refuses to start. The MCP leaf is the one place the
binary insists on a base directory because it enumerates cohorts at
session start.
Fix: export PULSE_DATA_DIR in the client’s MCP config, or pass
--data-dir /path/to/data on the command line. The
pulse mcp page has the full example.
“file not found” / “no such file or directory”
The cohort path was resolved against the wrong base. Pulse prefers
absolute paths; with PULSE_DATA_DIR set, relative paths resolve
against it.
Fix: call pulse cohort inspect /absolute/path/data.pulse to
verify the file is where you think it is. If you’re running inside
pulse mcp, check the data-dir line on stderr at startup.
“permission denied”
Pulse runs as your user; it does not escalate. When deployed as an
MCP process under a different user (e.g. via launchd / systemd),
the cohort directory and files must be readable by that user.
Fix: check id inside the MCP startup banner on stderr; check
the file mode with ls -l; widen the group as needed.
“invalid pulse magic bytes” / “unsupported pulse format version”
The file isn’t a .pulse file — or it’s from a newer binary that
introduced a format version this one cannot read (details.version
names the byte, details.supported_versions the set this binary
accepts). Upgrade Pulse, or re-import the source with this binary. The reader rejects unknown versions
at parse time (see Header Layout) so a future
binary doesn’t silently mis-decode an older file.
Fix: verify the file with file path/to/data.pulse and the first
nine bytes (hexdump -C). The expected magic is 50 55 4c 53 45 00 00 00
followed by a version byte (0x01 or 0x02).
“truncated pulse header”
The file is shorter than nine bytes or was cut off mid-write.
Fix: re-import. If you suspect a partial write, also check whether the writer was killed mid-flush — Pulse writes the header first, then the schema, then the records, so a truncated file usually fails here before any data is observed.
SERVICE_VALIDATION errors
A field name in the request doesn’t exist in the cohort, or an operator targets a field of the wrong type.
Fix: run pulse api predict on the same
request — predict diagnoses validation failures without executing.
Common cases: typo in field name; numeric aggregation on a
categorical field (warning code
PULSE_AGG_NOT_MEANINGFUL_FOR_CATEGORICAL); two-pass attribute
combined with a feature (currently buffered, not invalid — predict
will flag this in streamable_reasons).
PULSE_IMPORT_* errors
Import-time failures. The two most common:
PULSE_IMPORT_CATEGORICAL_OVERFLOW— too many distinct values for the chosen categorical width. Either bump the width (categorical_u16/categorical_u32), drop the categorical encoding, or filter the source before re-importing. See Dictionary Blocks.PULSE_IMPORT_DESCRIPTION_TOO_LONG— schema field description exceeds 1000 bytes. Trim it.PULSE_IMPORT_ROW_ERROR: null value in non-nullable field— a null cell (""/null/na/n/a) landed in a field your explicit--schemadeclared non-nullable. Mark the field"nullable": truein the schema JSON and re-import. (Inferred imports never raise this — see the promotion warning below.)
PULSE_IMPORT_NULL_PROMOTED
A warning, not an error. During an inferred import (no --schema),
schema inference samples only the first --sample-rows (default 500)
rows to decide nullability. If a null appears in a column past that
window, Pulse promotes the field to nullable and finishes the import
rather than failing — listing the promoted fields in the warning and in
promoted_fields on the managed-import result. The written .pulse is
correct.
Fix (optional): to make the schema exact up front, raise
--sample-rows so the sample covers the null, or generate and edit a
schema with pulse import schema-template
(which already reports the accurate nullable flag) and re-import with
--schema.
PULSE_IMPORT_WIDTH_PROMOTED
A warning, not an error. Inference sizes each categorical_* rung and
integer width from the first --sample-rows rows. When a later value
does not fit — the 257th distinct value of a categorical_u8 parent-name
column, 70000 in a column the sample saw as u8, -3 or 2.5 in an
integer column, 1e300 in a column the sample saw as f32 (under --float f32) — Pulse
promotes the field to the narrowest type that holds it and imports the
row. pulse convert does the same over an inferred schema instead of
failing with PULSE_IMPORT_CATEGORICAL_OVERFLOW. Details name the field, the inferred
from type, the written to type and the first source_row that forced
it. No value changes.
Fix (optional): raise --sample-rows, or pin the type with
column_type_overrides / --schema. A pinned type is a contract: a
value past a --schema type is then a PULSE_IMPORT_ROW_ERROR, and one
past a column_type_overrides type refuses the whole import with
PULSE_IMPORT_OVERRIDE_INVALID — never a promotion (on
pulse convert, a full declared categorical rung is the fatal
PULSE_IMPORT_CATEGORICAL_OVERFLOW). A non-boolean in a packed_bool
column never promotes either: it stays a row error.
PULSE_FIELD_DESCRIPTION_LOW_QUALITY
A warning by default, an error under --strict. The description is
empty, under ten characters, or a generic placeholder ("n/a",
"tbd", "unknown", "field", "data", "value", "column").
Fix: edit the description in the schema JSON, re-import with
--schema.
MCP “tool not found” / “no tools registered”
An MCP client connects but sees no Pulse tools.
Fix: check the client’s MCP log (Claude Desktop surfaces this in
~/Library/Logs/Claude/). Common causes: pulse binary is not on
PATH, the wrong working directory, or PULSE_DATA_DIR is not set in
the MCP env block. Re-read pulse mcp.
mmap / file-mapping failures
On very large .pulse files the streaming reader uses memory mapping
where available. If your environment forbids mmap (some sandboxed
containers, very locked-down macOS configurations), the reader falls
back to a buffered read.
Fix: typically transparent. If you suspect a regression, run with
verbose Go runtime tracing or compare against a non-mmap file by
copying it to /tmp and re-running.
When in doubt: predict, then process
Almost every “why doesn’t this work” question is answerable by
pulse api predict --request request.json --json
Predict reads only the header and schema — it never touches record
data — and returns the full envelope of errors, warnings, and the
streamable flag. If predict says valid:true and process still
fails, the bug is in the processing layer, not the request.
Development Setup
Audience: new contributors getting their first PR ready.
This page is the short version. The fuller treatment of the repo’s
conventions, CI gates, and Update Demand lives in the Internals
section and in
CLAUDE.md
at the repository root.
Clone
git clone https://github.com/frankbardon/pulse.git
cd pulse
Tooling
Pulse needs only the Go toolchain — there is no Node, Python, or
container build. Install Go 1.24+ (see go.mod for the canonical
version).
The repo also uses staticcheck for make lint; it is auto-installed on
first run via go run.
Common targets
| Command | What it does |
|---|---|
make build | Builds the CLI binary to bin/pulse (default goal) |
make test | Runs go test ./... |
make fmt | Runs go fmt ./... |
make vet | Runs go vet ./... |
make lint | Runs go vet then staticcheck ./... |
make cover | Runs tests with coverage; outputs coverage.out |
make clean | Removes bin/ and coverage.out |
A .env file at the repo root is auto-loaded and exported, so
PULSE_DATA_DIR and any other PULSE_* env vars can live there for
local development.
Run the binary you just built
make build
./bin/pulse --version
./bin/pulse --json | head -20
The CLI tree itself is mapped in the CLI Tour.
Where things live
The package layout is documented at Internals → Package Layout. Two pointers worth knowing on day one:
- Public facade:
pulse.go— every Go embedder API lives here. - CLI internals:
internal/cli/— one file per command group; never put processing logic here.
Read this before writing code
- Style Guide
- Testing Conventions
- Pull Request Process
- The Update Demand — what doc/skill updates ride alongside what code changes.
Style Guide
Audience: anyone writing code or docs in the Pulse repository.
This page summarises the conventions enforced by review and by CI. The
authoritative source is the “Code Conventions” section of
CLAUDE.md;
copy that file’s rules when in doubt.
Go style
- Standard
gofmt/go vetcleanliness —make lintis the gate. - Module path is
github.com/frankbardon/pulse. The standard-libraryiocollision is handled by aliasing the project’s package aspio "github.com/frankbardon/pulse/io". - Library-first: business logic lives in library packages, never in
cmd/pulse/. The CLI parses flags, calls the library, formats output. - All file I/O routes through the injected
afero.Fs— neveros.Open/os.ReadFiledirectly in library code, because that defeatsfs.NewMemMap()for tests and the extension hook for custom storage backends.
Naming
- Component types use
SCREAMING_SNAKE_CASE:AGG_COUNT,ATTR_ZSCORE,FILTER_INCLUDE,GROUP_CATEGORY,WIN_LAG,FEAT_LOG,TEST_T. - Error codes use
DOMAIN_CATEGORYformat, organised by the six domains listed in CLAUDE.md (ENCODING,PROCESSING,SERVICE,DATA,CLI,PULSE). - Field types use lowercase snake (
u8,packed_bool,categorical_u16,decimal128,set_u256).
Structural bans
These are enforced by non-skippable CI gates:
| Ban | Enforced by |
|---|---|
internal/descriptor/ MUST NOT import internal/service/ or internal/processing/ | TestPredictNoExecutionImports |
descriptor/ + internal/descriptor/ MUST NOT use fmt.Sprintf for JSON construction | TestDescriptorNoFmtSprintf |
Golden files in descriptor/testdata/ MUST NOT be hand-edited | TestGoldensNotHandEdited |
| No predecessor-project string prefixes (legacy “Orbit” naming) in error codes or constants | TestNoOrbitReferences, TestNoOrbitPrefix |
CLAUDE.md MUST mention every PULSE_* env var, every non-skippable gate, the current format_version | TestClaudeMd* family |
See the Pull Request Process for how these surface during review.
Comments and prose
- Public Go symbols carry a godoc-shaped comment opening with the symbol name.
- Skill files use YAML frontmatter (
name,description,type,applies_to) and are LLM-facing — keep them in MCP voice (tool calls, JSON payloads). The human-facing equivalent is this site; cross-link from each side. - mdBook chapters open with a one-sentence summary and an Audience line. See any of the already-authored chapters in this site for the tone.
The Update Demand
The single most important convention: if your code change ships without
the corresponding CLAUDE.md and skill updates, CI will fail. The
Update Demand chapter is the
authoritative table of triggers and the gates that enforce them. Read
it before opening a PR that touches a registered surface (new
aggregator, new error code, new CLI flag, new field type, …).
Testing Conventions
Audience: contributors writing tests, regenerating goldens, or trying to figure out which CI gate to run locally before pushing.
From CLAUDE.md, Non-Skippable CI Gates and The Update Demand.
Style
- Table-driven tests are the default. Put cases in a
[]struct{...}with anamefield, run witht.Run(tc.name, func(t *testing.T)). - Hermetic by construction: anything that touches the filesystem uses
fs.NewMemMap()so tests don’t depend on disk state. - New code lands with tests in the same PR — TDD first, then implementation. A test that passes without the implementation is suspicious; the test is probably wrong.
Running tests
# Full suite
go test ./...
# Single package
go test ./internal/processing/...
# Verbose, specific test
go test ./internal/service/... -v -run TestProcess
# Coverage report
make cover
# Fuzz the .pulse header
go test ./encoding/... -fuzz FuzzPulseFileHeader -fuzztime 30s
Non-skippable CI gates
These tests guard structural invariants. If one of them fails, the
underlying conventions (not the test) are what need re-thinking.
Their full names appear in CLAUDE.md so the
TestClaudeMdMentionsAllNonSkippableGates self-check can find them.
| Gate | Guards |
|---|---|
TestPredictNoExecutionImports | internal/descriptor/predict.go does not import internal/service/ or internal/processing/ |
TestDescriptorNoFmtSprintf | descriptor/ + internal/descriptor/ never build JSON via fmt.Sprintf |
TestGoldensNotHandEdited | descriptor/testdata/* hashes match the generator |
TestClaudeMdMentionsFormatVersion | CLAUDE.md references the current envelope format_version |
TestClaudeMdMentionsAllEnvVars | Every PULSE_* env var has a CLAUDE.md row |
TestClaudeMdMentionsAllNonSkippableGates | This very table is the source — CLAUDE.md must list every gate by name |
TestUpdateDemandTableCovers | The Update Demand table covers every registered component category |
TestPerPackageCoverageFloors | Package directories exist and meet documented coverage floors |
TestNoOrbitReferences, TestNoOrbitPrefix, TestNoOrbitPrefixes | No predecessor-project string prefixes leak in |
TestSkillsCoverAll* | Skill files mention every registered component, error code, distribution, CLI leaf, field type, MCP tool |
TestSkillsManifestConsistent | Every skill’s applies_to frontmatter names only real CLI leaves (there is no skills/index.json — the embed.FS walk is the manifest) |
TestSkillsFrontmatter_RequiredFields | Every skill has name, description, type, applies_to |
TestRegistryStreamabilityMatchesTypes | Aggregator OnlineAggregator capability matches AggregationType.Streamable() |
TestPredict_Streamable_MatchesRuntime | PredictResult.Streamable mirrors internal/processing’s CanStreamRequest |
TestStreamability_*Known | Every All*Types() entry has a streamability table row |
TestCanStreamRequest_RegressionMatrix | Regression matrix on the exported CanStreamRequest helper |
TestManifest*Complete | Manifest enumerates every registered operator, test, distribution, MCP tool, error code |
TestManifestStreamableMatchesTypes | Manifest Streamable flags mirror the type-level methods |
TestCodesHaveFixups, TestSkillsErrorCodeFixupsDocumented | Each error code has a fixup template and the skill row to match |
TestDefaults_Applied | Smart-default operator-type inference behaves as documented |
(See CLAUDE.md “Non-Skippable CI Gates” for the full prose; this table is the
quick-reference.)
Running a subset of gates locally
# All descriptor contract gates
go test ./descriptor/ ./internal/descriptor/ -run 'TestPredictNoExecution|TestDescriptorNoFmtSprintf|TestGoldensNotHandEdited'
# Skill coverage gates
go test ./internal/skills/ -run 'TestSkillsCoverAll|TestSkillsManifestConsistent|TestSkillsFrontmatter'
# CLAUDE.md gates
go test . -run 'TestClaudeMd|TestUpdateDemandTable'
# Predecessor-reference scrub
go test . -run TestNoOrbitReferences
Regenerating golden files
Golden files live in descriptor/testdata/. Each ends with a
// golden-hash: <sha256> line; TestGoldensNotHandEdited verifies
the hash. After a legitimate change to the generator:
go test ./descriptor/ -run 'Test.*Golden' -update
go test ./descriptor/ -run TestGoldensNotHandEdited # confirms the new hash sticks
Never hand-edit a golden file — the gate will catch you.
Adding a new gate
If your change introduces a structural invariant, add a test for it
under the same naming convention (TestX), and add it to the table in
CLAUDE.md so TestClaudeMdMentionsAllNonSkippableGates recognises
it. The Update Demand lists this as a
trigger row.
Running CI Gates Locally
Audience: contributors who want to mirror the CI surface on a local machine before pushing, or want to narrow down which gate failed in a remote run.
The full make lint && make test cycle is the default pre-push
check. This page is the targeted-subset reference for when you know
which contract you changed and want to verify only that contract’s
gates.
All tests
go test ./...
Runs everything. Slow but exhaustive. The recommended pre-push default.
Descriptor contract gates
The descriptor surface (predict, manifest, inspect, envelope) has structural invariants that catch hand-edits and import cycles:
go test ./descriptor/ ./internal/descriptor/ -run 'TestPredictNoExecution|TestDescriptorNoFmtSprintf|TestGoldensNotHandEdited'
TestPredictNoExecutionImports—internal/descriptor/predict.gomust not importinternal/service/orinternal/processing/.TestDescriptorNoFmtSprintf— nofmt.Sprintfindescriptor/envelope.go,manifest.go,predict.go,inspect.go.TestGoldensNotHandEdited— every golden file underdescriptor/testdata/must end with a valid// golden-hash: <sha256>line that matches the body.
Skill-coverage gates
The skill pack under skills/ is the LLM-facing surface. Every
registered component, error code, distribution, CLI leaf, field
type, MCP tool must be mentioned in its target skill by name:
go test ./internal/skills/ -run 'TestSkillsCoverAll|TestSkillsManifestConsistent|TestSkillsFrontmatter'
The specific gates this batch includes are listed in Testing Conventions → Non-skippable CI gates.
Predecessor-reference scrub
Pulse has a hard rule against leaking strings from its predecessor project (legacy “Orbit” naming) into error codes or type constants:
go test . -run TestNoOrbit
Should always return zero matches before opening a PR.
Public API gates
Every package outside internal/ and cmd/ is frozen Go API at
v1.0.0. Three guards keep the surface deliberate:
go test ./internal/apigolden/ # TestPublicAPIGolden (blocking)
make smoke # builds internal/embeddersmoke as an external module
TestPublicAPIGoldendumps the exported shape of every public package, with each root alias expanded into its target’s fields, tags and methods, and compares it tointernal/apigolden/testdata/public_api.txt. If you meant to change the surface, regenerate withgo test ./internal/apigolden/ -run TestPublicAPIGolden -updateand review the diff as the API delta.make smokecompiles and testsinternal/embeddersmoke, a nested module with its owngo.modthat may only use public spellings. CI runs it.- The
apidiffjob (.github/workflows/api-compat.yml) runs on PRs only. See Pull Request Process.
Contrib modules
contrib/otelpulse and contrib/prompulse are separately published Go
modules (own go.mod + go.sum, replace => ../.. for development).
The core module never depends on them, nor on any OpenTelemetry or
Prometheus module (TestCoreModuleNoObservabilityDeps).
make contrib # per module: go mod tidy -diff, vet, staticcheck, test
make contrib-tidy # re-tidy every contrib module
make contrib runs without -mod=mod, so a stale contrib go.mod or
go.sum fails locally and in CI. A root dependency bump therefore needs
make contrib-tidy in the same PR. Contrib modules are released in
lockstep with the root tag by scripts/release-contrib.sh (called by
release.yml); run it without --push for a dry-run tag plan.
CLAUDE.md hygiene gates
CLAUDE.md is itself a tested artefact. Every PULSE_* env var
named in Go source must appear there; every non-skippable gate must
be listed; the current format_version must be mentioned:
go test . -run 'TestClaudeMd|TestUpdateDemandTable'
Per-change quick reference
| If you changed… | Run |
|---|---|
| An aggregator / attribute / filterer / grouper / window / feature | go test ./internal/skills/ -run TestSkillsCoverAllComponents && go test ./internal/descriptor/ -run TestManifestOperatorsComplete |
| A statistical test | go test ./types/ -run TestStreamability_TestsKnown && go test ./internal/descriptor/ -run 'TestManifestTestsComplete|TestManifestPostTestsComplete' |
| A synth distribution | go test ./internal/skills/ -run TestSkillsCoverAllSynthDistributions && go test ./internal/descriptor/ -run TestManifestDistributionsComplete |
| A regression operator | go test ./internal/skills/ -run TestSkillsCoverAllRegressions && go test ./internal/descriptor/ -run TestManifestRegressionsComplete |
| An error code | go test ./errors/ -run 'TestCodesHaveFixups|TestErrorsLookup' && go test ./internal/descriptor/ -run 'TestManifestErrorCodesComplete|TestManifest_ErrorCodesSlim' |
| An MCP tool | go test ./internal/skills/ -run TestSkillsCoverAllMCPTools && go test ./internal/descriptor/ -run TestManifestMCPToolsComplete && go test ./internal/mcp/ -run TestMCPSchemaBinding |
| A field type | go test ./internal/skills/ -run TestSkillsCoverAllFieldTypes && go test ./encoding/... ./internal/encoding/... |
| The Update Demand table or a contract listed in it | go test . -run TestUpdateDemandTableCovers |
| Any exported identifier in a public package, or a root alias’s target | go test ./internal/apigolden/ (then -update only if the change is intended) + make smoke |
A package move under internal/io/** / internal/iocore | go test ./internal/iocore/ -run TestIOImportBoundary |
The full set is documented in Testing Conventions → Non-skippable
CI gates and enumerated by name
in CLAUDE.md.
Porting Workflow
Audience: contributors porting functionality into Pulse from an external source — a predecessor project, a one-off prototype, an upstream library being absorbed.
Porting is more error-prone than greenfield work. The mistakes cluster around two pitfalls: leaking the source project’s naming through, and writing tests against the source’s behaviour rather than the Pulse-native shape the new code should expose. This workflow exists to keep both at bay.
1. Map the boundary
Identify the source behaviour you are porting and the Pulse package
that will own the result. The destination is rarely a 1:1 mirror of
the source — Pulse’s package layout (library-first facade, no
business logic in cmd/pulse/, all I/O through the injected
afero.Fs) often demands a refactor on the way in.
2. Write Pulse-native tests first
Write the destination package’s tests in Pulse-native form before
porting any source code. Every identifier — type names, error codes,
constants — uses Pulse conventions (AGG_*, PULSE_*,
SCREAMING_SNAKE for component types, DOMAIN_CATEGORY for error
codes). No predecessor-project string prefixes leak in.
3. Confirm the tests fail informatively
Run go test ./... and confirm the new tests fail with informative
messages. If they pass without the implementation, the test is
wrong — fix it. A green test before the implementation lands is
almost always a sign that the test is asserting on the wrong shape.
4. Port the implementation
Port the source code into the destination package. Refactor for Pulse-native idioms as you go — there is no benefit to landing a direct copy and then refactoring later. Embedders and reviewers will read the final shape, not the migration trace.
5. Re-run tests until green
Iterate on the implementation until the tests pass. If a test refuses to come green, lean on the test rather than the implementation — porting often surfaces edge cases the source project handled implicitly.
6. Update target skill and CLAUDE.md
Update the target skill file(s) per The Update Demand. Run the skill-coverage gates locally (see Running CI Gates Locally). Update CLAUDE.md if the change touches a contract, env var, format version, or registered surface.
7. Predecessor-reference scrub
Run the hygiene gate before opening the PR and confirm zero matches:
go test . -run TestNoOrbit
The gate is non-skippable; CI will catch any leak, but catching it locally saves a round-trip.
8. Open the PR
Follow the Pull Request Process. The porting PR is otherwise identical to any other PR — same Update Demand obligations, same test-first preference, same Conventional Commits subject line.
Pull Request Process
Audience: contributors preparing to open or land a PR.
This page is a checklist. The longer prose lives in
CONTRIBUTING.md
and the Update Demand chapter.
1. Branch and commit shape
- One feature or fix per PR. Keep the diff focused.
- Conventional Commits in the subject line:
feat(...),fix(...),chore(...),docs(...),perf(...),refactor(...),test(...). - The PR title is usually the lead commit’s subject.
2. Tests first
A PR that adds a new aggregator, error code, field type, I/O format, statistical test, or skill must include tests in the same PR. The testing-first preference is documented in Testing Conventions. Implementation that lands without tests will be sent back; tests that pass without the implementation are suspicious and probably wrong.
3. The Update Demand
The single biggest source of “your PR was bounced” feedback. The full table lives in The Update Demand; the cliff-notes are:
| Change category | Doc/skill update required in the same PR |
|---|---|
| Registered aggregator / attribute / filterer / grouper | The matching skill file + the operator capability table |
| Registered window / feature / synth distribution / statistical test | Same — skill + capability file |
| Error code (added / removed / renamed) | errors/codes.go, errors/fixup_metadata.go (codeMetadata Message + Fixups), internal/descriptor/capabilities_errors.go |
| CLI leaf (added or flag added) | The command index in docs/src/cli/flags.md + skills/session-bootstrap.md if user-facing |
--json envelope change | CLAUDE.md “Output Format Contract” |
.pulse file format change | CLAUDE.md “Byte-layout invariants” + .claude/reference/byte-layout.md + skills/cohort-schema-design.md |
| New environment variable | CLAUDE.md “Build / Env” + skills/session-bootstrap.md |
| New non-skippable CI gate | List it by name in CLAUDE.md |
If you find yourself wanting to defer the doc update to a follow-up PR, stop. The follow-up PR will not happen, and the next contributor will read stale guidance. Update in the same PR or do not merge.
Public API changes
Every package outside internal/ and cmd/ is public Go API, frozen at
v1.0.0. Before adding an exported identifier to a public package (or
widening a type a root alias points at), check that a general embedder
use case justifies it; prefer internal/.
TestPublicAPIGolden(blocking) fails on any unregenerated change to the public surface, including the fields and methods behind a root alias intointernal/. Regenerate withgo test ./internal/apigolden/ -run TestPublicAPIGolden -updateand call the delta out in the PR description.apidiff(.github/workflows/api-compat.yml) compares the PR against the latestv*tag and writes the report to the job summary. It is advisory while the latest tag is a pre-release: the job stays green whatever it finds. Add theapi-break-oklabel to a PR whose break is intentional; the summary then records it as such. Once a stablev1.0.0tag exists the job becomes blocking for incompatible changes, andapi-break-okis the only override.make smokebuildsinternal/embeddersmoke, an external module exercising the migration-guide spellings; a removed or renamed public name breaks it.
4. Pre-flight checks
make fmt
make lint
make test
make smoke # when the public surface moved
make contrib # when contrib/ or a root dependency moved
For change-category-specific gates, see Testing → Running a subset of gates locally.
5. Open the PR
- Use the bug-report or feature-request template as a starting point if applicable.
- Fill in the PR template’s “Summary” and “Test plan” sections.
- Link related issues with
Closes #N. - Do not push
--forcetomain. Force-pushing your own feature branch is fine before review starts.
6. Review and CI
CI runs the full go test ./... plus the non-skippable gates listed in
Testing → Non-skippable CI gates.
A failing gate means a structural invariant is broken, not a flaky
test; fix the root cause rather than retrying.
When a pre-commit hook or PR check fails, create a new commit with
the fix. Do not git commit --amend after a hook failure; the prior
commit may not exist or may have already been pushed.
7. Merge
- Merge with a merge commit; squash merges are disabled so per-story commits keep their Conventional Commits subjects.
- Once merged, the deploy workflow rebuilds and publishes this docs site to https://frankbardon.github.io/pulse/.
For changes that introduce a new architectural decision, also update
the relevant section of CLAUDE.md and reference the PRD (if one
exists) in the PR description.