diff --git a/.changeset/scenario-form-default.md b/.changeset/scenario-form-default.md new file mode 100644 index 00000000000..7cb494fc583 --- /dev/null +++ b/.changeset/scenario-form-default.md @@ -0,0 +1,6 @@ +--- +"@hashintel/petrinaut": patch +"@hashintel/petrinaut-core": patch +--- + +Makes the scenario form the only scenario form and removes the Ad-hoc scenarios setting. diff --git a/libs/@hashintel/petrinaut-core/src/ai.ts b/libs/@hashintel/petrinaut-core/src/ai.ts index 058cbb30b95..d03bd2f7b20 100644 --- a/libs/@hashintel/petrinaut-core/src/ai.ts +++ b/libs/@hashintel/petrinaut-core/src/ai.ts @@ -113,9 +113,9 @@ export const petrinautDocSummaries: Record = { simulation: "Single-run simulation: initial state, simulation settings (scenario picker, dt, ODE solver, parameters), running, frame computation, deadlock, playback controls, timeline, locked editing.", scenarios: - "Named simulation configurations: scenario parameters, parameter bindings, per-place vs code-mode initial state, running and switching 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 surfaces (quick simulation, experiments, scenario creation), and Sweep selections with generated adhoc_* parameter names.", + "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.", 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.", "actual-mode": diff --git a/libs/@hashintel/petrinaut-core/src/index.ts b/libs/@hashintel/petrinaut-core/src/index.ts index 6aa0da8457c..92b6527a391 100644 --- a/libs/@hashintel/petrinaut-core/src/index.ts +++ b/libs/@hashintel/petrinaut-core/src/index.ts @@ -552,6 +552,10 @@ export { initialMarkingToAdHocPlaces, type TruncatedPlace, } from "./simulation/authoring/scenario/ad-hoc/materialize-run-state"; +export { + adHocStateFromScenario, + type AdHocStateFromScenario, +} from "./simulation/authoring/scenario/ad-hoc/scenario-to-ad-hoc-state"; export { adHocScenarioStateSchema } from "./simulation/authoring/scenario/ad-hoc/ad-hoc-state-schema"; export { createHirMetricEvaluator } from "./simulation/frames/hir-metric"; export { diff --git a/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/materialize-run-state.ts b/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/materialize-run-state.ts index 53ea5e67c04..37d14d5f450 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/materialize-run-state.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/materialize-run-state.ts @@ -36,7 +36,9 @@ export type TruncatedPlace = { total: number; }; -function literalExpression(value: number | boolean | bigint | string): string { +export function literalExpression( + value: number | boolean | bigint | string, +): string { switch (typeof value) { case "number": return String(value); diff --git a/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/scenario-to-ad-hoc-state.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/scenario-to-ad-hoc-state.test.ts new file mode 100644 index 00000000000..f59e258e903 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/scenario-to-ad-hoc-state.test.ts @@ -0,0 +1,506 @@ +import { describe, expect, it } from "vitest"; + +import { + cafeQueue, + deploymentPipelineSDCPN, + dronePatrol, + probabilisticSatellitesSDCPN, + productionMachines, + sirModel, + supplyChainProfit, + supplyChainWithDisruption, + vaccinationCampaign, +} from "../../../../examples/index"; +import { lowerScenarioToHir } from "../../../../hir/scenario"; +import { compileScenario } from "../compile-scenario"; +import { + adHocExposedParameterIdentifier, + synthesizeAdHocScenario, +} from "./ad-hoc-scenario"; +import { adHocStateFromScenario } from "./scenario-to-ad-hoc-state"; + +import type { + AdHocPlaceState, + AdHocScenarioState, + Color, + Parameter, + Place, + Scenario, + SDCPN, +} from "../../../../types/sdcpn"; +import type { AdHocSynthesisContext } from "./ad-hoc-scenario"; + +const place = (id: string, name: string, colorId: string | null): Place => ({ + id, + name, + colorId, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, +}); + +const SATELLITE: Color = { + id: "colour-satellite", + name: "Satellite", + iconSlug: "circle", + displayColor: "#3676b8", + elements: [ + { elementId: "e1", name: "altitude", type: "real" }, + { elementId: "e2", name: "active", type: "boolean" }, + { elementId: "e3", name: "label", type: "string" }, + { elementId: "e4", name: "tag", type: "uuid" }, + ], +}; + +const netParameter = (id: string, variableName: string): Parameter => ({ + id, + name: variableName, + variableName, + type: "real", + defaultValue: "1", +}); + +const CONTEXT: AdHocSynthesisContext = { + netParameters: [ + netParameter("param-gravity", "gravity"), + netParameter("param-drag", "drag"), + ], + places: [ + place("place-space", "Space", "colour-satellite"), + place("place-debris", "Debris", null), + place("place-orphan", "Orphan", "colour-missing"), + ], + types: [SATELLITE], +}; + +const scenario = (initialState: Scenario["initialState"]): Scenario => ({ + id: "scenario-moon", + name: "Moon Orbit", + scenarioParameters: [ + { type: "real", identifier: "launch_rate", default: 0.3 }, + { type: "integer", identifier: "satellites", default: 20 }, + { type: "boolean", identifier: "night_mode", default: 0 }, + { type: "boolean", identifier: "day_mode", default: 1 }, + { type: "ratio", identifier: "mix", default: 0.5 }, + ], + parameterOverrides: { "param-gravity": "9.81", "param-drag": "" }, + initialState, +}); + +const EXPECTED_VARIABLES = [ + { name: "launch_rate", type: "real", expression: "0.3" }, + { name: "satellites", type: "integer", expression: "20" }, + { name: "night_mode", type: "boolean", expression: "false" }, + { name: "day_mode", type: "boolean", expression: "true" }, + { name: "mix", type: "ratio", expression: "0.5" }, +].map((variable) => ({ ...variable, exposed: true, optimize: null })); + +const EXPECTED_NET_PARAMETERS = [ + { parameterId: "param-gravity", expression: "9.81", optimize: null }, + { parameterId: "param-drag", expression: "", optimize: null }, +]; + +describe("adHocStateFromScenario", () => { + const gravityOverride = { + parameterId: "param-gravity", + expression: "9.81", + optimize: null, + }; + const goneOverride = { + parameterId: "param-gone", + expression: "3", + optimize: null, + }; + const stored: AdHocScenarioState = { + variables: [ + { + name: "boost", + type: "real", + expression: "1.5", + exposed: true, + optimize: null, + }, + ], + netParameters: [gravityOverride, goneOverride], + places: { + "place-debris": { + kind: "uncoloured", + count: { expression: "scenario.satellites * 2", optimize: null }, + }, + }, + }; + /** A coloured block without rows: well-formed on any place the net colours. */ + const emptyColouredBlock: AdHocPlaceState = { + kind: "coloured", + variables: [], + rows: [], + sharedColumns: {}, + }; + + it("returns an adhoc scenario's stored definition", () => { + const result = adHocStateFromScenario( + scenario({ + type: "adhoc", + content: { ...stored, netParameters: [gravityOverride] }, + }), + CONTEXT, + ); + expect(result.kind).toBe("adhoc"); + expect(result.state).toEqual({ + ...stored, + netParameters: [gravityOverride], + }); + }); + + it("drops a stale override from an adhoc scenario's stored definition", () => { + const result = adHocStateFromScenario( + scenario({ type: "adhoc", content: stored }), + CONTEXT, + ); + expect(result.state.netParameters).toEqual([gravityOverride]); + expect(result.state.variables).toEqual(stored.variables); + expect(result.state.places).toEqual(stored.places); + }); + + it("drops a stale place from an adhoc scenario's stored definition", () => { + const content: AdHocScenarioState = { + ...stored, + netParameters: [gravityOverride], + places: { + ...stored.places, + "place-gone": { + kind: "uncoloured", + count: { expression: "1", optimize: null }, + }, + }, + }; + expect(synthesizeAdHocScenario(content, CONTEXT).ok).toBe(false); + + const result = adHocStateFromScenario( + scenario({ type: "adhoc", content }), + CONTEXT, + ); + + expect(result.state.places).toEqual(stored.places); + expect(synthesizeAdHocScenario(result.state, CONTEXT).ok).toBe(true); + }); + + it("drops a stored coloured block from a place that lost its colour", () => { + const content: AdHocScenarioState = { + ...stored, + netParameters: [gravityOverride], + places: { + "place-debris": emptyColouredBlock, + "place-space": emptyColouredBlock, + }, + }; + expect(synthesizeAdHocScenario(content, CONTEXT).ok).toBe(false); + + const result = adHocStateFromScenario( + scenario({ type: "adhoc", content }), + CONTEXT, + ); + + expect(result.state.places).toEqual({ "place-space": emptyColouredBlock }); + expect(synthesizeAdHocScenario(result.state, CONTEXT).ok).toBe(true); + }); + + it("drops a stored uncoloured block from a place that gained a colour, and a coloured block from a place whose colour the net lost", () => { + const content: AdHocScenarioState = { + ...stored, + netParameters: [gravityOverride], + places: { + ...stored.places, + "place-space": { + kind: "uncoloured", + count: { expression: "3", optimize: null }, + }, + "place-orphan": emptyColouredBlock, + }, + }; + expect(synthesizeAdHocScenario(content, CONTEXT).ok).toBe(false); + + const result = adHocStateFromScenario( + scenario({ type: "adhoc", content }), + CONTEXT, + ); + + expect(result.state.places).toEqual(stored.places); + expect(synthesizeAdHocScenario(result.state, CONTEXT).ok).toBe(true); + }); + + it("drops every adhoc override when the context carries no net parameters", () => { + const result = adHocStateFromScenario( + scenario({ type: "adhoc", content: stored }), + { ...CONTEXT, netParameters: [] }, + ); + expect(result.state.netParameters).toEqual([]); + expect(result.state.variables).toEqual(stored.variables); + expect(result.state.places).toEqual(stored.places); + }); + + it("returns a code scenario's body verbatim with Variables and Parameters only", () => { + const result = adHocStateFromScenario( + scenario({ type: "code", content: "return { Debris: 4 };" }), + CONTEXT, + ); + expect(result).toEqual({ + kind: "code", + code: "return { Debris: 4 };", + state: { + variables: EXPECTED_VARIABLES, + netParameters: EXPECTED_NET_PARAMETERS, + places: {}, + }, + }); + }); + + it("converts a per_place scenario's parameters, overrides and places", () => { + const result = adHocStateFromScenario( + scenario({ + type: "per_place", + content: { + "place-debris": "scenario.satellites + 1", + "place-space": [ + [400, true, "alpha", "0f9c5b2e-8c1a-4d6e-9b3f-2a7c1d4e5f60"], + [550.5], + ], + }, + }), + CONTEXT, + ); + expect(result.kind).toBe("per_place"); + expect(result.state.variables).toEqual(EXPECTED_VARIABLES); + expect(result.state.netParameters).toEqual(EXPECTED_NET_PARAMETERS); + expect(result.state.places).toEqual({ + "place-debris": { + kind: "uncoloured", + count: { expression: "scenario.satellites + 1", optimize: null }, + }, + "place-space": { + kind: "coloured", + variables: [], + sharedColumns: {}, + rows: [ + { + kind: "fixed", + cells: [ + { expression: "400", optimize: null }, + { expression: "true", optimize: null }, + { expression: '"alpha"', optimize: null }, + { + expression: '"0f9c5b2e-8c1a-4d6e-9b3f-2a7c1d4e5f60"', + optimize: null, + }, + ], + }, + { + kind: "fixed", + cells: [ + { expression: "550.5", optimize: null }, + { expression: "false", optimize: null }, + { expression: '""', optimize: null }, + { + expression: '"00000000-0000-0000-0000-000000000000"', + optimize: null, + }, + ], + }, + ], + }, + }); + }); + + it("drops an override for a parameter the net does not know", () => { + const result = adHocStateFromScenario( + { + ...scenario({ type: "per_place", content: {} }), + parameterOverrides: { + "param-gravity": "9.81", + "param-gone": "3", + "param-drag": "", + }, + }, + CONTEXT, + ); + expect(result.state.netParameters).toEqual(EXPECTED_NET_PARAMETERS); + }); + + it("drops every override when the context carries no net parameters", () => { + const result = adHocStateFromScenario( + scenario({ type: "code", content: "return {};" }), + { ...CONTEXT, netParameters: [] }, + ); + expect(result.state.netParameters).toEqual([]); + expect(result.state.variables).toEqual(EXPECTED_VARIABLES); + }); + + it("keeps an empty uncoloured expression empty", () => { + const result = adHocStateFromScenario( + scenario({ type: "per_place", content: { "place-debris": "" } }), + CONTEXT, + ); + expect(result.state.places).toEqual({ + "place-debris": { + kind: "uncoloured", + count: { expression: "", optimize: null }, + }, + }); + }); + + it("drops places the net does not know and coloured places without a colour", () => { + const result = adHocStateFromScenario( + scenario({ + type: "per_place", + content: { + "place-unknown": "3", + "place-orphan": [[1, true]], + "place-debris": "2", + }, + }), + CONTEXT, + ); + expect(Object.keys(result.state.places)).toEqual(["place-debris"]); + }); +}); + +// -- Core examples --------------------------------------------------------------- + +type Example = { title: string; petriNetDefinition: SDCPN }; + +const EXAMPLES: Example[] = [ + cafeQueue, + deploymentPipelineSDCPN, + dronePatrol, + probabilisticSatellitesSDCPN, + productionMachines, + sirModel, + supplyChainProfit, + supplyChainWithDisruption, + vaccinationCampaign, +]; + +const exampleScenarios = ( + type: Scenario["initialState"]["type"], +): [string, Scenario, SDCPN][] => + EXAMPLES.flatMap((example) => + (example.petriNetDefinition.scenarios ?? []) + .filter((candidate) => candidate.initialState.type === type) + .map((candidate): [string, Scenario, SDCPN] => [ + `${example.title} / ${candidate.name}`, + candidate, + example.petriNetDefinition, + ]), + ); + +const synthesisContext = (net: SDCPN): AdHocSynthesisContext => ({ + netParameters: net.parameters, + places: net.places, + types: net.types, +}); + +const compileOriginal = (candidate: Scenario, net: SDCPN) => { + const result = compileScenario( + candidate, + lowerScenarioToHir(candidate), + net.parameters, + net.places, + net.types, + ); + if (!result.ok) { + throw new Error(JSON.stringify(result.errors)); + } + return result.result; +}; + +const synthesizeConverted = (candidate: Scenario, net: SDCPN): Scenario => { + const context = synthesisContext(net); + const converted = adHocStateFromScenario(candidate, context); + const outcome = synthesizeAdHocScenario(converted.state, context); + if (!outcome.ok) { + throw new Error(JSON.stringify(outcome.errors)); + } + return outcome.scenario; +}; + +const compileConverted = (candidate: Scenario, net: SDCPN) => { + const context = synthesisContext(net); + const synthesized = synthesizeConverted(candidate, net); + const result = compileScenario( + synthesized, + lowerScenarioToHir(synthesized, { adHocContext: context }), + net.parameters, + net.places, + net.types, + ); + if (!result.ok) { + throw new Error(JSON.stringify(result.errors)); + } + return result.result; +}; + +describe("adHocStateFromScenario over the core examples", () => { + it("covers every stored kind the examples ship", () => { + expect(exampleScenarios("per_place")).toHaveLength(22); + expect(exampleScenarios("code")).toHaveLength(2); + expect(exampleScenarios("adhoc")).toHaveLength(0); + }); + + it.each([...exampleScenarios("per_place"), ...exampleScenarios("code")])( + "%s keeps every scenario parameter identifier verbatim", + (_title, candidate, net) => { + const converted = adHocStateFromScenario( + candidate, + synthesisContext(net), + ); + const identifiers = candidate.scenarioParameters.map( + (parameter) => parameter.identifier, + ); + expect( + converted.state.variables.map((variable) => variable.name), + ).toEqual(identifiers); + for (const identifier of identifiers) { + expect(adHocExposedParameterIdentifier(identifier)).toBe(identifier); + } + }, + ); + + it.each(exampleScenarios("per_place"))( + "%s compiles to the same initial state and parameter values once converted", + (_title, candidate, net) => { + const converted = adHocStateFromScenario( + candidate, + synthesisContext(net), + ); + expect(converted.kind).toBe("per_place"); + const original = compileOriginal(candidate, net); + const viaForm = compileConverted(candidate, net); + expect(viaForm.initialState).toEqual(original.initialState); + expect(viaForm.parameterValues).toEqual(original.parameterValues); + }, + ); + + it.each(exampleScenarios("code"))( + "%s keeps its code body and synthesizes the same parameters and overrides", + (_title, candidate, net) => { + const converted = adHocStateFromScenario( + candidate, + synthesisContext(net), + ); + expect(converted.kind).toBe("code"); + if (converted.kind !== "code") { + return; + } + expect(converted.code).toBe(candidate.initialState.content); + expect(converted.state.places).toEqual({}); + const synthesized = synthesizeConverted(candidate, net); + expect(synthesized.scenarioParameters).toEqual( + candidate.scenarioParameters, + ); + expect(synthesized.parameterOverrides).toEqual( + candidate.parameterOverrides, + ); + }, + ); +}); diff --git a/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/scenario-to-ad-hoc-state.ts b/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/scenario-to-ad-hoc-state.ts new file mode 100644 index 00000000000..2e756fb5d60 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/scenario-to-ad-hoc-state.ts @@ -0,0 +1,209 @@ +/** + * The form state a saved scenario edits through. The scenario form is the + * one scenario editor, so a scenario stored in any format must open in it: + * an `adhoc` scenario as its stored definition minus the overrides, places + * and place blocks the net no longer matches, a `per_place` scenario + * converted losslessly (saving stores it as `adhoc`), a `code` scenario with + * its Variables and Parameters only — the code body stays with the caller, + * who shows it read-only and writes it back verbatim. + */ + +import { adHocNeutralExpression } from "./ad-hoc-scenario"; +import { + classicRunVariables, + literalExpression, +} from "./materialize-run-state"; + +import type { + AdHocNetParameter, + AdHocPlaceState, + AdHocScenarioState, + Color, + Place, + Scenario, +} from "../../../../types/sdcpn"; +import type { AdHocSynthesisContext } from "./ad-hoc-scenario"; + +export type AdHocStateFromScenario = + /** The stored definition, minus the overrides, places and place blocks the net no longer matches. */ + | { kind: "adhoc"; state: AdHocScenarioState } + /** A lossless conversion; saving the form stores the scenario as `adhoc`. */ + | { kind: "per_place"; state: AdHocScenarioState } + /** Variables and Parameters only (`places: {}`); the caller keeps the code body verbatim. */ + | { kind: "code"; state: AdHocScenarioState; code: string }; + +type PerPlaceContent = Extract< + Scenario["initialState"], + { type: "per_place" } +>["content"]; + +/** + * The ids of the parameters the net has. An override for any other id (or + * every override, when the context carries no net parameters) is dropped on + * the way into the form: compilation skips such an override, but synthesis + * rejects it and the form has no row to clear it from. + */ +const knownParameterIds = (context: AdHocSynthesisContext): Set => + new Set(context.netParameters.map((parameter) => parameter.id)); + +/** + * One net-parameter entry per override key in `knownIds`, expression + * verbatim. + */ +const netParameterEntries = ( + scenario: Scenario, + knownIds: ReadonlySet, +): AdHocNetParameter[] => + Object.entries(scenario.parameterOverrides) + .filter(([parameterId]) => knownIds.has(parameterId)) + .map(([parameterId, expression]) => ({ + parameterId, + expression, + optimize: null, + })); + +/** + * Whether a stored place block still fits its place as the net has it: an + * uncoloured block on a place without a colour, a coloured block on a place + * whose colour the net knows. + */ +const matchesPlace = ( + place: Place, + block: AdHocPlaceState, + types: readonly Color[], +): boolean => + block.kind === "uncoloured" + ? place.colorId === null + : types.some((type) => type.id === place.colorId); + +/** + * The stored form state minus the entries the net no longer matches: an + * override for a parameter it lacks, a block for a place it lacks, and a + * block whose kind no longer fits its place (coloured on a place without a + * colour the net knows, uncoloured on one that gained a colour). Synthesis + * rejects the first three and the form would show the last as a count on a + * coloured place, with nowhere to clear any of them from: its rows and place + * blocks come from the net. A dropped place reads back as the empty block + * of the net's kind, so dropping is the reset. + */ +const storedAdHocState = ( + content: AdHocScenarioState, + context: AdHocSynthesisContext, +): AdHocScenarioState => { + const parameterIds = knownParameterIds(context); + const placeById = new Map(context.places.map((place) => [place.id, place])); + return { + ...content, + netParameters: content.netParameters.filter(({ parameterId }) => + parameterIds.has(parameterId), + ), + places: Object.fromEntries( + Object.entries(content.places).filter(([placeId, block]) => { + const place = placeById.get(placeId); + return place !== undefined && matchesPlace(place, block, context.types); + }), + ), + }; +}; + +/** + * Per-place content as form blocks, walked in the net's place order so a + * place id the net does not know is dropped. An uncoloured place's + * expression becomes its count verbatim (empty stays empty: both compile to + * 0). A coloured place's rows become fixed rows of literal cells in + * colour-element order; a row shorter than the colour takes the neutral + * expression for its missing cells, a row wider than it loses the extra + * values. A coloured place without a colour, and content whose shape does + * not match the place's kind, is dropped — compilation rejects both today. + */ +const perPlaceStates = ( + content: PerPlaceContent, + context: { places: Place[]; types: Color[] }, +): Record => { + const places: Record = {}; + for (const place of context.places) { + const value = Object.prototype.hasOwnProperty.call(content, place.id) + ? content[place.id] + : undefined; + if (value === undefined) { + continue; + } + if (typeof value === "string") { + if (place.colorId !== null) { + continue; + } + places[place.id] = { + kind: "uncoloured", + count: { expression: value, optimize: null }, + }; + continue; + } + const colour = context.types.find((type) => type.id === place.colorId); + if (!colour) { + continue; + } + places[place.id] = { + kind: "coloured", + variables: [], + rows: value.map((row) => ({ + kind: "fixed", + cells: colour.elements.map((element, index) => { + const cell = row[index]; + return { + expression: + cell === undefined + ? adHocNeutralExpression(element.type) + : literalExpression(cell), + optimize: null, + }; + }), + })), + sharedColumns: {}, + }; + } + return places; +}; + +/** + * An `adhoc` scenario keeps its stored definition, minus the overrides, + * places and place blocks the net no longer matches + * ({@link storedAdHocState}). In the other formats, scenario parameters + * become exposed top-level Variables named by their identifier verbatim + * (schema identifiers are snake_case, so `adHocExposedParameterIdentifier` + * is the identity) with the default as a literal (`true`/`false` for + * booleans); parameter overrides become one net-parameter entry per key the + * net knows, expression verbatim ({@link netParameterEntries}); per-place + * content converts through {@link perPlaceStates}. Places absent from the + * content stay absent: in both formats an absent place keeps the canvas + * marking. + */ +export const adHocStateFromScenario = ( + scenario: Scenario, + context: AdHocSynthesisContext, +): AdHocStateFromScenario => { + const { initialState } = scenario; + if (initialState.type === "adhoc") { + return { + kind: "adhoc", + state: storedAdHocState(initialState.content, context), + }; + } + const knownIds = knownParameterIds(context); + const variables = classicRunVariables(scenario, {}); + const netParameters = netParameterEntries(scenario, knownIds); + if (initialState.type === "code") { + return { + kind: "code", + state: { variables, netParameters, places: {} }, + code: initialState.content, + }; + } + return { + kind: "per_place", + state: { + variables, + netParameters, + places: perPlaceStates(initialState.content, context), + }, + }; +}; diff --git a/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/helpers.ts b/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/helpers.ts index 681b19adec2..c22698cf087 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/helpers.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/helpers.ts @@ -1,6 +1,7 @@ /** * Helper functions available to user-authored scenario code (parameter - * override expressions and "Define as code" initial state). + * override expressions, the scenario form's cells and code-mode initial + * state). * * Scenario code compiles through the HIR: `range(...)` lowers to a * `rangeCall` node and the interpreter (`hir/interpret.ts`) calls the diff --git a/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts b/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts index 5694265cc02..249fb42cb20 100644 --- a/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts +++ b/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts @@ -252,7 +252,7 @@ export type AdHocScenarioState = { variables: AdHocVariable[]; /** Overrides for net parameters; empty expression keeps the default. */ netParameters: AdHocNetParameter[]; - /** Keyed by `Place.id`; places absent here keep an empty initial state. */ + /** Keyed by `Place.id`; places absent here keep the canvas marking. */ places: Record; }; diff --git a/libs/@hashintel/petrinaut/docs/README.md b/libs/@hashintel/petrinaut/docs/README.md index 669cc299283..52208580e84 100644 --- a/libs/@hashintel/petrinaut/docs/README.md +++ b/libs/@hashintel/petrinaut/docs/README.md @@ -34,7 +34,7 @@ Petrinaut has three global modes in the top bar, though **Actual** is only enabl - [Useful Patterns](useful-patterns.md) -- Common modelling techniques, including duration and resource pools. - [Simulation](simulation.md) -- Set initial state, run a single simulation, use the timeline, control playback. - [Scenarios](scenarios.md) -- Save and switch between named simulation configurations. -- [Ad-hoc Scenarios](ad-hoc-scenarios.md) -- Define initial state and parameters inline for one run, without saving a scenario. +- [Ad-hoc Scenarios](ad-hoc-scenarios.md) -- The scenario form: define initial state and parameters inline for one run, or save them as a scenario. - [Experiments](experiments.md) -- Run Monte Carlo batches and inspect token-count distributions over time. - [Actual Mode](actual-mode.md) -- View a host-provided live Petri net execution, currently via Brunch. - [Embedded Preview](preview.md) -- Explore a compact, read-only Petri net embedded in a host application. diff --git a/libs/@hashintel/petrinaut/docs/ad-hoc-scenarios.md b/libs/@hashintel/petrinaut/docs/ad-hoc-scenarios.md index 3f943786801..4d808a93811 100644 --- a/libs/@hashintel/petrinaut/docs/ad-hoc-scenarios.md +++ b/libs/@hashintel/petrinaut/docs/ad-hoc-scenarios.md @@ -2,31 +2,27 @@ An **ad-hoc scenario** is an initial state and a set of parameter values defined inline, right where you run -- without saving a [scenario](scenarios.md) first. Petrinaut compiles what you enter through a scenario generated for that run. Nothing is added to the net's scenario list, and leaving the form discards nothing: your entries stay until you clear them. -Use an ad-hoc scenario for one-off runs and quick exploration. When you want to keep a configuration, name it, or compare several setups, [create a scenario](scenarios.md#creating-a-scenario) -- with the feature enabled, the creation form is the same ad-hoc form, plus a **Scenario Parameter** toggle on each Variable. - -## Enabling the feature - -Ad-hoc scenarios are **experimental and off by default**. Turn them on in the viewport settings dialog (the gear button over the canvas): the **Ad-hoc scenarios** toggle under General. While the setting is off, every surface below renders exactly as before the feature -- "No scenario" simply means the model's own initial marking. +Use an ad-hoc scenario for one-off runs and quick exploration. When you want to keep a configuration, name it, or compare several setups, [create a scenario](scenarios.md#creating-a-scenario) -- the creation form is the same form, plus a **Scenario Parameter** toggle on each Variable. ## Where the form appears -The same form appears in three places, always when **no scenario is selected**: +The same form appears in three places: -1. **Quick simulation** -- in the [Simulation Settings](simulation.md#simulation-settings) tab, with "No scenario" selected, the **Parameters** and **Initial state** columns are the form's own tables: parameter overrides as a spreadsheet on the left, token counts and values in the middle -- no separate dialog. A **Clear** button appears next to the Initial state title once you have entries. There are no Variables in this embedding. The next simulation run uses what you defined. Any [compile error](#errors) appears in the settings panel's error banner. +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). -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 [Saved ad-hoc scenarios](#saved-ad-hoc-scenarios). +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 The form has up to three sections. Variables come first -- parameter overrides may read them: - **Variables** -- named values (real, integer, boolean, or ratio -- a real between 0 and 1) written as `scenario.` in every expression below, exactly as scenario parameters are written in scenario code. Use them to drive many values from one number. Add one from the dimmed **Add a variable** line at the bottom of the list: like any cell, a first click selects it and a second click (or Enter, or its gutter's `+`) adds the variable -- or reach it with the down arrow from the last row; the fresh name opens ready to type. Each row starts with a small variable-glyph gutter whose menu offers **Delete variable**, and the add line's gutter shows a `+`. A variable's name edits like any other cell: select it, then press Enter (or click again) to edit, and Enter or Escape to leave. Its type select is a cell too: arrow keys move past it, Enter opens it. In the quick-simulation embedding, Variables sit above Parameters in the left column. -- **Parameters** -- one row per [net-level parameter](petri-net-extensions.md#global-parameters), showing its type and its value. An untouched parameter shows its default quietly, marked with a small `default` tag; enter an expression to override the value for this run -- it may read the Variables above. In the quick-simulation embedding this section is its own panel beside Initial state. +- **Parameters** -- one row per [net-level parameter](petri-net-extensions.md#global-parameters), showing its type and its value. An untouched parameter shows its default quietly, marked with a small `default` tag; enter an expression to override the value for this run -- it may read the Variables above. In the quick-simulation embedding this section sits under Variables in the left column, beside Initial state. - **Initial state** -- one block per place in the net. Each place's title carries its token colour dot (grey for untyped places). 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) 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 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. 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. @@ -86,15 +82,15 @@ Each selection becomes a swept parameter of the experiment with a deterministic 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. -A saved scenario shown through the form in the experiment drawer offers the same toggle on each numeric scenario parameter row, so its parameters sweep exactly as they do in the classic rows. +A saved scenario shown through the form in the experiment drawer offers the same toggle on each numeric scenario parameter row. -## Saved ad-hoc scenarios +## Saving a scenario from the form -With the feature enabled, [creating a scenario](scenarios.md#creating-a-scenario) opens the same form -- name and description above it -- as the one authoring surface (there is no "Define as code" toggle in this mode). Each top-level Variable's row carries a **Scenario Parameter** toggle: an exposed Variable becomes one of the saved scenario's tunable parameters, named after the Variable in snake_case (`baseLoad` becomes `base_load`), defaulting to its expression's value -- which must therefore be a constant, and between 0 and 1 for a ratio Variable, which becomes a ratio parameter. Everyone running the scenario can then adjust it wherever scenario parameters appear, without editing the scenario. +[Creating a scenario](scenarios.md#creating-a-scenario) opens the same form -- name and description above it -- as the one authoring surface. Each top-level Variable's row carries a **Scenario Parameter** toggle: an exposed Variable becomes one of the saved scenario's tunable parameters, named after the Variable in snake_case (`baseLoad` becomes `base_load`), defaulting to its expression's value -- which must therefore be a constant, and between 0 and 1 for a ratio Variable, which becomes a ratio parameter. Everyone running the scenario can then adjust it wherever scenario parameters appear, without editing the scenario. Saving keeps your form entries as the scenario's definition, so editing the scenario reopens exactly the form you left. -Selecting a saved ad-hoc scenario in Simulation Settings shows it through the same form, read-only: only the scenario parameters (the exposed Variables) take value edits, for that run alone; auxiliary Variables stay hidden, and the parameter overrides and initial state can be browsed with the usual keyboard navigation but not changed. A scenario authored this way always edits through the ad-hoc form, whatever the setting says -- the classical form cannot represent it. +Selecting a saved ad-hoc scenario in Simulation Settings shows it through the same form, read-only: only the scenario parameters (the exposed Variables) take value edits, for that run alone; auxiliary Variables stay hidden, and the parameter overrides and initial state can be browsed with the usual keyboard navigation but not changed. Editing any scenario opens this form: a scenario saved per place by an earlier version or by the AI assistant opens converted, and saving stores it in the form's format; a scenario that defines its initial state as code keeps that code, shown read-only -- edit its name, description, Variables and Parameters here, or recreate it from the form with a Dynamic row (see [Scenarios](scenarios.md#scenarios-stored-as-code)). Such a scenario stores no form entries, so every Variable must be marked **Scenario Parameter** to be kept -- the form refuses to save one that is not. ## Errors diff --git a/libs/@hashintel/petrinaut/docs/examples.md b/libs/@hashintel/petrinaut/docs/examples.md index 2bc31b82342..2853ba57aa3 100644 --- a/libs/@hashintel/petrinaut/docs/examples.md +++ b/libs/@hashintel/petrinaut/docs/examples.md @@ -171,12 +171,12 @@ An orbital mechanics simulation: satellites are continuously launched into orbit - **`Distribution.map()` for coordinate conversion** -- a uniform launch angle is sampled once, then `.map()` derives both `x` (cosine) and `y` (sine) from the same underlying sample for a coherent polar-to-cartesian position. - **Predicate transitions based on geometry** -- "Collision" checks the distance between two satellites and "Crash" checks distance from the planet's surface, routing tokens to the Debris place. - **Arc weight 2** on the "Collision" transition -- it consumes two satellites from the Space place at once to evaluate pairwise proximity. -- **Scenarios** -- _Moon Orbit_ (low gravity, gentle arcs) and _Earth Orbit_ (high orbital velocities, frequent launches) preconfigure the gravitational constant, planet radius, and launch parameters. _Pre-deployed Constellation_ defines its initial state [as code](scenarios.md#code-mode-define-as-code), building a ring of satellites with `range(...).map(...)` from two scenario parameters (`number_of_satellites`, `initial_altitude`). Each satellite starts tangentially at circular-orbit speed, so the whole ring stays in orbit from the first frame. +- **Scenarios** -- _Moon Orbit_ (low gravity, gentle arcs) and _Earth Orbit_ (high orbital velocities, frequent launches) preconfigure the gravitational constant, planet radius, and launch parameters. _Pre-deployed Constellation_ defines its initial state [as code](scenarios.md#scenarios-stored-as-code), building a ring of satellites with `range(...).map(...)` from two scenario parameters (`number_of_satellites`, `initial_altitude`). Each satellite starts tangentially at circular-orbit speed, so the whole ring stays in orbit from the first frame. - **[Metrics](simulation.md)** -- satellites in orbit, debris objects, average orbital radius, and average orbital speed. Its **Average orbital radius** and **Average orbital speed** metrics reduce over the satellites' attributes and compile to the GPU as loops. **Suggested initial state:** no initial tokens needed -- pick a scenario (e.g. _Earth Orbit_) and press Play. The "LaunchSatellite" source transition creates satellites with randomized orbital positions and velocities. Select the Space place and open the visualizer preview to watch the orbits fill up. The velocity for a roughly circular orbit at radius `r` is approximately `sqrt(gravitational_constant / r)`. -**Bundled extras:** five scenarios -- **Moon Orbit**, **Earth Orbit**, **Mars Orbit**, and **Solar Orbit** tune the physical constants for very different orbital regimes, and **Pre-deployed Constellation** starts with a configurable ring of satellites already in orbit (its initial state is authored in code mode). Switch between them in Simulation Settings to compare. +**Bundled extras:** five scenarios -- **Moon Orbit**, **Earth Orbit**, **Mars Orbit**, and **Solar Orbit** tune the physical constants for very different orbital regimes, and **Pre-deployed Constellation** starts with a configurable ring of satellites already in orbit (its initial state is stored as code). Switch between them in Simulation Settings to compare. **Key concepts:** [dynamics](petri-net-extensions.md#differential-equations-dynamics), [visualizers](petri-net-extensions.md#visualizer), [source transitions](useful-patterns.md#source-transitions-exogenous-arrivals), [distributions and `.map()`](petri-net-extensions.md#distributions), [arc weight](useful-patterns.md#arc-weight-for-multi-token-operations), [scenarios](scenarios.md). diff --git a/libs/@hashintel/petrinaut/docs/experiments.md b/libs/@hashintel/petrinaut/docs/experiments.md index b76bad6521a..4624cdc0b65 100644 --- a/libs/@hashintel/petrinaut/docs/experiments.md +++ b/libs/@hashintel/petrinaut/docs/experiments.md @@ -13,15 +13,15 @@ Experiments live under the **Simulate** [global mode](drawing-a-net.md#global-mo ### Configuration -| Setting | Default | Notes | -| ----------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **Name** | `Experiment` | Free text. | -| **Scenario** | `(Default)` | Either `(Default)` (no scenario; uses each place's manually-set initial marking and net-level parameter defaults) 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 a **Sweep** toggle — 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 as before. 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 a **Sweep** toggle -- see [Sweep selections](ad-hoc-scenarios.md#sweep-selections-experiments-only). -With a scenario selected (and ad-hoc scenarios enabled), 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 same **Sweep** toggle the classic rows offer, 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 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. | **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. | @@ -73,7 +73,7 @@ Two consequences worth knowing: 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. -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 [ad-hoc scenarios](ad-hoc-scenarios.md) enabled, the same toggle sits on every numeric value of the ad-hoc form -- 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 **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. 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: @@ -88,7 +88,7 @@ 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. 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 **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. 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. diff --git a/libs/@hashintel/petrinaut/docs/scenarios.md b/libs/@hashintel/petrinaut/docs/scenarios.md index 1b2014debae..b45f9146f66 100644 --- a/libs/@hashintel/petrinaut/docs/scenarios.md +++ b/libs/@hashintel/petrinaut/docs/scenarios.md @@ -9,9 +9,9 @@ Scenarios live under the **Simulate** [global mode](drawing-a-net.md#global-mode A scenario has four parts: 1. **Name** and optional description. -2. **Scenario parameters** -- numeric variables scoped to this scenario. Referenced in code as `scenario.`. -3. **Parameter bindings** -- expressions that override the default value of each net-level parameter, for this scenario only. -4. **Initial state** -- the starting marking of each place. Authored either per-place or as a single function (see below). +2. **Scenario parameters** -- numeric variables scoped to this scenario. Referenced in expressions as `scenario.`. +3. **Parameter overrides** -- expressions that override the default value of net-level parameters, for this scenario only (the form's **Parameters** section). +4. **Initial state** -- the starting marking of each place, authored in the scenario form: one block per place, every value an expression. You can save as many scenarios as you like; they are stored on the net alongside places, transitions, and parameters. @@ -29,35 +29,31 @@ You will need scenarios when you want to: 1. Switch to **Simulate** mode and open the **Scenarios** tab. 2. Click **Create**. The Create Scenario drawer opens. -3. Fill in **Name** (required, must be unique among scenarios) and an optional description. -4. Add **Scenario parameters** if you need variables scoped to this scenario. Each parameter has an identifier (snake_case, lowercase; the form auto-converts on blur), a type (Real / Integer / Boolean / Ratio), and a default value. Ratios are clamped to `[0, 1]`; booleans are stored as `1` or `0` and exposed to expressions as `true` or `false`. -5. Set **Parameter bindings** for any net-level parameters whose default you want to override. Each binding is a TypeScript expression; leaving it empty keeps the net default (shown in the placeholder). -6. Configure **Initial state** for each place that should start with tokens. -7. Click **Create**. Save is blocked while the form has validation or LSP errors -- hover the disabled button to see why. +3. Fill in **Scenario name** (required, unique among scenarios) and an optional description. +4. Add **Variables** -- one per value you want to drive from a single number, written `scenario.` in every expression below. Turn **Scenario Parameter** on to expose a Variable as a tunable parameter of the saved scenario: it needs a snake_case name, a constant expression as its default, and a value between 0 and 1 for a ratio. +5. Fill in **Parameters** -- an expression per net-level parameter whose default you want to override; the `default` tag marks the untouched ones. +6. Configure **Initial state** -- a count expression per untyped place, rows of cells per typed place (a Dynamic row builds many tokens from one count). See [Ad-hoc Scenarios](ad-hoc-scenarios.md#the-form) for the form itself. +7. Click **Create**. It is disabled while the name or any value has an error -- hover it to read the first. The view drawer opens from the Scenarios list, which works like the other Simulate-mode lists: the first click selects a row, and a click on the selected row (or Enter) opens it. The list is a single Tab stop whose rows the arrow keys walk. The drawer shows the same form populated with the existing values, with **Close** and **Save** buttons. -With the experimental [Ad-hoc scenarios](ad-hoc-scenarios.md#enabling-the-feature) setting on, the Create Scenario drawer instead shows the [ad-hoc form](ad-hoc-scenarios.md#saved-ad-hoc-scenarios): name and description above one inline Initial State + Parameters form, with a **Scenario Parameter** toggle on each Variable and no "Define as code" toggle. A scenario created that way always edits through the same form. +## Expression language -## Initial state: per-place vs code +Every value in the form is an expression: `parameters` (net-level) and `scenario` (this scenario's Variables) are in scope, along with the `range` helper: -The Initial State section has a **Define as code** toggle. - -### Per-place mode (default) - -You see a row per place. Which places appear depends on the **Show all places** toggle: +- `range(end)` -- integers from `0` (inclusive) to `end` (exclusive): `range(3)` is `[0, 1, 2]`. +- `range(start, end)` -- from `start` (inclusive) to `end` (exclusive). +- `range(start, end, step)` -- stepping by `step`; a negative step counts down. -- Off (default): only places whose **Default starting place** flag is on (configured in the place's properties panel). If no places are marked, the section is empty and shows a hint. -- On: every place in the net, with default-starting places listed first. +`range` mirrors Python's `range` and is handy with `.map` for building token arrays, e.g. `range(scenario.number_of_satellites).map((i) => ({ x: 10 * i, y: 10 * i }))`. A single `range` call is capped at 1,000,000 elements; larger calls fail with an error rather than freezing the editor. -What you enter per place depends on whether it has a type: +Scenario code compiles through the same restricted TypeScript subset as the other code surfaces (lambdas, kernels, dynamics, metrics) — it never runs as raw JavaScript. The subset covers `const` bindings, arithmetic and comparisons, ternaries and guard `if`s, `Math.*`, object and array literals, `.map(...)` (with an optional index parameter), `.reduce(...)`, `.concat(...)`, `range(...)`, and `Array.from({ length: n }, ...)`. Other constructs — loops, `.filter`/`.slice`/spread, template literals, `let` — are rejected with an error pointing at the offending code. -- **Uncoloured places**: a single-line TypeScript expression that evaluates to the token count. The result is rounded down and clamped to `>= 0`. You can reference `parameters.` and `scenario.`, plus the `range` helper described under [Code mode](#code-mode-define-as-code). Empty/missing means zero tokens. -- **Coloured places**: a small spreadsheet, one row per token, one column per element of the place's type. Cell values are literal values matching each column's type — numbers for Real/Integer, true/false for Boolean, free text for String, and identifiers for UUID; expressions are not supported in the spreadsheet. UUID columns accept any text: a UUID string is used as-is, and any other text (e.g. `order-1`) is converted deterministically to a UUID, so the same text always produces the same identifier. If you later edit the type itself, existing rows follow along: added elements get a default column, removed elements' columns are dropped, reordered elements keep their values, and changing an element's type converts each stored value (falling back to the new type's default when a value can't be converted). Each spreadsheet is a single Tab stop with arrow-key movement (arrows also flow from one place's spreadsheet into the next), click-to-select then click-to-edit cells, and a row-number column where Delete removes the row. +The subset is strict about booleans and equality: conditions and `&&`/`||` take booleans (write `parameters.x > 0`, not `parameters.x`), `==` is strict (comparing a boolean with a number is flagged as always false — use the boolean directly, e.g. `scenario.enabled ? 1 : 0`), and arithmetic takes numbers. -### Code mode (Define as code) +## Scenarios stored as code -You write a single function body (no `function` keyword, no `export default`) that returns an object keyed by **place name**: +Net files, the AI assistant and earlier versions of Petrinaut may store a scenario's initial state per place (one expression or one token spreadsheet per place) or as a single code block. Both run unchanged, and both preview as computed rows in Simulation Settings and the experiment drawer. Editing opens each in the form: a per-place scenario opens converted -- its parameters as exposed Variables, its expressions and rows as the form's blocks -- and saving stores it in the form's format; a code scenario opens with its name, description, Variables and Parameters editable and its code shown read-only in the Initial state slot -- edit its values here, change the code from the AI assistant or the net file, or recreate the scenario from the form (a Dynamic row builds many tokens from one count). A code scenario stores no form entries, only its scenario parameters: every Variable must be marked **Scenario Parameter** (the form refuses to save one that is not), and each is kept as its computed default. The code is a function body that returns an object keyed by **place name** -- a number for an untyped place (rounded, clamped to `>= 0`), an array of token objects for a typed one -- with `parameters`, `scenario` and `range` in scope; a key that is not a place name is a compile error, so a typo'd name fails the scenario instead of being silently ignored: ```ts return { @@ -68,31 +64,9 @@ return { }; ``` -`parameters` (net-level) and `scenario` (this scenario's parameters) are in scope, along with the `range` helper: - -- `range(end)` -- integers from `0` (inclusive) to `end` (exclusive): `range(3)` is `[0, 1, 2]`. -- `range(start, end)` -- from `start` (inclusive) to `end` (exclusive). -- `range(start, end, step)` -- stepping by `step`; a negative step counts down. - -`range` mirrors Python's `range` and is handy with `.map` for building token arrays, e.g. `range(scenario.number_of_satellites).map((i) => ({ x: 10 * i, y: 10 * i }))`. A single `range` call is capped at 1,000,000 elements; larger calls fail with an error rather than freezing the editor. - -Scenario code compiles through the same restricted TypeScript subset as the other code surfaces (lambdas, kernels, dynamics, metrics) — it never runs as raw JavaScript. The subset covers `const` bindings, arithmetic and comparisons, ternaries and guard `if`s, `Math.*`, object and array literals, `.map(...)` (with an optional index parameter), `.reduce(...)`, `.concat(...)`, `range(...)`, and `Array.from({ length: n }, ...)`. Other constructs — loops, `.filter`/`.slice`/spread, template literals, `let` — are rejected with an error pointing at the offending code. - -The subset is strict about booleans and equality: conditions and `&&`/`||` take booleans (write `parameters.x > 0`, not `parameters.x`), `==` is strict (comparing a boolean with a number is flagged as always false — use the boolean directly, e.g. `scenario.enabled ? 1 : 0`), and arithmetic takes numbers. - -For each returned key: - -- An **uncoloured** place takes a number (rounded, clamped to `>= 0`). -- A **coloured** place takes an array of token objects, with one property per type element. - -> Place keys are **names** in code mode, but **IDs** in per-place mode. This asymmetry is by design. -> A key that is not a place name is a compile error ("`` is not a place in this net"), so a typo'd name fails the scenario instead of being silently ignored. - -The TypeScript editor type-checks against the current net's place names and types as you write, so unrecognised names show up as compile errors before save. Leaving the code editor empty is not an error: empty code defines no scenario-specific initial state, so every place keeps the initial marking entered manually on the canvas. Note that this differs from per-place mode, where clearing a place's expression sets that place to **zero** tokens rather than leaving it alone. - -## Parameter bindings +## Parameters -Each net-level parameter gets one row. The placeholder shows that parameter's default. A bound expression replaces the default whenever this scenario is active. +Each net-level parameter gets one row in the form's **Parameters** section. An untouched row shows the parameter's default with a `default` tag; enter an expression to replace it whenever this scenario is active. Common patterns: @@ -100,13 +74,13 @@ Common patterns: - Derived from a scenario parameter: `scenario.peak_demand * 1.2` - Combination of both: `parameters.base_rate * scenario.surge_multiplier` -Bindings are evaluated once at the start of each run, before the initial state is computed, so you can safely reference parameter values from inside initial-state expressions or code. +Overrides are evaluated once at the start of each run, before the initial state is computed, so you can safely reference parameter values from inside initial-state expressions or code. ## Running a scenario In **Edit** mode, open **Simulation Settings** (bottom panel). The **Scenario** dropdown lists "No scenario" plus every saved scenario. While a scenario is selected: -- The **Parameters** section in Simulation Settings shows the **scenario parameters** (with the scenario's defaults pre-filled). Adjust them per run; net-level parameter values are not editable here, since they are fixed by the scenario's bindings. +- The form shows the **scenario parameters** editable on the left (with the scenario's defaults pre-filled); the parameter overrides and initial state sit read-only on the right. Adjust the parameters per run; net-level parameter values are fixed by the scenario's overrides. - The Properties panel **State** sub-view for each place becomes read-only ("Defined by scenario"). - Pressing **Play** runs the simulation with the scenario's overrides and initial state. @@ -122,6 +96,6 @@ Several of the built-in examples ship with scenarios so you can see realistic co - **SIR Epidemic Model** -- "Seasonal Flu" and "High Virulence Outbreak", driven by `population` and `infected_ratio` scenario parameters plus parameter overrides for infection and recovery rates. - **Production Machines** -- "Default Production", driven by `raw_material`, `machines_count`, and `initial_machine_damage`. -- **Probabilistic Satellites Launcher** -- four orbit scenarios (Moon, Earth, Mars, Solar), plus "Pre-deployed Constellation", which authors its initial state in code mode: `range(scenario.number_of_satellites).map(...)` builds a ring of satellites at a configurable altitude, each already travelling at circular-orbit speed so the ring holds its orbit as soon as you press play. +- **Probabilistic Satellites Launcher** -- four orbit scenarios (Moon, Earth, Mars, Solar), plus "Pre-deployed Constellation", which defines its initial state as code: `range(scenario.number_of_satellites).map(...)` builds a ring of satellites at a configurable altitude, each already travelling at circular-orbit speed so the ring holds its orbit as soon as you press play. -Loading any of these examples is the fastest way to see a working scenario authored in both modes. +Loading any of these examples is the fastest way to see working scenarios, including one stored as code. diff --git a/libs/@hashintel/petrinaut/docs/simulation.md b/libs/@hashintel/petrinaut/docs/simulation.md index 06e458b29a2..e607ac78cee 100644 --- a/libs/@hashintel/petrinaut/docs/simulation.md +++ b/libs/@hashintel/petrinaut/docs/simulation.md @@ -33,8 +33,8 @@ Quick-action buttons next to the picker let you edit the selected scenario, crea Override values for this run: -- With **No scenario** selected: each [net-level parameter](petri-net-extensions.md#global-parameters) shows its name and variable name. Boolean parameters use a toggle; real and integer parameters use a numeric input pre-filled with the default. -- With a scenario selected: the **scenario parameters** are shown instead, pre-filled with that scenario's defaults. Net-level parameter values are fixed by the scenario's [parameter bindings](scenarios.md#parameter-bindings) and are not editable here. Every selected scenario shows through the [ad-hoc form](ad-hoc-scenarios.md): its scenario parameters take value edits in the left column, and its parameter overrides and initial state sit read-only in the right one -- browsable with the same keyboard navigation, but only a scenario edit (the pencil next to the picker) changes them. A scenario saved from the ad-hoc form shows its definition; any other scenario shows a computed preview of the exact tokens the run will start with, recomputed as you change parameter values (very large places preview their first 100 rows). +- With **No scenario** selected: the form's **Parameters** table -- an expression per [net-level parameter](petri-net-extensions.md#global-parameters), the default shown with a `default` tag until you override it; expressions may read the Variables above as `scenario.`. +- With a scenario selected: the **scenario parameters** are shown instead, pre-filled with that scenario's defaults. Net-level parameter values are fixed by the scenario's [parameter overrides](scenarios.md#parameters) and are not editable here. Every selected scenario shows through the [ad-hoc form](ad-hoc-scenarios.md): its scenario parameters take value edits in the left column, and its parameter overrides and initial state sit read-only in the right one -- browsable with the same keyboard navigation, but only a scenario edit (the pencil next to the picker) changes them. A scenario saved from the ad-hoc form shows its definition; any other scenario shows a computed preview of the exact tokens the run will start with, recomputed as you change parameter values (very large places preview their first 100 rows). Changes here do not modify the parameter definition or the scenario -- they only apply to the simulation. Parameter values are locked while a simulation is running. Reset the simulation to change them. diff --git a/libs/@hashintel/petrinaut/docs/visual-settings.md b/libs/@hashintel/petrinaut/docs/visual-settings.md index 7ad61295ebb..575709edad7 100644 --- a/libs/@hashintel/petrinaut/docs/visual-settings.md +++ b/libs/@hashintel/petrinaut/docs/visual-settings.md @@ -46,10 +46,6 @@ Controls selection box behavior in [Select mode](drawing-a-net.md#pan-and-select - **Enabled** -- nodes that are only partially inside the selection box are selected. - **Disabled** -- nodes must be fully enclosed to be selected. -### Ad-hoc scenarios (experimental) - -Off by default. Enables the [ad-hoc scenario form](ad-hoc-scenarios.md): defining initial state and parameters inline in Simulation Settings, the experiment drawer, and the scenario creation form. Off, "No scenario" everywhere means the model's own initial marking, as before. - ### WebGPU (experimental) Off by default. Offers a GPU option when creating an experiment; each experiment then chooses its own backend. See [Compute backend](experiments.md#compute-backend-experimental). diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-lsp.ts b/libs/@hashintel/petrinaut/src/react/hooks/use-lsp.ts index 651460a2650..8548d115cc2 100644 --- a/libs/@hashintel/petrinaut/src/react/hooks/use-lsp.ts +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-lsp.ts @@ -37,9 +37,6 @@ export type LspActionsBundle = { requestCompletion: LanguageClientContextValue["requestCompletion"]; requestHover: LanguageClientContextValue["requestHover"]; requestSignatureHelp: LanguageClientContextValue["requestSignatureHelp"]; - initializeScenarioSession: LanguageClientContextValue["initializeScenarioSession"]; - updateScenarioSession: LanguageClientContextValue["updateScenarioSession"]; - killScenarioSession: LanguageClientContextValue["killScenarioSession"]; initializeMetricSession: LanguageClientContextValue["initializeMetricSession"]; updateMetricSession: LanguageClientContextValue["updateMetricSession"]; killMetricSession: LanguageClientContextValue["killMetricSession"]; @@ -53,9 +50,6 @@ export function useLspActions(): LspActionsBundle { requestCompletion: ctx.requestCompletion, requestHover: ctx.requestHover, requestSignatureHelp: ctx.requestSignatureHelp, - initializeScenarioSession: ctx.initializeScenarioSession, - updateScenarioSession: ctx.updateScenarioSession, - killScenarioSession: ctx.killScenarioSession, initializeMetricSession: ctx.initializeMetricSession, updateMetricSession: ctx.updateMetricSession, killMetricSession: ctx.killMetricSession, diff --git a/libs/@hashintel/petrinaut/src/react/lsp/context.ts b/libs/@hashintel/petrinaut/src/react/lsp/context.ts index 92d2054aa77..4d1defa8b2a 100644 --- a/libs/@hashintel/petrinaut/src/react/lsp/context.ts +++ b/libs/@hashintel/petrinaut/src/react/lsp/context.ts @@ -22,7 +22,6 @@ import type { AdHocSessionParams, ConstraintSessionParams, MetricSessionParams, - ScenarioSessionParams, } from "@hashintel/petrinaut-core/workers/lsp"; export interface LanguageClientContextValue { @@ -85,12 +84,6 @@ export interface LanguageClientContextValue { source: ConstraintSource, context: LowerConstraintContext, ) => Promise; - /** Initialize a temporary scenario editing session. */ - initializeScenarioSession: (params: ScenarioSessionParams) => void; - /** Update a scenario editing session. */ - updateScenarioSession: (params: ScenarioSessionParams) => void; - /** Kill a scenario editing session. */ - killScenarioSession: (sessionId: string) => void; /** Initialize a temporary metric editing session. */ initializeMetricSession: (params: MetricSessionParams) => void; /** Starts an ad-hoc scenario editing session for expression type-checking */ @@ -151,9 +144,6 @@ export const DEFAULT_LANGUAGE_CLIENT_CONTEXT: LanguageClientContextValue = { }, ], }), - initializeScenarioSession: () => {}, - updateScenarioSession: () => {}, - killScenarioSession: () => {}, initializeMetricSession: () => {}, initializeAdHocSession: () => {}, updateAdHocSession: () => {}, diff --git a/libs/@hashintel/petrinaut/src/react/lsp/provider.tsx b/libs/@hashintel/petrinaut/src/react/lsp/provider.tsx index 8f92632c5ce..a2768342595 100644 --- a/libs/@hashintel/petrinaut/src/react/lsp/provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/lsp/provider.tsx @@ -113,9 +113,6 @@ export const LanguageClientProvider: React.FC<{ requestScenarioHir: client.requestScenarioHir, requestFormatExpression: client.requestFormatExpression, requestConstraint: client.requestConstraint, - initializeScenarioSession: client.initializeScenarioSession, - updateScenarioSession: client.updateScenarioSession, - killScenarioSession: client.killScenarioSession, initializeMetricSession: client.initializeMetricSession, initializeAdHocSession: client.initializeAdHocSession, updateAdHocSession: client.updateAdHocSession, diff --git a/libs/@hashintel/petrinaut/src/react/petrinaut-provider.tsx b/libs/@hashintel/petrinaut/src/react/petrinaut-provider.tsx index 275a4ee4c3b..9aeb6eee6be 100644 --- a/libs/@hashintel/petrinaut/src/react/petrinaut-provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/petrinaut-provider.tsx @@ -79,8 +79,6 @@ export const PetrinautProvider: React.FC = ({ workerFactory={lspWorkerFactory} > - {/* The simulation provider reads the Ad-hoc scenarios user - setting, which the document layer above provides. */} = ({ const navigation = usePetrinautNavigation(); const { extensions, petriNetDefinition } = sdcpnContext; const { addNotification } = use(NotificationsContext); - // Gates the inline ad-hoc definition end to end: with the setting off, a - // definition kept in state (typed while it was on) must not steer runs - // the pre-feature UI no longer shows it in. - const { enableAdHocScenarios } = use(UserSettingsContext); const petriNetDefinitionRef = useLatest(petriNetDefinition); const extensionsRef = useLatest(extensions); @@ -789,7 +784,7 @@ export const SimulationProvider: React.FC = ({ })) : []; const adHocSynthesized = - enableAdHocScenarios && !selectedScenario && stateValues.adHocScenario + !selectedScenario && stateValues.adHocScenario ? synthesizeAdHocScenario(stateValues.adHocScenario, { netParameters: adHocNetParameters, places: petriNetDefinition.places, 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 ca97d9c7329..2105099d553 100644 --- a/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts +++ b/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts @@ -53,12 +53,6 @@ export type UserSettings = { partialSelection: boolean; enableNetComponents: boolean; enableNotebookView: boolean; - /** - * Experimental: offer the ad-hoc scenario form — inline Initial State + - * Parameters — in Simulation Settings, experiments, optimizations, and - * scenario creation. Off, every surface renders as before the feature. - */ - enableAdHocScenarios: boolean; /** * Persisted preference controlling whether the product walkthrough opens * automatically the next time the app initializes. The live open state is @@ -130,7 +124,6 @@ export type UserSettingsActions = { setPartialSelection: (value: boolean) => void; setEnableNetComponents: (value: boolean) => void; setEnableNotebookView: (value: boolean) => void; - setEnableAdHocScenarios: (value: boolean) => void; setShowWalkthroughOnInit: (value: boolean) => void; setWebGpuEnabled: (value: boolean) => void; setShowCompilationOutput: (value: boolean) => void; @@ -166,7 +159,6 @@ export const defaultUserSettings: UserSettings = { partialSelection: true, enableNetComponents: false, enableNotebookView: false, - enableAdHocScenarios: false, showWalkthroughOnInit: true, webGpuEnabled: false, showCompilationOutput: false, @@ -201,7 +193,6 @@ export const defaultUserSettingsContextValue: UserSettingsContextValue = { setPartialSelection: () => {}, setEnableNetComponents: () => {}, setEnableNotebookView: () => {}, - setEnableAdHocScenarios: () => {}, setShowWalkthroughOnInit: () => {}, setWebGpuEnabled: () => {}, setShowCompilationOutput: () => {}, diff --git a/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.test.tsx b/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.test.tsx index 5eb597f60cd..6530852f3e8 100644 --- a/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.test.tsx @@ -3,19 +3,21 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; * @vitest-environment jsdom */ import { use } from "react"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { UserSettingsContext } from "./user-settings-context"; import { UserSettingsProvider } from "./user-settings-provider"; afterEach(cleanup); +const storageKey = "petrinaut:user-settings"; + // The provider persists every change, so a test's toggle would otherwise seed // the next test's initial state. Guarded: some Node versions expose a global // `localStorage` whose methods are missing. beforeEach(() => { try { - localStorage.removeItem("petrinaut:user-settings"); + localStorage.removeItem(storageKey); } catch { // No storage to reset. } @@ -30,6 +32,17 @@ const DemoModeProbe = ({ name }: { name: string }) => { ); }; +/** Reads a persisted setting and writes another, so a write happens on demand. */ +const WalkthroughProbe = () => { + const { showWalkthroughOnInit, brunchDemoMode, setBrunchDemoMode } = + use(UserSettingsContext); + return ( + + ); +}; + describe("UserSettingsProvider", () => { it("starts with Brunch demo mode off and toggles it", () => { render( @@ -43,6 +56,45 @@ describe("UserSettingsProvider", () => { expect(screen.getByRole("button", { name: "probe: on" })).toBe(probe); }); + it("loads a blob written with the retired Ad-hoc scenarios key and drops the key on the next write", () => { + // An in-memory store: some Node versions expose a global `localStorage` + // whose methods are missing, so the test owns the storage it inspects. + const entries = new Map([ + [ + storageKey, + JSON.stringify({ + enableAdHocScenarios: true, + showWalkthroughOnInit: false, + }), + ], + ]); + vi.stubGlobal("localStorage", { + getItem: (key: string) => entries.get(key) ?? null, + setItem: (key: string, value: string) => entries.set(key, value), + removeItem: (key: string) => entries.delete(key), + }); + + try { + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "walkthrough: off" })); + + const persisted = JSON.parse(entries.get(storageKey) ?? "{}") as Record< + string, + unknown + >; + expect("enableAdHocScenarios" in persisted).toBe(false); + expect(persisted.showWalkthroughOnInit).toBe(false); + expect(persisted.brunchDemoMode).toBe(true); + } finally { + vi.unstubAllGlobals(); + } + }); + it("reuses an ancestor provider, so a host and the editor share one state", () => { // The host mounts the provider above the editor and reads the settings // in its own components; the editor's own provider must not fork them. diff --git a/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx b/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx index 9c8fe669dff..7d04b508de2 100644 --- a/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx @@ -38,6 +38,11 @@ type PersistedUserSettings = Partial & { * went with the Optimizations tab. Dropped on the next write. */ enableOptimizationSurface?: boolean; + /** + * Gated the scenario form while it was experimental. The form is the only + * scenario form, so the key is dropped on the next write. + */ + enableAdHocScenarios?: boolean; }; const loadSettings = (): UserSettings => { @@ -50,6 +55,7 @@ const loadSettings = (): UserSettings => { computeBackend, useEntitiesTreeView: _useEntitiesTreeView, enableOptimizationSurface: _enableOptimizationSurface, + enableAdHocScenarios: _enableAdHocScenarios, ...parsed } = JSON.parse(raw) as PersistedUserSettings; return { @@ -116,8 +122,6 @@ const OwnedUserSettingsProvider: React.FC = ({ setState((prev) => ({ ...prev, enableNetComponents: value })), setEnableNotebookView: (value: boolean) => setState((prev) => ({ ...prev, enableNotebookView: value })), - setEnableAdHocScenarios: (value: boolean) => - setState((prev) => ({ ...prev, enableAdHocScenarios: value })), setShowWalkthroughOnInit: (value: boolean) => setState((prev) => ({ ...prev, showWalkthroughOnInit: value })), setWebGpuEnabled: (value: boolean) => 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 c27df83d5d9..a33613eb1cb 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 @@ -112,14 +112,12 @@ const Harness: React.FC<{ selection?: AdHocFormSelection; onState?: (state: AdHocScenarioState) => void; initial?: AdHocScenarioState; - withVariables?: boolean; renderLayout?: React.ComponentProps["renderLayout"]; mode?: React.ComponentProps["mode"]; }> = ({ selection = "optimize", onState, initial = EMPTY_AD_HOC_STATE, - withVariables, renderLayout, mode, }) => { @@ -133,7 +131,6 @@ const Harness: React.FC<{ }} context={context} selection={selection} - withVariables={withVariables} renderLayout={renderLayout} mode={mode} /> @@ -1097,22 +1094,6 @@ describe("AdHocScenarioForm", () => { expect(screen.getByText("1 + 0 … 10 tokens")).toBeTruthy(); }); - it("hides the Variables section when the embedding offers none", () => { - render(); - expect( - screen.queryByRole("button", { - name: "Add a variable (Top-level variables)", - }), - ).toBeNull(); - expect( - screen.queryByRole("button", { name: "Toggle Variables section" }), - ).toBeNull(); - // The other groups are untouched. - expect( - screen.getByRole("button", { name: "Add a token row (pressure)" }), - ).toBeTruthy(); - }); - it("the add-variable line selects on the first pointer click and materializes on the second", () => { let latest: AdHocScenarioState | undefined; render( @@ -1225,7 +1206,6 @@ describe("AdHocScenarioForm", () => { it("renderLayout columns: vertical arrows stay, horizontal ones cross with memory", () => { render( (
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 80e7f38f9fe..7e210c466c2 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 @@ -1,20 +1,21 @@ /** * @layerRoot ui.adhoc-form - * @role The inline Initial State + Parameters form compiling to a generated, never-persisted scenario + * @role The inline Initial State + Parameters form: the one scenario form, compiling to a generated scenario or persisting as a saved one * * The ad-hoc scenario form: define Initial State + Parameters inline and let * the caller compile them through `synthesizeAdHocScenario` (plain runs) or - * `synthesizeAdHocOptimization` (optimization). The generated scenario is - * never persisted; this component only edits `AdHocScenarioState`. + * `synthesizeAdHocOptimization` (optimization), or save them as a scenario + * (`initialState.type: "adhoc"`). The generated scenario is never persisted; + * this component only edits `AdHocScenarioState`. * - * Four consumers share it: Quick Simulation renders it with `selection` + * 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; optimizations render it with "optimize", - * which grows an Optimize toggle on every value slot; the scenario creation - * form renders it with "expose", which offers a "Scenario Parameter" toggle - * on each top-level Variable — the saved scenario exposes those Variables - * as its tunable parameters. + * 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, @@ -97,20 +98,12 @@ export interface AdHocScenarioFormProps { * everything else is read-only yet keyboard-navigable and selectable. */ mode?: AdHocFormMode; - /** - * Whether the Variables section is offered. Embeddings that provide no - * scenario Variables (quick simulation's Simulation Settings) turn it - * off; an expression referencing `scenario.` then fails as unknown, - * exactly as it should. The Parameters section hides itself the same way - * when the context carries no net parameters. - */ - withVariables?: boolean; /** * Custom arrangement: the host receives each group — already wired to the * form's contexts — and lays them out itself (e.g. Simulation Settings * places Variables + Parameters and Initial state in separate panel - * columns). The groups render without section chrome; a group the props - * withhold (`withVariables`, an empty `netParameters`) is `null`. The + * columns). The groups render without section chrome; the Parameters + * group is `null` when the context carries no net parameters. The * host's own chrome may render inside too — the wrapper only carries the * form's keyboard handling. Wrap each visual column of the layout in a * `FormLayoutColumn`: vertical arrows chain the column's groups, and @@ -177,7 +170,6 @@ export const AdHocScenarioForm: React.FC = ({ context, selection, mode = "author", - withVariables = true, renderLayout, className, sessionId: externalSessionId, @@ -320,13 +312,13 @@ export const AdHocScenarioForm: React.FC = ({ const variableRows = mode === "run" ? ( - ) : withVariables ? ( + ) : ( - ) : null; + ); const placesList = (
@@ -386,14 +378,12 @@ export const AdHocScenarioForm: React.FC = ({ ) : ( - {variableRows ? ( - - {variableRows} - - ) : null} + + {variableRows} + {parameterRows ? ( ({ + CodeEditor: ({ + onChange, + value, + }: { + onChange: (value: string | undefined) => void; + value?: string; + }) => ( +