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

442 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: emblem-solver (emblem-solver)
**Package**: `@suaveplan/emblem-solver`
**Status**: SHIPPED
> 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"`
---
### 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 `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"`