A functional toolkit for authoring resource-leveling algorithms — compose constraint and scoring blocks, enumerate feasible schedules
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(level-blocks): family composition rules as registry data

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.

Russ T. Fugal (Jul 16, 2026, 4:20 PM -0600) ff991b2f 1e4486c6

+224 -7
+93
src/level-blocks/composition.ts
··· 1 + // Family composition rules as registry data — the grammar that says how the 2 + // four block families stack into a pipeline. The formatVersion-1 graph is a 3 + // flat SET (validateGraph is order-insensitive), so these rules encode a 4 + // canonical *reading* order — constraints, then scorers, then the search 5 + // strategy, then collectors — plus per-family cardinality, rather than 6 + // data-flow wiring. 7 + // 8 + // This is the levelset-side source of truth that a canvas connection checker 9 + // (src/lib/blockly/connections.ts) mirrors visually: a canvas must not be able 10 + // to author a stack these rules reject, and `validateGraph` enforces the 11 + // cardinality half. Pure data + pure predicates, no Blockly import — the meta 12 + // dump ships `{ order, follows, cardinality }` straight from here. 13 + 14 + import type { BlockFamily } from "./registry.ts"; 15 + 16 + /** Canonical band order — the reading order a stack is laid out in. */ 17 + export const FAMILY_ORDER: readonly BlockFamily[] = ["constraint", "scorer", "search", "collector"]; 18 + 19 + /** Families allowed to sit directly below each family in a stack. The 20 + * strict-checker reading (null never matches non-null, otherwise 21 + * intersection) buys the invariants: a scorer can't sit above a constraint, 22 + * only collectors follow the search, and collectors attach below the search 23 + * or another collector. */ 24 + export const FAMILY_FOLLOWS: Record<BlockFamily, readonly BlockFamily[]> = { 25 + constraint: ["constraint", "scorer", "search"], 26 + scorer: ["scorer", "search"], 27 + search: ["collector"], 28 + collector: ["collector"], 29 + }; 30 + 31 + /** How many instances of each family a valid graph may hold. `max: null` is 32 + * unbounded. Today only `search` is bounded (exactly one); the rest are 33 + * open. `validateGraph` loops over this rather than hard-coding the 34 + * one-search rule, so a future bound (e.g. exactly-one-objective) is a data 35 + * edit here, not a control-flow change there. */ 36 + export interface FamilyCardinality { 37 + readonly min: number; 38 + readonly max: number | null; 39 + } 40 + export const FAMILY_CARDINALITY: Record<BlockFamily, FamilyCardinality> = { 41 + constraint: { min: 0, max: null }, 42 + scorer: { min: 0, max: null }, 43 + search: { min: 1, max: 1 }, 44 + collector: { min: 0, max: null }, 45 + }; 46 + 47 + /** The pure grammar predicate the family bands encode: may a block of family 48 + * `below` connect directly beneath a block of family `above`? */ 49 + export function canFollow(above: BlockFamily, below: BlockFamily): boolean { 50 + return FAMILY_FOLLOWS[above].includes(below); 51 + } 52 + 53 + /** 54 + * Stable family sort into canonical band order. `validateGraph` treats the 55 + * block list as a set, so stored graphs may carry any order; sorting before 56 + * stacking guarantees every adjacent pair satisfies `canFollow` whenever the 57 + * graph holds at most one search block — i.e. every valid stored graph reads 58 + * as one connected stack. Unknown families sink to the end, first-seen order 59 + * preserved within a band. 60 + */ 61 + export function sortIntoBandOrder<T>( 62 + items: readonly T[], 63 + familyOf: (item: T) => BlockFamily | undefined, 64 + ): T[] { 65 + const rank = (item: T): number => { 66 + const family = familyOf(item); 67 + return family === undefined ? FAMILY_ORDER.length : FAMILY_ORDER.indexOf(family); 68 + }; 69 + return [...items] 70 + .map((item, index) => ({ item, index })) 71 + .sort((a, b) => rank(a.item) - rank(b.item) || a.index - b.index) 72 + .map(({ item }) => item); 73 + } 74 + 75 + /** A cardinality violation phrased for a graph issue — kept next to the data 76 + * so the search wording ("Expected exactly one search-family block, found N") 77 + * stays stable as the rule generalizes. */ 78 + export function cardinalityMessage( 79 + family: BlockFamily, 80 + card: FamilyCardinality, 81 + count: number, 82 + ): string { 83 + let expectation: string; 84 + if (card.max === card.min) { 85 + expectation = `exactly ${card.min === 1 ? "one" : String(card.min)}`; 86 + } else if (card.max === null) { 87 + expectation = `at least ${String(card.min)}`; 88 + } else { 89 + expectation = `between ${String(card.min)} and ${String(card.max)}`; 90 + } 91 + const plural = card.min === 1 && card.max === 1 ? "" : "s"; 92 + return `Expected ${expectation} ${family}-family block${plural}, found ${String(count)}`; 93 + }
+10 -7
src/level-blocks/graph.ts
··· 9 9 10 10 import { z } from "zod"; 11 11 12 - import type { BlockRegistry } from "./registry.ts"; 12 + import { cardinalityMessage, FAMILY_CARDINALITY, FAMILY_ORDER } from "./composition.ts"; 13 + import type { BlockFamily, BlockRegistry } from "./registry.ts"; 13 14 14 15 export const blockInstanceSchema = z.object({ 15 16 instanceId: z.string(), ··· 89 90 const issues: GraphIssue[] = []; 90 91 const seenIds = new Set<string>(); 91 92 const parsedParams = new Map<string, unknown>(); 92 - let searchCount = 0; 93 + const familyCounts = new Map<BlockFamily, number>(); 93 94 94 95 for (const instance of parsed.data.blocks) { 95 96 if (seenIds.has(instance.instanceId)) { ··· 111 112 continue; 112 113 } 113 114 114 - if (def.family === "search") searchCount += 1; 115 + familyCounts.set(def.family, (familyCounts.get(def.family) ?? 0) + 1); 115 116 116 117 const params = def.block.schema.input.safeParse(instance.params); 117 118 if (params.success) { ··· 128 129 } 129 130 } 130 131 131 - if (searchCount !== 1) { 132 - issues.push({ 133 - message: `Expected exactly one search-family block, found ${String(searchCount)}`, 134 - }); 132 + for (const family of FAMILY_ORDER) { 133 + const card = FAMILY_CARDINALITY[family]; 134 + const count = familyCounts.get(family) ?? 0; 135 + if (count < card.min || (card.max !== null && count > card.max)) { 136 + issues.push({ message: cardinalityMessage(family, card, count) }); 137 + } 135 138 } 136 139 137 140 if (issues.length > 0) return { ok: false, issues };
+1
src/leveling.ts
··· 78 78 79 79 // ── Pipeline-as-data (registry, graph format, compile traversal) ───── 80 80 export * from "./level-blocks/registry.ts"; 81 + export * from "./level-blocks/composition.ts"; 81 82 export * from "./level-blocks/graph.ts"; 82 83 export * from "./level-blocks/compile.ts"; 83 84 export * from "./level-blocks/standard.ts";
+120
test/level-blocks/composition.test.ts
··· 1 + import { test, expect, describe } from "bun:test"; 2 + 3 + import { 4 + canFollow, 5 + cardinalityMessage, 6 + FAMILY_CARDINALITY, 7 + FAMILY_FOLLOWS, 8 + FAMILY_ORDER, 9 + sortIntoBandOrder, 10 + } from "../../src/level-blocks/composition.ts"; 11 + import type { BlockFamily } from "../../src/level-blocks/registry.ts"; 12 + 13 + const FAMILIES: readonly BlockFamily[] = ["constraint", "scorer", "search", "collector"]; 14 + 15 + describe("canFollow — the family grammar", () => { 16 + // The full truth table, mirroring connections.ts NEXT_ALLOWED. When a 17 + // vendor sync deletes its copy, this table is the contract it inherits. 18 + const EXPECTED: Record<BlockFamily, Record<BlockFamily, boolean>> = { 19 + constraint: { constraint: true, scorer: true, search: true, collector: false }, 20 + scorer: { constraint: false, scorer: true, search: true, collector: false }, 21 + search: { constraint: false, scorer: false, search: false, collector: true }, 22 + collector: { constraint: false, scorer: false, search: false, collector: true }, 23 + }; 24 + 25 + for (const above of FAMILIES) { 26 + for (const below of FAMILIES) { 27 + test(`${below} may ${EXPECTED[above][below] ? "" : "not "}follow ${above}`, () => { 28 + expect(canFollow(above, below)).toBe(EXPECTED[above][below]); 29 + }); 30 + } 31 + } 32 + 33 + test("FAMILY_FOLLOWS agrees with canFollow", () => { 34 + for (const above of FAMILIES) { 35 + for (const below of FAMILIES) { 36 + expect(FAMILY_FOLLOWS[above].includes(below)).toBe(canFollow(above, below)); 37 + } 38 + } 39 + }); 40 + 41 + test("a scorer physically cannot sit above a constraint", () => { 42 + expect(canFollow("scorer", "constraint")).toBe(false); 43 + }); 44 + 45 + test("only collectors may follow the search block", () => { 46 + expect(FAMILY_FOLLOWS.search).toEqual(["collector"]); 47 + }); 48 + }); 49 + 50 + describe("FAMILY_CARDINALITY", () => { 51 + test("exactly one search, everything else open", () => { 52 + expect(FAMILY_CARDINALITY.search).toEqual({ min: 1, max: 1 }); 53 + expect(FAMILY_CARDINALITY.constraint).toEqual({ min: 0, max: null }); 54 + expect(FAMILY_CARDINALITY.scorer).toEqual({ min: 0, max: null }); 55 + expect(FAMILY_CARDINALITY.collector).toEqual({ min: 0, max: null }); 56 + }); 57 + 58 + test("cardinalityMessage preserves the validateGraph search wording", () => { 59 + expect(cardinalityMessage("search", FAMILY_CARDINALITY.search, 0)).toBe( 60 + "Expected exactly one search-family block, found 0", 61 + ); 62 + expect(cardinalityMessage("search", FAMILY_CARDINALITY.search, 2)).toBe( 63 + "Expected exactly one search-family block, found 2", 64 + ); 65 + }); 66 + 67 + test("cardinalityMessage phrasing generalizes to other bounds", () => { 68 + expect(cardinalityMessage("scorer", { min: 1, max: null }, 0)).toBe( 69 + "Expected at least 1 scorer-family blocks, found 0", 70 + ); 71 + expect(cardinalityMessage("collector", { min: 0, max: 2 }, 3)).toBe( 72 + "Expected between 0 and 2 collector-family blocks, found 3", 73 + ); 74 + }); 75 + }); 76 + 77 + describe("sortIntoBandOrder", () => { 78 + const familyOf = (item: { family: BlockFamily | undefined }): BlockFamily | undefined => 79 + item.family; 80 + 81 + test("sorts a shuffled set into canonical band order", () => { 82 + const items = [ 83 + { id: "collector", family: "collector" as const }, 84 + { id: "search", family: "search" as const }, 85 + { id: "scorer", family: "scorer" as const }, 86 + { id: "constraint", family: "constraint" as const }, 87 + ]; 88 + expect(sortIntoBandOrder(items, familyOf).map((i) => i.id)).toEqual([ 89 + "constraint", 90 + "scorer", 91 + "search", 92 + "collector", 93 + ]); 94 + }); 95 + 96 + test("is stable within a band", () => { 97 + const items = [ 98 + { id: "s2", family: "scorer" as const }, 99 + { id: "c1", family: "constraint" as const }, 100 + { id: "s1", family: "scorer" as const }, 101 + ]; 102 + expect(sortIntoBandOrder(items, familyOf).map((i) => i.id)).toEqual(["c1", "s2", "s1"]); 103 + }); 104 + 105 + test("unknown families sink to the end", () => { 106 + const items = [ 107 + { id: "unknown", family: undefined }, 108 + { id: "constraint", family: "constraint" as const }, 109 + ]; 110 + expect(sortIntoBandOrder(items, familyOf).map((i) => i.id)).toEqual(["constraint", "unknown"]); 111 + }); 112 + 113 + test("every adjacent pair of a sorted single-search set satisfies canFollow", () => { 114 + const items = FAMILY_ORDER.map((family) => ({ family })); 115 + const sorted = sortIntoBandOrder(items, (i) => i.family); 116 + for (let i = 1; i < sorted.length; i++) { 117 + expect(canFollow(sorted[i - 1]!.family, sorted[i]!.family)).toBe(true); 118 + } 119 + }); 120 + });