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

yaml
# 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_count
bash
soup 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, id field 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_build materialises datasets with five built-in transforms, and these are the whole list: identity, drop_empty, lowercase, add_field and token_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 to soup build on a shared CI host. A name that is neither is caught by the runner, not by the plan, so --dry-run exits 0 on a manifest whose real run dies on the first unknown transform. The schema deliberately does not validate the function reference. table rebuilds, view re-derives, incremental re-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's config mapping, and it returns a dict, or None to 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 runs soup (installed, or on PYTHONPATH): in a test, a module that sat only in the working directory was not found. v0.71.6 also makes soup data gen-magpie generate live (ollama / vllm raw-completion, SSRF-hardened; anthropic rejected).

soup expect — Great Expectations for chat data (LIVE)

yaml
# 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}
bash
soup expect ./data.jsonl ./suite.yaml
# exit 0 = pass; 2 = validation rejection; 3 = suite failure

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 (the judge hook is Python-only; see below). A bare name is refused with exit 2 (expectations[0] must be a dict) and a - expect_x: {...} mapping is refused with exit 2 as well (expectation name must be non-empty). The length check takes min_tokens and max_tokens, defaulting to 1 through 1,048,576 (so with no arguments an empty row fails), and it counts whitespace-separated words, not tokenizer tokens. Unknown keys under args are ignored, so a misspelled bound leaves that bound off without an error. For the PII and length checks it walks text, content, output, prompt, instruction, response top-level keys + messages[].content arrays; input, conversations[].value, chosen, rejected and completion are not read. _MAX_SUITE_LEN=64. Drop into CI between soup data and soup train.

expect_chosen_preferred_over_rejected_by_judge does not call a judge from the command line. soup expect runs the suite without a judge function, so on the CLI the expectation only checks that every row has both chosen and rejected (a row missing either fails); threshold is accepted and never compared with anything. A pass is not a preference-quality check; for length bias and identical pairs use soup data lint. A real judge needs run_suite called from Python with a judge_fn.

soup data gen-magpie — synthetic data via chat-template-prefix harvest

bash
soup data gen-magpie --base meta-llama/Llama-3-8B \
  --provider ollama --target 50000 \
  --output ./synth.jsonl

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

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

bash
soup data persona-mix --prompts ./prompts.jsonl \
  --n 20000 --output ./diverse.jsonl
# Optional BYO: --personas tencent-200k.jsonl --styles styles.jsonl

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

bash
soup data brain-rot ./data.jsonl --strict --max-major-fraction 0.25
# exit 3 if MAJOR fraction > 0.25

arXiv 2510.13928 brain-rot detector. Two pure-Python scorers:

  • score_triviality — token-diversity inversion + !! / ?? punctuation runs + low-effort token density + length penalty
  • score_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

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.