Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 PulseInstallation → Your First Cohort → CLI Tour
Driving Pulse from the shellCommand Line Reference
Embedding Pulse in a Go programLibrary Embedding
Curious about the binary format.pulse File Format
Hacking on Pulse itselfInternals and Contributing
Wiring Pulse into an LLM agentMCP 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.

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-bootstrap skill via pulse_skills_get — it covers session bootstrap rather than local install.

Prerequisites

RequirementMinimum
Go toolchain1.24 (see go.mod)
OSLinux, macOS, or Windows (anywhere Go cross-compiles)
DiskA 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

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-bootstrap skill, fetched via pulse_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) via pulse 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-bootstrap skill 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.

CommandPurposeChapter
pulse api processExecute one request against a cohortapi process
pulse api process-chainSource-rooted linear chain of mergeable processing stages(see cmd/pulse/main.go)
pulse api composeExecute multiple requests in batch / parallelapi compose
pulse api predictValidate a request without executingapi predict
pulse api sampleReturn up to N rowsapi sample
pulse api facetReturn distinct values of a field (simple) or a multi-field rich summaryapi 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

CommandPurposeChapter
pulse cohort inspect PATHRead header + schema (no record data)cohort inspect
pulse cohort filterWrite 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.

CommandPurpose
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 SHARDAppend a shard to an existing archive (cohesion validated)
pulse shard remove ARCHIVE BASENAMERemove a shard from an archive by basename
pulse shard list ARCHIVEList shards inside an archive with per-shard record counts
pulse shard extract ARCHIVE BASENAMEWrite one shard’s standalone .pulse bytes to stdout
pulse shard verify ARCHIVEStrict dict-prefix cohesion check across shards
pulse shard compact ARCHIVEDefragment 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

CommandPurposeChapter
pulse synth from-schemaGenerate from a JSON specsynth from-schema
pulse synth from-profileGenerate from a captured profilesynth from-profile
pulse profile createCapture a profile from an existing cohortprofile create

Self-description & LLM surface

CommandPurposeChapter
pulse --jsonRoot manifest (commands, components, field types, skills)manifest
pulse skills listList embedded skills with metadataHow LLMs Use Pulse
pulse skills show NAMEPrint a skill’s full markdown bodysame
pulse examples searchSearch embedded runnable request examples by tag, category, or operatorExamples Library
pulse examples get NAMEPrint one example’s full JSON bodysame
pulse errors lookup CODEPrint an error code’s canonical message and recovery fixups(see errors/)
pulse mcpServe MCP over stdiomcp

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

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.

OperatorIn plain wordsAnswers questions likeLevelInstead, when…
AGG_AVERAGEAverage of a numeric field, over all rows or per group.What is the average order value?basicAGG_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_LOWERLower 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?intermediateAGG_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_UPPERUpper 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?intermediateAGG_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_COUNTNumber of rows that have a value in a field, over all rows or per group.How many responses did each region return?basicAGG_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_COUNTNumber of different values a field takes, over all rows or per group.How many different customers ordered this month?basicAGG_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_SUMTotal 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?intermediateAGG_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_FREQUENCYHow 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?basicAGG_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_KURTOSISHow 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?advancedAGG_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_MAEAverage 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?intermediateAGG_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_MAPEAverage 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?intermediateAGG_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_MAXLargest value of a numeric or date field, over all rows or per group.What is the latest order date in each region?basicAGG_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_MEDIANMiddle 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?basicAGG_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_MINSmallest value of a numeric or date field, over all rows or per group.What is the earliest order date in each region?basicAGG_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_MODEMost common value of a field, over all rows or per group.What is the most common answer to the preferred-brand question?basicGROUP_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_COUNTHow many rows share a field’s most common value, over all rows or per group.How many respondents gave the most common answer?basicGROUP_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_COUNTNumber of rows where a field has no value, over all rows or per group.How many respondents skipped the income question?basicAGG_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_PERCENTILEValue 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?basicAGG_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_RANGEDistance 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?basicAGG_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_RATIOTotal 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?basicATTR_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_RMSETypical 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?intermediateAGG_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_AVGFor a multi-select field, the average number of options chosen per row.How many brands does a respondent recognise on average?basicAGG_SET_CARDINALITY_SUM when you want the total number of selections.
AGG_SET_FREQUENCY when you want which options were chosen.
AGG_SET_CARDINALITY_SUMFor a multi-select field, the total number of options chosen across all rows.How many brand mentions did the survey collect in total?basicAGG_SET_CARDINALITY_AVG when you want the number per row.
AGG_SET_FREQUENCY when you want the count for each option.
AGG_SET_DISTINCT_VALUESFor a multi-select field, how many different combinations of options appear.How many different combinations of channels do customers use?basicAGG_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_FREQUENCYFor 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?basicGROUP_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_INTERSECTIONFor 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?basicAGG_SET_UNION when you want every option chosen by anyone.
AGG_SET_FREQUENCY when you want how many rows chose each option.
AGG_SET_UNIONFor 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?basicAGG_SET_INTERSECTION when you want the options every row chose.
AGG_SET_FREQUENCY when you want how many rows chose each option.
AGG_SKEWNESSHow 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?intermediateAGG_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_STDDEVTypical 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?basicAGG_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_SUMTotal of a numeric field, over all rows or per group.What is total revenue by region?basicAGG_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_VARIANCESpread 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?intermediateAGG_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_MEANAverage 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?intermediateAGG_AVERAGE when every row should count equally.
AGG_RATIO when you want one total divided by another.
AGG_WELFORDMean, 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?intermediateAGG_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_ZSCOREPopulation 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?intermediateATTR_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_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.

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_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.

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_LOWER when you want the lower end of the same interval.
  • TEST_WELCH when 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_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.

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_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.

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_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.

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_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.

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_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.

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_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.

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_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.

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_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.

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_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.

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_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.

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_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.

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_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.

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_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.

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:

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_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.

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_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.

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_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.

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:

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:

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_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.

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:

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:

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:

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_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.

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_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.

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_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.

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_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.

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_AVERAGE when every row should count equally.
  • AGG_RATIO when 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_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.

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:

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.

OperatorIn plain wordsAnswers questions likeLevelInstead, when…
ATTR_CODE_INAdds 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)?basicFILTER_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_PARTAdds 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?basicGROUP_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_SCOREAdds 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?advancedMAT_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_FORMULAAdds 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?intermediateFILTER_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_MAHALANOBISAdds 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?advancedATTR_ZSCORE when only one field matters.
TEST_HOTELLING_T2 when you want how far apart groups are, not single rows.
ATTR_NORMALIZEDRescales 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?basicATTR_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_SCOREAdds 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?advancedMAT_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_PERCENTILEAdds 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?basicAGG_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_FITTEDAdds 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?advancedREG_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_LEVERAGEAdds 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?advancedATTR_REG_RESIDUAL when you want rows the model predicts badly.
REG_OLS when you want the model itself.
ATTR_REG_PREDICTAdds 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)?advancedATTR_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_RESIDUALAdds 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?advancedREG_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_HASAdds 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?basicFILTER_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_POPCOUNTAdds to every row the number of options its multi-select field has selected.How many reasons did each respondent tick?basicAGG_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_TSCOREAdds 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?intermediateATTR_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_ZSCOREAdds 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?intermediateAGG_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:

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_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.

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_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.

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_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.

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:

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_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.

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_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.

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_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.

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_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.

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:

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_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.

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_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.

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:

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:

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_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).

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_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.

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.

OperatorIn plain wordsAnswers questions likeLevelInstead, when…
FILTER_DATE_RANGESKeeps 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?basicGROUP_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_EXCLUDEDrops 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?basicFILTER_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_EXPRESSIONKeeps 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?intermediateFILTER_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_FALSEKeeps 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?basicFILTER_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_INCLUDEKeeps 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?basicFILTER_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_NULLKeeps 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?basicAGG_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_RANGEKeeps only the rows whose number lies between a low and a high limit, both limits included.What do adults aged 18 to 34 say?basicFILTER_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_ALLKeeps 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?basicFILTER_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_ANYKeeps 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?basicFILTER_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_NONEKeeps 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?basicFILTER_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_EQUALSKeeps 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?basicFILTER_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_TRUEKeeps only the rows where a yes/no field is yes, such as active accounts or completed surveys.What do only the completed interviews say?basicFILTER_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_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.

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:

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_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.

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_TRUE when you want the rows where the field is yes.
  • FILTER_NULL when 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_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.

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_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.

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_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.

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:

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:

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:

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:

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:

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.

OperatorIn plain wordsAnswers questions likeLevelInstead, when…
GROUP_CATEGORYSplits 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?basicGROUP_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_DATESplits 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?basicGROUP_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_RANGESLabels 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?basicGROUP_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_KMEANSFinds 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?advancedGROUP_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_QUANTILESplits 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%?intermediateGROUP_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_RANGESplits 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?basicGROUP_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_ROUNDEDGroups 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?basicGROUP_RANGE when you want the band’s both edges in its name.
GROUP_QUANTILE when you want groups of equal size.
GROUP_SET_PER_ELEMENTSplits 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?intermediateGROUP_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_VALUEGroups 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?intermediateGROUP_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_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.

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:

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_DATE when plain calendar months, quarters or weeks are enough.
  • FILTER_DATE_RANGES when 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:

Glossary: centroid, z-score

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_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.

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_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.

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:

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_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.

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:

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.

OperatorIn plain wordsAnswers questions likeLevelInstead, when…
WIN_DELTAAdds 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?basicWIN_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_RANKRanks 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?basicWIN_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_EWMAAdds 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?intermediateWIN_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_LAGAdds 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?basicWIN_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_LEADAdds 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?basicWIN_LAG when you want the value from an earlier row.
WIN_DELTA when you want how much the field changed between rows.
WIN_MOVING_AVGAdds 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?basicWIN_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_CHANGEAdds 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?basicWIN_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_RANKRanks 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?basicWIN_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_NUMBERNumbers 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?basicWIN_RANK when rows with equal values should share a position.
WIN_DENSE_RANK when you want ranks without gaps after equal values.
WIN_RUNNING_AVGAdds 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?basicWIN_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_SUMAdds 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?basicAGG_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_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.

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_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.

Glossary: rank, ties

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:

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_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.

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_LAG when you want the value from an earlier row.
  • WIN_DELTA when 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:

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_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.

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_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.

Glossary: rank, ties

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_RANK when rows with equal values should share a position.
  • WIN_DENSE_RANK when you want ranks without gaps after equal values.

Glossary: rank, ties

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_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.

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_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.

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.

OperatorIn plain wordsAnswers questions likeLevelInstead, when…
FEAT_BUCKETIZEPuts 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?basicGROUP_RANGE when you want results grouped by fixed-width bands.
GROUP_QUANTILE when you want results grouped by equal-count bins.
FEAT_DATE_FEATURESSplits 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?basicATTR_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_ENCODEReplaces 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?intermediateGROUP_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_LOGAdds 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?intermediateFEAT_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_HOTTurns 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?basicFEAT_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_POLYAdds 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?advancedREG_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_SQRTAdds 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?intermediateFEAT_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_ENCODEReplaces 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?advancedAGG_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_SPLITLabels 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?intermediatecapability: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_RANGE when you want results grouped by fixed-width bands.
  • GROUP_QUANTILE when 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_PART when you need just one part of the date.
  • GROUP_DATE when 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_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.

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_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.

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_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.

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_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.

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_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.

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_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.

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:sample when you just want a random subset of rows to look at.
  • GROUP_CATEGORY when 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.

OperatorIn plain wordsAnswers questions likeLevelInstead, when…
TEST_ANOVA_FChecks whether the average of a numeric measure differs across three or more groups.Does average spend differ across regions?intermediateTEST_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_RMChecks 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?advancedTEST_ANOVA_F when each group holds different, unrelated subjects.
TEST_PAIRED_T when there are only two conditions.
TEST_ANOVA_WELCHChecks 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?intermediateTEST_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_SPHERICITYChecks 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?advancedMAT_CORRELATION when you want how strongly each pair is related.
MAT_PCA when you want the dimensions themselves.
TEST_BOX_MChecks 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?advancedTEST_BROWN_FORSYTHE when there is only one numeric field.
TEST_MANOVA when you want whether the average profiles differ.
TEST_BROWN_FORSYTHEChecks 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?advancedTEST_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_CHISQChecks whether two categorical fields are associated by comparing a cross-tabulation with the counts expected if unrelated.Is preferred channel associated with age band?intermediateTEST_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_EXACTExact 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?intermediateTEST_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_T2Checks 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?advancedTEST_T when there is only one numeric field.
TEST_WELCH when you want each field compared on its own.
TEST_KENDALL_TAUMeasures 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?intermediateTEST_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_WALLISRank-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?intermediateTEST_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_KSChecks 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?intermediateTEST_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_URank-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?intermediateTEST_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_MANOVAChecks 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?advancedTEST_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_MARDIAChecks 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?advancedTEST_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_TChecks 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?intermediateTEST_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_RMeasures how strongly two numeric fields rise and fall together along a straight line.Do customers who spend more also visit more often?intermediateTEST_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_ZChecks 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?basicTEST_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_WILKChecks whether a numeric field looks normally distributed, overall or within each group.Is response time roughly bell-shaped, or clearly skewed or heavy-tailed?intermediateTEST_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_RMeasures 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?intermediateTEST_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_TChecks 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?intermediateTEST_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_TRENDChecks whether an ordered series, such as monthly totals, tends to keep rising or keep falling.Is monthly churn creeping up?intermediateREG_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_HSDAfter 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?advancedTEST_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_WELCHChecks 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?intermediateTEST_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_SRRank-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?intermediateTEST_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_SAMPLELarge-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?intermediateTEST_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:

Follow up with:

  • TEST_TUKEY_HSD when the groups differ and you want to know which pairs of groups do.
  • OVERLAY_PAIRWISE_WELCH_T when 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:

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_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.

Follow up with:

  • OVERLAY_PAIRWISE_WELCH_T when the groups differ and you want each pair tested without assuming equal spread.
  • TEST_TUKEY_HSD when 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_CORRELATION when you want how strongly each pair is related.
  • MAT_PCA when 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:

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:

Follow up with:

  • TEST_ANOVA_WELCH when 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:

Follow up with:

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_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.

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_T when there is only one numeric field.
  • TEST_WELCH when 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_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.

Follow up with:

  • REG_OLS when 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_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).

Follow up with:

  • TEST_MANN_WHITNEY_U when 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:

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_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.

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:

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_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.

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_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.

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_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.

Follow up with:

  • REG_OLS when 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_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.

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_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.

Follow up with:

  • TEST_MANN_WHITNEY_U when the shape is far from normal and you planned a two-group t-test.
  • TEST_KRUSKAL_WALLIS when 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_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.

Follow up with:

  • REG_OLS when 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_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.

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_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.

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_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.

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_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.

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_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.

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_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.

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.

OperatorIn plain wordsAnswers questions likeLevelInstead, when…
REG_BAYES_LINEARFits 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?advancedREG_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_GLMFits 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?advancedREG_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_OLSFits 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?intermediateREG_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_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.

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_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.

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_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.

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.

OperatorIn plain wordsAnswers questions likeLevelInstead, when…
MAT_COLLINEARITYWhether 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?intermediateREG_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_CORRELATIONHow 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?intermediateTEST_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_COVARIANCEHow 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?intermediateTEST_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_DISTANCEHow 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?advancedTEST_MANOVA when you want to test whether the centres differ.
ATTR_MAHALANOBIS when you want how unusual each row is.
MAT_FACTORWhich 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?advancedMAT_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_CORRELATIONHow 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?advancedMAT_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_PCAHow 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?advancedMAT_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_RELIABILITYHow 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?intermediateMAT_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_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.

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:

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_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.

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:

Follow up with:

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_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.

Follow up with:

  • MAT_RELIABILITY when 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_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.

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_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.

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:

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.

OperatorIn plain wordsAnswers questions likeLevelInstead, when…
OVERLAY_CHISQ_COLChi-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?intermediateOVERLAY_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_MATRIXChi-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?intermediateOVERLAY_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_ROWChi-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?intermediateOVERLAY_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_POPChi-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?intermediateOVERLAY_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_REFChi-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?advancedTEST_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_BASELINEEach 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?basicOVERLAY_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_MARGINEach 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?basicOVERLAY_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_PRIOREach 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?basicOVERLAY_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_REFEach 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?intermediateOVERLAY_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_SIBLINGEach 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?basicOVERLAY_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_STAGEA 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?intermediateOVERLAY_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_CELLFisher’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?advancedOVERLAY_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_FORMULAComputes 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?advancedATTR_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_BASELINEEach 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?basicOVERLAY_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_MARGINIndex 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?basicOVERLAY_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_POPEach 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?basicOVERLAY_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_PRIOREach 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?basicOVERLAY_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_REFIndex 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?intermediateOVERLAY_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_MEANEach 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?intermediateOVERLAY_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_SIBLINGEach 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?basicOVERLAY_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_STAGEIndex 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?intermediateOVERLAY_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_TOTALEach 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?basicOVERLAY_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_POPKolmogorov-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’?advancedOVERLAY_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_TPairwise 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?advancedOVERLAY_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_ZTwo-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?advancedOVERLAY_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_ZLarge-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?advancedOVERLAY_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_ZLarge-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?advancedOVERLAY_PAIRWISE_WELCH_T when the cells are unweighted averages.
AGG_WEIGHTED_MEAN when you only need the weighted averages themselves.
OVERLAY_PAIRWISE_WELCH_TWelch 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?advancedOVERLAY_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_REFIndex 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?intermediateOVERLAY_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_CELLTwo-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?intermediateOVERLAY_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_PANELTwo-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?advancedOVERLAY_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_RANKRanks 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?basicOVERLAY_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_COLEach 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?basicOVERLAY_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_ROWEach 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?basicOVERLAY_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_TOTALEach 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?basicOVERLAY_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_CELLWelch 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?advancedOVERLAY_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_REFWelch 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?advancedOVERLAY_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_YOYEach 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?basicOVERLAY_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_MARGINEach 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?intermediateOVERLAY_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_POPPuts 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?intermediateOVERLAY_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_ROLLINGHow 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?intermediateTEST_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_TOTALHow 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?intermediateTEST_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_CELLLarge-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?advancedOVERLAY_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_REFLarge-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?advancedOVERLAY_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:

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:

Follow up with:

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:

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_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.

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_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.

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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.

OperatorIn plain wordsAnswers questions likeLevelInstead, when…
bernoulliDraws 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?basicweighted_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.
constantPuts 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?basicmonotonic_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.
discreteDraws 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?intermediatenormal 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.
exponentialDraws 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?intermediatepoisson 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.
lognormalDraws 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?intermediatenormal 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.
mixtureDraws 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?advancednormal 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_fromCounts 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?basicconstant 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.
normalDraws 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?basiclognormal 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.
paretoDraws 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?advancedlognormal 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.
poissonDraws 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?intermediateexponential 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.
regexGenerates 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?intermediateweighted_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_bernoulliFills 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?intermediateweighted_categorical when each row picks exactly one answer.
bernoulli when there is only one yes/no flag.
uniformDraws 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?basicnormal 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_dateDraws calendar dates between a start and an end date, every day equally likely, both ends included.How do I spread synthetic orders across 2024?basicuniform when the field is a plain number, not a date.
constant when every row shares one date.
weighted_categoricalPicks 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?basicbernoulli 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:

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:

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:

  • 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.

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:

  • 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.

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:

  • 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.

Glossary: skew, median

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:

  • 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.

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:

  • 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.

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:

  • 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.

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:

  • 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.

Glossary: mean, variance

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:

  • 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.

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:

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:

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:

  • 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.

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:

  • uniform when the field is a plain number, not a date.
  • constant when every row shares one date.

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:

  • 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.

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

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):

ValueLabel
below 0.01very small
from 0.01 to below 0.06small
from 0.06 to below 0.14medium
0.14 and abovelarge

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):

ValueLabel
below 0.01very small
from 0.01 to below 0.06small
from 0.06 to below 0.14medium
0.14 and abovelarge

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):

ValueLabel
below 0.01very small
from 0.01 to below 0.06small
from 0.06 to below 0.14medium
0.14 and abovelarge

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):

ValueLabel
below 0.1very small
from 0.1 to below 0.3small
from 0.3 to below 0.5medium
0.5 and abovelarge

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):

ValueLabel
below 1.44very small
from 1.44 to below 2.48small
from 2.48 to below 4.27medium
4.27 and abovelarge

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 valueLabel
below 0.1very small
from 0.1 to below 0.3small
from 0.3 to below 0.5medium
0.5 and abovelarge

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 valueLabel
below 0.2very small
from 0.2 to below 0.5small
from 0.5 to below 0.8medium
0.8 and abovelarge

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 valueLabel
below 0.2very small
from 0.2 to below 0.5small
from 0.5 to below 0.8medium
0.8 and abovelarge

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 valueLabel
below 0.2very small
from 0.2 to below 0.5small
from 0.5 to below 0.8medium
0.8 and abovelarge

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 valueLabel
below 0.2very small
from 0.2 to below 0.5small
from 0.5 to below 0.8medium
0.8 and abovelarge

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):

ValueLabel
below 0.02very small
from 0.02 to below 0.13small
from 0.13 to below 0.26medium
0.26 and abovelarge

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 valueLabel
below 0.1very small
from 0.1 to below 0.3small
from 0.3 to below 0.5medium
0.5 and abovelarge

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):

ValueLabel
below 0.5unacceptable
from 0.5 to below 0.6miserable
from 0.6 to below 0.7mediocre
from 0.7 to below 0.8middling
from 0.8 to below 0.9meritorious
0.9 and abovemarvelous

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 valueLabel
below 0.1very small
from 0.1 to below 0.3small
from 0.3 to below 0.5medium
0.5 and abovelarge

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):

ValueLabel
below 0.5unacceptable
from 0.5 to below 0.6miserable
from 0.6 to below 0.7mediocre
from 0.7 to below 0.8middling
from 0.8 to below 0.9meritorious
0.9 and abovemarvelous

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):

ValueLabel
below 0.5unacceptable
from 0.5 to below 0.6poor
from 0.6 to below 0.7questionable
from 0.7 to below 0.8acceptable
from 0.8 to below 0.9good
0.9 and aboveexcellent

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):

ValueLabel
below 0.5unacceptable
from 0.5 to below 0.6poor
from 0.6 to below 0.7questionable
from 0.7 to below 0.8acceptable
from 0.8 to below 0.9good
0.9 and aboveexcellent

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

KeyTypeEmittedMeaning
nintalwaysNumber of records aggregated (non-null inputs).
n_nullintalwaysNumber of null inputs encountered.

Groupers

KeyTypeEmittedMeaning
total_nintalwaysTotal records partitioned across all buckets (post-filter).
n_nullintalwaysRecords that landed in the null/skip path (no bucket assignment).

Filterers

KeyTypeEmittedMeaning
n_inintalwaysRecords that entered this filter pass.
n_outintalwaysRecords that passed (entered the downstream stage).
n_null_inputintalwaysRecords where the filter input field was null at evaluation time.

Matrix operators

KeyTypeEmittedMeaning
nintalwaysRows the co-moment counted: listwise the complete rows, pairwise the rows with any member present; weight-0 rows count.
n_nullintalwaysRows skipped for missing members: listwise any member null, pairwise every member null.
n_listwise_droppedintalwaysRows listwise deletion dropped; 0 under pairwise.
min_pair_nintwhen its condition holdsPairwise only: the smallest pair N (the minimum of auxiliary.n).
max_pair_nintwhen its condition holdsPairwise 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.

KeyTypeEmittedMeaning
sum_weightsfloat64when its condition holdsWeighted slots only: the sum of the valid weights of the rows whose value was present.
n_efffloat64when its condition holdsWeighted slots of kind probability only: Kish effective sample size (sum of weights)^2 / sum of squared weights.
n_weight_invalidintwhen its condition holdsWeighted 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.

KeyTypeEmittedMeaning
sumfloat64alwaysRunning sum of non-null field values; combined with n to recover the mean (the weighted sum Σw·x on a weighted slot).
sum_weightedfloat64when its condition holdsWeighted slots only: Σw·x over the contributing rows.
weighted_meanfloat64when its condition holdsWeighted slots only: Σw·x / Σw (the scalar).
m2_weightedfloat64when its condition holdsWeighted slots only: Σw(x − mean)², the weighted second central moment.
sum_weights_sqfloat64when its condition holdsWeighted slots only: Σw², the Kish n_eff denominator.
weighted_variancefloat64when its condition holdsWeighted slots only: m2_weighted / (Σw − 1), the frequency-weights sample variance; 0 when Σw ≤ 1.

AGG_CI_LOWER

Mergeability: mergeable. See its catalog entry.

KeyTypeEmittedMeaning
meanfloat64alwaysWelford-running mean of the field.
stderrfloat64alwaysStandard error of the mean derived from variance and n.
alphafloat64alwaysSignificance level (1 - confidence).
t_criticalfloat64alwaysCritical value used to scale the standard error.
lowerfloat64alwaysResolved lower bound of the confidence interval.

AGG_CI_UPPER

Mergeability: mergeable. See its catalog entry.

KeyTypeEmittedMeaning
meanfloat64alwaysWelford-running mean of the field.
stderrfloat64alwaysStandard error of the mean derived from variance and n.
alphafloat64alwaysSignificance level (1 - confidence).
t_criticalfloat64alwaysCritical value used to scale the standard error.
upperfloat64alwaysResolved 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.

KeyTypeEmittedMeaning
cardinalityintalwaysNumber of distinct non-null values observed.

AGG_DISTINCT_SUM

Mergeability: partial. See its catalog entry.

KeyTypeEmittedMeaning
sumfloat64alwaysSum of the first value observed for each distinct key.
distinct_countintalwaysNumber of distinct keys that contributed to the sum.

AGG_FREQUENCY

Mergeability: mergeable. See its catalog entry.

KeyTypeEmittedMeaning
match_countintalwaysNon-null rows equal to params.value (= the scalar); on a weighted slot their sum of weights (a float).
sharefloat64alwaysmatch_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.

KeyTypeEmittedMeaning
meanfloat64alwaysRunning mean of non-null field values.
m2float64alwaysSecond-moment accumulator (sum of squared deviations from the running mean).
m3float64alwaysThird-moment accumulator (sum of cubed deviations from the running mean).
m4float64alwaysFourth-moment accumulator (sum of fourth-power deviations from the running mean).
kurtosisfloat64alwaysPopulation 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.

KeyTypeEmittedMeaning
loss_sumfloat64alwaysRunning sum of the per-row loss over contributing rows (weighted slot: of weight * loss). Additive across partitions.
weight_sumfloat64alwaysRunning sum of the weights of contributing rows; the contributing-row count on an unweighted slot. Additive across partitions.
n_scoredintalwaysRows that contributed: actual and prediction both answered (and, weighted, the weight was valid and > 0).
n_null_predictionintalwaysRows whose actual answered but whose prediction is null; skipped. The floor’s n / n_null key on the actual (field) only.
maefloat64alwaysResolved MAE: loss_sum / weight_sum (NaN, null in JSON, when no row contributed).

AGG_MAPE

Mergeability: mergeable. See its catalog entry.

KeyTypeEmittedMeaning
loss_sumfloat64alwaysRunning sum of the per-row loss over contributing rows (weighted slot: of weight * loss). Additive across partitions.
weight_sumfloat64alwaysRunning sum of the weights of contributing rows; the contributing-row count on an unweighted slot. Additive across partitions.
n_scoredintalwaysRows that contributed: actual and prediction both answered (and, weighted, the weight was valid and > 0).
n_null_predictionintalwaysRows whose actual answered but whose prediction is null; skipped. The floor’s n / n_null key on the actual (field) only.
n_zero_actualintalwaysRows whose actual and prediction both answered but whose actual is exactly 0: their percentage error is undefined, so they are excluded from the figure.
mapefloat64alwaysResolved MAPE fraction: loss_sum / weight_sum (NaN, null in JSON, when no row contributed).

AGG_MAX

Mergeability: mergeable. See its catalog entry.

KeyTypeEmittedMeaning
maxfloat64alwaysLargest non-null value observed.

AGG_MEDIAN

Mergeability: none. See its catalog entry.

KeyTypeEmittedMeaning
position_lowintalwaysLower 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_highintalwaysUpper 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).
medianfloat64alwaysResolved median value (linear interpolation between the bracketing positions).

AGG_MIN

Mergeability: mergeable. See its catalog entry.

KeyTypeEmittedMeaning
minfloat64alwaysSmallest non-null value observed.

AGG_MODE

Mergeability: partial. See its catalog entry.

KeyTypeEmittedMeaning
valueanyalwaysMost-frequent value observed (smallest-value tie-break).
countintalwaysRow count of the modal value; on a weighted slot its sum of weights (a float).
distinct_countintalwaysNumber of distinct values observed.
tie_countintalwaysNumber of values tied with the mode at the same maximum count.

AGG_MODE_COUNT

Mergeability: partial. See its catalog entry.

KeyTypeEmittedMeaning
distinct_countintalwaysNumber of distinct values observed.
mode_valueanyalwaysMost-frequent value — the largest sum of weights on a weighted slot (ties broken by the smallest value, matching AGG_MODE).
mode_countintalwaysRow 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.

KeyTypeEmittedMeaning
pfloat64alwaysPercentile requested, in [0, 100].
positionintalwaysIndex 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).
lowerfloat64alwaysLower bracketing value used during interpolation.
upperfloat64alwaysUpper bracketing value used during interpolation.
methodstringalwaysInterpolation method (e.g. “linear”).
valuefloat64alwaysResolved percentile value.

AGG_RANGE

Mergeability: mergeable. See its catalog entry.

KeyTypeEmittedMeaning
minfloat64alwaysSmallest non-null value observed.
maxfloat64alwaysLargest non-null value observed.

AGG_RATIO

Mergeability: mergeable. See its catalog entry.

KeyTypeEmittedMeaning
numeratorfloat64alwaysRunning sum of the numerator field (weighted slot: of weight * numerator).
denominatorfloat64alwaysRunning sum of the denominator field (weighted slot: of weight * denominator).
ratiofloat64alwaysResolved ratio: numerator / denominator (NaN, null in JSON, when denominator is zero).

AGG_RMSE

Mergeability: mergeable. See its catalog entry.

KeyTypeEmittedMeaning
loss_sumfloat64alwaysRunning sum of the per-row loss over contributing rows (weighted slot: of weight * loss). Additive across partitions.
weight_sumfloat64alwaysRunning sum of the weights of contributing rows; the contributing-row count on an unweighted slot. Additive across partitions.
n_scoredintalwaysRows that contributed: actual and prediction both answered (and, weighted, the weight was valid and > 0).
n_null_predictionintalwaysRows whose actual answered but whose prediction is null; skipped. The floor’s n / n_null key on the actual (field) only.
msefloat64alwaysMean squared error loss_sum / weight_sum; rmse is its square root (NaN, null in JSON, when no row contributed).
rmsefloat64alwaysResolved RMSE: sqrt(mse).

AGG_SET_CARDINALITY_AVG

Mergeability: mergeable. See its catalog entry.

KeyTypeEmittedMeaning
sum_cardinalityintalwaysSum of popcounts across contributing rows; weighted slot: sum(w * popcount) (a float).
avg_cardinalityfloat64alwaysAverage popcount per contributing row (sum_cardinality / n; weighted slot: / sum of weights).

AGG_SET_CARDINALITY_SUM

Mergeability: mergeable. See its catalog entry.

KeyTypeEmittedMeaning
sum_cardinalityintalwaysSum 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.

KeyTypeEmittedMeaning
mask_union[]uint64alwaysBitwise 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.
popcountintalwaysNumber of bits set in mask_union (count of distinct labels observed).
labels[]stringalwaysResolved dictionary labels for every bit set in mask_union.

AGG_SET_FREQUENCY

Mergeability: partial. See its catalog entry.

KeyTypeEmittedMeaning
total_label_observationsintalwaysSum of popcounts across contributing rows (total label selections seen); weighted slot: the weight-summed selections (a float).
distinct_labelsintalwaysNumber of distinct labels observed at least once (weighted slot: with a positive weight sum).
per_label_countmap[string]intalwaysPer-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.

KeyTypeEmittedMeaning
mask_intersection[]uint64alwaysBitwise 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.
popcountintalwaysNumber of bits set in mask_intersection.
labels[]stringalwaysResolved dictionary labels for every bit set in mask_intersection.

AGG_SET_UNION

Mergeability: mergeable. See its catalog entry.

KeyTypeEmittedMeaning
mask_union[]uint64alwaysBitwise 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.
popcountintalwaysNumber of bits set in mask_union.
labels[]stringalwaysResolved dictionary labels for every bit set in mask_union.

AGG_SKEWNESS

Mergeability: mergeable. See its catalog entry.

KeyTypeEmittedMeaning
meanfloat64alwaysRunning mean of non-null field values.
m2float64alwaysSecond-moment accumulator (sum of squared deviations from the running mean).
m3float64alwaysThird-moment accumulator (sum of cubed deviations from the running mean).
skewnessfloat64alwaysPopulation 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.

KeyTypeEmittedMeaning
meanfloat64alwaysRunning Welford mean of non-null field values.
m2float64alwaysWelford second-moment accumulator (sum of squared deviations from the running mean).
variancefloat64alwaysPopulation variance derived from m2 / n.
stddevfloat64alwaysPopulation standard deviation (square root of variance).

AGG_SUM

Mergeability: mergeable. See its catalog entry.

KeyTypeEmittedMeaning
sumfloat64alwaysRunning sum of non-null field values.

AGG_VARIANCE

Mergeability: mergeable. See its catalog entry.

KeyTypeEmittedMeaning
meanfloat64alwaysRunning Welford mean of non-null field values.
m2float64alwaysWelford second-moment accumulator (sum of squared deviations from the running mean).
variancefloat64alwaysPopulation variance derived from m2 / n.

AGG_WEIGHTED_MEAN

Mergeability: mergeable. See its catalog entry.

KeyTypeEmittedMeaning
sum_weightedfloat64alwaysRunning sum of (field * weight) across contributing rows.
weighted_meanfloat64alwaysResolved weighted mean: sum_weighted / sum_weights.
m2_weightedfloat64alwaysWeighted second central moment: sum(weight * (field - weighted_mean)^2).
sum_weights_sqfloat64alwaysRunning sum of squared weights; feeds the Kish effective sample size.
weighted_variancefloat64alwaysFrequency-weights variance m2_weighted / (sum_weights - 1); 0 when sum_weights <= 1.

AGG_WELFORD

Mergeability: mergeable. See its catalog entry.

KeyTypeEmittedMeaning
meanfloat64alwaysRunning Welford mean of non-null field values.
m2float64alwaysWelford second-moment accumulator (sum of squared deviations from the running mean).
variancefloat64alwaysUnbiased sample variance derived from m2 / (n-1).
stddevfloat64alwaysSample standard deviation (square root of variance).

AGG_ZSCORE

Mergeability: mergeable. See its catalog entry.

KeyTypeEmittedMeaning
pop_meanfloat64alwaysPopulation mean used as the z-score center.
pop_stddevfloat64alwaysPopulation standard deviation used as the z-score scale.
target_valuefloat64alwaysValue being standardized against the population summary.
zscorefloat64alwaysStandardized score: (target_value - pop_mean) / pop_stddev.

Groupers

GROUP_CATEGORY

Mergeability: mergeable. See its catalog entry.

KeyTypeEmittedMeaning
dict_sizeintalwaysNumber of distinct values observed across all buckets (cardinality of the partition key).
buckets[]bucketalwaysPer-bucket records, ordered by emission; each entry carries {key, label, count}.

GROUP_DATE

Mergeability: mergeable. See its catalog entry.

KeyTypeEmittedMeaning
granularitystringalwaysCalendar component used to bucket (hour, day, day_of_week, week, month, quarter, year).
range_startstringalwaysISO date string of the earliest period observed (YYYY-MM-DDTHH for component=hour).
range_endstringalwaysISO date string of the latest period observed (YYYY-MM-DDTHH for component=hour).
n_bucketsintalwaysNumber of distinct calendar buckets observed.
buckets[]bucketalwaysPer-bucket records, ordered by emission; each entry carries {key, period_start, period_end, count}.

GROUP_DATE_RANGES

Mergeability: mergeable. See its catalog entry.

KeyTypeEmittedMeaning
n_rangesintalwaysNumber of configured labeled date ranges.
unmatched_labelstringalwaysBucket label used for rows outside every configured range.
buckets[]bucketalwaysPer-bucket records in supplied range order (unmatched last when observed); each entry carries {key, label, count}.

GROUP_KMEANS

Mergeability: none. See its catalog entry.

KeyTypeEmittedMeaning
kintalwaysCluster count (params.k).
seedintalwaysThe resolved PRNG seed (params.seed 0 resolves to the fixed default).
n_initintalwaysSeeded starts run (params.n_init).
max_iterintalwaysLloyd iteration cap per start (params.max_iter).
standardizeboolalwaysWhether members were z-scored before clustering.
members[]stringalwaysThe resolved member columns, axis order.
center[]float64alwaysPer-member standardization mean (0 when standardize is false).
scale[]float64alwaysPer-member standardization sd (1 when standardize is false or a member has no spread).
distinct_pointsintalwaysDistinct member tuples among the clustered rows.
buckets[]bucketalwaysPer-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_ssfloat64alwaysTotal sum of squares about the grand centroid, in the clustering space.
within_ssfloat64alwaysTotal within-cluster sum of squares.
between_ssfloat64alwaystotal_ss − within_ss.
between_ss_ratiofloat64alwaysbetween_ss / total_ss (null when total_ss is 0).
iterationsintalwaysLloyd iterations the winning start ran.
convergedboolalwaysWhether the winning start reached a fixed point before max_iter.
best_initintalwaysThe winning start, 1-based.

GROUP_QUANTILE

Mergeability: none. See its catalog entry.

KeyTypeEmittedMeaning
n_quantilesintalwaysNumber of equal-population quantile buckets requested (Group.Interval).
methodstringalwaysQuantile interpolation method (e.g. “linear”).
edges[]float64alwaysSorted cutpoints separating adjacent quantile buckets.
buckets[]bucketalwaysPer-bucket records, ordered by emission; each entry carries {key, low, high, count}.

GROUP_RANGE

Mergeability: mergeable. See its catalog entry.

KeyTypeEmittedMeaning
intervalfloat64alwaysBucket width on the value axis (Group.Interval).
range_minfloat64alwaysSmallest non-null value observed across all buckets.
range_maxfloat64alwaysLargest non-null value observed across all buckets.
n_bucketsintalwaysNumber of half-open range buckets emitted.
edges[]float64alwaysSorted cutpoints separating adjacent range buckets.
buckets[]bucketalwaysPer-bucket records, ordered by emission; each entry carries {key, low, high, count}.
underflow_countintalwaysRecords below the lowest configured bucket edge.
overflow_countintalwaysRecords at or above the highest configured bucket edge.

GROUP_ROUNDED

Mergeability: mergeable. See its catalog entry.

KeyTypeEmittedMeaning
precisionfloat64alwaysRounding increment used as the bucket scalar (Group.Interval).
edges[]float64alwaysSorted rounded scalars separating adjacent buckets.
buckets[]bucketalwaysPer-bucket records, ordered by emission; each entry carries {key, low, high, count}.

GROUP_SET_PER_ELEMENT

Mergeability: mergeable. See its catalog entry.

KeyTypeEmittedMeaning
total_label_observationsintalwaysSum of buckets[].count across the partition; may exceed total_n because each row fans into one bucket per selected label.
buckets[]bucketalwaysPer-label records, ordered by emission; each entry carries {key, label, count, dict_index}.

GROUP_SET_VALUE

Mergeability: mergeable. See its catalog entry.

KeyTypeEmittedMeaning
n_empty_maskintalwaysRecords whose set mask was the empty selection (zero-bit mask is a valid distinct bucket from null).
buckets[]bucketalwaysPer-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.

KeyTypeEmittedMeaning
ddofintalwaysThe delta degrees of freedom the covariance used (params.ddof, default 1).

MAT_DISTANCE

Mergeability: mergeable. See its catalog entry.

KeyTypeEmittedMeaning
metricstringalwaysThe metric the distances use (params.metric).
standardizeboolalwaysWhether the members were z-scored first (params.standardize).
bucketsintalwaysBuckets compared (k).
n_null_keyintalwaysFilter-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.

KeyTypeEmittedMeaning
iterationsintwhen its condition holdsCoordinate 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).
convergedboolwhen its condition holdsWhether 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

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

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 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

See also: mean, baseline

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

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 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 — 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.
  • 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 response fits map, 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 gate
  • derived-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 compose fits entry or a loaded models file) — 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 row periods positions 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 row periods positions 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 cell
  • response-shaping: 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.
  • 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 order resolveConflicts applies once per spec, how rule / model / shape pre-claims compose, and where the dropped-relationship warnings surface.
  • synth-correlations: 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.
  • synth-determinism: 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).
  • synth-fidelity-report: 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.
  • synth-marginals: 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.
  • 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’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.
  • synth-model-selection: 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.
  • 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: 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.
  • 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’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.
  • 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-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.
  • synth-rule-expressions: 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.
  • synth-rule-nulls: 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.
  • synth-rule-validation: 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.
  • synth-rules-from-profile: 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.
  • synth-set-fields: 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.
  • 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 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.
  • synthetic-data: 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.
  • 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

SlotTypeWhenOutput
filterers[]types.FiltererBefore grouping + aggregationdrops rows; counters → Components.Filterers[i]
aggregations[]types.AggregationAfter groupingscalar (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 same MergeOnline path as the scalar; streaming chunks carry ComponentsDelta.
  • 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 surfaces BufferedComponents=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 field with type omitted 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 (filterers runs before attributes). 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

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

  1. Labels must be unique — never a schema field, feature output or earlier label. Shadowing → SERVICE_VALIDATION, runtime and predict.
  2. No in-slot chaining. A formula cannot reference another attribute’s label. To stage a derived value into another formula, use Compose / ProcessChain.
  3. 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.
  4. Row-local attributes (formula, calendar part, set membership / popcount) stream — one pass, no state.
  5. Null inputs propagate. Null source → null output for most attributes. A formula is stricter — arithmetic on a null field raises PROCESSING_RUNTIME unless guarded by ??.
  6. Output is coerced to a scalar. Numbers pass through; bool → 1.0 / 0.0; a formula’s nil → null; anything else errors. Attributes do not emit Rich payloads.

ATTR vs FEAT

Both add columns. Split:

Aspectattributes (ATTR_*)features (FEAT_*)
PositionAfter filterers, before groupsBefore filterers
Sees post-filter rowsyesno
Output columnlabel (rebindable)fixed per FEAT
Use forper-record scoring, formulas, regression diagnosticsencoding (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 Mergeable paths and across-shard union for buffered paths. See cohort-schema-design.
  • Attribute output is f64 by default; declare decimal128 source explicitly if precision matters — see financial-cohorts.

See

  • Per-ATTR recipes: pulse_examples_search tags=["feature-engineering"], tags=["regression"], tags=["outlier-detection"] plus atomic op-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 → byte i/8, bit i%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:

StateFlat text (csv / tsv / excel)JSON (ndjson / jsonarray)arrow / parquet
null"" (any null token)nullvalidity bit clear
empty mask| — a bare delimiter, no token"|" on write, [] also accepted on readzero-length LIST<UTF8>
selectionA|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

QuestionSkill
Null bitmap; null promotion on import; null vs empty vs selected in set_* / categorical cells per formatcohort-null-states
A value outgrew its width — promote or refuse?cohort-width-overflow
Storing a repeated parent block once (0x02), --group, dedup, constant elisioncohort-parent-groups
Shard archive cohesion, auto-widen, anchors, memory, concurrency, moving a cohortcohort-sharding
The point-lookup sidecar index: format, staleness, discovery, keyable typescohort-sidecar-index
A cohort imported from .sav / .zsavspss-cohorts
One type’s bytes, range, null and dictionary rulesatomic type-<kebab>

Field-type matrix (all 20)

NameBytesDict?Bit-packed?Notes
u40noyes0..15
u81nono0..255
u162nono0..65,535
u324nono0..~4.29B
u648nono
f324nono~7 sig digits; index key = raw bit pattern (-0.0/NaN caveat)
f648nono~15 sig digits; same bit-pattern caveat
date4nonosigned int32 epoch days (pre-1970 negative)
datetime8nonoepoch seconds, naive UTC; index-key literal parses as a datetime, never a float
packed_bool0noyes1 bit
categorical_u81inline, ≤256nodict-encoded; index key = dictionary ID
categorical_u162inline, ≤65,536no
categorical_u324inline, ≤~4.29Bno
decimal12816nonoper-field (precision, scale); index key = exact mantissa, no float round-trip
set_u81shared, ≤8 labelsnomulti-select bitmask; not index-keyable
set_u162shared, ≤16no
set_u324shared, ≤32no
set_u648shared, ≤64no
set_u12816shared, ≤128no
set_u25632shared, ≤256nowidest 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.

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 the set_* 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_DIVERGENCE when 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 a set_u128 shard legitimately meets a set_u64 archive. Either way the field is promoted to the narrowest rung that holds every member, across _schema.pulse and 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.CreateShardArchive auto-widens on exactly the same conditions as shard 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. CreateShardArchive returns a *CreateShardArchiveResult rather than a bare error for the same reason AddShard does — so the warning has nowhere to be dropped.
  • The whole-archive rewrite is atomic (temp + fsync + rename) and emits a mandatory PULSE_SHARD_SET_WIDENED warning 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_widened tells the two directions apart (from != to ⇒ the archive moved; equal ⇒ only the arriving shard did). Past set_u256 there is nowhere to widen to and PULSE_SHARD_DICT_WIDTH_OVERFLOW stands. Categorical widths are unaffected; they stay fixed at folder creation.
  • pulse shard verify reports set-width headroom per set field (entries used, capacity, headroom, next rung) so an impending widen is foreseeable rather than a surprise the next shard add bills for.
  • Grouped (0x02) shards. An archive has ONE parent-group layout — part of strict cohesion, so shard verify refuses a 0x01 shard in a 0x02 archive. 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 mandatory PULSE_SHARD_GROUPS_REWRITTEN (details.reason, archive_rewritten); shard verify adds group_index_headroom (a constant group always shows 0). To keep appends cheap, import every period with the same --group flags and don’t elide a column that varies between periods. pulse dedup refuses archives — dedup each shard, then shard 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 rung ConvertJob.Run refuses outright — it stops at the offending cell (row / column / type / max_entries / value in details) and writes no --keep-pulse intermediate.
  • PULSE_IMPORT_SET_OVERFLOW — set_* cardinality exceeded width.
  • PULSE_SHARD_DICT_WIDTH_OVERFLOW — shard insert would expand union dict past declared width. For a set_* field this now fires only past set_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

  • request is an ordinary request holding exactly ONE regression and no groups or crosstab — 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 return is 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

  • name is required and unique. Fits share ONE name namespace; a duplicate is PULSE_MODEL_NAME_COLLISION, and a reference to a name nothing binds is PULSE_MODEL_UNKNOWN (details.available lists 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 predictors is the reference level, never inferred.
  • A slot scores a fit per row with ATTR_REG_PREDICT (params: {fit: "<name>"}, optional terms / scale / bound): no refit, full precision, null on a null predictor; see op-attr-reg-predict.
  • A slot’s formula or filter expression reads a fit’s figure with coef("<name>", "<term>"[, "point|lower|upper|se"]); see expression-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_INVALID with details.reason: name_missing, request_missing, regression_count (with count), 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 gains details.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 name plus a non-empty list of scalar values (string, number, boolean). Numbers keep the digits as written.
  • mode grid (default) is the Cartesian product, first axis slowest: 2 x 3 values => 6 slots. zip walks 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 $when guard may name an axis. {{ inside an object key is refused.
  • Every axis must be referenced by the body, an authored label pattern or the overlays; a placeholder naming no axis is refused. Both ride PULSE_SWEEP_INVALID with details.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

  • requests and sweep combine: explicit slots first (indices unchanged), sweep slots after. A request with only a sweep is valid.
  • MaxComposeSlots counts the EXPANDED total (explicit + sweep); an over-limit batch is PULSE_LIMIT_EXCEEDED before anything runs, serial and parallel alike.
  • pulse_predict with composed (and PredictCompose) expands too: same limit, label and slot checks as runtime, plus a sweep summary {axes[{name,count}], mode, expanded_count, labels}. Predict a sweep before running it.
  • --echo-request / EchoRequest echoes the expanded slots the service ran. MCP fills a missing return in the sweep body with the standard default, 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.reason syntax, missing, not_number, ambiguous) when by resolves in no slot, hits a non-number or is ambiguous; an empty segment is syntax.
  • A null (NaN) value, or a path missing in this slot only, EXCLUDES that slot with a PULSE_SWEEP_RANK_PATH warning on its own response.
  • omitempty figures 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 return excludes it. ranking sits outside the compose return: always emitted, never rounded.
  • --stream ends 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) rides margin_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 first L+1 groupers sum to 1. Rejections: _OUT_OF_RANGE, _WITHOUT_NESTED_AXIS, _INCOMPATIBLE (with total).
  • normalize_within: W — fixes a prefix of the opposite axis in the denominator; composes with normalize_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 every margin_aggregations entry 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

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, else TYPE_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_UNOBSERVED warning — 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 include list 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:

KeyIndexed 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.
  • present is load-bearing, not a zero value. A slot admitting no record carries present: false and NO value key — an aggregator over an empty set has no defined output, and a fabricated 0 would be indistinguishable from a real one. Its components still carry the floor, whose n = 0 is 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 sort order, keyed on source fields or derived columns with pulse_process’s comparator. sort moves rows, never values: each window already ran under its own order_by. Features see every record; attributes and windows the survivors — exactly as pulse_process computes them.
  • Columns: the source fields verbatim (types, nullability, dictionaries, descriptions), then one nullable f64 per 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 says ignores_frame) and the source cohort; an operator flagged reads_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) is PULSE_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.

CodeCauseFix
PULSE_MATERIALIZE_SLOT_UNSUPPORTEDa 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_DERIVEDno feature, attribute or windownothing to write: run the request directly
PULSE_MATERIALIZE_OUTPUT_UNDECLAREDan embedder-registered feature, whose outputs cannot be named before it runsapply it in the step-2 request instead
SERVICE_VALIDATIONout existspick 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 typeBinds asWrite
numeric (u*, f*)numberweight_kg / (height_m ** 2)
categorical_*dictionary STRING, never the indexbrand == "Apple", region in ["NA", "EU"]
set_*sorted []string of member labels"a" in tags, has_any(tags, "a", "b")
decimal128Decimal128—

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-only contains, 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. contains is a string operator keyword, never a set helper — test membership with "x" in tags.
  • % on a field is float math.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.

FunctionResult
vsum vmean vmin vmax norm (L2)float
vcountint count of valid elements
vsdsample SD (ddof 1); < 2 valid → NaN
dot dist cosine (vector pair)pairwise-complete; zero norm → cosine NaN; unequal lengths → PROCESSING_VECTOR_LENGTH_MISMATCH
argmax argmin0-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 nil raises PROCESSING_RUNTIME; a nil result is a null value.
  • Filter — == is false, != true; > / + / len on nil make 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_SITE in a filter or overlay formula). One argument, no nesting; unweighted; nulls skipped. Makes the formula two-pass (one extra scan). Not overridable by ExprFunctions.
  • 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:

KindShapeStreamHost armComputes
OVERLAY_INDEX_VS_POPper-value seriesyesdiscrete or numeric(subset_freq / pop_freq) * 100 per value/bin
OVERLAY_ZSCORE_VS_POPper-value seriesyesdiscrete or numeric(subset_freq - pop_mean) / pop_sd
OVERLAY_CHISQ_VS_POPscalarbuffereddiscrete onlyχ² goodness-of-fit + df + p-value
OVERLAY_KS_VS_POPscalarbufferednumeric onlyKS 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?” ⇒ FacetSchema with additive_fields.
  • Counts or statistics split by a COMBINATION of keys ⇒ Process with groups + aggregations (grouper-design). FacetSchema is not a grouper — cross-tabs belong in Process.

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

  1. Order matters. Later features can reference earlier features’ labels.
  2. Labels unique within the slot. Collision → PROCESSING_CONFIG.
  3. 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.
  4. 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_CONFIG and a source type outside the manifest accepts_types as SERVICE_VALIDATION — check it before using a decimal128 or categorical source (financial-cohorts).
  • Polynomial expansion overflows unstandardised — Degree=10 on |x|=100 yields 1e20. 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 atomic op-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_RISK recovery 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).

TypeUse 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)

OpResult precisionResult scale
(p1,s1) + (p2,s2)max(p1-s1, p2-s2) + max(s1,s2) + 1max(s1, s2)
(p1,s1) - (p2,s2)same as +same as +
(p1,s1) * (p2,s2)p1 + p2s1 + s2
(p1,s1) / (p2,s2)p1 + s2 + 1max(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:

KindResult
sumdecimal128 (overflow → error)
min / maxdecimal128
meandecimal128; f64 fallback + PULSE_PRECISION_LOSS warning on overflow
variancedecimal128 at 2 * mean_scale (f64 fallback)
standard deviationdecimal128 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_SCALE configuration on division (locked at 4).
  • Decimal-aware median / percentile / mode.
  • cohort-schema-design — schema layout, null bitmap, shards.
  • aggregation-design — aggregator slot semantics; per-op decimal support in op-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

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 typeDefault grouper
numeric (u4/u*, f32/f64, decimal128)GROUP_RANGE (Interval=10)
categorical_*, packed_boolGROUP_CATEGORY
date, datetimeGROUP_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_n is 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 increments n_null.
  • Weekday components emit names that lex-sort, not Sun→Sat — sort explicitly.
  • Band groupers reject categorical fields at construction.

See

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; zip walks equal-length axes in lockstep.
  • {{axis}} splices text inside any string — label, field, a formula expression. {"$var": "axis"} splices a whole value type-preserving, but only inside params (opaque JSON); a typed key such as order_by cannot 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_INVALID with details.field (e.g. windows[0].for_each.axes[1]) and details.reason: the sweep’s structural reasons, axis_unreferenced, placeholder_unknown, placeholder_invalid, item_decode.
  • A for_each inside a compose sweep body 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 is PULSE_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 a for_each item’s own {{axis}} and {"$var": "axis"} untouched for the expansion — no escape needed. Template variables still substitute beside them and inside for_each itself, so "values": {"$var": "decays"} takes an axis list from a list variable. 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_each spell its field, type, partition_by / order_by keys 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 → value map used inside an expression — an embedder LookupTables entry 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.

FieldMeaning
rightRight-side cohort: single-file .pulse, shard archive, or archive.pulse#shard.pulse anchor.
kind"inner" (empty = "inner"). "left", "outer", "anti" are reserved.
onEqui-join key pairs (OnPair[]), AND-ed for composite keys.
asOptional 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 always right (no automatic swap). A right side over the instance’s max_join_build_rows limit — or a join whose memory estimate passes a set max_estimated_memory — is refused PULSE_LIMIT_EXCEEDED before any record decodes; pulse_predict reports it as a certain limit_findings entry. pulse_inspect reports each side’s record_count without reading records.
  • Crosstab honours the join. Request.Crosstab crosstabs the JOINED rows — axes and cell may name as-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

SurfaceReplaceAugment
Sample (SampleWithRequest)row value → labelrow + <field>_label
Facet (FacetSchema)FacetValueCount.Value → labelsibling FacetField
Process group keysgroup key → labelsibling column in each output row
Export / Convertcolumn value → labelextra 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:

CodeMeaning
PULSE_LABEL_FIELD_UNKNOWNBinding references a field not in the schema.
PULSE_LABEL_FIELD_NOT_CATEGORICALBound field is not categorical_u8/u16/u32.
PULSE_LABEL_TABLE_UNKNOWNTable not registered.
PULSE_LABEL_FIELD_COLLISIONAugment sibling collides with an existing field or agg label.
PULSE_LABEL_DUPLICATE_BINDINGTwo 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; write country = "US".
  • They don’t sort. Sort keys see raw values.
  • They don’t persist. The .pulse file is unchanged; reopening with a different LabelTables produces 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 — registering LabelTables on Options.Extensions.
  • skills/facet-design.md — surfacing labels on FacetField values.
  • skills/tool-label-tables.md / skills/tool-label-resolve.md — pulse_label_tables / pulse_label_resolve schemas.
  • 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 fields entry keeps the caller’s position; a glob (q*) expands in place in schema order; pattern is an unanchored regexp in schema order.
  • Members: integer and float types. packed_bool only with coerce: "binary". Categorical, set_*, date, datetime, decimal128 are refused (PULSE_VECTOR_MEMBER_TYPE).
  • labels: one per member, else PULSE_VECTOR_LABELS_MISMATCH. A vector no slot or expression uses warns PULSE_VECTOR_UNREFERENCED.
  • pulse predict echoes resolved_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.ddof 0 or 1 (default 1).
  • MAT_CORRELATION — Pearson r, or params.method spearman / kendall (frequency weights only); no p-values; params.summary.top_pairs: k lists the k strongest pairs.
  • MAT_PARTIAL_CORRELATION — partial r, params.control "all" or fields held fixed; non-PSD input fatal unless params.repair: "nearest" (a repair stopped at its iteration cap also warns PULSE_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.components k / kaiser / {variance}.
  • MAT_RELIABILITY — alpha, omega, item diagnostics; params.reverse + scale_min / scale_max flip 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) warns PULSE_MATRIX_LISTWISE_HEAVY_DROP when more than that share of rows was dropped.
  • PULSE_MATRIX_INSUFFICIENT_N (under two rows or no weight mass) and PULSE_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 toward n but 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:

KeyMeaning
bucket_basiswhere the bucket count comes from: ungrouped, dictionary, boolean, include, quantile_bins, unknown
estimated_bucketsupper bound on buckets (omitted when unknown)
estimated_cellsbuckets × p²
estimated_bytesmerge 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

  • matrices with joins, or on a chain stage after the first: PULSE_MATRIX_UNSUPPORTED_SOURCE.
  • matrices with crosstab: PULSE_MATRIX_HOST_CONFLICT.
  • Matrices are not streamed row by row: they arrive at the final flush. ProcessStream rows 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; by is valid under any dependence). Same results as R p.adjust.
  • family — which p-values are corrected together (below).
  • alpha — overlay significance level in (0, 1), default 0.05. A test reads its own alpha, so alpha on 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

FamilyPoolsOffered on
layerone overlay layeroverlays (default)
row / columnone row / column of one layer’s matrixoverlays whose payload is a MATRIX
requestthe request’s tests, post-tests and request-family overlaysrequest tests (default), request overlays
composeevery compose member over all slots and Compose-host layersinside 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 own alpha) and multiplicity {method, family, alpha, m}; m is the number of defined p-values corrected together.
  • Overlay summary: p_adjusted / significant_adjusted beside p_value (or the statistic the t / z-versus-reference kinds carry their p in).
  • Overlay matrix payload: parallel payload.p_adjusted / payload.significant_adjusted grids on identical headers and coordinates; a panel cell’s vector maps element for element.
  • Overlay layer: a multiplicity echo, present only when a correction ran.
  • An undefined p (NaN) gives p_adjusted: null, no significant_adjusted, and is left out of m.

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, compose pools all. --stream emits 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_HSD is already family-wise: an explicit non-none block 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: uncorrected at or above threshold (10) with no block anywhere raises the PULSE_ADVISORY_MANY_TESTS advisory (predict-advisories). basis is exact, dictionary (assumes every dictionary entry becomes a bucket, so it may over- or under-count) or lower_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

QuestionOperator
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_share warns when many do.
  • pairwise: each cell uses its own rows (auxiliary.n has 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 warns PULSE_MATRIX_NOT_CONVERGED. Reliability keeps alpha and nulls omega instead of refusing.
  • A singular matrix is refused as PULSE_MATRIX_SINGULAR with rank, condition_number and dependent_fields: drop or combine the named fields, do not repair.
  • Pairwise inference sizes use a conservative N* (the smallest pair); PULSE_MATRIX_PAIRWISE_N_STAR says 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].n and the per-pair counts before trusting a cell.
  • A null cell is undefined (constant member, thin bucket), never 0.
  • Grouped requests return one matrix per bucket; compare them as separate answers.

See

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

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_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.

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

ParamAccepted field types
Fieldnumeric: 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:

KeyTypeNotes
sumfloat64Running sum; mean = sum / n
  • Mergeability: Mergeable
  • Streaming: per-chunk emits running mean via (sum, n)

Gotchas

  • Null inputs skipped — mean is over n non-null, not the cohort.
  • decimal128 under any weight in force (default included) → PULSE_WEIGHT_UNSUPPORTED; "weight": null opts out.

See

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_UPPER when you want the upper end of the same interval.

Params

NameTypeDefaultDescription
confidencefloat0.95Confidence level in (0, 1).
methodstring"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

ParamAccepted field types
Fieldnumeric (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:

KeyTypeNotes
meanfloat64Welford running mean
stderrfloat64Standard error of the mean
alphafloat641 - confidence
t_criticalfloat64Normal critical z (qnorm(1 − α/2))
lowerfloat64Resolved 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 returns PROCESSING_CONFIG until the buffered follow-up lands.

See

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_LOWER when you want the lower end of the same interval.

Params

NameTypeDefaultDescription
confidencefloat0.95Confidence level in (0, 1).
methodstring"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

ParamAccepted field types
Fieldnumeric (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:

KeyTypeNotes
meanfloat64Welford running mean
stderrfloat64Standard error of the mean
alphafloat641 - confidence
t_criticalfloat64Normal critical z (qnorm(1 − α/2))
upperfloat64Resolved 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 returns PROCESSING_CONFIG until the buffered follow-up lands.

See

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_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.

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

ParamAccepted field types
Fieldany 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 Type never infers AGG_COUNT — name it explicitly.
  • decimal128 under any weight in force (default included) → PULSE_WEIGHT_UNSUPPORTED; "weight": null opts out.

See

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_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.

Params

None.

Inputs

ParamAccepted field types
Fieldany 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:

KeyTypeNotes
cardinalityintDistinct 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 with PROCESSING_CONFIG.

See

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_SUM when 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

ParamAccepted field types
Fieldnumeric, no decimal128 (incl. u4, date, datetime, packed_bool)
distinct_byany numeric-coded field, categorical_* included

Output

Scalar float64 — one value per key, summed.

Components

Universal floor {n, n_null} plus:

KeyTypeNotes
sumfloat64Sum of the first value seen per key
distinct_countintDistinct keys that contributed
  • Partial merge (per-key map union); MarginIndependent margins — 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

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_COUNT when you want how many rows share the field’s most common value, whatever it is.
  • FILTER_INCLUDE when you want rows holding any of several values: keep them, then count rows.

Params

NameTypeDefaultDescription
valuestring(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

ParamAccepted field types
Fieldany 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:

KeyTypeNotes
match_countintRows equal to value (= scalar; weighted: Σw)
sharefloat64match_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 with PROCESSING_CONFIG.

See

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_SKEWNESS when 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

ParamAccepted field types
Fieldnumeric (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:

KeyTypeNotes
meanfloat64Running mean
m2float64Second-moment accumulator
m3float64Third-moment accumulator
m4float64Fourth-moment accumulator
kurtosisfloat64Derived from m2, m4, n
  • Mergeability: Mergeable (Chan moments combine)
  • Streaming: per-chunk online moments

Gotchas

  • n <= 1 or zero variance → 0 (not NaN): check n before 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).
  • decimal128 rejected.

See

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_RMSE when 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

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_MAE when 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_MAE there.

See

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_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.

Params

None.

Inputs

ParamAccepted field types
Fieldnumeric: 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:

KeyTypeNotes
maxfloat64Largest non-null value observed
  • Mergeability: Mergeable
  • Streaming: per-chunk emits running max

Gotchas

  • Null rows skipped; n_null counts them.
  • All-null cohort → emits NaN.

See

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_PERCENTILE when you want a value other than the middle, such as the 90th percentile.
  • AGG_MODE when 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

ParamAccepted field types
Fieldnumeric (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:

KeyTypeNotes
position_lowintLower bracket index (weighted: ⌊h⌋, expanded)
position_highintUpper bracket index (weighted: ⌈h⌉)
medianfloat64Resolved 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

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_MAX when you want the largest value.
  • AGG_PERCENTILE when one extreme row would mislead and you want a low value that ignores it.

Params

None.

Inputs

ParamAccepted field types
Fieldnumeric: 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:

KeyTypeNotes
minfloat64Smallest non-null value observed
  • Mergeability: Mergeable
  • Streaming: per-chunk emits running min

Gotchas

  • Null rows skipped; n_null counts them.
  • All-null cohort → emits NaN (sentinel for “no data”).
  • Decimal128 preserved on input; float64 output coerces the comparison space.

See

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_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.

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

ParamAccepted field types
Fieldany 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:

KeyTypeNotes
valueanyMost-frequent value
countintRow count of the modal value (weighted: Σw)
distinct_countintDistinct values observed
tie_countintValues 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 > 1 flags a tie.
  • High cardinality costs memory; pre-filter.
  • set_* → PROCESSING_CONFIG (no modal scalar).

See

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_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.

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

ParamAccepted field types
Fieldany 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:

KeyTypeNotes
distinct_countintNumber of distinct values
mode_valueanyModal value (smallest wins a tie)
mode_countintRow 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

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_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.

Params

None.

Inputs

ParamAccepted field types
Fieldany 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

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_MEDIAN when you want the 50th percentile.
  • ATTR_PERCENTILE when you want each row’s own percentile position.

Params

NameTypeDefaultDescription
percentilefloat(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

ParamAccepted field types
Fieldnumeric (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:

KeyTypeNotes
pfloat64Requested percentile
positionintIndex in sorted set (weighted: ⌊h⌋, expanded)
lowerfloat64Lower bracket value
upperfloat64Upper bracket value
methodstringInterpolation method (e.g. "linear")
valuefloat64Resolved percentile
  • Mergeability: None — exact percentile needs full sort
  • Streaming: NOT streamable

Gotchas

  • Buffered full-input path; cohort-sized memory peak.
  • Out-of-range percentile rejected.

See

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_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.

Params

None.

Inputs

ParamAccepted field types
Fieldnumeric (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:

KeyTypeNotes
minfloat64Smallest non-null value
maxfloat64Largest non-null value
  • Mergeability: Mergeable
  • Streaming: per-chunk tracks (min, max)

Gotchas

  • Outlier-sensitive — one extreme blows the spread.
  • All-null cohort → NaN.

See

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_FORMULA when 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; n counts the rest.
  • weight must name a cohort field, not a label.

See

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_MAE when 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_MAE on the same rows.

See

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_SUM when you want the total number of selections.
  • AGG_SET_FREQUENCY when you want which options were chosen.

Params

None. Weight-aware ("weight": null opts out): Σw·popcount / Σw.

Inputs

ParamAccepted field types
Fieldany 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:

KeyTypeNotes
sum_cardinalityintSum of popcounts
avg_cardinalityfloat64Avg per row
  • Mergeability: Mergeable
  • Margin: mean-reducible — needs per-cell n to 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

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_AVG when you want the number per row.
  • AGG_SET_FREQUENCY when you want the count for each option.

Params

None. Weight-aware ("weight": null opts out): Σw·popcount (a float).

Inputs

ParamAccepted field types
Fieldany 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:

KeyTypeNotes
sum_cardinalityintSum of popcounts
  • Mergeability: Mergeable; margin-summable
  • Streaming: per-chunk popcount sum

Gotchas

  • Counts label selections, not rows — sum_cardinality ≥ n for any non-empty masks.
  • Empty masks contribute 0 (and count toward n).

See

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_FREQUENCY when you want the count of each single option.
  • AGG_SET_UNION when you want every option chosen by anyone.

Params

None.

Inputs

ParamAccepted field types
Fieldany 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:

KeyTypeNotes
mask_union[]uint64Bitwise OR of every row’s mask; 4 little-endian words, words[0] = bits 0-63
popcountintDistinct labels observed
labels[]stringResolved dictionary labels
  • Mergeability: Mergeable via 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

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_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.

Params

None. Weight-aware ("weight": null opts out): Σw per member — map values become floats.

Inputs

ParamAccepted field types
Fieldany 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:

KeyTypeNotes
total_label_observationsintSum of popcounts (label selections)
distinct_labelsintDistinct labels seen ≥ once
per_label_countmap[string]intLabel → 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_observations can exceed n.

See

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_UNION when you want every option chosen by anyone.
  • AGG_SET_FREQUENCY when you want how many rows chose each option.

Params

None.

Inputs

ParamAccepted field types
Fieldany 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:

KeyTypeNotes
mask_intersection[]uint64Bitwise AND across contributing rows; 4 little-endian words, words[0] = bits 0-63
popcountintBits set in mask_intersection
labels[]stringResolved dictionary labels
  • Mergeability: Mergeable per-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

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_INTERSECTION when you want the options every row chose.
  • AGG_SET_FREQUENCY when you want how many rows chose each option.

Params

None.

Inputs

ParamAccepted field types
Fieldany 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:

KeyTypeNotes
mask_union[]uint64Bitwise OR across contributing rows; 4 little-endian words, words[0] = bits 0-63
popcountintBits set in mask_union
labels[]stringResolved 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

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_KURTOSIS when 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

ParamAccepted field types
Fieldnumeric (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:

KeyTypeNotes
meanfloat64Running mean
m2float64Squared-deviation accumulator
m3float64Cubed-deviation accumulator
skewnessfloat64Derived from m2, m3, n
  • Mergeability: Mergeable (Chan moments combine)
  • Streaming: per-chunk online moments

Gotchas

  • n <= 1 or zero variance → 0 (not NaN): check n before reading a 0.
  • Small-n bias: G1 = g1·√(n(n−1))/(n−2), so |g1| < |G1| on small groups.
  • decimal128 rejected — cast via ATTR_FORMULA.
  • Sensitive to outliers; pre-filter or use rank-based alternatives.

See

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

ParamAccepted field types
Fieldnumeric: 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:

KeyTypeNotes
meanfloat64Welford running mean
m2float64Sum of squared deviations
variancefloat64m2 / n
stddevfloat64√variance
  • Mergeability: Mergeable (Chan parallel-merge)
  • Streaming: per-chunk; merge via Chan combine

Gotchas

  • Population stddev (n denominator), not sample (n-1).
  • decimal128 is supported, but not by Welford: a decimal two-pass (mean, then Σ(x−μ)², then decimal Sqrt). An overflowing intermediate drops the WHOLE aggregate to an f64 pass and warns PULSE_DECIMAL_PRECISION_LOSS. The decimal claim therefore rests partly on an f64 fallback.
  • Single-row group → 0.
  • decimal128 under any weight in force (default included) → PULSE_WEIGHT_UNSUPPORTED; "weight": null opts out.

See

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_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.
  • WIN_RUNNING_SUM when 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

ParamAccepted field types
Fieldnumeric: 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:

KeyTypeNotes
sumfloat64Running sum of non-null values
  • Mergeability: Mergeable
  • Streaming: per-chunk; orchestrator sums

Gotchas

  • Smart default for numeric fields when Type omitted.
  • Set-typed fields (set_*) NOT supported.
  • Overflow on huge u64 sums silently promotes through float64.
  • decimal128 under any weight in force (default included) → PULSE_WEIGHT_UNSUPPORTED; "weight": null opts out.

See

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_STDDEV when 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

ParamAccepted field types
Fieldnumeric: 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:

KeyTypeNotes
meanfloat64Welford running mean
m2float64Sum of squared deviations
variancefloat64m2 / n
  • Mergeability: Mergeable (Chan parallel-merge)
  • Streaming: per-chunk Welford

Gotchas

  • Population variance (n), not sample (n-1).
  • decimal128 is supported, but not by Welford: a decimal two-pass (mean, then Σ(x−μ)²). An overflowing intermediate drops the WHOLE aggregate to an f64 pass and warns PULSE_DECIMAL_PRECISION_LOSS. The decimal claim therefore rests partly on an f64 fallback.
  • Single-row group → 0.
  • decimal128 under any weight in force (default included) → PULSE_WEIGHT_UNSUPPORTED; "weight": null opts out.

See

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_AVERAGE when every row should count equally.
  • AGG_RATIO when you want one total divided by another.

Params

NameTypeDefaultDescription
weight_fieldstring(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

ParamAccepted field types
Fieldnumeric except decimal128 (incl. date, datetime, packed_bool, u4)
weight_fieldnumeric, 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):

KeyNotes
sum_weightedΣ field·w
sum_weightsΣw
weighted_meanΣ field·w / Σw
m2_weightedΣw(x − mean)²
sum_weights_sqΣw²
weighted_variancem2/(Σw−1); 0 if Σw ≤ 1
n_effKish (Σ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_null track Field); size from sum_weights/n_eff.
  • Unknown weight_field → SERVICE_VALIDATION (predict + runtime).
  • decimal128 rejected.

See

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_AVERAGE when you want only the average.
  • TEST_WELCH when 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

ParamAccepted field types
Fieldstrict 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:

KeyTypeNotes
meanfloat64Welford running mean
m2float64Sum of squared deviations
variancefloat64Unbiased sample variance (m2/(n-1))
stddevfloat64Sample 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

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_ZSCORE when you want a z-score on every row.

Params

None.

Inputs

ParamAccepted field types
Fieldnumeric (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:

KeyTypeNotes
pop_meanfloat64Population mean (center)
pop_stddevfloat64Population stddev (scale)
target_valuefloat64Value standardized
zscorefloat64Resolved score
  • Mergeability: Mergeable
  • Streaming: NOT streamable — finalize needs full deviation sum

Gotchas

  • Buffered path only.
  • Zero stddev or empty group → 0 (not NaN).
  • decimal128 rejected.

See

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_INCLUDE when you want to keep only the rows with those codes.

Params

NameTypeDefaultDescription
codesarray(required)Non-empty; strings or integers (4 ≡ "4"); a fraction → PROCESSING_CONFIG

Inputs

ParamAccepted field types
Fieldcategorical_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_INCLUDE on 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 (u4 0..15, u8 0..255…) → PROCESSING_CONFIG.
  • A categorical code absent from the dictionary matches nothing; predict warns PULSE_ATTR_CODE_NOT_IN_DICTIONARY (error under --strict).
  • Field must 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

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_DATE when 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_day for seasonality) or as a FEAT substitute when post-filter visibility is needed.
  • Unknown part, non-date-family field, hour on a date → PROCESSING_CONFIG.
  • Zone-capable: a datetime reads the LOCAL clock (slot tz → time_zone → default → UTC). A date ignores zones: explicit tz refused, inherited not applied.

See

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) or bartlett. missing — listwise only.
  • factors, extraction, rotation, kappa, gamma — the factor fit’s knobs (MAT_FACTOR’s); no repair.
  • 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

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_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.

Params

NameTypeDefaultDescription
Expressionstring(required)expr-lang/expr v1.17.x string evaluated per row.

Inputs

ParamAccepted field types
Fieldany referenced field type
Labelrequired — 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-level expr over post-filter rows — the same figure on every row, also when grouped. Unweighted regardless of Request.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 total formula is two-pass (like the z-score attribute): streams alone, buffers beside groups / features; shard archive, parallel decode and ShardWorkers run sequentially (whole-cohort total); ProcessChain refuses it (PULSE_CHAIN_NOT_MERGEABLE). Predict adds PULSE_ADVISORY_EXTRA_PASS per 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 embedder ExprFunction named total → PULSE_EXTENSION_NAME_RESERVED.
  • Compiled once; bad syntax / types → PROCESSING_RUNTIME at build.
  • Categorical → STRING (== / in); set_* → []string ("a" in tags, has_any(tags, "a", "b")).
  • % on a field: float math.Mod (score % 1, dividend’s sign); % 0 → NaN, as / 0 → ±Inf.

See

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_ZSCORE when only one field matters.

Params

  • output — d2 (default) or p (χ² upper tail on p df). missing — listwise only.
  • 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_N warning.
  • Two-pass, never merged.

See

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_PERCENTILE when the field has extreme values that would squeeze the rest of the range.

Params

None.

Inputs

ParamAccepted field types
Fieldnumeric (no decimal128): u8/u16/u32/u64, f32/f64, date, packed_bool, u4
Labelrequired — 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.DefaultWeight is skipped; an explicit slot or request weight is PROCESSING_CONFIG.
  • Two-pass: pre-pass tracks min/max across filter-passing rows; pass 2 emits per row.
  • Constant field (max == min) → 0 per row.
  • Outlier-sensitive — one extreme value compresses the rest of the range.
  • decimal128 rejected.
  • 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 with PROCESSING_CONFIG — a bitmask has no value to standardise.

See

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_MAHALANOBIS when you want how unusual a row is across the fields.

Params

  • component — required, 1-based. on — correlation (default) or covariance. missing — listwise only.
  • rotation — none (default) or varimax with components = 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

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_PERCENTILE when you want the value at a chosen percentile, such as the 90th.
  • GROUP_QUANTILE when you want rows split into equal-sized bands.

Params

None.

Inputs

ParamAccepted field types
Fieldnumeric (no decimal128): u8/u16/u32/u64, f32/f64, date, packed_bool, u4
Labelrequired — 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) is PULSE_WEIGHT_UNSUPPORTED with details.reason; set "weight": null on 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.
  • decimal128 rejected.
  • Under shard archives the sort runs per-shard along the Mergeable path; pass 2 emits global ranks once shards merge.
  • set_* rejected at build time with PROCESSING_CONFIG — a bitmask has no value to standardise.

See

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_OLS when you want the coefficients, R-squared and p-values of the model.
  • REG_GLM when the target is yes/no or a count.

Params

NameTypeDescription
TargetstringDependent field (required).
Predictors[]stringIndependent fields (required).
Penaltyenum"", l1, l2, elasticnet.
AlphafloatRegularization strength.
L1RatiofloatElasticnet mix.
weightslot weightnull opts out; REG_OLS’s kinds: WLS refit; residual raw y − ŷ.

Inputs

ParamAccepted field types
Target / Predictorsnumeric (no decimal128)
Labelrequired — 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).
  • Mergeable per-shard; predict reports streamability.
  • Penalized fits (l1/l2/elasticnet) reuse the same machinery; mis-tuned Alpha/L1Ratio shrinks coefficients toward zero.

See

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_OLS when you want the model itself.

Params

NameTypeDefaultDescription
Targetstring(required)Dependent variable field.
Predictors[]string(required)Independent variable fields.
weightslot weightinheritednull opts out; REG_OLS kinds. Diag of W½X(XᵀWX)⁻¹XᵀW½ (R hatvalues); excluded row: 0.

Inputs

ParamAccepted field types
Target / Predictorsnumeric (no decimal128)
Labelrequired — 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 Penalty raises PROCESSING_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

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_FITTED when the model should be fitted to these same rows inside the slot.

Params

NameTypeDescription
fitstringBound model name (required).
terms[]stringScore only these terms, (intercept) opt-in; fit order. Omit: whole model.
scaleenumGLM only: response (default, inverse link) or link.
boundenumpoint (default), lower, upper: ci bound × column. Needs terms = one predictor.

Inputs

ParamAccepted field types
model predictorsnumeric slot columns (schema, features, earlier labels)
labeloutput 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 declares models: [{name: "m", path: "models/m.pulse-model.json"}] and scores fit: "m" — bit-equal to the in-compose fit, any cohort with the same predictor columns.

See

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_OLS when you want the model’s summary (coefficients, R-squared).

Params

NameTypeDescription
TargetstringDependent field (required).
Predictors[]stringIndependent fields (required).
Penaltyenum"", l1, l2, elasticnet.
AlphafloatRegularization strength.
L1RatiofloatElasticnet mix.
weightslot weightnull opts out; REG_OLS’s kinds: WLS refit; residual raw y − ŷ.

Inputs

ParamAccepted field types
Target / Predictorsnumeric (no decimal128)
Labelrequired — 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

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_ANY when you want to keep only rows that include any of several options.

Params

NameTypeDefaultDescription
labelstring(required)Dictionary label whose bit position is checked per row. Resolved against the set field’s inline dictionary at schema time.

Inputs

ParamAccepted field types
Fieldany 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_FORMULA using has_any / has_all.
  • Coerces to f64 (1.0 / 0.0) when read by downstream numeric ops.

See

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_AVG when you want the average number of selections per group.
  • ATTR_SET_HAS when you want whether one particular option was chosen.

Params

None.

Inputs

ParamAccepted field types
Fieldany set rung — set_u8, set_u16, set_u32, set_u64, set_u128, set_u256
Labelrequired — 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_GTE after this attribute, or precompute as FEAT if you need it before the filterers slot.
  • A fully-selected set_u256 gives 256, one past a u8’s 255 — size any destination column from emits_type, not from the rung’s byte width.

See

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_ZSCORE when 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

ParamAccepted field types
Fieldnumeric (no decimal128): u8/u16/u32/u64, f32/f64, date, packed_bool, u4
Labelrequired — 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 → 50 per row.
  • decimal128 rejected.
  • Not a percentile — same shape as the underlying distribution.
  • set_* rejected at build time with PROCESSING_CONFIG — a bitmask has no value to standardise.

See

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_ZSCORE when 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

ParamAccepted field types
Fieldnumeric (no decimal128): u8/u16/u32/u64, f32/f64, date, packed_bool, u4
Labelrequired — 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) → 0 per row.
  • decimal128 rejected.
  • 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 with PROCESSING_CONFIG — a bitmask has no value to standardise.

See

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_RANGE when 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; v lands in bucket i where boundaries[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_VALIDATION at predict (runtime PROCESSING_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

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_PART when you need just one part of the date.
  • GROUP_DATE when 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):

ColumnTypeRange
<prefix>_yearf64year
<prefix>_monthf641..12
<prefix>_dayf641..31
<prefix>_dowf640..6, 0 = Sunday
<prefix>_quarterf641..4
<prefix>_hourf640..23 — datetime only

Gotchas

  • Null source → every column null.
  • dow 0 = Sunday, NOT ISO; ignores week_start.
  • Suffixes are dow / quarter (not day_of_week / is_weekend).
  • Zone-capable: a datetime reads the LOCAL clock (slot tz → time_zone → default → UTC). A date ignores zones: explicit tz refused, inherited not applied.
  • Streamable per-row.

See

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_CATEGORY when 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

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

ParamAccepted field types
Fieldnumeric: 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 (where 1 + x <= 0) produce a null, 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

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_ENCODE when 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

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_OLS when 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

  • degree outside [2, 10] → PROCESSING_CONFIG.
  • OVERFLOW WITHOUT STANDARDISATION: x^10 at |x| = 100 is already 1e20. Centre / standardise first; an orthogonal basis is out of scope for v1.
  • Null inputs → null on every emitted column. Streamable per-row.

See

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_LOG when the tail is very long and needs a stronger squeeze.

Params

None. Field (required, numeric) — params block is unused.

Inputs

ParamAccepted field types
Fieldnumeric: 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) emit null — 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

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_AVERAGE when you want to report each category’s average outcome.

Params

  • target — string, required. Numeric field whose grouped mean replaces the category.
  • smoothing — float, default 0.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_RISK on every use (errors under --strict). Means use EVERY row’s target (test rows, own row): no split column is read, so a prior split or split == 0 filter changes nothing. See feature-engineering.
  • Missing target, non-numeric target, or smoothing < 0 → PROCESSING_CONFIG.
  • GLOBAL-PASS: PrePass tallies per-category + global (sum, count); Finalize freezes globalMean. Streamable via iter.Reset() (file-backed: second I/O).
  • Zero non-null targets → every row null. Category with all targets null → globalMean.

See

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:sample when you just want a random subset of rows to look at.

Params

NameTypeDefaultDescription
ratioslist[float](required)Two elements → train + val; three → all three splits. Must sum to 1.0 within 1e-6.
seedint0Same seed ⇒ byte-identical assignments.
stratifystring—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

  • ratios length outside [2, 3], any negative ratio, a sum off 1.0 by more than 1e-6, or a non-categorical stratify → 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, so PULSE_FEAT_TARGET_LEAKAGE_RISK still fires.

See

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_RANGES when you want each row labelled with its range instead of dropping the rest.
  • FILTER_RANGE when 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 — registered RangeTable (Extensions or PULSE_RANGE_TABLES_DIR).
  • tz — slot key (not params); IANA, beats time_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/Berlin keeps 2026-02-28T23:30Z.
  • tz on date, non-UTC on a derived field, any tz under FilterToFileWithRequest → PROCESSING_CONFIG.
  • Row-local streamable.

See

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_INCLUDE when you want to keep only the listed values.
  • FILTER_NULL when you want to drop the rows where the field is missing.

Params

NameTypeDefaultDescription
Values[]string(required)Block-list. Categorical fields resolve labels through the dictionary; numeric fields parse each entry via strconv.ParseFloat.

Inputs

ParamAccepted field types
Fieldany 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_CONFIG at build time, surfaced via predict.
  • set_* rejected at build time with PROCESSING_CONFIG — a bitmask has no comparable scalar at any rung.

See

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_INCLUDE when you only need to keep a list of values.
  • FILTER_RANGE when you only need a low and a high limit.

Params

NameTypeDefaultDescription
Expressionstring(required)expr-lang/expr v1.17.x bool predicate.

Inputs

ParamAccepted field types
Fieldunused — 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_RUNTIME at build.
  • Null binds nil: == false, != true, x ?? 0 fills; > / + / len on nil → predicate UNKNOWN, row dropped.
  • Error / non-bool on a non-null row → PROCESSING_RUNTIME.
  • % on a field: float math.Mod (age % 2 == 0, dividend’s sign); % 0 → NaN, as / 0 → ±Inf.
  • No attribute output — filters run first.

See

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_TRUE when you want the rows where the field is yes.
  • FILTER_NULL when you want only the rows where the field is missing.

Params

NameTypeDefaultDescription
Values[]stringempty (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

ParamAccepted field types
Fieldstrict 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

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_EXCLUDE when you want to drop the listed values and keep everything else.
  • FILTER_SET_CONTAINS_ANY when the field is a multi-select answer.

Params

NameTypeDefaultDescription
Values[]string(required)Allow-list. Categorical fields resolve labels through the dictionary; numeric fields parse each entry via strconv.ParseFloat.

Inputs

ParamAccepted field types
Fieldany 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_CONFIG at 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 with PROCESSING_CONFIG — a bitmask has no comparable scalar at any rung.

See

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_COUNT when you only want to count the missing values, not filter on them.

Params

NameTypeDefaultDescription
Values[1]string(required)"is_null" keeps null-valued rows; "is_not_null" keeps non-null rows. Any other value → PROCESSING_CONFIG.

Inputs

ParamAccepted field types
Fieldany 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

  • Field is required; empty → PROCESSING_CONFIG.
  • Reads the bitmap only — the underlying type’s sentinel value (e.g. NaN, empty set_*) is NOT treated as null.
  • For “non-null AND in a set”, put this filter (is_not_null) before the value filter.

See

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_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.

Params

NameTypeDefaultDescription
Values[2]string(required)[low, high] — both parsed via strconv.ParseFloat. Exactly two entries; any other count → PROCESSING_CONFIG.

Inputs

ParamAccepted field types
Fieldnumeric: 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).
  • date interpreted as the numeric day-since-epoch — pass numeric strings, not formatted dates.

See

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_ANY when one of the listed options is enough.

Params

NameTypeDefaultDescription
Values[]string(required)Dictionary labels. Resolved at build time to a single query mask; per-row check is a single bitwise AND-eq.

Inputs

ParamAccepted field types
Fieldany 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 Values resolves 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

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_ALL when the row must include every listed option.
  • FILTER_INCLUDE when the field holds one value per row, not several.

Params

NameTypeDefaultDescription
Values[]string(required)Dictionary labels. Resolved at build time to a single query mask; per-row check is a single bitwise AND.

Inputs

ParamAccepted field types
Fieldany 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 Values resolves 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

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_ANY when you want rows that include one of the options.
  • FILTER_EXCLUDE when the field holds one value per row, not several.

Params

NameTypeDefaultDescription
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

ParamAccepted field types
Fieldany 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

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_ALL when other options are allowed alongside the listed ones.
  • GROUP_SET_VALUE when you want to see every combination with its count.

Params

NameTypeDefaultDescription
Values[]string(required)Dictionary labels. Resolved at build time to a single query mask; per-row check is a single equality.

Inputs

ParamAccepted field types
Fieldany 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 Values resolves 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

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_FALSE when you want the rows where the field is no.
  • FILTER_EXPRESSION when the condition involves more than one field.

Params

NameTypeDefaultDescription
Values[]stringempty (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

ParamAccepted field types
Fieldstrict 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_CONFIG with the suggested Values=["truthy"] fix.
  • Truthy mode coerces: 0, NaN, "", null → falsy; everything else → truthy. Null rows DROP in both directions.
  • decimal128 truthiness uses Sign() != 0.

See

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_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.

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

  • Include matches 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 with PROCESSING_CONFIG — a bitmask has no single scalar to bucket on at any rung.

See

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_RANGES when your periods are custom, such as campaign windows.
  • OVERLAY_INDEX_VS_PRIOR when you want each period against the one before it.

Params

  • component: enum, default month: hour, day, day_of_week, week, month, quarter, year.
  • week_start: week only; monday (default, ISO 2026-W09) .. sunday (key = first day: 2026-03-01).
  • fiscal_offset: int -11..11; year/quarter only; keys FY (end-year).
  • tz: slot key (IANA), beats time_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_week lex-sorts; Group.Include ignored.
  • Local wall clock: a skipped DST hour has no bucket; a repeated one is ONE.
  • PROCESSING_CONFIG: tz on date, zone on a derived field, hour off datetime, misplaced week_start / fiscal_offset.

See

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_DATE when plain calendar months, quarters or weeks are enough.

Params

One source — ranges XOR table:

  • ranges — ordered []{label,start,end}; ISO, omitted = open, inclusive.
  • table — registered RangeTable (Extensions or PULSE_RANGE_TABLES_DIR).
  • unmatched_label — default unmatched; not a range label.
  • tz — slot key (not params); IANA, beats time_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.
  • tz on date, or non-UTC on a derived field → PROCESSING_CONFIG.
  • Group.Include not honoured.

See

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_CATEGORY when you already know the groups.
  • GROUP_QUANTILE when you want bands of one number.

Params

  • k (required, 2 ≤ k ≤ distinct tuples). seed int64 (0 = fixed). max_iter 100, n_init 10 (lowest within-SS wins). standardize true. missing listwise.
  • 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 (solver kmeans_lloyd).

See

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_RANGE when you want bands of a fixed width, such as every 10 years of age.

Params

NameTypeDefaultDescription
intervalint4Number 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

ParamAccepted field types
Fieldnumeric: 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:

KeyTypeNotes
n_quantilesintEqual-population buckets
methodstringInterpolation ("linear")
edges[]float64Sorted 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.Include not honoured — buckets are derived ranks.

See

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_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.

Params

NameTypeDefaultDescription
intervalfloat(required)Bucket width on value axis; set on Group.Interval.

Inputs

ParamAccepted field types
Fieldnumeric: 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:

KeyTypeNotes
intervalfloat64Bucket width
range_minfloat64Smallest non-null
range_maxfloat64Largest non-null
n_bucketsintBuckets emitted
edges[]float64Sorted cutpoints
buckets[]bucket{key, low, high, count}
underflow_countintBelow low
overflow_countintAt/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.Include not honoured.

See

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_RANGE when you want the band’s both edges in its name.
  • GROUP_QUANTILE when you want groups of equal size.

Params

NameTypeDefaultDescription
intervalfloat(required)Rounding increment; set on Group.Interval.

Inputs

ParamAccepted field types
Fieldnumeric: 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:

KeyTypeNotes
precisionfloat64Rounding increment (Group.Interval)
edges[]float64Sorted 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_RANGE at equal width; only the key differs ("10" vs "10-20").
  • Rejects categorical/decimal128 at construction.
  • Group.Include not honoured — filter source field instead.

See

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_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.

Params

None. Group.Label renames the output column; Group.Include allow-lists fan-out labels and fixes emission order.

Inputs

ParamAccepted field types
Fieldany 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:

KeyTypeNotes
total_label_observationsintSum 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_n is 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

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_ELEMENT when 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

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

NameTypeDefaultDescription
centerboolfalseBelsley on centered predictors (no intercept).
repairnearestnoneRead the nearest correlation matrix.
missing, max_drop_share, weight, encodingAs 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_SINGULAR with dependent_fields. Non-PSD pairwise R: fatal PULSE_MATRIX_NOT_PSD unless repair.
  • VIF unbanded: 5 / 10 are rules of thumb only.

See

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_R when you need a p-value or confidence interval for one pair.

Params

NameTypeDefaultDescription
methodpearson | spearman | kendallpearsonRank methods: buffered, serial.
missinglistwise | pairwiselistwisePairwise: per-pair rows (rank methods re-rank per pair).
max_drop_share0–1noneListwise drop-share warning.
summary.top_pairsint ≥ 1nonek strongest pairs.
weightslot weightinheritedPearson: both kinds; rank: frequency only. null opts out.
encodingfull | upperfullupper: 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.p arrives with U28): run the test per pair. Weight-0 rows count in n, add no mass.
  • Rank methods: predict streamable / mergeable false; a probability weight is PULSE_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

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_R when you want how closely two numeric fields follow a straight line together, on a scale from -1 to 1.

Params

NameTypeDefaultDescription
ddof0 | 11Denominator Σw − ddof.
missinglistwise | pairwiselistwisePairwise: per-pair rows.
max_drop_share0–1noneListwise drop-share warning.
weightslot weightinheritedBoth kinds; null opts out.
encodingfull | upperfullupper: 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).
  • summary is refused (top_pairs: op-mat-correlation). Refusals and worker invariance: matrix-results.

See

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_MANOVA when you want to test whether the centres differ.

Params

  • between — required grouper spec as in groups, extension groupers too; no own weight.
  • 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 between does; a whole-set or extension grouper buffers.
  • Date-family between: its tz, else time_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

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 or kaiser (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.
  • ml needs positive-definite R and enough rows.

See

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_CORRELATION when you want each pair’s link with nothing held fixed.

Params

NameTypeDefaultDescription
control"all" | [fields]allHeld-fixed set; a listed member leaves the axis, an outside field joins the fold.
repairnearestnoneRepair non-PSD input.
missing, max_drop_share, weight, encodingAs 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 with frobenius_adjustment.
  • Collinear fields: PULSE_MATRIX_SINGULAR (dependent_fields).

See

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]

As op-mat-covariance.

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_CORRELATION when you only want to see which measures go together, pair by pair.

Params

  • on — correlation (default) or covariance. rotation — none, varimax (k ≥ 2). repair — nearest.
  • components — int k (≤ p), kaiser (λ > 1, default) or {variance: s}; required on covariance.
  • 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_PSD unless repair.

See

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_CORRELATION when you only want to see which items go together, pair by pair.

Params

NameTypeDefaultDescription
reverse[fields]noneReverse-keyed items: x’ = min + max − x before the fold.
scale_min, scale_maxnumbernoneBattery range; both or neither, required with reverse.
repairnearestnoneFit omega on the nearest correlation matrix.
missing, max_drop_share, weight, encodingAs 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

  • reverse without 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 without repair (PULSE_MATRIX_NOT_PSD). Alpha always computes.
  • Negative alpha usually means an unreversed item.

See

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_ROW when 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}; adds p_adjusted figures, 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’s Parameters adds that column’s sum_weights (+ n_eff). Probability host with components disabled ⇒ PROCESSING_CONFIG (no n_eff to scale by; predict AND runtime).
  • Reuses chiSquareSurvival — p-values byte-equal to every χ² test and overlay on the same contingency.
  • Any expected < 5 in a column emits ONE PULSE_OVERLAY_EXPECTED_LOW per 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_ROW when 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}; adds p_adjusted figures, 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_eff first (PULSE_OVERLAY_EXPECTED_LOW on the scaled expected) — a first-order Kish approximation, not Rao-Scott. Summary.Parameters adds sum_weights (+ n_eff). Probability host with components disabled ⇒ PROCESSING_CONFIG (no n_eff to scale by; predict AND runtime).
  • Expected cell formula: row_margin × col_margin / grand_total. p-value via chiSquareSurvival — byte-equal to the χ² test on the same contingency.
  • Any expected < 5 → ONE PULSE_OVERLAY_EXPECTED_LOW per layer.
  • Absent host cell treated as observed count of 0.
  • Scope MUST be matrix. Populated Ref arm → 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_COL when 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}; adds p_adjusted figures, 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’s Parameters adds that row’s sum_weights (+ n_eff). Probability host with components disabled ⇒ PROCESSING_CONFIG (no n_eff to scale by; predict AND runtime).
  • Reuses chiSquareSurvival — byte-equal p-values to every χ² test and overlay on the same contingency.
  • Any expected < 5 in a row emits ONE PULSE_OVERLAY_EXPECTED_LOW per offending row.
  • Absent host cell treated as observed count of 0.
  • Scope MUST be row. Populated Ref arm → 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_POP when 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}; adds p_adjusted figures, 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 + one PULSE_OVERLAY_REF_ZERO. Byte-equal survival to the χ² test.
  • Any expected < 5 → ONE PULSE_OVERLAY_EXPECTED_LOW per 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_CELL when 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}; adds p_adjusted figures, 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.Parameters adds the target’s sum_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). Reuses chiSquareSurvival — byte-equal to the χ² test on the same contingency.
  • df = (target cells with expected > 0) - 1.
  • Any expected < 5 → ONE PULSE_OVERLAY_EXPECTED_LOW per layer (canonical χ² low-count rule).
  • target_N == 0, ref_N == 0, or every expected == 0 → NaN + PULSE_OVERLAY_REF_ZERO.
  • Dict-prefix drift between slots → PULSE_OVERLAY_COMPOSE_DICT_DIVERGENCE (under DictPrefixFast).
  • 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_BASELINE when 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 via ResolveBaselineIndex).
  • Zero baseline → no warning (subtraction defined for every finite value; delta becomes raw host value). The ratio twin raises PULSE_OVERLAY_REF_ZERO instead.
  • Absent host point → SeriesEntry with unset Statistic (canonical absent-slot shape).
  • Buffered — host.ValueAt(Position) read post-finalize via ApplyOverlaysSeries.

See

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_MARGIN when 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 = grand is 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_PRIOR when 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 0 would 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.Lag reserved for future window-N priors; v1 ships lag-1 only.
  • Empty Ref and populated Ref.Prior both spell lag-1.
  • Other Ref arms → 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_REF when you want a ratio rather than a gap.
  • OVERLAY_T_CELL when 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_ZERO with ref_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_SIBLING when 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 → ONE PULSE_OVERLAY_REF_UNKNOWN per layer + NaN across entries.
  • Zero sibling → no warning (subtraction is defined; delta becomes the raw group value), unlike the ratio twin.
  • Absent host group → SeriesEntry with unset Statistic.
  • 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_STAGE when 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_UNKNOWN when it lands).
  • Buffered (whole-chain barrier runs after every stage finalises by construction).

See

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_MATRIX when 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}; adds p_adjusted figures, 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.Parameters adds sum_weights; a probability weight — on the overlay slot OR only on the host cell — is PULSE_WEIGHT_UNSUPPORTED naming the kind (predict AND runtime).
  • 2×2 contingency: [cell, row_margin-cell; col_margin-cell, grand-row_margin-col_margin+cell]. Reuses logHypergeometric — byte-equal to the Fisher exact test on the same 2×2.
  • Cochran rule: any of four expected counts < 1 OR >= 20% of expected < 5 → ONE PULSE_OVERLAY_EXPECTED_LOW per offending cell. Advisory only — Fisher stays exact.
  • grand_total <= 0 → absent cells everywhere + ONE PULSE_OVERLAY_REF_ZERO.
  • Populated Ref arm → 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_FORMULA when 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_cell on Compose).
  • SERIES: value, total, prior (+ opt-in baseline, ref_value on Compose).
  • SCALAR: value (+ ref on 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 ExprFunctions widen the function surface; variables are fixed per host shape (widen via a custom OverlayKinds entry). lookup(...) needs a registered LookupTable.
  • Built-ins: scalar sqrt / log / exp / pow and the set helpers; the vector functions (vmean, dot, …) are PULSE_OVERLAY_FORMULA_INVALID_IDENT on predict (request and Compose hosts) and runtime.
  • total(expr) is a formula-attribute-only function: calling it here (Compose host included) is PULSE_FORMULA_TOTAL_SITE on predict and runtime. Use the total variable (SERIES) instead.
  • Buffered at v1 (margins / totals / SDs need post-fold state).

See

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_BASELINE when 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 via ResolveBaselineIndex).
  • Zero baseline → NaN across entries + ONE PULSE_OVERLAY_REF_ZERO per layer, unlike the subtractive twin.
  • Absent host point → SeriesEntry with unset Statistic.
  • Absent baseline ordinal yields 0.0 from host → routes to zero-baseline arm.
  • Buffered — host.ValueAt(Position) post-finalize via ApplyOverlaysSeries.
  • 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_ROW when 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 + ONE PULSE_OVERLAY_REF_ZERO per affected slice (not per cell).
  • All three axes supported. Axis = grand is 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_POP when 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) → ONE PULSE_OVERLAY_REF_ZERO per affected entry + SKIP it (Statistic unset).
  • Numeric host without IncludeHistogram → zero entries (no per-value buckets).
  • Unknown population field → PULSE_OVERLAY_REF_UNKNOWN from 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_PRIOR when 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: null in JSON.
  • First ordinal → NaN, no warning (“no comparison available” ≠ “denominator zero”).
  • Absent host point → no statistic key + carrier does NOT advance (next present point still divides by last present value).
  • Zero prior → NaN + ONE PULSE_OVERLAY_REF_ZERO per layer.
  • Ref.Prior.Lag reserved for future window-N priors; v1 ships lag-1 only.
  • Empty Ref and populated Ref.Prior both spell lag-1.
  • Other Ref arms → 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_REF when 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_ZERO per coord.
  • Missing reference coordinate → PULSE_OVERLAY_REF_ZERO with ref_missing=true.
  • SERIES dispatch is fold-only (streamable per OverlayStreamability); MATRIX is forced buffered by the slot barrier.
  • OverlayOptions.DictPrefixFast enables the byte-equal dictionary prefix probe. Other Ref arms → 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_ROLLING when 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: null in JSON.
  • First W present ordinals → NaN, no warning (window unfilled). Absent host point → no statistic key, ring does NOT advance. Zero rolling mean → NaN + ONE PULSE_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_SIBLING when 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 → ONE PULSE_OVERLAY_REF_UNKNOWN per layer + NaN entries.
  • Zero sibling value → NaN entries + ONE PULSE_OVERLAY_REF_ZERO per layer. The subtractive twin does not warn on zero.
  • Absent host group → SeriesEntry with unset Statistic.
  • 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_STAGE when 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 + ONE PULSE_OVERLAY_REF_ZERO per layer.
  • Target defaults to the latest stage when Index is nil and Name empty; Ref has 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_UNKNOWN when it lands).
  • Buffered (whole-chain barrier post stage loop).

See

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_TOTAL when 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 + ONE PULSE_OVERLAY_REF_ZERO per layer.
  • Absent host group → SeriesEntry with unset Statistic and does NOT contribute to grand total.
  • Populated Ref arm → PULSE_OVERLAY_REF_INCOMPATIBLE_WITH_SHAPE.
  • Streamable — one float64 grand-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_POP when the field is categorical.
  • TEST_KS when 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}; adds p_adjusted figures, 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=true or NumericPercentiles=[...] 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

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_Z when 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}; adds p_adjusted figures, 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, or Options.DefaultWeight) is PULSE_WEIGHT_UNSUPPORTED with details.reason; set "weight": null on the overlay to run it unweighted.
  • df <= 0 (n_i + n_j <= 2) or a non-finite t skips the pair (aggregated PULSE_OVERLAY_REF_ZERO).
  • RAW p-values only — direction / thresholds / min-n are the embedder’s job.
  • p_source mismatch fails silently: cell_value over 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_PARTITIONED for 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_T when 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}; adds p_adjusted figures, 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_source omitted n = the cell’s N* (sum_weights frequency, n_eff probability). Unweighted-count modes (and cell_weight_sum / cell_value_weighted under probability) are PROCESSING_CONFIG in predict AND runtime (pairwise-n-sources). Summary.Parameters adds sum_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_ZERO per reason.
  • p_source mismatch fails silently and totally. cell_value over 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_count too, but theirs counts answer codes. n_within_distinct additionally 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

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_T when 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}; adds p_adjusted figures, 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, or Options.DefaultWeight) is PULSE_WEIGHT_UNSUPPORTED with details.reason and details.alternative: use OVERLAY_PAIRWISE_WEIGHTED_TWO_MEANS_Z for weighted means; set "weight": null on the overlay to run it unweighted.
  • Either leg with n <= 1 skips the pair (aggregated PULSE_OVERLAY_REF_ZERO).
  • Assumes the normal approximation: no small-sample df correction.
  • n_basis here 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_WELFORD cell is non-mergeable, so CanFuseCrosstab rejects on the cell-aggregator arm. Expected (TestCrosstabWelfordCell_StaysBufferedWithCorrectOverlays).

See

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_T when 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}; adds p_adjusted figures, 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): weights leg Σw ≤ 1; kish leg with one weighted row; zero SE.
  • Mergeable cell: stays fused. n_basis sets n: weights needs 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_Z when 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}; adds p_adjusted figures, 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 from m2 on w*, df on N*; Summary.Parameters adds sum_weights (+ n_eff). There an unweighted-count n_source (and a weight-sum one under probability) is PROCESSING_CONFIG in predict AND runtime (pairwise-n-sources).
  • Either leg with n <= 1 skips the pair (aggregated PULSE_OVERLAY_REF_ZERO).
  • RAW p-values only — direction / thresholds are the embedder’s job.
  • Buffered (inferential) — and so is the HOST: the AGG_WELFORD cell is non-mergeable, so CanFuseCrosstab rejects on the cell-aggregator arm. Expected (TestCrosstabWelfordCell_StaysBufferedWithCorrectOverlays).

See

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_REF when 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 becomes OVERLAY_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_PANEL when 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}; adds p_adjusted figures, 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_eff under probability (never Σw; no readable floor ⇒ missing margin). A probability slot with components disabled ⇒ PROCESSING_CONFIG (predict AND runtime). Summary.Parameters adds sum_weights (+ n_eff).
  • Pooled SE: sqrt(pooled × (1-pooled) × (1/n_target + 1/n_ref)) where pooled = (target+ref) / (n_target+n_ref). Reuses normalTwoSidedP — 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 + ONE PULSE_OVERLAY_REF_ZERO per 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_CELL when 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 — counted n from the slot’s Components.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 of RowMarginComponents as the cell aggregator’s distinct-KEY cardinality. A different CARRIER, hence not row_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}; adds p_adjusted figures, 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_eff under probability); cell_n_unweighted / row_margin_distinct_within on a weighted slot are PROCESSING_CONFIG (predict AND runtime). Summary.Parameters adds sum_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 + ONE PULSE_OVERLAY_REF_ZERO per (cell, pair).
  • Absent value → nil slice + REF_ZERO ref_missing; an unreadable counted n adds n_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_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.

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.rankdata default).
  • 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_ROW when 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 share is never read as index/100.
  • col_margin == 0 → NaN cell + ONE PULSE_OVERLAY_REF_ZERO per 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_COL when 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 share is never read as index/100.
  • row_margin == 0 → NaN cell + ONE PULSE_OVERLAY_REF_ZERO per affected row.
  • Absent host cells stay absent on the overlay.
  • Scope MUST be cell. Empty Ref.Margin or 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_ROW when 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 computeSeriesGrandTotal accumulator as the total index; MATRIX buffers.
  • Empty Ref.Margin on MATRIX, or a populated Ref on SERIES → PULSE_OVERLAY_REF_INCOMPATIBLE_WITH_SHAPE.
  • grand_total == 0 → NaN + ONE PULSE_OVERLAY_REF_ZERO per 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_CELL when 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}; adds p_adjusted figures, 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_weights reads N* = sum_weights (frequency) or n_eff (probability) — never the raw n — and its variance from m2 on w*. Summary.Parameters adds sum_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 share studentTTwoSidedP and the df recurrence.
  • Legacy processing.WelfordTriple smuggle REMOVED v0.20.0 — MatrixCell.Value carries the scalar mean.
  • Canonical pairing: AGG_WELFORD + OVERLAY_T_CELL. Buffered (inferential).

See

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_REF when 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}; adds p_adjusted figures, 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_weights reads N* = sum_weights (frequency) or n_eff (probability), never the raw n, and its variance from m2 on w*. Summary.Parameters adds sum_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_ZERO with ref_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_PRIOR when 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_ZERO per 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_CELL when 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}; adds p_adjusted figures, 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_weights reads N* = sum_weights (frequency) or n_eff (probability) — never the raw n — and its variance from m2 on w*. Summary.Parameters adds sum_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.WelfordTriple smuggle REMOVED v0.20.0; MatrixCell.Value holds the scalar mean.
  • Buffered (inferential).

See

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_REF when 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}; adds p_adjusted figures, 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_weights reads N* = sum_weights (frequency) or n_eff (probability), never the raw n, and its variance from m2 on w*. Summary.Parameters adds sum_weights (+ n_eff) over the legs read.
  • Byte-equal to the two-sample z-test on the same inputs — shares normalTwoSidedP with it and the cell z overlay.
  • Missing reference row, or degenerate inputs (se == 0, n < 2) → PULSE_OVERLAY_REF_ZERO with ref_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_MARGIN when 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 + ONE PULSE_OVERLAY_REF_ZERO per 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) from FacetPopulationView.
  • sd_pop == 0 → ONE PULSE_OVERLAY_REF_ZERO per affected entry + SKIP it (Statistic unset). An absent population entry reads as pop_freq = 0 and 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_TREND when 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 + ONE PULSE_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_WELCH when 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 + ONE PULSE_OVERLAY_REF_ZERO per 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_GLM when the outcome is yes/no (0/1) or a count.

Params

NameTypeDefaultDescription
targetfieldrequiredResponse variable (numeric).
predictors[]fieldrequired≥1 numeric predictor.
priorenumnigOnly "nig" (conjugate Normal-Inverse-Gamma) in v1.
prior_mu[]floatzeroPrior mean vector, length predictors + 1, intercept FIRST.
prior_precisionfloat0.001Scalar ε, Λ₀ = ε·I, relative to σ²: prior SD = σ/√ε.
prior_shape / prior_ratefloatengineInverse-gamma α₀ / β₀ on residual variance.
credible_levelfloat0.95Posterior credible-interval mass.
conf_levelfloatcredible_levelCILower / CIUpper mass, 0<c<1.
weightslot weightinheritednull opts out; frequency only (= the expanded rows: X’WX, X’Wy, Σw in the posterior). Adds SumWeights; NObs raw rows.
vcovboolfalseAdds the POSTERIOR covariance b_n/(a_n−1)·Λ_n⁻¹ (null if a_n ≤ 1) + Correlation; diag = StdErrors²·a_n/(a_n−1).
covarianceobjectabsent{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_UNSUPPORTED naming the kind; "weight": null runs it unweighted.
  • penalty / alpha / l1_ratio / family / link rejected — other engines’ knobs.
  • resample / selection → PROCESSING_CONFIG (not advertised) — credible intervals already convey uncertainty.
  • prior_mu length ≠ predictors + 1 → PROCESSING_CONFIG.
  • Robust covariance is 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 repeated order_by → PROCESSING_REGRESSION_VCOV_UNSUPPORTED.
  • Vague-prior limit reproduces the OLS point estimate; intervals and ResidualStdErr run below OLS with few rows per predictor (σ² ≈ RSS/n, not RSS/(n−p−1)).
  • R2 is on the posterior-mean fit, not averaged over posterior draws.

See

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_Z when you only want to know whether a yes/no rate differs between two groups.

Params

NameTypeDefaultDescription
targetfieldrequiredResponse variable (numeric).
predictors[]fieldrequired≥1 numeric predictor.
familyenumrequiredbinomial, poisson, gamma.
linkenumfamily defaultbinomial→logit, poisson→log, gamma→inverse. identity, probit, cloglog, sqrt reserved.
max_iters / tolint / floatengineIRLS caps.
weightslot weightinheritednull opts out; both kinds. IRLS prior weights w* (R glm(weights = w*)); SEs, deviances on w*.
conf_levelfloat0.95CILower / CIUpper coverage, 0<c<1.
vcovboolfalseAdds Vcov = (XᵀWX)⁻¹ at the converged weights (dispersion 1) + Correlation; √diag = StdErrors. Resample: replicate covariance; selection: final refit’s.
covarianceobjectabsent{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_ratio rejected → PROCESSING_CONFIG. Regularized GLM is a later phase.
  • Binomial separation diverges → PROCESSING_REGRESSION_NO_CONVERGE. Raise max_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; covariance hc0–hc3 is dispersion-free.
  • Robust covariance beside resample / selection, < 2 clusters or a repeated order_by → PROCESSING_REGRESSION_VCOV_UNSUPPORTED. A null cluster / order_by drops the row.
  • Collinearity → PROCESSING_REGRESSION_RANK_DEFICIENT; drop a predictor.

See

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.

NameTypeDefaultDescription
resampleenum"""", jackknife (leave-one-out), bootstrap (non-parametric).
bootstrap_itersint1000Replicate count; read only when resample == "bootstrap".
rng_seedint00 derives a deterministic per-request seed; non-zero is reproducible.

Inputs

Inherits target / predictors from the host RegressionSpec. No additional field inputs.

HostAccepted?
REG_OLS (any penalty)yes
REG_GLMyes
REG_BAYES_LINEARrejected — 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.
  • Resample echoes back; Coefficients unchanged. For l1 / elasticnet OLS, bootstrap suppresses PROCESSING_REGRESSION_APPROXIMATE_SE.

Gotchas

  • Any non-empty resample forces 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 = 0 is 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) is PULSE_WEIGHT_UNSUPPORTED with details.reason; "weight": null on the slot runs it unweighted.

See

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. aic or bic.

Inputs

Inherits target / predictors from the host RegressionSpec. No additional field inputs.

HostAccepted?
REG_OLS (penalty == "")yes
REG_OLS (penalty != "")→ PROCESSING_REGRESSION_REGULARIZED_SELECTION
REG_GLMyes
REG_BAYES_LINEARrejected — stepwise on a NIG fit is out of scope

Output

  • SelectedFeatures []string — chosen predictors in fit order.
  • Coefficients for selected predictors plus (intercept) only. Absent ≠ zero — non-selected predictors were dropped entirely.
  • StdErrors / PValues for selected predictors only. Selection / Criterion echo back.

Gotchas

  • Any non-empty selection forces buffered — RegressionSpec.Streamable() → false. Predict reports the downgrade.
  • criterion missing when selection set → SERVICE_VALIDATION. bic penalizes complexity harder than aic.
  • Worst-case O(p²) fits (stepwise); on wide tables prefer forward.
  • 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) is PULSE_WEIGHT_UNSUPPORTED with details.reason; "weight": null on the slot runs it unweighted.

See

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_GLM when the outcome is yes/no (0/1) or a count.

Params

NameTypeDefaultDescription
targetfieldrequiredResponse variable (numeric).
predictors[]fieldrequired≥1 numeric predictor.
penaltyenum"""", l1, l2, elasticnet.
alphafloat0Strength; >0 when penalty set.
l1_ratiofloat0Elastic-net mix in [0,1].
max_iters / tolint / floatengineCoordinate-descent caps for regularized fits.
weightslot weightinheritednull 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_levelfloat0.95CILower / CIUpper coverage, 0<c<1.
vcovboolfalseAdds 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.
covarianceobjectabsent{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, ResidualStdErr null.
  • l1 / elasticnet SE is plug-in over the active set — pair with op-reg-mod-resample; otherwise PROCESSING_REGRESSION_APPROXIMATE_SE warns.
  • Robust covariance forces the buffered path (second pass for residuals / leverage); beside penalty / resample / selection, < 2 clusters or a repeated order_by → PROCESSING_REGRESSION_VCOV_UNSUPPORTED. A null cluster / order_by drops the row.
  • Collinearity → PROCESSING_REGRESSION_RANK_DEFICIENT / SINGULAR_GRAM; drop a predictor or add l2.
  • penalty != "" + selection != "" → PROCESSING_REGRESSION_REGULARIZED_SELECTION.
  • Polynomial: stage FEAT_POLY polynomial columns upstream; degree gate [2,10]; standardize first.
  • Per-row residual / fitted / leverage live in ATTR_REG_* — separate slot, separate prepass.

See

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_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.

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 create reconstructs every packed_bool as bernoulli with p = 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 bernoulli target is a PROBIT. quantileFor’s step Q makes value = Q(Φ(μ + σz)) into P(1 | row) = Φ((μ − Φ⁻¹(1−p)) / σ). Coefficients order rows and hold the marginal exactly; they are NOT probability changes. The fidelity models section scores these on the interval-midpoint probit score, not a latent inversion.
  • p outside [0, 1] → SERVICE_VALIDATION at spec parse. p of exactly 0 or 1 is legal and exact but has zero variance, so a model on that target is dropped with a warning.
  • For > 2 experiment arms use weighted_categorical.
  • Observed p_hat converges at O(1/sqrt(n)) — small cohorts diverge from declared p.
  • Same (spec, opts.Seed) ⇒ identical bit pattern.

See

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_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.

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 value param → SERVICE_VALIDATION (“requires param value”).
  • A value the field cannot hold (text on a number field) → SERVICE_VALIDATION at 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 use bernoulli with p=0 / p=1.

See

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:

  • normal when the field is a continuous measure.
  • weighted_categorical when 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 create uses 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 discrete key in the profile is the record — no warning, no knob.
  • A modelled target is an ORDERED PROBIT. The staircase Q shifts the latent, not the value: direction and ordering carry, scale-point magnitude does not. latentFor refuses a staircase, so the fidelity models section scores both sides on the interval-midpoint probit score rather than inverting.
  • Non-ascending or duplicate values → SERVICE_VALIDATION; silent sorting would break Q’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:

  • poisson when you want whole-number counts of events per period.
  • pareto when a few values should be extremely large.

Params

NameTypeDefaultDescription
lambdafloat1.0Rate parameter; must be > 0. Mean = 1/lambda.

Inputs

ParamAccepted 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_VALIDATION at spec parse.
  • Memoryless property: hazard rate is constant. Pair with monotonic_from for 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: exponential lifetimes / wait-times, log-transformed for distribution-shape work.

See

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:

  • normal when values are spread evenly around a centre.
  • pareto when the tail should follow a power law, as with wealth or city sizes.

Params

  • mu — float, default 0.0. Log-space mean (mean of ln(X)).
  • sigma — float, default 1.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 sigma increases dispersion exponentially. Use pareto for true power-law tails.
  • sigma <= 0 → SERVICE_VALIDATION at spec parse.
  • No built-in clamp — pair with a constraint (amount < 1_000_000) to bound outliers.
  • Common synthetic-money pattern: lognormal over f64, then cast to decimal128 with a constraint enforcing the precision floor.

See

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:

  • normal when one peak is enough.
  • lognormal when there is one peak with a long right tail.

Params

NameTypeDefaultDescription
meanslist[float]requiredPer-component means; length sets the component count (>= 2).
stdslist[float]requiredPer-component std devs; each > 0, length must match means.
weightslist[float]uniformMixing 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 (use normal for one component).
  • stds/weights length mismatch, any std <= 0, or negative/all-zero weights → SERVICE_VALIDATION.
  • profile create --fit-shape fits and emits mixture automatically, 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 through residual_correlations instead.
  • Composes with --fit-models: a shape-fitted target draws through its model with the mixture as Q via a fixed-count bisection inverse, so coefficients are LATENT-scale — non-linear in value space. See synthetic-data.

See

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:

  • 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.

Params

  • start — int, default 0. First emitted value.
  • step — int, default 1, 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_VALIDATION at spec parse.
  • Consumes NO RNG — adding / removing a monotonic_from field 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=10 overflows u8 after row 1. Declare wide enough, or use u64.
  • Pairs with pulse synth from-schema for fixtures; Seed is irrelevant here.

See

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:

  • lognormal when values are always positive with a long right tail, like income.
  • mixture when the data has two or more peaks.

Params

  • mean — float, default 0.0.
  • std — float, default 1.0; must be > 0.
  • min / max — float, default -inf / +inf. Setting EITHER flips an internal clamped flag; 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|_requested when a partner field is clamp-narrow.
  • std <= 0 → SERVICE_VALIDATION at 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 == 0 is stable, not random.

See

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:

  • lognormal when the tail is long but values have a typical size.
  • exponential when the values are waiting times between events.

Params

  • xm — float, default 1.0. Scale (minimum emitted value); must be > 0.
  • alpha — float, default 1.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_VALIDATION at 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.16 reproduces an 80/20 Pareto split.
  • No clamp param — use constraints to bound the tail when needed (e.g. wealth < 1e9).

See

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:

  • 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.

Params

  • lambda — float, default 1.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, draws k uniforms until product < exp(-lambda).
  • lambda >= 30: normal approximation N(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_VALIDATION at spec parse.
  • Variance equals mean; for over-dispersed counts pair with a lognormal mixer or use rejection sampling via constraints.
  • Same (spec, opts.Seed) is deterministic across both algorithm branches.

See

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_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.

Params

  • pattern — string, required, non-empty. Perl/RE2 source via regexp/syntax.Parse.
  • max_repeat — int, default 8, 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_VALIDATION with the syntax error.
  • OpAnyChar emits printable ASCII (33–126), for cross-platform stability.
  • Unbounded * / + clamps to max_repeat; bounded {m,n} to min(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

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_categorical when each row picks exactly one answer.
  • bernoulli when there is only one yes/no flag.

Params

  • options — list[string], required. Dictionary entries in bit order (bit i ↔ 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], default 0.5 each. Per-option P(bit set) in [0, 1]; length must match options.

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, frequencies length mismatch, or a frequency outside [0, 1] → SERVICE_VALIDATION.
  • options count over MaxSetEntries() → PULSE_IMPORT_SET_OVERFLOW at schema build.
  • SpecFromProfile fills this from FieldProfile.Set.Options and wires SetCategoricalPairs/SetNumericPairs/SetSetPairs from Conditional.Set*Pairs only when the paired field also reconstructed to its expected distribution (weighted_categorical / normal / set_bernoulli).
  • A profile captured without --conditional has no joint sections — every option draws its own marginal, byte-for-byte the pre-joint behaviour.
  • SpecFromProfile carries EVERY captured member, at every rung — FieldProfile.Set.Options is bit-ordered and uncapped below MaxSetEntries(), so a 206-member set_u256 round-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 options order), so the seeded stream stays byte-identical.

See

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:

  • normal when values should cluster around a typical value.
  • uniform_date when the field is a calendar date.

Params

NameTypeDefaultDescription
minfloat0.0Lower bound (inclusive).
maxfloat1.0Upper bound (exclusive before rounding; on u8–u64 fields rounding can produce it, and both ends come out half as often). Must be > min.

Inputs

ParamAccepted 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_VALIDATION at spec parse.
  • Half-open, but integer fields ROUND — max=10 over u8 can store 10 (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

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:

  • uniform when the field is a plain number, not a date.
  • constant when every row shares one date.

Params

  • start / end — string, both required, ISO-8601 YYYY-MM-DD. end must not precede start; equal is legal. Each parses via time.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 == start is legal (every draw on that day); only end < start → SERVICE_VALIDATION (“end must not be before start”).
  • Epoch is 1970-01-01; pre-epoch dates are legal — date stores signed int32 days, so they are negative.
  • For sub-day granularity model the timestamp as u64 seconds-since-epoch via uniform.

See

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:

  • 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.

Params

  • values — list[string], required. Dictionary entries to draw from; non-empty.
  • weights — list[float], default uniform. Per-value weight; length MUST match values. 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

  • weights length mismatch → SERVICE_VALIDATION at 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 bernoulli instead — avoids the dictionary block.

See

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_WELCH when the groups have clearly unequal spread.
  • TEST_KRUSKAL_WALLIS when the measure is heavily skewed or only ordered.
  • TEST_WELCH when there are only two groups.

Params

  • alpha — float, default 0.05, in (0, 1).
  • weight — slot weight (null opts 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. Details add sum_weights (+ n_eff, probability) shaped like n (raw rows); n_eff < k+1 warns PULSE_WEIGHT_LOW_NEFF.
  • multiplicity — optional {method, family}; adds p_adjusted beside 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_HSD via ms_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_summary reads group summaries; same keys.

See

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_F when each group holds different, unrelated subjects.

Params

  • alpha — float, default 0.05, in (0, 1).
  • multiplicity — optional {method, family}; adds p_adjusted beside 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) is PULSE_WEIGHT_UNSUPPORTED with details.reason; set "weight": null on 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

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_WALLIS when the measure is heavily skewed or only ordered.

Params

  • alpha — float, default 0.05, in (0, 1).
  • weight — slot weight (null opts out), both kinds: per-group moments on w*, weights = N*_j/s²_j, df on N*_j−1 (Σw or Kish n_eff). Details add sum_weights (+ n_eff, probability) shaped like n (raw rows); n_eff < 2 warns PULSE_WEIGHT_LOW_NEFF.
  • multiplicity — optional {method, family}; adds p_adjusted beside 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_post reads per-group {mean, variance, n}; same keys.
  • Post-hoc: Tukey HSD assumes equal variance; for unequal fall back to pairwise TEST_WELCH with a multiplicity block.
  • Constant Field within a group → PULSE_TEST_VARIANCE_ZERO.

See

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_CORRELATION when you want how strongly each pair is related.

Params

  • alpha — float, default 0.05. missing — listwise only.

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

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_FORSYTHE when there is only one numeric field.

Params

  • alpha — float, default 0.05. missing — listwise only.

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_SINGULAR warning.
  • Very sensitive to non-normality; many read only p < 0.001.

See

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_WELCH when you want to compare the group averages themselves.
  • TEST_SHAPIRO_WILK when you want to check whether a measure is bell-shaped.

Params

  • alpha — float, default 0.05, in (0, 1).
  • weight — slot weight (null opts out), kind frequency only (a probability weight is PULSE_WEIGHT_UNSUPPORTED naming the kind): weighted group medians, ANOVA on Σw — equals the expanded rows. Details add sum_weights beside n (raw rows).

Slot params: Field (required, numeric), SplitBy (required, categorical, ≥ 2 groups).

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_post runs over result columns.
  • Tiny groups destabilize the median; n_i < 2 or N ≤ k → PULSE_TEST_INSUFFICIENT_N.

See

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_EXACT when the table is 2x2 and some expected counts are small.
  • TEST_SPEARMAN_R when both fields are numeric.

Params

  • alpha — float, default 0.05, in (0, 1).
  • weight — slot weight (null opts 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 < 5 guard, V, φ read the scaled table; Details add scalar sum_weights (+ n_eff); n stays raw rows.

Slot params: Rows (required, categorical), Cols (required, categorical). Field is ignored.

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 < 5 emits PULSE_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_POP is the FACET-host equivalent.

See

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_CHISQ when either field has more than two categories.

Params

  • alpha — float, default 0.05, in (0, 1).
  • weight — slot weight (null opts out), kind frequency only (a probability weight is PULSE_WEIGHT_UNSUPPORTED naming the kind): the exact test on the Σw table — equals the expanded rows. contingency becomes the Σw table; n stays raw rows, sum_weights beside it.
  • multiplicity — optional {method, family}; adds p_adjusted beside 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

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_T when there is only one numeric field.

Params

  • alpha (0.05). mu — per member (default zeros). missing — listwise only.
  • variance — pooled (default) or unequal (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 (Details add sum_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); unequal refused on one-sample / paired; missing: pairwise refused.
  • Singular covariance: null statistic + PULSE_MATRIX_SINGULAR.

See

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_R when the data are large; its cost grows with the square of the row count.

Params

  • alpha — float, default 0.05, in (0, 1).
  • weight — slot weight (null opts out), kind frequency only (a probability weight is PULSE_WEIGHT_UNSUPPORTED naming the kind): a row pair counts w_i·w_j times — equals τ-b on the expanded pairs. Details add sum_weights beside n (raw rows); pair counts become Σ w_i·w_j.

Slot params: Field (required, numeric), Field2 (required, numeric).

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_post runs over result columns.
  • For high-cardinality numeric fields the O(n²) cost dominates.

See

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_WELCH when the data are roughly normal and you want to compare averages.
  • TEST_MANN_WHITNEY_U when there are only two groups.

Params

  • alpha — float, default 0.05, in (0, 1).
  • weight — slot weight (null opts out), kind frequency only (a probability weight is PULSE_WEIGHT_UNSUPPORTED naming the kind): a row of weight w ranks as w identical rows — equals the test on the expanded rows. Details add sum_weights shaped like n; n / n_total stay raw row counts.
  • multiplicity — optional {method, family}; adds p_adjusted beside 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_U with a multiplicity block (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

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_WILK when you want to check one field against the normal distribution.

Params

  • alpha — float, default 0.05, in (0, 1). Always two-sided; there is no one-sided form.
  • weight — slot weight (null opts out), kind frequency only (a probability weight is PULSE_WEIGHT_UNSUPPORTED naming the kind): ECDFs step by Σw, p on the Σw sizes — equals the expanded rows. Details add sum_weights beside n (raw rows).

Slot params: Field (required, numeric), SplitBy (required, categorical, exactly 2 groups).

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_post runs between two output columns of the result set.

See

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_SR when the same subjects are measured twice.
  • TEST_KRUSKAL_WALLIS when there are three or more groups.

Params

  • alpha — float, default 0.05, in (0, 1).
  • weight — slot weight (null opts out), kind frequency only (a probability weight is PULSE_WEIGHT_UNSUPPORTED naming the kind): a row of weight w ranks as w identical rows — equals the test on the expanded rows. Details add sum_weights shaped like n (raw rows).
  • multiplicity — optional {method, family}; adds p_adjusted beside 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

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_F when there is only one numeric field.

Params

  • alpha — float, default 0.05. missing — listwise only.

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 (Details add sum_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_value means R prints NA.
  • 2 levels: all four share one F (= pooled TEST_HOTELLING_T2).

See

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_WILK when there is only one numeric field and you want a sharper normality test.

Params

  • statistic — skewness (default) or kurtosis: the headline. alpha — default 0.05. missing — listwise only.

Slot: one of vector (a vectors entry) / fields, ≥ 1 member; no split_by.

  • weight — frequency only (= expanded rows); sum_weights beside raw n.

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

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_WELCH when the two sets of values come from different, unrelated subjects.

Params

  • alpha — float, default 0.05, in (0, 1).

Slot params: Field (required, numeric), Field2 (required, numeric — the pre / before value).

  • weight — slot weight (null opts 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). Details add sum_weights (+ n_eff, probability) shaped like n (raw rows); n_eff < 2 warns PULSE_WEIGHT_LOW_NEFF.
  • multiplicity — optional {method, family}; adds p_adjusted beside 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; n counts complete pairs.
  • Tier-2 variant TEST_PAIRED_T/paired_two_sided_post runs over two output columns of the result set.

See

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_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.

Params

  • alpha — float, default 0.05, in (0, 1).
  • weight — slot weight (null opts out), both kinds: r is scale-free (same under either kind); t and df = N*−2 (Σw or Kish n_eff), Fisher CI on N*−3. Details add sum_weights (+ n_eff, probability) shaped like n (raw rows); n_eff < 3 warns PULSE_WEIGHT_LOW_NEFF; n_eff ≤ 2 (df ≤ 0) is PULSE_TEST_INSUFFICIENT_N.
  • multiplicity — optional {method, family}; adds p_adjusted beside 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_post over 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

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_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.

Params

  • alpha — float, default 0.05, in (0, 1).
  • weight — slot weight (null opts 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. successes becomes Σw_success; Details add sum_weights (+ n_eff) shaped like n (raw rows).
  • success — string, required. Dictionary value of Field treated as a “success”.
  • multiplicity — optional {method, family}; adds p_adjusted beside 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

  • success must match a dictionary value; otherwise PULSE_TEST_INVALID_SUCCESS.
  • Streamable — per-group counts feed both numerator and pooled denominator in one pass.
  • More than two SplitBy groups is a (SplitBy × Field) contingency-table question, not this test.
  • Pairs with OVERLAY_PROP_Z_CELL for crosstab cell-level proportion tests.

See

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_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.

Params

  • alpha — float, default 0.05, in (0, 1).
  • multiplicity — optional {method, family}; adds p_adjusted beside 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) is PULSE_WEIGHT_UNSUPPORTED with details.reason; set "weight": null on the slot to run it unweighted.
  • Buffered — requires the ordered values.
  • p calibrated for 5..5000 rows; outside, it still runs and per_group[].warning marks 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_post runs on a result column.

See

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_R when you want the strength of a straight-line link.
  • TEST_CHISQ when both fields are categories.

Params

  • alpha — float, default 0.05, in (0, 1).
  • weight — slot weight (null opts out), kind frequency only (a probability weight is PULSE_WEIGHT_UNSUPPORTED naming the kind): a pair of weight w ranks as w identical pairs; DF = Σw − 2 — equals the test on the expanded pairs. Details add sum_weights beside n (raw rows).

Slot params: Field (required, numeric), Field2 (required, numeric).

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_post runs over result columns.
  • Heavy ties degrade the asymptotic p-value.

See

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_T when the same subjects are measured twice, such as before and after.
  • TEST_ANOVA_WELCH when there are three or more groups.

Params

  • alpha — float, default 0.05, in (0, 1).
  • mu — float, default 0.0. Hypothesized mean, one-sample only; ignored when SplitBy is set.
  • weight — slot weight (null opts 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. Details add sum_weights (+ n_eff, probability) shaped like n (raw rows); n_eff < 2 warns PULSE_WEIGHT_LOW_NEFF.
  • multiplicity — optional {method, family}; adds p_adjusted beside 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 SplitBy groups, else PULSE_TEST_INVALID_SPLITBY.
  • Streamable — running Welford state per SplitBy group.
  • Constant Field within a group → PULSE_TEST_VARIANCE_ZERO.
  • n < 2 (per group) → PULSE_TEST_INSUFFICIENT_N.

See

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_OLS when you want the size of a straight-line slope.
  • TEST_WELCH when you compare the averages of two specific periods.

Params

  • alpha — float, default 0.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.

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) is PULSE_WEIGHT_UNSUPPORTED with details.reason; set "weight": null on 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

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_F when you only need to know whether any group differs.

Params

  • alpha — float, default 0.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 multiplicity family; an explicit non-none block 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) is PULSE_WEIGHT_UNSUPPORTED with details.reason; set "weight": null on 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.pairs for the full matrix.

See

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_T when the same subjects are measured twice, such as before and after.

Params

  • alpha — float, default 0.05, in (0, 1).

Slot params: Field (required, numeric), SplitBy (required, categorical, exactly 2 groups).

  • weight — slot weight (null opts out), both kinds: per-group moments on w*, Welch–Satterthwaite df on N*_g (Σw frequency, Kish n_eff probability; fractional). Details add sum_weights (+ n_eff, probability) shaped like n (raw rows); n_eff < 2 warns PULSE_WEIGHT_LOW_NEFF.
  • multiplicity — optional {method, family}; adds p_adjusted beside 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_T with SplitBy; 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_CELL on crosstabs.
  • Constant Field within a group → PULSE_TEST_VARIANCE_ZERO.

See

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_U when the two sets of values come from different, unrelated subjects.

Params

  • alpha — float, default 0.05, in (0, 1).
  • weight — slot weight (null opts out), kind frequency only (a probability weight is PULSE_WEIGHT_UNSUPPORTED naming the kind): a pair of weight w ranks as w identical pairs — equals the test on the expanded pairs. Details add sum_weights beside n; n and zero_diffs stay raw row counts.

Slot params: Field (required, numeric), Field2 (required, numeric — the pre value).

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_post runs over two result-row columns.
  • Pairing is per row: both values sit on one record.

See

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_WELCH when either group is small; the t-distribution p-value is more honest there.

Params

  • alpha — float, default 0.05, in (0, 1).

Slot params: Field (required, numeric), SplitBy (required, categorical, exactly 2 groups).

  • weight — slot weight (null opts out), both kinds: per-group moments on w*, SE reads N*_g (Σw frequency, Kish n_eff probability). Details add sum_weights (+ n_eff, probability) shaped like n (raw rows); n_eff < 2 warns PULSE_WEIGHT_LOW_NEFF.
  • multiplicity — optional {method, family}; adds p_adjusted beside 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

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_CHANGE when 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 weight is PROCESSING_CONFIG (windows carry no slot weight); Options.DefaultWeight is 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 == 0 emits cur.
  • Frame rejection happens at CONSTRUCTION, not just in predict — the factory returns PROCESSING_CONFIG for a non-nil frame. The rest of the family declares it in predict only, so a framed request skipping predict is ignored there but REFUSED here.
  • periods <= 0 rejected (PULSE_WINDOW_INVALID); either side null → null; rows NOT reordered (Request.Sort); buffered.

See

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_RANK when 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

ParamAccepted field types
Fieldmust 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 weight is PROCESSING_CONFIG (windows carry no slot weight); Options.DefaultWeight is skipped.
  • Tie comparison uses every order_by key; tie only when ALL keys equal.
  • WIN_DENSE_RANK does NOT distinguish row count from rank count.
  • Result rows are NOT reordered — use Request.Sort for response order.
  • Forces buffered execution (Streamable=false).

See

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_AVG when you want every row in a fixed window to count equally.

Params

NameTypeDefaultDescription
alphafloat—Smoothing factor in (0, 1]; higher = more weight on recent.
decayfloat—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

ParamAccepted field types
Fieldnumeric: 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 weight is PROCESSING_CONFIG (windows carry no slot weight); Options.DefaultWeight is skipped.
  • Exactly one of alpha / decay — both, neither, alpha outside (0, 1] or decay outside [0, 1) → PULSE_WINDOW_INVALID (runtime PROCESSING_CONFIG).
  • alpha = 1 − decay from the decay’s EXACT decimal: decay: 0.7 smooths bit-identically to alpha: 0.3 (not float 1 − 0.7). decay: 0 is alpha: 1 (identity).
  • Rows preceding the first non-null emit null (no seed).
  • Null values emit null but state survives through to the next non-null row.
  • Result rows are NOT reordered — use Request.Sort.
  • Forces buffered execution (Streamable=false).

See

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_DELTA when you want the difference from the earlier row, not its value.

Params

NameTypeDefaultDescription
offsetint1Lookback distance (≥ 0).
defaultanynullSubstitute when offset crosses partition start.

partition_by (carve), order_by (≥1, required, numeric / date), frame (forbidden).

Inputs

ParamAccepted field types
Fieldnumeric: 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 weight is PROCESSING_CONFIG (windows carry no slot weight); Options.DefaultWeight is skipped.
  • order_by required — predict rejects empty slate (PULSE_WINDOW_INVALID).
  • frame forbidden — set one and predict rejects.
  • Partitioning by the raw date collapses each row to its own partition. Pick a coarser partition (region, account) and order by date.
  • Forces buffered execution (Streamable=false).

See

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_LAG when you want the value from an earlier row.
  • WIN_DELTA when you want how much the field changed between rows.

Params

NameTypeDefaultDescription
offsetint1Lookahead distance (≥ 0).
defaultanynullSubstitute when offset crosses partition end.

partition_by (carve), order_by (≥1, required, numeric / date), frame (forbidden).

Inputs

ParamAccepted field types
Fieldnumeric: 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 weight is PROCESSING_CONFIG (windows carry no slot weight); Options.DefaultWeight is skipped.
  • Mirror of a lag — same partition / order / frame rules, opposite scan direction.
  • order_by required, frame forbidden.
  • Result rows are NOT reordered by order_by — use Request.Sort for response order.
  • Forces buffered execution (Streamable=false).

See

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_AVG when 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

ParamAccepted field types
Fieldnumeric: 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 weight is PROCESSING_CONFIG (windows carry no slot weight); Options.DefaultWeight is 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

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_DELTA when the earlier value can be 0, near 0 or negative, or the field is itself a percentage.

Params

NameTypeDefaultDescription
periodsint1Lookback distance (≥ 1) for the comparison row.

partition_by (carve), order_by (≥1, required, numeric / date), frame (forbidden). field (required, numeric).

Inputs

ParamAccepted field types
Fieldnumeric: 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 weight is PROCESSING_CONFIG (windows carry no slot weight); Options.DefaultWeight is skipped.
  • periods <= 0 REJECTED at predict (PULSE_WINDOW_INVALID).
  • prev == 0 emits null (no +Inf); a negative prev flips the sign.
  • Either side null emits null.
  • Result rows are NOT reordered — use Request.Sort.
  • Forces buffered execution (Streamable=false).

See

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_RANK when 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

ParamAccepted field types
Fieldmust 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 weight is PROCESSING_CONFIG (windows carry no slot weight); Options.DefaultWeight is skipped.
  • order_by defines BOTH scan order AND tie key — same field choice changes the answer.
  • Multiple order_by keys: tie only when ALL keys equal.
  • Result rows are NOT reordered — use Request.Sort for response order.
  • Forces buffered execution (Streamable=false).

See

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_RANK when rows with equal values should share a position.
  • WIN_DENSE_RANK when 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

ParamAccepted field types
Fieldmust 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 weight is PROCESSING_CONFIG (windows carry no slot weight); Options.DefaultWeight is skipped.
  • Ties on order_by: ROW_NUMBER picks a deterministic but arbitrary order.
  • Common top-N idiom: WIN_ROW_NUMBER partitioned 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.Sort for response order.
  • Forces buffered execution (Streamable=false).

See

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_AVG when 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

ParamAccepted field types
Fieldnumeric: 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 weight is PROCESSING_CONFIG (windows carry no slot weight); Options.DefaultWeight is skipped.
  • Mechanically identical to a moving average; the differentiator is FRAME — MOVING_AVG requires bounded both sides, RUNNING_AVG accepts unbounded preceding (cumulative).
  • Nulls skipped (not zero-filled); denominator is non-null count.
  • Result rows are NOT reordered — use Request.Sort for response order.
  • Forces buffered execution (Streamable=false).

See

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_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.

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

ParamAccepted field types
Fieldnumeric: 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 weight is PROCESSING_CONFIG (windows carry no slot weight); Options.DefaultWeight is skipped.
  • Frame REQUIRED — null preceding = 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

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

  1. 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).
  2. Filter manifest overlays[] on that intent, then on the HOST you have — each entry lists shapes, scopes, ref_kinds, buffered and inferential.
  3. 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:

FamilyCompares againstHost
margin{axis}the row / column / grand margincrosstab
sibling{field, value}another group’s valuegrouped Process
baseline_index{position}, prior, rolling_mean, yoyan earlier point in the same seriesgrouped Process
population{cohort}a reference cohortfacet
stage{index or name}an earlier chain stageprocess chain
reference + targets (spec top level)another Compose slotcompose

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 (NOT Request.Overlays): population comparisons, layers on FacetResult.Overlays (facet-design).
  • CHAIN — whole-chain ChainRequest.Overlays against 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 (reference vs targets); slots must align on keys, schema and dictionaries. Layers on ComposedResponse.Overlays[i] (top-level return prunes 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

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_weights under kind frequency, Kish n_eff under probability). Unweighted counts (cell_n_unweighted, margin _n, n_within, every distinct mode) are PROCESSING_CONFIG there, and cell_weight_sum / cell_value_weighted stand under frequency only.
  • The pair compares slices of a wider population (the share of a region WITHIN an issuer) → n_within, holding the first n_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_sourceLeg
cell_n_unweighted (default; empty means this)CellCounts[r][c]
cell_value_weightedthe cell VALUE, truncated
cell_weight_sumCellComponents[r][c]["sum_weights"]
row_margin_n / column_margin_nRowMarginCounts[r] / ColumnMarginCounts[c]
n_withinthe SLAB: CellCounts summed across the pair axis, first n_within_depth+1 dims held fixed
n_within_distinctthat slab, in distinct KEYS
row_margin_distinct / column_margin_distinctRowMarginComponents[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. --strict never turns one into an error and valid never 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.suggested is 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 CODE returns the fixup text.

Codes

CodeFires whendetails
PULSE_ADVISORY_TWO_GROUP_TEST_MANY_GROUPSa 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_TESTSp_values.uncorrected ≥ threshold (10) and no multiplicity block anywhere — request, slot or instance defaulttotal, uncorrected, basis, threshold, suggested: {"multiplicity": {"method": "holm"}}
PULSE_ADVISORY_CATEGORICAL_AS_NUMERICthe 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 itslot, operator, field, measure, suggested (a frequency count or chi-square)
PULSE_ADVISORY_ORDINAL_PARAMETRICthe SPSS sidecar records a numeric field ordinal and a parametric test reads itslot, operator, field, measure, suggested (the rank-based test from the operator’s not_for; absent when it names none)
PULSE_ADVISORY_WEIGHT_AVAILABLE_UNUSEDpredict echoes suggested_weight (an SPSS weight variable, no slot resolving a weight)field, source, suggested: {"weight": {"field": F}}
PULSE_ADVISORY_EXTRA_PASSan 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 carries details.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 ordinary Request.Overlays, run at that stage’s exit BEFORE the next stage reads its rows. Lands on stages[i].overlays. Nothing chain-specific.
  • Whole-chain (ChainRequest.overlays, ChainOverlaySpec) — runs AFTER every stage finalises and compares two stage results: one kind per manifest process_chain.overlay_kinds entry, an index (target / ref × 100) and a delta (target − ref). Each spec needs kind, scope, ref, target. Lands on ChainResponse.overlays in declared order — never on stages[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).

Fitqdf
OLS, ridgeStudent tresidual N* − p − 1 (fractional weighted)
GLMnormal (Wald, R confint.default)absent
Bayesian linearposterior t, = credible_intervals at one level2a_N
Resamplenormal on the resampled SEabsent

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

EngineMatrix
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 linearthe 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) and vcov(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 (raw vcov(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

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.

DecidePushes 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)

ValueBehavior
""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)

ValueBehavior
""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

NameSpec
Simple / Linear / MultipleREG_OLS, one or more predictors
RidgeREG_OLS{Penalty:"l2", Alpha:λ}
LassoREG_OLS{Penalty:"l1", Alpha:λ}
Elastic NetREG_OLS{Penalty:"elasticnet", Alpha, L1Ratio}
LogisticREG_GLM{Family:"binomial", Link:"logit"}
PolynomialFEAT_POLY upstream → REG_OLS
Bayesian LinearREG_BAYES_LINEAR{Prior:"nig"}
Jackknifean OLS / GLM spec with Resample:"jackknife"
Stepwisean OLS / GLM spec with Selection:"stepwise", Criterion:"aic"|"bic"
Ecologicala 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: raise MaxIters / Tol, or reduce Alpha.
  • Knobs belong to their engine: a penalty on the GLM, or Penalty / Family / Link on the Bayesian fit, is rejected (PROCESSING_CONFIG).

See

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"; additive data fields 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 via pulse_errors_lookup.

Request shapes (per command)

Operation (CLI)Wire typeTop-level keys
process, predict (pulse api process)Requestcohort, time_zone, filterers, features, attributes, groups, aggregations, windows, sort, tests, post_tests, joins, crosstab, overlays, outputs, return, multiplicity, vectors, matrices
compose (pulse api compose)ComposedRequestrequests[] (each = Request), return (overlays only)
process chain (pulse api process-chain)ChainRequestcohort, stages[], overlays (each stage: {request: Request})
facet (pulse api facet)FacetRequestcohort, time_zone, fields[], top_k, percentiles, histogram, additive, overlays
sample (pulse api sample)SampleRequestcohort, 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.

WrongRight
groupersgroups
aggregatorsaggregations
filtersfilterers
tests_postpost_tests
outputoutputs
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 typeDefault aggDefault grouper
numeric (u4, u8..u64, f32/f64, decimal128)AGG_SUMGROUP_RANGE (interval 10)
categorical_*, packed_boolAGG_MODE_COUNTGROUP_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).

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

FormRule
{"$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: only YYYY-MM-DD (03/04/2024 is ambiguous).
  • period: exactly one of ranges or table, mirroring the labeled date-range operators’ Params shape; 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 fileBehaviour
at pulse.New()hard-fails startup, path named — a boot break is a deploy error
after startup, parsed beforeserves last-good parse; ListTemplates marks Broken + Error
after startup, never parsedlisted 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.

ShellJSON keys
AggregationComponentsn, n_null
GrouperComponentstotal_n, n_null
FiltererComponentsn_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.

BlockCardinality · identityCarries
aggregationsone per Request.Aggregations, declared order · labelfloor + operator; grouped: cohort-wide floor + groups (below)
groupersone per Request.Groups, declared order · field (+ label when a field repeats)floor + operator (bucket edges, dictionary mappings, buckets)
crosstabonly when the Request carried crosstabcell / margin / axis-key components (below)
filterersone per Request.Filterers, declared order · labelfloor only
runalways on a successful runtotal_records (pre-filter), filtered_records, null_records, shard_count (0 = single file), partial_cohort_reason (a shard failed to open)
matricesone per Response.Matrices result, same order · namen (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): each aggregations[i] carries the COHORT-WIDE floor (every filter-passing record, no operator) and groups[] — one {group_key, n, n_null, weighted floor, operator} per Data row, in Data order (sort included), equal to an ungrouped run over that bucket. A stream carries groups[] 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.
  • run coexists with Response.Metadata: Metadata.TotalRows == Run.TotalRecords; Metadata keeps non-numerical run facts (cohort filename), run the 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

KnobScope
pulse.Options.DisableComponents boolengine default for every request the instance runs
types.Request.DisableComponents *boolper request — nil inherits, true forces off, false forces ON even on an engine shipping them off
--no-componentsCLI, 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.

ClassWireMeaning 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; no components.
  • minimal — the PRIMARY RESULT only: data, warnings, crosstab matrix, matrix primaries, test statistic and p-value, regression coefficients. No metadata, no components.

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, naming returned, 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 as unresolved_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) shapes responses[i]; ComposedRequest.return shapes top-level overlays only (overlays… paths), responses stays whole.
  • Chain: each stage has its own return, applied AFTER the whole chain, so a stage excluding data still feeds the next stage; final follows the last stage.
  • Streaming (CLI --stream, library iterator): rows carry only selected columns, returned rides the terminal chunk only, excluding data emits no rows; no PULSE_RETURN_PATH_UNMATCHED (predict unresolved_includes instead). MCP has no streaming. Precision is wire-only: StreamChunk values 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.

#CallCadenceReturns / effect
1pulse_manifest (intent scopes it to one question kind)once per sessionoperator 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
2pulse_inspectonce per cohortschema (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
3pulse_examples_searchper questionname + summary, by query + tags + category; or pulse_recommend by intents ID
4pulse_examples_getper candidaterunnable Request JSON (body, _meta stripped). Adapt cohort filename / fields / labels — do not invent
5pulse_skills_geton demandshape, gotchas, contract; pick via session-skill-routing. Runnable JSON comes from examples, NOT skills
6pulse_predict (bare request, or composed, or facet, or chain)until cleanerrors, warnings, data.suggestions, data.defaults_applied, data.streamable, data.streamable_reasons
7the 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_lookupexecute{format_version, data, errors, warnings} + additive data.components; format_version is "1.1"; read via pulse_explain
8pulse_errors_lookupper unique codecanonical 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

QuestionSkill
Which skill to fetch for an operator prefix, manifest entry, request / response slot or CLI leaf; reading data.components on first sightsession-skill-routing
Excel / SPSS read flags, parent groups on the managed import path, .sav write flagssession-format-flags
pulse profile create and pulse synth from-profile flagssession-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 *.json beneath it is parsed as a label table and an unparseable file hard-fails pulse.New; Pulse’s own .spss.json / .meta.json sidecars 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 via RenderTemplate / RenderTemplateRequest, then predict the rendered request. See request-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).

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.

FlagFormatLeavesMust know
--sheetExcelpulse import excel, import predict, import schema-template, pulse import auto; pulse_import as sheet—
--spss-missing (auto | null, default auto)SPSS .sav / .zsavpulse import spss, import predict, import schema-template, convert, convert predictauto 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
--charsetSPSS .sav / .zsavthe --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.

FlagMust know
--ignore-sidecarsynthesise 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
--uncompressedflat 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-namesrewrite 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.

TriggerSkill 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].kindsynthetic-data
cohort_types[i].name (field type)cohort-schema-design
a .sav / .zsav source, or a cohort carrying a .spss.json sidecarspss-cohorts
mcp_tools[i].nametool-<name minus pulse_> — one atomic skill per tool
pulse_lookup / pulse index build / pulse index list / pulse index verify / pulse index drop / pulse api lookuptool-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 cohorttool-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 mindintents (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 caveatstool-explain
a statistical term in a result or skillglossary (pulse-skill://glossary)
error_codes[i]pulse_errors_lookup — the tool is the surface, not a skill
Request slot Joinsjoin-design
Request slot Crosstabcrosstab-guide
Request slot Overlaysoverlay-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.LabelBindingslabel-display
ComposedRequestcompose-requests
ChainRequestprocess-chain
FacetRequest / FacetSchemaRequestfacet-design
streaming, watch, request hashingstreaming-and-watching
extension operator (anything in manifest.extensions)docs/src/internals/extension-points.md
predict failed — fix loopdocs/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.

FlagAdds
--conditionalpick-one conditional pair sections (numeric-numeric, categorical-categorical, categorical-numeric, three set_* arms)
--fit-shape2-component Gaussian mixture per numeric on a BIC win; generates as mixture, not normal
--fit-modelsone 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-continuationrun_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.

FlagMust 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

  1. Columns the .sav never declared — <var>_missing siblings, one set_* per multiple-dichotomy question. Count columns from pulse_inspect / ReadHeader, never from the SPSS variable count.
  2. Categorical columns hold codes — "1" / "2", not Male / Female.
  3. Never point PULSE_LABEL_TABLES_DIR at a cohort directory — it parses every .json beneath it.

SPSS import — what it changes about a .pulse schema

  1. The schema is not inferred. An SPSS dictionary DECLARES every column, so internal/io/spss implements io.SchemaAwareReader and internal/io/infer.go’s sample-and-vote pass is skipped: SampleRows, SetInferenceMinPct, SetDelimiters are inert, ColumnTypeOverrides is refused (PULSE_IMPORT_OVERRIDE_INVALID), and there is no null promotion — declared nullability is a contract, so an unexpected null is PULSE_IMPORT_ROW_ERROR. An explicit ImportJob.Schema still wins.
  2. The cohort can be wider than the source. Two derived kinds: a <var>_missing categorical_* sibling per numeric variable declaring user-missing values (the null bitmap is one bit and cannot say why), and one set_* column per multiple-dichotomy response set, emitted beside its constituents. --spss-missing=null suppresses the siblings; the set_* 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

QuestionSkill
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 codespss-missing-values
Multiple-dichotomy vs multiple-category sets; the derived registry and its closed kind vocabularyspss-response-sets
What cohort.pulse.spss.json holds; absent vs stale vs invalid vs ignoredspss-metadata-sidecar
Writing .sav: the writer, the four flags, convert, export predictspss-export
Variable-name policy, set_* masks, nulls, codes vs positions, byte order on the way outspss-export-values
What the round-trip claim rests on; the write-side charset rulesspss-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.

  • cohort-schema-design — .pulse field-type matrix, nullability bitmap, sidecar index.
  • label-display — resolving SPSS value labels from the codes the cohort stores.
  • tool-import — the pulse_import MCP surface and its SPSS caveats.
  • session-bootstrap — --spss-missing / --charset flag 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: -1 when the count is unknown up front: Finish patches it via DictionaryPlan.SetCaseCount, which writes the header int32 and record 7/16 together — two disagreeing counts are a file no reader can adjudicate. A -1 plan emits no 7/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 --tz refused with PULSE_SPSS_EXPORT_UNSUPPORTED, not silently ignored — project or relabel into a narrowed cohort, then export that; a .sav DATETIME has no offset slot, so it stays UTC.
  • Overlays warn-and-skip, like CSV.
  • A convert has 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, naming converted-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 .pulse byte-identical to the first (cohort identity — .sav byte-identity is unreachable for the reasons in spss-export-values); emitted .sav a fixed point under a second export; exactly the source’s own variables declared. TestRoundTrip_MatrixCoversFR62 fails 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 independent foreign 0.8.91, requiring each reader’s cycled read to match its own source read. Both pass — but they t.Skip without 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 nor foreign exposes 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-int32 form of record 7/11: Pulse READS two- and three-int32 shapes, 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. ColumnTypeOverrides is refused (PULSE_IMPORT_OVERRIDE_INVALID) — re-type via an explicit ImportJob.Schema.
  • No null promotion — declared nullability is a contract; an unexpected null is PULSE_IMPORT_ROW_ERROR, never a silent widening. promoted_fields always empty.
  • Explicit ImportJob.Schema still wins.
SPSSPulseWhy
numeric (F/E/COMMA/DOT/PCT…)f64no range-probe narrowing — a probe types two identical files differently
numeric with value labelscategorical_u8/u16/u32width 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/JDATEdate, else datetime + PULSE_SPSS_DATE_WIDENEDwidens only on a time-of-day value; pre-1970 stays date (signed epoch days)
DATETIMEdatetime (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/DTIMEf64 secondsdurations, never a datetime
system-missing (sysmis)null (bitmap bit)the one missing state with a format sentinel
numeric user-missingnull + <var>_missing siblingspss-missing-values
categorical/string user-missingdictionary entries, verbatim + FLAGGEDspss-missing-values
multiple-DICHOTOMY set (7/5, 7/7, 7/19)every constituent PLUS a derived set_*additive, never a replacement
multiple-CATEGORY setN categorical_*; definition sidecar-onlypositional, 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-inconsistent ZHEADER/ZTRAILER index, 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 record 3/4 set — a label is display metadata, so refusing the file would cost the data to save the labels), _VERY_LONG_STRING_INVALID (unusable 7/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?

StateVerdictCodeThen
no filewarningPULSE_SPSS_SIDECAR_ABSENTsynthesise a default; the normal case for synth / CSV output
size or mtime movederrorPULSE_SPSS_SIDECAR_STALEnothing — no resolution returned at all
not JSON / foreign kind / unknown format_version / bad digesterrorPULSE_SPSS_SIDECAR_INVALIDnothing
IgnoreSidecar set, file presentwarningPULSE_SPSS_SIDECAR_IGNOREDsynthesise 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 .sav where IF q1 EQ 5 addresses 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-sidecar on pulse 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 under omitempty with no SidecarFormatVersion bump, so an ABSENT fields key means “written before the slot existed” ⇒ back-filled from variables[].short_name (case-insensitive, first declaration wins, unknown member → ""). WRONG LENGTH ⇒ rejected — index-for-index with variables, 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:

NumericCategorical / string
where can the reason live?nowhere — f64 has no dictionary, bitmap is one bitalready there — the code IS a dictionary entry
mappingcolumn nulled + generated <var>_missing siblingcode verbatim, entry FLAGGED
cost of the other choicereason lost outrighta redundant sibling on EVERY item — 200 questions → 400 columns, no new information
--spss-missingauto (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 — additive omitempty; no such codes ⇒ byte-identical document, SidecarFormatVersion unmoved.
  • 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 on derived[].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_u256 is 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.

kindFold actionOpt out
numeric_missingCONSUMED — its per-row ID decides what its one source variable writes wherever that variable is null--spss-missing=null
multiple_dichotomyDROPPED — every bit re-reads a constituent still in the cohortnone by design; one column per set
  • kind is a CLOSED vocabulary (DerivedKinds() in internal/io/spss), one action each via DerivedFoldFor, which reports false otherwise — 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 carries set_name + sources in BIT order.
  • Derived columns INTERLEAVE ⇒ variables[].position is 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:

  1. 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).
  2. In pulse_manifest, keep the tests[] entries whose intents carry that ID. Each entry also states its tier and streamable.
  3. Read the chosen op-test-<name> skill; narrow by data shape (below), not by catalogue.

Two tiers, one shape

SlotStageConsumesCost
tests (tier 1)during row scanraw recordsnear-zero when reusing aggregator Welford state
post_tests (tier 2)after windowsmaterialized result rowsone 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:

QuestionPushes 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:

  1. Per-group normality — the Shapiro-Wilk test with split_by (n ≤ 5000 per group; larger → PULSE_TEST_SHAPIRO_N_BOUND).
  2. 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:

  1. Tier-1 one-way ANOVA; read ms_within / df_within from Details.
  2. 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

  • alpha defaults to 0.05, range (0, 1) (else PULSE_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: multiplicity adds p_adjusted beside 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 field names match the aggregator’s projected column (AGG_<TYPE>_<field>).
  • Tier-2 Welch’s ANOVA needs params.n_col + params.variance_col; Tukey HSD needs params.ms_within + params.df_within from a tier-1 ANOVA.
  • Tiny groups → unstable p (PULSE_TEST_INSUFFICIENT_N floor).
  • 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 atomic op-test-<name>.
  • aggregation-design — Welford-triple reuse + MetaAggregator contract.
  • 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 — operator carries 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; grouper field, label, total_n, n_null) with operator nil. 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 (fieldMoments validates, quantileFor builds Q_i). Anything else in correlations → SERVICE_VALIDATION. |rho| ≥ 1 rejected at validation.
  • A participant must also be a SCALAR field TYPE, and that predicate is DERIVED, never transcribed. isNumericFieldType reads fieldTypeFromName: 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 of Spec.Correlations once — do not reintroduce one. The boundary is TYPE, not distribution: a date’s uniform_date is refused one call later, NAMING the distribution.
  • normal reduces exactly to mean + 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, and min/max clamping 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 mixture arm exists for the MODEL draw, not for correlations: a --fit-shape field is excluded from the value-scale matrix either way (pre-claimed when unmodelled, permanently refused when modelled — its structure rides residual_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. correlations is a LIST, the Cholesky needs a MATRIX, so every unnamed pair is filled with 0 (drawn independent) and buildCorrelator WARNS 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

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:

  1. 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 emitted std. 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.
  2. Float fusion. Go may contract a + b*c into 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 explicit float64(...) conversion — the only construct that forbids contraction — and internal/synth/moments_internal_test.go fails if one is dropped (it can only DETECT on a contracting architecture, so it is silent on CI by construction). math.FMA is 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

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

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

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

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 the quantileFor the draw used) before the refit, and the captured coefficient is divided by the target’s marginal std — precisely the division buildModelDrawers performs. 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-shape target as badly recovered while generation was exactly correct. latent_scale rides each entry; scale says "latent".
  • A STAIRCASE target (bernoulli, discrete) recovers through a CALIBRATED probit score, marked scale: "probit_score". A step Q has 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_expected EXACTLY under faithful generation, so dividing by b_expected / captured REMOVES the attenuation rather than estimating it. That ratio ships as score_retention and divides the standard error too — low retention is a WIDE BAND, not a suspect number. No K gate (K = 2 is not degenerate, merely the case retaining least); below minScoreRetention (0.05) the entry ships an error. A staircase entry reports no recovered intercept — its cut points come from the captured marginal, which generation holds exactly.
  • Only applied models. BuildModelFidelity re-runs resolveConflicts AND buildModelDrawers against the same *Spec generate() 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 and Categorical.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 no predictors array (an empty one would read as failure) and the intercept comparison only.
  • Flagging band. flagged fires only when the gap exceeds both internal/synth.ModelRecoveryTolerance (0.10 latent sd) and twice the refit’s own standard error. n_fired separates “fired thousands of times and recovered nothing” (a fault) from “never fired” (nothing to recover).
  • Same engine, same adapter, unpenalized. internal/processing/regression through dummyRecord, 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:

  1. Well-supported columns move too, by ≈50/n_j — bounded, small, not zero.
  2. GATED on a thin column being present ⇒ clean designs stay exact OLS, at the cost of a bounded discontinuity at the boundary.
  3. 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

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.

QuestionSkill
Which predictors enter; how thin levels are shrunksynth-model-selection
How a modelled value is drawnsynth-model-draw
A --fit-shape mixture target under a modelsynth-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 modelsynth-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_alpha omitempty; absent ⇔ unpenalized OLS (synth-model-selection).
  • Fitted residual reservoir (FieldModel.Residuals / ResidualPresent) also json:"-" — reach it via synth.Profile.FittedModels(), never a re-read document.
  • No flag ⇒ no models key ⇒ 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 in Profile.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 under conditional.): conditional.numeric_pairs {a,b,rho,n} row-aligned, n the TRUE co-occurrence count (both fields non-null on one row) — preferred over pairwise when 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-k collapses to "other" FIRST, then ContingencyCellCap (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 — plus overall, high_fields (rate ≥ 0.75) and advice (low: below 0.5, pointing at an upstream ORDER BY of the parent key). A ROW-ORDER fact, not a distribution: SpecFromProfile never reads it. It needs every record, so it is not in pulse inspect. Pairs never span a shard; a shard archive profiles as one stream against the canonical schema (an archive.pulse#shard.pulse anchor is not resolved).
  • --fit-shape (numeric.shape, numeric only): a 2-component Gaussian mixture kept only on a genuine BIC improvement; SpecFromProfile then emits mixture instead of normal. 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 fieldRebuilt as
packed_boolbernoulli (synth-marginals)
integer with ≤64 observed levelsdiscrete (synth-marginals)
other numericnormal clamped to observed min/max, or mixture when shape is present
categoricalweighted_categorical
dateuniform_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

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

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 shapeClaims?Why
set / set_expr, no whenyeswrites every row from inputs the field’s own generation does not supply
carries a whennowrites only some rows; claiming strips the model from the rest, leaving a plausible bare marginal
set_null (any conditionality), owns_nulls or notnoremoves 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_togethernocopies one null decision; supplies no value
set_expr reading its OWN targetnotransforms 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

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, including packed_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 emitted when is 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_together has no dial for “almost” and, unlike a gating candidate’s when, nothing in it can be corrected. Measure is the null-SET overlap (nearBlockAgreement 0.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 constant with null_rate 1.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/u4 only; sources categorical_*/packed_bool/u4 with at most maxDepLevels (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. form names the reading: threshold / threshold_chain total, membership / membership_complement / enumeration_chain not.
  • ONE dependency candidate per SOURCE, carrying its null_together in the SAME rule (split out, the derivation runs afterwards and un-nulls every member) and every numeric term inside round() — so nothing writes the source and no !isnull guard is needed. No when, 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

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:

resultscalar targetcategorical_*set_*
bool1 / 0refusedrefused
number (float64/int)the number, RANGE-CHECKEDrefusedrefused
stringrefused (see decimal)must be a DECLARED valuerefused
selection (map[string]bool, option list, split())refusedrefusedthe 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

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_rate is 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_null becomes 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 a set_null over a NON-gate member of the same rule is overridden. Across rules, ordinary last-write-wins — and the case that bites is a later set_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_null and nothing else — declaring it with an empty set_null is PULSE_SYNTH_RULE_OWNERSHIP_INVALID, because an ignored claim is invisible.
  • It zeroes the draw rather than applying the residual (r − g)/(1 − g), because g — 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 discarded null_rate by 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), since set_null removes 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 — nullableSampler consumes one inner draw PLUS one rng.Float64() whatever the rate, so a rebuild shifts the whole seeded stream.
  • null_together already does this for its non-gate members, by COPY, so the gated-block idiom is ownership as a side effect. owns_nulls says 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

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:

CodeFires on
PULSE_SYNTH_RULE_EMPTYno action slot (_evidence alone is still empty)
_FIELD_UNKNOWNa slot’s name, AND an undeclared name inside isnull("…") — that expression COMPILES, so it is an unknown FIELD, not an _EXPR_INVALID
_FIELD_NOT_NULLABLEset_null only
_EXPR_INVALIDuncompilable, or a when not returning bool
_VALUE_INVALIDthe coercion matrix, either timing
_CONFLICTone rule naming a field in two slots that DISAGREE (set_null + null_together do not disagree and are ordered instead)
_BLOCK_INVALIDnull_together naming fewer than two DISTINCT fields
_OWNERSHIP_INVALIDowns_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:

  1. 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.
  2. PRE-ROUNDING — the predicate reads a field that rounds on write AND draws continuous values; remedy round(field) in an earlier rule, never int().
  3. 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

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 bare rules array — cut and paste between spec and file) and REPLACES Spec.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 bypasses validateSpec by design), so a refused file leaves Spec.Rules untouched.
  • --emit-spec <path> writes the derived spec AFTER the merge as indented JSON. The spec that GENERATED, not a rendering: fed to synth from-schema at the same seed it reproduces the rows byte for byte. Authoring aid (field names, types, floors a when must 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.

DetectorProposesAdmission rule (the non-guessable part)
gating (detector: "gating")set_null + "owns_nulls": truea 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_togetherIDENTICAL 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

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:

SectionOne entry perShape
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_pairsoption 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

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.

QuestionSkill
constraints[], the row expression environment, set_expr coercion, pre-roundingsynth-rule-expressions
null_together blocks and owns_nullssynth-rule-nulls
Refusal codes, non-nullable targets, a rule that never firessynth-rule-validation
Which rules retire a field’s model / pairs / residual correlationssynth-rule-claims
--rules / --emit-spec, and detecting rules with --suggest-rulessynth-rules-from-profile
Detector-specific rules and the emitted candidate ordersynth-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.

SlotValueNull mask
set_nullLEFT as drawn (the shape nullableSampler produces; the wire gets the type’s zero, never the masked value)set
set / set_exprwrittenCLEARED — 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 CODE is authoritative.
  • docs/src/cli/synth-from-schema.md — CLI surface; the rules array 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

QuestionSkill
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, generationsynth-set-fields
correlations: which marginals / types may joinsynth-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-reportsynth-fidelity-report
Byte-identical output; why a profile can driftsynth-determinism
--fit-shape mixturessynth-shape-fit
One distribution’s paramsatomic op-synth-<kind>

Two modes

ModeInputWhen
pulse synth from-schemahand-written JSON speccaller knows desired shape — fixtures, CI seeds, demos
pulse synth from-profileprofile JSON + the source cohort it was captured fromtagged 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 — values strictly ASCENDING + optional weights. The exact per-level histogram of an integer column; what profile create reconstructs 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 → a set_* mask, string REQUIRED for a categorical, verbatim exact string for decimal128 (ParseDecimal128). The only sampler valued from the document, hence the only one that could put a Go bool where the expr environment promises float64; a shape the row cannot hold is refused at parse.
  • set_bernoulli — options also 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 Q is normal); a quantized modelled target attenuates at write time — synth-models.
  • --fit-models does not imply --residual-correlations, and neither implies --conditional. --fit-shape composes with all of them.
  • weighted_categorical weights 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_DATE keys are local labels. component: hour keys YYYY-MM-DDTHH local wall clock (datetime only; the skipped DST hour has no bucket, the repeated hour is one bucket). week_start (week only) defaults to monday (ISO YYYY-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_PART and FEAT_DATE_FEATURES accept datetime and read the zone’s wall clock; part hour (and the sixth <prefix>_hour feature column) is datetime-only. Feature dow stays Sunday = 0 and ignores week_start.
  • OVERLAY_YOY has 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[] — full Response per slot in input order; each carries its own Data, Metadata, Components, per-Request Overlays.
  • data.overlays[] — OverlayLayer per Compose overlays[i] spec via internal/service/compose_overlay.go. Omitted when no Compose overlays declared.
  • data.overlays[i].warnings[] — per-layer OverlayWarning{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.DictPrefixFast when 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

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 .pulse cohort.
  • suggest_groups (bool): detect candidates over the cohort’s records. Alone the call is READ-ONLY.
  • groups (array): [{key: [...], members: [...]}], the pulse_import shape. 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_groups only, then pass the suggested candidates’ key/members as groups. Never guess.
  • Prefer out unless 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_CONSTANT and nothing is written.
  • Weak groups warn: PULSE_GROUP_TOO_NARROW (dropped), PULSE_DEDUP_LOW_RATIO (written; floor 2). Floor, strict and constant elision are pulse dedup flags only.
  • A grouped cohort is regrouped from scratch.
  • Sidecars are reported, never rebuilt; run each rebuild before the next point lookup.
  • Shard archives → SERVICE_VALIDATION: dedup each shard, then pulse shard create (it union-merges grouped shards). Output is format 0x02 (older binaries cannot read it).

See

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 (from pulse_imports_list or the name you passed to pulse_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_drop returns PULSE_IMPORT_SOURCE_MISSING for them. They were never managed.
  • Drop deletes both the .pulse file 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-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 / query MUST be set; all empty → error specify at least one of code, domain, query.
  • Every code carries at least one Fixup template OR FixupNotApplicable: true. Enforced by TestCodesHaveFixups.
  • Intersection preserves the first-supplied axis’s ordering (Search ranking, ByDomain alphabetical, or single-code).

See

  • Error-code list in pulse_manifest (error_codes slim 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.name field (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

  • body is types.Request — for ComposedRequest / ChainRequest / FacetRequest examples 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 _meta block is stripped on emit so the body is directly runnable; the surrounding record carries the same metadata externally.

See

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.valid lists 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.
  • category is 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-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, facet or sample. Never run; a root naming a cohort is predict-checked.
  • Response mode: response, composed_response, chain_response or facet_result, exactly as Pulse returned it, plus its own request beside it (request / composed / chain / facet).
  • detail: terse (default) or full.

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 null figure is undefined and reads not_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

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 .pulse file.
  • 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_schema when you need histograms, percentiles, null tallies, or multi-field rollups.
  • Categorical dictionaries are truncated only when the field uses DefaultDictionaryLimit semantics elsewhere; facet always returns the full distinct set encountered in records.
  • Unknown field → PULSE_REQUEST_UNKNOWN_FIELD.

See

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_percentiles forces a buffered per-field sort — not streaming.
  • additive_fields strips that field’s own filter clauses before counting — designed for UI panels that surface “what else could you add”.
  • FACET-host overlays (the *_VS_POP kinds) wire via service.applyFacetOverlays (FacetSchema-buffered-exit hook).

See

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

FieldNotes
source (req)path relative to PULSE_DATA_DIR
formatcsv/tsv/ndjson/jsonarray/parquet/arrow/excel/spss/pulse; default extension-detected (.sav/.zsav → spss)
handledefault = source basename
ttlGo duration (24h) or day form (7d); pin = no expiry (the only way). Default 7d
sheetExcel sheet name; inert elsewhere
charsetSPSS-only decode override (windows-1252/cp1252/1252 fold); empty keeps the file’s declaration
overwritedefault false → PULSE_IMPORT_HANDLE_EXISTS
groupsparent groups, [{key: [...], members: [...]}] (omit key for a plain tuple group); unknown entry key → PULSE_GROUP_DECLARATION_INVALID
suggest_groupsalso return measured group_candidates; one extra full pass, declares nothing
schemaexplicit schema [{name, type, nullable, description}] (precision/scale for decimal128) — the CLI --schema document; omit to infer
ignore_extrawith schema: drop columns / late keys it does not name instead of refusing
floatinferred float width: f64 (default) or f32; else PULSE_IMPORT_FLOAT_WIDTH_INVALID
source_tz / column_source_tz / dst_policyzone 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_fields always empty. Dictionaries hold SPSS codes, not labels (two codes may share one label) — resolve labels at output via a LabelTable. Writing .sav is CLI-only: pulse export spss, pulse convert x.csv out.sav.
  • Non-fatal PULSE_SPSS_* ride source_warnings / envelope warnings. Read them — they change what the cohort MEANS. Damage and self-contradiction (byte order, compression, charset) are FATAL coded refusals, never silent wrong numbers.
  • charset is the recourse when a .sav is wrong about itself (stale record 7/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>_missing sibling recording why. The only alternative discards those siblings; ask for it explicitly with pulse import spss --spss-missing=null.
  • A blank categorical cell is not always a null. ndjson / jsonarray / arrow / parquet carry the source’s own null channel, so JSON "" (or a set validity bit) imports as an empty-string VALUE and only null imports as null. csv / tsv / excel have 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 (schema here; CLI --schema on import, import predict, convert; library ImportJob / ConvertJob / ImportSpec Schema): exact spelling, any order; the array order is the cohort’s. Refused before any row is read: a field with no column PULSE_IMPORT_SCHEMA_FIELD_MISSING; a column no field names PULSE_IMPORT_SCHEMA_COLUMN_UNMAPPED; a repeated name PULSE_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 name PULSE_IMPORT_SCHEMA_TYPE_UNKNOWN; an unknown entry key, an empty array or a schema on a .pulse source PULSE_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_materialize it (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 raises PULSE_IMPORT_ROW_ERROR instead.
  • 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 one width_warnings entry (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; library ImportJob / ImportSpec SourceTZ). DST gap/overlap is FATAL PULSE_IMPORT_DST_AMBIGUOUS / _NONEXISTENT by default; dst_policy: earlier|later resolves and warns PULSE_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 with overwrite: true.
  • Zero rows out of a non-empty source is a FATAL PULSE_IMPORT_ROW_ERROR, not an empty import — details carry rows_read / rows_failed / first_row / first_error, and no cohort is written. Export and convert mirror it with PULSE_EXPORT_ROW_ERROR (convert’s row errors all come from the TARGET writer), and convert writes no --keep-pulse intermediate. PARTIAL failure is unchanged (rows land, the rest ride row_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, read group_candidates.candidates (suggested: true = the non-overlapping viable set), then re-import with those {key, members} as groups and overwrite: true. Groups write a smaller 0x02 cohort older binaries cannot open; a member varying within its key is FATAL PULSE_GROUP_MEMBER_NOT_CONSTANT. Weak groups WARN at the default floor 2 (PULSE_GROUP_TOO_NARROW dropped, PULSE_DEDUP_LOW_RATIO written) — there is no floor/strict knob because neither changes the bytes. Refused on a .pulse passthrough. No constant elision here — its value is at-rest bytes and a managed import is a TTL’d cache; use pulse import <fmt> --elide-constants.
  • One dispatch. Detection and reader construction use the io factory (io.FormatFromPath → io.NewReader), the same one the CLI and embedders use. No extension match and no format → PULSE_IMPORT_FORMAT_UNKNOWN; an explicit format outside the list → PULSE_IO_FORMAT_UNSUPPORTED.
  • A .pulse.zst (zstd transfer artifact) is refused with PULSE_COHORT_COMPRESSED, even renamed .pulse. Decompressing is an operator act: pulse import transfer (CLI) — no MCP slot.
  • Passthrough skips copy + sidecar (pulse_drop no-ops). MCP success rebinds session-scoped tools from the new cohort (as tool-inspect).

See

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=true entries are still openable (Pulse never garbage-collects the underlying .pulse); use pulse_drop to evict.
  • pinned=true ⇒ TTL is pin; expires_at is reported as zero-value.

See

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 .pulse file or shard archive; archive.pulse#shard.pulse opens 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; verdict at floor 2).
  • suggested_weight is NEVER applied — name it as a request weight; SPSS WEIGHT BY replicates cases (kind: "frequency"). Absent, stale or bad sidecar ⇒ none.
  • record_count is DERIVED (payload bytes / stride); a torn tail reports the FLOOR, signalled only by an ENCODING_INVALID warning (record_stride, trailing_bytes). Library callers use Pulse.InspectEnvelope — Pulse.Inspect drops it.
  • FullDict: true (--full-dict): full label list.
  • Unknown magic / format-version mismatch → ENCODING_INVALID.

See

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 (from pulse_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. Default 10.

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.9 AND 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 enumerable via pulse_label_tables first.
  • Empty query is valid — browse mode returns the first rows up to limit with score 0.

See

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=false tables are forward-only (key→label); pulse_label_resolve rejects 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-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 over field/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 (default assert_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, read pulse index list / Pulse.ListIndexes — a sidecar’s filename is a hash of its key tuple, so globbing .idx tells you nothing and is unavailable on object storage; the listing is served from the keyless <cohort>.indexes.json manifest.
  • 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_checked says 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.pulse anchor works around it.
  • PULSE_LOOKUP_NOT_FOUND — fresh index, no matching record.
  • PULSE_LOOKUP_AMBIGUOUS — default assert_unique rejects >1-row matches; opt into first/all for 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_get and per-error prose via pulse_errors_lookup on demand.
  • Operator names in components.aggregators[] / components.groupers[] are the manifest catalog, NOT the request slot keys — request bodies use "aggregations" / "groups" (see tool-process Gotchas).
  • Each operator entry carries its component_schema; the unscoped manifest also keys them by name under components_schemas.

See

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 (the pulse_process shape) with cohort. Only cohort, label, features, filterers, attributes, windows, sort, weight, time_zone, disable_components.
  • out (string, required): the NEW .pulse path, relative to the data dir.
  • overwrite (bool): replace an existing cohort at out.

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 out without overwrite → SERVICE_VALIDATION. Every refusal happens before anything is written; the write is atomic.
  • sort orders the ROWS only (keys: source fields or derived columns; Process’s comparator, nulls last); window values follow each window’s own order_by.
  • Output is always a single-file cohort; source sidecars are not carried.

See

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.go MUST NOT import internal/service/ or internal/processing/ (enforced by TestPredictNoExecutionImports). Header + schema only.
  • Streamable reflects per-operator Streamable() plus schema gates (decimal128 forces buffered).
  • CrosstabFusable is absent (nil) without a crosstab; it runs the engine’s own fusion rule on the defaulted request and honours Options.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.
  • DefaultsApplied is always computed — disabling defaults at request time via --no-defaults does NOT suppress the report.
  • Unknown top-level keys are rejected with PULSE_REQUEST_UNKNOWN_FIELD and a “did you mean” suggestion.

See

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_FIELD with 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_get or pulse_manifest.
  • Smart defaults: omitting Type on a slot that names a field auto-infers from schema type. Disable via Options.DisableDefaults or CLI --no-defaults.
  • models: [{name, path}] loads saved model files by name for scoring references; see compose-fits (Loading).

See

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_chain block’s mergeable_aggregators, mergeable_groupers and row_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 by pulse_compose).
  • Synthesized schema between stages: grouper keys → categorical_u32 columns; aggregator outputs → f64.

See

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.New time (non-overlapping, unique labels, non-empty, parseable bounds) — a listed table is always safe to reference.
  • Both bounds are inclusive; an empty/absent start or end is an open bound.
  • Entries are calendar days with no zone of their own: over a datetime they match the local day in the referencing slot’s resolved zone, exactly like inline ranges.
  • Reference a table by name from the grouper’s table field or the filter’s Params.table — do not re-inline the ranges.

See

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 the intents skill. Unknown or empty → PULSE_RECOMMEND_INTENT_UNKNOWN; details.valid lists every ID.
  • cohort (string): a .pulse path. 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 in placeholders) must be replaced; then pulse_predict it.
  • bound: false on a cohort draft means it needs a value only you can choose — a success value, a reference mean, a model family. Fill each needs[].param before running.
  • bound: true drafts passed predict; still read their advisories.
  • Empty recommendations + routes_to is 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

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 .pulse file.
  • count (number, optional): maximum rows to return. Default 10.

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: path may be either the archive or archive.pulse#shard.pulse anchor. Sample reads from the resolved cohort’s first records.
  • count <= 0 or non-numeric returns the default 10.

See

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

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

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 ## See section to the topical design skill.
  • applies_to only carries valid CLI leaves (process, process-chain, compose, sample, facet, inspect, predict, manifest, mcp). Invalid entries fail TestSkillsManifestConsistent.

See

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-u16 for 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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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 as u64 micros).
  • Cross-link: type-date for 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

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

WhereShapeNotes
Request weight{"field": "wt", "kind": "probability"}kind ∈ probability (default) | frequency
per-slot weight"wt", {field, kind} or nullaggregations (crosstab cell, margin_aggregations[i]), tests, post_tests, regressions, attributes, overlays, groups, crosstab axes. Windows have none
pulse.Options.DefaultWeight*types.WeightSpecinstance 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. Adds n_eff.
  • frequency — a row stands for w identical 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).

ClassExplicit weightOnly Options.DefaultWeight
weight_kinds both kindsweightedweighted
weight_kinds: ["frequency"]frequency weighted; probability ⇒ PULSE_WEIGHT_UNSUPPORTEDsame
min / max / distinct-style aggregators, min-max rescalePROCESSING_CONFIGskipped
windowsrequest weight PROCESSING_CONFIGskipped
filters, features, row-local attributes, most groupersskippedskipped
inferential, no weight_kindsPULSE_WEIGHT_UNSUPPORTEDsame

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_obs and add sum_weights + n_eff (test details, regression results, overlay summary.parameters). Read those as the sample size, never n.
  • 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_source raw-count and distinct-key modes are PROCESSING_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}
}
KeyRequiredNotes
typeyesA window constant from pulse_manifest components.windows.
fieldconditionalRequired for value-bearing ops; forbidden for the row-number / rank family (they read only order).
labelnoOutput column; default <TYPE>_<field>. Naming an existing output column or earlier window label is SERVICE_VALIDATION (shadow).
partition_bynoField names. Empty → single global partition.
order_byyes (≥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.
frameconditionalRequired for running, moving and exponentially weighted aggregates; forbidden for offset (lag / lead), rank and change ops. Mode always "rows" in v1.
paramsper opOperator-specific overrides (offset, alpha, periods, default, …).

Partition / order / frame

The three axes every window shares:

  • Partition — partition_by carves the row set into independent slices. The math computes inside one slice and never crosses. Empty → one global slice.
  • Order — order_by defines the scan direction inside the partition; the stable nulls-last comparator is shared with Request.Sort and post-test order_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:

  1. Compute the partition map from partition_by.
  2. Sort each partition once by order_by.
  3. For each row in scan order, read the framed slice and emit one value into label.
  4. Result rows are NOT reordered — order_by is scan order, not response order; use Request.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 date field 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 atomic op-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_process MCP tool — see skills/session-bootstrap.md and skills/aggregation-design.md for request authoring guidance.

Synopsis

pulse api process --request FILE [--json] [--stream] [--no-defaults]
                                 [--no-components] [--no-project]
                                 [--strict] [--echo-request]

Flags

FlagAliasTypeDefaultPurpose
--request-rstring(required)Path to the request JSON file
--jsonboolfalseEmit the result wrapped in the JSON envelope
--streamboolfalseStream rows as NDJSON (one per line) instead of buffering
--no-defaultsboolfalseDisable smart operator-type inference; require explicit Type on every aggregation and grouper
--no-componentsboolfalseSuppress 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-projectboolfalseDisable 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
--strictboolfalsePromote request-validation warnings (e.g. numeric aggregation on a categorical field) into hard errors
--echo-requestboolfalseInclude 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

CodeMeaning
0Success
1Any 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)'

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_compose MCP tool and the compose-requests skill.

Synopsis

pulse api compose --request FILE [--json] [--stream]
                                  [--parallel N] [--no-fail-fast]
                                  [--no-defaults] [--echo-request]

Flags

FlagAliasTypeDefaultPurpose
--request-rstring(required)Composed-request JSON path
--jsonboolfalseWrap output in the standard envelope
--streamboolfalseStream rows as NDJSON; each line is {"index": N, "row": {...}}; a ranked sweep ends with one {"ranking": [...]} line
--parallelint1Worker count; 0 = GOMAXPROCS, 1 = sequential
--no-fail-fastboolfalseAggregate errors across slots instead of cancelling on first failure (parallel mode only)
--no-defaultsboolfalseDisable smart operator-type inference
--echo-requestboolfalseInclude 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) — sequential Compose, equivalent to running each request through pulse api process in a loop.
  • 0 — runtime.GOMAXPROCS workers.
  • >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_INTERNAL error whose details.failed_indices lists the slot indices that failed.
  • Successful slots populate data.responses[]; failed slots are null.

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

CodeMeaning
0All requests succeeded
1One 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)'

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-design skill and the pulse_inspect tool.

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

FlagTypeDefaultPurpose
--jsonboolfalseEmit the standard envelope
--full-dictboolfalsePrint 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

CodeMeaning
0Success
1File 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'

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_predict MCP tool and the tool-predict skill. Predict is the LLM’s primary “would this work?” probe.

Synopsis

pulse api predict --request FILE [--json] [--strict] [--echo-request]

Flags

FlagAliasTypeDefaultPurpose
--request-rstring(required)Request JSON path
--jsonboolfalseEmit the standard envelope
--strictboolfalseTreat warnings as errors
--echo-requestboolfalseInclude 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 typeDefault aggregationDefault grouper
u4, u8..u64, f32, f64, decimal128AGG_SUMGROUP_RANGE (interval 10)
categorical_u8/u16/u32AGG_MODE_COUNTGROUP_CATEGORY
date(none — must be explicit)GROUP_DATE (component "day")
packed_boolAGG_MODE_COUNTGROUP_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

CodeMeaning
0Valid (or valid with warnings, in non-strict mode)
1Invalid, 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.

LeafRequest filedata on --json
pulse api predict-composea ComposedRequestthe batch verdict: valid, the echoed request, rejected overlay pairs, overlay cost, the p-value count and each slot’s advisories
pulse api predict-faceta FacetRequestvalid, the echoed request, schema_info and the accepted overlays
pulse api predict-chaina ChainRequestvalid, 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

CodeWhat to do
PULSE_AGG_NOT_MEANINGFUL_FOR_CATEGORICALUse AGG_COUNT / AGG_MODE_COUNT instead of AGG_SUM / AGG_AVG on categoricals
PULSE_AGG_NOT_MEANINGFUL_FOR_DECIMALDecimal-typed field; switch to a decimal-aware aggregator
PULSE_ATTR_CODE_NOT_IN_DICTIONARYAn 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_QUALITYEdit the schema description; re-import
PULSE_FEAT_TARGET_LEAKAGE_RISKEvery 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.

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_sample MCP 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

FlagAliasTypeDefaultPurpose
--input-istring(required)Cohort .pulse file path
--count-nint10Rows to sample
--jsonboolfalseEmit the standard envelope
--labelsstring(none)Categorical label binding: `field=table[:replace
--echo-requestboolfalseInclude 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

CodeMeaning
0Success
1File 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 process with a FILTER_* 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.

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 a FacetResult covering 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_facet MCP tool for simple mode and pulse_facet_schema for 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

FlagAliasTypeDefaultPurpose
--input-istring(required for simple mode)Cohort .pulse file path
--field-fstring(required for simple mode)Field name to facet on; repeat for rich mode
--request-rstring(none)Full FacetRequest JSON file (overrides individual flags)
--top-kint0Cap discrete values per field (rich mode)
--percentilefloat(none)Numeric percentile in (0, 1); repeatable (rich mode)
--histogramboolfalseInclude numeric histograms (rich mode)
--histogram-binsint20Histogram bin count
--histogram-minfloat(none)Histogram lower bound (required with --histogram)
--histogram-maxfloat(none)Histogram upper bound (required with --histogram)
--additivestring(none)Compute additive contribution counts for this field; repeatable
--labelsstring(none)Categorical label binding: `field=table[:replace
--jsonboolfalseEmit the standard envelope
--echo-requestboolfalseInclude 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 typeBehaviour
categorical_u8 / _u16 / _u32Read directly from the schema’s inline dictionary; O(distinct values), no record scan
Non-categoricalFull 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

CodeMeaning
0Success
1File 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

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_lookup MCP tool and the tool-lookup / cohort-sidecar-index skills.

Synopsis

pulse api lookup --input PATH --key FIELD=VALUE [--key FIELD=VALUE ...]
                 [--return FIELDS] [--mode MODE]
                 [--request FILE] [--json] [--echo-request]

Flags

FlagAliasTypeDefaultPurpose
--input-istring(required unless --request)Cohort .pulse file path
--key-kstring(required unless --request)field=value pair; repeat for a composite key, in the same column order the sidecar index was built with
--returnstring(none)Comma-separated field names to project into each result row; omit for every schema field
--modestringassert-uniqueMultiplicity when the key matches more than one row: assert-unique (errors PULSE_LOOKUP_AMBIGUOUS), first, or all
--request-rstring(none)Full LookupRequest JSON file; flags layer on top of (override) the file
--jsonboolfalseEmit the standard envelope
--echo-requestboolfalseInclude 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

CodeMeaning
PULSE_INDEX_MISSINGNo sidecar built for the requested key field(s) — run pulse index build first
PULSE_INDEX_STALEThe 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_SHARDEDThe 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_FOUNDThe index is fresh but no record matches the key
PULSE_LOOKUP_AMBIGUOUSDefault 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
  • 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 reads
  • pulse api process — full scan with FILTER_* for non-exact-key or set_* 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 policy
  • skills/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 spss re-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.

.zsav imports 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

SPSSPulseNotes
numeric (F, E, COMMA, DOT, PCT, …)f64No integer narrowing by range probe — a probe would type two otherwise identical files differently
numeric with value labelscategorical_u8 / u16 / u32Width 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_* columnReassembled from the record 7/14 segments — see Very long strings
DATE / ADATE / EDATE / SDATE / JDATEdate, or datetime with PULSE_SPSS_DATE_WIDENEDWidens only when a value carries a time of day; pre-1970 dates stay date (signed epoch days)
DATETIMEdatetime (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 / DTIMEf64 (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 valuesnull, plus a generated <var>_missing sibling columnSee 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 sidecarSee 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 columnSee Multiple-response sets — the derived column is additive, because a bit cannot tell “not selected” from “not asked”
multiple-category response setN separate categorical_* columns, definition on the sidecarPositional 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_SUM over an income column silently adds 99999 for 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:

ColumnHolds
<var>the real values, and null at every missing position
<var>_missinga 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_u8 to u16.
  • 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_COLLISION warns. 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. INCOME labelled solely on 97/98/99 is a continuous measurement whose labels annotate its missing states. Q1: 1=Yes, 2=No, 9=Refused still maps categorical, because two of its labels name ordinary answers.
  • Name collisions are a hard error. A file that already declares income_missing alongside an income that needs a sibling is PULSE_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)MeansBit 1
0shown the option, did not pick itclear
. (sysmis)never asked — skipped or filtered pastclear

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 ↔ entry i ↔ 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 own variables[].label in the sidecar.
  • The mask uses the DECLARED counted value. Nothing assumes 1. A set declaring 2, 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. $media becomes media, because a leading sigil is not a legal expr-lang identifier and a field named $media would be unreachable from ATTR_FORMULA / FILTER_EXPRESSION. The full name is kept on the sidecar as derived[].set_name and in multiple_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_u256 is the widest set type Pulse has, so a battery naming more constituents than that has no honest mask. The import emits the constituents and warns PULSE_SPSS_MR_SET_NOT_DERIVED naming the set. The ceiling was 64 until set_u128 / set_u256 landed; a 206-option battery derives now, and the importer reads the ceiling off the same width ladder internal/io/infer.go uses 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.

ReasonCode
more than 256 constituents (the set_u256 ceiling)PULSE_SPSS_MR_SET_NOT_DERIVED
a member no record type 2 declares, or one named twicePULSE_SPSS_MR_SET_NOT_DERIVED
a counted value that will not compare against a numeric memberPULSE_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 holdsPULSE_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 setset_* bitmask
slot orderfirst choice ≠ third choiceunordered
a repeated codetwo slots may both hold 2idempotent — 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.

KindWhere it comes fromWhat it holds
numeric_missinga numeric variable declaring user-missing values<var>_missing, the reason each null had — see Missing values
multiple_dichotomya multiple-dichotomy response seta 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_dichotomy column 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_missing column 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

KindOpt outCost
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_dichotomyno flagIt 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_DIR may safely point at a directory of cohorts. The loader parses every *.json beneath 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.json suffix) 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 *.json that fails to parse still fails pulse.New outright, naming the offending path:

pulse: parsing label table /data/cohorts/regions.json:
json: cannot unmarshal number into Go value of type string

A 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.

FlagAliasEffect on an SPSS import
--input-irequired — the .sav / .zsav path
--output-orequired — the .pulse path to write
--schemawins outright. An explicit schema file overrides the SPSS dictionary and the adapter’s PulseSchema() is never called
--charsetoverride the encoding the file declares about itself — see Character encoding
--spss-missingauto (default) or null — how numeric user-missing values are represented; see Missing values
--sample-rowsinert. Nothing is sampled
--jsonstandard 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.

CodeRaised when
PULSE_SPSS_CARDINALITY_HIGHA string column is near-unique — a free-text signature
PULSE_SPSS_DATE_WIDENEDA date column widened to datetime
PULSE_SPSS_TEMPORAL_PRECISIONA temporal column demoted to f64 raw seconds
PULSE_SPSS_VALUE_COLLISIONTwo 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_MISMATCHA 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_COLLISIONA cell’s text is a null sentinel ("", NA, N/A, NULL) and imports as null
PULSE_SPSS_CATEGORICAL_USER_MISSINGInformational, once per file: categorical columns carry user-missing codes as ordinary dictionary entries — see Categorical user-missing codes
PULSE_SPSS_EXTENSION_UNKNOWNA record type 7 extension subtype this reader does not interpret; its bytes are retained verbatim
PULSE_SPSS_EXTENSION_INVALIDAn interpreted subtype carried a payload of the wrong shape; framing stayed sound, only the interpretation is dropped
PULSE_SPSS_VERY_LONG_STRING_INVALIDA record 7/14 segmentation could not be reassembled; the segments import as separate columns — see Very long strings
PULSE_SPSS_DATA_CASE_COUNT_MISMATCHThe header’s declared case count disagrees with the cases present
PULSE_SPSS_CHARSET_MISMATCHThe file states its character encoding twice and the two disagree — see Character encoding
PULSE_SPSS_MAGIC_FLAG_MISMATCHThe 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_DROPPEDA 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:

  1. the record 7/20 character-encoding name (windows-1252, UTF-8, …), which is what PSPP and modern SPSS write;
  2. the record 7/3 character code — the legacy numeric field (2/3 ASCII, 1252, 65001, …);
  3. 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:

RecordCarriesWhere it lands
7/21value labels for a wide stringThe column’s categorical_* dictionary, in record order, exactly as records 3/4 do for narrow ones
7/22up to three missing values for a wide stringThe 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 foreign reads 7/14 files but deliberately does not reassemble — it imports each segment as its own variable and says so in a warning. R’s haven (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.

EncodingHeader flagHeader magicStatus
Uncompressed0$FL2Read
Bytecode1$FL2Read — this is what SPSS writes by default
ZSAV (zlib blocks)2$FL3Read — 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:

CommandMeans
0Padding — occupies a command slot, produces no value. Fills out the final block.
1..251The whole number command - bias. The bias is read from the header; it is conventionally 100, giving the range −99…151.
252End of the data section.
253The next eight bytes of the stream are the value, verbatim.
254An all-spaces eight-byte string segment.
255System-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:

StructureWhereCarries
ZHEADERfirst 24 bytes of the data sectionits own offset, the trailer’s offset, the trailer’s length
compressed blocksafter the ZHEADERone independent zlib stream each
ZTRAILERend of the filethe 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 spss at all.

Fatal errors

CodeMeaning
PULSE_SPSS_DICT_INVALIDThe dictionary is malformed
PULSE_SPSS_DICT_TRUNCATEDThe file ends mid-dictionary
PULSE_SPSS_FILE_EMPTYThe source has no bytes at all — a zero-length file, not a truncated one
PULSE_SPSS_ENDIANNESS_MISMATCHThe header layout code and record 7/3 contradict each other about byte order — see Byte order and damaged files
PULSE_SPSS_COMPRESSION_UNSUPPORTEDA compression flag the format does not define — all three defined encodings are read
PULSE_SPSS_COMPRESSION_INVALIDA bytecode stream that disagrees with its own dictionary, or an unusable compression bias — see above
PULSE_SPSS_ZSAV_INVALIDA ZSAV block index that does not describe its file — names the block — see above
PULSE_SPSS_ZSAV_BLOCK_CORRUPTA ZSAV zlib block that will not inflate, or inflates to the wrong length — names the block — see above
PULSE_SPSS_DATA_TRUNCATEDThe data section ends mid-case, or a 253 command’s value is missing
PULSE_SPSS_CATEGORICAL_OVERFLOWA labelled variable has more distinct codes than categorical_u32 holds
PULSE_SPSS_DERIVED_NAME_COLLISIONA 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_DERIVEDA 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_MISSINGInformational: 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_UNSUPPORTEDThe declared character encoding resolves to no decoder — see Character encoding
PULSE_SPSS_CHARSET_INVALIDA byte sequence is not decodable in the declared character encoding — names the variable and the value
PULSE_SPSS_EXPORT_UNSUPPORTEDSomething 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 --json path a fatal import error is currently reported under the generic envelope code IMPORT_ERROR (CLI_ERROR for a convert), with the PULSE_SPSS_* code carried inside the message string. Non-fatal PULSE_SPSS_* warnings do reach warnings[].code structurally.

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:

CodeWhat happenedWhat to do
PULSE_SPSS_FILE_EMPTYzero bytesthe target was created and never written — check the export or the redirection
PULSE_SPSS_DICT_TRUNCATEDstops mid-dictionaryre-transfer; compare byte sizes
PULSE_SPSS_DATA_TRUNCATEDstops mid-casere-transfer
PULSE_SPSS_DICT_INVALIDstructurally wrong — bad magic, unidentifiable byte order, unknown record tag, out-of-range fieldthis 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
  • pulse cohort inspect — read the schema and dictionaries the import produced
  • pulse manifest — import.formats[] declares spss with schema_source: "authoritative" and export: true
  • pulse 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 / TargetWarningEmitter contracts this adapter implements
  • skills/spss-cohorts.md — the agent-facing SPSS surface: mapping table, derived columns, missing-value split, metadata sidecar, diagnostics
  • skills/cohort-schema-design.md — the .pulse field-type matrix
  • skills/label-display.md — turning the stored codes into labels at output time
  • skills/tool-import.md — the pulse_import MCP 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.

.sav only — ZSAV is read-only. All three data encodings import (uncompressed, bytecode, ZSAV zlib blocks). Only the first two are emitted. Asking for ZSAV output is PULSE_SPSS_COMPRESSION_UNSUPPORTED, not a silent downgrade.

--include, --labels and a non-UTC --tz are refused, not ignored. The .sav writer 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-names is 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 predict does not validate the .sav write. ExportJob.Predict reads only the source cohort’s header and schema and reports the field count and estimated rows; the target writer is never constructed, so --format spss is 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:

StateVerdictCodeWhat the export does
No sidecar filewarningPULSE_SPSS_SIDECAR_ABSENTsynthesises a default dictionary from the .pulse schema
Cohort size or mtime moved since the sidecar was writtenerrorPULSE_SPSS_SIDECAR_STALEnothing — the export stops
Not JSON / foreign kind / unknown format_version / bad digesterrorPULSE_SPSS_SIDECAR_INVALIDnothing — the export stops
--ignore-sidecar given, file present or notwarningPULSE_SPSS_SIDECAR_IGNOREDsynthesises 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 variables Q1A / Q1B beside the real Q1A / Q1B and stops on PULSE_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 kindFoldWhy
numeric_missingrestore — the encoder writes the recorded SPSS code back into every null, instead of the sysmis sentinelthe .pulse null bitmap is one bit, so which missing state each row was in exists nowhere else
multiple_dichotomydrop — after checking the constituents really are being emittedevery 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: a kind outside its vocabulary (a document from a newer import), a numeric_missing entry with no reasons dictionary, 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 recordsNeeds the data pass
PULSE_SPSS_NAME_INVALID / _COLLISION / _SANITIZEDPULSE_SPSS_WIDTH_OVERFLOW on a value
PULSE_SPSS_SIDECAR_ABSENT / _STALE / _INVALID / _IGNOREDPULSE_SPSS_CHARSET_UNENCODABLE in cell text
PULSE_SPSS_CHARSET_UNSUPPORTED / _UNENCODABLE in dictionary textPULSE_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 ID triple supplies them. IF q1 EQ 5 addresses 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 a 7/7 dichotomy definition. CategoryCode.Known stays false, 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/3 agrees). One corollary is easy to miss: a numeric missing-value slot is a flt64, 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_name identifies 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.

CodeSeverityMeaning
PULSE_SPSS_SIDECAR_ABSENTwarningNo sidecar; dictionary synthesised from the .pulse schema
PULSE_SPSS_SIDECAR_IGNOREDwarning--ignore-sidecar given; the read was skipped entirely
PULSE_SPSS_SIDECAR_STALEerrorCohort size/mtime moved since the sidecar was written
PULSE_SPSS_SIDECAR_INVALIDerrorNot JSON, foreign kind, unknown format_version, bad digest, or a broken multiple_response_sets[].fields array
PULSE_SPSS_NAME_INVALIDerrorA cohort field name a .sav cannot carry as a variable
PULSE_SPSS_NAME_COLLISIONerrorTwo cohort fields resolve to one SPSS variable name (case-insensitively)
PULSE_SPSS_NAME_SANITIZEDwarning--sanitize-names rewrote names; details carry the full field → name list
PULSE_SPSS_COLUMN_UNMAPPEDerrorA cohort column would leave the export silently, and the registry does not account for it
PULSE_SPSS_DERIVED_UNFOLDABLEerrorA registry entry this binary cannot fold back
PULSE_SPSS_EXPORT_UNSUPPORTEDerror--include / --labels / non-UTC --tz on a .sav export, or a value with no honest .sav form
PULSE_SPSS_COMPRESSION_UNSUPPORTEDerrorZSAV emission — read-only, not implemented
PULSE_SPSS_CHARSET_UNENCODABLEerrorA character the target charset cannot represent
PULSE_SPSS_CHARSET_UNSUPPORTEDerrorA --charset name Pulse cannot write
PULSE_SPSS_CHARSET_MISMATCHwarningA verbatim 7/10 / 7/17 / 7/18 payload under an overridden target charset
PULSE_SPSS_WIDTH_OVERFLOWerrorAn 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/5 and 7/19. Neither haven nor foreign exposes 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-int32 form of record 7/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.

  • pulse import spss — the read half, in full detail
  • pulse cohort inspect — see the cohort’s schema and dictionaries before exporting
  • pulse manifest — import.formats[] declares spss with export: true; export.formats[] declares its warn_and_skip overlay support
  • Adding an I/O Format — the CohortWriter / CohortValidator / TargetWarningEmitter contracts this adapter implements
  • skills/spss-cohorts.md — the agent-facing SPSS surface, read and write
  • skills/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_lookup assumes an index already exists. Build indexes via this CLI or pulse.BuildIndex / pulse.VerifyIndex / pulse.ListIndexes / pulse.DropIndex in 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:

  1. cohort.pulse.indexes.json when 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.
  2. The directory listing (cohort.pulse.<hash>.idx beside 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)

FlagAliasTypeDefaultPurpose
--input-istring(required)Cohort .pulse file path (or the positional argument)
--key-kstring(required for build/verify/drop)Comma-separated key column(s), in build-time order
--jsonboolfalseEmit the standard envelope

Coded errors

CodeMeaning
PULSE_INDEX_MISSINGverify / drop targeted a key combination with no built sidecar (and, for drop, no catalog entry either)
PULSE_INDEX_UNSUPPORTED_SHARDEDThe cohort is a shard archive — point-lookup indexing is single-file only
PULSE_INDEX_MANIFEST_INVALIDcohort.pulse.indexes.json exists but is not a readable catalog (malformed JSON, foreign kind, unknown format_version)
PULSE_INDEX_MANIFEST_STALEThe 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; the archive.pulse#shard.pulse anchor 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 on pulse api lookup.
  • pulse api lookup — read path that consumes the index this group manages
  • skills/cohort-sidecar-index.md — sidecar index byte format (v3) + keyable-type policy
  • pulse 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_manifest MCP 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

FlagTypeDefaultPurpose
--jsonboolfalseEmit the manifest as a JSON envelope
--slimboolfalseDrop prose descriptions from the manifest payload (smaller for size-sensitive clients)
--intentstring“”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’s streamable flag mirrors the per-type method.

When to use the manifest

Use caseReach for
Discover what’s availablepulse --json
Confirm a specific operator’s params and emit type`jq ’.data.operators[]
List embedded skills with their applies_tojq '.data.skills[]'
Generate documentation or client stubsParse the full manifest once at boot
Quick “is this name a real operator?”`pulse –json –slim

Exit codes

CodeMeaning
0Always (the manifest is in-memory, deterministic, never errors)

Examples

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")'

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. The synthetic-data skill covers spec authoring, the supported distributions (manifest synth_distributions), and constraint patterns.

Synopsis

pulse synth from-schema --spec FILE --output FILE
                        [--rows N] [--seed N] [--json]

Flags

FlagAliasTypeDefaultPurpose
--spec-sstring(required)Synth spec JSON path
--output-ostring(required)Output .pulse file path
--rowsintfrom specOverride row_count in the spec
--seedint0Deterministic RNG seed
--jsonboolfalseEmit 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 fieldJSON keyReconstructs
CategoricalPairscategorical_pairsfield B resampled from a captured contingency table conditioned on field A’s drawn value
CategoricalNumericPairscategorical_numeric_pairsnumeric field B resampled from Normal(mean, std) per observed category of categorical field A
SetCategoricalPairsset_categorical_pairsa set_* field’s option (bit) resampled from Bernoulli(P(selected | categorical A's value))
SetNumericPairsset_numeric_pairsnumeric field B resampled from Normal(mean, std) conditioned on a set_* field’s option state
SetSetPairsset_set_pairsone 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"]}
  ]
}
SlotTypeMeaning
whenexpr stringthe rule applies to rows satisfying it; absent means every row
set_nullarraynull these fields
setobjectassign these literal values
set_exprobjectassign the result of these expressions
null_togetherarraynull this block as one decision (needs ≥ 2 distinct fields)
owns_nullsboolmodifier, 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 resultscalar targetcategorical_*set_*
true / false1 / 0refusedrefused
a numberthe number, range-checkedrefusedrefused
a stringrefused (see below)must be a declared valuerefused
a selection (another set_* field, a list of option names, split())refusedrefusedthe 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

CodeMeaning
0Success
1Spec parse error, unknown distribution, infeasible constraints, or output write failure

Common error codes

CodeCause
PULSE_SYNTH_DISTRIBUTION_UNKNOWNSpec references a distribution name not in the catalog
PULSE_SYNTH_CONSTRAINT_INFEASIBLEConstraints reject too high a fraction of generated rows
PULSE_SYNTH_RULE_EMPTYA rule declares no action slot (set / set_expr / set_null / null_together)
PULSE_SYNTH_RULE_FIELD_UNKNOWNA rule names a field the spec does not declare — in a slot, or inside isnull("…")
PULSE_SYNTH_RULE_FIELD_NOT_NULLABLEA rule’s set_null names a field that is not declared "nullable": true, so the null could not be recorded
PULSE_SYNTH_RULE_EXPR_INVALIDA rule’s when or a set_expr value does not compile (when must return a bool)
PULSE_SYNTH_RULE_VALUE_INVALIDA 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_CONFLICTOne rule names the same field in two slots that disagree about it
PULSE_SYNTH_RULE_BLOCK_INVALIDA null_together block names fewer than two distinct fields
PULSE_SYNTH_RULE_OWNERSHIP_INVALIDA 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
  • pulse synth from-profile — generate from a captured profile of an existing cohort
  • pulse profile create — capture the profile
  • skills/synthetic-data.md — spec grammar, distribution table, and the index of focused synth-* skills
  • skills/synth-correlations.md / skills/synth-conflicts.md — correlations and claim arbitration
  • Synth calibration figures and design rationale
  • skills/synth-structural-rules.md — rules[] and constraints[] entry skill; the focused skills/synth-rule-*.md skills 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-data skill.

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

FlagAliasTypeDefaultPurpose
--profile-pstring(required)Profile JSON path
--sourcestring(required)Source .pulse cohort the profile was captured from — copied into the output, never opened for write
--output-ostring(required)Output .pulse file path — must be a distinct path from --source
--rowsint(required)Number of new rows to generate
--seedint0Deterministic RNG seed
--fidelity-reportstring(none)Write a JSON fidelity report to this path after generation completes
--rulesstring(none)Load structural rules from a standalone JSON file and apply them to the derived spec
--emit-specstring(none)Write the derived spec (after any --rules merge) to this path as indented JSON
--jsonboolfalseEmit 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 models survived translation onto the spec,
  • which distribution each field reconstructed to (normal, bernoulli, mixture, weighted_categorical, constant for 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:

FaultCode
A rule names a field the derived spec does not carryPULSE_SYNTH_RULE_FIELD_UNKNOWN
A when or set_expr does not compilePULSE_SYNTH_RULE_EXPR_INVALID
A set literal the target cannot holdPULSE_SYNTH_RULE_VALUE_INVALID
The file is not readableDATA_FILE
The file is not a JSON array of rule objectsSERVICE_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:

RuleRetires?Why
set / set_expr with no whenyeswrites every row
any rule carrying a whennowrites only some rows; the model still produces the value the rest keep
set_null, at any conditionality (with or without owns_nulls)noit 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_togethernocopies one null decision; supplies no value
set_expr reading its own targetnoit 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.

ArmReached whenWhat it says
constraintthe 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-roundingthe predicate reads a field that rounds on write and draws continuous valuesnormalise with round(field) in an earlier rule, or compare a range
neutralneithernames 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"}}]}
gaterows it fires onwire score <= 1stored level 0 / 1
score <= 1, un-normalised1,0001,480659 / 821
behind {"set_expr": {"score": "round(score)"}}1,4801,480659 / 821 — unchanged
behind {"set_expr": {"score": "int(score)"}}2,1402,1401,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 atQuantization attenuationA real generation fault
Target’s field typepacked_bool, u4, or another narrow integer ("scale": "probit_score")f32/f64, or a wide integer (no scale key)
n_firedHealthy — thousands of rowsHealthy — thousands of rows (a zero here is neither case: nothing fired, so there was nothing to recover)
recovered_coefficientSame sign, systematically smallerNear zero, or the wrong sign
PeersMost models of the same target type flag alikeAn 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 error and 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:

ChannelProduced byAlso written to
Capture-timeprofile create, read back off the documentthe profile document’s warnings
TranslationSpecFromProfile — model drops and conditional conflicts--fidelity-report’s warnings
Generationgenerate() — 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

CodeMeaning
0Success
1Profile 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-k levels. (The "other" catch-all that appears in a --fit-models design and in conditional.* 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_* or set_*, so u4 … u64, f32/f64, date, decimal128 and packed_bool all 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 Pearson rho for jointly-normal fields — see skills/synth-correlations.md for 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-level u4 columns at a captured rho of +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 as bernoulli with p = 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 the models section of a fidelity report recovers it through a calibrated probit score (marked scale: "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 a packed_bool is still biased (the writer rounds at 0.5); declare bernoulli there 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 aware was captured at p = 0.7473906704010238 and generated with P(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 lone bernoulli field 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 as discrete when the column carried at most 64 distinct values, which holds every level’s share exactly; wider columns keep the clamped normal (see profile create for 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 the models section 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_correlations still 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 under no_model_fit rather 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-shape at 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 captured shape does 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. See pulse profile create’s --fit-shape section.

  • pulse profile create
  • pulse synth from-schema
  • skills/synthetic-data.md — the spec grammar and the index of focused synth-* skills
  • skills/synth-fidelity-report.md — the tagged top-up contract and the fidelity sections
  • skills/synth-models.md — --fit-models entry skill; skills/synth-residual-correlations.md, skills/synth-model-recovery.md and skills/synth-residual-recovery.md carry 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 the synthetic-data skill.

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

FlagAliasTypeDefaultPurpose
--input-istring(required)Source .pulse cohort
--output-ostring(required)Output profile JSON path
--top-kint32Top-K categorical entries to retain per field
--include-statsbooltrueInclude percentile / std stats
--include-correlationsboolfalseCapture pairwise numeric correlations
--correlation-top-kint16Cap on retained correlation pairs
--conditionalboolfalseCapture 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-shapeboolfalseFit a 2-component Gaussian mixture per numeric field, kept as numeric.shape only when it’s a genuine improvement over plain normal (BIC)
--fit-modelsboolfalseFit 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-correlationsboolfalseCapture 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-rulesstring(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-continuationboolfalseMeasure 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-limitint0 (unlimited)Cap rows ingested for the profile (0 disables)
--seedint0Deterministic 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
--jsonboolfalseAlso print the envelope to stdout

What the profile captures

Field typeWhat 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_boolThe same numeric summary (a boolean falls to the numeric accumulator), but reconstructed as bernoulli, not as a clamped normal — see “Boolean fields” below
CategoricalCardinality, 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.)
dateMin, 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 prevalenceGenerated (before)
0.200.691
0.500.842
0.800.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-models on a boolean target still works, and its coefficients still order rows, but the composed draw is P(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:

FieldLevelSource shareGenerated (before)
familiarity (u4, 1–7)10.25260.1373
familiarity70.21980.1323
nps (u4, 0–10)100.32000.2253
nps00.02840.0020
useCon (u4, 1–6)60.31430.1806
sow (u16, 0–15)00.64690.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 discrete key 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) and catSpend (a u32 currency amount) abandoned. The cap is a package constant, not a flag, matching every other tuned threshold in synth.
  • 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-models on one still works and its coefficients still order rows, but the staircase Q means 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-correlations or --conditional is 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 — cells is the same {a_value, b_value, count} shape as conditional.categorical_pairs, with a_value always "selected"/"not_selected" and b_value the categorical field’s value (subject to that field’s own --top-k collapse). The same ContingencyCellCap (128) bounds each option’s cell table.
  • conditional.set_numeric_pairs: one {set, option, numeric, categories, n} entry per (option, numeric field) combination — categories holds the numeric field’s conditional mean/std for "selected" and "not_selected" rows, the same shape conditional.categorical_numeric_pairs uses per category.
  • conditional.set_set_pairs: one {set_a, option_a, set_b, option_b, cells, n} entry per option pair between two DIFFERENT set_* 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 model105
…of those, carrying at least one predictor55
Distinct (predictor field → target) relationships admitted113
Targets skipped0
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 +30 on such a field really does move the drawn value by +30 units, and reading it as “thirty dollars of spend” is correct.
  • A field carrying a --fit-shape mixture 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 u4 field storing a 0–10 scale is written to the nearest whole number; a packed_bool field 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; see synth 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 normal target the mapping is affine and a coefficient of +30 moves 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:

reasonMeans
insufficient_overlapFewer 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_varianceThe 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 measures P(target null | gate = level), and proposes a set_null rule for each field whose levels split a target’s null rate into ~1 and ~0.
  • Co-missing. It proposes a null_together rule 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_expr rule for each field that IS a function of one other field on every row where both are present — promoter is nps >= 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).

gatetargetsgated_sharedeviation
round(aware) == 0630.25260932959897621.2e-02
round(familiarity) == 1630.25260932959897621.2e-02
round(useCon) ∈ {3,4,5,6} ∨ isnull40.82601934313077600
round(peopleAware) == 0 ∨ isnull1 (people)0.4272880
round(promotionAware) == 0 ∨ isnull1 (promotion)0.3212310
round(placementAware) == 0 ∨ isnull1 (placement)0.3044290
round(productAware) == 0 ∨ isnull1 (product)0.2931580
round(priceAware) == 0 ∨ isnull1 (price)0.2888570

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 alone8,693
writer-before-reader0

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.

membersgated_sharefirst members
500.2526093295989762regard, meaningfulness, uniqueness, …
260.3564055763602606funcAppearance, attentionToDetail, brandAssets, …
130.2648718674932603appropriate, beliefsValues, clarity, …
40.8260193431307760nps, 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
Targetpacked_bool, u4 — a boolean flag or a small integer
Sourcecategorical_*, packed_bool, u4, with at most 16 observed levels
Arityexactly 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 bare nps >= 9 classifies 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_together in THIS rule, naming the SOURCE first. A set_expr clears its target’s null mask; within a rule null_together is 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_expr has no dial for “almost” and would rewrite exactly the rows that disagreed, and unlike a gating candidate’s when there 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.

sourcetargetsexpressionformco-present
nps (11 levels)detractorround(nps) <= 6threshold66,343
passiveround(nps) >= 7 && round(nps) <= 8threshold
promoterround(nps) >= 9threshold
familiarity (7 levels)awareround(familiarity) >= 2threshold381,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:

KeyMeaning
pairsadjacent row pairs compared — rows − 1 per shard
shardspayloads scanned (1 for a single-file cohort)
overallmean of the per-field rates: the share of field writes the run-skip decode avoids on a full-row scan
high_threshold0.75 — the rate at or above which a field is listed in high_fields
high_fieldsschema-order list of the high-continuation block (on a sorted join, the parent block)
adviceone-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-k collapse 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*c to be contracted into a single fused multiply-add; arm64 does it, amd64 does not. Every product in a capture formula now carries an explicit float64(...) conversion, which the language defines as forbidding contraction.

Two residuals remain, and they are stated rather than papered over:

  • --fit-shape runs an EM fit through math.Exp / math.Log, whose standard-library implementations are architecture-specific. A shape section’s last bits can therefore still differ between machines. Every other section is architecture-independent.
  • --fit-models solves its least squares in internal/processing/regression, which has not been made fusion-free. A models coefficient’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 …

logand 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-models survived 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 the needing attention figure.
  • 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 own warnings array 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

CodeMeaning
0Success
1Read 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

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:

  1. 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.
  2. 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:

FieldType / rangeLevelSourceGenerated
familiarityu4, 1–7 (U-shaped)10.25260.1373
familiarity70.21980.1323
npsu4, 0–10100.32000.2253
nps00.02840.0020
useConu4, 1–660.31430.1806
useCon40.07780.2121
sowu16, 0–1500.64690.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

FigureBefore selectionAfter selectionAfter shrinkage
numeric targets modelled0 (all skipped at 2,420 columns)103105
carrying predictors05355
admitted (field, target) relationships—76113
skipped targets10520

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:

FigureRefusedCalibrated
comparable targets2 of 5353 of 53
compared predictor terms150 of 2,4992,343 of 2,499
step-quantile errors510
flagged terms797

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:

Normalisationnps rows movedBand disagreements repaired
none—0 of 1,029
round(nps)01,029
int(nps)3,018 of 6,944 scored0

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). aware gating 63 fields — a strict superset of the 50-field block, the extra 13 being the 0.2649 cluster. It also proposes round(familiarity) == 1 with 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_share 0.2526093295989762 matched 50 fields’ null_rate to 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; and aware = round(familiarity) >= 2 on 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 checkWithout rulesWith the candidate file
all four NPS fields present together396,968 (target ~6,959)
all four null together18,56033,032
nps present, flag null6,8450
nps null, flag present14,5560
mixed blocks21,4010
exactly one flag set1136,968
band agreement166,968 (100%)
non-gated rows carrying all 50 fields022,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.

AlternativeWhy 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 gateat 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 scoringmakes the admitted set depend on candidate order; redundancy is resolved by the solver’s refit instead
A functional-dependency probe for collinear predictorsthe dependency that bites is LINEAR, not functional, so the probe misses it while claiming to have handled it
Sharing MinPairObservations (30) for thin LEVELSdifferent 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 listan 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 capturestill order-dependent in principle; sort the keys instead
math.FMA for architecture-independent floatsforces fusion everywhere — it changes amd64’s values rather than preserving them
Inverse-Mills recovery for a step targetreports a large false attenuation for a generation path that is exactly correct
An ordered-probit refit for the recoverymore efficient, not more correct; internal/processing/regression has binomial+probit as a reserved, unimplemented link
A tolerance-terminated mixture quantile inversemakes 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": …} markerleaves {"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 --rulesa derived spec carries nothing to append to; it would only invent an ordering question
Auto-applying --suggest-rules candidatesa 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_ratetwo unrelated fields can share a rate to sixteen digits and overlap by chance; admission is identical null PATTERN
A per-field null-pattern hashanswers 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 setevery share below the cut becomes a share of an arbitrary subset — the defect the detector exists to remove
Index-based handles in _evidencethe file is edited by deletion and every index below a deleted line shifts
Read-side rehabilitation of the old numeric catch-all spellingwould 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 nullablethe gated-block idiom names a never-null field first on purpose
  • 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 — the rules[] / constraints[] contract (entry skill; focused synth-rule-* / synth-rules-from-profile children).

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-bootstrap skill — fetch it via pulse_skills_get for 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

FlagTypeDefaultPurpose
--data-dirstringfrom PULSE_DATA_DIR env varCohort base directory
--bind-on-openbooltrueRegister session-scoped JSON-schema-bound tool variants on successful pulse_inspect
--no-cohort-scanboolfalseSkip the startup walk that enumerates .pulse files as pulse:// resources (env: PULSE_MCP_NO_COHORT_SCAN)
--feature-profilestringfrom PULSE_FEATURE_PROFILE env varFeature profile JSON file — an OS path, not resolved under the data dir. An invalid profile fails startup
--returnstringunset (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)
--limitstring, repeatableunset (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-addrstringunset (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-formatstringoff / textRoot 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:

  1. the request’s own return block (a slot’s, a stage’s);
  2. --return / gosdk.Config.DefaultReturn / mcpserve.Options.DefaultReturn;
  3. the instance default — pulse.Options.DefaultReturn, else the feature profile’s return;
  4. 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[]'

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, …) — see pulse mcp and Adding an MCP tool.

Global flags

Available on the bare pulse invocation:

FlagEffect
--jsonPrint the root manifest as JSON (envelope-wrapped)
--slimWith --json, drop prose descriptions for size-sensitive clients
--intent IDWith --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):

FlagEffect
--log-level off|debug|info|warn|errorStructured 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|jsonLog 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

VariableUsed byRequiredPurpose
PULSE_DATA_DIRAll commands when no path override is given; required by pulse mcpconditionallyBase directory for cohort files. Relative cohort paths resolve against it
PULSE_IMPORTS_DIRpulse import auto / list / dropnoManaged-imports subdir under the data root. Defaults to imports
PULSE_IMPORT_TTLpulse import autonoDefault TTL for managed imports. Go duration (24h, 30m), day form (7d, 30d), or pin. Defaults to 7d
PULSE_LABEL_TABLES_DIRpulse api sample --labels, pulse api facet --labelsnoDirectory 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_SCANpulse mcpnoSkip 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_PROFILEpulse mcp onlynoOS 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:

CodeWhenDetails
PULSE_IMPORT_SCHEMA_FIELD_MISSINGa schema field has no column of that namefield, columns
PULSE_IMPORT_SCHEMA_COLUMN_UNMAPPEDa source column no schema field namescolumn, fields
PULSE_IMPORT_SCHEMA_DUPLICATEa field name declared twice, or a repeated header namefield or column, side
PULSE_IMPORT_SCHEMA_KEY_LATEan NDJSON / JSON-array key first seen after the first objectcolumn, row, columns
PULSE_IMPORT_SCHEMA_TYPE_UNKNOWNa field’s type is not a registered field type (never read as f64)field, type, valid
PULSE_IMPORT_SCHEMA_INVALIDthe file is not a JSON array of field objects, or lists no fieldreason, 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.

LeafPurposeDocumented in
pulse api composeExecute multiple processing requests in batchapi compose
pulse api facetDistinct values for a field, or a rich multi-field summaryapi facet
pulse api lookupKey-exact row read against a prebuilt sidecar indexapi lookup
pulse api materializeWrite 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 codeMaterialize
pulse api predictValidate a request against a cohort without executingapi predict
pulse api predict-chainValidate a process-chain request without executingapi predict
pulse api predict-composeValidate a Compose batch without executingapi predict
pulse api predict-facetValidate a facet request without executingapi predict
pulse api processExecute a processing request against a cohortapi process
pulse api process-chainSource-rooted linear chain of mergeable stagesapi process
pulse api sampleReturn sample rows from a cohortapi sample
pulse cohort filterFilter a .pulse file to a new .pulse file--help
pulse cohort inspectInspect a .pulse header and schemacohort inspect
pulse convertConvert between tabular formats, auto-detected from extensionsimport spss, export spss
pulse convert predictValidate a conversion without writing outputexport spss
pulse dedupDeduplicate 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 nothingparent groups
pulse recommendTurn 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 explainSay 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 exportWrite 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_EMPTYguided analysis
pulse errors listList error codes by domain and/or substring--help
pulse errors lookupMessage + fixups for one error code--help
pulse examples searchSearch the embedded request-example library--help
pulse examples showPrint a named example’s runnable request JSON--help
pulse export arrowExport .pulse to Arrow IPC--help
pulse export csvExport .pulse to CSV--help
pulse export excelExport .pulse to Excel--help
pulse export jsonarrayExport .pulse to a JSON array--help
pulse export ndjsonExport .pulse to NDJSON--help
pulse export parquetExport .pulse to Parquet--help
pulse export predictValidate an export without writing outputexport spss
pulse export spssExport .pulse to SPSS .savexport spss
pulse export transferCompress 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 cohorttransfer compression
pulse export tsvExport .pulse to TSV--help
pulse features checkValidate 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-zerofeature profiles
pulse features diffList the features this build offers that a feature profile does not list (flagged new past its written_with) and the names it does not resolvefeature profiles
pulse features initPrint strict feature-profile JSON listing every feature this build offers, or --from NAME seeded from an example feature profilefeature profiles
pulse features showDescribe every feature a feature profile lists: kind, category, source, since and dependenciesfeature profiles
pulse import arrowImport Arrow IPC into .pulse--help
pulse import autoAuto-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-missingimport spss
pulse import csvImport CSV into .pulse--help
pulse import dropRemove a managed-import handle--help
pulse import excelImport Excel into .pulse--help
pulse import jsonarrayImport a JSON array into .pulse--help
pulse import listList managed-import handles and TTL status--help
pulse import ndjsonImport NDJSON into .pulse--help
pulse import parquetImport Parquet into .pulse--help
pulse import predictValidate an import without writing outputimport spss
pulse import schema-templateEmit an editable schema template from input dataimport spss
pulse import spssImport SPSS .sav / .zsav into .pulseimport spss
pulse import transferDecompress a pulse export transfer artifact into a byte-identical .pulse at rest (temp file, fsync, rename); refuses an existing --output without --overwritetransfer compression
pulse import tsvImport TSV into .pulse--help
pulse index buildBuild a point-lookup sidecar indexindex
pulse index dropRemove a cohort’s sidecar index (destructive, no prompt)index
pulse index listList every sidecar index built for a cohortindex
pulse index verifyReport whether a cohort’s sidecar index is freshindex
pulse mcpRun 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 createCreate 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 archiveprofile create
pulse schemaPrint the payload JSON Schema (raw, not envelope-wrapped)schema
pulse shard addAppend a shard to an existing archive--help
pulse shard compactRewrite an archive to reclaim orphan bytes--help
pulse shard createCreate a shard archive from single-file shards--help
pulse shard extractWrite a shard’s standalone .pulse bytes to stdout--help
pulse shard listList shards inside an archive--help
pulse shard removeRemove a shard from an archive by basename--help
pulse shard verifyRe-validate every shard against the canonical schema--help
pulse skills listList every embedded skill--help
pulse skills showPrint one skill’s markdown--help
pulse synth from-profileGenerate 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 earnssynth from-profile
pulse synth from-schemaGenerate a cohort from a JSON schema/specsynth from-schema
pulse versionPrint 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 widenWiden 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 & examplesCLI Tour and each cli/ page
Library-side equivalentsLibrary Embedding
MCP-side equivalentsHow LLMs Use Pulse
Envelope and error code semanticsTroubleshooting 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:

PackagePurpose
github.com/frankbardon/pulsePublic facade (Pulse, Options, Request, Response, …)
github.com/frankbardon/pulse/typesRequest/response structs, component-type constants (AGG_*, …)
github.com/frankbardon/pulse/ioJobs (ImportJob, ExportJob, ConvertJob, …), Reader / Writer, and the factory: io.NewReader(io.FormatCSV, fs, path, io.ReaderOptions{}), NewReaderFromBytes, NewWriter, NewWriterToBuffer, FormatFromPath
github.com/frankbardon/pulse/errorsTyped CodedError system and code constants
github.com/frankbardon/pulse/descriptorResult and envelope types for manifest, predict, inspect (Envelope, Manifest, PredictResult, InspectResult)
github.com/frankbardon/pulse/synthSynthetic data specs, profiles, Synth / SynthBytes for fixtures
github.com/frankbardon/pulse/encodingSchema nouns and the ungrouped raw-byte primitives
github.com/frankbardon/pulse/mcp/gosdk, .../mcpserveMount 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:

MethodPurpose
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) *ManifestDeterministic root self-description
Fs() afero.FsThe 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

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 Pulse instance 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.

SourceResult
Non-empty Options.DataDirUsed directly
Empty + FS non-nilDataDir is ignored — the FS is the trust boundary
Empty + FS nilPulse 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 OptionsEffective behaviour
DataDir and FS both emptyPulse builds its default filesystem → reads PULSE_DATA_DIR env var. Errors if unset and the operation needs filesystem access.
DataDir onlyUses an afero.NewOsFs() rooted at DataDir.
FS onlyUses the provided FS verbatim.
BothFS wins; DataDir is ignored.
DisableDefaults omittedDefaults enabled.
DisableCrosstabFusion omittedFusion engages whenever the gate admits the crosstab.
DefaultTimeZone omittedZone-capable slots without tz / time_zone resolve to UTC.
Limits omittedCount limits at their high defaults; RequestTimeout and MaxEstimatedMemory unlimited.
FeatureProfile / FeatureProfileFile omittedNo 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_DIR or --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.Open if the custom FS fails. The custom FS is the only filesystem; if it errors, that error propagates verbatim.
  • The MCP server (pulse mcp) currently uses afero.NewOsFs() only. Custom filesystems are a library-side capability today.
  • The Go race detector and go test -race work 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_process with 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 existing ProcessStream engine.
  • 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_compose as a sequential operation. Parallelism is a library-side capability.

When to use

GoalReach for
Single request, single resultProcess
Single request, pulled as rowsProcessStream
Batch of independent requests, in order, sequentialCompose
Batch of independent requests, in parallel, with bounded workersComposeParallel

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
}
FieldDefaultNotes
MaxWorkersruntime.GOMAXPROCS(0)0 resolves to GOMAXPROCS; <1 clamps to 1
PerRequestTimeoutunlimitedWhen positive, each worker derives context.WithTimeout
FailFasttrueFirst 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 with context.Canceled.
  • ComposeParallel returns (nil, err) where err is 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_INTERNAL error whose details map carries failed_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 is SERVICE_INTERNAL (compose parallel: N/M requests failed); details.first_error holds the lowest-index failure’s text. A caller that needs the slots that succeeded must call Process per 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) → sequential Compose.
  • 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 — ComposeParallel over 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

  • Pulse is safe for concurrent use after New.
  • Per-request operator state (running sums, dictionaries, sorted buffers) is allocated fresh inside each Process call.
  • The afero.Fs you 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 shapeMethod
pulse.Requestreq.Hash()
pulse.ComposedRequestreq.Hash()
pulse.FacetRequestreq.Hash()
pulse.ChainRequestreq.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 carry omitempty.
  • Negative-zero collapsing. -0.0 hashes identically to 0.0.
  • Type-namespaced. A Request and a ComposedRequest with identical wire bytes never collide — the hash function prefixes a namespace tag per request shape.
  • Schema-free. Hash() never reads a cohort, so a vectors entry hashes AS WRITTEN: a pattern and the explicit fields list it resolves to hash differently. Resolved-member identity needs the schema; inside the module internal/vectors.Normalize(req, schema) rewrites every vector to its resolved fields (order = axis order, pattern cleared), 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

  1. json.Marshal(v) — produces canonical-ordered struct fields and sorted map keys.
  2. Walk the resulting JSON tree, dropping representational variants (collapse -0.0 → 0.0, integer-valued floats keep integer form).
  3. Re-emit with sorted map keys and no whitespace.
  4. SHA-256(tag + 0x00 + canonical).
  5. 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 .pulse file’s header + schema + first dictionary block, enough to detect any structural change. Use -1 when you need full-file hashing for small files where prefix collisions matter.
  • Suffix — filter directory contents to files ending in this suffix. WatchDir defaults 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 CoalesceWindow of 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 PollInterval for your latency budget.
  • Symlinks are followed implicitly (the underlying afero.Fs.Stat resolves 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:

  1. Deterministic output naming — same source + same predicate resolves to the same output path.
  2. Atomic write — the engine never leaves a partially-written .pulse file at the target path.
  3. 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
  • sourceHash is the SHA-256 of the source file’s bytes.
  • predicateHash is types.CanonicalHash of the predicate payload (Expression or Filterers slice, normalised to the same canonical JSON form used by Request.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

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. CohortRow slot i holds Schema().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, as CountRecords does).
  • 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 typeGo value
u4, u8, u16, u32, u64uint64
f32 / f64float32 / float64
dateint32 epoch days
datetimeint64 epoch seconds
decimal128encoding.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_boolbool
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

SituationCode
index outside [0, Len())SERVICE_VALIDATION
RecordAt after CloseSERVICE_RESOURCE
a record cut shortENCODING_INVALID
a shard not decode-compatible with the canonical schemaPULSE_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 RecordAt returns (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, nil in a non-nullable field, a value past its type, a new label past the dictionary’s rung — is PULSE_IMPORT_ROW_ERROR with row (the 1-based Append call), field and reason details; 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 in Groups, never in the schema.
  • Groups and elision. Groups and ElideConstants behave exactly as pulse import --group / --elide-constants: the same PULSE_GROUP_* declaration checks (at NewCohortBuilder), the same viability gate (a group no wider than its index is dropped with PULSE_GROUP_TOO_NARROW; one below RatioFloor rows per tuple is still written, with PULSE_DEDUP_LOW_RATIO) and the same encoder. A key that does not determine its members fails Close with PULSE_GROUP_MEMBER_NOT_CONSTANT (source_row = the Append call). The header is 0x02 only when a group is written — a build whose every group was dropped is a plain 0x01 file.
  • Strict turns PULSE_FIELD_DESCRIPTION_LOW_QUALITY and 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 Close or an Abort leaves no target and no temp files. CohortBuildResult carries Records, the FormatVersion written, the final Schema, Warnings, the per-group Groups verdicts and ElidedConstants.
  • Overwrite. An existing target is refused unless Overwrite: true; replacing a cohort reports the sidecars it invalidated (InvalidatedSidecars), as Pulse.Dedup does. Replacing a managed import (a cohort under the imports directory with its .meta.json sidecar) also slides its expiry by its TTL — the same refresh an in-place Dedup or 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 MaxRecords rows named part-00001.pulse, part-00002.pulse, …; the last shard is partial, and an empty build is one empty shard. The archive reads back through CohortReader as the appended rows, in order. MaxRecords must be positive (SERVICE_VALIDATION), and a build holds at most 65,535 shards — the row that would open one more is refused with SERVICE_VALIDATION (reason: shard_limit).
  • One layout. Groups and ElideConstants are 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. Close stages the shard files in a directory beside the target and publishes them with one Pulse.CreateShardArchive call, which writes the archive atomically. The staging directory is removed whatever the outcome, so a failed Close leaves no archive, no spool and no shard file — and an existing archive being replaced (Overwrite: true) stays as it was.
  • Result. CohortBuildResult.Shards lists the shard entries in archive order. Warnings ends with any warnings CreateShardArchive raised (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 with ShardSplit): SERVICE_VALIDATION with reason archive_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, ElideConstants and Shards are refused — the archive’s group layout wins — and so is Overwrite, since an append replaces nothing: SERVICE_VALIDATION, reason: append_option, details.option naming the option.
  • Cohesion, checked up front too. NewCohortBuilder reads the archive’s canonical schema and runs AddShard’s own merge rules over your schema, so a shard AddShard is certain to refuse fails at construction — before any row is spooled — with the same code, message and details: PULSE_SHARD_SCHEMA_MISMATCH for a field count, name, order, numeric type, categorical width or set-vs-non-set that differs, PULSE_SHARD_DICT_WIDTH_OVERFLOW when your pre-seeded dictionaries alone overflow the union. Everything AddShard accepts 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 at Close, and Close re-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.AddShard call, 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 end CohortBuildResult.Warnings, codes and details unchanged.
  • Failures pass through. AddShard’s errors are Close’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. Target is the anchored path, Shards the one entry added. Schema / FormatVersion describe the shard as built, before AddShard reconciled it; open the archive (or the anchor) to read the stored layout. The new shard reads back through CohortReader over 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 and time_zone. Any other slot is refused with PULSE_MATERIALIZE_SLOT_UNSUPPORTED (details.slot); a request with no feature, attribute or window with PULSE_MATERIALIZE_NO_DERIVED; an embedder-registered feature (its outputs are unknown before it runs) with PULSE_MATERIALIZE_OUTPUT_UNDECLARED. Process’s own pre-flight runs first, so a request Process refuses 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 Process computes them. With sort, the rows follow the order Request.Sort gives Process’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 own partition_by / order_by. Only the permutation and each survivor’s source index are held beside the buffered records, and the memory pre-flight is Process’s (PULSE_LIMIT_EXCEEDED before 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 f64 per derived output in pipeline order — features (every output of a multi-output feature), attributes, windows — each with a generated description such as WIN_LAG of spend (partition region; order week) — derived from sales.pulse. A window’s frame is stated only when the operator reads it: WIN_EWMA is cumulative whatever its required frame says (manifest ignores_frame), so its description has no frame clause. An operator whose input is the slot’s expression (manifest reads_expression) is described by that expression, verbatim — ATTR_FORMULA = tv_spend_k * 0.80866 — derived from panel.pulse — and a regression attribute names its penalty / 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 as f64; a non-numeric value (a lag copying a categorical label) fails the run with PULSE_MATERIALIZE_OUTPUT_NOT_NUMERIC.
  • Writing. Always an ungrouped (0x01) single file, written atomically through NewCohortBuilder; source sidecars are not carried. An existing target needs Overwrite; replacing one reports its stale sidecars in InvalidatedSidecars plus a PULSE_MATERIALIZE_SIDECARS_INVALIDATED warning. Strict escalates 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:

ConstantWire valueBehavior
pulse.LookupMultiplicityAssertUniqueassert_uniqueDefault (zero value). Fails with PULSE_LOOKUP_AMBIGUOUS on more than one match; a single match succeeds normally.
pulse.LookupMultiplicityFirstfirstTakes the lowest row-id, never errors on a multi-row match.
pulse.LookupMultiplicityAllallReturns 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_STALE immediately, 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:

StateResult
No manifest (indexes built by an older Pulse)Falls back to the directory listing; unchanged behaviour
Manifest plus a sidecar it does not nameBoth are listed — the union, so an older sidecar does not vanish
Manifest present, directory not listableManifest answers; not an error
Manifest unreadablePULSE_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 gonePULSE_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 for Lookup, plus pulse index build/list/verify/drop for 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 $when are request-authoring parameters, substituted before the JSON is decoded into a request. ATTR_FORMULA and FILTER_EXPRESSION are expr-lang expressions 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": {}
}
KeyRequiredWhy it exists
targetyesNames 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.
variablesnoDeclares 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.
bodyyesThe parameterised request, markers still in place. Deliberately not runnable as-is: it becomes a request only once rendered.
namenoOptional, 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.
descriptionnoHuman-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:

targetDecodes intoExecute with
requesttypes.RequestProcess, Predict, ProcessStream
composedtypes.ComposedRequestCompose, ComposeParallel
chaintypes.ChainRequestProcessChain
facettypes.FacetRequestFacetSchema
sampletypes.SampleRequestSampleWithRequest

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 name must be literal. Written as {{…}} or as a $var marker it is PULSE_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_each item in the body: inside that item the placeholder would mean two things. That is PULSE_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

TypeAcceptsExtra declaration slotNotes
stringany JSON string—
numberany JSON number—integral or fractional
integera JSON number with no fractional part—1 and 1.0 qualify; 1.5 does not
booleana JSON bool—
fielda 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
enuma string in the declared setvalues (required)membership is exact and case-sensitive
lista JSON arrayitems (required)items must be a scalar type — lists do not nest
datea string parsing as YYYY-MM-DD—a strict subset of encoding.DateFormats; 03/04/2024 is ambiguous and rejected
periodan 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. required together with a default is 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 JSON null means “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:

RootBehaviour
blank / whitespace-onlyskipped — an empty element in a PATH-style list is not a fault
does not existskipped — an absent optional override layer is not a fault
exists but is a regular fileerror, naming the path — a root pointed at a file is a genuine misconfiguration
exists and is a directorywalked

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:

PhaseA malformed template fileWhy
At pulse.New()hard-fails startup, naming the offending pathAt boot, a broken document is a deploy error the operator must see immediately.
After startup, parsed successfully beforekeeps serving its last-good parse; marked Broken in ListTemplates() with the fault in ErrorA 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 successfullyListed with Broken: true and an empty Target; GetTemplate / RenderTemplate return PULSE_TEMPLATE_INVALID naming the pathThere 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, the period ranges-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.

CodeRaised whenWhat the author or caller should do
PULSE_TEMPLATE_NOT_FOUNDthe requested name is not registered — including every name on an engine with no template directoriesCheck the derived name (path relative to its own root, minus .json); check the roots are configured and reachable.
PULSE_TEMPLATE_INVALIDa 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 pathFix the document. This is always an author fault, and at startup it is fatal.
PULSE_TEMPLATE_TARGET_UNKNOWNtarget is absent or not one of the five roots — and also when RenderTemplateRequest is called on a template declaring some other (perfectly valid) targetTwo 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_MISSINGa required variable resolved to nothing — no supplied value, no defaultSupply the variable, or give the declaration a default.
PULSE_TEMPLATE_VAR_UNKNOWNthe caller supplied a variable the template does not declareFix 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_TYPEa supplied value does not match the declared type — including a list or period used inside a {{}} string, which has no text formCorrect the supplied JSON type, or splice with a {"$var": ...} marker instead of string sugar.
PULSE_TEMPLATE_VAR_ENUMa supplied value is not a member of an enum’s values setMembership is exact and case-sensitive; the details carry the permitted set.
PULSE_TEMPLATE_UNRESOLVEDan unguarded marker survived substitution — declared, but nothing resolvedSupply 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_INVALIDsubstitution succeeded but the rendered JSON failed strict decode into the target request typeUsually an unknown field, or a substituted value landing in a slot whose type it does not fit.
PULSE_TEMPLATE_AXIS_COLLISIONa declared variable shares its name with an axis of a for_each item in the bodyRename 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 and pulse 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 its digest equals the one predict returns.
  • precision (1 to 17 significant digits) is wire-only. Integers, decimal128 strings and NaN (null) are untouched, and counts stay exact: count-aggregation columns, count crosstab cells, and matrices[*].auxiliary.n. Extension aggregators are never exempt.

Errors

CodeWhen
PULSE_RETURN_INVALIDunknown preset, precision outside 1 to 17, malformed path, naming returned, a non-overlay path in a Compose-level block
PULSE_RETURN_PATH_UNKNOWNa path this instance’s Response does not carry, including a hidden feature’s path
PULSE_RETURN_PATH_UNMATCHEDwarning, 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’s return shapes responses[i]; ComposedRequest.Return shapes top-level overlays only.
  • ProcessChain: each stage’s return is applied after the whole chain, so a stage that excludes data still feeds the next stage.
  • Streaming: rows carry only the selected columns; returned rides the terminal chunk only; excluding data yields no rows. See Streaming & ProcessStream. A stream never raises PULSE_RETURN_PATH_UNMATCHED (it has no warnings slot); Predict lists the includes that may match nothing as return.unresolved_includes. Precision is wire-only: the library StreamChunk keeps 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:

  1. the slot’s own weight (an explicit null opts that slot out, and resolution stops there);
  2. the request’s weight;
  3. the engine default, pulse.Options.DefaultWeight;
  4. 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

kindMeaningConsequence
probability (default)design / sampling weightsRatios, means, shares and quantiles are scale-free: multiplying every weight by a constant changes nothing. Totals scale. Components report Kish n_eff.
frequencyeach row stands for w identical rowsInteger 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:

WeightTreatment
positive, finiteused
zerovalid; contributes nothing
null, negative, NaN / ±Infrow excluded from that figure and counted
non-integer under frequencyrow 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 classExplicit weight (slot or request)DefaultWeight only
weight-aware aggregatorsweightedweighted
other aggregators (min, max, range, distinct-style, null count, set union / intersection)PROCESSING_CONFIGskipped
windowsrequest weight: PROCESSING_CONFIGskipped
filters, features, row-local attributes, other groupersskippedskipped
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 testweighted (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-Forsythekind: frequency: weighted; kind: probability: PULSE_WEIGHT_UNSUPPORTEDsame, 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) overlaysweighted (both kinds)weighted
the Fisher exact cell overlaykind: frequency: weighted; kind: probability: PULSE_WEIGHT_UNSUPPORTEDsame, 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 regressionkind: frequency: weighted; kind: probability: PULSE_WEIGHT_UNSUPPORTEDsame, 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 overlaysPULSE_WEIGHT_UNSUPPORTED (permanent)PULSE_WEIGHT_UNSUPPORTED (permanent)
the min-max rescale attribute (ATTR_NORMALIZED)PROCESSING_CONFIGskipped

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², for probability weights 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.

KeyValuesMeaning
methodnone, bonferroni, holm, bh, bythe procedure; matches R p.adjust (bh / by are R’s BH / BY)
familylayer, row, column, request, composewhich p-values are corrected together
alphaa number in (0, 1), default 0.05the 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

FamilyCorrects togetherWhere
layerone overlay layeroverlays (the overlay default)
row / columneach row / column of one layer’s matrixoverlays with a MATRIX payload
requestthe request’s tests, post-tests and request-family overlaystests (the test default), request overlays
composeevery compose member across all slots and Compose-host layersinside 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_adjusted and multiplicity {method, family, alpha, m} beside p_value.
  • Overlay summaries: p_adjusted / significant_adjusted beside the raw figure (p_value, or the statistic that the t / z-versus-reference kinds carry their p in).
  • Overlay matrices: parallel payload.p_adjusted / payload.significant_adjusted matrices on identical headers and coordinates; a panel cell’s vector maps element for element.
  • Overlay layers: a multiplicity echo, with m_per for row / 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 ComposeParallel agree byte for byte; --stream emits 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 an overlays slot with no enabled host and kind) is refused with PULSE_REQUEST_UNKNOWN_FIELD, exactly as an unrecognised JSON key, and valid_keys lists only the slots the instance offers. A request template that renders such a slot fails PULSE_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.Import and 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 }
}
KeyRequiredMeaning
profilenoYour own label. Informational; the pulse mcp startup line prints it
written_withnoThe Pulse version you wrote the profile against. Stored as-is, never compared, and never an error
featuresyesThe exact feature names the instance offers. May be empty ([]), and must not repeat a name
behaviournoEngine switches the profile turns on (below)
returnnoThe instance’s default response selection — the return block a request without its own is shaped by ({"preset": "standard"}, include, exclude, precision)
limitsnoThe 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

KindSpellingExamples
operatorbare registered nameAGG_SUM, GROUP_DATE, TEST_T, REG_OLS, OVERLAY_YOY, and your extension operators
capabilitycapability:<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 formatio_format:<name>io_format:csv, io_format:parquet, io_format:spss. One name covers both import and export
MCP extramcp_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_SUM and process (without its kind) are refused, and the error suggests the right spelling.
  • A statistical test is one name. TEST_T covers both its row-test and post-test forms.
  • Synth distributions are not features. capability:synth gates 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.New still validates its registration and its DependsOn, 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:compose or capability:process_chain.
  • capability:models needs a host that runs requests or capability:facet (a facet’s models feed its filters’ coef()).
  • capability:multiplicity needs nothing: its blocks ride every request host and facet overlays alike.
  • capability:stream, capability:watch, capability:filter_to_file and capability:materialize need capability:process.
  • An intent prompt (mcp_extra:prompt_<intent>) needs every tool its script calls: capability:recommend, capability:explain and capability:process (all three; pulse_inspect is always mounted).
  • An overlay kind needs a host that can run it: capability:crosstab for crosstab overlays, capability:compose for compose and series overlays, capability:process_chain for stage overlays, capability:facet for facet overlays.
  • Some operators read another’s results, and filter-to-file runs on the filter-expression engine:
FeatureAlso needs
OVERLAY_T_CELL, OVERLAY_Z_CELL, OVERLAY_T_VS_REF, OVERLAY_Z_VS_REF, OVERLAY_PAIRWISE_WELCH_T, OVERLAY_PAIRWISE_TWO_MEANS_ZAGG_WELFORD
OVERLAY_PAIRWISE_WEIGHTED_TWO_MEANS_ZAGG_WEIGHTED_MEAN or AGG_AVERAGE (either one)
ATTR_REG_FITTED, ATTR_REG_LEVERAGE, ATTR_REG_RESIDUALREG_OLS
ATTR_REG_PREDICTREG_BAYES_LINEAR, REG_GLM or REG_OLS (any one)
OVERLAY_YOYGROUP_DATE
capability:filter_to_fileFILTER_EXPRESSION (filter-to-file compiles every filterer into one filter expression)
capability:model_saveREG_BAYES_LINEAR, REG_GLM or REG_OLS (any one; a save writes a fitted regression as a model file)
capability:modelsATTR_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

KeyTurns on
disable_defaultsOptions.DisableDefaults
disable_componentsOptions.DisableComponents
disable_projectionOptions.DisableProjection
disable_cohort_scanthe 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.

CodeWhenRead
PULSE_FEATURE_PROFILE_INVALIDThe profile cannot be used as writtendetails.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_UNKNOWNA name does not resolvedetails.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_DEPENDENCYAn enabled feature is missing what it needs, or an Options value needs a feature the profile omitsdetails.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, or nil, false without 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 effective behaviour switches (the profile’s ORed with Options). 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. The facet, process_chain, join, crosstab, export and import capability blocks are omitted when their capability is off. This is a public type change: descriptor.Manifest.Facet, ProcessChain, Join, Crosstab, Export and Import are 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’s skills list and examples count, categories and tags cover only the skills and examples the instance serves (see the MCP section). extensions.label_tables is empty ([]) when capability:labels is off and extensions.range_tables when capability:range_tables is off, exactly as on an instance that registered no tables, and requests resolve them the same way: a label binding naming one fails PULSE_LABEL_TABLE_UNKNOWN, an AutoLabels default naming one is not applied, and a date-range table: fails PULSE_RANGE_TABLE_UNKNOWN. p.LabelTables() and p.RangeTables() still return them: facade methods are not gated. Lookup tables (the expression lookup() 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) is PULSE_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 to pulse schema’s published golden apart from the digest comment. pulse schema serves the default instance; the MCP pulse://schema resource serves the mounted instance’s p.PayloadSchema().
  • Virtual vectors and matrices. Without capability:matrices the request vectors and matrices slots fail PULSE_REQUEST_UNKNOWN_FIELD on every entry point, the payload schema drops them with the VectorSpec / VectorCoerce / MatrixSpec definitions and the Response.matrices result slot, and the manifest drops the matrix block. Every MAT_* operator depends on capability:matrices.
  • Item fan-out. Without capability:for_each a for_each on a window, feature or attribute item (inside Compose requests and chain stages too) fails PULSE_REQUEST_UNKNOWN_FIELD, naming the item under details.path (windows[1]); the payload schema and the MCP tool input schemas drop the key and the ForEach definition. A hand-expanded request runs unchanged.
  • Row weighting. Without capability:weighting the instance has no weight surface. The request weight and every per-slot weight (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) fail PULSE_REQUEST_UNKNOWN_FIELD, even when set to null. A nested one names its object under details.path (aggregations[0]) and lists that object’s keys under valid_keys. The payload schema and the MCP tool input schemas drop every weight property, the manifest drops weight_aware (built-in and extension) and the weighted sum_weights / n_eff / n_weight_invalid component keys, predict reports no weights, inspect and predict report no SPSS suggested_weight, and pulse.New refuses Options.DefaultWeight (PULSE_FEATURE_PROFILE_DEPENDENCY). AGG_WEIGHTED_MEAN and its params.weight_field keep working: they are the operator’s own parameters, not the weight surface, so its invalid-weight warning and decimal refusal stay listed while AGG_WEIGHTED_MEAN is enabled.
  • Multiple-comparison correction. Without capability:multiplicity every multiplicity block — on the request, the composed request, a test or post-test, a request, facet or Compose overlay, inside Compose requests and chain stages too — fails PULSE_REQUEST_UNKNOWN_FIELD (a nested one under details.path), the payload schema drops every multiplicity property with the Multiplicity defs, PULSE_MULTIPLICITY_* leave the error lists, and pulse.New refuses Options.DefaultMultiplicity (PULSE_FEATURE_PROFILE_DEPENDENCY).
  • Errors. p.ErrorLookup, p.ErrorsByDomain and p.ErrorsSearch, and the manifest’s error lists, show only codes the instance can raise. A hidden code looks up as unknown. pulse errors lookup on 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_RANK to WIN_RANK hint (the generic unknown-attribute error when WIN_RANK is 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 to p.FeatureSetDigest(). The field is additive; the envelope format_version stays "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 as pulse_skills_list and the pulse-skill:// resource list.
  • p.Skill(name) returns the body pulse_skills_get and a pulse-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:

NameOffers
minimalcapability:process plus core aggregators and the default groupers
survey-crosstabProcess, 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-analystEvery 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-empty from seeds the profile from that example instead (label, features, behaviour; no extension names are added). written_with is the running release core: 1.0.0-alpha.2 stamps 1.0.0; a development build with no release core stamps the newest release its feature table knows. The result always passes CheckFeatureProfile with the same extensions.
  • CheckFeatureProfile(fp, opts) returns a report and the coded error pulse.New would return for the same profile and extensions (nil when valid). With opts.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 than SYNTH, a namespace other than BUILTIN / STANDARD / CORE / PULSE) becomes a warning, “unverified extension name, check in-process”, coded PULSE_FEATURE_PROFILE_UNKNOWN with details.reason unverified_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...) reports missing — features the running build offers that the profile omits, each flagged new when its Since is later than the profile’s written_with — and unknown — names the build does not resolve, with the same reasons PULSE_FEATURE_PROFILE_UNKNOWN uses. Unknown names are reported, not fatal. A missing or unparseable written_with flags nothing new.
  • DescribeFeatureProfile(fp, ext...) describes every listed name: kind, category (operators only), source (builtin, extension or unknown), since (built-ins) and depends_on, an AND of any-of groups. A name the build does not resolve carries an unknown explanation 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.

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):

FieldJSON / --limit keyDefaultUnitGuards againstChecked
RequestTimeoutrequest_timeoutnone (Unlimited)durationa call that runs far longer than intendedruntime, an inner deadline
MaxGroupsmax_groups10,000,000distinct groupsgrouping on a high-cardinality fieldpredict (possible) + every bucket mint at runtime
MaxCrosstabCellsmax_crosstab_cells10,000,000rows x columnsa huge crosstab gridpredict (possible) + both crosstab arms
MaxEstimatedMemorymax_estimated_memorynone (Unlimited)bytesa run whose buffered state cannot fitpredict (certain) and the same pre-flight before any record decodes
MaxMatrixDimmax_matrix_dim2,048columns pa quadratic matrix outputpredict (certain) + pre-flight
MaxComposeSlotsmax_compose_slots1,000requestsfan-out through Compose; a sweep counts its EXPANDED slots plus the explicit onesup front, serial and parallel, and in PredictCompose
MaxChainStagesmax_chain_stages1,000stagesfan-out through ProcessChainup front
MaxJoinBuildRowsmax_join_build_rows100,000,000recordsa join’s in-memory build sidepredict (certain, from the right side’s header) + the build loop
MaxDerivedColumnsmax_derived_columns1,000window + feature + attribute itemsfan-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; unlimited as text): no limit.
  • any other negative value: refused with PULSE_LIMIT_INVALID (PULSE_FEATURE_PROFILE_INVALID, reason invalid_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 the PULSE_LIMIT_EXCEEDED error, so valid is false, and Process refuses 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 stays valid and the runtime decides. MaxGroups and MaxCrosstabCells are possible.

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, so observed is NONDETERMINISTIC there: compare {code, limit, configured}, never observed. A merge-only breach (every shard under the limit, the union over it) reports the merged count.
  • Fused crosstab observed is 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, configured and observed are 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 callError
RequestTimeoutPULSE_LIMIT_EXCEEDED {limit: "request_timeout"}
the caller’s deadlinecontext.DeadlineExceeded, unchanged
the caller cancelledcontext.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) and max_estimated_memory first; the count limits are rarely the binding one.
  • Tighten max_groups when 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 Predict over them and look at limit_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 fieldWhat it isCost when nil
Logger *slog.Loggerstructured records (stdlib log/slog)nothing written
Hooks *observe.Hookscallbacks around every operation and phasenothing called
Metrics observe.Metricsan instrument factory for counters, histograms, gaugesnothing 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 shapeWire it with
Logs only (slog)Options.Logger = slog.New(handler)
Custom callbacks (audit, tracing of your own)Options.Hooks = &observe.Hooks{...}
OpenTelemetry traces and metricscontrib/otelpulse: Hooks(tp) + Metrics(mp)
Prometheus via client_golangcontrib/prompulse: Metrics(reg)
The CLI MCP serverpulse 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.New ready; 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 OnOperationStart returns is the one the work, OnPhase and OnOperationEnd see.
  • OnPhase fires when the operation ends, once per phase that ran, in execution order, just before OnOperationEnd (plan, open, decode, scan, reduce, post, overlay, shape). It is a summary, not a live feed. Streaming operations have no shape phase.
  • 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 Close an iterator.
  • OperationResult.Code is ok, an error code, or uncoded.

Metrics

MetricTypeLabels
pulse_operations_totalcounterop, code, scope
pulse_operation_duration_secondshistogramop, scope
pulse_phase_duration_secondshistogramop, phase
pulse_rows_scanned_totalcounterop
pulse_bytes_read_totalcounterop
pulse_limit_trips_totalcounterlimit
pulse_hook_panics_totalcounterhook
pulse_operations_in_flightup-down counterop

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 an Interpretation. Pulse renders them for you: the book’s Analysis Guide (operator catalog, “Reading your results”, glossary), the ## Use when and ## Reading the output sections of every operator skill, and p.ExportReference / pulse docs export for your own instance. The prose is still pulled, never pushed: it appears in none of the default Response, PredictResult or manifest. Recommend, Explain and 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 intents is 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.

CodeFires when
PULSE_ADVISORY_TWO_GROUP_TEST_MANY_GROUPSa two-sample test splits on a field with more than two groups
PULSE_ADVISORY_MANY_TESTSten or more uncorrected p-values and no multiplicity
PULSE_ADVISORY_CATEGORICAL_AS_NUMERICan SPSS nominal field is averaged as a number
PULSE_ADVISORY_ORDINAL_PARAMETRICa parametric test runs on an SPSS ordinal field
PULSE_ADVISORY_WEIGHT_AVAILABLE_UNUSEDthe cohort names a weight variable and the request uses none
PULSE_ADVISORY_EXTRA_PASSan 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 intents
  • pulse_skills_get with name: "glossary" / "intents", and p.Skill("glossary") / p.Skill("intents") in Go
  • the resources pulse-skill://glossary and pulse-skill://intents
  • the manifest’s skills list

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

TypeShapeBuild with
Matrixdense, row-majorNewMatrix(rows, cols, data), NewMatrixFromRows([][]float64)
Symsymmetric; stores the upper triangle, packedNewSym(n, packed), NewSymFromRows([][]float64) (reads the lower triangle)
VecvectorNewVec([]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

NeedRoutineSame bits on every architecture?
Factor, solve or invert a symmetric positive-definite matrix, with reproducible outputCholesky, CholeskyRidge, SolveSPD, InverseSPDyes: pure Go, FMA-free
Running means and covariances, mergeableCoMoment, MergeTreeyes: pure Go, FMA-free
Factor once, then solve many right-hand sides, with a condition checkFactorSPD → SPDFactor.Solve / Inverse / ConditionNumber; Mulno; gonum-backed, but identical bits run to run on one machine
Eigen, singular value, QR, rank, condition numberSymEigen, SVD, QR, Rank, ConditionNumbervalues 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 DominanceTolerance goes to the lowest index. QR makes R’s diagonal non-negative.
  • Rank. One tolerance, RankTolerance(rows, cols, σmax) = max(rows, cols)·Epsilon·σmax, the same default as LAPACK and NumPy. ConditionNumber is +Inf when 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 NaN in x is missing. Listwise drops the row. Pairwise drops it only for the pairs it touches; PairN(i, j) / PairW(i, j) report each pair’s count and weight.
  • Weights.
    • A NaN, ±Inf or negative weight skips the row and is counted in NWeightInvalid().
    • A weight of 0 is valid: the row counts in N() but carries no mass.
    • W() is the weight sum, and NEff() is Kish’s effective sample size. Use weight 1 for unweighted data.
  • 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.
  • Concurrency. A CoMoment is not safe for concurrent use. Give each goroutine its own accumulator and Merge them.

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:

  1. Fold the row at absolute index r into a per-block accumulator for block r / linalg.MergeBlockSize (4096 rows). Each block’s rows must be added in order.
  2. 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-length x passed to CoMoment.Add panics instead.

  • PULSE_MATRIX_SINGULAR: the matrix cannot be factored, solved, inverted or decomposed. details.reason says why where the routine knows:

    • not_positive_definite or ill_conditioned (with condition_number);
    • non_finite (a NaN / Inf element in a decomposition);
    • no_convergence.

    A reference Cholesky failure carries pivot; an exhausted ridge schedule carries attempts and ridge. A failed Cholesky (so SolveSPD / InverseSPD) or FactorSPD on a finite matrix also carries rank, condition_number (+Inf, null on 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 as dependent_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:

KeyMeaning
primarythe matrix, a types.MatrixValues {kind, encoding, row_keys, column_keys, labels?, values}
auxiliarypairwise only: n, each pair’s row count, same shape
vectorstop_pairs ([{row, col, r, n}]) for MAT_CORRELATION with summary.top_pairs
scalarsdeterminant, null unless the matrix is positive definite
warningsPULSE_MATRIX_* data-quality findings
group_key, group_headerset 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) warns PULSE_MATRIX_LISTWISE_HEAVY_DROP.
  • PULSE_MATRIX_INSUFFICIENT_N and PULSE_MATRIX_ZERO_VARIANCE flag 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 n and 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-design skill via pulse_skills_get. It speaks in field-type semantics rather than byte layout; this page covers the bytes.

Constants

These live in encoding/header.go:

NameValuePurpose
MagicBytes[]byte{'P','U','L','S','E', 0x00, 0x00, 0x00}8-byte identifier; rejects non-Pulse files
FormatVersionV10x01Original layout — every file written before 0x02 existed
FormatVersionV20x02Adds a length-prefixed schema extension block carrying parent groups (see Schema Block)
FormatVersion0x01Baseline: what writers emit for a schema that uses no 0x02 feature
MaxFormatVersion0x02Newest version this binary reads and writes
HeaderSize9Total 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-design skill via pulse_skills_get for the same matrix plus “which type to pick”, and type-<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.

TypeByteByteSizeNotes
u801Unsigned 8-bit integer
u1612Unsigned 16-bit integer
u3224Unsigned 32-bit integer
u6438Unsigned 64-bit integer
f324432-bit IEEE 754 float
f645864-bit IEEE 754 float
u460Bit-packed 4-bit unsigned (0–15); shares a byte with a neighbour
date74Epoch DAYS as signed int32
packed_bool80Bit-packed boolean; up to 8 share one byte
categorical_u891Dictionary-backed single value; ≤ 256 entries
categorical_u16102≤ 65,536 entries
categorical_u32114≤ 4,294,967,295 entries
decimal1281216Fixed-point exact decimal; per-field (precision, scale), both ≤ 38
set_u8131Multi-select bitmask over an inline dictionary of ≤ 8 labels
set_u16142≤ 16 labels
set_u32154≤ 32 labels
set_u64168≤ 64 labels
datetime178Epoch SECONDS as signed int64
set_u1281816≤ 128 labels; two little-endian 64-bit words, words[0] = bits 0–63
set_u2561932≤ 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 the uint64 value API: ReadFieldValue / WriteFieldValue refuse them with ENCODING_TYPE_MISMATCH rather than truncating to the low 64 bits (FieldType.IsWideSet()). Wide masks travel as encoding.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

ConcernSource
Which aggregators are meaningful on which typesskills/aggregation-design.md (LLM) / api process (CLI)
Per-type depth, one file per typeskills/type-<name>.md (LLM)
Decimal arithmetic semanticsskills/financial-cohorts.md (LLM)
Categorical and set dictionary limitsDictionary Blocks
Multiple-response set importimport 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 .pulse files, plus the on-disk format documented in encoding/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:

#FieldSizeEncoding
1type1 byteFieldType byte (see Field Types)
2nullable1 byte1 = the field participates in the per-record null bitmap, 0 = it does not. Immediately after the type byte
3name_length2 bytesu16 little-endian
4namename_length bytesUTF-8
5byte_offset4 bytesu32 LE — offset within a record
6bit_position1 byteu8 — slot within the field’s byte (bit-packed types only; 0 otherwise)
7csv_column_idx2 bytesu16 LE — source column index at import time
8description2 bytes length + UTF-8Capped at 1000 bytes (PULSE_IMPORT_DESCRIPTION_TOO_LONG)
9(decimal only) precision1 bytedecimal128 only
10(decimal only) scale1 bytesame
11(dictionary types only) dictionaryvariablecategorical_* 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:

  • decimal128 gets a (precision, scale) pair (u8, u8). Both ≤ 38.
  • Dictionary-bearing types (FieldType.HasDictionary() — the three categorical_* and all six set_* rungs) get a full dictionary block inline; see Dictionary Blocks. A dictionary-bearing field with no dictionary still writes an empty one (a u32 zero 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.Schema with one encoding.Field per 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-design skill covers when to pick which categorical width; the tool-import skill 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

TypeMax entriesBytes per record value
categorical_u82561
categorical_u1665,5362
categorical_u324,294,967,2954

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()):

TypeMax entriesBytes per record value
set_u881
set_u16162
set_u32324
set_u64648
set_u12812816
set_u25625632

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:

  1. Re-import with a wider categorical type (categorical_u8 → categorical_u16 → categorical_u32).
  2. 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).
  3. 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 familyEncoding
u8 / categorical_u8 / set_u81 byte, unsigned
u16 / categorical_u16 / set_u162 bytes, little-endian unsigned
u32 / categorical_u32 / set_u324 bytes, little-endian unsigned
date4 bytes, little-endian two’s-complement signed int32 (epoch days)
u64 / set_u648 bytes, little-endian unsigned
datetime8 bytes, little-endian two’s-complement signed int64 (epoch seconds)
f324 bytes, little-endian IEEE 754
f648 bytes, little-endian IEEE 754
decimal12816 bytes, little-endian two’s-complement integer (scaled by 10^scale). Exceeds the uint64 API — use the 16-byte accessors
set_u128 / set_u25616 / 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_boolBit-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 > 0 selects the HIGH nibble (b >> 4); BitPosition == 0 selects 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 → byte i/8, bit i%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:

  1. Compute record size from the schema (field widths + one byte per bit-packed field + the bitmap, if any).
  2. Read record_size bytes.
  3. Walk the schema fields in declaration order, advancing a cursor:
    • If ByteSize() > 0, decode ByteSize() bytes at the cursor.
    • If ByteSize() == 0 (u4, packed_bool), consume ONE byte and decode the bit slot at BitPosition within it.
  4. If the schema has a bitmap, the last BitmapByteSize() bytes of the record are it; mark field i null when bit i%8 of byte i/8 is 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

TermMeaning
Logical schemaSchema.Fields — every field, original order. Field indices, records, operators, dictionaries and inspect all use it.
Logical rowThe 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 rowWhat 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.
EntryOne 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 Process paths, 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 0x01 decoder 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 — and source_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 (details group_labels names 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. u32 exhaustion is PULSE_GROUP_ENTRIES_EXHAUSTED, never a wraparound.
  • Composition. Declared groups come first in the descriptor, in declaration order; with --elide-constants the constant group follows them, and declared members are never elided.
  • Report. ImportReport.Groups lists each declared group’s label, fields, verdict, entry_count, entry_width, member_row_bytes, index_width, dictionary_bytes, ratio, break_even_ratio, ratio_floor and byte_delta — see the viability gate below.
  • Zero groups write the 0x01 cohort 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.

CheckWhenOutcome
Width floor: members’ bytes per row ≤ the 4-byte indexbefore the row passgroup 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 builtgroup written, PULSE_DEDUP_LOW_RATIO
Grows the file: deduped bytes ≥ undeduped bytes, at any floorafter the dictionary is builtgroup 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.

FlagLeafMeaning
--input / -iboththe source file
--output / -oexportthe artifact; default <input>.zst; overwritten
--output / -oimportthe cohort to write; must not exist
--levelexportzstd 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
--overwriteimportallow replacing an existing output cohort
--jsonboththe 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, 0x02 grouped, 0x02 elided, and shard archives of each.
  • Atomic arrival. import transfer writes 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

CodeWhen
PULSE_COHORT_COMPRESSEDA 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_INVALIDdetails.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:

ShapeCohortArtifactRatio
flat, sorted by parent9.16 MB3.53 MB2.60x
flat, scattered9.39 MB5.59 MB1.68x
grouped, sorted5.02 MB3.38 MB1.48x
grouped, scattered5.04 MB3.48 MB1.45x
  • Sorted: a flat cohort sent as .zst comes 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.

ToolPurpose
pulse_manifestCall 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_inspectRead .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_predictValidate a request against the schema without executing. Returns errors, warnings, applied defaults, streamability reasons.
pulse_processExecute one pre-built request.
pulse_composeExecute a batch of requests against the same cohort in one round trip.
pulse_sampleReturn up to N rows for preview / diagnostics.
pulse_facetDistinct values for a single field.
pulse_importConvert 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_dedupDeduplicate an existing single-file cohort into parent groups (format 0x02), in place or to out; suggest_groups alone is read-only candidate detection.
pulse_materializeWrite 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_recommendTurn 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_explainSay 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_dropDelete a managed-import handle and its sidecar.
pulse_imports_listEnumerate managed handles with sidecar metadata (source, format, imported_at, expires_at, ttl, expired flag, pinned flag).
pulse_examples_searchSearch the embedded request-example library by query, taxonomy tags (ANDed), or category.
pulse_examples_getFetch one runnable example body by name.
pulse_errors_lookupPer-code Message + Fixup detail (kept out of the manifest for context economy).
pulse_skills_listEmbedded skill metadata; the optional intent filter returns that intent’s skills.
pulse_skills_getFetch one skill body by name.

Resources

URI schemeYields
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

NameArgsReturns
pulse-bootstrapnoneA 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-requestquestionClassifies 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-describecohort, measureThe guided intent workflow for describe (below).
pulse-compare-groupscohort, outcome, groupThe guided intent workflow for compare_groups (below).
pulse-relationshipcohort, measures, categoriesThe guided intent workflow for relationship (below).
pulse-driverscohort, outcome, predictorsThe guided intent workflow for drivers (below).
pulse-change-over-timecohort, time, measureThe guided intent workflow for change_over_time (below).
pulse-compositioncohort, category, byThe guided intent workflow for composition (below).
pulse-benchmarkcohort, measure, groupThe guided intent workflow for benchmark (below).
pulse-distribution-shapecohort, measureThe guided intent workflow for distribution_shape (below).
pulse-segmentcohort, measuresThe guided intent workflow for segment (below).
pulse-measure-constructcohort, itemsThe guided intent workflow for measure_construct (below).
pulse-flowscohort, from, toThe guided intent workflow for flows (below).
pulse-data-qualitycohort, fieldsThe 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.

The default sequence for nearly every user request:

  1. pulse_manifest once 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.
  2. pulse_import when 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 .pulse file in PULSE_DATA_DIR.
  3. pulse_inspect on the handle (or path). Reads header + schema only — no record bytes — and registers session-scoped, schema-bound variants of the action tools (see below).
  4. pulse_predict with 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.
  5. pulse_process to execute. Use pulse_compose to batch multiple requests against the same cohort in one round trip.

Cheaper probes are available without going through pulse_process:

  • pulse_sample for a row preview.
  • pulse_facet for distinct values of a single field.
  • pulse_examples_search / pulse_examples_get to 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>.pulse with a sidecar <handle>.pulse.meta.json carrying imported_at, expires_at, ttl_seconds, source path, source format, and row count — plus source_tz / column_source_tz / dst_policy when the import read naive datetimes in a source zone (the pulse_import fields of the same names; omitted for a UTC import). result.managed=true.
  • Pulse passthroughs (.pulse extension) under PULSE_DATA_DIR are not copied — the server returns the relative path verbatim with managed=false. A .pulse outside PULSE_DATA_DIR is 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:

PathEnum
aggregations[].fieldAll cohort field names
aggregations[].typeFull aggregator catalogue (AGG_*)
attributes[].fieldNumeric fields only (includes decimal)
attributes[].typeFull attribute catalogue (ATTR_*)
filterers[].fieldAll cohort field names
filterers[].typeFull filterer catalogue (FILTER_*)
groups[].fieldAll cohort field names
groups[].typeFull grouper catalogue (GROUP_*)
windows[].field, windows[].partition_by[]All cohort field names
windows[].order_by[].fieldNumeric and date fields
windows[].typeFull window catalogue (WIN_*)
tests[].field, tests[].field2Numeric fields only
tests[].split_by / rows / cols / subject_fieldAll cohort field names
tests[].typeFull test catalogue (TEST_*)
attributes[].target, attributes[].predictors[]Numeric fields only (regression attributes)
regressions[].target, regressions[].predictors[]Numeric fields only
regressions[].typeFull regression catalogue (REG_*)
matrices[].typeFull matrix catalogue (MAT_*)
joins[].on[].left_fieldAll cohort field names (the right cohort is not bound)
pulse_facet field argAll 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_SUM then aggregations[i].field must be numeric.” Operator–type compatibility lives in the type property description; strict validation remains pulse_predict’s job.
  • Transport support: binding mutates the single stdio-session server post-serve (AddTool / RemoveTools auto-emit list_changed), so stdio — the transport pulse mcp ships — honours it. There is no per-session schema override for a shared HTTP server, a documented limitation. The manifest’s accepts_types table 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 varPurposeDefault
PULSE_DATA_DIRCohort base directory. Required.(none — server fails to start without it)
PULSE_IMPORTS_DIRSubdirectory for managed-import handles.imports
PULSE_IMPORT_TTLDefault 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 mcp ships 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 mcp CLI leaf yet. The underlying go-sdk server supports it; embedders build their own go-sdk server and call gosdk.Register(server, p, cfg) (or use mcpserve.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

SymptomCauseFix
data directory required: set PULSE_DATA_DIR or pass --data-dirNeither env var nor flag setPass PULSE_DATA_DIR in the host’s env block, or --data-dir in args
Tools don’t appear in the host UI after editing configHost caches tool listRestart the host fully (not just the conversation)
pulse_import returns PULSE_IMPORT_SOURCE_FORBIDDEN for an absolute pathPath 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 fireStdio session — binding is a no-op thereUse pulse_predict for validation; the manifest’s accepts_types lists give the LLM the same information
Tool calls hangHost wrote non-protocol bytes to the server’s stdin, or server wrote non-protocol bytes to stdoutCheck 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 taskSkill
MCP wiring, tool surface, schema bindingsession-bootstrap
Author a Process requestrequest-envelope, aggregation-design
Compose multiple sub-requests in one callcompose-requests
Iterate on a request with pulse_predicttool-predict
Look up an error code or warningtool-errors-lookup
Pick an aggregator / filtereraggregation-design
Pick an attribute (z-score, percentile, formula, …)attribute-composition
Design a groupergrouper-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 datasynthetic-data
Gate, mask or derive a synthetic field (rules[])synth-structural-rules
Understand a cohort’s schema layoutcohort-schema-design
Import a tabular source into .pulsetool-import
Pick an export formatpulse 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.

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):

SurfaceHow
Docs URLhttps://frankbardon.github.io/pulse/payload-schema.json — the file’s own $id.
CLIpulse schema prints it to stdout (offline; no cohort needed).
MCP resourceRead pulse://schema (MIME application/json) alongside pulse_manifest.
Go libraryp.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 hidden vectors takes VectorSpec and VectorCoerce with it; hidden matrices takes MatrixSpec, the Response.matrices and ResponseComponents.matrices slots and MatrixResult / MatrixComponents with it — MatrixValues stays, shared with the regression vcov / correlation); without capability:weighting no per-slot weight is a property either (so SlotWeight and WeightSpec are absent), and without capability:multiplicity no multiplicity block is a property of any root or slot (so Multiplicity, MultiplicityMethod and MultiplicityFamily are 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, ExplainRequest drops 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, plus detail).
  • Results — #/$defs/Response, ComposedResponse, ChainResponse, FacetResult, LookupResult, RecommendResult (its draft request is an open object: a "<placeholder>" may sit where Request takes a number), ExplainResult (its findings[].numbers values are ["number", "null"], verdict the closed Verdict enum).
  • Advisory — #/$defs/Advisory, one entry of a predict result’s advisories ({code, message, details}): a coded, non-blocking note that the analysis may not fit the data. Never a warning.
  • Envelope — #/$defs/Envelope, the universal --json wrapper. Its data slot is intentionally open: it carries whatever the operation returned (a Response, the manifest, a predict result, an inspect result, …). To validate a wrapped result strictly, validate the unwrapped data value 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:

  1. Reflection over the Go payload structs — a new or renamed field changes the output.
  2. 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.
  3. Hand-tuned strict unions for the two shapes reflection cannot express: OverlayRef (at most one arm populated) and OverlayPayload (shape-discriminated scalar / 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 (the json.RawMessage slot 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 than enums — 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_zone on Request and FacetRequest — the request-level zone. ComposedRequest and ChainRequest carry none of their own (each inner Request does); SampleRequest has none.
  • tz on every Group (so also crosstab.rows[] / crosstab.columns[]), Filterer, Attribute and Feature entry — the per-slot override. It is a slot key beside type / field, never a key inside params.

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 UTC or an IANA Area/Location zone (Europe/Berlin, Etc/GMT-5) — anything else is PULSE_TIMEZONE_UNKNOWN;
  • tz is accepted only on operators whose manifest entry carries "zone": "capable", and never on a date field — otherwise PROCESSING_CONFIG;
  • precedence is slot tz → time_zone → the engine’s DefaultTimeZone → UTC; a resolved zone on a datetime field reads each instant on its local calendar day, and a non-UTC zone reaching a derived field (absent from the schema) is refused with PROCESSING_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):

  • weight on Request — a WeightSpec object {field, kind}, kind ∈ probability (default when omitted) | frequency. ComposedRequest and ChainRequest carry none of their own (each inner Request does); FacetRequest and SampleRequest have none.
  • weight on every Aggregation (so also crosstab.cell and each crosstab.margin_aggregations[] entry), Test (tests[] and post_tests[]), RegressionSpec, Attribute, OverlaySpec and Group (groups[] and both crosstab axes) — the per-slot SlotWeight union: a field-name string, a WeightSpec object, or null. Inferential slots (tests, regressions, reference-distribution attributes, the quantile grouper, inferential overlays) are refused PULSE_WEIGHT_UNSUPPORTED while a weight is in force, so on them null is 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).

Slotminimal (primary result)standard
data, warningsyesyes
metadatanoyes
crosstabshape, matrixwhole
matrices[*]name, type, group_key, primaryall but auxiliary
tests[*] / post_tests[*]label, type, statistic, p_value, reject_null, p_adjusted, significant_adjustedminimal’s keys + variant, df, alpha, multiplicity, details.effect_size (no other details key)
regressions[*]name, type, coefficients, p_valuesall 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
componentsnono

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 pathBest 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:

FilterBehaviour
queryCase-insensitive substring across the example’s name, description, and operator list
tagsAn example must carry every requested tag
categoryExact 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:

DimensionTags
Domain / use casetime-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 methodhypothesis-test, t-test, parametric, nonparametric, paired, one-sample, two-sample, k-sample, repeated-measures, post-hoc, normality-test, homogeneity-test, exact-test
Regression / modelingregression, ecological, ols, glm, logistic, bayesian, regularization, ridge, lasso, elasticnet, polynomial, resampling, jackknife, selection, stepwise
Pipeline machinerytier-1-test, tier-2-test, composed, pre-filter, feature-pipeline, window-operator, streaming-friendly, buffered-pipeline
Matrices and multivariate testsmatrix, covariance, multivariate
Risk / edgeleakage-risk, small-sample
Cohort shapesharded, anchor
Result decorationoverlay, 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

  1. Write the request JSON under internal/examples/<category>/. Use existing files as shape templates. Keep cohort.data_dir = ".data" and reference one of the fixture cohorts.
  2. Add a _meta block 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 of AGG_* / ATTR_* / FILTER_* / GROUP_* / WIN_* / FEAT_* / TEST_* types appearing in the body, alphabetized and deduped.
    • description — one-sentence, present-tense summary.
  3. Re-run go test ./internal/examples/... ./internal/descriptor/... to confirm the new file passes:
    • TestExamples_AllParseAsRequest
    • TestExamples_UniqueNames
    • TestExamples_TagsFromTaxonomy
    • TestExamples_OperatorsMatchBody
    • TestExamples_CategoryMatchesDirectory
    • TestManifestExamplesPopulated
  4. Keep the _meta block in sync by hand when the body changes — TestExamples_OperatorsMatchBody fails the moment operators drifts 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

OperatorEngineStreaming
REG_OLSOrdinary least squares + optional regularizationStreams sufficient statistics (Phase 1 + 2)
REG_GLMGeneralized linear model via IRLSAlways buffered (Newton-Raphson refit)
REG_BAYES_LINEARBayesian 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. Requires Criterion. 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 namePulse expressionExample
1SimpleREG_OLS with one predictorinternal/examples/regression/02_simple_linear.json
2MultipleREG_OLS with multiple predictorsinternal/examples/regression/03_multiple_linear.json
3Linear= #1internal/examples/regression/02_simple_linear.json
4Multiple Linear= #2internal/examples/regression/03_multiple_linear.json
5LogisticREG_GLM{Family:"binomial", Link:"logit"}internal/examples/regression/04_logistic.json
6RidgeREG_OLS{Penalty:"l2", Alpha:λ}internal/examples/regression/05_ridge.json
7LassoREG_OLS{Penalty:"l1", Alpha:λ}internal/examples/regression/06_lasso.json
8PolynomialFEAT_POLY{Field:x, Degree:n} upstream → REG_OLSinternal/examples/regression/07_polynomial.json
9Bayesian LinearREG_BAYES_LINEAR{Prior:"nig"}internal/examples/regression/08_bayesian_linear.json
10Jackknifeany regression with Resample:"jackknife"internal/examples/regression/09_jackknife.json
11Elastic NetREG_OLS{Penalty:"elasticnet", Alpha, L1Ratio}internal/examples/regression/10_elasticnet.json
12EcologicalGROUP_* upstream → REG_OLS over group means (composed request)internal/examples/regression/01_ecological_fallacy.json
13Stepwiseany regression with Selection:"stepwise", Criterion:"aic"|"bic"internal/examples/regression/11_stepwise.json

Streamability matrix

SpecStreamableMemoryNotes
REG_OLS no penaltyyesO(p²)sufficient stats: n, Σx, Σy, XᵀX, Xᵀy, Σy²
REG_OLS + l1 / l2 / elasticnetyesO(p²)streaming Gram; regularized solve at finalize
REG_BAYES_LINEAR (conjugate NIG)yesO(p²)streaming sufficient stats + closed-form posterior update
REG_GLM (binomial / poisson / gamma)noO(n·p)IRLS / Newton requires multiple passes
Any regression with Resample != ""noO(n·p)LOO / bootstrap refit
Any regression with Selection != ""noO(n·p)refit per candidate subset
REG_OLS / REG_BAYES_LINEAR with a robust covariance (not classical)noO(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.

ParamRequiredNotes
targetyesNumeric response field.
predictorsyesOne or more numeric predictor fields.
penaltyno"" (default), "l1", "l2", or "elasticnet".
alphaconditionalRequired and > 0 when penalty != "".
l1_ratioconditionalRequired and in [0, 1] when penalty == "elasticnet".
max_itersnoCoordinate-descent cap (default 1000).
tolnoConvergence tolerance (default 1e-6).
resampleno"jackknife" or "bootstrap". Downgrades streaming.
selectionno"forward", "backward", or "stepwise". Requires criterion. Downgrades streaming.
conf_levelnoCoverage of ci_lower / ci_upper, 0 < c < 1. Default 0.95. Student t on the residual df.
covarianceno{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.

ParamRequiredNotes
targetyesNumeric response.
predictorsyesOne or more numeric predictor fields.
familyyes"binomial", "poisson", or "gamma".
linknoFamily-specific default when empty (binomial→logit, poisson→log, gamma→inverse).
max_itersnoIRLS iteration cap (default 50).
tolnoConvergence tolerance (default 1e-8).
resampleno"jackknife" or "bootstrap".
selectionnoSubset-selection wrapper; requires criterion.
conf_levelnoCoverage of the Wald ci_lower / ci_upper, 0 < c < 1. Default 0.95.
covariancenoAs 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.

ParamRequiredNotes
targetyesNumeric response.
predictorsyesOne or more numeric predictor fields.
priornoOnly "nig" accepted in v1. Default "nig".
prior_munoLength p+1 mean vector (intercept first); defaults to zero.
prior_precisionnoScalar ε ≥ 0 on the precision matrix ε·I. Default 1e-3.
prior_shapenoInverse-gamma shape a₀. Default 1e-3.
prior_ratenoInverse-gamma rate b₀. Default 1e-3.
credible_levelnoPosterior interval mass. Default 0.95.
conf_levelnoMass of ci_lower / ci_upper (the same posterior t interval). Defaults to credible_level.
covariancenoAs 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).

ValueBehavior
""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).

ValueBehavior
""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 moderate n.
  • BIC = -2·logL + log(n)·k. Heavier per-parameter penalty; rejects noise predictors more reliably at moderate n.

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.

AttributeEmits per row
ATTR_REG_FITTEDŷ_i = Xᵢ β — the model’s prediction at each row.
ATTR_REG_RESIDUALy_i − ŷ_i — the per-row residual.
ATTR_REG_LEVERAGEh_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.

CodeMeaning (one-liner)
PROCESSING_REGRESSION_NOT_IMPLEMENTEDReserved as of Phase 8; no engine returns this today.
PROCESSING_REGRESSION_RANK_DEFICIENTXᵀX is singular; add regularization or drop a predictor.
PROCESSING_REGRESSION_NO_CONVERGEIRLS or coordinate descent failed within MaxIters.
PROCESSING_REGRESSION_SINGULAR_GRAMXᵀX non-invertible even after regularization; increase alpha.
PROCESSING_REGRESSION_INVALID_FAMILYREG_GLM Family outside {binomial, poisson, gamma}.
PROCESSING_REGRESSION_INVALID_LINKLink incompatible with the chosen Family.
PROCESSING_REGRESSION_INSUFFICIENT_DATAFiltered set has fewer rows than predictors + 1, or below resample minimum.
PROCESSING_REGRESSION_APPROXIMATE_SEWarning: l1 / elasticnet SE is a plug-in approximation; set resample for rigor.
PROCESSING_REGRESSION_REGULARIZED_SELECTIONWarning: penalty != "" plus selection != "" is unusual.
PROCESSING_CONFIGInvalid 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:

Architecture Overview

Source of truth: the canonical architectural contract is CLAUDE.md at 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.go facade (pulse.New, pulse.Options and 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 under internal/. TestPublicAPIGolden freezes the exported shape; see Package Layout.
  • Self-describing. Every .pulse file carries its schema in the header. The no-execute manifest, predict and inspect operations (implemented in internal/descriptor/, result types in the public descriptor/) 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 the pulse-skill:// resources, and discover the rest of Pulse’s capabilities via pulse 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 — encoding and descriptor keep their public subset at the public path; the remainder lives in an internal/ twin with the same package name.
  • Alias facade — io and synth hold only type aliases, re-declared constants and thin wrappers over internal/io (+ internal/iocore) and internal/synth. Nothing under internal/io/** may import the public io (its factory imports every adapter); TestIOImportBoundary enforces it.
  • Root aliases — facade-returned types from internal packages keep their pulse.X spelling 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 == false means 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, one PULSE_WEIGHT_INVALID_ROWS warning per weight column). A WeightAware attribute owes every row a value, so it still receives such a row, with Weight() reporting false.
  • The factory sees the resolved weight on its spec (spec.Weight.Spec() gives {field, kind} with the kind spelled out); nil when 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 its ComponentSchema.

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])?$
ExampleCategoryNamespaceName
AGG_ACME_BRAND_SCOREaggregatorACMEBRAND_SCORE
ATTR_ACME_ADJUSTMENTattributeACMEADJUSTMENT
FILTER_ACME_GEO_FENCEfiltererACMEGEO_FENCE
TEST_FINANCE_VARtestFINANCEVAR

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.New call → 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?

CategoryDeclares ComponentSchema?Notes
AggregatorYesUniversal floor {n, n_null} filled by orchestrator.
GrouperYesUniversal floor {total_n, n_null} filled by orchestrator.
FiltererYes (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.
AttributeNoAttributes do not flow into Response.Components.
WindowNoWindow outputs land in Response.Data rows.
FeatureNoPre-filter; no components surface.
TestNoTest results carry their own typed shape.
SynthNoGenerators, 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:

ValueMeaningExamples
MergeableStream-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.
PartialUnions 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.
NoneTerminal-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:

ConditionError code
ComponentsFunc set (or the value’s own Components() method adopted) but ComponentSchema.Keys emptyPULSE_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 orderPULSE_EXTENSION_COMPONENT_SCHEMA_MISMATCH
ComponentsFunc returns a universal-floor key the orchestrator ownsPULSE_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:

  1. a built-in constant answers from types.GroupType.FansOut();
  2. 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’s FansOut);
  3. 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:

  • nil or empty slice: no extra fields beyond the spec’s Field.
  • 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 — FieldInputs runs 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.

CategoryStreamable meansRequired interface
Aggregatorone-pass onlineextend.OnlineAggregator
Attribute (row_local)per-row eval, no PrePassextend.RowLocalAttribute
Attribute (two_pass)PrePass + Finalize + Rowextend.TwoPassAttribute
Grouperderive key from a single rowextend.StreamingGrouper (fan-out: extend.MultiKeyStreamingGrouper)
FeatureStreamingComputer pipelineextend.StreamingFeatureComputer
Test (tier-1)folds with online aggregatorsextend.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 and row_local attributes merge as row-local operators; two_pass / buffered attributes, features, windows and tests never merge (as for built-ins), so a request naming one runs the parallel shard and parallel buffered Process arms serially. Declaring Mergeable in a ComponentSchema describes 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) alongside Mergeable; undeclared, the crosstab runs the buffered arm with the same result. An axis grouper fuses only on a declared Streamable or FansOut. Predict reports the answer as crosstab_fusable + crosstab_fusion_reasons.
  • Grouped Components: grouper and per-group aggregator figures, on every path. An extension grouper’s ComponentsFunc output lands on Components.Groupers[i].Operator on every grouped path (streaming, buffered, and the merged parallel arms via MergeState), exactly as a built-in grouper’s does. An extension aggregator under a grouper gets the per-group floor automatically and its operator map off each per-bucket instance’s own components hook in Components.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_pass extension attribute declines the fused crosstab exactly as the built-in ATTR_ZSCORE does — the fused walk never runs a PrePass.
  • Decimal targets follow the declarations. Extension aggregators are admitted on decimal128 and read DecimalValue; they stream there per their declared Streamable flag and merge per Mergeable (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 zone key and an explicit slot tz on it is refused with PROCESSING_CONFIG (details {slot, operator, tz}), in runtime and predict alike. A request-level time_zone simply does not reach it. Only the built-in date-family operators declared in internal/descriptor/capabilities_zone.go resolve 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

StemFrontmatterDocuments
op-<category>-<kebab>.mdkind: operator, category: = the operator’s prefix (AGG, ATTR, FILTER, GROUP, WIN, FEAT, TEST, SYNTH), operator: = the registered nameONE 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>.mdkind: 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:

reasonRule
layoutthe 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
stemop-<category>-<kebab> naming its operator, or ext-<kebab>
frontmatter / name / descriptiona --- block; name equals the stem; a description
kind / operator / category / requiresatomic: kind: operator, a registered extension operator, its prefix as category; topical: kind: design; each requires: entry a feature (capability:crosstab) or registered operator
sectionsthe family’s required ## headings
budgetbody (frontmatter, fence markers and generated-section markers stripped) ≤ 1200 bytes for op-*, ≤ 6000 for ext-* — hard, unlike the built-in atomic budget
fenceevery <!-- feature: … --> fence closes and names a feature or registered operator
fence_coverageevery 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
seeevery 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:

reasonRule
layoutthe root reads; every entry is a regular top-level .json file
metathe file is a JSON object with a _meta block holding only name, category, description, tags, operators, intents, capabilities
namelowercase 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 / descriptiona lowercase category token; a description
tagsevery tag from the canonical example taxonomy
intentsevery intent an intent-taxonomy ID (pulse.Intents())
capabilitiesevery entry a non-operator feature name (capability:stream); operators belong in operators
bodythe body decodes STRICTLY — an unknown key is refused — as its request root: requests → compose, stages → process chain, fields → facet, otherwise process
operators_bodyoperators equals the operators the body names as a "type" value
operatorsevery operators entry is a built-in operator or one registered in the same Extensions
edge_coverageevery 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).

CodeTrigger
PULSE_EXTENSION_NAME_INVALIDname fails the registration regex
PULSE_EXTENSION_NAME_RESERVEDnamespace is BUILTIN/STANDARD/CORE/PULSE, or an ExprFunction is named total (engine-owned)
PULSE_EXTENSION_NAME_COLLISIONname matches a built-in
PULSE_EXTENSION_DUPLICATEsame name registered twice
PULSE_EXTENSION_STREAMABLE_MISMATCHdeclared streaming tier does not match factory interface
PULSE_EXTENSION_FANOUT_MISMATCHgrouper FansOut disagrees with extend.MultiKeyStreamingGrouper, either direction
PULSE_EXTENSION_PURPOSE_INVALIDa Purpose breaks a guidance validity rule, or a test Interpretation is structurally invalid
PULSE_EXTENSION_MERGEABLE_MISMATCHaggregator / 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_MISMATCHaggregator MarginReducibility is not a known class, or is a fusable class (summable / mean_reducible / independent) without Mergeable
PULSE_EXTENSION_FACTORY_PANICfactory panicked or returned nil during probe
PULSE_EXTENSION_PARAM_INVALIDbad ParamMeta, missing Mode/Tier, lookup table with neither Rows nor Lookup, etc.
PULSE_EXTENSION_MISSING_COMPONENT_SCHEMAemitter wired (closure or sibling interface) but ComponentSchema.Keys empty
PULSE_EXTENSION_COMPONENT_SCHEMA_MISMATCHemitter returned a key not in ComponentSchema.Keys, or re-emitted a floor key
PULSE_EXTENSION_SKILL_INVALIDan Extensions.Skills file breaks a rule in “Embedder skills” — details.reason names which
PULSE_EXTENSION_SKILL_COLLISIONan Extensions.Skills stem is a built-in skill or is shipped twice
PULSE_EXTENSION_EXAMPLE_INVALIDan Extensions.Examples file breaks a rule in “Embedder examples” — details.reason names which
PULSE_EXTENSION_EXAMPLE_COLLISIONan Extensions.Examples name is a built-in example or is shipped twice
PULSE_FEATURE_PROFILE_UNKNOWNa DependsOn entry names no built-in feature or registered extension operator
PULSE_EXTENSION_NOT_WEIGHT_AWAREa row weight reached a registration without WeightAware — explicit on an aggregator, or any weight in force on an attribute or test
PULSE_LOOKUP_TABLE_UNKNOWNexpression referenced an unregistered table
PULSE_LOOKUP_MISSlookup 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.

  • 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 ComponentSchema changes and naming-policy drift.
  • skills/response-components.md — universal Response.Components contract (typed shells, floor keys, additive omitempty shape).

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 public extend package 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:

ClassWire valueWhen 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 public extend package 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 public extend package 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 public extend package 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-record KeyFor(record) / KeyForRow(record) returns a bucket key as the iterator walks rows; the orchestrator accumulates per key.
  • MultiKeyStreamingGrouper — multi-emit streaming. KeysForRow returns 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:

ClassWire valueWhen 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_IncludeFiltersLabels
  • TestGroupSetValue_IncludeFiltersCompositeKey
  • TestGroupSetPerElement_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 public extend package 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 public extend package 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_VALIDATION error code on invalid input).
  • Emit the operator’s output column labels in featureOutputLabels so predict can show the LLM client what columns the request will materialise. TestPredict_Feature enforces 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 public extend package 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.go and internal/processing/test_post_more.go — tier-2 post-tests.
  • internal/processing/test_studentized.go — numerical integration utilities (used by TEST_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 Format constant and its entry in the formats list behind Formats() (which also drives Format.CanRead / CanWrite);
  • an ext → Format case in FormatFromPath (this is what makes pulse convert, pulse import auto and pulse_import detect the file at all);
  • a case in each of NewReader, NewReaderFromBytes, NewWriter and NewWriterToBuffer, 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 / WriterOptions only when the CLI or imports.Spec actually 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 Parquet TIMESTAMP with isAdjustedToUTC=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 Parquet INT96, every SPSS DATETIME) 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:

StateFlat text (csv / tsv / excel)JSON (ndjson / jsonarray)arrow / parquet
null"" (any isNullToken spelling)nullvalidity bit clear
empty maskiocore.EmptySetCell — a bare `““`, no token`“
selection`AB``“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 via jsonshared.CoerceValue) needs no set-specific code: the marker is already in the string ExportJob.Run hands over.
  • A Writer with a native list type MUST route set cells through parrow.AppendSetList (or reproduce it): nil and "" 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’s formatStringListSlice is 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.

Stateexport row ([]any)JSONarrow / parquetflat text
value"red""red""red"red
empty-string value""""present ""(collapses)
nullnilnullvalidity 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.

AdapterCarries null vs ""How
ndjson, jsonarrayyesJSON null vs "", via jsonshared.CoerceValueExplicitNull on write and key-presence on read
arrow, parquetyesAppendNull vs Append(""); Array.IsNull on read
csv, tsvnoRFC 4180 has one spelling for an absent value, and Go’s encoding/csv does not preserve unquoted-empty vs quoted-empty on read
excelno (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
spssn/adoes 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:

SlotWith an authoritative schema
SampleRowsinert — nothing to sample
SetInferenceMinPctinert — no delimited-cell heuristic runs
SetDelimitersinert — DefaultSetDelimiter is the fixed contract
ColumnTypeOverridesrefused — 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:

  1. Name it by suffix, per the imports.Sidecar convention — 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 with imports.SidecarSuffix (.meta.json), which a managed import writes for the same cohort.
  2. Version the document and carry a kind, so a reader can reject one written by something else before trusting its shape.
  3. Carry a fingerprint block modelled on encoding.Index: a 32-byte SHA-256 plus SourceSize (u64) and SourceModTime (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).
  4. 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 .pulse schema metadata block would delete the sidecar entirely but needs a FormatVersion bump; 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/spss then 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.go deliberately calls Warnings() again after writer.Close() rather than trusting report.TargetWarnings. A writer that buffers and encodes at Close — which the .sav row 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:

DomainWhen 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 Message plus one or more Fixup templates explaining how to recover (the canonical case), or
  • FixupNotApplicable: true when 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_RemovesInvalidFields
  • TestMCPSchemaBinding_AllFieldsInFiltererEnum
  • TestMCPSchemaBinding_SampleAndFacetFieldEnum
  • TestMCPSchemaBinding_DedupAndSort
  • TestMCPSchemaBinding_NilSchema
  • TestBindForInstance_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 (in types/chain.go): Name string, Kind OverlayKind, Ref StageRef, Target StageRef, Scope OverlayScope, Params map[string]any.
  • StageRef (in types/chain.go): XOR {Index *int, Name string}. Index is a pointer so 0 is meaningful — the canonical “stage 0” call site sets Index = &zero, not Index unset. The downstream validator enforces “exactly one of Index / Name”.
  • OverlayRef.Stage (in types/overlay.go) is the same *StageRef — there is exactly one StageRef declaration in the codebase. The legacy OverlayStageRef identifier is a type alias to StageRef and is deprecated.
  • ChainResponse.Overlays []*OverlayLayer reuses the universal OverlayLayer wrapper from types/overlay.go (one entry per ChainRequest.Overlays spec 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 outside OVERLAY_INDEX_VS_STAGE / OVERLAY_DELTA_VS_STAGE is rejected today.
  • PULSE_OVERLAY_REFERENCE_UNKNOWN / PULSE_OVERLAY_TARGET_UNKNOWN — StageRef resolution failures. Same Index / Name / XOR / latest-stage-default contract the runtime resolveChainStageRef uses.
  • PULSE_OVERLAY_CHAIN_STAGE_SHAPE_DIVERGENT — Ref and Target stages produce different host shapes per inferChainStageShape: 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:

  1. The dictionary union outgrows the bitmask. The field is promoted to the narrowest rung that holds the union.
  2. The arriving shard declares a different rung. A re-import of a new period infers the rung its own data needs, so a set_u128 shard legitimately meets a set_u64 archive 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):

  1. 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).
  2. 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.
  3. 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.
  4. 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.
  5. 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:

FileRole
internal/encoding/archive.goZip64 read / write + EOCD
internal/encoding/schema_doc.go_schema.pulse parser / writer
internal/encoding/cohesion.goStructural + dict-prefix validators
internal/service/shard_iter.goMulti-shard row iterator
internal/service/shard_reduce.goParallel reducer for mergeable ops
internal/service/shard_admin.gocreate / add / remove / list / extract; both create and add plan set-rung reconciliation before strict cohesion
internal/service/shard_widen.goSet-width auto-widen on create + add + the mandatory warning
internal/service/shard_groups.goThe shared create / add merge (mergeShard), grouped-shard reconciliation + PULSE_SHARD_GROUPS_REWRITTEN
internal/encoding/shard_groups.goFlattenCohortBytes, ValidateGroupCohesion, group prefix rule, GroupIndexHeadroomFor
internal/encoding/widen.goThe re-stride engine (WidenSetFieldBytes / WidenSchemaSetField)
internal/service/shard_compact.gocompact
internal/service/shard_verify.goverify
internal/service/anchor_overlay.goAnchor-syntax overlay
internal/cli/shard.goCLI 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:

SymptomLikely code
Field-name typoSERVICE_VALIDATION
Numeric aggregation on a categorical fieldPULSE_AGG_NOT_MEANINGFUL_FOR_CATEGORICAL
Description below the quality thresholdPULSE_FIELD_DESCRIPTION_LOW_QUALITY
ATTR_CODE_IN code absent from a categorical dictionaryPULSE_ATTR_CODE_NOT_IN_DICTIONARY
Operator’s AcceptsTypes excludes the fieldSERVICE_VALIDATION
Filterer referenced from Aggregation not declaredSERVICE_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:

  • TestManifestOperatorsComplete
  • TestStreamability_AggregationsKnown
  • TestPredict_Streamable_MatchesRuntime
  • TestCanStreamRequest_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 the OnlineAggregator capability of the registered operator (caught by TestRegistryStreamabilityMatchesTypes), 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.

PackageVersionUsed by
jsonlite2.0.0every golden (gen_reference.R)
Matrix1.7-5nearest-PD repair, gen_singularity.R rank (recommended package, ships with R)
psych2.6.9reliability, KMO / Bartlett, minres / principal-axis / ML factor analysis, factor scores
car3.1-5VIF
ppcor1.1partial correlation
perturb2.10collinearity diagnostics (archived on CRAN; needs gdata)
corpcor1.6.10cor2pcor; a Hotelling dependency
Hotelling1.0-8TEST_HOTELLING_T2 (gen_multivariate_tests.R)
biotools4.3TEST_BOX_M
GPArotation2026.8-2oblimin rotation (gen_rotation.R, gen_factor.R)
sandwich3.1-3robust covariance (gen_robust_vcov*.R)
lmtest0.9-40robust 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

  1. With the toolchain you want to take the data from active, run make tzdata. It copies the zip into internal/temporal/ and prints the release (DATA= from lib/time/update.bash) and the new SHA-256.
  2. Set TZDataVersion and tzdataSHA256 in internal/temporal/tzdata.go to the printed values.
  3. 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.
  4. The manifest reports the release as top-level tzdata_version, so regenerate the manifest goldens: go test ./descriptor/ -run 'Test.*Golden' -update.
  5. Commit the zip, tzdata.go and 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.md is authoritative if they ever diverge (a TestUpdateDemandTableCovers CI 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 aggregatorskills/op-agg-<kebab>.md (one atomic skill per operator)TestOperatorHasAtomicSkill, TestAtomicSkillHasRequiredSections, TestSkillTokenBudget, TestSkillsCoverAllComponents
A registered attributeskills/op-attr-<kebab>.mdTestOperatorHasAtomicSkill, TestAtomicSkillHasRequiredSections, TestSkillTokenBudget, TestSkillsCoverAllComponents
A registered filtererskills/op-filter-<kebab>.mdTestOperatorHasAtomicSkill, TestAtomicSkillHasRequiredSections, TestSkillTokenBudget, TestSkillsCoverAllComponents
A registered grouperskills/op-group-<kebab>.mdTestOperatorHasAtomicSkill, TestAtomicSkillHasRequiredSections, TestSkillTokenBudget, TestSkillsCoverAllComponents
A registered window operatorskills/op-win-<kebab>.mdTestOperatorHasAtomicSkill, 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 flagTestSkillsCoverAllCliLeaves (checks the leaf path is NAMED under skills/ or docs/src/; it cannot check that the prose is adequate)
A --json envelope or format_versionCLAUDE.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.mdTestOperatorHasAtomicSkill, TestAtomicSkillHasRequiredSections, TestSkillTokenBudget, TestClaudeMdMentionsFormatVersion, TestSkillsCoverAllFieldTypes
A new non-skippable CI gateCLAUDE.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 decisionCLAUDE.md (relevant section) + PRD if applicablereviewer enforcement
An environment variableCLAUDE.md “Build / Env” + skills/session-bootstrap.mdTestClaudeMdMentionsAllEnvVars
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 parametersinternal/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 operatorskills/op-feat-<kebab>.md + capability declaration in internal/descriptor/capabilities_features.goTestOperatorHasAtomicSkill, TestAtomicSkillHasRequiredSections, TestSkillTokenBudget, TestSkillsCoverAllComponents
A registered synth distribution kindskills/op-synth-<kebab>.md + capability declaration in internal/descriptor/capabilities_distributions.goTestOperatorHasAtomicSkill, 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.goTestOperatorHasAtomicSkill, TestAtomicSkillHasRequiredSections, TestSkillTokenBudget, TestStreamability_TestsKnown, TestManifestTestsComplete
A registered tier-2 post-test variantCapability declaration in internal/descriptor/capabilities_tests.go (postTestCapabilities)TestManifestPostTestsComplete
A registered aggregator/attribute/filterer/grouper/window capability metadataCapability declaration in internal/descriptor/capabilities_<category>.go (params, accepts_types, emits_type, streamable_hint)TestManifestOperatorsComplete
A new error codeDescription row in internal/descriptor/capabilities_errors.go (errorMetaTable)TestManifestErrorCodesComplete
An error code’s fixup templateEntry in errors/fixup_metadata.go (codeMetadata) — surfaced per-code via pulse_errors_lookup / pulse errors lookup CODETestCodesHaveFixups
A new operator’s streaming capabilitytypes/streamability.go (case for the new type) + table in types/streamability_test.goTestRegistryStreamabilityMatchesTypes, TestStreamability_*Known, TestManifestStreamableMatchesTypes
The default operator tableCLAUDE.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-bootstrap skill via pulse_skills_get for 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:

FlagDefaultPurpose
--data-dirfrom PULSE_DATA_DIROverride the cohort base directory
--bind-on-opentrueRegister 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 mcp reads everything under PULSE_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-level records also go to stderr only and carry no row data; stdout stays pure JSON-RPC.
  • Metrics port. --metrics-addr host:port is the only way pulse mcp opens a port; the /metrics endpoint 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 predict to check Streamable before running an unfamiliar request — see Performance Notes.
  • No mutating background state. Pulse never writes to a cohort during process/compose. The only write paths are import, export, synth, profile, and cohort 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-predict covers 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 is O(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, and AGG_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 of TestType.

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

PathMemory profile
Single-pass streamingConstant — O(aggregator state)
Grouped streamingO(distinct_groups × per-aggregator state)
Two-pass streamingConstant; cost is 2× iter scan (typically OS-page-cached)
BufferedO(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.

Benchms/opMB/opallocs/op
BenchmarkProcess_BufferedComponents (/)30.1649.25600,122
BenchmarkProcessStream_WithComponents (/)30.4449.38600,487
BenchmarkCrosstabWideCohort_Fused (/service/)8.6921.07143,903
BenchmarkCrosstabWideCohort_Buffered (/service/)9.0822.0383,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_lookup for per-code detail — code=PULSE_XXX for one code, domain=PULSE to 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 --schema declared non-nullable. Mark the field "nullable": true in 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

CommandWhat it does
make buildBuilds the CLI binary to bin/pulse (default goal)
make testRuns go test ./...
make fmtRuns go fmt ./...
make vetRuns go vet ./...
make lintRuns go vet then staticcheck ./...
make coverRuns tests with coverage; outputs coverage.out
make cleanRemoves 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

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 vet cleanliness — make lint is the gate.
  • Module path is github.com/frankbardon/pulse. The standard-library io collision is handled by aliasing the project’s package as pio "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 — never os.Open/os.ReadFile directly in library code, because that defeats fs.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_CATEGORY format, 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:

BanEnforced by
internal/descriptor/ MUST NOT import internal/service/ or internal/processing/TestPredictNoExecutionImports
descriptor/ + internal/descriptor/ MUST NOT use fmt.Sprintf for JSON constructionTestDescriptorNoFmtSprintf
Golden files in descriptor/testdata/ MUST NOT be hand-editedTestGoldensNotHandEdited
No predecessor-project string prefixes (legacy “Orbit” naming) in error codes or constantsTestNoOrbitReferences, TestNoOrbitPrefix
CLAUDE.md MUST mention every PULSE_* env var, every non-skippable gate, the current format_versionTestClaudeMd* 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 a name field, run with t.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.

GateGuards
TestPredictNoExecutionImportsinternal/descriptor/predict.go does not import internal/service/ or internal/processing/
TestDescriptorNoFmtSprintfdescriptor/ + internal/descriptor/ never build JSON via fmt.Sprintf
TestGoldensNotHandEditeddescriptor/testdata/* hashes match the generator
TestClaudeMdMentionsFormatVersionCLAUDE.md references the current envelope format_version
TestClaudeMdMentionsAllEnvVarsEvery PULSE_* env var has a CLAUDE.md row
TestClaudeMdMentionsAllNonSkippableGatesThis very table is the source — CLAUDE.md must list every gate by name
TestUpdateDemandTableCoversThe Update Demand table covers every registered component category
TestPerPackageCoverageFloorsPackage directories exist and meet documented coverage floors
TestNoOrbitReferences, TestNoOrbitPrefix, TestNoOrbitPrefixesNo predecessor-project string prefixes leak in
TestSkillsCoverAll*Skill files mention every registered component, error code, distribution, CLI leaf, field type, MCP tool
TestSkillsManifestConsistentEvery skill’s applies_to frontmatter names only real CLI leaves (there is no skills/index.json — the embed.FS walk is the manifest)
TestSkillsFrontmatter_RequiredFieldsEvery skill has name, description, type, applies_to
TestRegistryStreamabilityMatchesTypesAggregator OnlineAggregator capability matches AggregationType.Streamable()
TestPredict_Streamable_MatchesRuntimePredictResult.Streamable mirrors internal/processing’s CanStreamRequest
TestStreamability_*KnownEvery All*Types() entry has a streamability table row
TestCanStreamRequest_RegressionMatrixRegression matrix on the exported CanStreamRequest helper
TestManifest*CompleteManifest enumerates every registered operator, test, distribution, MCP tool, error code
TestManifestStreamableMatchesTypesManifest Streamable flags mirror the type-level methods
TestCodesHaveFixups, TestSkillsErrorCodeFixupsDocumentedEach error code has a fixup template and the skill row to match
TestDefaults_AppliedSmart-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.go must not import internal/service/ or internal/processing/.
  • TestDescriptorNoFmtSprintf — no fmt.Sprintf in descriptor/envelope.go, manifest.go, predict.go, inspect.go.
  • TestGoldensNotHandEdited — every golden file under descriptor/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
  • TestPublicAPIGolden dumps the exported shape of every public package, with each root alias expanded into its target’s fields, tags and methods, and compares it to internal/apigolden/testdata/public_api.txt. If you meant to change the surface, regenerate with go test ./internal/apigolden/ -run TestPublicAPIGolden -update and review the diff as the API delta.
  • make smoke compiles and tests internal/embeddersmoke, a nested module with its own go.mod that may only use public spellings. CI runs it.
  • The apidiff job (.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 / featurego test ./internal/skills/ -run TestSkillsCoverAllComponents && go test ./internal/descriptor/ -run TestManifestOperatorsComplete
A statistical testgo test ./types/ -run TestStreamability_TestsKnown && go test ./internal/descriptor/ -run 'TestManifestTestsComplete|TestManifestPostTestsComplete'
A synth distributiongo test ./internal/skills/ -run TestSkillsCoverAllSynthDistributions && go test ./internal/descriptor/ -run TestManifestDistributionsComplete
A regression operatorgo test ./internal/skills/ -run TestSkillsCoverAllRegressions && go test ./internal/descriptor/ -run TestManifestRegressionsComplete
An error codego test ./errors/ -run 'TestCodesHaveFixups|TestErrorsLookup' && go test ./internal/descriptor/ -run 'TestManifestErrorCodesComplete|TestManifest_ErrorCodesSlim'
An MCP toolgo test ./internal/skills/ -run TestSkillsCoverAllMCPTools && go test ./internal/descriptor/ -run TestManifestMCPToolsComplete && go test ./internal/mcp/ -run TestMCPSchemaBinding
A field typego test ./internal/skills/ -run TestSkillsCoverAllFieldTypes && go test ./encoding/... ./internal/encoding/...
The Update Demand table or a contract listed in itgo test . -run TestUpdateDemandTableCovers
Any exported identifier in a public package, or a root alias’s targetgo test ./internal/apigolden/ (then -update only if the change is intended) + make smoke
A package move under internal/io/** / internal/iocorego 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 categoryDoc/skill update required in the same PR
Registered aggregator / attribute / filterer / grouperThe matching skill file + the operator capability table
Registered window / feature / synth distribution / statistical testSame — 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 changeCLAUDE.md “Output Format Contract”
.pulse file format changeCLAUDE.md “Byte-layout invariants” + .claude/reference/byte-layout.md + skills/cohort-schema-design.md
New environment variableCLAUDE.md “Build / Env” + skills/session-bootstrap.md
New non-skippable CI gateList 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 into internal/. Regenerate with go test ./internal/apigolden/ -run TestPublicAPIGolden -update and call the delta out in the PR description.
  • apidiff (.github/workflows/api-compat.yml) compares the PR against the latest v* 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 the api-break-ok label to a PR whose break is intentional; the summary then records it as such. Once a stable v1.0.0 tag exists the job becomes blocking for incompatible changes, and api-break-ok is the only override.
  • make smoke builds internal/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 --force to main. 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.