diff --git a/docs/canvas-lab-architecture.md b/docs/canvas-lab-architecture.md index 629ae49f26..9935570089 100644 --- a/docs/canvas-lab-architecture.md +++ b/docs/canvas-lab-architecture.md @@ -99,6 +99,7 @@ File: Responsibilities: - create default config objects (`makeSamplerConfig`, `makeLlmConfig`, `makeModelProviderConfig`, `makeModelConfig`, `makeExpressionConfig`) - map `NodeConfig -> CanvasNodeData` via `nodeDataFromConfig` +- sampler set includes `category`, `subcategory`, `uniform`, `gaussian`, `bernoulli`, `datetime`, `timedelta`, `uuid`, `person`, `person_from_faker` Example mapping: @@ -148,15 +149,19 @@ Semantic edge classifier: ```ts function isSemanticEdge(source: NodeConfig, target: NodeConfig): boolean { if (source.kind === "model_provider" && target.kind === "model_config") return true; - if (source.kind === "model_config" && target.kind === "llm") return true; - return source.kind === "sampler" && source.sampler_type === "category" - && target.kind === "sampler" && target.sampler_type === "subcategory"; + return source.kind === "model_config" && target.kind === "llm"; } ``` +Handle lanes: +- data edges use `data-out -> data-in` (`right -> left`) +- semantic edges use `semantic-out -> semantic-in` (`bottom -> top`) +- semantic lane only used for `model_provider -> model_config -> llm` + Connection side effects: - `model_provider -> model_config`: set `model_config.provider = source.name` - `model_config -> llm`: set `llm.model_alias = source.name` +- `datetime -> timedelta`: set `timedelta.reference_column_name = source.name` - regular data edges into LLM/expression append `{{ source_name }}` refs Edge rendering (dotted semantic edges): @@ -208,7 +213,9 @@ How relation is enforced: - collect `model_alias` values used by LLM columns - ensure each alias exists in `recipe.model_configs` - validate `model_config.provider` points to existing provider +- validate `timedelta.reference_column_name` points to a datetime sampler - require endpoint/provider_type only for providers that are actually referenced +- category sampler supports typed `conditional_params` in payload output This is why unused provider/config blocks can exist without blocking preview. @@ -227,10 +234,11 @@ Order of reconstruction: Edge inference file: `/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/import/edges.ts` -If UI edges missing, infer semantic edges from fields: -- `subcategory_parent` +If UI edges missing, infer edges from fields: +- `subcategory_parent` (canvas edge) - `model_config.provider` - `llm.model_alias` +- infer data edge from `timedelta.reference_column_name` ## 11) Dialog routing and edit UIs @@ -249,6 +257,7 @@ Model dialogs: `ModelConfigDialog` and `LlmDialog` use shadcn `Combobox` fed from store configs: - model config `provider` suggests model-provider node names - llm `model_alias` suggests model-config aliases +- timedelta dialog suggests datetime columns for `reference_column_name` ## 12) How to add a new block (checklist) diff --git a/studio/frontend/src/features/canvas-lab/blocks/registry.tsx b/studio/frontend/src/features/canvas-lab/blocks/registry.tsx index a50a779ce0..d9ab7fa022 100644 --- a/studio/frontend/src/features/canvas-lab/blocks/registry.tsx +++ b/studio/frontend/src/features/canvas-lab/blocks/registry.tsx @@ -30,9 +30,11 @@ import { ModelConfigDialog } from "../dialogs/models/model-config-dialog"; import { ModelProviderDialog } from "../dialogs/models/model-provider-dialog"; import { CategoryDialog } from "../dialogs/samplers/category-dialog"; import { DatetimeDialog } from "../dialogs/samplers/datetime-dialog"; +import { BernoulliDialog } from "../dialogs/samplers/bernoulli-dialog"; import { GaussianDialog } from "../dialogs/samplers/gaussian-dialog"; import { PersonDialog } from "../dialogs/samplers/person-dialog"; import { SubcategoryDialog } from "../dialogs/samplers/subcategory-dialog"; +import { TimedeltaDialog } from "../dialogs/samplers/timedelta-dialog"; import { UniformDialog } from "../dialogs/samplers/uniform-dialog"; import { UuidDialog } from "../dialogs/samplers/uuid-dialog"; @@ -101,6 +103,7 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [ renderDialog: ({ config, onUpdate }) => config.kind === "sampler" && config.sampler_type === "category" ? ( onUpdate(config.id, patch)} /> @@ -153,6 +156,22 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [ /> ) : null, }, + { + kind: "sampler", + type: "bernoulli", + title: "Bernoulli", + description: "Binary sampler with probability.", + icon: EqualSignIcon, + createConfig: (id, existing) => + makeSamplerConfig(id, "bernoulli", existing), + renderDialog: ({ config, onUpdate }) => + config.kind === "sampler" && config.sampler_type === "bernoulli" ? ( + onUpdate(config.id, patch)} + /> + ) : null, + }, { kind: "sampler", type: "datetime", @@ -168,6 +187,22 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [ /> ) : null, }, + { + kind: "sampler", + type: "timedelta", + title: "Timedelta", + description: "Offset from datetime column.", + icon: Clock01Icon, + createConfig: (id, existing) => + makeSamplerConfig(id, "timedelta", existing), + renderDialog: ({ config, onUpdate }) => + config.kind === "sampler" && config.sampler_type === "timedelta" ? ( + onUpdate(config.id, patch)} + /> + ) : null, + }, { kind: "sampler", type: "uuid", diff --git a/studio/frontend/src/features/canvas-lab/components/canvas-edge.tsx b/studio/frontend/src/features/canvas-lab/components/canvas-edge.tsx index d56906b707..ca47bd35b1 100644 --- a/studio/frontend/src/features/canvas-lab/components/canvas-edge.tsx +++ b/studio/frontend/src/features/canvas-lab/components/canvas-edge.tsx @@ -1,5 +1,5 @@ import { BaseEdge, type EdgeProps, getSmoothStepPath } from "@xyflow/react"; -import { memo } from "react"; +import { memo, type ReactElement } from "react"; export const CanvasEdge = memo(function CanvasEdge({ id, @@ -11,7 +11,7 @@ export const CanvasEdge = memo(function CanvasEdge({ targetPosition, style, type, -}: EdgeProps): JSX.Element { +}: EdgeProps): ReactElement { const [path] = getSmoothStepPath({ sourceX, sourceY, diff --git a/studio/frontend/src/features/canvas-lab/components/canvas-node.tsx b/studio/frontend/src/features/canvas-lab/components/canvas-node.tsx index 0a4b19824c..b98206b6e6 100644 --- a/studio/frontend/src/features/canvas-lab/components/canvas-node.tsx +++ b/studio/frontend/src/features/canvas-lab/components/canvas-node.tsx @@ -25,6 +25,7 @@ import type { LlmType, SamplerType, } from "../types"; +import { HANDLE_IDS } from "../utils/handles"; type IconType = typeof CodeIcon; @@ -51,7 +52,9 @@ const SAMPLER_ICONS: Record = { subcategory: TagsIcon, uniform: EqualSignIcon, gaussian: Parabola02Icon, + bernoulli: EqualSignIcon, datetime: Clock01Icon, + timedelta: Clock01Icon, uuid: FingerPrintIcon, person: UserAccountIcon, person_from_faker: UserAccountIcon, @@ -94,13 +97,26 @@ function CanvasNodeBase({ const meta = NODE_META[data.kind]; const icon = resolveNodeIcon(data.kind, data.blockType); const layoutDirection = data.layoutDirection ?? "LR"; - const isHorizontal = layoutDirection === "LR"; const updateNodeInternals = useUpdateNodeInternals(); useEffect(() => { updateNodeInternals(id); }, [id, layoutDirection, updateNodeInternals]); + const showDataHandles = + data.kind === "llm" || + data.kind === "expression" || + (data.kind === "sampler" && + data.blockType !== "model_provider" && + data.blockType !== "model_config"); + const showSemanticIn = + data.kind === "llm" || + data.kind === "model_config"; + const showSemanticOut = + data.kind === "llm" || + data.kind === "model_config" || + data.kind === "model_provider"; + return (
- - + {showDataHandles && ( + <> + + + + )} + {showSemanticIn && ( + + )} + {showSemanticOut && ( + + )} ); } diff --git a/studio/frontend/src/features/canvas-lab/dialogs/samplers/bernoulli-dialog.tsx b/studio/frontend/src/features/canvas-lab/dialogs/samplers/bernoulli-dialog.tsx new file mode 100644 index 0000000000..fb97f7fe35 --- /dev/null +++ b/studio/frontend/src/features/canvas-lab/dialogs/samplers/bernoulli-dialog.tsx @@ -0,0 +1,42 @@ +import { Input } from "@/components/ui/input"; +import type { ReactElement } from "react"; +import type { SamplerConfig } from "../../types"; +import { NameField } from "../shared/name-field"; + +type BernoulliDialogProps = { + config: SamplerConfig; + onUpdate: (patch: Partial) => void; +}; + +export function BernoulliDialog({ + config, + onUpdate, +}: BernoulliDialogProps): ReactElement { + const pId = `${config.id}-bernoulli-p`; + return ( +
+ onUpdate({ name: value })} + /> +
+ + onUpdate({ p: event.target.value })} + /> +
+
+ ); +} diff --git a/studio/frontend/src/features/canvas-lab/dialogs/samplers/category-dialog.tsx b/studio/frontend/src/features/canvas-lab/dialogs/samplers/category-dialog.tsx index 8d016dfb3b..8276449422 100644 --- a/studio/frontend/src/features/canvas-lab/dialogs/samplers/category-dialog.tsx +++ b/studio/frontend/src/features/canvas-lab/dialogs/samplers/category-dialog.tsx @@ -1,7 +1,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { type ReactElement, useEffect, useState } from "react"; +import { type ReactElement, useState } from "react"; import type { SamplerConfig } from "../../types"; import { NameField } from "../shared/name-field"; @@ -15,13 +15,14 @@ export function CategoryDialog({ onUpdate, }: CategoryDialogProps): ReactElement { const [valueDraft, setValueDraft] = useState(""); + const [conditionDraft, setConditionDraft] = useState(""); + const [conditionalValueDrafts, setConditionalValueDrafts] = useState< + Record + >({}); const valuesInputId = `${config.id}-values`; + const conditionInputId = `${config.id}-conditional-rule`; - useEffect(() => { - if (config.id) { - setValueDraft(""); - } - }, [config.id]); + const conditional = config.conditional_params ?? {}; const handleAddValue = () => { const nextValue = valueDraft.trim(); @@ -36,6 +37,58 @@ export function CategoryDialog({ setValueDraft(""); }; + const handleAddCondition = () => { + const condition = conditionDraft.trim(); + if (!condition || conditional[condition]) { + return; + } + onUpdate({ + // biome-ignore lint/style/useNamingConvention: api schema + conditional_params: { + ...conditional, + [condition]: { + // biome-ignore lint/style/useNamingConvention: api schema + sampler_type: "category", + values: [], + weights: [], + }, + }, + }); + setConditionDraft(""); + }; + + const removeCondition = (condition: string) => { + const next = { ...conditional }; + delete next[condition]; + onUpdate({ + // biome-ignore lint/style/useNamingConvention: api schema + conditional_params: Object.keys(next).length > 0 ? next : undefined, + }); + }; + + const addConditionalValue = (condition: string) => { + const draft = conditionalValueDrafts[condition]?.trim(); + if (!draft) { + return; + } + const current = conditional[condition] ?? { + // biome-ignore lint/style/useNamingConvention: api schema + sampler_type: "category" as const, + values: [], + weights: [], + }; + const values = [...(current.values ?? []), draft]; + const weights = [...(current.weights ?? []), null]; + onUpdate({ + // biome-ignore lint/style/useNamingConvention: api schema + conditional_params: { + ...conditional, + [condition]: { ...current, values, weights }, + }, + }); + setConditionalValueDrafts((prev) => ({ ...prev, [condition]: "" })); + }; + return (
+
+
+

+ Conditional params (category) +

+ + {Object.keys(conditional).length} rules + +
+
+ setConditionDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + handleAddCondition(); + } + }} + /> + +
+ {Object.entries(conditional).map(([condition, params]) => ( +
+
+

{condition}

+ +
+
+ + setConditionalValueDrafts((prev) => ({ + ...prev, + [condition]: event.target.value, + })) + } + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + addConditionalValue(condition); + } + }} + /> + +
+
+ {(params.values ?? []).map((value, index) => ( + + {value} + + + ))} +
+
+

+ Rule weights (optional) +

+
+ {(params.values ?? []).map((value, index) => ( +
+ + {value} + + { + const weights = [ + ...(params.weights ?? + Array.from( + { length: (params.values ?? []).length }, + () => null, + )), + ]; + weights[index] = event.target.value + ? Number(event.target.value) + : null; + onUpdate({ + // biome-ignore lint/style/useNamingConvention: api schema + conditional_params: { + ...conditional, + [condition]: { ...params, weights }, + }, + }); + }} + /> +
+ ))} +
+
+
+ ))} +
); } diff --git a/studio/frontend/src/features/canvas-lab/dialogs/samplers/timedelta-dialog.tsx b/studio/frontend/src/features/canvas-lab/dialogs/samplers/timedelta-dialog.tsx new file mode 100644 index 0000000000..a636cec331 --- /dev/null +++ b/studio/frontend/src/features/canvas-lab/dialogs/samplers/timedelta-dialog.tsx @@ -0,0 +1,137 @@ +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { type ReactElement, useMemo } from "react"; +import { useCanvasLabStore } from "../../stores/canvas-lab"; +import type { SamplerConfig } from "../../types"; +import { NameField } from "../shared/name-field"; + +const TIMEDELTA_UNITS: Array<"D" | "h" | "m" | "s"> = ["D", "h", "m", "s"]; +const NONE_VALUE = "__none"; + +type TimedeltaDialogProps = { + config: SamplerConfig; + onUpdate: (patch: Partial) => void; +}; + +export function TimedeltaDialog({ + config, + onUpdate, +}: TimedeltaDialogProps): ReactElement { + const configs = useCanvasLabStore((state) => state.configs); + const datetimeOptions = useMemo( + () => + Object.values(configs) + .filter( + (item) => item.kind === "sampler" && item.sampler_type === "datetime", + ) + .map((item) => item.name), + [configs], + ); + const dtMinId = `${config.id}-timedelta-min`; + const dtMaxId = `${config.id}-timedelta-max`; + const unitId = `${config.id}-timedelta-unit`; + const referenceId = `${config.id}-timedelta-reference`; + const updateField = ( + key: K, + value: SamplerConfig[K], + ) => { + onUpdate({ [key]: value } as Partial); + }; + return ( +
+ onUpdate({ name: value })} + /> +
+
+ + updateField("dt_min", event.target.value)} + /> +
+
+ + updateField("dt_max", event.target.value)} + /> +
+
+
+ + +
+
+ + +
+
+ ); +} diff --git a/studio/frontend/src/features/canvas-lab/stores/canvas-lab-helpers.ts b/studio/frontend/src/features/canvas-lab/stores/canvas-lab-helpers.ts index 8818720c22..53347175e4 100644 --- a/studio/frontend/src/features/canvas-lab/stores/canvas-lab-helpers.ts +++ b/studio/frontend/src/features/canvas-lab/stores/canvas-lab-helpers.ts @@ -1,4 +1,11 @@ -import type { CanvasNode, LayoutDirection, NodeConfig } from "../types"; +import type { + CanvasNode, + LayoutDirection, + LlmConfig, + ModelConfig, + NodeConfig, + SamplerConfig, +} from "../types"; import { nodeDataFromConfig } from "../utils"; import { removeRef, replaceRef } from "../utils/refs"; @@ -129,19 +136,31 @@ export function applyRenameToConfig( config.sampler_type === "subcategory" && config.subcategory_parent === from ) { - const base = next === config ? config : next; + const base = next as SamplerConfig; next = { ...base, // biome-ignore lint/style/useNamingConvention: api schema subcategory_parent: to, }; } + if ( + config.kind === "sampler" && + config.sampler_type === "timedelta" && + config.reference_column_name === from + ) { + const base = next as SamplerConfig; + next = { + ...base, + // biome-ignore lint/style/useNamingConvention: api schema + reference_column_name: to, + }; + } if (config.kind === "model_config" && config.provider === from) { - const base = next === config ? config : next; + const base = next as ModelConfig; next = { ...base, provider: to }; } if (config.kind === "llm" && config.model_alias === from) { - const base = next === config ? config : next; + const base = next as LlmConfig; next = { ...base, model_alias: to }; } return next; @@ -157,7 +176,7 @@ export function applyRemovalToConfig( config.sampler_type === "subcategory" && config.subcategory_parent === ref ) { - const base = next === config ? config : next; + const base = next as SamplerConfig; next = { ...base, // biome-ignore lint/style/useNamingConvention: api schema @@ -166,12 +185,24 @@ export function applyRemovalToConfig( subcategory_mapping: {}, }; } + if ( + config.kind === "sampler" && + config.sampler_type === "timedelta" && + config.reference_column_name === ref + ) { + const base = next as SamplerConfig; + next = { + ...base, + // biome-ignore lint/style/useNamingConvention: api schema + reference_column_name: "", + }; + } if (config.kind === "model_config" && config.provider === ref) { - const base = next === config ? config : next; + const base = next as ModelConfig; next = { ...base, provider: "" }; } if (config.kind === "llm" && config.model_alias === ref) { - const base = next === config ? config : next; + const base = next as LlmConfig; next = { ...base, model_alias: "" }; } return next; diff --git a/studio/frontend/src/features/canvas-lab/stores/canvas-lab.ts b/studio/frontend/src/features/canvas-lab/stores/canvas-lab.ts index a0641eeeec..cd04a6b607 100644 --- a/studio/frontend/src/features/canvas-lab/stores/canvas-lab.ts +++ b/studio/frontend/src/features/canvas-lab/stores/canvas-lab.ts @@ -21,6 +21,7 @@ import type { import { getBlockDefinition } from "../blocks/registry"; import { isCategoryConfig, isSubcategoryConfig } from "../utils"; import { applyCanvasConnection, isValidCanvasConnection } from "../utils/graph"; +import { HANDLE_IDS } from "../utils/handles"; import type { CanvasSnapshot } from "../utils/import"; import { getLayoutedElements } from "../utils/layout"; import { @@ -212,9 +213,9 @@ export const useCanvasLabStore = create((set, get) => ({ { source: parentId, target: id, - sourceHandle: null, - targetHandle: null, - type: "semantic", + sourceHandle: HANDLE_IDS.dataOut, + targetHandle: HANDLE_IDS.dataIn, + type: "canvas", }, edges, ); @@ -241,8 +242,8 @@ export const useCanvasLabStore = create((set, get) => ({ { source: providerId, target: id, - sourceHandle: null, - targetHandle: null, + sourceHandle: HANDLE_IDS.semanticOut, + targetHandle: HANDLE_IDS.semanticIn, type: "semantic", }, edges, @@ -251,6 +252,51 @@ export const useCanvasLabStore = create((set, get) => ({ } } + const hasReferencePatch = Object.prototype.hasOwnProperty.call( + patch, + "reference_column_name", + ); + if ( + current.kind === "sampler" && + current.sampler_type === "timedelta" && + hasReferencePatch + ) { + const nextReference = + (patch as Partial).reference_column_name ?? ""; + edges = edges.filter((edge) => { + if (edge.target !== id) { + return true; + } + const source = configs[edge.source]; + return !( + source && + source.kind === "sampler" && + source.sampler_type === "datetime" + ); + }); + if (nextReference) { + const referenceId = findNodeIdByName(configs, nextReference); + const source = referenceId ? configs[referenceId] : null; + if ( + referenceId && + source && + source.kind === "sampler" && + source.sampler_type === "datetime" + ) { + edges = addEdge( + { + source: referenceId, + target: id, + sourceHandle: HANDLE_IDS.dataOut, + targetHandle: HANDLE_IDS.dataIn, + type: "canvas", + }, + edges, + ); + } + } + } + const hasModelAliasPatch = Object.prototype.hasOwnProperty.call( patch, "model_alias", @@ -272,8 +318,8 @@ export const useCanvasLabStore = create((set, get) => ({ { source: modelConfigId, target: id, - sourceHandle: null, - targetHandle: null, + sourceHandle: HANDLE_IDS.semanticOut, + targetHandle: HANDLE_IDS.semanticIn, type: "semantic", }, edges, @@ -419,5 +465,13 @@ export const useCanvasLabStore = create((set, get) => ({ }); }, isValidConnection: (connection) => - isValidCanvasConnection(connection, get().configs), + isValidCanvasConnection( + { + source: connection.source ?? null, + target: connection.target ?? null, + sourceHandle: connection.sourceHandle ?? null, + targetHandle: connection.targetHandle ?? null, + }, + get().configs, + ), })); diff --git a/studio/frontend/src/features/canvas-lab/types/index.ts b/studio/frontend/src/features/canvas-lab/types/index.ts index 311799c157..06c4b51fea 100644 --- a/studio/frontend/src/features/canvas-lab/types/index.ts +++ b/studio/frontend/src/features/canvas-lab/types/index.ts @@ -5,7 +5,9 @@ export type SamplerType = | "subcategory" | "uniform" | "gaussian" + | "bernoulli" | "datetime" + | "timedelta" | "uuid" | "person" | "person_from_faker"; @@ -32,6 +34,13 @@ export type CanvasNodeData = { export type CanvasNode = Node; +export type CategoryConditionalParams = { + // biome-ignore lint/style/useNamingConvention: api schema + sampler_type: "category"; + values: string[]; + weights?: Array; +}; + export type SamplerConfig = { id: string; kind: "sampler"; @@ -46,6 +55,7 @@ export type SamplerConfig = { high?: string; mean?: string; std?: string; + p?: string; // biome-ignore lint/style/useNamingConvention: api schema datetime_start?: string; // biome-ignore lint/style/useNamingConvention: api schema @@ -53,6 +63,14 @@ export type SamplerConfig = { // biome-ignore lint/style/useNamingConvention: api schema datetime_unit?: string; // biome-ignore lint/style/useNamingConvention: api schema + dt_min?: string; + // biome-ignore lint/style/useNamingConvention: api schema + dt_max?: string; + // biome-ignore lint/style/useNamingConvention: api schema + reference_column_name?: string; + // biome-ignore lint/style/useNamingConvention: api schema + timedelta_unit?: "D" | "h" | "m" | "s"; + // biome-ignore lint/style/useNamingConvention: api schema uuid_format?: string; // biome-ignore lint/style/useNamingConvention: api schema person_locale?: string; @@ -68,6 +86,8 @@ export type SamplerConfig = { subcategory_parent?: string; // biome-ignore lint/style/useNamingConvention: api schema subcategory_mapping?: Record; + // biome-ignore lint/style/useNamingConvention: api schema + conditional_params?: Record; }; export type ScoreOption = { diff --git a/studio/frontend/src/features/canvas-lab/utils/graph.ts b/studio/frontend/src/features/canvas-lab/utils/graph.ts index bcebfd67fd..ef5a79191e 100644 --- a/studio/frontend/src/features/canvas-lab/utils/graph.ts +++ b/studio/frontend/src/features/canvas-lab/utils/graph.ts @@ -1,5 +1,6 @@ import { type Connection, type Edge, addEdge } from "@xyflow/react"; import type { NodeConfig, SamplerConfig } from "../types"; +import { HANDLE_IDS } from "./handles"; import { isCategoryConfig, isExpressionConfig, @@ -45,18 +46,28 @@ function syncSubcategoryMapping( }; } -function isSemanticEdge(source: NodeConfig, target: NodeConfig): boolean { +function isSemanticRelation(source: NodeConfig, target: NodeConfig): boolean { if (source.kind === "model_provider" && target.kind === "model_config") { return true; } - if (source.kind === "model_config" && target.kind === "llm") { - return true; - } + return source.kind === "model_config" && target.kind === "llm"; +} + +function isModelInfraNode(config: NodeConfig): boolean { + return config.kind === "model_provider" || config.kind === "model_config"; +} + +function isSemanticLane(connection: Connection): boolean { return ( - source.kind === "sampler" && - source.sampler_type === "category" && - target.kind === "sampler" && - target.sampler_type === "subcategory" + connection.sourceHandle === HANDLE_IDS.semanticOut && + connection.targetHandle === HANDLE_IDS.semanticIn + ); +} + +function isDataLane(connection: Connection): boolean { + return ( + connection.sourceHandle === HANDLE_IDS.dataOut && + connection.targetHandle === HANDLE_IDS.dataIn ); } @@ -72,7 +83,17 @@ export function isValidCanvasConnection( } const source = configs[connection.source]; const target = configs[connection.target]; - return Boolean(source && target); + if (!(source && target)) { + return false; + } + const semanticRelation = isSemanticRelation(source, target); + if (semanticRelation) { + return isSemanticLane(connection); + } + if (isModelInfraNode(source) || isModelInfraNode(target)) { + return false; + } + return isDataLane(connection); } export function applyCanvasConnection( @@ -88,8 +109,9 @@ export function applyCanvasConnection( if (!(source && target)) { return { edges }; } + const semanticRelation = isSemanticRelation(source, target); const nextEdges = addEdge( - { ...connection, type: isSemanticEdge(source, target) ? "semantic" : "canvas" }, + { ...connection, type: semanticRelation ? "semantic" : "canvas" }, edges, ); if (source.kind === "model_provider" && target.kind === "model_config") { @@ -100,6 +122,19 @@ export function applyCanvasConnection( const next = { ...target, model_alias: source.name }; return { edges: nextEdges, configs: { ...configs, [target.id]: next } }; } + if ( + source.kind === "sampler" && + source.sampler_type === "datetime" && + target.kind === "sampler" && + target.sampler_type === "timedelta" + ) { + const next = { + ...target, + // biome-ignore lint/style/useNamingConvention: api schema + reference_column_name: source.name, + }; + return { edges: nextEdges, configs: { ...configs, [target.id]: next } }; + } if (isLlmConfig(target) && source.kind !== "model_provider" && source.kind !== "model_config") { const ref = `{{ ${source.name} }}`; const next = { diff --git a/studio/frontend/src/features/canvas-lab/utils/handles.ts b/studio/frontend/src/features/canvas-lab/utils/handles.ts new file mode 100644 index 0000000000..0191033ae1 --- /dev/null +++ b/studio/frontend/src/features/canvas-lab/utils/handles.ts @@ -0,0 +1,10 @@ +export const HANDLE_IDS = { + // data flow lanes + dataIn: "data-in", + dataOut: "data-out", + // semantic dependency lanes + semanticIn: "semantic-in", + semanticOut: "semantic-out", +} as const; + +export type CanvasHandleId = (typeof HANDLE_IDS)[keyof typeof HANDLE_IDS]; diff --git a/studio/frontend/src/features/canvas-lab/utils/import/edges.ts b/studio/frontend/src/features/canvas-lab/utils/import/edges.ts index 112e6dafc7..c0d7cf839b 100644 --- a/studio/frontend/src/features/canvas-lab/utils/import/edges.ts +++ b/studio/frontend/src/features/canvas-lab/utils/import/edges.ts @@ -1,20 +1,13 @@ import type { Edge } from "@xyflow/react"; import type { NodeConfig } from "../../types"; +import { HANDLE_IDS } from "../handles"; import { extractRefs } from "./helpers"; function isSemanticConnection(source: NodeConfig, target: NodeConfig): boolean { if (source.kind === "model_provider" && target.kind === "model_config") { return true; } - if (source.kind === "model_config" && target.kind === "llm") { - return true; - } - return ( - source.kind === "sampler" && - source.sampler_type === "category" && - target.kind === "sampler" && - target.sampler_type === "subcategory" - ); + return source.kind === "model_config" && target.kind === "llm"; } export function buildEdges( @@ -42,11 +35,22 @@ export function buildEdges( source && target && isSemanticConnection(source, target) ? "semantic" : (type ?? "canvas"); + const handles = + normalizedType === "semantic" + ? { + sourceHandle: HANDLE_IDS.semanticOut, + targetHandle: HANDLE_IDS.semanticIn, + } + : { + sourceHandle: HANDLE_IDS.dataOut, + targetHandle: HANDLE_IDS.dataIn, + }; edges.push({ id: `e-${key}`, source: sourceId, target: targetId, type: normalizedType, + ...handles, }); }; @@ -76,11 +80,18 @@ export function buildEdges( config.sampler_type === "subcategory" && config.subcategory_parent ) { - addEdgeByName(config.subcategory_parent, config.name, "semantic"); + addEdgeByName(config.subcategory_parent, config.name, "canvas"); } if (config.kind === "model_config" && config.provider) { addEdgeByName(config.provider, config.name, "semantic"); } + if ( + config.kind === "sampler" && + config.sampler_type === "timedelta" && + config.reference_column_name + ) { + addEdgeByName(config.reference_column_name, config.name, "canvas"); + } if (config.kind === "llm" && config.model_alias) { addEdgeByName(config.model_alias, config.name, "semantic"); } diff --git a/studio/frontend/src/features/canvas-lab/utils/import/parsers.ts b/studio/frontend/src/features/canvas-lab/utils/import/parsers.ts index a20a6ca00b..9cbc079423 100644 --- a/studio/frontend/src/features/canvas-lab/utils/import/parsers.ts +++ b/studio/frontend/src/features/canvas-lab/utils/import/parsers.ts @@ -22,13 +22,49 @@ const SAMPLER_TYPES: SamplerType[] = [ "subcategory", "uniform", "gaussian", + "bernoulli", "datetime", + "timedelta", "uuid", "person", "person_from_faker", ]; const EXPRESSION_DTYPES: ExpressionDtype[] = ["str", "int", "float", "bool"]; +const TIMEDELTA_UNITS = new Set(["D", "h", "m", "s"]); + +function parseCategoryConditionalParams( + column: Record, +): SamplerConfig["conditional_params"] { + if (!isRecord(column.conditional_params)) { + return undefined; + } + const conditional: NonNullable = {}; + for (const [condition, rawParams] of Object.entries(column.conditional_params)) { + if (!isRecord(rawParams)) { + continue; + } + if (readString(rawParams.sampler_type) !== "category") { + continue; + } + const values = Array.isArray(rawParams.values) + ? rawParams.values.filter((item) => typeof item === "string") + : []; + if (values.length === 0) { + continue; + } + const weights = Array.isArray(rawParams.weights) + ? rawParams.weights.map((item) => (typeof item === "number" ? item : null)) + : undefined; + conditional[condition] = { + // biome-ignore lint/style/useNamingConvention: api schema + sampler_type: "category", + values, + weights, + }; + } + return Object.keys(conditional).length > 0 ? conditional : undefined; +} function parseSampler( column: Record, @@ -67,6 +103,8 @@ function parseSampler( convert_to: normalizedConvertTo, values, weights, + // biome-ignore lint/style/useNamingConvention: api schema + conditional_params: parseCategoryConditionalParams(column), }; } if (samplerType === "subcategory") { @@ -118,6 +156,18 @@ function parseSampler( std: readNumberString(params.std), }; } + if (samplerType === "bernoulli") { + return { + id, + kind: "sampler", + // biome-ignore lint/style/useNamingConvention: api schema + sampler_type: "bernoulli", + name, + // biome-ignore lint/style/useNamingConvention: api schema + convert_to: normalizedConvertTo, + p: readNumberString(params.p), + }; + } if (samplerType === "datetime") { return { id, @@ -135,6 +185,30 @@ function parseSampler( datetime_unit: readString(params.unit) ?? "", }; } + if (samplerType === "timedelta") { + const rawUnit = readString(params.unit); + const unit = + rawUnit && TIMEDELTA_UNITS.has(rawUnit) + ? (rawUnit as "D" | "h" | "m" | "s") + : "D"; + return { + id, + kind: "sampler", + // biome-ignore lint/style/useNamingConvention: api schema + sampler_type: "timedelta", + name, + // biome-ignore lint/style/useNamingConvention: api schema + convert_to: normalizedConvertTo, + // biome-ignore lint/style/useNamingConvention: api schema + dt_min: readNumberString(params.dt_min), + // biome-ignore lint/style/useNamingConvention: api schema + dt_max: readNumberString(params.dt_max), + // biome-ignore lint/style/useNamingConvention: api schema + reference_column_name: readString(params.reference_column_name) ?? "", + // biome-ignore lint/style/useNamingConvention: api schema + timedelta_unit: unit, + }; + } if (samplerType === "uuid") { return { id, @@ -154,7 +228,7 @@ function parseSampler( params.age_range.every((item) => typeof item === "number") ? `${params.age_range[0]}-${params.age_range[1]}` : readString(params.age_range) ?? ""; - const base = { + const base: SamplerConfig = { id, kind: "sampler", name, diff --git a/studio/frontend/src/features/canvas-lab/utils/index.ts b/studio/frontend/src/features/canvas-lab/utils/index.ts index 92acfa1d82..f8d4f5233d 100644 --- a/studio/frontend/src/features/canvas-lab/utils/index.ts +++ b/studio/frontend/src/features/canvas-lab/utils/index.ts @@ -18,7 +18,9 @@ const SAMPLER_LABELS: Record = { subcategory: "Subcategory", uniform: "Uniform", gaussian: "Gaussian", + bernoulli: "Bernoulli", datetime: "Datetime", + timedelta: "Timedelta", uuid: "UUID", person: "Person", person_from_faker: "Person (Faker)", @@ -110,6 +112,16 @@ export function makeSamplerConfig( std: "1", }; } + if (samplerType === "bernoulli") { + return { + id, + kind: "sampler", + // biome-ignore lint/style/useNamingConvention: api schema + sampler_type: "bernoulli", + name, + p: "0.5", + }; + } if (samplerType === "datetime") { return { id, @@ -125,6 +137,23 @@ export function makeSamplerConfig( datetime_unit: "day", }; } + if (samplerType === "timedelta") { + return { + id, + kind: "sampler", + // biome-ignore lint/style/useNamingConvention: api schema + sampler_type: "timedelta", + name, + // biome-ignore lint/style/useNamingConvention: api schema + dt_min: "0", + // biome-ignore lint/style/useNamingConvention: api schema + dt_max: "1", + // biome-ignore lint/style/useNamingConvention: api schema + reference_column_name: "", + // biome-ignore lint/style/useNamingConvention: api schema + timedelta_unit: "D", + }; + } if (samplerType === "uuid") { return { id, diff --git a/studio/frontend/src/features/canvas-lab/utils/payload.ts b/studio/frontend/src/features/canvas-lab/utils/payload.ts index 8eb5244af5..8b5de84ad8 100644 --- a/studio/frontend/src/features/canvas-lab/utils/payload.ts +++ b/studio/frontend/src/features/canvas-lab/utils/payload.ts @@ -1,5 +1,6 @@ import type { Edge } from "@xyflow/react"; import type { + CategoryConditionalParams, CanvasNode, ExpressionConfig, LlmConfig, @@ -43,15 +44,7 @@ function isSemanticRelation( if (source.kind === "model_provider" && target.kind === "model_config") { return true; } - if (source.kind === "model_config" && target.kind === "llm") { - return true; - } - return ( - source.kind === "sampler" && - source.sampler_type === "category" && - target.kind === "sampler" && - target.sampler_type === "subcategory" - ); + return source.kind === "model_config" && target.kind === "llm"; } function parseNumber(value?: string): number | null { @@ -99,6 +92,46 @@ function parseJsonObject( return undefined; } +function buildCategoryConditionalParams( + config: SamplerConfig, + errors: string[], +): Record | undefined { + const conditional = config.conditional_params ?? {}; + const output: Record = {}; + for (const [rawCondition, params] of Object.entries(conditional)) { + const condition = rawCondition.trim(); + if (!condition) { + errors.push(`Sampler ${config.name}: conditional rule needs condition text.`); + continue; + } + const values = (params.values ?? []) + .map((value) => value.trim()) + .filter(Boolean); + if (values.length === 0) { + errors.push(`Sampler ${config.name}: conditional '${condition}' needs values.`); + continue; + } + const weights = params.weights ?? []; + const hasWeights = weights.some((weight) => weight !== null); + if ( + hasWeights && + (weights.length !== values.length || weights.some((weight) => weight === null)) + ) { + errors.push(`Sampler ${config.name}: conditional '${condition}' weights invalid.`); + continue; + } + output[condition] = { + // biome-ignore lint/style/useNamingConvention: api schema + sampler_type: "category", + values, + weights: hasWeights + ? weights.filter((weight): weight is number => weight !== null) + : undefined, + }; + } + return Object.keys(output).length > 0 ? output : undefined; +} + function buildModelProvider( config: ModelProviderConfig, errors: string[], @@ -216,6 +249,11 @@ function buildSamplerParams( std: parseNumber(config.std), }; } + if (config.sampler_type === "bernoulli") { + return { + p: parseNumber(config.p), + }; + } if (config.sampler_type === "datetime") { return { start: config.datetime_start ?? undefined, @@ -223,6 +261,17 @@ function buildSamplerParams( unit: config.datetime_unit ?? undefined, }; } + if (config.sampler_type === "timedelta") { + return { + // biome-ignore lint/style/useNamingConvention: api schema + dt_min: parseNumber(config.dt_min), + // biome-ignore lint/style/useNamingConvention: api schema + dt_max: parseNumber(config.dt_max), + // biome-ignore lint/style/useNamingConvention: api schema + reference_column_name: config.reference_column_name || undefined, + unit: config.timedelta_unit || undefined, + }; + } if (config.sampler_type === "uuid") { return { format: config.uuid_format ?? undefined, @@ -389,7 +438,7 @@ export function buildCanvasPayload( if (config.kind === "sampler") { nameToConfig.set(config.name, config); - columns.push({ + const samplerColumn: Record = { // biome-ignore lint/style/useNamingConvention: api schema column_type: "sampler", name: config.name, @@ -398,7 +447,15 @@ export function buildCanvasPayload( params: buildSamplerParams(config, errors), // biome-ignore lint/style/useNamingConvention: api schema convert_to: config.convert_to ?? undefined, - }); + }; + if (config.sampler_type === "category") { + const conditionalParams = buildCategoryConditionalParams(config, errors); + if (conditionalParams) { + // biome-ignore lint/style/useNamingConvention: api schema + samplerColumn.conditional_params = conditionalParams; + } + } + columns.push(samplerColumn); } else if (config.kind === "llm") { columns.push(buildLlmColumn(config, errors)); if (config.model_alias) { @@ -442,6 +499,24 @@ export function buildCanvasPayload( } } } + for (const config of Object.values(configs)) { + if (config.kind !== "sampler" || config.sampler_type !== "timedelta") { + continue; + } + const reference = config.reference_column_name?.trim() ?? ""; + if (!reference) { + errors.push(`Timedelta ${config.name}: reference datetime column required.`); + continue; + } + const parent = nameToConfig.get(reference); + if ( + !parent || + parent.kind !== "sampler" || + parent.sampler_type !== "datetime" + ) { + errors.push(`Timedelta ${config.name}: reference '${reference}' must be datetime.`); + } + } for (const alias of modelAliases) { if ( diff --git a/studio/frontend/src/features/canvas-lab/utils/validation.ts b/studio/frontend/src/features/canvas-lab/utils/validation.ts index b6a3b59474..5887441d06 100644 --- a/studio/frontend/src/features/canvas-lab/utils/validation.ts +++ b/studio/frontend/src/features/canvas-lab/utils/validation.ts @@ -44,6 +44,34 @@ export function getConfigErrors(config: NodeConfig | null): string[] { if (hasWeights && weights.some((weight) => weight === null)) { errors.push("Weights must be set for all values."); } + for (const [condition, params] of Object.entries( + config.conditional_params ?? {}, + )) { + if (!condition.trim()) { + errors.push("Category conditional rule needs condition text."); + continue; + } + const conditionalValues = (params.values ?? []) + .map((value) => value.trim()) + .filter(Boolean); + if (conditionalValues.length === 0) { + errors.push(`Category conditional '${condition}' needs values.`); + continue; + } + const conditionalWeights = params.weights ?? []; + const hasConditionalWeights = conditionalWeights.some( + (weight) => weight !== null, + ); + if ( + hasConditionalWeights && + (conditionalWeights.length !== conditionalValues.length || + conditionalWeights.some((weight) => weight === null)) + ) { + errors.push( + `Category conditional '${condition}' weights must be set for all values.`, + ); + } + } } if (config.sampler_type === "uniform") { const low = parseNumber(config.low); @@ -63,6 +91,14 @@ export function getConfigErrors(config: NodeConfig | null): string[] { errors.push("Gaussian std must be > 0."); } } + if (config.sampler_type === "bernoulli") { + const p = parseNumber(config.p); + if (p === null) { + errors.push("Bernoulli p must be a number."); + } else if (p < 0 || p > 1) { + errors.push("Bernoulli p must be between 0 and 1."); + } + } if (config.sampler_type === "datetime") { if (!config.datetime_unit) { errors.push("Datetime unit required."); @@ -77,6 +113,21 @@ export function getConfigErrors(config: NodeConfig | null): string[] { } } } + if (config.sampler_type === "timedelta") { + const min = parseNumber(config.dt_min); + const max = parseNumber(config.dt_max); + if (min === null || max === null) { + errors.push("Timedelta dt_min/dt_max must be numbers."); + } else if (min >= max) { + errors.push("Timedelta dt_min must be < dt_max."); + } + if (!config.reference_column_name?.trim()) { + errors.push("Timedelta reference datetime column required."); + } + if (!config.timedelta_unit) { + errors.push("Timedelta unit required."); + } + } if (config.sampler_type === "subcategory" && !config.subcategory_parent) { errors.push("Subcategory needs a parent category column."); }