app
Adom Concur
Public Made by Adomby adom
Cross-source consensus check for chip libraries — scores whether the KiCad, EAGLE, and Altium libraries AND the adom-ds2sf datasheet extraction all AGREE on pins, pads, names, and package, across seve
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916
//! Cross-source comparison engine.
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use crate::parsers::Part;
use crate::result::Variant;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Verdict {
Agree,
/// A source is missing this axis (e.g. ds2sf-only had no kicad_sym to compare).
/// Treated as informational, NOT a divergence.
SourceMissing,
Disagree,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AxisOutcome {
pub axis: String,
pub verdict: Verdict,
/// Per-source value for this axis, as a stringified summary.
pub values: BTreeMap<String, String>,
/// Specific conflicts found (pin numbers, dim diffs, etc.).
pub conflicts: Vec<String>,
/// Severity 1..3. 1 = informational, 2 = soft (recoverable / non-blocking),
/// 3 = hard (almost certainly bad data — the wrong variant landed in the lib).
pub severity: u8,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComparisonReport {
pub axes: Vec<AxisOutcome>,
pub overall: Verdict,
pub variants: Vec<Variant>,
}
/// Run all axis checks across the loaded parts. Sources with `pads.is_empty()`
/// AND `pins.is_empty()` AND no package — i.e. the parser found nothing — are
/// excluded from comparison. They're treated as "this format wasn't available
/// for this chip" rather than "this format disagreed."
pub fn compare(parts: &[Part]) -> ComparisonReport {
let parts: Vec<&Part> = parts
.iter()
.filter(|p| !p.pins.is_empty() || !p.pads.is_empty() || p.package.is_some())
.collect();
let mut axes = Vec::new();
axes.push(axis_pad_count(&parts));
axes.push(axis_pad_numbers(&parts));
axes.push(axis_symbol_pin_count(&parts));
axes.push(axis_pin_names(&parts));
axes.push(axis_pin_to_pad_map(&parts));
axes.push(axis_package_family(&parts));
axes.push(axis_body_dims(&parts));
let overall = if axes.iter().any(|a| a.verdict == Verdict::Disagree) {
Verdict::Disagree
} else {
Verdict::Agree
};
let variants = detect_variants(&parts, &axes);
ComparisonReport { axes, overall, variants }
}
// ── variant detection ─────────────────────────────────────────────────────
//
// A variant is a known-benign explanation for one or more disagreeing axes.
// The status-determination logic in cli.rs uses Variant::covers() to decide
// whether every disagreeing axis is explained — if yes, status becomes
// `golden_with_variants` (exit 0); if not, `divergent` (exit 2).
fn detect_variants(parts: &[&Part], axes: &[AxisOutcome]) -> Vec<Variant> {
let mut variants = Vec::new();
let pad_count_disagree = axes.iter().any(|a| a.axis == "pad_count" && a.verdict == Verdict::Disagree);
let package_family_disagree = axes.iter().any(|a| a.axis == "package_family" && a.verdict == Verdict::Disagree);
let body_dims_disagree = axes.iter().any(|a| a.axis == "body_dims" && a.verdict == Verdict::Disagree);
let pin_names_disagree = axes.iter().any(|a| a.axis == "pin_names" && a.verdict == Verdict::Disagree);
// ── Variant 1: ExposedPad / ModuleExtraPads ───────────────────────────
if pad_count_disagree {
let parts_with_pads: Vec<&&Part> = parts.iter().filter(|p| !p.pads.is_empty()).collect();
if parts_with_pads.len() >= 2 {
let counts: Vec<u32> = parts_with_pads.iter().map(|p| p.pads.len() as u32).collect();
let min_c = *counts.iter().min().unwrap();
let max_c = *counts.iter().max().unwrap();
let with_max: Vec<String> = parts_with_pads.iter()
.filter(|p| p.pads.len() as u32 == max_c)
.map(|p| p.source.clone()).collect();
let with_min: Vec<String> = parts_with_pads.iter()
.filter(|p| p.pads.len() as u32 == min_c)
.map(|p| p.source.clone()).collect();
if max_c - min_c == 1 {
variants.push(Variant::ExposedPadVariant {
sources_with_ep: with_max,
sources_without_ep: with_min,
});
} else if max_c > min_c {
// GUARD: a big pad-count gap is only a benign "module extras"
// variant when the SMALLER-source's package name carries a
// module marker. Otherwise — like an FPGA where ds2sf only
// got half the pins — it's a real undercount divergence and
// should NOT be auto-explained as a variant.
//
// Real-world catch: ICE40UP5K v0.3 saw ds2sf=24 pads, kicad=48
// pads (FPGA, not a module). v0.3 wrongly fired module_extras
// and reported golden_with_variants when it should have been
// divergent. v0.4 only fires module_extras when the smaller-
// source claims a module package name.
let smaller_is_module = parts_with_pads
.iter()
.filter(|p| p.pads.len() as u32 == min_c)
.any(|p| p.package.as_ref()
.map(|pkg| is_module_package(&pkg.raw_name))
.unwrap_or(false));
if smaller_is_module {
variants.push(Variant::ModuleExtraPads {
sources_with_extras: with_max,
sources_without_extras: with_min,
extra_count: max_c - min_c,
});
}
// else: leave the disagreement uncovered → status=divergent.
}
}
}
// ── Variant 2: LabelOnlyMismatch ──────────────────────────────────────
// package_family disagrees but the underlying geometry agrees (or the
// pad-count diff is already explained by ep/module-extras variants).
let geometry_agrees = {
let pad_count_agrees = axes.iter().any(|a| a.axis == "pad_count" && a.verdict == Verdict::Agree);
let pad_numbers_agrees = axes.iter().any(|a| a.axis == "pad_numbers" && a.verdict == Verdict::Agree);
let ep_or_module_explained = variants.iter().any(|v| matches!(
v, Variant::ExposedPadVariant { .. } | Variant::ModuleExtraPads { .. }
));
(pad_count_agrees && pad_numbers_agrees) || ep_or_module_explained
};
if package_family_disagree && geometry_agrees {
let pf = axes.iter().find(|a| a.axis == "package_family").unwrap();
let notes = pf
.values
.iter()
.map(|(s, v)| format!("{s}: {v}"))
.collect::<Vec<_>>()
.join(" | ");
variants.push(Variant::LabelOnlyMismatch { notes });
}
// ── Variant 3: StubSymbolPinNames ─────────────────────────────────────
if pin_names_disagree {
let mut stubbed = Vec::new();
let mut canonical = Vec::new();
for p in parts {
if p.pins.is_empty() { continue; }
let stub_count = p.pins.iter().filter(|pp| pp.is_stub_name).count();
if stub_count > p.pins.len() / 2 {
stubbed.push(p.source.clone());
} else {
canonical.push(p.source.clone());
}
}
if !stubbed.is_empty() && !canonical.is_empty() {
variants.push(Variant::StubSymbolPinNames {
stub_sources: stubbed,
canonical_sources: canonical,
});
}
}
// ── Variant 4: BodyDimMeasurementArtifact ─────────────────────────────
// body_dims disagrees but geometry (pad layout) agrees — body dim is
// courtyard-only, doesn't affect placement.
if body_dims_disagree && geometry_agrees {
let bd = axes.iter().find(|a| a.axis == "body_dims").unwrap();
let notes = bd
.values
.iter()
.map(|(s, v)| format!("{s}: {v}"))
.collect::<Vec<_>>()
.join(" | ");
variants.push(Variant::BodyDimMeasurementArtifact { notes });
}
// ── Variant 5b: KelvinSenseRepresentation ────────────────────────────
// 4-terminal Kelvin sense resistors (Vishay WSL, Bourns CSL, Susumu
// PCS, etc) are sometimes represented as 4 pads (I+/V+/V-/I- — the
// canonical precision-measurement form) and sometimes as 2 pads
// (lossy non-precision form). Both are valid for routing; the
// 4-pad form is just better for accurate current sensing. If one
// source has 4 pads with Kelvin pin names and another has 2 pads,
// explain the divergence as a representation choice rather than
// ds2sf-misread or wrong-variant.
if pad_count_disagree {
if let Some(kvariant) = detect_kelvin_sense_split(parts) {
variants.push(kvariant);
}
}
// ── Variant 6: NamingConventionMismatch ──────────────────────────────
// pin_names disagree but EVERY disagreeing pin pair is a benign naming-
// convention difference (digit-position swap OR power-pin alias). If even
// one pair has real-content disagreement, this variant doesn't fire and
// the divergence bubbles up.
if pin_names_disagree {
if let Some(examples) = detect_naming_convention_only(parts) {
variants.push(Variant::NamingConventionMismatch { examples });
}
}
variants
}
/// Detect the Kelvin-sense-vs-2-terminal-resistor representation split.
/// Triggers when:
/// - Some source has exactly 4 pads with names matching the Kelvin set
/// (I+, I-, V+, V- in any order — slashes/dashes/Unicode-minus normalized)
/// - Another source has exactly 2 pads (numeric "1"/"2" or any pair)
/// - Body sizes are roughly comparable (both within a 2010/2512-class size)
fn detect_kelvin_sense_split(parts: &[&Part]) -> Option<crate::result::Variant> {
use std::collections::HashSet;
let kelvin_target: HashSet<&str> = ["I+", "I-", "V+", "V-"].into_iter().collect();
let mut sources_with_kelvin: Vec<String> = Vec::new();
let mut sources_without_kelvin: Vec<String> = Vec::new();
let mut found_kelvin_names: Vec<String> = Vec::new();
for p in parts {
if p.pads.is_empty() && p.pins.is_empty() { continue; }
// Kelvin source: 4 pads/pins AND the names match the Kelvin set.
let has_4 = p.pads.len() == 4 || p.pins.len() == 4;
let has_2 = p.pads.len() == 2 || p.pins.len() == 2;
let names: HashSet<String> = if !p.pins.is_empty() {
p.pins.iter().map(|pp| canon_kelvin_name(&pp.name)).collect()
} else {
// No symbol-side names available; we can't classify.
continue;
};
let kelvin_match_count = names.iter()
.filter(|n| kelvin_target.contains(n.as_str()))
.count();
if has_4 && kelvin_match_count >= 3 {
sources_with_kelvin.push(p.source.clone());
if found_kelvin_names.is_empty() {
found_kelvin_names = p.pins.iter().map(|pp| pp.name.clone()).collect();
}
} else if has_2 {
sources_without_kelvin.push(p.source.clone());
}
}
if !sources_with_kelvin.is_empty() && !sources_without_kelvin.is_empty() {
Some(crate::result::Variant::KelvinSenseRepresentation {
sources_with_kelvin,
sources_without_kelvin,
kelvin_pin_names: found_kelvin_names,
})
} else {
None
}
}
/// Canonicalize a Kelvin pin name for set-membership checks.
/// Maps "I+", "I-", "V+", "V-" through dash/Unicode-minus variants.
fn canon_kelvin_name(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.trim().chars() {
match c {
'−' | '–' | '—' => out.push('-'), // Unicode minus → ASCII minus
'_' => out.push('-'), // sometimes "I_" used for "I-"
_ => out.push(c.to_ascii_uppercase()),
}
}
out
}
/// Returns Some(examples) if every per-pin name disagreement between any two
/// sources is a benign naming-convention swap (digit-position OR power alias).
/// Returns None on the first real-content disagreement.
fn detect_naming_convention_only(parts: &[&Part]) -> Option<Vec<String>> {
use std::collections::BTreeMap;
let maps: Vec<(String, BTreeMap<String, String>)> = parts
.iter()
.filter(|p| {
!p.pins.is_empty()
&& p.pins.iter().filter(|pp| pp.is_stub_name).count() <= p.pins.len() / 2
})
.map(|p| {
let m: BTreeMap<String, String> = p
.pins
.iter()
.map(|pp| (canon_pin_num(&pp.number), canon_pin_name(&pp.name)))
.collect();
(p.source.clone(), m)
})
.collect();
if maps.len() < 2 { return None; }
let mut examples: Vec<String> = Vec::new();
let (first_src, first_map) = &maps[0];
for (src, m) in &maps[1..] {
for (num, name1) in first_map {
let Some(name2) = m.get(num) else { continue; };
if name1 == name2 { continue; }
// Names differ — is it just a digit-position permutation?
// Acceptable if any of: (a) digit-position permutation,
// (b) both names are aliases of the same canonical power pin,
// (c) names match after stripping all separators (SPI_CS1 ↔
// SPICS1, MUX_SEL ↔ MUXSEL — common UL-vs-datasheet-vs-vendor
// convention difference).
if !same_digit_letter_multiset(name1, name2)
&& !same_power_pin_alias(name1, name2)
&& !same_with_separators_stripped(name1, name2)
&& !same_active_low_notation(name1, name2)
&& !same_thermal_pad(name1, name2)
{
return None;
}
if examples.len() < 4 {
examples.push(format!("pin {num}: {first_src}={name1} ↔ {src}={name2}"));
}
}
}
if examples.is_empty() { None } else { Some(examples) }
}
/// True if `a` and `b` have the same multiset of digit-runs and letter-runs
/// when each is tokenized into maximal letter / digit / underscore-or-other
/// chunks. So `OUT1` (["OUT","1"]) matches `1OUT` (["1","OUT"]). `IN1_` and
/// `1IN_` also match. But `P108` and `P400` don't (different digit runs).
fn same_digit_letter_multiset(a: &str, b: &str) -> bool {
let mut ta = tokenize_alnum(a);
let mut tb = tokenize_alnum(b);
ta.sort();
tb.sort();
ta == tb
}
/// True if `a` and `b` are both aliases of the same canonical power pin.
/// Handles the common chip-vendor-vs-library naming differences for supplies
/// and ground (single-supply convention vs dual-supply convention).
fn same_power_pin_alias(a: &str, b: &str) -> bool {
let ca = canonical_power_alias(a);
let cb = canonical_power_alias(b);
ca.is_some() && ca == cb
}
/// True when `a` and `b` are equal after stripping all `_` separators.
/// e.g. `SPI_CS1` ↔ `SPICS1`, `MUX_SEL` ↔ `MUXSEL`. Underscore-vs-no-
/// underscore is a UL-bundle-vs-datasheet convention difference that
/// doesn't change pin identity.
fn same_with_separators_stripped(a: &str, b: &str) -> bool {
let strip = |s: &str| -> String {
s.chars().filter(|c| *c != '_').collect()
};
let sa = strip(a);
let sb = strip(b);
!sa.is_empty() && sa == sb
}
/// True when `a` and `b` are the same signal in different **active-low
/// notations**. Datasheets/ds2sf write the bare name (`CS`, `FAULT`) or an
/// `N`-prefix (`NFAULT`); KiCad/EAGLE/Altium libraries mark active-low with an
/// `_N`/`_B`/`#` suffix or an overbar (`~{CS}`, `!CS`, `C\S\`). On a same-pin-
/// number comparison these denote one pin, so the difference is a benign
/// convention, not a conflict. Conservative: only fires when one side carries
/// an explicit negation marker that, once removed, matches the other side
/// (optionally accounting for the bare `N`-prefix form).
fn same_active_low_notation(a: &str, b: &str) -> bool {
// Strip overbar markup, then ONE trailing active-low suffix.
let neg_strip = |s: &str| -> String {
let mut t: String = s.chars().filter(|c| !matches!(c, '~' | '{' | '}' | '!' | '\\')).collect();
for suf in ["_N", "_B", "#", "_L"] {
if let Some(p) = t.strip_suffix(suf) { t = p.to_string(); break; }
}
t
};
if a == b { return false; }
let (na, nb) = (neg_strip(a), neg_strip(b));
// One side had an explicit marker and now matches the other.
if na.len() >= 2 && na == nb { return true; }
// `N`-prefix (NFAULT) vs `_N`-suffix base (FAULT_N → FAULT).
let lead_n = |x: &str, base: &str| x.len() > 2 && x.starts_with('N') && &x[1..] == base && base.len() >= 2;
lead_n(&na, &nb) || lead_n(&nb, &na)
}
/// True when both names denote the exposed/thermal pad — vendors, KiCad and
/// EAGLE/Altium each spell it differently (`PAD`, `THERMAL_PAD`, `EP`, `EPAD`…)
/// but it's the same physical pad, so the difference is benign.
fn same_thermal_pad(a: &str, b: &str) -> bool {
let is_pad = |s: &str| {
let u = s.replace('_', "");
matches!(u.as_str(),
"PAD" | "THERMALPAD" | "EP" | "EPAD" | "EXPPAD" | "EXPAD" | "GNDPAD"
| "PPAD" | "POWERPAD" | "DAP" | "TAB" | "HEATSINK" | "THERMAL")
};
is_pad(a) && is_pad(b)
}
fn canonical_power_alias(name: &str) -> Option<&'static str> {
match name {
// Positive supplies — `V+` is the chip-vendor convention; VCC/VDD are
// library/silkscreen conventions.
"V+" | "VPLUS" | "V_PLUS" | "VCC" | "VDD" | "VPP" | "VSUP" => Some("VCC"),
// Negative supplies / ground.
"V_" | "V-" | "VMINUS" | "V_MINUS" | "GND" | "VSS" | "VEE" => Some("GND"),
_ => None,
}
}
/// Split into runs of letters and runs of digits. Non-alphanumeric chars
/// (`+`, `_`, `-`, `/`) are treated as separators and dropped — so `IN1+`
/// and `1IN+` tokenize identically to ["IN","1"] / ["1","IN"], which sort
/// to the same multiset.
fn tokenize_alnum(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut buf = String::new();
let mut last_kind: Option<bool> = None;
for c in s.chars() {
if !c.is_ascii_alphanumeric() {
if !buf.is_empty() {
out.push(std::mem::take(&mut buf));
last_kind = None;
}
continue;
}
let kind = c.is_ascii_digit();
if last_kind != Some(kind) && !buf.is_empty() {
out.push(std::mem::take(&mut buf));
}
buf.push(c);
last_kind = Some(kind);
}
if !buf.is_empty() { out.push(buf); }
out
}
// ── individual axes ────────────────────────────────────────────────────────
fn axis_pad_count(parts: &[&Part]) -> AxisOutcome {
let mut values = BTreeMap::new();
let mut counts: Vec<u32> = Vec::new();
for p in parts {
if !p.pads.is_empty() {
values.insert(p.source.clone(), p.pads.len().to_string());
counts.push(p.pads.len() as u32);
} else {
values.insert(p.source.clone(), "(no footprint loaded)".to_string());
}
}
if counts.len() < 2 {
return AxisOutcome {
axis: "pad_count".into(),
verdict: Verdict::SourceMissing,
values, conflicts: vec![],
severity: 1,
};
}
let unique: std::collections::HashSet<_> = counts.iter().collect();
let verdict = if unique.len() == 1 { Verdict::Agree } else { Verdict::Disagree };
let conflicts = if verdict == Verdict::Disagree {
vec![format!("Pad counts differ across sources: {:?}", counts)]
} else { vec![] };
AxisOutcome { axis: "pad_count".into(), verdict, values, conflicts, severity: 3 }
}
fn axis_pad_numbers(parts: &[&Part]) -> AxisOutcome {
let mut values = BTreeMap::new();
let mut sets: Vec<(String, std::collections::BTreeSet<String>)> = Vec::new();
for p in parts {
if p.pads.is_empty() { continue; }
let s: std::collections::BTreeSet<String> =
p.pads.iter().map(|p| canon_pin_num(&p.number)).collect();
values.insert(p.source.clone(), summarize_set(&s));
sets.push((p.source.clone(), s));
}
if sets.len() < 2 {
return AxisOutcome {
axis: "pad_numbers".into(),
verdict: Verdict::SourceMissing,
values, conflicts: vec![], severity: 1,
};
}
// All sets equal?
let first = &sets[0].1;
let all_equal = sets.iter().skip(1).all(|(_, s)| s == first);
let verdict = if all_equal { Verdict::Agree } else { Verdict::Disagree };
let mut conflicts = Vec::new();
if !all_equal {
for (src, s) in &sets[1..] {
let only_first: Vec<_> = first.difference(s).cloned().collect();
let only_other: Vec<_> = s.difference(first).cloned().collect();
if !only_first.is_empty() {
conflicts.push(format!("{} has pads {} that {} is missing", sets[0].0, fmt_list(&only_first), src));
}
if !only_other.is_empty() {
conflicts.push(format!("{} has pads {} that {} is missing", src, fmt_list(&only_other), sets[0].0));
}
}
}
AxisOutcome { axis: "pad_numbers".into(), verdict, values, conflicts, severity: 3 }
}
fn axis_symbol_pin_count(parts: &[&Part]) -> AxisOutcome {
let mut values = BTreeMap::new();
let mut counts: Vec<u32> = Vec::new();
for p in parts {
if !p.pins.is_empty() {
values.insert(p.source.clone(), p.pins.len().to_string());
counts.push(p.pins.len() as u32);
} else {
values.insert(p.source.clone(), "(no symbol loaded)".to_string());
}
}
if counts.len() < 2 {
return AxisOutcome {
axis: "symbol_pin_count".into(),
verdict: Verdict::SourceMissing,
values, conflicts: vec![], severity: 1,
};
}
let unique: std::collections::HashSet<_> = counts.iter().collect();
let verdict = if unique.len() == 1 { Verdict::Agree } else { Verdict::Disagree };
let conflicts = if verdict == Verdict::Disagree {
vec![format!("Pin counts differ across sources: {:?}", counts)]
} else { vec![] };
AxisOutcome { axis: "symbol_pin_count".into(), verdict, values, conflicts, severity: 3 }
}
fn axis_pin_names(parts: &[&Part]) -> AxisOutcome {
// Compare pin-name DICTIONARIES across sources that have non-stub names.
let mut values = BTreeMap::new();
let mut maps: Vec<(String, BTreeMap<String, String>)> = Vec::new();
let mut stub_sources: Vec<String> = Vec::new();
for p in parts {
if p.pins.is_empty() {
values.insert(p.source.clone(), "(no symbol loaded)".to_string());
continue;
}
let stubbed = p.pins.iter().filter(|pp| pp.is_stub_name).count();
if stubbed > p.pins.len() / 2 {
// Mostly numbered placeholders (UL stub case) — exclude from name match.
stub_sources.push(p.source.clone());
values.insert(p.source.clone(), format!("(stub names: {} of {} are numeric)", stubbed, p.pins.len()));
continue;
}
let m: BTreeMap<String, String> = p
.pins
.iter()
.map(|pp| (canon_pin_num(&pp.number), canon_pin_name(&pp.name)))
.collect();
values.insert(p.source.clone(), format!("{} named pins", m.len()));
maps.push((p.source.clone(), m));
}
let mut conflicts: Vec<String> = Vec::new();
for src in &stub_sources {
conflicts.push(format!("{} has stub-only pin names (UL bundle did not carry the part's real pin labels)", src));
}
if maps.len() < 2 {
// Only one source has real names — cannot compare. Soft severity since
// stub sources are still flagged above.
let verdict = if conflicts.is_empty() { Verdict::SourceMissing } else { Verdict::Disagree };
return AxisOutcome { axis: "pin_names".into(), verdict, values, conflicts, severity: 2 };
}
let (first_src, first_map) = &maps[0];
for (src, m) in &maps[1..] {
for (num, name) in first_map {
if let Some(other) = m.get(num) {
if other != name {
conflicts.push(format!("pin {}: {}={} but {}={}", num, first_src, name, src, other));
}
} else {
conflicts.push(format!("pin {} present in {} ({}) but missing in {}", num, first_src, name, src));
}
}
for (num, name) in m {
if !first_map.contains_key(num) {
conflicts.push(format!("pin {} present in {} ({}) but missing in {}", num, src, name, first_src));
}
}
}
let verdict = if conflicts.is_empty() { Verdict::Agree } else { Verdict::Disagree };
AxisOutcome { axis: "pin_names".into(), verdict, values, conflicts, severity: 3 }
}
fn axis_pin_to_pad_map(parts: &[&Part]) -> AxisOutcome {
// Some sources (EAGLE, ds2sf) explicitly map pin name → pad number. Cross-check.
let mut values = BTreeMap::new();
let mut maps: Vec<(String, BTreeMap<String, String>)> = Vec::new();
for p in parts {
if p.pins.is_empty() { continue; }
let stubbed = p.pins.iter().filter(|pp| pp.is_stub_name).count();
if stubbed > p.pins.len() / 2 { continue; }
// Build {pin_name → pad_number} (pin_name is canonicalized).
let m: BTreeMap<String, String> = p
.pins
.iter()
.map(|pp| (canon_pin_name(&pp.name), canon_pin_num(&pp.number)))
.collect();
values.insert(p.source.clone(), format!("{} mappings", m.len()));
maps.push((p.source.clone(), m));
}
if maps.len() < 2 {
return AxisOutcome {
axis: "pin_to_pad_map".into(),
verdict: Verdict::SourceMissing,
values, conflicts: vec![], severity: 1,
};
}
let mut conflicts: Vec<String> = Vec::new();
let (first_src, first_map) = &maps[0];
for (src, m) in &maps[1..] {
for (name, pad) in first_map {
if let Some(other_pad) = m.get(name) {
if other_pad != pad {
conflicts.push(format!("pin {}: {}=pad {} but {}=pad {}", name, first_src, pad, src, other_pad));
}
}
}
}
let verdict = if conflicts.is_empty() { Verdict::Agree } else { Verdict::Disagree };
AxisOutcome { axis: "pin_to_pad_map".into(), verdict, values, conflicts, severity: 3 }
}
fn axis_package_family(parts: &[&Part]) -> AxisOutcome {
// Reduce each source's package name to a "family" token (SOIC, SOT, QFN, …) and compare.
let mut values = BTreeMap::new();
let mut families: Vec<(String, String)> = Vec::new();
for p in parts {
if let Some(pkg) = &p.package {
let fam = package_family(&pkg.raw_name);
values.insert(p.source.clone(), format!("{} → family={}", pkg.raw_name, fam));
families.push((p.source.clone(), fam));
} else {
values.insert(p.source.clone(), "(no package loaded)".to_string());
}
}
if families.len() < 2 {
return AxisOutcome {
axis: "package_family".into(),
verdict: Verdict::SourceMissing,
values, conflicts: vec![], severity: 1,
};
}
let unique: std::collections::HashSet<&String> = families.iter().map(|(_, f)| f).collect();
let verdict = if unique.len() == 1 { Verdict::Agree } else { Verdict::Disagree };
let mut conflicts = Vec::new();
if verdict == Verdict::Disagree {
conflicts.push(format!(
"Package families disagree across sources: {}",
families.iter().map(|(s, f)| format!("{s}={f}")).collect::<Vec<_>>().join(", ")
));
}
AxisOutcome { axis: "package_family".into(), verdict, values, conflicts, severity: 3 }
}
fn axis_body_dims(parts: &[&Part]) -> AxisOutcome {
// Compare body x/y across sources that report them. Tolerance: 0.5 mm
// (manufacturer drawings vary; this axis is informational unless the diff
// is enormous).
let mut values = BTreeMap::new();
let mut entries: Vec<(String, f64, f64)> = Vec::new();
for p in parts {
if let Some(pkg) = &p.package {
if let (Some(x), Some(y)) = (pkg.body_x_mm, pkg.body_y_mm) {
values.insert(p.source.clone(), format!("{:.2}×{:.2} mm", x, y));
entries.push((p.source.clone(), x, y));
}
}
}
if entries.len() < 2 {
return AxisOutcome {
axis: "body_dims".into(),
verdict: Verdict::SourceMissing,
values, conflicts: vec![], severity: 1,
};
}
let mut conflicts = Vec::new();
let (_, fx, fy) = entries[0];
for (src, x, y) in &entries[1..] {
if (x - fx).abs() > 0.5 || (y - fy).abs() > 0.5 {
conflicts.push(format!(
"{} body {:.2}×{:.2} mm differs from {} body {:.2}×{:.2} mm by >0.5 mm",
src, x, y, entries[0].0, fx, fy,
));
}
}
let verdict = if conflicts.is_empty() { Verdict::Agree } else { Verdict::Disagree };
AxisOutcome { axis: "body_dims".into(), verdict, values, conflicts, severity: 2 }
}
// ── helpers ────────────────────────────────────────────────────────────────
/// Canonicalize a pin number — uppercase, no surrounding whitespace.
fn canon_pin_num(s: &str) -> String {
s.trim().to_uppercase()
}
/// Canonicalize a pin name for comparison: uppercase, replace slashes with
/// underscores (EAGLE convention) so "ALERT/RDY" matches "ALERT_RDY". Also
/// strip trailing `_<digits>` disambiguation suffixes — UL-bundle KiCad
/// symbols often disambiguate repeated pins as "GND_1"/"GND_2"/"GND_3" while
/// the datasheet calls all three just "GND".
fn canon_pin_name(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.trim().chars() {
match c {
// ASCII slash / dash AND Unicode minus (U+2212) / en-dash
// (U+2013) / em-dash (U+2014) — datasheets routinely use the
// Unicode forms (e.g. "V−", "IN1−", "ALERT/RDY").
'/' | '-' | '−' | '–' | '—' => out.push('_'),
_ => out.push(c.to_ascii_uppercase()),
}
}
// Strip trailing _<digits>.
if let Some(idx) = out.rfind('_') {
let suffix = &out[idx + 1..];
if !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit()) {
out.truncate(idx);
}
}
out
}
/// Reduce a package name like "VSSOP-10" / "DGS0010A" / "SOIC-8W_5.3x5.3mm" /
/// "SOT_ADS1115IDYNR_TEX" / "SOP65P640X120-20N" / "QFP50P1600X1600X170-100N"
/// to a comparable family token. LQFP and TQFP both map to LQFP (same
/// JEDEC outline). DSBGA/WLCSP/CSP/BGA all map to BGA.
fn package_family(raw: &str) -> String {
let upper = raw.to_uppercase();
// ── 1. TI JEDEC outline-code prefixes (single-letter PKG_DESIG codes) ──
// TI prefixes their UL library names with these JEDEC codes; the TI
// "Generic Package View" lookup table maps them to actual families.
if upper.starts_with("DGS00") { return "VSSOP".into(); } // VSSOP/MSOP-10
if upper.starts_with("DCT00") { return "SOT".into(); }
if upper.starts_with("DCK00") { return "SC70".into(); }
if upper.starts_with("DCN00") { return "VSON".into(); }
if upper.starts_with("DBV00") { return "SOT".into(); } // SOT-23 family
if upper.starts_with("DDC00") { return "SOT".into(); } // SOT-23-6
if upper.starts_with("DLC00") { return "WSON".into(); } // TPS62840DLCR
if upper.starts_with("YBG00") { return "BGA".into(); } // DSBGA / TPS62840YBGR
if upper.starts_with("RGE00") || upper.starts_with("RGZ00") { return "QFN".into(); }
if upper.starts_with("RHB00") || upper.starts_with("RUG00") { return "VQFN".into(); }
if upper.starts_with("RRG00") { return "VQFN".into(); }
if upper.starts_with("RGF00") { return "VQFN".into(); } // TI QFN-40
if upper.starts_with("RTV00") { return "VQFN".into(); } // TI QFN-32
if upper.starts_with("RRY00") { return "VQFN".into(); }
if upper.starts_with("RSM00") { return "VQFN".into(); } // TI QFN-32 (RSM)
if upper.starts_with("RQM00") { return "VQFN".into(); } // TI QFN-29
if upper.starts_with("PW") { return "TSSOP".into(); }
if upper.starts_with("DR") { return "QFN".into(); }
if upper.starts_with("PBS00") { return "TQFP".into(); } // TI TQFP-32
// ── 2. Single-letter / short JEDEC outline codes ──
// KiCad UL sometimes labels parts with their bare JEDEC outline code.
// SOIC-8: "D" / "D8" / "D_8" (TI's D = SOIC-8).
// PDIP-8: "P" / "P8" (TI's P = PDIP).
if upper == "D8" || upper == "D_8" || upper == "D" { return "SOIC".into(); }
if upper.ends_with("_D8") { return "SOIC".into(); }
if upper == "P8" || upper == "P_8" || upper == "PDIP" { return "DIP".into(); }
if upper == "P14" || upper == "P_14" { return "DIP".into(); }
if upper == "P16" || upper == "P_16" { return "DIP".into(); }
if upper.ends_with("_P8") || upper.ends_with("_P14") || upper.ends_with("_P16") {
return "DIP".into();
}
// ── 3. TI's UL library-namespace prefix convention ──
// Many TI UL exports prefix every name with "SOT_" regardless of actual
// package (it's a library-namespace convention, not a package claim).
// Strip the prefix and re-inspect.
if upper.starts_with("SOT_") {
let stripped = &upper[4..];
for fam in ["VSSOP", "TSSOP", "SSOP", "MSOP", "SOIC", "QFN", "QFP", "BGA"] {
if stripped.contains(fam) { return fam.into(); }
}
}
// ── 4. IPC-7351 generic naming: "SOP<pp>P<wid>X<hgt>-<n>N", "QFP<pp>P...",
// "BGA<pp>P...", "DFN<pp>P..." ─────────────────────────────────────────
// pp = pitch * 100 (e.g. SOP65 = 0.65 mm pitch). Pitch alone disambiguates
// SOIC (1.27 mm) from TSSOP/SSOP (0.65 mm) from fine-pitch (0.5 mm).
if let Some(fam) = parse_ipc7351(&upper) {
return fam;
}
// ── 5. EAGLE / vendor outline aliases ─────────────────────────────────
if upper.starts_with("DGS00") { return "VSSOP".into(); }
if upper.contains("LGA") { return "LGA".into(); }
// ── 6. Family-keyword scan ────────────────────────────────────────────
// LQFP and TQFP map to the same family (same JEDEC outline; "L" is just
// low-profile). DSBGA / WLCSP / CSP all collapse to BGA.
for (token, family) in [
("VSSOP", "VSSOP"),
("TSSOP", "TSSOP"),
("SSOP", "SSOP"),
("MSOP", "VSSOP"), // MSOP-10 ≡ VSSOP-10
("SOIC-8W", "SOIC"),
("SOIC-16W", "SOIC"),
("SOIC", "SOIC"),
("SOIJ", "SOIC"),
("VQFN", "VQFN"),
("QFN", "QFN"),
("WSON", "WSON"),
("DFN", "DFN"),
("LQFP", "LQFP"),
("TQFP", "LQFP"), // same JEDEC outline
("QFP", "LQFP"), // bare "QFP" too — concur treats 'em as one family
("DSBGA", "BGA"),
("WLCSP", "BGA"),
("CSP", "BGA"),
("BGA", "BGA"),
("OLGA", "LGA"),
("LGA", "LGA"),
("SOT-563", "SOT"),
("SOT-23-5","SOT"),
("SOT-23-6","SOT"),
("SOT-23", "SOT"),
("SOT", "SOT"),
("SC-70", "SC70"),
("SC70", "SC70"),
("DIP", "DIP"),
("PDIP", "DIP"),
("WROOM", "MODULE"),
("WROVER", "MODULE"),
("MODULE", "MODULE"),
] {
if upper.contains(token) {
return family.into();
}
}
"UNKNOWN".into()
}
/// True when the package name signals an SMD module (chip + supporting
/// passives + RF shield + PCB antenna packaged as a sub-assembly with
/// castellated edge pads) rather than a bare IC. Used by the
/// `module_extra_pads` variant detector to avoid false-classifying
/// undercounted-IC extractions as benign module variants.
fn is_module_package(raw: &str) -> bool {
let upper = raw.to_uppercase();
const MODULE_MARKERS: &[&str] = &[
"MODULE", "WROOM", "WROVER", "WROOM-1", "WROOM-2",
"NORA-", "NORA_", "ATWINC", "SARA-", "SARA_", "MIKROBUS",
"FEATHER", "XBEE", "XPLAINED", "BREAKOUT",
"CASTELLATED", "EDGE-PAD", "EDGE_PAD",
];
MODULE_MARKERS.iter().any(|m| upper.contains(m))
}
/// Parse IPC-7351-style generic package names like:
/// SOP65P640X120-20N → TSSOP (pitch 0.65 mm, narrow)
/// QFP50P1600X1600X170-100N → LQFP (pitch 0.5 mm, 100-pin)
/// BGA50P800X800-49N → BGA
/// DFN65P200X300-8N → DFN
fn parse_ipc7351(upper: &str) -> Option<String> {
let bytes = upper.as_bytes();
let prefixes: &[(&str, &str)] = &[
// (prefix, default family — refined by pitch)
("SOP", "SOIC"),
("QFP", "LQFP"),
("BGA", "BGA"),
("CSP", "BGA"),
("DFN", "DFN"),
("QFN", "QFN"),
("SON", "WSON"),
("WSON", "WSON"),
];
for (prefix, default_family) in prefixes {
if !upper.starts_with(prefix) { continue; }
// Read pitch digits immediately after prefix.
let after = &upper[prefix.len()..];
let mut pitch_chars = String::new();
for &b in after.as_bytes() {
if b.is_ascii_digit() { pitch_chars.push(b as char); } else { break; }
}
if pitch_chars.is_empty() { continue; }
// The next char should be 'P' (per IPC-7351). If not, this isn't
// an IPC-7351 generic name — bail.
let next_idx = prefix.len() + pitch_chars.len();
if next_idx >= bytes.len() || bytes[next_idx] != b'P' { continue; }
let pitch_pp: u32 = pitch_chars.parse().ok()?;
let family = match (*prefix, pitch_pp) {
// SOP family disambiguation by pitch.
("SOP", 127) => "SOIC", // 1.27 mm = SOIC
("SOP", p) if p <= 50 => "TSSOP",
("SOP", p) if p <= 65 => "TSSOP",
("SOP", _) => "SSOP",
// QFP: any pitch is LQFP.
("QFP", _) => "LQFP",
_ => default_family,
};
return Some(family.into());
}
None
}
fn summarize_set(s: &std::collections::BTreeSet<String>) -> String {
if s.len() <= 4 {
s.iter().cloned().collect::<Vec<_>>().join(",")
} else {
format!("{} pads ({}…{})", s.len(), s.iter().next().unwrap_or(&"?".to_string()), s.iter().last().unwrap_or(&"?".to_string()))
}
}
fn fmt_list(v: &[String]) -> String {
if v.len() <= 4 { v.join(",") } else { format!("{} items", v.len()) }
}