brush-paint-opt: drop barrier penalties from meta-opt inner score

The 100M-magnitude barriers in score_for_letter_with_weights swamped
all soft-weight differences — so two different ScoreWeights candidates
produced inner-descent results dominated by "minimise barriers", and
the meta search couldn't tell them apart. Both candidates converged
to identical PaintParams and identical CorpusReports, making the
outer ranking meaningless.

Drop the barriers. Inner descent is now purely guided by the
candidate's ScoreWeights → different weights produce different
optima. The OUTER lex comparator (compare_reports) handles tier-1
filtering at the end.

Stroke-count penalties stay (per-letter natural-form requirements,
50k each) and the "refuse zero strokes" pin stays (200k) — without
that the inner descent can degenerate to "paint nothing" under low
coverage weight.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mitchell Hansen
2026-05-02 00:56:45 -07:00
parent 9190661d7a
commit 56fa480610

View File

@@ -446,27 +446,30 @@ pub fn evaluate_score_weights(
(best_params, report) (best_params, report)
} }
/// Same shape as score_for_letter but takes ScoreWeights so the meta- /// Inner score function used during META-OPTIMIZATION. Unlike
/// optimizer can vary them. Mirrors the original `score_for_letter` /// `score_for_letter` (the production score), this version DOES NOT
/// barriers exactly; the only thing the meta optimizer changes is the /// add 100M-magnitude barriers for bg / coverage / length-budget
/// soft-term weights (ScoreWeights), not the hard barriers. /// violations.
///
/// Why: the barriers are so large they swamp every soft-weight
/// difference. With barriers in place, two different ScoreWeights
/// candidates produce inner-descent results dominated by "minimise
/// barriers" rather than "minimise the weighted soft score" — so
/// the inner optimizer converges to identical PaintParams under
/// most weight choices and the meta search has nothing to compare.
///
/// Without barriers, the inner descent is purely guided by the
/// candidate's ScoreWeights → different weights produce genuinely
/// different optima → the OUTER lex comparator ranks them by the
/// hard criteria (tier-1 fail counts) at the end.
///
/// Stroke-count penalties stay (they're per-letter natural-form
/// requirements, not score-vs-feasibility tradeoffs) and the
/// "refuse zero strokes" pin stays (without it the inner descent
/// can degenerate to "paint nothing" under low coverage weight).
fn score_for_letter_with_weights(ch: char, m: &PaintMetrics, w: &ScoreWeights) -> f32 { fn score_for_letter_with_weights(ch: char, m: &PaintMetrics, w: &ScoreWeights) -> f32 {
use crate::brush_paint::score_weighted; use crate::brush_paint::score_weighted;
let mut s = score_weighted(m, *w); let mut s = score_weighted(m, *w);
if m.total_swept > 0 {
let bg_rate = m.bg_painted as f32 / m.total_swept as f32;
if bg_rate > 0.05 {
s += 100_000_000.0 * (bg_rate - 0.05);
}
}
let cluster_threshold = 0.5 * std::f32::consts::PI * m.brush_radius * m.brush_radius;
let max_cluster = m.unpainted_clusters.iter().copied().max().unwrap_or(0) as f32;
if max_cluster > cluster_threshold {
s += 1_000_000.0 * (max_cluster - cluster_threshold);
}
if m.skeleton_length > 0 && m.total_length > 2.0 * m.skeleton_length as f32 {
s += 100_000.0 * (m.total_length - 2.0 * m.skeleton_length as f32);
}
if m.strokes == 0 { s += 200_000.0; } if m.strokes == 0 { s += 200_000.0; }
if is_single_stroke_letter(ch) && m.strokes != 1 { if is_single_stroke_letter(ch) && m.strokes != 1 {
s += 50_000.0 * ((m.strokes as i64 - 1).abs() as f32); s += 50_000.0 * ((m.strokes as i64 - 1).abs() as f32);