alphaengine 0.9.2__tar.gz → 0.9.4__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 (93) hide show
  1. {alphaengine-0.9.2 → alphaengine-0.9.4}/PKG-INFO +1 -1
  2. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/_version.py +9 -1
  3. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/cli.py +88 -29
  4. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/client/agent.py +209 -25
  5. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/tui/app.py +147 -43
  6. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/tui/dispatch.py +2 -0
  7. {alphaengine-0.9.2 → alphaengine-0.9.4}/tests/test_agent.py +60 -0
  8. {alphaengine-0.9.2 → alphaengine-0.9.4}/.github/workflows/ci.yml +0 -0
  9. {alphaengine-0.9.2 → alphaengine-0.9.4}/.github/workflows/publish.yml +0 -0
  10. {alphaengine-0.9.2 → alphaengine-0.9.4}/.gitignore +0 -0
  11. {alphaengine-0.9.2 → alphaengine-0.9.4}/AGENTS.md +0 -0
  12. {alphaengine-0.9.2 → alphaengine-0.9.4}/LICENSE +0 -0
  13. {alphaengine-0.9.2 → alphaengine-0.9.4}/README.md +0 -0
  14. {alphaengine-0.9.2 → alphaengine-0.9.4}/SECURITY.md +0 -0
  15. {alphaengine-0.9.2 → alphaengine-0.9.4}/docs/assets/banner.png +0 -0
  16. {alphaengine-0.9.2 → alphaengine-0.9.4}/docs/assets/data_boundary.png +0 -0
  17. {alphaengine-0.9.2 → alphaengine-0.9.4}/docs/assets/session.png +0 -0
  18. {alphaengine-0.9.2 → alphaengine-0.9.4}/docs/figure_contract.md +0 -0
  19. {alphaengine-0.9.2 → alphaengine-0.9.4}/docs/workflows/evaluate_process.json +0 -0
  20. {alphaengine-0.9.2 → alphaengine-0.9.4}/docs/workflows/research_week.json +0 -0
  21. {alphaengine-0.9.2 → alphaengine-0.9.4}/docs/workflows/stress_study.json +0 -0
  22. {alphaengine-0.9.2 → alphaengine-0.9.4}/pyproject.toml +0 -0
  23. {alphaengine-0.9.2 → alphaengine-0.9.4}/scripts/gen_docs.py +0 -0
  24. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/__init__.py +0 -0
  25. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/__main__.py +0 -0
  26. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/agent/__init__.py +0 -0
  27. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/agent/answer.py +0 -0
  28. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/agent/driver.py +0 -0
  29. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/auth.py +0 -0
  30. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/book.py +0 -0
  31. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/charts.py +0 -0
  32. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/client/__init__.py +0 -0
  33. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/client/executor.py +0 -0
  34. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/client/session.py +0 -0
  35. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/commands.py +0 -0
  36. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/complete.py +0 -0
  37. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/connectors/__init__.py +0 -0
  38. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/core/__init__.py +0 -0
  39. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/core/active.py +0 -0
  40. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/core/allocate.py +0 -0
  41. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/core/backtest.py +0 -0
  42. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/core/covariance.py +0 -0
  43. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/core/cross_section.py +0 -0
  44. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/core/factors.py +0 -0
  45. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/core/pairs.py +0 -0
  46. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/core/panel.py +0 -0
  47. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/core/performance.py +0 -0
  48. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/core/process.py +0 -0
  49. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/core/profile.py +0 -0
  50. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/core/risk.py +0 -0
  51. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/core/screen.py +0 -0
  52. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/core/series_shapes.py +0 -0
  53. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/core/signals.py +0 -0
  54. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/core/stress.py +0 -0
  55. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/core/technical.py +0 -0
  56. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/core/trace.py +0 -0
  57. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/core/validation.py +0 -0
  58. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/core/walkforward.py +0 -0
  59. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/demo.py +0 -0
  60. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/demo_book.py +0 -0
  61. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/demo_returns.py +0 -0
  62. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/demo_signal.py +0 -0
  63. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/demo_universe.py +0 -0
  64. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/events.py +0 -0
  65. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/loaders.py +0 -0
  66. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/model.py +0 -0
  67. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/py.typed +0 -0
  68. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/repl.py +0 -0
  69. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/study/__init__.py +0 -0
  70. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/study/report.py +0 -0
  71. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/study/schema.py +0 -0
  72. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/sweep/__init__.py +0 -0
  73. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/sweep/runner.py +0 -0
  74. {alphaengine-0.9.2 → alphaengine-0.9.4}/src/alphaengine/tui/__init__.py +0 -0
  75. {alphaengine-0.9.2 → alphaengine-0.9.4}/tests/test_answer.py +0 -0
  76. {alphaengine-0.9.2 → alphaengine-0.9.4}/tests/test_book.py +0 -0
  77. {alphaengine-0.9.2 → alphaengine-0.9.4}/tests/test_cli.py +0 -0
  78. {alphaengine-0.9.2 → alphaengine-0.9.4}/tests/test_client.py +0 -0
  79. {alphaengine-0.9.2 → alphaengine-0.9.4}/tests/test_commands.py +0 -0
  80. {alphaengine-0.9.2 → alphaengine-0.9.4}/tests/test_events.py +0 -0
  81. {alphaengine-0.9.2 → alphaengine-0.9.4}/tests/test_executor_ops.py +0 -0
  82. {alphaengine-0.9.2 → alphaengine-0.9.4}/tests/test_goldens.py +0 -0
  83. {alphaengine-0.9.2 → alphaengine-0.9.4}/tests/test_loaders.py +0 -0
  84. {alphaengine-0.9.2 → alphaengine-0.9.4}/tests/test_math_audit.py +0 -0
  85. {alphaengine-0.9.2 → alphaengine-0.9.4}/tests/test_model.py +0 -0
  86. {alphaengine-0.9.2 → alphaengine-0.9.4}/tests/test_process.py +0 -0
  87. {alphaengine-0.9.2 → alphaengine-0.9.4}/tests/test_quant_maths.py +0 -0
  88. {alphaengine-0.9.2 → alphaengine-0.9.4}/tests/test_screen.py +0 -0
  89. {alphaengine-0.9.2 → alphaengine-0.9.4}/tests/test_signals.py +0 -0
  90. {alphaengine-0.9.2 → alphaengine-0.9.4}/tests/test_smoke.py +0 -0
  91. {alphaengine-0.9.2 → alphaengine-0.9.4}/tests/test_sweep.py +0 -0
  92. {alphaengine-0.9.2 → alphaengine-0.9.4}/tests/test_trace.py +0 -0
  93. {alphaengine-0.9.2 → alphaengine-0.9.4}/tests/test_tui.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: alphaengine
3
- Version: 0.9.2
3
+ Version: 0.9.4
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
@@ -84,4 +84,12 @@ is what a changed figure costs. The API may still move underneath it.
84
84
  # 0.9.2 IS THE SESSION, NOT THE MATHS. No computed value moved. A command
85
85
  # shows that it is working, and a run writes each step as it happens. A thesis
86
86
  # on the account can be pinned, and the run opened after that carries its id.
87
- __version__ = "0.9.2"
87
+ #
88
+ # 0.9.3 IS THE SESSION, NOT THE MATHS. No computed value moved. The math pane
89
+ # is narrower. A sentence is shown the workflow it is choosing and what a step
90
+ # already measured, and a finished step is not run again.
91
+ #
92
+ # 0.9.4 IS THE SESSION, NOT THE MATHS. No computed value moved. The session
93
+ # opens on the desk: what is loaded, the thesis on its own lines, and the
94
+ # next thing to type.
95
+ __version__ = "0.9.4"
@@ -301,6 +301,19 @@ QUESTION_VERBS: dict[str, str] = {
301
301
  #: hand-maintained copies of this map is precisely the drift `commands.py`
302
302
  #: exists to prevent.
303
303
  QUESTION_FOR_WORKFLOW: dict[str, str] = {w: v for v, w in QUESTION_VERBS.items()}
304
+ #: What each workflow answers, in a reader's words. The server publishes a name.
305
+ #: A name is not a choice. These sentences describe the choice and say nothing
306
+ #: about the order of steps inside it. The model sees the same sentences.
307
+ _WORKFLOW_ANSWERS: dict[str, str] = {
308
+ "diagnose_data": "can I trust this data",
309
+ "screen_universe": "what in my universe is worth a look",
310
+ "evaluate_signal": "does this signal carry information",
311
+ "validate_study": "does this result hold up once you count the tries",
312
+ "stress_study": "where does it break",
313
+ "check_overlap": "is it new, or my book again",
314
+ "size_position": "how much of it should I hold",
315
+ "monitor_sleeve": "has anything crossed a line",
316
+ }
304
317
  #: The held-back-rows marker. Probed like the rest: cp1252 renders a literal
305
318
  #: ellipsis as mojibake in exactly the place a user reads their result.
306
319
  ELLIPSIS = "…" if _UNICODE_OK else "..."
@@ -644,7 +657,8 @@ class Working:
644
657
  self._started = time.monotonic()
645
658
  sink = _SINK.get()
646
659
  if sink is not None:
647
- sink(self.plain)
660
+ # The session paints this on the status line. It is not a trace line.
661
+ sink("\x1e" + self.plain)
648
662
  return self
649
663
  if not _tty():
650
664
  return self
@@ -900,18 +914,6 @@ def cmd_workflows(args: argparse.Namespace) -> int:
900
914
  say("The server offers no workflows.")
901
915
  return 0
902
916
 
903
- # WHAT EACH ONE ANSWERS, in the reader's words. The server publishes a name,
904
- # a version and an input contract -- correctly, since anything more would be
905
- # workflow knowledge crossing the wire -- but a name is not a choice. These
906
- # sentences live here, on the client, for exactly that reason: they describe
907
- # what a reader is picking between and disclose nothing about how it works.
908
- ANSWERS = {
909
- "screen_universe": "what in my universe is worth a look",
910
- "validate_study": "does this result hold up once you count the tries",
911
- "size_position": "how much of it should I hold",
912
- "monitor_sleeve": "has anything crossed a line",
913
- }
914
-
915
917
  width = max(len(str(r.get("name"))) for r in rows)
916
918
  say("")
917
919
  for r in rows:
@@ -921,7 +923,7 @@ def cmd_workflows(args: argparse.Namespace) -> int:
921
923
  # the same question disagree. Same reasoning as a trial count that says
922
924
  # whether it was derived or asserted.
923
925
  repro = green("reproducible") if r.get("reproducible") else yellow("may differ")
924
- say(f" {bold(name.ljust(width))} {ANSWERS.get(name, '')}")
926
+ say(f" {bold(name.ljust(width))} {_workflow_answer(r)}")
925
927
  say(f" {' ' * width} {dim('needs ' + needs)} {DOT} {repro} {dim(str(r.get('version')))}")
926
928
  say("")
927
929
  say(dim(" alphaengine run <name> --data prices.csv ") + dim(f"{DOT} `commands run` for the flags"))
@@ -1619,12 +1621,12 @@ def _drive(run: Any, *, quiet: bool = False) -> None:
1619
1621
  # with no why sent people to the traceback that no longer
1620
1622
  # prints. The message came from the executor and was written
1621
1623
  # to be read.
1622
- say(f" {red(CROSS)} {bold(op)}")
1623
- say(f" {NEST} {took}")
1624
+ say(f" step {n:<2} {red(CROSS)} {bold(op)}")
1624
1625
  why = getattr(run, "last_error", None)
1625
1626
  if why:
1626
1627
  for line in str(why).splitlines():
1627
- say(red(f" {NEST} {line}"))
1628
+ say(red(f" {line}"))
1629
+ say(f" {took}")
1628
1630
  if failures[key] >= 2:
1629
1631
  run.status = "abandoned"
1630
1632
  run.stopped = {
@@ -1639,11 +1641,13 @@ def _drive(run: Any, *, quiet: bool = False) -> None:
1639
1641
  # `emit.*` seal rather than measure) and printing a blank line
1640
1642
  # for it would read as a stall.
1641
1643
  found = _found(seen, run)
1642
- say(f" {_accent(ON)} {found or bold(op)}")
1643
- say(f" {dim(NEST)} {took}")
1644
+ say(f" step {n:<2} {bold(op)}")
1645
+ if found:
1646
+ say(f" {found}")
1647
+ say(f" {took}")
1644
1648
  rejected = getattr(run, "trace_rejected", None)
1645
1649
  if rejected:
1646
- say(yellow(f" portal did not accept the series trace: {rejected}"))
1650
+ say(yellow(f" portal did not accept the series trace: {rejected}"))
1647
1651
 
1648
1652
  if run.status != "open":
1649
1653
  return
@@ -3223,6 +3227,51 @@ def _is_workflow(session: Any, name: str) -> bool:
3223
3227
  return False
3224
3228
 
3225
3229
 
3230
+ def _workflow_answer(row: dict[str, Any]) -> str:
3231
+ """What this workflow answers, from the server when it says, else the local sentence."""
3232
+ for key in ("answers", "purpose", "description", "summary"):
3233
+ value = row.get(key)
3234
+ if isinstance(value, str) and value.strip():
3235
+ return value.strip()
3236
+ return _WORKFLOW_ANSWERS.get(str(row.get("name") or ""), "")
3237
+
3238
+
3239
+ def _catalogue_option(row: dict[str, Any]) -> dict[str, Any]:
3240
+ """One catalogue row, as a choice the model can tell from the others.
3241
+
3242
+ The name alone is why every question was landing on the same workflow.
3243
+ The sentence, what it needs, and whether it repeats are the distinction.
3244
+ """
3245
+ name = str(row.get("name") or "?")
3246
+ params: dict[str, Any] = {}
3247
+ answers = _workflow_answer(row)
3248
+ if answers:
3249
+ params["answers"] = answers[:180]
3250
+ needs = [str(item) for item in (row.get("requires") or []) if str(item)]
3251
+ if needs:
3252
+ params["needs"] = ", ".join(needs[:6])
3253
+ agency = row.get("agency")
3254
+ if agency:
3255
+ params["agency"] = str(agency)
3256
+ if row.get("reproducible") is True:
3257
+ params["reproducible"] = "yes"
3258
+ elif row.get("reproducible") is False:
3259
+ params["reproducible"] = "no"
3260
+ return {"op": name, "params": params, "answers": answers}
3261
+
3262
+
3263
+ def _prior_line(run: Any) -> str:
3264
+ """One line of what the desk already ran, so the next question is not blind."""
3265
+ from .client.agent import summarize_figures
3266
+
3267
+ workflow = str((getattr(run, "artifact", None) or {}).get("workflow") or "the last run")
3268
+ line = f"{workflow} is {getattr(run, 'status', '')}."
3269
+ measured = summarize_figures(getattr(run, "figures", None))
3270
+ if measured != "(nothing yet)":
3271
+ line = f"{line} {' '.join(measured.split())[:240]}"
3272
+ return line
3273
+
3274
+
3226
3275
  def _ask(
3227
3276
  session: Any,
3228
3277
  url: str,
@@ -3234,6 +3283,7 @@ def _ask(
3234
3283
  thesis_id: str | None = None,
3235
3284
  thesis_name: str | None = None,
3236
3285
  thesis_statement: str | None = None,
3286
+ prior: str | None = None,
3237
3287
  think: Callable[[str], str] | None = None,
3238
3288
  ) -> Any:
3239
3289
  """Natural language → a workflow chosen and driven by the user's own model.
@@ -3245,6 +3295,7 @@ def _ask(
3245
3295
  without being unsafe.
3246
3296
  """
3247
3297
  from .client import AgentDriver, AgentRefusal, Offline, ServerError
3298
+ from .client.agent import prefer_index
3248
3299
  from .events import EventSink
3249
3300
  from .model import NoModelConfigured, build_think
3250
3301
 
@@ -3283,12 +3334,16 @@ def _ask(
3283
3334
  say(dim("The server offers no workflows, so there is nothing to choose from."))
3284
3335
  return None
3285
3336
 
3286
- say(f" {_accent(STAR)} {dim(label)}")
3337
+ say(f" model {dim(label)}")
3287
3338
  if thesis_name:
3288
- say(dim(f" thesis {thesis_name}"))
3339
+ say(dim(f" thesis {thesis_name}"))
3340
+ if prior:
3341
+ say(dim(" prior " + prior.splitlines()[0][:88]))
3289
3342
  goal = request
3290
3343
  if thesis_statement:
3291
3344
  goal = f"{request}\n\nThesis:\n{thesis_statement}"
3345
+ if prior:
3346
+ goal = f"{goal}\n\nAlready on the desk:\n{prior}"
3292
3347
 
3293
3348
  # Choosing the workflow is the same shape as choosing a step: an index into
3294
3349
  # a list the server produced. Reusing `AgentDriver.pick` rather than writing
@@ -3296,16 +3351,22 @@ def _ask(
3296
3351
  driver = AgentDriver(
3297
3352
  think,
3298
3353
  goal=goal,
3299
- on_thought=lambda why: say(f" {_accent(STAR)} {_c('2;3', why)}"),
3354
+ on_thought=lambda why: say(f" why {why}"),
3300
3355
  on_step=lambda line: say(f" {line}"),
3301
3356
  sink=sink,
3302
3357
  provider=provider or None,
3303
3358
  model=model_name or None,
3304
3359
  )
3305
- options = [{"op": w.get("name", "?"), "params": {"agency": w.get("agency")}} for w in catalogue]
3360
+ options = [_catalogue_option(w) for w in catalogue]
3306
3361
  try:
3362
+ say(dim(" reading the catalogue"))
3307
3363
  with Working(dim("reading the catalogue"), plain="reading the catalogue"):
3308
- index = driver.pick(options)
3364
+ matched = prefer_index(request, options)
3365
+ if matched is None:
3366
+ index = driver.pick(options)
3367
+ else:
3368
+ index = matched
3369
+ say(dim(" matched the question uses this workflow's own words"))
3309
3370
  chosen = catalogue[index]
3310
3371
  except AgentRefusal as exc:
3311
3372
  say(red(f"The model did not choose a workflow: {exc}"))
@@ -3364,10 +3425,7 @@ def _ask(
3364
3425
  return None
3365
3426
 
3366
3427
  repro = chosen.get("reproducible")
3367
- say(
3368
- f" {_accent(ON)} chose {bold(name)} "
3369
- + (green("reproducible") if repro else yellow("not reproducible"))
3370
- )
3428
+ say(f" workflow {bold(name)} " + (green("reproducible") if repro else yellow("exploratory")))
3371
3429
 
3372
3430
  try:
3373
3431
  run = session.open(
@@ -3390,6 +3448,7 @@ def _ask(
3390
3448
  say(red(f"The run stopped: {exc}"))
3391
3449
  return None
3392
3450
 
3451
+ say(dim(" result"))
3393
3452
  _report(run)
3394
3453
  if run.status == "closed":
3395
3454
  _publish_signals(session, run, workspace_id=workspace_id, label=None)
@@ -89,7 +89,11 @@ The goal, in the researcher's words:
89
89
  What has happened so far:
90
90
  {history}
91
91
 
92
- You may choose exactly ONE of these permitted steps:
92
+ What has already been measured:
93
+ {measured}
94
+
95
+ You may choose exactly ONE of these permitted steps. Do not choose a step the
96
+ history says has already finished.
93
97
  {options}
94
98
 
95
99
  Reply with ONLY a JSON object:
@@ -99,6 +103,67 @@ Choose the index of the step that best advances the goal. You may not invent a
99
103
  step, and you may not choose anything not listed above.
100
104
  """
101
105
 
106
+ _STOPWORDS = frozenset(
107
+ {
108
+ "a",
109
+ "an",
110
+ "the",
111
+ "this",
112
+ "that",
113
+ "these",
114
+ "those",
115
+ "with",
116
+ "from",
117
+ "into",
118
+ "over",
119
+ "once",
120
+ "your",
121
+ "you",
122
+ "are",
123
+ "was",
124
+ "were",
125
+ "been",
126
+ "have",
127
+ "has",
128
+ "had",
129
+ "does",
130
+ "did",
131
+ "can",
132
+ "what",
133
+ "which",
134
+ "where",
135
+ "when",
136
+ "who",
137
+ "how",
138
+ "and",
139
+ "or",
140
+ "not",
141
+ "for",
142
+ "its",
143
+ "about",
144
+ "just",
145
+ "than",
146
+ "then",
147
+ "them",
148
+ "they",
149
+ "my",
150
+ "our",
151
+ "of",
152
+ "to",
153
+ "in",
154
+ "on",
155
+ "is",
156
+ "it",
157
+ "be",
158
+ "as",
159
+ "at",
160
+ "by",
161
+ "if",
162
+ "so",
163
+ "we",
164
+ }
165
+ )
166
+
102
167
 
103
168
  class AgentDriver:
104
169
  """Drives a run by asking a model which permitted step to take next.
@@ -144,14 +209,14 @@ class AgentDriver:
144
209
  and cannot name an op. This wrapper is the prompt+goal layer that USES it.
145
210
  """
146
211
 
147
- def choose(permitted: list[Figures], _figures: Figures) -> int:
148
- return self.pick(permitted)
212
+ def choose(permitted: list[Figures], figures: Figures) -> int:
213
+ return self.pick(permitted, figures)
149
214
 
150
215
  return choose
151
216
 
152
217
  # ── the one decision ───────────────────────────────────────────────────
153
218
 
154
- def pick(self, permitted: list[Figures]) -> int:
219
+ def pick(self, permitted: list[Figures], figures: Any = None) -> int:
155
220
  """Return an INDEX into `permitted`. Never a step, never an op name.
156
221
 
157
222
  The index is the safety property. A model cannot name an operation the
@@ -172,6 +237,7 @@ class AgentDriver:
172
237
  prompt = _PROMPT.format(
173
238
  goal=self.goal,
174
239
  history="\n".join(f" - {h}" for h in self.history[-12:]) or " (nothing yet)",
240
+ measured=summarize_figures(figures),
175
241
  options=options,
176
242
  )
177
243
  raw = self.think(prompt)
@@ -213,34 +279,63 @@ class AgentDriver:
213
279
  """
214
280
  n = 0
215
281
  failures: dict[str, int] = {}
282
+ done_ids: set[str] = set()
283
+ done_sigs: set[str] = set()
216
284
  while run.status == "open" and n < self.max_steps:
217
285
  if not run.permitted:
218
286
  run.resume()
219
287
  if run.status != "open" or not run.permitted:
220
288
  break
221
289
 
222
- step = run.permitted[self.pick(run.permitted)]
223
- op = str(step.get("op") or "?")
224
- self.on_step(f"{n + 1} {op}")
225
- before = run.status
226
- started = time.monotonic()
227
- run.step(step)
228
- took = time.monotonic() - started
229
- n += 1
230
-
231
- still = any(p.get("step_id") == step.get("step_id") for p in run.permitted)
232
- if still and before == "open" and run.status == "open":
233
- key = str(step.get("step_id"))
234
- failures[key] = failures.get(key, 0) + 1
235
- self.history.append(f"{op} could not be executed here")
236
- self.on_step(f"{n} {op} could not be executed {took:.1f}s")
237
- if failures[key] >= 2:
238
- run.status = "abandoned"
239
- run.stopped = {"reason": "step_failed", "op": step.get("op")}
240
- return run
290
+ fresh = [p for p in run.permitted if not _already_done(p, done_ids, done_sigs)]
291
+ if not fresh:
292
+ op = str((run.permitted[0] or {}).get("op") or "?")
293
+ self.on_step(f"stop {op} already finished")
294
+ self.history.append(f"{op} already finished; not running it again")
295
+ run.status = "stopped"
296
+ run.stopped = {"reason": "already finished", "op": op}
297
+ return run
298
+
299
+ if getattr(run, "selection", None) == "all":
300
+ batch = list(fresh)
301
+ elif len(fresh) == 1:
302
+ batch = fresh
241
303
  else:
242
- self.history.append(f"{op} done")
243
- self.on_step(f"{n} {op} {took:.1f}s")
304
+ batch = [fresh[self.pick(fresh, getattr(run, "figures", None))]]
305
+
306
+ for step in batch:
307
+ op = str(step.get("op") or "?")
308
+ self.on_step(f"step {n + 1:<2} {op}")
309
+ before = run.status
310
+ started = time.monotonic()
311
+ run.step(step)
312
+ took = time.monotonic() - started
313
+ n += 1
314
+
315
+ still = any(p.get("step_id") == step.get("step_id") for p in run.permitted)
316
+ if still and before == "open" and run.status == "open":
317
+ key = str(step.get("step_id"))
318
+ failures[key] = failures.get(key, 0) + 1
319
+ self.history.append(f"{op} could not be executed here")
320
+ self.on_step(f" could not be executed {took:.1f}s")
321
+ if failures[key] >= 2:
322
+ run.status = "abandoned"
323
+ run.stopped = {"reason": "step_failed", "op": step.get("op")}
324
+ return run
325
+ else:
326
+ sid = str(step.get("step_id") or "")
327
+ if sid:
328
+ done_ids.add(sid)
329
+ done_sigs.add(_signature(step))
330
+ found = _finding(getattr(run, "figures", None), op)
331
+ note = f"{op} finished"
332
+ if found:
333
+ note += f" ({found})"
334
+ self.history.append(note)
335
+ detail = f" {found} {took:.1f}s" if found else f" {took:.1f}s"
336
+ self.on_step(detail)
337
+ if run.status != "open":
338
+ return run
244
339
 
245
340
  if run.status == "open" and n >= self.max_steps:
246
341
  raise AgentRefusal(
@@ -253,6 +348,95 @@ class AgentDriver:
253
348
  # ── parsing, which has to assume the model is sloppy ───────────────────────
254
349
 
255
350
 
351
+ def summarize_figures(figures: Any, *, limit: int = 8) -> str:
352
+ """Scalars the run already produced. Lists stay out: a list is a series."""
353
+ if not isinstance(figures, dict) or not figures:
354
+ return "(nothing yet)"
355
+ lines: list[str] = []
356
+ for op, blob in figures.items():
357
+ if not isinstance(blob, dict):
358
+ continue
359
+ bits = _scalar_bits(blob)
360
+ if bits:
361
+ lines.append(f" {op}: " + ", ".join(bits))
362
+ if len(lines) >= limit:
363
+ break
364
+ return "\n".join(lines) or "(nothing yet)"
365
+
366
+
367
+ def _scalar_bits(blob: dict[str, Any], limit: int = 4) -> list[str]:
368
+ bits: list[str] = []
369
+ for key, value in blob.items():
370
+ if isinstance(value, bool) or value is None:
371
+ continue
372
+ if isinstance(value, float):
373
+ bits.append(f"{key}={value:.4g}")
374
+ elif isinstance(value, int):
375
+ bits.append(f"{key}={value}")
376
+ elif isinstance(value, str) and value.strip() and len(value) <= 80:
377
+ bits.append(f"{key}={value.strip()}")
378
+ if len(bits) >= limit:
379
+ break
380
+ return bits
381
+
382
+
383
+ def _finding(figures: Any, op: str) -> str:
384
+ if not isinstance(figures, dict):
385
+ return ""
386
+ blob = figures.get(op)
387
+ if not isinstance(blob, dict):
388
+ return ""
389
+ return ", ".join(_scalar_bits(blob))
390
+
391
+
392
+ def _signature(step: dict[str, Any]) -> str:
393
+ """Same op and same params is the same step, even with a new id."""
394
+ return f"{step.get('op') or ''}|{_brief(step.get('params') or {})}"
395
+
396
+
397
+ def _already_done(step: dict[str, Any], done_ids: set[str], done_sigs: set[str]) -> bool:
398
+ sid = str(step.get("step_id") or "")
399
+ if sid and sid in done_ids:
400
+ return True
401
+ return _signature(step) in done_sigs
402
+
403
+
404
+ def prefer_index(question: str, options: list[dict[str, Any]]) -> int | None:
405
+ """The one option whose own words the question uses.
406
+
407
+ None when the question does not single one out. There is no default and
408
+ no preferred order: a tie, or a weak overlap, goes to the model.
409
+ """
410
+ if not options:
411
+ return None
412
+ question_l = question.lower()
413
+ q = _content_tokens(question)
414
+ q_words = set(re.findall(r"[a-z0-9]+", question_l))
415
+ scores: list[int] = []
416
+ for opt in options:
417
+ answers = opt.get("answers")
418
+ if not isinstance(answers, str):
419
+ params = opt.get("params")
420
+ answers = params.get("answers") if isinstance(params, dict) else ""
421
+ score = len(q & _content_tokens(str(answers or "")))
422
+ name = str(opt.get("op") or "").lower()
423
+ if name and name.replace("_", " ") in question_l:
424
+ score += 3
425
+ for piece in name.split("_"):
426
+ if len(piece) >= 4 and piece in q_words:
427
+ score += 2
428
+ scores.append(score)
429
+ best = max(scores)
430
+ if best < 2:
431
+ return None
432
+ winners = [i for i, score in enumerate(scores) if score == best]
433
+ return winners[0] if len(winners) == 1 else None
434
+
435
+
436
+ def _content_tokens(text: str) -> set[str]:
437
+ return {w for w in re.findall(r"[a-z0-9]+", text.lower()) if len(w) >= 4 and w not in _STOPWORDS}
438
+
439
+
256
440
  def _brief(params: Any) -> str:
257
441
  """A short, SAFE rendering of a step's params for the prompt.
258
442
 
@@ -8,6 +8,7 @@ import re
8
8
  import time
9
9
  from typing import Any
10
10
 
11
+ from rich.text import Text
11
12
  from textual.app import App, ComposeResult
12
13
  from textual.containers import Horizontal, Vertical
13
14
  from textual.widgets import DataTable, Input, ListItem, ListView, RichLog, Sparkline, Static
@@ -17,23 +18,18 @@ from .dispatch import Desk, submit
17
18
 
18
19
  _ANSI = re.compile(r"\x1b\[[0-9;]*m")
19
20
 
21
+ # Kept as whole lines so a short window still shows the desk above them.
20
22
  _COMMANDS = (
21
23
  "login",
22
24
  "enter model key",
23
25
  "load <name>",
24
26
  "thesis",
25
- "screen",
26
- "diagnose",
27
- "signal",
28
- "validate",
29
- "stress",
30
- "overlap",
31
- "size",
32
- "monitor",
27
+ "screen diagnose signal",
28
+ "validate stress overlap",
29
+ "size monitor",
33
30
  "run <name>",
34
31
  "workflows",
35
- "help",
36
- "quit",
32
+ "help quit",
37
33
  )
38
34
  _URL_AFTER = {
39
35
  "AZURE_OPENAI_API_KEY": "AZURE_OPENAI_ENDPOINT",
@@ -58,16 +54,17 @@ Screen { background: #0b0d10; }
58
54
  color: #c5cdd6;
59
55
  padding: 1 1;
60
56
  overflow: auto;
57
+ border-right: solid #1c2128;
61
58
  }
62
59
  #transcript {
63
60
  width: 1fr;
64
61
  height: 1fr;
65
62
  background: #0b0d10;
66
- padding: 0 1;
63
+ padding: 1 2 0 2;
67
64
  }
68
65
  #inspector {
69
- width: 44;
70
- min-width: 36;
66
+ width: 30;
67
+ min-width: 24;
71
68
  background: #101318;
72
69
  padding: 1 1;
73
70
  overflow: hidden;
@@ -84,9 +81,9 @@ Screen { background: #0b0d10; }
84
81
  height: 1;
85
82
  background: #0b0d10;
86
83
  color: #6b7380;
87
- padding: 0 1;
84
+ padding: 0 2;
88
85
  }
89
- #prompt { dock: bottom; margin: 0 1; }
86
+ #prompt { dock: bottom; margin: 0 1 0 1; }
90
87
  """
91
88
 
92
89
 
@@ -101,6 +98,23 @@ def _fit(text: str, width: int = 22) -> str:
101
98
  return text[: width - 1] + "…"
102
99
 
103
100
 
101
+ def _wrap(text: str, width: int) -> list[str]:
102
+ words = " ".join(str(text).split()).split(" ")
103
+ if not words or words == [""]:
104
+ return []
105
+ lines: list[str] = []
106
+ current = ""
107
+ for word in words:
108
+ if current and len(current) + 1 + len(word) > width:
109
+ lines.append(current)
110
+ current = word
111
+ else:
112
+ current = f"{current} {word}".strip()
113
+ if current:
114
+ lines.append(current)
115
+ return lines
116
+
117
+
104
118
  class QuantOSApp(App[None]):
105
119
  """Conversation first. The math pane opens when a run has a line to show."""
106
120
 
@@ -139,6 +153,7 @@ class QuantOSApp(App[None]):
139
153
  self.rail_text = ""
140
154
  self.formula_text = ""
141
155
  self.transcript: list[str] = []
156
+ self.turns = 0
142
157
 
143
158
  def compose(self) -> ComposeResult:
144
159
  yield Static("", id="status")
@@ -152,18 +167,12 @@ class QuantOSApp(App[None]):
152
167
  yield Static("", id="inputs")
153
168
  yield Sparkline([], id="spark")
154
169
  yield DataTable(id="series", zebra_stripes=True)
155
- yield Static("enter send · help · quit", id="hint")
170
+ yield Static("", id="hint")
156
171
  yield Input(placeholder="Ask in plain English, or run <workflow>", id="prompt")
157
172
 
158
173
  def on_mount(self) -> None:
159
174
  self.query_one("#series", DataTable).cursor_type = "row"
160
175
  self._refresh_rail()
161
- log = self.query_one("#transcript", RichLog)
162
- log.write("Ask in plain English.")
163
- log.write("A sentence picks a workflow. run <name> follows it exactly.")
164
- log.write("login signs you in. enter model key pastes whichever key you use.")
165
- log.write("")
166
- log.write("help lists what you can type. quit leaves.")
167
176
  self.query_one(Input).focus()
168
177
  self.set_interval(0.2, self._tick_working)
169
178
  self._sync_account()
@@ -192,46 +201,58 @@ class QuantOSApp(App[None]):
192
201
  else:
193
202
  loaded = "nothing"
194
203
 
195
- lines = ["commands", *[f" {name}" for name in _COMMANDS], ""]
196
- lines += [
204
+ lines = [
205
+ "desk",
197
206
  f"{on} the maths",
198
- " offline, always",
199
- "",
207
+ " offline",
200
208
  f"{on if keyed else off} workflows",
201
209
  " ready" if keyed else " sign in",
202
- "",
203
210
  f"{on if ask_on else off} ask",
204
211
  f" {ask_note}",
205
212
  "",
206
213
  "loaded",
207
214
  f" {loaded}",
208
- "",
209
215
  "thesis",
210
- f" {_fit(thesis_label(self.desk.thesis) if self.desk.thesis else 'none')}",
211
- "",
212
- "universes",
213
216
  ]
217
+ if self.desk.thesis:
218
+ for piece in _wrap(thesis_label(self.desk.thesis), 22)[:3]:
219
+ lines.append(f" {piece}")
220
+ else:
221
+ lines.append(" none")
222
+ lines += ["", "universes"]
214
223
  if self._universes:
215
224
  for name in self._universes[:12]:
216
225
  mark = " stored" if name in self._stored else ""
217
226
  lines.append(f" {_fit(name, 20 - len(mark))}{mark}")
218
227
  else:
219
228
  lines.append(" none in reach")
229
+ lines += ["", "commands", *[f" {name}" for name in _COMMANDS]]
220
230
  if not keyed:
221
231
  lines += ["", "login", "signs you in"]
222
232
  self.rail_text = "\n".join(lines)
223
233
  self.query_one("#rail", Static).update(self.rail_text)
224
234
 
225
- bits = ["quantOS", "signed in" if keyed else "not signed in"]
235
+ bar = Text()
236
+ bar.append("quantOS", style="bold #9ecbff")
237
+ bar.append(" ", style="")
238
+ bar.append("signed in" if keyed else "not signed in", style="#c5cdd6" if keyed else "#e6b450")
226
239
  if self.desk.loaded:
227
- bits.append(self.desk.loaded)
240
+ bar.append(" ", style="")
241
+ bar.append(self.desk.loaded, style="#e6edf3")
228
242
  elif self.desk.data is not None:
229
- bits.append("data loaded")
230
- else:
231
- bits.append("no data")
243
+ bar.append(" data loaded", style="#e6edf3")
232
244
  if ask_on:
233
- bits.append(models[0][0])
234
- self.query_one("#status", Static).update(" · ".join(bits))
245
+ bar.append(" ", style="")
246
+ bar.append(models[0][0], style="#8b949e")
247
+ elif self.desk.thesis:
248
+ bar.append(" ", style="")
249
+ bar.append(_fit(thesis_label(self.desk.thesis), 28), style="#8b949e")
250
+ self.query_one("#status", Static).update(bar)
251
+ hint = Text(self._next_line(), style="#6b7380")
252
+ self.query_one("#hint", Static).update(hint)
253
+ prompt = self.query_one(Input)
254
+ if not (self.desk.pending_secret or self.desk.pending_provider or self.desk.pending_url):
255
+ prompt.placeholder = self._placeholder(ask_on)
235
256
 
236
257
  def on_input_submitted(self, event: Input.Submitted) -> None:
237
258
  text = event.value.strip()
@@ -247,6 +268,7 @@ class QuantOSApp(App[None]):
247
268
  if self.desk.pending_url:
248
269
  self._accept_url(text)
249
270
  return
271
+ self.turns += 1
250
272
  self.busy = True
251
273
  self.activity = text
252
274
  self._started = time.monotonic()
@@ -368,6 +390,76 @@ class QuantOSApp(App[None]):
368
390
  label, model = ready[0]
369
391
  log.write(f"Model ready: {label}/{model}. Ask in plain English.")
370
392
 
393
+ def _placeholder(self, ask_on: bool) -> str:
394
+ if self.desk.thesis and (ask_on or self.desk.think is not None):
395
+ return "Ask about this thesis"
396
+ if ask_on or self.desk.think is not None:
397
+ return "Ask in plain English, or run <workflow>"
398
+ if not self.desk.keyed:
399
+ return "login"
400
+ return "enter model key"
401
+
402
+ def _next_line(self) -> str:
403
+ if not self.desk.keyed:
404
+ return "login signs you in. enter model key pastes whichever key you use."
405
+ if self.desk.thesis:
406
+ return "Ask about this thesis. screen ranks the book. run <name> follows a workflow."
407
+ if self.desk.data is not None:
408
+ return "Ask about the loaded data. screen ranks it. run <name> follows a workflow."
409
+ return "Ask in plain English. A sentence picks a workflow. run <name> follows it exactly."
410
+
411
+ def _paint_desk(self, notes: list[str]) -> None:
412
+ """The opening card. State first, then the one next move. Not a manual."""
413
+ from ..cli import thesis_text
414
+
415
+ log = self.query_one("#transcript", RichLog)
416
+ log.clear()
417
+ head = Text()
418
+ head.append("quantOS", style="bold #9ecbff")
419
+ head.append(" research session", style="#6b7380")
420
+ log.write(head)
421
+ log.write("")
422
+
423
+ state = Text()
424
+ state.append("signed in" if self.desk.keyed else "not signed in", style="#e6edf3")
425
+ if self.desk.loaded:
426
+ state.append(" ", style="")
427
+ state.append(self.desk.loaded, style="#9ecbff")
428
+ elif self.desk.data is not None:
429
+ state.append(" series loaded", style="#9ecbff")
430
+ log.write(state)
431
+ log.write("")
432
+
433
+ if self.desk.thesis:
434
+ log.write(Text("thesis", style="bold #8b949e"))
435
+ name = thesis_label(self.desk.thesis)
436
+ for piece in _wrap(name, 62):
437
+ log.write(Text(piece, style="#e6edf3"))
438
+ statement = thesis_text(self.desk.thesis)
439
+ if statement and statement != name:
440
+ log.write("")
441
+ for piece in _wrap(statement, 66)[:5]:
442
+ log.write(Text(piece, style="#8b949e"))
443
+ else:
444
+ log.write(Text("no thesis pinned", style="#6b7380"))
445
+ log.write(Text("thesis <name> chooses one, when the account has several.", style="#6b7380"))
446
+
447
+ shown = False
448
+ for note in notes:
449
+ plain = _plain(note).strip()
450
+ if not plain or plain.lower().startswith("thesis "):
451
+ continue
452
+ if not shown:
453
+ log.write("")
454
+ shown = True
455
+ for piece in _wrap(plain, 66):
456
+ log.write(Text(piece, style="#8b949e"))
457
+
458
+ log.write("")
459
+ log.write(Text("─" * 28, style="#1c2128"))
460
+ for piece in _wrap(self._next_line(), 66):
461
+ log.write(Text(piece, style="#c5cdd6"))
462
+
371
463
  def _sync_account(self) -> None:
372
464
  """Refresh portal universes. One stored book loads itself."""
373
465
  from ..cli import _narrow_to_universe, _stored_universes, narration_to
@@ -419,10 +511,13 @@ class QuantOSApp(App[None]):
419
511
  named = ", ".join(thesis_label(r) for r in theses[:8])
420
512
  notes.append(f"Theses on your account: {named}. Type thesis <name> to use one.")
421
513
  log = self.query_one("#transcript", RichLog)
422
- for note in notes:
423
- plain = _plain(note)
424
- if plain.strip():
425
- self.transcript.append(plain)
514
+ kept = [_plain(note).strip() for note in notes if _plain(note).strip()]
515
+ for plain in kept:
516
+ self.transcript.append(plain)
517
+ if self.turns == 0:
518
+ self._paint_desk(kept)
519
+ else:
520
+ for plain in kept:
426
521
  log.write(plain)
427
522
  self._refresh_rail()
428
523
 
@@ -437,6 +532,15 @@ class QuantOSApp(App[None]):
437
532
  self.query_one("#status", Static).update(f"{frame} working · {label} · {elapsed:.1f}s")
438
533
 
439
534
  def _live(self, chunk: str) -> None:
535
+ # A working label updates the status line and does not join the trace.
536
+ # The step lines are the trace; a second copy of the op name is noise.
537
+ if chunk.startswith("\x1e"):
538
+ label = _plain(chunk[1:]).strip()
539
+ if label:
540
+ self.activity = label
541
+ if self.busy:
542
+ self._paint_working()
543
+ return
440
544
  plain = _plain(chunk).strip()
441
545
  if not plain:
442
546
  return
@@ -473,7 +577,7 @@ class QuantOSApp(App[None]):
473
577
  prompt.placeholder = f"paste {self.desk.pending_url}"
474
578
  else:
475
579
  prompt.password = False
476
- prompt.placeholder = "Ask in plain English, or run <workflow>"
580
+ self._refresh_rail()
477
581
  self.busy = False
478
582
  self.activity = ""
479
583
  if self.desk.account_dirty:
@@ -22,6 +22,7 @@ from ..cli import (
22
22
  _help_text,
23
23
  _is_workflow,
24
24
  _near_verb,
25
+ _prior_line,
25
26
  _repl_gap,
26
27
  _report,
27
28
  _split_run,
@@ -487,6 +488,7 @@ def _sentence(desk: Desk, line: str) -> None:
487
488
  thesis_id=thesis_id_of(pinned) or None if pinned else None,
488
489
  thesis_name=thesis_label(pinned) if pinned else None,
489
490
  thesis_statement=thesis_text(pinned) if pinned else None,
491
+ prior=_prior_line(desk.last) if desk.last is not None else None,
490
492
  )
491
493
  if run is not None:
492
494
  desk.last = run
@@ -134,6 +134,66 @@ def test_as_choice_is_the_index_bound_primitive():
134
134
  assert callable(IndexDriver)
135
135
 
136
136
 
137
+ def test_measured_scalars_reach_the_model_and_series_do_not():
138
+ """A later choice that cannot see what the last step found picks the same
139
+ step again. The scalars go in. A list is a series and stays out."""
140
+ seen: list[str] = []
141
+
142
+ def think(prompt: str) -> str:
143
+ seen.append(prompt)
144
+ return '{"choice": 1, "why": "the sweep already reported"}'
145
+
146
+ AgentDriver(think, goal="g").pick(
147
+ PERMITTED,
148
+ {"compute.sweep": {"sharpe": 0.4, "curve": [1, 2, 3], "verdict": "stop"}},
149
+ )
150
+ assert "compute.sweep" in seen[0]
151
+ assert "sharpe=0.4" in seen[0]
152
+ assert "verdict=stop" in seen[0]
153
+ assert "[1, 2, 3]" not in seen[0]
154
+
155
+
156
+ def test_a_finished_step_is_not_executed_again():
157
+ """A new step id with the same op and the same params is the same work.
158
+ Running it again is how a sentence loops."""
159
+
160
+ class _Replay:
161
+ def __init__(self) -> None:
162
+ self.status = "open"
163
+ self.selection = "any"
164
+ self.permitted = [{"step_id": "s1", "op": "compute.screen", "params": {}}]
165
+ self.figures: dict = {}
166
+ self.calls = 0
167
+ self.stopped = None
168
+
169
+ def resume(self) -> None:
170
+ return None
171
+
172
+ def step(self, step: dict) -> None:
173
+ self.calls += 1
174
+ self.figures["compute.screen"] = {"sharpe": 1.25}
175
+ self.permitted = [{"step_id": "s9", "op": "compute.screen", "params": {}}]
176
+ self.status = "open"
177
+
178
+ run = _Replay()
179
+ AgentDriver(lambda _p: '{"choice": 0}', goal="g").drive(run)
180
+ assert run.calls == 1
181
+ assert run.status == "stopped"
182
+ assert run.stopped["reason"] == "already finished"
183
+
184
+
185
+ def test_a_question_that_names_one_workflow_is_not_left_to_guess():
186
+ from alphaengine.client.agent import prefer_index
187
+
188
+ options = [
189
+ {"op": "screen_universe", "answers": "what in my universe is worth a look"},
190
+ {"op": "validate_study", "answers": "does this result hold up once you count the tries"},
191
+ ]
192
+ assert prefer_index("screen my universe", options) == 0
193
+ assert prefer_index("which of my names are overbought", options) is None
194
+ assert prefer_index("look at the universe and validate the study", options) is None
195
+
196
+
137
197
  def test_params_are_truncated_before_reaching_the_model():
138
198
  """A step's params can carry a parameter grid, which is frequently bigger
139
199
  intellectual property than the returns. No reason to ship all of it to a
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes