diff --git a/backend/fluksio/alembic/versions/2e7ca7d10044_run_declared.py b/backend/fluksio/alembic/versions/2e7ca7d10044_run_declared.py new file mode 100644 index 0000000..e024fee --- /dev/null +++ b/backend/fluksio/alembic/versions/2e7ca7d10044_run_declared.py @@ -0,0 +1,30 @@ +"""run.declared + +The inputs the flow declared when a run was submitted, so its record reads +against that version of the flow and not against whatever the flow declares +now. + +Revision ID: 2e7ca7d10044 +Revises: 930ffa094aad +Create Date: 2026-10-15 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "2e7ca7d10044" +down_revision = "930ffa094aad" +branch_labels = None +depends_on = None + + +def upgrade(): + # Nullable: a run submitted before this kept no snapshot, and its readers + # fall back to the flow's current declarations. + op.add_column("run", sa.Column("declared", sa.JSON(), nullable=True)) + + +def downgrade(): + op.drop_column("run", "declared") diff --git a/backend/fluksio/api/routes/runs.py b/backend/fluksio/api/routes/runs.py index 56711e1..b7c52e0 100644 --- a/backend/fluksio/api/routes/runs.py +++ b/backend/fluksio/api/routes/runs.py @@ -210,6 +210,10 @@ class RunRow(BaseModel): class RunDetail(RunRow): result: dict[str, Any] = Field(default_factory=dict) flow_version: int = 1 + #: The inputs the flow declared when this was submitted, with their + #: declared values. Null for a run from before this was kept, which reads + #: against the flow's current declarations instead. + declared: dict[str, Any] | None = None nodes: list[RunNodeRow] = Field(default_factory=list) artifacts: list[ArtifactRow] = Field(default_factory=list) diff --git a/backend/fluksio/flow/runs.py b/backend/fluksio/flow/runs.py index 0ac7d13..9ac0ff2 100644 --- a/backend/fluksio/flow/runs.py +++ b/backend/fluksio/flow/runs.py @@ -454,6 +454,14 @@ def seed_values( return values +def declared_inputs(flow: FlowDef) -> dict[str, Any]: + """What a flow declares it can be given, by name, with the declared values. + + Kept on each run, since the flow can change after it. + """ + return {one.spec.name: one.initial for one in flow.inputs} + + def collect_result(flow: FlowDef, state: StateBackend) -> dict[str, Any]: """What the run produced, keyed by message name without the flow prefix.""" prefix = f"{flow.name}." @@ -925,6 +933,7 @@ class RunService: code_digest=run_digest(flow), params=params, passed=named if passed is None else passed, + declared=declared_inputs(flow), params_digest=digest_of(params, seed), seed=seed, group_id=group_id, @@ -1047,6 +1056,7 @@ class RunService: flow_version=flow.version, params=params, passed=passed, + declared=declared_inputs(flow), params_digest=digest_of(params, seed), seed=seed, group_id=entry["group_id"], diff --git a/backend/fluksio/models.py b/backend/fluksio/models.py index 16152e6..6665392 100644 --- a/backend/fluksio/models.py +++ b/backend/fluksio/models.py @@ -362,6 +362,9 @@ class Run(SQLModel, table=True): #: The names in `params` the caller chose; the rest are the flow's declared #: values. Null for a run recorded before this was kept. passed: list[str] | None = Field(sa_column=Column(JSON), default=None) + #: The inputs the flow declared when this was submitted, by name, with + #: their declared values. Null for a run recorded before this was kept. + declared: dict[str, Any] | None = Field(sa_column=Column(JSON), default=None) #: sha256 over params and seed. Two runs of the same thing share it, which #: is what makes "have I already run this?" a lookup. params_digest: str = Field(default="", index=True, max_length=64) diff --git a/backend/tests/api/routes/test_runs.py b/backend/tests/api/routes/test_runs.py index e34f331..c543591 100644 --- a/backend/tests/api/routes/test_runs.py +++ b/backend/tests/api/routes/test_runs.py @@ -456,6 +456,9 @@ def test_a_run_records_the_inputs_it_actually_starts_from(): assert spelled.params_digest == defaulted.params_digest # Only what the caller chose says which value was the flow's own. assert (defaulted.passed, spelled.passed) == (["epochs"], ["epochs", "lr"]) + # And what the flow declared then, so an input it adds later is not + # drawn on this run as a default it never received. + assert defaulted.declared == {"lr": 0.01, "epochs": None} finally: with Session(db_engine) as session: for run in session.exec(select(Run).where(col(Run.id).in_(made))).all(): diff --git a/docs/concepts/runs.md b/docs/concepts/runs.md index bb51221..7624362 100644 --- a/docs/concepts/runs.md +++ b/docs/concepts/runs.md @@ -183,7 +183,8 @@ cached stages. Cancel active runs before retrying them. The **Runs** screen filters by flow, status or sweep. Each run shows its parameters, per-node timings and logs, traceback, metrics, artifacts and -result. +result. Its inputs are listed as the flow declared them when the run was +submitted, and the ones it took from the flow are marked as defaults. Select two or more runs to compare curves and the parameters that differ. Plot against step, elapsed seconds or another metric from the same runs. diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index 5b91d4a..2851e61 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -3498,6 +3498,18 @@ export const RunDetailSchema = { title: 'Flow Version', default: 1 }, + declared: { + anyOf: [ + { + additionalProperties: true, + type: 'object' + }, + { + type: 'null' + } + ], + title: 'Declared' + }, nodes: { items: { '$ref': '#/components/schemas/RunNodeRow' diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index f69e78a..270b8d5 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -1189,6 +1189,9 @@ export type RunDetail = { [key: string]: unknown; }; flow_version?: number; + declared?: ({ + [key: string]: unknown; +} | null); nodes?: Array; artifacts?: Array; }; diff --git a/frontend/src/components/Runs/RunDetail.tsx b/frontend/src/components/Runs/RunDetail.tsx index a1a3d27..ac9f061 100644 --- a/frontend/src/components/Runs/RunDetail.tsx +++ b/frontend/src/components/Runs/RunDetail.tsx @@ -44,7 +44,7 @@ export function RunDetail({ id }: { id: string }) { const cancel = useCancelRun() const retry = useRetryRun() const names = useMetricNames(id) - const { declared, gone } = useFlowInputs(run?.flow) + const { declared: current, gone } = useFlowInputs(run?.flow) const [metric, setMetric] = useState("") if (isPending || !run) return @@ -58,6 +58,11 @@ export function RunDetail({ id }: { id: string }) { // Said once, where it applies. const cached = nodes.some((node) => node.status === "cached") + // What the flow declared when this run was submitted. A run from before + // that was kept reads against the flow's current declarations. + const declared = run.declared + ? new Map(Object.entries(run.declared)) + : current // Declared order first, so the panel reads the way the flow does; anything // the run carries that the flow no longer declares still shows, since it is // what the run was actually given. @@ -173,7 +178,7 @@ export function RunDetail({ id }: { id: string }) {

Inputs

{fed.length === 0 ? (

- {gone + {gone && !run.declared ? "The run carried no parameters of its own, and its flow is gone, so what it took from the flow cannot be read back." : "This flow declares no inputs, so there was nothing to choose."}

diff --git a/frontend/src/components/Runs/queries.ts b/frontend/src/components/Runs/queries.ts index ac68873..4592682 100644 --- a/frontend/src/components/Runs/queries.ts +++ b/frontend/src/components/Runs/queries.ts @@ -187,9 +187,9 @@ const isMissing = (error: unknown) => * ones it names and takes the flow's own for the rest — so this is what turns * `run.params` from "what was passed" into "what the run actually ran with". * - * The declarations are the flow's *current* ones, while a run carries the - * `flow_version` it was submitted against. An input added since is shown on an - * older run as a default it never actually received. And the flow may not be + * The declarations are the flow's *current* ones. A run keeps the ones it was + * submitted against in `run.declared`, and the run detail reads those, so this + * is the fallback for a run from before that was kept. And the flow may not be * there at all: deleting one leaves its runs standing, so `gone` is an ordinary * answer here rather than a fault, and the screen says so instead of drawing a * run that declares nothing.