quantlab-factor-agent 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- factor_agent/KUNQUANT_LICENSE +201 -0
- factor_agent/__init__.py +8 -0
- factor_agent/_acceptance_model.py +93 -0
- factor_agent/_admission.py +168 -0
- factor_agent/_expression_fold.py +48 -0
- factor_agent/_feedback.py +399 -0
- factor_agent/_max_drawdown.py +51 -0
- factor_agent/_model_cache.py +100 -0
- factor_agent/_scheduler.py +35 -0
- factor_agent/acceptance.py +808 -0
- factor_agent/accepted/__init__.py +1 -0
- factor_agent/aggregations.py +122 -0
- factor_agent/campaign.py +797 -0
- factor_agent/candidate.py +51 -0
- factor_agent/config.py +651 -0
- factor_agent/derived.py +98 -0
- factor_agent/evaluator.py +1096 -0
- factor_agent/events.py +343 -0
- factor_agent/exported.py +177 -0
- factor_agent/gate/__init__.py +359 -0
- factor_agent/gate/_canonical.py +331 -0
- factor_agent/gate/_checks.py +85 -0
- factor_agent/gate/_constraints.py +85 -0
- factor_agent/gate/_dimension.py +177 -0
- factor_agent/gate/_originality.py +146 -0
- factor_agent/gate/_sandbox.py +105 -0
- factor_agent/gate/graph.py +223 -0
- factor_agent/gate/stages.py +383 -0
- factor_agent/gate/vocabulary.py +1097 -0
- factor_agent/ledger.py +85 -0
- factor_agent/llm/__init__.py +0 -0
- factor_agent/llm/_common.py +18 -0
- factor_agent/llm/_raw.py +78 -0
- factor_agent/llm/anthropic.py +100 -0
- factor_agent/llm/base.py +124 -0
- factor_agent/llm/claude_code.py +101 -0
- factor_agent/llm/log.py +74 -0
- factor_agent/llm/openai_compatible.py +113 -0
- factor_agent/llm/replay.py +59 -0
- factor_agent/llm/scripted.py +39 -0
- factor_agent/period_lag.py +91 -0
- factor_agent/prompts.py +557 -0
- factor_agent/researcher.py +615 -0
- factor_agent/scoring.py +143 -0
- factor_agent/segments.py +184 -0
- factor_agent/session.py +500 -0
- factor_agent/snapshot.py +219 -0
- factor_agent/web/__init__.py +7 -0
- factor_agent/web/_acceptance.py +145 -0
- factor_agent/web/_config.py +51 -0
- factor_agent/web/_diagnosis.py +110 -0
- factor_agent/web/_files.py +78 -0
- factor_agent/web/_health.py +207 -0
- factor_agent/web/_ic.py +27 -0
- factor_agent/web/_ledger.py +293 -0
- factor_agent/web/_memory.py +42 -0
- factor_agent/web/_results.py +77 -0
- factor_agent/web/_round.py +219 -0
- factor_agent/web/_trials.py +112 -0
- factor_agent/web/app.py +118 -0
- factor_agent/web/run_view.py +329 -0
- factor_agent/web/static/assets/index-9QjjJgl3.css +1 -0
- factor_agent/web/static/assets/index-iOE9ei_S.js +74 -0
- factor_agent/web/static/index.html +13 -0
- factor_agent/zoo.py +136 -0
- quantlab_factor_agent-0.1.0.dist-info/METADATA +172 -0
- quantlab_factor_agent-0.1.0.dist-info/RECORD +72 -0
- quantlab_factor_agent-0.1.0.dist-info/WHEEL +5 -0
- quantlab_factor_agent-0.1.0.dist-info/entry_points.txt +2 -0
- quantlab_factor_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
- quantlab_factor_agent-0.1.0.dist-info/licenses/factor_agent/KUNQUANT_LICENSE +201 -0
- quantlab_factor_agent-0.1.0.dist-info/top_level.txt +1 -0
factor_agent/campaign.py
ADDED
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
"""The Campaign: the research loop, from one config to accepted and rejected candidates.
|
|
2
|
+
|
|
3
|
+
One round:
|
|
4
|
+
|
|
5
|
+
1. the quota: every mechanism type gets ``min_per_type`` slots and the rest
|
|
6
|
+
go by Thompson sampling on each type's admission rate (``_scheduler``);
|
|
7
|
+
2. the planner plans one direction per slot: a new mechanism, an
|
|
8
|
+
orthogonal mutation of an accepted factor, or a refinement of one;
|
|
9
|
+
3. the proposer writes one candidate per direction; the Gate checks each
|
|
10
|
+
against the reference set, the known factors and the accepted factors
|
|
11
|
+
(the ``Zoo``) and the refused ones go back to the proposer to be fixed;
|
|
12
|
+
4. the reviewer, in its own call, judges each gated candidate's hypothesis,
|
|
13
|
+
description and code for consistency, with the data section's fields;
|
|
14
|
+
the inconsistent ones go back to the proposer once, all in one fix
|
|
15
|
+
request with the reviewer's reasons; each rewrite passes the Gate once
|
|
16
|
+
(a refusal fails it) and the reviewer judges the rewrites in one more
|
|
17
|
+
call: one still inconsistent is rejected without being evaluated; with
|
|
18
|
+
``round.groups`` above 1, steps 3 and 4 make these calls per proposal
|
|
19
|
+
group, the groups' calls of each stage side by side (``Researcher``);
|
|
20
|
+
5. the Evaluator scores the rest on the training segment in one compile,
|
|
21
|
+
admission decides (``_admission``) in proposal order, and an accepted
|
|
22
|
+
candidate joins the zoo and the reference pool before the next is admitted;
|
|
23
|
+
a refusal for coverage says at which step of the graph the values are lost
|
|
24
|
+
(``Evaluator.trace_coverage``);
|
|
25
|
+
6. the round's records go to the ledger and the state to ``state.json``.
|
|
26
|
+
|
|
27
|
+
Before anything else ``run`` checks the replaced prompt templates for
|
|
28
|
+
their placeholders and builds the ``Vocabulary``, checking every field's
|
|
29
|
+
column against the price dataset; every problem is reported at once,
|
|
30
|
+
before any LLM call and before anything is written, so a run refused for
|
|
31
|
+
its files starts cleanly once they are fixed. It then opens the run's
|
|
32
|
+
``RunSnapshot``: a first start copies the config's field, operator and
|
|
33
|
+
template files into the run directory, a resume fails if they changed
|
|
34
|
+
since and warns if the commits did; every commit is listed in
|
|
35
|
+
``state.json``. The run searches with the snapshot's copies. A replay (roles on the ``replay`` backend) searches with the
|
|
36
|
+
snapshot of the run whose LLM log it replays, whatever the config's files
|
|
37
|
+
say now.
|
|
38
|
+
|
|
39
|
+
The prompts (``factor_agent.prompts.Prompts``) describe the config's market, the
|
|
40
|
+
label's horizon (its ``span_bars()``), the scoring, the vocabulary and the
|
|
41
|
+
config's mechanism types, through the snapshot's templates.
|
|
42
|
+
|
|
43
|
+
The planner and the proposer read the context of ``_feedback``, a pure
|
|
44
|
+
function of the ledger and the config: the seed directions, the reference
|
|
45
|
+
factor families and the known ones (marked as known), the memory, the last
|
|
46
|
+
round's feedback and the current best mechanisms; the planner also reads the recent history (the trials of the
|
|
47
|
+
last ``context.recent_rounds`` rounds, one line each) and the earlier
|
|
48
|
+
summary (the older trials counted by mechanism type and outcome). The
|
|
49
|
+
snapshot records ``recent_rounds`` at the first start and a resume may not
|
|
50
|
+
change it. Every ``memory_every`` rounds the distiller folds the trials
|
|
51
|
+
since the last memory into it; an empty or unparsable reply keeps the
|
|
52
|
+
previous memory. ``memory.md`` holds the current memory, which every later
|
|
53
|
+
prompt carries, and ``memory/round_NN.md`` every version, by the round it
|
|
54
|
+
was written before. Every trial is named by its id (``_feedback.trial_id``);
|
|
55
|
+
a direction may build on any evaluated trial by its id, and the proposer
|
|
56
|
+
is given that trial (``_feedback.parent``). With ``context.feedback`` off
|
|
57
|
+
the planner and the proposer read only the seed directions and the factor
|
|
58
|
+
families, every direction is new and no memory is distilled. With
|
|
59
|
+
``context.theme_correlation`` every evaluated trial joins the Evaluator's
|
|
60
|
+
tried trials and the planner also reads them grouped by what their factors
|
|
61
|
+
measure (``_feedback.themes``); a resume computes the tried trials again
|
|
62
|
+
from the ledger. With ``context.send_back_repeats`` too, before a round is
|
|
63
|
+
scored, a new direction's candidate that correlates that much with an
|
|
64
|
+
earlier trial is rewritten once (``_renew``); a slot is rewritten once in
|
|
65
|
+
all, so a candidate the reviewer sent back is not checked again.
|
|
66
|
+
|
|
67
|
+
Before each round the stop conditions are checked: the round limit, the
|
|
68
|
+
trial limit and ``patience`` rounds in a row without an acceptance; the
|
|
69
|
+
first that holds ends the run and is recorded. The library counts no
|
|
70
|
+
money: spending limits belong to the provider. When the provider refuses a
|
|
71
|
+
call because the balance or quota is exhausted (``ProviderQuotaExhausted``),
|
|
72
|
+
the round in progress is dropped, the stop is recorded as
|
|
73
|
+
``provider_quota`` with the provider's message, and the run returns; a
|
|
74
|
+
resume, once topped up, runs that round again. Every call goes
|
|
75
|
+
to the run's LLM log (``llm.log``), which the ``replay`` backend serves
|
|
76
|
+
back to reproduce the run without a provider; on a resume the calls of the
|
|
77
|
+
dropped round are moved to ``llm_log/dropped/``, so the log holds exactly
|
|
78
|
+
the calls the ledger was written from. ``run`` resumes
|
|
79
|
+
a run directory from its state: the rounds written are kept, a round a
|
|
80
|
+
crash left half written is dropped and run again, and the zoo, the pool and
|
|
81
|
+
the graphs the Gate compares duplicates with are restored from the ledger.
|
|
82
|
+
The reference set and the known factors are read from the config on every
|
|
83
|
+
start, a resume included; neither is snapshotted.
|
|
84
|
+
|
|
85
|
+
As it goes, the campaign appends what it does to the run's events file
|
|
86
|
+
(``factor_agent.events``): every start and resume, plan, LLM call, Gate
|
|
87
|
+
check, verdict, evaluation, admission, round end and stop. It never reads
|
|
88
|
+
the file, so a resume, a replay and the snapshot do not depend on it.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
import json
|
|
92
|
+
import math
|
|
93
|
+
import os
|
|
94
|
+
import socket
|
|
95
|
+
from collections.abc import Callable
|
|
96
|
+
from dataclasses import asdict, dataclass, field
|
|
97
|
+
from pathlib import Path
|
|
98
|
+
|
|
99
|
+
from factor_agent import _admission, _feedback, _scheduler
|
|
100
|
+
from factor_agent.config import ROLES, AdmissionConfig, CampaignConfig
|
|
101
|
+
from factor_agent.evaluator import CompileFailure, DailyIC, Evaluator, Metrics
|
|
102
|
+
from factor_agent.events import (
|
|
103
|
+
FILE as EVENTS_FILE, AdmissionOutcome, EvaluationStart, Events, Plan, Resume, RoundEnd, Start, Stopped,
|
|
104
|
+
)
|
|
105
|
+
from factor_agent.gate import Gate
|
|
106
|
+
from factor_agent.gate._originality import render
|
|
107
|
+
from factor_agent.gate.graph import Graph
|
|
108
|
+
from factor_agent.gate.vocabulary import Vocabulary
|
|
109
|
+
from factor_agent.ledger import Ledger, TrialRecord
|
|
110
|
+
from factor_agent.llm.base import AskingAgain, LLMBackend, ProviderQuotaExhausted, make_backend
|
|
111
|
+
from factor_agent.llm.log import LLMLog, RecordingBackend
|
|
112
|
+
from factor_agent.prompts import Prompts, Templates
|
|
113
|
+
from factor_agent.researcher import Proposal, Researcher
|
|
114
|
+
from factor_agent.scoring import Scoring
|
|
115
|
+
from factor_agent.segments import code_version
|
|
116
|
+
from factor_agent.session import SESSION_INPUTS
|
|
117
|
+
from factor_agent.snapshot import RunSnapshot, SnapshotMismatch
|
|
118
|
+
from factor_agent.zoo import Zoo
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True)
|
|
122
|
+
class Stop:
|
|
123
|
+
"""Why the campaign stopped: ``by`` is ``"max_rounds"``, ``"max_trials"``, ``"patience"`` or ``"provider_quota"``."""
|
|
124
|
+
|
|
125
|
+
by: str
|
|
126
|
+
reason: str
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True)
|
|
130
|
+
class CampaignResult:
|
|
131
|
+
"""Every trial record of the run, why it stopped, and where the ledger is.
|
|
132
|
+
|
|
133
|
+
``evaluated_trials`` counts the candidates the Evaluator scored.
|
|
134
|
+
``not_covered`` names the reference and known factors without a KunQuant graph,
|
|
135
|
+
which the Gate's originality check cannot compare a candidate with.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
records: list[TrialRecord]
|
|
139
|
+
ledger_path: Path
|
|
140
|
+
stop: Stop
|
|
141
|
+
rounds: int
|
|
142
|
+
memory: str = ""
|
|
143
|
+
not_covered: tuple[str, ...] = ()
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def accepted(self) -> list[TrialRecord]:
|
|
147
|
+
return [r for r in self.records if r.status == "accepted"]
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def rejected(self) -> list[TrialRecord]:
|
|
151
|
+
return [r for r in self.records if r.status == "rejected"]
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def failed(self) -> list[TrialRecord]:
|
|
155
|
+
return [r for r in self.records if r.status == "failed"]
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def evaluated_trials(self) -> int:
|
|
159
|
+
"""The number of evaluated candidates."""
|
|
160
|
+
return sum(r.evaluated for r in self.records)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass
|
|
164
|
+
class _State:
|
|
165
|
+
"""What ``state.json`` holds after every round; the ledger holds the trials."""
|
|
166
|
+
|
|
167
|
+
rounds: int = 0
|
|
168
|
+
memory: str = ""
|
|
169
|
+
#: The round after which the distiller was last asked.
|
|
170
|
+
memory_round: int = 0
|
|
171
|
+
#: The last round whose trials the memory holds: the next distillation reads the trials after it.
|
|
172
|
+
memory_through: int = 0
|
|
173
|
+
#: Calls in the LLM log when the state was last written at a round's end or after a distillation.
|
|
174
|
+
calls: int = 0
|
|
175
|
+
log: list[dict] = field(default_factory=list)
|
|
176
|
+
stop: dict | None = None
|
|
177
|
+
#: The code versions of every start and resume, as the run's snapshot records them.
|
|
178
|
+
versions: list[dict] = field(default_factory=list)
|
|
179
|
+
|
|
180
|
+
@classmethod
|
|
181
|
+
def read(cls, path: Path) -> "_State":
|
|
182
|
+
return cls(**json.loads(path.read_text())) if path.exists() else cls()
|
|
183
|
+
|
|
184
|
+
def write(self, path: Path) -> None:
|
|
185
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
186
|
+
tmp = path.with_suffix(".tmp")
|
|
187
|
+
tmp.write_text(json.dumps(asdict(self), indent=2) + "\n")
|
|
188
|
+
tmp.replace(path)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class Campaign:
|
|
192
|
+
"""Run a mining campaign from its config.
|
|
193
|
+
|
|
194
|
+
Parameters
|
|
195
|
+
----------
|
|
196
|
+
config : CampaignConfig
|
|
197
|
+
Every setting of the run.
|
|
198
|
+
backends : dict, optional
|
|
199
|
+
Backends by role that replace the ones the config names (the model
|
|
200
|
+
still comes from the config), such as a test's fake.
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
def __init__(self, config: CampaignConfig, backends: dict[str, LLMBackend] | None = None):
|
|
204
|
+
self.config = config
|
|
205
|
+
self._backends = dict(backends or {})
|
|
206
|
+
self._replaced = set(self._backends)
|
|
207
|
+
|
|
208
|
+
def _prompts(self, templates: Templates, vocabulary: Vocabulary, label, scoring: Scoring) -> Prompts:
|
|
209
|
+
"""What the prompts say: the config's market and mechanism types, ``label``'s horizon, ``scoring``'s sentence."""
|
|
210
|
+
return Prompts(
|
|
211
|
+
templates=templates, market=self.config.market, horizon=label.span_bars(), scoring=scoring.describe(),
|
|
212
|
+
vocabulary=vocabulary, mechanisms={m.name: m.description for m in self.config.mechanisms},
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def _role_backends(self) -> dict[str, LLMBackend]:
|
|
216
|
+
"""Each role's backend: the one passed in, or the one its config names, built once."""
|
|
217
|
+
for role in ROLES:
|
|
218
|
+
if role not in self._backends:
|
|
219
|
+
self._backends[role] = make_backend(self.config.roles[role].spec())
|
|
220
|
+
return self._backends
|
|
221
|
+
|
|
222
|
+
def run(self) -> CampaignResult:
|
|
223
|
+
"""Run, or resume, the campaign until a stop condition holds; return every candidate's outcome.
|
|
224
|
+
|
|
225
|
+
Raises
|
|
226
|
+
------
|
|
227
|
+
PromptError
|
|
228
|
+
Before anything is written, naming every placeholder a replaced template lacks or may not use.
|
|
229
|
+
SnapshotMismatch
|
|
230
|
+
Before any LLM call, on a resume whose field, operator or template files differ from the run's snapshot.
|
|
231
|
+
VocabularyError
|
|
232
|
+
Before any LLM call, with every problem of the field and operator files.
|
|
233
|
+
|
|
234
|
+
Warns
|
|
235
|
+
-----
|
|
236
|
+
CodeVersionChanged
|
|
237
|
+
On a resume on other commits than the run was last opened with.
|
|
238
|
+
"""
|
|
239
|
+
config, run_dir = self.config, Path(self.config.run_dir)
|
|
240
|
+
state_path = run_dir / "state.json"
|
|
241
|
+
prices = config.data.load("prices")
|
|
242
|
+
intraday = config.data.load("intraday") if config.data.intraday is not None else None
|
|
243
|
+
columns, intraday_columns = _columns(prices), _intraday_columns(intraday)
|
|
244
|
+
snapshot = self._snapshot(run_dir, state_path, columns, intraday_columns)
|
|
245
|
+
_keep_config(config, run_dir, opening=len(snapshot.versions))
|
|
246
|
+
recent_rounds = snapshot.settings["context"]["recent_rounds"]
|
|
247
|
+
vocabulary = config.vocabulary(columns, snapshot.search_files, intraday_columns)
|
|
248
|
+
label = config.data.load("label")
|
|
249
|
+
scoring = Scoring.from_config(config.scoring, config.data, njobs=config.evaluator.njobs)
|
|
250
|
+
prompts = self._prompts(config.templates(snapshot.search_files), vocabulary, label, scoring)
|
|
251
|
+
state = _State.read(state_path)
|
|
252
|
+
state.versions = snapshot.versions
|
|
253
|
+
ledger = Ledger(run_dir / "ledger.jsonl")
|
|
254
|
+
log = LLMLog(run_dir / "llm_log")
|
|
255
|
+
written = _written(ledger, log)
|
|
256
|
+
ledger.keep(state.rounds)
|
|
257
|
+
records = ledger.records()
|
|
258
|
+
reference, known = config.load_factors()
|
|
259
|
+
log.drop_after(state.calls)
|
|
260
|
+
events = Events(run_dir / EVENTS_FILE)
|
|
261
|
+
dropped = _written(ledger, log) != written
|
|
262
|
+
events.write(self._opened(snapshot, state, dropped))
|
|
263
|
+
researcher = Researcher(
|
|
264
|
+
{
|
|
265
|
+
role: (RecordingBackend(AskingAgain(backend), log), config.roles[role].model)
|
|
266
|
+
for role, backend in self._role_backends().items()
|
|
267
|
+
},
|
|
268
|
+
prompts,
|
|
269
|
+
groups=config.round.groups,
|
|
270
|
+
events=events,
|
|
271
|
+
)
|
|
272
|
+
store_key = _store_key(config)
|
|
273
|
+
evaluator = Evaluator.for_segment(
|
|
274
|
+
config.training,
|
|
275
|
+
prices=prices,
|
|
276
|
+
label=label,
|
|
277
|
+
scoring=scoring,
|
|
278
|
+
reference=reference,
|
|
279
|
+
known=known,
|
|
280
|
+
warmup_bars=config.evaluator.warmup_bars,
|
|
281
|
+
njobs=config.evaluator.njobs,
|
|
282
|
+
purge_bars=config.segments.purge_bars,
|
|
283
|
+
vocabulary=vocabulary,
|
|
284
|
+
intraday=intraday,
|
|
285
|
+
store=run_dir / "columns" / config.training.name,
|
|
286
|
+
store_key=store_key,
|
|
287
|
+
)
|
|
288
|
+
confirming = None if config.segments.confirmation is None else Evaluator.for_segment(
|
|
289
|
+
config.segments.confirmation,
|
|
290
|
+
prices=prices,
|
|
291
|
+
label=label,
|
|
292
|
+
scoring=scoring,
|
|
293
|
+
reference=reference,
|
|
294
|
+
warmup_bars=config.evaluator.warmup_bars,
|
|
295
|
+
njobs=config.evaluator.njobs,
|
|
296
|
+
purge_bars=config.segments.purge_bars,
|
|
297
|
+
vocabulary=vocabulary,
|
|
298
|
+
intraday=intraday,
|
|
299
|
+
store=run_dir / "columns" / config.segments.confirmation.name,
|
|
300
|
+
store_key=store_key,
|
|
301
|
+
)
|
|
302
|
+
zoo = Zoo.from_existing(reference, vocabulary, known=known)
|
|
303
|
+
_write_zoo(run_dir / "zoo.json", zoo)
|
|
304
|
+
gate = Gate(config.gate, vocabulary, seen=[Graph.from_json(r.graph, vocabulary.classes) for r in records if r.graph])
|
|
305
|
+
_restore(records, zoo, evaluator, confirming, vocabulary, themes=config.context.theme_correlation is not None)
|
|
306
|
+
while (stop := self._stop(state, records)) is None:
|
|
307
|
+
number = state.rounds + 1
|
|
308
|
+
try:
|
|
309
|
+
if config.context.feedback and _feedback.distill_due(
|
|
310
|
+
state.rounds, state.memory_round, config.round.memory_every,
|
|
311
|
+
):
|
|
312
|
+
since = [(t, r) for t, r in _feedback.named(records) if r.round > state.memory_through]
|
|
313
|
+
memory = researcher.distill(
|
|
314
|
+
number, state.memory, _feedback.history(since, statistics=False), _feedback.accepted(records),
|
|
315
|
+
)
|
|
316
|
+
state.memory_round = state.rounds
|
|
317
|
+
if memory is None:
|
|
318
|
+
print(
|
|
319
|
+
f"Campaign: the distiller's reply before round {number} is empty or not a memory in "
|
|
320
|
+
f"its three parts; the previous memory is kept", flush=True,
|
|
321
|
+
)
|
|
322
|
+
else:
|
|
323
|
+
state.memory, state.memory_through = memory, state.rounds
|
|
324
|
+
(run_dir / "memory").mkdir(parents=True, exist_ok=True)
|
|
325
|
+
(run_dir / "memory" / f"round_{number:02d}.md").write_text(memory + "\n")
|
|
326
|
+
(run_dir / "memory.md").write_text(memory + "\n")
|
|
327
|
+
state.calls = len(log.files())
|
|
328
|
+
state.write(state_path)
|
|
329
|
+
quota = _scheduler.quota(number, records, config.round, prompts.mechanism_types)
|
|
330
|
+
accepted = [r for r in records if r.status == "accepted"]
|
|
331
|
+
context = self._context(records, state, reference, known, evaluator, accepted, recent_rounds, prompts)
|
|
332
|
+
trials = _feedback.named(records)
|
|
333
|
+
parents = {
|
|
334
|
+
trial: _feedback.parent(trial, r, trials) for trial, r in trials if r.evaluated
|
|
335
|
+
} if config.context.feedback else {}
|
|
336
|
+
directions, issues = researcher.plan(
|
|
337
|
+
number, quota, context, list(parents) if config.context.feedback else None,
|
|
338
|
+
)
|
|
339
|
+
events.write(Plan(round=number, directions=[d.to_dict() for d in directions], quota=quota, issues=issues))
|
|
340
|
+
proposals = researcher.propose(number, directions, context, gate, zoo, config.round.retries, parents)
|
|
341
|
+
round_records = self._round(
|
|
342
|
+
number, proposals, researcher, gate, zoo, evaluator, records, events, confirming, vocabulary,
|
|
343
|
+
)
|
|
344
|
+
except ProviderQuotaExhausted as exc:
|
|
345
|
+
stop = Stop("provider_quota", f"{exc}; round {number} was stopped and dropped")
|
|
346
|
+
break
|
|
347
|
+
ledger.append(round_records)
|
|
348
|
+
records += round_records
|
|
349
|
+
state.rounds, state.calls = number, len(log.files())
|
|
350
|
+
accepted_now = sum(r.status == "accepted" for r in round_records)
|
|
351
|
+
state.log.append({"round": number, "quota": quota, "plan_issues": issues, "accepted": accepted_now})
|
|
352
|
+
state.write(state_path)
|
|
353
|
+
events.write(RoundEnd(round=number, trials=len(round_records), accepted=accepted_now))
|
|
354
|
+
state.stop = asdict(stop)
|
|
355
|
+
state.write(state_path)
|
|
356
|
+
events.write(Stopped(by=stop.by, reason=stop.reason))
|
|
357
|
+
return CampaignResult(
|
|
358
|
+
records=records, ledger_path=ledger.path, stop=stop, rounds=state.rounds,
|
|
359
|
+
memory=state.memory, not_covered=zoo.not_covered,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
def _snapshot(
|
|
363
|
+
self, run_dir: Path, state_path: Path, columns: set[str] | None, intraday_columns: set[str] | None = None,
|
|
364
|
+
) -> RunSnapshot:
|
|
365
|
+
"""Open the run's snapshot: of the config's files, or, in a replay, of the replayed run's snapshot.
|
|
366
|
+
|
|
367
|
+
The replaced templates and the vocabulary (against the price
|
|
368
|
+
dataset's ``columns`` and the intraday dataset's ``intraday_columns``)
|
|
369
|
+
are checked first, so broken files are never snapshotted and a run
|
|
370
|
+
refused for them starts cleanly once they are fixed. The settings
|
|
371
|
+
recorded are the prompt context and, with an intraday dataset, that
|
|
372
|
+
dataset, which the intraday family's fields mean.
|
|
373
|
+
|
|
374
|
+
Raises
|
|
375
|
+
------
|
|
376
|
+
PromptError
|
|
377
|
+
If a replaced template lacks a placeholder its role needs or uses one it may not.
|
|
378
|
+
VocabularyError
|
|
379
|
+
With every problem of the field and operator files.
|
|
380
|
+
SnapshotMismatch
|
|
381
|
+
If the run directory holds a state but no snapshot (a run started before snapshots).
|
|
382
|
+
ValueError
|
|
383
|
+
If the roles replay more than one recorded run.
|
|
384
|
+
"""
|
|
385
|
+
if state_path.exists() and not RunSnapshot.exists(run_dir):
|
|
386
|
+
raise SnapshotMismatch(
|
|
387
|
+
f"campaign: {run_dir} holds a run but no snapshot of its files, so a resume cannot be checked; "
|
|
388
|
+
f"start a new run directory"
|
|
389
|
+
)
|
|
390
|
+
replayed = {
|
|
391
|
+
Path(self.config.roles[role].options["log"]).parent for role in ROLES
|
|
392
|
+
if role not in self._replaced and self.config.roles[role].backend == "replay"
|
|
393
|
+
}
|
|
394
|
+
replayed = {path for path in replayed if RunSnapshot.exists(path)}
|
|
395
|
+
if len(replayed) > 1:
|
|
396
|
+
raise ValueError(f"campaign: the roles replay several runs {sorted(map(str, replayed))}; replay one")
|
|
397
|
+
if replayed:
|
|
398
|
+
recorded = RunSnapshot.read(replayed.pop())
|
|
399
|
+
files, settings = recorded.search_files, recorded.settings
|
|
400
|
+
else:
|
|
401
|
+
context = asdict(self.config.context)
|
|
402
|
+
if context["feedback"]:
|
|
403
|
+
del context["feedback"] # as runs started before the setting recorded their context
|
|
404
|
+
if context["theme_correlation"] is None:
|
|
405
|
+
del context["theme_correlation"] # likewise
|
|
406
|
+
if not context["send_back_repeats"]:
|
|
407
|
+
del context["send_back_repeats"] # likewise
|
|
408
|
+
files, settings = self.config.search_files(), {"context": context}
|
|
409
|
+
if self.config.data.intraday is not None:
|
|
410
|
+
settings["intraday"] = {"dataset": json.loads(json.dumps(self.config.data.intraday))}
|
|
411
|
+
self.config.templates(files)
|
|
412
|
+
self.config.vocabulary(columns, files, intraday_columns)
|
|
413
|
+
return RunSnapshot.open(run_dir, files, code_version(), settings)
|
|
414
|
+
|
|
415
|
+
def _opened(self, snapshot: RunSnapshot, state: _State, dropped: bool) -> Start:
|
|
416
|
+
"""The events file's record of this opening: a start, or a resume naming the round it ``dropped``."""
|
|
417
|
+
settings, admission = self.config.round, self.config.admission
|
|
418
|
+
opened = dict(
|
|
419
|
+
pid=os.getpid(), host=socket.gethostname(), versions=snapshot.versions[-1], rounds=state.rounds,
|
|
420
|
+
candidates=settings.candidates, max_rounds=settings.max_rounds, max_trials=settings.max_trials,
|
|
421
|
+
patience=settings.patience, t_threshold=admission.t_threshold, max_correlation=admission.max_correlation,
|
|
422
|
+
min_coverage=admission.min_coverage, min_monotonicity=admission.min_monotonicity,
|
|
423
|
+
trial_exponent=admission.trial_exponent,
|
|
424
|
+
)
|
|
425
|
+
if len(snapshot.versions) == 1:
|
|
426
|
+
return Start(**opened)
|
|
427
|
+
return Resume(**opened, dropped=state.rounds + 1 if dropped else None)
|
|
428
|
+
|
|
429
|
+
def _stop(self, state: _State, records: list[TrialRecord]) -> Stop | None:
|
|
430
|
+
"""The first stop condition that holds before the next round, or None."""
|
|
431
|
+
settings = self.config.round
|
|
432
|
+
if state.rounds >= settings.max_rounds:
|
|
433
|
+
return Stop("max_rounds", f"the round limit of {settings.max_rounds} rounds is reached")
|
|
434
|
+
if len(records) + settings.candidates > settings.max_trials:
|
|
435
|
+
return Stop(
|
|
436
|
+
"max_trials",
|
|
437
|
+
f"another round of {settings.candidates} candidates would pass the trial limit of "
|
|
438
|
+
f"{settings.max_trials} ({len(records)} trials so far)",
|
|
439
|
+
)
|
|
440
|
+
recent = range(state.rounds - settings.patience + 1, state.rounds + 1)
|
|
441
|
+
if state.rounds >= settings.patience and not any(r.status == "accepted" and r.round in recent for r in records):
|
|
442
|
+
return Stop("patience", f"no candidate was accepted in the last {settings.patience} rounds")
|
|
443
|
+
return None
|
|
444
|
+
|
|
445
|
+
def _context(
|
|
446
|
+
self, records: list[TrialRecord], state: _State, reference: list, known: list, evaluator: Evaluator,
|
|
447
|
+
accepted: list[TrialRecord], recent_rounds: int, prompts: Prompts,
|
|
448
|
+
) -> dict[str, str]:
|
|
449
|
+
"""The sections the planner and the proposer read; see ``_feedback``.
|
|
450
|
+
|
|
451
|
+
The trials of the last ``recent_rounds`` rounds are the recent
|
|
452
|
+
history, one line each; the older ones are counted in the earlier
|
|
453
|
+
summary, by mechanism type in the order of ``prompts``. With
|
|
454
|
+
``context.feedback`` off only the seed directions and the families.
|
|
455
|
+
"""
|
|
456
|
+
given = {"seed": _seed(self.config.directions), "families": _feedback.families(reference, known)}
|
|
457
|
+
if not self.config.context.feedback:
|
|
458
|
+
return given
|
|
459
|
+
labels = [_feedback.accepted_label(r) for r in accepted]
|
|
460
|
+
first_recent = _feedback.first_recent_round(state.rounds, recent_rounds)
|
|
461
|
+
trials = _feedback.named(records)
|
|
462
|
+
return {
|
|
463
|
+
**given,
|
|
464
|
+
"memory": state.memory,
|
|
465
|
+
"earlier_summary": _feedback.earlier_summary(
|
|
466
|
+
[r for r in records if r.round < first_recent], prompts.mechanism_types,
|
|
467
|
+
),
|
|
468
|
+
"recent_history": _feedback.history(
|
|
469
|
+
[(t, r) for t, r in trials if r.round >= first_recent], earlier=trials,
|
|
470
|
+
),
|
|
471
|
+
"last_round": _feedback.last_round([(t, r) for t, r in trials if r.round == state.rounds], trials),
|
|
472
|
+
"best": _feedback.best(trials, evaluator.contributions(labels) if labels else {}),
|
|
473
|
+
**({"themes": _feedback.themes(
|
|
474
|
+
trials, *evaluator.tried(), self.config.context.theme_correlation, self.config.context.send_back_repeats,
|
|
475
|
+
)} if self.config.context.theme_correlation is not None else {}),
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
def _round(
|
|
479
|
+
self, number: int, proposals: list[Proposal], researcher: Researcher, gate: Gate, zoo: Zoo, evaluator: Evaluator,
|
|
480
|
+
earlier: list[TrialRecord], events: Events, confirming: Evaluator | None = None,
|
|
481
|
+
vocabulary: Vocabulary | None = None,
|
|
482
|
+
) -> list[TrialRecord]:
|
|
483
|
+
"""Review, evaluate and admit one round's proposals; return its records in proposal order.
|
|
484
|
+
|
|
485
|
+
A candidate the reviewer finds inconsistent is rewritten once
|
|
486
|
+
(``Researcher.revise``); its record is that of the rewrite, failed if
|
|
487
|
+
the parser or the Gate refuses it and rejected if the reviewer still
|
|
488
|
+
finds it inconsistent. The candidates the reviewer finds consistent are tested together, so
|
|
489
|
+
each is judged at the evaluated-trial count after the round: the
|
|
490
|
+
earlier rounds' plus every candidate of the round that ran, whatever
|
|
491
|
+
its place in the proposal order. They are admitted in that order,
|
|
492
|
+
and the accepted ones are added to ``zoo``. With a ``confirming``
|
|
493
|
+
evaluator (of the confirmation segment) every candidate is evaluated
|
|
494
|
+
there first, against the pool before the round, and one accepted
|
|
495
|
+
joins its pool after the round. The evaluation's start
|
|
496
|
+
and each outcome go to ``events``, and each evaluated candidate's
|
|
497
|
+
daily IC to ``ic/round_NN.json`` (``_keep_daily_ic``).
|
|
498
|
+
"""
|
|
499
|
+
evaluated = sum(r.evaluated for r in earlier)
|
|
500
|
+
records = [self._failed(number, p) if p.checked is None else None for p in proposals]
|
|
501
|
+
gated = [i for i, p in enumerate(proposals) if p.checked is not None]
|
|
502
|
+
verdicts = researcher.review(number, [proposals[i] for i in gated])
|
|
503
|
+
passed = [i for i, verdict in zip(gated, verdicts) if verdict.consistent]
|
|
504
|
+
sent_back = [(i, verdict) for i, verdict in zip(gated, verdicts) if not verdict.consistent]
|
|
505
|
+
rewrites = dict(zip(
|
|
506
|
+
[i for i, _ in sent_back],
|
|
507
|
+
researcher.revise(number, [(proposals[i], verdict) for i, verdict in sent_back], gate, zoo),
|
|
508
|
+
))
|
|
509
|
+
if self.config.context.send_back_repeats:
|
|
510
|
+
repeats = self._renew(number, proposals, passed, researcher, gate, zoo, evaluator, earlier)
|
|
511
|
+
passed = [i for i in passed if i not in repeats]
|
|
512
|
+
rewrites |= repeats
|
|
513
|
+
proposals = [rewrites.get(i, proposal) for i, proposal in enumerate(proposals)]
|
|
514
|
+
judged, refused = self._judge_rewrites(number, rewrites, researcher)
|
|
515
|
+
passed = sorted(passed + judged)
|
|
516
|
+
for i, record in refused.items():
|
|
517
|
+
records[i] = record
|
|
518
|
+
|
|
519
|
+
daily = {}
|
|
520
|
+
|
|
521
|
+
def admit(position: int, metrics: Metrics, ran: int) -> str | None:
|
|
522
|
+
proposal = proposals[passed[position]]
|
|
523
|
+
daily[passed[position]] = metrics.daily
|
|
524
|
+
record = self._admit(
|
|
525
|
+
number, proposal, metrics, evaluated + ran, confirmations[position] if confirmations else None,
|
|
526
|
+
lambda: _coverage_cause(evaluator, proposal.checked.graph, gate.vocabulary, self.config.admission),
|
|
527
|
+
)
|
|
528
|
+
records[passed[position]] = record
|
|
529
|
+
events.write(AdmissionOutcome(
|
|
530
|
+
round=number, slot=passed[position], status=record.status, stage=record.stage,
|
|
531
|
+
reasons=list(record.reasons), threshold=_admission.t_threshold(self.config.admission, evaluated + ran),
|
|
532
|
+
metrics=record.metrics,
|
|
533
|
+
))
|
|
534
|
+
if record.status != "accepted":
|
|
535
|
+
return None
|
|
536
|
+
return zoo.add(proposal.candidate.description, _feedback.accepted_source(number), proposal.checked.graph).label
|
|
537
|
+
|
|
538
|
+
events.write(EvaluationStart(round=number, slots=list(passed)))
|
|
539
|
+
graphs = [proposals[i].checked.graph for i in passed]
|
|
540
|
+
confirmations = confirming.evaluate(graphs) if confirming is not None and graphs else None
|
|
541
|
+
tried = [_feedback.trial_id(number, i) for i in passed] if self.config.context.theme_correlation is not None else None
|
|
542
|
+
results = evaluator.evaluate(graphs, admit, tried)
|
|
543
|
+
if confirming is not None:
|
|
544
|
+
_join(confirming, [r for r in records if r is not None and r.status == "accepted"], vocabulary)
|
|
545
|
+
_keep_daily_ic(Path(self.config.run_dir), number, daily)
|
|
546
|
+
for i, result in zip(passed, results):
|
|
547
|
+
if isinstance(result, CompileFailure):
|
|
548
|
+
records[i] = TrialRecord(
|
|
549
|
+
status="failed", stage="evaluate", reasons=[result.error], **self._common(number, proposals[i]),
|
|
550
|
+
)
|
|
551
|
+
events.write(AdmissionOutcome(
|
|
552
|
+
round=number, slot=i, status="failed", stage="evaluate", reasons=[result.error], threshold=None,
|
|
553
|
+
metrics=None,
|
|
554
|
+
))
|
|
555
|
+
return records
|
|
556
|
+
|
|
557
|
+
def _renew(
|
|
558
|
+
self, number: int, proposals: list[Proposal], passed: list[int], researcher: Researcher, gate: Gate, zoo: Zoo,
|
|
559
|
+
evaluator: Evaluator, earlier: list[TrialRecord],
|
|
560
|
+
) -> dict[int, Proposal]:
|
|
561
|
+
"""The rewrites, by slot, of the new directions' candidates in ``passed`` that measure what an earlier trial measured.
|
|
562
|
+
|
|
563
|
+
Before anything is scored, a new direction's candidate whose factor
|
|
564
|
+
is correlated with an earlier evaluated trial's at
|
|
565
|
+
``context.theme_correlation`` or more in absolute value
|
|
566
|
+
(``Evaluator.nearest_tried``, which computes each new direction's
|
|
567
|
+
candidate once more) is rewritten once (``Researcher.renew``), with
|
|
568
|
+
that trial named and no statistic but the correlation, and a rewrite
|
|
569
|
+
the parser or the Gate refuses is fixed up to ``round.retries`` times;
|
|
570
|
+
the rewrites are judged as the reviewer's are (``_judge_rewrites``).
|
|
571
|
+
"""
|
|
572
|
+
threshold = self.config.context.theme_correlation
|
|
573
|
+
new = [i for i in passed if proposals[i].direction.action == "new"]
|
|
574
|
+
nearest = evaluator.nearest_tried([proposals[i].checked.graph for i in new])
|
|
575
|
+
by_id = dict(_feedback.named(earlier))
|
|
576
|
+
repeated = [
|
|
577
|
+
(i, name, correlation) for i, found in zip(new, nearest) if not isinstance(found, CompileFailure)
|
|
578
|
+
for name, correlation in [found] if name and abs(correlation) >= threshold
|
|
579
|
+
]
|
|
580
|
+
rewrites = researcher.renew(
|
|
581
|
+
number, [(proposals[i], _repeats(name, correlation, by_id[name], threshold)) for i, name, correlation in repeated],
|
|
582
|
+
gate, zoo, self.config.round.retries,
|
|
583
|
+
)
|
|
584
|
+
return {i: rewrite for (i, _, _), rewrite in zip(repeated, rewrites)}
|
|
585
|
+
|
|
586
|
+
def _judge_rewrites(
|
|
587
|
+
self, number: int, rewrites: dict[int, Proposal], researcher: Researcher,
|
|
588
|
+
) -> tuple[list[int], dict[int, TrialRecord]]:
|
|
589
|
+
"""Of the rewrites, by slot, the slots to evaluate and the records of the others.
|
|
590
|
+
|
|
591
|
+
A rewrite the parser or the Gate refused is failed; the reviewer
|
|
592
|
+
judges the others in one more call, and an inconsistent one is
|
|
593
|
+
rejected at review; a consistent one is evaluated with the rest.
|
|
594
|
+
"""
|
|
595
|
+
refused = {i: self._failed(number, p) for i, p in rewrites.items() if p.checked is None}
|
|
596
|
+
checked = [i for i, p in rewrites.items() if p.checked is not None]
|
|
597
|
+
judged = []
|
|
598
|
+
for i, verdict in zip(checked, researcher.review(number, [rewrites[i] for i in checked])):
|
|
599
|
+
if verdict.consistent:
|
|
600
|
+
judged.append(i)
|
|
601
|
+
else:
|
|
602
|
+
refused[i] = TrialRecord(
|
|
603
|
+
status="rejected", stage="review", reasons=[verdict.refusal], **self._common(number, rewrites[i]),
|
|
604
|
+
)
|
|
605
|
+
return judged, refused
|
|
606
|
+
|
|
607
|
+
@staticmethod
|
|
608
|
+
def _failed(number: int, proposal: Proposal) -> TrialRecord:
|
|
609
|
+
"""The record of a candidate the parser or the Gate still refused after its retries."""
|
|
610
|
+
last = proposal.attempts[-1]
|
|
611
|
+
item = proposal.item
|
|
612
|
+
return TrialRecord(
|
|
613
|
+
round=number, candidate=item if isinstance(item, dict) else {"raw": item}, status="failed",
|
|
614
|
+
stage=last.stage, reasons=list(last.reasons), attempts=_attempts(proposal),
|
|
615
|
+
direction=proposal.direction.to_dict(),
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
def _admit(
|
|
619
|
+
self, number: int, proposal: Proposal, metrics: Metrics, trials: int,
|
|
620
|
+
confirmation: Metrics | CompileFailure | None = None, coverage_cause: Callable[[], str] | None = None,
|
|
621
|
+
) -> TrialRecord:
|
|
622
|
+
"""Decide one evaluated candidate, the ``trials``-th evaluated trial of the campaign.
|
|
623
|
+
|
|
624
|
+
With its ``confirmation`` (its evaluation on the confirmation
|
|
625
|
+
segment) a candidate passing every other check is refused unless it
|
|
626
|
+
holds there (``_admission.confirmed``); a confirmation that did not
|
|
627
|
+
compile does not hold. A refusal for coverage goes on with what
|
|
628
|
+
``coverage_cause`` says of where the candidate loses its values.
|
|
629
|
+
"""
|
|
630
|
+
admission = self.config.admission
|
|
631
|
+
reasons = _admission.reasons(metrics, trials, admission)
|
|
632
|
+
if reasons and coverage_cause is not None and _admission.reason_kind(reasons[0]) == "coverage":
|
|
633
|
+
reasons = [reasons[0] + coverage_cause()]
|
|
634
|
+
kept = confirmation.to_dict() if isinstance(confirmation, Metrics) else None
|
|
635
|
+
if not reasons and confirmation is not None and (
|
|
636
|
+
kept is None or not _admission.confirmed(metrics.ic_mean, confirmation.partial_ic_t, admission)
|
|
637
|
+
):
|
|
638
|
+
reasons = [_admission.NOT_CONFIRMED]
|
|
639
|
+
return TrialRecord(
|
|
640
|
+
status="rejected" if reasons else "accepted", stage="admission", reasons=reasons,
|
|
641
|
+
metrics=metrics.to_dict(), confirmation=kept, **self._common(number, proposal),
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
@staticmethod
|
|
645
|
+
def _common(number: int, proposal: Proposal) -> dict:
|
|
646
|
+
"""The record fields of a candidate the Gate passed."""
|
|
647
|
+
checked = proposal.checked
|
|
648
|
+
return dict(
|
|
649
|
+
round=number, candidate=proposal.candidate.to_dict(), fingerprint=checked.fingerprint,
|
|
650
|
+
stats=checked.stats, attempts=_attempts(proposal), direction=proposal.direction.to_dict(),
|
|
651
|
+
graph=checked.graph.to_json(),
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _coverage_cause(evaluator: Evaluator, graph: Graph, vocabulary: Vocabulary, admission: AdmissionConfig) -> str:
|
|
656
|
+
"""Where ``graph`` loses its values on ``evaluator``'s segment, continuing its coverage refusal (``_admission.coverage_cause``)."""
|
|
657
|
+
daily, trace = evaluator.trace_coverage(graph)
|
|
658
|
+
return _admission.coverage_cause(trace, lambda node: render(daily, node, vocabulary), admission.min_coverage)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _keep_daily_ic(run_dir: Path, number: int, daily: dict[int, DailyIC | None]) -> None:
|
|
662
|
+
"""Keep round ``number``'s daily IC and partial IC by slot in ``ic/round_NN.json``, apart from the ledger.
|
|
663
|
+
|
|
664
|
+
The days are the label's, shared by the round's candidates; NaN (an unscored day) is written as null.
|
|
665
|
+
A round run again after a resume writes its file anew.
|
|
666
|
+
"""
|
|
667
|
+
kept = {slot: series for slot, series in daily.items() if series is not None}
|
|
668
|
+
if not kept:
|
|
669
|
+
return
|
|
670
|
+
(run_dir / "ic").mkdir(exist_ok=True)
|
|
671
|
+
(run_dir / "ic" / f"round_{number:02d}.json").write_text(json.dumps({
|
|
672
|
+
"days": list(next(iter(kept.values())).days),
|
|
673
|
+
"slots": {
|
|
674
|
+
str(slot): {"ic": [v if math.isfinite(v) else None for v in s.ic],
|
|
675
|
+
"partial_ic": [v if math.isfinite(v) else None for v in s.partial_ic]}
|
|
676
|
+
for slot, s in sorted(kept.items())
|
|
677
|
+
},
|
|
678
|
+
}))
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _keep_config(config: CampaignConfig, run_dir: Path, opening: int) -> None:
|
|
682
|
+
"""Keep the config text of the run's ``opening``-th start or resume as ``config/open-<opening>.yaml``.
|
|
683
|
+
|
|
684
|
+
A resume may raise a limit, so every opening keeps its own; a config made in code has no text to keep.
|
|
685
|
+
"""
|
|
686
|
+
if config.source is not None:
|
|
687
|
+
(run_dir / "config").mkdir(exist_ok=True)
|
|
688
|
+
(run_dir / "config" / f"open-{opening}.yaml").write_text(config.source)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _written(ledger: Ledger, log: LLMLog) -> tuple[str, int]:
|
|
692
|
+
"""What the run has written to its ledger and its LLM log: the ledger's text and the number of call files."""
|
|
693
|
+
return ledger.path.read_text() if ledger.path.exists() else "", len(log.files())
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _seed(directions) -> str:
|
|
697
|
+
"""The seed directions as the planner reads them."""
|
|
698
|
+
return "\n".join(f"- {direction}" for direction in directions)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _restore(
|
|
702
|
+
records: list[TrialRecord], zoo: Zoo, evaluator: Evaluator, confirming: Evaluator | None, vocabulary: Vocabulary,
|
|
703
|
+
themes: bool,
|
|
704
|
+
) -> None:
|
|
705
|
+
"""Put the accepted factors of the rounds already written back into ``zoo`` and each segment's pool, in order.
|
|
706
|
+
|
|
707
|
+
With ``themes`` the evaluated trials go back into the tried trials too.
|
|
708
|
+
The Evaluators read the columns from the run's store (``Evaluator.join``),
|
|
709
|
+
so a factor is computed only when the store lacks it: a run directory
|
|
710
|
+
written before the store, say, whose columns are stored now.
|
|
711
|
+
|
|
712
|
+
Raises
|
|
713
|
+
------
|
|
714
|
+
RuntimeError
|
|
715
|
+
If an accepted factor's graph no longer compiles or an evaluated
|
|
716
|
+
trial's no longer computes (the code or the quantlab version changed
|
|
717
|
+
under the run directory).
|
|
718
|
+
"""
|
|
719
|
+
accepted = [r for r in records if r.status == "accepted"]
|
|
720
|
+
graphs = [Graph.from_json(r.graph, vocabulary.classes) for r in accepted]
|
|
721
|
+
labels = [zoo.add(r.candidate["description"], _feedback.accepted_source(r.round), g).label for r, g in zip(accepted, graphs)]
|
|
722
|
+
tried = [
|
|
723
|
+
(trial, Graph.from_json(r.graph, vocabulary.classes)) for trial, r in _feedback.named(records) if r.evaluated
|
|
724
|
+
] if themes else []
|
|
725
|
+
broken = evaluator.join(pool=list(zip(labels, graphs)), tried=tried)
|
|
726
|
+
if lost := [name for name in broken if name in labels]:
|
|
727
|
+
raise RuntimeError(f"Campaign: cannot resume, accepted factor(s) no longer compile: {lost}")
|
|
728
|
+
if broken:
|
|
729
|
+
raise RuntimeError(f"Campaign: cannot resume, evaluated trial(s) no longer compute: {broken}")
|
|
730
|
+
if confirming is not None:
|
|
731
|
+
_join(confirming, accepted, vocabulary)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _repeats(trial: str, correlation: float, earlier: TrialRecord, threshold: float) -> str:
|
|
735
|
+
"""Why a new direction's candidate is sent back: it measures what the earlier ``trial`` measured."""
|
|
736
|
+
return (
|
|
737
|
+
f"the research direction is new, but the factor measures what earlier trial {trial} measured: its "
|
|
738
|
+
f"correlation with {trial}'s factor is {correlation:+.2f}, at least {threshold:g} in absolute value "
|
|
739
|
+
f"({trial} computed: {earlier.candidate.get('description', '')}; {_feedback.outcome(earlier)}); write the "
|
|
740
|
+
f"candidate for a mechanism no earlier trial measured, as the research direction asks"
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _join(evaluator: Evaluator, accepted: list[TrialRecord], vocabulary: Vocabulary) -> None:
|
|
745
|
+
"""Add the ``accepted`` factors to ``evaluator``'s pool, in order: the confirmation segment's pool.
|
|
746
|
+
|
|
747
|
+
A factor of the batch the evaluator computed last (the round's
|
|
748
|
+
candidates) or of its store is not computed again (``Evaluator.join``).
|
|
749
|
+
|
|
750
|
+
Raises
|
|
751
|
+
------
|
|
752
|
+
RuntimeError
|
|
753
|
+
If an accepted factor's graph does not compile there.
|
|
754
|
+
"""
|
|
755
|
+
labels = [_feedback.accepted_label(r) for r in accepted]
|
|
756
|
+
graphs = [Graph.from_json(r.graph, vocabulary.classes) for r in accepted]
|
|
757
|
+
broken = evaluator.join(pool=list(zip(labels, graphs)))
|
|
758
|
+
if broken:
|
|
759
|
+
raise RuntimeError(f"Campaign: accepted factor(s) do not compile on the confirmation segment: {broken}")
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _store_key(config: CampaignConfig) -> dict:
|
|
763
|
+
"""What an Evaluator's stored columns depend on besides its segment: the data, the scoring and the warm-up."""
|
|
764
|
+
return json.loads(json.dumps(
|
|
765
|
+
{"data": asdict(config.data), "scoring": asdict(config.scoring), "warmup_bars": config.evaluator.warmup_bars},
|
|
766
|
+
default=str,
|
|
767
|
+
))
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _columns(prices) -> set[str] | None:
|
|
771
|
+
"""The variables of the price dataset's panel; None when it reads no store (a panel held in memory).
|
|
772
|
+
|
|
773
|
+
The names are the panel's, as candidate code reads them: a merged or renaming input (a prefix, a
|
|
774
|
+
shared name) gives its columns under the names its panel holds, not its store's.
|
|
775
|
+
"""
|
|
776
|
+
if not any(Path(path).exists() for path in prices.stores()):
|
|
777
|
+
return None
|
|
778
|
+
return set(prices.head(0).collect_schema().names()) - {"timestamp", "symbol"}
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _intraday_columns(intraday) -> set[str] | None:
|
|
782
|
+
"""The intraday dataset's columns and the session inputs added to them; None without a dataset or a store."""
|
|
783
|
+
if intraday is None:
|
|
784
|
+
return None
|
|
785
|
+
columns = _columns(intraday)
|
|
786
|
+
return None if columns is None else columns | set(SESSION_INPUTS)
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _write_zoo(path: Path, zoo: Zoo) -> None:
|
|
790
|
+
"""Write which reference and known factors the originality check covers and which it cannot."""
|
|
791
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
792
|
+
path.write_text(json.dumps({"covered": list(zoo.covered), "not_covered": list(zoo.not_covered)}, indent=2) + "\n")
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _attempts(proposal: Proposal) -> list[dict]:
|
|
796
|
+
"""The refused versions of a proposal as the ledger stores them."""
|
|
797
|
+
return [{**asdict(a), "reasons": list(a.reasons)} for a in proposal.attempts]
|