Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/experiments-backend-registry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/petrinaut-core": patch
---

Add the `@hashintel/petrinaut-core/experiments` entry point: an `ExperimentBackend` interface, a worker-pool implementation of it, and `selectExperimentBackend`, which walks backends in preference order and records every refusal.
4 changes: 4 additions & 0 deletions libs/@hashintel/petrinaut-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@
"types": "./dist/index.d.d.ts",
"import": "./dist/index.js"
},
"./experiments": {
"types": "./dist/experiments.d.d.ts",
"import": "./dist/experiments.js"
},
"./ai": {
"types": "./dist/ai.d.d.ts",
"import": "./dist/ai.js"
Expand Down
39 changes: 39 additions & 0 deletions libs/@hashintel/petrinaut-core/src/experiments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* The contract both compute backends satisfy, and the registry that picks one.
*
* Backends already produce a `MonteCarloExperiment` that consumers drive without
* branching. This adds the two things that were hardcoded: asking a backend
* whether it can run a net, and choosing when one declines.
*
* A backend is constructed with its own wiring (a worker factory, an ODE method)
* and registered as data. A React context provider can therefore build the
* backends its environment supports and publish the list.
*
* Registration carries a deferred `load`. A heavy backend is imported the first
* time selection reaches it, so it stays out of bundles that never use it.
*/
export type {
ExperimentAssessment,
ExperimentBlocker,
ExperimentBlockerOrigin,
ExperimentBlockers,
ExperimentInstantiation,
ExperimentNote,
InstantiateExperimentOptions,
} from "./experiments/experiment-assessment";
export type {
ExperimentBackend,
ExperimentBackendRegistration,
ExperimentSelectionFailure,
} from "./experiments/experiment-backend";
export type { ExperimentRequest } from "./experiments/experiment-request";
export {
selectExperimentBackend,
type SelectExperimentBackendInput,
type SelectExperimentBackendResult,
} from "./experiments/select-experiment-backend";
export {
createWorkerPoolExperimentBackend,
WORKER_POOL_BACKEND_ID,
type WorkerPoolExperimentBackendOptions,
} from "./experiments/worker-pool-experiment-backend";
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* A backend's answer to "can you run this request, and if not, why?"
*
* Refusal is a value rather than an exception. A subset engine declining a net is
* ordinary: the caller tries another backend, and the reason reaches the user.
*
* The refusal carries structured blockers rather than a single string, so a UI
* can attribute a problem to the item that caused it and report several at once.
* A `reason: string` contract would collapse that at the abstraction boundary,
* and it cannot be widened later without changing every backend.
*/
import type { AbortSignalLike } from "../environment";
import type { MonteCarloExperiment } from "../simulation/monte-carlo/runtime/experiment";

/**
* Where a problem lives, and therefore who can act on it.
*
* This is the field a UI branches on, and the reason a blocker is worth more
* than a message:
*
* - `model`, the net must change. Attribute it to `itemId` and keep the backend
* offered but unavailable, because editing the net can fix it.
* - `configuration`, the *experiment* must change: run count, initial marking,
* metric shapes, missing artifacts. Actionable where the experiment is set up,
* not by editing the net.
* - `environment`, this browser or machine cannot do it at all. Do not blame the
* net and do not nag; hiding the option is reasonable.
* - `capacity`, the backend could normally do this but cannot right now: a full
* queue, a device out of memory. Distinct from `environment` precisely because
* it is transient, so "retry" or "use fewer runs" is the right advice where
* "hide the option" would be wrong.
*/
export type ExperimentBlockerOrigin =
| "model"
| "configuration"
| "environment"
| "capacity";

export type ExperimentBlocker = {
/**
* Stable, backend-namespaced code, for tests and for grouping in a UI.
*
* Intentionally `string` rather than a closed union: a union shared across
* backends could not be extended by a lazily loaded or third-party backend
* without editing this file.
*/
readonly code: string;
/** Written for whoever authored the net, not for whoever wrote the emitter. */
readonly message: string;
readonly origin: ExperimentBlockerOrigin;
/** The net item responsible, when one can be identified. */
readonly itemId?: string;
};

/** Something the user should know that did not prevent the run. */
export type ExperimentNote = {
readonly code: string;
readonly message: string;
};

/** At least one, so a refusal without a reason cannot be constructed. */
export type ExperimentBlockers = readonly [
ExperimentBlocker,
...ExperimentBlocker[],
];

/**
* The non-serializable half of starting an experiment.
*
* Separate from `ExperimentRequest` so the request stays plain data. Both of
* these are host-side wiring: a signal is a live object, and notes are delivered
* by calling back.
*/
export type InstantiateExperimentOptions = {
signal?: AbortSignalLike;
/**
* Receives problems that only become detectable once the run is under way.
* Today: a metric histogram whose top bin saturated.
*
* The notes on the assessment are assembled before anything runs and cannot
* carry these. Without a channel for them the results would be presented as
* fact.
*/
onNote?: (note: ExperimentNote) => void;
};

export type ExperimentInstantiation =
| {
readonly ok: true;
readonly handle: MonteCarloExperiment;
/**
* Where this actually ran, for the record: a GPU adapter description, a
* shard count, a server region. Free text because only a human reads it.
*/
readonly runtimeInfo?: string;
}
| { readonly ok: false; readonly blockers: ExperimentBlockers };

export type ExperimentAssessment =
| {
readonly eligible: true;
/** Non-blocking observations to surface before the run. */
readonly notes: readonly ExperimentNote[];
/**
* Starts the experiment that was assessed.
*
* A closure rather than a second call taking the request again, so the work
* already done, a compiled shader, a shard plan, carries forward and the
* verdict and the run can never be about different things.
*
* May still fail, but only for `environment` or `capacity` reasons: whether
* the *net and configuration* are runnable was settled by the assessment.
* The split exists so that assessing a net while the user edits, to decide
* what to offer, never acquires a device or a worker pool.
*/
instantiate(
this: void,
options?: InstantiateExperimentOptions,
): Promise<ExperimentInstantiation>;
}
| { readonly eligible: false; readonly blockers: ExperimentBlockers };
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* The contract every experiment backend satisfies.
*
* @layerRoot core.experiments
* @role Chooses a compute backend for an experiment, and asks whether it can run a net
*
* Small. Both paths already produce a `MonteCarloExperiment` that
* `ExperimentsProvider` consumes without branching. This adds only what was
* never abstracted: choosing a backend, and asking whether it can take a net.
*
* Backend-specific configuration is bound when the backend object is built, not
* passed through this interface. The worker-pool backend closes over a worker
* factory and a shard count; the WebGPU backend closes over an ODE method. That
* is why a shared config type is unnecessary, and it is also exactly the shape a
* React context provider wants later: the provider constructs backends with the
* environment's wiring and publishes the resulting list.
*/
import type {
ExperimentAssessment,
ExperimentBlockerOrigin,
} from "./experiment-assessment";
import type { ExperimentRequest } from "./experiment-request";

export type ExperimentBackend = {
/**
* Stable identifier, recorded against results.
*
* Results from two backends are not numerically interchangeable, they use
* different random generators, so which one ran is part of the data, not a
* detail.
*/
readonly id: string;
/** Shown to users, e.g. "CPU (Web Workers)". */
readonly label: string;
/**
* Whether this backend needs the lowered HIR *trees* on the artifacts.
*
* Declared rather than inferred from the id so a caller compiles once for the
* backends it is about to ask. The trees roughly triple artifact size, so the
* worker-pool backend does not want them; the WebGPU backend cannot generate a
* shader without them.
*/
readonly needsHirTrees: boolean;
/**
* Whether this backend could run *anything* in this environment.
*
* Synchronous and cheap, a feature test, not an assessment, so a UI can
* decide whether to offer the backend at all without compiling a net. A
* backend that is always usable returns `true`.
*/
isAvailable(this: void): boolean;
/**
* Decides whether this backend can run `request`, without starting it.
*
* Asynchronous because deciding can require real work (lowering a net,
* generating and compiling a shader). Must not acquire scarce resources: that
* belongs to `instantiate` on the eligible result, so that assessing a net
* while the user edits does not hold a GPU device.
*/
assess(this: void, request: ExperimentRequest): Promise<ExperimentAssessment>;
};

/**
* A backend plus how eagerly to load it.
*
* `load` is deferred so a caller can register the WebGPU backend without pulling
* the shader generator into the initial bundle, it is imported the first time a
* GPU run is actually attempted. Both backends are registered at once; the choice
* is per experiment, never global.
*/
export type ExperimentBackendRegistration = {
readonly id: string;
readonly label: string;
/**
* Loads the backend. Keep it cheap and side-effect free: the selection walk
* calls it once per walk, so anything expensive a backend acquires (a worker
* pool, a GPU device) belongs in `instantiate`, not here.
*/
load(this: void): Promise<ExperimentBackend>;
};

/** Why no backend could run the request. */
export type ExperimentSelectionFailure = {
readonly backendId: string;
readonly origin: ExperimentBlockerOrigin;
readonly reason: string;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* What to compute, as serializable data.
*
* Closed and plain: no worker factory, no GPU options, no abort signal, no
* callbacks. Anything describing *how* to compute belongs to the backend's
* construction (`ExperimentBackend`) or to the per-call options of `instantiate`.
*
* That rule keeps this from becoming
* `CreateMonteCarloExperimentConfig | CreateGpuMonteCarloExperimentConfig`, and
* keeps the request serializable. For an out-of-process backend this object is
* the request body, which a function-valued field would prevent.
*/
import type { PetrinautExtensionSettings } from "../extensions";
import type { HirArtifacts } from "../hir-runtime";
import type { InitialMarking } from "../simulation/api";
import type { MonteCarloMetricSpec } from "../simulation/monte-carlo/metrics/types";
import type { SDCPN } from "../types/sdcpn";

export type ExperimentRequest = {
readonly sdcpn: SDCPN;
readonly extensions?: PetrinautExtensionSettings;
readonly initialMarking: InitialMarking;
readonly parameterValues: Readonly<Record<string, string>>;
readonly seed: number;
readonly dt: number;
readonly maxTime: number;
readonly runCount: number;
/**
* Metrics to record.
*
* Expression metrics carry their compiled artifact. A backend may need to run
* them and cannot compile one itself: that needs the TypeScript frontend,
* which is also why `hirArtifacts` is passed rather than derived.
*/
readonly metricSpecs: readonly MonteCarloMetricSpec[];
/**
* Compiled user code for the net.
*
* Optional: a net with no user code needs none. Whether the HIR *trees* are
* included is declared per backend by `ExperimentBackend.needsHirTrees`, so a
* caller compiles once for whichever backends it is about to ask.
*/
readonly hirArtifacts?: HirArtifacts;
};
Loading
Loading