alphaengine 1.0.0__tar.gz → 1.0.1__tar.gz

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 (114) hide show
  1. {alphaengine-1.0.0 → alphaengine-1.0.1}/PKG-INFO +3 -1
  2. {alphaengine-1.0.0 → alphaengine-1.0.1}/pyproject.toml +5 -1
  3. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/_version.py +7 -1
  4. alphaengine-1.0.1/src/alphaengine/agent/bakeoff.py +240 -0
  5. alphaengine-1.0.1/src/alphaengine/agent/brief.py +860 -0
  6. alphaengine-1.0.1/src/alphaengine/agent/chart.py +1029 -0
  7. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/agent/sim.py +173 -15
  8. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/cli.py +3 -2
  9. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/client/session.py +21 -1
  10. alphaengine-1.0.1/src/alphaengine/core/bakeoff.py +278 -0
  11. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/core/covariance.py +1 -1
  12. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/core/regime.py +55 -13
  13. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/model.py +42 -10
  14. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/tui/app.py +430 -320
  15. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/tui/dispatch.py +384 -30
  16. alphaengine-1.0.1/src/alphaengine/tui/mark.py +92 -0
  17. alphaengine-1.0.1/src/alphaengine/tui/typeface.py +48 -0
  18. alphaengine-1.0.1/tests/test_bakeoff.py +89 -0
  19. {alphaengine-1.0.0 → alphaengine-1.0.1}/tests/test_model.py +43 -0
  20. alphaengine-1.0.1/tests/test_sim.py +191 -0
  21. {alphaengine-1.0.0 → alphaengine-1.0.1}/tests/test_tui.py +98 -27
  22. alphaengine-1.0.0/tests/test_sim.py +0 -91
  23. {alphaengine-1.0.0 → alphaengine-1.0.1}/.github/workflows/ci.yml +0 -0
  24. {alphaengine-1.0.0 → alphaengine-1.0.1}/.github/workflows/publish.yml +0 -0
  25. {alphaengine-1.0.0 → alphaengine-1.0.1}/.gitignore +0 -0
  26. {alphaengine-1.0.0 → alphaengine-1.0.1}/AGENTS.md +0 -0
  27. {alphaengine-1.0.0 → alphaengine-1.0.1}/LICENSE +0 -0
  28. {alphaengine-1.0.0 → alphaengine-1.0.1}/README.md +0 -0
  29. {alphaengine-1.0.0 → alphaengine-1.0.1}/SECURITY.md +0 -0
  30. {alphaengine-1.0.0 → alphaengine-1.0.1}/docs/assets/banner.png +0 -0
  31. {alphaengine-1.0.0 → alphaengine-1.0.1}/docs/assets/data_boundary.png +0 -0
  32. {alphaengine-1.0.0 → alphaengine-1.0.1}/docs/assets/session.png +0 -0
  33. {alphaengine-1.0.0 → alphaengine-1.0.1}/docs/figure_contract.md +0 -0
  34. {alphaengine-1.0.0 → alphaengine-1.0.1}/docs/workflows/evaluate_process.json +0 -0
  35. {alphaengine-1.0.0 → alphaengine-1.0.1}/docs/workflows/research_week.json +0 -0
  36. {alphaengine-1.0.0 → alphaengine-1.0.1}/docs/workflows/stress_study.json +0 -0
  37. {alphaengine-1.0.0 → alphaengine-1.0.1}/scripts/gen_docs.py +0 -0
  38. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/__init__.py +0 -0
  39. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/__main__.py +0 -0
  40. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/agent/__init__.py +0 -0
  41. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/agent/answer.py +0 -0
  42. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/agent/driver.py +0 -0
  43. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/auth.py +0 -0
  44. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/book.py +0 -0
  45. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/charts.py +0 -0
  46. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/client/__init__.py +0 -0
  47. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/client/agent.py +0 -0
  48. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/client/executor.py +0 -0
  49. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/commands.py +0 -0
  50. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/complete.py +0 -0
  51. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/connectors/__init__.py +0 -0
  52. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/context.py +0 -0
  53. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/core/__init__.py +0 -0
  54. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/core/active.py +0 -0
  55. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/core/allocate.py +0 -0
  56. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/core/backtest.py +0 -0
  57. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/core/cross_section.py +0 -0
  58. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/core/factors.py +0 -0
  59. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/core/market.py +0 -0
  60. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/core/options.py +0 -0
  61. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/core/pairs.py +0 -0
  62. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/core/panel.py +0 -0
  63. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/core/performance.py +0 -0
  64. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/core/portfolio.py +0 -0
  65. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/core/process.py +0 -0
  66. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/core/profile.py +0 -0
  67. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/core/rates.py +0 -0
  68. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/core/relations.py +0 -0
  69. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/core/risk.py +0 -0
  70. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/core/screen.py +0 -0
  71. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/core/series_shapes.py +0 -0
  72. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/core/signals.py +0 -0
  73. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/core/stress.py +0 -0
  74. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/core/technical.py +0 -0
  75. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/core/trace.py +0 -0
  76. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/core/validation.py +0 -0
  77. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/core/walkforward.py +0 -0
  78. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/demo.py +0 -0
  79. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/demo_book.py +0 -0
  80. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/demo_returns.py +0 -0
  81. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/demo_signal.py +0 -0
  82. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/demo_universe.py +0 -0
  83. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/events.py +0 -0
  84. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/loaders.py +0 -0
  85. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/py.typed +0 -0
  86. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/repl.py +0 -0
  87. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/study/__init__.py +0 -0
  88. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/study/report.py +0 -0
  89. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/study/schema.py +0 -0
  90. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/sweep/__init__.py +0 -0
  91. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/sweep/runner.py +0 -0
  92. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/tui/__init__.py +0 -0
  93. {alphaengine-1.0.0 → alphaengine-1.0.1}/src/alphaengine/tui/stage.py +0 -0
  94. {alphaengine-1.0.0 → alphaengine-1.0.1}/tests/test_agent.py +0 -0
  95. {alphaengine-1.0.0 → alphaengine-1.0.1}/tests/test_answer.py +0 -0
  96. {alphaengine-1.0.0 → alphaengine-1.0.1}/tests/test_book.py +0 -0
  97. {alphaengine-1.0.0 → alphaengine-1.0.1}/tests/test_cli.py +0 -0
  98. {alphaengine-1.0.0 → alphaengine-1.0.1}/tests/test_client.py +0 -0
  99. {alphaengine-1.0.0 → alphaengine-1.0.1}/tests/test_commands.py +0 -0
  100. {alphaengine-1.0.0 → alphaengine-1.0.1}/tests/test_context.py +0 -0
  101. {alphaengine-1.0.0 → alphaengine-1.0.1}/tests/test_events.py +0 -0
  102. {alphaengine-1.0.0 → alphaengine-1.0.1}/tests/test_executor_ops.py +0 -0
  103. {alphaengine-1.0.0 → alphaengine-1.0.1}/tests/test_goldens.py +0 -0
  104. {alphaengine-1.0.0 → alphaengine-1.0.1}/tests/test_loaders.py +0 -0
  105. {alphaengine-1.0.0 → alphaengine-1.0.1}/tests/test_math_audit.py +0 -0
  106. {alphaengine-1.0.0 → alphaengine-1.0.1}/tests/test_portfolio.py +0 -0
  107. {alphaengine-1.0.0 → alphaengine-1.0.1}/tests/test_process.py +0 -0
  108. {alphaengine-1.0.0 → alphaengine-1.0.1}/tests/test_quant_maths.py +0 -0
  109. {alphaengine-1.0.0 → alphaengine-1.0.1}/tests/test_relations.py +0 -0
  110. {alphaengine-1.0.0 → alphaengine-1.0.1}/tests/test_screen.py +0 -0
  111. {alphaengine-1.0.0 → alphaengine-1.0.1}/tests/test_signals.py +0 -0
  112. {alphaengine-1.0.0 → alphaengine-1.0.1}/tests/test_smoke.py +0 -0
  113. {alphaengine-1.0.0 → alphaengine-1.0.1}/tests/test_sweep.py +0 -0
  114. {alphaengine-1.0.0 → alphaengine-1.0.1}/tests/test_trace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: alphaengine
3
- Version: 1.0.0
3
+ Version: 1.0.1
4
4
  Summary: Validated research tooling for investment strategies: deflation, overfitting detection, and honest trial counts.
5
5
  Project-URL: Homepage, https://github.com/quantOSC/alphaengine
6
6
  Project-URL: Documentation, https://github.com/quantOSC/alphaengine#readme
@@ -31,6 +31,7 @@ Provides-Extra: connectors
31
31
  Requires-Dist: httpx>=0.27; extra == 'connectors'
32
32
  Requires-Dist: pyarrow>=15.0; extra == 'connectors'
33
33
  Provides-Extra: dev
34
+ Requires-Dist: matplotlib>=3.8; extra == 'dev'
34
35
  Requires-Dist: mypy==2.3.0; extra == 'dev'
35
36
  Requires-Dist: pytest-cov>=5.0; extra == 'dev'
36
37
  Requires-Dist: pytest>=8.0; extra == 'dev'
@@ -40,6 +41,7 @@ Requires-Dist: textual>=1.0; extra == 'dev'
40
41
  Provides-Extra: factors
41
42
  Requires-Dist: statsmodels>=0.14; extra == 'factors'
42
43
  Provides-Extra: tui
44
+ Requires-Dist: matplotlib>=3.8; extra == 'tui'
43
45
  Requires-Dist: textual>=1.0; extra == 'tui'
44
46
  Description-Content-Type: text/markdown
45
47
 
@@ -77,8 +77,11 @@ connectors = [
77
77
  ]
78
78
  # The interactive session. Not a core dependency: a researcher who only wants
79
79
  # a deflated Sharpe should not install a TUI framework to get one.
80
+ # matplotlib is the charted report only. It is imported when a trace is saved,
81
+ # never by `import alphaengine`.
80
82
  tui = [
81
83
  "textual>=1.0",
84
+ "matplotlib>=3.8",
82
85
  ]
83
86
  dev = [
84
87
  "pytest>=8.0",
@@ -94,6 +97,7 @@ dev = [
94
97
  # it resolves fine on a machine that happens to have it.
95
98
  "statsmodels>=0.14",
96
99
  "textual>=1.0",
100
+ "matplotlib>=3.8",
97
101
  ]
98
102
 
99
103
  [project.urls]
@@ -185,5 +189,5 @@ ignore_missing_imports = true
185
189
  # import is an error, so this is the difference between a build that passes on a
186
190
  # machine that happens to have them and one that passes anywhere.
187
191
  [[tool.mypy.overrides]]
188
- module = ["scipy.*", "statsmodels.*", "pyarrow.*", "httpx", "textual.*"]
192
+ module = ["scipy.*", "statsmodels.*", "pyarrow.*", "httpx", "textual.*", "matplotlib.*"]
189
193
  ignore_missing_imports = true
@@ -137,4 +137,10 @@ is what a changed figure costs. The API may still move underneath it.
137
137
  # stack of tool calls. The trace is the product, drawn while the tools
138
138
  # run. A report file is written on this machine only when the user
139
139
  # accepts it. The golden figures stay the 0.11 contract.
140
- __version__ = "1.0.0"
140
+ #
141
+ # 1.0.1 IS THE REPORT AND THE HARNESS TOOLS. No golden in tests/test_goldens.py
142
+ # moves. The session adds `monte_carlo` and `scenarios` to the tool stack, fixes
143
+ # the sample-count divisor in Ledoit-Wolf shrinkage so hierarchical risk parity
144
+ # and risk parity differentiate weights across a 50-name panel, and writes a
145
+ # multi-page portfolio, scenario, Monte Carlo, and bakeoff report to Downloads.
146
+ __version__ = "1.0.1"
@@ -0,0 +1,240 @@
1
+ """Bakeoff mode. Two roles, one clock, and a referee that does not take votes.
2
+
3
+ Generators may submit a rule. An adversary may submit an attack. Neither may
4
+ start another bakeoff, and neither may name the survivor. The loop is two
5
+ rounds of two proposals plus one attack after each round: six model calls,
6
+ and then it stops even if a model asks to continue.
7
+
8
+ The clock is the other stop. The default budget is 45 seconds. Nothing can
9
+ stretch it past 60. When the budget is gone, the call in flight is abandoned
10
+ and the referee scores whatever already came back.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import math
17
+ import threading
18
+ import time
19
+ from collections.abc import Callable
20
+ from dataclasses import dataclass
21
+ from typing import Any
22
+
23
+ from ..core.bakeoff import ATTACKS, RULES, referee, trial_paths
24
+ from .sim import Call, Think, Trace
25
+
26
+ DEFAULT_SECONDS = 45.0
27
+ HARD_CEILING = 60.0
28
+ MAX_ROUNDS = 2
29
+ MAX_SEATS = 2
30
+
31
+ _DROPPED = {"winner", "survivor", "score", "sharpe", "verdict", "deflated_sharpe"}
32
+
33
+ _GENERATOR = """BAKEOFF GENERATOR
34
+ Propose one strategy. You do not score it, you do not name a winner, and you
35
+ do not start another bakeoff.
36
+ Rules: @@RULES@@
37
+ lookback, for momentum and reversion, is 5 to 60.
38
+ Question:
39
+ @@QUESTION@@
40
+ Board:
41
+ @@BOARD@@
42
+ Reply with one JSON object: {"rule": "momentum", "lookback": 20}
43
+ """
44
+
45
+ _ADVERSARY = """BAKEOFF ADVERSARY
46
+ Name one attack. You do not propose a rule, you do not name a winner, and you
47
+ do not start another bakeoff.
48
+ Attacks: @@ATTACKS@@
49
+ Question:
50
+ @@QUESTION@@
51
+ Board:
52
+ @@BOARD@@
53
+ Reply with one JSON object: {"attack": "last_half"}
54
+ """
55
+
56
+
57
+ @dataclass
58
+ class Bakeoff:
59
+ """The trace the session shows, and the figures a tool result is allowed to carry."""
60
+
61
+ trace: Trace
62
+ figures: dict[str, Any]
63
+
64
+
65
+ def run_bakeoff(
66
+ question: str,
67
+ think: Think,
68
+ data: Any,
69
+ *,
70
+ rounds: int = MAX_ROUNDS,
71
+ seats: int = MAX_SEATS,
72
+ seconds: float = DEFAULT_SECONDS,
73
+ on_call: Callable[[Call], None] | None = None,
74
+ ) -> Bakeoff:
75
+ """Run one bakeoff. Returns when the rounds end or the clock does, whichever is first."""
76
+ budget = _budget(seconds)
77
+ deadline = time.monotonic() + budget
78
+ n_rounds = min(max(int(rounds), 1), MAX_ROUNDS)
79
+ n_seats = min(max(int(seats), 1), MAX_SEATS)
80
+ submissions: list[dict[str, Any]] = []
81
+ attacks: list[str] = []
82
+ trace = Trace(question=question)
83
+ stopped = "complete"
84
+
85
+ def board() -> str:
86
+ if not submissions and not attacks:
87
+ return "nothing proposed yet"
88
+ scored = referee(data, submissions, attacks)
89
+ lines = list(scored.get("trials") or [])
90
+ if attacks:
91
+ lines.append("attacks " + ", ".join(attacks))
92
+ return "\n".join(str(line) for line in lines) or "nothing proposed yet"
93
+
94
+ def emit(call: Call) -> None:
95
+ trace.calls.append(call)
96
+ if on_call is not None:
97
+ on_call(call)
98
+
99
+ for _round in range(n_rounds):
100
+ for _seat in range(n_seats):
101
+ text, in_time = _ask(think, _fill(_GENERATOR, question, board()), deadline)
102
+ if not in_time:
103
+ stopped = "clock"
104
+ break
105
+ submission = _proposal(text)
106
+ submissions.append(submission)
107
+ emit(Call(tool="submit", args=submission, note=str(submission.get("rule") or "refused")))
108
+ if stopped == "clock":
109
+ break
110
+ text, in_time = _ask(think, _fill(_ADVERSARY, question, board()), deadline)
111
+ if not in_time:
112
+ stopped = "clock"
113
+ break
114
+ attack = _attack(text)
115
+ if attack is None:
116
+ emit(Call(tool="attack", args={}, error="that attack is not in the menu"))
117
+ continue
118
+ attacks.append(attack)
119
+ emit(Call(tool="attack", args={"attack": attack}, note=attack))
120
+
121
+ figures = referee(data, submissions, attacks)
122
+ elapsed = min(budget, time.monotonic() - (deadline - budget))
123
+ figures["seconds"] = round(elapsed, 1)
124
+ figures["stopped"] = stopped
125
+ figures["budget_seconds"] = budget
126
+ trace.answer = _verdict(figures)
127
+ trace.bakeoff = {
128
+ "survivor": figures.get("survivor"),
129
+ "survivor_sharpe": figures.get("survivor_sharpe"),
130
+ "attacked_sharpe": figures.get("attacked_sharpe"),
131
+ "deflated_sharpe": figures.get("deflated_sharpe"),
132
+ "verdict": figures.get("verdict"),
133
+ "n_trials": figures.get("n_trials"),
134
+ "n_failed": figures.get("n_failed"),
135
+ "attacks": list(figures.get("attacks") or []),
136
+ "trials": list(figures.get("trials") or []),
137
+ "curves": trial_paths(data, submissions),
138
+ }
139
+ return Bakeoff(trace=trace, figures=figures)
140
+
141
+
142
+ def _budget(seconds: float) -> float:
143
+ """A positive request, never longer than a minute. A bad request uses the default."""
144
+ try:
145
+ number = float(seconds)
146
+ except (TypeError, ValueError):
147
+ return DEFAULT_SECONDS
148
+ if not math.isfinite(number) or number <= 0:
149
+ return DEFAULT_SECONDS
150
+ return min(HARD_CEILING, number)
151
+
152
+
153
+ def _ask(think: Think, prompt: str, deadline: float) -> tuple[str, bool]:
154
+ """One model call. The second value is false when the clock won and the loop must stop.
155
+
156
+ The worker is a daemon so a model that keeps talking after the deadline
157
+ cannot hold the process open. That reply is never read.
158
+ """
159
+ remaining = deadline - time.monotonic()
160
+ if remaining <= 0:
161
+ return "", False
162
+ box: list[str] = []
163
+
164
+ def run() -> None:
165
+ try:
166
+ text = think(prompt)
167
+ except Exception: # noqa: BLE001 — a broken call is a refused turn, not a crash
168
+ return
169
+ if isinstance(text, str):
170
+ box.append(text)
171
+
172
+ worker = threading.Thread(target=run, daemon=True)
173
+ worker.start()
174
+ worker.join(remaining)
175
+ if worker.is_alive():
176
+ return "", False
177
+ return (box[0] if box else ""), True
178
+
179
+
180
+ def _fill(template: str, question: str, board: str) -> str:
181
+ return (
182
+ template.replace("@@QUESTION@@", question)
183
+ .replace("@@BOARD@@", board)
184
+ .replace("@@RULES@@", ", ".join(RULES))
185
+ .replace("@@ATTACKS@@", ", ".join(ATTACKS))
186
+ )
187
+
188
+
189
+ def _proposal(text: str) -> dict[str, Any]:
190
+ obj = _object(text)
191
+ if obj is None:
192
+ return {"rule": ""}
193
+ if "bakeoff" in {str(obj.get("tool") or ""), str(obj.get("rule") or "")}:
194
+ return {"rule": ""}
195
+ rule = obj.get("rule")
196
+ if not isinstance(rule, str):
197
+ return {"rule": ""}
198
+ out: dict[str, Any] = {"rule": rule}
199
+ if "lookback" in obj:
200
+ out["lookback"] = obj["lookback"]
201
+ if isinstance(obj.get("names"), list):
202
+ out["names"] = [str(name) for name in obj["names"][:12]]
203
+ return out
204
+
205
+
206
+ def _attack(text: str) -> str | None:
207
+ obj = _object(text)
208
+ if obj is None:
209
+ return None
210
+ attack = str(obj.get("attack") or "").strip().lower().replace("-", "_").replace(" ", "_")
211
+ if attack not in ATTACKS:
212
+ return None
213
+ return attack
214
+
215
+
216
+ def _object(text: str) -> dict[str, Any] | None:
217
+ start = text.find("{")
218
+ end = text.rfind("}")
219
+ if start < 0 or end <= start:
220
+ return None
221
+ try:
222
+ obj = json.loads(text[start : end + 1])
223
+ except json.JSONDecodeError:
224
+ return None
225
+ if not isinstance(obj, dict):
226
+ return None
227
+ nested = obj.get("args")
228
+ if isinstance(nested, dict) and "rule" not in obj and "attack" not in obj:
229
+ obj = nested
230
+ return {str(key): value for key, value in obj.items() if str(key) not in _DROPPED}
231
+
232
+
233
+ def _verdict(figures: dict[str, Any]) -> str:
234
+ survivor = figures.get("survivor")
235
+ if not survivor:
236
+ return "Referee. No proposal survived."
237
+ return (
238
+ f"Referee. {figures.get('n_trials')} candidates, source bakeoff. "
239
+ f"Survivor {survivor}. Verdict {figures.get('verdict')}."
240
+ )