Files
suaveplan/openspec/archive/2026-05-25-enterprise-emblem-solver/specs/emblem-solver/spec.md
2026-05-27 23:29:53 +01:00

17 KiB
Raw Blame History

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 EmblemSolverError is constructed with cause: new Error("inner")
  • THEN error.cause MUST return the original error and error.code MUST 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 EmblemSolverError whose .code equals EMBLEM_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 EmblemSolverError whose .code equals EMBLEM_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 analyseGrid runs on a grid where one colour appears in two blocks separated by transparency
  • THEN that colour's islands.length MUST equal 2.

Scenario: diagonals are not connected

  • WHEN analyseGrid runs on a [[1,0],[0,1]] grid
  • THEN the only colour's islands.length MUST equal 2.

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 parseRGBABuffer reads a byte with a = 128
  • THEN the resulting RGBA.a MUST equal 1; for a = 127 it MUST be 0.

Scenario: indexed grid maps palette correctly

  • WHEN fromIndexedGrid([[1,0],[0,2]], {1:r, 2:g}) is called
  • THEN result pixels[0] MUST be the r-colour with a===1, pixels[1] MUST be transparent, and pixels[3] MUST be the g-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-true grid
  • 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 1 and height 5.

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 required are false
  • THEN enhancedGreedyCover MUST 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 perIslandCover MUST 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"


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 6 arrays.

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 cross3x3 fixture's red pixels
  • THEN the returned rect list MUST cover every required red pixel and MUST contain at most 2 rects.

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 convert runs on the upstream cross3x3 fixture
  • THEN ConvertResult.totalRects MUST be at most 2.

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 eliminateRedundant layer 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 convert runs on the upstream chessboard8x8 fixture
  • THEN result.totalRects ≤ 33.

Scenario: solid grid produces exactly one rect

  • WHEN convert runs on a 4×4 solid-red grid
  • THEN result.totalRects === 1.

Scenario: fully transparent grid produces zero rects

  • WHEN convert runs on a 4×4 fully-transparent grid
  • THEN result.totalRects === 0 and result.layers MUST be empty.

Scenario: shield emblem 32x32 compresses > 50x

  • WHEN convert runs on the upstream shieldEmblem32 fixture
  • 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 convert runs twice on the same > 8-colour fixture with the same seeded mulberry32 rng
  • THEN the two ConvertResult.svg strings 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 convert has not been called
  • THEN internal counter/histogram references MUST remain undefined.

Scenario: first call registers both instruments

  • WHEN convert is invoked once
  • THEN the internal counter and histogram MUST be defined and the counter MUST have been incremented by 1.

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 EmblemSolverError and EMBLEM_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-check generates 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"