Skip to content
Merged
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/fresh-metrics-shine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/petrinaut": patch
---

Add Metrics: user-authored functions over simulation state that produce a single number per frame, plotted via a new metric picker in the simulation timeline header
16 changes: 15 additions & 1 deletion libs/@hashintel/petrinaut/src/components/select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,10 @@ const itemStyle = css({
cursor: "pointer",
outline: "none",
color: "neutral.fg.body",
// Allow the inner text (a flex child) to shrink below its intrinsic
// content size so `text-overflow: ellipsis` on the label kicks in instead
// of the label wrapping or overflowing the dropdown container.
minWidth: "[0]",
"&[data-highlighted]": {
backgroundColor: "neutral.bg.min.hover",
},
Expand All @@ -166,6 +170,14 @@ const itemStyle = css({
},
});

const itemTextStyle = css({
flex: "[1]",
minWidth: "[0]",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
});

// -- Types --------------------------------------------------------------------

export interface SelectOption {
Expand Down Expand Up @@ -297,7 +309,9 @@ const SelectBase: React.FC<SelectBaseProps> = ({
{renderItem ? (
renderItem(item)
) : (
<ArkSelect.ItemText>{item.label}</ArkSelect.ItemText>
<ArkSelect.ItemText className={itemTextStyle}>
{item.label}
</ArkSelect.ItemText>
)}
</ArkSelect.Item>
))}
Expand Down
12 changes: 12 additions & 0 deletions libs/@hashintel/petrinaut/src/core/schemas/metric-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { z } from "zod";

import type { Metric } from "../types/sdcpn";

export const metricSchema = z.object({
id: z.string().min(1),
name: z.string().min(1, "Metric name is required"),
description: z.string().optional(),
code: z.string(),
}) satisfies z.ZodType<Metric>;

export type MetricSchema = typeof metricSchema;
17 changes: 17 additions & 0 deletions libs/@hashintel/petrinaut/src/core/types/sdcpn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,13 +112,30 @@ export type Scenario = {
};
};

/**
* A metric is a user-authored function that takes the current simulation state
* (places with token counts and, for colored places, named token attributes)
* and returns a single number to be plotted over time on the timeline chart.
*/
export type Metric = {
id: ID;
name: string;
description?: string;
/**
* Function body invoked with `state` in scope. Must `return` a number.
* See `MetricState` (in `simulation/compile-metric.ts`) for the input shape.
*/
code: string;
};

export type SDCPN = {
places: Place[];
transitions: Transition[];
types: Color[];
differentialEquations: DifferentialEquation[];
parameters: Parameter[];
scenarios?: Scenario[];
metrics?: Metric[];
};

export type MinimalNetMetadata = {
Expand Down
14 changes: 14 additions & 0 deletions libs/@hashintel/petrinaut/src/examples/sir-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,5 +156,19 @@ export const sirModel: { title: string; petriNetDefinition: SDCPN } = {
},
},
],
metrics: [
{
id: "metric__infected_fraction",
name: "Infected Fraction",
description: "Share of the population currently infected.",
code: [
"const s = state.places.Susceptible.count;",
"const i = state.places.Infected.count;",
"const r = state.places.Recovered.count;",
"const total = s + i + r;",
"return total === 0 ? 0 : i / total;",
].join("\n"),
},
],
},
};
8 changes: 8 additions & 0 deletions libs/@hashintel/petrinaut/src/file-format/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,21 @@ const scenarioSchema = z.object({
initialState: initialStateSchema.default({ type: "per_place", content: {} }),
});

const metricSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string().optional(),
code: z.string().default(""),
});

const sdcpnSchema = z.object({
places: z.array(placeSchema),
transitions: z.array(transitionSchema),
types: z.array(colorSchema).default([]),
differentialEquations: z.array(differentialEquationSchema).default([]),
parameters: z.array(parameterSchema).default([]),
scenarios: z.array(scenarioSchema).default([]),
metrics: z.array(metricSchema).default([]),
});

const fileMetaSchema = z.object({
Expand Down
10 changes: 10 additions & 0 deletions libs/@hashintel/petrinaut/src/lsp/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
Diagnostic,
DocumentUri,
Hover,
MetricSessionParams,
Position,
ScenarioSessionParams,
SignatureHelp,
Expand Down Expand Up @@ -35,6 +36,12 @@ export interface LanguageClientContextValue {
updateScenarioSession: (params: ScenarioSessionParams) => void;
/** Kill a scenario editing session. */
killScenarioSession: (sessionId: string) => void;
/** Initialize a temporary metric editing session. */
initializeMetricSession: (params: MetricSessionParams) => void;
/** Update a metric editing session. */
updateMetricSession: (params: MetricSessionParams) => void;
/** Kill a metric editing session. */
killMetricSession: (sessionId: string) => void;
}

const DEFAULT_CONTEXT_VALUE: LanguageClientContextValue = {
Expand All @@ -47,6 +54,9 @@ const DEFAULT_CONTEXT_VALUE: LanguageClientContextValue = {
initializeScenarioSession: () => {},
updateScenarioSession: () => {},
killScenarioSession: () => {},
initializeMetricSession: () => {},
updateMetricSession: () => {},
killMetricSession: () => {},
};

export const LanguageClientContext = createContext<LanguageClientContextValue>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import {
type VirtualFile,
} from "./create-language-service-host";
import {
generateMetricSessionFiles,
generateScenarioSessionFiles,
generateVirtualFiles,
type MetricSessionData,
type ScenarioSessionData,
} from "./generate-virtual-files";

Expand Down Expand Up @@ -142,6 +144,59 @@ export class SDCPNLanguageServer {
.filter((name) => name.startsWith(sessionPrefix));
}

/**
* Sync virtual files for a metric editing session.
* Updates content from the session data (form state is the source of truth).
*/
syncMetricFiles(sdcpn: SDCPN, session: MetricSessionData): void {
const sessionPrefix = `/_temp/metrics/${session.sessionId}/`;
const newFiles = generateMetricSessionFiles(sdcpn, session);

// Remove metric files that no longer exist for this session
for (const existingName of this.controller.getFileNames()) {
if (
existingName.startsWith(sessionPrefix) &&
!newFiles.has(existingName)
) {
this.controller.removeFile(existingName);
}
}

// Add or update files
for (const [name, newFile] of newFiles) {
if (!this.controller.hasFile(name)) {
this.controller.addFile(name, newFile);
} else {
const existing = this.controller.getFile(name)!;
if (
existing.content !== newFile.content ||
existing.prefix !== newFile.prefix ||
existing.suffix !== newFile.suffix
) {
this.controller.updateFile(name, newFile);
}
}
}
}

/** Remove all virtual files for a metric session. */
removeMetricSession(sessionId: string): void {
const sessionPrefix = `/_temp/metrics/${sessionId}/`;
for (const name of this.controller.getFileNames()) {
if (name.startsWith(sessionPrefix)) {
this.controller.removeFile(name);
}
}
}

/** Get all file paths that belong to a metric session. */
getMetricFileNames(sessionId: string): string[] {
const sessionPrefix = `/_temp/metrics/${sessionId}/`;
return this.controller
.getFileNames()
.filter((name) => name.startsWith(sessionPrefix));
}

/** Update only the user content of a single file (e.g., when the user types in an editor). */
updateDocumentContent(fileName: string, content: string): void {
this.controller.updateContent(fileName, content);
Expand Down
30 changes: 30 additions & 0 deletions libs/@hashintel/petrinaut/src/lsp/lib/document-uris.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ export function getScenarioDocumentUri(
}
}

/** Build a document URI for a metric expression (used as Monaco model URI). */
export function getMetricDocumentUri(sessionId: string): string {
return `inmemory://sdcpn/_temp/metrics/${sessionId}/code.ts`;
}

// ---------------------------------------------------------------------------
// URI regex patterns
// ---------------------------------------------------------------------------
Expand All @@ -65,6 +70,8 @@ const SCENARIO_INITIAL_STATE_URI_RE =
const SCENARIO_INITIAL_STATE_FULL_CODE_URI_RE =
/^inmemory:\/\/sdcpn\/_temp\/scenarios\/([^/]+)\/initial-state-code\.ts$/;

const METRIC_URI_RE = /^inmemory:\/\/sdcpn\/_temp\/metrics\/([^/]+)\/code\.ts$/;

// ---------------------------------------------------------------------------
// File path regex patterns
// ---------------------------------------------------------------------------
Expand All @@ -80,6 +87,8 @@ const SCENARIO_INITIAL_STATE_PATH_RE =
const SCENARIO_INITIAL_STATE_FULL_CODE_PATH_RE =
/^\/_temp\/scenarios\/([^/]+)\/initial_state_code\/code\.ts$/;

const METRIC_PATH_RE = /^\/_temp\/metrics\/([^/]+)\/code\.ts$/;

// ---------------------------------------------------------------------------
// URI parsing
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -140,6 +149,14 @@ export function parseScenarioDocumentUri(
return null;
}

/** Extract the session id from a metric document URI string. */
export function parseMetricDocumentUri(
uri: string,
): { sessionId: string } | null {
const match = METRIC_URI_RE.exec(uri);
return match ? { sessionId: match[1]! } : null;
}

// ---------------------------------------------------------------------------
// URI ↔ internal file path conversion
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -189,6 +206,14 @@ export function uriToFilePath(uri: string): string | null {
}
}

// Try metric URIs
const metricParsed = parseMetricDocumentUri(uri);
if (metricParsed) {
return getItemFilePath("metric-code", {
sessionId: metricParsed.sessionId,
});
}

// Try scenario URIs
return scenarioUriToFilePath(uri);
}
Expand Down Expand Up @@ -238,5 +263,10 @@ export function filePathToUri(filePath: string): string | null {
);
}

match = METRIC_PATH_RE.exec(filePath);
if (match) {
return getMetricDocumentUri(match[1]!);
}

return null;
}
16 changes: 15 additions & 1 deletion libs/@hashintel/petrinaut/src/lsp/lib/file-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ export type SDCPNFileType =
| "scenario-session-defs"
| "scenario-param-override-code"
| "scenario-initial-state-code"
| "scenario-initial-state-full-code";
| "scenario-initial-state-full-code"
| "metric-session-defs"
| "metric-code";

type FilePathParams = {
"sdcpn-lib-defs": Record<string, never>;
Expand All @@ -32,6 +34,8 @@ type FilePathParams = {
"scenario-param-override-code": { sessionId: string; paramId: string };
"scenario-initial-state-code": { sessionId: string; placeId: string };
"scenario-initial-state-full-code": { sessionId: string };
"metric-session-defs": { sessionId: string };
"metric-code": { sessionId: string };
};

/**
Expand Down Expand Up @@ -118,6 +122,16 @@ export const getItemFilePath = <T extends SDCPNFileType>(
return `/_temp/scenarios/${sessionId}/initial_state_code/code.ts`;
}

case "metric-session-defs": {
const { sessionId } = params as FilePathParams["metric-session-defs"];
return `/_temp/metrics/${sessionId}/defs.d.ts`;
}

case "metric-code": {
const { sessionId } = params as FilePathParams["metric-code"];
return `/_temp/metrics/${sessionId}/code.ts`;
}

default:
throw new Error(`Unknown file type: ${fileType}`);
}
Expand Down
Loading
Loading