Skip to content

Commit 948e40b

Browse files
ryan-williamsclaude
andcommitted
Server: return loss (error + penalties) per step, use fixed-LR stepping
Server handler (`handlers.rs`): - Compute `loss = |error| + penalties.total()` for each training step - `sparklineData.errors` now contains loss values (was area error) - Add `sparklineData.areaErrors` for pure area error - Add `minLoss` / `minLossStepIndex` to batch result - Switch from error-scaled `step()` to fixed-LR `step_clipped()`: `step()` freezes when area error → 0 because `step_size = error * lr`; `step_clipped()` uses `step = clamp(grad) * lr` so penalty gradients still drive optimization when area error is negligible WASM worker (`worker.ts`): - Add `penaltyTotal()` and `stepLoss()` helpers to `utils.ts` - Compute per-step loss from WASM step penalties - `sparklineData.errors` now contains loss, `.areaErrors` contains area error Also suppress pre-existing warnings: - `_non_largest_sum_v` unused variable in `step.rs` - `#[allow(dead_code)]` on `LayoutResult` in `layout_opt.rs` Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2a6286e commit 948e40b

5 files changed

Lines changed: 95 additions & 21 deletions

File tree

apvd-cli/src/layout_opt.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use apvd_core::scene::Scene;
1919
use apvd_core::shape::Shape;
2020

2121
/// Result of a layout optimization run.
22+
#[allow(dead_code)]
2223
pub struct LayoutResult {
2324
/// Number of shapes
2425
pub num_shapes: usize,

apvd-cli/src/server/handlers.rs

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -389,9 +389,13 @@ pub(super) fn handle_train_batch(id: &str, params: &Value) -> JsonRpcResponse {
389389
let mut steps: Vec<Value> = Vec::with_capacity(num_steps);
390390
let mut min_error = current_step.error.v();
391391
let mut min_step_index = 0usize;
392+
let initial_loss = current_step.error.v().abs() + current_step.penalties.total();
393+
let mut min_loss = initial_loss;
394+
let mut min_loss_step_index = 0usize;
392395

393-
// Collect sparkline data: errors and gradients for each step
394-
let mut sparkline_errors: Vec<f64> = Vec::with_capacity(num_steps);
396+
// Collect sparkline data: loss and area errors for each step
397+
let mut sparkline_errors: Vec<f64> = Vec::with_capacity(num_steps); // loss (error + penalties)
398+
let mut sparkline_area_errors: Vec<f64> = Vec::with_capacity(num_steps); // pure area error
395399
let mut sparkline_gradients: Vec<Vec<f64>> = Vec::with_capacity(num_steps);
396400
// Region errors: key -> [error at step 0, error at step 1, ...]
397401
let mut sparkline_region_errors: std::collections::BTreeMap<String, Vec<f64>> = std::collections::BTreeMap::new();
@@ -408,21 +412,27 @@ pub(super) fn handle_train_batch(id: &str, params: &Value) -> JsonRpcResponse {
408412
steps.push(serde_json::json!({
409413
"stepIndex": 0,
410414
"error": current_step.error.v(),
415+
"loss": initial_loss,
411416
"shapes": initial_shapes,
412417
}));
413-
sparkline_errors.push(current_step.error.v());
418+
sparkline_errors.push(initial_loss);
419+
sparkline_area_errors.push(current_step.error.v());
414420
sparkline_gradients.push(current_step.error.d().to_vec());
415421
for (key, err) in &current_step.errors {
416422
if let Some(vec) = sparkline_region_errors.get_mut(key) {
417423
vec.push(err.error.v());
418424
}
419425
}
420426

421-
// Compute remaining steps
427+
// Compute remaining steps using fixed-LR stepping (not error-scaled).
428+
// Error-scaled step() freezes the optimizer when area error → 0 but penalties remain.
429+
let max_grad_value = 0.5;
430+
let max_grad_norm = 1.0;
422431
for i in 1..num_steps {
423-
match current_step.step(learning_rate) {
432+
match current_step.step_clipped(learning_rate, max_grad_value, max_grad_norm) {
424433
Ok(next_step) => {
425434
let error = next_step.error.v();
435+
let loss = error.abs() + next_step.penalties.total();
426436

427437
// Check for NaN in error or shape coordinates
428438
if error.is_nan() {
@@ -448,24 +458,32 @@ pub(super) fn handle_train_batch(id: &str, params: &Value) -> JsonRpcResponse {
448458
};
449459
}
450460

451-
// Track minimum
461+
// Track minimum area error
452462
if error < min_error {
453463
min_error = error;
454464
min_step_index = i;
455465
}
456466

467+
// Track minimum loss
468+
if loss < min_loss {
469+
min_loss = loss;
470+
min_loss_step_index = i;
471+
}
472+
457473
// Record step
458474
let shapes: Vec<Value> = next_step.shapes.iter()
459475
.map(|s| serde_json::to_value(s.v()).unwrap())
460476
.collect();
461477
steps.push(serde_json::json!({
462478
"stepIndex": i,
463479
"error": error,
480+
"loss": loss,
464481
"shapes": shapes,
465482
}));
466483

467484
// Collect sparkline data
468-
sparkline_errors.push(error);
485+
sparkline_errors.push(loss);
486+
sparkline_area_errors.push(error);
469487
sparkline_gradients.push(next_step.error.d().to_vec());
470488
for (key, err) in &next_step.errors {
471489
if let Some(vec) = sparkline_region_errors.get_mut(key) {
@@ -493,17 +511,21 @@ pub(super) fn handle_train_batch(id: &str, params: &Value) -> JsonRpcResponse {
493511
.map(|s| serde_json::to_value(s.v()).unwrap())
494512
.collect();
495513

496-
eprintln!("[trainBatch] Completed {} steps, minError={} at step {}", steps.len(), min_error, min_step_index);
514+
eprintln!("[trainBatch] Completed {} steps, minError={} at step {}, minLoss={} at step {}",
515+
steps.len(), min_error, min_step_index, min_loss, min_loss_step_index);
497516

498517
JsonRpcResponse {
499518
id: id.to_string(),
500519
result: Some(serde_json::json!({
501520
"steps": steps,
502521
"minError": min_error,
503522
"minStepIndex": min_step_index,
523+
"minLoss": min_loss,
524+
"minLossStepIndex": min_loss_step_index,
504525
"finalShapes": final_shapes,
505526
"sparklineData": {
506527
"errors": sparkline_errors,
528+
"areaErrors": sparkline_area_errors,
507529
"gradients": sparkline_gradients,
508530
"regionErrors": sparkline_region_errors,
509531
},

apvd-core/src/optimization/step.rs

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -382,12 +382,16 @@ impl Step {
382382
}
383383

384384
// Fragmentation penalty: for region keys with multiple disconnected geometric
385-
// components, penalize all but the largest. Each key's penalty is the fraction of
386-
// that region's own area that's in non-largest fragments (normalized per-region,
387-
// not per-total-area). This produces stronger gradients for eliminating tiny nubs
388-
// on small regions.
385+
// components, penalize the existence of fragments weighted by the region's
386+
// importance (its share of total area).
387+
//
388+
// Uses saturating normalization on the fragment fraction: sat(f) = f/(f+k).
389+
// This means any non-trivial fragment quickly produces a penalty proportional
390+
// to the region's share of total area, creating strong gradients to eliminate
391+
// even tiny fragment nubs.
389392
let mut total_fragmentation_penalty = scene.zero();
390393
let mut region_penalties: BTreeMap<String, RegionPenalties> = BTreeMap::new();
394+
let frag_k = 1e-4_f64; // half-saturation: frag fraction of 0.01% gets half-penalty
391395
{
392396
let mut regions_by_key: BTreeMap<String, Vec<Dual>> = BTreeMap::new();
393397
for component in &scene.components {
@@ -401,16 +405,24 @@ impl Step {
401405
geo_areas.sort_by(|a, b| b.v().partial_cmp(&a.v()).unwrap());
402406
// Sum all fragments for this key (region total area)
403407
let region_total: Dual = geo_areas.iter().cloned().sum();
408+
let region_weight = region_total.v() / total_area.v();
404409
// Sum non-largest fragments
405410
let non_largest_sum: Dual = geo_areas.into_iter().skip(1).sum();
406-
let non_largest_sum_v = non_largest_sum.v();
407-
// Fraction of region that's fragmented (in [0, 1))
408-
let key_frac = non_largest_sum / &region_total;
409-
debug!(" fragmentation penalty: key={}, region_total={}, frag_sum={}, frac={}", key, region_total.v(), non_largest_sum_v, key_frac.v());
411+
let _non_largest_sum_v = non_largest_sum.v();
412+
// Fraction of region that's fragmented
413+
let frag_frac = non_largest_sum / &region_total;
414+
// Saturate: sat(f) = f/(f+k), so even tiny frags → ~1
415+
let frag_frac_v = frag_frac.v();
416+
let sat_denom = frag_frac.clone() + frag_k;
417+
let saturated = frag_frac / &sat_denom;
418+
// Weight by region's share of total area
419+
let key_penalty = saturated * region_weight;
420+
debug!(" fragmentation: key={}, region_weight={:.4}, frag_frac={:.6}, saturated={:.4}, penalty={:.6}",
421+
key, region_weight, frag_frac_v, key_penalty.v() / region_weight, key_penalty.v());
410422
let rp = region_penalties.entry(key).or_default();
411-
rp.fragmentation = key_frac.v();
423+
rp.fragmentation = key_penalty.v();
412424
rp.fragment_count = fragment_count;
413-
total_fragmentation_penalty += key_frac;
425+
total_fragmentation_penalty += key_penalty;
414426
}
415427
}
416428
}

apvd-wasm/ts/utils.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,34 @@ export function extractShapes(wasmShapes: unknown[]): Shape[] {
6060
return wasmShapes.map(s => extractShape(s));
6161
}
6262

63+
// ============================================================================
64+
// Penalty extraction
65+
// ============================================================================
66+
67+
/** Shape of the WASM Penalties struct after serde serialization */
68+
interface WasmPenalties {
69+
disjoint: number;
70+
contained: number;
71+
self_intersection: number;
72+
regularity: number;
73+
fragmentation: number;
74+
perimeter_area: number;
75+
region_perimeter_area: number;
76+
}
77+
78+
/** Sum all penalty fields from a serialized WASM Penalties struct */
79+
export function penaltyTotal(p: WasmPenalties | undefined | null): number {
80+
if (!p) return 0;
81+
return (p.disjoint ?? 0) + (p.contained ?? 0) + (p.self_intersection ?? 0)
82+
+ (p.regularity ?? 0) + (p.fragmentation ?? 0) + (p.perimeter_area ?? 0)
83+
+ (p.region_perimeter_area ?? 0);
84+
}
85+
86+
/** Compute loss (|area error| + penalties) from a WASM step */
87+
export function stepLoss(wasmStep: { error: { v: number }; penalties?: WasmPenalties }): number {
88+
return Math.abs(wasmStep.error.v) + penaltyTotal(wasmStep.penalties);
89+
}
90+
6391
// ============================================================================
6492
// Sparkline data extraction
6593
// ============================================================================

apvd-wasm/ts/worker.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import {
6464
extractShape,
6565
extractShapes,
6666
extractSparklineData,
67+
stepLoss,
6768
tier,
6869
resolution,
6970
isKeyframe,
@@ -447,6 +448,11 @@ async function handleTrainBatch(id: string, request: BatchTrainingRequest): Prom
447448
shapes: extractShapes((wasmStep as { shapes: unknown[] }).shapes),
448449
}));
449450

451+
// Compute per-step loss (area error + penalties)
452+
const lossPerStep = modelSteps.map((ws: unknown) =>
453+
stepLoss(ws as { error: { v: number }; penalties?: any })
454+
);
455+
450456
// Extract sparkline data from all steps
451457
const { gradients, regionErrors } = extractSparklineData(modelSteps, 0);
452458

@@ -456,7 +462,8 @@ async function handleTrainBatch(id: string, request: BatchTrainingRequest): Prom
456462
minStepIndex: minIdx,
457463
finalShapes: steps[steps.length - 1].shapes,
458464
sparklineData: {
459-
errors: steps.map(s => s.error),
465+
errors: lossPerStep,
466+
areaErrors: steps.map(s => s.error),
460467
gradients,
461468
regionErrors,
462469
},
@@ -503,18 +510,21 @@ async function handleContinueTraining(id: string, handleId: string, numSteps: nu
503510

504511
// Collect per-step data (skip first step as it duplicates last)
505512
const batchSteps: Array<{ stepIndex: number; error: number; shapes: Shape[] }> = [];
513+
const lossPerStep: number[] = [];
506514

507515
// Extract sparkline data (starting from index 1, skipping duplicate)
508516
const { gradients: sparklineGradients, regionErrors: sparklineRegionErrors } =
509517
extractSparklineData(modelSteps, 1);
510518

511519
for (let i = 1; i < modelSteps.length; i++) {
512-
const wasmStep = modelSteps[i] as { error: { v: number }; shapes: unknown[] };
520+
const wasmStep = modelSteps[i] as { error: { v: number }; shapes: unknown[]; penalties?: any };
513521
const stepIndex = startStep + i;
514522
const error = wasmStep.error.v;
515523
const shapes = extractShapes(wasmStep.shapes);
524+
const loss = stepLoss(wasmStep);
516525

517526
batchSteps.push({ stepIndex, error, shapes });
527+
lossPerStep.push(loss);
518528

519529
// Store keyframe in session history if tiered storage says so
520530
if (isKeyframe(stepIndex, bucketSize)) {
@@ -547,7 +557,8 @@ async function handleContinueTraining(id: string, handleId: string, numSteps: nu
547557
currentError,
548558
steps: batchSteps,
549559
sparklineData: {
550-
errors: batchSteps.map(s => s.error),
560+
errors: lossPerStep,
561+
areaErrors: batchSteps.map(s => s.error),
551562
gradients: sparklineGradients,
552563
regionErrors: sparklineRegionErrors,
553564
},

0 commit comments

Comments
 (0)