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.
- {alphaengine-0.9.2 → alphaengine-0.9.3}/PKG-INFO +1 -1
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/_version.py +5 -1
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/cli.py +88 -29
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/client/agent.py +209 -25
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/tui/app.py +11 -2
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/tui/dispatch.py +2 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_agent.py +60 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/.github/workflows/ci.yml +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/.github/workflows/publish.yml +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/.gitignore +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/AGENTS.md +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/LICENSE +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/README.md +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/SECURITY.md +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/docs/assets/banner.png +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/docs/assets/data_boundary.png +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/docs/assets/session.png +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/docs/figure_contract.md +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/docs/workflows/evaluate_process.json +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/docs/workflows/research_week.json +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/docs/workflows/stress_study.json +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/pyproject.toml +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/scripts/gen_docs.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/__init__.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/__main__.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/agent/__init__.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/agent/answer.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/agent/driver.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/auth.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/book.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/charts.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/client/__init__.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/client/executor.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/client/session.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/commands.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/complete.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/connectors/__init__.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/__init__.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/active.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/allocate.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/backtest.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/covariance.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/cross_section.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/factors.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/pairs.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/panel.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/performance.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/process.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/profile.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/risk.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/screen.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/series_shapes.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/signals.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/stress.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/technical.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/trace.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/validation.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/core/walkforward.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/demo.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/demo_book.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/demo_returns.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/demo_signal.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/demo_universe.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/events.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/loaders.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/model.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/py.typed +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/repl.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/study/__init__.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/study/report.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/study/schema.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/sweep/__init__.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/sweep/runner.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/src/alphaengine/tui/__init__.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_answer.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_book.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_cli.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_client.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_commands.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_events.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_executor_ops.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_goldens.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_loaders.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_math_audit.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_model.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_process.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_quant_maths.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_screen.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_signals.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_smoke.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_sweep.py +0 -0
- {alphaengine-0.9.2 → alphaengine-0.9.3}/tests/test_trace.py +0 -0
- {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.
|
|
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
|
-
|
|
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
|
-
|
|
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))} {
|
|
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"
|
|
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" {
|
|
1643
|
-
|
|
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"
|
|
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" {
|
|
3337
|
+
say(f" model {dim(label)}")
|
|
3287
3338
|
if thesis_name:
|
|
3288
|
-
say(dim(f" thesis
|
|
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" {
|
|
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 = [
|
|
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
|
-
|
|
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
|
-
|
|
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],
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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.
|
|
243
|
-
|
|
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:
|
|
70
|
-
min-width:
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|