// Pipeline-as-data: the canonical JSON block-graph. Visual-editor workspace // documents (e.g. Blockly JSON) are derived views and never enter levelset. // // Deliberate v1 simplification: no port-wiring edges. Today's engine // consumes constraints and scorers as *sets*, so a flat block list with // family grouping is the honest format. The `formatVersion` literal is the // migration seam for richer wiring (formatVersion 2, when SearchTransformers // land). import { z } from "zod"; import { cardinalityMessage, FAMILY_CARDINALITY, FAMILY_ORDER, } from "./composition.ts"; import type { BlockFamily, BlockRegistry } from "./registry.ts"; export const blockInstanceSchema = z.object({ instanceId: z.string(), /** Registry code resolving to a `BlockDef`. */ code: z.string(), params: z.record(z.string(), z.unknown()), provenance: z.enum(["curated", "candidate"]).default("curated"), /** True while the instance still carries an unreplaced curated default. */ shadow: z.boolean().default(false), /** The present-but-inactive lever: a disabled block stays in the graph * (for what-if toggling) but compiles to nothing enforced/scored. * Optional with "undefined = enabled" (rather than defaulted) so * existing BlockInstance constructions stay valid. */ enabled: z.boolean().optional(), }); export type BlockInstance = z.infer; export const pipelineGraphSchema = z.object({ formatVersion: z.literal(1), /** App convention (OHT/STK/CNV); opaque to levelset. */ systemCode: z.string().optional(), blocks: z.array(blockInstanceSchema), }); export type PipelineGraph = z.infer; export interface GraphIssue { instanceId?: string; code?: string; /** Structured location of the offending value — the zod issue path, * relative to the graph root for shape issues and to the block's * `params` for param issues — so canvas/panel UIs can target the exact * field instead of re-parsing the message. */ path?: (string | number)[]; message: string; } function formatPath(path: readonly PropertyKey[]): string { return path.map(String).join("."); } function toPath(path: readonly PropertyKey[]): (string | number)[] { return path.map((seg) => (typeof seg === "number" ? seg : String(seg))); } export type ValidateGraphResult = | { ok: true; graph: PipelineGraph; /** Per-instance params as parsed by each block's input schema * (defaults/transforms applied), keyed by instanceId. `graph` keeps * the raw params — it is the canonical artifact; this map is what * `apply()` should consume, so compilation never re-parses. */ parsedParams: ReadonlyMap; } | { ok: false; issues: GraphIssue[] }; /** * Validates an untrusted value as a pipeline graph against a registry: * schema shape, every `code` resolves, every `params` parses against that * block's `schema.input`, exactly one `search`-family block, and no * duplicate `instanceId`. This is the validation surface a canvas's * strict-connection checker mirrors visually — a canvas must not be able to * author a graph that fails here. */ export function validateGraph( registry: BlockRegistry, graph: unknown, ): ValidateGraphResult { const parsed = pipelineGraphSchema.safeParse(graph); if (!parsed.success) { return { ok: false, issues: parsed.error.issues.map((issue) => ({ path: toPath(issue.path), message: `${formatPath(issue.path)}: ${issue.message}`, })), }; } const issues: GraphIssue[] = []; const seenIds = new Set(); const parsedParams = new Map(); const familyCounts = new Map(); for (const instance of parsed.data.blocks) { if (seenIds.has(instance.instanceId)) { issues.push({ instanceId: instance.instanceId, code: instance.code, message: `Duplicate instanceId: ${instance.instanceId}`, }); } seenIds.add(instance.instanceId); const def = registry.get(instance.code); if (!def) { issues.push({ instanceId: instance.instanceId, code: instance.code, message: `Unknown block code: ${instance.code}`, }); continue; } familyCounts.set(def.family, (familyCounts.get(def.family) ?? 0) + 1); const params = def.block.schema.input.safeParse(instance.params); if (params.success) { parsedParams.set(instance.instanceId, params.data); } else { for (const issue of params.error.issues) { issues.push({ instanceId: instance.instanceId, code: instance.code, path: toPath(issue.path), message: `Invalid params for ${instance.code}: ${formatPath(issue.path)}: ${issue.message}`, }); } } } for (const family of FAMILY_ORDER) { const card = FAMILY_CARDINALITY[family]; const count = familyCounts.get(family) ?? 0; if (count < card.min || (card.max !== null && count > card.max)) { issues.push({ message: cardinalityMessage(family, card, count) }); } } if (issues.length > 0) return { ok: false, issues }; return { ok: true, graph: parsed.data, parsedParams }; }