17 KiB
Capability Spec Delta: emblem-solver (emblem-solver)
Package: @suaveplan/emblem-solver
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. No exceptions.
ADDED Requirements
Requirement: Stable error code registry
The package SHALL expose EMBLEM_SOLVER_CODES created via
defineCodes("EMBLEM_SOLVER", [...] as const) containing exactly the codes
INVALID_GRID, INVALID_OPTIONS, INVALID_PIXEL. The registry MUST be
frozen and each value MUST be the namespaced string
(EMBLEM_SOLVER.INVALID_GRID, …).
Scenario: registry shape is frozen
- WHEN a consumer reads
EMBLEM_SOLVER_CODES - THEN the returned object MUST contain all three expected keys with
namespaced string values and MUST be
Object.isFrozen.
Verified by: src/errors/errors.test.ts::"EMBLEM_SOLVER_CODES exposes all three codes"
Requirement: EmblemSolverError extends SuaveplanError
The package SHALL export EmblemSolverError extends SuaveplanError<EmblemSolverCode>.
Constructing new EmblemSolverError(EMBLEM_SOLVER_CODES.INVALID_GRID, "msg")
MUST yield an instance whose .code === "EMBLEM_SOLVER.INVALID_GRID", whose
.message === "msg", and which satisfies instanceof EmblemSolverError
AND instanceof Error. A cause passed in options MUST be preserved.
Scenario: round-trips through cause chain
- WHEN an
EmblemSolverErroris constructed withcause: new Error("inner") - THEN
error.causeMUST return the original error anderror.codeMUST preserve the namespaced code.
Verified by: src/errors/errors.test.ts::"EmblemSolverError preserves code and cause"
Requirement: ConvertOptions Zod validation
The package SHALL expose ConvertOptionsSchema (Zod) accepting
layerOrder (enum: auto | area-desc | area-asc | island-aware | perimeter-desc,
default auto), maxPermutations (integer ≥ 1, default 40320), and
optional rng (() => number). The schema MUST reject unknown keys
(strict). convert() MUST call safeParse and on failure throw
EmblemSolverError(INVALID_OPTIONS) whose message embeds the Zod issue list.
Scenario: rejects unknown option
- WHEN
convert(pixels, w, h, { wibble: 1 } as never)is invoked - THEN the call MUST throw
EmblemSolverErrorwhose.codeequalsEMBLEM_SOLVER.INVALID_OPTIONS.
Scenario: rejects non-positive maxPermutations
- WHEN
convert(pixels, w, h, { maxPermutations: 0 })is invoked - THEN the call MUST throw
EmblemSolverError(INVALID_OPTIONS).
Verified by: src/schemas/schemas.test.ts::"ConvertOptionsSchema rejects unknown keys" AND src/converter/converter.test.ts::"throws INVALID_OPTIONS on bad option"
Requirement: Grid input validation
The convert() entry point SHALL throw
EmblemSolverError(EMBLEM_SOLVER_CODES.INVALID_GRID) when width ≤ 0,
height ≤ 0, or pixels.length !== width * height. The
parseRGBABuffer() entry point SHALL throw the same error when the supplied
Uint8Array length is not exactly width * height * 4.
Scenario: width/height mismatch
- WHEN
convert([], 2, 2)is invoked with empty pixels - THEN the call MUST throw
EmblemSolverErrorwhose.codeequalsEMBLEM_SOLVER.INVALID_GRID.
Scenario: buffer length mismatch
- WHEN
parseRGBABuffer(new Uint8Array(3), 2, 2)is invoked - THEN the call MUST throw
EmblemSolverError(INVALID_GRID).
Verified by: src/converter/converter.test.ts::"throws INVALID_GRID on dimension mismatch" AND src/io/parse/parse.test.ts::"throws INVALID_GRID on buffer length mismatch"
Requirement: Public type surface
The package SHALL export the following types verbatim from the package root:
RGBA, Rect, Island, ColourAnalysis, ColourLayer, ConvertOptions,
ConvertResult, GridAnalysis, Rng, EmblemSolverCode. The shapes MUST
match the upstream contract (RGBA.a: 0 | 1, Rect: {x,y,w,h},
Island.pixels: Set<number>, ColourAnalysis includes
density/fragmentation/bounds/islands).
Scenario: type re-exports are present
- WHEN a TypeScript consumer imports the type set above
- THEN type-checking MUST succeed against the documented shapes.
Verified by: src/types/types.md AND src/converter/converter.test.ts::"ConvertResult exposes all documented fields"
Requirement: Pixel-grid analysis with 4-connected islands
The package SHALL expose analyseGrid(pixels, width, height) which produces
a GridAnalysis containing width, height, opaquePixels (a
Set<number> of packed indices), colourGroups (a Map<string, Set<number>>
keyed by "r,g,b"), and colourAnalysis (a Map<string, ColourAnalysis>)
where each entry reports totalPixels, bounds, density = pixels / bbox area,
fragmentation = islands / pixels, and the full islands list discovered via
4-connected BFS flood-fill.
Scenario: detects two disjoint islands
- WHEN
analyseGridruns on a grid where one colour appears in two blocks separated by transparency - THEN that colour's
islands.lengthMUST equal2.
Scenario: diagonals are not connected
- WHEN
analyseGridruns on a[[1,0],[0,1]]grid - THEN the only colour's
islands.lengthMUST equal2.
Verified by: src/grid/analyse/analyse.test.ts::"detects two islands separated by transparency" AND src/grid/analyse/analyse.test.ts::"treats diagonal pixels as separate islands (4-connected)"
Requirement: RGBA buffer + indexed grid IO
The package SHALL expose parseRGBABuffer(buf, w, h) returning RGBA[] of
length w*h where a is quantised to 0 | 1 at the threshold byte ≥ 128.
It SHALL also expose fromIndexedGrid(grid, palette) returning
{ pixels, width, height } from a number[][] palette map. Index 0 MUST
map to a fully-transparent pixel (a === 0). Indices not present in the
palette MUST also produce a transparent pixel.
Scenario: alpha quantisation rounds half-up
- WHEN
parseRGBABufferreads a byte witha = 128 - THEN the resulting
RGBA.aMUST equal1; fora = 127it MUST be0.
Scenario: indexed grid maps palette correctly
- WHEN
fromIndexedGrid([[1,0],[0,2]], {1:r, 2:g})is called - THEN result
pixels[0]MUST be ther-colour witha===1,pixels[1]MUST be transparent, andpixels[3]MUST be theg-colour.
Verified by: src/io/parse/parse.test.ts::"parseRGBABuffer quantises alpha" AND src/io/parse/parse.test.ts::"fromIndexedGrid maps palette indices to RGBA pixels"
Requirement: Dual-axis histogram maximal-rectangles
The package SHALL expose a dualAxisMaximalRects(available, rows, cols)
solver that enumerates every distinct maximal-rectangle candidate in the
opaque-pixel grid by combining a normal histogram sweep and a transposed
sweep. Duplicates MUST be deduplicated by their (x,y,w,h) key.
Scenario: solid 3x3 produces the full rect
- WHEN the input is a 3×3 all-
truegrid - THEN the returned set MUST include
{x:0,y:0,w:3,h:3}.
Verified by: src/solver/histogram/histogram.test.ts::"includes the full grid rect for a solid grid"
Requirement: Strip decomposition picks the smaller axis
The package SHALL expose stripDecomposition(required, rows, cols) that
computes maximal horizontal strips (and the transposed vertical variant)
where same-extent + adjacent runs merge, and returns whichever orientation
yields fewer rects.
Scenario: tall vertical strip beats horizontal runs
- WHEN the required mask is a single column of height
5 - THEN the returned strips MUST contain exactly one rect of width
1and height5.
Verified by: src/solver/strip/strip.test.ts::"single column produces one vertical strip"
Requirement: Enhanced greedy set-cover with three-key tiebreak
The package SHALL expose enhancedGreedyCover(candidates, required, w, h)
that selects rects to cover the required mask using a deterministic score
of covered*1000 + efficiency*100 + proximity*10. When no rect overlaps the
remaining uncovered pixels, the function MUST fall back to emitting 1×1 rects
for the residual.
Scenario: empty required returns no rects
- WHEN all entries of
requiredarefalse - THEN
enhancedGreedyCoverMUST return an empty array.
Verified by: src/solver/greedy/greedy.test.ts::"returns empty array when nothing is required"
Requirement: Per-island independent cover
The package SHALL expose perIslandCover(islands, candidates, w, h) that
runs enhancedGreedyCover independently on each island's local required
mask, restricting candidates to those that touch the island.
Scenario: independent covers don't span islands
- WHEN two 2×2 islands are separated by transparency
- THEN
perIslandCoverMUST produce at least two rects, one per island, and no rect MUST span the transparent gap.
Verified by: src/solver/greedy/greedy.test.ts::"covers two islands without spanning the gap"
Requirement: Layer-ordering search
The package SHALL expose getStaticOrdering(analysis, strategy) for the
strategies area-desc, area-asc, island-aware, perimeter-desc, and
permutations<T>(arr) implementing Heap's algorithm yielding n!
permutations including the identity, and factorial(n) returning the
mathematical factorial with factorial(0) === factorial(1) === 1.
Scenario: permutations yields n! arrays in total
- WHEN
permutations(["a","b","c"])is fully iterated - THEN the iteration MUST emit exactly
6arrays.
Scenario: factorial agrees with the definition
- WHEN
factorial(5)is called - THEN the return value MUST equal
120.
Verified by: src/solver/ordering/ordering.test.ts::"permutations yields n! arrays" AND src/solver/ordering/ordering.test.ts::"factorial matches the standard definition"
Requirement: Multi-strategy layer processor
The package SHALL expose processLayerMultiStrategy(key, ownPixels, analysis, earlierPixels) that runs the four covering strategies
(dual-axis-histogram + greedy, per-island, strip decomposition where it
fully covers, and a merge pass on the best so far) and returns the rect
list with the smallest cardinality.
Scenario: returns a valid cover
- WHEN the layer processor runs on the upstream
cross3x3fixture's red pixels - THEN the returned rect list MUST cover every required red pixel and
MUST contain at most
2rects.
Verified by: src/solver/layer/layer.test.ts::"cross3x3 produces at most 2 rects"
Requirement: Adjacent-rect merge optimisation
The package SHALL expose mergeAdjacentRects(rects, available) which
combines edge-sharing same-extent rect pairs into a single larger rect
provided the merged span is fully available. The function MUST iterate
until no further merge is possible.
Scenario: two adjacent unit squares merge
- WHEN
mergeAdjacentRects([{0,0,1,1},{1,0,1,1}], all-available)runs - THEN the output MUST be exactly
[{0,0,2,1}].
Verified by: src/optimizer/merge/merge.test.ts::"merges two horizontally adjacent unit squares"
Requirement: Overlap optimiser
The package SHALL expose optimiseOverlap(rects, required, available, rows, cols) that (1) merges pairs whose bbox is fully available and whose union
covers no more than the original required pixels, (2) extends a rect to
subsume another while remaining inside available, and (3) replaces a
triple of rects with a 2-rect cross/T/L pattern when one exists.
Scenario: cross 3x3 reduces to two rects
- WHEN
convertruns on the upstreamcross3x3fixture - THEN
ConvertResult.totalRectsMUST be at most2.
Verified by: src/optimizer/overlap/overlap.test.ts::"reduces a 3-rect L to 2 rects" AND src/converter/converter.test.ts::"cross 3x3 reduces to <=2 rects"
Requirement: Cross-layer redundancy elimination
The package SHALL expose eliminateRedundant(layers, width, height) which,
given a list of ColourLayer objects, removes any rect whose pixels are
either fully occluded by higher-z layers (of any colour) or already covered
by sibling rects in the same layer.
Scenario: fully-occluded rect is removed
- WHEN layer 0 contains a 4×4 red rect at (0,0) and layer 1 contains a 4×4 blue rect at (0,0)
- THEN after
eliminateRedundantlayer 0 MUST have zero rects.
Verified by: src/optimizer/redundancy/redundancy.test.ts::"removes a fully-occluded rect"
Requirement: SVG emission
The package SHALL expose buildSVG(layers, width, height) returning a string
that begins with <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 W H" shape-rendering="crispEdges"> and ends with </svg>, contains one <rect>
per layer-rect with fill="rgb(r,g,b)", and renders layers in zIndex
ascending order.
Scenario: empty layer list produces an empty viewBox svg
- WHEN
buildSVG([], 4, 4)is invoked - THEN the result MUST contain
viewBox="0 0 4 4"and zero<rect>tags.
Verified by: src/svg/build/build.test.ts::"empty layers yields a rect-free svg"
Requirement: Public convert() orchestrator
The package SHALL expose convert(pixels, width, height, options?). The
function MUST (1) parse options via ConvertOptionsSchema, (2) call
analyseGrid, (3) early-return an empty result for fully-transparent input,
(4) explore orderings (exhaustive for colourCount! ≤ maxPermutations;
heuristics + ≤ 1000 RNG-driven samples otherwise), (5) pick the ordering
with the smallest totalRects, (6) emit the SVG via buildSVG, and (7)
return { svg, layers, totalRects, naiveRects, ratio, orderingUsed, orderingsEvaluated, colourAnalysis }.
Scenario: chessboard 8x8 ≤ 33 rects
- WHEN
convertruns on the upstreamchessboard8x8fixture - THEN
result.totalRects ≤ 33.
Scenario: solid grid produces exactly one rect
- WHEN
convertruns on a 4×4 solid-red grid - THEN
result.totalRects === 1.
Scenario: fully transparent grid produces zero rects
- WHEN
convertruns on a 4×4 fully-transparent grid - THEN
result.totalRects === 0andresult.layersMUST be empty.
Scenario: shield emblem 32x32 compresses > 50x
- WHEN
convertruns on the upstreamshieldEmblem32fixture - THEN
result.ratio > 50.
Verified by: src/converter/converter.test.ts::"chessboard 8x8: ≤33 rects" AND src/converter/converter.test.ts::"solid colour 4x4 = 1 rect" AND src/converter/converter.test.ts::"fully transparent = 0 rects" AND src/converter/converter.test.ts::"shield emblem 32x32 ratio > 50"
Requirement: Injectable Rng for deterministic auto-ordering
convert() SHALL accept an optional rng: () => number in its options. The
heuristics-plus-sampling branch (entered when colourCount! > maxPermutations)
MUST consume the injected rng for every Fisher–Yates shuffle step, falling
back to Math.random only when no rng is supplied. The same seeded rng
MUST yield byte-identical ConvertResult.svg across runs.
Scenario: seeded rng yields deterministic output
- WHEN
convertruns twice on the same > 8-colour fixture with the same seeded mulberry32rng - THEN the two
ConvertResult.svgstrings MUST be identical.
Verified by: src/converter/converter.test.ts::"seeded rng yields deterministic auto output"
Requirement: OpenTelemetry instrumentation
convert() SHALL emit an OTel counter
emblem_solver.convert.total (incremented once per call) and a histogram
emblem_solver.convert.duration_ms (one observation per call, unit ms).
Instruments MUST be created lazily on first call (no module-load side
effects) using metrics.getMeter("@suaveplan/emblem-solver").
Scenario: no instrument is registered before the first call
- WHEN the module is imported but
converthas not been called - THEN internal
counter/histogramreferences MUST remainundefined.
Scenario: first call registers both instruments
- WHEN
convertis invoked once - THEN the internal
counterandhistogramMUST be defined and the counter MUST have been incremented by1.
Verified by: src/telemetry/telemetry.test.ts::"instruments are lazily registered" AND src/telemetry/telemetry.test.ts::"convert increments counter and records duration"
Requirement: Tree-shakeable subpath exports
The package's package.json SHALL expose ./types, ./errors,
./schemas, ./grid, ./io, ./svg, ./solver, and ./optimizer
subpath exports in addition to the root entry, each pointing at a feature
barrel. sideEffects MUST be false.
Scenario: each subpath is importable
- WHEN a consumer imports
@suaveplan/emblem-solver/errors - THEN the import MUST resolve and expose
EmblemSolverErrorandEMBLEM_SOLVER_CODES.
Verified by: src/errors/errors.test.ts::"errors subpath barrel re-exports the public surface"
Requirement: Property-based coverage correctness
A property test SHALL assert that for arbitrary single-colour pixel grids
generated via fast-check, the convert() output satisfies: (a) every
opaque pixel in the input is covered by at least one rect in the
corresponding layer, and (b) no rect's footprint extends beyond the grid
bounds.
Scenario: random solid-colour grid round-trip
- WHEN
fast-checkgenerates 50 random grids of width/height in[1, 12] - THEN every required pixel MUST be covered by at least one rect.
Verified by: src/converter/converter.test.ts::"property: every opaque pixel is covered"