From ff991b2ff4f607bf7e10eaf49d5ff24c9e2ca082 Mon Sep 17 00:00:00 2001 From: "Russ T. Fugal" Date: Thu, 16 Jul 2026 12:29:44 -0600 Subject: [PATCH] feat(level-blocks): family composition rules as registry data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lift the family grammar into src/level-blocks/composition.ts as pure data and predicates: FAMILY_ORDER, FAMILY_FOLLOWS, canFollow, sortIntoBandOrder, and FAMILY_CARDINALITY. validateGraph now loops over FAMILY_CARDINALITY instead of hard-coding the exactly-one-search check — behavior identical, existing tests unchanged — so a future per-family bound is a data edit. This is the levelset-side source of truth a canvas connection checker mirrors; a canFollow table test captures the contract so the vendor sync can delete its own copy. --- src/level-blocks/composition.ts | 93 ++++++++++++++++++++ src/level-blocks/graph.ts | 17 ++-- src/leveling.ts | 1 + test/level-blocks/composition.test.ts | 120 ++++++++++++++++++++++++++ 4 files changed, 224 insertions(+), 7 deletions(-) create mode 100644 src/level-blocks/composition.ts create mode 100644 test/level-blocks/composition.test.ts diff --git a/src/level-blocks/composition.ts b/src/level-blocks/composition.ts new file mode 100644 index 0000000..3a2bbcd --- /dev/null +++ b/src/level-blocks/composition.ts @@ -0,0 +1,93 @@ +// 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)}`; +} diff --git a/src/level-blocks/graph.ts b/src/level-blocks/graph.ts index 6a686b3..f18a46f 100644 --- a/src/level-blocks/graph.ts +++ b/src/level-blocks/graph.ts @@ -9,7 +9,8 @@ import { z } from "zod"; -import type { BlockRegistry } from "./registry.ts"; +import { cardinalityMessage, FAMILY_CARDINALITY, FAMILY_ORDER } from "./composition.ts"; +import type { BlockFamily, BlockRegistry } from "./registry.ts"; export const blockInstanceSchema = z.object({ instanceId: z.string(), @@ -89,7 +90,7 @@ export function validateGraph(registry: BlockRegistry, graph: unknown): Validate const issues: GraphIssue[] = []; const seenIds = new Set(); const parsedParams = new Map(); - let searchCount = 0; + const familyCounts = new Map(); for (const instance of parsed.data.blocks) { if (seenIds.has(instance.instanceId)) { @@ -111,7 +112,7 @@ export function validateGraph(registry: BlockRegistry, graph: unknown): Validate continue; } - if (def.family === "search") searchCount += 1; + familyCounts.set(def.family, (familyCounts.get(def.family) ?? 0) + 1); const params = def.block.schema.input.safeParse(instance.params); if (params.success) { @@ -128,10 +129,12 @@ export function validateGraph(registry: BlockRegistry, graph: unknown): Validate } } - if (searchCount !== 1) { - issues.push({ - message: `Expected exactly one search-family block, found ${String(searchCount)}`, - }); + 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 }; diff --git a/src/leveling.ts b/src/leveling.ts index d444b4f..92002ca 100644 --- a/src/leveling.ts +++ b/src/leveling.ts @@ -78,6 +78,7 @@ export { serialSGS } from "./level-core/search/serialSGS.ts"; // ── Pipeline-as-data (registry, graph format, compile traversal) ───── export * from "./level-blocks/registry.ts"; +export * from "./level-blocks/composition.ts"; export * from "./level-blocks/graph.ts"; export * from "./level-blocks/compile.ts"; export * from "./level-blocks/standard.ts"; diff --git a/test/level-blocks/composition.test.ts b/test/level-blocks/composition.test.ts new file mode 100644 index 0000000..3550399 --- /dev/null +++ b/test/level-blocks/composition.test.ts @@ -0,0 +1,120 @@ +import { test, expect, describe } from "bun:test"; + +import { + canFollow, + cardinalityMessage, + FAMILY_CARDINALITY, + FAMILY_FOLLOWS, + FAMILY_ORDER, + sortIntoBandOrder, +} from "../../src/level-blocks/composition.ts"; +import type { BlockFamily } from "../../src/level-blocks/registry.ts"; + +const FAMILIES: readonly BlockFamily[] = ["constraint", "scorer", "search", "collector"]; + +describe("canFollow — the family grammar", () => { + // The full truth table, mirroring connections.ts NEXT_ALLOWED. When a + // vendor sync deletes its copy, this table is the contract it inherits. + const EXPECTED: Record> = { + constraint: { constraint: true, scorer: true, search: true, collector: false }, + scorer: { constraint: false, scorer: true, search: true, collector: false }, + search: { constraint: false, scorer: false, search: false, collector: true }, + collector: { constraint: false, scorer: false, search: false, collector: true }, + }; + + for (const above of FAMILIES) { + for (const below of FAMILIES) { + test(`${below} may ${EXPECTED[above][below] ? "" : "not "}follow ${above}`, () => { + expect(canFollow(above, below)).toBe(EXPECTED[above][below]); + }); + } + } + + test("FAMILY_FOLLOWS agrees with canFollow", () => { + for (const above of FAMILIES) { + for (const below of FAMILIES) { + expect(FAMILY_FOLLOWS[above].includes(below)).toBe(canFollow(above, below)); + } + } + }); + + test("a scorer physically cannot sit above a constraint", () => { + expect(canFollow("scorer", "constraint")).toBe(false); + }); + + test("only collectors may follow the search block", () => { + expect(FAMILY_FOLLOWS.search).toEqual(["collector"]); + }); +}); + +describe("FAMILY_CARDINALITY", () => { + test("exactly one search, everything else open", () => { + expect(FAMILY_CARDINALITY.search).toEqual({ min: 1, max: 1 }); + expect(FAMILY_CARDINALITY.constraint).toEqual({ min: 0, max: null }); + expect(FAMILY_CARDINALITY.scorer).toEqual({ min: 0, max: null }); + expect(FAMILY_CARDINALITY.collector).toEqual({ min: 0, max: null }); + }); + + test("cardinalityMessage preserves the validateGraph search wording", () => { + expect(cardinalityMessage("search", FAMILY_CARDINALITY.search, 0)).toBe( + "Expected exactly one search-family block, found 0", + ); + expect(cardinalityMessage("search", FAMILY_CARDINALITY.search, 2)).toBe( + "Expected exactly one search-family block, found 2", + ); + }); + + test("cardinalityMessage phrasing generalizes to other bounds", () => { + expect(cardinalityMessage("scorer", { min: 1, max: null }, 0)).toBe( + "Expected at least 1 scorer-family blocks, found 0", + ); + expect(cardinalityMessage("collector", { min: 0, max: 2 }, 3)).toBe( + "Expected between 0 and 2 collector-family blocks, found 3", + ); + }); +}); + +describe("sortIntoBandOrder", () => { + const familyOf = (item: { family: BlockFamily | undefined }): BlockFamily | undefined => + item.family; + + test("sorts a shuffled set into canonical band order", () => { + const items = [ + { id: "collector", family: "collector" as const }, + { id: "search", family: "search" as const }, + { id: "scorer", family: "scorer" as const }, + { id: "constraint", family: "constraint" as const }, + ]; + expect(sortIntoBandOrder(items, familyOf).map((i) => i.id)).toEqual([ + "constraint", + "scorer", + "search", + "collector", + ]); + }); + + test("is stable within a band", () => { + const items = [ + { id: "s2", family: "scorer" as const }, + { id: "c1", family: "constraint" as const }, + { id: "s1", family: "scorer" as const }, + ]; + expect(sortIntoBandOrder(items, familyOf).map((i) => i.id)).toEqual(["c1", "s2", "s1"]); + }); + + test("unknown families sink to the end", () => { + const items = [ + { id: "unknown", family: undefined }, + { id: "constraint", family: "constraint" as const }, + ]; + expect(sortIntoBandOrder(items, familyOf).map((i) => i.id)).toEqual(["constraint", "unknown"]); + }); + + test("every adjacent pair of a sorted single-search set satisfies canFollow", () => { + const items = FAMILY_ORDER.map((family) => ({ family })); + const sorted = sortIntoBandOrder(items, (i) => i.family); + for (let i = 1; i < sorted.length; i++) { + expect(canFollow(sorted[i - 1]!.family, sorted[i]!.family)).toBe(true); + } + }); +}); -- 2.54.0