Value Stream — Readers and Transforms¶
This doc specifies how files turn into a Polars LazyFrame ready for processors. It covers:
- the file-discovery and chunk-grouping rules,
- every built-in reader (Pega DS export, Parquet, CSV, XLSX) with format details and parameters,
- every built-in transform that runs between the reader and the processor fan-out,
- the source's
defaultsmap and how it relates to transforms, - expected schema invariants by the time the data reaches a processor.
Companion docs:
- concepts/domain-model.md — Source / Reader / Transform definitions.
- reference/expression-dsl.md — AST for
filterandderive_columntransforms. - reference/processors.md — what processors expect to see in the resulting frame.
1. Source lifecycle¶
files in folder
│
▼
[ Discovery & grouping ] ← reader.file_pattern, reader.group_by_filename
│
▼
[ Reader ] ← reader.kind: parquet | pega_ds_export | csv | xlsx
│
▼
[ Transforms (ordered) ] ← source.transforms[]
│
▼
LazyFrame ready for processor.chunk_aggregate
A new (chunk_id, files_in_chunk) pair travels through this pipeline once per ingestion run.
2. Discovery and chunk grouping¶
2.1 Inputs¶
sources:
- id: ih
reader:
kind: pega_ds_export
file_pattern: "**/*.zip"
group_by_filename: '\d{8}(?=\d{6}_)'
hive_partitioning: false
streaming: true
background: false
2.2 Algorithm¶
files = sorted( glob(folder, file_pattern, recursive=true) )
if files is empty:
fallback_pattern = '**/*.json' # convention
files = glob(folder, fallback_pattern, recursive=true)
reader.kind = 'pega_ds_export' # auto-promote
groups = defaultdict(set)
for f in files:
if hive_partitioning:
# the path's parent (a partition directory) is the file unit
unit = parent(f)
else:
unit = f
try:
chunk_id = re.findall(group_by_filename, abspath(f))[0]
except IndexError:
chunk_id = basename(f) # one-file chunk
groups[chunk_id].add(unit)
groups = sort_desc_by_chunk_id(groups) # newest first
2.3 Skip already processed chunks¶
ledger = read_chunks_ledger(source_id)
for chunk_id in groups:
fingerprint = sha256(sorted(path, mtime_ns, size for path in chunk.files))
if (source_id, chunk_id, source_computation_hash, fingerprint) in ledger and not force:
skip
The effective idempotency key is (source_id, chunk_id,
source_computation_hash, file_hash). The source computation hash covers
workspace defaults, source behavior, and all bound processors. file_hash
covers sorted absolute paths, nanosecond mtimes, and sizes. Unchanged inputs are
skipped; changed files or behavior are reprocessed without invalidating the
previous successful aggregate until the new run commits.
2.4 Chunk granularity¶
- Daily file groups are the recommended default for IH-shaped sources. The regex
\d{8}(?=\d{6}_)capturesYYYYMMDDfrom filenames likeinteraction-XYZ-20240821000000_001.json.zip. - One file per chunk is fine for smaller sources; the regex falls back to the basename.
- Hive-partitioned datasets (e.g. monthly-partitioned Parquet folders) put one partition directory per chunk; the reader scans the whole directory at once.
2.5 Failure modes¶
| Symptom | Cause | Behavior |
|---|---|---|
| Folder doesn't exist | misconfiguration | engine refuses to start; CLI shows the path |
| Pattern matches no files | empty source | run finishes with chunks_total=0, status ok |
| Regex never matches | wrong group_by_filename |
engine logs chunk_id=basename(file); processed but not date-grouped |
| Two files belong to different chunks but share a name | rare; would collide | engine raises and aborts the run before processing |
3. Reader: pega_ds_export¶
Pega CDH "Dataset Export" files are gzipped or zipped JSON/NDJSON archives. Value Stream normalizes them to a single NDJSON stream, then polars.scan_ndjsons it.
3.1 Accepted file extensions¶
| Extension | Treatment |
|---|---|
.zip |
extract all .json and .ndjson members in alphabetical order; concatenate into one NDJSON stream |
.tar.gz, .tgz |
extract .json/.ndjson members from the tar; same concatenation |
.gz, .gzip |
gunzip in-place; expect a single JSON or NDJSON file |
.json, .ndjson |
read directly |
Each archive becomes one normalized NDJSON file in the OS temp directory; the temp directory is cleaned up at process exit.
3.2 Concatenation rules¶
When concatenating multiple JSON/NDJSON members, the reader appends a newline if the previous member did not end in one. This guarantees polars.scan_ndjson sees one record per line across boundaries.
3.3 Schema discovery¶
scan_ndjson(infer_schema_length=100_000) reads the first 100 K records to infer types. This is sufficient for IH exports up to billions of rows because Pega CDH exports use a stable per-tenant schema.
3.4 Parameters¶
reader:
kind: pega_ds_export
file_pattern: "**/*.zip"
group_by_filename: '\d{8}(?=\d{6}_)'
streaming: true # use Polars streaming engine on collect
infer_schema_length: 100000 # optional, default 100000
archive_temp_dir: /tmp/valuestream # optional, default OS tempdir
3.5 Exit-time cleanup¶
Every chunk creates one temp directory with the prefix dataset_export_. An atexit hook removes them on process shutdown. A janitor command (valuestream vacuum --tmp) removes orphans from prior crashes.
4. Reader: parquet¶
4.1 Parameters¶
reader:
kind: parquet
file_pattern: "**/*.parquet"
group_by_filename: '(\d{4}-\d{2}-\d{2})' # optional
hive_partitioning: true # if directory layout uses hive partitions
streaming: true
missing_columns: insert # behavior when a chunk's file lacks a known column
extra_columns: ignore # behavior when a chunk's file has extra columns
4.2 Implementation¶
ih = pl.scan_parquet(
files,
cache=False,
hive_partitioning=hive_partitioning,
missing_columns="insert",
extra_columns="ignore",
)
4.3 Notes¶
- Value Stream expects upstream Parquet to be tabular and timezone-aware where applicable.
- Hive partition columns become regular columns in the LazyFrame, available to transforms.
5. Reader: csv¶
5.1 Parameters¶
reader:
kind: csv
file_pattern: "**/*.csv"
separator: auto # auto | "," | ";" | "\t" | "|" | ":"
infer_schema_length: 10000
try_parse_dates: true
5.2 Delimiter detection¶
When separator: auto, the reader peeks at the first 2 lines and tries delimiters in this order:
, ; \t (space) | :. The first delimiter that parses both lines into the same number of fields wins; otherwise it defaults to ,.
5.3 Implementation¶
ih = pl.scan_csv(
file,
separator=separator,
infer_schema_length=infer_schema_length,
try_parse_dates=try_parse_dates,
cache=False,
)
6. Reader: xlsx¶
reader:
kind: xlsx
file_pattern: "**/*.xlsx"
sheet: 0 # int index or sheet name
6.1 Notes¶
- XLSX is read eagerly via
polars.read_excel(...)and immediately converted to a LazyFrame. - Multi-sheet workbooks must be expanded into one Source per sheet; Value Stream does not auto-fan-out.
7. Source defaults block¶
Defaults run after the reader, before transforms (or as the first transform — implementation-equivalent). Two behaviors:
| Case | Behavior |
|---|---|
| Column doesn't exist in the LazyFrame | add it as a literal with the default value |
| Column exists but has nulls | fill_null(default) |
Type promotion: if the default value parses as a float, it becomes Float64; otherwise it stays a string.
sources:
- id: ih
defaults:
ModelControlGroup: Test
PlacementType: "N/A"
ExperimentName: "N/A"
ExperimentGroup: "N/A"
FinalPropensity: 0.0
Priority: 1e-10
Revenue: 0.0
ConversionEventID: "N/A"
8. Transforms catalog¶
A Source's transforms list is processed in order. Each transform is a typed dictionary {kind: <name>, ...params}. The full set:
8.1 rename_capitalize¶
Renames every column to its Capitalized form. The legacy app does this so Pega's pyName becomes Name, pxFirstName becomes Pxfirstname, etc. The transform also drops a configurable list of non-essential columns first.
- kind: rename_capitalize
drop_columns: [pxObjClass, pxApplication, pxOutboundChannelInfo] # optional
8.2 parse_datetime¶
Parses string columns into Datetime. The transform is "ensure datetime":
columns a reader already typed as datetime (CSV date inference, Parquet
schemas) pass through unchanged, so the same source configuration works for
string-timestamp previews and typed runtime reads.
- kind: parse_datetime
columns: [OutcomeTime, DecisionTime]
format: "%Y%m%dT%H%M%S%.3f %Z" # strptime format
utc: true # convert to UTC
8.3 derive_calendar¶
Adds calendar columns from a timestamp.
- kind: derive_calendar
from: OutcomeTime
outputs: [Day, Month, Year, Quarter] # any subset
week_iso: false # also compute "Week"
Output columns: Day (date), Month (string YYYY-MM), Year (Int16), Quarter (string YYYY_Qn), optional Week (string YYYY-Www).
8.4 derive_action_id¶
Concatenates business-key parts into a single ActionID. Mirrors Pega's Issue/Group/Name.
- kind: derive_action_id
parts: [Issue, Group, Name]
sep: "/"
output: ActionID
8.5 derive_column¶
Adds a derived column from an AST expression.
- kind: derive_column
output: ResponseTime
expression:
op: date_diff
unit: seconds
end: {col: OutcomeTime}
start: {col: DecisionTime}
8.6 filter¶
Restricts rows by an AST predicate.
- kind: filter
expression:
op: and
args:
- {op: not_null, column: Channel}
- {op: in, column: Outcome, values: [Impression, Clicked, Pending, Conversion]}
8.7 dedup¶
Deduplicates by a key set, optionally keeping the row with the maximum of a column.
- kind: dedup
keys: [InteractionID, ActionID, Rank, Outcome]
keep: first # first | last | max | min
on_column: Outcome_Binary # only required if keep ∈ {max, min}
8.8 cast¶
Casts columns to a target dtype. Useful when the reader infers a wrong type.
- kind: cast
columns:
Propensity: Float64
Priority: Float64
8.9 drop_columns¶
Removes columns explicitly.
- kind: drop_columns
columns: [pxOutboundChannelInfo, pxOutboundCampaignInfo]
8.10 coalesce¶
Replaces nulls in target with the first non-null among from.
- kind: coalesce
target: ConversionEventID
from: [ConversionEventID, Name]
(Pega's interaction history uses this pattern: when ConversionEventID is empty, use Name.)
9. Schema invariants at processor entry¶
By the time a chunk's LazyFrame reaches processor.chunk_aggregate, it must satisfy:
- Required group-by columns exist — every transformed column referenced by
processor.group_by. - Required outcome / score / key columns exist — for
binary_outcome:outcome.column; forscore_distribution: every selectedscore_propertiescolumn. - Configured processor time input exists —
processor.time.column, when set; calendar output columns needed for physical grains are derived by transforms/grain handling. - Timestamps are tz-aware — UTC.
- Natural-key columns exist if
dedup_keysis set. - No
eval-derived columns remain — all transforms ran in a typed AST.
The engine checks authored group-by, time, outcome, score/property, lifecycle, entity, milestone, dedup, and explicit state-source columns against the transformed LazyFrame schema before any processor writes. Missing inputs fail the chunk with one grouped, actionable error rather than silently dropping a dimension or state.
10. Memory ownership¶
reader.streaming: true selects Polars' streaming engine for compatible source
scan and transform operations. Streaming reduces transient execution memory but
does not make the result of a collection lazy or guarantee a fixed RSS ceiling.
With materialize_transforms: true, the transformed result is collected once as
one eager DataFrame and reused by every processor. Processor plans run with the
in-memory engine because sketch builders and other Python callbacks are not
streaming-native. The shared frame is released immediately after processor
fan-out, and processor output frames are released one at a time after their
aggregate files are written. The allocator may retain released pages for later
chunks, so process RSS need not fall immediately.
There is currently no automatic RSS threshold, processing pause, or spill of a materialized chunk to temporary Parquet. If a transformed chunk does not fit, reduce the chunk grouping interval and run with lower chunk-process parallelism. Use the ingestion benchmark to measure peak RSS for the actual source and processor catalog.
11. Logging¶
For each chunk, the reader emits structured log lines:
{"event":"chunk_start", "source":"ih", "chunk_id":"20240821", "files":2}
{"event":"reader_done", "source":"ih", "chunk_id":"20240821", "rows_in":12_345_678, "ms":4321}
{"event":"transforms_done","source":"ih","chunk_id":"20240821", "rows_kept":12_300_000, "ms":250}
{"event":"chunk_done", "source":"ih", "chunk_id":"20240821", "ms":12_500}
These are correlated by pipeline_run_id for trace assembly.
12. Reader extensibility¶
A new reader implements:
class Reader(Protocol):
kind: str
def discover(self, folder: str, params: dict) -> dict[str, list[Path]]:
"""Return chunk_id -> list of file paths."""
def read_chunk(self, files: list[Path], params: dict) -> pl.LazyFrame:
"""Return a Polars LazyFrame for one chunk."""
Plug it into the registry:
register_reader("oracle_table", OracleTableReader)
The new kind is then usable from YAML:
reader:
kind: oracle_table
connection: ${env.ORACLE_DSN}
query: "SELECT ... WHERE created_at >= :since"
group_by_value: created_at_date
Built-in readers ship in valuestream.readers.builtin; community readers may live in plugins.
13. Examples¶
13.1 Pega CDH IH¶
sources:
- id: ih
reader:
kind: pega_ds_export
file_pattern: "**/*.zip"
group_by_filename: '\d{8}(?=\d{6}_)'
streaming: true
schema:
timestamp_column: OutcomeTime
natural_key: [InteractionID, ActionID, Rank, Outcome]
transforms:
- {kind: rename_capitalize}
- {kind: parse_datetime, columns: [OutcomeTime, DecisionTime], format: "%Y%m%dT%H%M%S%.3f %Z"}
- {kind: derive_calendar, from: OutcomeTime, outputs: [Day, Month, Year, Quarter]}
- {kind: derive_action_id, parts: [Issue, Group, Name], sep: "/", output: ActionID}
- {kind: derive_column, output: ResponseTime, expression: {op: date_diff, unit: seconds, end: {col: OutcomeTime}, start: {col: DecisionTime}}}
- {kind: filter, expression: {op: not_null, column: Channel}}
- {kind: coalesce, target: ConversionEventID, from: [ConversionEventID, Name]}
- {kind: dedup, keys: [InteractionID, ActionID, Rank, Outcome]}
defaults:
ModelControlGroup: Test
PlacementType: "N/A"
ExperimentName: "N/A"
ExperimentGroup: "N/A"
FinalPropensity: 0.0
Priority: 1e-10
Revenue: 0.0
ConversionEventID: "N/A"
13.2 Product Holdings (JSON exports)¶
sources:
- id: holdings
reader:
kind: pega_ds_export
file_pattern: "**/*.json"
group_by_filename: '(.*)'
streaming: false
transforms:
- {kind: rename_capitalize}
- {kind: parse_datetime, columns: [PurchasedDate], format: "%Y%m%dT%H%M%S%.3f %Z"}
- {kind: derive_column, output: PurchasedDateTime, expression: {col: PurchasedDate}}
defaults:
Channel: "N/A"
13.3 Internal CSV¶
sources:
- id: subscriptions
reader:
kind: csv
file_pattern: "**/*.csv"
separator: ","
try_parse_dates: true
transforms:
- {kind: cast, columns: {monthly_recurring: Float64, plan_id: Int32}}
- {kind: derive_calendar, from: created_at, outputs: [Day, Month, Year, Quarter]}
13.4 Hive-partitioned Parquet¶
sources:
- id: events
reader:
kind: parquet
file_pattern: "**/*.parquet"
group_by_filename: '(\d{4}-\d{2}-\d{2})'
hive_partitioning: true
streaming: true
transforms:
- {kind: parse_datetime, columns: [event_ts]}
- {kind: derive_calendar, from: event_ts, outputs: [Day, Month, Year, Quarter]}
14. Sanity checklist for new sources¶
Before binding a Processor to a new Source:
- [ ]
valuestream validatepasses againstpipelines.yaml. - [ ]
valuestream probe --source <id>produces: - the inferred schema (column names + dtypes),
- a 10-row sample after all transforms,
- the calendar columns derived,
- the count of distinct
chunk_ids discovered. - [ ] All processor
group_byentries resolve to existing transformed columns. - [ ] All processor outcome/score/key columns exist.
- [ ] No transform raised a warning about silently filling NULLs in a column the processor relies on.