Task reference¶
Every key a task YAML can contain. name, fields and prompts are
required; everything else has a default.
name: # required — identifies the task and names the output file
description: # shown by `seedmill tasks`, and available as $description
model: # backend, model, sampling — see Backends
vocabularies: # named value sets that become decode-time constraints
seeds: # optional coverage axis
fields: # the record schema
rules: # cross-field checks, with auto-fix
prompts: # system / user / retry templates
examples: # few-shot records rendered into $examples
generation: # batch size, retries, temperature, workers, dedup
exports: # splits and formats
hooks: # path to a Python file with postprocess() / EXPORTERS
name¶
Required. Also the default output path: output/<name>.jsonl, overridable
with -o.
description¶
Free text. Shown in seedmill tasks and substitutable into prompts as
$description.
model¶
See Backends for the full treatment.
| Key | Default | Applies to |
|---|---|---|
backend |
ollama |
all |
model |
per backend; required for openai |
all |
base_url |
per backend | all |
think |
false (ollama), true (openai_compat) |
local backends |
api_key |
$OPENAI_API_KEY |
openai |
strict |
true |
openai |
params |
{} |
openai |
Sampling temperature is not here — it lives in generation.temperature.
vocabularies¶
Named value sets. A field declaring vocab: <name> is validated against the
set and constrained at decode time, so the model cannot emit anything else.
Inline:
vocabularies:
intents: [billing, bug_report, cancellation]
Or from a file, with an optional key to index into it:
vocabularies:
icons:
file: ../icons.yaml
key: icons
Three file shapes are accepted:
| Shape | YAML | Behaviour |
|---|---|---|
| grouped | {group: [value, …]} |
groups are kept and rendered into $vocab_<name> |
| flat | [value, …] |
rendered as a sorted comma list |
| mapping | {value: <anything>} |
the keys are the vocabulary; values ride along unused |
The mapping shape is the useful one for app-owned label spaces: an icon slug → code point table exported from the app can be the single source of truth, with no hand-kept second list to drift out of sync.
seeds¶
The coverage axis. Without it the engine free-runs batches up to
generation.count; with it, it walks every seed and generates per_seed
records for each.
seeds:
file: ../concepts.yaml # or: values: {group: [seed, …]}
key: concepts
group_field: category # record field that stores the group name
value_field: concept_group # record field that stores the seed value
per_seed: 8
id_pattern: "{seed}_{counter:03d}"
| Key | Default | Meaning |
|---|---|---|
file / values |
— | seeds from a YAML file, or inline |
key |
— | index into the loaded file |
group_field |
None |
record field the group name is written to |
value_field |
seed |
record field the seed value is written to |
per_seed |
8 |
records generated per seed |
id_pattern |
None |
e.g. {seed}_{counter:03d}; enables resume |
A flat list instead of a mapping is treated as one group named default.
How resume actually works
A restarted seeded run counts the records already in the output file per
value_field, and generates only the shortfall against per_seed. It
matches on the seed value, not on ids — id_pattern only names records
and continues the counter, so resume works with or without one.
fields¶
The record schema. Drives three things at once: pydantic validation, the
constrained-decoding JSON schema, and the $schema_json prompt variable.
fields:
interest:
type: str
min_length: 3
max_length: 120
description: Short free-text phrase a user might type.
positive_icon:
type: str
vocab: icons
hard_negatives:
type: list
items: {vocab: icons}
min_items: 2
max_items: 4
entity:
type: object
nullable: true
fields:
kind: {type: str, enum: [tracker, streak]}
| Key | Meaning |
|---|---|
type |
str, int, float, bool, list, dict, object. Unrecognised → str |
enum |
allowed values; enforced by pydantic and at decode time |
vocab |
resolve a named vocabulary into enum |
nullable |
field may be null; dropped from required |
default |
value when absent; also drops the field from required |
items |
element spec for type: list (may itself carry vocab/enum) |
fields |
sub-fields for type: object |
description |
carried into the schema the model sees |
min_length / max_length |
string bounds, checked after validation |
min_items / max_items |
list bounds, checked after validation |
Why bounds are not in the decode schema
min_length and friends are checked after generation rather than
expressed as grammar constraints, for backend compatibility. They reject
a record; enums prevent one.
rules¶
Cross-field checks that run after pydantic validation. Each rule is a single-key dict. Some can auto-fix instead of rejecting.
rules:
- unique_items: {list: hard_negatives}
- not_in: {value: positive_icon, list: hard_negatives}
- contains: {list: acceptable_icons, value: positive_icon, autofix: true}
- disjoint: {a: acceptable_icons, b: hard_negatives, autofix_remove_from: a}
| Rule | Spec | Behaviour |
|---|---|---|
unique_items |
{list} |
deduplicates the list in place — always a fix, never a rejection |
not_in |
{value, list} |
rejects if record[value] appears in record[list] |
contains |
{list, value, autofix} |
record[list] must contain record[value]; with autofix, prepends it |
disjoint |
{a, b, autofix_remove_from} |
two lists must not overlap; with a/b, drops the overlap from that side |
exclusive_switch |
{switch, cases} |
discriminated union: when record[switch] == value, that case field must be non-null and every other case field null |
An unknown rule name is reported as an issue rather than ignored, so a typo fails loudly.
exclusive_switch¶
rules:
- exclusive_switch:
switch: entity_type
cases:
tracker: tracker_config
streak: streak_config
A switch value with no case listed (e.g. none) requires all case fields
to be null.
prompts¶
system and user are required; retry has a built-in default.
prompts:
system: |
You generate training data for …
user: |
Generate exactly $n records for "$seed_readable".
retry: |
These failed validation:
$errors
Regenerate exactly $n valid records.
$var substitution, available in system and user:
| Variable | Value |
|---|---|
$n |
records requested in this batch |
$name, $description |
from the task |
$schema_json |
compact sketch of fields |
$examples |
rendered examples: block |
$vocab_<name> |
one entry per vocabulary |
$seed, $group |
seeded mode only |
$seed_readable |
$seed with underscores as spaces |
retry receives only $errors (the first five, one per line) and $n.
Prompt order matters for local throughput
Put per-seed text at the end of the user prompt. Everything before
the first varying byte is reused from llama.cpp's slot prefix cache; a
Domain: X header at the top forces a full re-prefill of the vocabulary,
schema and examples on every call.
examples¶
A list of records rendered into $examples for few-shot prompting. Plain
YAML mappings, shaped like the records you want back.
generation¶
| Key | Default | Meaning |
|---|---|---|
count |
100 |
total records in batch mode (ignored when seeds: is set) |
batch_size |
4 |
records requested per model call |
max_retries |
2 |
error-feedback retries per batch |
temperature |
0.8 |
sampling temperature |
workers |
1 |
concurrent seeds in seeded mode |
dedup.fields |
all fields | fields hashed for exact-duplicate detection |
dedup.embeddings |
false |
near-duplicate filtering by cosine similarity |
dedup.threshold |
0.92 |
cosine similarity above which a record is a duplicate |
dedup.embeddings is off by default because it pulls sentence-transformers
(and therefore torch); install the embeddings extra before turning it on.
Hash-based exact dedup always runs.
workers on a local model
Throughput on a local server is decode-bound, not prompt-bound. On an
M-series Mac with Qwen3.8-27B Q4, --workers 4 buys roughly 1.4x
aggregate rather than 4x — the box is memory-bandwidth limited.
exports¶
exports:
split_by: concept_group
splits: {train: 0.8, val: 0.1, test: 0.1}
seed: 42
formats: [raw]
| Key | Default | Meaning |
|---|---|---|
split_by |
None |
field whose whole groups go to one split |
splits |
{train: 0.8, val: 0.1, test: 0.1} |
split ratios |
seed |
42 |
shuffle seed, so splits are reproducible |
formats |
every registered exporter | which formats to write |
raw is built in. Anything else comes from the task's hooks file — an
unknown format is an error listing what is available, rather than a silently
missing file.
split_by is the group-aware part: naming the seed field there keeps a whole
concept in one split, so eval measures generalization to unseen concepts
rather than memorized phrasings.
hooks¶
Path to a Python file, resolved like other task paths. Two entry points, both optional:
def postprocess(record: dict, ctx: dict) -> dict | None:
"""Return the record, modified — or None to reject it."""
EXPORTERS = {"sft": build_sft} # name -> (record, ctx) -> dict
The ctx passed to postprocess carries vocab, config, and — in seeded
mode — variables, seed and group. That last part is the point: a hook
can enforce something the decode-time grammar cannot, like a per-entity
subset of a flattened vocabulary, because a grammar cannot condition on a
field the model has not chosen yet.
Exporters receive a smaller ctx: vocab and config only.