alphaengine 0.10.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 (113) hide show
  1. {alphaengine-0.10.0 → alphaengine-1.0.1}/PKG-INFO +3 -1
  2. {alphaengine-0.10.0 → alphaengine-1.0.1}/pyproject.toml +5 -1
  3. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/_version.py +31 -6
  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.1/src/alphaengine/agent/sim.py +399 -0
  8. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/cli.py +3 -2
  9. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/client/executor.py +3 -1
  10. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/client/session.py +21 -1
  11. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/core/__init__.py +4 -0
  12. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/core/backtest.py +9 -3
  13. alphaengine-1.0.1/src/alphaengine/core/bakeoff.py +278 -0
  14. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/core/covariance.py +1 -1
  15. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/core/factors.py +26 -14
  16. alphaengine-1.0.1/src/alphaengine/core/market.py +154 -0
  17. alphaengine-1.0.1/src/alphaengine/core/options.py +143 -0
  18. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/core/pairs.py +34 -4
  19. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/core/performance.py +13 -10
  20. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/core/process.py +8 -4
  21. alphaengine-1.0.1/src/alphaengine/core/rates.py +45 -0
  22. alphaengine-1.0.1/src/alphaengine/core/regime.py +159 -0
  23. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/core/relations.py +18 -2
  24. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/core/stress.py +6 -2
  25. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/core/validation.py +17 -12
  26. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/model.py +42 -10
  27. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/study/schema.py +1 -1
  28. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/sweep/runner.py +1 -1
  29. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/tui/app.py +431 -300
  30. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/tui/dispatch.py +549 -52
  31. alphaengine-1.0.1/src/alphaengine/tui/mark.py +92 -0
  32. alphaengine-1.0.1/src/alphaengine/tui/stage.py +42 -0
  33. alphaengine-1.0.1/src/alphaengine/tui/typeface.py +48 -0
  34. alphaengine-1.0.1/tests/test_bakeoff.py +89 -0
  35. {alphaengine-0.10.0 → alphaengine-1.0.1}/tests/test_goldens.py +14 -10
  36. {alphaengine-0.10.0 → alphaengine-1.0.1}/tests/test_math_audit.py +63 -16
  37. {alphaengine-0.10.0 → alphaengine-1.0.1}/tests/test_model.py +43 -0
  38. alphaengine-1.0.1/tests/test_sim.py +191 -0
  39. {alphaengine-0.10.0 → alphaengine-1.0.1}/tests/test_tui.py +109 -33
  40. {alphaengine-0.10.0 → alphaengine-1.0.1}/.github/workflows/ci.yml +0 -0
  41. {alphaengine-0.10.0 → alphaengine-1.0.1}/.github/workflows/publish.yml +0 -0
  42. {alphaengine-0.10.0 → alphaengine-1.0.1}/.gitignore +0 -0
  43. {alphaengine-0.10.0 → alphaengine-1.0.1}/AGENTS.md +0 -0
  44. {alphaengine-0.10.0 → alphaengine-1.0.1}/LICENSE +0 -0
  45. {alphaengine-0.10.0 → alphaengine-1.0.1}/README.md +0 -0
  46. {alphaengine-0.10.0 → alphaengine-1.0.1}/SECURITY.md +0 -0
  47. {alphaengine-0.10.0 → alphaengine-1.0.1}/docs/assets/banner.png +0 -0
  48. {alphaengine-0.10.0 → alphaengine-1.0.1}/docs/assets/data_boundary.png +0 -0
  49. {alphaengine-0.10.0 → alphaengine-1.0.1}/docs/assets/session.png +0 -0
  50. {alphaengine-0.10.0 → alphaengine-1.0.1}/docs/figure_contract.md +0 -0
  51. {alphaengine-0.10.0 → alphaengine-1.0.1}/docs/workflows/evaluate_process.json +0 -0
  52. {alphaengine-0.10.0 → alphaengine-1.0.1}/docs/workflows/research_week.json +0 -0
  53. {alphaengine-0.10.0 → alphaengine-1.0.1}/docs/workflows/stress_study.json +0 -0
  54. {alphaengine-0.10.0 → alphaengine-1.0.1}/scripts/gen_docs.py +0 -0
  55. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/__init__.py +0 -0
  56. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/__main__.py +0 -0
  57. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/agent/__init__.py +0 -0
  58. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/agent/answer.py +0 -0
  59. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/agent/driver.py +0 -0
  60. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/auth.py +0 -0
  61. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/book.py +0 -0
  62. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/charts.py +0 -0
  63. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/client/__init__.py +0 -0
  64. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/client/agent.py +0 -0
  65. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/commands.py +0 -0
  66. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/complete.py +0 -0
  67. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/connectors/__init__.py +0 -0
  68. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/context.py +0 -0
  69. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/core/active.py +0 -0
  70. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/core/allocate.py +0 -0
  71. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/core/cross_section.py +0 -0
  72. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/core/panel.py +0 -0
  73. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/core/portfolio.py +0 -0
  74. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/core/profile.py +0 -0
  75. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/core/risk.py +0 -0
  76. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/core/screen.py +0 -0
  77. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/core/series_shapes.py +0 -0
  78. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/core/signals.py +0 -0
  79. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/core/technical.py +0 -0
  80. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/core/trace.py +0 -0
  81. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/core/walkforward.py +0 -0
  82. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/demo.py +0 -0
  83. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/demo_book.py +0 -0
  84. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/demo_returns.py +0 -0
  85. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/demo_signal.py +0 -0
  86. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/demo_universe.py +0 -0
  87. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/events.py +0 -0
  88. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/loaders.py +0 -0
  89. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/py.typed +0 -0
  90. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/repl.py +0 -0
  91. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/study/__init__.py +0 -0
  92. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/study/report.py +0 -0
  93. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/sweep/__init__.py +0 -0
  94. {alphaengine-0.10.0 → alphaengine-1.0.1}/src/alphaengine/tui/__init__.py +0 -0
  95. {alphaengine-0.10.0 → alphaengine-1.0.1}/tests/test_agent.py +0 -0
  96. {alphaengine-0.10.0 → alphaengine-1.0.1}/tests/test_answer.py +0 -0
  97. {alphaengine-0.10.0 → alphaengine-1.0.1}/tests/test_book.py +0 -0
  98. {alphaengine-0.10.0 → alphaengine-1.0.1}/tests/test_cli.py +0 -0
  99. {alphaengine-0.10.0 → alphaengine-1.0.1}/tests/test_client.py +0 -0
  100. {alphaengine-0.10.0 → alphaengine-1.0.1}/tests/test_commands.py +0 -0
  101. {alphaengine-0.10.0 → alphaengine-1.0.1}/tests/test_context.py +0 -0
  102. {alphaengine-0.10.0 → alphaengine-1.0.1}/tests/test_events.py +0 -0
  103. {alphaengine-0.10.0 → alphaengine-1.0.1}/tests/test_executor_ops.py +0 -0
  104. {alphaengine-0.10.0 → alphaengine-1.0.1}/tests/test_loaders.py +0 -0
  105. {alphaengine-0.10.0 → alphaengine-1.0.1}/tests/test_portfolio.py +0 -0
  106. {alphaengine-0.10.0 → alphaengine-1.0.1}/tests/test_process.py +0 -0
  107. {alphaengine-0.10.0 → alphaengine-1.0.1}/tests/test_quant_maths.py +0 -0
  108. {alphaengine-0.10.0 → alphaengine-1.0.1}/tests/test_relations.py +0 -0
  109. {alphaengine-0.10.0 → alphaengine-1.0.1}/tests/test_screen.py +0 -0
  110. {alphaengine-0.10.0 → alphaengine-1.0.1}/tests/test_signals.py +0 -0
  111. {alphaengine-0.10.0 → alphaengine-1.0.1}/tests/test_smoke.py +0 -0
  112. {alphaengine-0.10.0 → alphaengine-1.0.1}/tests/test_sweep.py +0 -0
  113. {alphaengine-0.10.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: 0.10.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
@@ -113,9 +113,34 @@ is what a changed figure costs. The API may still move underneath it.
113
113
  # at unit capital, so a loss on the first bar counts. Grinold alpha is empty
114
114
  # when no volatility is supplied, instead of treating every name as vol 1.
115
115
  # Breadth does not imply an IR until the names are declared independent or a
116
- # mean correlation is supplied.
117
- #
118
- # The same release answers a later question from the session record, and
119
- # builds a portfolio (hierarchical risk parity, or risk parity when asked)
120
- # from the loaded returns. Those two add no figure to the goldens.
121
- __version__ = "0.10.0"
116
+ # mean correlation is supplied. The same release answers a later question
117
+ # from the session record, and builds a portfolio (hierarchical risk parity,
118
+ # or risk parity when asked) from the loaded returns. Those two add no
119
+ # figure to the goldens.
120
+ #
121
+ # 0.11.0 MOVES THE CASH RATE, ON PURPOSE. An omitted rate is the H.15
122
+ # 3-month Treasury constant maturity, investment basis, 4.21% as of
123
+ # 2026-10-06 (DGS3MO). It is a pinned print, not a download. Pass 0 for
124
+ # excess of zero. Sharpe, Sortino, the deflated Sharpe, and PSR versus a
125
+ # Sharpe of zero all move because the excess is no longer the raw return.
126
+ # The five-trial golden crosses from marginal to likely_noise (0.5090 to
127
+ # 0.427). sr0 moves slightly because its sampling-error floor depends on
128
+ # the Sharpe. Drawdown, total return, geometric CAGR, volatility, and PBO
129
+ # do not use the rate and stay. HAC lags are the Newey-West automatic rule,
130
+ # so a factor t-stat on a short sample uses fewer than five lags and reports
131
+ # the lag it chose. A close below $1.50 is still a price when the path is
132
+ # calm. The cointegration screen reports Benjamini-Hochberg survivors;
133
+ # the single-test flag remains on the pair as cointegrated_raw.
134
+ #
135
+ # 1.0.0 IS THE FIRST RELEASE. Everything through 0.11 was the beta: the
136
+ # maths, the session, the airlock. 1.0 is the simulator. A sentence is a
137
+ # stack of tool calls. The trace is the product, drawn while the tools
138
+ # run. A report file is written on this machine only when the user
139
+ # accepts it. The golden figures stay the 0.11 contract.
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
+ )