// Family composition rules as registry data — the grammar that says how the // four block families stack into a pipeline. The formatVersion-1 graph is a // flat SET (validateGraph is order-insensitive), so these rules encode a // canonical *reading* order — constraints, then scorers, then the search // strategy, then collectors — plus per-family cardinality, rather than // data-flow wiring. // // This is the levelset-side source of truth that a canvas connection checker // (src/lib/blockly/connections.ts) mirrors visually: a canvas must not be able // to author a stack these rules reject, and `validateGraph` enforces the // cardinality half. Pure data + pure predicates, no Blockly import — the meta // dump ships `{ order, follows, cardinality }` straight from here. import type { BlockFamily } from "./registry.ts"; /** Canonical band order — the reading order a stack is laid out in. */ export const FAMILY_ORDER: readonly BlockFamily[] = [ "constraint", "scorer", "search", "collector", ]; /** Families allowed to sit directly below each family in a stack. The * strict-checker reading (null never matches non-null, otherwise * intersection) buys the invariants: a scorer can't sit above a constraint, * only collectors follow the search, and collectors attach below the search * or another collector. */ export const FAMILY_FOLLOWS: Record = { constraint: ["constraint", "scorer", "search"], scorer: ["scorer", "search"], search: ["collector"], collector: ["collector"], }; /** How many instances of each family a valid graph may hold. `max: null` is * unbounded. Today only `search` is bounded (exactly one); the rest are * open. `validateGraph` loops over this rather than hard-coding the * one-search rule, so a future bound (e.g. exactly-one-objective) is a data * edit here, not a control-flow change there. */ export interface FamilyCardinality { readonly min: number; readonly max: number | null; } export const FAMILY_CARDINALITY: Record = { constraint: { min: 0, max: null }, scorer: { min: 0, max: null }, search: { min: 1, max: 1 }, collector: { min: 0, max: null }, }; /** The pure grammar predicate the family bands encode: may a block of family * `below` connect directly beneath a block of family `above`? */ export function canFollow(above: BlockFamily, below: BlockFamily): boolean { return FAMILY_FOLLOWS[above].includes(below); } /** * Stable family sort into canonical band order. `validateGraph` treats the * block list as a set, so stored graphs may carry any order; sorting before * stacking guarantees every adjacent pair satisfies `canFollow` whenever the * graph holds at most one search block — i.e. every valid stored graph reads * as one connected stack. Unknown families sink to the end, first-seen order * preserved within a band. */ export function sortIntoBandOrder( items: readonly T[], familyOf: (item: T) => BlockFamily | undefined, ): T[] { const rank = (item: T): number => { const family = familyOf(item); return family === undefined ? FAMILY_ORDER.length : FAMILY_ORDER.indexOf(family); }; return [...items] .map((item, index) => ({ item, index })) .sort((a, b) => rank(a.item) - rank(b.item) || a.index - b.index) .map(({ item }) => item); } /** A cardinality violation phrased for a graph issue — kept next to the data * so the search wording ("Expected exactly one search-family block, found N") * stays stable as the rule generalizes. */ export function cardinalityMessage( family: BlockFamily, card: FamilyCardinality, count: number, ): string { let expectation: string; if (card.max === card.min) { expectation = `exactly ${card.min === 1 ? "one" : String(card.min)}`; } else if (card.max === null) { expectation = `at least ${String(card.min)}`; } else { expectation = `between ${String(card.min)} and ${String(card.max)}`; } const plural = card.min === 1 && card.max === 1 ? "" : "s"; return `Expected ${expectation} ${family}-family block${plural}, found ${String(count)}`; }