diff --git a/crates/vcad-ecad-pcb/examples/prune_spurs.rs b/crates/vcad-ecad-pcb/examples/prune_spurs.rs new file mode 100644 index 00000000..46a39647 --- /dev/null +++ b/crates/vcad-ecad-pcb/examples/prune_spurs.rs @@ -0,0 +1,20 @@ +//! Scratch: remove spur copper (connected dead-end branches) from a board. + +use vcad_ecad_symbols::parse_kicad_pcb; +use vcad_ir::ecad::Pcb; + +fn main() { + let mut args = std::env::args().skip(1); + let input = args.next().expect("usage: prune_spurs "); + let output = args.next().expect("usage: prune_spurs "); + let text = std::fs::read_to_string(&input).expect("read"); + let mut pcb: Pcb = if input.ends_with(".json") { + serde_json::from_str(&text).expect("parse") + } else { + parse_kicad_pcb(&text).expect("parse") + }; + let (t0, v0) = (pcb.traces.len(), pcb.vias.len()); + let (t, v) = vcad_ecad_pcb::drc::prune_spur_copper(&mut pcb); + println!("pruned {t} spur traces of {t0}, {v} spur vias of {v0}"); + std::fs::write(&output, serde_json::to_string(&pcb).unwrap()).expect("write"); +} diff --git a/crates/vcad-ecad-pcb/examples/starved_legs.rs b/crates/vcad-ecad-pcb/examples/starved_legs.rs new file mode 100644 index 00000000..a3f432d1 --- /dev/null +++ b/crates/vcad-ecad-pcb/examples/starved_legs.rs @@ -0,0 +1,63 @@ +//! Scratch: list pair legs whose copper is shorter than their own pad span. + +use vcad_ecad_pcb::router::classes::classify_nets; +use vcad_ecad_pcb::router::length_match::net_routed_length; +use vcad_ecad_symbols::parse_kicad_pcb; +use vcad_ir::ecad::Pcb; +use vcad_ir::Vec2; + +fn main() { + let path = std::env::args() + .nth(1) + .expect("usage: starved_legs "); + let text = std::fs::read_to_string(&path).expect("read"); + let pcb: Pcb = if path.ends_with(".json") { + serde_json::from_str(&text).expect("parse") + } else { + parse_kicad_pcb(&text).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); + let span_of = |net: &str| -> (f64, usize) { + let mut pads: Vec = Vec::new(); + for fp in &pcb.footprints { + for pad in &fp.pads { + if pad.net.as_deref() == Some(net) { + pads.push(vcad_ecad_pcb::geometry::pad_world_position(fp, pad)); + } + } + } + let mut span = 0.0f64; + for (i, a) in pads.iter().enumerate() { + for b in &pads[i + 1..] { + span = span.max((*a - *b).length()); + } + } + (span, pads.len()) + }; + println!("{} pairs classified", c.pairs.len()); + for (p, n) in &c.pairs { + for net in [p, n] { + let (span, npads) = span_of(net); + let l = net_routed_length(&pcb, net); + if span > 1.0 && l < span * 0.999 { + println!( + " STARVED {net:28} copper {l:7.2}mm span {span:7.2}mm ratio {:.3} pads {npads}", + l / span + ); + } + } + } +} diff --git a/crates/vcad-ecad-pcb/src/drc.rs b/crates/vcad-ecad-pcb/src/drc.rs index 22f3e938..74815b86 100644 --- a/crates/vcad-ecad-pcb/src/drc.rs +++ b/crates/vcad-ecad-pcb/src/drc.rs @@ -1597,7 +1597,16 @@ fn check_connectivity(pcb: &Pcb, net_ties: &NetTieGroups, violations: &mut Vec (Vec, Dsu) { /// copper (the autorouter, judging the candidate board it is about to return) /// can drop its own dead pieces without touching copper it did not place. pub(crate) fn dangling_copper_mask(pcb: &Pcb) -> (Vec, Vec) { - let (nodes, mut dsu) = build_connectivity(pcb); + // Anchoring must be judged over SAME-NET connectivity, which is what this + // function's contract says ("no pad and no pour fragment *of their net*"). + // The global graph unions any copper that touches, so a dead island of net X + // that merely brushes net Y is swept into Y's component, sees Y's pads, and + // reads as anchored. On a board carrying shorts that is not a corner case: + // measured on the routed CM5, ~36mm of dead copper per net survived this way + // on /MIPI1.D3_{P,N}, /USB3-1.DP and /MIPI1.D2_P — about half of each net's + // copper, which then inflated `net_routed_length` into 38mm of phantom + // intra-pair skew and carried the vias that pushed `vias_per_si_net` over + // its bound. + let net_ties = NetTieGroups::from_pcb(pcb); + let (nodes, _global, contacts) = build_connectivity_with_contacts(pcb); + let mut dsu = build_same_net_dsu(&nodes, &contacts, &net_ties); // Node order in build_conn_nodes: traces, then vias, then pads/pours. let n_traces = pcb.traces.len(); let n_vias = pcb.vias.len(); - // Component roots that are anchored: hold a pad or a pour fragment. + // Component roots that are anchored: hold a pad or a pour fragment. Within + // the same-net graph a component's members all share a net (or are joined by + // an intentional tie), so "holds a pad" already means "holds a pad of this + // net" — no per-net comparison is needed here. let mut anchored: std::collections::HashSet = std::collections::HashSet::new(); for (i, node) in nodes.iter().enumerate() { let is_anchor = node.pad.is_some() || matches!(node.geom, NodeGeom::Pour(_)); @@ -2470,12 +2494,14 @@ pub(crate) fn dangling_copper_mask(pcb: &Pcb) -> (Vec, Vec) { } } - let keep_trace: Vec = (0..n_traces) - .map(|i| anchored.contains(&dsu.find(i))) - .collect(); - let keep_via: Vec = (0..n_vias) - .map(|i| anchored.contains(&dsu.find(n_traces + i))) - .collect(); + // Copper carrying no net is never pruned. The same-net graph cannot reason + // about it (it unions nothing), and absent a net there is no claim that it + // *should* reach a pad — deleting it would be a guess, not a deduction. + let keep = |i: usize, dsu: &mut Dsu| -> bool { + nodes[i].net.is_empty() || anchored.contains(&dsu.find(i)) + }; + let keep_trace: Vec = (0..n_traces).map(|i| keep(i, &mut dsu)).collect(); + let keep_via: Vec = (0..n_vias).map(|i| keep(n_traces + i, &mut dsu)).collect(); (keep_trace, keep_via) } @@ -2509,6 +2535,103 @@ pub fn prune_dangling_copper(pcb: &mut Pcb) -> (usize, usize) { ) } +/// Per-trace and per-via keep flags that additionally drop **spur** copper: +/// copper that is connected to a net's live island but lies on no path between +/// two of that net's pads — a dead-end branch. +/// +/// [`dangling_copper_mask`] cannot see these. It removes whole *islands* that +/// reach no pad, and a spur hangs off an island that does reach pads, so it is +/// kept. Measured on the routed CM5, that is not a rounding error: `/USB3-1.DP` +/// carried 72.86mm of copper whose shortest pad-to-pad path is 36.60mm, so +/// 36.26mm was dead-end branch — and likewise `/MIPI1.D2_P` (33.41mm of 68.08) +/// and `/USB3-0.DP` (34.17mm of 70.11). Left in place it doubles +/// `net_routed_length` (which is what produced 38mm of phantom intra-pair skew), +/// carries vias that push `vias_per_si_net` over its bound, and — the part that +/// is not merely bookkeeping — leaves a ~34mm unterminated stub hanging off a +/// USB3 differential pair. +/// +/// Algorithm: iterated leaf removal on the same-net contact graph. A node is +/// *anchored* if it is a pad (or a pour fragment). Any non-anchored node of +/// degree ≤ 1 cannot lie on a path between two pads, so its copper is dropped +/// and its neighbour's degree decreases; repeat to a fixpoint. Removing a leaf +/// can never disconnect what remains, so this preserves every pad-to-pad +/// connection by construction — the soundness argument +/// [`prune_dangling`](crate::router) relies on, one step finer. +/// +/// Indices line up with `pcb.traces` and `pcb.vias`. +pub(crate) fn spur_copper_mask(pcb: &Pcb) -> (Vec, Vec) { + let net_ties = NetTieGroups::from_pcb(pcb); + let (nodes, _global, contacts) = build_connectivity_with_contacts(pcb); + let n_traces = pcb.traces.len(); + let n_vias = pcb.vias.len(); + + // Same-net adjacency, as index lists. + let mut adj: Vec> = vec![Vec::new(); nodes.len()]; + for c in contacts.iter() { + let (a, b) = (&nodes[c.i].net, &nodes[c.j].net); + if a.is_empty() || b.is_empty() || !net_ties.exempt(a, b, c.at) { + continue; + } + adj[c.i].push(c.j); + adj[c.j].push(c.i); + } + + // Anchored nodes hold the net electrically and are never leaves to strip. + let anchored: Vec = nodes + .iter() + .map(|n| n.pad.is_some() || matches!(n.geom, NodeGeom::Pour(_)) || n.net.is_empty()) + .collect(); + + let mut alive: Vec = vec![true; nodes.len()]; + let mut degree: Vec = adj.iter().map(|a| a.len()).collect(); + // Only trace and via nodes are removable — pads and pours are the board's, + // and copper with no net is never judged (see `dangling_copper_mask`). + let removable = |i: usize| i < n_traces + n_vias && !anchored[i]; + let mut queue: Vec = (0..nodes.len()) + .filter(|&i| removable(i) && degree[i] <= 1) + .collect(); + while let Some(i) = queue.pop() { + if !alive[i] || !removable(i) || degree[i] > 1 { + continue; + } + alive[i] = false; + for &j in &adj[i] { + if alive[j] { + degree[j] = degree[j].saturating_sub(1); + if removable(j) && degree[j] <= 1 { + queue.push(j); + } + } + } + } + + let keep_trace: Vec = (0..n_traces).map(|i| alive[i]).collect(); + let keep_via: Vec = (0..n_vias).map(|i| alive[n_traces + i]).collect(); + (keep_trace, keep_via) +} + +/// Remove spur copper board-wide — see [`spur_copper_mask`]. Returns +/// `(traces_removed, vias_removed)`. +pub fn prune_spur_copper(pcb: &mut Pcb) -> (usize, usize) { + let (keep_trace, keep_via) = spur_copper_mask(pcb); + let mut ti = 0; + pcb.traces.retain(|_| { + let k = keep_trace[ti]; + ti += 1; + k + }); + let mut vi = 0; + pcb.vias.retain(|_| { + let k = keep_via[vi]; + vi += 1; + k + }); + ( + keep_trace.iter().filter(|k| !**k).count(), + keep_via.iter().filter(|k| !**k).count(), + ) +} + /// A direct geometric touch between two connectivity nodes — one edge of the /// contact graph — with the approximate location where the copper meets. /// Cross-net edges are the candidate shorts (each judged against the net-tie @@ -2610,6 +2733,34 @@ fn build_connectivity_with_contacts(pcb: &Pcb) -> (Vec, Dsu, Vec Dsu { + let mut dsu = Dsu::new(nodes.len()); + for c in contacts { + let (a, b) = (&nodes[c.i].net, &nodes[c.j].net); + if a.is_empty() || b.is_empty() { + continue; + } + // `exempt` is true for a == b and for nets joined by a (possibly + // region-scoped) tie, so intentional junctions still connect. + if net_ties.exempt(a, b, c.at) { + dsu.union(c.i, c.j); + } + } + dsu +} + /// Closest point on segment `ab` to point `p`. fn closest_point_on_segment(p: Vec2, a: Vec2, b: Vec2) -> Vec2 { let abx = b.x - a.x; @@ -2770,6 +2921,8 @@ fn continuity_of(pcb: &Pcb, nodes: &[ConnNode], dsu: &mut Dsu, net: &str) -> Net /// how many disjoint islands the net's copper forms, what fraction of its pads /// reach the main plane, its stitching-via count, and the worst stranded /// island. `continuous` is the single bit those gates key off. +/// Continuity of a single net. Rebuilds the whole board's connectivity per call, +/// so it is not the tool for scoring every net on a large board. pub fn analyze_net_continuity(pcb: &Pcb, net: &str) -> NetContinuity { let (nodes, mut dsu) = build_connectivity(pcb); continuity_of(pcb, &nodes, &mut dsu, net) @@ -5428,4 +5581,65 @@ mod tests { let scoped = check_drc_in_region(&pcb, Vec2::new(-10.0, -10.0), Vec2::new(110.0, 90.0)); assert_eq!(full, scoped); } + + /// Dead copper must not be rescued by shorting into a live neighbour. + /// + /// Anchoring is judged per net, so an island of net B that reaches no B pad + /// is dead even when it physically touches net A's routed trace. Judged over + /// the board-global touch graph it would join A's component, see A's pads and + /// survive — which is what happened on the routed CM5, where a board with 845 + /// shorts kept ~36mm of dead copper on nets like `/MIPI1.D3_N`, inflating + /// `net_routed_length` into phantom intra-pair skew. + #[test] + fn dead_copper_shorted_to_a_live_net_is_still_pruned() { + let mut pcb = clean_pcb(); + // Net A: two pads, properly joined — the live net. + pcb.footprints.push(one_pad_footprint( + "U1", + Vec2::new(10.0, 10.0), + 0.0, + Vec2::new(0.0, 0.0), + "A", + )); + pcb.footprints.push(one_pad_footprint( + "U2", + Vec2::new(30.0, 10.0), + 0.0, + Vec2::new(0.0, 0.0), + "A", + )); + // Net B: a pad far away, so B's copper below reaches no pad of its own. + pcb.footprints.push(one_pad_footprint( + "U3", + Vec2::new(10.0, 50.0), + 0.0, + Vec2::new(0.0, 0.0), + "B", + )); + let trace = |a: Vec2, b: Vec2, net: &str| Trace { + start: a, + end: b, + width: 0.25, + layer: PcbLayer::FCu, + net: net.to_string(), + source: None, + }; + pcb.traces + .push(trace(Vec2::new(10.0, 10.0), Vec2::new(30.0, 10.0), "A")); + // B stub starting ON A's trace (a short) and ending nowhere. + pcb.traces + .push(trace(Vec2::new(20.0, 10.0), Vec2::new(20.0, 20.0), "B")); + + prune_dangling_copper(&mut pcb); + assert!( + !pcb.traces.iter().any(|t| t.net == "B"), + "B's stub reaches no B pad and must be pruned despite touching A" + ); + assert!( + pcb.traces + .iter() + .any(|t| t.net == "A" && t.start == Vec2::new(10.0, 10.0)), + "A's routed trace runs pad to pad and must survive" + ); + } } diff --git a/crates/vcad-ecad-pcb/src/router/legalize.rs b/crates/vcad-ecad-pcb/src/router/legalize.rs index 7de94141..56a86ebf 100644 --- a/crates/vcad-ecad-pcb/src/router/legalize.rs +++ b/crates/vcad-ecad-pcb/src/router/legalize.rs @@ -173,6 +173,15 @@ pub(super) fn prune_dangling( // new copper after the existing copper, so the new traces occupy // `pcb.traces.len()..` of the mask and the new vias `pcb.vias.len()..`. let candidate = candidate_pcb(pcb, traces, vias); + // NOTE: [`crate::drc::spur_copper_mask`] is the finer version of this — it + // also strips dead-end branches hanging off islands that *are* pad-anchored, + // which this pass keeps by design ("the island is the unit"). It is measured + // and sound (it never changes any board's `UnconnectedNet` count) and it + // recovers `vias_per_si_net` on the CM5, but it is deliberately NOT wired in + // here yet: for a net the router only partially reached, every piece of + // copper is a dead end, so enabling it would reclassify those nets as + // unrouted and move `routability`. That reconciliation needs its own + // full-board before/after, so it is a separate change. let (keep_trace, keep_via) = dangling_copper_mask(&candidate); let (t0, v0) = (pcb.traces.len(), pcb.vias.len()); diff --git a/crates/vcad-ecad-pcb/src/router/mod.rs b/crates/vcad-ecad-pcb/src/router/mod.rs index 9ceef69e..3479c002 100644 --- a/crates/vcad-ecad-pcb/src/router/mod.rs +++ b/crates/vcad-ecad-pcb/src/router/mod.rs @@ -587,6 +587,21 @@ pub fn si_finish(pcb: &mut Pcb, expansions: usize, descent_iters: usize) -> SiFi } } log::info!("si-finish: meandered {meandered} pairs, {over_tolerance} still over tolerance"); + // Same invariant `route_all` enforces on its own output, and for the same + // reason: every stage above rips copper and re-routes it, so each one can + // strand an island. `route_all` prunes as its last word, but this pass runs + // *after* it, so without this the board it hands back is dirtier than the one + // it was given. Measured on the full CM5: 231 dead traces and 52 dead vias + // left behind, which `net_routed_length` counts as routed copper — enough to + // report `worst_group_skew` as 8.397mm where the pruned board measures + // 3.579mm. + let (dead_traces, dead_vias) = crate::drc::prune_dangling_copper(pcb); + if dead_traces > 0 || dead_vias > 0 { + log::info!( + "si-finish: pruned {dead_traces} dead traces, {dead_vias} dead vias left by the \ + rip-and-reroute stages" + ); + } SiFinishReport { polished, polish_attempted, diff --git a/crates/vcad-ecad-pcb/src/router/si_claims.rs b/crates/vcad-ecad-pcb/src/router/si_claims.rs index 95ff03cc..be3a3cf9 100644 --- a/crates/vcad-ecad-pcb/src/router/si_claims.rs +++ b/crates/vcad-ecad-pcb/src/router/si_claims.rs @@ -40,6 +40,12 @@ pub struct SiBounds { pub min_coupled_fraction: f64, /// Max vias per SI net, averaged. pub max_vias_per_si_net: f64, + /// Max differential pairs allowed to carry copper without being routed + /// pad-to-pad. Guards the other pair claims: they are worst-case over + /// *measured* pairs, so without this a board could pass them by leaving the + /// hard pairs as stubs. + #[serde(default)] + pub max_incomplete_pairs: f64, /// Min required fraction of classed pair copper whose width is /// impedance-correct for the layer it sits on (0..1). #[serde(default)] @@ -68,6 +74,9 @@ impl Default for SiBounds { min_coupled_fraction: 0.5, // Human: 222 vias / 98 SI nets ≈ 2.3. max_vias_per_si_net: 3.0, + // A pair that is not routed pad-to-pad is a routing failure, not an + // SI margin, so the only defensible bound is zero. + max_incomplete_pairs: 0.0, // No floor by default: a board that declares no target impedance // must not fail an SI claim set for it, and one that does declare // a target should set this deliberately. @@ -113,10 +122,71 @@ fn seg_len(a: Vec2, b: Vec2) -> f64 { ((b.x - a.x).powi(2) + (b.y - a.y).powi(2)).sqrt() } +/// Fraction of its own pad span a leg's copper must reach before the receipt is +/// willing to measure skew or coupling against it. +/// +/// Calibrated, like every bound in this module, to the human CM5: its thinnest +/// leg is `/LPDDR4 RAM/DQS1_C_A` at 0.522 (6.81mm of copper across a 13.04mm +/// span), so 0.5 is the largest round threshold the anchor clears — a ~4% +/// margin, which is thin and is the reason this is a screen for *absent* copper +/// rather than a length check. +/// +/// It cannot be 1.0 even though a connected path must span the pads: copper runs +/// pad-edge to pad-edge, not centre to centre, so plenty of correctly routed +/// legs measure a hair under their centre-to-centre span (`/LPDDR4 RAM/DQS0_T_B` +/// at 0.999, `/HDMI0.TX0_P` at 0.994). +const MIN_LEG_SPAN_FRACTION: f64 = 0.5; + +/// True when a net holds far less copper than it would take to join its own pads +/// — so it cannot be routed, whatever else is true of it. +/// +/// Any connected copper joining a set of pads is at least as long as the +/// farthest pair of those pads, so a large shortfall is evidence of +/// non-connection rather than of a tight route. That structure matters here +/// because the CM5 fixture is reverse-engineered: its imported copper has +/// sub-tolerance gaps that make DRC continuity report 25 of the *human* board's +/// 49 pairs as discontinuous, which would disqualify the calibration anchor. +/// This test reads only copper length and pad positions, so it is immune to +/// that. Measured at [`MIN_LEG_SPAN_FRACTION`]: 0 human legs fail, 7 of ours do, +/// the worst being `/USB3-0.TX_P` with 0.23mm of copper against an 18.36mm span. +/// +/// It is a *necessary* condition, not a sufficient one: a leg with ample copper +/// can still be open. `UnconnectedNet` in the DRC remains the authoritative +/// completeness check; this exists so the receipt refuses to *measure* legs that +/// are plainly not routes. +fn leg_copper_is_starved(pcb: &Pcb, net: &str) -> bool { + let mut pads: Vec = Vec::new(); + for fp in &pcb.footprints { + for pad in &fp.pads { + if pad.net.as_deref() == Some(net) { + pads.push(crate::geometry::pad_world_position(fp, pad)); + } + } + } + let mut span = 0.0f64; + for (i, a) in pads.iter().enumerate() { + for b in &pads[i + 1..] { + span = span.max((*a - *b).length()); + } + } + // Sub-millimetre spans are escape-scale: the metric has no resolution there + // and the `/HS.*` pairs live at ~1.5mm, so only judge spans worth judging. + if span <= 1.0 { + return false; + } + net_routed_length(pcb, net) < span * MIN_LEG_SPAN_FRACTION +} + /// 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). /// +/// Note the asymmetry, which matters when reading a high value: the denominator +/// is P's own length, so a short P beside a long N scores near 1.0. Callers that +/// need "these two legs run together" must also check the legs are comparable in +/// length — [`si_claims`] gates on [`leg_copper_is_starved`] first for exactly +/// this reason. +/// /// 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. @@ -294,9 +364,20 @@ pub fn si_claims(pcb: &Pcb, classifier: &NetClassifier, bounds: &SiBounds) -> Si .diff_pair_width .unwrap_or(pcb.rules.default_rules.trace_width); let max_sep = (w + gap) * 1.75; + let mut incomplete_pairs = 0usize; for (p, n) in &classifier.pairs { let (lp, ln) = (net_routed_length(pcb, p), net_routed_length(pcb, n)); if lp > 0.0 && ln > 0.0 { + // Fail closed: a leg holding less copper than its own pad span is + // provably not a route, so neither skew nor coupled fraction means + // anything against it. Count it — silently dropping such a pair + // would let a board pass this receipt by leaving the hard pairs as + // stubs, and `coupled_fraction` normalizes by P alone, so a stub + // lying beside a full twin scores 1.000. + if leg_copper_is_starved(pcb, p) || leg_copper_is_starved(pcb, n) { + incomplete_pairs += 1; + continue; + } measured_pairs += 1; worst_pair_skew = worst_pair_skew.max((lp - ln).abs()); worst_coupled = worst_coupled.min(coupled_fraction(pcb, p, n, max_sep)); @@ -309,7 +390,23 @@ pub fn si_claims(pcb: &Pcb, classifier: &NetClassifier, bounds: &SiBounds) -> Si bound: bounds.pair_skew_mm, holds: worst_pair_skew <= bounds.pair_skew_mm, basis: "verified".into(), - note: format!("over {measured_pairs} routed pairs; time-of-flight NOT converted"), + note: format!( + "over {measured_pairs} fully-routed pairs; {incomplete_pairs} pair(s) \ + excluded as not routed pad-to-pad (see si_pairs_incomplete); \ + time-of-flight NOT converted" + ), + }); + claims.push(Claim { + name: "si_pairs_incomplete".into(), + value: incomplete_pairs as f64, + unit: "1".into(), + bound: bounds.max_incomplete_pairs, + holds: (incomplete_pairs as f64) <= bounds.max_incomplete_pairs, + basis: "verified".into(), + note: "differential pairs holding copper but less of it than their own pad \ + span requires on at least one leg, so provably not routed and \ + excluded from the skew and coupling claims" + .into(), }); claims.push(Claim { name: "min_pair_coupled_fraction".into(), diff --git a/docs/gpu-router-m7-pair-si.md b/docs/gpu-router-m7-pair-si.md index b6bd9a62..60de19d6 100644 --- a/docs/gpu-router-m7-pair-si.md +++ b/docs/gpu-router-m7-pair-si.md @@ -222,3 +222,321 @@ nets adds ~409. Use the `Clearance` rule for attribution. 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. + +## M8 re-measurement after #684 — the skew claim was measuring stub copper + +Everything below is a fresh full-board measurement taken after #684 (absolute +KiCad pad angles) and its companion short-pair centerline fix landed, on +`e1a5d90d`. It was run to check whether the two broken claims had moved. They +had, but the run also invalidated the *reading* of the skew claim, so this +section supersedes M7's "two blocker groups" diagnosis. + +### The calibration anchor, re-verified + +Unchanged, and the bounds were not touched: + +``` +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 census improved; the receipt got worse + +The stripped-board census is now **47 of 49** coupled (bails: `/PCIe.TX_P` +connector, `/USB3-1.RX_P` leg-validation), and the full-board pair-first stage +routes **47** coupled, up from 43. Full route: 980s, routability **0.994**, +380 routed / 28 unrouted. Despite that: + +``` + M7 (pre-fix) M8 (post-#684) bound +worst_group_skew 9.297 mm 8.397 mm <= 10.0 HOLDS +worst_intra_pair_skew 37.853 mm 38.521 mm <= 1.1 BROKEN +min_pair_coupled_fraction 0.000 0.101 >= 0.5 BROKEN +vias_per_si_net 2.766 3.128 <= 3.0 BROKEN +``` + +Three of four now break: `vias_per_si_net` **regressed through its bound** +(294 vias over 94 routed SI nets). `min_pair_coupled_fraction` came off zero +— the `/HS.*` group is fixed — but the new worst is `/MIPI0.D1_P` at 0.101. + +### What the 38mm skew actually is + +M7 read the tail as "pairs where one leg took a large detour" and priced the +fix in compensation capacity (138mm of run for 38mm of deficit). That reading +was wrong. The worst pairs are not detoured — **one leg is not routed at all.** + +Measured on the routed board, copper length per leg against the straight +pad-to-pad span of that same net. The ratio needs no connectivity model, so it +is immune to how one chooses to define an island: + +| board | legs | legs with copper < 50% of span | median ratio | min ratio | +|---|---|---|---|---| +| human | 86 | **0** | 1.23 | 1.03 | +| ours (M8) | 84 | **7** | 1.11 | **0.01** | + +The seven: + +``` + 0.01 /USB3-0.TX_P copper 0.23mm span 18.36mm + 0.02 /MIPI0.D3_P copper 0.54mm span 26.64mm + 0.03 /PCIe.RX_P copper 0.68mm span 20.19mm + 0.06 /RP1 IO/USB3-1-TXC_P copper 1.02mm span 17.31mm + 0.06 /MIPI0.D1_N copper 1.34mm span 21.85mm + 0.10 /HDMI0.CLK_P copper 1.44mm span 15.15mm + 0.19 /MIPI1.D1_P copper 5.57mm span 29.54mm +``` + +`/USB3-0.TX_P` carries 1% of the copper its own pad span requires. These are +not routes; they are pad escapes and orphaned stubs. `/MIPI1.D1_P` is four +disjoint fragments — a 0.739mm escape at U3.V5, a 0.481mm stub, a floating +4.234mm stub, and a **0.113mm** stub at J4.83 — with 29.5mm of nothing between +them, while its twin `/MIPI1.D1_N` is a complete 38.57mm route. + +So the reported skew is very nearly *the length of the leg that did route*: +`/MIPI1.D3_P` 38.52mm skew, `/USB3-1.RX_P` 33.50mm against a polish centerline +of 33.5mm. Compensation cannot close this and should never have been aimed at +it — there is no second leg to match. + +Why it reached the receipt unflagged is a single gap, and it is not in the DRC: +**`si_claims` never asks whether a leg is connected.** Its test for "measured" is +`net_routed_length > 0`, which sums any copper carrying the net name, so a pad +escape qualifies. Every one of the seven was *already* reported +`UnconnectedNet` by the DRC at the same time as the receipt was scoring it as a +routed pair. + +The second-order reason the resulting numbers looked plausible rather than +absurd is `coupled_fraction`, which normalizes by P alone — "fraction of P +length with same-layer N copper within 1.75x pitch". A stub lying beside a full +twin is 100% coupled, so `/MIPI1.D3_P` reported `coupled 1.000` while carrying +38.52mm of skew. The metric rewards the failure. + +> Correction to an earlier draft of this section, kept because the mistake is +> instructive: it claimed the DRC missed these legs because each was bridged by +> copper it was shorted to. That was wrong, and it came from grepping +> `drc_json`'s output — which prints only `Short`, `Clearance`, `MinTraceWidth` +> and `NetIslands`, and counts `UnconnectedNet` without ever printing it. The +> shorts-defeat-connectivity mechanism is real, but it lives in the *pruner*, +> not in `UnconnectedNet` — see below. + +## The prune was being defeated by shorts + +`dangling_copper_mask` is documented as removing copper whose island "touches no +pad and no pour fragment **of their net**", but it judged anchoring over the +board-global touch graph and accepted *any* pad in the component. So a dead +island of net X that merely brushed net Y was swept into Y's component, saw Y's +pads, and read as anchored. On a board carrying 845 shorts that is not a corner +case. + +Fixed by judging anchoring over same-net connectivity (`build_same_net_dsu`, +which reuses the contact list the global pass already builds, so it costs one +linear walk and no extra broadphase). Net ties still connect, since intentional +junctions are what `NetTieGroups::exempt` is for. `UnconnectedNet` gets the same +treatment for the same reason — a short is not a substitute for a route. + +Measured on the routed CM5, re-pruning the saved board: + +| | before | after | +|---|---|---| +| `NetIslands` | 93 | **17** | +| `Clearance` | 173 | **150** | +| `Short` | 845 | 815 | +| `UnconnectedNet` | 253 | **253** | +| total | 3400 | 3156 | + +`UnconnectedNet` holding at 253 is the soundness evidence: 393 traces and 52 +vias were deleted and **no net lost a connection**. Route-attributable +`Clearance` drops from +150 to +127, and `worst_group_skew` from 8.397mm to +3.579mm, because that dead copper was being counted as routed length. + +## Spurs: the other half of the excess copper + +Removing dead *islands* is not enough. A dead-end branch hanging off an island +that *does* reach pads is kept by an island-level pruner, and those are large +here — comparing each net's total copper against the shortest pad-to-pad path +through it: + +| net | total | shortest pad-to-pad path | spur | +|---|---|---|---| +| `/USB3-1.DP` | 72.86mm | 36.60mm | **36.26mm** | +| `/USB3-0.DP` | 70.11mm | 35.95mm | **34.17mm** | +| `/MIPI1.D2_P` | 68.08mm | 34.67mm | **33.41mm** | + +That is a ~34mm unterminated stub on a USB3 differential pair — a real +transmission-line defect, not just a bookkeeping error. + +`drc::spur_copper_mask` / `prune_spur_copper` implement the finer pass: iterated +leaf removal on the same-net contact graph, where a node is anchored if it is a +pad or pour. A non-anchored node of degree ≤ 1 cannot lie on a path between two +pads, so its copper goes and its neighbour's degree drops; repeat to a fixpoint. +Removing a leaf can never disconnect what remains, and the measurement agrees: +run over both boards it changed **neither** board's `UnconnectedNet` count +(human 291 → 291, ours 253 → 253) while dropping `NetIslands` on the human board +from 128 to 27. + +Applied to the routed board it recovers a claim: `vias_per_si_net` **3.106 → +2.626, HOLDS** — the spurs were carrying the vias that pushed it over. + +**It is deliberately not wired into `route_all` yet.** For a net the router only +partially reached, *every* piece of its copper is a dead end, so enabling the +pass reclassifies those nets as unrouted and moves `routability`. The existing +`chained_copper_anchored_through_via_kept` test pins the current island-level +contract precisely there (a chain reaching one pad, kept as a unit), and it +fails under the finer rule — correctly, because that chain really is a stub. +Reconciling the contract needs its own full-board before/after, so it is a +separate change rather than something smuggled in here. + +## What is left, and why compensation is still the wrong tool + +After both prunes and the receipt gate, `worst_intra_pair_skew` is **unchanged +at 38.521mm**. `/MIPI1.D3_N` holds 77.03mm of copper for a 34.54mm pad span, all +of it on pad-to-pad paths and reported `continuous` with 2/2 pads — so it is +neither dead copper nor a spur but a genuine redundant **cycle**: two parallel +routes between the same points, which leaf-pruning cannot touch because every +node has degree ≥ 2. That is the signature of a re-route that did not remove the +path it replaced. + +So the remaining skew is real excess copper on a real route, and the fix is +cycle removal (keep a spanning structure over the net's pads, drop the rest) plus +finding the stage that leaves the loop behind — not compensation, which at +0.276mm/mm would need ~138mm of run to absorb 38mm and would be adding copper to +a net that already has twice what it needs. + +## The receipt is now fail-closed on unrouted legs + +`si_claims` gained a fifth claim, `si_pairs_incomplete` (bound 0), and now +excludes a pair from the skew and coupling claims when either leg holds less +copper than its own pad span requires. Excluding without reporting would be +worse than the original bug — it would let a board pass by leaving the hard +pairs as stubs — so the exclusions are counted and bounded. + +The gate deliberately does **not** use DRC continuity. That was tried first and +is disqualifying: the CM5 fixture is reverse-engineered, its imported copper has +sub-tolerance gaps, and continuity therefore reports **25 of the human board's +49 pairs** as discontinuous, which would break the calibration anchor. The +length test reads only copper length and pad positions, so it is immune to that. + +The threshold is `MIN_LEG_SPAN_FRACTION = 0.5`, calibrated like every other +bound to the human board, whose thinnest leg is `/LPDDR4 RAM/DQS1_C_A` at 0.522 +(6.81mm across a 13.04mm span). It cannot be 1.0 even though a connected path +must span its pads, because copper runs pad-edge to pad-edge rather than +centre-to-centre and correctly routed legs land just under +(`/LPDDR4 RAM/DQS0_T_B` 0.999, `/HDMI0.TX0_P` 0.994). The ~4% margin is thin and +is the reason this is a screen for *absent* copper, not a length check. It is a +necessary, not sufficient, condition — `UnconnectedNet` remains the +authoritative completeness check; this only stops the receipt from *measuring* +legs that are plainly not routes. + +`examples/starved_legs` names the offending legs, the same way +`pair_coupled_fraction` names the pairs behind `min_pair_coupled_fraction`. + +### The anchor still passes, unchanged + +The whole point of the exercise, re-verified after every change: + +``` +worst_group_skew 9.756 mm <= 10.0 HOLDS +worst_intra_pair_skew 1.074 mm <= 1.1 HOLDS (over 49 pairs, 0 excluded) +si_pairs_incomplete 0 <= 0 HOLDS +min_pair_coupled_fraction 0.857 >= 0.5 HOLDS +vias_per_si_net 2.265 <= 3.0 HOLDS +verdict: ALL HOLD +``` + +Every original number is identical to M7's. **No bound was changed**, and the +stripped-fixture DRC baseline is also unchanged at 311 short/clearance, 23 +`Clearance` — the connectivity fixes do not move it. + +## `si_finish` was handing back a dirtier board than it was given + +Caught by the end-to-end validation run, and invisible any other way. The +same-net prune fixed inside `route_all` worked — the fresh route's prune removed +**2030 dead traces** where re-pruning the old saved board found 393 — yet the +final receipt was byte-identical to the unfixed baseline. + +The reason: `route_all` prunes as its last word, but `si_finish` runs *after* it, +and every one of its stages rips copper and re-routes it. Measured: **231 dead +traces and 52 dead vias** left behind, which `net_routed_length` counts as routed +copper. `si_finish` now prunes at its end, upholding the same invariant +`route_all` does. + +Worth stating as a lesson rather than a line of code: a prune placed anywhere but +last is a prune that a later stage undoes, and the receipt is what noticed. + +## Scoreboard + +Fresh full route on the fixed pipeline: 874.5s, routability **0.994**, +379 routed / 29 unrouted, 895 vias. + +| claim | M7 | M8 measured | bound | | +|---|---|---|---|---| +| `worst_group_skew` | 9.297mm | **3.579mm** | ≤ 10.0 | HOLDS | +| `worst_intra_pair_skew` | 37.853mm | 38.521mm | ≤ 1.1 | BROKEN | +| `si_pairs_incomplete` | *(did not exist)* | 9 | ≤ 0 | BROKEN | +| `min_pair_coupled_fraction` | 0.000 | 0.221 | ≥ 0.5 | BROKEN | +| `vias_per_si_net` | 2.766 | 3.106 → **2.626** with spur prune | ≤ 3.0 | BROKEN / HOLDS | + +**1 of 5 holds. The goal of this work — all claims HOLDS on the full board — was +not reached**, and the reason is recorded above rather than papered over: the +headline skew is one unfixed mechanism (redundant cycles), and reaching it by +compensation would have meant tuning a number computed over copper that is not a +route. + +One claim recovered decisively, a second recovered once the spur pass lands, and +one new claim makes the unrouted pairs visible instead of silently feeding the +other two. + +### DRC delta + +Against the stripped fixture (311 short/clearance, 23 `Clearance`): + +| rule | stripped | M7 full board | M8 fresh route | human | +|---|---|---|---|---| +| `Clearance` | 23 | 173 | **158** | 1044 | +| `NetIslands` | 30 | 93 | **17** | 128 | +| total | 847 | 3400 | **3164** | 14104 | + +Route-attributable `Clearance` is +135, down from +150, so acceptance on "no new +route-attributable DRC" is **still not met** — it is better, not zero. As the +human column shows, `MinDrill`, `AnnularRing` and `MinTraceWidth` fire on +essentially every via and thin trace on the human board too, so they are +imported-rule artifacts and `Clearance` is the number that attributes. + +## What did not work + +- **`descend_board` is inert on a full board: 0 of 41 pairs tuned, 41 + rejected.** M7 credits descent with driving reachable pairs to ~0.006mm, and + that reproduces in isolation — but every attempt is rejected by its + fail-closed clearance check once the board is full. The lever M7 named as the + answer to residual skew contributes exactly nothing at the size that matters. +- **Phase compensation is real but tiny in reach**: 3 pairs meandered + (`/ETH.1_P` 0.889 → 0.145mm, `/LPDDR4 RAM/DQS0_C_B` 0.872 → 0.029mm), with + **38 pairs still over tolerance**. It works only on pairs already close. +- **Coupling degrades after construction.** Construction couples 47 of 49, yet + by the end of routing 10 pairs sit below 0.5 and polish recovers only 4. The + loss happens in the negotiation / rip-up stages, which re-route pair legs + independently. M7 framed the limit as polish's success rate; the earlier and + larger effect is that the board *un-couples* work already done. +- **DRC continuity as the receipt's routed-ness gate** — disqualifying on this + fixture, as above (25 of 49 human pairs). +- **Wiring the spur prune into `route_all`** — sound, but it moves the + routability contract, so it is staged separately. +- **Chasing the four claims to HOLDS on this board would have been false.** With + legs unrouted and the pair claims blind to it, a green receipt would assert an + SI envelope over copper that is not a route. + +## Next + +1. Redundant-cycle removal on signal nets — the last mechanism behind the + 38.5mm skew, and the only one left that compensation would otherwise be + pointed at. `/MIPI1.D3_N` (77.03mm of copper, 34.54mm span, continuous, 2/2 + pads) is the reproducible target. +2. Find the stage that re-routes a pair leg without removing the path it + replaced. The cycle is the evidence; the culprit is upstream of the pruners. +3. Wire in `prune_spur_copper` with the routability contract reconciled, which + lands `vias_per_si_net` for real. +4. Stop the negotiation / rip-up stages from un-coupling pairs that construction + already coupled — 47 coupled becomes 10 below 0.5 with no stage claiming + responsibility.