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
factor_agent/events.py ADDED
@@ -0,0 +1,343 @@
1
+ """The events file: what a campaign is doing, appended as it happens, for anyone watching the run.
2
+
3
+ A campaign appends one event per line to ``events.jsonl`` in its run
4
+ directory: a JSON object with the file's schema version (``"schema"``), the
5
+ event's type (``"type"``), the time it happened (``"time"``, UTC, ISO 8601)
6
+ and the event's fields. The file is append-only, and the campaign never
7
+ reads it: it takes no part in a resume, a replay or the run's snapshot, so
8
+ deleting it loses the record of what happened but changes no result. A
9
+ resume that drops a round a crash left half written says so in its
10
+ ``resume`` event instead of removing that round's events.
11
+
12
+ The event types, defined here once and read back by ``read``:
13
+
14
+ - ``start`` and ``resume`` (``Start``, ``Resume``): the campaign's process
15
+ id and host, the code versions, the rounds already written, the stop
16
+ conditions (round and trial limits, patience) and the admission limits;
17
+ a resume also names the round it dropped;
18
+ - ``plan`` (``Plan``): a round's research directions in slot order, its
19
+ quota and the plan's issues;
20
+ - ``call_start`` and ``call_end`` (``CallStart``, ``CallEnd``): every LLM
21
+ call, with its role, the proposal group and the slots it concerns; its
22
+ end names the call file it wrote, how long it took, its tokens as far
23
+ as the provider's reply reports them, and whether the reply was cut off
24
+ at the output limit or empty;
25
+ - ``gate`` (``GateOutcome``): every version of a slot's candidate the
26
+ parser and the Gate checked, passed or refused with the reasons and hints;
27
+ - ``review`` (``ReviewVerdict``): the reviewer's verdict on a slot, with its reason;
28
+ - ``evaluation`` (``EvaluationStart``): the Evaluator starts on the round's
29
+ candidates the reviewer found consistent;
30
+ - ``admission`` (``AdmissionOutcome``): a slot's outcome after evaluation,
31
+ with the t threshold in force and the metrics;
32
+ - ``round_end`` (``RoundEnd``): a round's trials are written;
33
+ - ``stop`` (``Stopped``): the run stopped, as the state file records it.
34
+
35
+ A slot is a candidate's place in its round, from 0, in the order of the
36
+ round's research directions (and of the round's lines in the ledger). A
37
+ value the campaign holds as NaN or infinite is written as ``null``.
38
+ """
39
+
40
+ import itertools
41
+ import json
42
+ import math
43
+ import threading
44
+ from dataclasses import asdict, dataclass, field, fields
45
+ from datetime import datetime, timezone
46
+ from pathlib import Path
47
+ from typing import Literal
48
+
49
+ from factor_agent.llm._raw import raw_facts
50
+
51
+ #: The events file's name in a run directory.
52
+ FILE = "events.jsonl"
53
+
54
+ #: The version of the events file's format: it changes when an event loses or changes a field.
55
+ SCHEMA = 1
56
+
57
+
58
+ def _now() -> datetime:
59
+ return datetime.now(timezone.utc)
60
+
61
+
62
+ @dataclass(frozen=True, kw_only=True)
63
+ class Event:
64
+ """What every event has: the time it happened (UTC)."""
65
+
66
+ #: The event's type in the file; each event class sets its own.
67
+ type = ""
68
+ time: datetime = field(default_factory=_now)
69
+
70
+
71
+ @dataclass(frozen=True, kw_only=True)
72
+ class Start(Event):
73
+ """A campaign opened its run directory, with what it runs on and when it stops.
74
+
75
+ ``pid`` and ``host`` are the campaign's process id and host name;
76
+ ``versions`` the code versions the run's snapshot records for this
77
+ opening; ``rounds`` the rounds already written. ``max_rounds``,
78
+ ``max_trials`` and ``patience`` are the stop conditions, ``candidates``
79
+ the slots of a round; ``t_threshold``, ``trial_exponent``,
80
+ ``max_correlation``, ``min_coverage`` and ``min_monotonicity`` the
81
+ admission settings (``trial_exponent`` is 1 in a record written before it
82
+ was).
83
+ """
84
+
85
+ type = "start"
86
+ pid: int
87
+ host: str
88
+ versions: dict
89
+ rounds: int
90
+ candidates: int
91
+ max_rounds: int
92
+ max_trials: int
93
+ patience: int
94
+ t_threshold: float
95
+ max_correlation: float
96
+ min_coverage: float
97
+ min_monotonicity: float
98
+ trial_exponent: float = 1.0
99
+
100
+
101
+ @dataclass(frozen=True, kw_only=True)
102
+ class Resume(Start):
103
+ """A campaign opened a run directory it had opened before; ``dropped`` is the round it drops, if any.
104
+
105
+ The events of the dropped round stay in the file before this one; the
106
+ round runs again under the same number after it.
107
+ """
108
+
109
+ type = "resume"
110
+ dropped: int | None = None
111
+
112
+
113
+ @dataclass(frozen=True, kw_only=True)
114
+ class Plan(Event):
115
+ """Round ``round``'s research directions, in slot order, the quota by mechanism type and the plan's issues."""
116
+
117
+ type = "plan"
118
+ round: int
119
+ directions: list[dict]
120
+ quota: dict[str, int]
121
+ issues: list[str]
122
+
123
+
124
+ @dataclass(frozen=True, kw_only=True)
125
+ class CallStart(Event):
126
+ """An LLM call was sent.
127
+
128
+ ``call`` tells the calls of one opening of the run apart (``CallEnd``
129
+ names the same). ``group`` is the proposal group and ``slots`` the
130
+ slots the call concerns; both are empty for the planner and the
131
+ distiller, whose calls concern the whole round.
132
+ """
133
+
134
+ type = "call_start"
135
+ call: int
136
+ round: int
137
+ role: str
138
+ model: str
139
+ group: int | None = None
140
+ slots: list[int] = field(default_factory=list)
141
+
142
+
143
+ @dataclass(frozen=True, kw_only=True)
144
+ class CallEnd(CallStart):
145
+ """An LLM call came back, or failed.
146
+
147
+ ``file`` is the call file's name in ``llm_log/``, ``None`` when the call
148
+ failed (``error`` says how) and wrote none. ``seconds`` is how long it
149
+ took. The token counts are the provider's, ``None`` where its reply
150
+ does not report them: ``cache_hits`` are input tokens read from the
151
+ provider's cache, ``reasoning`` output tokens spent reasoning.
152
+ ``truncated`` is true when the reply was cut off at the output limit,
153
+ ``None`` when the provider's reply does not say; ``empty`` when the
154
+ reply holds no text.
155
+ """
156
+
157
+ type = "call_end"
158
+ file: str | None = None
159
+ seconds: float = 0.0
160
+ input_tokens: int | None = None
161
+ output_tokens: int | None = None
162
+ cache_hits: int | None = None
163
+ reasoning: int | None = None
164
+ truncated: bool | None = None
165
+ empty: bool = False
166
+ error: str | None = None
167
+
168
+
169
+ #: Which version of a slot's candidate the Gate checks: the first, one fixed after a refusal, or one rewritten.
170
+ GateStep = Literal["proposed", "fixed", "rewritten"]
171
+
172
+
173
+ @dataclass(frozen=True, kw_only=True)
174
+ class GateOutcome(Event):
175
+ """One version of a slot's candidate checked by the parser and the Gate.
176
+
177
+ ``step`` is ``"proposed"`` (the first version), ``"fixed"`` (a version
178
+ sent back by the parser or the Gate) or ``"rewritten"`` (a version the
179
+ reviewer sent back). ``item`` is the version as the proposer wrote it.
180
+ ``stage`` is ``"parse"`` or ``"gate"`` for a refusal, with its
181
+ ``reasons`` and ``hints``, and ``None`` when it ``passed``.
182
+ """
183
+
184
+ type = "gate"
185
+ round: int
186
+ slot: int
187
+ step: GateStep
188
+ item: object
189
+ passed: bool
190
+ stage: str | None = None
191
+ reasons: list[str] = field(default_factory=list)
192
+ hints: list[str] = field(default_factory=list)
193
+
194
+
195
+ @dataclass(frozen=True, kw_only=True)
196
+ class ReviewVerdict(Event):
197
+ """The reviewer's verdict on a slot's candidate, with its reason; ``rewrite`` when it judged a rewrite."""
198
+
199
+ type = "review"
200
+ round: int
201
+ slot: int
202
+ consistent: bool
203
+ reason: str
204
+ rewrite: bool = False
205
+
206
+
207
+ @dataclass(frozen=True, kw_only=True)
208
+ class EvaluationStart(Event):
209
+ """The Evaluator starts on round ``round``'s ``slots``: the candidates the reviewer found consistent."""
210
+
211
+ type = "evaluation"
212
+ round: int
213
+ slots: list[int]
214
+
215
+
216
+ @dataclass(frozen=True, kw_only=True)
217
+ class AdmissionOutcome(Event):
218
+ """A slot's outcome after evaluation.
219
+
220
+ ``status`` is ``"accepted"`` or ``"rejected"`` (``stage`` ``"admission"``),
221
+ or ``"failed"`` (``stage`` ``"evaluate"``) when its graph did not
222
+ compile. ``threshold`` is the partial-IC t it had to reach; ``metrics``
223
+ are the Evaluator's (``None`` for a failure).
224
+ """
225
+
226
+ type = "admission"
227
+ round: int
228
+ slot: int
229
+ status: str
230
+ stage: str
231
+ reasons: list[str]
232
+ threshold: float | None
233
+ metrics: dict | None
234
+
235
+
236
+ @dataclass(frozen=True, kw_only=True)
237
+ class RoundEnd(Event):
238
+ """Round ``round``'s ``trials`` are written, ``accepted`` of them accepted."""
239
+
240
+ type = "round_end"
241
+ round: int
242
+ trials: int
243
+ accepted: int
244
+
245
+
246
+ @dataclass(frozen=True, kw_only=True)
247
+ class Stopped(Event):
248
+ """The run stopped: ``by`` the stop condition, ``reason`` its sentence, as the state file records them."""
249
+
250
+ type = "stop"
251
+ by: str
252
+ reason: str
253
+
254
+
255
+ #: Every event class by its type.
256
+ TYPES: dict[str, type[Event]] = {
257
+ cls.type: cls
258
+ for cls in (Start, Resume, Plan, CallStart, CallEnd, GateOutcome, ReviewVerdict, EvaluationStart,
259
+ AdmissionOutcome, RoundEnd, Stopped)
260
+ }
261
+
262
+
263
+ class Events:
264
+ """The events file of one run directory, appended to by a campaign; safe to use from several threads."""
265
+
266
+ def __init__(self, path: str | Path):
267
+ self.path = Path(path)
268
+ self._writing = threading.Lock()
269
+ self._calls = itertools.count(1)
270
+
271
+ def write(self, event: Event) -> None:
272
+ """Append ``event`` as one line."""
273
+ line = {"schema": SCHEMA, "type": event.type, **_plain(asdict(event))}
274
+ text = json.dumps(line, ensure_ascii=False, allow_nan=False) + "\n"
275
+ with self._writing:
276
+ self.path.parent.mkdir(parents=True, exist_ok=True)
277
+ with self.path.open("a") as out:
278
+ out.write(text)
279
+
280
+ def next_call(self) -> int:
281
+ """A number for the next call, unique in this opening of the run."""
282
+ with self._writing:
283
+ return next(self._calls)
284
+
285
+
286
+ def read(path: str | Path) -> list[Event]:
287
+ """The events in the file at ``path``, in order; an empty list when there is none.
288
+
289
+ A line a crash cut short, a line of a later schema and an event of an
290
+ unknown type are skipped; a field this version does not know is ignored.
291
+ """
292
+ try:
293
+ lines = Path(path).read_text().splitlines()
294
+ except FileNotFoundError:
295
+ return []
296
+ events = []
297
+ for line in lines:
298
+ try:
299
+ value = json.loads(line)
300
+ except json.JSONDecodeError:
301
+ continue
302
+ cls = TYPES.get(value.get("type")) if isinstance(value, dict) else None
303
+ if cls is None or value.get("schema") != SCHEMA:
304
+ continue
305
+ known = {f.name for f in fields(cls)}
306
+ try:
307
+ events.append(cls(**{k: v for k, v in value.items() if k in known and k != "time"},
308
+ time=datetime.fromisoformat(value["time"])))
309
+ except (TypeError, KeyError, ValueError):
310
+ continue
311
+ return events
312
+
313
+
314
+ def reply_facts(reply) -> dict:
315
+ """What a call's ``CallEnd`` says of its reply: tokens, truncation and emptiness, as far as the reply reports them.
316
+
317
+ The input and output tokens are the reply's ``usage``; the cache hits,
318
+ the reasoning tokens and the stop reason come from the provider's raw
319
+ reply: an Anthropic message, an OpenAI-compatible chat completion or
320
+ the Claude Code CLI's result.
321
+ """
322
+ usage, facts = reply.usage or {}, raw_facts(reply.raw)
323
+ return {
324
+ "input_tokens": usage.get("input_tokens"),
325
+ "output_tokens": usage.get("output_tokens"),
326
+ "cache_hits": facts.cache_hits,
327
+ "reasoning": facts.reasoning_tokens,
328
+ "truncated": facts.truncated,
329
+ "empty": not reply.text.strip(),
330
+ }
331
+
332
+
333
+ def _plain(value):
334
+ """``value`` as strict JSON holds it: NaN and infinities as ``None``, times as ISO 8601 text."""
335
+ if isinstance(value, float):
336
+ return value if math.isfinite(value) else None
337
+ if isinstance(value, datetime):
338
+ return value.isoformat()
339
+ if isinstance(value, dict):
340
+ return {str(k): _plain(v) for k, v in value.items()}
341
+ if isinstance(value, (list, tuple)):
342
+ return [_plain(v) for v in value]
343
+ return value
@@ -0,0 +1,177 @@
1
+ """Accepted factors as quantlab factors: a ``FactorKunQuant`` whose outputs are recorded graphs.
2
+
3
+ Each output is a candidate's graph ranked across the whole market (KunQuant
4
+ ``Rank``), the values the Evaluator scores (``factor_agent.scoring``), built by the
5
+ same ``ranked_function``. ``Acceptance.export`` writes one module per
6
+ factor, a subclass of ``GraphFactor`` that fixes ``GRAPHS`` and the
7
+ ``CLASSES`` the campaign's vocabulary built them from;
8
+ ``Acceptance.validate`` uses ``GraphFactor`` itself, with both in
9
+ ``config.kwargs``.
10
+
11
+ A factor of an intraday family is held in its stages
12
+ (``factor_agent.gate.stages``, ``staged``): its graph is the daily stage,
13
+ reading each session column as an input, and ``SESSIONS`` maps each session
14
+ column to its intraday graph, computed from the intraday dataset of
15
+ ``INTRADAY`` by ``factor_agent.session.SessionColumns``, as the Evaluator
16
+ computes it.
17
+ """
18
+
19
+ from collections.abc import Mapping
20
+
21
+ from KunQuant.Op import OpBase, Output, Rank
22
+ from KunQuant.ops import AddConst, MulConst
23
+ from KunQuant.Stage import Function
24
+ from quantlab.core.component import rebuild
25
+
26
+ from factor_agent import derived, period_lag
27
+ from factor_agent.gate import stages
28
+ from factor_agent.gate.graph import Graph
29
+ from factor_agent.gate.vocabulary import Vocabulary, resolve_classes
30
+ from factor_agent.session import SessionColumns
31
+
32
+
33
+ def ranked_function(
34
+ graphs: Mapping[str, Graph], presence: Mapping[str, str] | None = None, directions: Mapping[str, int] | None = None,
35
+ ) -> Function:
36
+ """One KunQuant function with an output per graph, named after it, ranked across the market.
37
+
38
+ A graph whose ``directions`` entry is -1 (a higher value went with a lower
39
+ return) is output as one minus its rank, so every output reads the same
40
+ way: a higher value, a higher return.
41
+
42
+ KunQuant's ``Rank`` leaves a missing value missing and ranks the finite
43
+ values among themselves. ``presence`` maps a graph's name to the name of
44
+ a second output, 0 where the graph itself has a value and NaN where it
45
+ has none, read raw (never neutralised, so a symbol without exposures
46
+ still counts) and before any fill: it is computed from
47
+ ``Graph.without_fills``, so a missing value a ``SetInfOrNanToValue``
48
+ fills stays missing in it.
49
+ """
50
+ inputs: dict[str, OpBase] = {}
51
+ ops: list[OpBase] = []
52
+ for name, graph in graphs.items():
53
+ output, new = graph.build(inputs)
54
+ ranked = Rank(output)
55
+ ops += [*new, ranked]
56
+ if directions and directions.get(name) == -1:
57
+ flipped = MulConst(ranked, -1.0)
58
+ ranked = AddConst(flipped, 1.0)
59
+ ops += [flipped, ranked]
60
+ ops.append(Output(ranked, name))
61
+ if presence and name in presence:
62
+ unfilled = graph.without_fills()
63
+ if unfilled is not graph:
64
+ output, new = unfilled.build(inputs)
65
+ ops += new
66
+ zero = MulConst(output, 0.0)
67
+ ops += [zero, Output(zero, presence[name])]
68
+ return Function(ops)
69
+
70
+
71
+ class GraphFactor(derived.DerivedColumnsFactor):
72
+ """Recorded graphs, each ranked across the market, as one factor.
73
+
74
+ The graphs, name to ``Graph.to_json()``, are the class's ``GRAPHS`` (an
75
+ exported factor) or, when it has none, ``config.kwargs["graphs"]``; the
76
+ class path of each operator they record, ``CLASSES`` or
77
+ ``config.kwargs["classes"]`` (``Vocabulary.class_paths``); how each lag
78
+ column they read is computed, ``PERIOD_LAGS`` or
79
+ ``config.kwargs["period_lags"]`` (``period_lags``), none when neither is
80
+ set; each output's direction, ``DIRECTIONS`` or
81
+ ``config.kwargs["directions"]`` (+1 when unset), turns it so a higher
82
+ value means a higher return. A graph of an intraday family is its daily
83
+ stage (``staged``): the intraday graph of each session column it reads
84
+ is in ``SESSIONS`` or ``config.kwargs["sessions"]``, and the intraday
85
+ dataset they run on, as a ``get_config()`` dict, in ``INTRADAY`` or
86
+ ``config.kwargs["intraday"]``. ``config.data_columns`` must hold every
87
+ dataset column the graphs and their lags read (``data_columns``).
88
+ """
89
+
90
+ #: Output name -> ``Graph.to_json()``; set by an exported subclass.
91
+ GRAPHS: dict = {}
92
+ #: Operator name -> class path, of every operator ``GRAPHS`` records; set by an exported subclass.
93
+ CLASSES: dict = {}
94
+ #: Lag column -> how it is computed, of every lag column ``GRAPHS`` reads; set by an exported subclass.
95
+ PERIOD_LAGS: dict = {}
96
+ #: Output name -> its direction, +1 or -1; set by an exported subclass.
97
+ DIRECTIONS: dict = {}
98
+ #: Session column -> its intraday graph, of every session column ``GRAPHS`` reads; set by an exported subclass.
99
+ SESSIONS: dict = {}
100
+ #: The intraday dataset ``SESSIONS`` runs on, as a ``get_config()`` dict; set by an exported subclass.
101
+ INTRADAY: dict = {}
102
+
103
+ def _recorded(self) -> dict:
104
+ return type(self).GRAPHS or dict((self.config.kwargs or {})["graphs"])
105
+
106
+ def _class_paths(self) -> dict:
107
+ return type(self).CLASSES or dict((self.config.kwargs or {})["classes"])
108
+
109
+ def _classes(self) -> dict:
110
+ return resolve_classes(self._class_paths())
111
+
112
+ def _period_lags(self) -> dict:
113
+ return type(self).PERIOD_LAGS or dict((self.config.kwargs or {}).get("period_lags") or {})
114
+
115
+ def _sessions(self) -> dict:
116
+ return type(self).SESSIONS or dict((self.config.kwargs or {}).get("sessions") or {})
117
+
118
+ def _providers(self) -> list[derived.Provider]:
119
+ providers = period_lag.providers(self._period_lags())
120
+ sessions = self._sessions()
121
+ if sessions:
122
+ intraday = type(self).INTRADAY or (self.config.kwargs or {})["intraday"]
123
+ providers.append(SessionColumns(rebuild(intraday), sessions, self._class_paths(), self.config.njobs))
124
+ return providers
125
+
126
+ def _directions(self) -> dict:
127
+ return type(self).DIRECTIONS or dict((self.config.kwargs or {}).get("directions") or {})
128
+
129
+ def _get_factor_names(self) -> tuple[str, ...]:
130
+ return tuple(self._recorded())
131
+
132
+ def _get_factor_func(self) -> Function:
133
+ classes = self._classes()
134
+ graphs = {name: Graph.from_json(graph, classes) for name, graph in self._recorded().items()}
135
+ return ranked_function(graphs, directions=self._directions())
136
+
137
+ @staticmethod
138
+ def data_columns(
139
+ graphs: Mapping[str, list], period_lags: Mapping[str, Mapping] | None = None, sessions: Mapping[str, list] | None = None,
140
+ ) -> tuple[str, ...]:
141
+ """The daily dataset columns the recorded graphs read, with the columns and keys of their ``period_lags``, sorted.
142
+
143
+ The session columns of ``sessions`` are filled from the intraday dataset, not read.
144
+ """
145
+ return tuple(sorted(set(derived.data_columns(_inputs(graphs), period_lag.providers(period_lags or {})))
146
+ - set(sessions or {})))
147
+
148
+ @staticmethod
149
+ def staged(graphs: Mapping[str, list], vocabulary: Vocabulary) -> tuple[dict, dict]:
150
+ """Recorded graphs in their stages: each one's daily graph, by name, and every session column's intraday graph.
151
+
152
+ A graph of no intraday family is its own daily graph and reads no
153
+ session column. Both are ``Graph.to_json()``.
154
+
155
+ Raises
156
+ ------
157
+ ValueError
158
+ If a graph breaks a stage rule (``stages.split``).
159
+ """
160
+ daily, sessions = {}, {}
161
+ for name, recorded in graphs.items():
162
+ split = stages.split(Graph.from_json(recorded, vocabulary.classes), vocabulary)
163
+ if split.reasons:
164
+ raise ValueError(f"{name}: the graph breaks the stage rules: {'; '.join(split.reasons)}")
165
+ daily[name] = split.daily.to_json()
166
+ sessions |= {column: graph.to_json() for column, graph in split.intraday.items()}
167
+ return daily, sessions
168
+
169
+ @staticmethod
170
+ def period_lags(graphs: Mapping[str, list], lag_columns: Mapping[str, Mapping]) -> dict:
171
+ """The entries of ``lag_columns`` (``Vocabulary.lag_columns``) the recorded graphs read."""
172
+ return period_lag.read(_inputs(graphs), lag_columns)
173
+
174
+
175
+ def _inputs(graphs: Mapping[str, list]) -> set[str]:
176
+ """The names of the ``Input`` nodes of recorded graphs."""
177
+ return {dict(node[1])["name"] for graph in graphs.values() for node in graph if node[0] == "Input"}