Something went wrong. Try again.
A functional toolkit for authoring resource-leveling algorithms — compose constraint and scoring blocks, enumerate feasible schedules
Something went wrong. Try again.
5.3 kB · 149 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150// 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<typeof blockInstanceSchema>;
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<typeof pipelineGraphSchema>;
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<string, unknown>; } | { 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<string>(); const parsedParams = new Map<string, unknown>(); const familyCounts = new Map<BlockFamily, number>();
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 };}