feat(levels): select for 反差, the thing round 2 says separates hard from easy

Round 2 rated 16 runs a 3 and 84 a 2, which is too little spread to rank levels
by — but enough to ask what the 3s had in common. Contrast: 32.6 against 20.5,
a 59% gap and by far the largest of any feature. Rotation depth came second at
+1.2 turns. Board size and longest route ran the *other* way: the levels rated
harder were slightly smaller with shorter routes, which is what the design brief
has said all along — 难是难在用户无法一下子判断, not in how much board there is.

So within a depth, picking evenly across the ranked slice was diluting the one
thing the brief calls the point of the game. The depth quota already supplies
the difficulty range; inside a depth there is nothing to spread for, so it takes
the highest-contrast levels instead.

Taken alone that collapses the route grammars — two families filled 36 of the
50 — so the family cap comes down to 0.26. The pair beats the shipped round on
both axes rather than trading one for the other: contrast 14.8 / 22.0 / 27.9 →
15.2 / 24.9 / 28.6, families {18,12,8,2,10} → {13,13,5,9,10}. Same rule bot,
mechanics unchanged: worst blind streak still 1, dead taps still 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
cabinw
2026-09-14 17:18:42 +08:00
co-authored by Claude Opus 5
parent f6e92b05fb
commit dcdd4a0ada
4 changed files with 109452 additions and 25225 deletions
+19
View File
@@ -22,6 +22,14 @@ const FAMILY_CAP = Number(args.familyCap ?? 0.36);
// no matter how the score is weighted. A quota per depth states the curve
// instead of hoping it falls out of the ranking.
const CURVE = args.curve ? JSON.parse(args.curve) : null;
// Within one depth, what to take. Spreading evenly across the ranked slice was
// the default, and it quietly dilutes the thing the design brief calls the
// point of the game: levels the agent rated hardest carried 59% more 反差 than
// the rest (32.6 against 20.5), far and away the biggest difference between
// them — bigger than rotation depth, while board size and route length ran the
// other way. The depth quota already supplies the range, so inside a depth
// there is nothing to spread for.
const PICK = String(args.pickBy ?? 'contrast');
// Starting weights, replaced by the fit after a round with agents.
const DEFAULT_WEIGHTS = { rotations: 1.0, pieces: 0.10, lockedShare: 1.6, monkeyAPT: 0.14, cells: 0.004 };
@@ -70,6 +78,17 @@ const takeable = (rows, i) => {
// Even spread across a ranked list, taking `want` of it. Used on the whole pool
// when there is no curve, and on one depth's slice when there is.
const spread = (rows, want) => {
if (PICK === 'contrast') {
const out = [];
for (const row of [...rows].sort((a, b) => b.contrast - a.contrast)) {
if (out.length >= want) break;
if (!takeable([row], 0)) continue;
row.taken = true;
used[row.family] = (used[row.family] ?? 0) + 1;
out.push(row);
}
return out;
}
const out = [];
for (let slot = 0; slot < want; slot++) {
const ideal = Math.round(slot * (rows.length - 1) / Math.max(1, want - 1));