MatIntel Audit — July 2026
Scope: src/matintel/gaussian_scoring.py (2,664 lines, 71 scorers), viability.py,
gaussian_pipeline.py, and the contents of data/processed/.
Findings are ordered by impact on output quality, not by effort.
Part 1 — Defects that are currently corrupting results
1.1 CRITICAL: the top-10 rankings are partly meaningless (score saturation)
Every scorer accumulates additive weighted terms and then calls _clamp(), which
truncates to [0, 1] and rounds to 3 decimal places. But in most scorers the term
weights sum to well over 1.0. Example — score_solid_electrolyte (line 360):
0.40 (LLZO) + 0.30 (bandgap) + 0.22 (framework) + 0.22 (sulfide)
+ 0.20 (halide) + ... = 1.34+ before clamping
Measured against the current top10_per_category_gaussian_raw_score.csv:
| Symptom | Count |
|---|---|
Categories where all 10 top materials score exactly 1.000 |
8 / 71 |
Categories where the top-10 score range is < 0.01 |
29 / 71 |
| Median top-10 score range across all categories | 0.018 |
The eight fully-saturated categories are Battery Anode, Dehydrogenation/LOHC Catalyst, Hard Coating, High-k Dielectric, Optical Fibre/Waveguide, Semiconductor (General), Superconductor, and Supercapacitor Electrode. For these, the reported "top 10" is an arbitrary slice of what may be thousands of tied materials — the ordering is whatever pandas' sort happened to produce.
For the 29 low-range categories, 3-decimal rounding means the ranking is determined by numerical noise well below the accuracy of any underlying DFT input.
Fix: replace additive-then-clamp with a normalised aggregation. Two options:
- Weighted geometric mean over sub-criteria in
[0,1]. Naturally bounded, and has the desirable property that a material weak on any essential criterion cannot compensate by being strong elsewhere — which is how materials selection actually behaves (this is the standard argument for geometric aggregation in multi-criteria decision analysis). - Weight normalisation: divide the accumulated score by the sum of weights that were actually eligible to fire for that material. Cheaper to retrofit, preserves existing tuning, but keeps additive compensation.
I'd recommend geometric for the ~20 scorers where criteria are genuinely
conjunctive (electrolytes, catalysts, coatings), normalised-additive elsewhere.
Also raise _clamp rounding from 3 dp to 6 dp.
1.2 CRITICAL: the ALIGNN property predictions are 100% empty
data/processed/alignn_predictions.csv has 554,054 rows and nine property
columns — MBJ_Bandgap, Dielectric_Const, Bulk_Modulus, Shear_Modulus,
Seebeck_n, Seebeck_p, Exfoliation_Energy, Magnetic_Moment, Ehull.
All nine are entirely NaN. Non-null fraction: 0.000.
alignn_predictions_retry.csv is 0 bytes, so the retry never ran either. Neither
scoring engine references these columns anywhere (grep -c ALIGNN returns 0 in
both scoring.py and gaussian_scoring.py), so nothing is silently reading
garbage — but it means the system is scoring thermoelectrics with no Seebeck
coefficient, hard coatings with no elastic moduli, high-k dielectrics with no
dielectric constant, and 2D materials with no exfoliation energy. Those are the
defining properties of those applications; they're all currently inferred from
composition heuristics alone.
This is the single largest available quality improvement in the project.
1.3 HIGH: CHGNet_Formation_Energy is not a formation energy
CHGNet_Formation_Energy: mean -6.25, min -13.44, max -0.42 eV/atom
Formation Energy Per Atom (GNoME): typically -1 to -2 eV/atom
CHGNet's predict_structure() returns e as total energy per atom referenced
to its own training baseline, not a formation energy. The column is mislabeled by
roughly a 4–5 eV offset. It isn't currently consumed by any scorer, which is
fortunate: feeding it to _formation_sigmoid(center=-0.5, k=3.0) would saturate
every material in the dataset at weight 1.0 and silently null out that term's
discriminative power.
Either convert to a true formation energy by subtracting CHGNet elemental
reference energies, or rename the column to CHGNet_Energy_Per_Atom so it can't
be misused later.
CHGNet_Magnetic_Moment has the same class of problem: max value 219.75 μB, which
is a cell-summed moment. It needs normalising by nsites before it means anything.
1.4 MEDIUM: three common elements are missing from ABUNDANCE_PPM
viability.py:186 defaults any unlisted element to 0.001 ppm — the abundance
of rhodium. Elements present in the dataset but absent from the table:
| Element | Materials affected (in a 300k sample) | True crustal abundance |
|---|---|---|
| I | 6,608 | ~0.45 ppm |
| Cs | 5,221 | ~3 ppm |
| Be | 4,163 | ~2.8 ppm |
Iodine, caesium and beryllium are all being treated as rarer than platinum. This directly suppresses the viability of every halide-perovskite, caesium-containing optoelectronic, and beryllium ceramic in the database — several of which are technologically important classes.
(The other missing symbols — Tc, Pm, Pa, Ac, Np, Pu — are correctly handled by
ELEMENT_PENALTIES instead, so their abundance default doesn't matter.)
1.5 MEDIUM: element_penalty ignores stoichiometry
def element_penalty(composition):
product = 1.0
for symbol in composition.elements:
product *= ELEMENT_PENALTIES.get(str(symbol), 1.0)
return float(max(product, 0.02))
This is binary presence logic — exactly what the Gaussian rewrite set out to eliminate. A material with 0.5% lanthanum dopant is penalised identically to one that is 40% lanthanum. And penalties compound multiplicatively across elements, so a La–Ce–Nd compound takes 0.92 × 0.92 × 0.85 = 0.72 regardless of composition.
Fix: weight by molar fraction, e.g. penalty_i ** frac_i, consistent with the
_elem_frac approach used throughout the Gaussian scorers.
There's also a conceptual double-count: rare-earth penalties encode scarcity, but
abundance_score and supply_risk_score already do that independently. The
penalty table should be restricted to hazard (toxicity, radioactivity) and let
the abundance/supply terms carry criticality.
1.6 LOW: housekeeping
data/processed/failed_clscore.csv is 3.0 GB — larger than the rest of
processed/ combined, and appears to be a runaway error log rather than data.
Worth inspecting and deleting.
Part 2 — Systematic physics issues
2.1 The band gap is PBE/GGA, but the Gaussian centres are experimental targets
This affects roughly 30 of the 71 scorers and is the most pervasive methodological problem in the system.
GGA/PBE band gaps underestimate experimental gaps by ~40–50% on average, and the
error is not a constant offset — it scales roughly with gap magnitude. The
Bandgap column in the GNoME-derived dataset is a GGA value. But the Gaussian
peaks are placed at experimental/physical optima:
score_solar_absorber_singlejunctiontargets ~1.34 eV (Shockley–Queisser)score_led,score_photodetector,score_photocatalyst_water_splitting(~2.0–2.4 eV for overpotential-corrected water splitting)score_transparent_conductor,score_semiconductor_general,score_highk_dielectric
A material whose true gap is 1.34 eV typically shows a GGA gap near 0.7–0.9 eV, so it lands on the shoulder of the Gaussian rather than the peak — while a material with a true gap of ~2.2 eV lands on the peak and is wrongly promoted. The rankings for every gap-sensitive category are systematically biased toward wide-gap materials.
Three fixes, in increasing order of quality:
- Shift the Gaussian centres to GGA-equivalent values. Cheap, no new data, but the correction is material-class-dependent and this bakes in an average.
- Apply a scissor/linear correction to the gap column before scoring
(
E_corrected ≈ a·E_GGA + b, fit against a set with known experimental gaps — you haveexperimental_compounds.csvto fit against). - Use MBJ gaps from ALIGNN (see 1.2). This is the correct fix. The JARVIS-DFT MBJ model exists precisely for this; TBmBJ gaps are substantially closer to experiment than GGA.
Whichever route, the choice should be documented in the README, because right now the scorers look like they encode experimental physics and don't.
2.2 Hard return 0.0 gates contradict the continuous design
The module docstring says "hard disqualifiers are preserved where physical
reasoning demands them", which is defensible — but the gates are much more
numerous than that framing implies. score_battery_anode alone has three, and
score_solid_electrolyte has three including bg < 2.0 → 0.0.
A material with a GGA gap of 1.98 eV — which may well have a true gap above 4 eV — is hard-zeroed as a solid electrolyte. Combined with 2.1, these cliffs are being applied to a systematically biased variable.
Recommend converting all soft-physics gates to steep sigmoids (k=10–20), and
retaining true return 0.0 only for genuine categorical impossibilities: no Li
in a Li-ion cathode, centrosymmetric structure for a piezoelectric, radioactive
element in a biomedical implant.
2.3 14 of 21 computed structural descriptors are never used
cif_structural_descriptors.csv has full coverage (604,125 rows, no missing
values) for 21 descriptors. The scorers read exactly seven:
is_centrosymmetric, crystal_system, c_over_a, density, volume_per_atom,
space_group_number, NSites.
Unused, despite being computed and available: packing_fraction, mean_cn,
mean_nn_distance, min_nn_distance, mean_voronoi_volume, dimensionality,
and the individual lattice parameters a, b, c, alpha, beta, gamma.
Several map directly onto scorers that currently rely on weak composition proxies:
| Descriptor | Should feed |
|---|---|
packing_fraction |
hydrogen storage (free volume), radiation shielding, CO2 sorbent, desalination membrane |
mean_voronoi_volume |
ionic conductors (mobile-ion site volume), hydrogen storage |
min_nn_distance |
solid electrolyte (migration-bottleneck proxy), superionic screening |
mean_cn |
catalysis (undercoordinated surface-site proxy), OER/HER/CO2RR |
dimensionality |
material_2d (currently reconstructed from c_over_a + space group, when a direct label exists) |
alpha/beta/gamma |
ferroelectric/piezoelectric polar-axis reasoning |
This is free signal already sitting on disk.
Part 3 — Proposed new application categories
The current 71 have notable gaps. These are all scoreable with data you already have (composition, gap, formation energy, symmetry, density, structural descriptors), and represent active research areas:
Energy storage - Multivalent-ion cathode (Mg²⁺ / Ca²⁺ / Al³⁺) — a major field with zero coverage - Zn-ion (aqueous) battery cathode - K-ion cathode - Li-metal-compatible interlayer / anode-free current collector - Anti-perovskite superionic conductor (distinct structural family)
Energy conversion & catalysis - Proton-conducting ceramic (protonic ceramic fuel cells — BaZrO₃/BaCeO₃ family) - Chemical-looping combustion oxygen carrier - Ammonia oxidation / ammonia cracking catalyst - Hydrogen separation membrane (Pd-alternative) - Thermochemical energy storage redox pair
Electronics & magnetics
- Transparent p-type semiconductor — a long-standing open problem, and
distinct from the existing (implicitly n-type) transparent_conductor
- Magnetocaloric material — significant field, currently absent
- Elastocaloric / barocaloric (solid-state cooling)
- Electrocaloric
- Solar-blind UV photodetector (wide-gap, distinct from photodetector)
- Thermionic emitter / low-work-function material
Nuclear & extreme
- Neutron absorber / control rod material (distinct from radiation_shielding —
cross-section driven, not density driven)
- Scintillator (distinct from radiation_detector)
Other - Solid lubricant (layered, low shear strength) - High-entropy alloy candidate
My picks for highest value-per-effort: magnetocaloric, multivalent-ion cathode, transparent p-type semiconductor, proton conductor, neutron absorber, and scintillator. Each is well-defined by available features and each fills a real gap.
Part 4 — Retiring the discrete engine
scoring.py (1,967 lines) and gaussian_scoring.py (2,664 lines) are maintained
in parallel, with gaussian_pipeline.py producing a comparison artifact and
app.py:606 exposing an engine toggle. This doubles the maintenance surface and
means every fix above would need applying twice.
Proposed sequence:
- Freeze legacy. No further changes to
scoring.py. - Establish that Gaussian is at least as good before deleting anything —
scored_comparison_legacy_vs_gaussian.csvalready exists but only records deltas, not correctness. Useexperimental_compounds.csvas ground truth: for each category, does the engine rank known-good materials higher? This is the retrospective-validation study, and it doubles as the justification for the removal. - Fix the defects above in the Gaussian engine only.
- Remove the toggle from
app.py, default to Gaussian. - Delete
scoring.pyand the*_gaussianfilename suffixes (once Gaussian is the only engine, the suffix is noise —scored_dataset.csvshould just be the Gaussian output).
Step 2 is important and shouldn't be skipped. Right now there is no evidence in the repo that the Gaussian rewrite improved anything — only that it changed things. Retiring the baseline without measuring against it discards the only comparison you have.
Recommended order of work
| # | Task | Impact | Effort |
|---|---|---|---|
| 1 | Fix score saturation (normalised aggregation) | Very high | Medium |
| 2 | Re-run ALIGNN to populate the 9 property columns | Very high | Medium |
| 3 | Band gap correction (scissor fit, then MBJ when available) | High | Low → Medium |
| 4 | Wire ALIGNN properties into the ~20 scorers that need them | High | Medium |
| 5 | Fix ABUNDANCE_PPM gaps + stoichiometry-weight element_penalty |
Medium | Low |
| 6 | Use the 14 unused structural descriptors | Medium | Medium |
| 7 | Soften hard gates to steep sigmoids | Medium | Medium |
| 8 | Add new application categories | Medium | Medium |
| 9 | Validation study vs. experimental_compounds.csv |
High | Medium |
| 10 | Retire the discrete engine | Maintenance | Low |
Items 1, 3 and 5 are self-contained and can be done immediately. Item 2 is a GPU job on the RTX 4070 and gates items 4 and part of 3.