From 87831a10ac48988d3365e7b40fb40a1f552e517a Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Fri, 24 Jul 2026 23:19:15 -0400 Subject: [PATCH 01/10] feat(router): drive coupled-pair construction from 22 to 39/49 on the CM5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SI receipt's two pair claims (min_pair_coupled_fraction, worst_intra_pair_skew) are worst-case over every routed pair, so they are gated by the pairs that fail to route coupled at all. A bail census over the CM5's 49 inferred pairs found three causes, in order of size: * The phantom centerline was pinned to start and end on the OUTER layer. Inside a BGA, the outer layer is solid pads, so no fat capsule can ever sit there and the search failed outright — 28 of 32 census bails and 48 of 56 in a full route. Pairs escape a BGA the way the human board does: down a via, coupled on an inner layer under the field. Letting the centerline begin anywhere in the stack took the census from 26 to 41 coupled and made it 9x faster (failed searches no longer burn the whole retreat ladder). * `pair_partner` did not know the USB DP/DM convention that `classes.rs` uses to build the pair list, so the classifier declared USB pairs and this stage immediately reported "no partner name" for them. The two now share one source of truth. * The neck-down retreat was a fixed 15-rung table of guesses. It is now measured: probe outward from each end for the first point where a fat capsule is actually legal, and start the ladder there. Letting legs begin on inner layers exposed a latent connectivity bug: pad connectors searched from ALL copper layers, so a connector could start at a pad's XY on a layer the pad is not on and never touch it electrically — and nothing catches that, because the copper is same-net and the clearance probe is happy. Connectors now leave a pad only on that pad's own layers, with a regression test. Bail reasons are typed (`PairBail`) rather than log-only, with `census_pairs` and an `si_census` example, so the census is a measurement instead of a grep. Co-Authored-By: Claude Opus 5 --- .gitignore | 1 + crates/vcad-ecad-pcb/examples/cm5_bench.rs | 75 +- crates/vcad-ecad-pcb/examples/si_census.rs | 80 ++ crates/vcad-ecad-pcb/examples/si_descent.rs | 297 +------- crates/vcad-ecad-pcb/examples/si_finish.rs | 43 ++ crates/vcad-ecad-pcb/examples/si_report.rs | 36 + crates/vcad-ecad-pcb/src/router/classes.rs | 20 +- crates/vcad-ecad-pcb/src/router/descent.rs | 302 ++++++++ crates/vcad-ecad-pcb/src/router/mod.rs | 308 +++++++- crates/vcad-ecad-pcb/src/router/pair.rs | 732 ++++++++++++++++++- crates/vcad-ecad-pcb/src/router/si_claims.rs | 6 +- 11 files changed, 1542 insertions(+), 358 deletions(-) create mode 100644 crates/vcad-ecad-pcb/examples/si_census.rs create mode 100644 crates/vcad-ecad-pcb/examples/si_finish.rs diff --git a/.gitignore b/.gitignore index 59b0b681b..3d84003fa 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,4 @@ typst/vcad/vcad.wasm typst/vcad/examples/*.pdf typst/vcad/examples/*.png npm-dist/ +.scratch/ diff --git a/crates/vcad-ecad-pcb/examples/cm5_bench.rs b/crates/vcad-ecad-pcb/examples/cm5_bench.rs index 90f653a5b..fa213433d 100644 --- a/crates/vcad-ecad-pcb/examples/cm5_bench.rs +++ b/crates/vcad-ecad-pcb/examples/cm5_bench.rs @@ -186,21 +186,51 @@ fn main() { eprintln!("unrouted {}: {}", d.net, d.reason); } + // Apply the routed copper to the board, then run the SI finishing pass + // (reroute-then-descend). Both of its stages are non-regressive and + // oracle-gated, so this can only improve the pair claims — and it runs + // here, inside the route, so a *freshly routed* board is the one the + // receipt is measured on. + for t in &r.traces { + pcb.traces.push(Trace { + start: t.start, + end: t.end, + width: t.width, + layer: t.layer, + net: t.net.clone(), + source: None, + }); + } + for v in &r.vias { + pcb.vias.push(Via { + position: v.position, + diameter: pcb.rules.default_rules.via_diameter, + drill: pcb.rules.default_rules.via_drill, + start_layer: v.start_layer, + end_layer: v.end_layer, + net: v.net.clone(), + source: None, + }); + } + if std::env::var("VCAD_SI_FINISH").as_deref() != Ok("0") { + let t1 = Instant::now(); + let fin = vcad_ecad_pcb::router::si_finish(&mut pcb, 2_000_000, 2000); + println!( + "si-finish: {}/{} pairs re-coupled, {}/{} descended ({} rejected), {:.1}s", + fin.polished, + fin.polish_attempted, + fin.descent.tuned, + fin.descent.attempted, + fin.descent.rejected, + t1.elapsed().as_secs_f64(), + ); + } + // SI scoreboard: skew per length-match group and per differential pair, // measured on the routed copper. This is the gap the meander tuner must // close — and the number the human board is matched to within microns. { - let mut with_routes = pcb.clone(); - for t in &r.traces { - with_routes.traces.push(Trace { - start: t.start, - end: t.end, - width: t.width, - layer: t.layer, - net: t.net.clone(), - source: None, - }); - } + let with_routes = pcb.clone(); for (gname, members) in &classifier.match_groups { let lens: Vec<(f64, &str)> = members .iter() @@ -235,28 +265,9 @@ fn main() { } // Save the routed board for rendering / inspection without re-routing. + // The copper is already applied to `pcb` above (the SI finishing pass + // rewrites some of it, so it has to be applied before that runs). if let Some(out) = out_json { - for t in &r.traces { - pcb.traces.push(Trace { - start: t.start, - end: t.end, - width: t.width, - layer: t.layer, - net: t.net.clone(), - source: None, - }); - } - for v in &r.vias { - pcb.vias.push(Via { - position: v.position, - diameter: pcb.rules.default_rules.via_diameter, - drill: pcb.rules.default_rules.via_drill, - start_layer: v.start_layer, - end_layer: v.end_layer, - net: v.net.clone(), - source: None, - }); - } std::fs::write(&out, serde_json::to_string(&pcb).expect("serialize board")) .expect("write routed board json"); eprintln!("wrote {out}"); diff --git a/crates/vcad-ecad-pcb/examples/si_census.rs b/crates/vcad-ecad-pcb/examples/si_census.rs new file mode 100644 index 000000000..dba6e5d89 --- /dev/null +++ b/crates/vcad-ecad-pcb/examples/si_census.rs @@ -0,0 +1,80 @@ +//! Pair bail census: run the coupled-pair construction stage on a board with +//! all copper stripped and histogram why each pair did or did not couple. +//! +//! The full CM5 route takes hours; the pair stage sees a near-empty board in +//! round 0 regardless, so censusing it standalone measures the same geometry +//! in seconds. Kill the dominant bail mode, re-census, repeat. +//! +//! ```bash +//! cargo run --release -p vcad-ecad-pcb --example si_census -- board.kicad_pcb [expansions] +//! ``` + +use vcad_ecad_pcb::router::census_pairs; +use vcad_ecad_symbols::parse_kicad_pcb; +use vcad_ir::ecad::Pcb; + +fn main() { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")) + .format_timestamp_millis() + .init(); + let mut args = std::env::args().skip(1); + let path = args.next().expect("usage: si_census [expansions]"); + let expansions: usize = args + .next() + .and_then(|s| s.parse().ok()) + .unwrap_or(400_000); + + let text = std::fs::read_to_string(&path).expect("read board"); + let pcb: Pcb = if path.ends_with(".json") { + serde_json::from_str(&text).expect("parse pcb json") + } else { + parse_kicad_pcb(&text).expect("parse kicad_pcb") + }; + + let t0 = std::time::Instant::now(); + let census = census_pairs(&pcb, expansions); + let elapsed = t0.elapsed(); + + let total = census.rows.len(); + println!( + "pair census: {total} pairs, {} coupled, {} coupled >=0.5 fraction ({:.1}s)", + census.coupled(), + census.coupled_above(0.5), + elapsed.as_secs_f64() + ); + println!("\nbail histogram:"); + for (bail, n) in census.histogram() { + println!(" {n:3} {}", bail.slug()); + } + + // Successes with a WEAK coupled fraction are the silent failure mode: the + // pair constructed, so the stage counts it, but the receipt claim does not. + let mut weak: Vec<&vcad_ecad_pcb::router::PairCensusRow> = census + .rows + .iter() + .filter(|r| r.bail.is_none() && r.coupled_fraction < 0.5) + .collect(); + weak.sort_by(|a, b| a.coupled_fraction.partial_cmp(&b.coupled_fraction).unwrap()); + if !weak.is_empty() { + println!("\ncoupled but BELOW 0.5 fraction ({} pairs):", weak.len()); + for r in weak.iter().take(20) { + println!( + " {:.3} span {:6.2}mm {}", + r.coupled_fraction, r.span_mm, r.net_p + ); + } + } + + println!("\nbailed pairs (span, reason):"); + let mut bailed: Vec<&vcad_ecad_pcb::router::PairCensusRow> = + census.rows.iter().filter(|r| r.bail.is_some()).collect(); + bailed.sort_by(|a, b| a.span_mm.partial_cmp(&b.span_mm).unwrap()); + for r in bailed.iter().take(60) { + println!( + " {:6.2}mm {:26} {}", + r.span_mm, + r.bail.unwrap().slug(), + r.net_p + ); + } +} diff --git a/crates/vcad-ecad-pcb/examples/si_descent.rs b/crates/vcad-ecad-pcb/examples/si_descent.rs index 07bae6e7d..f4bf73166 100644 --- a/crates/vcad-ecad-pcb/examples/si_descent.rs +++ b/crates/vcad-ecad-pcb/examples/si_descent.rs @@ -5,299 +5,32 @@ //! clearance hinges) and commit the optimized geometry ONLY when the exact //! oracle passes every final segment — fail-closed, per the charter. //! +//! The implementation lives in `router::descend_board`; this is a driver, so +//! the router's own SI finishing pass and this example cannot drift apart. +//! //! ```bash -//! cargo run --release -p vcad-ecad-pcb --example si_descent -- in.pcb.json out.pcb.json +//! cargo run --release -p vcad-ecad-pcb --example si_descent -- in.pcb.json out.pcb.json [iters] //! ``` -use vcad_ecad_pcb::router::classes::{apply_classes, classify_nets}; -use vcad_ecad_pcb::router::descent::{descend_pair_runs, DescentObstacle, DescentWeights}; -use vcad_ecad_pcb::router::length_match::longest_chain; -use vcad_ecad_pcb::session::RouteSession; -use vcad_ecad_pcb::spatial::CopperGeom; -use vcad_ir::ecad::{Pcb, PcbLayer, Trace}; -use vcad_ir::Vec2; +use vcad_ecad_pcb::router::descend_board; +use vcad_ir::ecad::Pcb; fn main() { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) .format_timestamp_millis() .init(); let mut args = std::env::args().skip(1); - let input = args.next().expect("usage: si_descent "); - let output = args.next().expect("usage: si_descent "); + let input = args.next().expect("usage: si_descent [iters]"); + let output = args.next().expect("usage: si_descent [iters]"); + let iters: usize = args.next().and_then(|s| s.parse().ok()).unwrap_or(2000); + let mut pcb: Pcb = serde_json::from_str(&std::fs::read_to_string(&input).expect("read")).expect("parse"); - - let nets: Vec = { - let mut v: std::collections::BTreeSet = Default::default(); - for f in &pcb.footprints { - for pad in &f.pads { - if let Some(n) = &pad.net { - if !n.is_empty() { - v.insert(n.clone()); - } - } - } - } - v.into_iter().collect() - }; - let c = classify_nets(&nets); - apply_classes(&mut pcb, &c); - let gap_edge = pcb - .rules - .class_rules - .iter() - .find(|r| r.name == "diff-pair") - .and_then(|r| r.diff_pair_gap) - .unwrap_or(0.25); - let leg_w = pcb - .rules - .class_rules - .iter() - .find(|r| r.name == "diff-pair") - .and_then(|r| r.diff_pair_width) - .unwrap_or(0.2); - let gap_centre = gap_edge + leg_w; - - // Longest single-layer run of a net plus the length of everything else. - let net_run = |pcb: &Pcb, net: &str| -> Option<(PcbLayer, Vec, f64, f64)> { - let mut layers: Vec = pcb - .traces - .iter() - .filter(|t| t.net == net) - .map(|t| t.layer) - .collect(); - layers.sort(); - layers.dedup(); - let total: f64 = pcb - .traces - .iter() - .filter(|t| t.net == net) - .map(|t| (t.end - t.start).length()) - .sum(); - let mut best: Option<(f64, Vec, PcbLayer)> = None; - for layer in layers { - let segs: Vec<&Trace> = pcb - .traces - .iter() - .filter(|t| t.net == net && t.layer == layer) - .collect(); - if let Some(points) = longest_chain(&segs) { - let len: f64 = points.windows(2).map(|w| (w[1] - w[0]).length()).sum(); - if best.as_ref().map(|(l, _, _)| len > *l).unwrap_or(true) { - best = Some((len, points, layer)); - } - } - } - best.map(|(len, pts, layer)| { - let width = pcb - .traces - .iter() - .find(|t| t.net == net && t.layer == layer) - .map(|t| t.width) - .unwrap_or(0.08); - (layer, pts, total - len, width) - }) - }; - - let (mut tuned, mut attempted, mut rejected) = (0usize, 0usize, 0usize); - for (pn, nn) in &c.pairs { - let (Some((pl, p_raw, extra_p, p_w)), Some((nlayer, n_raw, extra_n, n_w))) = - (net_run(&pcb, pn), net_run(&pcb, nn)) - else { - continue; - }; - // Densify: point hinges only constrain POINTS — a long segment - // between two legal points sweeps across the board unconstrained. - // Resampling at ~1mm makes point coverage ≈ segment coverage. - let densify = |pts: &[Vec2]| -> Vec { - let mut out = vec![pts[0]]; - for w in pts.windows(2) { - let len = (w[1] - w[0]).length(); - let n = (len / 0.4).ceil().max(1.0) as usize; - for i in 1..=n { - let t = i as f64 / n as f64; - out.push(w[0] + (w[1] - w[0]).scale(t)); - } - } - out - }; - let p_pts = densify(&p_raw); - let n_pts = densify(&n_raw); - let run_w = p_w.max(n_w); - let plen: f64 = p_pts - .windows(2) - .map(|w| (w[1] - w[0]).length()) - .sum::() - + extra_p; - let nlen: f64 = n_pts - .windows(2) - .map(|w| (w[1] - w[0]).length()) - .sum::() - + extra_n; - if (plen - nlen).abs() < 0.2 || p_pts.len() < 3 || n_pts.len() < 3 { - continue; // matched already, or no interior freedom - } - attempted += 1; - - // Obstacles: same-layer copper of every OTHER net near the corridor. - let session = RouteSession::from_pcb(&pcb); - let clearance = session.clearance_for(pn); - let (mut lo, mut hi) = ( - Vec2::new(f64::INFINITY, f64::INFINITY), - Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY), - ); - for p in p_pts.iter().chain(n_pts.iter()) { - lo.x = lo.x.min(p.x - 3.0); - lo.y = lo.y.min(p.y - 3.0); - hi.x = hi.x.max(p.x + 3.0); - hi.y = hi.y.max(p.y + 3.0); - } - // Obstacles: EVERY copper element near the corridor on either run - // layer, from a session with both nets removed — pads and vias - // included (the first run's 17/17 oracle rejections were descended - // runs swinging across pad fields the trace-only set couldn't see). - let mut ob_board = pcb.clone(); - ob_board.traces.retain(|t| t.net != *pn && t.net != *nn); - ob_board.vias.retain(|v| v.net != *pn && v.net != *nn); - let ob_session = RouteSession::from_pcb(&ob_board); - let mut obstacles = Vec::new(); - let mut ob_layers = vec![pl]; - if nlayer != pl { - ob_layers.push(nlayer); - } - for &layer in &ob_layers { - ob_session.for_each_blocking( - layer, - "", - [lo.x, lo.y], - [hi.x, hi.y], - |geom, emin, emax, req| { - // +0.05mm safety: the hinge is a soft penalty traded - // against skew, so its equilibrium must land OUTSIDE the - // hard rule the oracle enforces. - let required = req + run_w / 2.0 + 0.05; - match geom { - CopperGeom::Segment { a, b, half_w } => obstacles.push(DescentObstacle { - a: *a, - b: *b, - required: required + half_w, - }), - CopperGeom::Disc { center, r } => obstacles.push(DescentObstacle { - a: *center, - b: *center, - required: required + r, - }), - _ => { - // Rect etc.: conservative disc over the bbox. - let c = Vec2::new((emin[0] + emax[0]) / 2.0, (emin[1] + emax[1]) / 2.0); - let hd = ((emax[0] - emin[0]).powi(2) + (emax[1] - emin[1]).powi(2)) - .sqrt() - / 2.0; - obstacles.push(DescentObstacle { - a: c, - b: c, - required: required + hd, - }); - } - } - }, - ); - } - - let Some(r) = descend_pair_runs( - &p_pts, - &n_pts, - extra_p, - extra_n, - pl == nlayer, - gap_centre, - &obstacles, - &DescentWeights::default(), - 2000, - ) else { - continue; - }; - - // Fail-closed: every optimized segment passes the exact oracle - // (pair-aware session probe) with BOTH nets' old copper removed. - let mut work = pcb.clone(); - work.traces.retain(|t| t.net != *pn && t.net != *nn); - let vsession = RouteSession::from_pcb(&work); - let diag = |pts: &[Vec2], net: &str, layer: PcbLayer, w: f64| { - for seg in pts.windows(2) { - let g = CopperGeom::Segment { - a: seg[0], - b: seg[1], - half_w: w / 2.0, - }; - let pr = vsession.probe(&g, layer, net, clearance); - if !pr.legal { - log::info!( - "reject {net}: seg ({:.2},{:.2})->({:.2},{:.2}) {layer:?} w={w} blocker {} at {:.3} (skew would be {:.3}, loss {:.2}, iters {})", - seg[0].x, seg[0].y, seg[1].x, seg[1].y, - pr.blockers.first().map(|b| b.net.clone()).unwrap_or_default(), - pr.min_clearance, r.skew, r.loss, r.iters - ); - return false; - } - } - true - }; - if !diag(&r.p_pts, pn, pl, p_w) || !diag(&r.n_pts, nn, nlayer, n_w) { - rejected += 1; - continue; - } - let before = (plen - nlen).abs(); - // Whole-net accounting: r.skew is run-only; judge the committed - // outcome on the full nets and accept only real improvements. - let run_p_after: f64 = r.p_pts.windows(2).map(|w| (w[1] - w[0]).length()).sum(); - let run_n_after: f64 = r.n_pts.windows(2).map(|w| (w[1] - w[0]).length()).sum(); - let after = ((run_p_after + extra_p) - (run_n_after + extra_n)).abs(); - if after >= before { - rejected += 1; - log::info!("descent: {pn} no improvement ({before:.3} -> {after:.3}) — skipped"); - continue; - } - // Replace only the optimized RUN's segments; the rest of each net's - // copper (other layers, stubs, vias) is untouched. - let mut work2 = pcb.clone(); - let replace_run = |work2: &mut Pcb, - net: &str, - layer: PcbLayer, - old: &[Vec2], - new: &[Vec2], - width: f64| { - let on_old = |t: &Trace| { - t.net == net - && t.layer == layer - && old.windows(2).any(|w| { - (t.start - w[0]).length() < 1e-6 && (t.end - w[1]).length() < 1e-6 - || (t.start - w[1]).length() < 1e-6 && (t.end - w[0]).length() < 1e-6 - }) - }; - work2.traces.retain(|t| !on_old(t)); - work2.traces.extend(new.windows(2).map(|w| Trace { - start: w[0], - end: w[1], - width, - layer, - net: net.to_string(), - source: None, - })); - }; - replace_run(&mut work2, pn, pl, &p_raw, &r.p_pts, p_w); - replace_run(&mut work2, nn, nlayer, &n_raw, &r.n_pts, n_w); - pcb = work2; - tuned += 1; - log::info!( - "descent: {pn} whole-net skew {:.3} -> {:.3} mm ({} iters, loss {:.3})", - before, - after, - r.iters, - r.loss - ); - } - println!("si-descent: tuned {tuned}/{attempted} pairs ({rejected} oracle-rejected)"); + let r = descend_board(&mut pcb, iters); + println!( + "si-descent: tuned {}/{} pairs ({} oracle-rejected)", + r.tuned, r.attempted, r.rejected + ); std::fs::write(&output, serde_json::to_string(&pcb).expect("serialize")).expect("write"); eprintln!("wrote {output}"); } diff --git a/crates/vcad-ecad-pcb/examples/si_finish.rs b/crates/vcad-ecad-pcb/examples/si_finish.rs new file mode 100644 index 000000000..db7903cad --- /dev/null +++ b/crates/vcad-ecad-pcb/examples/si_finish.rs @@ -0,0 +1,43 @@ +//! Signal-integrity finishing pass on an already-routed board: re-couple +//! uncoupled pairs, descend residual skew, then meander out what is left. +//! +//! Every stage is non-regressive and oracle-gated, so running this on a board +//! is safe and idempotent-ish — a second run only works whatever the first +//! could not close. +//! +//! ```bash +//! cargo run --release -p vcad-ecad-pcb --example si_finish -- in.pcb.json out.pcb.json [expansions] [iters] +//! ``` + +use vcad_ecad_pcb::router::si_finish; +use vcad_ir::ecad::Pcb; + +fn main() { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) + .format_timestamp_millis() + .init(); + let mut args = std::env::args().skip(1); + let input = args.next().expect("usage: si_finish [exp] [iters]"); + let output = args.next().expect("usage: si_finish [exp] [iters]"); + let expansions: usize = args + .next() + .and_then(|s| s.parse().ok()) + .unwrap_or(2_000_000); + let iters: usize = args.next().and_then(|s| s.parse().ok()).unwrap_or(2000); + + let mut pcb: Pcb = + serde_json::from_str(&std::fs::read_to_string(&input).expect("read")).expect("parse"); + let r = si_finish(&mut pcb, expansions, iters); + println!( + "si-finish: {}/{} re-coupled, {}/{} descended ({} rejected), {} meandered, {} still over tolerance", + r.polished, + r.polish_attempted, + r.descent.tuned, + r.descent.attempted, + r.descent.rejected, + r.meandered, + r.over_tolerance, + ); + std::fs::write(&output, serde_json::to_string(&pcb).expect("serialize")).expect("write"); + eprintln!("wrote {output}"); +} diff --git a/crates/vcad-ecad-pcb/examples/si_report.rs b/crates/vcad-ecad-pcb/examples/si_report.rs index 95a8aa7b0..ce1d53cb3 100644 --- a/crates/vcad-ecad-pcb/examples/si_report.rs +++ b/crates/vcad-ecad-pcb/examples/si_report.rs @@ -178,6 +178,42 @@ fn main() { worst.0, worst.1 ); + // Per-pair detail for the two claims that are worst-case minima/maxima + // over every routed pair: one bad pair breaks the claim, so the report + // has to name it, not just score it. + { + let gap = pcb.rules.default_rules.diff_pair_gap.unwrap_or(0.25); + let w = pcb + .rules + .default_rules + .diff_pair_width + .unwrap_or(pcb.rules.default_rules.trace_width); + let max_sep = (w + gap) * 1.75; + let mut rows: Vec<(f64, f64, &str)> = Vec::new(); + for (p, n) in &c.pairs { + let (lp, ln) = (net_routed_length(&pcb, p), net_routed_length(&pcb, n)); + if lp > 0.0 && ln > 0.0 { + rows.push(( + vcad_ecad_pcb::router::pair_coupled_fraction(&pcb, p, n, max_sep), + (lp - ln).abs(), + p.as_str(), + )); + } + } + rows.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); + let weak = rows.iter().filter(|r| r.0 < 0.5).count(); + println!("pairs below 0.5 coupled fraction: {weak} of {}", rows.len()); + for (frac, skew, net) in rows.iter().take(12) { + println!(" coupled {frac:.3} skew {skew:6.3} mm {net}"); + } + let mut by_skew = rows.clone(); + by_skew.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap()); + println!("worst intra-pair skews:"); + for (frac, skew, net) in by_skew.iter().take(8) { + println!(" skew {skew:6.3} mm coupled {frac:.3} {net}"); + } + } + // The claim set: the machine-checkable verdict this whole report exists // to feed. Bounds = the human CM5 envelope. let set = si_claims(&pcb, &c, &SiBounds::default()); diff --git a/crates/vcad-ecad-pcb/src/router/classes.rs b/crates/vcad-ecad-pcb/src/router/classes.rs index 9dd8ee042..7a4794c04 100644 --- a/crates/vcad-ecad-pcb/src/router/classes.rs +++ b/crates/vcad-ecad-pcb/src/router/classes.rs @@ -47,20 +47,6 @@ impl NetClassifier { } } -/// USB 2.0 `DP`/`DM` partner (D+/D−) — a convention -/// [`pair_partner`] does not cover because it is not symmetric-suffix. -fn usb_dp_dm_partner(net: &str) -> Option { - for (a, b) in [("DP", "DM"), ("DM", "DP"), ("D+", "D-"), ("D-", "D+")] { - if let Some(base) = net.strip_suffix(a) { - // Require a separator before the token so e.g. "LDP" doesn't match. - if base.ends_with('.') || base.ends_with('_') || base.ends_with('-') { - return Some(format!("{base}{b}")); - } - } - } - None -} - /// Scan `nets` and recover pairs and match groups from their names. pub fn classify_nets(nets: &[String]) -> NetClassifier { let set: std::collections::BTreeSet<&str> = nets.iter().map(|s| s.as_str()).collect(); @@ -71,9 +57,9 @@ pub fn classify_nets(nets: &[String]) -> NetClassifier { if paired.contains(net.as_str()) { continue; } - let partner = pair_partner(net) - .or_else(|| usb_dp_dm_partner(net)) - .filter(|p| set.contains(p.as_str())); + // `pair_partner` is the single source of truth: what this classifier + // declares as a pair is exactly what the pair router can route. + let partner = pair_partner(net).filter(|p| set.contains(p.as_str())); if let Some(p) = partner { // Canonical order: positive leg first when identifiable. let (pos, neg) = if net.ends_with('N') || net.ends_with('M') || net.ends_with('-') { diff --git a/crates/vcad-ecad-pcb/src/router/descent.rs b/crates/vcad-ecad-pcb/src/router/descent.rs index 0cdaa79e9..1d2a0e0ca 100644 --- a/crates/vcad-ecad-pcb/src/router/descent.rs +++ b/crates/vcad-ecad-pcb/src/router/descent.rs @@ -25,6 +25,7 @@ //! the product. use tang_expr::{ExprGraph, ExprId}; +use vcad_ir::ecad::Pcb; use vcad_ir::Vec2; /// Weights for the objective's terms. @@ -299,6 +300,307 @@ pub fn descend_pair_runs( }) } +/// What [`descend_board`] did. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct DescentReport { + /// Pairs whose geometry was replaced with a lower-skew descent. + pub tuned: usize, + /// Pairs that had enough interior freedom to attempt. + pub attempted: usize, + /// Attempts thrown away because the exact oracle rejected the geometry + /// or the whole-net skew did not actually improve. + pub rejected: usize, +} + +/// Run the differentiable pair polish over every classified pair on a routed +/// board, committing only geometry the exact oracle accepts. +/// +/// This is the library form of what `examples/si_descent.rs` used to do +/// inline, so the router's finishing stage and the example share one +/// implementation. Per pair: take the longest single-layer run of each leg, +/// densify it, build the obstacle set from every other net's copper near the +/// corridor, descend, then accept only if (a) every optimized segment probes +/// legal against a session with both nets' old copper removed and (b) the +/// WHOLE-net skew actually fell. Anything else leaves the pair untouched. +pub fn descend_board(pcb: &mut Pcb, iters: usize) -> DescentReport { + use super::classes::{apply_classes, classify_nets}; + use super::length_match::longest_chain; + use crate::session::RouteSession; + use crate::spatial::CopperGeom; + use vcad_ir::ecad::{PcbLayer, Trace}; + + let nets: Vec = { + let mut v: std::collections::BTreeSet = Default::default(); + for f in &pcb.footprints { + for pad in &f.pads { + if let Some(n) = &pad.net { + if !n.is_empty() { + v.insert(n.clone()); + } + } + } + } + v.into_iter().collect() + }; + let c = classify_nets(&nets); + apply_classes(pcb, &c); + let dp = pcb + .rules + .class_rules + .iter() + .find(|r| r.name == super::classes::DIFF_PAIR_CLASS); + let gap_edge = dp.and_then(|r| r.diff_pair_gap).unwrap_or(0.25); + let leg_w = dp.and_then(|r| r.diff_pair_width).unwrap_or(0.2); + // +20um cushion, the same reasoning the clearance hinge uses: the gap + // spring is a SOFT term traded against skew, so its equilibrium has to + // land outside the hard rule the oracle enforces (pair gap - 5um). + // Targeting the gap exactly puts every descended pair on the threshold + // and the oracle then rejects all of them. + let gap_centre = gap_edge + leg_w + 0.02; + + // Longest single-layer run of a net, plus the length of everything else. + let net_run = |pcb: &Pcb, net: &str| -> Option<(PcbLayer, Vec, f64, f64)> { + let mut layers: Vec = pcb + .traces + .iter() + .filter(|t| t.net == net) + .map(|t| t.layer) + .collect(); + layers.sort(); + layers.dedup(); + let total: f64 = pcb + .traces + .iter() + .filter(|t| t.net == net) + .map(|t| (t.end - t.start).length()) + .sum(); + let mut best: Option<(f64, Vec, PcbLayer)> = None; + for layer in layers { + let segs: Vec<&Trace> = pcb + .traces + .iter() + .filter(|t| t.net == net && t.layer == layer) + .collect(); + if let Some(points) = longest_chain(&segs) { + let len: f64 = points.windows(2).map(|w| (w[1] - w[0]).length()).sum(); + if best.as_ref().map(|(l, _, _)| len > *l).unwrap_or(true) { + best = Some((len, points, layer)); + } + } + } + best.map(|(len, pts, layer)| { + let width = pcb + .traces + .iter() + .find(|t| t.net == net && t.layer == layer) + .map(|t| t.width) + .unwrap_or(0.08); + (layer, pts, total - len, width) + }) + }; + + let mut report = DescentReport::default(); + for (pn, nn) in &c.pairs { + let (Some((pl, p_raw, extra_p, p_w)), Some((nlayer, n_raw, extra_n, n_w))) = + (net_run(pcb, pn), net_run(pcb, nn)) + else { + continue; + }; + // Point hinges only constrain POINTS — a long segment between two + // legal points sweeps across the board unconstrained. Resampling at + // ~0.4mm makes point coverage approximate segment coverage. + let densify = |pts: &[Vec2]| -> Vec { + let mut out = vec![pts[0]]; + for w in pts.windows(2) { + let len = (w[1] - w[0]).length(); + let n = (len / 0.4).ceil().max(1.0) as usize; + for i in 1..=n { + let t = i as f64 / n as f64; + out.push(w[0] + (w[1] - w[0]).scale(t)); + } + } + out + }; + let p_pts = densify(&p_raw); + let n_pts = densify(&n_raw); + let run_w = p_w.max(n_w); + let plen: f64 = p_pts.windows(2).map(|w| (w[1] - w[0]).length()).sum::() + extra_p; + let nlen: f64 = n_pts.windows(2).map(|w| (w[1] - w[0]).length()).sum::() + extra_n; + if (plen - nlen).abs() < 0.2 || p_pts.len() < 3 || n_pts.len() < 3 { + continue; // already matched, or no interior freedom + } + report.attempted += 1; + + let session = RouteSession::from_pcb(pcb); + let clearance = session.clearance_for(pn); + let (mut lo, mut hi) = ( + Vec2::new(f64::INFINITY, f64::INFINITY), + Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY), + ); + for p in p_pts.iter().chain(n_pts.iter()) { + lo.x = lo.x.min(p.x - 3.0); + lo.y = lo.y.min(p.y - 3.0); + hi.x = hi.x.max(p.x + 3.0); + hi.y = hi.y.max(p.y + 3.0); + } + // Obstacles: EVERY copper element near the corridor on either run + // layer, from a session with both nets removed — pads and vias + // included, since a descended run can swing across a pad field a + // trace-only obstacle set cannot see. + let mut ob_board = pcb.clone(); + ob_board.traces.retain(|t| t.net != *pn && t.net != *nn); + ob_board.vias.retain(|v| v.net != *pn && v.net != *nn); + let ob_session = RouteSession::from_pcb(&ob_board); + let mut obstacles = Vec::new(); + let mut ob_layers = vec![pl]; + if nlayer != pl { + ob_layers.push(nlayer); + } + for &layer in &ob_layers { + ob_session.for_each_blocking( + layer, + "", + [lo.x, lo.y], + [hi.x, hi.y], + |geom, emin, emax, req| { + // +0.05mm safety: the hinge is a soft penalty traded + // against skew, so its equilibrium must land OUTSIDE the + // hard rule the oracle enforces. + let required = req + run_w / 2.0 + 0.05; + match geom { + CopperGeom::Segment { a, b, half_w } => obstacles.push(DescentObstacle { + a: *a, + b: *b, + required: required + half_w, + }), + CopperGeom::Disc { center, r } => obstacles.push(DescentObstacle { + a: *center, + b: *center, + required: required + r, + }), + _ => { + let c = Vec2::new((emin[0] + emax[0]) / 2.0, (emin[1] + emax[1]) / 2.0); + let hd = ((emax[0] - emin[0]).powi(2) + (emax[1] - emin[1]).powi(2)) + .sqrt() + / 2.0; + obstacles.push(DescentObstacle { + a: c, + b: c, + required: required + hd, + }); + } + } + }, + ); + } + + let Some(r) = descend_pair_runs( + &p_pts, + &n_pts, + extra_p, + extra_n, + pl == nlayer, + gap_centre, + &obstacles, + &DescentWeights::default(), + iters, + ) else { + continue; + }; + + // Fail-closed: every optimized segment passes the exact oracle. Each + // leg is checked against a board with only ITS OWN old copper removed, + // so the twin stays an obstacle — the session applies the declared + // pair gap to it, so a correctly coupled leg passes while one that has + // drifted into its partner does not. (Dropping both nets would let the + // descent short the pair to itself unnoticed: the gap spring is a soft + // term, not a guarantee.) + // Both legs move together, so the twin must be present at its NEW + // position: checking P-new against N-old rejects every descent, + // because the pair as a whole has shifted. + let with_new = |keep: &str, moved: &[Vec2], moved_net: &str, layer: PcbLayer, w: f64| { + let mut b = pcb.clone(); + b.traces.retain(|t| t.net != *pn && t.net != *nn); + b.traces.extend(moved.windows(2).map(|s| Trace { + start: s[0], + end: s[1], + width: w, + layer, + net: moved_net.to_string(), + source: None, + })); + let _ = keep; + RouteSession::from_pcb(&b) + }; + // P is judged against a board holding N's descended copper, and + // vice versa. + let p_session = with_new(pn, &r.n_pts, nn, nlayer, n_w); + let n_session = with_new(nn, &r.p_pts, pn, pl, p_w); + let legal = |sess: &RouteSession, pts: &[Vec2], net: &str, layer: PcbLayer, w: f64| { + pts.windows(2).all(|seg| { + sess.probe( + &CopperGeom::Segment { + a: seg[0], + b: seg[1], + half_w: w / 2.0, + }, + layer, + net, + clearance, + ) + .legal + }) + }; + if !legal(&p_session, &r.p_pts, pn, pl, p_w) + || !legal(&n_session, &r.n_pts, nn, nlayer, n_w) + { + report.rejected += 1; + continue; + } + // Whole-net accounting: r.skew is run-only; judge the committed + // outcome on the full nets and accept only real improvements. + let before = (plen - nlen).abs(); + let run_p_after: f64 = r.p_pts.windows(2).map(|w| (w[1] - w[0]).length()).sum(); + let run_n_after: f64 = r.n_pts.windows(2).map(|w| (w[1] - w[0]).length()).sum(); + let after = ((run_p_after + extra_p) - (run_n_after + extra_n)).abs(); + if after >= before { + report.rejected += 1; + continue; + } + let mut work2 = pcb.clone(); + let replace_run = |work2: &mut Pcb, + net: &str, + layer: PcbLayer, + old: &[Vec2], + new: &[Vec2], + width: f64| { + let on_old = |t: &Trace| { + t.net == net + && t.layer == layer + && old.windows(2).any(|w| { + (t.start - w[0]).length() < 1e-6 && (t.end - w[1]).length() < 1e-6 + || (t.start - w[1]).length() < 1e-6 && (t.end - w[0]).length() < 1e-6 + }) + }; + work2.traces.retain(|t| !on_old(t)); + work2.traces.extend(new.windows(2).map(|w| Trace { + start: w[0], + end: w[1], + width, + layer, + net: net.to_string(), + source: None, + })); + }; + replace_run(&mut work2, pn, pl, &p_raw, &r.p_pts, p_w); + replace_run(&mut work2, nn, nlayer, &n_raw, &r.n_pts, n_w); + *pcb = work2; + report.tuned += 1; + log::info!("descent: {pn} whole-net skew {before:.3} -> {after:.3} mm"); + } + report +} + fn pt_seg_dist_f64(p: Vec2, a: Vec2, b: Vec2) -> f64 { let ab = b - a; let l2 = ab.x * ab.x + ab.y * ab.y; diff --git a/crates/vcad-ecad-pcb/src/router/mod.rs b/crates/vcad-ecad-pcb/src/router/mod.rs index 63f301217..0c9307ae1 100644 --- a/crates/vcad-ecad-pcb/src/router/mod.rs +++ b/crates/vcad-ecad-pcb/src/router/mod.rs @@ -68,7 +68,313 @@ pub use length_match::{ check_length_match, match_lengths, LengthMatchOptions, LengthMatchResult, NetLengthReport, }; pub use maze::route_net_maze; -pub use pair::polish_pairs; +pub use descent::{descend_board, DescentReport}; +pub use pair::{census_pairs, polish_pairs, PairBail, PairCensus, PairCensusRow}; +pub use si_claims::coupled_fraction as pair_coupled_fraction; + +/// What [`si_finish`] changed. +#[derive(Debug, Clone, Copy, Default)] +pub struct SiFinishReport { + /// Uncoupled pairs re-routed coupled by the polish stage. + pub polished: usize, + /// Uncoupled pairs the polish stage attempted. + pub polish_attempted: usize, + /// Differentiable-descent outcome. + pub descent: DescentReport, + /// Pairs whose residual skew was meandered out. + pub meandered: usize, + /// Pairs still over the skew tolerance after every stage. + pub over_tolerance: usize, +} + +/// Add `deficit` mm to `net` by meandering one of its runs, preferring runs +/// that have room. Returns the modified board, or `None` if no run could +/// absorb it. +/// +/// Run choice is the whole point. The obvious candidate — the net's longest +/// run — is the coupled leg, and a coupled leg is the one place on the board +/// where a meander provably cannot go: its twin sits a gap away for the run's +/// whole length, so every amplitude the generator tries collides with it (on +/// the CM5 this refused 4 of 7 pairs outright with "meanders do not fit"). +/// The breakout copper is the opposite: it is thinner than a leg, it is +/// uncoupled by construction, and it has open board around it. So candidates +/// are ordered narrowest-first, and the coupled legs are tried only as a last +/// resort. +fn compensate_run(pcb: &Pcb, net: &str, deficit: f64) -> Option { + use length_tune::{generate_meanders_checked, LengthTuneParams, MeanderStyle}; + use vcad_ir::ecad::Trace; + + // Candidate runs: longest unbranched chain per (layer, width) class. + let mut classes: Vec<(PcbLayer, f64)> = pcb + .traces + .iter() + .filter(|t| t.net == net) + .map(|t| (t.layer, (t.width * 1000.0).round() / 1000.0)) + .collect(); + classes.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.partial_cmp(&b.1).unwrap())); + classes.dedup(); + // Narrowest first: breakout copper before coupled legs. + classes.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap()); + + for (layer, width) in classes { + let segs: Vec<&Trace> = pcb + .traces + .iter() + .filter(|t| t.net == net && t.layer == layer && (t.width - width).abs() < 1e-6) + .collect(); + let Some(points) = length_match::longest_chain(&segs) else { + continue; + }; + let run_len: f64 = points.windows(2).map(|w| (w[1] - w[0]).length()).sum(); + if points.len() < 2 || run_len < 0.5 { + continue; + } + // Obstacles for the amplitude search: every other net's copper on + // this layer near the run, plus the twin (which is same-net-adjacent + // but a hard obstacle for a meander). + let session = RouteSession::from_pcb(pcb); + let clearance = session.clearance_for(net); + let (mut lo, mut hi) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]); + for p in &points { + lo[0] = lo[0].min(p.x - 3.0); + lo[1] = lo[1].min(p.y - 3.0); + hi[0] = hi[0].max(p.x + 3.0); + hi[1] = hi[1].max(p.y + 3.0); + } + let mut obstacles: Vec<(Vec2, Vec2, f64)> = Vec::new(); + session.for_each_blocking(layer, net, lo, hi, |geom, emin, emax, req| { + match geom { + crate::spatial::CopperGeom::Segment { a, b, half_w } => { + obstacles.push((*a, *b, req + half_w + width / 2.0)) + } + crate::spatial::CopperGeom::Disc { center, r } => { + obstacles.push((*center, *center, req + r + width / 2.0)) + } + _ => { + let c = Vec2::new((emin[0] + emax[0]) / 2.0, (emin[1] + emax[1]) / 2.0); + let hd = + ((emax[0] - emin[0]).powi(2) + (emax[1] - emin[1]).powi(2)).sqrt() / 2.0; + obstacles.push((c, c, req + hd + width / 2.0)); + } + } + }); + // Validation session: the board without THIS net's copper, so the + // meander is judged against everything else including its twin. + let mut bare = pcb.clone(); + bare.traces.retain(|t| t.net != net); + let vsession = RouteSession::from_pcb(&bare); + + let on_run = |t: &Trace| { + t.net == net + && t.layer == layer + && (t.width - width).abs() < 1e-6 + && points.windows(2).any(|w| { + ((t.start - w[0]).length() < 1e-6 && (t.end - w[1]).length() < 1e-6) + || ((t.start - w[1]).length() < 1e-6 && (t.end - w[0]).length() < 1e-6) + }) + }; + + // Amplitude retry loop. `generate_meanders_checked` only clearance- + // checks meander WAYPOINTS, so a bend can pass its check while the + // copper between two waypoints crosses an obstacle. Validate each + // candidate with the exact oracle instead and shrink the amplitude + // until one is genuinely legal. + let mut amplitude = 1.2; + for _ in 0..6 { + let params = LengthTuneParams { + target_length: run_len + deficit, + max_amplitude: amplitude, + spacing: 0.5, + style: MeanderStyle::Trombone, + }; + let meanders = generate_meanders_checked(&points, ¶ms, clearance, &obstacles); + amplitude *= 0.75; + let Some(meanders) = meanders else { continue }; + if meanders.is_empty() { + continue; + } + // Splice the meander waypoints into the run. + let mut tuned: Vec = Vec::new(); + for (i, w) in points.windows(2).enumerate() { + tuned.push(w[0]); + if let Some(m) = meanders.iter().find(|m| m.segment_index == i) { + tuned.extend(m.points.iter().copied()); + } + } + tuned.push(*points.last().unwrap()); + let legal = tuned.windows(2).all(|w| { + vsession + .probe( + &crate::spatial::CopperGeom::Segment { + a: w[0], + b: w[1], + half_w: width / 2.0, + }, + layer, + net, + clearance, + ) + .legal + }); + if !legal { + continue; + } + let mut work = pcb.clone(); + work.traces.retain(|t| !on_run(t)); + work.traces.extend(tuned.windows(2).map(|w| Trace { + start: w[0], + end: w[1], + width, + layer, + net: net.to_string(), + source: None, + })); + return Some(work); + } + } + None +} + +/// Meander the shorter leg of any pair still outside `tolerance` of its twin. +/// +/// The last stage of [`si_finish`], and the one that closes +/// `worst_intra_pair_skew`. A coupled pair's two legs are offsets of one +/// centerline, so they match by construction — the residual skew lives in the +/// pad breakout connectors, which are independent maze routes and can differ +/// by millimetres. Descent cannot fix that: on a well-coupled pair the gap +/// springs and clearance hinges both resist moving the run, which is exactly +/// why it rejects these. Adding the deficit back as a meander is the standard +/// answer and the one the human board uses. +/// +/// Fail-closed like every other stage: tuned copper is re-probed against the +/// exact oracle with the net's old copper removed, and anything illegal or +/// non-improving is dropped. +fn meander_pair_skew(pcb: &mut Pcb, tolerance: f64) -> (usize, usize) { + let nets: Vec = { + let mut v: std::collections::BTreeSet = Default::default(); + for f in &pcb.footprints { + for pad in &f.pads { + if let Some(n) = &pad.net { + if !n.is_empty() { + v.insert(n.clone()); + } + } + } + } + v.into_iter().collect() + }; + let classifier = classes::classify_nets(&nets); + let (mut fixed, mut over) = (0usize, 0usize); + for (pn, nn) in &classifier.pairs { + let lp = length_match::net_routed_length(pcb, pn); + let ln = length_match::net_routed_length(pcb, nn); + if lp <= 0.0 || ln <= 0.0 { + continue; + } + let before = (lp - ln).abs(); + if before <= tolerance { + continue; + } + // Which leg is short, and by how much. + let (short_net, deficit) = if lp < ln { (pn, ln - lp) } else { (nn, lp - ln) }; + let Some(work) = compensate_run(pcb, short_net, deficit) else { + log::debug!("si-finish: {pn} skew {before:.3}mm — no run could absorb {deficit:.3}mm"); + over += 1; + continue; + }; + let after = (length_match::net_routed_length(&work, pn) + - length_match::net_routed_length(&work, nn)) + .abs(); + if after >= before { + over += 1; + continue; + } + // Exact oracle over every new segment. Only the net under test has + // its own copper removed — the TWIN stays in the obstacle set, since + // a meander that swings into its partner is exactly the failure this + // stage could otherwise introduce (the session applies the declared + // pair gap to it rather than the base clearance). + let legal = [pn, nn].iter().all(|net| { + let mut bare = work.clone(); + bare.traces.retain(|t| t.net != **net); + let vsession = RouteSession::from_pcb(&bare); + work.traces + .iter() + .filter(|t| t.net == **net) + .all(|t| { + vsession + .probe( + &crate::spatial::CopperGeom::Segment { + a: t.start, + b: t.end, + half_w: t.width / 2.0, + }, + t.layer, + &t.net, + vsession.clearance_for(&t.net), + ) + .legal + }) + }); + if !legal { + log::debug!("si-finish: {pn} meander rejected by oracle"); + over += 1; + continue; + } + *pcb = work; + fixed += 1; + log::info!("si-finish: meandered {pn} skew {before:.3} -> {after:.3} mm"); + if after > tolerance { + over += 1; + } + } + (fixed, over) +} + +/// Signal-integrity finishing pass for a fully routed board. +/// +/// The two claims that judge differential pairs — `min_pair_coupled_fraction` +/// and `worst_intra_pair_skew` — are worst-case over EVERY routed pair, so a +/// single bad pair breaks them however good the rest is. Detailed routing +/// optimizes for completion, not for those, and leaves a tail. This pass +/// works that tail in the order that matters: +/// +/// 1. [`polish_pairs`] rips each still-uncoupled pair off the settled board +/// and re-routes it coupled, ripping crossing single-ended traces out of +/// its corridor and re-routing them after. This is *reroute*-then-descend: +/// descent is a local optimizer, so a pair that started far off-target has +/// to be re-routed before descending it is worth anything. +/// 2. [`descend_board`] then drives residual intra-pair skew out of the +/// geometry that is already close. +/// 3. [`meander_pair_skew`] meanders whatever skew survives — the breakout +/// mismatch descent structurally cannot reach. +/// +/// Every stage is strictly non-regressive and gated on the exact oracle: a +/// pair that fails restores its original copper, so this can only improve the +/// board or leave it alone. +pub fn si_finish(pcb: &mut Pcb, expansions: usize, descent_iters: usize) -> SiFinishReport { + let (polished, polish_attempted) = polish_pairs(pcb, expansions); + log::info!("si-finish: polish re-coupled {polished}/{polish_attempted} pairs"); + let descent = descend_board(pcb, descent_iters); + log::info!( + "si-finish: descent tuned {}/{} pairs ({} rejected)", + descent.tuned, + descent.attempted, + descent.rejected + ); + // Tolerance well inside the 1.1mm claim bound: the claim is a maximum + // over every pair, so leaving a pair at 1.05mm is one reroute away from + // breaking it. + let (meandered, over_tolerance) = meander_pair_skew(pcb, 0.6); + log::info!("si-finish: meandered {meandered} pairs, {over_tolerance} still over tolerance"); + SiFinishReport { + polished, + polish_attempted, + descent, + meandered, + over_tolerance, + } +} use vcad_ir::ecad::{Pcb, PcbLayer}; use vcad_ir::Vec2; diff --git a/crates/vcad-ecad-pcb/src/router/pair.rs b/crates/vcad-ecad-pcb/src/router/pair.rs index 4f95ad36b..54f752435 100644 --- a/crates/vcad-ecad-pcb/src/router/pair.rs +++ b/crates/vcad-ecad-pcb/src/router/pair.rs @@ -26,6 +26,14 @@ use super::maze::route_net_maze3d; /// - a bare polarity token after a final `.`: `/X.P` ↔ `/X.N` /// - DDR true/complement `_C` ↔ `_T` tokens, as a suffix or mid-name /// (`DQS1_C_B` ↔ `DQS1_T_B`, `CK_C` ↔ `CK_T`) +/// - the USB `DP` ↔ `DM` (and `D+` ↔ `D-`) convention, which is not a +/// symmetric suffix: `/USB3-0.DP` ↔ `/USB3-0.DM` +/// +/// This is the single source of truth for pair membership — [`super::classes`] +/// builds the diff-pair class from it, and this stage routes what that class +/// declares. When the two disagreed, the classifier put USB pairs in the class +/// and this function then reported `NoPartnerName`, so every USB pair fell +/// straight through to independent single routing (CM5 census: 3 pairs). /// /// Returns `None` for names with no polarity marker (`GND`, `/GPIO4`). pub(crate) fn pair_partner(net: &str) -> Option { @@ -62,6 +70,15 @@ pub(crate) fn pair_partner(net: &str) -> Option { } } } + // USB `DP`/`DM` (and `D+`/`D-`). Require a separator before the token so + // e.g. "LDP" or a net literally named "DP" does not match. + for (a, b) in [("DP", "DM"), ("DM", "DP"), ("D+", "D-"), ("D-", "D+")] { + if let Some(base) = net.strip_suffix(a) { + if base.ends_with('.') || base.ends_with('_') || base.ends_with('-') { + return Some(format!("{base}{b}")); + } + } + } None } @@ -98,6 +115,30 @@ fn pads_of_net(pcb: &Pcb, net: &str) -> Vec { out } +/// The copper layers the `net` pad at `pos` actually occupies. +/// +/// A pad connector may only *leave* the pad on a layer the pad is on: a +/// connector that starts at the pad's XY on some other layer never touches +/// it electrically, and no clearance check catches that (the copper is +/// same-net, so the probe is happy). This mattered little while coupled legs +/// always began on the outer layer; once they may begin anywhere in the +/// stack, an unconstrained connector start silently disconnects the net. +fn pad_layers_at(pcb: &Pcb, net: &str, pos: Vec2) -> Vec { + let mut best: Option<(f64, Vec)> = None; + for fp in &pcb.footprints { + for pad in &fp.pads { + if pad.net.as_deref() != Some(net) { + continue; + } + let d = dist(crate::geometry::pad_world_position(fp, pad), pos); + if best.as_ref().map(|(bd, _)| d < *bd).unwrap_or(true) { + best = Some((d, pad.layers.clone())); + } + } + } + best.map(|(_, l)| l).unwrap_or_default() +} + /// The pad of `pads` nearest to `p`. fn nearest_pad(pads: &[Vec2], p: Vec2) -> Option { pads.iter() @@ -105,6 +146,54 @@ fn nearest_pad(pads: &[Vec2], p: Vec2) -> Option { .min_by(|a, b| dist(*a, p).partial_cmp(&dist(*b, p)).unwrap()) } +/// Why a coupled-pair attempt gave up. +/// +/// The bail census — histogram these across a board's pairs, kill the +/// dominant mode, re-census — is the method that has driven this stage's +/// coupled fraction up (censuses 9-17 are quoted throughout this file). The +/// reasons are typed rather than log-only so the census is a measurement, +/// not a grep. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum PairBail { + /// The net name carries no polarity marker — not a pair at all. + NoPartnerName, + /// The partner name resolves but no pad on the board carries it. + PartnerNoPads, + /// Both partner endpoints snap to the same pad. + PartnerEndpointsCollapse, + /// The span is too short to fit leads plus a coupled run. + SpanTooShort, + /// The phantom fat-trace centerline search failed on every retreat rung. + CenterlineSearch, + /// The centerline could not be offset into two legs (degenerate, + /// interior cusp, or the in-line via gate). + DegenerateCenterline, + /// A realized leg failed the exact oracle. + LegValidation, + /// A pad connector (breakout stub) could not reach its leg. + Connector, +} + +impl PairBail { + /// Short stable slug for census tables. + pub fn slug(self) -> &'static str { + match self { + PairBail::NoPartnerName => "no-partner-name", + PairBail::PartnerNoPads => "partner-no-pads", + PairBail::PartnerEndpointsCollapse => "partner-endpoints-collapse", + PairBail::SpanTooShort => "span-too-short", + PairBail::CenterlineSearch => "centerline-search", + PairBail::DegenerateCenterline => "degenerate-centerline", + PairBail::LegValidation => "leg-validation", + PairBail::Connector => "connector", + } + } +} + +/// A polyline of routed copper: each segment with the layer it sits on. +/// Used for the phantom centerline before it is offset into two legs. +type Centerline = Vec<(Vec2, Vec2, PcbLayer)>; + /// One realized leg of the pair: its segments, vias, and endpoints. struct Leg { segments: Vec<(Vec2, Vec2, PcbLayer)>, @@ -136,19 +225,46 @@ pub(super) fn try_route_pair( cong: &Congestion, max_expansions: usize, ) -> Option<(Placed, Placed)> { - let partner = pair_partner(net)?; + try_route_pair_reason( + session, + pcb, + width, + net, + from, + to, + placed, + cong, + max_expansions, + ) + .ok() +} + +/// [`try_route_pair`] reporting *why* it gave up — the census entry point. +#[allow(clippy::too_many_arguments)] +pub(super) fn try_route_pair_reason( + session: &mut RouteSession, + pcb: &Pcb, + width: f64, + net: &str, + from: Vec2, + to: Vec2, + placed: &mut Vec, + cong: &Congestion, + max_expansions: usize, +) -> Result<(Placed, Placed), PairBail> { + let partner = pair_partner(net).ok_or(PairBail::NoPartnerName)?; let partner_pads = pads_of_net(pcb, &partner); if partner_pads.is_empty() { log::debug!("pair: {net} has partner name {partner} but no pads on board"); - return None; + return Err(PairBail::PartnerNoPads); } // MST-similar partner endpoints: the partner pads nearest to this // connection's own endpoints. They must be two distinct pads. - let p_from = nearest_pad(&partner_pads, from)?; - let p_to = nearest_pad(&partner_pads, to)?; + let p_from = nearest_pad(&partner_pads, from).ok_or(PairBail::PartnerNoPads)?; + let p_to = nearest_pad(&partner_pads, to).ok_or(PairBail::PartnerNoPads)?; if dist(p_from, p_to) < 1e-6 { log::debug!("pair: {net}/{partner}: partner endpoints collapse to one pad — bailing"); - return None; + return Err(PairBail::PartnerEndpointsCollapse); } let (w, gap) = pair_geometry(session, pcb, net, width); @@ -163,7 +279,6 @@ pub(super) fn try_route_pair( let voff_max = via_off.max(via_d / 2.0 + 1.5 * clearance + w / 2.0 - half_sep); let fat_w = (2.0 * w + gap).max(2.0 * (voff_max + via_d / 2.0)); let copper = copper_layers(pcb); - let first_layer = *copper.first().unwrap_or(&PcbLayer::FCu); // Centerline endpoints: the midpoints, pushed toward each other by a lead // so the fat phantom clears the four pads (the pads are other-net copper @@ -174,7 +289,7 @@ pub(super) fn try_route_pair( let lead = (fat_w + clearance + w).min(span * 0.25); if span < 4.0 * lead.max(0.1) { log::debug!("pair: {net}/{partner}: span {span:.2}mm too short for coupled routing"); - return None; + return Err(PairBail::SpanTooShort); } let dir = { let d = mid_to - mid_from; @@ -247,10 +362,60 @@ pub(super) fn try_route_pair( // in the open. let budget = max_expansions.max(100_000); let mut found = None; - // Asymmetric ladder: most pairs have only ONE end in a pin field, so - // necking both ends symmetrically wastes coupled length and misses the - // cases where only one side needs the retreat. - for (r_from, r_to) in [ + let usable_span = span - 2.0 * lead; + // MEASURED neck-down (census 18). The fixed table below guesses how far + // to retreat; on the CM5 that guess is the single dominant bail — 23 of + // 27 remaining failures — because a pair born at the centre of a large + // BGA needs more retreat than the table's largest rung that still fits + // the span. Probe instead: walk each end inward until a fat capsule is + // actually legal somewhere in the stack, and start the ladder there. This + // costs point probes, not searches, and it is exact — no capsule fits + // before that point, so every rung the table spends below it is wasted. + let fat_legal_at = |p: Vec2| -> bool { + copper.iter().any(|&l| { + session + .probe( + &CopperGeom::Segment { + a: p, + b: p, + half_w: fat_w / 2.0, + }, + l, + net, + clearance, + ) + .legal + }) + }; + let measure_inset = |from_start: bool| -> f64 { + let limit = (usable_span * 0.45).max(0.0); + let mut r = 0.0; + while r <= limit { + let p = if from_start { + start + dir.scale(r) + } else { + end - dir.scale(r) + }; + if fat_legal_at(p) { + return r; + } + r += 0.5; + } + 0.0 + }; + let (m_from, m_to) = (measure_inset(true), measure_inset(false)); + if m_from > 0.0 || m_to > 0.0 { + log::debug!("pair: {net}/{partner}: measured neck-down {m_from:.1}/{m_to:.1}mm"); + } + // Measured rungs first (they are the only ones that can succeed at all + // near a dense field), then escalating slack around them, then the + // original asymmetric table as a backstop for the cases the point probe + // clears but the corridor does not. + let mut rungs: Vec<(f64, f64)> = Vec::new(); + for extra in [0.0, 1.0, 2.0, 4.0, 8.0] { + rungs.push((m_from + extra, m_to + extra)); + } + rungs.extend([ (0.0, 0.0), (1.0, 0.0), (0.0, 1.0), @@ -266,22 +431,30 @@ pub(super) fn try_route_pair( (8.0, 8.0), (12.0, 12.0), (16.0, 16.0), - ] { + ]); + for (r_from, r_to) in rungs { let usable = span - 2.0 * lead; if r_from + r_to >= usable - 1.0 { continue; } let s_pt = start + dir.scale(r_from); let e_pt = end - dir.scale(r_to); + // Endpoint layers: the WHOLE stack, not just the outer layer. A pair + // escaping a BGA goes down a via and couples on an inner layer under + // the field, where there are no pads at all — that is how the human + // board does it. Pinning the phantom's ends to FCu instead forced the + // coupled run to begin inside the pin field, where no fat capsule can + // ever sit; the pad connectors already route across the full stack to + // reach `leg.first_layer`, so this costs nothing and matches them. let r = route_net_maze3d( session, &pcb.outline.vertices, &copper, net, s_pt, - &[first_layer], + &copper, e_pt, - &[first_layer], + &copper, fat_w, via_d, Some(cong), @@ -302,25 +475,136 @@ pub(super) fn try_route_pair( break; } } - let Some(r) = found else { + // Ladder 2 — narrow centerline with a MEASURED coupled window. + // + // The bail census says the fat search above is the dominant failure mode + // by an order of magnitude (CM5: 28 of 32 bails, and 48 of 56 in a full + // route). The reason is structural, not budgetary: the phantom is + // `2w + gap` = 0.66mm wide, and the channels between 0.4mm-pitch BGA pads + // are single-track. No retreat rung helps when BOTH ends sit in pin + // fields, because the ladder is *guessing* how far to neck down. + // + // So: search a NARROWER centerline — one that threads what a single + // thread — then discover the coupled extent instead of guessing it. Offset + // the centerline into two legs, probe both legs per centerline segment, + // and keep the longest contiguous window where both are legal. That window + // is the real coupled run; the pad connectors cover the necked ends, and + // the amount of neck-down is measured rather than drawn from a 15-rung + // table. Widths descend so a wider (better-coupled) corridor always wins + // when one exists; the best trimmed run across the ladder is kept. + let mut center: Option = found.map(|r| r.segments); + if center.is_none() { + // Minimum worthwhile coupling: below this the pair is better off as + // two singles, so we fall through rather than commit a token stub. + let min_coupled = (0.5 * span).max(1.0); + let mut best: Option<(f64, Centerline)> = None; + for cw in [ + 0.75 * fat_w, + 0.5 * fat_w, + w, + pcb.rules.default_rules.trace_width, + ] { + if cw < pcb.rules.default_rules.trace_width - 1e-9 { + continue; + } + let r = route_net_maze3d( + session, + &pcb.outline.vertices, + &copper, + net, + start, + &copper, + end, + &copper, + cw, + via_d, + Some(cong), + budget, + 1.0, + None, + &[], + &[], + true, + ); + if !r.success || r.segments.is_empty() { + if cw <= pcb.rules.default_rules.trace_width + 1e-9 { + // Endpoint diagnosis: a search that fails even at single + // width on every layer is usually an illegal endpoint, not + // a blocked corridor. + let probe_pt = |p: Vec2| -> usize { + copper + .iter() + .filter(|&&l| { + session + .probe( + &CopperGeom::Segment { + a: p, + b: p, + half_w: cw / 2.0, + }, + l, + net, + clearance, + ) + .legal + }) + .count() + }; + log::debug!( + "pair: {net}/{partner}: narrow search failed at w={cw}; start legal on {}/{} layers, end on {}/{}", + probe_pt(start), + copper.len(), + probe_pt(end), + copper.len() + ); + } + continue; + } + let Some((i, j)) = longest_coupled_window(session, &r.segments, net, half_sep, w) + else { + continue; + }; + let win = &r.segments[i..=j]; + let len: f64 = win.iter().map(|&(a, b, _)| dist(a, b)).sum(); + if len < min_coupled { + continue; + } + if best.as_ref().map(|(bl, _)| len > *bl).unwrap_or(true) { + best = Some((len, win.to_vec())); + } + } + if let Some((len, win)) = best { + log::debug!( + "pair: {net}/{partner}: narrow centerline coupled {len:.1}mm of {span:.1}mm span" + ); + center = Some(win); + } + } + let Some(center) = center else { log::debug!("pair: {net}/{partner}: phantom centerline search failed"); restore(session, placed, ripped); - return None; + return Err(PairBail::CenterlineSearch); }; // Realize the two legs from the centerline. let (leg_a, leg_b) = - match realize_legs(&r.segments, half_sep, via_off, via_d / 2.0, clearance, w) { + match realize_legs(¢er, half_sep, via_off, via_d / 2.0, clearance, w) { Some(l) => l, None => { log::debug!("pair: {net}/{partner}: degenerate centerline — bailing"); restore(session, placed, ripped); - return None; + return Err(PairBail::DegenerateCenterline); } }; // Leg → net assignment: the one whose pad connectors are shortest (the // non-crossing assignment — crossing connectors are strictly longer). + // + // Scoring these two choices on PREDICTED intra-pair skew instead was + // tried and measured worse (subset board: 1.499mm -> 2.100mm worst skew). + // The prediction has to model the connectors as straight stubs, but they + // are maze routes that detour around whatever is in the way, so the + // ranking it produces is mostly noise. let cost = |leg: &Leg, s: Vec2, e: Vec2| dist(s, leg.first) + dist(e, leg.last); let a_mine = cost(&leg_a, from, to) + cost(&leg_b, p_from, p_to) <= cost(&leg_b, from, to) + cost(&leg_a, p_from, p_to); @@ -349,7 +633,12 @@ pub(super) fn try_route_pair( if dist(from, to) <= 1e-9 { return Some((vec![], vec![])); } - if dist(from, to) <= nw * 2.0 { + // Straight-hop shortcut, but ONLY when the leg's layer is a layer the + // pad is on — otherwise this emits a short segment that starts at the + // pad's XY on a layer the pad does not occupy and connects nothing. + let pad_layers = pad_layers_at(pcb, net, from); + let hop_ok = pad_layers.is_empty() || pad_layers.contains(&to_layer); + if hop_ok && dist(from, to) <= nw * 2.0 { return Some((vec![(from, to, to_layer)], vec![])); } let margin = 4.0 + w; @@ -376,13 +665,27 @@ pub(super) fn try_route_pair( ) }) .collect(); + // Leave the pad only on a layer the pad is actually on; a via in the + // connector carries it to the leg's layer. + let from_layers: Vec = { + let on_board: Vec = pad_layers + .iter() + .copied() + .filter(|l| copper.contains(l)) + .collect(); + if on_board.is_empty() { + copper.clone() + } else { + on_board + } + }; let r = route_net_maze3d( session, &pcb.outline.vertices, &copper, net, from, - &copper, + &from_layers, to, &[to_layer], nw, @@ -432,7 +735,7 @@ pub(super) fn try_route_pair( else { log::debug!("pair: {net}/{partner}: leg 1 failed validation"); restore(session, placed, ripped); - return None; + return Err(PairBail::LegValidation); }; let Some(mut placed_theirs) = validate_and_commit( session, @@ -445,7 +748,7 @@ pub(super) fn try_route_pair( session.remove(sp); } restore(session, placed, ripped); - return None; + return Err(PairBail::LegValidation); }; // Connectors, all four against both committed legs. Committed as thin // copper directly into each leg's Placed (stubs channel). @@ -513,16 +816,73 @@ pub(super) fn try_route_pair( || !attach(session, &mut placed_theirs, &theirs, p_from, p_to) { rollback_all(session, placed, &placed_mine, &placed_theirs, ripped); - return None; + return Err(PairBail::Connector); } - let center_len: f64 = r.segments.iter().map(|(a, b, _)| dist(*a, *b)).sum(); + let center_len: f64 = center.iter().map(|(a, b, _)| dist(*a, *b)).sum(); log::info!( "pair: routed {net} + {partner} coupled (centerline {:.1}mm, {} via pair(s), w={w} gap={gap})", center_len, placed_mine.via_pts.len(), ); - Some((placed_mine, placed_theirs)) + Ok((placed_mine, placed_theirs)) +} + +/// The longest contiguous run of `center` (as an inclusive segment index +/// range) over which BOTH offset legs probe legal at the pair's leg width. +/// +/// This is what makes a narrow centerline usable: the search finds a path a +/// single-width trace fits, and this measures how much of that path actually +/// has room for the full coupled pair. Segments whose offsets collide with +/// pads or foreign copper — the pin-field breakout at each end — fall outside +/// the window and become neck-down connectors. +fn longest_coupled_window( + session: &RouteSession, + center: &[(Vec2, Vec2, PcbLayer)], + net: &str, + half_sep: f64, + w: f64, +) -> Option<(usize, usize)> { + let clearance = session.clearance_for(net); + let legal = |k: usize| -> bool { + let (a, b, layer) = center[k]; + let d = b - a; + let len = dist(a, b); + if len < 1e-9 { + return false; + } + // Perpendicular offset of this segment, both sides. + let nrm = Vec2::new(-d.y / len, d.x / len); + [half_sep, -half_sep].iter().all(|&off| { + let o = nrm.scale(off); + let geom = CopperGeom::Segment { + a: a + o, + b: b + o, + half_w: w / 2.0, + }; + session.probe(&geom, layer, net, clearance).legal + }) + }; + let (mut best, mut run_start): (Option<(usize, usize)>, Option) = (None, None); + for k in 0..center.len() { + if legal(k) { + run_start.get_or_insert(k); + } else if let Some(s) = run_start.take() { + let cand = (s, k - 1); + if best.map(|(bs, be)| cand.1 - cand.0 > be - bs).unwrap_or(true) { + best = Some(cand); + } + } + } + if let Some(s) = run_start { + let cand = (s, center.len() - 1); + if best.map(|(bs, be)| cand.1 - cand.0 > be - bs).unwrap_or(true) { + best = Some(cand); + } + } + // A single segment cannot carry a coupled run through realize_legs + // (which needs at least two points per layer run after simplification). + best.filter(|(s, e)| e > s) } /// Offset the centerline into two legs at ±`half_sep`, with per-leg vias offset @@ -721,6 +1081,178 @@ fn realize_legs( Some((leg(1.0)?, leg(-1.0)?)) } +/// One pair's outcome in a [`census_pairs`] run. +#[derive(Debug, Clone)] +pub struct PairCensusRow { + /// Positive leg net name. + pub net_p: String, + /// Negative leg net name. + pub net_n: String, + /// Crow-flight distance between the P net's two farthest pads (mm). + pub span_mm: f64, + /// `None` when the pair routed coupled, else why it bailed. + pub bail: Option, + /// Coupled fraction the committed copper achieved (successes only) — + /// the quantity `min_pair_coupled_fraction` actually judges. A pair can + /// route "coupled" and still score badly here when a long neck-down + /// retreat leaves most of its length in uncoupled breakout stubs. + pub coupled_fraction: f64, +} + +/// Census of a board's coupled-pair construction. +#[derive(Debug, Clone, Default)] +pub struct PairCensus { + /// Per-pair outcomes, in attempt order. + pub rows: Vec, +} + +impl PairCensus { + /// Pairs that routed coupled. + pub fn coupled(&self) -> usize { + self.rows.iter().filter(|r| r.bail.is_none()).count() + } + /// Pairs that routed coupled AND cleared `min_frac` coupled fraction — + /// the count that actually helps the receipt claim. + pub fn coupled_above(&self, min_frac: f64) -> usize { + self.rows + .iter() + .filter(|r| r.bail.is_none() && r.coupled_fraction >= min_frac) + .count() + } + /// Bail histogram, most frequent first. + pub fn histogram(&self) -> Vec<(PairBail, usize)> { + let mut counts: std::collections::BTreeMap = Default::default(); + for r in &self.rows { + if let Some(b) = r.bail { + *counts.entry(b).or_default() += 1; + } + } + let mut v: Vec<(PairBail, usize)> = counts.into_iter().collect(); + v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + v + } +} + +/// Run the pair-first stage in isolation on `pcb` with all routed copper +/// stripped, and report why each classified pair did or did not construct +/// coupled. +/// +/// This is the fast development loop behind lever 1: the full board takes +/// hours to route, but the pair stage sees a near-empty board in round 0 +/// anyway, so censusing it standalone measures the same thing in seconds — +/// and isolates *geometry and logic* failures from congestion (on an empty +/// board, congestion is not the explanation). +pub fn census_pairs(pcb: &Pcb, max_expansions: usize) -> PairCensus { + let mut board = pcb.clone(); + board.traces.clear(); + board.trace_arcs.clear(); + board.vias.clear(); + + let nets: Vec = { + let mut v: std::collections::BTreeSet = Default::default(); + for f in &board.footprints { + for pad in &f.pads { + if let Some(n) = &pad.net { + if !n.is_empty() { + v.insert(n.clone()); + } + } + } + } + v.into_iter().collect() + }; + let classifier = super::classes::classify_nets(&nets); + super::classes::apply_classes(&mut board, &classifier); + let width = board.rules.default_rules.trace_width; + + let mut session = RouteSession::from_pcb(&board); + let mut placed: Vec = Vec::new(); + let cong = Congestion::new(&board.outline.vertices); + let mut census = PairCensus::default(); + + for (pn, nn) in &classifier.pairs { + let p_pads = pads_of_net(&board, pn); + if p_pads.len() < 2 { + continue; + } + // MST endpoints: the two farthest pads of the P net (the same + // endpoints polish_pairs uses). + let (mut from, mut to, mut best) = (p_pads[0], p_pads[0], -1.0); + for i in 0..p_pads.len() { + for j in i + 1..p_pads.len() { + let d = dist(p_pads[i], p_pads[j]); + if d > best { + best = d; + from = p_pads[i]; + to = p_pads[j]; + } + } + } + let before = placed.len(); + let outcome = try_route_pair_reason( + &mut session, + &board, + width, + pn, + from, + to, + &mut placed, + &cong, + max_expansions, + ); + let (bail, frac) = match outcome { + Ok((mine, theirs)) => { + // Measure the committed copper the way the receipt does. + let (w, gap) = pair_geometry(&session, &board, pn, width); + let mut probe = board.clone(); + for pl in [&mine, &theirs] { + for &(a, b, l) in &pl.segments { + probe.traces.push(Trace { + start: a, + end: b, + width: pl.width, + layer: l, + net: pl.net.clone(), + source: None, + }); + } + for &(a, b, l) in &pl.stubs { + probe.traces.push(Trace { + start: a, + end: b, + width: pl.stub_width, + layer: l, + net: pl.net.clone(), + source: None, + }); + } + } + let f = crate::router::si_claims::coupled_fraction( + &probe, + pn, + nn, + (w + gap) * 1.75, + ); + placed.push(mine); + placed.push(theirs); + (None, f) + } + Err(b) => { + debug_assert_eq!(placed.len(), before, "failed pair must commit nothing"); + (Some(b), 0.0) + } + }; + census.rows.push(PairCensusRow { + net_p: pn.clone(), + net_n: nn.clone(), + span_mm: best, + bail, + coupled_fraction: frac, + }); + } + census +} + /// Pair polish: on a FINISHED board, rip each still-uncoupled pair and /// re-route it coupled against the settled copper. The board is quiet, the /// pair gets a focused high-effort attempt, and failure restores the @@ -1023,6 +1555,15 @@ mod tests { ); assert_eq!(pair_partner("USB_D_P").as_deref(), Some("USB_D_N")); assert_eq!(pair_partner("CK_C").as_deref(), Some("CK_T")); + // USB DP/DM: the classifier declares these as pairs, so this must + // agree or every USB pair falls through to independent singles. + assert_eq!(pair_partner("/USB3-0.DP").as_deref(), Some("/USB3-0.DM")); + assert_eq!(pair_partner("/USB3-0.DM").as_deref(), Some("/USB3-0.DP")); + assert_eq!(pair_partner("/USBC.DP").as_deref(), Some("/USBC.DM")); + assert_eq!(pair_partner("USB_D+").as_deref(), Some("USB_D-")); + // Separator required: a name merely ENDING in "DP" is not a pair. + assert_eq!(pair_partner("LDP"), None); + assert_eq!(pair_partner("DP"), None); // Negatives: no polarity marker. assert_eq!(pair_partner("GND"), None); assert_eq!(pair_partner("/GPIO4"), None); @@ -1431,6 +1972,147 @@ mod tests { ); } + /// The SI finishing pass is non-regressive and stays DRC-clean. + /// + /// `si_finish` rewrites committed copper (polish rips and re-routes whole + /// pairs; descent moves polyline interiors), so its contract is the one + /// thing every caller relies on: it may improve the pair claims or do + /// nothing, but it must never leave the board worse or dirtier than it + /// found it. Both stages are individually oracle-gated — this pins the + /// composition. + #[test] + fn si_finish_is_non_regressive_and_drc_clean() { + use super::super::auto::{route_all_with_opts, RouteOptions}; + let mut pcb = pair_board(); + let r = route_all_with_opts(&pcb, 0.25, &[], &RouteOptions::default()); + assert_eq!(r.unrouted_nets.len(), 0, "both legs must route"); + for t in &r.traces { + pcb.traces.push(Trace { + start: t.start, + end: t.end, + width: t.width, + layer: t.layer, + net: t.net.clone(), + source: None, + }); + } + for v in &r.vias { + pcb.vias.push(Via { + position: v.position, + diameter: pcb.rules.default_rules.via_diameter, + drill: pcb.rules.default_rules.via_drill, + start_layer: v.start_layer, + end_layer: v.end_layer, + net: v.net.clone(), + source: None, + }); + } + let clearance_errs = |p: &Pcb| -> usize { + crate::drc::check_drc(p) + .iter() + .filter(|v| { + matches!(v.rule, crate::drc::DrcRuleType::Clearance) + && matches!(v.severity, crate::drc::DrcSeverity::Error) + }) + .count() + }; + let before_errs = clearance_errs(&pcb); + let frac = |p: &Pcb| { + crate::router::si_claims::coupled_fraction(p, "/ETH.2_P", "/ETH.2_N", 0.45 * 1.75) + }; + let before_frac = frac(&pcb); + let before_skew = { + let lp = super::super::length_match::net_routed_length(&pcb, "/ETH.2_P"); + let ln = super::super::length_match::net_routed_length(&pcb, "/ETH.2_N"); + (lp - ln).abs() + }; + + crate::router::si_finish(&mut pcb, 200_000, 500); + + assert!( + clearance_errs(&pcb) <= before_errs, + "si_finish introduced clearance violations" + ); + assert!( + frac(&pcb) >= before_frac - 1e-9, + "coupled fraction regressed: {before_frac:.3} -> {:.3}", + frac(&pcb) + ); + let after_skew = { + let lp = super::super::length_match::net_routed_length(&pcb, "/ETH.2_P"); + let ln = super::super::length_match::net_routed_length(&pcb, "/ETH.2_N"); + (lp - ln).abs() + }; + assert!( + after_skew <= before_skew + 1e-6, + "intra-pair skew regressed: {before_skew:.3} -> {after_skew:.3}" + ); + } + + /// Connectivity contract: every pad of the pair must be left on a layer + /// that pad is actually on. + /// + /// Coupled legs may begin anywhere in the stack (that is how a pair + /// escapes a BGA — down a via, coupled on an inner layer under the + /// field). The pad connectors therefore have to carry the layer change + /// themselves. Nothing else catches a miss: copper starting at the pad's + /// XY on the wrong layer is same-net, so the clearance probe is perfectly + /// happy while the net is electrically open. + #[test] + fn pair_connectors_leave_pads_on_pad_layers() { + let _ = env_logger::builder().is_test(true).try_init(); + let mut pcb = pair_board(); + // Wall across FCu forces the coupled run onto BCu, so the legs no + // longer start on the pads' own layer. + pcb.traces.push(Trace { + start: Vec2::new(25.0, 0.0), + end: Vec2::new(25.0, 30.0), + width: 0.4, + layer: PcbLayer::FCu, + net: "WALL".into(), + source: None, + }); + let mut session = RouteSession::from_pcb(&pcb); + let mut placed: Vec = Vec::new(); + let cong = Congestion::new(&pcb.outline.vertices); + let (mine, theirs) = try_route_pair( + &mut session, + &pcb, + 0.25, + "/ETH.2_P", + Vec2::new(5.0, 15.325), + Vec2::new(45.0, 15.325), + &mut placed, + &cong, + 400_000, + ) + .expect("pair must route across the FCu wall"); + + for pl in [&mine, &theirs] { + let pads = pads_of_net(&pcb, &pl.net); + assert!(!pads.is_empty()); + // All copper this net committed, with its layer. + let copper: Vec<(Vec2, Vec2, PcbLayer)> = pl + .segments + .iter() + .chain(pl.stubs.iter()) + .copied() + .collect(); + for pad in pads { + let pad_layers = pad_layers_at(&pcb, &pl.net, pad); + let touched = copper.iter().any(|&(a, b, l)| { + pad_layers.contains(&l) && (dist(a, pad) < 1e-6 || dist(b, pad) < 1e-6) + }); + assert!( + touched, + "{}: pad ({:.3},{:.3}) is not left on any of its own layers {:?} — \ + the net is electrically open there", + pl.net, pad.x, pad.y, pad_layers + ); + } + } + } + /// Corner transition: walls force the pair through a via field at an /// L-turn — the corner-normal rotation case the straight-wall repro /// cannot exercise. diff --git a/crates/vcad-ecad-pcb/src/router/si_claims.rs b/crates/vcad-ecad-pcb/src/router/si_claims.rs index 07000f650..709642525 100644 --- a/crates/vcad-ecad-pcb/src/router/si_claims.rs +++ b/crates/vcad-ecad-pcb/src/router/si_claims.rs @@ -98,7 +98,11 @@ fn seg_len(a: Vec2, b: Vec2) -> f64 { /// Fraction of `p_net`'s routed length that runs coupled to `n_net`: sampled /// at each P segment midpoint, coupled means some same-layer N segment passes /// within `max_sep` (center-to-center). -pub(crate) fn coupled_fraction(pcb: &Pcb, p_net: &str, n_net: &str, max_sep: f64) -> f64 { +/// +/// Exposed as `router::pair_coupled_fraction` so reports can name the pairs +/// that break `min_pair_coupled_fraction` — the claim is a minimum over every +/// routed pair, so the aggregate alone never says which one to fix. +pub fn coupled_fraction(pcb: &Pcb, p_net: &str, n_net: &str, max_sep: f64) -> f64 { let p_segs: Vec<_> = pcb.traces.iter().filter(|t| t.net == p_net).collect(); let n_segs: Vec<_> = pcb.traces.iter().filter(|t| t.net == n_net).collect(); if p_segs.is_empty() || n_segs.is_empty() { From 66b1135ea2a75691febcfafe676c5d2613b7b47c Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Fri, 24 Jul 2026 23:31:33 -0400 Subject: [PATCH 02/10] =?UTF-8?q?feat(router):=20SI=20finishing=20pass=20?= =?UTF-8?q?=E2=80=94=20reroute,=20descend,=20then=20compensate=20pair=20sk?= =?UTF-8?q?ew?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `router::si_finish`, run at the end of the CM5 pipeline, which works the tail the detailed router leaves behind. The two pair claims are worst-case over EVERY routed pair, so one bad pair breaks them however good the rest is. Three stages, each non-regressive and gated on the exact oracle: 1. `polish_pairs` rips still-uncoupled pairs off the settled board and re-routes them coupled (reroute-then-descend: descent is a local optimizer, so a pair that started far off-target must be re-routed before descending it is worth anything). 2. `descend_board` — the si_descent driver moved into the library so the router and the example cannot drift — drives out residual skew. 3. Phase compensation adds length back to the short leg. Why stage 3 exists: a coupled pair's skew is structural, not sloppy routing. Both legs are offsets of ONE centerline, so at every bend the outer leg takes the long way and the inner the short way, and the difference is `(w + gap) · Σ 2·tan(θ/2)` over signed turns — ~0.9mm for a single net right-angle turn at this board's geometry, against a 1.1mm bound. It cannot be shifted away: a lateral shift moves both legs equally and their difference depends only on the separation. Length has to be added back. A generic meander cannot do it (its bends go into the twin one gap away — "meanders do not fit" refused 4 of 7 pairs), so bumps bulge AWAY from the twin, tiled along the run because one bump on a 17mm straight adds 0.19mm. The two claims genuinely trade against each other — length added to a coupled leg moves it away from its twin, which costs coupled fraction. Measured: at full amplitude, coupling fell 0.777 -> 0.252. Amplitude is therefore capped by the coupling window on leg-width runs, breakout copper keeps the generous amplitude, and a floor keeps every pair well clear of the 0.5 bound. The bounds themselves are untouched. Also fixes a real defect this work uncovered: the descent's fail-closed check dropped BOTH nets' copper before probing, so it could not see a leg drifting into its own partner — the gap spring is a soft term, not a guarantee. Each leg is now checked against the twin at its DESCENDED position (checking new-against-old rejects everything, since the pair moves as a unit), and the gap spring gets the same +20um cushion the clearance hinge already used, so its equilibrium sits outside the hard rule. On the subset board this took clearance violations from 4404 to 77 (7061 -> 2733 total): the shipped descent had been pinching pairs to 0.000mm and no check caught it. Co-Authored-By: Claude Opus 5 --- crates/vcad-ecad-pcb/src/router/mod.rs | 214 +++++++++++++++++++++++-- 1 file changed, 200 insertions(+), 14 deletions(-) diff --git a/crates/vcad-ecad-pcb/src/router/mod.rs b/crates/vcad-ecad-pcb/src/router/mod.rs index 0c9307ae1..3c9dec30d 100644 --- a/crates/vcad-ecad-pcb/src/router/mod.rs +++ b/crates/vcad-ecad-pcb/src/router/mod.rs @@ -87,6 +87,103 @@ pub struct SiFinishReport { pub over_tolerance: usize, } +/// Insert outward bumps into `points` adding about `deficit` mm of length, +/// each bulging AWAY from `twin` copper. +/// +/// This is phase compensation, and it is the only shape that works on a +/// coupled leg. A generic meander generator bends to whichever side its +/// pattern says, which on half the bends is straight into the partner trace +/// sitting one gap away. Bulging away from the twin uses the open board on +/// the far side of the pair instead. +/// +/// It is needed because a coupled pair's skew is not an accident of routing: +/// the two legs are offsets of ONE centerline, so at every bend the outer leg +/// takes the long way round and the inner the short way. The difference is +/// `(w + gap) · Σ 2·tan(θ/2)` over signed turns — about 0.9mm for a single net +/// right-angle turn at this board's 0.2/0.25 geometry, against a 1.1mm claim +/// bound. It cannot be tuned out by shifting the pair sideways either: a +/// lateral shift changes both legs by the same amount and their difference +/// depends only on the total separation. Length has to be added back. +fn bump_away( + points: &[Vec2], + twin: &[(Vec2, Vec2)], + deficit: f64, + amplitude: f64, +) -> Option> { + if points.len() < 2 || deficit <= 0.0 { + return None; + } + let pt_seg = |p: Vec2, a: Vec2, b: Vec2| -> f64 { + let ab = b - a; + let l2 = ab.x * ab.x + ab.y * ab.y; + if l2 < 1e-18 { + return (p - a).length(); + } + let t = (((p.x - a.x) * ab.x + (p.y - a.y) * ab.y) / l2).clamp(0.0, 1.0); + (p - (a + ab.scale(t))).length() + }; + let twin_dist = |p: Vec2| -> f64 { + twin.iter() + .map(|&(a, b)| pt_seg(p, a, b)) + .fold(f64::INFINITY, f64::min) + }; + + // Bumps are TILED along each segment, not one per segment. A single bump + // on a long straight adds almost nothing — its gain is + // 2·(hypot(L/3, h) − L/3), which for a 17mm run at 1.2mm amplitude is + // 0.19mm, nowhere near a 1.3mm deficit. Cells sized to the amplitude keep + // each bump's aspect ratio steep, where the length actually comes from. + let cell = (3.0 * amplitude).max(1.0); + let third = cell / 3.0; + let gain_per_cell = 2.0 * ((third * third + amplitude * amplitude).sqrt() - third); + if gain_per_cell < 1e-3 { + return None; + } + let mut out: Vec = Vec::with_capacity(points.len() * 4); + let mut added = 0.0f64; + for w in points.windows(2) { + let (a, b) = (w[0], w[1]); + out.push(a); + let len = (b - a).length(); + if added >= deficit || len < cell { + continue; + } + let dir = (b - a).scale(1.0 / len); + let nrm = Vec2::new(-dir.y, dir.x); + // Bulge to whichever side is farther from the twin. The twin runs + // parallel to the whole segment, so one decision per segment holds. + let mid = a + dir.scale(len / 2.0); + let sign = if twin_dist(mid + nrm.scale(amplitude)) >= twin_dist(mid - nrm.scale(amplitude)) + { + 1.0 + } else { + -1.0 + }; + let off = nrm.scale(sign * amplitude); + // Leave the last partial cell alone so the bumps stay clear of the + // segment's endpoints (vias, corners, pad breakouts). + let cells = ((len / cell).floor() as usize).saturating_sub(1); + let mut t = cell * 0.5; + for _ in 0..cells { + if added >= deficit { + break; + } + out.push(a + dir.scale(t + third) + off); + out.push(a + dir.scale(t + 2.0 * third) + off); + added += gain_per_cell; + t += cell; + } + } + out.push(*points.last().unwrap()); + // Only worth committing if it closed most of the gap; a partial bump just + // moves the skew around. + if added >= deficit * 0.6 { + Some(out) + } else { + None + } +} + /// Add `deficit` mm to `net` by meandering one of its runs, preferring runs /// that have room. Returns the modified board, or `None` if no run could /// absorb it. @@ -100,10 +197,27 @@ pub struct SiFinishReport { /// uncoupled by construction, and it has open board around it. So candidates /// are ordered narrowest-first, and the coupled legs are tried only as a last /// resort. -fn compensate_run(pcb: &Pcb, net: &str, deficit: f64) -> Option { +fn compensate_run(pcb: &Pcb, net: &str, twin_net: &str, deficit: f64) -> Option { use length_tune::{generate_meanders_checked, LengthTuneParams, MeanderStyle}; use vcad_ir::ecad::Trace; + // Pair geometry, for the coupling-preserving amplitude cap below. + let dp = pcb + .rules + .class_rules + .iter() + .find(|r| r.name == classes::DIFF_PAIR_CLASS); + let leg_w = dp + .and_then(|r| r.diff_pair_width) + .or(pcb.rules.default_rules.diff_pair_width) + .unwrap_or(0.2); + let gap = dp + .and_then(|r| r.diff_pair_gap) + .or(pcb.rules.default_rules.diff_pair_gap) + .unwrap_or(0.25); + let pair_pitch = leg_w + gap; + let max_sep = pair_pitch * 1.75; + // Candidate runs: longest unbranched chain per (layer, width) class. let mut classes: Vec<(PcbLayer, f64)> = pcb .traces @@ -179,7 +293,26 @@ fn compensate_run(pcb: &Pcb, net: &str, deficit: f64) -> Option { // copper between two waypoints crosses an obstacle. Validate each // candidate with the exact oracle instead and shrink the amplitude // until one is genuinely legal. - let mut amplitude = 1.2; + // The twin's copper on this layer, for the away-side decision. + let twin: Vec<(Vec2, Vec2)> = pcb + .traces + .iter() + .filter(|t| t.net == twin_net && t.layer == layer) + .map(|t| (t.start, t.end)) + .collect(); + + // On a COUPLED leg the amplitude is capped by the coupling window: + // `min_pair_coupled_fraction` counts a P sample as coupled only while + // some N copper is within 1.75x the pair pitch, so a bump swinging + // further than that buys skew by destroying coupling — measured, and + // it cost 0.777 -> 0.252 on the subset board. Breakout copper is + // uncoupled already and keeps the generous amplitude. + let is_leg = (width - leg_w).abs() < 0.01; + let mut amplitude = if is_leg { + ((max_sep - pair_pitch) * 0.7).max(0.05) + } else { + 1.2 + }; for _ in 0..6 { let params = LengthTuneParams { target_length: run_len + deficit, @@ -188,20 +321,31 @@ fn compensate_run(pcb: &Pcb, net: &str, deficit: f64) -> Option { style: MeanderStyle::Trombone, }; let meanders = generate_meanders_checked(&points, ¶ms, clearance, &obstacles); + let amp = amplitude; amplitude *= 0.75; - let Some(meanders) = meanders else { continue }; - if meanders.is_empty() { - continue; + // Two candidate shapes per amplitude: the generic meander, and — + // for coupled legs, where the generic one bends into the twin — + // bumps that bulge away from it. + let mut candidates: Vec> = Vec::new(); + if let Some(meanders) = meanders { + if !meanders.is_empty() { + let mut tuned: Vec = Vec::new(); + for (i, w) in points.windows(2).enumerate() { + tuned.push(w[0]); + if let Some(m) = meanders.iter().find(|m| m.segment_index == i) { + tuned.extend(m.points.iter().copied()); + } + } + tuned.push(*points.last().unwrap()); + candidates.push(tuned); + } } - // Splice the meander waypoints into the run. - let mut tuned: Vec = Vec::new(); - for (i, w) in points.windows(2).enumerate() { - tuned.push(w[0]); - if let Some(m) = meanders.iter().find(|m| m.segment_index == i) { - tuned.extend(m.points.iter().copied()); + if !twin.is_empty() { + if let Some(bumped) = bump_away(&points, &twin, deficit, amp) { + candidates.push(bumped); } } - tuned.push(*points.last().unwrap()); + for tuned in candidates { let legal = tuned.windows(2).all(|w| { vsession .probe( @@ -230,6 +374,7 @@ fn compensate_run(pcb: &Pcb, net: &str, deficit: f64) -> Option { source: None, })); return Some(work); + } } } None @@ -277,7 +422,8 @@ fn meander_pair_skew(pcb: &mut Pcb, tolerance: f64) -> (usize, usize) { } // Which leg is short, and by how much. let (short_net, deficit) = if lp < ln { (pn, ln - lp) } else { (nn, lp - ln) }; - let Some(work) = compensate_run(pcb, short_net, deficit) else { + let twin_net = if short_net == pn { nn } else { pn }; + let Some(work) = compensate_run(pcb, short_net, twin_net, deficit) else { log::debug!("si-finish: {pn} skew {before:.3}mm — no run could absorb {deficit:.3}mm"); over += 1; continue; @@ -289,6 +435,33 @@ fn meander_pair_skew(pcb: &mut Pcb, tolerance: f64) -> (usize, usize) { over += 1; continue; } + // The two pair claims pull against each other: length added to a + // coupled leg has to go somewhere, and "somewhere" is away from the + // twin, which costs coupled fraction. Demanding zero loss deadlocks + // (it refused 5 of 9 pairs outright and skew stayed at 1.499mm), so + // spend the slack the claim actually leaves: keep the pair well clear + // of the 0.5 bound — COUPLING_FLOOR is the human board's own margin, + // not a relaxed bound — and let skew take the rest. A pair already + // below the floor may not be pushed lower. + const COUPLING_FLOOR: f64 = 0.7; + let gap_c = pcb.rules.default_rules.diff_pair_gap.unwrap_or(0.25); + let w_c = pcb + .rules + .default_rules + .diff_pair_width + .unwrap_or(pcb.rules.default_rules.trace_width); + let sep = (w_c + gap_c) * 1.75; + let (cf_before, cf_after) = ( + si_claims::coupled_fraction(pcb, pn, nn, sep), + si_claims::coupled_fraction(&work, pn, nn, sep), + ); + if cf_after < COUPLING_FLOOR.min(cf_before) - 1e-9 { + log::debug!( + "si-finish: {pn} compensation would cost coupling ({cf_before:.3} -> {cf_after:.3}) — skipped" + ); + over += 1; + continue; + } // Exact oracle over every new segment. Only the net under test has // its own copper removed — the TWIN stays in the obstacle set, since // a meander that swings into its partner is exactly the failure this @@ -365,7 +538,20 @@ pub fn si_finish(pcb: &mut Pcb, expansions: usize, descent_iters: usize) -> SiFi // Tolerance well inside the 1.1mm claim bound: the claim is a maximum // over every pair, so leaving a pair at 1.05mm is one reroute away from // breaking it. - let (meandered, over_tolerance) = meander_pair_skew(pcb, 0.6); + // + // Iterated: one pass can only absorb what the run's straight stretches + // have room for at one amplitude, so a large deficit closes over several + // passes. Stops as soon as a pass fixes nothing, so a board that is + // already matched pays for exactly one pass. + let (mut meandered, mut over_tolerance) = (0usize, 0usize); + for _ in 0..6 { + let (fixed, over) = meander_pair_skew(pcb, 0.6); + meandered += fixed; + over_tolerance = over; + if fixed == 0 { + break; + } + } log::info!("si-finish: meandered {meandered} pairs, {over_tolerance} still over tolerance"); SiFinishReport { polished, From c9fda03b8aa13744cab38b581aa1410dfda3abe1 Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Sat, 25 Jul 2026 00:47:03 -0400 Subject: [PATCH 03/10] fix(router): gate pair polish on the DRC, and probe the connector shortcut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two paths in the pair stage committed copper the oracle never saw, and both show up as intra-pair clearance violations on a polished board: * `polish_pairs` assembles a board from several separately-probed pieces — the pair's legs, its four breakout connectors, and every displaced single re-routed around it — and nothing re-checked the assembly. Worse, the incremental session probe and the DRC disagree at mitred pair corners: the probe passed legs 0.191mm apart that the DRC rejects against its 0.245mm pair-gap rule. Since the DRC is the standard the receipt is judged by, the gate now runs `check_drc_in_region` over the pair's own bounding box and reverts the pair unless hard clearance/short errors are no worse than before. * The connector's straight-hop shortcut returned copper without probing it at all — the one path in this stage that bypassed the oracle outright. Measured on the CM5 40-net subset: the routed board carried 0 intra-pair clearance violations and the polished board carried 3 (down to 0.017mm against a 0.080mm base clearance). With both fixes the polished board carries 0, at a cost of two pairs that no longer polish (35 -> 33) — the correct trade, and the "strictly non-regressive" contract now holds in DRC terms rather than only in coupling terms. Total board clearance violations are identical with the finishing pass on and off (74), i.e. it introduces none. Co-Authored-By: Claude Opus 5 --- crates/vcad-ecad-pcb/src/router/mod.rs | 27 ++++++++--- crates/vcad-ecad-pcb/src/router/pair.rs | 60 ++++++++++++++++++++++++- 2 files changed, 79 insertions(+), 8 deletions(-) diff --git a/crates/vcad-ecad-pcb/src/router/mod.rs b/crates/vcad-ecad-pcb/src/router/mod.rs index 3c9dec30d..85137bd51 100644 --- a/crates/vcad-ecad-pcb/src/router/mod.rs +++ b/crates/vcad-ecad-pcb/src/router/mod.rs @@ -133,7 +133,11 @@ fn bump_away( // 2·(hypot(L/3, h) − L/3), which for a 17mm run at 1.2mm amplitude is // 0.19mm, nowhere near a 1.3mm deficit. Cells sized to the amplitude keep // each bump's aspect ratio steep, where the length actually comes from. - let cell = (3.0 * amplitude).max(1.0); + // Cell length is tied to the amplitude, not a fixed 1mm: a routed run is a + // polyline of SHORT segments (a maze staircase), so a coarse cell fits no + // bumps at all on most of them and the compensator reports "cannot reach" + // on runs with plenty of copper to work with. + let cell = (3.0 * amplitude).max(0.5); let third = cell / 3.0; let gain_per_cell = 2.0 * ((third * third + amplitude * amplitude).sqrt() - third); if gain_per_cell < 1e-3 { @@ -160,10 +164,13 @@ fn bump_away( -1.0 }; let off = nrm.scale(sign * amplitude); - // Leave the last partial cell alone so the bumps stay clear of the - // segment's endpoints (vias, corners, pad breakouts). - let cells = ((len / cell).floor() as usize).saturating_sub(1); - let mut t = cell * 0.5; + // Keep a quarter-cell margin at each end so bumps stay clear of the + // segment's endpoints (vias, corners, pad breakouts), then fit as many + // whole cells as the remainder allows. + let margin = cell * 0.25; + let usable = len - 2.0 * margin; + let cells = (usable / cell).floor().max(0.0) as usize; + let mut t = margin; for _ in 0..cells { if added >= deficit { break; @@ -341,8 +348,11 @@ fn compensate_run(pcb: &Pcb, net: &str, twin_net: &str, deficit: f64) -> Option< } } if !twin.is_empty() { - if let Some(bumped) = bump_away(&points, &twin, deficit, amp) { - candidates.push(bumped); + match bump_away(&points, &twin, deficit, amp) { + Some(bumped) => candidates.push(bumped), + None => log::trace!( + "compensate {net}: {layer:?} w={width} amp={amp:.3} run={run_len:.2} — bumps cannot reach {deficit:.3}mm" + ), } } for tuned in candidates { @@ -361,6 +371,9 @@ fn compensate_run(pcb: &Pcb, net: &str, twin_net: &str, deficit: f64) -> Option< .legal }); if !legal { + log::trace!( + "compensate {net}: {layer:?} w={width} amp={amp:.3} candidate illegal" + ); continue; } let mut work = pcb.clone(); diff --git a/crates/vcad-ecad-pcb/src/router/pair.rs b/crates/vcad-ecad-pcb/src/router/pair.rs index 54f752435..345a090ad 100644 --- a/crates/vcad-ecad-pcb/src/router/pair.rs +++ b/crates/vcad-ecad-pcb/src/router/pair.rs @@ -639,7 +639,21 @@ pub(super) fn try_route_pair_reason( let pad_layers = pad_layers_at(pcb, net, from); let hop_ok = pad_layers.is_empty() || pad_layers.contains(&to_layer); if hop_ok && dist(from, to) <= nw * 2.0 { - return Some((vec![(from, to, to_layer)], vec![])); + // PROBE the hop before taking it. This shortcut used to emit + // copper unchecked, which is the one path in this stage that + // bypasses the oracle — and it shows up as intra-pair clearance + // violations, because both twins take their own unchecked hop at + // opposite ends of the same gap (CM5: /USB3-1.DP to /USB3-1.DM at + // 0.058mm against a 0.080mm base clearance). Fall through to the + // maze when the straight line is not legal. + let hop = CopperGeom::Segment { + a: from, + b: to, + half_w: nw / 2.0, + }; + if session.probe(&hop, to_layer, net, session.clearance_for(net)).legal { + return Some((vec![(from, to, to_layer)], vec![])); + } } let margin = 4.0 + w; let window = ( @@ -1525,6 +1539,50 @@ pub fn polish_pairs(pcb: &mut Pcb, effort_expansions: usize) -> (usize, usize) { }); } } + // Final fail-closed gate on the assembled board, judged by the + // DRC rather than the session probe. + // + // The stages above each probe their own copper as they place it, + // but `work` is an ASSEMBLY — the pair's legs, its four breakout + // connectors, and every displaced single re-routed around it — + // and nothing re-checks the whole. Worse, the incremental probe + // and the DRC do not agree at mitred pair corners: the probe let + // through legs 0.191mm apart that the DRC rejects against the + // 0.245mm pair-gap rule. The DRC is the standard the receipt is + // judged by, so gate on the DRC, restricted to the pair's own + // bounding box to keep it affordable. Measured on the CM5 subset: + // routed board 0 intra-pair violations, polished board 3. + let (mut lo_b, mut hi_b) = ( + Vec2::new(f64::INFINITY, f64::INFINITY), + Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY), + ); + for t in work.traces.iter().filter(|t| &t.net == pn || &t.net == nn) { + for p in [t.start, t.end] { + lo_b.x = lo_b.x.min(p.x - 1.0); + lo_b.y = lo_b.y.min(p.y - 1.0); + hi_b.x = hi_b.x.max(p.x + 1.0); + hi_b.y = hi_b.y.max(p.y + 1.0); + } + } + let hard_here = |b: &Pcb| -> usize { + if !lo_b.x.is_finite() { + return 0; + } + crate::drc::check_drc_in_region(b, lo_b, hi_b) + .iter() + .filter(|v| { + matches!( + v.rule, + crate::drc::DrcRuleType::Clearance | crate::drc::DrcRuleType::Short + ) && matches!(v.severity, crate::drc::DrcSeverity::Error) + }) + .count() + }; + let pair_legal = hard_here(&work) <= hard_here(pcb); + if !pair_legal { + log::debug!("pair-polish: {pn} assembled board is illegal — reverting"); + continue; + } *pcb = work; polished += 1; log::info!("pair-polish: {pn} + {nn} now coupled"); From 5c1e19f67c96fe3e6054e339a232c2fce9c4ec8c Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Sat, 25 Jul 2026 00:50:05 -0400 Subject: [PATCH 04/10] perf(router): climb the compensation amplitude ladder instead of descending it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The length a run can absorb turns out to be independent of bump amplitude: gain per cell is 2·(hypot(cell/3, h) − cell/3), which at cell = 3h is 0.83·h spread over 3h, so any amplitude yields ~0.28mm per mm of run. Small bumps are therefore strictly better — same capacity, less coupled fraction spent, less chance of hitting a neighbour — so the ladder now starts at 0.06mm and climbs only when a rung cannot reach the deficit. Subset board: 8 -> 17 pairs compensated, coupled fraction 0.730 -> 0.777, worst skew 1.336 -> 1.312mm, still zero intra-pair DRC violations. Co-Authored-By: Claude Opus 5 --- crates/vcad-ecad-pcb/src/router/mod.rs | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/crates/vcad-ecad-pcb/src/router/mod.rs b/crates/vcad-ecad-pcb/src/router/mod.rs index 85137bd51..b1421a114 100644 --- a/crates/vcad-ecad-pcb/src/router/mod.rs +++ b/crates/vcad-ecad-pcb/src/router/mod.rs @@ -137,7 +137,14 @@ fn bump_away( // polyline of SHORT segments (a maze staircase), so a coarse cell fits no // bumps at all on most of them and the compensator reports "cannot reach" // on runs with plenty of copper to work with. - let cell = (3.0 * amplitude).max(0.5); + // Cell length tracks the amplitude, which makes the length a run can + // absorb independent of amplitude: gain per cell is 2·(hypot(cell/3, h) − + // cell/3) = 0.83·h at cell = 3h, over a cell of 3h, so a run yields about + // 0.28mm per mm whatever h is. Small bumps are therefore strictly better + // — same capacity, less coupling lost and less chance of hitting a + // neighbour — which is why the ladder below climbs instead of descending. + // The floor keeps the serpentine from becoming too fine to fabricate. + let cell = (3.0 * amplitude).max(0.3); let third = cell / 3.0; let gain_per_cell = 2.0 * ((third * third + amplitude * amplitude).sqrt() - third); if gain_per_cell < 1e-3 { @@ -315,21 +322,22 @@ fn compensate_run(pcb: &Pcb, net: &str, twin_net: &str, deficit: f64) -> Option< // it cost 0.777 -> 0.252 on the subset board. Breakout copper is // uncoupled already and keeps the generous amplitude. let is_leg = (width - leg_w).abs() < 0.01; - let mut amplitude = if is_leg { + let cap = if is_leg { ((max_sep - pair_pitch) * 0.7).max(0.05) } else { 1.2 }; - for _ in 0..6 { + // Climb: smallest disruption that works wins. + let ladder: [f64; 6] = [0.06, 0.10, 0.16, 0.24, 0.4, 1.2]; + for &rung_amp in ladder.iter() { + let amp = rung_amp.min(cap); let params = LengthTuneParams { target_length: run_len + deficit, - max_amplitude: amplitude, + max_amplitude: amp, spacing: 0.5, style: MeanderStyle::Trombone, }; let meanders = generate_meanders_checked(&points, ¶ms, clearance, &obstacles); - let amp = amplitude; - amplitude *= 0.75; // Two candidate shapes per amplitude: the generic meander, and — // for coupled legs, where the generic one bends into the twin — // bumps that bulge away from it. From 5b223d2511dcf73c33e100657da9cd9820718986 Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Sat, 25 Jul 2026 00:55:45 -0400 Subject: [PATCH 05/10] =?UTF-8?q?fix(router):=20tile=20compensation=20bump?= =?UTF-8?q?s=20by=20arclength=20=E2=80=94=20subset=20board=20now=20ALL=20H?= =?UTF-8?q?OLD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tiling per segment capped the achievable length at one small cell per segment. A routed leg is a simplified maze staircase — many ~0.45mm segments — so that came to ~0.03mm of added length each, and a 10.66mm run reported "cannot reach 1.312mm" while sitting on ample copper. Resampling the path at a fixed arclength step decouples the bump pattern from the original vertices and restores the true capacity: 2·(√2 − 1)/3 ≈ 0.276mm added per mm of run at cell = 3·amplitude, independent of amplitude. Only the prefix needed to absorb the deficit is reworked, so trace count stays bounded. On the CM5 40-net subset this closes the last claim: worst_group_skew 0.000 mm <= 10.0 HOLDS worst_intra_pair_skew 0.983 mm <= 1.1 HOLDS (was 1.499) min_pair_coupled_fraction 0.777 >= 0.5 HOLDS vias_per_si_net 2.727 <= 3.0 HOLDS verdict: ALL HOLD with zero intra-pair DRC violations and a board clearance count identical to the same board routed with the finishing pass disabled (74), i.e. the pass introduces none. Bounds are untouched — the human CM5 still scores Pass. Co-Authored-By: Claude Opus 5 --- crates/vcad-ecad-pcb/src/router/mod.rs | 89 +++++++++++++++----------- 1 file changed, 52 insertions(+), 37 deletions(-) diff --git a/crates/vcad-ecad-pcb/src/router/mod.rs b/crates/vcad-ecad-pcb/src/router/mod.rs index b1421a114..6f7aef5fc 100644 --- a/crates/vcad-ecad-pcb/src/router/mod.rs +++ b/crates/vcad-ecad-pcb/src/router/mod.rs @@ -128,41 +128,46 @@ fn bump_away( .fold(f64::INFINITY, f64::min) }; - // Bumps are TILED along each segment, not one per segment. A single bump - // on a long straight adds almost nothing — its gain is - // 2·(hypot(L/3, h) − L/3), which for a 17mm run at 1.2mm amplitude is - // 0.19mm, nowhere near a 1.3mm deficit. Cells sized to the amplitude keep - // each bump's aspect ratio steep, where the length actually comes from. - // Cell length is tied to the amplitude, not a fixed 1mm: a routed run is a - // polyline of SHORT segments (a maze staircase), so a coarse cell fits no - // bumps at all on most of them and the compensator reports "cannot reach" - // on runs with plenty of copper to work with. - // Cell length tracks the amplitude, which makes the length a run can - // absorb independent of amplitude: gain per cell is 2·(hypot(cell/3, h) − - // cell/3) = 0.83·h at cell = 3h, over a cell of 3h, so a run yields about - // 0.28mm per mm whatever h is. Small bumps are therefore strictly better - // — same capacity, less coupling lost and less chance of hitting a - // neighbour — which is why the ladder below climbs instead of descending. - // The floor keeps the serpentine from becoming too fine to fabricate. + // Bumps are tiled by ARCLENGTH, not per segment. + // + // A routed leg is a polyline of many short segments (a simplified maze + // staircase, ~0.45mm a side). Tiling per segment caps the achievable + // length at one small cell per segment — measured, that is ~0.03mm per + // segment, so a 10.66mm run reported "cannot reach 1.312mm" despite + // having ample copper. Resampling the path at a fixed arclength step + // decouples the pattern from the original vertices and restores the true + // capacity, which is 2·(√2 − 1)/3 ≈ 0.276mm of added length per mm of run + // at cell = 3·amplitude — and, notably, independent of amplitude. let cell = (3.0 * amplitude).max(0.3); - let third = cell / 3.0; - let gain_per_cell = 2.0 * ((third * third + amplitude * amplitude).sqrt() - third); - if gain_per_cell < 1e-3 { + let step = cell / 3.0; + let gain_per_cell = 2.0 * ((step * step + amplitude * amplitude).sqrt() - step); + if gain_per_cell < 1e-4 { + return None; + } + let total: f64 = points.windows(2).map(|w| (w[1] - w[0]).length()).sum(); + if total < 2.0 * cell { return None; } - let mut out: Vec = Vec::with_capacity(points.len() * 4); + // Only rework the prefix needed to absorb the deficit (plus margin); the + // rest of the run keeps its original vertices, so the trace count stays + // bounded. + let need_len = (deficit / (gain_per_cell / cell) * 1.25).min(total); + + let mut out: Vec = vec![points[0]]; let mut added = 0.0f64; + let mut travelled = 0.0f64; + let mut phase = 0usize; // 0 = on path, 1 and 2 = offset (one cell = 3 steps) + let mut carry = 0.0f64; // distance already consumed inside the current segment + for w in points.windows(2) { let (a, b) = (w[0], w[1]); - out.push(a); let len = (b - a).length(); - if added >= deficit || len < cell { + if len < 1e-9 { continue; } let dir = (b - a).scale(1.0 / len); let nrm = Vec2::new(-dir.y, dir.x); - // Bulge to whichever side is farther from the twin. The twin runs - // parallel to the whole segment, so one decision per segment holds. + // Bulge to whichever side is farther from the twin. let mid = a + dir.scale(len / 2.0); let sign = if twin_dist(mid + nrm.scale(amplitude)) >= twin_dist(mid - nrm.scale(amplitude)) { @@ -171,21 +176,31 @@ fn bump_away( -1.0 }; let off = nrm.scale(sign * amplitude); - // Keep a quarter-cell margin at each end so bumps stay clear of the - // segment's endpoints (vias, corners, pad breakouts), then fit as many - // whole cells as the remainder allows. - let margin = cell * 0.25; - let usable = len - 2.0 * margin; - let cells = (usable / cell).floor().max(0.0) as usize; - let mut t = margin; - for _ in 0..cells { - if added >= deficit { + + let mut t = carry; + while t < len { + if travelled + (t - carry) >= need_len || added >= deficit { break; } - out.push(a + dir.scale(t + third) + off); - out.push(a + dir.scale(t + 2.0 * third) + off); - added += gain_per_cell; - t += cell; + let p = a + dir.scale(t); + if phase == 0 { + out.push(p); + } else { + out.push(p + off); + if phase == 2 { + added += gain_per_cell; + } + } + phase = (phase + 1) % 3; + t += step; + } + travelled += len - carry; + carry = (t - len).max(0.0); + // Land back on the path at the vertex before moving on. + if added >= deficit || travelled >= need_len { + out.push(b); + phase = 0; + carry = 0.0; } } out.push(*points.last().unwrap()); From e0595359c5079cecc9e4f46086a5c1c56abce3a5 Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Sat, 25 Jul 2026 00:57:09 -0400 Subject: [PATCH 06/10] test(router): pin bump capacity to run length, not vertex count The per-segment tiling bug was invisible to every existing test because a synthetic straight run has few vertices and plenty of room. These pin the property that actually decides whether the skew claim can close: the same 20mm of copper must absorb the same deficit whether it arrives as two segments or forty, and every bump must go away from the twin. Co-Authored-By: Claude Opus 5 --- crates/vcad-ecad-pcb/src/router/mod.rs | 60 ++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/crates/vcad-ecad-pcb/src/router/mod.rs b/crates/vcad-ecad-pcb/src/router/mod.rs index 6f7aef5fc..8f367b697 100644 --- a/crates/vcad-ecad-pcb/src/router/mod.rs +++ b/crates/vcad-ecad-pcb/src/router/mod.rs @@ -712,6 +712,66 @@ pub fn route_net_push_shove( router.route_net(net, start, end) } +#[cfg(test)] +mod compensation_tests { + use super::*; + + /// A run's compensation capacity is set by its LENGTH, not by how many + /// vertices it happens to have. + /// + /// This is the property that decides whether `worst_intra_pair_skew` can + /// be closed at all. A routed leg is a simplified maze staircase, so the + /// same 20mm of copper arrives as either a couple of long segments or + /// forty short ones; tiling bumps per segment made capacity track the + /// vertex count and left real runs unable to absorb ~1mm deficits. Both + /// shapes must now absorb the same deficit, and the added length must + /// match the analytic 2·(√2−1)/3 ≈ 0.276mm per mm. + #[test] + fn bump_capacity_follows_run_length_not_vertex_count() { + let twin = [(Vec2::new(0.0, -0.45), Vec2::new(20.0, -0.45))]; + let len = |p: &[Vec2]| -> f64 { p.windows(2).map(|w| (w[1] - w[0]).length()).sum() }; + + // Same 20mm path, two segments vs forty. + let coarse = vec![Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(20.0, 0.0)]; + let fine: Vec = (0..=40).map(|i| Vec2::new(i as f64 * 0.5, 0.0)).collect(); + + for (name, run) in [("coarse", &coarse), ("fine", &fine)] { + let out = bump_away(run, &twin, 2.0, 0.06) + .unwrap_or_else(|| panic!("{name} run must absorb 2mm over 20mm of copper")); + let added = len(&out) - len(run); + assert!( + added >= 2.0 * 0.6, + "{name}: added only {added:.3}mm of a 2mm deficit" + ); + // Endpoints pinned. + assert_eq!(out[0], run[0]); + assert_eq!(*out.last().unwrap(), *run.last().unwrap()); + // Every bump goes AWAY from the twin (which sits at y = -0.45). + assert!( + out.iter().all(|p| p.y >= -1e-9), + "{name}: a bump crossed toward the twin" + ); + } + + // Capacity is independent of amplitude: a 10x smaller bump over the + // same run still reaches the same deficit. + assert!( + bump_away(&fine, &twin, 2.0, 0.6).is_some(), + "large amplitude must also reach" + ); + } + + /// Nothing is emitted when the run genuinely cannot absorb the deficit — + /// the caller relies on `None` to fall through to another run rather than + /// commit a partial fix that just moves the skew around. + #[test] + fn bump_refuses_when_run_is_too_short() { + let twin = [(Vec2::new(0.0, -0.45), Vec2::new(2.0, -0.45))]; + let short = vec![Vec2::new(0.0, 0.0), Vec2::new(1.0, 0.0)]; + assert!(bump_away(&short, &twin, 5.0, 0.06).is_none()); + } +} + #[cfg(test)] mod pcb_route_tests { use super::*; From 3d6173838b5d36627038bf2c1a05b148fb28da91 Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Sat, 25 Jul 2026 00:58:08 -0400 Subject: [PATCH 07/10] docs(changelog): differential pairs route coupled and length-matched Co-Authored-By: Claude Opus 5 --- changelog/entries/2026-07-25-router-diff-pair-si.json | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 changelog/entries/2026-07-25-router-diff-pair-si.json diff --git a/changelog/entries/2026-07-25-router-diff-pair-si.json b/changelog/entries/2026-07-25-router-diff-pair-si.json new file mode 100644 index 000000000..fedad3122 --- /dev/null +++ b/changelog/entries/2026-07-25-router-diff-pair-si.json @@ -0,0 +1,10 @@ +{ + "id": "2026-07-25-router-diff-pair-si", + "version": "0.9.4", + "date": "2026-07-25", + "category": "fix", + "title": "Differential pairs route coupled and length-matched", + "summary": "Coupled pairs can now escape a BGA on an inner layer instead of failing outright, USB DP/DM pairs are recognized, and a finishing pass re-couples, descends and phase-compensates each pair so intra-pair skew lands inside the signal-integrity receipt's bounds.", + "features": ["pcb", "autorouter", "diff-pairs", "signal-integrity", "drc"], + "mcpTools": ["route_nets", "route_diff_pair", "run_drc", "build_receipt"] +} From 9914dea65eb54df23e201180c22c11d616717d54 Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Sat, 25 Jul 2026 01:53:12 -0400 Subject: [PATCH 08/10] =?UTF-8?q?docs(router):=20M7=20=E2=80=94=20universa?= =?UTF-8?q?l=20pair=20coupling=20and=20the=20SI=20receipt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records what the M6 doc's named levers actually bought, including where they did not reach: coupled construction 17 -> 39 of 49 pairs (the phantom centerline had been pinned to the outer layer, which inside a BGA is solid pads — 28 of 32 census bails), a 40-net board reaching receipt Pass end to end, and the full board still at 2/4 pinned by two specific groups. Also states the closed forms the finishing work turned on: pair skew is structural at (w + gap)·Sum 2·tan(theta/2), and compensation capacity is 2(sqrt2 - 1)/3 ~= 0.276mm per mm of run, independent of amplitude. Co-Authored-By: Claude Opus 5 --- docs/gpu-router-m6-results.md | 7 +- docs/gpu-router-m7-pair-si.md | 199 ++++++++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 docs/gpu-router-m7-pair-si.md diff --git a/docs/gpu-router-m6-results.md b/docs/gpu-router-m6-results.md index c80fad05d..37ec3921f 100644 --- a/docs/gpu-router-m6-results.md +++ b/docs/gpu-router-m6-results.md @@ -102,5 +102,10 @@ the point of the row.) ## Next 1. Wire M4 negotiation into the route/ratchet stages (the <30 min path). -2. Reroute-then-descend for extreme-skew pairs; descent for the rest. +2. ~~Reroute-then-descend for extreme-skew pairs; descent for the rest.~~ — + done, see [gpu-router-m7-pair-si.md](gpu-router-m7-pair-si.md). Coupled + construction went 17 → 39 of 49 pairs (the phantom centerline had been + pinned to the outer layer, where a BGA has only pads); a 40-net board now + reaches receipt Pass end to end; the full board is still 2/4, pinned by + short pairs whose legs land on different layers and by four detour pairs. 3. Re-run this document's table after each; claims move only with runs. diff --git a/docs/gpu-router-m7-pair-si.md b/docs/gpu-router-m7-pair-si.md new file mode 100644 index 000000000..ea6a41c82 --- /dev/null +++ b/docs/gpu-router-m7-pair-si.md @@ -0,0 +1,199 @@ +# M7 — universal pair coupling and the SI receipt + +Follow-on to [gpu-router-m6-results.md](gpu-router-m6-results.md), whose +scoreboard closed with `receipt Pass | 1/4 HOLDS` and named the levers: +coupled routing during construction, and reroute-then-descend for the +extreme-skew tail. This document records what those levers actually bought, +including where they did not reach. + +Claims move only with runs. Every number below is measured. + +## The calibration anchor (unchanged) + +The human-routed CM5 scores `Pass`, which is what makes the bounds an +*envelope* argument rather than a target. Re-verified at the start and end of +this work, identical both times: + +``` +worst_group_skew 9.756 mm <= 10.0 HOLDS +worst_intra_pair_skew 1.074 mm <= 1.1 HOLDS +min_pair_coupled_fraction 0.857 >= 0.5 HOLDS +vias_per_si_net 2.265 <= 3.0 HOLDS +verdict: ALL HOLD +``` + +**The bounds were not touched.** Any change to them would invalidate the +argument the receipt exists to make. + +## Lever 1 — coupled construction: 17 → 39 of 49 pairs + +Method: typed bail reasons (`PairBail`) plus `census_pairs` / the `si_census` +example, which runs the pair stage alone on a copper-stripped board. The +census takes ~15s where a full route takes ~2.7h, and because the board is +empty it isolates geometry and logic failures from congestion. + +| census | coupled / 49 | dominant bail | +|---|---|---| +| baseline | 17 | `centerline-search` 28 of 32 | +| + USB `DP`/`DM` in `pair_partner` | 20 | `centerline-search` 28 | +| + narrow-centerline fallback | 22 | `centerline-search` 23 | +| + measured neck-down | 26 | `centerline-search` 22 | +| **+ inner-layer centerline endpoints** | **41** | `centerline-search` 6 | +| + connector pad-layer constraint | 39 | `centerline-search` 5 | + +The decisive one is the fifth row. The phantom centerline was pinned to start +and end on the **outer layer**. Inside a BGA the outer layer is solid pads, so +no fat capsule can ever sit there and the search failed outright — 28 of 32 +census bails and 48 of 56 in a full route. Pairs escape a BGA the way the +human board does: down a via, coupled on an inner layer *under* the field, +where there are no pads at all. Allowing that took the census 26 → 41 and made +it 9x faster, because failed searches stopped burning the entire retreat +ladder. + +The last row is a deliberate loss. Allowing inner-layer legs exposed a latent +connectivity bug — pad connectors searched from *all* copper layers, so one +could start at a pad's XY on a layer the pad is not on and never touch it +electrically. Nothing catches that: the copper is same-net, so the clearance +probe is happy. Constraining connectors to the pad's own layers costs two +pairs and buys correctness. + +In a full route the pair-first stage went from **22 to 43** pairs routed +coupled. + +## Lever 2 — the SI finishing pass + +`router::si_finish` runs three non-regressive, oracle-gated stages: +`polish_pairs` (rip and re-route coupled) → `descend_board` (differentiable +skew descent) → phase compensation. + +### Pair skew is structural + +The finishing work is shaped by a fact worth stating plainly: a coupled pair's +skew is not sloppy routing. Both legs are offsets of **one** centerline, so at +every bend the outer leg takes the long way and the inner the short way: + +``` +skew = (w + gap) · Σ_signed 2·tan(θ/2) +``` + +At this board's 0.2/0.25 geometry that is **~0.9mm for a single net +right-angle turn**, against a 1.1mm bound. It cannot be tuned out by moving +the pair sideways — a lateral shift changes both legs equally and their +difference depends only on the separation. Length has to be added back. + +### Compensation capacity + +Adding it back has a closed form. With cells of length `3·h` carrying bumps of +amplitude `h`, gain per cell is `2·(hypot(h, h) − h) = 0.83·h` over `3·h` of +run, so capacity is + +``` +2·(√2 − 1)/3 ≈ 0.276 mm of added length per mm of run +``` + +**independent of amplitude.** Three consequences, all of which cost a +measurement to learn: + +- Small bumps are strictly better — same capacity, less coupled fraction + spent, less chance of hitting a neighbour. The amplitude ladder climbs. +- Bumps must be tiled by **arclength, not per segment**. A routed leg is a + simplified maze staircase of ~0.45mm segments; per-segment tiling caps + capacity at ~0.03mm per segment, so a 10.66mm run reported "cannot reach + 1.312mm" while sitting on ample copper. +- A generic meander generator is useless on a coupled leg: its bends go into + the twin one gap away ("meanders do not fit" refused 4 of 7 pairs). Bumps + bulge away from the twin. + +### The two pair claims trade against each other + +Length added to a coupled leg moves it away from its twin, which costs +`min_pair_coupled_fraction`. Measured: at full amplitude, coupling fell +0.777 → 0.252. Amplitude is therefore capped by the coupling window on +leg-width runs, and a floor keeps every pair well clear of the 0.5 bound. + +## Three defects this surfaced + +Each was found by a measurement, not by reading code. + +1. **Descent was pinching pairs below the gap.** Its fail-closed check dropped + *both* nets' copper before probing, so it could not see a leg drifting into + its own partner — the gap spring is a soft term, not a guarantee. Checking + each leg against the twin *at its descended position* (new-against-old + rejects 100%, since the pair moves as a unit) plus a +20µm spring cushion + took subset clearance violations from **4404 to 77**. +2. **`polish_pairs` committed unchecked assemblies**, and worse, the + incremental session probe and the DRC disagree at mitred pair corners: the + probe passed legs 0.191mm apart that the DRC rejects against its 0.245mm + pair-gap rule. Gating on `check_drc_in_region` over the pair's bbox took + intra-pair violations from **3 to 0**. +3. **The connector straight-hop shortcut emitted copper without probing it** — + the one path in the stage that bypassed the oracle outright. + +## Results + +### 40-net subset — receipt Pass + +``` +worst_group_skew 0.000 mm <= 10.0 HOLDS +worst_intra_pair_skew 0.983 mm <= 1.1 HOLDS (was 1.499) +min_pair_coupled_fraction 0.777 >= 0.5 HOLDS +vias_per_si_net 2.727 <= 3.0 HOLDS +verdict: ALL HOLD +``` + +Zero intra-pair DRC violations, and the board's total `Clearance` count is +identical with the finishing pass on and off (74) — the pass introduces none. + +### Full board — still 2 of 4 + +Full route: 2h41m, routability 0.983, 393/408 nets, 830 vias (0.29x human). + +``` + routed + si_finish bound +worst_group_skew 9.204 mm 9.297 mm <= 10.0 HOLDS +worst_intra_pair_skew 38.443 mm 37.853 mm <= 1.1 BROKEN +min_pair_coupled_fraction 0.000 0.000 >= 0.5 BROKEN +vias_per_si_net 2.830 2.766 <= 3.0 HOLDS +``` + +The finishing pass re-coupled 19 of 49 pairs on the full board and reverted +none, but the two broken claims are worst-case over every pair and are pinned +by two small, distinct groups: + +- **Coupling is pinned at 0.000 by the short `/HS.*` pairs.** These are ~1.8mm + nets between adjacent pads. Routed as independent singles their legs land on + *different layers* (`/HS.1_P` on In1Cu, `/HS.1_N` on FCu), and + `coupled_fraction` requires same-layer twin copper, so they score exactly + zero. The phantom cannot help: `lead` insets the centerline so the fat + capsule clears the four pads, which on a ~1.8mm span leaves almost no + centerline to search. +- **Skew is pinned at 37.9mm by four pairs** (`/ETH.3`, `/USB3-0.RX`, + `/USB3-1.D`, `/MIPI0.D1`) where one leg took a large detour. Polish now + triggers on skew as well as coupling — a pair can be 100% coupled and still + carry 38mm of skew, because coupled fraction is measured on P while the + extra copper sits on N — but on the congested full board the rip-and-reroute + attempt fails for these. + +Compensation cannot reach either group: 38mm of deficit would need ~138mm of +run at 0.276mm/mm. + +## Honest gaps + +| target | measured | named closer | +|---|---|---| +| receipt Pass on the full board | 2/4 HOLDS | Two specific groups, above. Short pairs need a coupled path that does not inset a lead — the phantom is the wrong tool below ~4mm span. The detour pairs need polish to succeed under full-board congestion, i.e. a wider corridor rip or a higher-effort re-route. | +| receipt Pass on a subset | ✅ ALL HOLD | 40-net board, end to end through `cm5_bench`. | +| no new route-attributable DRC | finisher: proven (74 → 74). router: **not A/B'd** | The pre-change full-board baseline was not kept, so the routing stage's own delta is unmeasured. A clean pre/post full-board pair is the missing evidence. | + +Note on DRC accounting: `Short` counts are **not** route-attributable on this +fixture. The import carries ~906 pad-level shorts, and every routed trace +merges copper clusters, multiplying reported net-pair shorts — routing only 40 +nets adds ~409. Use the `Clearance` rule for attribution. + +## Next + +1. A short-pair path that keeps both legs on one layer without the lead inset + — this alone unpins `min_pair_coupled_fraction` from zero. +2. Higher-effort polish for the detour pairs, or catching them during + construction so no leg ever takes the detour. +3. The clean pre/post full-board DRC A/B. From 1b020cec95d30c9c54efdf537b9bfcfff77a8c5c Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Sat, 25 Jul 2026 01:53:48 -0400 Subject: [PATCH 09/10] style: cargo fmt Co-Authored-By: Claude Opus 5 --- crates/vcad-ecad-pcb/examples/si_census.rs | 5 +- crates/vcad-ecad-pcb/examples/si_finish.rs | 8 +- crates/vcad-ecad-pcb/src/router/descent.rs | 12 +- crates/vcad-ecad-pcb/src/router/mod.rs | 132 +++++++++++---------- crates/vcad-ecad-pcb/src/router/pair.rs | 48 ++++---- 5 files changed, 108 insertions(+), 97 deletions(-) diff --git a/crates/vcad-ecad-pcb/examples/si_census.rs b/crates/vcad-ecad-pcb/examples/si_census.rs index dba6e5d89..da0c61b3e 100644 --- a/crates/vcad-ecad-pcb/examples/si_census.rs +++ b/crates/vcad-ecad-pcb/examples/si_census.rs @@ -19,10 +19,7 @@ fn main() { .init(); let mut args = std::env::args().skip(1); let path = args.next().expect("usage: si_census [expansions]"); - let expansions: usize = args - .next() - .and_then(|s| s.parse().ok()) - .unwrap_or(400_000); + let expansions: usize = args.next().and_then(|s| s.parse().ok()).unwrap_or(400_000); let text = std::fs::read_to_string(&path).expect("read board"); let pcb: Pcb = if path.ends_with(".json") { diff --git a/crates/vcad-ecad-pcb/examples/si_finish.rs b/crates/vcad-ecad-pcb/examples/si_finish.rs index db7903cad..8011cf0c4 100644 --- a/crates/vcad-ecad-pcb/examples/si_finish.rs +++ b/crates/vcad-ecad-pcb/examples/si_finish.rs @@ -17,8 +17,12 @@ fn main() { .format_timestamp_millis() .init(); let mut args = std::env::args().skip(1); - let input = args.next().expect("usage: si_finish [exp] [iters]"); - let output = args.next().expect("usage: si_finish [exp] [iters]"); + let input = args + .next() + .expect("usage: si_finish [exp] [iters]"); + let output = args + .next() + .expect("usage: si_finish [exp] [iters]"); let expansions: usize = args .next() .and_then(|s| s.parse().ok()) diff --git a/crates/vcad-ecad-pcb/src/router/descent.rs b/crates/vcad-ecad-pcb/src/router/descent.rs index 1d2a0e0ca..2a313d1f4 100644 --- a/crates/vcad-ecad-pcb/src/router/descent.rs +++ b/crates/vcad-ecad-pcb/src/router/descent.rs @@ -424,8 +424,16 @@ pub fn descend_board(pcb: &mut Pcb, iters: usize) -> DescentReport { let p_pts = densify(&p_raw); let n_pts = densify(&n_raw); let run_w = p_w.max(n_w); - let plen: f64 = p_pts.windows(2).map(|w| (w[1] - w[0]).length()).sum::() + extra_p; - let nlen: f64 = n_pts.windows(2).map(|w| (w[1] - w[0]).length()).sum::() + extra_n; + let plen: f64 = p_pts + .windows(2) + .map(|w| (w[1] - w[0]).length()) + .sum::() + + extra_p; + let nlen: f64 = n_pts + .windows(2) + .map(|w| (w[1] - w[0]).length()) + .sum::() + + extra_n; if (plen - nlen).abs() < 0.2 || p_pts.len() < 3 || n_pts.len() < 3 { continue; // already matched, or no interior freedom } diff --git a/crates/vcad-ecad-pcb/src/router/mod.rs b/crates/vcad-ecad-pcb/src/router/mod.rs index 8f367b697..c92a31e39 100644 --- a/crates/vcad-ecad-pcb/src/router/mod.rs +++ b/crates/vcad-ecad-pcb/src/router/mod.rs @@ -63,12 +63,12 @@ pub use auto::{ UnroutedDiagnostic, }; pub use congestion::Congestion; +pub use descent::{descend_board, DescentReport}; pub use diff_pair::route_diff_pair; pub use length_match::{ check_length_match, match_lengths, LengthMatchOptions, LengthMatchResult, NetLengthReport, }; pub use maze::route_net_maze; -pub use descent::{descend_board, DescentReport}; pub use pair::{census_pairs, polish_pairs, PairBail, PairCensus, PairCensusRow}; pub use si_claims::coupled_fraction as pair_coupled_fraction; @@ -285,20 +285,17 @@ fn compensate_run(pcb: &Pcb, net: &str, twin_net: &str, deficit: f64) -> Option< hi[1] = hi[1].max(p.y + 3.0); } let mut obstacles: Vec<(Vec2, Vec2, f64)> = Vec::new(); - session.for_each_blocking(layer, net, lo, hi, |geom, emin, emax, req| { - match geom { - crate::spatial::CopperGeom::Segment { a, b, half_w } => { - obstacles.push((*a, *b, req + half_w + width / 2.0)) - } - crate::spatial::CopperGeom::Disc { center, r } => { - obstacles.push((*center, *center, req + r + width / 2.0)) - } - _ => { - let c = Vec2::new((emin[0] + emax[0]) / 2.0, (emin[1] + emax[1]) / 2.0); - let hd = - ((emax[0] - emin[0]).powi(2) + (emax[1] - emin[1]).powi(2)).sqrt() / 2.0; - obstacles.push((c, c, req + hd + width / 2.0)); - } + session.for_each_blocking(layer, net, lo, hi, |geom, emin, emax, req| match geom { + crate::spatial::CopperGeom::Segment { a, b, half_w } => { + obstacles.push((*a, *b, req + half_w + width / 2.0)) + } + crate::spatial::CopperGeom::Disc { center, r } => { + obstacles.push((*center, *center, req + r + width / 2.0)) + } + _ => { + let c = Vec2::new((emin[0] + emax[0]) / 2.0, (emin[1] + emax[1]) / 2.0); + let hd = ((emax[0] - emin[0]).powi(2) + (emax[1] - emin[1]).powi(2)).sqrt() / 2.0; + obstacles.push((c, c, req + hd + width / 2.0)); } }); // Validation session: the board without THIS net's copper, so the @@ -379,37 +376,37 @@ fn compensate_run(pcb: &Pcb, net: &str, twin_net: &str, deficit: f64) -> Option< } } for tuned in candidates { - let legal = tuned.windows(2).all(|w| { - vsession - .probe( - &crate::spatial::CopperGeom::Segment { - a: w[0], - b: w[1], - half_w: width / 2.0, - }, - layer, - net, - clearance, - ) - .legal - }); - if !legal { - log::trace!( - "compensate {net}: {layer:?} w={width} amp={amp:.3} candidate illegal" - ); - continue; - } - let mut work = pcb.clone(); - work.traces.retain(|t| !on_run(t)); - work.traces.extend(tuned.windows(2).map(|w| Trace { - start: w[0], - end: w[1], - width, - layer, - net: net.to_string(), - source: None, - })); - return Some(work); + let legal = tuned.windows(2).all(|w| { + vsession + .probe( + &crate::spatial::CopperGeom::Segment { + a: w[0], + b: w[1], + half_w: width / 2.0, + }, + layer, + net, + clearance, + ) + .legal + }); + if !legal { + log::trace!( + "compensate {net}: {layer:?} w={width} amp={amp:.3} candidate illegal" + ); + continue; + } + let mut work = pcb.clone(); + work.traces.retain(|t| !on_run(t)); + work.traces.extend(tuned.windows(2).map(|w| Trace { + start: w[0], + end: w[1], + width, + layer, + net: net.to_string(), + source: None, + })); + return Some(work); } } } @@ -457,7 +454,11 @@ fn meander_pair_skew(pcb: &mut Pcb, tolerance: f64) -> (usize, usize) { continue; } // Which leg is short, and by how much. - let (short_net, deficit) = if lp < ln { (pn, ln - lp) } else { (nn, lp - ln) }; + let (short_net, deficit) = if lp < ln { + (pn, ln - lp) + } else { + (nn, lp - ln) + }; let twin_net = if short_net == pn { nn } else { pn }; let Some(work) = compensate_run(pcb, short_net, twin_net, deficit) else { log::debug!("si-finish: {pn} skew {before:.3}mm — no run could absorb {deficit:.3}mm"); @@ -507,23 +508,20 @@ fn meander_pair_skew(pcb: &mut Pcb, tolerance: f64) -> (usize, usize) { let mut bare = work.clone(); bare.traces.retain(|t| t.net != **net); let vsession = RouteSession::from_pcb(&bare); - work.traces - .iter() - .filter(|t| t.net == **net) - .all(|t| { - vsession - .probe( - &crate::spatial::CopperGeom::Segment { - a: t.start, - b: t.end, - half_w: t.width / 2.0, - }, - t.layer, - &t.net, - vsession.clearance_for(&t.net), - ) - .legal - }) + work.traces.iter().filter(|t| t.net == **net).all(|t| { + vsession + .probe( + &crate::spatial::CopperGeom::Segment { + a: t.start, + b: t.end, + half_w: t.width / 2.0, + }, + t.layer, + &t.net, + vsession.clearance_for(&t.net), + ) + .legal + }) }); if !legal { log::debug!("si-finish: {pn} meander rejected by oracle"); @@ -732,7 +730,11 @@ mod compensation_tests { let len = |p: &[Vec2]| -> f64 { p.windows(2).map(|w| (w[1] - w[0]).length()).sum() }; // Same 20mm path, two segments vs forty. - let coarse = vec![Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(20.0, 0.0)]; + let coarse = vec![ + Vec2::new(0.0, 0.0), + Vec2::new(10.0, 0.0), + Vec2::new(20.0, 0.0), + ]; let fine: Vec = (0..=40).map(|i| Vec2::new(i as f64 * 0.5, 0.0)).collect(); for (name, run) in [("coarse", &coarse), ("fine", &fine)] { diff --git a/crates/vcad-ecad-pcb/src/router/pair.rs b/crates/vcad-ecad-pcb/src/router/pair.rs index 345a090ad..7a75ae46c 100644 --- a/crates/vcad-ecad-pcb/src/router/pair.rs +++ b/crates/vcad-ecad-pcb/src/router/pair.rs @@ -587,15 +587,14 @@ pub(super) fn try_route_pair_reason( }; // Realize the two legs from the centerline. - let (leg_a, leg_b) = - match realize_legs(¢er, half_sep, via_off, via_d / 2.0, clearance, w) { - Some(l) => l, - None => { - log::debug!("pair: {net}/{partner}: degenerate centerline — bailing"); - restore(session, placed, ripped); - return Err(PairBail::DegenerateCenterline); - } - }; + let (leg_a, leg_b) = match realize_legs(¢er, half_sep, via_off, via_d / 2.0, clearance, w) { + Some(l) => l, + None => { + log::debug!("pair: {net}/{partner}: degenerate centerline — bailing"); + restore(session, placed, ripped); + return Err(PairBail::DegenerateCenterline); + } + }; // Leg → net assignment: the one whose pad connectors are shortest (the // non-crossing assignment — crossing connectors are strictly longer). @@ -651,7 +650,10 @@ pub(super) fn try_route_pair_reason( b: to, half_w: nw / 2.0, }; - if session.probe(&hop, to_layer, net, session.clearance_for(net)).legal { + if session + .probe(&hop, to_layer, net, session.clearance_for(net)) + .legal + { return Some((vec![(from, to, to_layer)], vec![])); } } @@ -883,14 +885,20 @@ fn longest_coupled_window( run_start.get_or_insert(k); } else if let Some(s) = run_start.take() { let cand = (s, k - 1); - if best.map(|(bs, be)| cand.1 - cand.0 > be - bs).unwrap_or(true) { + if best + .map(|(bs, be)| cand.1 - cand.0 > be - bs) + .unwrap_or(true) + { best = Some(cand); } } } if let Some(s) = run_start { let cand = (s, center.len() - 1); - if best.map(|(bs, be)| cand.1 - cand.0 > be - bs).unwrap_or(true) { + if best + .map(|(bs, be)| cand.1 - cand.0 > be - bs) + .unwrap_or(true) + { best = Some(cand); } } @@ -1241,12 +1249,8 @@ pub fn census_pairs(pcb: &Pcb, max_expansions: usize) -> PairCensus { }); } } - let f = crate::router::si_claims::coupled_fraction( - &probe, - pn, - nn, - (w + gap) * 1.75, - ); + let f = + crate::router::si_claims::coupled_fraction(&probe, pn, nn, (w + gap) * 1.75); placed.push(mine); placed.push(theirs); (None, f) @@ -2150,12 +2154,8 @@ mod tests { let pads = pads_of_net(&pcb, &pl.net); assert!(!pads.is_empty()); // All copper this net committed, with its layer. - let copper: Vec<(Vec2, Vec2, PcbLayer)> = pl - .segments - .iter() - .chain(pl.stubs.iter()) - .copied() - .collect(); + let copper: Vec<(Vec2, Vec2, PcbLayer)> = + pl.segments.iter().chain(pl.stubs.iter()).copied().collect(); for pad in pads { let pad_layers = pad_layers_at(&pcb, &pl.net, pad); let touched = copper.iter().any(|&(a, b, l)| { From d7b077d5a971b90a252f4134bf086dd2821a1edb Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Sat, 25 Jul 2026 02:00:06 -0400 Subject: [PATCH 10/10] =?UTF-8?q?docs(router):=20add=20the=20250-net=20con?= =?UTF-8?q?trol=20=E2=80=94=20the=20subset=20Pass=20was=20uncongested?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 250-net board reproduces the full board's failure exactly (2/4, same two blocker groups, /ETH.3_P worst on both at 37.5mm and 37.9mm). That stops the 40-net Pass from being over-read: it reflects an uncongested board where polish can rip and re-route freely, not a solved pipeline. Polish's success rate under congestion is the real scaling limit. Co-Authored-By: Claude Opus 5 --- docs/gpu-router-m7-pair-si.md | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/docs/gpu-router-m7-pair-si.md b/docs/gpu-router-m7-pair-si.md index ea6a41c82..b6bd9a620 100644 --- a/docs/gpu-router-m7-pair-si.md +++ b/docs/gpu-router-m7-pair-si.md @@ -144,6 +144,26 @@ verdict: ALL HOLD Zero intra-pair DRC violations, and the board's total `Clearance` count is identical with the finishing pass on and off (74) — the pass introduces none. +### 250-net board — 2 of 4, and the important control + +``` +worst_group_skew 9.158 mm <= 10.0 HOLDS +worst_intra_pair_skew 37.525 mm <= 1.1 BROKEN +min_pair_coupled_fraction 0.000 >= 0.5 BROKEN +vias_per_si_net 2.405 <= 3.0 HOLDS +``` + +This run is what stops the 40-net result from being over-read. Routability +0.957, 36 pairs measured, 17 of 37 re-coupled by polish — and the *same* two +blocker groups as the full board, with `/ETH.3_P` the worst pair on both +(37.5mm here, 37.9mm there). + +So the 40-net Pass reflects an **uncongested** board rather than a solved +pipeline: `polish_pairs` can rip and re-route a pair freely when there is +space, and its success rate falls as congestion rises. The finishing pass is +sound and non-regressive at every size; what does not yet scale is its ability +to *land* a re-route on a full board. + ### Full board — still 2 of 4 Full route: 2h41m, routability 0.983, 393/408 nets, 830 vias (0.29x human). @@ -182,7 +202,7 @@ run at 0.276mm/mm. | target | measured | named closer | |---|---|---| | receipt Pass on the full board | 2/4 HOLDS | Two specific groups, above. Short pairs need a coupled path that does not inset a lead — the phantom is the wrong tool below ~4mm span. The detour pairs need polish to succeed under full-board congestion, i.e. a wider corridor rip or a higher-effort re-route. | -| receipt Pass on a subset | ✅ ALL HOLD | 40-net board, end to end through `cm5_bench`. | +| receipt Pass on a subset | ✅ ALL HOLD | 40-net board, end to end through `cm5_bench` — but see the 250-net control above: that Pass reflects an uncongested board, not a solved pipeline. | | no new route-attributable DRC | finisher: proven (74 → 74). router: **not A/B'd** | The pre-change full-board baseline was not kept, so the routing stage's own delta is unmeasured. A clean pre/post full-board pair is the missing evidence. | Note on DRC accounting: `Short` counts are **not** route-attributable on this @@ -195,5 +215,10 @@ nets adds ~409. Use the `Clearance` rule for attribution. 1. A short-pair path that keeps both legs on one layer without the lead inset — this alone unpins `min_pair_coupled_fraction` from zero. 2. Higher-effort polish for the detour pairs, or catching them during - construction so no leg ever takes the detour. -3. The clean pre/post full-board DRC A/B. + construction so no leg ever takes the detour. `/ETH.3_P` is the worst pair + on both the 250-net and full boards, so it is a stable, reproducible + target rather than a full-board-only artifact. +3. Polish's success rate under congestion is the scaling limit: it works on + an open board and fails on a full one. Wider corridor rip, or ordering + polish before the board fills. +4. The clean pre/post full-board DRC A/B.