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,207 @@
1
+ """What a run is doing now: whether it stopped, its phase, its process, its calls and tokens, the time it has left.
2
+
3
+ A resumed run keeps the stop of its previous open in its state file until
4
+ it stops again. With events, a stop counts only when a stop event follows
5
+ the latest start or resume. Without, it counts only when the state file
6
+ was written after the run's latest open and no LLM call ended after it.
7
+ The process that opened the run last is looked for only on the host it
8
+ ran on; on another host it is unknown, never gone.
9
+ """
10
+
11
+ import socket
12
+ from dataclasses import dataclass
13
+ from datetime import datetime, timedelta
14
+ from pathlib import Path
15
+
16
+ import psutil
17
+
18
+ from factor_agent import events
19
+ from factor_agent.web._files import Replayed, call_files, utc
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Stop:
24
+ """Why a run stopped, as the campaign recorded it: ``by`` is the stop condition, ``reason`` its sentence."""
25
+
26
+ by: str
27
+ reason: str
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Tokens:
32
+ """Tokens of LLM calls as the providers report them; a count no reply reported is ``None``.
33
+
34
+ ``cache_hits`` are input tokens the provider read from its cache, and
35
+ ``reasoning`` output tokens the model spent reasoning.
36
+ """
37
+
38
+ input: int | None
39
+ output: int | None
40
+ cache_hits: int | None
41
+ reasoning: int | None
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class RoundTokens:
46
+ """The tokens of a round's calls."""
47
+
48
+ round: int
49
+ tokens: Tokens
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class Health:
54
+ """What a run is doing now, as its events file and its process tell.
55
+
56
+ ``phase`` is ``"planning"`` (a round is written, or the run was just
57
+ opened, and no call of the next round has been sent), ``"asking"`` (the
58
+ LLM is being asked for the round in progress) or ``"evaluating"`` (the
59
+ Evaluator scores the round's candidates); ``None`` when the run has
60
+ stopped or crashed, or has no events file. ``round`` is the round in
61
+ progress and ``max_rounds`` the round limit, ``None`` when the run has
62
+ stopped or the limit is unknown. ``process`` is ``"running"`` or
63
+ ``"gone"`` when the campaign's process is checked on this host, and
64
+ ``"unknown"`` when the campaign ran on another host or the run has no
65
+ events file; ``crashed`` is true when the process is gone and the run
66
+ recorded no stop. ``last_call`` is when the latest LLM call came back
67
+ (UTC). ``stalled`` is true when the run looks stuck: no call was sent
68
+ or came back for ``STALL_ASKING`` while asking. An evaluation never
69
+ looks stuck: its length follows the data (an intraday round's takes
70
+ hours), so no limit tells a slow one from a stuck one. ``calls`` counts the calls of the round in
71
+ progress by role, since the run was last opened; a run without an
72
+ events file has only these counts, from its call files. ``tokens`` are
73
+ the tokens of every call of the run, a round a resume dropped
74
+ included, and ``round_tokens`` the same by round; ``None`` and empty
75
+ without an events file. ``time_left`` estimates the seconds until the
76
+ round or trial limit stops the run, from the mean length of its last
77
+ ``RECENT_ROUNDS`` rounds; ``None`` while no round was timed, and once
78
+ the run has stopped or crashed.
79
+ """
80
+
81
+ phase: str | None
82
+ round: int | None
83
+ max_rounds: int | None
84
+ process: str
85
+ crashed: bool
86
+ last_call: datetime | None
87
+ stalled: bool
88
+ calls: dict[str, int]
89
+ time_left: float | None
90
+ tokens: Tokens | None
91
+ round_tokens: tuple[RoundTokens, ...]
92
+
93
+
94
+ #: The events of each phase of a round: the first a phase starts with, the later ones it goes on with.
95
+ _PHASES = {
96
+ events.Start: "planning", events.Resume: "planning", events.RoundEnd: "planning",
97
+ events.CallStart: "asking", events.CallEnd: "asking", events.Plan: "asking", events.GateOutcome: "asking",
98
+ events.ReviewVerdict: "asking",
99
+ events.EvaluationStart: "evaluating", events.AdmissionOutcome: "evaluating",
100
+ }
101
+
102
+ #: How long a run asking the LLM may go without a call sent or back before it looks stuck.
103
+ STALL_ASKING = timedelta(minutes=15)
104
+
105
+ #: How many of the latest timed rounds the time left is estimated from.
106
+ RECENT_ROUNDS = 3
107
+
108
+
109
+ def stop(run_dir: Path, state: dict, opened_at: str | None, replayed: Replayed) -> Stop | None:
110
+ """The state's stop, unless it was recorded before the latest open (see the module's notes)."""
111
+ if not state.get("stop"):
112
+ return None
113
+ if replayed.opened is not None:
114
+ if not any(isinstance(event, events.Stopped) for event in replayed.current):
115
+ return None
116
+ return Stop(by=state["stop"]["by"], reason=state["stop"]["reason"])
117
+ written = (run_dir / "state.json").stat().st_mtime_ns
118
+ if opened_at and written < datetime.fromisoformat(opened_at).timestamp() * 1e9:
119
+ return None
120
+ if any(p.stat().st_mtime_ns > written for p in call_files(run_dir)):
121
+ return None
122
+ return Stop(by=state["stop"]["by"], reason=state["stop"]["reason"])
123
+
124
+
125
+ def health(
126
+ run_dir: Path, state: dict, rounds: int, trials: int, stopped: bool, replayed: Replayed, now: datetime,
127
+ ) -> Health:
128
+ """The health of a run with ``rounds`` rounds and ``trials`` trials written, ``stopped`` or not, at ``now``."""
129
+ opened = replayed.opened
130
+ process = process_state(opened)
131
+ crashed = not stopped and process == "gone"
132
+ phase = None
133
+ if opened is not None and not stopped and not crashed:
134
+ phase = _PHASES.get(type(replayed.current[-1]))
135
+ running = None if stopped else rounds + 1
136
+ ends = [e for e in replayed.all if isinstance(e, events.CallEnd) and e.error is None]
137
+ if opened is None:
138
+ files = sorted(call_files(run_dir))
139
+ last_call = utc(max(p.stat().st_mtime for p in files)) if files else None
140
+ roles = [] if running is None else [p.stem.partition("-")[2] for p in files[state.get("calls", 0):]]
141
+ else:
142
+ last_call = ends[-1].time if ends else None
143
+ roles = [e.role for e in replayed.current if isinstance(e, events.CallEnd) and e.file and e.round == running]
144
+ by_round = {}
145
+ for end in ends:
146
+ by_round.setdefault(end.round, []).append(end)
147
+ return Health(
148
+ phase=phase, round=running, max_rounds=opened.max_rounds if opened else None, process=process,
149
+ crashed=crashed, last_call=last_call, stalled=_stalled(phase, replayed, now),
150
+ calls={role: roles.count(role) for role in dict.fromkeys(roles)},
151
+ time_left=None if running is None or crashed else _time_left(rounds, trials, replayed, now),
152
+ tokens=_tokens(ends) if opened else None,
153
+ round_tokens=tuple(RoundTokens(number, _tokens(calls)) for number, calls in sorted(by_round.items())),
154
+ )
155
+
156
+
157
+ def _time_left(rounds: int, trials: int, replayed: Replayed, now: datetime) -> float | None:
158
+ """The seconds the rounds the limits still allow would take at the recent rounds' pace, the round begun included.
159
+
160
+ A round is timed from the end of the round before, or the opening of
161
+ the run when it was the first after it, to its own end.
162
+ """
163
+ opened = replayed.opened
164
+ if opened is None:
165
+ return None
166
+ lengths, since = [], None
167
+ for event in replayed.all:
168
+ if isinstance(event, (events.Start, events.RoundEnd)):
169
+ if isinstance(event, events.RoundEnd) and since is not None:
170
+ lengths.append((event.time - since).total_seconds())
171
+ since = event.time
172
+ if not lengths:
173
+ return None
174
+ left = min(opened.max_rounds - rounds, (opened.max_trials - trials) // max(opened.candidates, 1))
175
+ pace = sum(lengths[-RECENT_ROUNDS:]) / len(lengths[-RECENT_ROUNDS:])
176
+ return max(0.0, pace * left - (now - since).total_seconds())
177
+
178
+
179
+ def _tokens(ends: list[events.CallEnd]) -> Tokens:
180
+ """The tokens of the calls that ``ends`` end, each count ``None`` when no call reported it."""
181
+ def total(key):
182
+ counts = [getattr(end, key) for end in ends if getattr(end, key) is not None]
183
+ return sum(counts) if counts else None
184
+ return Tokens(total("input_tokens"), total("output_tokens"), total("cache_hits"), total("reasoning"))
185
+
186
+
187
+ def _stalled(phase: str | None, replayed: Replayed, now: datetime) -> bool:
188
+ """Whether a run asking has had no call sent or back for ``STALL_ASKING``."""
189
+ if phase != "asking":
190
+ return False
191
+ # The opening counts as the first sign of life, a failed call's end as one too.
192
+ activity = [e.time for e in replayed.current if isinstance(e, (events.Start, events.CallStart, events.CallEnd))]
193
+ return now - activity[-1] > STALL_ASKING
194
+
195
+
196
+ def process_state(opened: events.Start | None) -> str:
197
+ """Whether the process that opened the run last still runs, when it ran on this host."""
198
+ if opened is None or opened.host != socket.gethostname():
199
+ return "unknown"
200
+ try:
201
+ started = psutil.Process(opened.pid).create_time()
202
+ except psutil.NoSuchProcess:
203
+ return "gone"
204
+ except psutil.Error:
205
+ return "unknown"
206
+ # A process started after the run was opened has only been given the same id.
207
+ return "running" if started <= opened.time.timestamp() + 1 else "gone"
@@ -0,0 +1,27 @@
1
+ """A trial's daily IC, as the campaign keeps it in ``ic/round_NN.json`` for each evaluated trial."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from factor_agent.web._files import read_json
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class TrialIC:
11
+ """An evaluated trial's daily rank IC and partial IC on each day of the label, ``None`` where unscored.
12
+
13
+ The values are raw, as the Evaluator computed them; the page turns them to the factor's direction.
14
+ """
15
+
16
+ days: tuple[str, ...]
17
+ ic: tuple[float | None, ...]
18
+ partial_ic: tuple[float | None, ...]
19
+
20
+
21
+ def read_ic(run_dir: str | Path, round: int, slot: int) -> TrialIC | None:
22
+ """The daily IC of trial ``slot`` (from 0) of ``round``; ``None`` when it was not evaluated or the run kept none."""
23
+ kept = read_json(Path(run_dir) / "ic" / f"round_{round:02d}.json")
24
+ series = (kept or {}).get("slots", {}).get(str(slot))
25
+ if series is None:
26
+ return None
27
+ return TrialIC(tuple(kept["days"]), tuple(series["ic"]), tuple(series["partial_ic"]))
@@ -0,0 +1,293 @@
1
+ """The trials of a run's written rounds, the collisions their refusals name, and the limits admission judged them by."""
2
+
3
+ import json
4
+ import re
5
+ from dataclasses import dataclass
6
+ from functools import lru_cache
7
+ from pathlib import Path
8
+
9
+ import yaml
10
+
11
+ from factor_agent import events
12
+ from factor_agent.web._files import Replayed, loads, read_json
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Collision:
17
+ """A trial's refusal for being too like an existing factor: a reference, known or accepted one.
18
+
19
+ ``label`` names the factor as the Gate and the Evaluator do; ``name`` is
20
+ how a page names it, as ``Neighbour.name``. ``kind`` is ``"code"`` (the
21
+ Gate found a rewrite, the same graph with other parameters, or a too
22
+ large shared subtree) or ``"ranking"`` (admission found a correlation
23
+ above the limit). ``accepted_round`` is the round that accepted the
24
+ factor, ``None`` for one of the user's own factors; ``known`` is true
25
+ for a known factor, false for a reference or accepted one.
26
+ """
27
+
28
+ label: str
29
+ name: str
30
+ kind: str
31
+ accepted_round: int | None
32
+ known: bool
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Trial:
37
+ """One trial of a written round, as the ledger holds it, with NaN read as ``None``.
38
+
39
+ ``slot`` is the trial's place in its round, from 0, in the order of the
40
+ round's research directions. ``status``, ``stage``, ``reasons``,
41
+ ``candidate``, ``direction``, ``attempts``, ``stats`` and ``metrics`` are
42
+ the trial record's (``factor_agent.ledger.TrialRecord``); ``metrics`` is
43
+ ``None`` for a trial the Evaluator never scored. ``collisions`` holds
44
+ every collision of the trial's versions, each factor and kind once.
45
+ ``intraday`` is true when the graph the Gate built holds a session
46
+ aggregation, false for a daily graph and a trial the Gate built none for.
47
+ """
48
+
49
+ round: int
50
+ slot: int
51
+ status: str
52
+ stage: str
53
+ reasons: tuple[str, ...]
54
+ candidate: dict
55
+ direction: dict | None
56
+ attempts: tuple[dict, ...]
57
+ stats: dict | None
58
+ metrics: dict | None
59
+ collisions: tuple[Collision, ...] = ()
60
+ intraday: bool = False
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class Neighbour:
65
+ """The factor closest to an accepted one in ranking, and their correlation.
66
+
67
+ ``label`` names it as the Evaluator does. ``name`` is how a page names
68
+ it: a factor accepted earlier in the run as ``"round N: "`` and the first
69
+ words of its description, any other by its label. ``accepted_round`` is
70
+ N for a factor accepted in the run, ``None`` for a reference or known
71
+ factor. ``correlation`` is the raw cross-sectional correlation, which
72
+ admission compares in absolute value with its limit.
73
+ """
74
+
75
+ label: str
76
+ name: str
77
+ accepted_round: int | None
78
+ correlation: float | None
79
+
80
+
81
+ def _records(ledger: Path, rounds: int):
82
+ """The ledger's records of the first ``rounds`` rounds; later rounds and a line a crash cut short are skipped."""
83
+ try:
84
+ lines = ledger.read_text().splitlines()
85
+ except FileNotFoundError:
86
+ return
87
+ for line in lines:
88
+ try:
89
+ record = loads(line)
90
+ except json.JSONDecodeError:
91
+ continue
92
+ if record["round"] <= rounds:
93
+ yield record
94
+
95
+
96
+ def statuses(ledger: Path, rounds: int) -> list[str]:
97
+ """The status of every trial of the first ``rounds`` rounds, in ledger order: what a run's summary counts."""
98
+ return [record["status"] for record in _records(ledger, rounds)]
99
+
100
+
101
+ def read_trials(ledger: Path, rounds: int, aggregations: frozenset[str] = frozenset()) -> tuple[Trial, ...]:
102
+ """The trials of the first ``rounds`` rounds, each given its slot in its round.
103
+
104
+ ``aggregations`` are the names the run's graphs record its session
105
+ aggregations under (``aggregations``): a trial whose graph holds one is intraday.
106
+ """
107
+ trials, slots = [], {}
108
+ for record in _records(ledger, rounds):
109
+ slot = slots[record["round"]] = slots.get(record["round"], -1) + 1
110
+ trials.append(Trial(
111
+ round=record["round"], slot=slot, status=record["status"], stage=record["stage"],
112
+ reasons=tuple(record["reasons"]), candidate=record["candidate"], direction=record.get("direction"),
113
+ attempts=tuple(record.get("attempts") or ()), stats=record.get("stats"), metrics=record.get("metrics"),
114
+ collisions=_trial_collisions(record),
115
+ intraday=any(node[0] in aggregations for node in record.get("graph") or ()),
116
+ ))
117
+ return tuple(trials)
118
+
119
+
120
+ def aggregations(run_dir: Path) -> frozenset[str]:
121
+ """The names the run's operator file registers its session aggregations under; none for a daily run.
122
+
123
+ Only a run whose snapshot records an intraday dataset has an intraday
124
+ family, and only it may use an aggregation. An operator is one when its
125
+ class, from the modules an aggregation may come from, is a
126
+ ``SessionAggregation``; a class of another module is not imported.
127
+ """
128
+ manifest = read_json(run_dir / "snapshot.json") or {}
129
+ if "intraday" not in (manifest.get("settings") or {}):
130
+ return frozenset()
131
+ operators = next((name for name in manifest.get("files") or {} if name.startswith("operators/")), None)
132
+ try:
133
+ return _aggregations((run_dir / "snapshot" / operators).read_text()) if operators else frozenset()
134
+ except OSError:
135
+ return frozenset()
136
+
137
+
138
+ @lru_cache(maxsize=16)
139
+ def _aggregations(text: str) -> frozenset[str]:
140
+ """The aggregations of the operator file ``text``, by their names; see ``aggregations``."""
141
+ # Imported here, so a daily run's view never imports the operator classes.
142
+ from factor_agent.aggregations import SessionAggregation
143
+ from factor_agent.gate.vocabulary import FRAMEWORK_MODULES, resolve
144
+
145
+ try:
146
+ entries = (yaml.safe_load(text) or {}).get("operators") or []
147
+ except (yaml.YAMLError, AttributeError):
148
+ return frozenset()
149
+ found = set()
150
+ for entry in entries:
151
+ path = entry.get("class") if isinstance(entry, dict) else None
152
+ if not isinstance(path, str) or not any(path.startswith(root + ".") for root in FRAMEWORK_MODULES):
153
+ continue
154
+ try:
155
+ cls = resolve(path, FRAMEWORK_MODULES)
156
+ except (ImportError, AttributeError):
157
+ continue
158
+ if isinstance(cls, type) and issubclass(cls, SessionAggregation):
159
+ found.add(entry.get("name"))
160
+ return frozenset(found)
161
+
162
+
163
+ #: The partial-IC t threshold an admission refusal quotes.
164
+ THRESHOLD = re.compile(r"is below the threshold ([\d.]+) at \d+ evaluated trial")
165
+
166
+ #: The correlation limit an admission refusal quotes.
167
+ _LIMIT = re.compile(r"^cross-sectional correlation .* is above ([\d.]+) in absolute value$", re.DOTALL)
168
+
169
+
170
+ def correlation_limit(trials: tuple[Trial, ...], replayed: Replayed) -> float | None:
171
+ """The correlation limit the run was last opened with, else the latest refusal quoting it, else ``None``."""
172
+ if replayed.opened is not None:
173
+ return replayed.opened.max_correlation
174
+ quoted = [found.group(1) for trial in trials for reason in trial.reasons if (found := _LIMIT.match(reason))]
175
+ return float(quoted[-1]) if quoted else None
176
+
177
+
178
+ def thresholds(trials: tuple[Trial, ...], replayed: Replayed) -> dict[int, tuple[float | None, str | None]]:
179
+ """Each round's threshold and where it comes from: ``"admission"``, ``"refusals"`` or ``"carried_over"``.
180
+
181
+ A round's admission events give it; without them its refusals quote it;
182
+ when neither does it is the round before's, carried over. The admission
183
+ events of a round a resume dropped come before those of the round run
184
+ again under the same number, so the later ones count. A round before
185
+ any threshold has ``(None, None)``.
186
+ """
187
+ given = {}
188
+ for trial in trials:
189
+ for reason in trial.reasons:
190
+ if found := THRESHOLD.search(reason):
191
+ given[trial.round] = (float(found.group(1)), "refusals")
192
+ for event in replayed.all:
193
+ if isinstance(event, events.AdmissionOutcome) and event.threshold is not None:
194
+ given[event.round] = (event.threshold, "admission")
195
+ found, last = {}, None
196
+ for number in sorted({trial.round for trial in trials}):
197
+ if number in given:
198
+ found[number] = last = given[number]
199
+ else:
200
+ found[number] = (None, None) if last is None else (last[0], "carried_over")
201
+ return found
202
+
203
+
204
+ #: An accepted factor's label: its description and the round that accepted it.
205
+ _ACCEPTED = re.compile(r"^(.*) \(accepted in round (\d+)\)$", re.DOTALL)
206
+
207
+ #: How many words of an earlier accepted factor's description name it.
208
+ _FIRST_WORDS = 6
209
+
210
+
211
+ def neighbour(label: str | None, correlation: float | None) -> Neighbour | None:
212
+ """The nearest factor the Evaluator recorded, ``None`` when there was none to compare with."""
213
+ if not label:
214
+ return None
215
+ if accepted := _ACCEPTED.match(label):
216
+ words = accepted.group(1).split()
217
+ first = " ".join(words[:_FIRST_WORDS]) + ("…" if len(words) > _FIRST_WORDS else "")
218
+ number = int(accepted.group(2))
219
+ return Neighbour(label=label, name=f"round {number}: {first}", accepted_round=number, correlation=correlation)
220
+ return Neighbour(label=label, name=label, accepted_round=None, correlation=correlation)
221
+
222
+
223
+ #: The refusals that name the factors a candidate collided with, by kind; group 1 holds the names.
224
+ _COLLISIONS = (
225
+ ("code", re.compile(r"^the factor rewrites (.+?(?:\)| more)): ", re.DOTALL)),
226
+ ("code", re.compile(r"^the factor is (.+?) with other windows or parameter values only; ", re.DOTALL)),
227
+ ("code", re.compile(r"^the factor shares a \d+-node subtree with (.+?), more than the limit of \d+: ", re.DOTALL)),
228
+ ("ranking", re.compile(
229
+ r"^cross-sectional correlation [-+]?[\d.]+ with (.+?)(?:, sampled on [^,]*,)? is above [\d.]+ in absolute value$",
230
+ re.DOTALL)),
231
+ )
232
+
233
+ #: One label in the Gate's list of them: a reference, known or accepted factor's label ends in its parentheses.
234
+ _LABEL = re.compile(r"(.+?) \((accepted in round \d+|[\w.]+(?:, a known factor)?)\)(?:, |(?: and \d+ more)?$)", re.DOTALL)
235
+
236
+
237
+ def _collisions(reason: str) -> list[Collision]:
238
+ """The collisions a refusal names, none for any other refusal."""
239
+ for kind, pattern in _COLLISIONS:
240
+ if found := pattern.match(reason):
241
+ names = found.group(1)
242
+ labels = [names] if kind == "ranking" else [f"{m[1]} ({m[2]})" for m in _LABEL.finditer(names)]
243
+ return [_collision(label, kind) for label in labels]
244
+ return []
245
+
246
+
247
+ def _collision(label: str, kind: str) -> Collision:
248
+ """The collision of ``kind`` with the factor ``label`` names."""
249
+ named = neighbour(label, None)
250
+ return Collision(label=label, name=named.name, kind=kind, accepted_round=named.accepted_round,
251
+ known=label.endswith(", a known factor)"))
252
+
253
+
254
+ def _trial_collisions(record: dict) -> tuple[Collision, ...]:
255
+ """Every collision of a trial's versions, each factor and kind once."""
256
+ reasons = [r for attempt in record.get("attempts") or () for r in attempt.get("reasons") or ()]
257
+ found = {}
258
+ for reason in reasons + list(record["reasons"]):
259
+ for collision in _collisions(reason):
260
+ found.setdefault((collision.label, collision.kind), collision)
261
+ return tuple(found.values())
262
+
263
+
264
+ def in_direction(metrics: dict) -> tuple[int, dict[str, float | None]]:
265
+ """A scored trial's direction, +1 or -1 by the sign of its training IC, and its ICs and t in that direction."""
266
+ sign = 1 if (metrics.get("ic_mean") or 0) > 0 else -1
267
+ return sign, {key: None if metrics.get(key) is None else sign * metrics[key]
268
+ for key in ("ic_mean", "ic_t", "partial_ic_mean", "partial_ic_t")}
269
+
270
+
271
+ def _ranks(values: list[float]) -> list[float]:
272
+ """Each value's rank from 1, ties given their mean rank."""
273
+ order = sorted(range(len(values)), key=values.__getitem__)
274
+ ranks = [0.0] * len(values)
275
+ start = 0
276
+ while start < len(order):
277
+ end = start
278
+ while end + 1 < len(order) and values[order[end + 1]] == values[order[start]]:
279
+ end += 1
280
+ for i in order[start:end + 1]:
281
+ ranks[i] = (start + end) / 2 + 1
282
+ start = end + 1
283
+ return ranks
284
+
285
+
286
+ def monotonicity(deciles: tuple[float | None, ...], sign: int) -> float | None:
287
+ """The Spearman correlation of the decile number with the decile returns, signed by ``sign``, as admission's."""
288
+ if len(deciles) < 2 or any(r is None for r in deciles) or len(set(deciles)) == 1:
289
+ return None
290
+ ranks, n = _ranks(list(deciles)), len(deciles)
291
+ mean = (n + 1) / 2
292
+ spread = sum((r - mean) ** 2 for r in ranks) * sum((i + 1 - mean) ** 2 for i in range(n))
293
+ return sign * sum((r - mean) * (i + 1 - mean) for i, r in enumerate(ranks)) / spread ** 0.5
@@ -0,0 +1,42 @@
1
+ """The memory's versions, as the distiller wrote them in ``memory/round_NN.md``, each compared with the one before."""
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from factor_agent.web._files import LineChange, line_changes
8
+
9
+
10
+ #: One line of a memory version compared with the version before it; see ``LineChange``.
11
+ MemoryLine = LineChange
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class MemoryVersion:
16
+ """One version of the memory the distiller wrote.
17
+
18
+ ``round`` is the round after which it was written. ``changes`` holds the
19
+ lines of this version and of the one before, in order, each marked kept,
20
+ added or removed; it is ``None`` for the run's first version.
21
+ """
22
+
23
+ round: int
24
+ text: str
25
+ changes: tuple[MemoryLine, ...] | None
26
+
27
+
28
+ def memory(folder: Path) -> tuple[MemoryVersion, ...]:
29
+ """The memory's versions, the earliest first, each but the first compared with the one before.
30
+
31
+ ``round_NN.md`` is written before round ``NN``, so after round ``NN - 1``.
32
+ """
33
+ files = {}
34
+ for path in folder.glob("round_*.md"):
35
+ if match := re.fullmatch(r"round_(\d+)\.md", path.name):
36
+ files[int(match[1]) - 1] = path.read_text().rstrip("\n")
37
+ versions, before = [], None
38
+ for number, text in sorted(files.items()):
39
+ versions.append(MemoryVersion(round=number, text=text, changes=None if before is None else line_changes(before, text)))
40
+ before = text
41
+ return tuple(versions)
42
+
@@ -0,0 +1,77 @@
1
+ """The results page's accepted factors, each with the facts that say how far to trust it."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from factor_agent.web._files import Replayed
6
+ from factor_agent.web._ledger import Neighbour, Trial, in_direction, monotonicity, neighbour, thresholds
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class Factor:
11
+ """An accepted factor, written up with the facts that say how far to trust it.
12
+
13
+ ``label`` is the name the Evaluator and Acceptance give it,
14
+ ``"<description> (accepted in round N)"``. ``round``, ``slot`` and
15
+ ``mechanism_type`` place it in the run; ``hypothesis``, ``description``
16
+ and ``code`` are its candidate's. ``factor_direction`` is +1 when a
17
+ higher value went with a higher training return, -1 when with a lower
18
+ one. ``ic_mean``, ``ic_t``, ``partial_ic_mean`` and ``partial_ic_t`` are
19
+ the training IC and the partial IC and their Newey-West t, in the
20
+ factor's direction, as admission judged them; ``metrics`` holds every
21
+ statistic as the ledger records it, the raw values among them.
22
+ ``threshold`` is its round's t threshold and ``threshold_source`` where
23
+ it comes from: ``"admission"`` (the admission events of its round),
24
+ ``"refusals"`` (what its round's refusals quote, for a run without
25
+ events) or ``"carried_over"`` (neither gives it, so it is an earlier
26
+ round's); both are ``None`` while nothing gives a threshold.
27
+ ``decile_returns`` and ``coverage`` are the Evaluator's. ``intraday``
28
+ is its trial's (``Trial.intraday``): its graph holds a session aggregation.
29
+ """
30
+
31
+ label: str
32
+ round: int
33
+ slot: int
34
+ mechanism_type: str | None
35
+ hypothesis: str
36
+ description: str
37
+ code: str
38
+ factor_direction: int
39
+ ic_mean: float | None
40
+ ic_t: float | None
41
+ partial_ic_mean: float | None
42
+ partial_ic_t: float | None
43
+ threshold: float | None
44
+ threshold_source: str | None
45
+ monotonicity: float | None
46
+ nearest: Neighbour | None
47
+ nearest_known: Neighbour | None
48
+ decile_returns: tuple[float | None, ...]
49
+ coverage: float | None
50
+ metrics: dict
51
+ intraday: bool = False
52
+
53
+
54
+ def factors(trials: tuple[Trial, ...], replayed: Replayed) -> tuple[Factor, ...]:
55
+ """The accepted factors, in the order they were accepted."""
56
+ by_round = thresholds(trials, replayed)
57
+ found = []
58
+ for trial in trials:
59
+ if trial.status != "accepted":
60
+ continue
61
+ metrics, candidate = trial.metrics, trial.candidate
62
+ sign, oriented = in_direction(metrics)
63
+ deciles = tuple(metrics.get("decile_returns") or ())
64
+ threshold, source = by_round[trial.round]
65
+ found.append(Factor(
66
+ label=f"{candidate['description']} (accepted in round {trial.round})",
67
+ round=trial.round, slot=trial.slot, mechanism_type=(trial.direction or {}).get("mechanism_type"),
68
+ hypothesis=candidate.get("hypothesis", ""), description=candidate["description"], code=candidate["code"],
69
+ factor_direction=sign, ic_mean=oriented["ic_mean"], ic_t=oriented["ic_t"],
70
+ partial_ic_mean=oriented["partial_ic_mean"], partial_ic_t=oriented["partial_ic_t"],
71
+ threshold=threshold, threshold_source=source, monotonicity=monotonicity(deciles, sign),
72
+ nearest=neighbour(metrics.get("nearest"), metrics.get("nearest_correlation")),
73
+ nearest_known=neighbour(metrics.get("nearest_known"), metrics.get("nearest_known_correlation")),
74
+ decile_returns=deciles, coverage=metrics.get("coverage"), metrics=metrics,
75
+ intraday=trial.intraday,
76
+ ))
77
+ return tuple(found)