alphaengine 0.9.2__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.2 → alphaengine-0.9.3}/PKG-INFO +1 -1
  2. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/_version.py +5 -1
  3. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/cli.py +88 -29
  4. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/client/agent.py +209 -25
  5. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/tui/app.py +11 -2
  6. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/tui/dispatch.py +2 -0
  7. {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_agent.py +60 -0
  8. {alphaengine-0.9.2 → alphaengine-0.9.3}/.github/workflows/ci.yml +0 -0
  9. {alphaengine-0.9.2 → alphaengine-0.9.3}/.github/workflows/publish.yml +0 -0
  10. {alphaengine-0.9.2 → alphaengine-0.9.3}/.gitignore +0 -0
  11. {alphaengine-0.9.2 → alphaengine-0.9.3}/AGENTS.md +0 -0
  12. {alphaengine-0.9.2 → alphaengine-0.9.3}/LICENSE +0 -0
  13. {alphaengine-0.9.2 → alphaengine-0.9.3}/README.md +0 -0
  14. {alphaengine-0.9.2 → alphaengine-0.9.3}/SECURITY.md +0 -0
  15. {alphaengine-0.9.2 → alphaengine-0.9.3}/docs/assets/banner.png +0 -0
  16. {alphaengine-0.9.2 → alphaengine-0.9.3}/docs/assets/data_boundary.png +0 -0
  17. {alphaengine-0.9.2 → alphaengine-0.9.3}/docs/assets/session.png +0 -0
  18. {alphaengine-0.9.2 → alphaengine-0.9.3}/docs/figure_contract.md +0 -0
  19. {alphaengine-0.9.2 → alphaengine-0.9.3}/docs/workflows/evaluate_process.json +0 -0
  20. {alphaengine-0.9.2 → alphaengine-0.9.3}/docs/workflows/research_week.json +0 -0
  21. {alphaengine-0.9.2 → alphaengine-0.9.3}/docs/workflows/stress_study.json +0 -0
  22. {alphaengine-0.9.2 → alphaengine-0.9.3}/pyproject.toml +0 -0
  23. {alphaengine-0.9.2 → alphaengine-0.9.3}/scripts/gen_docs.py +0 -0
  24. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/__init__.py +0 -0
  25. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/__main__.py +0 -0
  26. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/agent/__init__.py +0 -0
  27. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/agent/answer.py +0 -0
  28. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/agent/driver.py +0 -0
  29. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/auth.py +0 -0
  30. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/book.py +0 -0
  31. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/charts.py +0 -0
  32. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/client/__init__.py +0 -0
  33. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/client/executor.py +0 -0
  34. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/client/session.py +0 -0
  35. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/commands.py +0 -0
  36. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/complete.py +0 -0
  37. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/connectors/__init__.py +0 -0
  38. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/__init__.py +0 -0
  39. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/active.py +0 -0
  40. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/allocate.py +0 -0
  41. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/backtest.py +0 -0
  42. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/covariance.py +0 -0
  43. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/cross_section.py +0 -0
  44. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/factors.py +0 -0
  45. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/pairs.py +0 -0
  46. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/panel.py +0 -0
  47. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/performance.py +0 -0
  48. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/process.py +0 -0
  49. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/profile.py +0 -0
  50. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/risk.py +0 -0
  51. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/screen.py +0 -0
  52. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/series_shapes.py +0 -0
  53. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/signals.py +0 -0
  54. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/stress.py +0 -0
  55. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/technical.py +0 -0
  56. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/trace.py +0 -0
  57. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/validation.py +0 -0
  58. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/walkforward.py +0 -0
  59. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/demo.py +0 -0
  60. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/demo_book.py +0 -0
  61. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/demo_returns.py +0 -0
  62. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/demo_signal.py +0 -0
  63. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/demo_universe.py +0 -0
  64. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/events.py +0 -0
  65. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/loaders.py +0 -0
  66. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/model.py +0 -0
  67. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/py.typed +0 -0
  68. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/repl.py +0 -0
  69. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/study/__init__.py +0 -0
  70. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/study/report.py +0 -0
  71. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/study/schema.py +0 -0
  72. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/sweep/__init__.py +0 -0
  73. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/sweep/runner.py +0 -0
  74. {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/tui/__init__.py +0 -0
  75. {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_answer.py +0 -0
  76. {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_book.py +0 -0
  77. {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_cli.py +0 -0
  78. {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_client.py +0 -0
  79. {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_commands.py +0 -0
  80. {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_events.py +0 -0
  81. {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_executor_ops.py +0 -0
  82. {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_goldens.py +0 -0
  83. {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_loaders.py +0 -0
  84. {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_math_audit.py +0 -0
  85. {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_model.py +0 -0
  86. {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_process.py +0 -0
  87. {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_quant_maths.py +0 -0
  88. {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_screen.py +0 -0
  89. {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_signals.py +0 -0
  90. {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_smoke.py +0 -0
  91. {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_sweep.py +0 -0
  92. {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_trace.py +0 -0
  93. {alphaengine-0.9.2 → alphaengine-0.9.3}/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.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
@@ -84,4 +84,8 @@ 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
+ __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
@@ -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
 
@@ -66,8 +66,8 @@ Screen { background: #0b0d10; }
66
66
  padding: 0 1;
67
67
  }
68
68
  #inspector {
69
- width: 44;
70
- min-width: 36;
69
+ width: 30;
70
+ min-width: 24;
71
71
  background: #101318;
72
72
  padding: 1 1;
73
73
  overflow: hidden;
@@ -437,6 +437,15 @@ class QuantOSApp(App[None]):
437
437
  self.query_one("#status", Static).update(f"{frame} working · {label} · {elapsed:.1f}s")
438
438
 
439
439
  def _live(self, chunk: str) -> None:
440
+ # A working label updates the status line and does not join the trace.
441
+ # The step lines are the trace; a second copy of the op name is noise.
442
+ if chunk.startswith("\x1e"):
443
+ label = _plain(chunk[1:]).strip()
444
+ if label:
445
+ self.activity = label
446
+ if self.busy:
447
+ self._paint_working()
448
+ return
440
449
  plain = _plain(chunk).strip()
441
450
  if not plain:
442
451
  return
@@ -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