GestaltBI la forma all’origine del significato

stream

@gestaltbi/stream

A configurable analysis pipeline over tabular data. Ops, tags, and checks — no rendering, no framework.

Load a frame, run it through a graph of named operations described in JSON, and get back rows, verdicts or a cross-tab. Nothing here draws anything: the pipeline is the product, and the host decides what to do with what comes out.

It is the engine underneath GestaltBI, and it runs standalone — in Node, in a worker, in a browser, in a test.

npm install @gestaltbi/stream on npm →

The shape of it

Three pieces. An op is a unit of computation with a run(df) method. A process graph names ops and says which must run first. The column directory tells ops what each column is, so no op ever hard-codes a column name.

import { Processor, StructureDirectory, type ProcessConfig } from '@gestaltbi/stream';
import { firstValueFrom } from 'rxjs';

const columnDirectory = new StructureDirectory(structureJson);
const processes: ProcessConfig = JSON.parse(processingJson);

const proc = new Processor({ columnDirectory, processes });
proc.workOn({ data: rows });

const frame = await firstValueFrom(proc.getProcessed('by_month'));

getProcessed(name, identifier) returns an Observable that re-emits whenever the data or the filter changes. The identifier keys the filter state, so several views can read the same graph with different filters and never see each other's — setFilter(filter, identifier) pushes one through.

The process graph

A graph is JSON, which means a deployment can change what the analysis does without a rebuild. Each entry names an op, optionally the processes it requires, and its options.

{
  "type": "processing",
  "version": "1",
  "process": {
    "format_dates": { "op": "format", "options": { "dateTag": "uatu:date", "numberTag": "uatu:measure" } },
    "clear":        { "op": "clear",       "require": ["format_dates"] },
    "localfilter":  { "op": "localfilter", "require": ["clear"] },

    "margin": {
      "op": "enhance",
      "require": ["localfilter"],
      "options": { "columns": [
        { "column": "calc:margin", "calculate": "expr", "expr": ["-", "revenue", "cost"] }
      ]}
    },

    "by_month": { "op": "aggregate", "require": ["margin"], "options": { "groupby": ["uatu:date"] } }
  }
}

Branching, and bringing branches back together

Fan-out needs no ceremony — point two processes at the same upstream. Fan-in needs an op that asks for it: require may name several processes, but a frame only reaches an op that declares it reads more than one.

{
  "actuals":   { "op": "aggregate", "require": ["clean"], "options": { "groupby": ["sku"] } },
  "targets":   { "op": "clear",     "require": ["target_import"] },
  "vs_target": { "op": "join", "require": ["actuals", "targets"],
                 "options": { "on": "sku", "prefix": "target:" } }
}

join is a left join by default, because enrichment should not drop the rows that failed to match, and it takes only the first match on a duplicated key — a join that quietly multiplies rows breaks every sum below it. union stacks frames and can record which branch each row came from, which turns the origin into a dimension you can group by.

Tags, not column names

An op that knows a column is called revenue only works on one dataset. Ops ask the directory for columns carrying a tag instead, so the same graph runs against any structure that describes itself.

tagmeaning
uatu:dimensionsomething to group or split by
uatu:dimension:timethe time axis; ordering and period logic use it
uatu:dimension:geocarries a coordinate or a place
uatu:dimension:cohorta cohort bucket
uatu:measurea number worth aggregating
uatu:aggregablemay be rolled up by the aggregate op
uatu:measure:flowproduced by a period, so it sums across periods
uatu:measure:stocka level at a point in time — summing it is meaningless
uatu:measure:ratiocarries numerator / denominator, so it re-aggregates correctly
uatu:measure:unit:<unit>stops covers comparing terabytes with dollars
uatu:measure:basis:cash / :accrualwhich accounting basis a money column is on

Untagged numeric columns keep behaving as they always did — the vocabulary is additive, and a structure that says nothing extra loses nothing.

A rate is not an average of rates

The single most common way to get an aggregate wrong is to average a ratio. A success rate over three months is not the mean of three monthly rates — it is the total successes over the total attempts, and the two differ whenever the months are different sizes.

{
  "column": "calc:success_rate",
  "tags": ["uatu:measure", "uatu:measure:ratio"],
  "aggregation": [
    { "type": "ratio", "target": "calc:success_rate", "numerator": "successful", "denominator": "resolved" }
  ]
}

Declared this way, aggregate, pivot and every roll-up accumulate the numerator and the denominator separately and divide once at the end. The rate of a group becomes the rate of that group. On the public Kickstarter set that is the difference between 58.50% and 58.81% — small, and wrong.

Across two dimensions

pivot

aggregate rolls a frame up along one axis. pivot puts a second axis across the top, which is what it takes to see how two dimensions interact rather than each one's totals separately.

import { Pivot } from '@gestaltbi/stream';

const pivot = new Pivot(
  { rows: ['category'], columns: ['country'], measure: 'pledged', type: 'sum', totals: true },
  context,
);
const table = pivot.run([rows, {}]);   // one record per category, one field per country

columnLimit caps how many buckets are emitted and folds the tail into otherLabel, so a high-cardinality dimension degrades into a readable table instead of a thousand columns.

correlate

Scores every pair of columns and ranks them: Pearson or Spearman between two measures, eta between a dimension and a measure, Cramér's V between two dimensions.

new Correlate({ method: 'spearman', minCoefficient: 0.5, include: ['measure-measure', 'dimension-measure'] }, context)

Checks, and being wrong out loud

A number nobody can contradict is a number nobody should trust. A check is a predicate over a processed frame that returns a Verdict — pass, fail, warn or skip — naming the periods that broke it.

import { runChecks } from '@gestaltbi/stream';

const verdicts = runChecks(
  [
    { id: 'margin', type: 'sign', measure: 'calc:margin', expect: '>0', label: 'Margin was positive' },
    { id: 'covers', type: 'covers', measure: 'revenue', by: 'cost' },
  ],
  frame,
  { columnDirectory: directory, orderBy: 'uatu:date' },
);
typeasserts
monotonica measure never falls (or never rises) across the ordered periods
signa measure keeps a sign — >0, >=0, <0, <=0
coversone measure is at least as large as another, unit-aware
divergencetwo measures stay within a relative gap of each other
window_completethe period at the edge is settled enough to compare
ratio_boundsa ratio stays inside an expected band

The assert op runs them inside a graph and is normally terminal: it returns verdicts rather than rows, so a host can put pass/fail cards beside the charts and re-run them on every refresh.

Bringing your own op

Extend AbstractOp, register it, and it is available to the graph under whatever name you give it.

import { AbstractOp, buildDefaultRegistry } from '@gestaltbi/stream';

class Redact extends AbstractOp {
  run([rows]) {
    const secret = this.columnDirectory.getColumnsFor('pii:name');
    return rows.map((r) => ({ ...r, ...Object.fromEntries(secret.map((c) => [c, '—'])) }));
  }
}

const registry = buildDefaultRegistry();
registry.register('redact', Redact);

An op that needs to load something first implements getExternal(), which returns an Observable the processor waits on before every run. That is how geocode fetches its lookup without this package depending on an HTTP client.

An op that combines frames overrides runAll instead of run, and declares how many inputs it reads. The number is declared rather than inferred so a bad graph can be caught before anything runs — and so a visual editor knows how many input sockets to draw.

export class Interleave extends AbstractOp {
  public override readonly inputs = 2;
  public override runAll([left, right]) {
    return left.flatMap((row, i) => (right[i] ? [row, right[i]] : [row]));
  }
}

Reference

Extracted from src/index.ts when this page was built, so it cannot drift from what the package actually exports.

Functions

buildDefaultRegistry

fn
function buildDefaultRegistry(): OpRegistry

Build a registry pre-populated with the built-in ops under the canonical names referenced in processing.json.

dimensionColumns

fn
function dimensionColumns(dir: ColumnDirectory | undefined): string[]

Every column acting as a dimension, in {@link DIMENSION_TAGS} order, deduplicated.

finalize

fn
function finalize(type: AggKind | string, target: any): any

Turn an accumulator into the value that lands in the output record.

neuter

fn
function neuter(type: AggKind | string): any

Empty accumulator for a kind.

resolveTimeColumn

fn
function resolveTimeColumn( dir: ColumnDirectory | undefined, rows?: any[], explicit?: string, ): string | undefined

Find the column carrying time, in order of confidence: 1. an explicit column name from the op's options 2. any of {@link TIME_TAGS} in the column directory 3. a column literally named uatu:date — the canonical code that mapping.json produces, present even when no structure is loaded 4. the first column whose first value is a Date

Returns undefined when nothing matches, so callers can decide whether that is fatal or merely means "leave the order alone".

runCheck

fn
function runCheck(check: Check, rows: any[], ctx: CheckContext = {}): Verdict

Run one check and return its verdict.

Never throws: a missing column, an empty frame or a unit mismatch comes back as skip with a reason, because a check that cannot run has not failed and must not be reported as though it had.

step

fn
function step(type: AggKind | string, target: any, value: any, spec?: AggSpec, fact?: any): any

Fold one value into an accumulator.

Non-numeric input is skipped rather than folded in: parseFloat('') is NaN, and one blank cell used to poison a whole column's sum.

Classes

AbstractFilter

class
class AbstractFilter extends AbstractOp

The predicate shared by the filter ops.

A filter is an object keyed by column: an array means "one of these", and an object means a comparison — between, gt, lt and friends. Absent or empty entries match everything, so a partly-filled filter narrows rather than excludes.

AbstractOp

class
class AbstractOp implements Op

Base class for every op.

Holds the options and the context, and passes the frame straight through until a subclass overrides run. Constructed without a context it falls back to one that knows no columns, so an op built outside a process graph runs rather than throwing on first access.

inputs: number

See {@link Op.inputs}. Raised by ops that combine frames.

public getExternal(): Observable<any>
public setOptions(options: any)
public run(df: any): any
public runAll(inputs: any[], externals: any): any

Default multi-input behaviour: read the first input, ignore the rest.

Which is what an op written against the single-input contract means. An op that genuinely combines inputs overrides this instead of run.

Aggregate

class
class Aggregate extends AbstractOp

Rolls the frame up along options.groupby.

How each measure folds comes from the structure, not from here: a column declares its own aggregation specs, so one op produces :sum, :avg, :last and ratio columns together, and a rate is accumulated as numerator and denominator and divided once.

ac: any
run(df: any): any
neuter(type: string): any
agg(type: string, target: any, value: any, spec?: AggSpec, fact?: any): any
finalize(type: string, target: any): any

Assert

class
class Assert extends AbstractOp

Runs validation checks over the upstream frame.

Unlike every other op, assert is normally terminal: it returns an array of {@link Verdict} records rather than data rows, so the host can render them as pass/fail cards. Set passthrough: true to return the rows untouched and read the verdicts off {@link Assert.getVerdicts} instead — useful when you want a check in the middle of a graph.

``json { "op": "assert", "require": ["margin"], "options": { "checks": [ { "id": "gross-margin-positive", "type": "sign", "measure": "margin_accrual", "expect": ">0" } ] } } ``

public run(df: any): any
public getVerdicts(): Verdict[]

Verdicts from the most recent {@link run}.

ClearEmpty

class
class ClearEmpty extends AbstractOp

Drops rows with no identity.

A CSV with a trailing newline, or a sheet exported with padding, arrives with rows whose id is empty. They are not observations, and counting them shifts every total.

public run(df: any): any

Cohort

class
class Cohort extends AbstractOp

Cohort axis: bucket rows by the period a subject first appeared, and stamp how many periods have elapsed since.

Retention and conversion curves need "periods since first event" as a real dimension; aggregate can only group by columns that already exist, so this op creates them.

``json { "op": "cohort", "options": { "subject": "user_id", "date": "event_date", "period": "month", "into": { "cohort": "cohort_month", "since": "periods_since" }, "window": { "days": 30, "asOf": "2013-11-06", "mask": ["retained"] } } } ``

window is the guard that stops a half-observed cohort reading as a perfect one: any cohort younger than days as of asOf has the listed measures set to null and is flagged incomplete. Without it a cohort formed yesterday shows 100% 30-day retention, which is an artefact, not a result.

public run(df: any): any

Correlate

class
class Correlate extends AbstractOp

How strongly the columns of a frame move together.

The rest of the package reads a frame along time — deltas, recognition, cohorts. This reads it across dimensions instead, and answers the question a pivot table raises but cannot settle: of everything collected, which pairs actually travel together, and how strongly.

Three relationships, because "correlation" means a different statistic depending on what is being related:

- measure x measure — Pearson's r, or Spearman's rho on ranks when the relationship is monotonic but not straight. Signed: -1 to 1. - dimension x measure — the correlation ratio eta: how much of the measure's variance is explained by which group a row falls in. 0 to 1. - dimension x dimension — Cramer's V over the contingency table. 0 to 1.

``json { "op": "correlate", "require": ["clean"], "options": { "method": "spearman", "minCoefficient": 0.3, "limit": 20 } } ``

Terminal, like assert: it returns {@link Association} records rather than rows, ordered strongest first.

A coefficient is not a cause. Two measures derived from one another — a total and its own component — will correlate near 1 and mean nothing.

public run(df: any): any

Deviation

class
class Deviation

Joins two streams on aggField, optionally pads, then applies an Enhance pass to compute derived fields. Used by the diff/change visualizations.

stream1: Observable<any[]>
stream2: Observable<any[]>
prefixes: any[]
fields: any[]
outStream: Observable<any[]>
getStream(): Observable<any[]>
pimp(prefix: string, list: any[], keep: string): any[]

DiffCalc

class
class DiffCalc extends AbstractOp

Period-over-period deltas, and the stock/flow bridge.

For each configured measure emits the absolute change, the relative change, and (for a stock) the implied flow — the first difference of a level. Rows are ordered by the date column; the first period gets null, not 0, so a missing baseline never reads as "no change".

``json { "op": "diffcalc", "options": { "date": "month", "measures": [ { "column": "users", "kind": "stock", "flowInto": "users_added" }, { "column": "revenue", "kind": "flow", "lag": 12, "suffix": "_yoy" } ] } } ``

With no measures, every column tagged uatu:measure:stock or uatu:measure:flow is picked up automatically.

public run(df: any): any

Enhance

class
class Enhance extends AbstractOp

Adds derived columns.

Each entry names the column to write and how to compute it: expr for a Polish-notation expression over other columns, or func for a windowed function such as cumsum accumulated along cumulateOn. With nullSafe a missing input yields an absent result rather than a spurious zero.

public run(df: any): any
operate(row: any, op: any, df: any): void
funcCall(df: any[], column: string, func: string, options: any[]): void
hydrate(row: any, field: any): any
neuter(op: string): number
polish(expr: any[]): number | null

Format

class
class Format extends AbstractOp

Parses the frame into the types the rest of the graph assumes.

A CSV arrives as strings. Dates are parsed with options.dateFormat for every column carrying options.dateTag, numbers for every column carrying options.numberTag — by tag, so the same op serves any structure.

public run(df: any): any
parseDate(date: any): Date
cleanNumber(num: any): number | any

Geocode

class
class Geocode extends AbstractOp

Attaches coordinates by looking rows up in external geocoding files.

The files are named in options.geocoding and fetched through the context's ExternalFetcher, so this package never depends on an HTTP client of its own.

public getExternal(): Observable<any>
public run(data: any[]): any

GeoDeviation

class
class GeoDeviation

Like Deviation but reshapes both upstream GeoJSON FeatureCollections into row-oriented data first, joins, then re-wraps the result as GeoJSON for map rendering.

geocolumn: string
stream1: Observable<any>
stream2: Observable<any>
prefixes: any[]
fields: any[]
aggField: string
latField: string
lonField: string
outStream: Observable<any>
provs1: Map<string, any>
provs2: Map<string, any>
getStream(): Observable<any[]>

Geojsonify

class
class Geojsonify extends AbstractOp

Turns rows into a GeoJSON FeatureCollection.

Coordinates come from the columns tagged gcx:lat and gcx:lon, and every other field rides along as a feature property. Also records each numeric property's range, which is what a choropleth needs to pick a scale.

geoj: any
public run(df: any): any
public extractGeoJsonRange(object: any): void
numberify(row: any): any

GlobalFilter

class
class GlobalFilter extends AbstractFilter

Applies the filter every stream shares.

Use it above the fork point, for the constraints that belong to the whole page — the period under study, the company being looked at.

public run(df: any): any

Heatmap

class
class Heatmap extends AbstractOp

Reserved: registered as heatmap, but not implemented.

It inherits the pass-through run, so a graph naming it gets its input back unchanged rather than an error. Named here so a config written against it keeps loading.

Join

class
class Join extends AbstractOp

Bring the columns of one frame onto the rows of another, matched on a key.

A left join by default, because the usual reason to reach for this is enrichment — targets onto actuals, a lookup table onto transactions — and losing the rows that did not match would quietly change every total computed downstream. inner is there when the match itself is the question.

A right-hand key with several rows is a fan-out waiting to happen, so only the first match is taken: a join that silently multiplies row counts breaks every sum below it, and is the classic way a dashboard starts overstating.

inputs: any
public override runAll(inputs: any[]): any

LocalFilter

class
class LocalFilter extends AbstractFilter

Applies the filter belonging to one stream.

Keyed by options.identifier, so two views reading the same graph can narrow their own copy without disturbing each other.

public run(df: any): any

OpRegistry

class
class OpRegistry

Maps op-name strings (as referenced from processing.json) to op classes. Pre-populated with the eleven built-in ops by Processor's default constructor; callers can register additional custom ops at runtime.

register(name: string, op: OpConstructor): void
has(name: string): boolean
get(name: string): OpConstructor | undefined
instantiate(name: string, opts: any, ctx: OpContext): Op | null

Construct an op instance bound to the supplied context.

Pivot

class
class Pivot extends AbstractOp

Cross-tabulation: dimensions down the side, dimensions across the top, one aggregated measure in the cells.

aggregate rolls a frame up along a single axis. This puts a second axis across the top, which is what it takes to see how two dimensions interact rather than each one's totals separately.

``json { "op": "pivot", "require": ["clean"], "options": { "rows": ["product_family"], "columns": ["region"], "measure": "revenue", "type": "sum", "totals": true } } ``

Emits one plain record per row key — the row dimensions, then one field per column bucket — so a grid or a chart consumes it without unpacking a nested shape.

Column buckets are capped: pivoting across something high-cardinality would otherwise emit thousands of fields. The tail is collected under otherLabel and counted in {@link Pivot.getOmitted}, never dropped without saying so.

public getColumns(): string[]

Column buckets emitted by the last run, in output order.

public getOmitted(): number

How many distinct column values fell past columnLimit into the tail.

public run(df: any): any

Processor

class
class Processor

Orchestrates streaming data through a graph of named ops.

Held data: - start: the immutable input dataframe (after workOn) - work: the current "live" dataframe (mutated in place by ops) - one OLAP Cube materialized from the dimension hierarchy

Streams are keyed by an identifier string. Multiple consumers can subscribe to independent processed streams concurrently.

processes: ProcessConfig
start: any
work: any
mode: string | undefined
cube: any
cubeError: Error | undefined

Set when {@link initializeAggregator} could not build a cube.

workObs: Observable<any> | undefined
localFilterObs: any
localFilterSub: any
setMode(mode: string): void
initializeAggregator(data: any): void
workOn(dataframe: any): void
clear(): void
resolvedStreams(): string[]

Which streams are currently built, as identifier::process.

The live shape of the graph rather than its declared shape — useful for a debug view, and for an editor that wants to show what a given view actually caused to run.

getProcesses(): string[]
process(name: string, identifier = 'default'): void

Build (or reuse) the stream for one process, and leave it where this identifier's consumer will find it.

getProcessed(processed: string | null = null, identifier = 'default'): Observable<any>
clearStreams(): void
getDimensionMembers(dimension: string): any[]
liveCube(): any
setFilter(filter: any, identifier = 'default'): void
getFilter(identifier = 'default'): any
getProcessInfo(name: string): any
context(): OpContext

The context ops are constructed with. Exposed so a host can build an op — or a Deviation / GeoDeviation — outside a process graph and still give it the column directory and fetcher the graph-run ops get.

Recognize

class
class Recognize extends AbstractOp

Revenue recognition — the matching principle, mechanized.

Spreads an amount booked in one period across the periods it actually serves, so a twelve-month prepayment stops looking like twelve months of margin on the day it lands. Also emits the deferred balance: the obligation still owed.

``json { "op": "recognize", "options": { "amount": "cash_yearly", "date": "month", "term": 12, "into": "recognized_yearly", "deferredInto": "deferred_yearly" } } ``

term is either a constant number of periods or, via termColumn, a column holding a per-row term. Rows are matched on their position in the date order, so the frame must carry one row per period (no gaps) — {@link Cohort} or a prior aggregate normally guarantees that.

Amounts scheduled past the last row are not lost: they stay in the final deferred balance and are counted in {@link Recognize.getSpill}.

public run(df: any): any
public getSpill(): number

Amount scheduled beyond the end of the frame (still owed, not yet recognizable).

Regionify

class
class Regionify extends AbstractOp

Reserved: registered as regionify, but not implemented.

Like {@link Heatmap}, it passes the frame through untouched.

StructureDirectory

class
class StructureDirectory implements ColumnDirectory

Reference {@link ColumnDirectory} backed by a structure.json document.

The Angular client wraps its own service; this exists so the package is usable — and testable — on its own, with no host framework.

structure: StructureDoc
static fromJSON(text: string): StructureDirectory
getColumnsFor(tag: string): string[]
getDataStructureFor(tag: string): StructureDoc
getColumn(code: string): StructureColumn | undefined

Full entry for one column code.

getTags(): string[]

Every tag in use, deduplicated — the raw material for an auto-built glossary.

getDimensionHierarchies(): any

Dimension hierarchies in the shape olap-cube-js expects: { dimensionHierarchies: [{ dimensionTable: { dimension, keyProps }, level: [] }] }.

One flat hierarchy per dimension column — including the refinements, since uatu:dimension:geo marks a dimension every bit as much as the base tag does. If a structure declares no dimensions at all the cube is given an empty list rather than a malformed one.

Union

class
class Union extends AbstractOp

Stack several frames into one.

The simplest thing two branches can do once the graph allows them: last year and this year, two regions prepared differently, a forecast beside an actual. Rows keep their own columns — a column missing from one input is simply absent on those rows rather than filled with a zero nobody measured.

sourceInto writes which branch each row came from, which is usually what makes the union worth doing: it turns the origin into a dimension you can then group by.

inputs: any

Anything from two upwards; the graph decides.

public override runAll(inputs: any[]): any

Interfaces

AggSpec

interface
interface AggSpec

ratio reads its own two columns off the fact rather than a single value.

type?: AggKind
numerator?: string
denominator?: string

Association

interface
interface Association

One scored pair: which two columns, how strongly they move together, and by what method.

a: string
b: string
kind: PairKind
method: string

pearson / spearman for two measures, eta for a split, cramersV for two dimensions.

coefficient: number | null

Signed for measure pairs, 0..1 for the others. Null when it could not be computed.

n: number

Complete observations behind it.

strength: 'none' | 'weak' | 'moderate' | 'strong' | 'undefined'
summary: string

One line, safe to render straight into a card.

reason?: string

Set when the pair was skipped.

CheckContext

interface
interface CheckContext

What a check is run against, beyond the rows themselves.

columnDirectory?: ColumnDirectory
orderBy?: string

Fallback ordering column when a check does not name one.

ColumnDirectory

interface
interface ColumnDirectory

Column metadata directory consumed by ops that need to look up which raw columns are tagged a certain way (e.g. all date columns, all geocodable address columns).

Implement this with whatever backing store fits (a JSON document, a dbt manifest, a config file). The Angular adapter wraps a service that loads it from assets/structure.json.

getColumnsFor(tag: string): string[];

Returns column codes that carry the given tag.

getDataStructureFor(tag: string): any;

Returns the structure document filtered to only columns carrying the tag.

getDimensionHierarchies(): any;

Returns OLAP cube dimension hierarchies derived from tagged columns.

CorrelateOptions

interface
interface CorrelateOptions

Which pairs to score, and how strong a result has to be to be worth reporting.

measures?: string[]

Numeric columns. Defaults to everything tagged uatu:measure and present in the frame.

dimensions?: string[]

Categorical columns. Defaults to every column tagged as a dimension.

method?: 'pearson' | 'spearman'

pearson (default) measures a straight-line relationship, spearman a monotonic one.

include?: PairKind[]

Which families to compute. Default: all three.

pairs?: Array<[string, string]>

Only these pairs, in order. Overrides the generated matrix.

minPairs?: number

Fewer complete observations than this and the pair is skipped. Default 3.

maxLevels?: number

A dimension with more levels than this is not summarised. Default 50.

minCoefficient?: number

Drop pairs weaker than this absolute coefficient. Default 0 (keep all).

limit?: number

Cap on emitted rows, strongest first.

CoversCheck

interface
interface CoversCheck extends BaseCheck

Asserts one measure is at least as large as another, every period.

Unit-aware: comparing a column tagged in terabytes against one in dollars skips rather than answering a question nobody asked.

type: 'covers'
measure: string

The measure that must be at least as large as by.

by: string
atLeast?: number | 'all'

DivergenceCheck

interface
interface DivergenceCheck extends BaseCheck

Asserts two measures stay within a relative gap of each other.

For the pairs that should track — a cash and an accrual basis of the same quantity — where the interesting event is them coming apart.

type: 'divergence'
a: string
b: string
warn?: number

Relative gap |a-b| / max(|a|,|b|) above which to warn / fail.

fail?: number

JoinOptions

interface
interface JoinOptions

Which key to match on, which rows to keep, and what to carry across.

on: string | [string, string]

Key column. Use [left, right] when the two sides name it differently.

type?: 'left' | 'inner'

left keeps every left row; inner keeps only the matched ones. Default left.

prefix?: string

Prefix for columns coming from the right, so a shared name does not overwrite.

columns?: string[]

Right-hand columns to bring across. Default: all but the key.

MonotonicCheck

interface
interface MonotonicCheck extends BaseCheck

Asserts a measure never falls (or never rises) across the ordered periods.

type: 'monotonic'
measure: string
direction?: 'increasing' | 'decreasing'
strict?: boolean

Require strict change between periods (default false — flat is allowed).

Op

interface
interface Op

The contract an op implements: run over a frame, and say what it needs loaded first.

run(df: any): any;

Run the op synchronously over df, where df[0] is upstream data and df[1]+ are external resources.

runAll?(inputs: any[], externals: any): any;

Run over every input, for an op that reads more than one process.

Optional, and the reason the single-input contract never had to change: an op that does not implement it is called through run with its first input exactly as before. An op that does — a join, a union — receives the frames in the order its require array named them.

inputs?: number

How many processes this op reads. One unless it combines frames.

Declared rather than inferred so it can be checked before anything runs — a graph that wires two inputs into an op that reads one is a mistake worth a message, not a silently discarded branch. A visual editor reads the same number to know how many input sockets to draw.

getExternal(): Observable<any>;

Return any external resources the op needs combined with upstream data before run.

setOptions?(options: any): void;

Optional: replace runtime options (used by the registry on instantiation).

OpContext

interface
interface OpContext

Context handed to every op at construction. Held on AbstractOp so subclasses can access the column directory, fetch external resources, and ask the host pipeline for filter state.

columnDirectory: ColumnDirectory
fetcher: ExternalFetcher
getFilter: (identifier?: string) => any

PivotOptions

interface
interface PivotOptions extends AggSpec

How to lay out a cross-tab, and how to fold the measure in its cells.

rows?: string[]

Dimensions down the side. Defaults to every dimension not used as a column.

columns?: string[]

Dimensions across the top. Omit for a plain group-by with one measure column.

measure?: string

Measure to roll up. Not needed for count.

type?: AggKind

How to fold the measure. Default sum.

totals?: boolean

Add a per-row total and a grand-total record.

totalInto?: string

Field name for the row total. Default Total.

totalLabel?: string

Label of the grand-total record in its first row dimension. Default Total.

columnLimit?: number

Highest number of column buckets to emit. Default 50.

otherLabel?: string | null

Where the tail beyond columnLimit goes. Set null to drop it. Default Other.

emptyLabel?: string

Stands in for a null or empty dimension value. Default (blank).

separator?: string

Joins several column dimensions into one header. Default / .

prefix?: string

Prepended to every generated column field.

ProcessConfig

interface
interface ProcessConfig

The shape of processing.json: a map of process names to specs.

process: Record<string, ProcessSpec>

ProcessorOptions

interface
interface ProcessorOptions

Everything a {@link Processor} needs to run a graph.

columnDirectory: ColumnDirectory

Column metadata source.

processes: ProcessConfig

Process graph (e.g. parsed from processing.json).

fetcher?: ExternalFetcher

External resource fetcher (defaults to no-op so non-fetching ops still work).

registry?: OpRegistry

Pre-built op registry. If omitted, a default with the eleven built-in ops is used.

ProcessSpec

interface
interface ProcessSpec

A single named transformation step in a process graph.

op?: string

Op key registered in the OpRegistry.

require?: string[]

Process names that must be wired upstream of this one.

options?: any

Op-specific configuration; merged with {identifier} at runtime.

RatioBoundsCheck

interface
interface RatioBoundsCheck extends BaseCheck

Asserts a ratio stays inside an expected band.

A conversion rate above 1 or below 0 is a broken denominator, not a result.

type: 'ratio_bounds'
measure: string
min?: number
max?: number

SignCheck

interface
interface SignCheck extends BaseCheck

Asserts a measure keeps its sign.

type: 'sign'
measure: string
expect: '>0' | '>=0' | '<0' | '<=0'
atLeast?: number | 'all'

Periods that must satisfy it: a count, or 'all' (default).

StructureColumn

interface
interface StructureColumn

A column entry in a structure.json document.

column: string
type?: 'string' | 'number' | 'date' | 'boolean'
tags?: string[]
label?: string
aggregation?: Array<{ target: string; type: 'sum' | 'avg' | 'last' | 'first' | 'min' | 'max' | 'median' | 'concat' | 'ratio'; /** `ratio` only — sum these, then divide once. */ numerator?: string; denominator?: string; }>

Consumed by aggregate: how this column rolls up.

StructureDoc

interface
interface StructureDoc

The parsed structure.json: what every column is, and how it aggregates.

type?: string
version?: string
name?: string
columns: StructureColumn[]

UnionOptions

interface
interface UnionOptions

How to stack the frames, and whether to record where each row came from.

sourceInto?: string

Column to write each input's origin into, when you need to tell them apart.

sourceLabels?: string[]

Names for each input, positionally. Defaults to the index.

distinctOn?: string[]

Drop rows whose values for these columns have already been seen.

Verdict

interface
interface Verdict

What a check concluded.

Carries enough to render without re-running anything: a one-line summary, how many periods were evaluated, how many satisfied it, and the rows that broke it.

id: string
type: string
status: CheckStatus
summary: string

One-line, human-readable result. Safe to render straight into a card.

label?: string
measure?: string
n?: number

Periods actually evaluated (nulls excluded).

hits?: number

Periods satisfying the predicate.

observed?: number
expected?: string
offenders?: any[]

The rows that broke it, capped by offenderLimit.

reason?: string

Set when the check could not run (missing column, no rows, unit mismatch).

WindowCompleteCheck

interface
interface WindowCompleteCheck extends BaseCheck

Asserts a period is settled enough to be compared with the others.

A 30-day-active metric for a cohort that started a week ago is not low, it is unfinished. This is what stops the newest bar being read as a decline.

type: 'window_complete'
measure: string
cohortDate: string

Column holding the period/cohort start date.

windowDays: number

Measurement window in days (e.g. 30 for a 30-day-active metric).

asOf: string | Date

Observation date. Cohorts younger than the window as of this date are incomplete.

span?: 'day' | 'week' | 'month' | 'quarter' | 'year' | number

How wide the cohort bucket is. A monthly cohort is not fully observed until the window has elapsed past the end of the month — someone who signed up on 31 October is only six days old on 6 November even though the bucket starts 36 days back. Default 'day' treats each row as a point in time, which is the old behaviour.

Types

AggKind

type
type AggKind = | 'sum' | 'avg' | 'last' | 'first' | 'min' | 'max' | 'median' | 'concat' | 'ratio' | 'count' | 'countDistinct'

Aggregation kinds, shared by every op that rolls values up.

aggregate and pivot accumulate the same way on purpose: a sum in a pivot cell has to mean what a sum in a rolled-up row means, or the two views of the same figure disagree.

Check

type
type Check = | MonotonicCheck | SignCheck | CoversCheck | DivergenceCheck | WindowCompleteCheck | RatioBoundsCheck

Any check, discriminated by type.

CheckStatus

type
type CheckStatus = 'pass' | 'fail' | 'warn' | 'skip'

The four answers a check can give. skip is not a failure: it means the check could not run.

ExternalFetcher

type
type ExternalFetcher = (url: string) => Observable<any>

External resource fetcher. Ops that need to load files (e.g. geocoding GeoJSON) request them through this. The Angular adapter wraps HttpClient; in Node you'd pass a function backed by fetch or axios.

OpConstructor

type
type OpConstructor = new (opts: any, ctx: OpContext) => Op

Constructor signature expected by the registry.

PairKind

type
type PairKind = 'measure-measure' | 'dimension-measure' | 'dimension-dimension'

What kind of pair a coefficient describes.

Constants

allPassed

const
const allPassed

True when no verdict failed. Warnings do not fail a suite.

byDate

const
const byDate

Ascending comparator over a date-ish column. Unparseable values compare equal.

DIMENSION_TAGS

const
const DIMENSION_TAGS

Every tag that marks a column as a dimension.

uatu:dimension:time, :geo and :cohort are refinements of uatu:dimension, so a structure carrying only the refinement still has a dimension. Reading the base tag alone makes a dataset whose only axis is time look like it has no axes at all.

num

const
const num

Coerce to a finite number, or null. Blank cells are absent, not zero.

runChecks

const
const runChecks

Run a list of checks. Order preserved.

TIME_TAGS

const
const TIME_TAGS

Tags that mark the time dimension, newest vocabulary last.

Two vocabularies are in circulation. Config repos authored for gestaltbi-core (see GestaltBI/sample-config) rename the date column to the canonical code uatu:date via mapping.json and tag it uatu:timedimension. @gestaltbi/infer emits the newer uatu:dimension / uatu:dimension:time pair instead. Both are accepted everywhere so an op works against either.

Namespaces

tags

namespace
namespace tags

Tag vocabulary.

Tags are the contract between structure.json and the ops: an op never hard-codes a column name, it asks the ColumnDirectory for the columns carrying a tag. Everything here is a plain string so a structure document authored by hand (or by @gestaltbi/infer) stays readable.