Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,441 @@
<!-- markdownlint-disable MD024 -->
# 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"`
---
### 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"`