diff --git a/.changeset/create-experiment-resolves-record.md b/.changeset/create-experiment-resolves-record.md new file mode 100644 index 00000000000..a9e1b687a15 --- /dev/null +++ b/.changeset/create-experiment-resolves-record.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +`createExperiment` resolves with the experiment record, which now carries the scenario it compiled (`ExperimentRecord.scenario`), and no longer selects it. diff --git a/libs/@hashintel/petrinaut-core/src/ai.ts b/libs/@hashintel/petrinaut-core/src/ai.ts index d03bd2f7b20..5d2c9afdac5 100644 --- a/libs/@hashintel/petrinaut-core/src/ai.ts +++ b/libs/@hashintel/petrinaut-core/src/ai.ts @@ -115,9 +115,9 @@ export const petrinautDocSummaries: Record = { scenarios: "Named simulation configurations authored through the scenario form: Variables exposed as scenario parameters, parameter overrides, per-place initial state blocks, running and switching scenarios, the expression language, scenarios stored per place or as code by files, the AI or earlier versions.", "ad-hoc-scenarios": - "Inline initial state + parameters without saving a scenario: the shared form (scenario. variables, fixed/dynamic/swept-count rows chosen from the row gutter's menu, shared columns, phantom row, place totals, live type checking), its three surfaces (quick simulation, experiments, scenario creation and editing with Scenario Parameter toggles), Sweep selections with generated adhoc_* parameter names, saved scenarios shown in run mode.", + "Inline initial state + parameters without saving a scenario: the shared form (scenario. variables, fixed/dynamic/swept-count rows chosen from the row gutter's menu, shared columns, phantom row, place totals, live type checking), its three surfaces (quick simulation, experiments, scenario creation and editing with Scenario Parameter toggles), interval selections — Sweep or Optimize by setting — with generated adhoc_* parameter names, saved scenarios shown in run mode.", experiments: - "Monte Carlo batches: configuration (runs, seed, dt, max time, scenario), parameter sweeps, constraints (parameter and state, pass threshold), optimizing a sweep from its Parameters card (in-browser optimizer, steps, Stop), lifecycle/statuses, cancel/remove, header columns (Steps, Steps clear), metric charts, the Constraints and Sensitivity analysis cards, the steps table, Objective by step, compute backend, active-experiments popover.", + "Monte Carlo batches: configuration (runs, seed, dt, max time, scenario), parameter sweeps, constraints (parameter and state, pass threshold), Optimize toggles and an Objective section (metric, direction, steps) at creation, the drawer opening already optimizing, Stop on the Parameters card, one study per experiment, lifecycle/statuses, cancel/remove, header columns (Steps, Steps clear), metric charts, the Constraints and Sensitivity analysis cards, the steps table, Objective by step, compute backend, active-experiments popover.", "actual-mode": "Actual mode: host-provided live execution view, Brunch stream URL route, read-only extension-free net, current limits.", preview: diff --git a/libs/@hashintel/petrinaut/README.md b/libs/@hashintel/petrinaut/README.md index 91dfdf3e59e..94c24dbb761 100644 --- a/libs/@hashintel/petrinaut/README.md +++ b/libs/@hashintel/petrinaut/README.md @@ -84,8 +84,9 @@ yarn workspace @hashintel/petrinaut dev ``` The **Simulate / SimulateView / Run Supply Chain optimization (synthetic -optimizer)** story creates a parameter sweep and drives it with an internal -fake optimizer, so it does not require the Python service or Docker. +optimizer)** story creates a parameter sweep with its study, as the Create +Experiment drawer's Optimize does, and drives it with an internal fake +optimizer, so it does not require the Python service or Docker. ## Host-owned interactive AI tools diff --git a/libs/@hashintel/petrinaut/docs/ad-hoc-scenarios.md b/libs/@hashintel/petrinaut/docs/ad-hoc-scenarios.md index 4d808a93811..6b0367e2b4e 100644 --- a/libs/@hashintel/petrinaut/docs/ad-hoc-scenarios.md +++ b/libs/@hashintel/petrinaut/docs/ad-hoc-scenarios.md @@ -9,7 +9,7 @@ Use an ad-hoc scenario for one-off runs and quick exploration. When you want to The same form appears in three places: 1. **Quick simulation** -- in the [Simulation Settings](simulation.md#simulation-settings) tab, with "No scenario" selected, the panel's two columns are the form's own tables: **Variables** above **Parameters** on the left, **Initial state** -- token counts and values -- on the right, no separate dialog. A quiet **Clear** button next to the Initial state title resets your entries. The next simulation run uses what you defined. Any [compile error](#errors) appears in the settings panel's error banner. -2. **Experiments** -- in the [create-experiment drawer](experiments.md#creating-an-experiment), choosing "No scenario" shows the form inside the Scenario section. The experiment's runs start from the state you defined, and the experiments table shows "Ad-hoc scenario" in its Scenario column. With [Parameter sweeps](experiments.md#parameter-sweeps) enabled, every numeric value carries a **Sweep** toggle (see below). +2. **Experiments** -- in the [create-experiment drawer](experiments.md#creating-an-experiment), choosing "No scenario" shows the form inside the Scenario section. The experiment's runs start from the state you defined, and the experiments table shows "Ad-hoc scenario" in its Scenario column. With [Parameter sweeps](experiments.md#parameter-sweeps) enabled, every numeric value carries an interval toggle (see below). 3. **Scenario creation** -- [creating or editing a scenario](scenarios.md#creating-a-scenario) uses the same form with a **Scenario Parameter** toggle on each top-level Variable; see [Saving a scenario from the form](#saving-a-scenario-from-the-form). ## The form @@ -22,7 +22,7 @@ The form has up to three sections. Variables come first -- parameter overrides m In the experiment drawer each section collapses: click the chevron in its header, or focus the header and press Left to collapse and Right to expand. Place headers inside Initial state collapse the same way everywhere, and a collapsed place shows a one-line summary of its rows and token total. In the quick-simulation embedding, places start collapsed. -Every value in the form is an expression. A first click selects a value; a second click, a double-click, or Enter opens the editor in place: a code input with completion and type checking at exactly the cell's position, the value's path (for example `Space › item 0 › x`) above it, and -- in the experiment drawer with sweeps enabled -- the Sweep control below it. Expressions may use your Variables (`scenario.`), net parameters (`parameters.`), and arithmetic -- the same [expression language](scenarios.md#expression-language) scenarios use. Press Enter, Escape, or click elsewhere to close the editor. Escape closes only the innermost thing that is open -- a completion list, a bound edit, the editor itself -- and never the drawer or dialog around the form; close those from their own buttons. Closing tidies a valid expression's formatting (spacing, redundant parentheses) without changing its meaning. A value may also be left **empty**: an empty cell reads as its type's neutral value -- 0 for numbers, `false` for booleans, `""` for text, the nil UUID -- shown grayed in the cell, and it is never an error. An empty dynamic-row count means 1 token; an empty place count means 0. +Every value in the form is an expression. A first click selects a value; a second click, a double-click, or Enter opens the editor in place: a code input with completion and type checking at exactly the cell's position, the value's path (for example `Space › item 0 › x`) above it, and -- in the experiment drawer with sweeps enabled -- the interval toggle below it. Expressions may use your Variables (`scenario.`), net parameters (`parameters.`), and arithmetic -- the same [expression language](scenarios.md#expression-language) scenarios use. Press Enter, Escape, or click elsewhere to close the editor. Escape closes only the innermost thing that is open -- a completion list, a bound edit, the editor itself -- and never the drawer or dialog around the form; close those from their own buttons. Closing tidies a valid expression's formatting (spacing, redundant parentheses) without changing its meaning. A value may also be left **empty**: an empty cell reads as its type's neutral value -- 0 for numbers, `false` for booleans, `""` for text, the nil UUID -- shown grayed in the cell, and it is never an error. An empty dynamic-row count means 1 token; an empty place count means 0. Opening a value with Enter or a second click selects its whole content, so typing replaces it. Opening by typing keeps the caret right after what you typed. @@ -32,7 +32,7 @@ Every table in the form is a keyboard grid: arrow keys move between cells, phant The walk does not stop at a table's edge: moving down from a table's last row continues to the next part of the form -- a section header, a place header, the next table -- and moving up continues backwards the same way. Collapsed sections are skipped. -The whole form has one undo history: Cmd/Ctrl+Z undoes and Shift+Cmd/Ctrl+Z (or Ctrl+Y) redoes any edit -- a changed value, an added or deleted row, a shared column, a Sweep toggle. Typing in one value counts as a single step, however long the pause; editing another value starts the next step. Redo restores exactly the state you undid from. An open text editor keeps its own text-level undo until you close it. +The whole form has one undo history: Cmd/Ctrl+Z undoes and Shift+Cmd/Ctrl+Z (or Ctrl+Y) redoes any edit -- a changed value, an added or deleted row, a shared column, an interval toggle. Typing in one value counts as a single step, however long the pause; editing another value starts the next step. Redo restores exactly the state you undid from. An open text editor keeps its own text-level undo until you close it. ### Connections around the focused value @@ -48,7 +48,7 @@ A place with a [token type](petri-net-extensions.md#typed-vs-untyped-places) is - **Fixed** (`#1`, `#2`, ...) -- the row emits exactly one token. - **Dynamic** (`i`, blue) -- the row emits many tokens: a quiet strip above the cells shows `×` and the row's **count expression**, and each cell is evaluated once per token with `i` running from `0` to `count - 1` (`count` is also available). The gutter's tooltip shows the row number. -- **Swept count** (`i`, purple; experiments with sweeps only) -- a dynamic row whose count is a swept parameter: the strip shows the count's bounds, `× 0 … 12`. +- **Swept count** or **Optimized count** (`i`, purple; experiments with sweeps only, the word following the interval toggle's) -- a dynamic row whose count is a swept parameter: the strip shows the count's bounds, `× 0 … 12`. Changing a row's kind never loses anything: its count (bounds included) is restored when you change back. The dimmed trailing row is a **phantom row**, and its cells follow the same selection model as every other cell: a first click selects one, and a second click (or Enter, or the row's `+` gutter) materializes a new fixed row. Remove a row from its gutter: the menu offers **Delete row**, and the Delete key removes it directly. In fixed rows, `i` is the row's position in the list and `count` is `1`. @@ -68,9 +68,9 @@ A dynamic row's **count** may read the place's variables too, as long as their v Every expression is type-checked as you work. The open editor marks problems inline; a closed value with a problem underlines in red and shows the message when you hover it. Cells inherit their type from the token type's field; declared types exist on Variables and counts only. Structural rules (duplicate names, bounds that do not resolve, sweeping a text field) surface the same way, on the value they belong to. -## Sweep selections (experiments only) +## Interval selections (experiments) -In the create-experiment drawer, with [Parameter sweeps](experiments.md#parameter-sweeps) enabled, every numeric value slot -- cells, counts, variables, shared columns, and net parameters -- carries a labeled **Sweep** toggle, purple while on: under the open cell editor, and on the row for Variables and Parameters. Turning it on replaces the expression with **Min** and **Max** cells; a sweep declares an interval and nothing else, so there is no Scale or Step. Each bound is an expression cell with the same selection model as the rest of the form -- select it, press Enter (or click again) to edit, Enter or Escape to leave; Escape from a selected cell closes the editor. Turning Sweep off restores the expression you had, and the bounds are remembered too. A swept value shows its bounds (`0 … 12`) on a purple slot. Boolean and text values offer no toggle, and changing a swept Variable to boolean turns its Sweep off; a cell muted by a shared column does not count. A row's gutter menu offers **Swept count** for a dynamic row's count. +In the create-experiment drawer, with [Parameter sweeps](experiments.md#parameter-sweeps) enabled, every numeric value slot -- cells, counts, variables, shared columns, and net parameters -- carries a labeled interval toggle, purple while on: under the open cell editor, and on the row for Variables and Parameters. It reads **Sweep**, or **Optimize** when the [in-browser optimizer](experiments.md#optimizing-a-sweep) is on; the word is the same on every toggle of the form, and both mean the same thing. Turning it on replaces the expression with **Min** and **Max** cells; an interval declares nothing else, so there is no Scale or Step. Each bound is an expression cell with the same selection model as the rest of the form -- select it, press Enter (or click again) to edit, Enter or Escape to leave; Escape from a selected cell closes the editor. Turning the toggle off restores the expression you had, and the bounds are remembered too. A selected value shows its bounds (`0 … 12`) on a purple slot. Boolean and text values offer no toggle, and changing a selected Variable to boolean turns its toggle off; a cell muted by a shared column does not count. A row's gutter menu offers **Swept count** or **Optimized count** for a dynamic row's count, to match. Each selection becomes a swept parameter of the experiment with a deterministic name, shown in the sweep navigator under the value's path (`Space › item 0 › x`): @@ -80,7 +80,7 @@ Each selection becomes a swept parameter of the experiment with a deterministic - `adhoc_var_net_` -- a top-level Variable; place-scoped variables use the place's name as the scope. - `adhoc_param_` -- a net parameter override. -Bounds must resolve to constants, integer values need integer bounds, and the maximum must exceed the minimum; a value that does not run shows its problem on the bound, and the drawer's footer names it. The experiment then behaves like any [parameter sweep](experiments.md#parameter-sweeps): the initial state compiles at the navigator's selection, parameter overrides follow each run's draw. +Bounds must resolve to constants, integer values need integer bounds, and the maximum must exceed the minimum; a value that does not run shows its problem on the bound, and the drawer's footer names it. The experiment then behaves like any [parameter sweep](experiments.md#parameter-sweeps): the initial state compiles at the navigator's selection, parameter overrides follow each run's draw. Under **Optimize**, the study searches the generated parameters like any others; only [Constraints](experiments.md#constraints) need a saved scenario. A saved scenario shown through the form in the experiment drawer offers the same toggle on each numeric scenario parameter row. diff --git a/libs/@hashintel/petrinaut/docs/examples.md b/libs/@hashintel/petrinaut/docs/examples.md index 2853ba57aa3..6237c5cacfd 100644 --- a/libs/@hashintel/petrinaut/docs/examples.md +++ b/libs/@hashintel/petrinaut/docs/examples.md @@ -34,7 +34,7 @@ The SIR model with two policy levers and a cost account, built as the model to o - **GPU-ready modelling** -- untyped places and rates that read only parameters, so an experiment measuring the **Infected** place's token count (**Built-in › Place tokens**) runs on the GPU backend as shipped. The model's expression metrics — **Total cost**, **Infected**, **Attack rate** — compile to the GPU too. - Two further metrics -- **Infected** (the wave's curve, dying out or growing) and **Attack rate** (share of the population infected so far). -**Suggested initial state:** pick **Winter wave** and create an experiment over it with a max time of 60, **Sweep** on `vaccination_coverage` (0 to 0.9) and `contact_reduction` (0 to 0.8) and a **Total cost** metric; then press **Optimize** on the sweep's Parameters card and minimize **Total cost**: the Surface shows a valley along the epidemic threshold and the steps settle around a coverage of 0.45 and a contact reduction of 0.4 (see [Optimizing a sweep](experiments.md#optimizing-a-sweep)). To watch a single run instead, press Play and select the **Infected** metric in the timeline. +**Suggested initial state:** create an experiment on **Winter wave** with a max time of 60 and **Total cost** as a metric, flip **Optimize** on `vaccination_coverage` (0 to 0.9) and `contact_reduction` (0 to 0.8), choose **Minimize**, and the experiment opens searching: the Surface shows a valley along the epidemic threshold and the steps settle around a coverage of 0.45 and a contact reduction of 0.4 (see [Optimizing a sweep](experiments.md#optimizing-a-sweep)). To watch a single run instead, press Play and select the **Infected** metric in the timeline. **Key concepts:** [stochastic firing](petri-net-extensions.md#stochastic-rate), [parameters](petri-net-extensions.md#global-parameters), [scenarios](scenarios.md), [optimization objectives](useful-patterns.md#optimization-objectives-metrics-that-read-parameters), [parameter sweeps](experiments.md#parameter-sweeps). @@ -48,7 +48,7 @@ A small service system: customers arrive, wait, are served by a limited staff po - Rate parameters (`arrival_rate`, `begin_rate`, `service_rate`) a sweep can range over: the **Morning Rush** scenario exposes `arrival_rate` and `service_rate` as scenario parameters wired straight to the net's rates, so a two-parameter sweep explores under- and over-staffed regimes. - A conserved staff pool (**FreeStaff** + **Serving** always totals the staff count). -**Suggested initial state:** pick **Morning Rush** and create an experiment measuring **Waiting** — then sweep `arrival_rate` against `service_rate` and watch the queue-explosion boundary appear on the surface plot. +**Suggested initial state:** pick **Morning Rush** and create an experiment measuring **Waiting** — flip the interval toggle on `arrival_rate` and `service_rate` (**Sweep** with the optimizer off, so the surface is yours to explore) and watch the queue-explosion boundary appear on the surface plot. ## Drone Patrol @@ -62,7 +62,7 @@ A typed fleet of drones cycling between the hangar and the air: launch, drain ba - **Token-reading rates**: launch tempo scales with the candidate drone's battery, and returns become more likely as the battery falls. - The **Standard Patrol** scenario exposes `launch_rate` and `drain_rate` for sweeping. -**Suggested initial state:** pick **Standard Patrol** and create an experiment measuring **Airborne**; on the GPU, sweeping `launch_rate` against `drain_rate` streams the fleet's equilibrium as a distribution. +**Suggested initial state:** pick **Standard Patrol** and create an experiment measuring **Airborne**; on the GPU, with the interval toggle on `launch_rate` and `drain_rate` (**Sweep** with the optimizer off), the fleet's equilibrium streams as a distribution. ## Supply Chain with Disruption diff --git a/libs/@hashintel/petrinaut/docs/experiments.md b/libs/@hashintel/petrinaut/docs/experiments.md index 4624cdc0b65..b474d834695 100644 --- a/libs/@hashintel/petrinaut/docs/experiments.md +++ b/libs/@hashintel/petrinaut/docs/experiments.md @@ -9,19 +9,19 @@ Experiments live under the **Simulate** [global mode](drawing-a-net.md#global-mo 1. Switch to **Simulate** mode and open the **Experiments** tab. 2. Click **Create**. The Create Experiment drawer opens. 3. Fill in the configuration (see below). -4. Click **Run** -- **Create sweep** when a parameter is swept. The button reads **Starting** (or **Creating**) while the experiment starts. +4. Click **Run** -- **Create sweep** when a value is swept, **Optimize** when the in-browser optimizer will search it. The button reads **Starting** (or **Creating**) while the experiment starts. ### Configuration -| Setting | Default | Notes | -| ----------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **Name** | `Experiment` | Free text. | -| **Scenario** | the first saved scenario, or **No scenario** when none is saved | **No scenario** -- the [scenario form](ad-hoc-scenarios.md) below the picker, or one of your saved [scenarios](scenarios.md). An experiment runs against exactly one scenario. | -| **Scenario parameters** | each scenario parameter's default | When a scenario is selected, you can override its scenario parameters per experiment. Expressions are evaluated once at start. Each numeric parameter also has a **Sweep** toggle — see [Parameter sweeps](#parameter-sweeps). | +| Setting | Default | Notes | +| ----------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Name** | `Experiment` | Free text. | +| **Scenario** | the first saved scenario, or **No scenario** when none is saved | **No scenario** -- the [scenario form](ad-hoc-scenarios.md) below the picker, or one of your saved [scenarios](scenarios.md). An experiment runs against exactly one scenario. | +| **Scenario parameters** | each scenario parameter's default | When a scenario is selected, you can override its scenario parameters per experiment. Expressions are evaluated once at start. Each numeric parameter also has an interval toggle — **Sweep**, or **Optimize** with In-browser optimization on — see [Parameter sweeps](#parameter-sweeps). | -With "No scenario" selected, the Scenario section shows the [ad-hoc scenario form](ad-hoc-scenarios.md): define the initial state and parameter values inline for this experiment, without saving a scenario. Left untouched, the experiment runs from the manually-set markings and defaults. The experiments table shows "Ad-hoc scenario" in its Scenario column for such runs. With [Parameter sweeps](#parameter-sweeps) enabled, every numeric value of the form carries a **Sweep** toggle -- see [Sweep selections](ad-hoc-scenarios.md#sweep-selections-experiments-only). +With "No scenario" selected, the Scenario section shows the [ad-hoc scenario form](ad-hoc-scenarios.md): define the initial state and parameter values inline for this experiment, without saving a scenario. Left untouched, the experiment runs from the manually-set markings and defaults. The experiments table shows "Ad-hoc scenario" in its Scenario column for such runs. With [Parameter sweeps](#parameter-sweeps) enabled, every numeric value of the form carries the same interval toggle -- see [Interval selections](ad-hoc-scenarios.md#interval-selections-experiments). -With a scenario selected, the Scenario section shows it through the same form: the scenario parameters take value edits in worksheet style -- a ratio parameter's edit applies only between 0 and 1; outside, the form marks it and the run keeps the previous value -- each numeric one with a **Sweep** toggle when Parameter sweeps is on, and a collapsed **Computed state** sub-section underneath previews the exact parameter values and initial tokens each run will start with -- computed only when you open it, and recomputed as you change the values above. A swept parameter previews at the start of its range, the first combination the sweep runs, and the preview says so. The preview sits in its own tinted panel and scrolls as one, so a net with many places leaves the rest of the drawer in reach. +With a scenario selected, the Scenario section shows it through the same form: the scenario parameters take value edits in worksheet style -- a ratio parameter's edit applies only between 0 and 1; outside, the form marks it and the run keeps the previous value -- each numeric one with the interval toggle when Parameter sweeps is on, and a collapsed **Computed state** sub-section underneath previews the exact parameter values and initial tokens each run will start with -- computed only when you open it, and recomputed as you change the values above. A swept parameter previews at the start of its range, the first combination the sweep runs, and the preview says so. The preview sits in its own tinted panel and scrolls as one, so a net with many places leaves the rest of the drawer in reach. | **Runs** | `1000` | Positive integer; how many independent simulations to run. For a sweep the field reads **Max runs per selection**: each selection refines progressively (8, 25, 100, … 1000, 5000, …) up to this ceiling, so large budgets — 100,000 on the GPU — sharpen the distribution the longer you stay. | | **Time step (dt)** | `0.1` | Same meaning as in single-run simulations (see [Simulation](simulation.md#time-step-dt)). | | **Max time (seconds)** | `180` | Each run advances until simulation time reaches this value, then completes. | @@ -33,28 +33,28 @@ The model used is a snapshot of the current net at the time you press **Run**. E ### Constraints -With [Parameter sweeps](#parameter-sweeps) and [In-browser optimization](visual-settings.md#in-browser-optimization-experimental) both on, flipping the first **Sweep** toggle on a saved scenario's parameter adds a **Constraints** section to the drawer, between Scenario and Metrics. Its rows record boolean conditions the optimizer must respect when it [drives the sweep](#optimizing-a-sweep). The sweep itself ignores them: no run is excluded from the charts and the objective is never changed by them. Two kinds, added from the **Parameter constraint** and **State constraint** buttons under the list and mixed in one list, each row marked with a **Parameters** or **State** chip: +With [Parameter sweeps](#parameter-sweeps) and [In-browser optimization](visual-settings.md#in-browser-optimization-experimental) both on, flipping the first **Optimize** toggle on a saved scenario's parameter adds a **Constraints** section to the drawer, between [Objective](#optimizing-a-sweep) and Metrics. Its rows record boolean conditions the optimizer must respect when it [drives the sweep](#optimizing-a-sweep). The sweep itself ignores them: no run is excluded from the charts and the objective is never changed by them. An experiment created with **No scenario** has no Constraints section: the names of its generated parameters are not yours to write. Two kinds, added from the **Parameter constraint** and **State constraint** buttons under the list and mixed in one list, each row marked with a **Parameters** or **State** chip: - **Parameter constraints** -- one-line expressions over the sweep's parameters (`scenario.*` for scenario parameters, `parameters.*` for net parameters) that must produce a boolean, for example `scenario.min_load < scenario.max_load`. Before a step runs, the optimizer checks them at the step's values, snapped to the sweep's grid. A step whose values break one is **infeasible**: it costs one step and no simulation, the sliders do not move to it, it is reported as pruned with the constraint named, and its row is greyed in the steps table. - **State constraints** -- small code bodies that read the simulation `state` exactly like a metric and `return` a boolean, for example `return state.places.Queue.count <= 10;`. Every run of a step reports whether the condition held on every sampled frame: a run **passed** when it did and **failed** otherwise, and a run that errors reports neither, so its step's fraction is over the runs that reported. A state constraint runs beside the sweep's metrics on every batch, from the sweep's creation on, and it runs on the CPU: the WebGPU switch greys out while a state row is drafted, and a sweep with state constraints computes on the CPU whether or not a study drives it. A step's verdict comes from its runs. The **Pass threshold**, one setting for the whole sweep shown in the section's header once a State row exists, is the share of a step's runs that must pass (95 percent by default, an alpha of 0.05). A step is **clear** when every state constraint held on at least that share of its runs and **limited** when one fell short. Every rate in the results is printed as its raw fraction beside the percentage, `52 / 60 · 87%`, so the run count behind a percentage is always in view. -Each row checks as you type: type errors, unknown names and a result that is not a boolean are underlined, and the message reads in the line under the row. Typing `scenario.`, `parameters.` or `state.places.` offers completions, and hovering a name shows its type. **Create sweep** stays disabled, with the first failing row named in the footer, until every row compiles; the rows are compiled once more when you press it. Empty rows are ignored, and removing a row is its trash button. Changing the scenario clears the rows. The constraints are recorded with the experiment: once created, the sweep's **Parameters** card lists them behind **Show N constraints** in its footer, one line per constraint with its kind, its label (**Parameter constraint 1**, **State constraint 1**, in the order you added them) and its code, and the pass threshold under them when a state constraint exists. +Each row checks as you type: type errors, unknown names and a result that is not a boolean are underlined, and the message reads in the line under the row. Typing `scenario.`, `parameters.` or `state.places.` offers completions, and hovering a name shows its type. **Optimize** stays disabled, with the first failing row named in the footer, until every row compiles; the rows are compiled once more when you press it. Empty rows are ignored, and removing a row is its trash button. Changing the scenario clears the rows. The constraints are recorded with the experiment: once created, the sweep's **Parameters** card lists them behind **Show N constraints** in its footer, one line per constraint with its kind, its label (**Parameter constraint 1**, **State constraint 1**, in the order you added them) and its code, and the pass threshold under them when a state constraint exists. ## Lifecycle and statuses Experiments progress through these status labels: -| Status | Meaning | -| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Initializing** | The experiment has been created and its workers are starting up. | -| **Running** | Runs are in progress. | -| **Idle** | A sweep computing nothing: fresh, or its selected region fully sampled. Moving a parameter control resumes running. Grey in the list. | -| **Optimizing** | A sweep whose sliders a study drives (see [Optimizing a sweep](#optimizing-a-sweep)). The drawer's header reads it; the list keeps the sweep's own status, Running or Idle. | -| **Complete** | All runs finished without error. | -| **Error** | The experiment failed to start or hit an unrecoverable error. The drawer shows the error message. For a sweep the error belongs to the selection that failed: move a control and the next selection computes normally. | -| **Cancelled** | You clicked **Cancel**, or the experiment was cancelled. | +| Status | Meaning | +| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Initializing** | The experiment has been created and its workers are starting up. | +| **Running** | Runs are in progress. | +| **Idle** | A sweep computing nothing: fresh, or its selected region fully sampled. Moving a parameter control resumes running. Grey in the list. | +| **Optimizing** | A sweep whose sliders a study drives (see [Optimizing a sweep](#optimizing-a-sweep)). An experiment created with **Optimize** opens in this state. The drawer's header reads it; the list keeps the sweep's own status, Running or Idle. | +| **Complete** | All runs finished without error. | +| **Error** | The experiment failed to start or hit an unrecoverable error. The drawer shows the error message. For a sweep the error belongs to the selection that failed: move a control and the next selection computes normally. | +| **Cancelled** | You clicked **Cancel**, or the experiment was cancelled. | Experiments run in background Web Workers, so simulation playback and editor interactions stay responsive. Multiple experiments can run concurrently. @@ -71,11 +71,11 @@ Two consequences worth knowing: ### Parameter sweeps -Parameter sweeps are experimental and off by default. Turn on **Parameter sweeps** under Simulation in the [settings dialog](visual-settings.md#parameter-sweeps-experimental) to get the Sweep toggle. +Parameter sweeps are experimental and off by default. Turn on **Parameter sweeps** under Simulation in the [settings dialog](visual-settings.md#parameter-sweeps-experimental) to get the interval toggle on every numeric value of the experiment form: it reads **Sweep** on its own, and **Optimize** once [In-browser optimization](visual-settings.md#in-browser-optimization-experimental) is on with an optimizer that runs in your browser -- a host whose optimizer runs elsewhere keeps **Sweep**, since a sweep can only be optimized in the browser. Either word means the same thing on the value: an interval instead of one number. -Flip **Sweep** on any numeric scenario parameter to explore an interval of values instead of one. Set the minimum and the maximum — that is all a sweep declares. Petrinaut quantizes the interval finely (about fifty steps; integer parameters step by whole numbers) so a selection has a stable identity and revisiting one restores its results. With **No scenario** selected, the same toggle sits on every numeric value of the [form](ad-hoc-scenarios.md) -- a token count, a cell, a variable, a parameter override -- and each selection sweeps as a generated parameter named after the value, shown in the navigator under the value's path. +Flip the toggle on any numeric value to explore an interval of values instead of one. Set the minimum and the maximum — that is all a sweep declares. Petrinaut quantizes the interval finely (about fifty steps; integer parameters step by whole numbers) so a selection has a stable identity and revisiting one restores its results. With **No scenario** selected, the same toggle sits on every numeric value of the [form](ad-hoc-scenarios.md) -- a token count, a cell, a variable, a parameter override -- and each selection sweeps as a generated parameter named after the value, shown in the navigator under the value's path. -A sweep computes **what you have selected**, and nothing until you select: a fresh sweep sits idle with every slider spanning its whole interval, its charts empty, and the line under the sliders says what to do -- collapse a control to a point or click the surface to compute a point, widen a range to sample across it -- until you move a control, click the surface, or hand the controls to the optimizer. The results drawer grows a **Parameters** card across the top of its body, with one slider per swept parameter and the swept count under its title. Each slider selects a range on its interval, and starts spanning the whole of it: +A sweep computes **what you have selected**, and nothing until something selects. A sweep created with **Create sweep** sits idle with every slider spanning its whole interval, its charts empty, and the line under the sliders says what to do -- collapse a control to a point or click the surface to compute a point, widen a range to sample across it -- until you move a control or click the surface. A sweep created with **Optimize** is selected by its study from its first step (see [Optimizing a sweep](#optimizing-a-sweep)). The results drawer grows a **Parameters** card across the top of its body, with one slider per swept parameter and the swept count under its title. Each slider selects a range on its interval, and starts spanning the whole of it: - **Range** (the default): Petrinaut runs **one stochastic simulation over the ranges** — every run draws its own value for each ranged parameter, spread across the selected interval — and the metric charts stream the live distribution **over the region**, sharpening exactly like a plain experiment's. Resize a range from either end to focus; compute restarts on the new selection. Range selections run on the GPU when the net qualifies — each run's parameter draw is uploaded alongside its state — and otherwise on the CPU at full parallelism; an initial state that a scenario derives from a ranged parameter holds at the range's midpoint, while the simulation itself reads each run's own value. - **Point**: switch a parameter's control to Point and its slider collapses to a single value. A point refines in escalating batches (8, 25, 100, … up to your run budget), exactly like a plain experiment at that value — including on the GPU. @@ -88,15 +88,17 @@ Every selection uses the same seed sequence (common random numbers), and a run's The in-browser optimizer is experimental and off by default. Turn on **In-browser optimization** under Simulation in the [settings dialog](visual-settings.md#in-browser-optimization-experimental); the setting is offered only when the host application provides an optimizer that runs in your browser. Turning it off while a study runs cancels the study. -With it on, the **Parameters** card of a sweep over a saved scenario carries one purple **Optimize** button in its header. A sweep created with **No scenario** cannot be optimized or constrained -- save the scenario first. It asks which metric to optimize, whether to **Maximize** or **Minimize** it, and how many steps to take (30 by default, 1,000 at most), then hands the sliders to the optimizer: the card turns purple, the header's status reads **Optimizing** and its progress bar counts the steps, the controls lock and move by themselves to each point the optimizer tries, the line under the sliders reads **Following step N of M** with the point's runs as they stream (**— 5 of 8 runs**), and every point lands on the Surface as it computes. Each step computes eight runs at its point before the optimizer reads the metric's value there, the mean over those runs on the last sampled frame; the **N computing** chip lists that batch as **Step N**. The optimizer draws its first steps at random, about a third of the requested steps and at least 2 and at most 10, then proposes each further step from the results so far. Every step's runs use the sweep's common random numbers, so the differences between steps come from the parameters, not from sampling luck. Steps run one after another. Parameters you did not sweep hold at the values the sweep was created with. +With it on, the interval toggles of the Create Experiment drawer read **Optimize**, and the first one you flip adds an **Objective** section to the drawer, between Scenario and [Constraints](#constraints): the metric to optimize (one of the experiment's metrics, the first by default), **Maximize** or **Minimize**, and the number of steps to take (30 by default, 1,000 at most). Each step computes eight runs at one point of the sweep before the optimizer reads the metric's value there, the mean over those runs on the last sampled frame; the line under the fields says so -- **30 steps · 8 runs each — the best point then refines to your run budget** -- or names the step budget the optimizer refuses (a run of more than 100,000 simulation steps, or steps × 8 runs × simulation steps over 5,000,000), and the footer stays disabled until it is met. A **No scenario** experiment optimizes too, over the generated parameters of its form values; only Constraints need a saved scenario. -While the study drives the sweep the same button reads **Stop**: it ends the search where it stands, and the point it was trying refines to your run budget; when the search finishes on its own the sliders settle on the best point found and that point refines the same way. Once the search settles, the line under the sliders keeps its outcome -- **Finished 30 steps · best step so far: step 12 (650.500)**, or **Stopped after 17 of 30 steps · …** -- with the parked point's sampling after it, until the next **Optimize** or the experiment's removal. The value is named for what it is: the best of the steps tried, not a confirmed result at that configuration. **Cancel** in the drawer's footer stops the study as well as the sweep; **Remove** discards both. A study that fails reports its message in the line under the header, where the experiment's own error would read. The study appears nowhere else: the sweep's drawer is its home, and removing the experiment removes it. A stopped study cannot be resumed; **Optimize** again starts a fresh search on the same sweep, with everything the sweep already computed still cached. +The footer reads **Optimize**, then **Starting** while the scenario compiles and the study registers. The experiment then opens already optimizing: the **Parameters** card is purple, the header's status reads **Optimizing** and its progress bar counts the steps, the controls are locked and move by themselves to each point the optimizer tries, the line under the sliders reads **Following step N of M** with the point's runs as they stream (**— 5 of 8 runs**), and every point lands on the Surface as it computes; the **N computing** chip lists the step's batch as **Step N**. The optimizer draws its first steps at random, about a third of the requested steps and at least 2 and at most 10, then proposes each further step from the results so far. Every step's runs use the sweep's common random numbers, so the differences between steps come from the parameters, not from sampling luck. Steps run one after another, and one study at a time: a study started while another runs waits for it, reading **Optimizing** at step 1 with no runs until its turn. Parameters you did not sweep hold at the values the experiment was created with. If the optimizer cannot start -- the optimizer disconnected, or a budget the form did not catch -- nothing is created: the drawer stays open with the reason in its footer and every field as you left it. + +While the study drives the sweep the card's header carries one purple **Stop** button: it ends the search where it stands, and the point it was trying refines to your run budget; when the search finishes on its own the sliders settle on the best point found and that point refines the same way. Once the search settles, the sliders unlock and the line under the sliders keeps its outcome -- **Finished 30 steps · best step so far: step 12 (650.500)**, or **Stopped after 17 of 30 steps · …** -- with the parked point's sampling after it, until the experiment's removal. The value is named for what it is: the best of the steps tried, not a confirmed result at that configuration. From there the sweep is yours to explore by hand -- sliders, **Point** and **Range**, the Surface -- with the study's picture kept; the card offers nothing more, and a new search is a new experiment. **Cancel** in the drawer's footer stops the study as well as the sweep; **Remove** discards both. A study that fails reports its message in the line under the header, where the experiment's own error would read. The study appears nowhere else: the sweep's drawer is its home, and removing the experiment removes it. The first study in a browser downloads the Python runtime and the optimizer packages before its first step starts; the header reads **Optimizing** with no steps completed while that happens. Later studies reuse the browser's cache. Closing or reloading the page ends the study, and while one runs the browser asks you to confirm first; the study is gone on the next load, the sweep with it. The optimizer proposes with the same sampler, seed and start-up draws the [Petrinaut CLI](../../../@local/petrinaut-arch-docs/content/cli/usage-manual.mdx) uses, so a study's proposals match the CLI's step for step while the objective values it is told match. -From the first **Optimize** on, an **Objective by step** strip sits under the sliders: every step's objective value as a purple dot over the step number, with the best so far as a line stepping through them, drawn as the steps land; the axis reaches to the steps asked for while the search runs and ends at the last step run once it settles. Infeasible draws carry no value and are left off the strip, and the best step so far is never one of them. Its title line names the metric and counts the steps, with the best value found; click the line to fold the chart away or bring it back. The strip stays once the search settles. A further **Optimize** continues the same axis after the previous steps, with a dashed line where it began and its own best-so-far line, so one strip holds every optimization of the sweep. Before any **Optimize** the card shows no strip. +An **Objective by step** strip sits under the sliders from the moment the drawer opens: every step's objective value as a purple dot over the step number, with the best so far as a line stepping through them, drawn as the steps land; the axis reaches to the steps asked for while the search runs and ends at the last step run once it settles. Infeasible draws carry no value and are left off the strip, and the best step so far is never one of them. Its title line names the metric and counts the steps, with the best value found; click the line to fold the chart away or bring it back. The strip stays once the search settles. A sweep created with **Create sweep** has no strip. -The first **Optimize** also changes the drawer's shape once, and it holds that shape across running, stopped and failed studies and every later one: a headline over the header's columns, **Steps** and **Steps clear** columns after **Compute** (see [Reading the header](#reading-the-header)), the **Constraints** and **Sensitivity analysis** cards after the metric charts and the steps table under them (see [Metric charts](#metric-charts)). A second **Optimize** swaps their content in place. +The drawer's shape is fixed when the experiment is created, and it holds through running, stopped and failed studies: an experiment created with **Optimize** has, from its first frame, a headline over the header's columns, **Steps** and **Steps clear** columns after **Compute** (see [Reading the header](#reading-the-header)), the **Constraints** and **Sensitivity analysis** cards after the metric charts and the steps table under them (see [Metric charts](#metric-charts)), empty until the steps fill them; a sweep created with **Create sweep** has none of them. Nothing appears later, and nothing moves. #### The surface view @@ -135,22 +137,22 @@ Two things to know before comparing results: Open an experiment's drawer and its header names the experiment in one line: the name, the scenario (or **Default scenario**) and the run count, for example **SIR transmission sweep · Seasonal Flu · 100 runs**. Beneath it, a strip of labelled columns divided by hairlines, always on one line: in a narrow drawer the labels become tooltips and the columns read as chips, **Runs** and **Selection** shorten to their counts, and whatever still does not fit scrolls sideways under a fade at the edge. -| Column | Meaning | -| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Status** | One of the statuses above, as a pill with a coloured dot. | -| **Runs** | Plain experiments: how many runs are in flight, and how many have finished. A sweep shows **Selection** in its place. | -| **Selection** | Sweeps, in place of **Runs**: the selected combination's runs sampled over the run budget. | -| **Errors** | How many individual runs errored. An experiment can complete with some runs errored. | -| **Time** | Simulated time reached, against the configured maximum. This is model time, not clock time. A sweep that has computed nothing reads `0`. | -| **Elapsed** | Plain experiments only: clock time the experiment has been simulating; it stops with the experiment and holds the total it took. A sweep never finishes, so it has no clock. | -| **Activity** | The **N computing** chip: how many batches run right now, **0 computing** when nothing does. Click it while something runs to list them. | -| **Compute** | Whether the run uses the **CPU** or the **GPU**. Hover it for detail; on a CPU-backed experiment that asked for the GPU, it names the requirement the net did not meet. | -| **Steps** | Sweeps with a study, from the first **Optimize** on: the steps finished over the steps requested, with the runs per step, **4 / 30 · 8 runs each**; the count alone in a narrow drawer. | -| **Steps clear** | Sweeps with a study over a sweep with [constraints](#constraints): the steps clear over the steps that simulated, **3 / 4 · 75%**. | +| Column | Meaning | +| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Status** | One of the statuses above, as a pill with a coloured dot. | +| **Runs** | Plain experiments: how many runs are in flight, and how many have finished. A sweep shows **Selection** in its place. | +| **Selection** | Sweeps, in place of **Runs**: the selected combination's runs sampled over the run budget. | +| **Errors** | How many individual runs errored. An experiment can complete with some runs errored. | +| **Time** | Simulated time reached, against the configured maximum. This is model time, not clock time. A sweep that has computed nothing reads `0`. | +| **Elapsed** | Plain experiments only: clock time the experiment has been simulating; it stops with the experiment and holds the total it took. A sweep never finishes, so it has no clock. | +| **Activity** | The **N computing** chip: how many batches run right now, **0 computing** when nothing does. Click it while something runs to list them. | +| **Compute** | Whether the run uses the **CPU** or the **GPU**. Hover it for detail; on a CPU-backed experiment that asked for the GPU, it names the requirement the net did not meet. | +| **Steps** | Sweeps created with **Optimize**: the steps finished over the steps requested, with the runs per step, **4 / 30 · 8 runs each**; the count alone in a narrow drawer. | +| **Steps clear** | Sweeps with a study over a sweep with [constraints](#constraints): the steps clear over the steps that simulated, **3 / 4 · 75%**. | A progress bar runs along the header's bottom edge: the selected combination's runs for a sweep (the study's steps while one drives it), simulated time otherwise. If the experiment failed, the error reads in the line under the header; so does the error of a study that failed while driving a sweep. -From the first **Optimize** on, the title line also carries the study's headline at its right: **Step 5 of 30 · best step so far: step 2 (650.500)** while it runs, then **Finished 30 steps · …**, **Stopped after 17 of 30 steps · …** or **Failed after …**. While it runs, a chip beside the line says whether the study is still finding better steps: **Still improving** when the best moved within the last few completed steps (a tenth of the requested steps, five at least), **Converging** when that many steps passed without a better one, and **Too early to say** before one such window has completed. The chip's place is reserved, so nothing moves when it appears or goes. +For a sweep created with **Optimize**, the title line also carries the study's headline at its right: **Starting · no best step yet** before the first step, **Step 5 of 30 · best step so far: step 2 (650.500)** while it runs, then **Finished 30 steps · …**, **Stopped after 17 of 30 steps · …** or **Failed after …**. While it runs, a chip beside the line says whether the study is still finding better steps: **Still improving** when the best moved within the last few completed steps (a tenth of the requested steps, five at least), **Converging** when that many steps passed without a better one, and **Too early to say** before one such window has completed. The chip's place is reserved, so nothing moves when it appears or goes. Once the drawer's body has scrolled, the header condenses to one line, with the columns folded in as compact chips beside the title, the compute badge and the computing chip still among them; move the pointer over it, or Tab onto one of its controls, and it grows back. Nothing in the header moves when a status changes, a count goes to zero or a number grows a digit: every column is as wide as its widest value, and every card in the body keeps its height. @@ -169,7 +171,7 @@ Click (or drag across) a timeline chart to inspect single time steps — a popov #### The study's cards -From the first **Optimize** on a sweep, two more cards follow the metric charts in the same grid, at the same height, and stay there through every later study; a second **Optimize** swaps their content in place. +For a sweep created with **Optimize**, two more cards follow the metric charts in the same grid, at the same height, from the moment the drawer opens. - The **Constraints** card, only for a sweep with [constraints](#constraints). Its headline is the steps **clear** across the study over the steps that simulated, `14 / 20 · 70%`, with the pass threshold and the infeasible draws counted in the line under the title, **pass threshold 95% (alpha 0.05) · 2 infeasible draws**. Beneath it, one line gives the latest step's verdict -- **Clear**, **Limited · 6 / 8 runs passed · 75% · State constraint 1**, or **Infeasible: Parameter constraint 1** -- and one bar per state constraint shows the share of steps it passed, with a dashed mark at the threshold. The same headline sits in the header's strip as **Steps clear**. A step stopped mid-flight, or pruned because the sliders moved on, carries no verdict and counts in neither number. - The **Sensitivity analysis** card lists the swept parameters in the scenario's order with a bar for how much each one matters for reaching the best steps and a **Share** percentage per parameter; the rows keep their places as estimates land. The estimate is Optuna's PED-ANOVA: it takes the best tenth of the completed steps and measures how concentrated each parameter's values are there relative to its whole range, a relative importance that sums to 100% rather than a share of the objective's variance. It is computed by the optimizer running in your browser once the study is over, and again every few steps while a long study runs (every tenth step, or every twentieth of the requested steps when that is more) once it is past the floor. The line under the title names the statistic and says how many completed steps it is fitted on. Below the floor, 50 completed steps for a study of under 100 steps and 100 otherwise, the card is muted, the bars fade and the line says **below the N-step floor, treat as a hint**, N being the floor just named: a confident estimate over a handful of steps would mislead, and at the default 30 steps the card stays muted. A **Correlation** column beside the bars gives each parameter's signed correlation with the objective over the completed steps (`+0.34`, `−0.12`), computed from the steps themselves, so it is there from the third completed step whatever the floor. Before the first estimate the rows show a dash. A study that optimizes a single parameter has nothing to rank it against: its line says **PED-ANOVA ranks two or more parameters**, the card is never muted, and only the correlation column carries information. @@ -217,7 +219,7 @@ You can press Play in Edit mode while experiments are running in the background, Changing the net while an experiment is running does **not** retroactively affect that experiment -- it captured its model snapshot when you pressed Run. -A parameter sweep is also where Petrinaut searches parameters: its **Optimize** -button hands the sliders to the in-browser optimizer, which explores the swept -intervals through the sweep's own compute (see [Optimizing a +A parameter sweep is also where Petrinaut searches parameters: created with +**Optimize**, it searches its intervals with the in-browser optimizer through +its own compute, from the moment it starts (see [Optimizing a sweep](#optimizing-a-sweep)). There is no separate optimization workflow. diff --git a/libs/@hashintel/petrinaut/docs/visual-settings.md b/libs/@hashintel/petrinaut/docs/visual-settings.md index 575709edad7..bb3474d7e79 100644 --- a/libs/@hashintel/petrinaut/docs/visual-settings.md +++ b/libs/@hashintel/petrinaut/docs/visual-settings.md @@ -56,11 +56,11 @@ Off by default. Adds a [Compilation](compilation-output.md) tab to the bottom pa ### Parameter sweeps (experimental) -Off by default. Adds a **Sweep** toggle to every numeric scenario parameter in the experiment form, so an experiment can explore an interval instead of one value. See [Parameter sweeps](experiments.md#parameter-sweeps). +Off by default. Adds an interval toggle to every numeric value of the experiment form -- **Sweep**, or **Optimize** when In-browser optimization is on -- so an experiment explores an interval instead of one value. See [Parameter sweeps](experiments.md#parameter-sweeps). ### In-browser optimization (experimental) -Shown only when the host application provides an optimizer that runs in your browser. Off by default. On, a sweep's Parameters card offers **Optimize** and the Create Experiment drawer offers **Constraints**; off, both hide and any running in-browser optimization is cancelled. See [Optimizing a sweep](experiments.md#optimizing-a-sweep). +Shown only when the host application provides an optimizer that runs in your browser. Off by default. On, the experiment form's interval toggles read **Optimize**: creating the experiment starts a study over the selected intervals, with an **Objective** and **Constraints** chosen in the form. Off, the toggles read **Sweep** and the sweep waits for your selection; any running in-browser optimization is cancelled. See [Optimizing a sweep](experiments.md#optimizing-a-sweep). ### Arcs rendering diff --git a/libs/@hashintel/petrinaut/src/react/experiments/context.test.ts b/libs/@hashintel/petrinaut/src/react/experiments/context.test.ts index d335ee2b6cc..def38e85f6f 100644 --- a/libs/@hashintel/petrinaut/src/react/experiments/context.test.ts +++ b/libs/@hashintel/petrinaut/src/react/experiments/context.test.ts @@ -1,7 +1,12 @@ +import { createElement, use } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { + type CreateExperimentInput, type ExperimentRecord, + ExperimentsContext, + type ExperimentsContextValue, type ExperimentStatus, getExperimentElapsedMs, isExperimentActive, @@ -35,6 +40,7 @@ function makeRecord(overrides: Partial): ExperimentRecord { scenarioParameterValues: {}, constraints: [], constraintPolicy: null, + scenario: null, ...overrides, }; } @@ -104,3 +110,33 @@ describe("getExperimentElapsedMs", () => { expect(getExperimentElapsedMs(experiment, 4_000)).toBe(0); }); }); + +describe("ExperimentsContext default value", () => { + /** The value a consumer reads with no provider above it. */ + const readDefaultValue = (): ExperimentsContextValue => { + let value: ExperimentsContextValue | null = null; + const Consumer = () => { + value = use(ExperimentsContext); + return null; + }; + renderToStaticMarkup(createElement(Consumer)); + return value!; + }; + + it("rejects creation instead of resolving a record nobody holds", async () => { + const input: CreateExperimentInput = { + name: "Orphan", + scenarioId: null, + scenarioParameterValues: {}, + runCount: 1, + seed: 1, + dt: 1, + maxTime: 10, + metricSpecs: [], + }; + + await expect(readDefaultValue().createExperiment(input)).rejects.toThrow( + "createExperiment was called outside an ExperimentsProvider", + ); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/react/experiments/context.ts b/libs/@hashintel/petrinaut/src/react/experiments/context.ts index 5f7445b2104..c28e60467c1 100644 --- a/libs/@hashintel/petrinaut/src/react/experiments/context.ts +++ b/libs/@hashintel/petrinaut/src/react/experiments/context.ts @@ -23,6 +23,7 @@ import type { MonteCarloMetricSpec, MonteCarloUserDefinedMetricFrame, MonteCarloWorkerProgress, + Scenario, } from "@hashintel/petrinaut-core"; import type { PetrinautOptimizationConstraintPolicy } from "@hashintel/petrinaut-core/optimization"; @@ -172,6 +173,14 @@ export type ExperimentRecord = { scenarioParameterValues: Readonly>; constraints: readonly Constraint[]; constraintPolicy: PetrinautOptimizationConstraintPolicy | null; + /** + * The scenario the runs compile from, as it was at creation: the saved + * scenario's snapshot, or the generated ad-hoc scenario (id + * "adhoc-scenario", one parameter per interval toggle). A study of this + * sweep binds its parameters. Null when the experiment runs the net's own + * marking. + */ + scenario: Scenario | null; }; /** Navigator-facing state of a sweep experiment. */ @@ -231,7 +240,12 @@ export type ExperimentsContextValue = { selectedExperimentId: string | null; selectedExperiment: ExperimentRecord | null; setSelectedExperimentId: (experimentId: string | null) => void; - createExperiment: (input: CreateExperimentInput) => Promise; + /** + * Creates the experiment and starts its compute; resolves with the record + * once a sweep's session is registered (or a plain run's backend selection + * has begun). Selects nothing: the caller decides what opens, and when. + */ + createExperiment: (input: CreateExperimentInput) => Promise; cancelExperiment: (experimentId: string) => void; removeExperiment: (experimentId: string) => void; /** Moves a sweep's navigator; compute follows the selection up to the run count. */ @@ -255,7 +269,10 @@ const DEFAULT_CONTEXT_VALUE: ExperimentsContextValue = { selectedExperimentId: null, selectedExperiment: null, setSelectedExperimentId: () => {}, - createExperiment: () => Promise.resolve(""), + createExperiment: () => + Promise.reject( + new Error("createExperiment was called outside an ExperimentsProvider"), + ), cancelExperiment: () => {}, removeExperiment: () => {}, setSweepSelection: () => {}, diff --git a/libs/@hashintel/petrinaut/src/react/experiments/provider.test.tsx b/libs/@hashintel/petrinaut/src/react/experiments/provider.test.tsx index 23041cbb78e..6113943de8d 100644 --- a/libs/@hashintel/petrinaut/src/react/experiments/provider.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/experiments/provider.test.tsx @@ -34,7 +34,12 @@ import { type AddNotificationInput, } from "../notifications/context"; import { SDCPNContext, type SDCPNContextValue } from "../state/sdcpn-context"; -import { ExperimentsContext, type ExperimentsContextValue } from "./context"; +import { + type CreateExperimentInput, + type ExperimentRecord, + ExperimentsContext, + type ExperimentsContextValue, +} from "./context"; import { buildSweepAxes, ExperimentsProvider } from "./provider"; import { compileExperimentScenario } from "./provider/create-experiment"; @@ -166,6 +171,20 @@ const flushWorkerSetup = async () => { }); }; +/** + * Creates through the provider and selects the record, as the Create + * Experiment drawer does once creation resolves; the tests below read the + * experiment back through `selectedExperiment`. + */ +const createSelectedExperiment = async ( + getValue: () => ExperimentsContextValue, + input: CreateExperimentInput, +): Promise => { + const experiment = await getValue().createExperiment(input); + getValue().setSelectedExperimentId(experiment.id); + return experiment.id; +}; + /** * Makes the WebGPU backend report itself available for the duration of `body`. * @@ -402,7 +421,7 @@ describe("buildSweepAxes", () => { }); describe("ExperimentsProvider", () => { - it("replaces the creation overlay with the created experiment location", async () => { + it("leaves the creation overlay open until the caller selects the created experiment", async () => { const worker = new FakeMonteCarloWorker(); const { getNavigationState, getValue, renderResult } = renderExperimentsProvider(worker, { @@ -410,9 +429,9 @@ describe("ExperimentsProvider", () => { }); try { - let experimentId = ""; + let experiment!: ExperimentRecord; await act(async () => { - experimentId = await getValue().createExperiment({ + experiment = await getValue().createExperiment({ name: "Navigated experiment", scenarioId: null, scenarioParameterValues: {}, @@ -425,10 +444,20 @@ describe("ExperimentsProvider", () => { await flushWorkerSetup(); }); + // Creation navigates nowhere: the drawer decides what opens, and when. + expect(getNavigationState()).toMatchObject({ + simulateResource: null, + overlay: { type: "create-experiment" }, + }); + + await act(async () => { + getValue().setSelectedExperimentId(experiment.id); + }); + expect(getNavigationState()).toMatchObject({ mode: "simulate", simulateView: "experiments", - simulateResource: { type: "experiment", id: experimentId }, + simulateResource: { type: "experiment", id: experiment.id }, overlay: null, }); } finally { @@ -441,9 +470,9 @@ describe("ExperimentsProvider", () => { const { getValue, renderResult } = renderExperimentsProvider(worker); try { - let experimentId = ""; + let experiment!: ExperimentRecord; await act(async () => { - experimentId = await getValue().createExperiment({ + experiment = await getValue().createExperiment({ name: "Initializing experiment", scenarioId: null, scenarioParameterValues: {}, @@ -457,13 +486,21 @@ describe("ExperimentsProvider", () => { }); expect(worker.sent.map((message) => message.type)).toEqual(["init"]); - expect(getValue().experiments).toHaveLength(1); - expect(getValue().selectedExperimentId).toBe(experimentId); - expect(getValue().selectedExperiment).toMatchObject({ - id: experimentId, + // The resolved record is the one in the list, and nothing selected it. + expect(getValue().experiments).toEqual([ + expect.objectContaining({ + id: experiment.id, + name: "Initializing experiment", + status: "initializing", + scenario: null, + }), + ]); + expect(experiment).toMatchObject({ name: "Initializing experiment", status: "initializing", + sweep: null, }); + expect(getValue().selectedExperimentId).toBeNull(); await act(async () => { worker.emit({ type: "ready" }); @@ -474,7 +511,7 @@ describe("ExperimentsProvider", () => { "init", "start", ]); - expect(getValue().selectedExperiment?.status).toBe("running"); + expect(getValue().experiments[0]?.status).toBe("running"); } finally { renderResult.unmount(); } @@ -487,7 +524,7 @@ describe("ExperimentsProvider", () => { try { let experimentId = ""; await act(async () => { - experimentId = await getValue().createExperiment({ + experimentId = await createSelectedExperiment(getValue, { name: "Remove before ready", scenarioId: null, scenarioParameterValues: {}, @@ -527,7 +564,7 @@ describe("ExperimentsProvider", () => { try { let experimentId = ""; await act(async () => { - experimentId = await getValue().createExperiment({ + experimentId = await createSelectedExperiment(getValue, { name: "Cancel before ready", scenarioId: null, scenarioParameterValues: {}, @@ -579,7 +616,7 @@ describe("ExperimentsProvider", () => { try { let experimentId = ""; await act(async () => { - experimentId = await getValue().createExperiment({ + experimentId = await createSelectedExperiment(getValue, { name: "Cancel during compile", scenarioId: null, scenarioParameterValues: {}, @@ -666,7 +703,7 @@ describe("ExperimentsProvider", () => { }); worker.emit({ type: "ready" }); - experimentId = await createPromise; + experimentId = (await createPromise).id; }); expect(worker.sent.map((message) => message.type)).toEqual([ @@ -674,8 +711,13 @@ describe("ExperimentsProvider", () => { "start", ]); expect(getValue().experiments).toHaveLength(1); + expect(getValue().selectedExperimentId).toBeNull(); + expect(getValue().experiments[0]?.status).toBe("running"); + + await act(async () => { + getValue().setSelectedExperimentId(experimentId); + }); expect(getValue().selectedExperimentId).toBe(experimentId); - expect(getValue().selectedExperiment?.status).toBe("running"); const frame = makeMetricFrame(); const progress = makeProgress(); @@ -774,7 +816,7 @@ describe("ExperimentsProvider", () => { const runExperiment = async () => { let experimentId = ""; await act(async () => { - const createPromise = getValue().createExperiment({ + const createPromise = createSelectedExperiment(getValue, { name: "Pooled", scenarioId: null, scenarioParameterValues: {}, @@ -830,7 +872,7 @@ describe("ExperimentsProvider", () => { try { await act(async () => { - const createPromise = getValue().createExperiment({ + const createPromise = createSelectedExperiment(getValue, { name: "Blocking experiment", scenarioId: null, scenarioParameterValues: {}, @@ -921,7 +963,7 @@ describe("ExperimentsProvider", () => { try { await act(async () => { - const createPromise = getValue().createExperiment({ + const createPromise = createSelectedExperiment(getValue, { name: "Ad-hoc experiment", scenarioId: null, scenarioParameterValues: {}, @@ -1002,7 +1044,7 @@ describe("ExperimentsProvider", () => { try { await act(async () => { - const createPromise = getValue().createExperiment({ + const createPromise = createSelectedExperiment(getValue, { name: "Metric experiment", scenarioId: null, scenarioParameterValues: {}, @@ -1080,7 +1122,7 @@ describe("ExperimentsProvider", () => { try { await act(async () => { - const createPromise = getValue().createExperiment({ + const createPromise = createSelectedExperiment(getValue, { name: `${computeBackend} experiment`, scenarioId: null, scenarioParameterValues: {}, @@ -1143,7 +1185,7 @@ describe("ExperimentsProvider", () => { try { await withWebGpuAvailable(async () => { await act(async () => { - const createPromise = getValue().createExperiment({ + const createPromise = createSelectedExperiment(getValue, { name: "gpu experiment", scenarioId: null, scenarioParameterValues: {}, @@ -1193,7 +1235,7 @@ describe("ExperimentsProvider", () => { let createPromise!: Promise; await act(async () => { - createPromise = getValue().createExperiment({ + createPromise = createSelectedExperiment(getValue, { name: "Timed experiment", scenarioId: null, scenarioParameterValues: {}, @@ -1275,7 +1317,7 @@ describe("ExperimentsProvider", () => { try { let createPromise!: Promise; await act(async () => { - createPromise = getValue().createExperiment({ + createPromise = createSelectedExperiment(getValue, { name: "Erroring experiment", scenarioId: null, scenarioParameterValues: {}, @@ -1326,7 +1368,7 @@ describe("ExperimentsProvider", () => { try { await act(async () => { - void getValue().createExperiment({ + void createSelectedExperiment(getValue, { name: "Cancelled during setup", scenarioId: null, scenarioParameterValues: {}, @@ -1360,7 +1402,7 @@ describe("ExperimentsProvider", () => { try { await act(async () => { - const createPromise = getValue().createExperiment({ + const createPromise = createSelectedExperiment(getValue, { name: "CPU experiment", scenarioId: null, scenarioParameterValues: {}, @@ -1407,7 +1449,7 @@ describe("ExperimentsProvider", () => { try { await withWebGpuAvailable(async () => { await act(async () => { - const createPromise = getValue().createExperiment({ + const createPromise = createSelectedExperiment(getValue, { name: "GPU experiment", scenarioId: null, scenarioParameterValues: {}, @@ -1462,7 +1504,7 @@ describe("ExperimentsProvider", () => { try { await act(async () => { - const createPromise = getValue().createExperiment({ + const createPromise = createSelectedExperiment(getValue, { name: "Erroring experiment", scenarioId: null, scenarioParameterValues: {}, @@ -1497,6 +1539,69 @@ describe("ExperimentsProvider", () => { } }); + it("registers a sweep's session before creation resolves, so a navigation right after it is taken", async () => { + // A study's first trial navigates the sweep as soon as `createExperiment` + // resolves. That works only because the sweep branch of initialization + // awaits nothing before `startSweepSession`; an await inserted there + // would surface as "The sweep is no longer running" on every such trial. + const worker = new FakeMonteCarloWorker(); + const { getValue, renderResult } = renderExperimentsProvider(worker, { + petriNetDefinition: { + ...EMPTY_SDCPN, + scenarios: [ + { + id: "scenario", + name: "Scenario", + scenarioParameters: [ + { type: "real", identifier: "beta", default: 0.5 }, + ], + parameterOverrides: {}, + initialState: { type: "per_place", content: {} }, + }, + ], + }, + }); + const rejected = vi.fn(); + const resolved = vi.fn(); + + try { + let experiment!: ExperimentRecord; + await act(async () => { + experiment = await getValue().createExperiment({ + name: "Sweep", + scenarioId: "scenario", + scenarioParameterValues: { beta: { mode: "range", min: 0, max: 1 } }, + runCount: 8, + seed: 42, + dt: 1, + maxTime: 10, + metricSpecs: CONSTANT_METRIC_SPEC, + }); + getValue() + .navigateSweep(experiment.id, { beta: { from: 10, to: 10 } }) + .then(resolved, rejected); + await flushWorkerSetup(); + }); + + expect(experiment).toMatchObject({ + status: "initializing", + sweep: { computing: false, selectionKey: "beta=0..50" }, + scenario: { id: "scenario" }, + }); + // The session took the move: a batch reached the worker, and the + // navigation waits for its runs rather than failing. + expect(worker.sent.map((message) => message.type)).toEqual(["init"]); + expect(rejected).not.toHaveBeenCalled(); + expect(resolved).not.toHaveBeenCalled(); + expect(getValue().experiments[0]).toMatchObject({ + id: experiment.id, + sweep: { computing: true, selectionKey: "beta=10" }, + }); + } finally { + renderResult.unmount(); + } + }); + it("cancelling a sweep mid-batch leaves its selection idle", async () => { const worker = new FakeMonteCarloWorker(); const { getValue, renderResult } = renderExperimentsProvider(worker, { @@ -1518,7 +1623,7 @@ describe("ExperimentsProvider", () => { let experimentId = ""; await act(async () => { - experimentId = await getValue().createExperiment({ + experimentId = await createSelectedExperiment(getValue, { name: "Sweep", scenarioId: "scenario", scenarioParameterValues: { beta: { mode: "range", min: 0, max: 1 } }, @@ -1590,7 +1695,7 @@ describe("ExperimentsProvider", () => { let experimentId = ""; await act(async () => { - experimentId = await getValue().createExperiment({ + experimentId = await createSelectedExperiment(getValue, { name: "Sweep", scenarioId: "scenario", scenarioParameterValues: { beta: { mode: "range", min: 0, max: 1 } }, diff --git a/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx b/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx index 400884fd203..05c92f84c39 100644 --- a/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx @@ -451,9 +451,9 @@ export const ExperimentsProvider: React.FC = ({ scenario?.name ?? (input.adHocScenario ? "Ad-hoc scenario" : null), axes: compiled.axes, fixedScenarioValues: compiled.fixedScenarioValues, + scenario: compiled.scenario, }); setExperiments((prev) => [experiment, ...prev]); - setSelectedExperimentId(experimentId); const abortController = new AbortController(); pendingRegistrationsRef.current.set(experimentId, { abortController }); @@ -575,7 +575,7 @@ export const ExperimentsProvider: React.FC = ({ void initializeExperiment(); - return experimentId; + return experiment; }; const cancelExperiment: ExperimentsContextValue["cancelExperiment"] = ( diff --git a/libs/@hashintel/petrinaut/src/react/experiments/provider/create-experiment.test.ts b/libs/@hashintel/petrinaut/src/react/experiments/provider/create-experiment.test.ts index 7d97d017fea..ea02bce6cd9 100644 --- a/libs/@hashintel/petrinaut/src/react/experiments/provider/create-experiment.test.ts +++ b/libs/@hashintel/petrinaut/src/react/experiments/provider/create-experiment.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { DEFAULT_PETRINAUT_EXTENSIONS } from "@hashintel/petrinaut-core"; import { sirModel } from "@hashintel/petrinaut-core/examples"; import { selectExperimentBackend } from "@hashintel/petrinaut-core/experiments"; +import { lowerScenarioToHir } from "@hashintel/petrinaut-core/hir"; import { createWebGpuExperimentBackend } from "@hashintel/petrinaut-core/webgpu"; import { sirOptimizationConstraints } from "../../optimizations/sir-optimization-input.fixtures"; @@ -17,7 +18,12 @@ import { import type { CreateExperimentInput } from "../context"; import type { CompiledExperimentScenario } from "./create-experiment"; -import type { Constraint, Scenario } from "@hashintel/petrinaut-core"; +import type { + AdHocScenarioState, + Constraint, + Scenario, + SDCPN, +} from "@hashintel/petrinaut-core"; const span = { start: 0, length: 0 }; @@ -110,6 +116,40 @@ describe("assertExperimentInput", () => { }); }); +/** A one-place net for the ad-hoc definitions below. */ +const queueSdcpn: SDCPN = { + places: [ + { + id: "place-queue", + name: "Queue", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + transitions: [], + types: [], + parameters: [], + differentialEquations: [], +}; + +/** The queue's count with an interval toggle on it. */ +const toggledAdHocScenario: AdHocScenarioState = { + variables: [], + netParameters: [], + places: { + "place-queue": { + kind: "uncoloured", + count: { + expression: "4", + optimize: { min: "2", max: "8", scale: "linear" }, + }, + }, + }, +}; + describe("compileExperimentScenario", () => { const requestScenarioHir = vi.fn(() => Promise.resolve({ @@ -118,6 +158,21 @@ describe("compileExperimentScenario", () => { placeExpressions: {}, }), ); + const compileAdHoc = (adHocSweeps: boolean) => + compileExperimentScenario({ + input: { + ...input, + scenarioId: null, + adHocScenario: toggledAdHocScenario, + adHocSweeps, + }, + scenario: null, + fixedValues: {}, + axes: [], + sdcpn: queueSdcpn, + requestScenarioHir: (adHocScenario, adHocContext) => + Promise.resolve(lowerScenarioToHir(adHocScenario, { adHocContext })), + }); it("surfaces every scenario parameter's parsed value, swept ones at their fixed form", async () => { const { fixedValues, axes } = buildSweepAxes(scenario, { @@ -142,6 +197,29 @@ describe("compileExperimentScenario", () => { population: 1000, vaccinated: 0, }); + expect(compiled.scenario).toBe(scenario); + }); + + it("keeps the generated scenario of a toggled ad-hoc definition, one parameter per axis", async () => { + const compiled = await compileAdHoc(true); + + expect(compiled.scenario?.id).toBe("adhoc-scenario"); + expect(compiled.axes.map((axis) => axis.identifier)).toEqual([ + "adhoc_count_Queue", + ]); + expect( + compiled.scenario?.scenarioParameters.map( + (parameter) => parameter.identifier, + ), + ).toEqual(compiled.axes.map((axis) => axis.identifier)); + }); + + it("keeps the generated scenario of a plain ad-hoc definition", async () => { + const compiled = await compileAdHoc(false); + + expect(compiled.scenario?.id).toBe("adhoc-scenario"); + expect(compiled.scenario?.scenarioParameters).toEqual([]); + expect(compiled.axes).toEqual([]); }); it("surfaces no values for an experiment without a scenario", async () => { @@ -154,6 +232,7 @@ describe("compileExperimentScenario", () => { requestScenarioHir, }); expect(compiled.fixedScenarioValues).toEqual({}); + expect(compiled.scenario).toBeNull(); }); }); @@ -169,8 +248,10 @@ describe("newExperimentRecord", () => { scenarioName: "Swept", axes: [], fixedScenarioValues: { transmission_rate: 0.4, population: 1000 }, + scenario, }); + expect(record.scenario).toBe(scenario); expect(record.constraints).toEqual([parameterConstraint, stateConstraint]); expect(record.constraintPolicy).toEqual({ alpha: 0.1 }); expect(record.scenarioParameterValues).toEqual({ @@ -186,8 +267,10 @@ describe("newExperimentRecord", () => { scenarioName: null, axes: [], fixedScenarioValues: {}, + scenario: null, }); + expect(record.scenario).toBeNull(); expect(record.constraints).toEqual([]); expect(record.constraintPolicy).toBeNull(); expect(record.scenarioParameterValues).toEqual({}); @@ -205,6 +288,7 @@ describe("createExperimentRequestBuilder", () => { sweptCompiler: null, axes: [], fixedScenarioValues: {}, + scenario: null, }; const requestHirArtifacts = vi.fn(() => Promise.resolve({ @@ -257,6 +341,7 @@ describe("createExperimentRequestBuilder", () => { scenarioName: null, axes: [], fixedScenarioValues: {}, + scenario: null, }); expect(record.metricSpecs).toEqual(constrainedInput.metricSpecs); expect(sdcpn.metrics).toEqual([]); diff --git a/libs/@hashintel/petrinaut/src/react/experiments/provider/create-experiment.ts b/libs/@hashintel/petrinaut/src/react/experiments/provider/create-experiment.ts index f91c3c4e722..19361e16822 100644 --- a/libs/@hashintel/petrinaut/src/react/experiments/provider/create-experiment.ts +++ b/libs/@hashintel/petrinaut/src/react/experiments/provider/create-experiment.ts @@ -205,6 +205,11 @@ export type CompiledExperimentScenario = { axes: ExperimentParameterAxis[]; /** `parseFixedScenarioValues`' result; `{}` for an ad-hoc definition. */ fixedScenarioValues: Readonly>; + /** + * The scenario compiled: the saved one, the generated ad-hoc scenario (with + * the interval toggles as its parameters, or plain), or null for none. + */ + scenario: Scenario | null; }; /** @@ -282,6 +287,7 @@ export const compileExperimentScenario = async ({ sweptCompiler, axes, fixedScenarioValues: fixed, + scenario, }; } @@ -318,6 +324,7 @@ export const compileExperimentScenario = async ({ sweptCompiler, axes: adHocAxes.axes, fixedScenarioValues: {}, + scenario: generated, }; } } @@ -342,6 +349,7 @@ export const compileExperimentScenario = async ({ sweptCompiler: null, axes: [], fixedScenarioValues: {}, + scenario: synthesized.scenario, }; } @@ -351,6 +359,7 @@ export const compileExperimentScenario = async ({ sweptCompiler: null, axes: [], fixedScenarioValues: {}, + scenario: null, }; }; @@ -376,12 +385,14 @@ export const newExperimentRecord = ({ scenarioName, axes, fixedScenarioValues, + scenario, }: { id: string; input: CreateExperimentInput; scenarioName: string | null; axes: readonly ExperimentParameterAxis[]; fixedScenarioValues: Readonly>; + scenario: Scenario | null; }): ExperimentRecord => ({ id, name: input.name.trim(), @@ -408,6 +419,7 @@ export const newExperimentRecord = ({ scenarioParameterValues: fixedScenarioValues, constraints: input.constraints ?? [], constraintPolicy: input.constraintPolicy ?? null, + scenario, }); /** diff --git a/libs/@hashintel/petrinaut/src/react/optimizations/context.ts b/libs/@hashintel/petrinaut/src/react/optimizations/context.ts index 32ad8aa5f30..d1adaecf0f2 100644 --- a/libs/@hashintel/petrinaut/src/react/optimizations/context.ts +++ b/libs/@hashintel/petrinaut/src/react/optimizations/context.ts @@ -39,7 +39,7 @@ export type OptimizationRecord = { id: string; input: PetrinautOptimizationInput; createdAt: number; - /** The experiment the study drives from its Parameters card. */ + /** The experiment the study was created with and drives. */ origin: OptimizationOrigin; status: OptimizationStatus; error: string | null; diff --git a/libs/@hashintel/petrinaut/src/react/optimizations/provider/create-sweep-trial-evaluator.ts b/libs/@hashintel/petrinaut/src/react/optimizations/provider/create-sweep-trial-evaluator.ts index 5d64c3cd1d8..9a63cd564da 100644 --- a/libs/@hashintel/petrinaut/src/react/optimizations/provider/create-sweep-trial-evaluator.ts +++ b/libs/@hashintel/petrinaut/src/react/optimizations/provider/create-sweep-trial-evaluator.ts @@ -2,8 +2,8 @@ * @layerRoot react.optimizations.sweep-evaluator * @role Evaluates an optimizer's trials through a parameter sweep's own compute: each trial moves the sweep to the suggested point and reads the metric there * - * A study started from an experiment's Parameters card runs no batches of - * its own. Its evaluator turns every suggestion into a point on the sweep's + * A study started with the experiment from the Create Experiment drawer + * runs no batches of its own. Its evaluator turns every suggestion into a point on the sweep's * quantized axes, asks the experiments provider to navigate there and wait * for the trial's runs, and reports the metric's value at that point. The * sweep's navigator therefore moves trial by trial, every trial is a visited diff --git a/libs/@hashintel/petrinaut/src/react/optimizations/use-optimization-source.ts b/libs/@hashintel/petrinaut/src/react/optimizations/use-optimization-source.ts index 163e4a9dc11..ed97406d52c 100644 --- a/libs/@hashintel/petrinaut/src/react/optimizations/use-optimization-source.ts +++ b/libs/@hashintel/petrinaut/src/react/optimizations/use-optimization-source.ts @@ -11,9 +11,9 @@ import { UserSettingsContext } from "../state/user-settings-context"; /** * The host's optimization source as the UI may use it. A remote capability * passes through unchanged; a connected one counts only while the experimental - * In-browser optimization setting is on. `null` hides the sweep's Optimize - * control and the Create Experiment drawer's Constraints section, and - * nothing connects. + * In-browser optimization setting is on. `null` turns the experiment form's + * interval toggles back to Sweep, hides the Create Experiment drawer's + * Objective and Constraints sections, and nothing connects. */ export const useOptimizationSource = (): PetrinautOptimizationSource | null => { const source = use(PetrinautOptimizationContext); diff --git a/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts b/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts index 2105099d553..4b418b46c5e 100644 --- a/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts +++ b/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts @@ -81,17 +81,20 @@ export type UserSettings = { */ showCompilationOutput: boolean; /** - * Experimental: offer parameter sweeps. On, every numeric scenario parameter - * in the experiment form gets a Sweep toggle that turns its value into an - * interval. Off, experiments take fixed values only. + * Experimental: offer parameter sweeps. On, every numeric value of the + * experiment form gets an interval toggle — reading Sweep, or Optimize when + * In-browser optimization is on — that turns its value into an interval. + * Off, experiments take fixed values only. */ enableParameterSweeps: boolean; /** - * Experimental: connect a host-supplied in-browser optimizer, which runs - * studies through the experiments backend and streams each step's metrics - * as it is evaluated. Off, a connected optimizer counts as none at all and - * the Optimizations surfaces stay hidden. A remote optimization capability - * is unaffected either way. + * Experimental: connect a host-supplied in-browser optimizer. On, the + * experiment form's interval toggles read Optimize: creating the experiment + * starts a study over the selected intervals, with an Objective and + * Constraints chosen in the form. Off, a connected optimizer counts as none + * at all, the toggles read Sweep and the sweep waits for a selection; any + * running in-browser optimization is cancelled. A remote optimization + * capability is unaffected either way. */ enableInBrowserOptimization: boolean; /** diff --git a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/ad-hoc-scenario-form.stories.tsx b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/ad-hoc-scenario-form.stories.tsx index 68df59ad241..56881cda3aa 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/ad-hoc-scenario-form.stories.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/ad-hoc-scenario-form.stories.tsx @@ -150,7 +150,7 @@ const optimizedSatellitesState: AdHocScenarioState = { variables: [ { ...satellitesState.variables[0]!, - optimize: { min: "200", max: "2000", scale: "log" }, + optimize: { min: "200", max: "2000", scale: "linear" }, }, satellitesState.variables[1]!, ], @@ -300,8 +300,11 @@ export const SatellitesForPlainRuns: Story = { ), }; -/** Optimization: select which values the optimizer searches over. */ -export const SatellitesOptimization: Story = { +/** + * Experiment creation with the in-browser optimizer connected: every numeric + * value carries the Optimize toggle, and a flipped one opens Min and Max only. + */ +export const SatellitesOptimizeSelections: Story = { render: () => ( ( + + ), +}; + /** Scenario authoring: expose top-level Variables as scenario parameters. */ export const SatellitesScenarioParameters: Story = { render: () => ( diff --git a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/ad-hoc-scenario-form.test.tsx b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/ad-hoc-scenario-form.test.tsx index a33613eb1cb..6f539ecc131 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/ad-hoc-scenario-form.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/ad-hoc-scenario-form.test.tsx @@ -7,6 +7,7 @@ import { render, screen, waitFor, + within, } from "@testing-library/react"; import { useState } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -58,8 +59,8 @@ class ObserverStub { globalThis.ResizeObserver = ObserverStub as unknown as typeof ResizeObserver; globalThis.IntersectionObserver = ObserverStub as unknown as typeof IntersectionObserver; -// jsdom implements no scrolling at all, and the Scale list scrolls itself to -// the selected option when it opens. +// jsdom implements no scrolling at all, and an open Select list (the type +// column's) scrolls itself to the selected option when it opens. Element.prototype.scrollTo = () => {}; afterEach(cleanup); @@ -875,7 +876,7 @@ describe("AdHocScenarioForm", () => { name: "n", type: "integer", expression: "2", - optimize: { min: "0", max: "10", step: "1", scale: "linear" }, + optimize: { min: "0", max: "10", scale: "linear" }, }, ], netParameters: [], @@ -895,7 +896,7 @@ describe("AdHocScenarioForm", () => { fireEvent.click(screen.getByRole("button", { name: "n" })); const minCell = await screen.findByRole("button", { name: "Min of n" }); expect(document.activeElement).toBe(minCell); - expect(screen.getByText("Scale")).toBeTruthy(); + expect(screen.getByText("Max")).toBeTruthy(); // Arrows move between the bound cells; Enter opens the expression // editor in place (no per-cell path or Optimize chrome). @@ -915,11 +916,11 @@ describe("AdHocScenarioForm", () => { expect(document.activeElement).toBe( screen.getByRole("button", { name: "Max of n" }), ); - expect(screen.getByText("Scale")).toBeTruthy(); + expect(screen.getByText("Max")).toBeTruthy(); // ...then the whole slab. fireEvent.keyDown(document.activeElement!, { key: "Escape" }); - expect(screen.queryByText("Scale")).toBe(null); + expect(screen.queryByText("Max")).toBe(null); }); it("keeps the optimize bounds usable while the slab is open", async () => { @@ -930,7 +931,7 @@ describe("AdHocScenarioForm", () => { name: "n", type: "integer", expression: "2", - optimize: { min: "0", max: "10", step: "1", scale: "linear" }, + optimize: { min: "0", max: "10", scale: "linear" }, }, ], netParameters: [], @@ -955,13 +956,6 @@ describe("AdHocScenarioForm", () => { // outside and closes the slab on. expect(slab?.className).toContain("pointer-events_auto"); - // A layer opened from inside the slab lives inside it, so pressing its - // options is not an outside press. - fireEvent.click(screen.getByRole("combobox", { name: "Scale of n" })); - const scaleList = await screen.findByRole("listbox"); - expect(slab?.contains(scaleList)).toBe(true); - fireEvent.keyDown(scaleList, { key: "Escape" }); - // Editing one bound keeps focus in its editor. Each keystroke dispatches // and re-renders the slab, and the one-shot Min selection must not // re-arm on the re-render: it used to steal focus mid-edit, so the next @@ -1419,8 +1413,15 @@ describe("AdHocScenarioForm", () => { }); }); -describe("sweep selection", () => { - const SWEEP_STATE: AdHocScenarioState = { +describe.each([ + { + selection: "optimize" as const, + word: "Optimize", + gutter: "Optimized count", + }, + { selection: "sweep" as const, word: "Sweep", gutter: "Swept count" }, +])("interval selection ($word)", ({ selection, word, gutter }) => { + const INTERVAL_STATE: AdHocScenarioState = { variables: [ { name: "altitude", @@ -1441,21 +1442,21 @@ describe("sweep selection", () => { places: {}, }; - it("run mode offers a Sweep toggle on numeric scenario parameters only", () => { + it("run mode offers the toggle on numeric scenario parameters only", () => { let latest: AdHocScenarioState | undefined; render( { latest = state; }} />, ); - expect(screen.queryByRole("button", { name: "Sweep armed" })).toBe(null); - fireEvent.click(screen.getByRole("button", { name: "Sweep altitude" })); + expect(screen.queryByRole("button", { name: `${word} armed` })).toBe(null); + fireEvent.click(screen.getByRole("button", { name: `${word} altitude` })); expect(latest?.variables[0]?.optimize).toEqual({ min: "0", max: "1", @@ -1463,21 +1464,23 @@ describe("sweep selection", () => { }); }); - it("authoring offers Sweep on numeric values and Parameters, never on booleans", () => { - render(); + it("authoring offers the toggle on numeric values and Parameters, never on booleans", () => { + render(); - expect(screen.getByRole("button", { name: "Sweep Rate" })).toBeTruthy(); - expect(screen.getByRole("button", { name: "Sweep altitude" })).toBeTruthy(); - expect(screen.queryByRole("button", { name: "Sweep armed" })).toBe(null); + expect(screen.getByRole("button", { name: `${word} Rate` })).toBeTruthy(); + expect( + screen.getByRole("button", { name: `${word} altitude` }), + ).toBeTruthy(); + expect(screen.queryByRole("button", { name: `${word} armed` })).toBe(null); }); - it("run mode offers no Sweep on the computed net parameters", () => { + it("run mode offers no toggle on the computed net parameters", () => { render( { // The computed parameters are a preview the host derives; a toggle on // them would revert on the next render. - expect(screen.getByRole("button", { name: "Sweep altitude" })).toBeTruthy(); - expect(screen.queryByRole("button", { name: "Sweep Rate" })).toBe(null); + expect( + screen.getByRole("button", { name: `${word} altitude` }), + ).toBeTruthy(); + expect(screen.queryByRole("button", { name: `${word} Rate` })).toBe(null); }); - it("turns a Sweep off with a type change to boolean, as one undo step", async () => { + it.each(["integer", "real"] as const)( + "edits a toggled %s value as Min and Max cells only", + async (type) => { + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "altitude" })); + const minCell = await screen.findByRole("button", { + name: "Min of altitude", + }); + expect(document.activeElement).toBe(minCell); + expect( + screen.getByRole("button", { name: "Max of altitude" }), + ).toBeTruthy(); + // The interval is Min and Max under either word: no Step, no Scale. + const slab = document.querySelector("[data-adhoc-slab]")!; + expect(within(slab).queryByText("Step")).toBe(null); + expect(within(slab).queryByText("Scale")).toBe(null); + expect(within(slab).queryByRole("combobox")).toBe(null); + // The right arrow stops at Max. + fireEvent.keyDown(minCell, { key: "ArrowRight" }); + const maxCell = screen.getByRole("button", { name: "Max of altitude" }); + expect(document.activeElement).toBe(maxCell); + fireEvent.keyDown(maxCell, { key: "ArrowRight" }); + expect(document.activeElement).toBe(maxCell); + }, + ); + + it("turns the toggle off with a type change to boolean, as one undo step", async () => { let latest: AdHocScenarioState | undefined; render( { @@ -1519,7 +1565,7 @@ describe("sweep selection", () => { ); expect( screen - .getByRole("button", { name: "Sweep altitude" }) + .getByRole("button", { name: `${word} altitude` }) .getAttribute("aria-pressed"), ).toBe("true"); @@ -1527,9 +1573,11 @@ describe("sweep selection", () => { fireEvent.click(await screen.findByRole("option", { name: "Boolean" })); await waitFor(() => expect(latest?.variables[0]?.type).toBe("boolean")); expect(latest?.variables[0]?.optimize).toBe(null); - expect(screen.queryByRole("button", { name: "Sweep altitude" })).toBe(null); + expect(screen.queryByRole("button", { name: `${word} altitude` })).toBe( + null, + ); - // One step: undo restores the type and the Sweep together. + // One step: undo restores the type and the selection together. fireEvent.keyDown( screen.getByRole("button", { name: "Name of variable 1 (Top-level variables)", @@ -1544,17 +1592,17 @@ describe("sweep selection", () => { }); }); - it("keeps a Sweep that arrived on a boolean clearable", () => { + it("keeps a toggle that arrived on a boolean clearable", () => { let latest: AdHocScenarioState | undefined; render( { />, ); - // Booleans offer no Sweep, but one already there shows its toggle so + // Booleans offer no toggle, but one already there shows its toggle so // the definition can be brought back to something that runs. - const toggle = screen.getByRole("button", { name: "Sweep armed" }); + const toggle = screen.getByRole("button", { name: `${word} armed` }); expect(toggle.getAttribute("aria-pressed")).toBe("true"); fireEvent.click(toggle); expect(latest?.variables[1]?.optimize).toBe(null); - expect(screen.queryByRole("button", { name: "Sweep armed" })).toBe(null); + expect(screen.queryByRole("button", { name: `${word} armed` })).toBe(null); + }); + + it("names the token gutter's interval kind after the word", async () => { + render(); + fireEvent.click( + screen.getByRole("button", { name: "Add a token row (pressure)" }), + ); + + fireEvent.click(screen.getByRole("button", { name: "Row 1 kind" })); + expect( + await screen.findByRole("menuitemradio", { name: gutter }), + ).toBeTruthy(); + expect( + screen.queryByRole("menuitemradio", { + name: gutter === "Swept count" ? "Optimized count" : "Swept count", + }), + ).toBe(null); }); }); diff --git a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/ad-hoc-scenario-form.tsx b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/ad-hoc-scenario-form.tsx index 7e210c466c2..4625625f358 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/ad-hoc-scenario-form.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/ad-hoc-scenario-form.tsx @@ -8,14 +8,16 @@ * (`initialState.type: "adhoc"`). The generated scenario is never persisted; * this component only edits `AdHocScenarioState`. * - * It is the one scenario form. Quick Simulation renders it with `selection` - * "none"; experiment creation renders it with "sweep", which grows a Sweep - * toggle on every numeric value slot — each selection becomes a swept - * parameter of the experiment; scenario creation and editing render it with - * "expose", which offers a "Scenario Parameter" toggle on each top-level - * Variable — the saved scenario exposes those Variables as its tunable - * parameters. Simulation Settings and the experiment drawer also reuse it - * with `mode="run"` to show a saved scenario for a run. + * It is the one scenario form, with three consumers. Quick Simulation + * renders it with `selection` "none"; experiment creation renders it with + * "optimize" when the in-browser optimizer can drive the sweep and "sweep" + * otherwise — either word grows an interval toggle (Min and Max) on every + * numeric value slot, and each selection becomes a swept parameter of the + * experiment; scenario creation and editing render it with "expose", which + * offers a "Scenario Parameter" toggle on each top-level Variable — the + * saved scenario exposes those Variables as its tunable parameters. + * Simulation Settings and the experiment drawer also reuse it with + * `mode="run"` to show a saved scenario for a run. * * The form runs its own ad-hoc LSP session, so every expression is * type-checked live: open editors are Monaco documents with inline markers, diff --git a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/form-context.ts b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/form-context.ts index 51a90407c54..63f0c046e85 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/form-context.ts +++ b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/form-context.ts @@ -4,7 +4,7 @@ * * - the whole form state and the net context, for attribution labels and * place totals; - * - whether Optimize controls exist at all in this consumer; + * - what the selection toggle means in this consumer, if it exists at all; * - the LSP session the slots' Monaco documents belong to; * - per-slot error lookup, joining synthesis errors with LSP diagnostics * through `adHocSlotKey`. @@ -25,10 +25,11 @@ import type { } from "@hashintel/petrinaut-core"; /** - * What selecting a value means in this consumer: nothing, marking a value - * for the optimizer (with bounds), sweeping a value over an interval in an - * experiment (bounds only, no scale or step), or exposing a top-level - * Variable as a scenario parameter the saved scenario's users can tune. + * What selecting a value means in this consumer: nothing; marking a numeric + * value's interval for an experiment — the toggle reads "Optimize" when the + * in-browser optimizer will search it, "Sweep" when the sweep waits for a + * selection (Min and Max, nothing else); or exposing a top-level Variable as + * a scenario parameter the saved scenario's users can tune. */ export type AdHocFormSelection = "none" | "optimize" | "sweep" | "expose"; @@ -44,30 +45,27 @@ export const adHocSelectionText = (selection: AdHocFormSelection): string => { } }; +/** Whether the selection marks an interval on a value slot ("optimize" or "sweep"). */ +export const adHocIntervalSelection = ( + selection: AdHocFormSelection, +): selection is "optimize" | "sweep" => + selection === "optimize" || selection === "sweep"; + /** - * Whether a value of this domain can carry a selection in this consumer. - * Optimization tries booleans as a two-way choice; a sweep is an interval, - * so it needs a number. Text values never carry a selection: synthesis - * reports them at the slot when a stale toggle is on. + * Whether a value of this domain can carry a selection in this consumer. An + * interval needs a number: real, integer, ratio or count. Text and boolean + * values never carry one: synthesis reports them at the slot when a stale + * toggle is on. */ export const adHocSelectionApplies = ( selection: AdHocFormSelection, kind: "real" | "integer" | "boolean" | "ratio" | "string" | "uuid" | "count", -): boolean => { - switch (selection) { - case "optimize": - return true; - case "sweep": - return ( - kind === "real" || - kind === "integer" || - kind === "ratio" || - kind === "count" - ); - default: - return false; - } -}; +): boolean => + adHocIntervalSelection(selection) && + (kind === "real" || + kind === "integer" || + kind === "ratio" || + kind === "count"); /** * What the form lets the user change. "author" is the full editor. "run" diff --git a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/parameter-rows.tsx b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/parameter-rows.tsx index 0255e5b6312..388245a8923 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/parameter-rows.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/parameter-rows.tsx @@ -12,6 +12,7 @@ import { emptyAdHocValue } from "@hashintel/petrinaut-core"; import { useFocusGrid } from "../../worksheet/use-focus-grid"; import { AdHocFormContext, + adHocIntervalSelection, adHocSelectionApplies, adHocSelectionText, } from "./form-context"; @@ -116,13 +117,12 @@ export const ParameterRows: React.FC = ({ entries }) => { onTriggerKeyDown={onKeyDown(parameterIndex, 0)} /> - {/* Optimize and Sweep only, and never in run mode: a net + {/* Interval selections only, and never in run mode: a net parameter cannot be exposed (the Scenario Parameter toggle belongs to Variables alone), and a run's computed parameters are a preview the host derives, not slots to select. */} - {(selection === "optimize" || selection === "sweep") && - mode !== "run" ? ( + {adHocIntervalSelection(selection) && mode !== "run" ? ( = ({ const { highlight, selection, setFocusedValue, dispatch } = use(AdHocFormContext); const { register, onKeyDown, attach } = useFocusGrid(); - const withToggles = selection === "sweep"; + const withToggles = adHocIntervalSelection(selection); const exposed = variables.flatMap((variable, index) => variable.exposed ? [{ variable, index }] : [], diff --git a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/token-table.tsx b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/token-table.tsx index 417999f907a..ea63013f8b1 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/token-table.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/token-table.tsx @@ -41,7 +41,7 @@ import { } from "../../worksheet/use-focus-stops"; import { useRowSelection } from "../../worksheet/use-row-selection"; import { useSelectFirstActivation } from "../../worksheet/use-select-first"; -import { AdHocFormContext } from "./form-context"; +import { AdHocFormContext, adHocIntervalSelection } from "./form-context"; import { FormSpreadsheet } from "./spreadsheet/form-spreadsheet"; import { cellStyle, @@ -398,7 +398,7 @@ export const TokenTable: React.FC = ({ label: "Dynamic count", checked: kind === "dynamic", }, - ...(selection === "optimize" || selection === "sweep" + ...(adHocIntervalSelection(selection) ? [ { id: "optimized", diff --git a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/value-editor.tsx b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/value-editor.tsx index 09d04580d6f..e5f926073e6 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/value-editor.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/value-editor.tsx @@ -2,13 +2,13 @@ * The editor every value slot opens, in place: the trigger is the cell, and * opening replaces it with a Monaco single-line editor at exactly the cell's * position — no chrome, no padding. The slot's attribution path (`Space › - * item 0 › x`) floats quietly above the cell; the Optimize control floats - * below it, and turning Optimize on replaces the expression editor with a - * small bounds spreadsheet in the same slot: labeled Min/Max/Scale cells - * (Step where definable), each a square expression cell with the form's - * selection model — focus selects, Enter edits in a frameless Monaco, - * Enter or Escape returns to the cell. Toggling destroys nothing: the core - * transition retains bounds and expression alike. + * item 0 › x`) floats quietly above the cell; the interval toggle (Optimize + * or Sweep) floats below it, and turning it on replaces the expression + * editor with a small bounds spreadsheet in the same slot: labeled Min and + * Max cells, each a square expression cell with the form's selection model + * — focus selects, Enter edits in a frameless Monaco, Enter or Escape + * returns to the cell. Toggling destroys nothing: the core transition + * retains bounds and expression alike. * * A closed slot still shows its problems: the trigger underlines in red and * carries the first synthesis error or LSP diagnostic as its tooltip. @@ -27,7 +27,6 @@ import { import { PortalContainerContext, - Select, usePortalContainerRef, } from "@hashintel/ds-components"; import { css, cx } from "@hashintel/ds-helpers/css"; @@ -47,7 +46,6 @@ import { import { cellButtonStyle, cellErrorUnderlineStyle, - cellSelectStyle, dependencyHighlightStyle, } from "./spreadsheet/form-table"; import { OptimizeToggle } from "./spreadsheet/optimize-toggle"; @@ -99,7 +97,7 @@ const overlayStyle = css({ // A portal container is often a full-bleed layer that lets presses through // to the app beneath it (`pointer-events: none`), so a surface portaled // into one has to take its own presses back. Without this the whole slab - // is click-through: Min, Max, Step and Scale never see the press, and the + // is click-through: Min and Max never see the press, and the // press lands on whatever sits under the slab — which the dismiss handler // then reads as a click outside and closes the slab. pointerEvents: "auto", @@ -180,10 +178,6 @@ const boundsColumnStyle = css({ _first: { borderLeft: "none" }, }); -const boundsScaleColumnStyle = css({ - flex: "[0 0 96px]", -}); - const boundsLabelStyle = css({ display: "flex", alignItems: "center", @@ -219,16 +213,6 @@ const fieldErrorStyle = css({ paddingX: "1", }); -const booleanNoteStyle = css({ - display: "flex", - alignItems: "center", - height: "[28px]", - paddingX: "2", - fontSize: "[10px]", - color: "purple.s110", - whiteSpace: "nowrap", -}); - const expressionRowStyle = css({ display: "flex", width: "[100%]", @@ -246,11 +230,10 @@ export interface ValueEditorProps { target: AdHocValueTarget; /** * The slot's value domain — one fact, everything else derives from it: - * booleans optimize as a true/false choice with no bounds and step with - * Up/Down; integers get a Step bound; counts are integers with an implied - * step of 1 (no Step field); ratios step by 0.1 within 0 and 1; strings - * and UUIDs don't arrow-step. The default placeholder is the domain's - * neutral value. + * numbers carry an interval toggle, booleans and text never do; booleans + * step with Up/Down as true/false; ratios step by 0.1 within 0 and 1; + * strings and UUIDs don't arrow-step. The default placeholder is the + * domain's neutral value. */ kind: ColorElementType | "count" | "ratio"; /** @@ -302,6 +285,9 @@ const MIN_OVERLAY_WIDTH = 220; /** The wider minimum when the bounds spreadsheet is showing. */ const MIN_BOUNDS_OVERLAY_WIDTH = 340; +/** The interval's two bounds, in the order the cells sit and the arrows walk. */ +type BoundKey = "min" | "max"; + interface BoundCellProps { /** The visible field label ("Min"); the accessible name appends the value's path. */ label: string; @@ -444,7 +430,6 @@ export const ValueEditor: React.FC = ({ dispatch, overlayKeyDown, } = use(AdHocFormContext); - const booleanDomain = kind === "boolean"; const triggerPlaceholder = placeholder ?? (kind === "count" ? "0" : adHocNeutralExpression(kind)); const label = labelFor(target); @@ -470,13 +455,11 @@ export const ValueEditor: React.FC = ({ // Which bound cell holds an open expression editor. Escape peels one // layer: it leaves the bound edit first, and closes the slab from a // selected cell. - const [editingBound, setEditingBound] = useState< - "min" | "max" | "step" | null - >(null); + const [editingBound, setEditingBound] = useState(null); if (!open && editingBound !== null) { setEditingBound(null); } - const boundRefs = useRef(new Map()); + const boundRefs = useRef(new Map()); // Opening the slab selects the Min cell once, and the guard is the element // it selected rather than a flag. The ref callback is a fresh closure on // every render, so React detaches and re-attaches it on every commit, and a @@ -487,13 +470,11 @@ export const ValueEditor: React.FC = ({ // so a reopened slab always presents a new button to select. const minSelectedElementRef = useRef(null); - // Value slots carry a toggle in optimize mode, and in sweep mode when the - // value is a number; expose mode marks whole top-level Variables (in their - // own rows), never value slots. + // Value slots carry the interval toggle (Optimize or Sweep) when the value + // is a number; expose mode marks whole top-level Variables (in their own + // rows), never value slots. const selectable = adHocSelectionApplies(selection, kind); const optimized = selectable && value.optimize !== null; - // A sweep declares an interval and nothing else: no step, no scale. - const sweeping = selection === "sweep"; // Closing the slab commits the expression, and a valid one is re-printed // canonically (worker-side, from the lowered tree) — normalized spacing, // minimal parentheses, literals preserved. The nonce discards a response @@ -544,7 +525,7 @@ export const ValueEditor: React.FC = ({ } }, [open, editorId]); - const endBoundEdit = (key: "min" | "max" | "step") => { + const endBoundEdit = (key: BoundKey) => { setEditingBound(null); setTimeout(() => boundRefs.current.get(key)?.focus(), 0); }; @@ -705,8 +686,8 @@ export const ValueEditor: React.FC = ({ ) { return; } - // An open Ark layer inside the slab (the Scale select) is above the - // slab: its own dismissal handles this Escape. + // An open Ark layer inside the slab is above the slab: its own + // dismissal handles this Escape. if ( overlay?.querySelector('[data-part="trigger"][aria-expanded="true"]') ) { @@ -775,9 +756,7 @@ export const ValueEditor: React.FC = ({ const text = display ?? (optimized - ? booleanDomain - ? "true / false" - : `${value.optimize!.min} … ${value.optimize!.max}` + ? `${value.optimize!.min} … ${value.optimize!.max}` : value.expression || triggerPlaceholder); const expressionSlot = { target, part: "expression" as const }; @@ -791,25 +770,19 @@ export const ValueEditor: React.FC = ({ const error = boundsError ?? errorFor(expressionSlot); const showTriggerError = error !== undefined && !open; - const boundFields: { key: "min" | "max" | "step"; fieldLabel: string }[] = [ + const boundFields: { key: BoundKey; fieldLabel: string }[] = [ { key: "min", fieldLabel: "Min" }, { key: "max", fieldLabel: "Max" }, - ...(kind === "integer" && !sweeping - ? [{ key: "step" as const, fieldLabel: "Step" }] - : []), ]; - const boundOrder = [ - ...boundFields.map((field) => field.key), - ...(sweeping ? [] : ["scale"]), - ]; - const navigateBound = (from: string, delta: -1 | 1) => { + const boundOrder = boundFields.map((field) => field.key); + const navigateBound = (from: BoundKey, delta: -1 | 1) => { const next = boundOrder[boundOrder.indexOf(from) + delta]; if (next) { boundRefs.current.get(next)?.focus(); } }; const registerBound = - (key: string, selectOnAttach = false) => + (key: BoundKey, selectOnAttach = false) => (element: HTMLButtonElement | null) => { if (element) { boundRefs.current.set(key, element); @@ -821,10 +794,7 @@ export const ValueEditor: React.FC = ({ boundRefs.current.delete(key); } }; - const boundValue = (key: "min" | "max" | "step"): string => - key === "step" - ? (value.optimize?.step ?? "1") - : (value.optimize?.[key] ?? ""); + const boundValue = (key: BoundKey): string => value.optimize?.[key] ?? ""; return ( <> @@ -926,19 +896,16 @@ export const ValueEditor: React.FC = ({ left: rect.left, width: Math.max( rect.width, - optimized && !booleanDomain - ? MIN_BOUNDS_OVERLAY_WIDTH - : MIN_OVERLAY_WIDTH, + optimized ? MIN_BOUNDS_OVERLAY_WIDTH : MIN_OVERLAY_WIDTH, ), minHeight: rect.height, }} > - {/* A layer opened from inside the slab — the Scale list — portals - into the slab itself, not into the app container it would - otherwise share with the slab as a sibling. As a descendant it - inherits the slab's pointer events, the dismiss handler's - containment test accepts a press on it, and choosing a value - commits instead of closing the slab. */} + {/* A layer opened from inside the slab portals into the slab + itself, not into the app container it would otherwise share + with the slab as a sibling. As a descendant it inherits the + slab's pointer events and the dismiss handler's containment + test accepts a press on it. */}
{label}
= ({ !selectable && bodyBottomEdgeStyle, )} > - {optimized && booleanDomain ? ( -
- The optimizer tries true and false. -
- ) : optimized ? ( + {optimized ? (
{boundFields.map((field, fieldIndex) => (
@@ -988,61 +951,6 @@ export const ValueEditor: React.FC = ({ />
))} - {sweeping ? null : ( -
{ - const trigger = - element?.querySelector( - "[data-part='trigger']", - ) ?? null; - if (trigger) { - boundRefs.current.set("scale", trigger); - } else { - boundRefs.current.delete("scale"); - } - }} - className={cx( - boundsColumnStyle, - boundsScaleColumnStyle, - cellSelectStyle, - )} - onKeyDownCapture={(event) => { - const trigger = event.currentTarget.querySelector( - "[data-part='trigger']", - ); - if ( - trigger?.getAttribute("aria-expanded") === "true" - ) { - return; - } - if (event.key === "ArrowLeft") { - event.preventDefault(); - event.stopPropagation(); - navigateBound("scale", -1); - } - }} - > -
Scale
- onChange(event.target.value)}> + ); + const { SegmentedControl } = await import("../shared/ds-control-stubs"); - return { ...actual, Drawer, Select }; + return { ...actual, Drawer, Select, SegmentedControl }; }); /** @@ -165,18 +187,73 @@ function makeLanguageClient(): LanguageClientContextValue { }; } +/** What the real provider resolves: the record it created, selected by nobody yet. */ +const createdExperiment = (id: string): Promise => + Promise.resolve(makeExperiment(0, { id })); + +/** The SIR net with one scenario exposing a numeric parameter, so the form + * renders a parameter row. */ +const sweptScenario: Scenario = { + id: "scenario-swept", + name: "Swept", + scenarioParameters: [ + { identifier: "transmission_rate", type: "real", default: 0.3 }, + ], + parameterOverrides: {}, + initialState: { type: "per_place", content: {} }, +}; + +/** + * What the real provider resolves for a sweep of the swept scenario: the + * record keeps the scenario it compiled, its axis and the input's metrics, + * which is all a study needs to start from it. + */ +const createdSweep = ( + input: CreateExperimentInput, + id: string, +): Promise => + Promise.resolve( + makeExperiment(0, { + id, + name: input.name, + scenarioId: sweptScenario.id, + scenario: sweptScenario, + seed: input.seed, + dt: input.dt, + maxTime: input.maxTime, + metricSpecs: input.metricSpecs, + parameterAxes: [ + { + identifier: "transmission_rate", + min: 0.15, + max: 0.45, + stepCount: 50, + integer: false, + }, + ], + }), + ); + const TestProviders = ({ webGpuEnabled, enableParameterSweeps = false, sdcpnContextValue = sirSdcpnContextValue, - createExperiment = () => Promise.resolve("experiment-test"), + createExperiment = () => createdExperiment("experiment-test"), + removeExperiment = () => {}, + setSelectedExperimentId = () => {}, + createOptimization = () => Promise.resolve("study-test"), languageClient, optimizationSource = null, }: { webGpuEnabled: boolean; enableParameterSweeps?: boolean; sdcpnContextValue?: SDCPNContextValue; - createExperiment?: (input: CreateExperimentInput) => Promise; + createExperiment?: ( + input: CreateExperimentInput, + ) => Promise; + removeExperiment?: (experimentId: string) => void; + setSelectedExperimentId?: (experimentId: string | null) => void; + createOptimization?: OptimizationsContextValue["createOptimization"]; languageClient?: LanguageClientContextValue; /** The host's optimizer; the In-browser optimization setting follows it on. */ optimizationSource?: PetrinautOptimizationSource | null; @@ -220,22 +297,31 @@ const TestProviders = ({ {}, + setSelectedExperimentId, createExperiment, cancelExperiment: () => {}, - removeExperiment: () => {}, + removeExperiment, setSweepSelection: () => {}, navigateSweep: () => Promise.resolve(null), }} > - - - -
- {}} /> - - - + {}, + removeOptimization: () => {}, + }} + > + + + +
+ {}} /> + + + + @@ -264,17 +350,6 @@ const colouredContextValue: SDCPNContextValue = { extensions: DEFAULT_PETRINAUT_EXTENSIONS, }; -/** The SIR net with one scenario exposing a numeric parameter, so the form - * renders a parameter row. */ -const sweptScenario: Scenario = { - id: "scenario-swept", - name: "Swept", - scenarioParameters: [ - { identifier: "transmission_rate", type: "real", default: 0.3 }, - ], - parameterOverrides: {}, - initialState: { type: "per_place", content: {} }, -}; const sweptContextValue: SDCPNContextValue = { ...sirSdcpnContextValue, petriNetDefinition: { @@ -283,6 +358,40 @@ const sweptContextValue: SDCPNContextValue = { }, }; +/** The swept scenario with a second numeric parameter, so two toggles can flip. */ +const twoParametersContextValue: SDCPNContextValue = { + ...sweptContextValue, + petriNetDefinition: { + ...sweptContextValue.petriNetDefinition, + scenarios: [ + { + ...sweptScenario, + scenarioParameters: [ + ...sweptScenario.scenarioParameters, + { identifier: "recovery_days", type: "integer", default: 7 }, + ], + }, + ], + }, +}; + +/** A connected source that never runs: the drawer only asks what kind it is. */ +const connectedSource: PetrinautConnectedOptimization = { + kind: "connected", + connect: () => { + throw new Error("The test's optimizer is never connected"); + }, +}; + +/** A remote capability: studies run elsewhere, so nothing here can evaluate a sweep. */ +const remoteSource: PetrinautOptimization = { + createOptimizationRun: () => Promise.resolve({ runId: "run-test" }), + async *attachOptimizationRun() { + yield { type: "started", requestedTrials: 1, seq: 1 }; + }, + cancelOptimizationRun: () => Promise.resolve(), +}; + /** The SIR net with one saved scenario exposing nothing, so the run form has no rows. */ const unparameterizedContextValue: SDCPNContextValue = { ...sirSdcpnContextValue, @@ -435,12 +544,30 @@ describe("CreateExperimentDrawer parameter sweeps setting", () => { expect(screen.queryByRole("button", { name: /^Sweep / })).toBeNull(); }); - it("offers a Sweep toggle per numeric parameter when the setting is on", async () => { + it("offers a Sweep toggle per numeric parameter when the setting is on and no optimizer is wired", async () => { + render( + , + ); + + expect( + await screen.findByRole("button", { name: "Sweep transmission_rate" }), + ).toBeInstanceOf(HTMLElement); + expect( + screen.queryByRole("button", { name: "Optimize transmission_rate" }), + ).toBeNull(); + }); + + it("reads Sweep on the toggle for a remote-only optimizer, which cannot drive a sweep", async () => { render( , ); @@ -449,6 +576,48 @@ describe("CreateExperimentDrawer parameter sweeps setting", () => { ).toBeInstanceOf(HTMLElement); }); + it("reads Optimize on the toggle when the in-browser optimizer can drive the sweep", async () => { + render( + , + ); + + expect( + await screen.findByRole("button", { name: "Optimize transmission_rate" }), + ).toBeInstanceOf(HTMLElement); + expect( + screen.queryByRole("button", { name: "Sweep transmission_rate" }), + ).toBeNull(); + }); + + it("keeps the word when a second toggle flips", async () => { + render( + , + ); + + // The word follows the settings and the source, never the toggle count: + // the first toggle relabels nothing. + fireEvent.click( + await screen.findByRole("button", { name: "Optimize transmission_rate" }), + ); + fireEvent.click( + screen.getByRole("button", { name: "Optimize recovery_days" }), + ); + expect(screen.getAllByRole("button", { name: /^Optimize / })).toHaveLength( + 2, + ); + expect(screen.queryByRole("button", { name: /^Sweep / })).toBeNull(); + }); + it("tells a saved scenario without parameters apart from an empty form", async () => { render( { ).toBeTruthy(); }); - it("offers no Sweep toggle on the ad-hoc form while sweeps are off", async () => { + it("reads Sweep on the ad-hoc form's toggle for a remote-only optimizer", async () => { + render( + , + ); + + expect(await screen.findByLabelText("Sweep Rate")).toBeInstanceOf( + HTMLElement, + ); + }); + + it("reads Optimize on the ad-hoc form's toggle when the in-browser optimizer can drive the sweep", async () => { + render( + , + ); + + expect(await screen.findByLabelText("Optimize Rate")).toBeInstanceOf( + HTMLElement, + ); + expect(screen.queryByLabelText("Sweep Rate")).toBeNull(); + }); + + it("offers no interval toggle on the ad-hoc form while sweeps are off", async () => { render( { await screen.findByText("Rate"); expect(screen.queryByRole("button", { name: /^Sweep / })).toBeNull(); + expect(screen.queryByRole("button", { name: /^Optimize / })).toBeNull(); }); it("hands the form's draft to the experiment with sweeps off, never as a sweep", async () => { const createExperiment = vi.fn((_input: CreateExperimentInput) => - Promise.resolve("experiment-adhoc"), + createdExperiment("experiment-adhoc"), ); render( { }); }); -/** A connected source that never runs: the drawer only asks what kind it is. */ -const connectedSource: PetrinautConnectedOptimization = { - kind: "connected", - connect: () => { - throw new Error("The test's optimizer is never connected"); - }, -}; - -/** A remote capability: studies run elsewhere, so nothing here can evaluate a sweep. */ -const remoteSource: PetrinautOptimization = { - createOptimizationRun: () => Promise.resolve({ runId: "run-test" }), - async *attachOptimizationRun() { - yield { type: "started", requestedTrials: 1, seq: 1 }; - }, - cancelOptimizationRun: () => Promise.resolve(), -}; - /** The language client with constraint lowering that succeeds, keeping the source's name. */ const makeLoweringLanguageClient = (): LanguageClientContextValue => ({ ...makeLanguageClient(), @@ -619,13 +803,36 @@ const firstConstraintSession = ( return params; }; -/** Flips a scenario parameter's Sweep pill in the run form. */ -const flipSweep = (identifier: string) => { - fireEvent.click(screen.getByRole("button", { name: `Sweep ${identifier}` })); +/** Flips a scenario parameter's interval pill in the run form, under the word the drawer reads. */ +const flipInterval = (identifier: string, word: "Optimize" | "Sweep") => { + fireEvent.click( + screen.getByRole("button", { name: `${word} ${identifier}` }), + ); }; +/** + * Matches a footer button by its word alone: the ds Button pads a prefixed + * label with a zero-width space, and the pills carry a name after theirs. + */ +const footerWord = + (word: string) => + (name: string): boolean => + name.replaceAll("\u200B", "").trim() === word; + +const footerButton = (word: string) => + screen.getByRole("button", { name: footerWord(word) }) as HTMLButtonElement; + +const findFooterButton = (word: string) => + screen.findByRole("button", { name: footerWord(word) }); + +/** The footer's submit button under any of its three words. */ const submitButton = () => - screen.getByRole("button", { name: /Create sweep|Run/ }) as HTMLButtonElement; + screen.getByRole("button", { + name: (name) => + ["Optimize", "Create sweep", "Run"].some((word) => + footerWord(word)(name), + ), + }) as HTMLButtonElement; /** A constrained sweep's drawer: sweeps on, connected optimizer, the swept scenario's toggle flipped. */ const openConstrainedSweep = async ( @@ -641,7 +848,7 @@ const openConstrainedSweep = async ( {...props} />, ); - flipSweep("transmission_rate"); + flipInterval("transmission_rate", "Optimize"); expect(await screen.findByText("Constraints")).toBeTruthy(); return rendered; }; @@ -660,7 +867,7 @@ describe("CreateExperimentDrawer constraints", () => { expect(screen.queryByText("Constraints")).toBeNull(); }); - it("offers no Constraints section until a Sweep toggle flips", async () => { + it("offers no Constraints section until an Optimize toggle flips", async () => { render( { ); expect(screen.queryByText("Constraints")).toBeNull(); - flipSweep("transmission_rate"); + flipInterval("transmission_rate", "Optimize"); expect(await screen.findByText("Constraints")).toBeTruthy(); expect( screen.getByText( "No constraints — the optimizer may try any point of the sweep.", ), ).toBeTruthy(); - expect(screen.getByText("Create sweep")).toBeTruthy(); + expect(footerButton("Optimize")).toBeTruthy(); // Flipping it back hides the section with the sweep. - flipSweep("transmission_rate"); + flipInterval("transmission_rate", "Optimize"); await waitFor(() => { expect(screen.queryByText("Constraints")).toBeNull(); }); @@ -696,12 +903,12 @@ describe("CreateExperimentDrawer constraints", () => { optimizationSource={remoteSource} />, ); - flipSweep("transmission_rate"); + flipInterval("transmission_rate", "Sweep"); expect(await screen.findByText("Create sweep")).toBeTruthy(); expect(screen.queryByText("Constraints")).toBeNull(); }); - it("offers no Constraints section for an ad-hoc sweep, which no study can drive", async () => { + it("offers no Constraints section for an ad-hoc sweep, whose generated names are not authorable", async () => { render( { optimizationSource={connectedSource} />, ); - // The ad-hoc form's Sweep toggle is a button of its own. - fireEvent.click(await screen.findByLabelText("Sweep Rate")); - expect(await screen.findByText("Create sweep")).toBeTruthy(); + // The ad-hoc form's Optimize toggle is a button of its own. + fireEvent.click(await screen.findByLabelText("Optimize Rate")); + expect(await findFooterButton("Optimize")).toBeTruthy(); expect(screen.queryByText("Constraints")).toBeNull(); }); @@ -789,7 +996,7 @@ describe("CreateExperimentDrawer constraints", () => { expect(screen.queryByLabelText("Pass threshold (percent)")).toBeNull(); }); - it("shows a row's error in its reserved line and blocks Create sweep naming the row", async () => { + it("shows a row's error in its reserved line and blocks Optimize naming the row", async () => { const languageClient = makeLoweringLanguageClient(); const { rerender } = await openConstrainedSweep({ languageClient }); fireEvent.click(screen.getByRole("button", { name: /Add metric/ })); @@ -886,8 +1093,8 @@ describe("CreateExperimentDrawer constraints", () => { it("lowers the rows under their labels and hands them to the experiment without a policy at the default threshold", async () => { const languageClient = makeLoweringLanguageClient(); - const createExperiment = vi.fn((_input: CreateExperimentInput) => - Promise.resolve("experiment-constrained"), + const createExperiment = vi.fn((input: CreateExperimentInput) => + createdSweep(input, "experiment-constrained"), ); await openConstrainedSweep({ languageClient, createExperiment }); fireEvent.click(screen.getByRole("button", { name: /Add metric/ })); @@ -906,7 +1113,7 @@ describe("CreateExperimentDrawer constraints", () => { { target: { value: "return state.places.Infected.count <= 900;" } }, ); - fireEvent.click(screen.getByRole("button", { name: /Create sweep/ })); + fireEvent.click(footerButton("Optimize")); await waitFor(() => expect(createExperiment).toHaveBeenCalledOnce()); expect( @@ -950,8 +1157,8 @@ describe("CreateExperimentDrawer constraints", () => { }); it("writes a changed pass threshold to the experiment as alpha", async () => { - const createExperiment = vi.fn((_input: CreateExperimentInput) => - Promise.resolve("experiment-threshold"), + const createExperiment = vi.fn((input: CreateExperimentInput) => + createdSweep(input, "experiment-threshold"), ); await openConstrainedSweep({ createExperiment }); fireEvent.click(screen.getByRole("button", { name: /Add metric/ })); @@ -966,7 +1173,7 @@ describe("CreateExperimentDrawer constraints", () => { target: { value: "90" }, }); - fireEvent.click(screen.getByRole("button", { name: /Create sweep/ })); + fireEvent.click(footerButton("Optimize")); await waitFor(() => expect(createExperiment).toHaveBeenCalledOnce()); expect(createExperiment.mock.calls[0]![0].constraintPolicy).toEqual({ alpha: 0.1, @@ -975,8 +1182,8 @@ describe("CreateExperimentDrawer constraints", () => { it("ignores blank rows at submission", async () => { const languageClient = makeLoweringLanguageClient(); - const createExperiment = vi.fn((_input: CreateExperimentInput) => - Promise.resolve("experiment-blank"), + const createExperiment = vi.fn((input: CreateExperimentInput) => + createdSweep(input, "experiment-blank"), ); await openConstrainedSweep({ languageClient, createExperiment }); fireEvent.click(screen.getByRole("button", { name: /Add metric/ })); @@ -987,7 +1194,7 @@ describe("CreateExperimentDrawer constraints", () => { screen.getByRole("button", { name: "Add state constraint" }), ); - fireEvent.click(screen.getByRole("button", { name: /Create sweep/ })); + fireEvent.click(footerButton("Optimize")); await waitFor(() => expect(createExperiment).toHaveBeenCalledOnce()); expect(languageClient.requestConstraint).not.toHaveBeenCalled(); expect(createExperiment.mock.calls[0]![0].constraints).toEqual([]); @@ -1012,7 +1219,7 @@ describe("CreateExperimentDrawer constraints", () => { ), }; const createExperiment = vi.fn((_input: CreateExperimentInput) => - Promise.resolve("never"), + createdExperiment("never"), ); await openConstrainedSweep({ languageClient, createExperiment }); fireEvent.click(screen.getByRole("button", { name: /Add metric/ })); @@ -1024,7 +1231,7 @@ describe("CreateExperimentDrawer constraints", () => { { target: { value: "scenario.transmission_rate" } }, ); - fireEvent.click(screen.getByRole("button", { name: /Create sweep/ })); + fireEvent.click(footerButton("Optimize")); expect( await screen.findByText( "Parameter constraint 1: Type 'number' is not assignable to type 'boolean'.", @@ -1048,7 +1255,7 @@ describe("CreateExperimentDrawer constraints", () => { }); // The other scenario's sweep has to be turned on again, as its inputs reset. expect(screen.queryByText("Constraints")).toBeNull(); - flipSweep("recovery_days"); + flipInterval("recovery_days", "Optimize"); expect(await screen.findByText("Constraints")).toBeTruthy(); expect( screen.queryByRole("group", { name: "Parameter constraint 1" }), @@ -1091,3 +1298,325 @@ describe("CreateExperimentDrawer constraints", () => { }); }); }); + +/** The objective section's metric select. */ +const objectiveMetricSelect = () => + screen.getByLabelText("Metric to optimize") as HTMLSelectElement; + +const stepsInput = () => + screen.getByLabelText("Optimization steps") as HTMLInputElement; + +/** The default step count's helper line, present exactly while the section is. */ +const objectiveHelper = () => + screen.queryByText( + "30 steps · 8 runs each — the best point then refines to your run budget", + ); + +describe("CreateExperimentDrawer objective", () => { + it("offers no Objective section for a plain experiment", async () => { + render( + , + ); + await screen.findByText("transmission_rate"); + expect(screen.queryByText("Objective")).toBeNull(); + expect(footerButton("Run")).toBeTruthy(); + }); + + it("offers no Objective section under the Sweep word, with or without a remote optimizer", async () => { + const { unmount } = render( + , + ); + flipInterval("transmission_rate", "Sweep"); + expect(await findFooterButton("Create sweep")).toBeTruthy(); + expect(screen.queryByText("Objective")).toBeNull(); + unmount(); + + render( + , + ); + flipInterval("transmission_rate", "Sweep"); + expect(await findFooterButton("Create sweep")).toBeTruthy(); + expect(screen.queryByText("Objective")).toBeNull(); + }); + + it("adds the Objective and Constraints sections together at the first Optimize on a saved scenario", async () => { + render( + , + ); + await screen.findByRole("button", { name: "Optimize transmission_rate" }); + expect(screen.queryByText("Objective")).toBeNull(); + expect(footerButton("Run")).toBeTruthy(); + + flipInterval("transmission_rate", "Optimize"); + expect(await screen.findByText("Objective")).toBeTruthy(); + expect(screen.getByText("Constraints")).toBeTruthy(); + expect( + screen.getByText( + /transmission_rate optimized over its interval — the study picks the points/, + ), + ).toBeTruthy(); + expect(stepsInput().value).toBe("30"); + expect( + screen + .getByRole("button", { name: "Maximize" }) + .getAttribute("aria-pressed"), + ).toBe("true"); + expect(footerButton("Optimize")).toBeTruthy(); + expect( + screen.queryByRole("button", { name: footerWord("Create sweep") }), + ).toBeNull(); + + // Without a metric draft the section asks for one, and so does the footer. + expect(objectiveMetricSelect().disabled).toBe(true); + expect(objectiveMetricSelect().selectedOptions[0]?.text).toBe( + "Add a metric below", + ); + expect(screen.getByText("Add a metric to optimize")).toBeTruthy(); + expect(submitButton().disabled).toBe(true); + }); + + it("offers the Objective section for No scenario, and no Constraints", async () => { + render( + , + ); + fireEvent.click(await screen.findByLabelText("Optimize Rate")); + + expect(await screen.findByText("Objective")).toBeTruthy(); + expect(screen.queryByText("Constraints")).toBeNull(); + expect(screen.getByText(/Rate optimized over its interval/)).toBeTruthy(); + expect(footerButton("Optimize")).toBeTruthy(); + }); + + it("keeps the Objective section and the Optimize word while an ad-hoc bound is being edited", async () => { + render( + , + ); + fireEvent.click(await screen.findByLabelText("Optimize Rate")); + expect(await screen.findByText("Objective")).toBeTruthy(); + + // Blanking Max leaves the definition unable to synthesize: the form + // reports it at the slot and the summary line repeats it, the section + // and the word stay, the footer waits. + fireEvent.click(screen.getByRole("button", { name: "Rate" })); + fireEvent.click(await screen.findByRole("button", { name: "Max of Rate" })); + const slab = document.querySelector("[data-adhoc-slab]"); + if (!(slab instanceof HTMLElement)) { + throw new Error("expected the bounds slab to be open"); + } + fireEvent.change(within(slab).getByRole("textbox"), { + target: { value: "" }, + }); + + expect(screen.getByText("Objective")).toBeTruthy(); + expect(footerWord("Optimize")(submitButton().textContent)).toBe(true); + expect(submitButton().disabled).toBe(true); + expect( + screen.getAllByText(/^The maximum of "param__rate"/).length, + ).toBeGreaterThanOrEqual(2); + }); + + it("defaults the metric to the first draft and follows its removal", async () => { + await openConstrainedSweep(); + fireEvent.click(screen.getByRole("button", { name: /Add metric/ })); + fireEvent.click(screen.getByRole("button", { name: /Add metric/ })); + const labels = screen.getAllByLabelText("Metric label"); + fireEvent.change(labels[1]!, { target: { value: "Peak" } }); + + expect(objectiveMetricSelect().disabled).toBe(false); + expect(objectiveMetricSelect().selectedOptions[0]?.text).toBe( + "Susceptible tokens", + ); + expect(objectiveHelper()).toBeTruthy(); + expect(submitButton().disabled).toBe(false); + + const peak = [...objectiveMetricSelect().options].find( + (option) => option.text === "Peak", + )!; + fireEvent.change(objectiveMetricSelect(), { + target: { value: peak.value }, + }); + expect(objectiveMetricSelect().value).toBe(peak.value); + + // The chosen draft goes; the choice falls back to the first, with no stale id. + fireEvent.click( + screen.getAllByRole("button", { name: "Remove metric" })[1]!, + ); + expect(objectiveMetricSelect().selectedOptions[0]?.text).toBe( + "Susceptible tokens", + ); + expect(objectiveMetricSelect().value).not.toBe(peak.value); + }); + + it("reads Optimize in the footer and disables it with the step message at 1,001 steps and the budget message at 1,000", async () => { + await openConstrainedSweep(); + fireEvent.click(screen.getByRole("button", { name: /Add metric/ })); + expect(footerWord("Optimize")(submitButton().textContent)).toBe(true); + expect(submitButton().disabled).toBe(false); + + // The message lands on the section's reserved line and in the footer. + fireEvent.change(stepsInput(), { target: { value: "1001" } }); + expect(screen.getAllByText("Ask for 1 to 1,000 steps")).toHaveLength(2); + expect(submitButton().disabled).toBe(true); + + // The drawer's defaults: 180 / 0.1 = 1,800 simulation steps a run. + fireEvent.change(stepsInput(), { target: { value: "1000" } }); + expect( + screen.getAllByText( + "1,000 steps × 8 runs × 1,800 simulation steps is over the optimizer's 5,000,000 budget", + ), + ).toHaveLength(2); + expect(submitButton().disabled).toBe(true); + + fireEvent.change(stepsInput(), { target: { value: "12" } }); + expect(submitButton().disabled).toBe(false); + }); + + it("creates the experiment, starts its study from the record, then selects it", async () => { + const order: string[] = []; + const createExperiment = vi.fn((input: CreateExperimentInput) => { + order.push("createExperiment"); + return createdSweep(input, "experiment-objective"); + }); + const createOptimization = vi.fn< + OptimizationsContextValue["createOptimization"] + >(() => { + order.push("createOptimization"); + return Promise.resolve("study-objective"); + }); + const setSelectedExperimentId = vi.fn(() => { + order.push("setSelectedExperimentId"); + }); + await openConstrainedSweep({ + createExperiment, + createOptimization, + setSelectedExperimentId, + }); + fireEvent.click(screen.getByRole("button", { name: /Add metric/ })); + fireEvent.click(screen.getByRole("button", { name: "Minimize" })); + fireEvent.change(stepsInput(), { target: { value: "12" } }); + + fireEvent.click(footerButton("Optimize")); + await waitFor(() => + expect(setSelectedExperimentId).toHaveBeenCalledWith( + "experiment-objective", + ), + ); + + expect(order).toEqual([ + "createExperiment", + "createOptimization", + "setSelectedExperimentId", + ]); + const metricId = createExperiment.mock.calls[0]![0].metricSpecs[0]!.id; + const [manifest, options] = createOptimization.mock.calls[0]!; + expect(options).toEqual({ + sweep: { + experimentId: "experiment-objective", + axes: [ + { + identifier: "transmission_rate", + min: 0.15, + max: 0.45, + stepCount: 50, + integer: false, + }, + ], + metricId, + }, + }); + expect(manifest).toMatchObject({ + objective: { metricId, direction: "minimize" }, + execution: { dt: 0.1, maxTime: 180, seedsPerTrial: 8 }, + study: { trials: 12 }, + }); + }); + + it("keeps the drawer open with the reason, every field intact, and removes the experiment when the study cannot start", async () => { + const createExperiment = vi.fn((input: CreateExperimentInput) => + createdSweep(input, "experiment-orphan"), + ); + const removeExperiment = vi.fn(); + const setSelectedExperimentId = vi.fn(); + await openConstrainedSweep({ + createExperiment, + removeExperiment, + setSelectedExperimentId, + createOptimization: () => + Promise.reject( + new Error("A sweep can only be optimized in the browser"), + ), + }); + fireEvent.click(screen.getByRole("button", { name: /Add metric/ })); + fireEvent.change(screen.getByDisplayValue("Experiment"), { + target: { value: "Peak search" }, + }); + fireEvent.change(stepsInput(), { target: { value: "12" } }); + + fireEvent.click(footerButton("Optimize")); + expect( + await screen.findByText("A sweep can only be optimized in the browser"), + ).toBeTruthy(); + + expect(createExperiment).toHaveBeenCalledOnce(); + expect(removeExperiment).toHaveBeenCalledWith("experiment-orphan"); + expect(setSelectedExperimentId).not.toHaveBeenCalled(); + expect(screen.getByDisplayValue("Peak search")).toBeTruthy(); + expect(stepsInput().value).toBe("12"); + expect(screen.getByText("Objective")).toBeTruthy(); + expect(footerWord("Optimize")(submitButton().textContent)).toBe(true); + expect(submitButton().disabled).toBe(false); + }); + + it("starts nothing twice on a second click while submitting", async () => { + let resolveCreation: (experiment: ExperimentRecord) => void = () => {}; + const createExperiment = vi.fn( + (input: CreateExperimentInput) => + new Promise((resolve) => { + resolveCreation = (experiment) => { + void createdSweep(input, experiment.id).then(resolve); + }; + }), + ); + const createOptimization = vi.fn(() => Promise.resolve("study-once")); + await openConstrainedSweep({ createExperiment, createOptimization }); + fireEvent.click(screen.getByRole("button", { name: /Add metric/ })); + + fireEvent.click(footerButton("Optimize")); + const starting = (await findFooterButton("Starting")) as HTMLButtonElement; + expect(starting.disabled).toBe(true); + fireEvent.click(starting); + expect(createExperiment).toHaveBeenCalledOnce(); + + resolveCreation(makeExperiment(0, { id: "experiment-once" })); + await waitFor(() => expect(createOptimization).toHaveBeenCalledOnce()); + expect(createExperiment).toHaveBeenCalledOnce(); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.tsx index 7373e6ccf12..cf62107dbab 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.tsx @@ -19,10 +19,6 @@ import { } from "@hashintel/petrinaut-core"; import { isConnectedOptimization } from "@hashintel/petrinaut-core/optimization"; -import { - ExperimentsActionsContext, - type ExperimentMetricSpecInput, -} from "../../../../../../react/experiments/context"; import { axisDisplayName, buildAdHocSweepAxes, @@ -49,17 +45,31 @@ import { } from "../metrics/metric-picker-options"; import { ComputeBackendToggle } from "../shared/compute-backend-toggle"; import { useGpuAvailability } from "../shared/use-gpu-availability"; +import { hasAdHocIntervalToggle } from "./create-experiment-drawer/ad-hoc-interval-toggles"; import { type ConstraintDraftsState, EMPTY_CONSTRAINT_DRAFTS, } from "./create-experiment-drawer/constraint-drafts"; import { summarizeConstraintLspErrors } from "./create-experiment-drawer/constraint-lsp"; import { ConstraintsSection } from "./create-experiment-drawer/constraints-section"; +import { + fieldStyle, + gridStyle, + labelStyle, +} from "./create-experiment-drawer/form-field-styles"; import { constraintPolicyFor, lowerConstraintDrafts, stateConstraintGateSpecs, } from "./create-experiment-drawer/lower-constraint-drafts"; +import { ObjectiveSection } from "./create-experiment-drawer/objective-section"; +import { + EMPTY_SWEEP_OBJECTIVE, + resolveObjectiveMetricId, + sweepObjectiveError, + sweepObjectiveFor, +} from "./create-experiment-drawer/sweep-objective"; +import { useCreateOptimizedExperiment } from "./create-optimized-experiment"; import { areMetricLspDiagnosticSummariesEqual, EMPTY_METRIC_LSP_DIAGNOSTICS, @@ -67,7 +77,10 @@ import { type MetricLspDiagnosticSummary, } from "./experiment-metric-lsp-validation"; import { ExperimentScenarioRun } from "./experiment-scenario-run"; +import { SWEEP_OPTIMIZATION_RUNS_PER_STEP } from "./sweep-optimizer"; +import type { ExperimentMetricSpecInput } from "../../../../../../react/experiments/context"; +import type { AdHocFormSelection } from "../../../../../components/ad-hoc-scenario-form/form-context"; import type { AdHocScenarioState, MonteCarloMetricSpec, @@ -77,24 +90,6 @@ import type { // -- Styles ------------------------------------------------------------------- -const fieldStyle = css({ - display: "flex", - flexDirection: "column", - gap: "[6px]", -}); - -const labelStyle = css({ - fontSize: "sm", - fontWeight: "medium", - color: "neutral.s120", -}); - -const gridStyle = css({ - display: "grid", - gridTemplateColumns: "[repeat(3, minmax(0, 1fr))]", - gap: "3", -}); - const sweepSummaryStyle = css({ fontSize: "xs", color: "neutral.s80", @@ -791,7 +786,7 @@ export const CreateExperimentDrawer = ({ // Read here, not in ExperimentsProvider: that provider is mounted outside // UserSettingsProvider and so cannot see these settings. const { webGpuEnabled, enableParameterSweeps } = use(UserSettingsContext); - const { createExperiment } = use(ExperimentsActionsContext); + const createOptimizedExperiment = useCreateOptimizedExperiment(); const { diagnosticsByUri, requestConstraint } = use(LanguageClientContext); const optimizationSource = useOptimizationSource(); const scenarios = petriNetDefinition.scenarios ?? EMPTY_SCENARIOS; @@ -810,6 +805,7 @@ export const CreateExperimentDrawer = ({ const [metricDrafts, setMetricDrafts] = useState([]); const [constraintDrafts, setConstraintDrafts] = useState(EMPTY_CONSTRAINT_DRAFTS); + const [objectiveDraft, setObjectiveDraft] = useState(EMPTY_SWEEP_OBJECTIVE); const [metricLabelFocusId, setMetricLabelFocusId] = useState( null, ); @@ -845,6 +841,16 @@ export const CreateExperimentDrawer = ({ }; const adHocSweeping = enableParameterSweeps && effectiveSelectedScenarioId === NO_SCENARIO_VALUE; + const optimizerConnected = + optimizationSource !== null && isConnectedOptimization(optimizationSource); + // The word on every interval toggle, from the settings and the source + // alone — never from how many toggles are on: Optimize where the + // in-browser optimizer can drive the sweep, Sweep otherwise. + const selection: AdHocFormSelection = !enableParameterSweeps + ? "none" + : optimizerConnected + ? "optimize" + : "sweep"; /** * The sweep the current interval inputs define. `error` carries the first @@ -869,8 +875,10 @@ export const CreateExperimentDrawer = ({ axes.push(outcome.axis); } if (adHocSweeping && adHocState) { - // A definition that does not synthesize reports at its slots and - // refuses to run on submit; the summary only speaks for its sweeps. + // A definition that does not synthesize reports at its slots. While a + // toggle is on, the summary carries its first error the way a saved + // scenario's invalid interval does, so the Objective section and the + // footer word hold their place while a bound is being edited. const synthesized = synthesizeAdHocOptimization( adHocState, adHocFormContext, @@ -881,14 +889,24 @@ export const CreateExperimentDrawer = ({ return { text: outcome.error, tone: "error", error: true }; } axes.push(...outcome.axes); + } else if (hasAdHocIntervalToggle(adHocState)) { + const firstError = synthesized.errors[0]; + if (firstError) { + return { text: firstError.message, tone: "error", error: true }; + } } } if (axes.length === 0) { return null; } const names = axes.map(axisDisplayName).join(", "); + const intervals = + axes.length === 1 ? "over its interval" : "over their intervals"; return { - text: `${axes.length === 1 ? `${names} swept over its interval` : `${names} swept over their intervals`} — the sweep computes only the points you select, click on the Surface or hand to the optimizer`, + text: + selection === "optimize" + ? `${names} optimized ${intervals} — the study picks the points` + : `${names} swept ${intervals} — the sweep computes only the points you select, click on the Surface`, tone: "neutral", error: false, }; @@ -896,40 +914,60 @@ export const CreateExperimentDrawer = ({ // Shown under whichever scenario body is on screen: the form, or a saved // scenario shown through it. - // A sweep computes nothing at creation: it waits for a selection. - const submitLabel = sweepSummary - ? isSubmitting - ? "Creating" - : "Create sweep" - : isSubmitting - ? "Starting" - : "Run"; const sweepSummaryLine = sweepSummary ? ( {sweepSummary.text} ) : null; - // Constraints are authored only where a study could ever read them: a - // saved scenario's sweep, with the in-browser optimizer to drive it — the - // same facts that make the Parameters card offer Optimize. The rows stay - // in state while the section is hidden and are never lowered. - const constraintsEnabled = - enableParameterSweeps && - optimizationSource !== null && - isConnectedOptimization(optimizationSource) && - selectedScenario !== undefined && - sweepSummary !== null; + // An interval toggle is on and the in-browser optimizer can drive the + // sweep: creating the experiment starts its study, so the drawer asks for + // the objective. Constraints are authored only where a study could read + // them by name: a saved scenario's sweep (an ad-hoc definition's generated + // names are not authorable). The rows stay in state while the section is + // hidden and are never lowered. + const objectiveEnabled = selection === "optimize" && sweepSummary !== null; + const constraintsEnabled = objectiveEnabled && selectedScenario !== undefined; const constraintLspError = constraintsEnabled ? summarizeConstraintLspErrors(diagnosticsByUri, constraintDrafts.rows) : null; + const objectiveMetrics = metricDrafts.map((metric, index) => ({ + id: metric.id, + label: + metric.label.trim() === "" ? `Metric ${index + 1}` : metric.label.trim(), + })); + const objectiveMetricId = resolveObjectiveMetricId( + objectiveDraft, + objectiveMetrics, + ); + const objectiveExecution = { + dt: Number(dt), + maxTime: Number(maxTime), + runsPerStep: SWEEP_OPTIMIZATION_RUNS_PER_STEP, + }; + const objectiveError = objectiveEnabled + ? sweepObjectiveError(objectiveDraft, objectiveMetricId, objectiveExecution) + : null; - const footerError = error ?? metricFormError ?? constraintLspError; + const footerError = + error ?? metricFormError ?? objectiveError ?? constraintLspError; const canRun = !isSubmitting && metricFormError === null && + objectiveError === null && constraintLspError === null && sweepSummary?.error !== true; + const submitLabel = objectiveEnabled + ? isSubmitting + ? "Starting" + : "Optimize" + : sweepSummary + ? isSubmitting + ? "Creating" + : "Create sweep" + : isSubmitting + ? "Starting" + : "Run"; // `null` while the drafts are incomplete: the GPU metric gate has nothing to // judge yet, and Run is disabled for the same reason. A drafted state @@ -974,6 +1012,7 @@ export const CreateExperimentDrawer = ({ setMaxTime(DEFAULT_MAX_TIME); setMetricDrafts([]); setConstraintDrafts(EMPTY_CONSTRAINT_DRAFTS); + setObjectiveDraft(EMPTY_SWEEP_OBJECTIVE); setMetricLabelFocusId(null); setError(null); setIsSubmitting(false); @@ -1079,30 +1118,45 @@ export const CreateExperimentDrawer = ({ }, }) : []; - await createExperiment({ - name, - scenarioId: - effectiveSelectedScenarioId === NO_SCENARIO_VALUE - ? null - : effectiveSelectedScenarioId, - scenarioParameterValues: paramInputs, - adHocScenario: - effectiveSelectedScenarioId === NO_SCENARIO_VALUE ? adHocState : null, - adHocSweeps: adHocSweeping, - runCount: Number(runCount), - seed: Number(seed), - dt: Number(dt), - maxTime: Number(maxTime), - metricSpecs, - // Read here rather than in ExperimentsProvider, which is mounted outside - // UserSettingsProvider and so cannot see this setting. - computeBackend, - constraints, - constraintPolicy: - constraints.length > 0 - ? constraintPolicyFor(constraintDrafts.passThresholdPercent) - : undefined, - }); + // Create, start the study when there is an objective, then select: + // the results drawer mounts with the study already in place, and a + // study that cannot start leaves no experiment behind. + await createOptimizedExperiment( + { + name, + scenarioId: + effectiveSelectedScenarioId === NO_SCENARIO_VALUE + ? null + : effectiveSelectedScenarioId, + scenarioParameterValues: paramInputs, + adHocScenario: + effectiveSelectedScenarioId === NO_SCENARIO_VALUE + ? adHocState + : null, + adHocSweeps: adHocSweeping, + runCount: Number(runCount), + seed: Number(seed), + dt: Number(dt), + maxTime: Number(maxTime), + metricSpecs, + // Read here rather than in ExperimentsProvider, which is mounted outside + // UserSettingsProvider and so cannot see this setting. + computeBackend, + constraints, + constraintPolicy: + constraints.length > 0 + ? constraintPolicyFor(constraintDrafts.passThresholdPercent) + : undefined, + }, + objectiveEnabled + ? sweepObjectiveFor( + objectiveDraft, + objectiveMetricId, + objectiveExecution, + ) + : null, + ); + // A no-op on the unmounted drawer once the selection navigated away. resetForm(); } catch (submitError) { setIsSubmitting(false); @@ -1241,7 +1295,7 @@ export const CreateExperimentDrawer = ({ scenario={selectedScenario} context={adHocFormContext} inputs={paramInputs} - sweepable={enableParameterSweeps} + selection={selection} onInputsChange={(updates) => setParamInputs((prev) => { const next = { ...prev }; @@ -1264,13 +1318,24 @@ export const CreateExperimentDrawer = ({ state={adHocState ?? EMPTY_AD_HOC_STATE} onChange={setAdHocState} context={adHocFormContext} - selection={enableParameterSweeps ? "sweep" : "none"} + selection={selection} /> {sweepSummaryLine} )} + {objectiveEnabled ? ( + + ) : null} + {constraintsEnabled ? ( + ) : objectiveEnabled ? ( + ) : sweepSummary ? undefined : ( ) diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer/ad-hoc-interval-toggles.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer/ad-hoc-interval-toggles.test.ts new file mode 100644 index 00000000000..5c4849c2994 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer/ad-hoc-interval-toggles.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; + +import { EMPTY_AD_HOC_STATE, type AdHocValue } from "@hashintel/petrinaut-core"; + +import { hasAdHocIntervalToggle } from "./ad-hoc-interval-toggles"; + +const plain: AdHocValue = { expression: "1", optimize: null }; +const toggled: AdHocValue = { + expression: "1", + optimize: { min: "0", max: "2", scale: "linear" }, +}; + +describe("hasAdHocIntervalToggle", () => { + it("is false for the empty form and for values whose toggles are off", () => { + expect(hasAdHocIntervalToggle(EMPTY_AD_HOC_STATE)).toBe(false); + expect( + hasAdHocIntervalToggle({ + variables: [{ ...plain, name: "n", type: "integer" }], + netParameters: [{ ...plain, parameterId: "param" }], + places: { + debris: { kind: "uncoloured", count: plain }, + space: { + kind: "coloured", + variables: [], + rows: [{ kind: "template", count: plain, cells: [plain] }], + sharedColumns: { x: plain }, + }, + }, + }), + ).toBe(false); + }); + + it("is true for a toggle on any slot: a Variable, a net parameter, a count, a cell or a shared column", () => { + expect( + hasAdHocIntervalToggle({ + ...EMPTY_AD_HOC_STATE, + variables: [{ ...toggled, name: "n", type: "integer" }], + }), + ).toBe(true); + expect( + hasAdHocIntervalToggle({ + ...EMPTY_AD_HOC_STATE, + netParameters: [{ ...toggled, parameterId: "param" }], + }), + ).toBe(true); + expect( + hasAdHocIntervalToggle({ + ...EMPTY_AD_HOC_STATE, + places: { debris: { kind: "uncoloured", count: toggled } }, + }), + ).toBe(true); + expect( + hasAdHocIntervalToggle({ + ...EMPTY_AD_HOC_STATE, + places: { + space: { + kind: "coloured", + variables: [], + rows: [{ kind: "fixed", cells: [plain, toggled] }], + sharedColumns: {}, + }, + }, + }), + ).toBe(true); + expect( + hasAdHocIntervalToggle({ + ...EMPTY_AD_HOC_STATE, + places: { + space: { + kind: "coloured", + variables: [], + rows: [{ kind: "template", count: toggled, cells: [] }], + sharedColumns: {}, + }, + }, + }), + ).toBe(true); + expect( + hasAdHocIntervalToggle({ + ...EMPTY_AD_HOC_STATE, + places: { + space: { + kind: "coloured", + variables: [], + rows: [], + sharedColumns: { velocity: toggled }, + }, + }, + }), + ).toBe(true); + }); + + it("ignores retained settings, which only restore the next toggle", () => { + expect( + hasAdHocIntervalToggle({ + ...EMPTY_AD_HOC_STATE, + variables: [ + { + ...plain, + name: "n", + type: "integer", + retainedOptimize: toggled.optimize ?? undefined, + }, + ], + }), + ).toBe(false); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer/ad-hoc-interval-toggles.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer/ad-hoc-interval-toggles.ts new file mode 100644 index 00000000000..93af8c248aa --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer/ad-hoc-interval-toggles.ts @@ -0,0 +1,24 @@ +/** + * Whether the ad-hoc form has an interval toggle on, read from its state + * alone. The drawer keeps the Objective section and its Optimize word on + * that, not on whether the definition synthesizes yet: a bound mid-edit + * must not unmount the section beneath it. + */ +import type { AdHocScenarioState, AdHocValue } from "@hashintel/petrinaut-core"; + +const isToggled = (value: AdHocValue): boolean => value.optimize !== null; + +export const hasAdHocIntervalToggle = (state: AdHocScenarioState): boolean => + state.variables.some(isToggled) || + state.netParameters.some(isToggled) || + Object.values(state.places).some((place) => + place.kind === "uncoloured" + ? isToggled(place.count) + : place.variables.some(isToggled) || + Object.values(place.sharedColumns).some(isToggled) || + place.rows.some( + (row) => + row.cells.some(isToggled) || + (row.kind === "template" && isToggled(row.count)), + ), + ); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer/form-field-styles.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer/form-field-styles.ts new file mode 100644 index 00000000000..a6dca5970bf --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer/form-field-styles.ts @@ -0,0 +1,23 @@ +/** + * The labelled-cell styles the Create Experiment drawer's sections share: a + * three-column grid of fields, each a label over its control. + */ +import { css } from "@hashintel/ds-helpers/css"; + +export const fieldStyle = css({ + display: "flex", + flexDirection: "column", + gap: "[6px]", +}); + +export const labelStyle = css({ + fontSize: "sm", + fontWeight: "medium", + color: "neutral.s120", +}); + +export const gridStyle = css({ + display: "grid", + gridTemplateColumns: "[repeat(3, minmax(0, 1fr))]", + gap: "3", +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer/objective-section.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer/objective-section.test.tsx new file mode 100644 index 00000000000..cf23d995810 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer/objective-section.test.tsx @@ -0,0 +1,144 @@ +/** + * @vitest-environment jsdom + */ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ObjectiveSection } from "./objective-section"; +import { EMPTY_SWEEP_OBJECTIVE } from "./sweep-objective"; + +vi.mock("@hashintel/ds-components", async (importOriginal) => { + const actual = + await importOriginal(); + const stubs = await import("../../shared/ds-control-stubs"); + return { ...actual, ...stubs }; +}); + +afterEach(cleanup); + +const metrics = [ + { id: "peak", label: "Infected peak" }, + { id: "cost", label: "Total cost" }, +]; + +const metricSelect = () => + screen.getByLabelText("Metric to optimize") as HTMLSelectElement; +const stepsInput = () => + screen.getByLabelText("Optimization steps") as HTMLInputElement; +const directionButton = (label: string) => + screen.getByRole("button", { name: label }); + +describe("ObjectiveSection", () => { + it("shows the resolved metric, the direction and the steps over the description line", () => { + render( + {}} + />, + ); + + expect(screen.getByText("Objective")).toBeTruthy(); + expect(metricSelect().value).toBe("peak"); + expect(metricSelect().selectedOptions[0]?.text).toBe("Infected peak"); + expect(directionButton("Maximize").getAttribute("aria-pressed")).toBe( + "true", + ); + expect(directionButton("Minimize").getAttribute("aria-pressed")).toBe( + "false", + ); + expect(stepsInput().value).toBe("30"); + expect( + screen + .getByText( + "30 steps · 8 runs each — the best point then refines to your run budget", + ) + .getAttribute("data-tone"), + ).toBeNull(); + }); + + it("puts the error on the reserved line in place of the description", () => { + render( + {}} + />, + ); + + const line = screen.getByText("Ask for 1 to 1,000 steps"); + expect(line.getAttribute("data-tone")).toBe("error"); + expect(screen.queryByText(/runs each/)).toBeNull(); + }); + + it("disables the metric select and asks for a metric while there are no drafts", () => { + render( + {}} + />, + ); + + expect(metricSelect().disabled).toBe(true); + expect(metricSelect().selectedOptions[0]?.text).toBe("Add a metric below"); + expect(screen.getByText("Add a metric to optimize")).toBeTruthy(); + expect(stepsInput().disabled).toBe(false); + }); + + it("edits the draft one field at a time", () => { + const onChange = vi.fn(); + render( + , + ); + + fireEvent.change(metricSelect(), { target: { value: "cost" } }); + expect(onChange).toHaveBeenLastCalledWith({ + ...EMPTY_SWEEP_OBJECTIVE, + metricId: "cost", + }); + + fireEvent.click(directionButton("Minimize")); + expect(onChange).toHaveBeenLastCalledWith({ + ...EMPTY_SWEEP_OBJECTIVE, + direction: "minimize", + }); + + fireEvent.change(stepsInput(), { target: { value: "12" } }); + expect(onChange).toHaveBeenLastCalledWith({ + ...EMPTY_SWEEP_OBJECTIVE, + steps: 12, + }); + }); + + it("disables every control while the drawer submits", () => { + render( + {}} + disabled + />, + ); + + expect(metricSelect().disabled).toBe(true); + expect((directionButton("Minimize") as HTMLButtonElement).disabled).toBe( + true, + ); + expect(stepsInput().disabled).toBe(true); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer/objective-section.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer/objective-section.tsx new file mode 100644 index 00000000000..cf88209174f --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer/objective-section.tsx @@ -0,0 +1,122 @@ +/** + * The Create Experiment drawer's Objective section: the metric the study + * optimizes, its direction and the number of steps, in one fixed-height row + * of labelled cells, over a reserved line that describes the search or names + * the budget the optimizer would refuse. Every value lives in the draft the + * drawer owns; the section only edits it. + */ +import { + NumberInput, + SegmentedControl, + Select, +} from "@hashintel/ds-components"; +import { css } from "@hashintel/ds-helpers/css"; +import { PETRINAUT_OPTIMIZATION_MAX_TRIALS } from "@hashintel/petrinaut-core/optimization"; + +import { Section } from "../../../../../../components/section"; +import { SWEEP_OPTIMIZATION_RUNS_PER_STEP } from "../sweep-optimizer"; +import { fieldStyle, gridStyle, labelStyle } from "./form-field-styles"; +import { + describeSweepObjective, + SWEEP_OPTIMIZATION_DEFAULT_STEPS, + type SweepObjectiveDraft, +} from "./sweep-objective"; + +import type { PetrinautOptimizationDirection } from "@hashintel/petrinaut-core/optimization"; + +// Always mounted at one height: the description gives way to the error and +// back without moving the section below. +const helperStyle = css({ + fontSize: "xs", + lineHeight: "[16px]", + minHeight: "[16px]", + color: "neutral.s80", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + "&[data-tone='error']": { color: "red.s100" }, +}); + +const SECTION_TOOLTIP = `What the optimizer searches the swept intervals for: one experiment metric, pushed up or down, over this many steps. Each step computes ${SWEEP_OPTIMIZATION_RUNS_PER_STEP} runs at one point of the sweep before the optimizer reads the metric there; once the search settles the best point refines to the experiment's run budget.`; + +const directionItems: { + value: PetrinautOptimizationDirection; + label: string; +}[] = [ + { value: "maximize", label: "Maximize" }, + { value: "minimize", label: "Minimize" }, +]; + +export const ObjectiveSection = ({ + draft, + metricId, + metrics, + error, + onChange, + disabled = false, +}: { + draft: SweepObjectiveDraft; + /** The resolved metric id the Select shows; null without a metric draft. */ + metricId: string | null; + /** The drawer's metric drafts as id/label pairs, in list order. */ + metrics: readonly { id: string; label: string }[]; + /** What the reserved line shows in red; null shows the description. */ + error: string | null; + onChange: (draft: SweepObjectiveDraft) => void; + disabled?: boolean; +}) => ( +
+
+
+ Metric + ({ - value: spec.id, - text: spec.label, - }))} - value={metricId} - onChange={(value) => setMetricId(value ?? "")} - /> - Direction - - setDirection(value as PetrinautOptimizationDirection) - } - /> - Steps - setSteps(value)} - /> - {error === null ? null : ( - - {error} - - )} -
- void start()} - > - Start - - } - /> - - - ) : null} - - ); -}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-optimizer.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-optimizer.test.ts index 4ee31d98f0e..db6de071c43 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-optimizer.test.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-optimizer.test.ts @@ -1,7 +1,9 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { synthesizeAdHocOptimization } from "@hashintel/petrinaut-core"; import { sirModel } from "@hashintel/petrinaut-core/examples"; +import { buildAdHocSweepAxes } from "../../../../../../react/experiments/parameter-grid"; import { sirOptimizationConstraints, sirOptimizationMetric, @@ -10,14 +12,22 @@ import { import { makeExperiment } from "./experiments-story-fixtures"; import { buildSweepOptimizationInput, + startSweepStudy, sweepOptimizationMetric, + type SweepOptimizationExperiment, + type SweepStudyStarter, } from "./sweep-optimizer"; import type { ExperimentMetricSpecInput, ExperimentRecord, } from "../../../../../../react/experiments/context"; -import type { Metric, Scenario } from "@hashintel/petrinaut-core"; +import type { + AdHocScenarioState, + Metric, + Scenario, + SDCPN, +} from "@hashintel/petrinaut-core"; const definition = sirModel.petriNetDefinition; @@ -35,10 +45,11 @@ const scenario: Scenario = { initialState: { type: "per_place", content: {} }, }; -/** A sweep over `count` (integer) and `share` (continuous). */ +/** A sweep over `count` (integer) and `share` (continuous), keeping its scenario. */ const experiment = makeExperiment(1, { name: "Mixed sweep", scenarioId: scenario.id, + scenario, seed: 42, dt: 0.5, maxTime: 20, @@ -58,11 +69,9 @@ const build = (overrides: { steps?: number; runsPerStep?: number } = {}) => buildSweepOptimizationInput({ title: "SIR", definition, - scenario, - experiment, + experiment: { ...experiment, scenario }, metric, - direction: "maximize", - steps: overrides.steps ?? 30, + objective: { direction: "maximize", steps: overrides.steps ?? 30 }, runsPerStep: overrides.runsPerStep ?? 8, }); @@ -76,9 +85,7 @@ const sirScenario: Scenario = { }; /** A sweep over the infected ratio, created with the population raised and vaccination off. */ -const sirExperiment: Parameters< - typeof buildSweepOptimizationInput ->[0]["experiment"] = { +const sirExperiment: SweepOptimizationExperiment = { name: "Ratio sweep", seed: 7, dt: 0.5, @@ -99,6 +106,7 @@ const sirExperiment: Parameters< }, constraints: [], constraintPolicy: null, + scenario: sirScenario, }; const buildSir = ( @@ -109,18 +117,72 @@ const buildSir = ( buildSweepOptimizationInput({ title: sirModel.title, definition, - scenario: sirScenario, experiment: { ...sirExperiment, ...overrides }, metric: { id: sirOptimizationMetric.id, name: sirOptimizationMetric.name, code: sirOptimizationMetric.code, }, - direction: "minimize", - steps: 12, + objective: { direction: "minimize", steps: 12 }, runsPerStep: 8, }); +/** A one-place net for the ad-hoc definition below. */ +const queueSdcpn: SDCPN = { + places: [ + { + id: "place-queue", + name: "Queue", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + transitions: [], + types: [], + parameters: [], + differentialEquations: [], +}; + +/** The queue's count with an interval toggle on it. */ +const toggledAdHocScenario: AdHocScenarioState = { + variables: [], + netParameters: [], + places: { + "place-queue": { + kind: "uncoloured", + count: { + expression: "4", + optimize: { min: "2", max: "8", scale: "linear" }, + }, + }, + }, +}; + +/** The record `createExperiment` keeps for the toggled definition: the generated scenario and one axis per toggle. */ +const adHocExperiment = (): SweepOptimizationExperiment => { + const synthesized = synthesizeAdHocOptimization(toggledAdHocScenario, { + netParameters: [], + places: queueSdcpn.places, + types: [], + }); + if (!synthesized.ok) { + throw new Error("The ad-hoc fixture does not synthesize"); + } + const axes = buildAdHocSweepAxes(synthesized.output.optimizedFields); + if (!axes.ok) { + throw new Error(axes.error); + } + return { + ...makeExperiment(2, { name: "Queue sweep", scenarioId: null }), + scenario: synthesized.output.scenario, + parameterAxes: axes.axes, + scenarioParameterValues: {}, + }; +}; + describe("buildSweepOptimizationInput", () => { it("fixes the parameters the sweep leaves alone at their defaults, booleans as booleans", () => { const input = build(); @@ -222,6 +284,110 @@ describe("buildSweepOptimizationInput", () => { expect(manifest).not.toHaveProperty("constraints"); expect(manifest).not.toHaveProperty("constraintPolicy"); }); + + it("embeds an ad-hoc record's generated scenario and optimizes every generated parameter, fixing nothing", () => { + const record = adHocExperiment(); + const manifest = buildSweepOptimizationInput({ + title: "Queue", + definition: queueSdcpn, + experiment: record, + metric: { + id: "queued", + name: "Queued", + code: "return state.places.Queue.count;", + }, + objective: { direction: "minimize", steps: 6 }, + runsPerStep: 8, + }); + + expect(record.scenario.id).toBe("adhoc-scenario"); + expect(manifest.model.definition.scenarios).toEqual([record.scenario]); + expect(manifest.scenario.id).toBe("adhoc-scenario"); + expect(Object.keys(manifest.scenario.parameterBindings)).toEqual([ + "adhoc_count_Queue", + ]); + expect(manifest.scenario.parameterBindings.adhoc_count_Queue).toEqual({ + kind: "optimize", + domain: { + kind: "integer", + minimum: 2, + maximum: 8, + step: 1, + scale: "linear", + }, + }); + }); +}); + +describe("startSweepStudy", () => { + const spec: ExperimentMetricSpecInput = { + kind: "expression", + id: "infected", + label: "Infected", + code: metric.code, + sampleRuns: "all", + runOutput: { type: "distribution" }, + }; + const record: ExperimentRecord = { ...experiment, metricSpecs: [spec] }; + const fakeCreateOptimization = () => + vi.fn(() => + Promise.resolve("study"), + ); + const starter = ( + createOptimization: SweepStudyStarter["createOptimization"] = fakeCreateOptimization(), + ): SweepStudyStarter => ({ + title: "SIR", + definition, + createOptimization, + }); + + it("hands the manifest and the sweep's evaluator options to the optimizations context", async () => { + const createOptimization = fakeCreateOptimization(); + + await startSweepStudy(starter(createOptimization), record, { + metricId: "infected", + direction: "maximize", + steps: 30, + }); + + expect(createOptimization).toHaveBeenCalledOnce(); + const [manifest, options] = createOptimization.mock.calls[0]!; + expect(manifest).toMatchObject({ + name: "Mixed sweep · Maximize Infected", + execution: { seedsPerTrial: 8 }, + study: { trials: 30 }, + }); + expect(options).toEqual({ + sweep: { + experimentId: record.id, + axes: record.parameterAxes, + metricId: "infected", + }, + }); + }); + + it("rejects a record without a scenario before any study exists", async () => { + const createOptimization = fakeCreateOptimization(); + + await expect( + startSweepStudy( + starter(createOptimization), + { ...record, scenario: null }, + { metricId: "infected", direction: "maximize", steps: 30 }, + ), + ).rejects.toThrow("The experiment sweeps nothing"); + expect(createOptimization).not.toHaveBeenCalled(); + }); + + it("rejects a metric the experiment does not measure", async () => { + await expect( + startSweepStudy(starter(), record, { + metricId: "missing", + direction: "maximize", + steps: 30, + }), + ).rejects.toThrow("Pick a metric to optimize"); + }); }); describe("sweepOptimizationMetric", () => { diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-optimizer.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-optimizer.ts index 41b71d23a28..f18408fc7f7 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-optimizer.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-optimizer.ts @@ -1,16 +1,14 @@ /** - * The optimizer behind a sweep's Parameters card: the study manifest built - * from the experiment, and the hook that starts, stops and finds the study - * driving the sweep. The study evaluates its trials through the sweep's own - * compute (`createOptimization` with `sweep`), so this file only describes - * the search and reads the record back. + * The optimizer behind a parameter sweep: the study manifest built from the + * experiment record, the hook that starts the study with the experiment from + * the Create Experiment drawer, and the hook that reads the one study back + * for the results drawer. The study evaluates its trials through the sweep's + * own compute (`createOptimization` with `sweep`), so this file only + * describes the search and reads the record. */ import { use } from "react"; -import { - isConnectedOptimization, - petrinautOptimizationInputSchema, -} from "@hashintel/petrinaut-core/optimization"; +import { petrinautOptimizationInputSchema } from "@hashintel/petrinaut-core/optimization"; import { EXPERIMENT_RUN_LADDER } from "../../../../../../react/experiments/parameter-grid"; import { @@ -18,7 +16,6 @@ import { isOptimizationActive, OptimizationsContext, } from "../../../../../../react/optimizations/context"; -import { useOptimizationSource } from "../../../../../../react/optimizations/use-optimization-source"; import { SDCPNContext } from "../../../../../../react/state/sdcpn-context"; import { directionWord } from "../shared/study-labels"; @@ -26,7 +23,10 @@ import type { ExperimentMetricSpecInput, ExperimentRecord, } from "../../../../../../react/experiments/context"; -import type { OptimizationRecord } from "../../../../../../react/optimizations/context"; +import type { + OptimizationRecord, + OptimizationsContextValue, +} from "../../../../../../react/optimizations/context"; import type { Metric, Scenario, @@ -39,16 +39,22 @@ import type { PetrinautOptimizationParameterBinding, } from "@hashintel/petrinaut-core/optimization"; -/** What the Optimize prompt asks for. */ -export type SweepOptimizationChoice = { +/** What the Create Experiment drawer's Objective section decides. */ +export type SweepObjective = { metricId: string; direction: PetrinautOptimizationDirection; - /** Optimizer steps: one sweep point each. */ + /** Optimizer steps: one sweep point each, 1..PETRINAUT_OPTIMIZATION_MAX_TRIALS. */ steps: number; }; -/** The default number of steps the prompt proposes. */ -export const SWEEP_OPTIMIZATION_DEFAULT_STEPS = 30; +/** + * Runs each step's point computes before the optimizer reads its value: the + * ladder's first rung, so a step's batch boundary — and with it the seeds — + * matches a point the user climbs to. Exported for the form's budget + * pre-check and the story harness. + */ +export const SWEEP_OPTIMIZATION_RUNS_PER_STEP: number = + EXPERIMENT_RUN_LADDER[0]; /** * The experiment metric as the study's objective `Metric`. The study never @@ -84,46 +90,48 @@ export const sweepOptimizationMetric = ( }; }; +/** What the manifest reads of the experiment: its execution, axes, constraints and the scenario it compiled. */ +export type SweepOptimizationExperiment = Pick< + ExperimentRecord, + | "name" + | "seed" + | "dt" + | "maxTime" + | "parameterAxes" + | "scenarioParameterValues" + | "constraints" + | "constraintPolicy" +> & { scenario: Scenario }; + /** - * The manifest of a study that searches a sweep's swept parameters for the - * best value of one of its metrics. The swept axes become optimize bindings - * over the same intervals; every other scenario parameter is fixed at the - * value the experiment was created with, so the trials' values — which the - * evaluator judges the experiment's parameter constraints against — match - * what the sweep simulates. The experiment's constraints and pass threshold - * ride the manifest as they are. Throws with the schema's message when the - * experiment cannot be a study (a step budget over the cap, say). + * The manifest of a study searching the experiment's swept axes for the best + * value of one of its metrics. Bindings come from `experiment.scenario`: an + * axis is an optimize binding over its interval, every other parameter is + * fixed at the value the experiment was created with, so the trials' values + * — which the evaluator judges the experiment's parameter constraints + * against — match what the sweep simulates. An ad-hoc record's generated + * scenario has only axes, so every binding is optimize. The experiment's + * constraints and pass threshold ride the manifest as they are. Throws with + * the schema's message when the experiment cannot be a study (a step budget + * over the cap, say). */ export const buildSweepOptimizationInput = ({ title, definition, - scenario, experiment, metric, - direction, - steps, + objective, runsPerStep, }: { title: string; definition: SDCPN; - scenario: Scenario; - experiment: Pick< - ExperimentRecord, - | "name" - | "seed" - | "dt" - | "maxTime" - | "parameterAxes" - | "scenarioParameterValues" - | "constraints" - | "constraintPolicy" - >; + experiment: SweepOptimizationExperiment; metric: Metric; - direction: PetrinautOptimizationDirection; - steps: number; + objective: Pick; /** Runs each point computes before its value is read. */ runsPerStep: number; }): PetrinautOptimizationInput => { + const { scenario } = experiment; const fixedValueFor = (parameter: ScenarioParameter): number | boolean => { const value = experiment.scenarioParameterValues[parameter.identifier] ?? @@ -170,13 +178,13 @@ export const buildSweepOptimizationInput = ({ return petrinautOptimizationInputSchema.parse({ kind: "petrinaut-optimization", version: 1, - name: `${experiment.name} · ${directionWord(direction)} ${metric.name}`, + name: `${experiment.name} · ${directionWord(objective.direction)} ${metric.name}`, model: { title, definition: { ...definition, scenarios: [scenario], metrics: [metric] }, }, scenario: { id: scenario.id, parameterBindings }, - objective: { metricId: metric.id, direction }, + objective: { metricId: metric.id, direction: objective.direction }, ...(constraints.length > 0 ? { constraints } : {}), ...(constraints.length > 0 && constraintPolicy ? { constraintPolicy } : {}), execution: { @@ -185,10 +193,70 @@ export const buildSweepOptimizationInput = ({ maxTime: experiment.maxTime, seedsPerTrial: runsPerStep, }, - study: { trials: steps, sampler: "tpe" }, + study: { trials: objective.steps, sampler: "tpe" }, + }); +}; + +/** What starting a study reads beside the experiment: the net, its title and the optimizations action. */ +export type SweepStudyStarter = { + title: string; + definition: SDCPN; + createOptimization: OptimizationsContextValue["createOptimization"]; +}; + +/** + * Starts the study that drives an experiment's sweep. Calls + * `createOptimization` with no await before it, so the study record lands in + * the same flush as whatever the caller does next. Rejects with the reason + * before any record exists: no scenario, unknown metric, optimizer + * unavailable or not connected, a schema cap. + */ +export const startSweepStudy = async ( + { title, definition, createOptimization }: SweepStudyStarter, + experiment: ExperimentRecord, + objective: SweepObjective, +): Promise => { + if (experiment.scenario === null) { + throw new Error("The experiment sweeps nothing"); + } + const spec = experiment.metricSpecs.find( + (candidate) => candidate.id === objective.metricId, + ); + if (spec === undefined) { + throw new Error("Pick a metric to optimize"); + } + const input = buildSweepOptimizationInput({ + title, + definition, + experiment: { ...experiment, scenario: experiment.scenario }, + metric: sweepOptimizationMetric(spec, definition), + objective, + runsPerStep: SWEEP_OPTIMIZATION_RUNS_PER_STEP, + }); + await createOptimization(input, { + sweep: { + experimentId: experiment.id, + axes: experiment.parameterAxes, + metricId: objective.metricId, + }, }); }; +/** {@link startSweepStudy} over the net and the optimizations context. */ +export const useStartSweepStudy = (): (( + experiment: ExperimentRecord, + objective: SweepObjective, +) => Promise) => { + const { petriNetDefinition, title } = use(SDCPNContext); + const { createOptimization } = use(OptimizationsContext); + return (experiment, objective) => + startSweepStudy( + { title, definition: petriNetDefinition, createOptimization }, + experiment, + objective, + ); +}; + /** The step a driving study is on, of those requested. */ export type SweepStepProgress = { step: number; total: number }; @@ -202,113 +270,46 @@ const studyStepProgress = ( total: study.requestedTrials, }); +/** The one study of this sweep, read back from the optimizations context. */ export type SweepOptimizer = { /** - * The optimizer can run for this sweep: the in-browser optimizer is on, - * the experiment has a saved scenario and at least one metric. - */ - available: boolean; - /** - * Every study started from this sweep, oldest first; empty before any. The - * objective strip draws them end to end. - */ - studies: readonly OptimizationRecord[]; - /** - * The study started most recently, `studies.at(-1)`; null before any. Read - * for its outcome and its error once `driving` is null. + * The study started with the experiment; null for a sweep created without + * the optimizer. Read for its outcome and its error once `driving` is null. */ study: OptimizationRecord | null; - /** The step of the study driving the sweep now; null while none does. */ + /** The step the study is on while it drives the sweep; null otherwise. */ driving: SweepStepProgress | null; - /** Starts a study; rejects with the reason when the experiment cannot be one. */ - start: (choice: SweepOptimizationChoice) => Promise; /** Stops the driving study; the sweep keeps its last point. */ stop: () => void; - /** Removes every study started from this sweep, with the experiment. */ + /** Removes the study with the experiment. */ discard: () => void; }; -/** - * Runs each step's point computes before the optimizer reads its value: the - * ladder's first rung, so a step's batch boundary — and with it the seeds — - * matches a point the user climbs to. - */ -const SWEEP_OPTIMIZATION_RUNS_PER_STEP = EXPERIMENT_RUN_LADDER[0]; - export const useSweepOptimizer = ( - experiment: ExperimentRecord, + experiment: Pick, ): SweepOptimizer => { - const source = useOptimizationSource(); - const { petriNetDefinition, title } = use(SDCPNContext); - const { - optimizations, - createOptimization, - cancelOptimization, - removeOptimization, - } = use(OptimizationsContext); + const { optimizations, cancelOptimization, removeOptimization } = + use(OptimizationsContext); - // The provider prepends; the strip reads oldest first. - const studies = optimizations - .filter( + const study = + optimizations.find( (optimization) => optimization.origin.experimentId === experiment.id, - ) - .toSorted((left, right) => left.createdAt - right.createdAt); - const study = studies.at(-1) ?? null; - const scenario = - petriNetDefinition.scenarios?.find( - (candidate) => candidate.id === experiment.scenarioId, ) ?? null; - const available = - source !== null && - isConnectedOptimization(source) && - experiment.sweep !== null && - scenario !== null && - experiment.metricSpecs.length > 0; return { - available, - studies, study, driving: study !== null && isOptimizationActive(study) ? studyStepProgress(study) : null, - start: async (choice) => { - if (scenario === null) { - throw new Error("The experiment's scenario is gone"); - } - const spec = experiment.metricSpecs.find( - (candidate) => candidate.id === choice.metricId, - ); - if (spec === undefined) { - throw new Error("Pick a metric to optimize"); - } - const input = buildSweepOptimizationInput({ - title, - definition: petriNetDefinition, - scenario, - experiment, - metric: sweepOptimizationMetric(spec, petriNetDefinition), - direction: choice.direction, - steps: choice.steps, - runsPerStep: SWEEP_OPTIMIZATION_RUNS_PER_STEP, - }); - await createOptimization(input, { - sweep: { - experimentId: experiment.id, - axes: experiment.parameterAxes, - metricId: choice.metricId, - }, - }); - }, stop: () => { if (study !== null && isOptimizationActive(study)) { cancelOptimization(study.id); } }, discard: () => { - for (const record of studies) { - removeOptimization(record.id); + if (study !== null) { + removeOptimization(study.id); } }, }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.stories.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.stories.tsx index 69a25392234..1e156259600 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.stories.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.stories.tsx @@ -1,10 +1,10 @@ /** * The sweep-experiment drawer against fake compute: drag a parameter slider * and the charts bridge the compute gap with the previous picture, dimmed, - * until the new selection's first frames arrive. With the in-browser - * optimizer available the drawer also shows the study started from the - * sweep: its headline, its Steps columns, its Constraints and Sensitivity - * cards and its steps table, in one shape from the first Optimize on. + * until the new selection's first frames arrive. For a sweep created with + * Optimize the drawer also shows the study it was created with: its + * headline, its Steps columns, its Constraints and Sensitivity cards and its + * steps table, in one shape from its first frame. */ import { use } from "react"; @@ -174,26 +174,18 @@ const sweepStudy = ( }; }; -/** - * The sweep drawer with the in-browser optimizer available and `studies` - * started from the sweep, newest first as the provider keeps them; the - * first is the one the drawer shows. - */ -const SweepWithStudies = ({ +/** The sweep drawer with the in-browser optimizer on and `study` the study the sweep was created with. */ +const SweepWithStudy = ({ sweep, - studies, + study, }: { sweep: ExperimentRecord; - studies: readonly OptimizationRecord[]; + study: OptimizationRecord; }) => ( - + ); -/** The steps the latest study has landed in each state: 4 of 30 while it runs, 17 when Stop ended it. */ -const latestStudySteps = { running: 4, complete: 30, cancelled: 17 } as const; +/** The steps the study has landed in each state: 4 of 30 while it runs, 17 when Stop ended it. */ +const studySteps = { running: 4, complete: 30, cancelled: 17 } as const; /** - * The Parameters card offers Optimize, and with a study driving the sweep - * it turns purple, the header reads Optimizing, its sliders follow the - * steps, the button reads Stop and the objective strip under the sliders + * A sweep created with Optimize: while its study drives it the Parameters + * card turns purple, the header reads Optimizing, the sliders follow the + * steps, Stop sits on the card and the objective strip under the sliders * fills in step by step, its axis reaching to the steps asked for. Settled, - * the strip keeps the whole history and its axis ends at the last step run, - * complete or stopped; `previous` adds an earlier, stopped study before it, - * so the strip shows the two end to end with a divider where the second - * began. + * complete or stopped, the card offers no control, the sliders unlock and + * the strip keeps the history with its axis ending at the last step run. */ -const OptimizableSweep = ({ - latest, - previous = false, -}: { - latest: keyof typeof latestStudySteps; - previous?: boolean; -}) => { +const OptimizedSweep = ({ status }: { status: keyof typeof studySteps }) => { const sweep = makeParameterSweepExperiment(); - const study = sweepStudy(sweep, { - id: "sweep-study-2", - status: latest, - steps: latestStudySteps[latest], - startedAgoMs: 90_000, - }); - const studies = previous - ? [ - study, - sweepStudy(sweep, { - id: "sweep-study-1", - status: "cancelled", - steps: 17, - startedAgoMs: 600_000, - }), - ] - : [study]; - return ; + return ( + + ); }; -export const Optimizable: Story = { - name: "Sweep, optimizer available", - render: () => , +export const Optimized: Story = { + name: "Sweep, optimized", + render: () => , }; export const Optimizing: Story = { name: "Sweep, optimizer driving", - render: () => , + render: () => , }; -export const StoppedOnce: Story = { +export const Stopped: Story = { name: "Sweep, optimization stopped", - render: () => , -}; - -export const OptimizedTwice: Story = { - name: "Sweep, optimized twice", - render: () => , + render: () => , }; /** @@ -282,19 +254,16 @@ const ConstrainedSweep = ({ }) => { const sweep = makeConstrainedSweepExperiment(); return ( - ); }; @@ -321,19 +290,17 @@ const ImportanceSweep = ({ steps }: { steps: 30 | 60 }) => { const trials = steps === 60 ? fakeLongStudyTrials.trials : fakeStudyTrials.trials; return ( - ); }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx index 47164c60853..86ea66de1d3 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx @@ -6,7 +6,6 @@ import { fireEvent, render, screen, - waitFor, within, } from "@testing-library/react"; import { use } from "react"; @@ -55,17 +54,14 @@ const optimizer = vi.hoisted<{ current: SweepOptimizer | null }>(() => ({ })); const idleOptimizer: SweepOptimizer = { - available: false, - studies: [], study: null, driving: null, - start: () => Promise.resolve(), stop: () => {}, discard: () => {}, }; -// A test that exercises the control alone sets a fake; otherwise the real -// hook reads the host's optimizer through its contexts. +// A test that exercises the Stop control alone sets a fake; otherwise the +// real hook reads the host's study through its contexts. vi.mock("./sweep-optimizer", async (importOriginal) => { const actual = await importOriginal(); return { @@ -104,25 +100,22 @@ vi.mock("./sweep-surface", async () => { }); // uPlot cannot mount in jsdom; the strip's row and fold around the chart are -// real, and so is the history the row describes. The axis edge and the -// dividers the chart would draw sit on the stub as data attributes. +// real, and so is the history the row describes. The axis edge the chart +// would draw to sits on the stub as a data attribute. vi.mock("../shared/objective-history-chart", () => ({ ObjectiveHistoryChart: ({ plotHeight, xMax, - dividers, emptyLabel, }: { plotHeight: number; xMax: number | undefined; - dividers: readonly number[]; emptyLabel: string; }) => (
), @@ -198,10 +191,10 @@ const sweepStudy = ( ...overrides, }); -/** The sweep's drawer over the host's studies, newest first as the provider keeps them. */ +/** The sweep's drawer over the host's study, the one its experiment was created with. */ const renderDrawerWithStudies = ( experiment: ExperimentRecord, - [first, ...rest]: readonly [OptimizationRecord, ...OptimizationRecord[]], + study: OptimizationRecord, overrides: Partial = {}, ) => render( @@ -209,10 +202,7 @@ const renderDrawerWithStudies = ( , ); -/** No study yet: the optimizer is offered, nothing has been started. */ -const noStudies: OptimizationsContextValue = { - optimizations: [], - createOptimization: () => Promise.resolve("never"), - cancelOptimization: () => {}, - removeOptimization: () => {}, -}; - -/** The sweep's drawer with the optimizer offered and no study, so the record can be swapped in place. */ -const renderOptimizableDrawer = (experiment: ExperimentRecord) => { - const tree = (record: ExperimentRecord) => ( - - - - - {}} experiment={record} /> - - - - - ); - const { rerender } = render(tree(experiment)); - return { swapTo: (record: ExperimentRecord) => rerender(tree(record)) }; -}; - -/** The sweep's drawer with one study started from it, four steps landed (one pruned), driving or settled. */ +/** The sweep's drawer with the study it was created with, four steps landed (one pruned), driving or settled. */ const renderDrawerWithStudy = ( experiment: ExperimentRecord, status: "running" | "cancelled", ) => - renderDrawerWithStudies(experiment, [ + renderDrawerWithStudies( + experiment, sweepStudy(experiment, { status, completedTrials: 3, prunedTrials: 1 }), - ]); + ); + +/** The buttons of every axis's Range / Point control: disabled while a study drives the sweep. */ +const selectionModeButtons = () => + screen + .getAllByRole("group", { name: /selection mode$/u }) + .flatMap((group) => + within(group).getAllByRole("button"), + ); /** The sweep in each state a drawer can show it. */ const sweepIn = (status: ExperimentRecord["status"]): ExperimentRecord => ({ @@ -451,44 +425,21 @@ describe("ViewExperimentDrawer in the frame", () => { expect(screen.queryByRole("button", { name: /Optimize$/u })).toBeNull(); expect(screen.getByRole("button", { name: /Cancel$/u })).toBeTruthy(); expect(screen.getByText(/^Following step 5 of 30/u)).toBeTruthy(); + expect(selectionModeButtons().every((button) => button.disabled)).toBe( + true, + ); }); - it("starts the Optimize prompt afresh for another sweep swapped into the drawer", () => { - const { swapTo } = renderOptimizableDrawer(sweep); - fireEvent.click(screen.getByRole("button", { name: /Optimize$/u })); - const metricPicker = () => - screen.getByRole("combobox", { - name: "Metric to optimize", - }) as HTMLSelectElement; - expect(metricPicker().value).toBe("infected"); - - swapTo({ - ...sweep, - id: "experiment-9", - metricSpecs: [ - { - kind: "placeTokenCountMean", - id: "recovered", - label: "Recovered", - placeId: "place__recovered", - runOutput: { type: "distribution", binning: "exact" }, - }, - ], - }); - - // The prompt closed with the record it belonged to; reopened, it offers - // the new sweep's metric rather than an identifier this sweep never had. - expect(screen.queryByRole("combobox")).toBeNull(); - fireEvent.click(screen.getByRole("button", { name: /Optimize$/u })); - expect(metricPicker().value).toBe("recovered"); - }); - - it("offers Optimize again once the study settles and keeps its outcome on the status line", () => { + it("keeps the settled outcome on the status line with no card control", () => { renderDrawerWithStudy({ ...sweep, status: "idle" }, "cancelled"); expect(screen.getByText("Idle")).toBeTruthy(); expect(document.querySelector("[data-sweep-optimizing]")).toBeNull(); - expect(screen.getByRole("button", { name: /Optimize$/u })).toBeTruthy(); + expect(screen.queryByRole("button", { name: /Optimize$/u })).toBeNull(); + expect(screen.queryByRole("button", { name: /Stop$/u })).toBeNull(); + expect(selectionModeButtons().some((button) => button.disabled)).toBe( + false, + ); // The headline and the navigator's status line read the same outcome. const outcome = screen.getAllByText(/^Stopped after 4 of 30 steps/u); expect(outcome).toHaveLength(2); @@ -497,7 +448,7 @@ describe("ViewExperimentDrawer in the frame", () => { ).toBe(true); }); - it("shows no objective strip before any study", () => { + it("shows no objective strip for a sweep created without a study", () => { renderDrawer(sweep); expect(document.querySelector("[data-sweep-objective]")).toBeNull(); @@ -507,7 +458,8 @@ describe("ViewExperimentDrawer in the frame", () => { }); it("reads the strip's row as 0 steps, without a metric, when the sweep's only study failed before its first step", () => { - renderDrawerWithStudies({ ...sweep, status: "idle" }, [ + renderDrawerWithStudies( + { ...sweep, status: "idle" }, sweepStudy(sweep, { status: "error", trials: [], @@ -515,7 +467,7 @@ describe("ViewExperimentDrawer in the frame", () => { completedTrials: 0, prunedTrials: 0, }), - ]); + ); const row = screen.getByRole("button", { name: /^Objective by step/u }); expect(row.textContent).toMatch(/step0 steps$/u); @@ -526,7 +478,8 @@ describe("ViewExperimentDrawer in the frame", () => { }); it("waits for the first step in the strip's fold while the driving study has drawn none yet", () => { - renderDrawerWithStudies({ ...sweep, status: "idle" }, [ + renderDrawerWithStudies( + { ...sweep, status: "idle" }, sweepStudy(sweep, { status: "running", trials: [], @@ -534,7 +487,7 @@ describe("ViewExperimentDrawer in the frame", () => { completedTrials: 0, prunedTrials: 0, }), - ]); + ); expect(screen.getByTestId("objective-history").dataset.emptyLabel).toBe( "Waiting for the first step", @@ -601,7 +554,7 @@ describe("ViewExperimentDrawer in the frame", () => { }); }); -describe("the Optimize control", () => { +describe("the Stop control", () => { /** A study driving the sweep, three steps landed and one pruned. */ const drivingStudy = sweepStudy(sweep, { id: "study", @@ -610,119 +563,40 @@ describe("the Optimize control", () => { prunedTrials: 1, }); - const openPrompt = () => { - fireEvent.click(screen.getByRole("button", { name: /Optimize$/u })); - return screen.getByRole("button", { name: /Start$/u }); - }; - - it("starts a study with the chosen metric, direction and steps, then closes the prompt", async () => { - const start = vi.fn(() => Promise.resolve()); - optimizer.current = { ...idleOptimizer, available: true, start }; - renderDrawer(sweep); - - fireEvent.click(openPrompt()); - - expect(start).toHaveBeenCalledWith({ - metricId: "infected", - direction: "maximize", - steps: 30, - }); - await waitFor(() => { - expect(screen.queryByRole("button", { name: /Start$/u })).toBeNull(); - }); - }); - - it("seeds the prompt from the experiment the drawer swapped to", () => { - const start = vi.fn(() => Promise.resolve()); - optimizer.current = { ...idleOptimizer, available: true, start }; - const view = renderDrawer(sweep); - openPrompt(); - - // The drawer swaps records in place; the open prompt, its metric and - // its steps belong to the previous experiment. - const recovered = { - ...sweep.metricSpecs[0]!, - id: "recovered", - label: "Recovered", - }; - view.rerender( - drawerElement({ ...sweep, id: "other", metricSpecs: [recovered] }), - ); - - expect(screen.queryByRole("button", { name: /Start$/u })).toBeNull(); - fireEvent.click(openPrompt()); - expect(start).toHaveBeenCalledWith({ - metricId: "recovered", - direction: "maximize", - steps: 30, - }); - }); - - it("shows a refused start's reason in the prompt", async () => { - optimizer.current = { - ...idleOptimizer, - available: true, - start: () => Promise.reject(new Error("Pick a metric to optimize")), - }; - renderDrawer(sweep); - - fireEvent.click(openPrompt()); - - expect((await screen.findByRole("alert")).textContent).toBe( - "Pick a metric to optimize", - ); - expect(screen.getByRole("button", { name: /Start$/u })).toBeTruthy(); - }); - it("stops the driving study from the Parameters card", () => { const stop = vi.fn(); optimizer.current = { ...idleOptimizer, - available: true, study: drivingStudy, driving: { step: 5, total: 30 }, stop, }; renderDrawer(sweep); - expect(screen.queryByRole("button", { name: /Optimize$/u })).toBeNull(); fireEvent.click(screen.getByRole("button", { name: /Stop$/u })); expect(stop).toHaveBeenCalledTimes(1); }); - it("orders the host's studies by creation, summarises the strip from the later one and stops it", () => { + it("reads the host's study for the experiment, pins the strip's axis to its steps and stops it through the optimizations context", () => { const cancelOptimization = vi.fn(); const experiment = { ...sweep, status: "idle" as const }; - const earlier = sweepStudy(experiment, { - id: "study-1", - status: "cancelled", - createdAt: Date.now() - 200_000, - completedTrials: 3, - prunedTrials: 1, - }); - const later = sweepStudy(experiment, { - id: "study-2", - status: "running", - trials: fakeStudyTrials.trials.slice(0, 3), - completedTrials: 3, - prunedTrials: 0, - best: { trial: 1, parameters: {}, objective: 700.25 }, - }); - // The provider prepends, so the later study comes first. - renderDrawerWithStudies(experiment, [later, earlier], { - cancelOptimization, - }); + renderDrawerWithStudies( + experiment, + sweepStudy(experiment, { + id: "study-2", + status: "running", + trials: fakeStudyTrials.trials.slice(0, 3), + completedTrials: 3, + prunedTrials: 0, + best: { trial: 1, parameters: {}, objective: 700.25 }, + }), + { cancelOptimization }, + ); const row = screen.getByRole("button", { name: /^Objective by step/u }); - expect(row.textContent).toMatch( - / · 7 steps in 2 optimizations · best 700\.250$/u, - ); - // The divider sits at the later study's first step; the axis reaches its - // requested steps past the earlier study's four. - const chart = screen.getByTestId("objective-history"); - expect(chart.dataset.dividers).toBe("5"); - expect(chart.dataset.xMax).toBe(String(4 + 30)); + expect(row.textContent).toMatch(/ · 3 steps · best 700\.250$/u); + expect(screen.getByTestId("objective-history").dataset.xMax).toBe("30"); expect(screen.getByText(/^Following step 4 of 30/u)).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: /Stop$/u })); @@ -735,7 +609,7 @@ describe("the Optimize control", () => { describe("ViewExperimentDrawer with a study", () => { const idleSweep: ExperimentRecord = { ...sweep, status: "idle" }; - it("shows nothing of a study before one exists", () => { + it("shows nothing of a study for a sweep created without one", () => { renderDrawer(idleSweep); expect(document.querySelector("[data-study-header]")).toBeNull(); @@ -786,7 +660,7 @@ describe("ViewExperimentDrawer with a constrained study", () => { }); it("adds the Constraints card with the steps clear, the latest step's verdict and one bar per state constraint", () => { - renderDrawerWithStudies(constrainedSweep, [settled]); + renderDrawerWithStudies(constrainedSweep, settled); const card = screen .getByText("Constraints") @@ -809,7 +683,7 @@ describe("ViewExperimentDrawer with a constrained study", () => { }); it("puts the steps clear in the strip and a Runs passed column in the table, greying the infeasible draws", () => { - renderDrawerWithStudies(constrainedSweep, [settled]); + renderDrawerWithStudies(constrainedSweep, settled); expect(screen.getByText("Steps clear")).toBeTruthy(); expect(screen.getByText("Runs passed")).toBeTruthy(); @@ -825,13 +699,14 @@ describe("ViewExperimentDrawer with a constrained study", () => { }); it("shows none of it for a study without constraints", () => { - renderDrawerWithStudies({ ...sweep, status: "idle" }, [ + renderDrawerWithStudies( + { ...sweep, status: "idle" }, sweepStudy(sweep, { status: "complete", trials: fakeStudyTrials.trials, best: fakeStudyTrials.best, }), - ]); + ); expect(screen.queryByText("Constraints")).toBeNull(); expect(screen.queryByText("Steps clear")).toBeNull(); @@ -849,7 +724,8 @@ describe("ViewExperimentDrawer's Sensitivity analysis card", () => { .closest("[data-chart-card]")!; it("lists the optimized parameters in binding order with a bar each above the floor, the count in the subtitle and a Correlation column", () => { - renderDrawerWithStudies(idleSweep, [ + renderDrawerWithStudies( + idleSweep, sweepStudy(idleSweep, { status: "complete", input: fakeLongStudyInput, @@ -860,7 +736,7 @@ describe("ViewExperimentDrawer's Sensitivity analysis card", () => { fakeLongStudyTrials.trials, ), }), - ]); + ); const card = importanceCard(); expect(card.getAttribute("data-tone")).toBe("default"); @@ -886,7 +762,8 @@ describe("ViewExperimentDrawer's Sensitivity analysis card", () => { }); it("mutes the card and fades the bars below the floor, and says so in the subtitle", () => { - renderDrawerWithStudies(idleSweep, [ + renderDrawerWithStudies( + idleSweep, sweepStudy(idleSweep, { status: "complete", input: fakeShortStudyInput, @@ -897,7 +774,7 @@ describe("ViewExperimentDrawer's Sensitivity analysis card", () => { fakeShortStudyTrials.trials, ), }), - ]); + ); const card = importanceCard(); expect(card.getAttribute("data-tone")).toBe("muted"); @@ -917,14 +794,15 @@ describe("ViewExperimentDrawer's Sensitivity analysis card", () => { }); it("shows dashed rows and the correlations while no estimate has arrived", () => { - renderDrawerWithStudies(idleSweep, [ + renderDrawerWithStudies( + idleSweep, sweepStudy(idleSweep, { status: "running", input: fakeShortStudyInput, trials: fakeShortStudyTrials.trials, best: fakeShortStudyTrials.best, }), - ]); + ); const card = importanceCard(); const rows = card.querySelectorAll("[data-importance-row]"); @@ -948,14 +826,15 @@ describe("ViewExperimentDrawer's Sensitivity analysis card", () => { }, }); const singleParameter = makeTrials(singleParameterInput, 30); - renderDrawerWithStudies(idleSweep, [ + renderDrawerWithStudies( + idleSweep, sweepStudy(idleSweep, { status: "complete", input: singleParameterInput, trials: singleParameter.trials, best: singleParameter.best, }), - ]); + ); const card = importanceCard(); expect(card.getAttribute("data-tone")).toBe("default"); @@ -975,32 +854,23 @@ describe("ViewExperimentDrawer holds every box still across a study's states", ( ...makeConstrainedSweepExperiment(), status: "idle", }; - const studyIn = ( - status: OptimizationRecord["status"], - id = "sweep-study", - ): OptimizationRecord => + const studyIn = (status: OptimizationRecord["status"]): OptimizationRecord => sweepStudy(constrainedSweep, { - id, status, input: fakeConstrainedStudyInput, trials: fakeConstrainedStudyTrials.trials.slice(0, 4), best: fakeConstrainedStudyTrials.best, }); - it("gives the header, the note row, every card and the steps table one height while running, stopped and failed, and on a second study", () => { - const signatures = ( - [ - [studyIn("running")], - [studyIn("cancelled")], - [studyIn("error")], - [studyIn("running", "sweep-study-2"), studyIn("cancelled")], - ] as const - ).map((studies) => { - const view = renderDrawerWithStudies(constrainedSweep, studies); - const signature = frameLayoutSignature(view.container); - view.unmount(); - return signature; - }); + it("gives the header, the note row, every card and the steps table one height while running, stopped and failed", () => { + const signatures = (["running", "cancelled", "error"] as const).map( + (status) => { + const view = renderDrawerWithStudies(constrainedSweep, studyIn(status)); + const signature = frameLayoutSignature(view.container); + view.unmount(); + return signature; + }, + ); expect(signatures[0]!.header).toBe("false"); expect(signatures[0]!.note).toBe("20px"); @@ -1018,7 +888,7 @@ describe("ViewExperimentDrawer holds every box still across a study's states", ( }); it("puts a failed study's error in the reserved note row", () => { - renderDrawerWithStudies(constrainedSweep, [studyIn("error")]); + renderDrawerWithStudies(constrainedSweep, studyIn("error")); const note = document.querySelector("[data-frame-note]")!; expect(note.textContent).toBe("The optimizer lost its worker"); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/ds-control-stubs.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/ds-control-stubs.tsx index e337f163827..bf4dad6bb27 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/ds-control-stubs.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/ds-control-stubs.tsx @@ -24,23 +24,32 @@ export const Popover = Object.assign( }, ); -/** A required single select: `onChange` receives the picked value. */ +/** + * A single select: `onChange` receives the picked value. A null value shows + * the placeholder as an empty option, the way the trigger would read it. + */ export const Select = ({ "aria-label": ariaLabel, + disabled, items, onChange, + placeholder, value, }: { "aria-label"?: string; + disabled?: boolean; items: readonly { value: string; text: string }[]; onChange: (value: string) => void; - value: string; + placeholder?: string; + value: string | null | undefined; }) => (