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,359 @@
1
+ """The Gate: deterministic checks that every candidate passes before it is evaluated.
2
+
3
+ ``Gate.check`` never reads data; what candidates may write with is its
4
+ ``Vocabulary``, from the campaign's field and operator files. A bad candidate comes back ``Rejected``
5
+ with readable reasons; only an infrastructure failure raises. The checks
6
+ run in this order:
7
+
8
+ 1. static checks on the code (syntax allow-list, names in the vocabulary, ``alpha`` assigned);
9
+ 2. the graph build in a sandboxed subprocess (timeout from when the sandbox is
10
+ ready, memory limit, bubblewrap filesystem isolation where it is installed);
11
+ 3. the stage rules of an intraday family's candidate (``stages.split``):
12
+ an intraday field reaches the daily stage only through a session
13
+ aggregation, the intraday stage reads no daily field, no aggregation of
14
+ a daily value or of an aggregation, each operator is used only in its
15
+ stages, each window and lag is one of its stage's set; each refusal
16
+ says how to write it instead (``Rejected.hints``);
17
+ 4. the graph's size, parameter values, dimensions and complexity, all reported together;
18
+ 5. originality against the zoo: not a rewrite of a zoo factor, not one with
19
+ other windows or parameters only, and no common subtree with one larger
20
+ than the limit. The reasons name the zoo factor and quote the subtree;
21
+ 6. not a duplicate of a candidate this gate passed before: the same canonical
22
+ graph up to what keeps or reverses its ranking (``Graph.ranking``): a
23
+ factor and its negation predict alike, in opposite directions.
24
+ """
25
+
26
+ import ctypes
27
+ import ctypes.util
28
+ import importlib
29
+ import json
30
+ import os
31
+ import re
32
+ import select
33
+ import shutil
34
+ import subprocess
35
+ import sys
36
+ import tempfile
37
+ import time
38
+ from dataclasses import dataclass, field
39
+ from functools import cache
40
+ from pathlib import Path
41
+ from typing import TYPE_CHECKING
42
+
43
+ import psutil
44
+
45
+ from factor_agent.candidate import Candidate
46
+ from factor_agent.gate import _constraints, _originality, stages
47
+ from factor_agent.gate._checks import static_reasons
48
+ from factor_agent.gate.graph import Graph
49
+
50
+ if TYPE_CHECKING: # the sandbox imports this package; it must not load pandas through the config
51
+ from factor_agent.config import GateConfig
52
+ from factor_agent.gate.vocabulary import Vocabulary
53
+ from factor_agent.zoo import Zoo
54
+
55
+ #: Characters of the subprocess's standard error quoted when it crashes.
56
+ _STDERR_TAIL = 2000
57
+
58
+ #: Seconds between two looks at the sandbox's memory.
59
+ _POLL_SECONDS = 0.02
60
+
61
+ #: The interpreter arguments that run the sandbox subprocess.
62
+ SANDBOX_ARGS = ("-m", "factor_agent.gate._sandbox")
63
+
64
+ #: The line the sandbox writes once it has started, before it runs the candidate's code.
65
+ READY = b"ready\n"
66
+
67
+ #: Seconds the sandbox may take to be ready (its imports and the vocabulary's rebuild); a graph build's time counts from then.
68
+ STARTUP_SECONDS = 60.0
69
+
70
+ #: The reason given when a graph build runs out of memory.
71
+ MEMORY_REASON = "graph build used more than the {memory_mb} MB memory limit"
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class Passed:
76
+ """A candidate whose graph was built: the graph, its fingerprint and its statistics."""
77
+
78
+ graph: Graph
79
+ fingerprint: str
80
+ stats: dict = field(default_factory=dict)
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class Rejected:
85
+ """A candidate refused by the gate, with the reasons sent back to the proposer.
86
+
87
+ ``hints`` say how to write it instead: the vocabulary's fixes for a
88
+ writing error (a static check or the graph build), or those of the
89
+ stage rules it breaks; they go with the reasons into the rewrite
90
+ request only.
91
+ """
92
+
93
+ reasons: tuple[str, ...]
94
+ hints: tuple[str, ...] = ()
95
+
96
+
97
+ class Gate:
98
+ """Check candidates; see the module docstring for the order of the checks.
99
+
100
+ A gate remembers the ranking of every candidate it passed and rejects
101
+ a later candidate that only keeps or reverses one.
102
+
103
+ Parameters
104
+ ----------
105
+ config : GateConfig
106
+ The sandbox limits and the graph constraints.
107
+ vocabulary : Vocabulary
108
+ The fields, operators and parameter values candidates may use;
109
+ the sandbox rebuilds the same one from its sources.
110
+ seen : iterable of Graph, optional
111
+ The graphs of candidates passed before, such as those of a campaign
112
+ that is resumed; a candidate that keeps or reverses one's ranking
113
+ (``Graph.ranking``) is a duplicate.
114
+ """
115
+
116
+ def __init__(self, config: "GateConfig", vocabulary: "Vocabulary", seen=()):
117
+ self.config = config
118
+ self.vocabulary = vocabulary
119
+ self._seen: set[str] = {graph.ranking() for graph in seen}
120
+
121
+ def check(self, candidate: Candidate, zoo: "Zoo") -> Passed | Rejected:
122
+ """Return ``Passed`` with the candidate's graph, or ``Rejected`` with why not.
123
+
124
+ ``zoo`` holds the factors the candidate must be original against.
125
+ A writing error carries the hints of the vocabulary's fixes it
126
+ matches, a broken stage rule its own (``Rejected.hints``).
127
+
128
+ Raises
129
+ ------
130
+ RuntimeError
131
+ If the sandbox cannot be started or replies with something
132
+ that is not its protocol.
133
+ """
134
+ reasons = static_reasons(candidate.code, self.vocabulary.names)
135
+ if reasons:
136
+ return self._written_badly(reasons)
137
+ reply = self._build(candidate.code)
138
+ if isinstance(reply, Rejected):
139
+ return self._written_badly(reply.reasons)
140
+ graph = Graph.from_json(reply, self.vocabulary.classes)
141
+ staged = stages.split(graph, self.vocabulary)
142
+ if staged.reasons:
143
+ return Rejected(staged.reasons, staged.hints)
144
+ reasons, stats = _constraints.check(graph, self.config, self.vocabulary)
145
+ if reasons:
146
+ return Rejected(tuple(reasons))
147
+ reasons = _originality.reasons(graph, zoo, self.config.max_common_subtree_nodes, self.vocabulary)
148
+ if reasons:
149
+ return Rejected(tuple(reasons))
150
+ ranking = graph.ranking()
151
+ if ranking in self._seen:
152
+ return Rejected((
153
+ "the graph is a duplicate: it keeps or reverses the ranking of an earlier candidate (the same "
154
+ "canonical graph up to a rank, a constant offset or a scale, a sign flip included); "
155
+ "propose a different mechanism",
156
+ ))
157
+ self._seen.add(ranking)
158
+ return Passed(graph, graph.fingerprint(), stats)
159
+
160
+ def withdraw(self, graph: Graph) -> None:
161
+ """Forget that ``graph`` passed, so a rewrite of its candidate is not refused as its duplicate.
162
+
163
+ A candidate the reviewer sends back is rewritten, and its rewrite
164
+ often keeps the graph: the rewrite continues the same candidate, so
165
+ the version it replaces gives up its ranking. A candidate the
166
+ reviewer rejects for good keeps it, as a resume restores it.
167
+ """
168
+ self._seen.discard(graph.ranking())
169
+
170
+ def _written_badly(self, reasons) -> Rejected:
171
+ """A writing error's refusal, with the hints of the fixes its reasons match."""
172
+ return Rejected(tuple(reasons), self.vocabulary.fix_hints(reasons))
173
+
174
+ def _build(self, code: str) -> list | Rejected:
175
+ """Build the graph in the sandbox subprocess; return its nodes or the rejection."""
176
+ request = json.dumps({"code": code, "memory_mb": self.config.memory_mb, "vocabulary": self.vocabulary.sources})
177
+ # The request (the vocabulary's files included) is larger than a pipe holds, so it is read from a file.
178
+ with tempfile.TemporaryDirectory(prefix="factor-agent-gate-") as scratch, tempfile.TemporaryFile("w+") as stdin:
179
+ stdin.write(request)
180
+ stdin.flush()
181
+ stdin.seek(0)
182
+ command = [sys.executable, "-I", *SANDBOX_ARGS]
183
+ if shutil.which("bwrap") and _bubblewrap_works():
184
+ command = _bubblewrap(command, self.vocabulary.sources["operator_modules"])
185
+ try:
186
+ process = subprocess.Popen(
187
+ command, stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
188
+ cwd=scratch, env={"PATH": os.defpath},
189
+ )
190
+ except OSError as exc:
191
+ raise RuntimeError(f"Gate: cannot start the sandbox: {exc}") from exc
192
+ outcome = self._watch(process)
193
+ if isinstance(outcome, Rejected):
194
+ return outcome
195
+ stdout, stderr = outcome
196
+ if process.returncode != 0:
197
+ tail = stderr[-_STDERR_TAIL:].strip()
198
+ if "ModuleNotFoundError" in tail and "factor_agent" in tail:
199
+ raise RuntimeError(f"Gate: the sandbox cannot import factor_agent:\n{tail}")
200
+ if "MemoryError" in tail:
201
+ return Rejected((MEMORY_REASON.format(memory_mb=self.config.memory_mb),))
202
+ return Rejected((f"graph build crashed (exit {process.returncode}): {_last_line(tail)}",))
203
+ try:
204
+ message = json.loads(stdout)
205
+ except json.JSONDecodeError as exc:
206
+ raise RuntimeError(f"Gate: the sandbox replied {stdout[:200]!r}") from exc
207
+ if "reasons" in message:
208
+ return Rejected(tuple(message["reasons"]))
209
+ return message["graph"]
210
+
211
+ def _watch(self, process: subprocess.Popen) -> tuple[str, str] | Rejected:
212
+ """Wait for the reply, killing the sandbox past the time or memory limit.
213
+
214
+ The time limit counts from the sandbox's ``READY``: its start, the
215
+ imports and the vocabulary's rebuild, is not the candidate's.
216
+
217
+ Raises
218
+ ------
219
+ RuntimeError
220
+ If the sandbox is not ready within ``STARTUP_SECONDS``: a broken
221
+ environment, not a bad candidate.
222
+ """
223
+ limit = self.config.memory_mb * 2**20
224
+ watched = psutil.Process(process.pid)
225
+ output = _Output(process)
226
+ deadline, ready = time.monotonic() + STARTUP_SECONDS, False
227
+ while True:
228
+ output.read(_POLL_SECONDS)
229
+ if output.closed:
230
+ process.wait()
231
+ return output.stdout, output.stderr
232
+ if output.ready and not ready:
233
+ deadline, ready = time.monotonic() + self.config.timeout_seconds, True
234
+ try:
235
+ used = sum(_memory(p) for p in [watched, *watched.children(recursive=True)])
236
+ except psutil.Error:
237
+ continue
238
+ if used > limit:
239
+ outcome = Rejected((MEMORY_REASON.format(memory_mb=self.config.memory_mb),))
240
+ elif time.monotonic() > deadline and ready:
241
+ outcome = Rejected((f"graph build timed out after {self.config.timeout_seconds:g} s",))
242
+ elif time.monotonic() > deadline:
243
+ outcome = RuntimeError(f"Gate: the sandbox was not ready within {STARTUP_SECONDS:g} s")
244
+ else:
245
+ continue
246
+ process.kill()
247
+ process.wait()
248
+ if isinstance(outcome, RuntimeError):
249
+ raise outcome
250
+ return outcome
251
+
252
+
253
+ class _Output:
254
+ """The sandbox's standard output and error, read to their end as they come, so neither pipe fills."""
255
+
256
+ def __init__(self, process: subprocess.Popen):
257
+ self._stdout, self._stderr = process.stdout.fileno(), process.stderr.fileno()
258
+ self._open = [self._stdout, self._stderr]
259
+ self._read = {fd: b"" for fd in self._open}
260
+
261
+ def read(self, timeout: float) -> None:
262
+ """Read what the sandbox writes within ``timeout`` seconds."""
263
+ for fd in select.select(self._open, [], [], timeout)[0]:
264
+ chunk = os.read(fd, 65536)
265
+ self._read[fd] += chunk
266
+ if not chunk:
267
+ self._open.remove(fd)
268
+
269
+ @property
270
+ def closed(self) -> bool:
271
+ """Whether the sandbox has closed both pipes: it has exited."""
272
+ return not self._open
273
+
274
+ @property
275
+ def ready(self) -> bool:
276
+ """Whether the sandbox has written ``READY``: it has started."""
277
+ return READY in self._read[self._stdout]
278
+
279
+ @property
280
+ def stdout(self) -> str:
281
+ """The standard output after ``READY``, all of it if the sandbox exited before."""
282
+ before, ready, after = self._read[self._stdout].partition(READY)
283
+ return (after if ready else before).decode()
284
+
285
+ @property
286
+ def stderr(self) -> str:
287
+ """The standard error."""
288
+ return self._read[self._stderr].decode(errors="replace")
289
+
290
+
291
+ def _memory(process: psutil.Process) -> int:
292
+ """Return the bytes ``process`` uses.
293
+
294
+ On macOS this is its physical footprint, which counts the pages the
295
+ memory compressor holds; the resident set alone falls as the compressor works.
296
+ """
297
+ if sys.platform != "darwin":
298
+ return process.memory_info().rss
299
+ usage = (ctypes.c_uint64 * 40)() # struct rusage_info_v2
300
+ if _libproc().proc_pid_rusage(process.pid, 2, ctypes.byref(usage)) != 0:
301
+ raise psutil.NoSuchProcess(process.pid)
302
+ return usage[9] # ri_phys_footprint, after the 16-byte uuid and seven counters
303
+
304
+
305
+ @cache
306
+ def _libproc() -> ctypes.CDLL:
307
+ """macOS's libproc, loaded once."""
308
+ return ctypes.CDLL(ctypes.util.find_library("proc"))
309
+
310
+
311
+ def _last_line(stderr: str) -> str:
312
+ """The last line of a crash's stderr, its exception, with no file path, so a replay elsewhere reads the same."""
313
+ lines = [line for line in stderr.splitlines() if line.strip()]
314
+ return re.sub(r"(?<![\w.])(?:/[\w.-]+){2,}/", "", lines[-1].strip()) if lines else "no output"
315
+
316
+
317
+ @cache
318
+ def _bubblewrap_works() -> bool:
319
+ """Check once that the installed bubblewrap can run the sandbox's imports.
320
+
321
+ Raises
322
+ ------
323
+ RuntimeError
324
+ If it cannot (for example where unprivileged user namespaces are
325
+ disabled): every build would crash, which is a broken sandbox, not
326
+ a bad candidate.
327
+ """
328
+ probe = _bubblewrap([sys.executable, "-I", "-c", "import factor_agent.gate.graph"])
329
+ done = subprocess.run(probe, capture_output=True, text=True, timeout=60, env={"PATH": os.defpath})
330
+ if done.returncode != 0:
331
+ raise RuntimeError(f"Gate: bubblewrap is installed but cannot run the sandbox:\n{done.stderr[-_STDERR_TAIL:]}")
332
+ return True
333
+
334
+
335
+ def _bubblewrap(command: list[str], modules=()) -> list[str]:
336
+ """Wrap ``command`` in bubblewrap.
337
+
338
+ The sandbox gets no network, a private /tmp, and read-only access to the
339
+ system directories, the Python installation and the packages it imports
340
+ (the vocabulary's ``modules`` among them), nothing else. A package
341
+ imported from outside the Python installation (an editable checkout) is
342
+ bound alone, so the rest of its checkout, credentials included, stays
343
+ out of view.
344
+ """
345
+ prefixes = {Path(p).resolve() for p in (sys.prefix, sys.base_prefix, sys.exec_prefix)}
346
+ visible = set(prefixes) | {Path(p) for p in ("/usr", "/lib", "/lib64", "/bin", "/etc/ld.so.cache")}
347
+ for name in dict.fromkeys(["factor_agent", "quantlab", "KunQuant", *(m.split(".")[0] for m in modules)]):
348
+ source = Path(importlib.import_module(name).__file__).resolve()
349
+ package = source.parent if source.name == "__init__.py" else source
350
+ if not any(package.is_relative_to(prefix) for prefix in prefixes):
351
+ visible.add(package)
352
+ binds = []
353
+ for path in sorted(visible):
354
+ binds += ["--ro-bind-try", str(path), str(path)]
355
+ return [
356
+ "bwrap", "--unshare-all", "--die-with-parent", "--new-session",
357
+ *binds, "--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--chdir", "/tmp",
358
+ *command,
359
+ ]
@@ -0,0 +1,331 @@
1
+ """Canonical hashes of a graph's nodes: what the fingerprint and duplicate checks compare.
2
+
3
+ Each node's hash is a Merkle hash of its operator, attributes, inputs and
4
+ enclosing loop, computed after three rewrites:
5
+
6
+ - constant folding: an elementwise operator whose inputs are all constants
7
+ becomes the constant it computes;
8
+ - constant arithmetic in one form: ``x + c``, ``Add(x, ConstantOp(c))`` and
9
+ ``x - (-c)`` are all ``AddConst(x, c)``, ``x / c`` is ``MulConst(x, 1/c)``,
10
+ nested constant offsets and scales merge, and ``x + 0`` and ``x * 1`` are ``x``;
11
+ - commutative ordering: the inputs of a commutative operator are sorted;
12
+ - division guards: ``Select(d > eps, n / d, nan)``, also on ``Abs(d)``, with
13
+ ``d <= eps`` and the NaN first, and with the denominator itself guarded
14
+ (``n / Select(d > eps, d, 1)``, as quantlab's ``FundamentalAlpha`` writes
15
+ its ratios), is ``n / d`` for any positive ``eps`` up to ``GUARD_EPSILON``.
16
+
17
+ A repeated subexpression hashes the same wherever it appears, so the
18
+ fingerprint does not depend on how the code shared intermediate values.
19
+ Multiplying by zero is never folded: ``0 * x`` is NaN where ``x`` is, which
20
+ the NaN-preserving stand-ins rely on.
21
+ """
22
+
23
+ import hashlib
24
+ import json
25
+ import math
26
+ from typing import TYPE_CHECKING
27
+
28
+ if TYPE_CHECKING:
29
+ from factor_agent.gate.graph import Graph
30
+
31
+ #: Operators whose inputs may be reordered.
32
+ COMMUTATIVE = frozenset({
33
+ "Add", "Mul", "Max", "Min", "Equals", "And", "Or", "WindowedCorrelation", "WindowedCovariance", "EWCov",
34
+ "SessionCorr",
35
+ })
36
+
37
+ #: Elementwise operators folded when every input is a constant.
38
+ _FOLD = {
39
+ "Add": lambda a, b: a + b,
40
+ "Sub": lambda a, b: a - b,
41
+ "Mul": lambda a, b: a * b,
42
+ "Div": lambda a, b: a / b,
43
+ "Max": max,
44
+ "Min": min,
45
+ "Pow": lambda a, b: a ** b,
46
+ "Abs": abs,
47
+ "Sign": lambda a: float((a > 0) - (a < 0)),
48
+ "Exp": math.exp,
49
+ "Log": math.log,
50
+ "Sqrt": math.sqrt,
51
+ }
52
+
53
+ #: Significant digits a constant keeps, so 1/3 * 3 and 1 hash alike.
54
+ _DIGITS = 12
55
+
56
+ #: The largest threshold a division guard may test the denominator against.
57
+ GUARD_EPSILON = 1e-6
58
+
59
+
60
+ class _Const:
61
+ """A node that folded to a constant."""
62
+
63
+ def __init__(self, value: float):
64
+ self.value = value
65
+
66
+ def key(self):
67
+ return ["const", "nan" if math.isnan(self.value) else _rounded(self.value)]
68
+
69
+
70
+ class _Node:
71
+ """A node's canonical form: operator, attributes, input terms, loop term."""
72
+
73
+ def __init__(self, op: str, attrs: dict, inputs: list, parent):
74
+ self.op = op
75
+ self.attrs = attrs
76
+ self.inputs = inputs
77
+ self.parent = parent
78
+ self.hash = _sha([op, sorted(attrs.items()), [_key(t) for t in inputs], None if parent is None else _key(parent)])
79
+
80
+ def key(self):
81
+ return self.hash
82
+
83
+
84
+ def _rounded(value: float) -> float:
85
+ return float(f"{value:.{_DIGITS}g}")
86
+
87
+
88
+ def _sha(value) -> str:
89
+ return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest()
90
+
91
+
92
+ def _key(term):
93
+ return term.key()
94
+
95
+
96
+ def _same(one, other) -> bool:
97
+ return (one is None and other is None) or (one is not None and other is not None and _key(one) == _key(other))
98
+
99
+
100
+ def _number(value):
101
+ """Attribute values as JSON numbers; 5 and 5.0 are the same window."""
102
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
103
+ return value
104
+ return float(value)
105
+
106
+
107
+ def canonical_hashes(graph: "Graph") -> list[str]:
108
+ """Return the canonical hash of every node of ``graph``, in node order; the last is the factor's."""
109
+ return [_sha(["term", _key(t)]) for t in canonical_terms(graph)]
110
+
111
+
112
+ #: Operators whose result does not depend on the order of their inputs, to the last bit, NaN included.
113
+ #: ``Max`` and ``Min`` are not among them: a SIMD maximum returns its second input when one is NaN.
114
+ _EXACTLY_COMMUTATIVE = frozenset({"Add", "Mul", "Equals", "And", "Or", "SessionCorr"})
115
+
116
+
117
+ def computation_hashes(graph: "Graph") -> list[str]:
118
+ """Return a hash of what every node of ``graph`` computes, in node order; the last is the factor's.
119
+
120
+ Two nodes hash alike only when they compute the same values to the last
121
+ bit: the hash is of the operator, attributes, inputs and loop as
122
+ written, without the canonical hash's rewrites (``x + 1e8 - 1e8`` is not
123
+ ``x`` in float32, nor ``x / 3`` ``x * (1/3)``), the inputs of an
124
+ operator whose result does not depend on their order sorted. A repeated
125
+ subexpression hashes the same wherever it appears.
126
+ """
127
+ hashes: list[str] = []
128
+ for node in graph.nodes:
129
+ inputs = [hashes[i] for i in node.inputs]
130
+ if node.op in _EXACTLY_COMMUTATIVE:
131
+ inputs.sort()
132
+ parent = None if node.parent is None else hashes[node.parent]
133
+ hashes.append(_sha(["computation", node.op, sorted(node.attrs), inputs, parent]))
134
+ return hashes
135
+
136
+
137
+ def effective_parameters(graph: "Graph", lags_below: frozenset[str] = frozenset()) -> list[tuple[str, str, float]]:
138
+ """Return ``(operator, kind, value)`` for every value the factor uses once constants are merged.
139
+
140
+ Kinds are ``"constant"`` (a merged offset or a folded constant),
141
+ ``"scale"`` (a merged multiplier; dividing by ``c`` is scaling by ``1/c``)
142
+ and ``"lag"`` (a merged chain of ``BackRef``). Chaining allowed values
143
+ (``x * 0.25 * 1.5``, ``BackRef(BackRef(x, 5), 10)``) shows up here as the
144
+ value it really is. The lags below an operator of ``lags_below`` (a
145
+ session aggregation, whose inputs count bars, not days) are left out.
146
+ """
147
+ found, seen, stack = [], set(), [(canonical_terms(graph)[-1], True)]
148
+ while stack:
149
+ term, lags = stack.pop()
150
+ if isinstance(term, _Const):
151
+ found.append(("ConstantOp", "constant", term.value))
152
+ continue
153
+ if (term.hash, lags) in seen:
154
+ continue
155
+ seen.add((term.hash, lags))
156
+ if term.op in ("AddConst", "SubConstSwap", "DivConstSwap"):
157
+ found.append((term.op, "constant", term.attrs["value"]))
158
+ elif term.op == "MulConst":
159
+ found.append((term.op, "scale", term.attrs["value"]))
160
+ elif term.op == "BackRef" and lags:
161
+ found.append((term.op, "lag", term.attrs["window"]))
162
+ below = lags and term.op not in lags_below
163
+ stack += [(t, below) for t in term.inputs + ([] if term.parent is None else [term.parent])]
164
+ return found
165
+
166
+
167
+ def canonical_terms(graph: "Graph") -> list:
168
+ """Return every node's canonical term, in node order; the last is the factor's."""
169
+ terms = []
170
+ for node in graph.nodes:
171
+ attrs = dict(node.attrs)
172
+ inputs = [terms[i] for i in node.inputs]
173
+ parent = None if node.parent is None else terms[node.parent]
174
+ terms.append(_canonical(node.op, attrs, inputs, parent))
175
+ return terms
176
+
177
+
178
+ def _canonical(op: str, attrs: dict, inputs: list, parent):
179
+ """Return one node's canonical term, given its inputs' and loop's terms."""
180
+ if op == "ConstantOp":
181
+ value = attrs["value"]
182
+ return _Const(float("nan") if value == "nan" else float(value))
183
+ swap = bool(attrs.pop("swap", False))
184
+ attrs = {k: _number(v) for k, v in attrs.items()}
185
+
186
+ if op in ("AddConst", "SubConst", "MulConst", "DivConst"):
187
+ x, c = inputs[0], attrs["value"]
188
+ return _with_constant(op, x, c, swap, parent)
189
+ if op in _FOLD and inputs and all(isinstance(t, _Const) for t in inputs):
190
+ try:
191
+ return _Const(float(_FOLD[op](*(t.value for t in inputs))))
192
+ except (ArithmeticError, ValueError):
193
+ pass
194
+ if op in ("Add", "Sub", "Mul", "Div") and len(inputs) == 2:
195
+ left, right = inputs
196
+ if isinstance(right, _Const) and not isinstance(left, _Const):
197
+ return _with_constant(op + "Const", left, right.value, False, parent)
198
+ if isinstance(left, _Const) and not isinstance(right, _Const):
199
+ swapped = op in ("Sub", "Div")
200
+ return _with_constant(op + "Const", right, left.value, swapped, parent)
201
+ if op == "BackRef" and isinstance(inputs[0], _Node) and inputs[0].op == "BackRef" and _same(inputs[0].parent, parent):
202
+ return _Node(op, {"window": inputs[0].attrs["window"] + attrs["window"]}, inputs[0].inputs, parent)
203
+ if op == "Select":
204
+ ratio = _unguarded(inputs, parent)
205
+ if ratio is not None:
206
+ return ratio
207
+ if op in COMMUTATIVE:
208
+ inputs = sorted(inputs, key=lambda t: json.dumps(_key(t)))
209
+ return _Node(op, attrs, inputs, parent)
210
+
211
+
212
+ def _guard(condition) -> tuple[object, bool] | None:
213
+ """``(d, holds)`` when ``condition`` tests ``d`` (or ``Abs(d)``) against a small positive threshold.
214
+
215
+ ``holds`` is True for ``d > eps`` (the ratio is valid where it holds),
216
+ False for ``d <= eps``; None for any other condition.
217
+ """
218
+ if not isinstance(condition, _Node):
219
+ return None
220
+ tested, threshold, holds = None, None, None
221
+ if condition.op in ("GreaterThan", "LessEqual") and isinstance(condition.inputs[1], _Const):
222
+ tested, threshold, holds = condition.inputs[0], condition.inputs[1].value, condition.op == "GreaterThan"
223
+ elif condition.op in ("LessThan", "GreaterEqual") and isinstance(condition.inputs[0], _Const):
224
+ tested, threshold, holds = condition.inputs[1], condition.inputs[0].value, condition.op == "LessThan"
225
+ elif condition.op in ("GreaterThanConst", "LessEqualConst") and not condition.attrs.get("swap"):
226
+ tested, threshold, holds = condition.inputs[0], condition.attrs["value"], condition.op == "GreaterThanConst"
227
+ if tested is None or not 0 < threshold <= GUARD_EPSILON:
228
+ return None
229
+ if isinstance(tested, _Node) and tested.op == "Abs":
230
+ tested = tested.inputs[0]
231
+ return tested, holds
232
+
233
+
234
+ def _unguarded(inputs: list, parent):
235
+ """``n / d`` for a guarded ratio ``Select(guard on d, n / d, nan)`` (either order); None for another Select."""
236
+ if len(inputs) != 3:
237
+ return None
238
+ guard = _guard(inputs[0])
239
+ if guard is None:
240
+ return None
241
+ d, holds = guard
242
+ ratio, fill = (inputs[1], inputs[2]) if holds else (inputs[2], inputs[1])
243
+ if not (isinstance(fill, _Const) and math.isnan(fill.value)) or not isinstance(ratio, _Node):
244
+ return None
245
+ if ratio.op == "Div":
246
+ numerator, denominator = ratio.inputs
247
+ elif ratio.op == "DivConstSwap":
248
+ numerator, [denominator] = None, ratio.inputs
249
+ else:
250
+ return None
251
+ inner = _guard(denominator.inputs[0]) if isinstance(denominator, _Node) and denominator.op == "Select" else None
252
+ if inner is not None and inner[1] and _same(inner[0], d) and _same(denominator.inputs[1], d):
253
+ denominator = d
254
+ if not _same(denominator, d) or not _same(ratio.parent, parent):
255
+ return None
256
+ if numerator is None:
257
+ return _Node("DivConstSwap", ratio.attrs, [d], parent)
258
+ return _canonical("Div", {}, [numerator, d], parent)
259
+
260
+
261
+ def _with_constant(op: str, x, c: float, swap: bool, parent):
262
+ """``x op c`` (``c op x`` when ``swap``) as ``AddConst``/``MulConst``, or a swapped form."""
263
+ if isinstance(x, _Const):
264
+ a, b = (c, x.value) if swap else (x.value, c)
265
+ try:
266
+ return _Const(float(_FOLD[op.removesuffix("Const")](a, b)))
267
+ except (ArithmeticError, ValueError):
268
+ pass
269
+ if op == "SubConst" and not swap:
270
+ op, c = "AddConst", -c
271
+ elif op == "DivConst" and not swap and c != 0:
272
+ op, c = "MulConst", 1.0 / c
273
+ if op in ("SubConst", "DivConst"): # c - x, c / x
274
+ return _Node(op + "Swap", {"value": _number(c)}, [x], parent)
275
+ if isinstance(x, _Node) and x.op == op and _same(x.parent, parent):
276
+ inner = x.attrs["value"]
277
+ c = inner + c if op == "AddConst" else inner * c
278
+ x = x.inputs[0]
279
+ if (op == "AddConst" and c == 0) or (op == "MulConst" and c == 1):
280
+ return x
281
+ return _Node(op, {"value": _rounded(c)}, [x], parent)
282
+
283
+
284
+ #: Root operators a cross-sectional rank IC cannot tell from their input.
285
+ _RANK_PRESERVING = frozenset({"Rank", "Scale", "CrossSectionalZScore"})
286
+
287
+
288
+ def ranked_root(term):
289
+ """Return ``term`` without the rank-preserving operators at its root.
290
+
291
+ A cross-sectional rank, scale or z-score, a constant offset and a
292
+ nonzero scale keep or reverse the factor's cross-sectional order, so
293
+ ``Rank(x) * 2 - 1`` and ``1 - x`` are ``x`` to the originality check
294
+ (the sign is the factor's direction, read from its training IC, not a new factor).
295
+ """
296
+ while isinstance(term, _Node) and (
297
+ term.op in _RANK_PRESERVING
298
+ or term.op in ("AddConst", "SubConstSwap")
299
+ or (term.op == "MulConst" and term.attrs["value"] != 0)
300
+ ):
301
+ term = term.inputs[0]
302
+ return term
303
+
304
+
305
+ def term_key(term) -> str:
306
+ """Return a canonical term's identity as a string."""
307
+ return json.dumps(_key(term))
308
+
309
+
310
+ def shape_key(term) -> str:
311
+ """Return the hash of a canonical term with every numeric attribute erased.
312
+
313
+ Two terms with the same shape differ at most in their windows, lags and
314
+ constants.
315
+ """
316
+ memo: dict[int, str] = {}
317
+
318
+ def shape(t) -> str:
319
+ if isinstance(t, _Const):
320
+ return "const"
321
+ if id(t) not in memo:
322
+ attrs = sorted(
323
+ (k, "#" if isinstance(v, (int, float)) and not isinstance(v, bool) else v) for k, v in t.attrs.items()
324
+ )
325
+ inputs = [shape(i) for i in t.inputs]
326
+ if t.op in COMMUTATIVE:
327
+ inputs.sort()
328
+ memo[id(t)] = _sha([t.op, attrs, inputs, None if t.parent is None else shape(t.parent)])
329
+ return memo[id(t)]
330
+
331
+ return shape(term)