new blocks timedelta, and some tweaks

This commit is contained in:
shine1i 2026-02-05 21:55:50 +01:00
commit d67efb8516
17 changed files with 918 additions and 71 deletions

View file

@ -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" ? (
<CategoryDialog
key={config.id}
config={config}
onUpdate={(patch) => 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" ? (
<BernoulliDialog
config={config}
onUpdate={(patch) => 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" ? (
<TimedeltaDialog
config={config}
onUpdate={(patch) => onUpdate(config.id, patch)}
/>
) : null,
},
{
kind: "sampler",
type: "uuid",

View file

@ -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,

View file

@ -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<SamplerType, IconType> = {
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 (
<div
className={cn(
@ -126,16 +142,38 @@ function CanvasNodeBase({
</p>
</div>
</div>
<Handle
type="target"
position={isHorizontal ? Position.Left : Position.Top}
className="size-2 border border-border bg-white"
/>
<Handle
type="source"
position={isHorizontal ? Position.Right : Position.Bottom}
className="size-2 border border-border bg-white"
/>
{showDataHandles && (
<>
<Handle
id={HANDLE_IDS.dataIn}
type="target"
position={Position.Left}
className="size-2 border border-border bg-white"
/>
<Handle
id={HANDLE_IDS.dataOut}
type="source"
position={Position.Right}
className="size-2 border border-border bg-white"
/>
</>
)}
{showSemanticIn && (
<Handle
id={HANDLE_IDS.semanticIn}
type="target"
position={Position.Top}
className="size-2 border border-border bg-white"
/>
)}
{showSemanticOut && (
<Handle
id={HANDLE_IDS.semanticOut}
type="source"
position={Position.Bottom}
className="size-2 border border-border bg-white"
/>
)}
</div>
);
}

View file

@ -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<SamplerConfig>) => void;
};
export function BernoulliDialog({
config,
onUpdate,
}: BernoulliDialogProps): ReactElement {
const pId = `${config.id}-bernoulli-p`;
return (
<div className="space-y-4">
<NameField
value={config.name}
onChange={(value) => onUpdate({ name: value })}
/>
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={pId}
>
Probability (p)
</label>
<Input
id={pId}
type="number"
min="0"
max="1"
step="0.01"
className="nodrag"
value={config.p ?? ""}
onChange={(event) => onUpdate({ p: event.target.value })}
/>
</div>
</div>
);
}

View file

@ -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<string, string>
>({});
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 (
<div className="space-y-4">
<NameField
@ -117,6 +170,149 @@ export function CategoryDialog({
</div>
</div>
</div>
<div className="space-y-3 rounded-2xl border border-border/60 p-3">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold uppercase text-muted-foreground">
Conditional params (category)
</p>
<span className="text-xs text-muted-foreground">
{Object.keys(conditional).length} rules
</span>
</div>
<div className="flex gap-2">
<Input
id={conditionInputId}
className="nodrag"
placeholder="Condition (e.g., {{ region }} == 'US')"
value={conditionDraft}
onChange={(event) => setConditionDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleAddCondition();
}
}}
/>
<Button type="button" size="sm" onClick={handleAddCondition}>
Add rule
</Button>
</div>
{Object.entries(conditional).map(([condition, params]) => (
<div
key={condition}
className="space-y-3 rounded-2xl border border-border/60 p-3"
>
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold text-foreground">{condition}</p>
<Button
type="button"
size="xs"
variant="ghost"
onClick={() => removeCondition(condition)}
>
Remove
</Button>
</div>
<div className="flex gap-2">
<Input
className="nodrag"
placeholder="Add conditional value"
value={conditionalValueDrafts[condition] ?? ""}
onChange={(event) =>
setConditionalValueDrafts((prev) => ({
...prev,
[condition]: event.target.value,
}))
}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
addConditionalValue(condition);
}
}}
/>
<Button
type="button"
size="sm"
onClick={() => addConditionalValue(condition)}
>
Add
</Button>
</div>
<div className="flex flex-wrap gap-2">
{(params.values ?? []).map((value, index) => (
<Badge
key={`${condition}-${value}-${index}`}
variant="secondary"
>
<span>{value}</span>
<button
type="button"
className="ml-2 text-xs"
onClick={() => {
const values = [...(params.values ?? [])];
const weights = [...(params.weights ?? [])];
values.splice(index, 1);
weights.splice(index, 1);
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
conditional_params: {
...conditional,
[condition]: { ...params, values, weights },
},
});
}}
>
×
</button>
</Badge>
))}
</div>
<div className="grid gap-2">
<p className="text-xs font-semibold uppercase text-muted-foreground">
Rule weights (optional)
</p>
<div className="grid gap-2">
{(params.values ?? []).map((value, index) => (
<div
key={`${condition}-${value}-${index}-weight`}
className="flex items-center gap-3"
>
<span className="text-xs text-muted-foreground w-28 truncate">
{value}
</span>
<Input
type="number"
className="nodrag"
placeholder="Weight"
value={params.weights?.[index] ?? ""}
onChange={(event) => {
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 },
},
});
}}
/>
</div>
))}
</div>
</div>
</div>
))}
</div>
</div>
);
}

View file

@ -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<SamplerConfig>) => 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 = <K extends keyof SamplerConfig>(
key: K,
value: SamplerConfig[K],
) => {
onUpdate({ [key]: value } as Partial<SamplerConfig>);
};
return (
<div className="space-y-4">
<NameField
value={config.name}
onChange={(value) => onUpdate({ name: value })}
/>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={dtMinId}
>
dt_min
</label>
<Input
id={dtMinId}
type="number"
className="nodrag"
value={config.dt_min ?? ""}
onChange={(event) => updateField("dt_min", event.target.value)}
/>
</div>
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={dtMaxId}
>
dt_max
</label>
<Input
id={dtMaxId}
type="number"
className="nodrag"
value={config.dt_max ?? ""}
onChange={(event) => updateField("dt_max", event.target.value)}
/>
</div>
</div>
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={unitId}
>
Unit
</label>
<Select
value={config.timedelta_unit ?? "D"}
onValueChange={(value) =>
updateField("timedelta_unit", value as "D" | "h" | "m" | "s")
}
>
<SelectTrigger className="nodrag w-full" id={unitId}>
<SelectValue placeholder="Select unit" />
</SelectTrigger>
<SelectContent>
{TIMEDELTA_UNITS.map((unit) => (
<SelectItem key={unit} value={unit}>
{unit}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={referenceId}
>
Reference datetime column
</label>
<Select
value={config.reference_column_name?.trim() || NONE_VALUE}
onValueChange={(value) =>
updateField("reference_column_name", value === NONE_VALUE ? "" : value)
}
>
<SelectTrigger className="nodrag w-full" id={referenceId}>
<SelectValue placeholder="Select datetime column" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_VALUE}>None</SelectItem>
{datetimeOptions.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
}

View file

@ -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;

View file

@ -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<CanvasLabState>((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<CanvasLabState>((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<CanvasLabState>((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<SamplerConfig>).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<CanvasLabState>((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<CanvasLabState>((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,
),
}));

View file

@ -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<CanvasNodeData, "builder">;
export type CategoryConditionalParams = {
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "category";
values: string[];
weights?: Array<number | null>;
};
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<string, string[]>;
// biome-ignore lint/style/useNamingConvention: api schema
conditional_params?: Record<string, CategoryConditionalParams>;
};
export type ScoreOption = {

View file

@ -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 = {

View file

@ -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];

View file

@ -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");
}

View file

@ -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<string, unknown>,
): SamplerConfig["conditional_params"] {
if (!isRecord(column.conditional_params)) {
return undefined;
}
const conditional: NonNullable<SamplerConfig["conditional_params"]> = {};
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<string, unknown>,
@ -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,

View file

@ -18,7 +18,9 @@ const SAMPLER_LABELS: Record<SamplerType, string> = {
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,

View file

@ -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<string, CategoryConditionalParams> | undefined {
const conditional = config.conditional_params ?? {};
const output: Record<string, CategoryConditionalParams> = {};
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<string, unknown> = {
// 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 (

View file

@ -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.");
}