feat: compose native research workflows in QuantFlow

This commit is contained in:
yuxuanhui
2026-09-08 22:42:29 +08:00
parent 7860434b04
commit a6e36e50ec
22 changed files with 2274 additions and 85 deletions
+214 -56
View File
@@ -6,21 +6,32 @@ Backtest source fields are provenance, never a grant to execute automatically.
import asyncio
import logging
import random
from collections import defaultdict
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from ..backtests.contracts import ControlInput, StartInput
from ..backtests.contracts import ControlInput, SimulationSettings, StartInput
from ..backtests.service import Backtests, uid
from ..models import Account, ResearchFlowRun, ResearchStepRun, now
from .assets import Assets
from .evaluations import Evaluations
from .experiments import Experiments
from .experiments import Experiments, scope_of
from .features import Features
from .model import request_model
from .workflows import validate_graph
from .workspace_contracts import AssetWrite, EvaluateInput, Expansion, Generation, TemplateSpec, WorkflowSpec
from .workspace_contracts import (
AssetWrite,
EvaluateInput,
Expansion,
FeatureSpec,
Generation,
SettingVariants,
TemplateSpec,
WorkflowSpec,
)
logger = logging.getLogger(__name__)
ACTIVE = ("queued", "running")
@@ -162,7 +173,8 @@ class ResearchRuntime:
model_work = await self.prepare(run_id)
if model_work:
step_id, context, revision = model_work
result, evidence = await request_model(self.ai, context, TemplateSpec, revision)
output_type = FeatureSpec if context.get("method") == "feature" else TemplateSpec
result, evidence = await request_model(self.ai, context, output_type, revision)
await self.finish_model(run_id, step_id, result, evidence)
except HTTPException as exc:
await self.fail(run_id, str(exc.detail))
@@ -271,7 +283,7 @@ class ResearchRuntime:
else None
)
step.output = {
**(previous.output if previous else {}),
**(previous.output if previous else {"template": run.authorization.get("template")}),
"type": "context",
"input_ids": run.authorization["input_ids"],
}
@@ -287,50 +299,45 @@ class ResearchRuntime:
}
else:
return await self.reserve_model(db, run, step, data, node)
elif node.type == "feature":
reference = run.authorization.get("node_assets", {}).get(key)
if step.output.get("feature"):
ref = step.output["feature"]
reference = await Assets(db).get(ref["id"], ref["version"], "feature")
if reference:
template = await Features(db).to_template(reference["id"], reference["version"])
step.output = {
**data,
**step.output,
"type": "context",
"feature": {"id": reference["id"], "version": reference["version"]},
"input_ids": reference["content"]["input_ids"],
"template": {"id": template["id"], "version": template["version"]},
}
else:
return await self.reserve_model(db, run, step, data, node)
elif node.type == "expand":
if not data.get("template"):
raise HTTPException(422, "展开节点没有固定模板版本")
body = Expansion(
asset_id=data["template"]["id"],
version=data["template"]["version"],
input_ids=run.authorization["input_ids"],
hypothesis=run.authorization["hypothesis"],
settings=run.authorization["settings"],
mode="random",
limit=run.authorization["batch_candidates"],
seed=run.authorization["seed"] + run.round,
parent_alpha_ids=run.authorization["parent_alpha_ids"],
parent_experiment_ids=data.get("parent_experiment_ids", []),
reference = run.authorization.get("node_assets", {}).get(key) or data.get("template")
await self.expand(db, run, step, {**data, "template": reference})
return
elif node.type == "variant":
if node.config.get("method", "structure") == "structure":
if step.output.get("template"):
await self.expand(db, run, step, step.output)
return
return await self.reserve_model(db, run, step, data, node)
experiment = await Experiments(db).setting_variants(
SettingVariants(
alpha_id=run.authorization["parent_alpha_ids"][0],
input_ids=run.authorization["input_ids"],
hypothesis=run.authorization["hypothesis"],
),
parent_snapshot=run.authorization["parents"][0],
extra_evidence={"flow_run_id": run.id, "node_id": key, "round": run.round},
kind=run.authorization["kind"],
)
frozen_parents = run.authorization["parents"] + await Experiments(db).parents(
[], body.parent_experiment_ids
)
experiment = await Experiments(db).create(
body,
"pipeline",
{
"flow_run_id": run.id,
"node_id": key,
"round": run.round,
"method": "pipeline",
"authorized_seed_snapshots": run.authorization["parents"],
},
parent_snapshots=frozen_parents,
)
step.output = {
"type": "candidates",
"experiment_id": experiment["id"],
"candidate_ids": [
c["client_item_id"]
for c in experiment["candidates"]
if c["validation"]["status"] == "valid"
],
"template": data["template"],
}
if not step.output["candidate_ids"]:
step.status, step.error = "blocked", "候选均未通过本地校验,请核实字段、算子和设置"
halt(run, "needs_review", step.error)
return
self.candidates(run, step, experiment, {})
return
elif node.type == "backtest":
await self.backtest(db, run, step, data)
return
@@ -349,6 +356,55 @@ class ResearchRuntime:
"experiment_id": data["experiment_id"],
"backtest_run_id": data["backtest_run_id"],
}
elif node.type == "condition":
step.output = {**data, "type": "evaluation"}
elif node.type == "filter":
report = (await Evaluations(db).get(data["evaluation_id"]))["report"]
ids = [
r["client_item_id"]
for r in report["records"]
if r["verdict"] in node.config.get("verdicts", ["pass"])
]
experiment = await Experiments(db).get(data["experiment_id"])
template = experiment["evidence"].get("template")
step.output = {
**data,
"type": "candidates",
"candidate_ids": ids,
"template": {"id": template["id"], "version": template["version"]}
if template
else None,
}
if not ids:
step.status, step.updated_at = "skipped", now()
return
elif node.type == "summarize":
# References keep joins bounded, without recursively copying the upstream graph.
step.output = {
"type": "summary",
"artifacts": [
{
"step_id": steps[e.source].id,
"node_id": e.source,
**{
k: steps[e.source].output[k]
for k in (
"type",
"template",
"feature",
"experiment_id",
"evaluation_id",
"backtest_run_id",
"verdict",
)
if k in steps[e.source].output
},
}
for e in upstream_edges
if steps[e.source].status != "skipped"
and (not e.branch or steps[e.source].output.get("verdict") == e.branch)
],
}
elif node.type == "iterate":
step.output = {
**data,
@@ -370,10 +426,16 @@ class ResearchRuntime:
generation = Generation(
name=run.name,
hypothesis=run.authorization["hypothesis"],
input_ids=run.authorization["input_ids"],
input_ids=data.get("input_ids", run.authorization["input_ids"]),
method="feature"
if node.type == "feature"
else "structure"
if node.type == "variant"
else "template",
parent_experiment_ids=[data["experiment_id"]] if data.get("experiment_id") else [],
)
context = await Experiments(db).generation_context(generation)
context["method"] = generation.method
context["parents"] = run.authorization["parents"] + context["parents"]
context["operators"] = run.authorization["operators_snapshot"]["content"]["items"][:100]
context["node_prompt"] = node.config.get("prompt", "")
@@ -414,9 +476,16 @@ class ResearchRuntime:
step = await db.get(ResearchStepRun, step_id)
if not step or step.status != "running":
return
if isinstance(result, FeatureSpec) and set(result.input_ids) != set(
[i["id"] for i in step.output["context"]["inputs"]]
):
raise HTTPException(422, "模型不能改变已固定的输入范围")
# A paused/stopped run may collect this already-issued model output, but cannot advance.
asset = await Assets(db).save(
AssetWrite(kind="template", content=result.model_dump(mode="json")),
AssetWrite(
kind="feature" if isinstance(result, FeatureSpec) else "template",
content=result.model_dump(mode="json"),
),
provenance={
"flow_run_id": run_id,
"step_id": step_id,
@@ -424,18 +493,92 @@ class ResearchRuntime:
"context": step.output["context"],
},
)
feature = (
{"id": asset["id"], "version": asset["version"]} if isinstance(result, FeatureSpec) else None
)
mark_model_attempt(step, "completed")
step.output = {
"model_attempts": step.output.get("model_attempts", []),
"type": "template",
"template": {"id": asset["id"], "version": asset["version"]},
"type": "context" if feature else "template",
"feature": feature,
"input_ids": result.input_ids
if feature
else [i["id"] for i in step.output["context"]["inputs"]],
"template": None if feature else {"id": asset["id"], "version": asset["version"]},
"parent_experiment_ids": step.output.get("parent_experiment_ids", []),
"generation": evidence,
"reserved_call": step.output["reserved_call"],
}
step.status, step.updated_at = "completed", now()
node = next(n for n in run.definition["nodes"] if n["id"] == step.node_id)
# Keep post-processing durable and separate: pause/account changes are checked again
# before converting features or expanding variants on the next active tick.
step.status = "generated" if node["type"] in ("variant", "feature") else "completed"
step.updated_at = now()
changed(run)
def candidates(self, run, step, experiment, data):
step.output = {
**data,
"type": "candidates",
"experiment_id": experiment["id"],
"candidate_ids": [
c["client_item_id"] for c in experiment["candidates"] if c["validation"]["status"] == "valid"
],
}
ids = step.output["candidate_ids"]
maximum = run.authorization["batch_candidates"]
if len(ids) > maximum:
step.output = {
**step.output,
"candidate_ids": random.Random(run.authorization["seed"] + run.round).sample(ids, maximum),
}
step.status, step.updated_at = "completed", now()
if not step.output["candidate_ids"]:
step.status, step.error = "blocked", "候选均未通过本地校验,请核实字段、算子和设置"
if run.status in ACTIVE:
halt(run, "needs_review", step.error)
async def expand(self, db, run, step, data):
reference = data.get("template")
if not reference:
raise HTTPException(422, "展开节点没有固定模板版本")
scope = scope_of(SimulationSettings.model_validate(run.authorization["settings"]))
ids = [
i["id"]
for i in run.authorization["inputs"]
if i["scope"] == scope and i["id"] in data.get("input_ids", run.authorization["input_ids"])
]
if not ids:
raise HTTPException(422, "模板展开需要与基础设置匹配的固定输入")
body = Expansion(
asset_id=reference["id"],
version=reference["version"],
input_ids=ids,
hypothesis=run.authorization["hypothesis"],
settings=run.authorization["settings"],
mode="random",
limit=run.authorization["batch_candidates"],
seed=run.authorization["seed"] + run.round,
parent_alpha_ids=run.authorization["parent_alpha_ids"],
parent_experiment_ids=data.get("parent_experiment_ids", []),
)
frozen_parents = run.authorization["parents"] + await Experiments(db).parents(
[], body.parent_experiment_ids
)
experiment = await Experiments(db).create(
body,
run.authorization["kind"],
{
"flow_run_id": run.id,
"node_id": step.node_id,
"round": run.round,
"method": run.authorization["kind"],
"authorized_seed_snapshots": run.authorization["parents"],
},
parent_snapshots=frozen_parents,
)
self.candidates(run, step, experiment, data)
async def backtest(self, db, run, step, data):
service = Backtests(db)
if step.backtest_run_id:
@@ -470,12 +613,27 @@ class ResearchRuntime:
):
raise HTTPException(409, "研究候选预览不再匹配保存的授权步骤")
experiment = await Experiments(db).get(step.output["experiment_id"])
if experiment["evidence"].get("flow_run_id") != run.id or {
s["id"] for s in experiment["inputs"]
} != set(run.authorization["input_ids"]):
if (
experiment["evidence"].get("flow_run_id") != run.id
or {s["id"] for s in experiment["inputs"]}.issubset(set(run.authorization["input_ids"])) is False
):
raise HTTPException(403, "候选不属于此研究运行的固定输入范围")
if "backtest" not in run.authorization["methods"] or any(
c["settings"] != run.authorization["settings"]
c["settings"]
not in (
run.authorization.get("allowed_settings", [run.authorization["settings"]])
if experiment["evidence"].get("method") == "settings"
else [run.authorization["settings"]]
)
or not c.get("input_ids")
or any(
not any(
i["id"] == input_id
and i["scope"] == scope_of(SimulationSettings.model_validate(c["settings"]))
for i in run.authorization["inputs"]
)
for input_id in c["input_ids"]
)
for c in experiment["candidates"]
if c["client_item_id"] in step.output["candidate_ids"]
):