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.
Files changed (72) hide show
  1. factor_agent/KUNQUANT_LICENSE +201 -0
  2. factor_agent/__init__.py +8 -0
  3. factor_agent/_acceptance_model.py +93 -0
  4. factor_agent/_admission.py +168 -0
  5. factor_agent/_expression_fold.py +48 -0
  6. factor_agent/_feedback.py +399 -0
  7. factor_agent/_max_drawdown.py +51 -0
  8. factor_agent/_model_cache.py +100 -0
  9. factor_agent/_scheduler.py +35 -0
  10. factor_agent/acceptance.py +808 -0
  11. factor_agent/accepted/__init__.py +1 -0
  12. factor_agent/aggregations.py +122 -0
  13. factor_agent/campaign.py +797 -0
  14. factor_agent/candidate.py +51 -0
  15. factor_agent/config.py +651 -0
  16. factor_agent/derived.py +98 -0
  17. factor_agent/evaluator.py +1096 -0
  18. factor_agent/events.py +343 -0
  19. factor_agent/exported.py +177 -0
  20. factor_agent/gate/__init__.py +359 -0
  21. factor_agent/gate/_canonical.py +331 -0
  22. factor_agent/gate/_checks.py +85 -0
  23. factor_agent/gate/_constraints.py +85 -0
  24. factor_agent/gate/_dimension.py +177 -0
  25. factor_agent/gate/_originality.py +146 -0
  26. factor_agent/gate/_sandbox.py +105 -0
  27. factor_agent/gate/graph.py +223 -0
  28. factor_agent/gate/stages.py +383 -0
  29. factor_agent/gate/vocabulary.py +1097 -0
  30. factor_agent/ledger.py +85 -0
  31. factor_agent/llm/__init__.py +0 -0
  32. factor_agent/llm/_common.py +18 -0
  33. factor_agent/llm/_raw.py +78 -0
  34. factor_agent/llm/anthropic.py +100 -0
  35. factor_agent/llm/base.py +124 -0
  36. factor_agent/llm/claude_code.py +101 -0
  37. factor_agent/llm/log.py +74 -0
  38. factor_agent/llm/openai_compatible.py +113 -0
  39. factor_agent/llm/replay.py +59 -0
  40. factor_agent/llm/scripted.py +39 -0
  41. factor_agent/period_lag.py +91 -0
  42. factor_agent/prompts.py +557 -0
  43. factor_agent/researcher.py +615 -0
  44. factor_agent/scoring.py +143 -0
  45. factor_agent/segments.py +184 -0
  46. factor_agent/session.py +500 -0
  47. factor_agent/snapshot.py +219 -0
  48. factor_agent/web/__init__.py +7 -0
  49. factor_agent/web/_acceptance.py +145 -0
  50. factor_agent/web/_config.py +51 -0
  51. factor_agent/web/_diagnosis.py +110 -0
  52. factor_agent/web/_files.py +78 -0
  53. factor_agent/web/_health.py +207 -0
  54. factor_agent/web/_ic.py +27 -0
  55. factor_agent/web/_ledger.py +293 -0
  56. factor_agent/web/_memory.py +42 -0
  57. factor_agent/web/_results.py +77 -0
  58. factor_agent/web/_round.py +219 -0
  59. factor_agent/web/_trials.py +112 -0
  60. factor_agent/web/app.py +118 -0
  61. factor_agent/web/run_view.py +329 -0
  62. factor_agent/web/static/assets/index-9QjjJgl3.css +1 -0
  63. factor_agent/web/static/assets/index-iOE9ei_S.js +74 -0
  64. factor_agent/web/static/index.html +13 -0
  65. factor_agent/zoo.py +136 -0
  66. quantlab_factor_agent-0.1.0.dist-info/METADATA +172 -0
  67. quantlab_factor_agent-0.1.0.dist-info/RECORD +72 -0
  68. quantlab_factor_agent-0.1.0.dist-info/WHEEL +5 -0
  69. quantlab_factor_agent-0.1.0.dist-info/entry_points.txt +2 -0
  70. quantlab_factor_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
  71. quantlab_factor_agent-0.1.0.dist-info/licenses/factor_agent/KUNQUANT_LICENSE +201 -0
  72. quantlab_factor_agent-0.1.0.dist-info/top_level.txt +1 -0
@@ -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]