quantlab-factor-agent 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- factor_agent/KUNQUANT_LICENSE +201 -0
- factor_agent/__init__.py +8 -0
- factor_agent/_acceptance_model.py +93 -0
- factor_agent/_admission.py +168 -0
- factor_agent/_expression_fold.py +48 -0
- factor_agent/_feedback.py +399 -0
- factor_agent/_max_drawdown.py +51 -0
- factor_agent/_model_cache.py +100 -0
- factor_agent/_scheduler.py +35 -0
- factor_agent/acceptance.py +808 -0
- factor_agent/accepted/__init__.py +1 -0
- factor_agent/aggregations.py +122 -0
- factor_agent/campaign.py +797 -0
- factor_agent/candidate.py +51 -0
- factor_agent/config.py +651 -0
- factor_agent/derived.py +98 -0
- factor_agent/evaluator.py +1096 -0
- factor_agent/events.py +343 -0
- factor_agent/exported.py +177 -0
- factor_agent/gate/__init__.py +359 -0
- factor_agent/gate/_canonical.py +331 -0
- factor_agent/gate/_checks.py +85 -0
- factor_agent/gate/_constraints.py +85 -0
- factor_agent/gate/_dimension.py +177 -0
- factor_agent/gate/_originality.py +146 -0
- factor_agent/gate/_sandbox.py +105 -0
- factor_agent/gate/graph.py +223 -0
- factor_agent/gate/stages.py +383 -0
- factor_agent/gate/vocabulary.py +1097 -0
- factor_agent/ledger.py +85 -0
- factor_agent/llm/__init__.py +0 -0
- factor_agent/llm/_common.py +18 -0
- factor_agent/llm/_raw.py +78 -0
- factor_agent/llm/anthropic.py +100 -0
- factor_agent/llm/base.py +124 -0
- factor_agent/llm/claude_code.py +101 -0
- factor_agent/llm/log.py +74 -0
- factor_agent/llm/openai_compatible.py +113 -0
- factor_agent/llm/replay.py +59 -0
- factor_agent/llm/scripted.py +39 -0
- factor_agent/period_lag.py +91 -0
- factor_agent/prompts.py +557 -0
- factor_agent/researcher.py +615 -0
- factor_agent/scoring.py +143 -0
- factor_agent/segments.py +184 -0
- factor_agent/session.py +500 -0
- factor_agent/snapshot.py +219 -0
- factor_agent/web/__init__.py +7 -0
- factor_agent/web/_acceptance.py +145 -0
- factor_agent/web/_config.py +51 -0
- factor_agent/web/_diagnosis.py +110 -0
- factor_agent/web/_files.py +78 -0
- factor_agent/web/_health.py +207 -0
- factor_agent/web/_ic.py +27 -0
- factor_agent/web/_ledger.py +293 -0
- factor_agent/web/_memory.py +42 -0
- factor_agent/web/_results.py +77 -0
- factor_agent/web/_round.py +219 -0
- factor_agent/web/_trials.py +112 -0
- factor_agent/web/app.py +118 -0
- factor_agent/web/run_view.py +329 -0
- factor_agent/web/static/assets/index-9QjjJgl3.css +1 -0
- factor_agent/web/static/assets/index-iOE9ei_S.js +74 -0
- factor_agent/web/static/index.html +13 -0
- factor_agent/zoo.py +136 -0
- quantlab_factor_agent-0.1.0.dist-info/METADATA +172 -0
- quantlab_factor_agent-0.1.0.dist-info/RECORD +72 -0
- quantlab_factor_agent-0.1.0.dist-info/WHEEL +5 -0
- quantlab_factor_agent-0.1.0.dist-info/entry_points.txt +2 -0
- quantlab_factor_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
- quantlab_factor_agent-0.1.0.dist-info/licenses/factor_agent/KUNQUANT_LICENSE +201 -0
- quantlab_factor_agent-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Static checks on candidate code, before anything runs.
|
|
2
|
+
|
|
3
|
+
The syntax is an allow-list: assignments, arithmetic and comparisons, calls,
|
|
4
|
+
``def``/``return``, ``for``/``while``/``if`` and ``with`` blocks over plain
|
|
5
|
+
names and constants. Anything else, attribute access included, is refused.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
|
|
10
|
+
#: The syntax candidate code may use.
|
|
11
|
+
_ALLOWED_NODES = (
|
|
12
|
+
ast.Module, ast.Expr, ast.Assign, ast.AugAssign, ast.Name, ast.Load, ast.Store,
|
|
13
|
+
ast.Constant, ast.Tuple, ast.List, ast.Subscript, ast.Slice,
|
|
14
|
+
ast.BinOp, ast.UnaryOp, ast.BoolOp, ast.Compare, ast.IfExp, ast.Call, ast.keyword,
|
|
15
|
+
ast.operator, ast.unaryop, ast.boolop, ast.cmpop,
|
|
16
|
+
ast.FunctionDef, ast.arguments, ast.arg, ast.Return,
|
|
17
|
+
ast.For, ast.While, ast.If, ast.Break, ast.Continue, ast.Pass, ast.With, ast.withitem,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
#: Clearer reasons for the refusals a proposer is most likely to hit.
|
|
21
|
+
_REASONS = {
|
|
22
|
+
ast.Import: "import is not allowed",
|
|
23
|
+
ast.ImportFrom: "import is not allowed",
|
|
24
|
+
ast.ClassDef: "class definitions are not allowed",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def static_reasons(code: str, names: frozenset[str]) -> list[str]:
|
|
29
|
+
"""Return why ``code`` is refused before it runs; empty when it may run.
|
|
30
|
+
|
|
31
|
+
``names`` are the names the code may read without binding them (the vocabulary's).
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
tree = ast.parse(code, mode="exec")
|
|
35
|
+
except SyntaxError as exc:
|
|
36
|
+
return [f"syntax error at line {exc.lineno}: {exc.msg}"]
|
|
37
|
+
reasons = _reasons(tree, names, _bound_names(tree))
|
|
38
|
+
if "alpha" not in _bound_names(tree):
|
|
39
|
+
reasons.append("the code never assigns alpha; it must end by assigning the factor to alpha")
|
|
40
|
+
return list(dict.fromkeys(reasons))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def expression_reasons(expression: str, names: frozenset[str]) -> list[str]:
|
|
44
|
+
"""Return why ``expression`` (a derived field's formula) is refused: one expression over ``names`` only."""
|
|
45
|
+
try:
|
|
46
|
+
tree = ast.parse(expression, mode="eval")
|
|
47
|
+
except SyntaxError as exc:
|
|
48
|
+
return [f"syntax error: {exc.msg}; a formula is one expression"]
|
|
49
|
+
return list(dict.fromkeys(_reasons(tree, names, set())))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _reasons(tree: ast.AST, names: frozenset[str], bound: set[str]) -> list[str]:
|
|
53
|
+
reasons: list[str] = []
|
|
54
|
+
for node in ast.walk(tree):
|
|
55
|
+
line = getattr(node, "lineno", None)
|
|
56
|
+
where = f"line {line}: " if line else ""
|
|
57
|
+
if type(node) in _REASONS:
|
|
58
|
+
reasons.append(where + _REASONS[type(node)])
|
|
59
|
+
elif isinstance(node, ast.Attribute):
|
|
60
|
+
reasons.append(f"{where}attribute access (.{node.attr}) is not allowed")
|
|
61
|
+
elif not isinstance(node, _ALLOWED_NODES + (ast.Expression,)):
|
|
62
|
+
reasons.append(f"{where}{type(node).__name__} is not allowed in candidate code")
|
|
63
|
+
elif isinstance(node, ast.Name) and node.id.startswith("__"):
|
|
64
|
+
reasons.append(f"{where}the name {node.id} is reserved; candidate code may not read or bind a name with two leading underscores")
|
|
65
|
+
elif (
|
|
66
|
+
isinstance(node, ast.Name)
|
|
67
|
+
and isinstance(node.ctx, ast.Load)
|
|
68
|
+
and node.id not in names
|
|
69
|
+
and node.id not in bound
|
|
70
|
+
):
|
|
71
|
+
reasons.append(f"{where}name {node.id} is not in the vocabulary (its fields, operators and builtins)")
|
|
72
|
+
return reasons
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _bound_names(tree: ast.AST) -> set[str]:
|
|
76
|
+
"""Return every name the code binds: assignments, loop targets, functions and their arguments."""
|
|
77
|
+
names = set()
|
|
78
|
+
for node in ast.walk(tree):
|
|
79
|
+
if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store):
|
|
80
|
+
names.add(node.id)
|
|
81
|
+
elif isinstance(node, ast.FunctionDef):
|
|
82
|
+
names.add(node.name)
|
|
83
|
+
elif isinstance(node, ast.arg):
|
|
84
|
+
names.add(node.arg)
|
|
85
|
+
return names
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Checks on a built graph: size, parameter values, dimensions and complexity.
|
|
2
|
+
|
|
3
|
+
Complexity is measured on the candidate's own nodes: a derived field (such
|
|
4
|
+
as ``vwap``) counts as one node and one field, and the bar mask the
|
|
5
|
+
NaN-preserving operators add is not the candidate's. Parameter values and
|
|
6
|
+
dimensions are the vocabulary's checks.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from factor_agent.gate._canonical import canonical_hashes
|
|
12
|
+
from factor_agent.gate.graph import Graph
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from factor_agent.config import GateConfig
|
|
16
|
+
from factor_agent.gate.vocabulary import Vocabulary
|
|
17
|
+
|
|
18
|
+
#: Magnitudes of constants that carry no tuning: zero and one (NaN is not counted either).
|
|
19
|
+
_STRUCTURAL = (0.0, 1.0)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def check(graph: Graph, config: "GateConfig", vocabulary: "Vocabulary") -> tuple[list[str], dict]:
|
|
23
|
+
"""Return the reasons ``graph`` is refused (empty if none) and its statistics under ``config``'s limits."""
|
|
24
|
+
reasons: list[str] = []
|
|
25
|
+
fields, nodes, operators, constants = _candidate_part(graph, vocabulary)
|
|
26
|
+
if nodes > config.max_nodes:
|
|
27
|
+
reasons.append(f"the graph has {nodes} nodes, more than the limit of {config.max_nodes}")
|
|
28
|
+
vocabulary_reasons, dimension = vocabulary.check(graph)
|
|
29
|
+
reasons += vocabulary_reasons
|
|
30
|
+
ratio = constants / max(operators, 1)
|
|
31
|
+
if ratio > config.max_free_parameter_ratio:
|
|
32
|
+
reasons.append(
|
|
33
|
+
f"{constants} hand-set constants over {operators} operators is a free-parameter ratio of "
|
|
34
|
+
f"{ratio:.2f}, above {config.max_free_parameter_ratio:g}; express the mechanism with fewer constants"
|
|
35
|
+
)
|
|
36
|
+
if len(fields) > config.max_base_fields:
|
|
37
|
+
reasons.append(
|
|
38
|
+
f"the factor reads {len(fields)} input fields ({', '.join(fields)}), more than {config.max_base_fields}"
|
|
39
|
+
)
|
|
40
|
+
stats = {
|
|
41
|
+
"nodes": nodes,
|
|
42
|
+
"operators": operators,
|
|
43
|
+
"free_parameters": constants,
|
|
44
|
+
"free_parameter_ratio": round(ratio, 4),
|
|
45
|
+
"base_fields": fields,
|
|
46
|
+
"dimension": dimension,
|
|
47
|
+
}
|
|
48
|
+
return reasons, stats
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _candidate_part(graph: Graph, vocabulary: "Vocabulary") -> tuple[list[str], int, int, int]:
|
|
52
|
+
"""Return the input fields the candidate reads and its node, operator and hand-set constant counts.
|
|
53
|
+
|
|
54
|
+
A derived input counts as one node; the bar mask counts as none.
|
|
55
|
+
"""
|
|
56
|
+
hashes = canonical_hashes(graph)
|
|
57
|
+
derived, mask = vocabulary.reference_hashes
|
|
58
|
+
fields, seen = set(), set()
|
|
59
|
+
nodes = operators = constants = 0
|
|
60
|
+
stack = [len(graph.nodes) - 1]
|
|
61
|
+
while stack:
|
|
62
|
+
i = stack.pop()
|
|
63
|
+
if i in seen:
|
|
64
|
+
continue
|
|
65
|
+
seen.add(i)
|
|
66
|
+
node = graph.nodes[i]
|
|
67
|
+
if hashes[i] in mask:
|
|
68
|
+
continue
|
|
69
|
+
nodes += 1
|
|
70
|
+
if hashes[i] in derived:
|
|
71
|
+
fields.add(derived[hashes[i]])
|
|
72
|
+
continue
|
|
73
|
+
if node.op == "Input":
|
|
74
|
+
column = dict(node.attrs)["name"]
|
|
75
|
+
fields.add(vocabulary.field_name(column))
|
|
76
|
+
continue
|
|
77
|
+
if node.op != "ConstantOp":
|
|
78
|
+
operators += 1
|
|
79
|
+
params = vocabulary.spec(node.op).params
|
|
80
|
+
constants += sum(
|
|
81
|
+
1 for key, value in node.attrs
|
|
82
|
+
if params.get(key, "") == vocabulary.constant_set and value != "nan" and abs(float(value)) not in _STRUCTURAL
|
|
83
|
+
)
|
|
84
|
+
stack += list(node.inputs) + ([] if node.parent is None else [node.parent])
|
|
85
|
+
return sorted(fields), nodes, operators, constants
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Dimension inference: what each node of a graph measures, from the operators' dimension rules.
|
|
2
|
+
|
|
3
|
+
A dimension is a pair of exponents ``(dollars, shares)``, from the units of
|
|
4
|
+
the fields: a price ``(1, -1)``, a volume or a share count ``(0, 1)``, money
|
|
5
|
+
(an amount traded or a statement total) ``(1, 0)``, a pure number (a ratio)
|
|
6
|
+
``(0, 0)``, a variance of price ``(2, -2)``. The rules are linear in the
|
|
7
|
+
exponents, so this basis gives the verdicts the earlier ``(price, volume)``
|
|
8
|
+
basis gave on price and volume. The rules are the framework's fixed set;
|
|
9
|
+
each operator of the operator file picks one (``OPERATOR_RULES``).
|
|
10
|
+
|
|
11
|
+
Besides those, a node may be a comparison (``BOOL``), a constant that
|
|
12
|
+
takes whatever dimension it is combined with (``ANY``), a window loop (``LOOP``) or something the rules cannot say (``UNKNOWN``, e.g. the log
|
|
13
|
+
of a price). An invalid combination, such as price plus volume, is a reason
|
|
14
|
+
to reject; ``UNKNOWN`` is passed on.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from fractions import Fraction
|
|
18
|
+
from typing import TYPE_CHECKING
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from factor_agent.gate.vocabulary import Vocabulary
|
|
22
|
+
|
|
23
|
+
BOOL = "bool"
|
|
24
|
+
ANY = "any"
|
|
25
|
+
LOOP = "loop"
|
|
26
|
+
UNKNOWN = "unknown"
|
|
27
|
+
PURE = (Fraction(0), Fraction(0))
|
|
28
|
+
|
|
29
|
+
_NAMES = {PURE: "pure number", (1, -1): "price", (0, 1): "volume", (1, 0): "money"}
|
|
30
|
+
_BASES = ("dollars", "shares")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def name(dim) -> str:
|
|
34
|
+
"""Return a readable name: ``"price"``, ``"money"``, ``"dollars^2/shares^2"``, ``"unknown"``..."""
|
|
35
|
+
if isinstance(dim, str):
|
|
36
|
+
return {BOOL: "comparison", ANY: "constant"}.get(dim, dim)
|
|
37
|
+
if dim in _NAMES:
|
|
38
|
+
return _NAMES[dim]
|
|
39
|
+
up = [f"{base}^{e}" if e != 1 else base for base, e in zip(_BASES, dim) if e > 0]
|
|
40
|
+
down = [f"{base}^{-e}" if e != -1 else base for base, e in zip(_BASES, dim) if e < 0]
|
|
41
|
+
return ("*".join(up) or "1") + ("/" + "*".join(down) if down else "")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _Invalid(Exception):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def infer(graph, vocabulary: "Vocabulary") -> tuple[object, list[str]]:
|
|
49
|
+
"""Return the factor's dimension and the reasons the graph's dimensions are invalid (empty if none).
|
|
50
|
+
|
|
51
|
+
An ``Input`` has the dimension of the field reading its column; each
|
|
52
|
+
operator follows the rule ``vocabulary`` gives it.
|
|
53
|
+
"""
|
|
54
|
+
dims: list = []
|
|
55
|
+
reasons: list[str] = []
|
|
56
|
+
for node in graph.nodes:
|
|
57
|
+
spec = vocabulary.spec(node.op)
|
|
58
|
+
attrs = dict(node.attrs)
|
|
59
|
+
ins = [dims[i] for i in node.inputs]
|
|
60
|
+
try:
|
|
61
|
+
_check_kinds(node.op, spec.inputs, ins)
|
|
62
|
+
if spec.dimension == "input":
|
|
63
|
+
exponents = vocabulary.column_dimension(attrs["name"])
|
|
64
|
+
dims.append(UNKNOWN if exponents is None else tuple(Fraction(e) for e in exponents))
|
|
65
|
+
else:
|
|
66
|
+
dims.append(OPERATOR_RULES[spec.dimension](node.op, ins, attrs))
|
|
67
|
+
except _Invalid as exc:
|
|
68
|
+
reasons.append(str(exc))
|
|
69
|
+
dims.append(UNKNOWN)
|
|
70
|
+
output = dims[-1]
|
|
71
|
+
if output == BOOL:
|
|
72
|
+
reasons.append("alpha is a comparison (true or false), not a number; turn it into a number with Select")
|
|
73
|
+
elif output == ANY:
|
|
74
|
+
reasons.append("alpha is a constant; it must depend on the inputs")
|
|
75
|
+
elif output == LOOP:
|
|
76
|
+
reasons.append("alpha is a window loop; reduce it with a Reduce* operator")
|
|
77
|
+
return output, list(dict.fromkeys(reasons))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _check_kinds(op: str, kinds: tuple[str, ...], ins: list) -> None:
|
|
81
|
+
"""Every input must be what the operator expects: a number, a comparison or a loop."""
|
|
82
|
+
for position, dim in enumerate(ins):
|
|
83
|
+
kind = kinds[position] if position < len(kinds) else "series"
|
|
84
|
+
if kind == "condition" and dim not in (BOOL, UNKNOWN):
|
|
85
|
+
raise _Invalid(f"{op} needs a comparison as input {position + 1}, got a {name(dim)}")
|
|
86
|
+
if kind == "loop" and dim != LOOP:
|
|
87
|
+
raise _Invalid(f"{op} needs a ForeachBackWindow loop as input {position + 1}")
|
|
88
|
+
if kind == "series" and dim == BOOL:
|
|
89
|
+
raise _Invalid(f"{op} takes a number but gets a comparison; turn it into a number with Select")
|
|
90
|
+
if kind == "series" and dim == LOOP:
|
|
91
|
+
raise _Invalid(f"{op} takes a series but gets a loop; read the loop with IterValue")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _known(*dims) -> bool:
|
|
95
|
+
return UNKNOWN not in dims
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _unify(op: str, a, b):
|
|
99
|
+
if a == ANY:
|
|
100
|
+
return b
|
|
101
|
+
if b == ANY or a == b:
|
|
102
|
+
return a
|
|
103
|
+
if not _known(a, b):
|
|
104
|
+
return UNKNOWN
|
|
105
|
+
raise _Invalid(f"{op} combines a {name(a)} with a {name(b)}")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _numeric(d):
|
|
109
|
+
"""A constant in a product counts as a pure number."""
|
|
110
|
+
return PURE if d == ANY else d
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _add(a, b, sign=1):
|
|
114
|
+
a, b = _numeric(a), _numeric(b)
|
|
115
|
+
if not _known(a, b):
|
|
116
|
+
return UNKNOWN
|
|
117
|
+
return (a[0] + sign * b[0], a[1] + sign * b[1])
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _times(d, factor: Fraction):
|
|
121
|
+
if d in (UNKNOWN, ANY):
|
|
122
|
+
return d
|
|
123
|
+
return (d[0] * factor, d[1] * factor)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _scale(op, ins, attrs):
|
|
127
|
+
# Multiplying by zero gives 0 where x has data and NaN where it has none:
|
|
128
|
+
# it combines with anything, as a constant does.
|
|
129
|
+
if op == "MulConst" and attrs["value"] == 0:
|
|
130
|
+
return ANY
|
|
131
|
+
if op == "DivConst" and attrs.get("swap"):
|
|
132
|
+
return _times(ins[0], Fraction(-1))
|
|
133
|
+
return ins[0]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _pure_only(op, ins, attrs):
|
|
137
|
+
return PURE if all(_numeric(d) == PURE for d in ins) else UNKNOWN
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _power(op, ins, attrs):
|
|
141
|
+
base, exponent = (_numeric(d) for d in ins)
|
|
142
|
+
return PURE if base == PURE and exponent == PURE else UNKNOWN
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _select(op, ins, attrs):
|
|
146
|
+
# A choice between two constants depends on its condition: a pure number.
|
|
147
|
+
chosen = _unify(op, ins[1], ins[2])
|
|
148
|
+
return PURE if chosen == ANY else chosen
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _compare(op, ins, attrs):
|
|
152
|
+
_unify(op, ins[0], ins[1])
|
|
153
|
+
return BOOL
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
#: The dimension rules an operator may pick, by name.
|
|
157
|
+
OPERATOR_RULES = {
|
|
158
|
+
"constant": lambda op, ins, attrs: ANY,
|
|
159
|
+
"same": lambda op, ins, attrs: ins[0],
|
|
160
|
+
"first": lambda op, ins, attrs: ins[0],
|
|
161
|
+
"second": lambda op, ins, attrs: ins[1],
|
|
162
|
+
"pure": lambda op, ins, attrs: PURE,
|
|
163
|
+
"match": lambda op, ins, attrs: _unify(op, ins[0], ins[1]),
|
|
164
|
+
"product": lambda op, ins, attrs: ANY if ins[0] == ins[1] == ANY else _add(ins[0], ins[1]),
|
|
165
|
+
"quotient": lambda op, ins, attrs: ANY if ins[0] == ins[1] == ANY else _add(ins[0], ins[1], -1),
|
|
166
|
+
"scale": _scale,
|
|
167
|
+
"square": lambda op, ins, attrs: _times(ins[0], Fraction(2)),
|
|
168
|
+
"sqrt": lambda op, ins, attrs: _times(ins[0], Fraction(1, 2)),
|
|
169
|
+
"transcendental": _pure_only,
|
|
170
|
+
"repeated_product": _pure_only,
|
|
171
|
+
"power": _power,
|
|
172
|
+
"select": _select,
|
|
173
|
+
"compare": _compare,
|
|
174
|
+
"compare_constant": lambda op, ins, attrs: BOOL,
|
|
175
|
+
"logic": lambda op, ins, attrs: BOOL,
|
|
176
|
+
"loop": lambda op, ins, attrs: LOOP,
|
|
177
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Originality against the zoo: rewrites, parameter-only refinements and large common subtrees.
|
|
2
|
+
|
|
3
|
+
All three compare canonical graphs (``_canonical``):
|
|
4
|
+
|
|
5
|
+
- a rewrite is a candidate whose canonical graph, without rank-keeping or rank-reversing
|
|
6
|
+
root operators, is a zoo factor's;
|
|
7
|
+
- a parameter-only refinement has a zoo factor's shape: the same graph up
|
|
8
|
+
to its windows, lags and constants;
|
|
9
|
+
- a common subtree is a node of the candidate whose canonical hash is a
|
|
10
|
+
node of a zoo factor; its size counts the distinct nodes under it as the
|
|
11
|
+
graph-size limit does (a derived input is one node, the bar mask none),
|
|
12
|
+
and the largest one may not exceed the limit (AlphaAgent's largest common
|
|
13
|
+
subtree, MIT; the limit of 5 nodes is QuantaAlpha's).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from factor_agent.gate._canonical import canonical_hashes, canonical_terms, ranked_root, shape_key, term_key
|
|
20
|
+
from factor_agent.gate.graph import Graph
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from factor_agent.gate.vocabulary import Vocabulary
|
|
24
|
+
from factor_agent.zoo import Zoo, ZooEntry
|
|
25
|
+
|
|
26
|
+
#: Characters of a common subtree quoted in a reason.
|
|
27
|
+
_RENDER_LIMIT = 400
|
|
28
|
+
|
|
29
|
+
#: Factors named in one reason before the rest are counted.
|
|
30
|
+
_NAMED = 3
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class _Index:
|
|
35
|
+
"""What a graph is compared by: its node hashes, its ranked root and that root's shape."""
|
|
36
|
+
|
|
37
|
+
hashes: frozenset[str]
|
|
38
|
+
root: str
|
|
39
|
+
shape: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
#: Indexes of zoo graphs by ``id``, with the graph kept so the id stays its own.
|
|
43
|
+
_INDEXES: dict[int, tuple[Graph, _Index]] = {}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _index(graph: Graph) -> _Index:
|
|
47
|
+
"""Return the graph's index, cached for zoo graphs (a graph's attributes may be unhashable lists)."""
|
|
48
|
+
cached = _INDEXES.get(id(graph))
|
|
49
|
+
if cached is None or cached[0] is not graph:
|
|
50
|
+
root = ranked_root(canonical_terms(graph)[-1])
|
|
51
|
+
cached = _INDEXES[id(graph)] = (graph, _Index(frozenset(canonical_hashes(graph)), term_key(root), shape_key(root)))
|
|
52
|
+
return cached[1]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def reasons(graph: Graph, zoo: "Zoo", max_common_nodes: int, vocabulary: "Vocabulary") -> list[str]:
|
|
56
|
+
"""Return why ``graph`` is not original against ``zoo`` (empty if it is); ``vocabulary`` names its fields."""
|
|
57
|
+
root = ranked_root(canonical_terms(graph)[-1])
|
|
58
|
+
mine = _Index(frozenset(), term_key(root), shape_key(root))
|
|
59
|
+
entries = [(entry, _index(entry.graph)) for entry in zoo.entries]
|
|
60
|
+
rewrites = [entry for entry, index in entries if index.root == mine.root]
|
|
61
|
+
if rewrites:
|
|
62
|
+
terms = canonical_terms(graph)
|
|
63
|
+
root = max((i for i, term in enumerate(terms) if term_key(term) == mine.root), default=len(terms) - 1)
|
|
64
|
+
return [
|
|
65
|
+
f"the factor rewrites {_names(rewrites)}: {render(graph, root, vocabulary)} is that factor's whole graph, up to "
|
|
66
|
+
"a transform that keeps or reverses its ranking; propose a different mechanism"
|
|
67
|
+
]
|
|
68
|
+
found = []
|
|
69
|
+
tuned = [entry for entry, index in entries if index.shape == mine.shape]
|
|
70
|
+
if tuned:
|
|
71
|
+
found.append(
|
|
72
|
+
f"the factor is {_names(tuned)} with other windows or parameter values only; a refinement must "
|
|
73
|
+
"change the expression of the mechanism, not its parameters"
|
|
74
|
+
)
|
|
75
|
+
size, node, sharing = _largest_common_subtree(graph, entries, vocabulary)
|
|
76
|
+
if size > max_common_nodes:
|
|
77
|
+
found.append(
|
|
78
|
+
f"the factor shares a {size}-node subtree with {_names(sharing)}, more than the limit of "
|
|
79
|
+
f"{max_common_nodes}: {render(graph, node, vocabulary)}; express the mechanism without reusing that expression"
|
|
80
|
+
)
|
|
81
|
+
return found
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _largest_common_subtree(graph: Graph, entries: list, vocabulary: "Vocabulary") -> tuple[int, int | None, list["ZooEntry"]]:
|
|
85
|
+
"""Return the size and node of the candidate's largest subtree found in a zoo factor, and those factors."""
|
|
86
|
+
hashes = canonical_hashes(graph)
|
|
87
|
+
sizes = _subtree_sizes(graph, hashes, vocabulary)
|
|
88
|
+
best, node, sharing = 0, None, []
|
|
89
|
+
for i in sorted(range(len(hashes)), key=lambda i: -sizes[i]):
|
|
90
|
+
if sizes[i] <= best and node is not None:
|
|
91
|
+
break
|
|
92
|
+
holders = [entry for entry, index in entries if hashes[i] in index.hashes]
|
|
93
|
+
if holders:
|
|
94
|
+
best, node, sharing = sizes[i], i, holders
|
|
95
|
+
return best, node, sharing
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _subtree_sizes(graph: Graph, hashes: list[str], vocabulary: "Vocabulary") -> list[int]:
|
|
99
|
+
"""Return the number of distinct nodes under each node, itself included."""
|
|
100
|
+
derived, mask = vocabulary.reference_hashes
|
|
101
|
+
reach: list[frozenset[str]] = []
|
|
102
|
+
for i, node in enumerate(graph.nodes):
|
|
103
|
+
if hashes[i] in mask:
|
|
104
|
+
reach.append(frozenset())
|
|
105
|
+
elif hashes[i] in derived:
|
|
106
|
+
reach.append(frozenset({hashes[i]}))
|
|
107
|
+
else:
|
|
108
|
+
under = [reach[j] for j in node.inputs] + ([] if node.parent is None else [reach[node.parent]])
|
|
109
|
+
reach.append(frozenset({hashes[i]}).union(*under))
|
|
110
|
+
return [len(r) for r in reach]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _names(entries: list["ZooEntry"]) -> str:
|
|
114
|
+
named = ", ".join(entry.label for entry in entries[:_NAMED])
|
|
115
|
+
rest = len(entries) - _NAMED
|
|
116
|
+
return named + (f" and {rest} more" if rest > 0 else "")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def render(graph: Graph, node: int, vocabulary: "Vocabulary") -> str:
|
|
120
|
+
"""Return the expression under ``node`` as candidate code would write it."""
|
|
121
|
+
hashes = canonical_hashes(graph)
|
|
122
|
+
derived, mask = vocabulary.reference_hashes
|
|
123
|
+
|
|
124
|
+
def render(i: int) -> str:
|
|
125
|
+
n = graph.nodes[i]
|
|
126
|
+
if hashes[i] in derived:
|
|
127
|
+
return derived[hashes[i]]
|
|
128
|
+
attrs = dict(n.attrs)
|
|
129
|
+
if n.op == "Input":
|
|
130
|
+
return vocabulary.written(attrs["name"])
|
|
131
|
+
if n.op == "ConstantOp":
|
|
132
|
+
return _display(attrs["value"])
|
|
133
|
+
inputs = [j for j in n.inputs if hashes[j] not in mask]
|
|
134
|
+
if n.op == "Add" and len(inputs) == 1: # a NaN-preserving stand-in: the operator plus the bar mask
|
|
135
|
+
return render(inputs[0])
|
|
136
|
+
values = [_display(v) for k, v in n.attrs if not (k == "swap" and not v)]
|
|
137
|
+
return f"{n.op}({', '.join([render(j) for j in inputs] + values)})"
|
|
138
|
+
|
|
139
|
+
text = render(node)
|
|
140
|
+
return text if len(text) <= _RENDER_LIMIT else text[:_RENDER_LIMIT] + "..."
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _display(value) -> str:
|
|
144
|
+
if isinstance(value, float) and value.is_integer():
|
|
145
|
+
return str(int(value))
|
|
146
|
+
return f"{value:g}" if isinstance(value, float) else str(value)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""The sandbox subprocess: run candidate code once and send back its graph.
|
|
2
|
+
|
|
3
|
+
Reads ``{"code": ..., "memory_mb": ..., "vocabulary": ...}`` on standard input and writes on
|
|
4
|
+
standard output the ``READY`` line once it has started, then one JSON object:
|
|
5
|
+
``{"graph": [...]}`` or ``{"reasons": [...]}``.
|
|
6
|
+
The parent runs it with ``python -I``, an empty environment, a scratch
|
|
7
|
+
working directory, a timeout, a memory watchdog and, where bubblewrap is
|
|
8
|
+
installed, its own filesystem view; the code sees only the namespace of
|
|
9
|
+
the vocabulary, rebuilt here from the parent's ``Vocabulary.sources``.
|
|
10
|
+
Where the platform allows it (Linux), the address space is also capped
|
|
11
|
+
here, so an allocation past the limit fails at once.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import resource
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
from KunQuant.Op import OpBase, Output
|
|
19
|
+
from KunQuant.Stage import Function
|
|
20
|
+
|
|
21
|
+
from factor_agent.gate import MEMORY_REASON, READY
|
|
22
|
+
from factor_agent.gate.graph import Graph
|
|
23
|
+
from factor_agent.gate.vocabulary import PeriodLagError, Vocabulary
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build(code: str, memory_mb: int, vocabulary: Vocabulary) -> dict:
|
|
27
|
+
"""Run ``code`` over ``vocabulary`` and return the message for the parent."""
|
|
28
|
+
try:
|
|
29
|
+
scope = vocabulary.execute(code)
|
|
30
|
+
except MemoryError:
|
|
31
|
+
return {"reasons": [MEMORY_REASON.format(memory_mb=memory_mb)]}
|
|
32
|
+
except PeriodLagError as exc:
|
|
33
|
+
return {"reasons": [str(exc)]}
|
|
34
|
+
except Exception as exc: # the candidate's own error, reported back to it
|
|
35
|
+
return {"reasons": [f"graph build failed: {type(exc).__name__}: {exc}{_bad_input(exc, code, vocabulary)}"]}
|
|
36
|
+
if "alpha" not in scope:
|
|
37
|
+
return {"reasons": ["the code never assigns alpha"]}
|
|
38
|
+
alpha = scope["alpha"]
|
|
39
|
+
if isinstance(alpha, (tuple, list)):
|
|
40
|
+
return {"reasons": [f"alpha holds {len(alpha)} values; a candidate has exactly one output"]}
|
|
41
|
+
if not isinstance(alpha, OpBase):
|
|
42
|
+
return {"reasons": [f"alpha must be an operator, got {type(alpha).__name__}"]}
|
|
43
|
+
try:
|
|
44
|
+
graph = Graph.record(alpha, vocabulary)
|
|
45
|
+
except ValueError as exc:
|
|
46
|
+
return {"reasons": [str(exc)]}
|
|
47
|
+
try:
|
|
48
|
+
output, ops = graph.build({})
|
|
49
|
+
Function(ops + [Output(output, "alpha")])
|
|
50
|
+
except Exception as exc:
|
|
51
|
+
return {"reasons": [f"graph build failed: {type(exc).__name__}: {exc}"]}
|
|
52
|
+
return {"graph": graph.to_json()}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _bad_input(exc: Exception, code: str, vocabulary: Vocabulary) -> str:
|
|
56
|
+
"""Where KunQuant's "Bad inputs" came from: the candidate's line and the operator given a number, or "" for any other error.
|
|
57
|
+
|
|
58
|
+
KunQuant names only the type of the value, so the fix of a candidate
|
|
59
|
+
with many numbers had no way to tell which one to change.
|
|
60
|
+
"""
|
|
61
|
+
line = op = inputs = None
|
|
62
|
+
tb = exc.__traceback__
|
|
63
|
+
while tb is not None:
|
|
64
|
+
frame = tb.tb_frame
|
|
65
|
+
if frame.f_code.co_filename == "<candidate>":
|
|
66
|
+
line = tb.tb_lineno
|
|
67
|
+
elif frame.f_code is OpBase.__init__.__code__:
|
|
68
|
+
op, inputs = frame.f_locals.get("self"), frame.f_locals.get("inputs")
|
|
69
|
+
tb = tb.tb_next
|
|
70
|
+
if line is None or op is None or not inputs:
|
|
71
|
+
return ""
|
|
72
|
+
position, value = next(((k, v) for k, v in enumerate(inputs, 1) if not isinstance(v, OpBase)), (None, None))
|
|
73
|
+
if position is None:
|
|
74
|
+
return ""
|
|
75
|
+
name = vocabulary.recorded_name(type(op)) or type(op).__name__
|
|
76
|
+
source = code.splitlines()[line - 1].strip() if line <= len(code.splitlines()) else ""
|
|
77
|
+
return f": on line {line} ({source}), input {position} of {name} is the number {value!r}, not an operator"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _cap_address_space(memory_mb: int) -> None:
|
|
81
|
+
"""Cap the address space at what is mapped now plus ``memory_mb``; a no-op where unsupported."""
|
|
82
|
+
try:
|
|
83
|
+
with open("/proc/self/statm") as statm:
|
|
84
|
+
mapped = int(statm.read().split()[0]) * resource.getpagesize()
|
|
85
|
+
_, hard = resource.getrlimit(resource.RLIMIT_AS)
|
|
86
|
+
resource.setrlimit(resource.RLIMIT_AS, (mapped + memory_mb * 2**20, hard))
|
|
87
|
+
except (OSError, ValueError):
|
|
88
|
+
pass # macOS has no /proc and does not enforce RLIMIT_AS; the parent's watchdog applies
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def main() -> None:
|
|
92
|
+
"""Read the request, build, write the reply."""
|
|
93
|
+
request = json.load(sys.stdin)
|
|
94
|
+
out = sys.stdout
|
|
95
|
+
sys.stdout = sys.stderr # nothing the code does can corrupt the reply
|
|
96
|
+
vocabulary = Vocabulary.from_sources(request["vocabulary"])
|
|
97
|
+
_cap_address_space(request["memory_mb"])
|
|
98
|
+
out.write(READY.decode())
|
|
99
|
+
out.flush() # started: the graph build's time counts from here
|
|
100
|
+
json.dump(build(request["code"], request["memory_mb"], vocabulary), out)
|
|
101
|
+
out.flush()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
if __name__ == "__main__":
|
|
105
|
+
main()
|