Initial Commit
This commit is contained in:
441
openspec/specs/emblem-solver/spec.md
Normal file
441
openspec/specs/emblem-solver/spec.md
Normal file
@@ -0,0 +1,441 @@
|
||||
<!-- 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"`
|
||||
Reference in New Issue
Block a user