diff --git a/.github/workflows/ablation.yml b/.github/workflows/ablation.yml index b98a34e..9b0bb67 100644 --- a/.github/workflows/ablation.yml +++ b/.github/workflows/ablation.yml @@ -33,7 +33,7 @@ jobs: strategy: fail-fast: false matrix: - config: [full, no-research, no-cleanup, no-exact-grouping, no-box-shadows, no-tag-rules, + config: [full, no-research, no-parts, research-1, no-cleanup, no-exact-grouping, no-box-shadows, no-tag-rules, no-clip-reuse, top-only, no-corner-split, no-outline-polygons, no-length-aware, no-gradient-base, no-soft-stops, no-motifs] steps: diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 4f310ee..c6a44b9 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -4,6 +4,10 @@ name: Benchmark on: workflow_dispatch: + inputs: + research: + description: "Re-search the N best results (compile_image research=N)" + default: "3" jobs: bench: @@ -21,7 +25,7 @@ jobs: - run: npm ci working-directory: bench - run: python bench/fetch.py - - run: python bench/run.py + - run: python bench/run.py --research ${{ inputs.research }} - run: python bench/tradeoff.py - run: python bench/build_page.py - uses: actions/upload-artifact@v7 diff --git a/.github/workflows/heldout.yml b/.github/workflows/heldout.yml new file mode 100644 index 0000000..9874128 --- /dev/null +++ b/.github/workflows/heldout.yml @@ -0,0 +1,46 @@ +# Held-out evaluation: generate one held-out set of structural families +# (bench/families.py), compile it with each method one after another on the +# same runner (so CPU times compare), render in Chromium and print the +# summary (bench/evaluate.py). Run it by hand from the Actions tab, once per +# version: a set is spent once it has been run (see docs/method.md). +name: Held-out + +on: + workflow_dispatch: + inputs: + set: + description: "Held-out set, a key of HELDOUT_SEEDS in bench/families.py" + required: true + methods: + description: "Methods of bench/evaluate.py, space-separated" + required: true + default: "full" + +jobs: + evaluate: + runs-on: ubuntu-latest + timeout-minutes: 330 + env: + SET: ${{ inputs.set }} + METHODS: ${{ inputs.methods }} + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: "3.13" + - uses: actions/setup-node@v7 + with: + node-version: 22 + - run: pip install -e . + - run: npm ci + working-directory: bench + - run: python bench/families.py "$SET" + - run: for m in $METHODS; do python bench/evaluate.py --set "$SET" --method "$m"; done + - run: python bench/evaluate.py --set "$SET" --compare $METHODS | tee bench/eval/summary.txt + - uses: actions/upload-artifact@v7 + with: + name: heldout + path: | + bench/${{ inputs.set }}/cases.json + bench/${{ inputs.set }}/*.html + bench/eval/ diff --git a/.gitignore b/.gitignore index 798d80f..511dfc9 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ bench/train/ bench/learn/ bench/heldout2/*.png bench/heldout3/*.png +bench/heldout4/*.png # the demo site, assembled locally (demo/README.md) demo/site/ diff --git a/README.md b/README.md index 53c5e97..14d6e13 100644 --- a/README.md +++ b/README.md @@ -40,10 +40,10 @@ Chromium and compared pixel by pixel: | | | |---|---| -| Shorter than the human solution (minified) | **56 of 66** | -| Total code length | **23,329** characters (human: 35,532) | +| Shorter than the human solution (minified) | **60 of 66** | +| Total code length | **21,175** characters (human: 35,532) | | Targets at ≥ 99% pixel match | **66 of 66** (lowest 99.21%) | -| Mean pixel match | 99.82% | +| Mean pixel match | 99.84% | The human solutions are readable code from two public repositories, minified but not golfed. Top CSSBattle players write far shorter code, so these @@ -61,25 +61,28 @@ rows don't add up. | Switched off | Total J | Characters | Shorter than human | |---|---|---|---| -| nothing (full compiler) | 60,441 | 23,329 | 56 / 66 | -| Insertion at any depth (shapes go on top) | 64,637 (+6.9%) | 24,887 (+1,558) | 54 / 66 | -| Polygons traced from outlines | 64,449 (+6.6%) | 25,791 (+2,462) | 52 / 66 | -| Re-search of the best result | 64,444 (+6.6%) | 24,652 (+1,323) | 55 / 66 | -| Gradient backgrounds | 63,265 (+4.7%) | 24,077 (+748) | 54 / 66 | -| Length-aware shape choice (by pixels only) | 62,921 (+4.1%) | 24,017 (+688) | 54 / 66 | -| Length-aware clean-up | 61,982 (+2.5%) | 24,914 (+1,585) | 52 / 66 | -| Splitting regions that touch at corners | 61,705 (+2.1%) | 24,005 (+676) | 55 / 66 | -| Nested-ring motifs | 61,309 (+1.4%) | 23,495 (+166) | 56 / 66 | -| Box-shadows | 61,258 (+1.4%) | 24,150 (+821) | 56 / 66 | -| Soft gradient stops | 61,106 (+1.1%) | 23,680 (+351) | 55 / 66 | -| Per-tag rules (``, ``, ``) | 60,807 (+0.6%) | 23,583 (+254) | 55 / 66 | -| `clip-path` reuse for copies and mirrors | 60,708 (+0.4%) | 23,544 (+215) | 55 / 66 | -| Exact (CP-SAT) shadow grouping, greedy kept | 60,554 (+0.2%) | 23,442 (+113) | 56 / 66 | - -Inserting shapes below existing ones, outline polygons and the re-search -matter most; gradient backgrounds and choosing shapes by length as well as -pixels come next. Exact shadow grouping and `clip-path` reuse are small on -average but decide a few targets. +| nothing (full compiler) | 55,925 | 21,175 | 60 / 66 | +| Re-search of the best results | 63,980 (+14.4%) | 24,086 (+2,911) | 56 / 66 | +| Redrawing a color in parts (re-search) | 59,350 (+6.1%) | 22,692 (+1,517) | 57 / 66 | +| Polygons traced from outlines | 59,032 (+5.6%) | 23,074 (+1,899) | 59 / 66 | +| Insertion at any depth (shapes go on top) | 58,545 (+4.7%) | 21,819 (+644) | 59 / 66 | +| Length-aware shape choice (by pixels only) | 57,824 (+3.4%) | 21,386 (+211) | 61 / 66 | +| Gradient backgrounds | 57,462 (+2.7%) | 21,514 (+339) | 59 / 66 | +| Re-searching the 3 best results (only the best) | 57,269 (+2.4%) | 21,691 (+516) | 60 / 66 | +| Nested-ring motifs | 56,758 (+1.5%) | 21,260 (+85) | 60 / 66 | +| Box-shadows | 56,674 (+1.3%) | 21,876 (+701) | 60 / 66 | +| Length-aware clean-up | 56,567 (+1.1%) | 21,779 (+604) | 61 / 66 | +| `clip-path` reuse for copies and mirrors | 56,184 (+0.5%) | 21,254 (+79) | 59 / 66 | +| Per-tag rules (``, ``, ``) | 56,171 (+0.4%) | 21,293 (+118) | 59 / 66 | +| Splitting regions that touch at corners | 55,990 (+0.1%) | 21,158 (−17) | 61 / 66 | +| Exact (CP-SAT) shadow grouping, greedy kept | 55,971 (+0.1%) | 21,221 (+46) | 60 / 66 | +| Soft gradient stops | 55,961 (+0.1%) | 21,167 (−8) | 60 / 66 | + +The re-search matters most, and its step that redraws a color in parts is +worth 6.1% on its own; outline polygons and inserting shapes below existing +ones come next. Three parts now move the total by 0.1% or less: splitting +regions at corners, soft gradient stops and exact shadow grouping. Rows that +show more targets shorter than human (61) do so at a higher total J. ### Beyond the development set @@ -89,28 +92,30 @@ test it elsewhere (details in [docs/method.md](docs/method.md#beyond-the-develop **Held-out structural families.** 48 targets generated from six structures: mirrored shapes, repeats, concentric rings, petals, stripes and unrelated shapes. They are compiled once per version, never inspected, and scored in -Chromium (`bench/evaluate.py`). The second baseline climbs 40% more starts, -to spend at least the CPU time the motif search does: +Chromium (`bench/evaluate.py`, run by the **Held-out** workflow). The fourth +set was spent on 0.7.0, all three methods on one runner. The second baseline +climbs 48 starts per step instead of 10, to spend at least the CPU time +0.7.0 does: -| | solved (≥ 99%) | characters, on the 40 all solve | CPU | +| | solved (≥ 99%) | characters, on the 43 all solve | CPU | |---|---|---|---| -| v0.5.0 | 40 / 48 | 11,944 | 2,634 s | -| v0.5.0, 14 starts per step | 41 / 48 | **11,240** | 3,177 s | -| with nested-ring motifs | **43 / 48** | 11,627 | **2,311 s** | - -The motifs solve the most with the least time. The baseline with more starts -writes 3.4% less code on the targets all three solve, but that rests on three -scatter and petals targets: it is shorter on 6 and longer on 7, and the 95% -bootstrap interval runs from 2.1% shorter to 9.6% longer. Giving the default -more starts makes its code longer, not shorter (+6.5% on the development -split). (This is the third -held-out set; the earlier two were spent on earlier versions, see -[docs/method.md](docs/method.md#motifs).) +| 0.6.0's search | 43 / 48 | 16,722 | **4,667 s** | +| 0.6.0's search, 48 starts per step | 43 / 48 | 16,728 | 9,143 s | +| 0.7.0 | 43 / 48 | **14,696** | 8,141 s | + +0.7.0 writes 12.1% less code on the targets all three solve: shorter than +0.6.0's search on 9 of them and longer on 3, with a 95% bootstrap interval +from 22.7% to 1.4% shorter. Rings (2,564 to 1,528 characters) and petals +(4,333 to 3,292) gain the most; scatter is 70 characters longer. It solves +no more targets and takes 1.7 times the CPU, and giving 0.6.0's search that +time as more starts changes nothing (16,728 characters). (The earlier three +sets were spent on earlier versions, see +[docs/method.md](docs/method.md#beyond-the-development-set).) **Known optima.** On 40 tiny targets (up to four grid-aligned boxes and ellipses), an exact solver finds the shortest program that cssynth's own emitter can write. cssynth reproduces 35 exactly and is optimal on 33 of -them (30 before this round); the other two are 40 characters over. +them (30 before 0.6.0; the same in 0.7.0); the other two are 40 characters over. Petals (leaves turned about a center) remain the weak family: Chromium antialiases turned and curved edges differently from cssynth's model, so the @@ -124,6 +129,7 @@ cssynth target.png # prints the code; length and details on stderr cssynth -v target.png # also logs the search cssynth --rho 2 target.png # golf harder (see "Objective") cssynth --fast target.png # skip the re-search: about 2x faster, longer code +cssynth --research 1 target.png # re-search only the best result: less CPU, longer code ``` From Python: @@ -158,7 +164,7 @@ flowchart LR E --> F[simplify for length] F --> G[code generation] G --> H{lowest J
of 16 variants} - H --> I[re-search parts
of the best] + H --> I[re-search parts
of the 3 best] ``` 1. **Palette** (`search.extract_palette`): flat colors with at least 40 @@ -243,12 +249,16 @@ flowchart LR but not all: it can pick a cheap shape that leaves more for later. 8. **Re-search** (`improve.improve`): the greedy search commits to shapes in the order they first help, so an early polygon can take a region that a - shorter primitive would have drawn. The best result is taken apart and - refilled: one polygon at a time (refilled without polygons of its color), - then every shape of one color (refilled as before, and without polygons). - Refills use the winning variant's mode. Each is refined and simplified, - and kept when J drops. `--fast` - skips this step. + shorter primitive would have drawn. Each of the 3 best results (distinct + programs, in parallel) is taken apart and refilled: one polygon at a + time (refilled without polygons of its color), + then every shape of one color (refilled as before, and without polygons), + then every shape of a color whose region falls into parts, one shape per + part (two circles stacked on an oval). Refills use the winning variant's + mode. Each is refined and simplified, and kept when J drops; the best + finished program wins, which isn't always the one that started best. + `--fast` skips this step; `--research 1` runs it on the best result + only. ## Benchmark @@ -303,7 +313,7 @@ ruff check . ``` cssynth/ compiler.py compile_image: run the variants, simplify, keep the lowest J - improve.py re-search: take the best result apart and refill it + improve.py re-search: take the best results apart and refill them search.py palette, base layer, greedy shape search, refine shapes.py the Shape model and a rasterizer that predicts Chromium's simplify.py length-aware clean-up moves @@ -321,14 +331,16 @@ tests/ turns as motifs rescued some petals but lengthened other targets. A filter learned from outcome-labeled proposals didn't transfer to structures it hadn't seen (docs/method.md). Otherwise the search still goes one shape at - a time, and the re-search only repairs its choices locally. Two light circles stacked on an oval (2024-02-27) become one tall - rounded box plus patches, because the box fixes more pixels than either - circle, and a climb from one circle grows back into the box. The lotus is - drawn with polygons where a golfer uses three rotated leaves. Comparing - whole programs (two circles plus a cheap shadow against a box plus - patches) is the next step. A beam over partial J is not enough on its - own: the two-circle branch trails for two steps before it pulls ahead, - and the climbs never propose a lone circle to start it. + a time. The re-search now compares whole programs in one case: a color + whose region falls into parts (two circles stacked on an oval, a cloud of + discs) is redrawn with one shape per part, finished, and kept if the + finished program scores better. On cssynth's own scores this lowered the + development total by 4.6% and made no target worse; measured in + Chromium, 59 targets instead of 56 come out shorter than the human code + (docs/method.md). Choices that don't show up as parts + of one color still go one step at a time, and a beam over partial J + doesn't fix that: on 2024-02-27 the two-circle branch trails for two + steps before it pulls ahead. - **Reuse.** Turned copies of a box share a rule and mirrored copies of a polygon share a `clip-path`, but nothing yet uses `::before`/`::after` to repeat a shape, or mirrors a rounded box. diff --git a/bench/ablation.json b/bench/ablation.json index e707869..79f5f67 100644 --- a/bench/ablation.json +++ b/bench/ablation.json @@ -2,127 +2,145 @@ { "config": "full", "off": "nothing", - "chars": 23329, - "J": 60441, - "shorter": 56, - "cpu_min": 84.6, + "chars": 21175, + "J": 55925, + "shorter": 60, + "cpu_min": 251.1, "n": 66 }, { "config": "no-research", - "off": "re-search of the best result", - "chars": 24652, - "J": 64444, - "shorter": 55, - "cpu_min": 88.3, + "off": "re-search of the best results", + "chars": 24086, + "J": 63980, + "shorter": 56, + "cpu_min": 94.3, + "n": 66 + }, + { + "config": "no-parts", + "off": "redrawing a color in parts (re-search)", + "chars": 22692, + "J": 59350, + "shorter": 57, + "cpu_min": 218.1, + "n": 66 + }, + { + "config": "research-1", + "off": "re-searching the 3 best results (the best only)", + "chars": 21691, + "J": 57269, + "shorter": 60, + "cpu_min": 87.4, "n": 66 }, { "config": "no-cleanup", "off": "length-aware clean-up", - "chars": 24914, - "J": 61982, - "shorter": 52, - "cpu_min": 69.5, + "chars": 21779, + "J": 56567, + "shorter": 61, + "cpu_min": 136.5, "n": 66 }, { "config": "no-exact-grouping", "off": "exact (CP-SAT) shadow grouping; greedy only", - "chars": 23442, - "J": 60554, - "shorter": 56, - "cpu_min": 127.8, + "chars": 21221, + "J": 55971, + "shorter": 60, + "cpu_min": 114.6, "n": 66 }, { "config": "no-box-shadows", "off": "box-shadows", - "chars": 24150, - "J": 61258, - "shorter": 56, - "cpu_min": 58.8, + "chars": 21876, + "J": 56674, + "shorter": 60, + "cpu_min": 259.3, "n": 66 }, { "config": "no-tag-rules", "off": "per-tag rules (, , )", - "chars": 23583, - "J": 60807, - "shorter": 55, - "cpu_min": 122.0, + "chars": 21293, + "J": 56171, + "shorter": 59, + "cpu_min": 239.5, "n": 66 }, { "config": "no-clip-reuse", "off": "clip-path reuse for copies and mirrors", - "chars": 23544, - "J": 60708, - "shorter": 55, - "cpu_min": 130.6, + "chars": 21254, + "J": 56184, + "shorter": 59, + "cpu_min": 226.7, "n": 66 }, { "config": "top-only", "off": "insertion at any depth; new shapes go on top", - "chars": 24887, - "J": 64637, - "shorter": 54, - "cpu_min": 63.7, + "chars": 21819, + "J": 58545, + "shorter": 59, + "cpu_min": 138.0, "n": 66 }, { "config": "no-corner-split", "off": "splitting regions that touch at corners", - "chars": 24005, - "J": 61705, - "shorter": 55, - "cpu_min": 131.3, + "chars": 21158, + "J": 55990, + "shorter": 61, + "cpu_min": 168.6, "n": 66 }, { "config": "no-outline-polygons", "off": "polygons traced from outlines", - "chars": 25791, - "J": 64449, - "shorter": 52, - "cpu_min": 39.9, + "chars": 23074, + "J": 59032, + "shorter": 59, + "cpu_min": 134.4, "n": 66 }, { "config": "no-length-aware", "off": "length-aware shape choice; by pixels only", - "chars": 24017, - "J": 62921, - "shorter": 54, - "cpu_min": 88.8, + "chars": 21386, + "J": 57824, + "shorter": 61, + "cpu_min": 127.5, "n": 66 }, { "config": "no-gradient-base", "off": "gradient backgrounds", - "chars": 24077, - "J": 63265, - "shorter": 54, - "cpu_min": 95.5, + "chars": 21514, + "J": 57462, + "shorter": 59, + "cpu_min": 126.4, "n": 66 }, { "config": "no-soft-stops", "off": "soft gradient stops", - "chars": 23680, - "J": 61106, - "shorter": 55, - "cpu_min": 110.3, + "chars": 21167, + "J": 55961, + "shorter": 60, + "cpu_min": 227.8, "n": 66 }, { "config": "no-motifs", "off": "nested-ring motifs", - "chars": 23495, - "J": 61309, - "shorter": 56, - "cpu_min": 120.7, + "chars": 21260, + "J": 56758, + "shorter": 60, + "cpu_min": 155.5, "n": 66 } ] \ No newline at end of file diff --git a/bench/ablation.py b/bench/ablation.py index 25cdbcb..c48e6cc 100644 --- a/bench/ablation.py +++ b/bench/ablation.py @@ -40,7 +40,9 @@ def _no_simplify(T, bg, pal, shapes, **kw): # name: (what is switched off, how) ABLATIONS = { "full": ("nothing", lambda: None), - "no-research": ("re-search of the best result", None), + "no-research": ("re-search of the best results", None), + "no-parts": ("redrawing a color in parts (re-search)", lambda: setattr(improve, "PARTS", False)), + "research-1": ("re-searching the 3 best results (the best only)", None), "no-cleanup": ("length-aware clean-up", lambda: (setattr(compiler, "simplify", _no_simplify), setattr(improve, "simplify", _no_simplify))), "no-exact-grouping": ("exact (CP-SAT) shadow grouping; greedy only", @@ -82,7 +84,7 @@ def work(args): name, case = args t = time.time() code, info, _ = compiler.compile_image(BENCH / "human" / f"{case['id']}.png", variants=variants(name), - research=name != "no-research") + research={"no-research": 0, "research-1": 1}.get(name, compiler.RESEARCH)) return {"id": case["id"], "chars": len(code), "wrong_px": info["wrong_px"], "J": info["J"], "human_min_len": case["human_min_len"], "seconds": round(time.time() - t, 1)} diff --git a/bench/build_page.py b/bench/build_page.py index fb0b2ae..ffc5191 100755 --- a/bench/build_page.py +++ b/bench/build_page.py @@ -19,7 +19,8 @@ def fmt(n): ABLATION_NAMES = { - "no-research": "Re-search of the best result", "no-cleanup": "Length-aware clean-up", + "no-research": "Re-search of the best results", "no-parts": "Redrawing a color in parts (re-search)", + "research-1": "Re-searching the 3 best results (the best only)", "no-cleanup": "Length-aware clean-up", "no-exact-grouping": "Exact shadow grouping (greedy kept)", "no-box-shadows": "Box-shadows", "no-tag-rules": "Per-tag rules (<i>, <u>, <s>)", "no-clip-reuse": "clip-path reuse for copies and mirrors", @@ -280,6 +281,10 @@ def main():

What changed since the first version

+
+

Comparing whole programs

+

The greedy search takes the shape that fixes the most pixels now, so two circles stacked on an oval became one tall box plus patches. The re-search now also splits a color's region where its distance to the edge comes apart, draws one shape per part, and keeps the finished program when its score is lower: that target went from 706 to 200 characters (human: 481). It also re-searches the three best results instead of only the best, because one that starts a little worse can end much better: the sunburst went from 935 to 460 characters. Over the benchmark, 60 targets come out shorter than the human code instead of 56, with 2,154 fewer characters.

+

Re-search and soft gradient stops

The greedy search commits to shapes in the order they first help. The best result is now taken apart and refilled, one polygon or one color at a time, and kept when the score drops; Pythagoras went from 330 to 240 characters. Chromium doesn't antialias hard gradient stops, so a flag whose bands were drawn with rotated boxes used to leave a band of wrong pixels along every edge. Each stop is now fitted to a sub-pixel position with a short ramp, which Chromium draws within 3/255 of the model: Tanzania became a single gradient, 602 → 142 characters (human: 336). A polygon that mirrors another now reuses its clip-path with scale:-1 1.

@@ -352,8 +357,8 @@ def main():
  • Layered search. Repeatedly add the shape that fixes the most wrong pixels: a box with any corner radii, an ellipse, a rotated bar or leaf, or a polygon clipped with clip-path, fitted to the region's convex hull or traced from its outline, concave or not. A new shape can go at any depth in the stack, not just on top. That is how rings, eyes and other nested figures get built.
  • Refine. Merge two pieces of the same color into one shape when that costs almost nothing, re-fit every shape against its final surroundings, drop shapes that no longer pay for their characters, and round coordinates where that changes no pixels.
  • Code generation. A shape that is a shifted, uniformly grown or shrunk copy of another becomes a box-shadow of it, when the paint order allows and CSS keeps the shape exact (a spread larger than the corner radius bends the corners). The grouping and the paint order are chosen together by an exact solver. Boxes are sized with unitless padding (allowed in quirks mode), saturated corner radii get short spellings like 7em, and properties shared by every element move into one rule in a trailing, unclosed <style>; families of alike elements can take their own tag and rule.
  • -
  • Choose. Eight search settings run per image (six searches, two of which also stop early at a higher threshold); each result is cleaned up for length and scored by characters + wrong pixels ÷ ρ. The lowest score wins.
  • -
  • Re-search. The winner is taken apart, one polygon or one color at a time, refilled by the same search, cleaned up again and kept when its score drops.
  • +
  • Choose. Twelve search settings run per image (six searches, each choosing shapes by pixels alone or by length as well; two of the searches also stop early at a higher threshold); each result is cleaned up for length and scored by characters + wrong pixels ÷ ρ.
  • +
  • Re-search. The three best results are each taken apart, one polygon or one color at a time, or a color redrawn with one shape per part, refilled by the same search, cleaned up again and kept when the score drops. The lowest score wins.
  • diff --git a/bench/cssynth-results.html b/bench/cssynth-results.html index a56d4bf..ca9f1ff 100644 --- a/bench/cssynth-results.html +++ b/bench/cssynth-results.html @@ -118,15 +118,15 @@

    cssynth

    66/66targets at 99% match or better (66 at 98%+)
    -
    327median characters on those
    +
    302median characters on those
    480median for the human solutions, minified
    -
    56of those shorter than the human code
    +
    60of those shorter than the human code
    64 → 66targets at 99%+ match, first version → now
    -
    10 → 22targets at a 100% match
    -
    34,963 → 23,329characters across all targets
    -
    −33%total code length since the first version
    +
    10 → 24targets at a 100% match
    +
    34,963 → 21,175characters across all targets
    +
    −39%total code length since the first version

    Match counts pixels whose color is within 8/255 per channel of the target. The human solutions come from two public GitHub repos and are written to be readable, not golfed. Top CSSBattle players are far shorter, so these lengths are a baseline, not a leaderboard. All results, including the trade-off table, are rendered on Linux with the same Chromium build as the targets.

    @@ -134,6 +134,10 @@

    cssynth

    What changed since the first version

    +
    +

    Comparing whole programs

    +

    The greedy search takes the shape that fixes the most pixels now, so two circles stacked on an oval became one tall box plus patches. The re-search now also splits a color's region where its distance to the edge comes apart, draws one shape per part, and keeps the finished program when its score is lower: that target went from 706 to 200 characters (human: 481). It also re-searches the three best results instead of only the best, because one that starts a little worse can end much better: the sunburst went from 935 to 460 characters. Over the benchmark, 60 targets come out shorter than the human code instead of 56, with 2,154 fewer characters.

    +

    Re-search and soft gradient stops

    The greedy search commits to shapes in the order they first help. The best result is now taken apart and refilled, one polygon or one color at a time, and kept when the score drops; Pythagoras went from 330 to 240 characters. Chromium doesn't antialias hard gradient stops, so a flag whose bands were drawn with rotated boxes used to leave a band of wrong pixels along every edge. Each stop is now fitted to a sub-pixel position with a short ramp, which Chromium draws within 3/255 of the model: Tanzania became a single gradient, 602 → 142 characters (human: 336). A polygon that mirrors another now reuses its clip-path with scale:-1 1.

    @@ -148,11 +152,11 @@

    Outline polygons

    Gradient backgrounds

    -

    For striped pictures the compiler fits a hard-stop linear-gradient at any angle: every pixel is projected onto the gradient line, and dynamic programming picks the band boundaries that minimize wrong pixels plus a cost per stop. Shapes are then layered on top as before. 9 targets use one.

    +

    For striped pictures the compiler fits a hard-stop linear-gradient at any angle: every pixel is projected onto the gradient line, and dynamic programming picks the band boundaries that minimize wrong pixels plus a cost per stop. Shapes are then layered on top as before. 5 targets use one.

    Exactly optimal shadow grouping

    -

    Choosing which shapes become box-shadows of which, together with a valid paint order, is now solved exactly with a constraint solver: each shape gets a paint position, shadows share their anchor's, and every pair that must stay in order needs strictly increasing positions unless they share a group. Of 30 targets with any grouping, it beat the greedy grouping on 7 (42 characters) and proved optimality on 28.

    +

    Choosing which shapes become box-shadows of which, together with a valid paint order, is now solved exactly with a constraint solver: each shape gets a paint position, shadows share their anchor's, and every pair that must stay in order needs strictly increasing positions unless they share a group. Of 31 targets with any grouping, it beat the greedy grouping on 7 (51 characters) and proved optimality on 30.

    Scoring by code length

    @@ -189,13 +193,13 @@

    All 66 targets

    What each part buys

    Each row recompiles all 66 targets with one technique switched off. Scores come from the compiler's own rasterizer rather than Chromium, and the parts overlap, so the rows don't add up. Inserting shapes below existing ones and re-searching the result matter most.

    -
    Switched offTotal JCharactersShorter than human
    nothing (full compiler)60,44123,32956 / 66
    Insertion at any depth64,637 (+6.9%)24,887 (+1,558)54 / 66
    Polygons traced from outlines64,449 (+6.6%)25,791 (+2,462)52 / 66
    Re-search of the best result64,444 (+6.6%)24,652 (+1,323)55 / 66
    Gradient backgrounds63,265 (+4.7%)24,077 (+748)54 / 66
    Length-aware shape choice62,921 (+4.1%)24,017 (+688)54 / 66
    Length-aware clean-up61,982 (+2.5%)24,914 (+1,585)52 / 66
    Splitting regions that touch at corners61,705 (+2.1%)24,005 (+676)55 / 66
    Nested-ring motifs61,309 (+1.4%)23,495 (+166)56 / 66
    Box-shadows61,258 (+1.4%)24,150 (+821)56 / 66
    Soft gradient stops61,106 (+1.1%)23,680 (+351)55 / 66
    Per-tag rules (<i>, <u>, <s>)60,807 (+0.6%)23,583 (+254)55 / 66
    clip-path reuse for copies and mirrors60,708 (+0.4%)23,544 (+215)55 / 66
    Exact shadow grouping (greedy kept)60,554 (+0.2%)23,442 (+113)56 / 66
    +
    Switched offTotal JCharactersShorter than human
    nothing (full compiler)55,92521,17560 / 66
    Re-search of the best results63,980 (+14.4%)24,086 (+2,911)56 / 66
    Redrawing a color in parts (re-search)59,350 (+6.1%)22,692 (+1,517)57 / 66
    Polygons traced from outlines59,032 (+5.6%)23,074 (+1,899)59 / 66
    Insertion at any depth58,545 (+4.7%)21,819 (+644)59 / 66
    Length-aware shape choice57,824 (+3.4%)21,386 (+211)61 / 66
    Gradient backgrounds57,462 (+2.7%)21,514 (+339)59 / 66
    Re-searching the 3 best results (the best only)57,269 (+2.4%)21,691 (+516)60 / 66
    Nested-ring motifs56,758 (+1.5%)21,260 (+85)60 / 66
    Box-shadows56,674 (+1.3%)21,876 (+701)60 / 66
    Length-aware clean-up56,567 (+1.1%)21,779 (+604)61 / 66
    clip-path reuse for copies and mirrors56,184 (+0.5%)21,254 (+79)59 / 66
    Per-tag rules (<i>, <u>, <s>)56,171 (+0.4%)21,293 (+118)59 / 66
    Splitting regions that touch at corners55,990 (+0.1%)21,158 (−17)61 / 66
    Exact shadow grouping (greedy kept)55,971 (+0.1%)21,221 (+46)60 / 66
    Soft gradient stops55,961 (+0.1%)21,167 (−8)60 / 66

    Trading match for length

    ρ is how many wrong pixels the compiler accepts to save one character. CSSBattle tells players to reach a 100% match before cutting characters, so the default is cautious. Raising ρ golfs harder. Each row below is the whole benchmark re-run at that setting, rendered in Chromium and scored.

    -
    SettingTargets at 99%+At 99.5%+Total charactersMedian match
    default (ρ = 0.5)665923,32999.90%
    ρ = 0.25665923,32999.90%
    ρ = 1665922,562 (−3%)99.90%
    ρ = 2665922,013 (−6%)99.89%
    ρ = 4665921,452 (−8%)99.83%
    +
    SettingTargets at 99%+At 99.5%+Total charactersMedian match
    default (ρ = 0.5)666121,17599.91%
    ρ = 0.25666121,17599.91%
    ρ = 1666120,893 (−1%)99.91%
    ρ = 2666120,213 (−5%)99.89%
    ρ = 4655919,560 (−8%)99.85%
    @@ -206,8 +210,8 @@

    How it works

  • Layered search. Repeatedly add the shape that fixes the most wrong pixels: a box with any corner radii, an ellipse, a rotated bar or leaf, or a polygon clipped with clip-path, fitted to the region's convex hull or traced from its outline, concave or not. A new shape can go at any depth in the stack, not just on top. That is how rings, eyes and other nested figures get built.
  • Refine. Merge two pieces of the same color into one shape when that costs almost nothing, re-fit every shape against its final surroundings, drop shapes that no longer pay for their characters, and round coordinates where that changes no pixels.
  • Code generation. A shape that is a shifted, uniformly grown or shrunk copy of another becomes a box-shadow of it, when the paint order allows and CSS keeps the shape exact (a spread larger than the corner radius bends the corners). The grouping and the paint order are chosen together by an exact solver. Boxes are sized with unitless padding (allowed in quirks mode), saturated corner radii get short spellings like 7em, and properties shared by every element move into one rule in a trailing, unclosed <style>; families of alike elements can take their own tag and rule.
  • -
  • Choose. Eight search settings run per image (six searches, two of which also stop early at a higher threshold); each result is cleaned up for length and scored by characters + wrong pixels ÷ ρ. The lowest score wins.
  • -
  • Re-search. The winner is taken apart, one polygon or one color at a time, refilled by the same search, cleaned up again and kept when its score drops.
  • +
  • Choose. Twelve search settings run per image (six searches, each choosing shapes by pixels alone or by length as well; two of the searches also stop early at a higher threshold); each result is cleaned up for length and scored by characters + wrong pixels ÷ ρ.
  • +
  • Re-search. The three best results are each taken apart, one polygon or one color at a time, or a color redrawn with one shape per part, refilled by the same search, cleaned up again and kept when the score drops. The lowest score wins.
  • @@ -224,7 +228,7 @@

    Limits of this prototype