Download layout_report.md from Pine532/chameleon-layout-generator: direct link, hf CLI and curl.
- Browser
- Download file 38.8 kB
-
https://huggingface.co/Pine532/chameleon-layout-generator/resolve/main/layout_report.md
- Command line
-
hf download hf://Pine532/chameleon-layout-generator/layout_report.md
-
curl -L -o layout_report.md https://huggingface.co/Pine532/chameleon-layout-generator/resolve/main/layout_report.md
A learned layout generator trained on Crello designers (Dev 3, 2026-10-27)
Question. The selector is good (judge-distilled critic, tie-breaks: docs/crello-gains-2026-10-26.md) but can only choose among
what the section 2 generators propose, and on 3,000 designer-made size variants the uniform stretch is closer to the designer
than our pick (0.190 vs 0.237 mean distance). Can a small model that sees the source layout and the target size propose better
candidates, and do the fitter, hard checks, rule score and critic make use of them?
Answer in one paragraph. A 33-input, 2 x 64 tanh MLP (31 KB, numpy) predicts a box per content unit. Alone it beats the stretch
clearly on held-out design families (unit distance 0.1886 β 0.1525, element distance 0.1905 β 0.1545; better in 67% of pairs). As a
candidate family (learned) it is the first of ours that is, on average, closer to the designers than the stretch (0.183 vs 0.195 where
it is valid) and the valid candidate closest to the designer in 53% of pairs. Left to the selector alone it ships in 29% of held-out
pairs and moves the pick distance 0.2522 β 0.2352 (+0.0169, family-bootstrap interval [+0.0132, +0.0209]) with no hard-check, fallback
or floor-count change on our three sets, but a judge round on its 144 changed picks went only 66 to 44 for it (section 6b): the family
reproduces what designers do, including small text and top-heavy stacks on tall canvases, and the rule score's fidelity term rewards
exactly that. Shipped with an admission guard (a learned layout replaces the pick made without the family only if its smallest text
is within 1 px and its utilization score within 0.05 of that pick; everything else is byte-identical to stage one): the kept picks were
judged 54 to 17, 97 picks change on our three sets (all to learned; hard checks, fallbacks, floor counts and the mean smallest text
unchanged), and on Crello the held-out gain is +0.0093 [+0.0062, +0.0127] with the changed picks' smallest text larger in 392 pairs and
smaller in 36.
1. Dataset (tools/crello/layout_dataset.py)
| count | |
|---|---|
| raw same-design pairs (Hugging Face) | 37,279 |
| both templates in the extracted parquet | 31,153 (6,126 dropped: the pairs file also spans the test split, whose layouts were not extracted) |
| same element count | 26,809 (4,344 dropped) |
| same type sequence | 22,977 (3,832 dropped) |
| same text-length sequence | 18,463 (4,514 dropped) |
| at most 30 elements and at least one text | 18,449 usable pairs (14 dropped), 1,316 families, 5,563 templates |
| dropped while converting, analysing or featurising | 0 (no analysis error, no pair without a content unit) |
| directed pairs (each pair read both ways) | 36,898 |
| rows (one per content unit) | 240,227 (6.51 per pair); 120,039 as given, 120,188 swapped |
| member elements of those units | 280,676 |
split by family (pairs.py, 70 / 30) |
train 915 families, 160,997 rows; test 401 families, 79,230 rows (39,620 as given, 6,412 pairs) |
the 3,000-pair sample of designer_eval.py |
all 3,000 pairs are in the dataset; 1,008 are in test families |
The filters are pairs.py's (pairs_stats.json); nothing new was dropped. Every usable pair is used, not just the 3,000 sample.
Both directions are included (rev column): they read the same designer pair the other way, never straddle the split, and
raised held-out quality slightly (exploration, one seed: unit distance 0.1555 as given only, 0.1522 with both).
Inputs. engine.resize._layout_features(analysis, target), the function the engine calls at run time, so training and serving
cannot differ: the unit's source box and ink box as shares of the source canvas (8), kind flags (text, image, shape, pinned
furniture, band, rotated, group), prominence, font size over the source short side, log text length, block index / count /
relative position, flow axis, number of content units, source / target / log-ratio aspect, source and target short sides over 1000,
rank by area and by font size, left / right alignment, sibling flag: 33 numbers, geometry and text length only (a rename test checks
it). Target. The union of the designer's drawn bounds of the unit's member elements at the target size, as shares of the target
canvas (cx, cy, w, h); the designer layout is make_tasks.designer_layout (same elements, the target template's geometry, text
heights as our renderer measures the synthetic text). Baseline. The uniform stretch, which in these units is the source box
itself. The designer's target font size per unit is stored too (fs_t) and was used to check text sizing, not as a target.
Not modelled: backgrounds and decorations (the model places content units only; decorations follow the anchor's rules).
2. Models (tools/crello/train_layout.py; held-out families only)
All models predict the correction to the stretch. Training uses 85% of the train families, early stopping and model selection use the
other 15% (a hash of the family id), the 401 test families are only evaluated. Rows are weighted by 1 / sqrt(pairs in the family) (a
handful of boilerplate families hold 20% of the pairs; exploration, one seed, unit distance: weight power 0 β 0.1589, 0.5 β 0.1517,
1 β 0.1559). Metrics on the 39,620 test rows (6,412 pairs): MAE of (cx, cy, w, h) as shares of the target canvas; unit distance =
the engine's dist formula on unit boxes; element distance = the same on member elements of content units (predicted unit boxes
mapped back to elements), pairs pooled and family-balanced.
| model | MAE | MAE cx / cy / w / h | unit dist | element dist (pooled) | element dist (family-balanced) | sample test pairs (1,008) |
|---|---|---|---|---|---|---|
| (a) stretch | 0.0739 | 0.0684 / 0.0657 / 0.1028 / 0.0586 | 0.1886 | 0.1905 | 0.1260 | 0.1842 |
| uniform scale min(sx, sy), centres kept | 0.0718 | 0.0684 / 0.0657 / 0.0969 / 0.0561 | 0.1843 | 0.1876 | 0.1250 | 0.1849 |
| (b) ridge, squared loss (alpha 10) | 0.0705 | 0.0742 / 0.0685 / 0.0823 / 0.0570 | 0.1828 | 0.1822 | 0.1365 | 0.1774 |
| (b') ridge model trained on the distance | 0.0669 | 0.0680 / 0.0653 / 0.0826 / 0.0518 | 0.1742 | 0.1757 | 0.1189 | 0.1692 |
(c) sklearn MLPRegressor 2 x 64 tanh, squared loss, 3 seeds |
0.0743 | 0.0914 / 0.0768 / 0.0757 / 0.0533 | 0.1976 (3 seeds 0.1974 Β± 0.0003) | 0.1993 | 0.1612 | 0.1955 |
| (d) the same + set context (mean of the other units' inputs) | 0.0810 | 0.1018 / 0.0833 / 0.0811 / 0.0579 | 0.2162 | 0.2181 | 0.1754 | 0.2139 |
| numpy MLP 2 x 64 tanh, squared loss | 0.0646 | 0.0742 / 0.0672 / 0.0681 / 0.0490 | 0.1707 | 0.1711 | 0.1288 | 0.1667 |
| (c') numpy MLP 2 x 64 tanh on the distance, 5 seeds (shipped) | 0.0573 | 0.0633 / 0.0611 / 0.0624 / 0.0425 | 0.1525 (5 seeds 0.1524 Β± 0.0004) | 0.1545 | 0.1081 | 0.1488 |
| (d') the same + set context | 0.0580 | 0.0646 / 0.0612 / 0.0637 / 0.0426 | 0.1541 (5 seeds Β± 0.0004) | 0.1562 | 0.1096 | 0.1511 |
What the table says:
- The loss is what matters. Designers keep relative positions, so the stretch's centres are already good (MAE 0.068) and a
squared-loss regression, which regresses to the mean, makes them worse (0.074 ridge, 0.091 sklearn MLP) while fixing the sizes. sklearn's
MLPRegressorcannot train on anything else; a small numpy Adam loop on the metric itself (the engine's distance, smoothed) is what beats the stretch: every output improves except the centres, which it keeps at stretch quality (0.063, 0.061 against 0.068, 0.066). The squared-loss sklearn MLPs lose to the stretch, as does the one with context. - Capacity and context. The model is small on purpose: one hidden layer 0.1540, 2 x 32 0.1530, 2 x 64 0.1517, 2 x 128 0.1515, 3 x 64 0.1505, relu 0.1524 (exploration, validation protocol, one seed: unit distance). A 3-seed ensemble gives 0.1500 for three times the file. Twelve extra features (bounding box of all content, position within it, gaps to the nearest unit in each direction, overlaps, area share) gave 0.1506 against 0.1515, so the 33 features stay. A linear model on the metric is 0.1742: the gain needs the nonlinearity. Set context does not help (0.1541 against 0.1525): the simplest model that beats the stretch clearly is the 2 x 64 MLP without it, and that is what ships.
- Where it wins. Better than the stretch in 67% of held-out pairs: 35% when the aspect changes by under 0.2 (it is not better than the stretch there: 0.0631 against 0.0606; the engine does not generate it below a 3% change anyway), 71% from 0.2 to 0.7, 84% above 0.7.
By aspect-change bucket and target class (all 6,412 held-out pairs as given, element distance, pooled / family-balanced):
| group | pairs | stretch | uniform | ridge | mlp | mlp beats stretch |
|---|---|---|---|---|---|---|
| all | 6412 | 0.1905 / 0.1260 | 0.1876 / 0.1250 | 0.1822 / 0.1365 | 0.1545 / 0.1081 | 67% |
| aspect change 0-0.2 | 1557 | 0.0606 / 0.0633 | 0.0619 / 0.0644 | 0.0892 / 0.0930 | 0.0631 / 0.0656 | 35% |
| aspect change 0.2-0.7 | 2707 | 0.1575 / 0.1341 | 0.1581 / 0.1336 | 0.1573 / 0.1383 | 0.1391 / 0.1165 | 71% |
| aspect change 0.7+ | 2148 | 0.3263 / 0.3070 | 0.3157 / 0.3010 | 0.2808 / 0.2670 | 0.2402 / 0.2276 | 84% |
| target strip | 544 | 0.2939 / 0.2228 | 0.2975 / 0.2234 | 0.2688 / 0.2151 | 0.2271 / 0.1780 | 73% |
| target small | 1750 | 0.1686 / 0.1390 | 0.1659 / 0.1370 | 0.1662 / 0.1447 | 0.1432 / 0.1184 | 66% |
| target square | 874 | 0.1803 / 0.1518 | 0.1726 / 0.1480 | 0.1732 / 0.1504 | 0.1566 / 0.1302 | 67% |
| target vertical | 1693 | 0.1963 / 0.1422 | 0.1971 / 0.1443 | 0.1841 / 0.1466 | 0.1504 / 0.1146 | 62% |
| target other | 1551 | 0.1783 / 0.1498 | 0.1714 / 0.1431 | 0.1727 / 0.1543 | 0.1452 / 0.1270 | 70% |
Against our current candidates, on the 1,005 sample pairs in test families that have a recorded pick (element distance over the
elements of content units, from candidates.jsonl of the branch without the model): stretch 0.1845, engine pick 0.2456, engine
oracle (the valid candidate closest to the designer) 0.1748, the model's raw boxes 0.1489; it beats the stretch in 66% of these
pairs, the pick in 86% and the oracle in 76%. By bucket (0-0.2 / 0.2-0.7 / 0.7+): 0.0595 / 0.1362 / 0.2332 against pick 0.0833 / 0.2420 /
0.3784 and oracle 0.0757 / 0.1561 / 0.2730. These are raw predictions (not fitted, checked or clamped), which is why the engine
numbers below are smaller. Full per-class table: output/crello/layout_train.md (regenerated by the training command).
The shipped file is the best of 5 seeds on validation (seed 0; test unit distance of the 5 seeds 0.1524 Β± 0.0004), trained on train families only, 6,596 weights, 31.3 KB.
3. In the engine (engine/resize.py: end of section 2, section 5b)
_learned_candidates(an, target, ctx, existing) (after _proportional_candidates in generate_candidates, same Β±3% gate):
_layout_model() (lazy, numpy, no pickle, cached per file state, validates names and shapes; the family is simply not generated if
the file is absent, corrupt or inconsistent, so the output is byte-identical to the output without it; CHAMELEON_LEARNED=0 and
TUNABLES["learned"]["enabled"] switch it off) β _layout_predict (features β boxes in target px) β _anchor_ladder(ctx, pred=β¦),
the anchor's own rung ladder with _anchor_try(pred=β¦):
- free units are centred on the predicted centre; furniture, bands and satellites are placed as in
prop; - text takes the predicted width (at least its longest word, at most the inner width), the height its wrapped text needs, and a
size that starts at what
propgives (uniform scale lifted to the floor of the rung) and, at rungs above 0, grows to the largest whole size, up to the anchor's cap, whose wrapped text is at most 1.15 times as tall as the predicted box; - images, groups and rotated elements get the largest uniform scale that fits the predicted box and the margins (floor-lifted); a plain shape takes the predicted box;
- then the anchor's margin clamp, ink push-apart, strictness per rung, satellites; decorations as in
prop; - up to 2 layouts (
learned,learned:<rung>, highest rung first), skipping layouts equal to any existing candidate, appended last, so every older candidate is unchanged; never raises (a failure logs[learned] skippedand yields none).
Admission guard (docs/engine.md B30, added after judge round 6, section 6b). resize() chooses twice (_choose: _select, then
the critic): over every candidate and over the candidates that are not learned. When the two picks differ, the learned pick ships only if
_learned_admissible holds: its smallest text is at most TUNABLES["learned"]["guard_px"] (1) px under the family-off pick's and its
utilization score (S5) at most guard_util (0.05) lower; otherwise the family-off pick ships. The log line ends in learned over <pick>
when a learned layout ships. So the family cannot change the output except through a learned layout that passes the guard: the 8 picks
its presence moved through the critic's finalist set (section 5) revert, and without a shipped learned layout the output is byte-identical
to stage one's (tests/test_learned.py).
Why "box" text: designers' font sizes are the area-preserving size, median 1.0 x sqrt(sx sy) of the source size, against the uniform
min(sx, sy) the anchor and prop use (designer / uniform: median 1.03 below a 0.2 aspect change, 1.24 from 0.2 to 0.7, 1.76 above 0.7).
Taking the size prop gives (text: "prop") made learned a re-positioned prop with small text (median candidate / designer font
size 0.80); deriving it from the predicted box gives 0.995 (p10-p90 0.76-1.28, valid candidates above rung 0; prop 0.75, anchor 0.78,
lanes 1.03 with a wider spread 0.55-1.48; the last-resort rung 0 keeps the prop size, 0.77). Measured on the 1,008 test-family pairs, pick distance
0.2522 before: prop text 0.2460 and picked in 15%; box text 0.2354 and 29%; box tolerance 1.0 / 1.15 / 1.35: 0.2387 / 0.2354 /
0.2346 (1.15 kept; these three were measured before the rung-0 refinement). Using the prop size at the last-resort rung 0 (crowded canvases, where box-sized text overlapped) raised the
validity of generated candidates from 88% to 96% at the same pick distance. These three engine settings were chosen on the 3,000
Crello pairs; the gain is the same on train and test families (below), so little of it can be overfitting, but the 1,008 test pairs
were looked at while choosing them. The model itself never saw them. The samples, synthetic and holdout sets were only used to check
for regressions.
4. Crello designer pairs: engine without and with the model (tools/crello/layout_eval.py)
Same 3,000 pairs, same engine (local macOS), eval_base = the branch without layout.npz, eval_learned = with it.
dist is designer_eval.py's (all non-background elements, decorations included).
Held-out (test) families, 1,008 pairs:
| group | pairs | generated | valid | closest valid to the designer | oracle dist before β after | picked | pick dist before β after | stretch dist | picks changed |
|---|---|---|---|---|---|---|---|---|---|
| all | 1008 | 94% | 96% | 52.8% | 0.1860 β 0.1670 | 28.8% (290) | 0.2522 β 0.2352 | 0.1930 | 319 |
| aspect change 0-0.2 | 285 | 78% | 98% | 35.4% | 0.0846 β 0.0820 | 40.4% (115) | 0.0919 β 0.0876 | 0.0587 | 121 |
| aspect change 0.2-0.7 | 368 | 100% | 95% | 56.5% | 0.1629 β 0.1477 | 36.1% (133) | 0.2419 β 0.2126 | 0.1541 | 150 |
| aspect change 0.7+ | 355 | 100% | 95% | 62.8% | 0.2904 β 0.2548 | 11.8% (42) | 0.3915 β 0.3773 | 0.3410 | 48 |
| target strip | 129 | 91% | 93% | 42.6% | 0.2508 β 0.2312 | 24.8% (32) | 0.3265 β 0.3101 | 0.2836 | 35 |
| target small | 247 | 95% | 97% | 56.3% | 0.1735 β 0.1560 | 28.7% (71) | 0.2437 β 0.2260 | 0.1678 | 74 |
| target square | 120 | 94% | 93% | 45.0% | 0.1880 β 0.1752 | 26.7% (32) | 0.2410 β 0.2283 | 0.1873 | 35 |
| target vertical | 273 | 93% | 96% | 55.3% | 0.1743 β 0.1504 | 31.1% (85) | 0.2238 β 0.2054 | 0.1851 | 96 |
| target other | 239 | 95% | 96% | 55.6% | 0.1760 β 0.1585 | 29.3% (70) | 0.2589 β 0.2421 | 0.1819 | 79 |
Pick distance gain over the 1,008 test pairs +0.0169, family-bootstrap 95% interval [+0.0132, +0.0209] (232 families); train
families (1,992 pairs) +0.0162 [+0.0132, +0.0194]; all 3,000 pairs 0.2489 β 0.2325 (+0.0164 [+0.0140, +0.0190]). Where learned is
picked, the distance of those pairs goes 0.1909 β 0.1381 (test). Where a valid learned candidate exists (904 of 1,008 test pairs),
the closest one averages 0.1831 against prop 0.2077, anchor 0.2169, lanes 0.334, the stretch 0.195 and the shipped pick 0.241; it is
closer than prop in 69% of the pairs where both are valid and closer than the stretch in 47%. The selector still ships a
layout 0.04 farther from the designers than the stretch (0.2352 against 0.1930), and the oracle (0.1670) shows how much of the gap
sits in selection and how much in generation: the rule score prefers lanes layouts (better readability and utilisation, worse
fidelity) to the closest learned layout in 273 of the 332 test pairs where learned is closest but not picked (prop 40, anchor 19).
Other effects, all 3,000 pairs: shipped picks valid 2,994 β 2,994, fallbacks 10 β 9; candidates per pair 6.14 β 7.62 (valid 6.09 β 7.50); mean smallest text of the pick 26.40 β 26.82 px; texts below floor per pick 0.661 β 0.613; of the 934 changed picks the smallest text is larger in 432, smaller in 151, equal in 351 (mean 23.56 β 24.93 px). Picked families: lanes 1,699 β 1,411, prop 820 β 373, anchor 469 β 356, learned 0 β 849.
With the admission guard (shipped; eval_guard). Test families: picked in 23.5% (237 pairs), pick distance 0.2522 β 0.2429
(+0.0093, family bootstrap [+0.0062, +0.0127]); by aspect change 0.0919 β 0.0898 (0 to 0.2, picked 37.5%), 0.2419 β 0.2294 (0.2 to 0.7,
25.5%), 0.3915 β 0.3798 (0.7 and up, 10.1%); train families +0.0082 [+0.0062, +0.0104]; all 3,000 pairs 0.2489 β 0.2403 (+0.0086
[+0.0068, +0.0104]). The guard keeps 699 of the 849 learned picks, gives back about 45% of the unguarded gain, and turns the family into
a readability gain as well: of the 699 changed picks the smallest text is larger in 392, smaller in 36, equal in 271 (mean 21.98 β 24.63
px); all pairs: mean smallest text 26.40 β 27.01 px, texts below floor per pick 0.661 β 0.617, fallbacks 10 β 9, picked families lanes
1,699 β 1,549, prop 820 β 396, anchor 469 β 345, learned 699.
5. Our three sets (tools/evaluate.py --no-render --no-invariance; local macOS, critic on)
Baseline = the branch with CHAMELEON_LEARNED=0, which reproduces combined5/ exactly (all 1,162 picks and scores identical).
| set | hard checks | fallbacks | all text at floor | texts below floor per pair | smallest text, mean px | mean rule score | picks changed | to learned (smaller / larger / equal smallest text) |
|---|---|---|---|---|---|---|---|---|
| samples (46) | 46/46 β 46/46 | 0 β 0 | 35 β 35 | 0.913 β 0.913 | 22.91 β 22.83 | 0.9215 β 0.9227 | 8 | 5 (2 / 0 / 3) |
| synthetic (271) | 271/271 β 271/271 | 0 β 0 | 178 β 178 | 1.144 β 1.125 | 19.89 β 19.82 | 0.9056 β 0.9088 | 38 | 29 (7 / 1 / 21) |
| holdout (845) | 845/845 β 845/845 | 0 β 0 | 438 β 438 | 1.611 β 1.593 | 13.17 β 13.09 | 0.8899 β 0.8940 | 124 | 102 (34 / 7 / 61) |
Shipped (with the admission guard, section 3; evals/guard):
| set | hard checks | fallbacks | all text at floor | texts below floor per pair | smallest text, mean px | mean rule score | picks changed | to learned (smaller / larger / equal smallest text) |
|---|---|---|---|---|---|---|---|---|
| samples (46) | 46/46 β 46/46 | 0 β 0 | 35 β 35 | 0.913 β 0.913 | 22.91 β 22.91 | 0.9215 β 0.9228 | 3 | 3 (0 / 0 / 3) |
| synthetic (271) | 271/271 β 271/271 | 0 β 0 | 178 β 178 | 1.144 β 1.129 | 19.89 β 19.90 | 0.9056 β 0.9074 | 21 | 21 (2 / 1 / 18) |
| holdout (845) | 845/845 β 845/845 | 0 β 0 | 438 β 438 | 1.611 β 1.594 | 13.17 β 13.18 | 0.8899 β 0.8926 | 73 | 73 (13 / 6 / 54) |
Every pick that changes with the guard is a learned layout (the 8 side effects revert); the 15 with smaller smallest text lose 1 px each. The table below and the rest of this section describe the family without the guard, as it was judged.
No hard-check, fallback or floor-count regression. Full run with the invariance checks (determinism, rename invariance, shuffle validity) and the self-check, family on: the same picks as the numbers above, 0 self-check warnings; determinism and shuffle validity hold in every pair; rename invariance fails in 2 pairs (05_wide 728x90, h27_panel 320x50), the same two with CHAMELEON_LEARNED=0: pre-existing on this branch, neither pick is a learned layout, not touched here.
Of the 136 picks that move to learned, 126 are the rule score's and tie-breaks' (text smaller in 38, larger in 8, equal in 80) and 10 are
the critic overriding to a learned finalist (smaller 5, equal 5). Adding the family also changes the critic's finalist set: 8 further
picks move to a different non-learned layout (3 with smaller text: 08_scrim 300x250 7 β 3 px, h37_split 300x250 4 β 3 px and 2480x3508
27 β 21 px), and 26 picks keep their layout and only change whether the critic or the rule chose it. Every one of the 136: layout_picks.md.
The picks are spread over every target class (the family is generated at every aspect change above 3%). Of the 43 picks with smaller smallest text, 25 lose 1 px (9 to 14 px mostly), 4 lose 2 px, 7 lose 3 to 4 px (five of them on canvases where both layouts are under 8 px: h01_stack 300x250 5 β 2, 06_story 160x600 7 β 3, 09_partial_panel 300x250 6 β 3, L1 160x600 13 β 10, h03_stack 1080x1350 14 β 11) and 7 lose 5 to 12 px, all in large layouts where the old pick was a one-column layout (h03_stack 2480x3508 38 β 26, h35_text 2480x3508 37 β 27, h39_stack 1080x1350 19 β 13, h15_text 1080x1080 20 β 14, h30_text 1080x1920 26 β 20, h28_left 2480x3508 27 β 21, 14_rotated 2480x3508 40 β 34). The 8 larger ones: h08_text at three sizes (24 β 30, 26 β 33, 36 β 37 px), h30_text 1000x1500 16 β 20, 09_partial_panel 2480x3508 27 β 31, h26_stack 2480x3508 31 β 33, h30_text 1080x1080 14 β 16, h15_text 1080x1920 20 β 22.
Runtime. resize() per task, three alternating runs each, best of three: 300 Crello pairs 19.3 s β 22.2 s (64 β 74 ms mean, +15%), the 252
grid pairs of the samples and synthetic designs 30.0 s β 30.8 s (+3%); slowest task on those grids about 2 s either way. The machine was not
always quiet (the first run of each took 20 s and 32 s), so read it as roughly +3 to +15%: one or two more candidates are fitted and scored
per task. designer_eval.py's summed engine time is dominated by machine state (the same CHAMELEON_LEARNED=0 run took 2.6 min once and
7.6 min later; interleaved on 800 pairs 1.8 min without the model, 2.2 min with). With CHAMELEON_LEARNED=0 the new code reproduces
every candidate, score and pick of the original 3,000 Crello records.
6. Visual review of changed picks (before the judge round)
Ten required, fifteen looked at, old pick (left) against new pick (right) rendered with the vendored renderer
(tools/ai_judge.py:render_candidate), by me, one reviewer, no judge model:
| design, size | old β new | what I see |
|---|---|---|
| L1 1080x1350 | lanes:1 (critic) β learned | same elements, centred stack, 37 px both; equal |
| 03_badge_1 1080x1080 | lanes:1 (critic) β learned (critic) | bottle smaller, copy equal, 20 β 18 px; equal, slightly less filled |
| L3 1080x1080 | prop β learned | near-identical, slightly more air between rows; equal |
| 18_collage 1280x720 | lanes:2 β learned | the split (bottle left, copy right) becomes a centred stack whose bottle base touches the headline; cramped, worse |
| 05_wide 1584x396 | lanes:3 β learned | larger headline and subhead (smallest text 10 β 9 px), tidy left copy, bottle centre, pills right; arguably better |
| 06_story 160x600 | lanes:1 β learned (critic) | 7 px β 3 px, legal text vanishes, smaller product; worse |
| 14_rotated 1080x1350 | anchor β learned | ribbon and rotated side label kept, a little more air; equal |
| h15_text 1080x1080 | lanes:1 β learned | the one-column stack that fills the canvas becomes a block in the top half with 14 px pills and an empty lower half; worse |
| h39_stack 1080x1350 | lanes:1 β learned | bigger bottle, content biased up, label 19 β 13 px; equal |
| h03_stack 1080x1350 | lanes:1 β learned | content moved up, 14 β 11 px, ribbon over a circle; equal to slightly worse |
| h10_stack 1080x1350 | lanes:1 β learned:0.85 | larger headline, fills a little better, 10 β 9 px; slightly better |
| h04_split 1500x500 | lanes:2 β learned | keeps the source arrangement (headline and copy top left, bottle right), 13 β 12 px; equal |
| h38_left 728x90 | lanes:1 β learned:0.55 (critic) | subhead 8 β 6 px under a two-line headline, rule score 0.832 β 0.782; worse |
| h01_stack 300x250 | lanes:1 β learned | both unreadable, legal text 5 β 2 px; worse |
| h23_split 1080x1920 | lanes:1+row2 (critic) β learned | bottle placed beside the copy block instead of floating low right; better |
Three better, six equal, six worse. The pattern behind the worse ones: (1) at very narrow or very short canvases the uniform scale gives
2 to 3 px text and the lanes layouts it replaces reached 5 to 8 px; (2) the designers' habit of keeping relative positions leaves the
lower half of a taller canvas empty, where the one-column layouts the judge preferred fill it; (3) a collage whose parts overlap in the
source stays overlapped. The first and third are what the earlier judge rounds penalised. The rule score picks learned there because
its fidelity term (weight 0.70, tuned on these same designer pairs) is higher (fidelity 0.50 β 0.91 on h15_text 1080x1080, 0.60 β 0.96
on 18_collage 1280x720), outweighing utilisation and readability.
What to do with it. A guard that a learned pick may not lower the smallest text (offline simulation on the Crello picks, reverting
to the old pick when the learned pick's smallest text is 1 px smaller or more) removes 47 of 290 learned picks on test families and keeps
+0.0123 of the +0.0169 pick distance gain (a 2 px guard: 24 removed, +0.0139); on our sets it would remove the 43 picks with smaller text.
That is a selector rule, which needs its own judge round (section 5 of docs/crello-gains-2026-10-26.md is the protocol: render old
against new for every changed pick, both letter orders). The round was run (6b) and the rule, with a utilization axis added, is the guard.
6b. Judge round 6: old pick against new pick, and the admission guard (2026-10-27)
Protocol of rounds 4 and 5 (docs/crello-gains-2026-10-26.md Β§5): every pick that changes on the three sets with the family on and no
guard (144: 136 to a learned layout, 8 to another layout through the critic's finalist set) rendered old against new at 1:1
(tools/judge/build_change_set6.py), two sheets per pair with the letters swapped, each sheet judged once by a strong vision model
(ten judges, the two orders of a pair by different judges, tools/judge/make_lists6.py; JUDGE_PROMPT.md unchanged); decided = the
same winner in both orders (tools/judge/aggregate6.py; raw answers with reasons in tools/judge/labels6/).
| set | comparisons | decided | new (learned family) wins | old wins | tie | inconsistent | mean confidence |
|---|---|---|---|---|---|---|---|
| samples | 5 | 2 | 1 | 1 | 0 | 3 | 2.7 |
| synthetic | 32 | 23 | 18 | 5 | 1 | 8 | 2.7 |
| holdout | 107 | 85 | 47 | 38 | 0 | 22 | 3.2 |
| all | 144 | 110 | 66 | 44 | 1 | 33 | 3.1 |
66 to 44 (two-sided binomial p = 0.045) is a gain, far smaller than the critic's 67 to 13 (round 5) or the selection rules' 40 to 1 (round 4), and it splits on the two things the visual review above had named before the round:
| subset of the decided comparisons | new wins | old wins |
|---|---|---|
new pick is learned |
64 | 40 |
| β¦ chosen by the rule score / by the critic | 58 / 6 | 36 / 4 |
| new pick is another layout (the critic's finalist set changed) | 2 | 4 |
| smallest text equal | 44 | 16 |
| smallest text larger | 3 | 3 |
| smallest text 1 px smaller | 14 | 9 |
| smallest text 2 px smaller | 2 | 2 |
| smallest text 3 px or more smaller | 1 | 10 |
| utilization score (S5) up by 0.05 or more | 38 | 6 |
| utilization within 0.05 | 18 | 13 |
| utilization down by 0.05 or more | 8 | 21 |
| β¦ of which tall targets (height > width) | 1 | 15 |
The judges' reasons for the 44 old wins: top-heavy stacks that leave the lower half of a tall canvas empty (h15, h23, h24, h28, h30, h32, h34, h36, h39), text that goes unreadable on narrow or short canvases (06_story and L1 at 160x600, h01 and h18 at 300x250, h22 at 468x60), tag pills that touch or stagger (h07, h14, h15 1080x1920, h30), the legal line touching or crossing the CTA (h26 at four sizes: the source draws them overlapping, so every family keeps the pair as intentional layering and only the one-column layouts separate them), and the 16:9 collage that stays a portrait stack (18_collage twice).
The admission guard (_learned_admissible, TUNABLES["learned"]["guard_px"] = 1, guard_util = 0.05; section 3). On this round
it keeps 97 of the 144 changed picks, 71 of them decided at 54 to 17 (p = 1e-5), and reverts 47 that went 12 new, 27 old, 7
inconsistent, 1 tie. Alternatives on the same labels: the text guard alone keeps 61 to 28; text or a 0.10 utilization drop 59 to 25;
1 px or 0.10, 47 to 17. The two axes were named before the round (section 6: small text, top-heavy stacks) and the offline simulation
of the text guard was in the report before any label existed; the thresholds were chosen on the round, so 54 to 17 is in-sample and a
seventh round on designs neither the model nor the guard has seen is the honest check. What the guard costs on Crello is in section 4
(+0.0169 β +0.0093) and what it does on the three sets in section 5.
6c. Judge round 7: the guard out of sample (2026-10-28)
The check section 6b asked for. 90 new designs from the holdout generator with a new seed (tools/holdout7/,
python tools/holdout7/build_holdout.py 90 20261028: the same five style families as tools/holdout/ with new copy, sizes and
element mixes; nobody looked at them before the labels were in) on the holdout grid of about 21 sizes per design, 1,900 pairs.
Three engine runs: family off (CHAMELEON_LEARNED=0), the shipped engine (guard on) and the family without its guard
(CHAMELEON_LEARNED_GUARD=0, an evaluation-only switch added for this round). Hard checks 1,900 of 1,900 and fallbacks 0 in all
three; all text at floor 996 β 1,001 (shipped) / 1,000 (no guard); mean smallest text 12.90 β 12.92 / 12.85 px; mean rule score
0.8866 β 0.8889 / 0.8900. Without the guard the family changes 168 picks (8.8%), all to learned layouts; the guard keeps 117 (6.2%)
and reverts 51. Every one of the 168 was judged old against new, both letter orders, by twelve judges (the two orders of a pair by
different judges; tools/judge/build_change_set7.py and build_change_set7b.py, the second batch of 60 designs added when the
first 30 gave only 37 changed picks; make_lists7*.py; aggregate7.py; raw answers in tools/judge/labels7/).
Decision rule, written down before any label existed: merge if the kept picks win at least 60% of the decided comparisons and the reverted picks win less often than the kept ones.
| comparisons | decided | new wins | old wins | new share | |
|---|---|---|---|---|---|
| all (family without the guard) | 168 | 141 | 86 | 55 | 61% (p = 0.011) |
| kept by the guard (what ships) | 117 | 98 | 71 | 27 | 72% (p = 1e-5) |
| reverted by the guard | 51 | 43 | 15 | 28 | 35% |
Both conditions hold; the in-sample 76% of round 6 (54 to 17) becomes 72% out of sample. Kept picks by target class: small canvases (300x250, 336x280, 300x600, 160x600, 320x50, 468x60) 31 to 3, strips 7 to 1, squares 3 to 0, vertical 23 to 15, other 7 to 8. The breakdowns that produced the guard replicate on the utilization axis and only partly on the text axis:
| subset of the decided comparisons | new wins | old wins |
|---|---|---|
| utilization score up by 0.05 or more | 61 | 13 |
| within 0.05 | 17 | 20 |
| down by 0.05 or more | 8 | 22 |
| β¦ of which tall targets | 3 | 13 |
| smallest text equal | 59 | 20 |
| smallest text larger | 6 | 9 |
| 1 px smaller | 9 | 13 |
| 2 px smaller | 8 | 3 |
| 3 px or more smaller | 4 | 10 |
| reverted for utilization alone | 3 | 15 |
| reverted for text alone | 7 | 6 |
| reverted for both | 5 | 7 |
The utilization guard does the work (3 to 15 where it alone reverts); the text guard alone is a wash out of sample (7 to 6) and costs a few wins at exactly 2 px (8 to 3) while still removing the 3-px-and-more losses (4 to 10). It stays as judged: the shipped rule is the one these labels measured, and loosening it to 2 px would be a new in-sample choice.
Caveats: the same generator as the old holdout (unseen instances, not unseen kinds of design); the judge is the model family whose labels trained the critic (so this is an independent check on the family and the guard, not on the critic); local CoreText text measurement.
7. Tests
tests/test_learned.py (12 tests): features (shape, determinism, rename invariance, target enters only through aspect and size);
inert without weights (absent, corrupt, wrong names, wrong shapes, non-finite: no candidates, resize() byte-identical to
CHAMELEON_LEARNED=0 on three samples at four sizes); the env switch and tunable; not generated within Β±3%; a zero-output model
reproduces the stretch's boxes through the whole plumbing; with the shipped file, learned exists on L2 at 1600x400 and 1080x1920,
is complete, deterministic, on the canvas, unique, appended last with every older candidate unchanged, valid on L2 1080x1920 (at
1600x400 only the last-resort rung exists and it fails H8); the file is β€ 200 KB, loads without pickle and predicts finite boxes
on three samples at ten sizes; every learned candidate on that grid fits without errors and keeps every id; degenerate and
non-finite designs never raise; a failing model is skipped. Four older tests that pin the older families' behaviour or compare with
a models directory that is also missing layout.npz now set CHAMELEON_LEARNED=0; one (test_proportional_candidates_exist...)
ignores learned candidates. Three guard tests (LearnedGuardTests): the thresholds on stand-in picks; on the three samples at ten sizes the output with the
family differs from CHAMELEON_LEARNED=0 only where the log says a learned layout shipped (and it ships at least once); with guards
that always revert, the two outputs are identical everywhere.
Results: all 66 tests pass (51 existing + 15 new; tests/test_layout.py and python -m unittest discover -s tests -p "test_*.py", as CI runs them), and python -m engine.main on the three samples gives byte-identical JSON in two runs with no fallback or self-check warning.
8. Caveats
- The guard's thresholds were chosen on round 6 and checked out of sample in round 7 (section 6c: 90 fresh designs, kept picks 71 to 27, reverted 15 to 28). The text axis alone did not replicate (7 to 6); the utilization axis did (3 to 15). The Crello data says designers shrink text; the judge does not.
- Crello is not the organizers' briefs. Geometry of VistaCreate templates (grey boxes for art, synthetic words, Solstice fonts); nothing here was checked on a brand banner that is not one of our 3 + 18 + 40 designs.
- The critic had never seen these layouts; of the 136 unguarded learned picks, 10 are its overrides (judged 6 to 4). The 8 other picks its finalist set moved (judged 2 to 4) revert through the guard.
- Selection, not generation, is the bottleneck for designer closeness: oracle 0.1670 against pick 0.2352 on test families.
- Local macOS only (CoreText); the Docker (FreeType) run, which differs by up to ~8% in text width at small sizes (R15), was not
repeated, and
tools/evaluate.pynumbers in CI may differ by a few picks. - Trained on train families only (915 of 1,316 families); retraining on all of them is one flag away but would remove the held-out numbers.
- Validity: 95% of generated candidates pass every hard check (91% at aspect changes above 0.7); invalid ones are last-resort layouts with text overlapping text and are never shipped.
- The sklearn MLP in the table is trained with squared loss because that is all
MLPRegressoroffers; it is the weakest row for a reason that has nothing to do with network size.
9. Commands
.venv/bin/python tools/crello/layout_dataset.py
OMP_NUM_THREADS=4 /opt/anaconda3/bin/python3 tools/crello/train_layout.py --export engine/models/layout.npz --report output/crello/layout_train.md # add --skip-sklearn to save 2 min
CHAMELEON_LEARNED=0 .venv/bin/python tools/crello/designer_eval.py --workers 8 --out output/crello/eval_base
.venv/bin/python tools/crello/designer_eval.py --workers 8 --out output/crello/eval_learned
.venv/bin/python tools/crello/layout_eval.py --base output/crello/eval_base/candidates.jsonl --new output/crello/eval_learned/candidates.jsonl
CHAMELEON_LEARNED=0 .venv/bin/python tools/evaluate.py --set samples --no-render --no-invariance --summary-md /tmp/base_samples.md # likewise synthetic, holdout
.venv/bin/python tools/evaluate.py --set samples --no-render --no-invariance --summary-md /tmp/new_samples.md
.venv/bin/python tools/crello/layout_picks.py --base <dir of base summaries> --new <dir of new summaries> --out tools/crello/layout_picks.md
# the guard: CHAMELEON_LEARNED=0 vs the shipped engine is the "shipped" table of section 5; `eval_guard` in section 4 is designer_eval.py with the guard on
.venv/bin/python tests/test_layout.py && .venv/bin/python -m unittest discover -s tests -p "test_*.py"
Files: engine/resize.py (features, _learned_size, _learned_candidates, _anchor_try(pred=), loader _layout_model, _layout_predict),
engine/models/layout.npz, tools/crello/{layout_dataset,train_layout,layout_eval}.py, tests/test_learned.py, this report,
LAYOUT_MODEL_CARD.md, layout_picks.md, docs/engine.md (B29, section 6), tools/crello/README.md.