The campaign config#
One YAML file holds every setting of a campaign. CampaignConfig.from_file(path) reads it,
resolving the field, operator and template paths relative to the file, and refuses
unknown sections, malformed entries and credentials before any data loads. An unknown key
is refused rather than ignored: an option an LLM role’s backend does not take (timeout
for timeout_seconds, say) is refused naming the role and listing the options that
backend takes, and a segment entry takes only start and end. Campaign(config).run()
runs it.
Sections#
Section |
Required |
Holds |
|---|---|---|
|
yes |
the directory the run writes to |
|
yes |
the data as the prompts describe it, one line |
|
yes |
|
|
yes |
|
|
yes |
|
|
yes |
the vocabulary files; |
|
no |
the reference set; empty, candidates are only compared with each other |
|
no |
the known factors |
|
no |
seed research directions for the planner, one string each |
|
no |
mechanism types and replaced templates; see prompts |
|
no |
settings with defaults (below) |
|
yes |
one entry per role; see LLM backends |
|
yes |
the acceptance model and seeds; see acceptance |
Data#
data, reference and known hold quantlab components. An entry is one of:
a component’s
get_config()dict, written inline;the path of a store folder, whose
component.jsondescribes the factor that owns it;{store: <folder>, factor_names: [...]}: that store’s factor, pinned to some of its outputs.
data:
prices: {...} # the whole-market price dataset
label: /path/to/quantlab/labels/market/ret_5 # a forward label store
membership: {...} # the members of each day, or omitted
intraday: {...} # the whole-market intraday bars, for an intraday family
scoring:
neutralize:
exposures: {...}
size_column: marketcap # its logarithm is regressed on
industry_column: industry # an integer industry code
reference:
- /path/to/quantlab/factors/market/alpha158_neutral
- {store: /path/to/quantlab/factors/market/barra_style, factor_names: [size, beta]}
known:
- /path/to/quantlab/factors/market/alpha101_neutral
Factors are computed on the whole market, then masked to the day’s members. A candidate
is ranked across the whole market, neutralised when scoring.neutralize is given, and
masked; a reference or known entry given as a store is read as stored and masked, one
given as a component without a store is scored as a candidate is. scoring.neutralize
must be written, as null to score ranked values, so no campaign is neutralised by
default.
An output of a known factor that the reference set already holds is taken from the
reference set only, so whole classes can be listed in known.
intraday is the dataset an intraday family’s fields read (one-minute
trade bars, with configs/fields/massive_minute_bars.yaml as their field file, in
configs/us3000_intraday_ret5.yaml): a campaign that opens an intraday family must name it, and the run keeps it with its snapshot, so a resume with
another intraday dataset is refused as a resume with other field files is. A candidate’s
session values are matched to the price dataset by date and by symbol label, so
permatickers of the intraday bars find the same labels of the daily bars.
Every stored reference or baseline factor is read from its store’s first bar, so a store that starts after the training segment (an intraday store from 2016, say) is empty there.
The segments must lie inside the data of every family the field files open. The Evaluator
reads the fields over the training segment, and acceptance over the validation and
hold-out segments; a field whose data starts later or ends earlier is missing there, and
so is every candidate that reads it. A campaign that opens a high-frequency family starts
its training segment no earlier than the family’s statistics: configs/us3000_hf_ret5.yaml
trains from 2016-10-11, the first session of its minute data, and the intraday example
configs/us3000_intraday_ret5.yaml from 2016-10-10, the first session of its minute bars.
A day before an intraday family’s bars begin has no session values and counts against a
candidate’s coverage. The warm-up bars read
before the training segment may lie outside the data; a long-window candidate is then
missing on the segment’s first bars, and its coverage is measured over the dates that
have values.
The Evaluator keeps the known factors only on one scored training date in five, as normal scores in a float32 panel of its own: 4 bytes per known output per sampled member-day. For us3000 (training 2000 to 2014, about 750 sampled dates of about 3,000 members) that is about 9 MB per known output, about 2.7 GB for 300 outputs. It is built once per start, one known factor at a time.
Settings with defaults#
Key |
Default |
Meaning |
|---|---|---|
|
260 |
bars read before the segment so every window is filled |
|
51 |
KunQuant executor threads |
|
10 |
seconds the sandboxed graph build may take, counted from when the sandbox is ready |
|
1024 |
memory the sandboxed build may use |
|
200 |
nodes a candidate’s graph may have |
|
0.5 |
hand-set constants (other than 0 and ±1) per operator |
|
5 |
distinct input fields a candidate may read |
|
5 |
the largest subtree a candidate may share with a zoo factor |
|
3.0 |
partial-IC t the first evaluated trial needs |
|
1.0 |
power of the trial count the threshold’s correction divides by; below 1 it rises more slowly |
|
0.7 |
largest absolute correlation with a reference, accepted or known factor |
|
0.6 |
smallest Spearman correlation of the deciles with the decile number |
|
0.5 |
smallest mean daily share of the members with a value |
|
20 |
candidates per round |
|
3 |
slots of every mechanism type per round |
|
2 |
times a refused candidate goes back to the proposer |
|
1 |
proposal groups per round (the repository’s configs use 4) |
|
30 |
rounds at most |
|
600 |
trials at most; a round that would pass it is not started |
|
5 |
rounds in a row without an acceptance before the run stops |
|
5 |
rounds between two distillations |
|
0 |
seed of the Thompson sampling |
|
null |
trials built on one trial at which no direction may build on it any more (see below); needs |
|
0 |
slots a round whose parent the code chooses, the Picked parents (see below); needs |
|
5 |
rounds whose trials the planner reads one line each |
|
true |
whether the planner and the proposer read the earlier trials and a memory is distilled; off, every direction is new (a control run) |
|
null |
the absolute correlation at which an evaluated trial is grouped with an earlier one; set, the planner reads the groups (see below) |
|
false |
whether a new direction’s candidate in a group is rewritten once before it is evaluated; needs |
|
false |
whether the planner’s accepted factors and the proposer’s parent carry each trial’s Lineage (see below); needs |
|
3 |
best children’s outcomes a trial’s fertility is the mean of (see below) |
|
0.75 |
share of each utility the Picked parents take from the related trials, in [0, 1) (see below) |
|
100 |
propagation steps at most |
|
1e-8 |
the largest move of a utility at which propagation stops |
|
1/3 each |
each relation’s share, |
|
0.20 |
smallest share of subtrees two trials have in common that joins them by structure |
|
30 |
structure neighbours each trial keeps, the closest |
|
0.10 |
smallest absolute correlation that joins two trials by correlation |
|
30 |
correlation neighbours each trial keeps, the closest |
|
10 |
accepted factors a trial’s novelty is measured against, the nearest |
|
5 |
trials built on one trial at which it is no longer picked |
Checking a config#
Campaign(config).check() finds what run() would refuse before its first LLM call,
without starting the run: it writes nothing (a run directory that does not exist yet is
not made, one that does is left byte for byte as it was) and computes no factor, so it
takes seconds where a start would first score the reference set. It checks each part on
its own and reports every problem at once:
each data component loads and the stores it reads exist, and the price dataset’s columns can be read;
the scoring’s exposures load;
each role’s backend can be built: an unset API key variable is a problem;
the reference and known factors load;
on a resume, the field, operator and template files and the recorded settings are the run snapshot’s (a difference is a problem, as it would refuse the resume), and the code versions are the run’s last (a difference is a warning, as a resume only warns of it);
the vocabulary builds against the price dataset’s columns, and the templates with it.
The vocabulary needs the price dataset (and the intraday one, when the config names it), and the templates need the vocabulary, the label and the scoring; when one of those fails, the check that needs it is reported as skipped, with the reason, so a clean report after a fix is not mistaken for a complete one.
From the shell, factor-agent check prints the report and exits with status 1 when it
holds a problem, so a script or CI can gate a launch on it; a config that cannot be read
(bad YAML, an unknown section or key) is printed as its first problem instead of a
traceback:
factor-agent check campaign.yaml # 0: the run can start, 1: a problem
factor-agent check campaign.yaml --ping # also send each backend one short request
ok data.prices
ok data.label
...
problem llm.reviewer
- the environment variable DEEPSEEK_API_KEY holding the API key is not set
ok run snapshot
ok vocabulary
ok templates
1 problem(s), 0 warning(s), 0 check(s) skipped; the run would be refused
In Python, check() returns the report:
from factor_agent.campaign import Campaign
from factor_agent.config import CampaignConfig
report = Campaign(CampaignConfig.from_file("campaign.yaml")).check()
print(report.text()) # one line per check: ok, problem, warning or skipped
if not report.ok: # report.problems lists them as "<check>: <problem>"
raise SystemExit(1)
Without a ping the check sends no request to a provider, so a wrong model name or base URL
is found only at the first call. With --ping (check(ping=True)) each distinct backend
(roles with the same settings share one) gets one short request after the other checks,
outside the LLM log; its check, named after its roles (ping planner, proposer), gives
how long the answer took, or the provider’s error as a problem. Roles on the replay or
scripted backend need no provider and are not pinged, nor is a role whose backend could
not be built; each is listed as skipped with the reason. A ping costs one short call per
backend.
Stopping and resuming#
Before each round the run checks its stop conditions: max_rounds, max_trials and
patience. The first that holds ends the run, and state.json records which one. When a
provider refuses a call because the balance or quota is exhausted, the run drops the
round in progress, records the stop provider_quota with the provider’s message and
returns normally; the library counts no money, so spending limits belong to the provider.
run() on a run directory that has a state resumes it: the rounds written are kept, a
round a crash left half written is dropped and run again, and the zoo, the pool and the
graphs the Gate compares with are rebuilt from the ledger. The calls of a dropped round
move to llm_log/dropped/, so the log holds exactly the calls the ledger was written from.
The accepted factors’ values, and with themes the evaluated trials’, are read back from
columns/ rather than computed again; a resume only checks that their graphs still
compile. The stored values are kept across a change of code, so a resume after a fix
judges its new trials against the values the earlier ones were judged against; a change
of the data, the scoring, the warm-up or a segment empties a segment’s store, and its
factors are computed once more. Deleting columns/ costs one such computation.
The first start copies the field, operator and replaced template files into
snapshot/ with their hashes, and the run searches with those copies. A resume whose
files differ fails: a new search space needs a new run directory. A resume on other
commits of factor_agent or quantlab only warns, and state.json lists the commits of every
start and resume. round.groups may change on a resume; context.recent_rounds, context.feedback, context.theme_correlation, context.send_back_repeats, context.lineage, context.fertile_children, round.max_children, round.picked_parents and, with Picked parents, the trial_graph values may not.
The reference set and the known factors are read from the config on every start. Every
start and resume also keeps the config it was given as config/open-<k>.yaml (the first
start is open-1.yaml), so a run says which settings it ran with even after the config
file has changed.
The run directory#
Path |
Holds |
|---|---|
|
one line per candidate: round, candidate, status, stage, reasons, the Gate’s statistics, the graph, the metrics, refused attempts |
|
rounds written, the stop, the memory’s round, the code versions of every start |
|
every LLM call in full: prompt, parameters, reply, token usage, the raw reply |
|
what the campaign did, one event per line as it happened (see The events file) |
|
the distilled memory and every version of it |
|
the search files of the first start and their hashes |
|
the config of the k-th start or resume, as it was given |
|
the scored values of every factor in a segment’s pool and, with themes, of every evaluated trial, one file per graph, for a resume to read instead of computing them; about 45 MB per accepted factor and 2.3 MB per evaluated trial on 3000 members over 15 years |
|
each evaluated trial’s daily rank IC and partial IC in round NN, by slot, for the frontend’s cumulative IC |
|
with |
|
the reference and known factors the Gate covers, and those without a graph it cannot |
|
validation and hold-out reports and the model cache |
|
written when the hold-out is opened |
A trial’s status is accepted, rejected (by the reviewer, or by admission after
evaluation) or failed (refused by the parser or the Gate after every retry, or its
graph did not compile). Its stage is where it ended: parse, gate, review,
evaluate or admission.
The events file#
The ledger and the state are written at a round’s end. To show the round in progress, the
campaign also appends to events.jsonl as it goes: one JSON object per line, never
rewritten. The campaign itself never reads the file, so it takes no part in a resume, a
replay or the run’s snapshot; deleting it loses the record of the run’s progress and
changes no result. A resume that drops a round a crash left half written does not remove
that round’s events: it writes a resume event naming the dropped round, and the round’s
events from the new start follow it.
Every line has schema (the format’s version, now 1), type, time (UTC, ISO 8601) and
the event’s fields. A slot is a candidate’s place in its round, from 0, in the order of the
round’s research directions and of the round’s lines in the ledger. A value the campaign
holds as NaN is written as null.
|
When |
Fields |
|---|---|---|
|
the campaign opens the run directory, before its first call |
|
|
the planner’s directions are set |
|
|
an LLM call is sent |
|
|
an LLM call comes back or fails |
the fields of its |
|
the parser and the Gate check one version of a slot’s candidate |
|
|
the reviewer judges a slot’s candidate |
|
|
the Evaluator starts on the round |
|
|
a slot’s outcome after evaluation |
|
|
the round’s trials are written to the ledger and the state |
|
|
the run stops |
|
A token count is null when the provider’s reply does not report it: cache_hits are input
tokens read from the provider’s cache and reasoning output tokens the model spent
reasoning. A crash writes nothing: a run whose process has gone without a stop crashed.
factor_agent.events.read reads the file back into the event classes.
Memory and context#
The planner and the proposer read the same context: the seed directions, the reference
factor families and then the known ones (marked as known), the memory, the last round’s
training statistics and rejection reasons, and the accepted factors with their weight and
residual IC in the accepted pool. The planner also reads the trials of the last
context.recent_rounds rounds one line each, and the older trials counted by mechanism
type and outcome. Every round.memory_every rounds the distiller folds the trials since
the last memory into memory.md; an empty or unparsable reply keeps the previous memory.
It also reads every factor accepted so far, each with its mechanism type, hypothesis and
direction, and the memory’s working patterns are about those factors only: a factor a
refusal names is one of the reference set, a known one or one accepted earlier.
Every trial is named in the prompts by its id, T<round>-<slot> (T3-07 is round 3’s
seventh). A research direction may build on any evaluated trial, accepted or refused: a
refinement measures the parent’s mechanism another way, to remove what refused it or
to strengthen an accepted factor; a mutation follows another channel the parent’s
result points to. The direction names the parent by its id, and the proposer is given the
parent’s code, statistics and reasons. A direction whose parent is not an evaluated trial
(or is one round.max_children has capped, see below) is kept as a new one, and the plan event records the issue.
With context.theme_correlation set (0.5, say), the Evaluator keeps every evaluated trial,
as the normal scores of its factor on every 20th scored date, and the trials are grouped
by what their factors measure: each joins the group of the earlier trial it is most
correlated with, when that correlation reaches the setting in absolute value, and opens a
group of its own otherwise. The planner reads the groups, the largest first, each with the
trial that opened it, its trials, how many were accepted, its largest residual t, the last
round it was tried in and its refusals by kind. On a resume the evaluated trials are
computed again from the ledger.
With context.send_back_repeats: true as well, before a round is scored, a new direction’s
candidate correlated that much with an earlier trial is sent back to the proposer once,
with that trial named and no statistic but the correlation; the version sent back is not
evaluated and is not a trial. Its rewrite is checked by the Gate again, and fixed up to
round.retries times as a proposed candidate is, then by the reviewer, and evaluated with
the rest, whatever it measures. A refinement or a mutation is not
checked, and a slot is rewritten once in all: a candidate the reviewer sent back is not
checked again.
With context.lineage: true, each accepted factor in the planner’s context, and the parent
trial the proposer is given for a refinement or a mutation, carries the trial’s
Lineage, one line on the trials whose direction names it as their parent (its direct
children):
lineage: 6 trials built on it (4 refinements, 2 mutations), 2 stopped before evaluation, 1 accepted (T2-03); best evaluated child T2-03, residual t in its direction +5.00 against this trial's +3.50
A child that stopped before evaluation (refused by the parser, the Gate or the reviewer, or not compiled) counts, so a parent whose children all failed does not look untried. The residual t is the partial IC’s Newey-West t in each trial’s own direction. The Lineage is computed from the ledger before each round; off, no prompt holds it.
With round.max_children: n, a trial that n trials have built on (its Lineage’s count,
those that stopped before evaluation included) is no longer one a refinement or a mutation
may name: a direction naming it is kept as a new one, and the plan event records the issue,
as for a parent that is not an evaluated trial. The cap needs no context.lineage; the
planner just sees no Lineage line without it. Unset, every evaluated trial stays a parent.
With round.picked_parents: k, the code chooses the parent of k slots each round, the
Picked parents, after the quota is drawn. It judges every evaluated trial, accepted or
not, by three utilities: its quality, the residual t in its direction; its
fertility, the mean of its context.fertile_children (3) best children’s outcomes in
its Lineage (how far each child’s residual t cleared the threshold it was judged against, 0
for a child that stopped before evaluation); and its novelty, its mean distance, in
structure (the subtrees of its graph) and in correlation, from the
trial_graph.nearest_accepted (10) nearest factors accepted so far. The trials form the
Trial graph, joined by three relations: generation (parent and child), structure (the
share of subtrees two trials have in common, from trial_graph.min_jaccard) and
correlation (from trial_graph.min_correlation), each trial keeping its closest
structure_neighbours and correlation_neighbours (30). Each trial then shares its
utilities with the trials related to it, the relations weighted by trial_graph.weights,
so a trial whose relatives did well is judged by that evidence too: its utility after
propagation lies trial_graph.gamma (0.75) of the way from its own to its neighbours’
average. The trials are ranked by Pareto front on the three, not by a weighted sum,
within a front by how far each lies from the others, and a trial built on
trial_graph.max_expansions (5) times is not picked. In that order, each takes one slot of
its own mechanism type while that type has a slot not yet taken, until k are taken; a trial
round.max_children has capped is passed over.
The trial_graph section’s defaults are the values of the CosmosAlpha paper
(arXiv:2611.04555, Tables 6 and 7), so a config without it runs the method as published:
trial_graph:
gamma: 0.75
iterations: 100
tolerance: 1.0e-8
weights: {generation: 0.3333333333, structure: 0.3333333333, correlation: 0.3333333334}
min_jaccard: 0.20
structure_neighbours: 30
min_correlation: 0.10
correlation_neighbours: 30
nearest_accepted: 10
max_expansions: 5
A value out of its range (gamma outside [0, 1), weights that are negative or do not sum
to 1, a count below 1, a threshold outside [0, 1]) is refused when the config is read, and
factor-agent check reports it. With Picked parents on, the run snapshot records every
trial_graph value at the first start, so a resume that changes one is refused and a
replay picks with the recorded run’s values whatever its own config says; without Picked
parents the section is not recorded. A run with Picked parents started before the section
existed ran with its defaults: it resumes while the config keeps them. context.fertile_children is recorded when it is not
3, and fixed alike.
The planner is told the picked slots in its prompt, each with its type and its parent, and
writes each as a refinement or a mutation of that parent, choosing the action and the idea:
the first direction of the type that names the parent fills the slot. A picked slot the
planner leaves out is planned as a refinement of its parent, and the plan event records the
issue. The plan event in events.jsonl lists the picked parents with their utilities and
Pareto fronts (empty with round.picked_parents: 0). The correlations come from the
evaluated trials that context.theme_correlation keeps, so picked parents need it. A
replaced plan template must hold the placeholder {picked}, where the picked slots go,
when picked parents are on; without it the run is refused at its start, and the check
reports it.
Each round that picks parents writes the graph it picked them on to
trial_graph/round_NN.json once the round is planned: γ, every evaluated trial with its
own utilities, its utilities after propagation, the average it took from its neighbours and,
when it could not be picked, why (expansion_cap, built on trial_graph.max_expansions
times, or max_children), and the edges of each relation (generation, structure,
correlation) with the weights propagation
used. It holds no ranks; factor_agent._trial_graph.read(run_dir, round) reads it back, and
its pick_order() gives the order the round’s parents were claimed in. A resume that plans
a round again writes its file anew.
A candidate refused for its coverage is told where its graph loses its values: the first
step whose coverage falls below admission.min_coverage while its inputs’ does not, with
its inputs’ coverage and the share of the member days on which a step is infinite (a
division by zero, say, on a day without a range), or the data column that has too few
values.
With context.feedback: false the planner and the proposer read only the data, the seed
directions and the factor families, every direction is new and no memory is distilled: a
run that cannot learn from its trials, to compare a run that can with.