Data Engineering Pro (v0.69.0)
Dataset prep stops being "throw a JSONL at the trainer" and becomes a first-class engineering discipline. dbt-shaped DAG, Great-Expectations suite, Magpie synth, Persona-Hub diversity, and the arXiv 2510.13928 brain-rot detector.
soup build — dbt-shaped DAG of dataset transforms
# manifest.yaml
# Each model names a transform, not SQL. A seed model reads a source;
# every downstream model names the models it consumes in refs.
models:
- name: chats_raw
kind: incremental
source: data/thumbs_up.jsonl
transform: identity
- name: chats_nonempty
kind: incremental
refs: [chats_raw]
transform: drop_empty
- name: chats_train
kind: table
refs: [chats_nonempty]
transform: token_countsoup build manifest.yaml --dry-run # validates topology, exits 0 (LIVE today)
soup build manifest.yaml # materialises (live since v0.71.6)--output-dir (-o, default build_out, which must stay under the working directory) receives one <model>.jsonl for every table and incremental model, plus the SQLite state store .soup_build_state.sqlite. A view is derived in memory for the models that refs it and is never written to disk, which is why the last model above is a table: make the model you want to train on a table or an incremental. drop_empty drops rows whose text field is empty or blank (config.field names a different field); no built-in transform decontaminates anything.
- Closed
SUPPORTED_MODEL_KINDS = {incremental, table, view} - Topo-sort via Kahn's algorithm
- Re-tokenise only changed rows:
compute_row_hash(SHA-256 over canonical-JSON,idfield excluded) +incremental_diff(prev, new) → {added, changed, removed, unchanged} - DoS caps:
_MAX_MODELS=256,_MAX_REFS_PER_MODEL=32,_MAX_FILE_BYTES=1 MiB - Live as of v0.71.6 —
run_buildmaterialises datasets with five built-in transforms, and these are the whole list:identity,drop_empty,lowercase,add_fieldandtoken_count. Anything else must be a dotted import path (my_pkg.transforms:filter_low_quality), resolved from the CLI, which upstream flags as arbitrary code execution: only run a manifest you trust, and do not feed an untrusted one tosoup buildon a shared CI host. A name that is neither is caught by the runner, not by the plan, so--dry-runexits 0 on a manifest whose real run dies on the first unknown transform. The schema deliberately does not validate the function reference.tablerebuilds,viewre-derives,incrementalre-transforms only the rows whose content hash changed (SQLite state store, keyed by row hash + transform fingerprint). A custom transform is a function of exactly two positional arguments, the row and the model'sconfigmapping, and it returns a dict, orNoneto drop the row; the function is resolved, and its module imported, once when the build starts. It has to be importable by the Python that runssoup(installed, or onPYTHONPATH): in a test, a module that sat only in the working directory was not found. v0.71.6 also makessoup data gen-magpiegenerate live (ollama / vllm raw-completion, SSRF-hardened;anthropicrejected).
soup expect — Great Expectations for chat data (LIVE)
# suite.yaml: every entry is a mapping with a name; arguments go under args
expectations:
- name: expect_no_pii
- name: expect_token_length_between
args: {min_tokens: 32, max_tokens: 2048}
- name: expect_no_refusal_pattern
- name: expect_chosen_preferred_over_rejected_by_judge
args: {threshold: 0.7, judge: "ollama://llama3.1"}soup expect ./data.jsonl ./suite.yaml
soup expect ./data.jsonl ./suite.yaml --judge ollama://llama3.1Exit codes (changed in v0.76.0): 0 the suite passed, 2 an expectation failed, 3 the suite or the data was rejected (a missing or invalid suite, an unreadable or malformed dataset, a path outside the working directory, a mistyped flag), and 1 the judge could not be reached. Through v0.75.2 the codes were the other way round: 3 meant a failed expectation and 2 a rejected input. A CI step written for the old codes now reads a failing dataset as an input error, or the reverse. Check any exit 3 test in a pipeline. The same move applies to the other gate commands; the list is on Exit codes. The exit-1 case comes from the code and was not run here, because no judge server was available.
Closed allowlist: expect_no_pii (reuses v0.47 Presidio), expect_token_length_between, expect_no_refusal_pattern (reuses v0.56 refusal detector), expect_chosen_preferred_over_rejected_by_judge (see below). _MAX_SUITE_LEN=64. Drop into CI between soup data and soup train.
The suite is checked strictly. A suite entry takes only name and args, and args takes only what that expectation declares: min_tokens and max_tokens for the length check, threshold, judge and advisory for the judge expectation, nothing for the other two. A key beside name (threshold: 1 at the entry level), an argument the expectation does not take (min_token), a bare name instead of a mapping (expectations[0] must be a dict), a - expect_x: {...} mapping (expectation name must be non-empty) and a top-level key other than expectations (fail_fast: true) are all refused when the suite loads, with exit 3, naming the key and suggesting the close spelling where there is one: expect_token_length_between takes no argument 'min_token' (did you mean 'min_tokens'?). Through v0.75.2 an unknown key under args was ignored, so a misspelled bound silently left that bound off. The length check defaults to min_tokens 1 and max_tokens 1,048,576 (so with no arguments an empty row fails), and it counts whitespace-separated words, not tokenizer tokens.
expect_chosen_preferred_over_rejected_by_judge needs a judge. Give it one with args: {judge: "ollama://llama3.1"} in the suite or soup expect --judge <url> on the command line; the flag wins when both are set. The URL takes the same forms as soup eval gate's judge_model (ollama://<model>, loopback http://, or https://), and plain http:// is accepted only for loopback and a private, link-local or reserved IP literal is refused (both exit 3). Each row is judged in both orders: chosen winning both times scores 1.0, rejected winning both times scores 0.0, and a tie or a disagreement between the two orders scores 0.5. threshold (default 0.7) is the lowest score that passes, so at the default a row passes only when the judge picks chosen in both orders. A row scoring below threshold, a row missing chosen or rejected, and a row whose judge call raises an unexpected error (judge raised) are violations; a judge that cannot be reached at all is the exit-1 case above. With no judge configured, every preference row is a violation and the run exits 2. The suite at the top of this section does exactly that if you drop judge: and pass no --judge. If you want to trust the existing labels instead, set args: {advisory: true}: nothing is scored, the report prints advisory: no judge ran; N rows not scored, and the expectation passes. A pass in advisory mode is not a preference-quality check, and --judge with a suite that has no judge expectation only prints a note. Through v0.75.2 the command-line run had no judge at all: the expectation only checked that chosen and rejected were present, threshold was never compared with anything, and it passed, which is why a clean result from an older release says nothing about preference quality. For length bias and identical pairs use soup data lint.
What the checks read. The PII and length checks scan every text field the row's format carries (v0.76.0; through v0.75.2 only a handful of top-level names were read, and input, conversations[].value, chosen, rejected and completion were skipped):
- ChatML, tool-calling and audio rows:
messages[].content(a string or a list of text parts), tool-call arguments andtools[].function.description - ShareGPT, LLaVA and ShareGPT4V:
conversations[].value - Alpaca:
instruction,input,output,system,response - DPO:
prompt,chosen,rejected; KTO:prompt,completion - embedding:
anchor,positive,negative - plaintext and ASR:
text - RAFT:
query,golden_doc,distractor_docs,answer; PRM:prompt,completions; input_output:segments[].text
expect_no_refusal_pattern reads only the assistant side: assistant and gpt turns, DPO chosen, KTO completion, Alpaca output and response, RAFT answer, PRM completions, segments not marked label: false, and text for plaintext rows. A row with text but no assistant side passes it. A row with no extractable text at all fails every expectation, so a blank or unparseable row cannot pass CI. A line that is not valid JSON, a JSON value that is not an object, and a file with no checkable rows are refused with exit 3 and line numbers, instead of being skipped. A UTF-8 byte-order mark at the start of the file is dropped.
soup data gen-magpie — synthetic data via chat-template-prefix harvest
soup data gen-magpie --base meta-llama/Llama-3-8B \
--provider ollama --target 50000 \
--output ./synth.jsonlMagpie trick: prime the base model with just the assistant chat-template prefix and let it complete the prompt itself. Reuses the v0.20 provider stack, minus Anthropic: Magpie primes the base model with a bare chat-template prefix, which a hosted chat API will not do, so --provider anthropic is refused here. Ollama and vLLM work. --base is sent to the provider as its model name, so with --provider ollama it has to be a model your Ollama server has pulled (the example id above is illustrative); a provider that is unreachable or does not know the model produces zero rows and a warning, not an error. _MAX_TARGET_ROWS=1_000_000, _MAX_BASE_MODEL_LEN=512. The live run_magpie loop and the v0.47 quality-filter chain shipped in v0.71.6, with --quality-filter on by default. The default filter keeps a row that is non-empty and scores at least 0.1 on the heuristic educational-value scorer; as of v0.76.0 it no longer applies the abuse-keyword heuristic, which is not a toxicity classifier (see Data forge).
--max-tokens caps the tokens requested per generation (default 512, allowed 1 to 16384, otherwise exit 2). --base-url points at the provider and defaults to loopback (http://localhost:11434 for Ollama, http://localhost:8000 for vLLM). For Ollama it must still be a loopback host: a remote URL is refused with exit 2 (Remote Ollama instances are not supported). For vLLM, plain HTTP is accepted only for localhost, any other host needs HTTPS, and a private, link-local or reserved IP literal is refused on either scheme.
soup data persona-mix — Persona-Hub diversity sampler (LIVE)
soup data persona-mix --prompts ./prompts.jsonl \
--n 20000 --output ./diverse.jsonl
# Optional BYO: --personas tencent-200k.jsonl --styles styles.jsonlBundled 12 personas × 5 writing styles (BYO Tencent 200k corpus via --personas / --styles). Deterministic by seed (random.Random(seed)). compute_topic_diversity = Shannon entropy over pooled whitespace tokens. Atomic JSONL write + cwd-contained input + enforce_under_cwd_and_no_symlink on output. Caps: 100 MiB / 100k entries per loader.
soup data brain-rot — AI-slop detector (LIVE)
soup data brain-rot ./data.jsonl --strict --max-major-fraction 0.25
# exit 3 if MAJOR fraction > 0.25arXiv 2510.13928 brain-rot detector. Two pure-Python scorers:
score_triviality— token-diversity inversion +!!/??punctuation runs + low-effort token density + length penaltyscore_popularity_signal— clickbait phrase scan + emoji U+1F300–U+1FAFF density
Worst-signal-wins: 1.0 − max(triviality, popularity). Bands match v0.26/v0.56/v0.65: OK ≥ 0.85, MINOR ≥ 0.60, else MAJOR. refuse_if_rotten raises when MAJOR fraction exceeds threshold. English-keyword-only in v0.69.0; multilingual shipped in v0.71.6 behind --lang en|es|fr|de|ru|auto.
Cross-cutting hardening
Refactored 3 duplicate TOCTOU blocks behind a new shared paths.enforce_under_cwd_and_no_symlink helper (code-review CRITICAL fix). v0.70 + future releases reuse it.
Numbers
+262 tests in v0.69.0 (11,225 → 11,487) across 5 new test files. 3 POSIX-only symlink tests skip on Windows.
See also
- Anti-trend insurance (v0.68) — the TOCTOU helper this consolidates was first lifted here.
- Loop hardening (v0.70) —
soup expectgates data going into--reward-hack-detectorruns. - Eval depth (v0.65) — same OK / MINOR / MAJOR taxonomy.
Soup is free and Apache-2.0. If it saved you a training run, starring the repo costs nothing and helps most. You can also fund the GPU time behind the work a 4 GB laptop cannot reach.