From b50ffa9dbe4567e89c26728bb2cec827bcaa9f41 Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Sat, 25 Jul 2026 12:33:33 -0400 Subject: [PATCH 1/4] =?UTF-8?q?docs(router):=20M8=20re-measurement=20?= =?UTF-8?q?=E2=80=94=20the=20SI=20skew=20claim=20was=20measuring=20stub=20?= =?UTF-8?q?copper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-measured the full CM5 SI receipt on e1a5d90d (post-#684) to check whether the two broken claims had moved. They moved, and the run also invalidated the reading of the skew claim, so this supersedes M7's "two blocker groups". Three of four claims now break: vias_per_si_net regressed through its bound (2.766 -> 3.128). worst_intra_pair_skew is 38.521mm, and it is not detour skew — seven pair legs are not routed. Measured copper length against each net's own straight pad-to-pad span, a ratio needing no connectivity model: human board 0 of 86 legs below 50%, ours 7 of 84, minimum 0.01 (/USB3-0.TX_P carries 0.23mm for an 18.36mm span). /MIPI1.D1_P is four disjoint fragments, the largest a floating 4.234mm stub, beside a complete 38.57mm twin. Two gaps let that reach the receipt: si_claims gates "measured" on net_routed_length > 0, and coupled_fraction normalizes by P alone, so a stub beside a full twin scores 1.000 — the metric rewards the failure. The DRC misses it too: each starved leg is bridged by copper it is SHORTED to, and the connectivity walk crosses the short. Also recorded, with the human-board control M7 lacked: MinDrill/AnnularRing/ MinTraceWidth fire on nearly every via and thin trace on the human board too, so Clearance is the attributable rule — stripped 23 -> ours 173, i.e. +150. What did not work: descend_board tunes 0 of 41 pairs on a full board (all rejected by its fail-closed check) despite reaching ~0.006mm in isolation; phase compensation fixes 3 pairs and leaves 38 over tolerance; and coupling degrades after construction (47 coupled, 10 below 0.5 by the end) because negotiation re-routes pair legs independently. No bounds were touched. The human board re-verifies ALL HOLD, unchanged. Co-Authored-By: Claude Opus 5 --- docs/gpu-router-m7-pair-si.md | 156 ++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/docs/gpu-router-m7-pair-si.md b/docs/gpu-router-m7-pair-si.md index b6bd9a62..f59dc514 100644 --- a/docs/gpu-router-m7-pair-si.md +++ b/docs/gpu-router-m7-pair-si.md @@ -222,3 +222,159 @@ 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. + +Two mechanisms let this reach the receipt unflagged: + +1. **`net_routed_length > 0` is the gate for "measured".** A 0.23mm stub + qualifies a pair for both pair claims. `si_claims` never asks whether the + copper connects the net's pads. +2. **`coupled_fraction` normalizes by P alone** — "fraction of P length with + same-layer N copper within 1.75x pitch". A 0.23mm stub lying beside a full + twin is 100% coupled. The metric *rewards* the failure: `/MIPI1.D3_P` scores + `coupled 1.000` at 38.52mm of skew. + +The DRC does not catch it either. None of the seven is reported +`UnconnectedNet` or `Disjoint`, because each is bridged by copper it is +**shorted** to — `/MIPI1.D1_P` shorts to `/USB3-0.RX_N` and `/MIPI1.C_N`, and +the connectivity walk crosses the short. A net continuous only through a short +is being counted as connected. + +### DRC delta, with the control M7 was missing + +Against the same fixture stripped of all copper (the post-#684 baseline: +311 short/clearance, 23 `Clearance`): + +| rule | stripped | ours (M8) | human | verdict | +|---|---|---|---|---| +| `Clearance` | 23 | **173** | 1044 | +150 route-attributable | +| `Short` | 258 | 845 | 3132 | +587, but see M7's accounting note | +| `MinTraceWidth` | 0 | 380 | 2690 | 0.08mm stub copper vs 0.2mm rule | +| `MinDrill` | 0 | 767 | 2901 | fires on ~every via, human included | +| `AnnularRing` | 0 | 767 | 2847 | same | + +The human-board column is the control M7 lacked, and it settles the +attribution question: `MinDrill`, `AnnularRing` and `MinTraceWidth` fire on +essentially every via and thin trace on the *human* board too, so they are +imported-rule artifacts rather than route defects. `Clearance` is the +attributable number, and **+150 is not zero** — the route does add +clearance violations at full-board scale. + +### 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. +- **Chasing the four claims to HOLDS on this board would be false.** With + seven legs unrouted and the pair claims blind to it, a green receipt here + would assert an SI envelope over copper that is not a route. Compensation + tuning, higher-effort polish and wider corridor rips all move the number + without touching the cause. + +### The actual next step + +Not SI tuning. Three fail-closed gaps, in dependency order: + +1. **Router**: do not report a net routed when only escapes and orphan stubs + were placed; and stop negotiation from leaving one leg of a coupled pair + starved. This is the defect — the other two are the reasons it stayed + invisible. +2. **Connectivity**: a net continuous only through a `Short` must not satisfy + the connectivity check. Today it does, which is what let seven starved legs + read as complete. +3. **Receipt**: `si_claims` must fail closed on a starved leg rather than + measure it (gate on copper reaching every pad of both legs, not + `length > 0`), and `coupled_fraction` must normalize by the longer leg so a + stub cannot score 1.000. Both changes make the numbers worse, which is the + point — the human board still passes them, so the envelope argument + survives. + +292 `vcad-ecad-pcb` tests pass and clippy is clean throughout the above, which +is its own finding: nothing in the suite asserts that a routed net's copper +reaches its pads. From b6aae4b6d05fb9af680754900201f3256b664d22 Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Sat, 25 Jul 2026 13:03:08 -0400 Subject: [PATCH 2/4] fix(ecad): judge copper anchoring per net, and fail the SI receipt closed on unrouted legs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from re-measuring the full-board CM5 SI receipt, each fixed or staged with the measurement that motivates it. No bound was changed; the human board re-verifies ALL HOLD with every original number identical. 1. The pruner was defeated by shorts. `dangling_copper_mask` is documented as dropping copper whose island touches no pad "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 saw Y's pads and survived. On a board with 845 shorts that is not a corner case. Judging over same-net connectivity (build_same_net_dsu, built from the contact list the global pass already produces, so no extra broadphase; net ties still connect) removes 393 dead traces and 52 dead vias from the routed CM5 while UnconnectedNet holds at 253 — nothing lost a connection. worst_group_skew falls 8.397mm -> 3.579mm, Clearance 173 -> 150. UnconnectedNet gets the same graph, for the same reason: a short is not a substitute for a route. 2. The receipt measured legs that are not routes. si_claims gated "measured" on net_routed_length > 0, which sums any copper carrying the net name, so a pad escape qualified — /USB3-0.TX_P was scored as a routed pair with 0.23mm of copper across an 18.36mm pad span, while the DRC was already reporting it UnconnectedNet. coupled_fraction then normalizes by P alone, so a stub beside a full twin scores 1.000: /MIPI1.D3_P read coupled 1.000 at 38.52mm of skew. The metric rewarded the failure. Now a pair is excluded when either leg holds less copper than its own pad span requires, and the exclusions are counted and bounded by a new si_pairs_incomplete claim — excluding silently would be worse than the original bug, since a board could pass by leaving the hard pairs as stubs. DRC continuity was tried as the gate first and is disqualifying: the fixture is reverse-engineered, and continuity calls 25 of the HUMAN board's 49 pairs discontinuous. The length test reads only copper length and pad positions. Threshold 0.5 is calibrated to the anchor's thinnest leg (0.522). 3. Spurs, staged not shipped. Dead-end branches hanging off live islands are invisible to an island-level pruner and large here: /USB3-1.DP carried 72.86mm whose shortest pad-to-pad path is 36.60mm, so 36.26mm was stub — a real unterminated stub on a USB3 pair, not just bad bookkeeping. spur_copper_mask implements iterated leaf removal and is sound (it changes neither board's UnconnectedNet count) and recovers vias_per_si_net 3.106 -> 2.626, but it is NOT wired into route_all: for a partially-reached net every piece of copper is a dead end, so it reclassifies those nets as unrouted and moves routability. That contract change needs its own full-board before/after. Remaining: worst_intra_pair_skew is still 38.521mm. /MIPI1.D3_N holds 77.03mm for a 34.54mm span, continuous with 2/2 pads and all of it on pad-to-pad paths — a redundant cycle, the signature of a re-route that never removed the path it replaced. Compensation is the wrong tool for it either way. Co-Authored-By: Claude Opus 5 --- crates/vcad-ecad-pcb/examples/prune_spurs.rs | 20 ++ crates/vcad-ecad-pcb/examples/starved_legs.rs | 61 +++++ crates/vcad-ecad-pcb/src/drc.rs | 217 +++++++++++++++- crates/vcad-ecad-pcb/src/router/legalize.rs | 9 + crates/vcad-ecad-pcb/src/router/si_claims.rs | 98 +++++++- docs/gpu-router-m7-pair-si.md | 236 +++++++++++++----- 6 files changed, 574 insertions(+), 67 deletions(-) create mode 100644 crates/vcad-ecad-pcb/examples/prune_spurs.rs create mode 100644 crates/vcad-ecad-pcb/examples/starved_legs.rs 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..ba1c3703 --- /dev/null +++ b/crates/vcad-ecad-pcb/examples/starved_legs.rs @@ -0,0 +1,61 @@ +//! 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 3b277040..0c6f0861 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) @@ -5424,4 +5577,50 @@ 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 da3f5dcc..96dd8825 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/si_claims.rs b/crates/vcad-ecad-pcb/src/router/si_claims.rs index 70964252..dc602c5f 100644 --- a/crates/vcad-ecad-pcb/src/router/si_claims.rs +++ b/crates/vcad-ecad-pcb/src/router/si_claims.rs @@ -40,6 +40,11 @@ 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. + pub max_incomplete_pairs: f64, } impl Default for SiBounds { @@ -55,6 +60,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, } } } @@ -95,10 +103,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. @@ -185,9 +254,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)); @@ -200,7 +280,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 f59dc514..11e2c557 100644 --- a/docs/gpu-router-m7-pair-si.md +++ b/docs/gpu-router-m7-pair-si.md @@ -300,43 +300,171 @@ So the reported skew is very nearly *the length of the leg that did route*: of 33.5mm. Compensation cannot close this and should never have been aimed at it — there is no second leg to match. -Two mechanisms let this reach the receipt unflagged: - -1. **`net_routed_length > 0` is the gate for "measured".** A 0.23mm stub - qualifies a pair for both pair claims. `si_claims` never asks whether the - copper connects the net's pads. -2. **`coupled_fraction` normalizes by P alone** — "fraction of P length with - same-layer N copper within 1.75x pitch". A 0.23mm stub lying beside a full - twin is 100% coupled. The metric *rewards* the failure: `/MIPI1.D3_P` scores - `coupled 1.000` at 38.52mm of skew. +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: -The DRC does not catch it either. None of the seven is reported -`UnconnectedNet` or `Disjoint`, because each is bridged by copper it is -**shorted** to — `/MIPI1.D1_P` shorts to `/USB3-0.RX_N` and `/MIPI1.C_N`, and -the connectivity walk crosses the short. A net continuous only through a short -is being counted as connected. +``` +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 +``` -### DRC delta, with the control M7 was missing +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. -Against the same fixture stripped of all copper (the post-#684 baseline: -311 short/clearance, 23 `Clearance`): +## Scoreboard -| rule | stripped | ours (M8) | human | verdict | +| claim | M7 | M8 measured | bound | | |---|---|---|---|---| -| `Clearance` | 23 | **173** | 1044 | +150 route-attributable | -| `Short` | 258 | 845 | 3132 | +587, but see M7's accounting note | -| `MinTraceWidth` | 0 | 380 | 2690 | 0.08mm stub copper vs 0.2mm rule | -| `MinDrill` | 0 | 767 | 2901 | fires on ~every via, human included | -| `AnnularRing` | 0 | 767 | 2847 | same | +| `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 | -The human-board column is the control M7 lacked, and it settles the -attribution question: `MinDrill`, `AnnularRing` and `MinTraceWidth` fire on -essentially every via and thin trace on the *human* board too, so they are -imported-rule artifacts rather than route defects. `Clearance` is the -attributable number, and **+150 is not zero** — the route does add -clearance violations at full-board scale. +Two claims recovered (`worst_group_skew` decisively, `vias_per_si_net` once the +spur pass lands), one new claim now makes the unrouted pairs visible instead of +silently feeding the other two, and the headline skew is diagnosed to a +reproducible cause with a named fix. -### What did not work +## 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 @@ -351,30 +479,24 @@ clearance violations at full-board scale. 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. -- **Chasing the four claims to HOLDS on this board would be false.** With - seven legs unrouted and the pair claims blind to it, a green receipt here - would assert an SI envelope over copper that is not a route. Compensation - tuning, higher-effort polish and wider corridor rips all move the number - without touching the cause. - -### The actual next step - -Not SI tuning. Three fail-closed gaps, in dependency order: - -1. **Router**: do not report a net routed when only escapes and orphan stubs - were placed; and stop negotiation from leaving one leg of a coupled pair - starved. This is the defect — the other two are the reasons it stayed - invisible. -2. **Connectivity**: a net continuous only through a `Short` must not satisfy - the connectivity check. Today it does, which is what let seven starved legs - read as complete. -3. **Receipt**: `si_claims` must fail closed on a starved leg rather than - measure it (gate on copper reaching every pad of both legs, not - `length > 0`), and `coupled_fraction` must normalize by the longer leg so a - stub cannot score 1.000. Both changes make the numbers worse, which is the - point — the human board still passes them, so the envelope argument - survives. - -292 `vcad-ecad-pcb` tests pass and clippy is clean throughout the above, which -is its own finding: nothing in the suite asserts that a routed net's copper -reaches its pads. +- **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. From 7e8aa1156c06c7a13f2f3f002902e1f7943e7cb4 Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Sat, 25 Jul 2026 13:24:21 -0400 Subject: [PATCH 3/4] fix(router): si_finish prunes the copper its own rip-and-reroute stages strand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit route_all prunes dangling copper as its last word, but si_finish runs after it, and polish_pairs / descend_board / meander_pair_skew all rip copper and re-route it. So the board si_finish handed back was dirtier than the one it was given: measured on the full CM5, 231 dead traces and 52 dead vias, which net_routed_length counts as routed copper. Caught by the end-to-end run and invisible without it. The same-net anchoring fix was working inside route_all — the fresh route's prune dropped 2030 dead traces where re-pruning the saved board found 393 — yet the final receipt came out byte-identical to the unfixed baseline, because everything route_all cleaned up was replaced afterwards. Fresh full route, 874.5s, routability 0.994: worst_group_skew 9.297mm -> 3.579mm, Clearance 173 -> 158 (route-attributable +150 -> +135), NetIslands 93 -> 17. Co-Authored-By: Claude Opus 5 --- crates/vcad-ecad-pcb/src/router/mod.rs | 15 ++++++++ docs/gpu-router-m7-pair-si.md | 48 +++++++++++++++++++++++--- 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/crates/vcad-ecad-pcb/src/router/mod.rs b/crates/vcad-ecad-pcb/src/router/mod.rs index c92a31e3..dc3541d2 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/docs/gpu-router-m7-pair-si.md b/docs/gpu-router-m7-pair-si.md index 11e2c557..60de19d6 100644 --- a/docs/gpu-router-m7-pair-si.md +++ b/docs/gpu-router-m7-pair-si.md @@ -449,8 +449,27 @@ 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 | @@ -459,10 +478,31 @@ stripped-fixture DRC baseline is also unchanged at 311 short/clearance, 23 | `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 | -Two claims recovered (`worst_group_skew` decisively, `vias_per_si_net` once the -spur pass lands), one new claim now makes the unrouted pairs visible instead of -silently feeding the other two, and the headline skew is diagnosed to a -reproducible cause with a named fix. +**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 From 49714ac0f2eb2e04adc54b043161dd1c87fc1bc9 Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Sat, 25 Jul 2026 13:25:10 -0400 Subject: [PATCH 4/4] style(ecad): cargo fmt Co-Authored-By: Claude Opus 5 --- crates/vcad-ecad-pcb/examples/starved_legs.rs | 4 ++- crates/vcad-ecad-pcb/src/drc.rs | 27 ++++++++++++++----- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/crates/vcad-ecad-pcb/examples/starved_legs.rs b/crates/vcad-ecad-pcb/examples/starved_legs.rs index ba1c3703..a3f432d1 100644 --- a/crates/vcad-ecad-pcb/examples/starved_legs.rs +++ b/crates/vcad-ecad-pcb/examples/starved_legs.rs @@ -7,7 +7,9 @@ use vcad_ir::ecad::Pcb; use vcad_ir::Vec2; fn main() { - let path = std::env::args().nth(1).expect("usage: starved_legs "); + 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") diff --git a/crates/vcad-ecad-pcb/src/drc.rs b/crates/vcad-ecad-pcb/src/drc.rs index 0c6f0861..7d5817be 100644 --- a/crates/vcad-ecad-pcb/src/drc.rs +++ b/crates/vcad-ecad-pcb/src/drc.rs @@ -5590,13 +5590,28 @@ mod tests { 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")); + 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")); + 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,