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,615 @@
|
|
|
1
|
+
"""The Researcher: the LLM roles of a round, from prompt to checked candidates.
|
|
2
|
+
|
|
3
|
+
Four roles, each with its own backend and model: the planner plans the
|
|
4
|
+
round's directions to the quota of each mechanism type, the proposer writes
|
|
5
|
+
one candidate per direction (and fixes those the parser or the Gate
|
|
6
|
+
refuses), the reviewer judges each gated candidate's consistency in a
|
|
7
|
+
separate call (and the proposer rewrites, once, each it finds
|
|
8
|
+
inconsistent: ``revise``), and the distiller turns the ledger into the memory.
|
|
9
|
+
|
|
10
|
+
What the prompts say comes from ``Prompts``: the templates (built in or
|
|
11
|
+
replaced by the config), the market, the label's horizon, the scoring, the
|
|
12
|
+
vocabulary and the mechanism types, which also set the plan's slots and
|
|
13
|
+
the types a candidate may have.
|
|
14
|
+
|
|
15
|
+
A round's directions are split into proposal groups: contiguous runs of
|
|
16
|
+
near-equal size, in the plan's type order. Each group's first proposal,
|
|
17
|
+
fixes, review, rewrite and second review are calls of their own, and every
|
|
18
|
+
group's call of a stage is sent at once on threads and waited for; the
|
|
19
|
+
parser and the Gate then run over the whole round in slot order, so the
|
|
20
|
+
outcome does not depend on which reply lands first. The planner and the
|
|
21
|
+
distiller make one call a round.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import dataclasses
|
|
25
|
+
import json
|
|
26
|
+
import re
|
|
27
|
+
import time
|
|
28
|
+
from collections.abc import Callable, Mapping
|
|
29
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
30
|
+
from dataclasses import asdict, dataclass
|
|
31
|
+
|
|
32
|
+
from factor_agent.candidate import Candidate
|
|
33
|
+
from factor_agent.events import CallEnd, CallStart, Event, Events, GateOutcome, GateStep, ReviewVerdict, reply_facts
|
|
34
|
+
from factor_agent.gate import Gate, Passed
|
|
35
|
+
from factor_agent.llm.base import LLMBackend, ProviderQuotaExhausted, Request
|
|
36
|
+
from factor_agent.prompts import ACTION_RULES, MEMORY_ITEMS, MEMORY_PARTS, ONLY_NEW, SECTIONS, Prompts
|
|
37
|
+
from factor_agent.zoo import Zoo
|
|
38
|
+
|
|
39
|
+
_FENCE = re.compile(r"```(?:json)?\s*(.*?)```", re.DOTALL)
|
|
40
|
+
|
|
41
|
+
#: The planner's actions: a new mechanism, an orthogonal mutation of an accepted one, a refinement of one.
|
|
42
|
+
ACTIONS = ("new", "mutation", "refinement")
|
|
43
|
+
|
|
44
|
+
#: The context sections each role reads (see ``factor_agent.prompts.SECTIONS``); ``render_context`` shows them.
|
|
45
|
+
PLANNER_SECTIONS = ("seed", "families", "memory", "earlier_summary", "themes", "recent_history", "last_round", "best")
|
|
46
|
+
PROPOSER_SECTIONS = ("families", "memory", "last_round", "best")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class Direction:
|
|
51
|
+
"""One planned slot of a round: its mechanism type, the action, the parent trial and the idea.
|
|
52
|
+
|
|
53
|
+
``parent`` is the id of the evaluated trial a mutation or a refinement
|
|
54
|
+
builds on (``_feedback.trial_id``), and is empty for a new direction.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
mechanism_type: str
|
|
58
|
+
action: str
|
|
59
|
+
parent: str
|
|
60
|
+
direction: str
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> dict:
|
|
63
|
+
"""The direction as the ledger and the prompts hold it."""
|
|
64
|
+
return asdict(self)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class Verdict:
|
|
69
|
+
"""The reviewer's judgement of one candidate."""
|
|
70
|
+
|
|
71
|
+
consistent: bool
|
|
72
|
+
reason: str
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def refusal(self) -> str:
|
|
76
|
+
"""The reason an inconsistent candidate is sent back, and rejected, with: the reviewer's own words."""
|
|
77
|
+
return f"the reviewer found it inconsistent: {self.reason}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class Attempt:
|
|
82
|
+
"""One refused version of a candidate: what the proposer wrote, where it failed and why.
|
|
83
|
+
|
|
84
|
+
``stage`` is ``"parse"`` (not a valid candidate for its direction), ``"gate"``,
|
|
85
|
+
``"review"`` (the reviewer found it inconsistent) or ``"novelty"`` (a new
|
|
86
|
+
direction's candidate that measures what an earlier trial measured).
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
item: object
|
|
90
|
+
stage: str
|
|
91
|
+
reasons: tuple[str, ...]
|
|
92
|
+
#: How to write it instead, from the vocabulary's fixes (``Rejected.hints``); sent with the reasons.
|
|
93
|
+
hints: tuple[str, ...] = ()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class Proposal:
|
|
98
|
+
"""One direction of a round after its retries.
|
|
99
|
+
|
|
100
|
+
``checked`` is set when the last version passed the Gate; otherwise the
|
|
101
|
+
slot is a failed trial and the last of ``attempts`` says why. ``item``
|
|
102
|
+
is the last version the proposer wrote, ``candidate`` its parsed form
|
|
103
|
+
(None if it did not parse), ``attempts`` every refused version in order,
|
|
104
|
+
``group`` the proposal group whose calls write, fix and review it, and
|
|
105
|
+
``slot`` its place in the round, from 0.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
direction: Direction
|
|
109
|
+
item: object
|
|
110
|
+
candidate: Candidate | None
|
|
111
|
+
checked: Passed | None
|
|
112
|
+
attempts: tuple[Attempt, ...]
|
|
113
|
+
group: int
|
|
114
|
+
slot: int
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True)
|
|
118
|
+
class _Scope:
|
|
119
|
+
"""What an LLM call concerns, as its start and end in the events file say: its round, proposal group and slots.
|
|
120
|
+
|
|
121
|
+
The planner's and the distiller's calls concern their round only.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
round: int
|
|
125
|
+
group: int | None = None
|
|
126
|
+
slots: tuple[int, ...] = ()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def output_unit(checked: Passed) -> str:
|
|
130
|
+
"""The unit of a gated graph's output, as the Gate's dimension inference names it: "price", "pure number"...
|
|
131
|
+
|
|
132
|
+
"unknown" where the inference cannot say, as for the log of a price.
|
|
133
|
+
"""
|
|
134
|
+
return checked.stats.get("dimension", "unknown")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class Researcher:
|
|
138
|
+
"""The LLM roles of a campaign.
|
|
139
|
+
|
|
140
|
+
Parameters
|
|
141
|
+
----------
|
|
142
|
+
roles : dict
|
|
143
|
+
Maps each role (``planner``, ``proposer``, ``reviewer``,
|
|
144
|
+
``distiller``) to its ``(backend, model)``.
|
|
145
|
+
prompts : Prompts
|
|
146
|
+
The templates and what fills them; its mechanism types are the plan's, in order.
|
|
147
|
+
groups : int, default 1
|
|
148
|
+
The proposal groups a round's directions are split into (at most one per direction).
|
|
149
|
+
events : Events, optional
|
|
150
|
+
The run's events file, which every call, every check of a version
|
|
151
|
+
and every verdict is written to (``factor_agent.events``).
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
def __init__(
|
|
155
|
+
self, roles: dict[str, tuple[LLMBackend, str]], prompts: Prompts, groups: int = 1,
|
|
156
|
+
events: Events | None = None,
|
|
157
|
+
):
|
|
158
|
+
self.roles = dict(roles)
|
|
159
|
+
self.prompts = prompts
|
|
160
|
+
self.groups = groups
|
|
161
|
+
self.events = events
|
|
162
|
+
self._types = prompts.mechanism_types
|
|
163
|
+
|
|
164
|
+
def plan(
|
|
165
|
+
self, number: int, quota: dict[str, int], context: dict[str, str], parents: list[str] | None,
|
|
166
|
+
) -> tuple[list[Direction], list[str]]:
|
|
167
|
+
"""Return round ``number``'s directions, ``quota[type]`` of each type in type order, and the plan's issues.
|
|
168
|
+
|
|
169
|
+
``parents`` are the ids of the trials a mutation or a refinement may
|
|
170
|
+
name, or None when the planner reads no earlier trial and every
|
|
171
|
+
direction is new. A direction naming another parent is kept as a new
|
|
172
|
+
direction; one the planner leaves out, or writes with an unknown type
|
|
173
|
+
or action, is replaced by a new direction of its type. Each such
|
|
174
|
+
repair is one issue.
|
|
175
|
+
"""
|
|
176
|
+
prompt = self.prompts.render(
|
|
177
|
+
"plan", round=number, context=render_context(context, PLANNER_SECTIONS), quota=json.dumps(quota),
|
|
178
|
+
actions=ONLY_NEW if parents is None else ACTION_RULES,
|
|
179
|
+
)
|
|
180
|
+
parents = parents or []
|
|
181
|
+
issues, by_type = [], {kind: [] for kind in self._types}
|
|
182
|
+
try:
|
|
183
|
+
items = _json_array(self._ask("planner", prompt, _Scope(number)))
|
|
184
|
+
except ValueError as exc:
|
|
185
|
+
items, issues = [], [f"planner: {exc}"]
|
|
186
|
+
for i, item in enumerate(items, 1):
|
|
187
|
+
problem = _direction_problem(item, self._types)
|
|
188
|
+
if problem:
|
|
189
|
+
issues.append(f"planner: direction {i} {problem}")
|
|
190
|
+
continue
|
|
191
|
+
if item["action"] == "new":
|
|
192
|
+
item = {**item, "parent": ""}
|
|
193
|
+
elif item["parent"] not in parents:
|
|
194
|
+
issues.append(
|
|
195
|
+
f"planner: direction {i} names {item['parent']!r}, which is not an evaluated trial; "
|
|
196
|
+
f"it is planned as a new direction"
|
|
197
|
+
)
|
|
198
|
+
item = {**item, "action": "new", "parent": ""}
|
|
199
|
+
by_type[item["mechanism_type"]].append(Direction(**{k: item[k] for k in Direction.__annotations__}))
|
|
200
|
+
directions = []
|
|
201
|
+
for kind in self._types:
|
|
202
|
+
if len(by_type[kind]) > quota[kind]:
|
|
203
|
+
issues.append(f"planner: {len(by_type[kind])} {kind} directions for {quota[kind]} slots; the first are kept")
|
|
204
|
+
planned = by_type[kind][:quota[kind]]
|
|
205
|
+
if len(planned) < quota[kind]:
|
|
206
|
+
issues.append(f"planner: {len(planned)} of {quota[kind]} {kind} directions; the rest are new directions")
|
|
207
|
+
planned += [
|
|
208
|
+
Direction(kind, "new", "", f"a {kind} mechanism not tried before")
|
|
209
|
+
for _ in range(quota[kind] - len(planned))
|
|
210
|
+
]
|
|
211
|
+
directions += planned
|
|
212
|
+
return directions, issues
|
|
213
|
+
|
|
214
|
+
def propose(
|
|
215
|
+
self, number: int, directions: list[Direction], context: dict[str, str], gate: Gate, zoo: Zoo, retries: int,
|
|
216
|
+
parents: Mapping[str, str] | None = None,
|
|
217
|
+
) -> list[Proposal]:
|
|
218
|
+
"""Ask for one candidate per direction and return one ``Proposal`` per direction, in order.
|
|
219
|
+
|
|
220
|
+
Each proposal group's directions go in a request of their own, all
|
|
221
|
+
sent at once. A candidate must have its direction's mechanism type.
|
|
222
|
+
Every candidate the parser or ``gate`` (checking originality against
|
|
223
|
+
``zoo``) refuses is sent back with its reasons, all of a group's in
|
|
224
|
+
one request, up to ``retries`` times; the candidates are checked in
|
|
225
|
+
slot order once every group's reply is back. A direction with a
|
|
226
|
+
parent in ``parents`` (a trial's id, and the trial as
|
|
227
|
+
``_feedback.parent`` writes it) carries that trial as its
|
|
228
|
+
``parent_trial``.
|
|
229
|
+
"""
|
|
230
|
+
parents = parents or {}
|
|
231
|
+
directions_by_group = _split(len(directions), self.groups)
|
|
232
|
+
context = render_context(context, PROPOSER_SECTIONS)
|
|
233
|
+
items = self._ask_groups(
|
|
234
|
+
"proposer", number, dict(enumerate(directions_by_group)), list(range(len(directions))),
|
|
235
|
+
lambda slots: self.prompts.render(
|
|
236
|
+
"propose", round=number, context=context, n=len(slots),
|
|
237
|
+
directions=json.dumps([
|
|
238
|
+
{"number": i + 1, **directions[s].to_dict(),
|
|
239
|
+
**({"parent_trial": parents[directions[s].parent]} if directions[s].parent in parents else {})}
|
|
240
|
+
for i, s in enumerate(slots)
|
|
241
|
+
], indent=2),
|
|
242
|
+
),
|
|
243
|
+
lambda slots, text: _items(text, [None] * len(slots)),
|
|
244
|
+
)
|
|
245
|
+
group_of = {slot: group for group, slots in enumerate(directions_by_group) for slot in slots}
|
|
246
|
+
attempts: list[list[Attempt]] = [[] for _ in directions]
|
|
247
|
+
proposals: list[Proposal | None] = [None] * len(directions)
|
|
248
|
+
pending = list(range(len(directions)))
|
|
249
|
+
for retry in range(retries + 1):
|
|
250
|
+
for slot in pending:
|
|
251
|
+
proposals[slot] = self._check(
|
|
252
|
+
number, slot, "fixed" if retry else "proposed", directions[slot], items[slot], gate, zoo,
|
|
253
|
+
attempts[slot], group_of[slot],
|
|
254
|
+
)
|
|
255
|
+
pending = [slot for slot in pending if proposals[slot].checked is None]
|
|
256
|
+
if not pending or retry == retries:
|
|
257
|
+
break
|
|
258
|
+
for slot, item in zip(pending, self._ask_to_fix(number, [proposals[slot] for slot in pending])):
|
|
259
|
+
items[slot] = item
|
|
260
|
+
return proposals
|
|
261
|
+
|
|
262
|
+
def review(self, number: int, proposals: list[Proposal]) -> list[Verdict]:
|
|
263
|
+
"""Judge the consistency of each gated proposal's candidate, one call per proposal group, all sent at once.
|
|
264
|
+
|
|
265
|
+
Each candidate is listed with its output unit, as the Gate inferred
|
|
266
|
+
it (``output_unit``). A group whose reply holds no usable verdict at
|
|
267
|
+
all is asked once more; a candidate still without a verdict is inconsistent.
|
|
268
|
+
"""
|
|
269
|
+
def render(positions):
|
|
270
|
+
return self.prompts.render("review", round=number, candidates=json.dumps([
|
|
271
|
+
{"number": i + 1, **proposals[p].candidate.to_dict(), "output_unit": output_unit(proposals[p].checked)}
|
|
272
|
+
for i, p in enumerate(positions)
|
|
273
|
+
], indent=2))
|
|
274
|
+
|
|
275
|
+
def read(positions, text):
|
|
276
|
+
try:
|
|
277
|
+
items = _json_array(text)
|
|
278
|
+
except ValueError:
|
|
279
|
+
items = []
|
|
280
|
+
found: dict[int, Verdict] = {}
|
|
281
|
+
for item in items:
|
|
282
|
+
if isinstance(item, dict) and isinstance(item.get("consistent"), bool) and isinstance(item.get("number"), int):
|
|
283
|
+
found.setdefault(item["number"], Verdict(item["consistent"], str(item.get("reason", ""))))
|
|
284
|
+
return [found.get(i + 1) for i in range(len(positions))]
|
|
285
|
+
|
|
286
|
+
judged: dict[int, Verdict | None] = {}
|
|
287
|
+
asking = _positions_by_group([p.group for p in proposals])
|
|
288
|
+
slots = [p.slot for p in proposals]
|
|
289
|
+
for _ in range(2):
|
|
290
|
+
judged |= self._ask_groups("reviewer", number, asking, slots, render, read)
|
|
291
|
+
asking = {group: positions for group, positions in asking.items() if all(judged[p] is None for p in positions)}
|
|
292
|
+
missing = Verdict(False, "the reviewer gave no verdict on this candidate")
|
|
293
|
+
verdicts = [judged[p] or missing for p in range(len(proposals))]
|
|
294
|
+
for proposal, verdict in zip(proposals, verdicts):
|
|
295
|
+
self._event(ReviewVerdict(
|
|
296
|
+
round=number, slot=proposal.slot, consistent=verdict.consistent, reason=verdict.reason,
|
|
297
|
+
rewrite=any(attempt.stage == "review" for attempt in proposal.attempts),
|
|
298
|
+
))
|
|
299
|
+
return verdicts
|
|
300
|
+
|
|
301
|
+
def revise(
|
|
302
|
+
self, number: int, inconsistent: list[tuple[Proposal, Verdict]], gate: Gate, zoo: Zoo,
|
|
303
|
+
) -> list[Proposal]:
|
|
304
|
+
"""Send each candidate the reviewer found inconsistent back to be rewritten once; return the rewrites, in order.
|
|
305
|
+
|
|
306
|
+
Every candidate goes, with the reviewer's reason and its output unit, in one rewrite request
|
|
307
|
+
per proposal group, all sent at once, and its first review becomes an attempt of stage ``"review"``. Each
|
|
308
|
+
rewrite is checked by the parser and ``gate`` once, with no further
|
|
309
|
+
retries: a refused rewrite is a proposal without ``checked``. The
|
|
310
|
+
refused versions' graphs are withdrawn from ``gate`` first, so a
|
|
311
|
+
rewrite that keeps its graph is not its own duplicate.
|
|
312
|
+
"""
|
|
313
|
+
return self._rewrite(
|
|
314
|
+
number, [(proposal, Attempt(proposal.item, "review", (verdict.refusal,))) for proposal, verdict in inconsistent],
|
|
315
|
+
gate, zoo,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
def renew(
|
|
319
|
+
self, number: int, repeated: list[tuple[Proposal, str]], gate: Gate, zoo: Zoo, retries: int = 0,
|
|
320
|
+
) -> list[Proposal]:
|
|
321
|
+
"""Send each new direction's candidate that measures what an earlier trial measured back to be rewritten once.
|
|
322
|
+
|
|
323
|
+
Each goes with its reason, as an attempt of stage ``"novelty"``, in
|
|
324
|
+
the rewrite request ``revise`` sends; a rewrite the parser or ``gate``
|
|
325
|
+
refuses is sent back with its reasons up to ``retries`` times, as a
|
|
326
|
+
proposed candidate is. Return the rewrites, in order.
|
|
327
|
+
"""
|
|
328
|
+
return self._rewrite(
|
|
329
|
+
number, [(proposal, Attempt(proposal.item, "novelty", (reason,))) for proposal, reason in repeated], gate, zoo,
|
|
330
|
+
retries,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
def _rewrite(
|
|
334
|
+
self, number: int, refused: list[tuple[Proposal, Attempt]], gate: Gate, zoo: Zoo, retries: int = 0,
|
|
335
|
+
) -> list[Proposal]:
|
|
336
|
+
"""Send gated proposals back with the attempt that refused them, in one rewrite request per proposal group.
|
|
337
|
+
|
|
338
|
+
A rewrite the parser or ``gate`` refuses is sent back to be fixed up to ``retries`` times.
|
|
339
|
+
"""
|
|
340
|
+
if not refused:
|
|
341
|
+
return []
|
|
342
|
+
sent_back = []
|
|
343
|
+
for proposal, attempt in refused:
|
|
344
|
+
gate.withdraw(proposal.checked.graph)
|
|
345
|
+
sent_back.append(dataclasses.replace(proposal, attempts=(*proposal.attempts, attempt)))
|
|
346
|
+
rewrites = [
|
|
347
|
+
self._check(
|
|
348
|
+
number, proposal.slot, "rewritten", proposal.direction, item, gate, zoo, list(proposal.attempts),
|
|
349
|
+
proposal.group,
|
|
350
|
+
)
|
|
351
|
+
for proposal, item in zip(sent_back, self._ask_to_fix(number, sent_back))
|
|
352
|
+
]
|
|
353
|
+
for _ in range(retries):
|
|
354
|
+
pending = [k for k, rewrite in enumerate(rewrites) if rewrite.checked is None]
|
|
355
|
+
if not pending:
|
|
356
|
+
break
|
|
357
|
+
for k, item in zip(pending, self._ask_to_fix(number, [rewrites[k] for k in pending])):
|
|
358
|
+
rewrite = rewrites[k]
|
|
359
|
+
rewrites[k] = self._check(
|
|
360
|
+
number, rewrite.slot, "fixed", rewrite.direction, item, gate, zoo, list(rewrite.attempts),
|
|
361
|
+
rewrite.group,
|
|
362
|
+
)
|
|
363
|
+
return rewrites
|
|
364
|
+
|
|
365
|
+
def distill(self, number: int, memory: str, history: str, accepted: str) -> str | None:
|
|
366
|
+
"""The memory the distiller folds from the previous ``memory``, ``history`` (the trials since it) and
|
|
367
|
+
``accepted``, every factor accepted so far (``_feedback.accepted``).
|
|
368
|
+
|
|
369
|
+
The reply must hold the three parts under their headings, in order;
|
|
370
|
+
each part keeps its first ``MEMORY_ITEMS`` items. An empty reply, or
|
|
371
|
+
one without the three headings (cut short, or not a memory), gives
|
|
372
|
+
None, and the previous memory stays.
|
|
373
|
+
"""
|
|
374
|
+
reply = self._ask("distiller", self.prompts.render(
|
|
375
|
+
"distill", round=number, memory=memory or "(none: this is the first memory)", history=history,
|
|
376
|
+
accepted=accepted or "(none: no candidate has been accepted yet)",
|
|
377
|
+
), _Scope(number))
|
|
378
|
+
return _memory(reply)
|
|
379
|
+
|
|
380
|
+
def _check(
|
|
381
|
+
self, number: int, slot: int, step: GateStep, direction: Direction, item: object, gate: Gate, zoo: Zoo,
|
|
382
|
+
attempts: list[Attempt], group: int,
|
|
383
|
+
) -> Proposal:
|
|
384
|
+
"""Parse and gate one item, of ``slot`` in proposal group ``group``; a refusal is appended to ``attempts``.
|
|
385
|
+
|
|
386
|
+
``step`` says which version of the slot's candidate it is, as the
|
|
387
|
+
events file names it: ``"proposed"``, ``"fixed"`` or ``"rewritten"``.
|
|
388
|
+
"""
|
|
389
|
+
def refused(stage, reasons, candidate=None, shown=item, hints=()):
|
|
390
|
+
attempts.append(Attempt(shown, stage, tuple(reasons), tuple(hints)))
|
|
391
|
+
self._event(GateOutcome(
|
|
392
|
+
round=number, slot=slot, step=step, item=shown, passed=False, stage=stage,
|
|
393
|
+
reasons=list(reasons), hints=list(hints),
|
|
394
|
+
))
|
|
395
|
+
return Proposal(direction, shown, candidate, None, tuple(attempts), group, slot)
|
|
396
|
+
|
|
397
|
+
if isinstance(item, _Missing):
|
|
398
|
+
return refused("parse", [item.reason], shown=item.previous)
|
|
399
|
+
try:
|
|
400
|
+
candidate = Candidate.from_dict(item, self._types)
|
|
401
|
+
except ValueError as exc:
|
|
402
|
+
return refused("parse", [str(exc)])
|
|
403
|
+
if candidate.mechanism_type != direction.mechanism_type:
|
|
404
|
+
return refused("parse", [
|
|
405
|
+
f"mechanism_type {candidate.mechanism_type!r} is not its direction's {direction.mechanism_type!r}"
|
|
406
|
+
])
|
|
407
|
+
checked = gate.check(candidate, zoo)
|
|
408
|
+
if isinstance(checked, Passed):
|
|
409
|
+
self._event(GateOutcome(round=number, slot=slot, step=step, item=item, passed=True))
|
|
410
|
+
return Proposal(direction, item, candidate, checked, tuple(attempts), group, slot)
|
|
411
|
+
return refused("gate", checked.reasons, candidate, hints=checked.hints)
|
|
412
|
+
|
|
413
|
+
def _ask_to_fix(self, number: int, refused: list[Proposal]) -> list:
|
|
414
|
+
"""Send each refused proposal's last version back with its last attempt, one request per proposal group, all at once.
|
|
415
|
+
|
|
416
|
+
Returns one fixed item per proposal, in order. A version that passed
|
|
417
|
+
the Gate (one the reviewer sent back) is listed with its output unit.
|
|
418
|
+
"""
|
|
419
|
+
listed = [
|
|
420
|
+
{
|
|
421
|
+
"direction": proposal.direction.to_dict(), "refused_version": proposal.item,
|
|
422
|
+
**({"output_unit": output_unit(proposal.checked)} if proposal.checked is not None else {}),
|
|
423
|
+
"refused_because": list(attempt.reasons), **({"how_to_write_it": list(attempt.hints)} if attempt.hints else {}),
|
|
424
|
+
}
|
|
425
|
+
for proposal in refused
|
|
426
|
+
for attempt in [proposal.attempts[-1]]
|
|
427
|
+
]
|
|
428
|
+
fixed = self._ask_groups(
|
|
429
|
+
"proposer", number, _positions_by_group([proposal.group for proposal in refused]),
|
|
430
|
+
[proposal.slot for proposal in refused],
|
|
431
|
+
lambda positions: self.prompts.render(
|
|
432
|
+
"retry", round=number, n=len(positions),
|
|
433
|
+
failures=json.dumps([{"number": i + 1, **listed[p]} for i, p in enumerate(positions)], indent=2),
|
|
434
|
+
),
|
|
435
|
+
lambda positions, text: _items(text, [refused[p].item for p in positions]),
|
|
436
|
+
)
|
|
437
|
+
return [fixed[p] for p in range(len(refused))]
|
|
438
|
+
|
|
439
|
+
def _ask(self, role: str, prompt: str, scope: _Scope) -> str:
|
|
440
|
+
"""Send ``prompt`` to ``role``'s backend and return the reply's text.
|
|
441
|
+
|
|
442
|
+
The call's start and end in the events file say what it concerns: ``scope``.
|
|
443
|
+
"""
|
|
444
|
+
backend, model = self.roles[role]
|
|
445
|
+
request = Request(role=role, model=model, prompt=prompt)
|
|
446
|
+
if self.events is None:
|
|
447
|
+
return backend.complete(request).text
|
|
448
|
+
call = dict(call=self.events.next_call(), round=scope.round, role=role, model=model, group=scope.group,
|
|
449
|
+
slots=list(scope.slots))
|
|
450
|
+
self.events.write(CallStart(**call))
|
|
451
|
+
started = time.monotonic()
|
|
452
|
+
try:
|
|
453
|
+
# A recording backend says which call file it wrote; any other backend writes none.
|
|
454
|
+
reply, path = backend.record(request) if hasattr(backend, "record") else (backend.complete(request), None)
|
|
455
|
+
except BaseException as exc:
|
|
456
|
+
self.events.write(CallEnd(**call, seconds=time.monotonic() - started, error=f"{type(exc).__name__}: {exc}"))
|
|
457
|
+
raise
|
|
458
|
+
self.events.write(CallEnd(
|
|
459
|
+
**call, file=path.name if path else None, seconds=time.monotonic() - started, **reply_facts(reply),
|
|
460
|
+
))
|
|
461
|
+
return reply.text
|
|
462
|
+
|
|
463
|
+
def _event(self, event: Event) -> None:
|
|
464
|
+
if self.events is not None:
|
|
465
|
+
self.events.write(event)
|
|
466
|
+
|
|
467
|
+
def _ask_groups(
|
|
468
|
+
self, role: str, number: int, positions_by_group: dict[int, list[int]], slots: list[int],
|
|
469
|
+
render: Callable[[list[int]], str], read: Callable[[list[int], str], list],
|
|
470
|
+
) -> dict[int, object]:
|
|
471
|
+
"""Ask ``role`` one call of round ``number`` per proposal group, all at once; scatter the replies to their positions.
|
|
472
|
+
|
|
473
|
+
``positions_by_group`` holds each group's positions by its number and
|
|
474
|
+
``slots`` each position's slot; ``render(positions)`` is the group's
|
|
475
|
+
prompt and ``read(positions, text)`` one value per position from its reply.
|
|
476
|
+
"""
|
|
477
|
+
groups = list(positions_by_group.items())
|
|
478
|
+
replies = self._ask_all(role, [
|
|
479
|
+
(render(positions), _Scope(number, group, tuple(slots[p] for p in positions))) for group, positions in groups
|
|
480
|
+
])
|
|
481
|
+
return {
|
|
482
|
+
p: value
|
|
483
|
+
for (_, positions), text in zip(groups, replies)
|
|
484
|
+
for p, value in zip(positions, read(positions, text), strict=True)
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
def _ask_all(self, role: str, asked: list[tuple[str, _Scope]]) -> list[str]:
|
|
488
|
+
"""Make ``asked`` calls to ``role`` side by side, one thread each; return the replies' texts in order.
|
|
489
|
+
|
|
490
|
+
Each call is its prompt and what it concerns. Every call is waited for, so each that succeeds is
|
|
491
|
+
logged; then a ``ProviderQuotaExhausted`` among the errors is raised,
|
|
492
|
+
so the campaign stops for the quota, and otherwise the first error in
|
|
493
|
+
the order of ``asked``.
|
|
494
|
+
"""
|
|
495
|
+
if not asked:
|
|
496
|
+
return []
|
|
497
|
+
with ThreadPoolExecutor(max_workers=len(asked)) as pool:
|
|
498
|
+
calls = [pool.submit(self._ask, role, prompt, scope) for prompt, scope in asked]
|
|
499
|
+
errors = [call.exception() for call in calls if call.exception() is not None]
|
|
500
|
+
if errors:
|
|
501
|
+
raise next((error for error in errors if isinstance(error, ProviderQuotaExhausted)), errors[0])
|
|
502
|
+
return [call.result() for call in calls]
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _split(count: int, parts: int) -> list[list[int]]:
|
|
506
|
+
"""The slots ``0..count-1`` cut into ``parts`` contiguous runs of near-equal size, the longer first; none empty."""
|
|
507
|
+
parts = min(parts, count)
|
|
508
|
+
if not parts:
|
|
509
|
+
return []
|
|
510
|
+
size, longer = divmod(count, parts)
|
|
511
|
+
starts = [g * size + min(g, longer) for g in range(parts + 1)]
|
|
512
|
+
return [list(range(a, b)) for a, b in zip(starts, starts[1:])]
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _positions_by_group(group_ids: list[int]) -> dict[int, list[int]]:
|
|
516
|
+
"""The positions of ``group_ids`` by proposal group: the groups in order, each one's positions in order."""
|
|
517
|
+
return {group: [p for p, g in enumerate(group_ids) if g == group] for group in sorted(set(group_ids))}
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
@dataclass(frozen=True)
|
|
521
|
+
class _Missing:
|
|
522
|
+
"""No usable item came back for a slot; ``previous`` is its last version (None before the first)."""
|
|
523
|
+
|
|
524
|
+
reason: str
|
|
525
|
+
previous: object
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _items(text: str, previous: list) -> list:
|
|
529
|
+
"""One item per slot of ``previous`` from a reply's JSON array; a slot without one is ``_Missing``."""
|
|
530
|
+
try:
|
|
531
|
+
items = _json_array(text)
|
|
532
|
+
except ValueError as exc:
|
|
533
|
+
return [_Missing(str(exc), item) for item in previous]
|
|
534
|
+
short = f"the reply had {len(items)} items for {len(previous)} candidates"
|
|
535
|
+
return [_unwrapped(items[i]) if i < len(items) else _Missing(short, item) for i, item in enumerate(previous)]
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
#: The fields of a candidate, as the proposer writes them.
|
|
539
|
+
_CANDIDATE_FIELDS = frozenset(field.name for field in dataclasses.fields(Candidate))
|
|
540
|
+
#: The keys a reply may nest a candidate under when it echoes the entry it answers.
|
|
541
|
+
_NESTED = ("candidate", "refused_version")
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _unwrapped(item: object) -> object:
|
|
545
|
+
"""``item``, or the candidate it nests under "candidate" or "refused_version" when it lacks a candidate's fields.
|
|
546
|
+
|
|
547
|
+
A reply to a rewrite request sometimes echoes the refused entry with the
|
|
548
|
+
corrected candidate inside it; only that shape is unwrapped, so any other
|
|
549
|
+
item reaches the parser as it came.
|
|
550
|
+
"""
|
|
551
|
+
if not isinstance(item, dict) or _CANDIDATE_FIELDS <= item.keys():
|
|
552
|
+
return item
|
|
553
|
+
nested = [item[key] for key in _NESTED if isinstance(item.get(key), dict)]
|
|
554
|
+
return nested[0] if nested else item
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _direction_problem(item: object, types: tuple[str, ...]) -> str:
|
|
558
|
+
"""Why a planner item is not a usable direction, whatever its parent, or an empty string."""
|
|
559
|
+
if not isinstance(item, dict) or not all(isinstance(item.get(k), str) for k in Direction.__annotations__):
|
|
560
|
+
return f"needs the string fields {list(Direction.__annotations__)}"
|
|
561
|
+
if item["mechanism_type"] not in types:
|
|
562
|
+
return f"has the unknown mechanism type {item['mechanism_type']!r}"
|
|
563
|
+
if item["action"] not in ACTIONS:
|
|
564
|
+
return f"has the unknown action {item['action']!r}"
|
|
565
|
+
return ""
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _memory(reply: str) -> str | None:
|
|
569
|
+
"""The memory in a distiller's ``reply``, with plain headings, "- " bullets and at most ``MEMORY_ITEMS`` items a part.
|
|
570
|
+
|
|
571
|
+
A heading may come with a leading "#", surrounding "**" and a trailing
|
|
572
|
+
":", in any case; an item may start with "- ", "* ", "• ", "1." or "1)".
|
|
573
|
+
None if a heading is missing or out of order, or if no part holds an item.
|
|
574
|
+
"""
|
|
575
|
+
lines = reply.strip().removeprefix("```text").removesuffix("```").strip().splitlines()
|
|
576
|
+
starts = [next((i for i, line in enumerate(lines) if _heading(line) == part.lower()), None) for part in MEMORY_PARTS]
|
|
577
|
+
if None in starts or starts != sorted(starts):
|
|
578
|
+
return None
|
|
579
|
+
kept, found = [], 0
|
|
580
|
+
for part, start, end in zip(MEMORY_PARTS, starts, [*starts[1:], len(lines)]):
|
|
581
|
+
items = [match.group(1).strip() for line in lines[start + 1:end] if (match := _BULLET.match(line))]
|
|
582
|
+
found += len(items)
|
|
583
|
+
kept += [part, *(f"- {item}" for item in items[:MEMORY_ITEMS])]
|
|
584
|
+
return "\n".join(kept) if found else None
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
#: An item of the memory: its bullet, then its text.
|
|
588
|
+
_BULLET = re.compile(r"^\s*(?:[-*\u2022]|\d+[.)])\s+(\S.*)$")
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _heading(line: str) -> str:
|
|
592
|
+
"""``line`` as a bare lower-case heading: without leading "#", surrounding "**" and a trailing ":"."""
|
|
593
|
+
text = line.strip().lstrip("#").strip().strip("*").strip().removesuffix(":").strip().strip("*").strip()
|
|
594
|
+
return text.lower()
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def render_context(context: dict[str, str], keys: tuple[str, ...]) -> str:
|
|
598
|
+
"""The non-empty sections of ``context`` among ``keys``, each under its heading inside the tag of its key."""
|
|
599
|
+
return "\n\n".join(
|
|
600
|
+
f"## {heading}\n<{key}>\n{context[key]}\n</{key}>"
|
|
601
|
+
for key, heading in SECTIONS if key in keys and context.get(key)
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _json_array(text: str) -> list:
|
|
606
|
+
"""Return the JSON array in ``text``: the last fenced block if there is one, else the whole text."""
|
|
607
|
+
fenced = _FENCE.findall(text)
|
|
608
|
+
body = fenced[-1] if fenced else text
|
|
609
|
+
try:
|
|
610
|
+
value = json.loads(body)
|
|
611
|
+
except json.JSONDecodeError as exc:
|
|
612
|
+
raise ValueError(f"the reply is not JSON ({exc}): {text[:200]!r}") from exc
|
|
613
|
+
if not isinstance(value, list):
|
|
614
|
+
raise ValueError(f"the reply is a JSON {type(value).__name__}, not an array")
|
|
615
|
+
return value
|