Materializing Derived Columns
A derived column — a window, a feature output, an attribute — exists
only inside the request that computes it. Regressions, groupers,
filters, joins and tests read cohort FIELDS, so none of them can name
one. Pulse.Materialize writes the derived columns as ordinary fields
of a NEW cohort; the next request reads them like any other field.
Library
Pulse.Materialize(ctx, req, out, opts) turns a request’s derived
columns into ordinary fields of a NEW single-file cohort, so later
requests can filter, group or join on them:
res, err := p.Materialize(ctx, &pulse.Request{
Cohort: &types.Cohort{Filename: "sales.pulse"},
Features: []*types.Feature{{Type: types.FEAT_LOG, Field: "spend"}},
Windows: []*types.Window{{Type: types.WIN_LAG, Field: "spend",
PartitionBy: []string{"region"}, OrderBy: []types.OrderKey{{Field: "week"}}}},
}, "sales_derived.pulse", pulse.MaterializeOptions{})
// res.Records, res.DerivedColumns ("LOG_spend", "WIN_LAG_spend")
- Accepted slots. Features, filterers, attributes, windows,
sort, weight andtime_zone. Any other slot is refused withPULSE_MATERIALIZE_SLOT_UNSUPPORTED(details.slot); a request with no feature, attribute or window withPULSE_MATERIALIZE_NO_DERIVED; an embedder-registered feature (its outputs are unknown before it runs) withPULSE_MATERIALIZE_OUTPUT_UNDECLARED. Process’s own pre-flight runs first, so a requestProcessrefuses is refused with the same error. Every refusal happens before anything is created. - Rows. One per filter-passing source record, in source order (an
archive’s shards concatenated in archive order). Features see every
record, attributes and windows the survivors — exactly as
Processcomputes them. Withsort, the rows follow the orderRequest.SortgivesProcess’s row set — the engine’s own comparator, ties in source order, nulls last in both directions — on keys naming source fields or derived columns (feature outputs, attribute and window labels). Sorting moves rows, never values: every window has already run under its ownpartition_by/order_by. Only the permutation and each survivor’s source index are held beside the buffered records, and the memory pre-flight isProcess’s (PULSE_LIMIT_EXCEEDEDbefore anything is created). Source cells are the exact stored values (CohortReader.RecordAt). - Schema. The source’s logical schema verbatim (order, types,
nullability, dictionaries, descriptions), then one nullable
f64per derived output in pipeline order — features (every output of a multi-output feature), attributes, windows — each with a generated description such asWIN_LAG of spend (partition region; order week) — derived from sales.pulse. A window’s frame is stated only when the operator reads it:WIN_EWMAis cumulative whatever its required frame says (manifestignores_frame), so its description has no frame clause. An operator whose input is the slot’sexpression(manifestreads_expression) is described by that expression, verbatim —ATTR_FORMULA = tv_spend_k * 0.80866 — derived from panel.pulse— and a regression attribute names itspenalty/alpha/l1_ratio. Every description is capped at 1,000 bytes, cut on a rune boundary with an ellipsis. Integer window outputs (row number, rank) are stored asf64; a non-numeric value (a lag copying a categorical label) fails the run withPULSE_MATERIALIZE_OUTPUT_NOT_NUMERIC. - Writing. Always an ungrouped (
0x01) single file, written atomically throughNewCohortBuilder; source sidecars are not carried. An existing target needsOverwrite; replacing one reports its stale sidecars inInvalidatedSidecarsplus aPULSE_MATERIALIZE_SIDECARS_INVALIDATEDwarning.Strictescalates a weak description on the output schema (the generated ones always pass; an undescribed source field does not). A limit breach, a refusal or a write failure leaves nothing at the target.
The two-step recipe: a regression on a window
Step 1 materialises the window (only derived columns and filters):
zero := 0 // a cumulative frame: unbounded preceding through the current row
res, err := p.Materialize(ctx, &pulse.Request{
Cohort: &types.Cohort{Filename: "weekly.pulse"},
Windows: []*types.Window{{Type: types.WIN_RUNNING_SUM, Field: "x", Label: "cum_x",
OrderBy: []types.OrderKey{{Field: "week"}},
Frame: &types.FrameSpec{Mode: "rows", Following: &zero}}},
}, "weekly_derived.pulse", pulse.MaterializeOptions{})
Step 2 points the next request at the output and names the column by its label:
resp, err := p.Process(ctx, &pulse.Request{
Cohort: &types.Cohort{Filename: "weekly_derived.pulse"},
Regressions: []*types.RegressionSpec{{Type: types.REG_OLS, Target: "y",
Predictors: []string{"cum_x"}}},
})
Label every output a later request references; an unlabelled one takes
the operator’s default output name (res.DerivedColumns lists each).
The output is a snapshot: re-materialise after the source changes.
CLI
pulse api materialize -r req.json -o weekly_derived.pulse [--overwrite] [--strict] [--json]
req.json is an ordinary request body. --json wraps the
MaterializeResult (target, records, derived_columns,
warnings, invalidated_sidecars) in the standard envelope and lifts
its warnings onto the envelope’s warnings; a refusal keeps its own
code in errors[0].code (pulse errors lookup CODE). The leaf is a
thin adapter over Pulse.Materialize.
MCP
pulse_materialize takes {request, out, overwrite} — the request
NESTED under request, out relative to the data dir — and returns
the same result object. A request slot spread beside out (or a
misspelled argument) is PULSE_REQUEST_UNKNOWN_FIELD with a
suggestion; an unknown key inside request is refused as
pulse_process refuses it. Description-quality strict mode is CLI and
library only.
Feature profiles
capability:materialize (which needs capability:process) gates the
tool and the CLI leaf; a profile without it does not mount
pulse_materialize and lists no materialize command. The library
method itself is never hidden — an embedder calling it holds the
instance — but the instance’s operator set still applies: a hidden
operator in the request is refused exactly as an unknown one.