alphaengine 0.9.1__tar.gz → 0.9.3__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.1 → alphaengine-0.9.3}/PKG-INFO +4 -1
  2. {alphaengine-0.9.1 → alphaengine-0.9.3}/README.md +3 -0
  3. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/_version.py +9 -1
  4. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/cli.py +170 -30
  5. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/client/agent.py +214 -19
  6. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/client/session.py +19 -11
  7. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/commands.py +37 -1
  8. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/tui/app.py +71 -19
  9. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/tui/dispatch.py +88 -2
  10. {alphaengine-0.9.1 → alphaengine-0.9.3}/tests/test_agent.py +60 -0
  11. {alphaengine-0.9.1 → alphaengine-0.9.3}/tests/test_client.py +25 -0
  12. {alphaengine-0.9.1 → alphaengine-0.9.3}/tests/test_tui.py +71 -1
  13. {alphaengine-0.9.1 → alphaengine-0.9.3}/.github/workflows/ci.yml +0 -0
  14. {alphaengine-0.9.1 → alphaengine-0.9.3}/.github/workflows/publish.yml +0 -0
  15. {alphaengine-0.9.1 → alphaengine-0.9.3}/.gitignore +0 -0
  16. {alphaengine-0.9.1 → alphaengine-0.9.3}/AGENTS.md +0 -0
  17. {alphaengine-0.9.1 → alphaengine-0.9.3}/LICENSE +0 -0
  18. {alphaengine-0.9.1 → alphaengine-0.9.3}/SECURITY.md +0 -0
  19. {alphaengine-0.9.1 → alphaengine-0.9.3}/docs/assets/banner.png +0 -0
  20. {alphaengine-0.9.1 → alphaengine-0.9.3}/docs/assets/data_boundary.png +0 -0
  21. {alphaengine-0.9.1 → alphaengine-0.9.3}/docs/assets/session.png +0 -0
  22. {alphaengine-0.9.1 → alphaengine-0.9.3}/docs/figure_contract.md +0 -0
  23. {alphaengine-0.9.1 → alphaengine-0.9.3}/docs/workflows/evaluate_process.json +0 -0
  24. {alphaengine-0.9.1 → alphaengine-0.9.3}/docs/workflows/research_week.json +0 -0
  25. {alphaengine-0.9.1 → alphaengine-0.9.3}/docs/workflows/stress_study.json +0 -0
  26. {alphaengine-0.9.1 → alphaengine-0.9.3}/pyproject.toml +0 -0
  27. {alphaengine-0.9.1 → alphaengine-0.9.3}/scripts/gen_docs.py +0 -0
  28. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/__init__.py +0 -0
  29. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/__main__.py +0 -0
  30. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/agent/__init__.py +0 -0
  31. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/agent/answer.py +0 -0
  32. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/agent/driver.py +0 -0
  33. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/auth.py +0 -0
  34. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/book.py +0 -0
  35. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/charts.py +0 -0
  36. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/client/__init__.py +0 -0
  37. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/client/executor.py +0 -0
  38. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/complete.py +0 -0
  39. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/connectors/__init__.py +0 -0
  40. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/core/__init__.py +0 -0
  41. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/core/active.py +0 -0
  42. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/core/allocate.py +0 -0
  43. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/core/backtest.py +0 -0
  44. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/core/covariance.py +0 -0
  45. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/core/cross_section.py +0 -0
  46. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/core/factors.py +0 -0
  47. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/core/pairs.py +0 -0
  48. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/core/panel.py +0 -0
  49. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/core/performance.py +0 -0
  50. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/core/process.py +0 -0
  51. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/core/profile.py +0 -0
  52. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/core/risk.py +0 -0
  53. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/core/screen.py +0 -0
  54. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/core/series_shapes.py +0 -0
  55. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/core/signals.py +0 -0
  56. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/core/stress.py +0 -0
  57. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/core/technical.py +0 -0
  58. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/core/trace.py +0 -0
  59. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/core/validation.py +0 -0
  60. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/core/walkforward.py +0 -0
  61. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/demo.py +0 -0
  62. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/demo_book.py +0 -0
  63. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/demo_returns.py +0 -0
  64. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/demo_signal.py +0 -0
  65. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/demo_universe.py +0 -0
  66. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/events.py +0 -0
  67. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/loaders.py +0 -0
  68. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/model.py +0 -0
  69. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/py.typed +0 -0
  70. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/repl.py +0 -0
  71. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/study/__init__.py +0 -0
  72. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/study/report.py +0 -0
  73. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/study/schema.py +0 -0
  74. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/sweep/__init__.py +0 -0
  75. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/sweep/runner.py +0 -0
  76. {alphaengine-0.9.1 → alphaengine-0.9.3}/src/alphaengine/tui/__init__.py +0 -0
  77. {alphaengine-0.9.1 → alphaengine-0.9.3}/tests/test_answer.py +0 -0
  78. {alphaengine-0.9.1 → alphaengine-0.9.3}/tests/test_book.py +0 -0
  79. {alphaengine-0.9.1 → alphaengine-0.9.3}/tests/test_cli.py +0 -0
  80. {alphaengine-0.9.1 → alphaengine-0.9.3}/tests/test_commands.py +0 -0
  81. {alphaengine-0.9.1 → alphaengine-0.9.3}/tests/test_events.py +0 -0
  82. {alphaengine-0.9.1 → alphaengine-0.9.3}/tests/test_executor_ops.py +0 -0
  83. {alphaengine-0.9.1 → alphaengine-0.9.3}/tests/test_goldens.py +0 -0
  84. {alphaengine-0.9.1 → alphaengine-0.9.3}/tests/test_loaders.py +0 -0
  85. {alphaengine-0.9.1 → alphaengine-0.9.3}/tests/test_math_audit.py +0 -0
  86. {alphaengine-0.9.1 → alphaengine-0.9.3}/tests/test_model.py +0 -0
  87. {alphaengine-0.9.1 → alphaengine-0.9.3}/tests/test_process.py +0 -0
  88. {alphaengine-0.9.1 → alphaengine-0.9.3}/tests/test_quant_maths.py +0 -0
  89. {alphaengine-0.9.1 → alphaengine-0.9.3}/tests/test_screen.py +0 -0
  90. {alphaengine-0.9.1 → alphaengine-0.9.3}/tests/test_signals.py +0 -0
  91. {alphaengine-0.9.1 → alphaengine-0.9.3}/tests/test_smoke.py +0 -0
  92. {alphaengine-0.9.1 → alphaengine-0.9.3}/tests/test_sweep.py +0 -0
  93. {alphaengine-0.9.1 → alphaengine-0.9.3}/tests/test_trace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: alphaengine
3
- Version: 0.9.1
3
+ Version: 0.9.3
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
@@ -333,6 +333,7 @@ w = hrp_weights(cov, names=names) # no matrix inverse; weights sum to one
333
333
  |---|---|---|
334
334
  | `book [<name> \| status]` | show or load sleeves on the multi-strategy book | session |
335
335
  | `load <file \| module \| universe>` | a CSV, a project module, or a portal universe | session |
336
+ | `thesis [<name> \| clear]` | choose a portal thesis for the next run | session |
336
337
  | `universe <name>` | same as load: a universe registered in the portal | session |
337
338
  | `data <file>` | same as load: a local CSV or parquet | session |
338
339
  | `project <module>` | same as load: a module with data and backtest_fn | session |
@@ -363,6 +364,7 @@ w = hrp_weights(cov, names=names) # no matrix inverse; weights sum to one
363
364
  | `--project MODULE` | a module exposing `data` and `backtest_fn` |
364
365
  | `--data FILE` | a local CSV: wide, long, or a single series |
365
366
  | `--universe NAME` | a universe registered in the portal, with its stored closes |
367
+ | `--thesis NAME` | a thesis on this account; the run is attached to it |
366
368
  | `--symbol TICKER` | one name out of a loaded universe; its closes become the return series |
367
369
  | `--label TEXT` | what to call the artifact this run produces |
368
370
  | `--input K=V` | a workflow input; repeatable |
@@ -398,6 +400,7 @@ alphaengine run size_position --data returns.csv
398
400
  alphaengine run validate_study --project research.momentum
399
401
  alphaengine models
400
402
  alphaengine trace
403
+ alphaengine run screen_universe --thesis momentum
401
404
  alphaengine logout
402
405
  alphaengine version
403
406
  ```
@@ -288,6 +288,7 @@ w = hrp_weights(cov, names=names) # no matrix inverse; weights sum to one
288
288
  |---|---|---|
289
289
  | `book [<name> \| status]` | show or load sleeves on the multi-strategy book | session |
290
290
  | `load <file \| module \| universe>` | a CSV, a project module, or a portal universe | session |
291
+ | `thesis [<name> \| clear]` | choose a portal thesis for the next run | session |
291
292
  | `universe <name>` | same as load: a universe registered in the portal | session |
292
293
  | `data <file>` | same as load: a local CSV or parquet | session |
293
294
  | `project <module>` | same as load: a module with data and backtest_fn | session |
@@ -318,6 +319,7 @@ w = hrp_weights(cov, names=names) # no matrix inverse; weights sum to one
318
319
  | `--project MODULE` | a module exposing `data` and `backtest_fn` |
319
320
  | `--data FILE` | a local CSV: wide, long, or a single series |
320
321
  | `--universe NAME` | a universe registered in the portal, with its stored closes |
322
+ | `--thesis NAME` | a thesis on this account; the run is attached to it |
321
323
  | `--symbol TICKER` | one name out of a loaded universe; its closes become the return series |
322
324
  | `--label TEXT` | what to call the artifact this run produces |
323
325
  | `--input K=V` | a workflow input; repeatable |
@@ -353,6 +355,7 @@ alphaengine run size_position --data returns.csv
353
355
  alphaengine run validate_study --project research.momentum
354
356
  alphaengine models
355
357
  alphaengine trace
358
+ alphaengine run screen_universe --thesis momentum
356
359
  alphaengine logout
357
360
  alphaengine version
358
361
  ```
@@ -80,4 +80,12 @@ is what a changed figure costs. The API may still move underneath it.
80
80
  # 0.9.1 IS THE SESSION, NOT THE MATHS. No computed value moved. A signed-in
81
81
  # account loads its one stored universe from the portal. `enter model key`
82
82
  # takes whichever provider key is pasted. The left pane lists the commands.
83
- __version__ = "0.9.1"
83
+ #
84
+ # 0.9.2 IS THE SESSION, NOT THE MATHS. No computed value moved. A command
85
+ # shows that it is working, and a run writes each step as it happens. A thesis
86
+ # on the account can be pinned, and the run opened after that carries its id.
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
+ __version__ = "0.9.3"
@@ -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
@@ -1708,11 +1712,28 @@ def cmd_run(args: argparse.Namespace) -> int:
1708
1712
  # named one explicitly. Without this, a sweep received `grid={}`.
1709
1713
  if "grid" not in inputs and project_grid():
1710
1714
  inputs["grid"] = project_grid()
1715
+ thesis_id = None
1716
+ named_thesis = getattr(args, "thesis", None)
1717
+ if named_thesis:
1718
+ chosen, candidates = match_thesis(session.theses(), str(named_thesis))
1719
+ if chosen is None:
1720
+ if candidates:
1721
+ names = ", ".join(thesis_label(r) for r in candidates)
1722
+ say(yellow(f"Which thesis? {names}"))
1723
+ else:
1724
+ say(yellow(f"No thesis named {named_thesis!r} on this account."))
1725
+ return 2
1726
+ thesis_id = thesis_id_of(chosen)
1727
+ if not thesis_id:
1728
+ say(yellow(f"{thesis_label(chosen)} has no id, so a run cannot be attached to it."))
1729
+ return 2
1730
+ say(dim(f" thesis {thesis_label(chosen)}"))
1711
1731
  run = session.open(
1712
1732
  args.workflow,
1713
1733
  data=data,
1714
1734
  backtest_fn=backtest_fn,
1715
1735
  workspace_id=workspace_id,
1736
+ thesis_id=thesis_id,
1716
1737
  **inputs,
1717
1738
  )
1718
1739
  _drive(run, quiet=args.quiet)
@@ -2235,7 +2256,7 @@ def _extract_flags(line: str) -> tuple[str, list[str]]:
2235
2256
  i = 0
2236
2257
  while i < len(parts):
2237
2258
  tok = parts[i]
2238
- if tok in ("--data", "--universe", "--project", "--symbol") and i + 1 < len(parts):
2259
+ if tok in ("--data", "--universe", "--project", "--symbol", "--thesis") and i + 1 < len(parts):
2239
2260
  flags += [tok, parts[i + 1]]
2240
2261
  i += 2
2241
2262
  continue
@@ -2357,6 +2378,58 @@ def _stored_universes(session: Any, question: str = "") -> tuple[str | None, lis
2357
2378
  return (ready[0] if len(ready) == 1 else None), ready
2358
2379
 
2359
2380
 
2381
+ def thesis_label(row: dict[str, Any]) -> str:
2382
+ """The name a person types. The id is what the run carries."""
2383
+ return str(row.get("name") or row.get("title") or row.get("id") or "").strip()
2384
+
2385
+
2386
+ def thesis_text(row: dict[str, Any]) -> str:
2387
+ """The statement, under whichever key the portal sent it."""
2388
+ for key in ("statement", "summary", "body", "text"):
2389
+ value = row.get(key)
2390
+ if isinstance(value, str) and value.strip():
2391
+ return value.strip()
2392
+ return ""
2393
+
2394
+
2395
+ def thesis_id_of(row: dict[str, Any]) -> str:
2396
+ return str(row.get("id") or row.get("thesis_id") or "").strip()
2397
+
2398
+
2399
+ def match_thesis(rows: list[Any], query: str) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
2400
+ """One thesis, or the candidates when the name matches more than one.
2401
+
2402
+ Exact name or id wins. A unique substring is enough. Several matches are
2403
+ returned so the caller can name them instead of guessing.
2404
+ """
2405
+ usable = [r for r in rows if isinstance(r, dict)]
2406
+ q = query.strip().casefold()
2407
+ exact = [r for r in usable if thesis_label(r).casefold() == q or thesis_id_of(r).casefold() == q]
2408
+ if len(exact) == 1:
2409
+ return exact[0], []
2410
+ if len(exact) > 1:
2411
+ return None, exact
2412
+ partial = [r for r in usable if q and q in thesis_label(r).casefold()]
2413
+ if len(partial) == 1:
2414
+ return partial[0], []
2415
+ return None, partial
2416
+
2417
+
2418
+ def pop_flag(flags: list[str], name: str) -> tuple[str | None, list[str]]:
2419
+ """Take one ``--name value`` out of a flag list. The rest stay in order."""
2420
+ kept: list[str] = []
2421
+ value: str | None = None
2422
+ i = 0
2423
+ while i < len(flags):
2424
+ if flags[i] == name and i + 1 < len(flags):
2425
+ value = flags[i + 1]
2426
+ i += 2
2427
+ continue
2428
+ kept.append(flags[i])
2429
+ i += 1
2430
+ return value, kept
2431
+
2432
+
2360
2433
  def _split_run(rest: str) -> tuple[str, list[str]]:
2361
2434
  """`screen_universe --universe sp100` -> ("screen_universe", [...]).
2362
2435
 
@@ -3154,6 +3227,51 @@ def _is_workflow(session: Any, name: str) -> bool:
3154
3227
  return False
3155
3228
 
3156
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
+
3157
3275
  def _ask(
3158
3276
  session: Any,
3159
3277
  url: str,
@@ -3162,6 +3280,10 @@ def _ask(
3162
3280
  data: Any,
3163
3281
  backtest_fn: Any,
3164
3282
  workspace_id: str | None = None,
3283
+ thesis_id: str | None = None,
3284
+ thesis_name: str | None = None,
3285
+ thesis_statement: str | None = None,
3286
+ prior: str | None = None,
3165
3287
  think: Callable[[str], str] | None = None,
3166
3288
  ) -> Any:
3167
3289
  """Natural language → a workflow chosen and driven by the user's own model.
@@ -3173,6 +3295,7 @@ def _ask(
3173
3295
  without being unsafe.
3174
3296
  """
3175
3297
  from .client import AgentDriver, AgentRefusal, Offline, ServerError
3298
+ from .client.agent import prefer_index
3176
3299
  from .events import EventSink
3177
3300
  from .model import NoModelConfigured, build_think
3178
3301
 
@@ -3211,23 +3334,39 @@ def _ask(
3211
3334
  say(dim("The server offers no workflows, so there is nothing to choose from."))
3212
3335
  return None
3213
3336
 
3214
- say(f" {_accent(STAR)} {dim(label)}")
3337
+ say(f" model {dim(label)}")
3338
+ if thesis_name:
3339
+ say(dim(f" thesis {thesis_name}"))
3340
+ if prior:
3341
+ say(dim(" prior " + prior.splitlines()[0][:88]))
3342
+ goal = request
3343
+ if thesis_statement:
3344
+ goal = f"{request}\n\nThesis:\n{thesis_statement}"
3345
+ if prior:
3346
+ goal = f"{goal}\n\nAlready on the desk:\n{prior}"
3215
3347
 
3216
3348
  # Choosing the workflow is the same shape as choosing a step: an index into
3217
3349
  # a list the server produced. Reusing `AgentDriver.pick` rather than writing
3218
3350
  # a second selector keeps one place where a model's answer is validated.
3219
3351
  driver = AgentDriver(
3220
3352
  think,
3221
- goal=request,
3222
- on_thought=lambda why: say(f" {_accent(STAR)} {_c('2;3', why)}"),
3353
+ goal=goal,
3354
+ on_thought=lambda why: say(f" why {why}"),
3355
+ on_step=lambda line: say(f" {line}"),
3223
3356
  sink=sink,
3224
3357
  provider=provider or None,
3225
3358
  model=model_name or None,
3226
3359
  )
3227
- options = [{"op": w.get("name", "?"), "params": {"agency": w.get("agency")}} for w in catalogue]
3360
+ options = [_catalogue_option(w) for w in catalogue]
3228
3361
  try:
3362
+ say(dim(" reading the catalogue"))
3229
3363
  with Working(dim("reading the catalogue"), plain="reading the catalogue"):
3230
- 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"))
3231
3370
  chosen = catalogue[index]
3232
3371
  except AgentRefusal as exc:
3233
3372
  say(red(f"The model did not choose a workflow: {exc}"))
@@ -3286,10 +3425,7 @@ def _ask(
3286
3425
  return None
3287
3426
 
3288
3427
  repro = chosen.get("reproducible")
3289
- say(
3290
- f" {_accent(ON)} chose {bold(name)} "
3291
- + (green("reproducible") if repro else yellow("not reproducible"))
3292
- )
3428
+ say(f" workflow {bold(name)} " + (green("reproducible") if repro else yellow("exploratory")))
3293
3429
 
3294
3430
  try:
3295
3431
  run = session.open(
@@ -3297,6 +3433,7 @@ def _ask(
3297
3433
  data=data,
3298
3434
  backtest_fn=backtest_fn,
3299
3435
  workspace_id=workspace_id,
3436
+ thesis_id=thesis_id,
3300
3437
  **({"grid": project_grid()} if project_grid() else {}),
3301
3438
  )
3302
3439
  sink.bind(run.run_id)
@@ -3311,6 +3448,7 @@ def _ask(
3311
3448
  say(red(f"The run stopped: {exc}"))
3312
3449
  return None
3313
3450
 
3451
+ say(dim(" result"))
3314
3452
  _report(run)
3315
3453
  if run.status == "closed":
3316
3454
  _publish_signals(session, run, workspace_id=workspace_id, label=None)
@@ -3520,6 +3658,7 @@ def build_parser() -> argparse.ArgumentParser:
3520
3658
  r = sub.add_parser("run", parents=[common], help="run one workflow to completion")
3521
3659
  r.add_argument("workflow")
3522
3660
  r.add_argument("--label", help="what to call the artifact")
3661
+ r.add_argument("--thesis", help="name or id of a thesis on this account; attached to the run")
3523
3662
  r.add_argument("--input", action="append", help="workflow input as key=value (repeatable)")
3524
3663
  r.add_argument("--quiet", action="store_true", help="only the result")
3525
3664
  r.add_argument("--stream", action="store_true", help="print the answer after the citation guard")
@@ -3532,6 +3671,7 @@ def build_parser() -> argparse.ArgumentParser:
3532
3671
  for verb, workflow in QUESTION_VERBS.items():
3533
3672
  q = sub.add_parser(verb, parents=[common], help=f"run {workflow}")
3534
3673
  q.add_argument("--label", help="what to call the artifact")
3674
+ q.add_argument("--thesis", help="name or id of a thesis on this account; attached to the run")
3535
3675
  q.add_argument("--input", action="append", help="workflow input as key=value (repeatable)")
3536
3676
  q.add_argument("--quiet", action="store_true", help="only the result")
3537
3677
  q.add_argument("--stream", action="store_true", help="print the answer after the citation guard")