quantlab-factor-agent 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. factor_agent/KUNQUANT_LICENSE +201 -0
  2. factor_agent/__init__.py +8 -0
  3. factor_agent/_acceptance_model.py +93 -0
  4. factor_agent/_admission.py +168 -0
  5. factor_agent/_expression_fold.py +48 -0
  6. factor_agent/_feedback.py +399 -0
  7. factor_agent/_max_drawdown.py +51 -0
  8. factor_agent/_model_cache.py +100 -0
  9. factor_agent/_scheduler.py +35 -0
  10. factor_agent/acceptance.py +808 -0
  11. factor_agent/accepted/__init__.py +1 -0
  12. factor_agent/aggregations.py +122 -0
  13. factor_agent/campaign.py +797 -0
  14. factor_agent/candidate.py +51 -0
  15. factor_agent/config.py +651 -0
  16. factor_agent/derived.py +98 -0
  17. factor_agent/evaluator.py +1096 -0
  18. factor_agent/events.py +343 -0
  19. factor_agent/exported.py +177 -0
  20. factor_agent/gate/__init__.py +359 -0
  21. factor_agent/gate/_canonical.py +331 -0
  22. factor_agent/gate/_checks.py +85 -0
  23. factor_agent/gate/_constraints.py +85 -0
  24. factor_agent/gate/_dimension.py +177 -0
  25. factor_agent/gate/_originality.py +146 -0
  26. factor_agent/gate/_sandbox.py +105 -0
  27. factor_agent/gate/graph.py +223 -0
  28. factor_agent/gate/stages.py +383 -0
  29. factor_agent/gate/vocabulary.py +1097 -0
  30. factor_agent/ledger.py +85 -0
  31. factor_agent/llm/__init__.py +0 -0
  32. factor_agent/llm/_common.py +18 -0
  33. factor_agent/llm/_raw.py +78 -0
  34. factor_agent/llm/anthropic.py +100 -0
  35. factor_agent/llm/base.py +124 -0
  36. factor_agent/llm/claude_code.py +101 -0
  37. factor_agent/llm/log.py +74 -0
  38. factor_agent/llm/openai_compatible.py +113 -0
  39. factor_agent/llm/replay.py +59 -0
  40. factor_agent/llm/scripted.py +39 -0
  41. factor_agent/period_lag.py +91 -0
  42. factor_agent/prompts.py +557 -0
  43. factor_agent/researcher.py +615 -0
  44. factor_agent/scoring.py +143 -0
  45. factor_agent/segments.py +184 -0
  46. factor_agent/session.py +500 -0
  47. factor_agent/snapshot.py +219 -0
  48. factor_agent/web/__init__.py +7 -0
  49. factor_agent/web/_acceptance.py +145 -0
  50. factor_agent/web/_config.py +51 -0
  51. factor_agent/web/_diagnosis.py +110 -0
  52. factor_agent/web/_files.py +78 -0
  53. factor_agent/web/_health.py +207 -0
  54. factor_agent/web/_ic.py +27 -0
  55. factor_agent/web/_ledger.py +293 -0
  56. factor_agent/web/_memory.py +42 -0
  57. factor_agent/web/_results.py +77 -0
  58. factor_agent/web/_round.py +219 -0
  59. factor_agent/web/_trials.py +112 -0
  60. factor_agent/web/app.py +118 -0
  61. factor_agent/web/run_view.py +329 -0
  62. factor_agent/web/static/assets/index-9QjjJgl3.css +1 -0
  63. factor_agent/web/static/assets/index-iOE9ei_S.js +74 -0
  64. factor_agent/web/static/index.html +13 -0
  65. factor_agent/zoo.py +136 -0
  66. quantlab_factor_agent-0.1.0.dist-info/METADATA +172 -0
  67. quantlab_factor_agent-0.1.0.dist-info/RECORD +72 -0
  68. quantlab_factor_agent-0.1.0.dist-info/WHEEL +5 -0
  69. quantlab_factor_agent-0.1.0.dist-info/entry_points.txt +2 -0
  70. quantlab_factor_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
  71. quantlab_factor_agent-0.1.0.dist-info/licenses/factor_agent/KUNQUANT_LICENSE +201 -0
  72. quantlab_factor_agent-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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