algorithm-discovery-engine 1.0.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.
- ads/__init__.py +85 -0
- ads/benchmark.py +84 -0
- ads/problems.py +190 -0
- ads/problems_advanced.py +203 -0
- ads/structures.py +370 -0
- ads/structures_advanced.py +385 -0
- algo_discovery/__init__.py +19 -0
- algo_discovery/__main__.py +38 -0
- algo_discovery/engine.py +64 -0
- algo_discovery/features.py +115 -0
- algo_discovery/hypotheses.py +251 -0
- algo_discovery/models.py +63 -0
- algorithm_discovery_engine-1.0.0.dist-info/METADATA +287 -0
- algorithm_discovery_engine-1.0.0.dist-info/RECORD +27 -0
- algorithm_discovery_engine-1.0.0.dist-info/WHEEL +4 -0
- algorithm_discovery_engine-1.0.0.dist-info/entry_points.txt +2 -0
- algorithm_discovery_engine-1.0.0.dist-info/licenses/LICENSE +21 -0
- gui/__init__.py +3 -0
- gui/__main__.py +26 -0
- gui/app.py +407 -0
- gui/core.py +149 -0
- synth/__init__.py +22 -0
- synth/__main__.py +66 -0
- synth/corpus.py +222 -0
- synth/discovery.py +198 -0
- synth/grammar.py +174 -0
- synth/search.py +432 -0
synth/corpus.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Discovery targets, reference oracles, and fuzz generators.
|
|
2
|
+
|
|
3
|
+
Curated targets live in ``catalog/discovery_targets.json``; every target maps
|
|
4
|
+
to an oracle here (used as the truth source for fuzz verification) and a fuzz
|
|
5
|
+
input generator.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import random
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
ROOT = Path(__file__).resolve().parent.parent.parent
|
|
17
|
+
|
|
18
|
+
PARSE_DELIM = "|"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_input(
|
|
22
|
+
spec: str, arg_types: list[str] | None = None
|
|
23
|
+
) -> list[Any]:
|
|
24
|
+
"""Parse a '|'-delimited spec into typed arguments.
|
|
25
|
+
|
|
26
|
+
Without ``arg_types`` a scalar token is parsed as ``int`` and multiple
|
|
27
|
+
tokens become a ``list[int]``. With ``arg_types`` each segment is read per
|
|
28
|
+
its declared type, so a single-element list like ``"0|"`` correctly
|
|
29
|
+
produces ``[0]`` (a one-arg list, not the scalar ``0``).
|
|
30
|
+
"""
|
|
31
|
+
if arg_types is None:
|
|
32
|
+
return _parse_input_legacy(spec)
|
|
33
|
+
raw = spec.split(PARSE_DELIM)
|
|
34
|
+
if raw and raw[-1] == "":
|
|
35
|
+
raw.pop()
|
|
36
|
+
parsed: list[Any] = []
|
|
37
|
+
for piece, atype in zip(raw, arg_types, strict=True):
|
|
38
|
+
if atype == "int":
|
|
39
|
+
parsed.append(int(piece))
|
|
40
|
+
else:
|
|
41
|
+
parsed.append([] if not piece.strip() else [int(t) for t in piece.split()])
|
|
42
|
+
return parsed
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _parse_input_legacy(spec: str) -> list[Any]:
|
|
46
|
+
"""Legacy whitespace heuristic (scalar token -> int, more -> list)."""
|
|
47
|
+
parsed: list[Any] = []
|
|
48
|
+
for part in spec.split(PARSE_DELIM):
|
|
49
|
+
tokens = part.split()
|
|
50
|
+
if not tokens:
|
|
51
|
+
continue
|
|
52
|
+
if len(tokens) == 1:
|
|
53
|
+
token = tokens[0]
|
|
54
|
+
if token.lower() in {"true", "false"}:
|
|
55
|
+
parsed.append(token.lower() == "true")
|
|
56
|
+
else:
|
|
57
|
+
parsed.append(int(token))
|
|
58
|
+
else:
|
|
59
|
+
parsed.append([int(token) for token in tokens])
|
|
60
|
+
return parsed
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# --- oracles --------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _kadane(nums: list[int]) -> int:
|
|
67
|
+
best = nums[0] if nums else 0
|
|
68
|
+
running = 0
|
|
69
|
+
for value in nums:
|
|
70
|
+
running = max(value, running + value)
|
|
71
|
+
best = max(best, running)
|
|
72
|
+
return best
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def oracle_max_circular_subarray(nums: list[int]) -> int:
|
|
76
|
+
"""Maximum circular subarray sum (empty selection allowed -> 0)."""
|
|
77
|
+
if not nums:
|
|
78
|
+
return 0
|
|
79
|
+
total = sum(nums)
|
|
80
|
+
if all(value < 0 for value in nums):
|
|
81
|
+
return 0
|
|
82
|
+
min_sub = _kadane([-value for value in nums])
|
|
83
|
+
return max(_kadane(nums), total + min_sub)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def oracle_best_time_buy_sell(prices: list[int]) -> int:
|
|
87
|
+
"""Maximum profit from one buy/sell; 0 if none possible."""
|
|
88
|
+
if len(prices) < 2:
|
|
89
|
+
return 0
|
|
90
|
+
best = 0
|
|
91
|
+
low = prices[0]
|
|
92
|
+
for price in prices[1:]:
|
|
93
|
+
low = min(low, price)
|
|
94
|
+
best = max(best, price - low)
|
|
95
|
+
return best
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def oracle_jump_game(nums: list[int]) -> bool:
|
|
99
|
+
"""Whether the last index is reachable by jumping nums[i] steps."""
|
|
100
|
+
reach = 0
|
|
101
|
+
for i, value in enumerate(nums):
|
|
102
|
+
if i > reach:
|
|
103
|
+
return False
|
|
104
|
+
reach = max(reach, i + value)
|
|
105
|
+
return True
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def oracle_majority_element(nums: list[int]) -> int:
|
|
109
|
+
"""Element appearing more than n//2 times (guaranteed in test inputs)."""
|
|
110
|
+
return sorted(nums)[len(nums) // 2]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def oracle_contains_duplicate(nums: list[int]) -> bool:
|
|
114
|
+
"""Whether any value appears more than once."""
|
|
115
|
+
return len(nums) != len(set(nums))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def oracle_climbing_stairs(n: int) -> int:
|
|
119
|
+
"""Ways to climb n stairs taking 1 or 2 steps."""
|
|
120
|
+
a, b = 1, 1
|
|
121
|
+
for _ in range(n):
|
|
122
|
+
a, b = b, a + b
|
|
123
|
+
return a
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
ORACLES: dict[str, Callable[..., Any]] = {
|
|
127
|
+
"max_circular_subarray": oracle_max_circular_subarray,
|
|
128
|
+
"best_time_buy_sell": oracle_best_time_buy_sell,
|
|
129
|
+
"jump_game": oracle_jump_game,
|
|
130
|
+
"majority_element": oracle_majority_element,
|
|
131
|
+
"contains_duplicate": oracle_contains_duplicate,
|
|
132
|
+
"climbing_stairs": oracle_climbing_stairs,
|
|
133
|
+
"max_subarray": _kadane,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# --- fuzz generators -------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
def _small_int_list(rng: random.Random, low: int, high: int) -> list[int]:
|
|
140
|
+
length = rng.randrange(0, 13)
|
|
141
|
+
return [rng.randrange(low, high + 1) for _ in range(length)]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def fuzz_int_list(rng: random.Random) -> list[int]:
|
|
145
|
+
return _small_int_list(rng, -12, 12)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def fuzz_prices(rng: random.Random) -> list[int]:
|
|
149
|
+
return _small_int_list(rng, 0, 12)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def fuzz_reach(rng: random.Random) -> list[int]:
|
|
153
|
+
return _small_int_list(rng, 0, 5)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def fuzz_majority(rng: random.Random) -> list[int]:
|
|
157
|
+
length = rng.randrange(1, 9)
|
|
158
|
+
token = rng.randrange(-6, 6)
|
|
159
|
+
majority_count = length // 2 + 1
|
|
160
|
+
fill = [rng.randrange(-6, 6) for _ in range(length - majority_count)]
|
|
161
|
+
out = [token] * majority_count + fill
|
|
162
|
+
rng.shuffle(out)
|
|
163
|
+
return out
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def fuzz_small_int(rng: random.Random) -> int:
|
|
167
|
+
return rng.randrange(0, 16)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
FUZZ: dict[str, Callable[[random.Random], Any]] = {
|
|
171
|
+
"max_circular_subarray": fuzz_int_list,
|
|
172
|
+
"best_time_buy_sell": fuzz_prices,
|
|
173
|
+
"jump_game": fuzz_reach,
|
|
174
|
+
"majority_element": fuzz_majority,
|
|
175
|
+
"contains_duplicate": fuzz_int_list,
|
|
176
|
+
"climbing_stairs": fuzz_small_int,
|
|
177
|
+
"max_subarray": fuzz_int_list,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
# --- loading ----------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
DISCOVERY_TARGETS: dict[str, dict[str, Any]] = {}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def parse_output(spec: str) -> Any:
|
|
186
|
+
"""Parse an expected output token into bool/int."""
|
|
187
|
+
token = spec.strip()
|
|
188
|
+
if token.lower() in {"true", "false"}:
|
|
189
|
+
return token.lower() == "true"
|
|
190
|
+
return int(token)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def load_targets() -> dict[str, dict[str, Any]]:
|
|
194
|
+
"""Load ``catalog/discovery_targets.json`` once."""
|
|
195
|
+
if DISCOVERY_TARGETS:
|
|
196
|
+
return DISCOVERY_TARGETS
|
|
197
|
+
path = ROOT / "catalog" / "discovery_targets.json"
|
|
198
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
199
|
+
for target in payload["targets"]:
|
|
200
|
+
oracle_name = target.get("oracle", target["id"])
|
|
201
|
+
fuzz_name = target.get("fuzz", target["id"])
|
|
202
|
+
target["oracle"] = ORACLES[oracle_name]
|
|
203
|
+
target["fuzz"] = FUZZ[fuzz_name]
|
|
204
|
+
target["examples"] = [
|
|
205
|
+
(parse_input(case["in"], target.get("arg_types")), parse_output(case["out"]))
|
|
206
|
+
for case in target["cases"]
|
|
207
|
+
]
|
|
208
|
+
DISCOVERY_TARGETS[target["id"]] = target
|
|
209
|
+
return DISCOVERY_TARGETS
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def fuzz_cases(target_id: str, count: int) -> list[tuple[list[Any], Any]]:
|
|
213
|
+
"""Draw ``count`` fuzz cases verified against the target's oracle."""
|
|
214
|
+
target = load_targets()[target_id]
|
|
215
|
+
oracle = target["oracle"]
|
|
216
|
+
generator = target["fuzz"]
|
|
217
|
+
rng = random.Random(target_id + str(count))
|
|
218
|
+
cases: list[tuple[list[Any], Any]] = []
|
|
219
|
+
for _ in range(count):
|
|
220
|
+
inputs = [generator(rng)]
|
|
221
|
+
cases.append((inputs, oracle(*inputs)))
|
|
222
|
+
return cases
|
synth/discovery.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Discovery orchestrator: search, verify, classify, and report.
|
|
2
|
+
|
|
3
|
+
For every target in ``catalog/discovery_targets.json``:
|
|
4
|
+
|
|
5
|
+
1. Search for a candidate algorithm (grammar search or strategy template).
|
|
6
|
+
2. Verify it on the curated examples **and** on out-of-sample fuzz inputs
|
|
7
|
+
whose expected outputs come from an independent reference oracle.
|
|
8
|
+
3. Classify novelty (rediscovered vs. new to the solved catalog) and estimate
|
|
9
|
+
complexity.
|
|
10
|
+
4. Persist ``catalog/discoveries/report.json``, ``report.md``, and a runnable
|
|
11
|
+
per-target solution module.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import time
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from synth import corpus, search
|
|
21
|
+
|
|
22
|
+
ROOT = corpus.ROOT
|
|
23
|
+
DISCOVERIES_DIR = ROOT / "catalog" / "discoveries"
|
|
24
|
+
SOLUTIONS_DIR = DISCOVERIES_DIR / "solutions"
|
|
25
|
+
|
|
26
|
+
FUZZ_COUNT = 60
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _solved_catalog_ids() -> set[str]:
|
|
30
|
+
payload = json.loads((ROOT / "catalog" / "problems.json").read_text(encoding="utf-8"))
|
|
31
|
+
ids = {entry["id"] for entry in payload.get("algorithms", [])}
|
|
32
|
+
ids.update({entry["id"] for entry in payload.get("structures", [])})
|
|
33
|
+
return ids
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _verify_curated(candidate: search.Candidate, target: dict[str, Any]) -> bool:
|
|
37
|
+
namespace: dict[str, Any] = {}
|
|
38
|
+
exec(compile(candidate.source, "<synth>", "exec"), namespace)
|
|
39
|
+
fn = namespace[candidate.function_name]
|
|
40
|
+
for inputs, expected in target["examples"]:
|
|
41
|
+
try:
|
|
42
|
+
if fn(*inputs) != expected:
|
|
43
|
+
return False
|
|
44
|
+
except Exception: # pragma: no cover - defensive
|
|
45
|
+
return False
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _verify_fuzz(candidate: search.Candidate, target_id: str) -> tuple[bool, int]:
|
|
50
|
+
cases = corpus.fuzz_cases(target_id, FUZZ_COUNT)
|
|
51
|
+
namespace: dict[str, Any] = {}
|
|
52
|
+
exec(compile(candidate.source, "<synth>", "exec"), namespace)
|
|
53
|
+
fn = namespace[candidate.function_name]
|
|
54
|
+
for inputs, expected in cases:
|
|
55
|
+
try:
|
|
56
|
+
if fn(*inputs) != expected:
|
|
57
|
+
return False, -1
|
|
58
|
+
except Exception: # pragma: no cover - defensive
|
|
59
|
+
return False, -1
|
|
60
|
+
return True, len(cases)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _classify(target_id: str, kind: str) -> dict[str, str]:
|
|
64
|
+
solved = _solved_catalog_ids()
|
|
65
|
+
if target_id in solved:
|
|
66
|
+
status = (
|
|
67
|
+
"rediscovered" if kind == "scan" else "rediscovered-via-template"
|
|
68
|
+
)
|
|
69
|
+
note = (
|
|
70
|
+
"target already solved in the shared (4-language) catalog; "
|
|
71
|
+
"this candidate matches the same contract all tier runners assert"
|
|
72
|
+
)
|
|
73
|
+
else:
|
|
74
|
+
status = "new-to-catalog"
|
|
75
|
+
note = (
|
|
76
|
+
"not part of the shared catalog yet "
|
|
77
|
+
"- a candidate worth porting to the language tiers"
|
|
78
|
+
)
|
|
79
|
+
return {"status": status, "note": note}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def discover(
|
|
83
|
+
smoke: bool = False, scan_budget: int | None = None, fuzz_count: int | None = None
|
|
84
|
+
) -> dict[str, Any]:
|
|
85
|
+
"""Run the full discovery pass over every target."""
|
|
86
|
+
targets = corpus.load_targets()
|
|
87
|
+
solved = _solved_catalog_ids()
|
|
88
|
+
global FUZZ_COUNT
|
|
89
|
+
if fuzz_count is not None:
|
|
90
|
+
FUZZ_COUNT = fuzz_count
|
|
91
|
+
|
|
92
|
+
entries: list[dict[str, Any]] = []
|
|
93
|
+
for target_id, target in targets.items():
|
|
94
|
+
started = time.monotonic()
|
|
95
|
+
budget = 200_000 if smoke else (scan_budget or 600_000)
|
|
96
|
+
candidate = search.discover_for_target(target, scan_budget=budget)
|
|
97
|
+
elapsed = time.monotonic() - started
|
|
98
|
+
if candidate is None:
|
|
99
|
+
entries.append(
|
|
100
|
+
{
|
|
101
|
+
"id": target_id,
|
|
102
|
+
"status": "no-candidate-found",
|
|
103
|
+
"kind": target["kind"],
|
|
104
|
+
"search_ms": round(elapsed * 1000),
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
continue
|
|
108
|
+
curated_ok = _verify_curated(candidate, target)
|
|
109
|
+
fuzz_ok, fuzz_run = _verify_fuzz(candidate, target_id)
|
|
110
|
+
classification = _classify(target_id, candidate.kind)
|
|
111
|
+
entries.append(
|
|
112
|
+
{
|
|
113
|
+
"id": target_id,
|
|
114
|
+
"status": (
|
|
115
|
+
"verified" if (curated_ok and fuzz_ok) else "candidate-rejected"
|
|
116
|
+
),
|
|
117
|
+
"hidden": False,
|
|
118
|
+
"search_ms": round(elapsed * 1000),
|
|
119
|
+
"curated_examples_pass": curated_ok,
|
|
120
|
+
"fuzz_examples_pass": fuzz_ok,
|
|
121
|
+
"fuzz_run": fuzz_run,
|
|
122
|
+
"source": candidate.source.strip(),
|
|
123
|
+
"size_metric": candidate.nodes,
|
|
124
|
+
"kind": candidate.kind,
|
|
125
|
+
"strategy": candidate.params.get("strategy", ""),
|
|
126
|
+
"time_class": candidate.time_class,
|
|
127
|
+
"novelty": classification["status"],
|
|
128
|
+
"novelty_note": classification["note"],
|
|
129
|
+
"signature": target["signature"],
|
|
130
|
+
}
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
report: dict[str, Any] = {
|
|
134
|
+
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
135
|
+
"fuzz_per_target": FUZZ_COUNT,
|
|
136
|
+
"solved_catalog_problems": len(solved),
|
|
137
|
+
"targets": entries,
|
|
138
|
+
}
|
|
139
|
+
_write_report(report)
|
|
140
|
+
return report
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _write_report(report: dict[str, Any]) -> None:
|
|
144
|
+
DISCOVERIES_DIR.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
SOLUTIONS_DIR.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
(DISCOVERIES_DIR / "report.json").write_text(
|
|
147
|
+
json.dumps(report, indent=2) + "\n", encoding="utf-8"
|
|
148
|
+
)
|
|
149
|
+
for entry in report["targets"]:
|
|
150
|
+
if "source" in entry:
|
|
151
|
+
(SOLUTIONS_DIR / f"{entry['id']}.py").write_text(
|
|
152
|
+
entry["source"] + "\n", encoding="utf-8"
|
|
153
|
+
)
|
|
154
|
+
(DISCOVERIES_DIR / "report.md").write_text(_render_markdown(report), encoding="utf-8")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _render_markdown(report: dict[str, Any]) -> str:
|
|
158
|
+
lines = [
|
|
159
|
+
"# Discovery report",
|
|
160
|
+
"",
|
|
161
|
+
f"Fuzz cases per target: {report['fuzz_per_target']} · "
|
|
162
|
+
f"solved catalog problems: {report['solved_catalog_problems']}",
|
|
163
|
+
"",
|
|
164
|
+
"| target | status | strategy | size | novelty |",
|
|
165
|
+
"| --- | --- | --- | ---: | --- |",
|
|
166
|
+
]
|
|
167
|
+
for entry in report["targets"]:
|
|
168
|
+
lines.append(
|
|
169
|
+
"| {} | {} | {} | {} | {} |".format(
|
|
170
|
+
entry["id"],
|
|
171
|
+
entry["status"],
|
|
172
|
+
entry.get("strategy", "-"),
|
|
173
|
+
entry.get("size_metric", "-"),
|
|
174
|
+
entry.get("novelty", "-"),
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
lines.append("")
|
|
178
|
+
for entry in report["targets"]:
|
|
179
|
+
if "source" not in entry or entry["status"] != "verified":
|
|
180
|
+
continue
|
|
181
|
+
lines.extend(
|
|
182
|
+
[
|
|
183
|
+
f"## {entry['id']} (`{entry['kind']}`)",
|
|
184
|
+
"",
|
|
185
|
+
f"`{entry['signature']}`",
|
|
186
|
+
"",
|
|
187
|
+
f"- Complexity: {entry['time_class']}",
|
|
188
|
+
f"- Novelty: {entry['novelty']} — {entry['novelty_note']}",
|
|
189
|
+
f"- Verified on {entry['curated_examples_pass'] and 'all curated examples'}"
|
|
190
|
+
f" and {entry['fuzz_run']} fuzz inputs.",
|
|
191
|
+
"",
|
|
192
|
+
"```python",
|
|
193
|
+
entry["source"],
|
|
194
|
+
"```",
|
|
195
|
+
"",
|
|
196
|
+
]
|
|
197
|
+
)
|
|
198
|
+
return "\n".join(lines) + "\n"
|
synth/grammar.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Expression grammar for synthesized algorithms.
|
|
2
|
+
|
|
3
|
+
A tiny, typed-free expression language used by the search backends. Every
|
|
4
|
+
expression can be evaluated over a binding environment and printed back as
|
|
5
|
+
Python source, so discovered candidates are always runnable and reviewable.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
# Hard negative/positive sentinels far from real inputs.
|
|
14
|
+
NEG_INF = -(1 << 30)
|
|
15
|
+
POS_INF = 1 << 30
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Node:
|
|
20
|
+
"""Immutable expression tree node.
|
|
21
|
+
|
|
22
|
+
``op`` is either a literal value (int/str) or one of the operator names
|
|
23
|
+
below. ``args`` holds child nodes.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
op: str | int
|
|
27
|
+
args: tuple[Node, ...] = ()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def const(value: int | str) -> Node:
|
|
31
|
+
return Node(value)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def var(name: str) -> Node:
|
|
35
|
+
return Node("var", (Node(name),))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def call(name: str, *args: Node) -> Node:
|
|
39
|
+
return Node(name, args)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# nodes available to a scanner program ---------------------------------------
|
|
43
|
+
|
|
44
|
+
UNARY_OPS = {"neg", "abs"}
|
|
45
|
+
BINARY_OPS = {"add", "sub", "mul", "fdiv", "mod", "min", "max"}
|
|
46
|
+
CMP_OPS = {"lt", "gt", "le", "ge", "eq"}
|
|
47
|
+
|
|
48
|
+
ALL_OPS = UNARY_OPS | BINARY_OPS | CMP_OPS
|
|
49
|
+
|
|
50
|
+
# simple canonical variables a scan understands
|
|
51
|
+
VARIABLES = {"i", "n", "x"}
|
|
52
|
+
|
|
53
|
+
_OPERATOR_TO_PY = {
|
|
54
|
+
"neg": "(-{})",
|
|
55
|
+
"abs": "abs({})",
|
|
56
|
+
"add": "({} + {})",
|
|
57
|
+
"sub": "({} - {})",
|
|
58
|
+
"mul": "({} * {})",
|
|
59
|
+
"fdiv": "({} // {})",
|
|
60
|
+
"mod": "({} % {})",
|
|
61
|
+
"min": "min({}, {})",
|
|
62
|
+
"max": "max({}, {})",
|
|
63
|
+
"lt": "({} < {})",
|
|
64
|
+
"gt": "({} > {})",
|
|
65
|
+
"le": "({} <= {})",
|
|
66
|
+
"ge": "({} >= {})",
|
|
67
|
+
"eq": "({} == {})",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def evaluate(node: Node, env: dict[str, Any]) -> Any:
|
|
72
|
+
"""Evaluate the expression tree over ``env``.
|
|
73
|
+
|
|
74
|
+
Division by zero and mod-by-zero short-circuit to a benign neutral value
|
|
75
|
+
so search does not crash on degenerate candidates.
|
|
76
|
+
"""
|
|
77
|
+
op = node.op
|
|
78
|
+
if isinstance(op, int):
|
|
79
|
+
return op
|
|
80
|
+
if op == "var":
|
|
81
|
+
key = node.args[0].op
|
|
82
|
+
assert isinstance(key, str)
|
|
83
|
+
return env[key]
|
|
84
|
+
if op == "neg":
|
|
85
|
+
return -evaluate(node.args[0], env)
|
|
86
|
+
if op == "abs":
|
|
87
|
+
return abs(evaluate(node.args[0], env))
|
|
88
|
+
if op in BINARY_OPS or op in CMP_OPS:
|
|
89
|
+
left = evaluate(node.args[0], env)
|
|
90
|
+
right = evaluate(node.args[1], env)
|
|
91
|
+
if op == "add":
|
|
92
|
+
return left + right
|
|
93
|
+
if op == "sub":
|
|
94
|
+
return left - right
|
|
95
|
+
if op == "mul":
|
|
96
|
+
return left * right
|
|
97
|
+
if op == "fdiv":
|
|
98
|
+
if right == 0:
|
|
99
|
+
return left
|
|
100
|
+
sign = -1 if (left < 0) != (right < 0) else 1
|
|
101
|
+
return sign * (abs(left) // abs(right))
|
|
102
|
+
if op == "mod":
|
|
103
|
+
if right == 0:
|
|
104
|
+
return 0
|
|
105
|
+
sign = 1 if right > 0 else -1
|
|
106
|
+
return sign * (((left % abs(right)) + abs(right)) % abs(right))
|
|
107
|
+
if op == "min":
|
|
108
|
+
return min(left, right)
|
|
109
|
+
if op == "max":
|
|
110
|
+
return max(left, right)
|
|
111
|
+
if op == "lt":
|
|
112
|
+
return left < right
|
|
113
|
+
if op == "gt":
|
|
114
|
+
return left > right
|
|
115
|
+
if op == "le":
|
|
116
|
+
return left <= right
|
|
117
|
+
if op == "ge":
|
|
118
|
+
return left >= right
|
|
119
|
+
return left == right
|
|
120
|
+
raise ValueError(f"unknown node: {node}")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def to_python(node: Node) -> str:
|
|
124
|
+
"""Render the expression tree as Python source."""
|
|
125
|
+
op = node.op
|
|
126
|
+
if isinstance(op, int):
|
|
127
|
+
return repr(op)
|
|
128
|
+
if op == "var":
|
|
129
|
+
return str(node.args[0].op)
|
|
130
|
+
if op in UNARY_OPS:
|
|
131
|
+
return _OPERATOR_TO_PY[op].format(to_python(node.args[0]))
|
|
132
|
+
if op in BINARY_OPS or op in CMP_OPS:
|
|
133
|
+
return _OPERATOR_TO_PY[op].format(
|
|
134
|
+
to_python(node.args[0]), to_python(node.args[1])
|
|
135
|
+
)
|
|
136
|
+
raise ValueError(f"unknown node: {node}")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def size(node: Node) -> int:
|
|
140
|
+
"""Number of nodes in the tree (a cheap complexity metric)."""
|
|
141
|
+
return 1 + sum(size(child) for child in node.args)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def tree_depth(node: Node) -> int:
|
|
145
|
+
"""Tree depth (used for search budget pruning)."""
|
|
146
|
+
return 1 + max((tree_depth(child) for child in node.args), default=0)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def grow(depth: int, variables: tuple[str, ...], constants: tuple[int, ...]) -> list[Node]:
|
|
150
|
+
"""Enumerate all expressions up to ``depth`` over the given leaves."""
|
|
151
|
+
if depth <= 1:
|
|
152
|
+
return [var(name) for name in variables] + [const(c) for c in constants]
|
|
153
|
+
results = [var(name) for name in variables] + [const(c) for c in constants]
|
|
154
|
+
below = grow(depth - 1, variables, constants)
|
|
155
|
+
for name in UNARY_OPS:
|
|
156
|
+
for child in below:
|
|
157
|
+
results.append(call(name, child))
|
|
158
|
+
for name in BINARY_OPS | CMP_OPS:
|
|
159
|
+
for left in below:
|
|
160
|
+
for right in below:
|
|
161
|
+
results.append(call(name, left, right))
|
|
162
|
+
return results
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def dedupe_python(nodes: list[Node]) -> list[Node]:
|
|
166
|
+
"""Drop nodes whose rendered Python source is identical (dedup + normalize)."""
|
|
167
|
+
seen: set[str] = set()
|
|
168
|
+
out: list[Node] = []
|
|
169
|
+
for node in nodes:
|
|
170
|
+
key = to_python(node)
|
|
171
|
+
if key not in seen:
|
|
172
|
+
seen.add(key)
|
|
173
|
+
out.append(node)
|
|
174
|
+
return out
|