Metal sites: the pipeline

This page follows one structure through the code that turns it into the metal-site report: bonds, ligand charges, the metal’s state, the electron count, the trust grade, the ligand-field picture and the identifiers. For each stage it names the module and function that does the work, the rules in the order they apply, the constants that matter and what they were set against, the papers a rule rests on, and what the stage cannot do. Metal sites says what the report contains. Metal-site accuracy says how often it is right and what each trust grade means. Core Crates, §5.10 is the short map of the modules; this page is the long one.

The code is the authority. The dated reasoning, the measurement behind each rule and the alternatives that were tried and dropped are in notes/archive/electron-counting.md (read it by heading), notes/archive/open-items-closed-through-2026-10-12.md and notes/archive/metal-complex-identifiers.md. notes/archive/prior-art-metal-sites.md surveys other tools. A figure below is the one recorded when a rule was added, against the benchmark named beside it. It is evidence for a threshold, not the current accuracy, which moves with every change and is kept in the accuracy page and notes/archive/open-items-closed-through-2026-10-12.md.

Terms used throughout:

The pipeline at a glance

Stage Question Entry points Output
1. Bonds Which atoms touch the metal; which cage substituents are bonded infer_covalent_bonds in core/backbone/src/scene/utils/bond_inference.rs Bond list with orders
2. Ligands Bond orders and charge of each ligand perceive_ligands_one_by_one, ionic_ligand_charges_radical, the repairs in resolve_ionic_in_order, all in core/backbone/src/scene/charge_inference/; core/backbone/src/scene/huckel.rs Atoms with ligand charges
3. Division What the metals take, and what the ligands carry when a state is chosen elsewhere resolve_ionic_complex, resolve_ionic_complex_toward, allocate_to_targets; ionic_reading IonicReading
4. State Which state is reported, and from which source and rule StateEvidence::collect and StateEvidence::decide_with_reason in oxidation_state.rs; core/backbone/src/bond_valence.rs StateDecision (state, OxidationStateSource, Override)
5. Non-innocent ligands Which ligands have more than one closed-shell reading; a dioxolene’s redox level core/edit/src/geometry/non_innocent.rs; core/backbone/src/scene/redox_ligand.rs Vec<NonInnocent>, Vec<RedoxLigand>
6. Boron cages The charge of a borane or carborane core/backbone/src/scene/polyhedral_cage.rs Vec<PolyhedralCage>
7. Count Valence electrons at the metal count_electrons in core/edit/src/geometry/electron_count.rs ElectronCount or Decline
8. Trust How often a state like this one is right, and what the program saw assess_site_trust in core/edit/src/geometry/site_trust.rs; the table in trust_table.rs and trust_table.csv SiteTrust (grade, cautions, features, calibration)
9. Ligand field The d-orbital pattern the geometry gives site_ligand_field in core/edit/src/geometry/ligand_field.rs LigandField or a reason
10. Sphere Which polyhedron the donors form; which atoms are sites assess_spheres in core/edit/src/geometry/sphere_assessment.rs SphereAssessment
11. Identifiers InChI and SMILES, and each metal’s state as written identify_scene in core/inchi/src/identify.rs Identification, MetalState

Stage 10 runs before stage 7 (the count walks the sphere’s sites) and is documented last because it is the shared geometry rather than a step in the charge story. Stage 6 runs inside stage 2: the resolver calls the cage finder.

 structure + declared charge
        |
 [1] infer_covalent_bonds
        |
 [2][3][5][6]  ionic_reading ------------------> InChI writer, SMILES writer,
        |        (IonicReading)                   2D depiction (interpret_for_depiction)
        |
 [4] StateEvidence (found once per metal) -> decide -> reported state and its rule
        |
 [10] assess_spheres -> [7] count_electrons -> [8] assess_site_trust -> [9] site_ligand_field
        |
 analyze_metal_sites (MetalSite records): analyze metal-sites, Scene Info panel,
 plot ligand-field; identify (metal_states) reads the same evidence

ionic_reading is the one division of charge. The InChI writer (core/inchi/src/input.rs), the SMILES writer (core/backbone/src/smiles/mod.rs), the 2D depiction (core/backbone/src/interpretation.rs), the electron count and the identical-ligands caution all read it, and it is cached per thread by scene digest because one identify asks for it several times. It returns None when:

  • the file declares no total charge;
  • the file states its own bond orders (BOND_ORDERS_TAG);
  • a metal already carries a formal charge, or the scene has no metal;
  • the scene is over perception_fits_budget (5,785 atoms);
  • no division gives every metal a share it can have.

Where a charge is declared and the reading is None, the identifier writers and the depiction fall back to resolve_declared_charge, and the count declines unless the fixed-state fallback applies (assumed_lone_counts, stage 7: a metal with one state and one-atom donors, each counted as two electrons). The reading asks stage 4 (StateEvidence::decide, without the reading check) which state each metal should come out at, so stages 3 and 4 call each other; the order above is the order of the data, not of the calls.

Any change that can move an identifier for the same input bumps IDENTIFIER_REVISION in core/backbone/src/lib.rs and adds a line to its log, core/backbone/IDENTIFIER_REVISIONS.md (stage 11).

The front ends call the same functions. orbitron analyze metal-sites is analyze_metal_sites in core/edit/src/geometry/metal_site.rs, which build_metal_sites_report in automation/cli/src/handlers/reports.rs wraps; the desktop’s Coordination section is ui/shell/src/panels/scene_info/coordination.rs; orbitron identify is automation/cli/src/handlers/commands/identify.rs; the Python bridge exposes identify. A change in a core crate reaches all of them, and a change to wording or layout belongs to one.

Locating a stage from a wrong result

Symptom Look at
Wrong coordination number, no_donors, a carbon bonded to the metal behind a carboxylate Stage 1; the report’s donors; within_covalent_cutoff
unresolved, odd_electrons, a state two too high with no unusual-state flag Stage 2; run identify --json and compare identifier_state with a reading you expect; check the fragment’s parity
identifier_state_differs, reconciled: false Stage 3; allocate_to_targets found no reading, or the file declares multiplicity 1
The reported state has the wrong oxidation_state_source Stage 4; the trust.cautions list names the rule
A radical ligand not flagged Stage 5; it is outside the eight classes
A cage complex with a surprising charge Stage 6; read the count’s unsettled.reason and alternatives in the report, then contacts, decided and withheld on PolyhedralCage in polyhedral_cage.rs (internal fields, not in the report)
A count that is 2 off Stage 7; the unsettled.reason names the open contact
high on a wrong state Stage 8; the grade is what the two methods said, and shares their blind spots

Stage 1: bonds around a metal and a cage

infer_covalent_bonds is the only place connectivity is made for a scene whose file states none. Every later stage reads its bond list, and within_covalent_cutoff lets a later stage ask whether an atom the list dropped was a candidate.

The cutoff. Two atoms are candidates when their distance is at most the sum of their covalent radii plus a slack. The radii are in core/backbone/src/periodic.rs: Cordero et al. through curium, Pyykkö and Atsumi from berkelium. pair_slack adds:

Term Value Applies
Base 0.1 Å + 0.05 × (r1 + r2) Every pair (BASE_SLACK_FLOOR, BASE_SLACK_SCALE)
N or O in the pair + 0.15 Å Unless either radius exceeds 1.8 Å (HETEROATOM_SLACK_BOOST, HEAVY_METAL_RADIUS_THRESHOLD)
A metal in the pair + 0.10 Å Same exception (METAL_SLACK_BOOST)

The exception keeps the boosts off heavy-metal pairs, where they made spurious contacts. For Hg-C the cutoff is 1.32 + 0.76 + 0.20 + 0.10 = 2.38 Å. The cutoff accepts every one of fourteen experimental aqua distances with 0.12 to 0.46 Å to spare (every_experimental_aqua_bond_clears_the_cutoff in core/backbone/src/scene/utils/bond_inference/tests.rs).

Coordination limits. Candidates are taken shortest first, and each takes a slot against max_coordination(z) (carbon, nitrogen and boron 4, oxygen 3, chlorine and bromine 5, iodine 7, Li, Na, K, Be, Mg, Ca and Al 6). A contact to a metal does not spend the donor’s slot, because the limit is a valence table: oxygen’s 3 is hydronium, and every oxygen in MgAl2O4 has four metal neighbours. Hydrogen is the exception to the exception, since a bonded hydrogen has no pair left to donate; exempting it let a calcium recruit four hydrogens out of an EDTA chelate.

Passes after the pair test, in the order they run:

Pass Rule Evidence
bond_orphaned_hydrogens A hydrogen bonded to nothing bonds to its nearest atom that has room (a metal, a boron, another orphan, an atom under its limit) if it is within ORPHANED_HYDROGEN_REACH = 1.1 times the pair cutoff 49 tmQM complexes had one just past the cutoff: B-H-B bridges at 1.31 to 1.35 Å against 1.31, Fe-H at 1.92 against 1.91, side-on H2 at 0.79 against 0.75. A lone hydrogen is a one-electron fragment and puts the ligand charges one electron off
bond_cage_exo_partners A hydrogen bonded only to metals and within 1.1 times the cutoff of a cage boron is also bonded to that boron. Any non-hydrogen, non-metal atom within the cutoff of a cage vertex is bonded to it, unless it is within 2.0 Å of three vertices (then it is a heteroborane vertex) Boron and carbon take their four shortest bonds, a vertex has five or six cage neighbours at 1.65 to 1.85 Å, so B-S (1.92 Å) and C-P (1.85 Å) lost to cage bonds. A sulfonium on a carborane then read as a thioether and the cage’s charge-compensating cation went unnoticed
drop_metal_bonds_through_donors Drop a metal-X bond when X is bonded to one of the metal’s donors and is more than BEHIND_DONOR_RATIO = 1.15 times as far; or when X bridges at least CHELATE_CENTRE_BRIDGES = 2 of the metal’s donors of a different element, each no further than CHELATE_CENTRE_SLACK = 1.05 times X’s distance Over 420 complexes, 258 of 2,474 inferred metal bonds went to carbon and 251 of those bridged a donor: the carbon of a chelating carboxylate. Lowering the ratio to 1.03 removed 20 of 52 genuine M-C bonds. The element test separates an oxoanion centre (bridges oxygens) from a ring carbon (bridges carbons) where distance cannot
drop_geminal_contacts For non-metals only: drop the third bond of a triangle whose apex angle exceeds MAX_RING_APEX_DEGREES = 75 Rings run 60.0 (cyclopropane) to 70.1 (a strained threonine CB-OG1), contacts 80.7 and 91.5. Metals are excluded because an Fe4S4 cubane has real Fe-S-Fe angles near 73
drop_bridged_metal_bonds (bridged_metals.rs) Drop a metal-metal contact when the two metals share a bridging N, O, F, P, S, Cl, Se, Br, Te or I and neither has a carbon neighbour Cations in an oxide or halide share anions (Mg-Mg at 2.84 to 3.08 Å in forsterite against a 3.16 Å cutoff). A real Fe-Fe bond under a mu-P has a carbon-bearing metal and is kept; so is a mu-CO’s. A bridging hydrogen does not count
perceive_bond_orders Bond orders from geometry with GeometryEvidence::Trust; in a scene over 5,785 atoms, one connected piece at a time (perceive_small_components, pieces up to 1,000 atoms) Stage 2 resets and re-perceives every ligand with the metals detached, so these orders matter for the diagram and for the non-metal parts of the scene, and little for the charge division

What the stage leaves out on purpose. An atom behind a donor, or a hydrogen on boron or silicon beside the metal, is dropped from the bond list and stays inside the cutoff. Stage 7 asks about those contacts. Bonding them here would draw a three-membered ring where a long contact is the better picture, and would change the identifiers.

Limits. The cutoff is not per element. tmQM’s optimised Hg-C bonds run 2.39 to 2.67 Å against 2.38, so 55 metals (44 of them mercury) have no bond and report the verdict no_donors. Nothing narrower than the shared cutoff would bond them, and a count made from contacts the identifiers do not see would disagree with the identifiers.

Stage 2: ligand perception and charge

resolve_ionic_complex(atoms, bonds, net) divides a declared charge between ligands and metals. This stage is the ligand half; stage 3 is the metals’ half. The entry chain is resolve_ionic_complex, resolve_ionic, in_canonical_order, resolve_ionic_in_order.

Canonical order

The resolver breaks ties by position: which odd fragment’s donor is pinned first, which of two equal candidates is kept. in_canonical_order hands it the atoms in an order that does not depend on the file’s: by bonds from the nearest metal (furthest first, so bond-order perception leaves the short atoms on the metal, where a short atom is the anion the model means), then by a Morgan-style connectivity class (refined for at most 64 rounds), then by position. Atom and bond ids are renumbered to match, and only charges, bond orders and properties are written back. Bond-order perception at load runs in the same order (bond_inference.rs), because a complex the resolver declines keeps the bonds perceived there. After the resolver, relieve_overvalent_carbanions swaps the orders along an alternating path between two carbanions perception left with four bonds, which a cyclooctatetraenide’s two pins otherwise are. Over 4,466 tmQM complexes, reversing each file changed 284 InChIs and 626 SMILES without any of this and changes none with all of it. core/inchi/tests/identifier_order_invariance.rs shuffles and reverses every ionic fixture, and the two declined complexes in fixtures/xyz/declined, and compares the strings.

resolve_ionic also holds the complex’s net charge in a thread-local (ClusterNet) for the duration of the call, because the cage finder (stage 6) needs it and the many places that charge a ligand do not each carry it.

Perceiving one ligand

perceive_ligands_one_by_one follows xyz2mol for metal complexes (Rasmussen et al., 2025). For each connected component of non-metal atoms, with the metal bonds set aside and the ligand’s bond orders reset to single:

  1. perceive_kekule_candidates_at collects up to CANDIDATES = 48 complete bond-order assignments at each total charge pinned + shift, for shift in CHARGE_SHIFTS = 0, -1, +1, -2, +2. A candidate is a valence combination that every atom accepts, not a resonance form: acetate at -1 gives three, two with the same orders.
  2. anion_sites marks the atoms the search may also try one valence below their own: a metal-bound carbon with no hydrogen that is not part of a hapto site, and a metal-bound terminal nitrogen (one heavy partner, no hydrogen, not on a ring). The ionic model charges a metal-bound anion nothing, so these are free in the ranking. Making every donor an anion site turned pyridines and phosphines into pyridides and cost 74 tmQM InChI matches; making every metal-bound carbon one, the carbons of Cp and arene rings included, still lost 70.
  3. path_ends marks carbons off the metal that perception may pair along an alternating path (augmenting_path) to finish a reading the greedy passes leave short.
  4. Candidates whose length_misfit is not within MISFIT_TOLERANCE = 0.02 of the best are dropped. The misfit is the sum over bonds of the squared distance of d / (r_a + r_b) from the ratio the order expects: 1.0 for a single, DOUBLE_RATIO 0.88, TRIPLE_RATIO 0.80.
  5. The rest are ranked by Ranking, lower first: nitroxide radicals beyond the first, charged atoms (a negative charge on a metal-bound atom is not counted, nor is a nitroxide’s single-bonded oxygen), misfit in thousandths, distance of the total charge from the extended Hückel guess, size of the shift.
  6. A ligand with no complete assignment keeps the best effort that perceive_kekule_bond_orders gives at the pinned charge, and unsound_fragments later notices it.

What the ranking was set against (CSD complexes whose reported state matches the name, tmQM InChI matches, tmQM odd counts on declared closed shells), each variant measured alone:

Ranking CSD reported right tmQM InChI Odd counts
Pinned charge only, first complete assignment 62,089 3,703 669
Plus shifts of one and two units, fewest charged atoms 62,170 3,758 522
Hückel guess alone as the charge 61,970 3,732 550
All shifts, aromatic-system size first (as the paper does) 62,025 3,730 533
All shifts, fewest charges, then misfit, then Hückel distance (kept) 62,164 3,777 461

The aromatic-system size is not available here in a usable form: the geometry-based detector reads the ring and not the assignment, so it cannot separate two assignments of the same atoms, and the Kekulé-pattern count that stands in for it cost 480 CSD complexes. Cost is about 30 ms a complex, four times the single search.

MISFIT_TOLERANCE exists because a glyoximate’s two oxygens sit 1.33 Å from nitrogen, the length of a single N-O. The dianion misfits by about 0.002 and the neutral dinitroso alkene, which asks for double bonds at single lengths, by about 0.055; with no charged atom the neutral won on parsimony and 30 cobaloximes came out Co(I) for Co(III). Net CSD gain over no filter: 0.01, +10; 0.02, +31; 0.03, +31; 0.04, +19. 0.02 is the smaller of the two that tie.

The extended Hückel guess

core/backbone/src/scene/huckel.rs is Orbitron’s own extended Hückel calculation with no dependency on RDKit. It is a ranking key below the odd-electron and unsound tests, never an override.

  • Parameters: the YAeHMOP set RDKit ships (eht_parms.dat), valence-state ionisation energies and single-zeta Slater exponents, main-group elements only (H, B, C, N, O, F, Si, P, S, Cl, Ge, As, Se, Br, Sn, Sb, Te, I).
  • Basis: one s and three p orbitals per heavy atom, one s per hydrogen. Each Slater orbital is expanded in six Gaussians fitted by least squares to the radial function (scripts/fit_sto_gaussians.py), so every overlap has a closed form.
  • Off-diagonal term: the weighted Wolfsberg-Helmholz form with WH_K = 1.75, confirmed from RDKit’s own H and S matrices.
  • Solution: Cholesky reduction of the generalised problem, then eigenvalues only (orbitron_math::symmetric_eigenvalues in core/math/src/eigenvalues.rs: Householder tridiagonalisation and implicit QL).
  • Guess (charge_from_levels): the valence electrons less two for each orbital below FILLED_BELOW = -10.0 eV, moved by two while a positive guess sits beside a LUMO below LOW_LUMO = -9.0 eV or a charge of -2 or lower beside a HOMO above HIGH_HOMO = -10.2 eV. These are the xyz2mol_tm thresholds.
  • A ligand over MAX_BASIS = 480 functions, or with an element outside the table, gets no guess and is ranked without it.

Agreement with RDKit’s energies over twenty small molecules is 0.1 eV across the spectrum and 0.002 eV between -13 and -7 eV, where the charge is decided; the tests pin it, and pin every expansion’s self-overlap to 5e-4. Used alone as the charge it matches the CSD name for 87.5% of complexes against 95.7% for the resolver; where the two disagree the resolver is right 5,913 times and the guess 1,133. It earns its place between assignments the other evidence cannot separate, a neutral quinoid and an aromatic dianion of the same atoms (tmQM KIXJIO).

From an assignment to charges

After perception, ionic_ligand_charges_radical turns bond orders into charges. In order:

  1. Pre-perception pins. A metal-bound oxygen with one carbon partner and no hydrogen is an anion when the C-O is longer than 1.285 Å, and a sulfur when the C-S is longer than 1.73 Å. Phenolate C-O runs 1.30 to 1.34 against 1.21 to 1.26 for a coordinated C=O, thiolate C-S 1.75 to 1.80 against 1.67 to 1.70 for a thione. Where a three-valent carbon has two such oxygens (a carboxylate) the shortest is spared, because a force-field geometry runs both to 1.32 to 1.34 Å and charging both read a Ce(III) EDTA chelate as Ce(V).
  2. Carbenes are known before perception. A metal-bound carbon with no hydrogen and two heavy partners, neither itself on a metal, one of them an amino nitrogen (three partners), an ether oxygen or a thioether sulfur (two), is pinned two-valent with a charge of -2 so the valence tables see it, and the charge is taken back afterwards. Not a pyrrol-2-yl or indol-2-yl (azolyl): those are anions.
  3. Cages. Where polyhedral_cages_with finds a cage, each vertex goes to perception as a hydrogen and a cage hydrogen as a helium no bond reaches (perceive_around_cages). A donor on a vertex is a cation, decided from its other partners and geometry and not from a perceived valence: three partners on N or P (not a phosphine oxide or sulfide), two on S, a nitrile (carbon at 1.19 Å or less, in line with the vertex), an oxide (O on P or S at 1.56 Å or less), a carbonyl (C-O at 1.30 Å or less), an iminium (N-H with a carbon at 1.32 Å or less). A nitrilium bound through its carbon, B-C#N-R, puts the cation on its nitrogen. Of two oxygens on one P or C bridging two vertices only one is the donor.
  4. Terminal oxyanions. A one-short terminal O, S or Se on a centre with room takes a double bond instead of a charge (highest_neutral: C and Si 4; N 3, or 4 beside two or more terminal oxygens; P and As 5; S and Se 6; Cl, Br, I 7), those not bound to a metal first, so a carboxylate bound through one oxygen keeps its C=O on the other. An oxygen pinned as the anion keeps its charge and is not given the double bond too.
  5. A metal-bound phosphorus or arsenic has spent a lone pair on the metal, so its neutral reading is three bonds and a P=O would make it four. A multiple bond to a terminal chalcogen that takes it above three goes back to single (R2P-O-) when that brings it to three and its other partners are satisfied. A P=C ylide’s second bond is left alone, because the carbon is short and wants it.
  6. Give-back. A metal-bound C or B with two ligand partners, or Si or Ge with three, hands back a double bond to an over-valent N (C and B only), O or S. This is what makes an N-heterocyclic carbene a neutral carbene and a boryl or silyl an X-type anion. Silicon does not give back to nitrogen: an amidinato silylene is Si(II), and taking the Si=N back made it a silyl anion.
  7. Linear nitrosyl. Set to N#O+ after perception, from the M-N-O angle (stage 7). It is set after and not pinned before: pinned, the search still ranked the neutral reading first for having no charged atom.
  8. infer_formal_charges, for cations the anions make possible. An atom with more bonds than its typical valence is charged only if a neighbour is visibly short the matching electron (a terminal oxygen on a nitro group), or it is protonated (N or O with at least as many hydrogens as the excess), or it is an onium (four single bonds on N, P or As, three on O or S). The default excludes the radicals: nitric oxide has no sink and stays neutral. The onium test needs every neighbour to be settled (settled): a boron counts only with four partners, and any other neighbour must be aromatic, on a metal, or at its typical valence. This is the boron partner test: charged alone beside a three-bonded boron, a phosphonium-borata ylide left its ligand +2 and the metal to balance it, and the N-methylated PTA on a phosphine ligand needed the four-single-bond rule (112 tmQM odd counts).
  9. Cage anions from PolyhedralCage::anions, nothing else on a vertex.
  10. Per atom, for each non-cage atom still at zero: main-group centres coordinated past their neutral valence are anions (B, Al, Ga with four partners -1; P, As, Sb with six -1; Si, Ge with five -1 and six -2). A hydride stays neutral (see the conventions section). A boron on the metal with three partners, one a nitrogen given a fourth bond, and two carbons is -2 beside the pyridinium’s +1. Otherwise the shortfall below the neutral valence (ligand_valence: H and the halogens 1; O, S, Se, Te 2; B, N, P, As 3; C, Si, Ge
    1. is the charge, with these exceptions: a carbon one short and triple-bonded to an over-valent O or N is a neutral CO or isocyanide pair; a carbon two short is a neutral carbene, or C2- between two onium partners (a carbone); a silicon or germanium two short with two partners is a neutral silylene; an amidinato silylene is left neutral. The shortfall applies to metal-bound atoms and terminal chalcogens only; a short atom elsewhere is left for the repairs.
  11. The iminium step. A nitrogen off the metal with three partners, a double bond to C, N or P, and valence four in a fragment left odd. If the iminium’s carbon also holds a single-bonded anion (N+=C-S-, N+=C-N2-) the pair goes back to the neutral form the convention writes, N-C=S and N-C=N-. In a fragment left even the same neutral form is taken for an oxygen, sulfur or selenium anion where two such arms pair up, so the fragment stays even: a selone boratrane’s two Se- arms (tmQM EFADIB and EDUYAG are two). One alone is left, since the repair that follows an odd fragment then charges something else (tmQM VUFMIV went Rh(-I)). A boron partner is not a donor left short. Before this step an oxime drawn N(=O)=C, a neutral nitrogen with four bonds to two partners, is read as the oximate C=N-O- where its N-O is 1.25 Å or more (OXIMATE_N_O_FLOOR); shorter it is an N=O, and a boron-capped tris(dioxime) whose oxime hydrogens the file lacks (NODCUE, 1.23 to 1.24 Å) stays Tc(III). The floor is not clean: GOYVEX, a nickel oximate in tmQM, sits at 1.24 Å in tmQM’s geometry. Otherwise, for a nitrogen of a five-membered ring (an N-substituted azole), a path of alternating bonds to a neutral nitrogen or oxygen on the metal and outside any ring is swapped and that donor takes the charge, -1: an N-methyltetrazolyl amidate’s amide nitrogen rather than its ring (tmQM LUTSAU, JORREQ). Not from a six-membered ring, whose N-substituted nitrogen is by convention the azinium cation (tmQM NADRES), and not to a ring donor, since an N-alkyl bipyridinium is a cation whichever way its rings are drawn. Otherwise the nitrogen is charged +1. Not an N=B (an aminoborane is R2N=BR2, and charging its N put Cr(0) at Cr(-1)), not beside a partner left short and uncharged (the amidinato silylene), and only in an odd fragment: where the fragment is even with the N neutral, something else balances the N+, and charging it alone moved a cobalamin from Co(III) to Co(II).

The repairs, in the order they run

Every step below works on the plain reading from ionic_ligand_charges(atoms, bonds, &[]) and is kept only if its acceptance test holds. Almost all of them test the same thing: strictly fewer odd fragments than before (ligand_fragments(...).odd.len()). That is the odd-fragment gate. Parity is the only check that is independent of the choice being made, which is why it is the common test, and why a new repair needs a control in which it would have made a right reading wrong. The notes record one for nearly every row.

# Function Trigger Action Kept when
1 cyclopentadienyl_sites_in A five-carbon ring with at least three atoms on a metal (cyclopentadienyl_rings) Pin one carbon per ring as the anion: the atom perception already charged if exactly one, else the CH with the fewest heavy partners No more odd fragments than the plain reading, and no ring with two anions unless it is a tucked-in Cp* (a carbon off the ring on the metal). A pin perception drew with four bonds moves to the ring’s short carbon and the ligands are perceived again
2 cyclooctatetraenyl_candidates An eight-carbon ring on Sc, Y, La to Lu or an actinide at four or more atoms Try pairs of ring atoms for the two charges, three apart first The first pair with no more odd fragments than the reading. Not on Ti, Zr or a late metal: 56 tmQM complexes went past a state the metal can hold, and on a late metal an eta-4 COT is a neutral diene
3 tetrapyrrole_sites Four pyrrolic nitrogens on one metal in rings joined by one atom Porphyrin: two opposite nitrogens as anions. Corrole (one direct ring-ring link, no sp3 ring atom): three. Corrin: left alone Odd count not above the plain reading
4 odd_detached_anions An odd fragment bonded to no metal, with at most one atom within DETACHED_ANION_REACH = 3.3 Å of a metal, at most eight heavy atoms and two carbons A lone halogen, else a terminal N, O or S, is the anion Strictly fewer odd fragments
5 odd_fragment_donors, metal_bound_anions Odd fragments Pin the fragment’s best metal-bound donor: terminal first, then O, N, S/Se/Te, C; not a carbon of a Cp ring that already holds a charge. A second try keeps the first reading’s metal-bound N, O and S anions as well Strictly fewer odd fragments, best of the two
6 odd_fragment_ketimide_nitrogens An odd fragment with a nitrogen of one heavy partner, no hydrogen, at a C=N length (1.25 to 1.29 Å imine, 1.33 and up single) The nitrogen as the anion with its C=N Fewer odd fragments
7 odd_fragment_imido_nitrogens An odd fragment with no donor left to pin A nitrogen drawn with a double bond is perceived again as a dianion (imido, hydrazido) Fewer odd fragments
8 undecided_alkylidenes A heteroatom-free alkylidene carbon left at -1 in an odd ligand Perceived two-valent, first every candidate, then one per fragment (the one nearer the metal) Fewer odd fragments
9 odd_fragment_ring_nitrogens, odd_fragment_phosphonium, ylide_phosphonium_beside_carbene, odd_fragment_cage_carbons An odd fragment with a substituted aromatic ring nitrogen; a four-carbon phosphorus; a phosphorus drawn as an ylide beside a two-valent carbon on the metal; the one short carbon of a cage ligand The cation (pyridinium, imidazolium; phosphonium; carbenium or amidinium on a carborane) Fewer odd fragments (the ylide case: no more, and no two-valent carbon left on the metal)
10 complete_pi_pairing A fragment still odd Pair short carbons along an alternating path Fewer odd fragments. A last resort: as a first reading it took an anion’s place and read 170 Ni(II) and Pd(II) salens as M(0)
11 donor_subset_search Odd or unsound fragments remain For each such fragment of at most MAX_SEARCHED_DONORS = 6 metal-bound O, N, S, Se, Te or C donors, perceive every subset as the anions (and a vinylidene or allenylidene carbon as two-valent), at most MAX_SEARCHED_READINGS = 192 readings. Prefer O, N, S, C then fewer anions. A second pass holds the donors of a delocalised chelate or a thioamide neutral. Skips fragments with an alkylidene A repair that only cleared short carbons is taken only if no fragment is left odd or unsound; 94 CSD complexes went right to wrong without that condition, and 488 were won with it
12 quinoidal_donors A metal-bound nitrogen with no hydrogen, in no ring of the ligand, double-bonded to a benzene carbon The amide anion, when the N-C is at least QUINOID_SINGLE_N_C = 1.35 Å (an amide’s runs 1.38 to 1.42, a quinone diimine’s 1.28 to 1.33) No more odd fragments and no more unsound ones
13 Fixed-state block Every metal has a fixed_oxidation_state See stage 3 See stage 3
14 Dioxolene radicals A chelating catecholate or N-aryl amidophenoxide whose metrical oxidation state is within 0.35 of -1 See stage 5 See stage 5
15 apply_alkylidene_convention A heteroatom-free alkylidene See the conventions section n/a

A fragment can come out even by chance, so the odd test alone misses a ligand that perception gave up on. unsound_fragments covers that case and sends such fragments to step 11.

Four rules that look arbitrary and are not, each found by a loss:

  • The pin is not the whole story for a Cp. Step 1 refuses a ring with two anions because perception can draw a pinned carbon with a double bond, leaving the next carbon short; the pin keeps its charge, the short carbon takes another, and nothing is odd to refuse the dianion. Both rings of an ansa-zirconocene came out that way, Zr(VIII) for Zr(IV).
  • Step 6 skips a ring that already holds a charge. A pendant pyridinium on a zirconocene is a cation off the metal; given no other donor, the repair charged the ring a second time and the metal came out Zr(VI). Passing over the whole fragment instead lost a tethered Cp* on titanium whose second anion is a sigma-vinyl carbon, so the next donor is taken.
  • Step 12 does not search a fragment that holds an alkylidene. Searched, 188 Hoveyda-Grubbs benzylidenes and Schrock imido-alkylidenes were made even by moving a charge somewhere else, an imido read as a nitride.
  • Step 7 is a repair and not a pin before perception. Applied to every such nitrogen first, it changed 146 tmQM counts and left five fragments odd that were even.

What the stage cannot do

  • It reads each ligand as closed-shell. A radical ligand outside the nitroxide and the dioxolene is not recognised, and radical ligands are the largest class of wrong states that was traced to a ligand family in the CSD benchmark.
  • It cannot see a charge the file does not carry: a counter-anion absent from the coordinates (a complex named for a Co(III) salt whose third borate the file lacks reads Co(II), right for the file as written), or an oxime hydrogen the file leaves out.
  • A file short of hydrogens makes ligands that are not whole molecules. carbons_short_of_a_neighbour in stage 7 names that case instead of reporting unresolved.
  • The allocation in stage 3 reads at most six coordinating ligands, four donors and two ring nitrogens each.

Stage 3: the division and the metal’s state

The tail of resolve_ionic_in_order gives the metals their share.

Fixed-state metals. When every metal in the scene has one, fixed_oxidation_state(z) is +1 for Li, Na, K, Rb, Cs, Fr; +2 for Be, Mg, Ca, Sr, Ba, Ra, Zn, Cd; +3 for Al, Ga, Sc, Y, La, Pr, Nd, Pm, Gd, Tb, Dy, Ho, Er, Lu, Ac; +4 for Th. Not Ce, Sm, Eu, Tm or Yb. Such a metal says exactly how many anionic sites the ligands must hold, and the resolver checks. If the ligands hold fewer, up to as many metal-bound donors as are missing (anionic_donor_sites: terminal O and S, then N with at most two other partners, then an alkyl or benzyl carbon with three ligand neighbours, never a cage vertex) are made anions and the ligands are read again, kept only if no more fragments are odd and the total is the one the metal leaves. If the total is still not net minus the fixed states, allocate_to_targets runs (below).

Open metals. After the ligands are settled, a fixed-state metal’s charge is its state less one per hydride (a hydride is written neutral, so ZnH is Zn+), and remainder = net - ligand charges - the fixed metals' charges. Metals without a fixed state share the remainder evenly, the first taking any odd unit. A share outside -2 to +8 refuses the whole resolution. With no open metal the remainder must be zero.

A state chosen elsewhere. resolve_ionic_complex_toward takes targets, one state per metal. If the ligands’ charges already sum to what the targets leave, the reading stands. Otherwise allocate_to_targets reads the ligands again with their charges allocated to make the target. The alternatives a ligand has are the ones the repairs already use: which of its metal-bound donors (O, N, S, Se, Te; at most MAX_DONORS = 4; terminal first) are anions and which of its substituted ring nitrogens (at most MAX_CATIONS = 2) are cations. Each coordinating ligand (at most MAX_LIGANDS = 6) is read with each subset pinned, every other ligand left alone, and each option is costed as [odd, unsound, distance from the Hückel guess, atoms changed]. Ligands are independent, so the cheapest combination whose charges sum to the target is a dynamic program over the total. One more run with the pins together is kept only if it gives the target and is no worse than the reading it replaces.

“No worse” has one exception that matters. Charge is conserved, so once the ligands sum to the target the parity of the number of odd-electron ligands is fixed by it: a target an odd number of units from the ionic state needs a ligand radical whatever the readings. The gate allows the forced radical (forced_odd), unless the file declares multiplicity 1 (closed_shell): then a radical is a ligand the file says is not there, the target is refused and the metal keeps the ionic state. Refusing every odd fragment refused 340 of the targets. Of 1,507 CSD complexes whose identifier had disagreed with the reported state, 933 are reconciled this way; 574 remain, mostly an ionic state that is too high with ligands that are single atoms or have no alternative (CMADPT, a Pt(IV) tetrahalide, where nothing sums to Pt(II)).

ionic_reading is where the targets come from (compute_ionic_reading):

  1. Resolve the complex with no targets. Those states are ionic_states, what trust is graded against.
  2. For each metal ask StateEvidence::decide (stage 4, with ReadingCheck::NotRequired) for the reported state. Where its source is BondValence, Coordination or Stated and it differs from the ionic state, that is the target and moved is set; otherwise the ionic state is the target.
  3. If moved, call resolve_ionic_complex_toward with closed_shell from the declared multiplicity. If every target is reached the result is the reading and reconciled holds the metals that moved; if not, the plain reading stands and the conflict is reported by stage 11.

A state given with --oxidation-state is not a target: the reading follows only a state a sum or the coordination number chose, or one the file states. A stated state is never taken on trust against the ligands either; where no reading reaches it the identifiers keep the inferred state and report the conflict.

The fallback. Where a charge is declared and ionic_reading is None, the identifier writers and the depiction call resolve_declared_charge: fixed-state metals take their state first, then resolve_variable_metal (a single variable metal tried at each state its bond-valence table has, keeping the state that leaves the fewest uncharged one-short oxygens and sulfurs), then resolve_ligand_charges, which places the charge on one-short terminal oxygens and sulfurs, over-valent chalcogens and four-coordinate boron-group atoms until the total is reached. It is a provisional charge placement and not a division by the ionic model: the depiction labels it InterpretationModel::DeclaredCharge, and the count declines with Unresolved.

Stage 4: the state source order

OxidationStates::resolve(snapshot, atom, given) returns the state a metal is assessed at and where it came from. OxidationStates::for_scene solves the ionic model once per scene. The order, from StateEvidence::decide_with_reason and the private computed it calls:

# Source Rule Evidence
1 Given The caller’s state (--oxidation-state) n/a
2 Stated A state the file states (a CIF’s Fe3+, a PDB FORMUL charge), kept as STATED_OXIDATION_STATE_PROPERTY n/a
3 FormalCharge The atom’s own formal charge, when not zero n/a
4 BondValence The ionic state is 0 and a consistent sum gives another An oxocarbon (squarate, croconate) is read as a neutral ligand and leaves Cu(II) at 0: 292 CSD complexes. Organometallics have no consistent sum and keep their 0
5 BondValence The ionic state is 0, or +1 on iron or manganese (needs_pi_acceptors), and a complete sum (every bond summed, consistent or not) gives a higher usual state, in a Werner sphere (is_werner_sphere) Of 187 CSD results left at 0, almost none was named at 0, and a complete sum named the right state for 133 of the 186 it answered. Iron(I) and manganese(I): ionic model right for 13 of 79, sum for 56. Cobalt(I) and nickel(I) are left out: the ionic model is right for 86 of 152 and 79 of 115 there, and the sum is worse for both
6 BondValence The ionic state is outside the element’s usual set, and the best self-consistent sum, however poor, gives a usual one (prefers_bond_valence) 1,477 CSD results sat outside the usual set and 5.2% were right. Gain 958, loss 78; derived from one half of the entries and tested on the other half, 1,093 gained and 71 lost
7 IonicModel The ionic state, when the file declares a total charge and the model divides it Where the two disagree the ionic model is right 9,088 times to 3,319. Ru(II) is the sharpest case: the tables have no Ru(II) row, so a sum can only land on Ru(III) or (IV), and 790 adopted sums were right for 14
8 BondValence No ionic state and a consistent sum n/a
9 Element Neither answered and the metal has a fixed_oxidation_state 81 wrong CSD entries were zinc, cadmium or yttrium, 72 of them left at 0 with no source because the ionic model could not divide the charge; the element’s state is the named one for 72
10 None Nothing answered The state shown is the formal charge, zero

Two corrections follow the table:

  • The reading check. If the source is BondValence, an ionic state exists and differs, the ionic state is not beyond the metal’s group number (beyond_the_group), the metal has a carbon neighbour, and no reading of the ligands reaches the sum’s state (ionic_reading(...).reconciled lacks the atom), the ionic state is restored. Over the 1,192 tmQM sites that no reading reached, 258 organometallic, the count at the ionic state lands on 18 or 16 in 43% (14 over 18) against 24% (101 over) at the sum’s. Without a carbon bond BOS-Lig agrees, but the CSD names do not: dropping the replacement everywhere cost 317 of 62,566, so those keep the sum.
  • coordination_state replaces a state the donor count cannot carry, after the rules above and only for ionic-model and bond-valence results: gold in at most three bonds at +3 or more becomes Au(I); palladium or platinum in at most four bonds at +4 or more becomes +2; copper that a sum puts at +1 over nitrogen and oxygen donors alone becomes Cu(II). The source is Coordination. Of the CSD results read as Au(III) in two or three bonds, almost all are named Au(I). Of those read at +4 in four bonds, 63 of 65 are named +2 (Pd 37 of 38, Pt 26 of 27), while in six bonds 358 of 367 are named +4. Together they gained 106 and lost 2. Copper: 42 of 48 sum-Cu(I) results over N and O donors alone were named Cu(II), because the sum’s N and O parameters do not depend on the state and it lands on the lower. The ionic model’s own Cu(I) is left alone; a Cu(MeCN)4+ is right.

The usual-state table. usual_oxidation_states(z) lists, for the d block, the states the CSD names for at least 0.4% of that element’s mononuclear Werner complexes (64,104 entries), plus the zero- and low-valent states organometallic chemistry uses that the benchmark cannot show. The lanthanides and actinides take rows by the same rule from the f-block benchmark: every lanthanide but promethium, and thorium, uranium, neptunium and plutonium, plutonium with plutonyl’s +5 and +6 added. It feeds prefers_bond_valence (rule 6), the Werner-sphere test of rule 5, flag_unusual in stage 8 and the peroxide alternative in stage 11. At 0.4%, 1,477 results fall outside and 77 are right; at 0.2% the table would flag 1,243 at 1.9% right, at 1% 1,832 at 8.9%. A state outside the table is not impossible (Cu(III), Ni(IV) are real); it is one the program reads wrongly nineteen times in twenty.

The bond-valence second opinion

core/backbone/src/bond_valence.rs holds the parameters and the sums. The expression is s = exp((R0 - R) / B) with B = 0.37 throughout (bond_valence).

  • PARAMETERS: 896 rows, each tagged with its paper. Brown and Altermatt (1985) wherever it has a value, otherwise Brese and O’Keeffe (1991). A pair whose best value comes from any other source in Brown’s compilation is absent, not downgraded: the accumulated compilation is licensed for non-profit use and is not vendored, so every row traces to a primary paper.
  • UNSPECIFIED_VALENCE = 9: the compilation’s rows with no state. They are the fallback for a state with no row of its own and are counted in generic. They are not a state: offered as a candidate, 374 of 1,039 estimates against BOS-Lig were +9, a rhodium with only nitrogen bonds among them.
  • COMPLEX_PARAMETERS: 35 Liu and Thorp (1993) rows for V, Mn, Fe, Ni, Cu and Mo, consulted only where the two papers have no row for the state. Their V=O and V=S rows are left out. With every_state_covered required: 84 consistent estimates, 73 right, against 80 and 68 without; nickel from 2 of 2 to 7 of 8.
  • LANTHANIDE_COMPLEX_PARAMETERS: 64 Trzesowska, Kruszynski and Bartczak (2004 to 2006) rows fitted to lanthanide coordination complexes, Ln(III) to O, N, Cl and S and Ce(IV), Sm(II), Eu(II) and Yb(II) to O and N, consulted after COMPLEX_PARAMETERS.
  • F_BLOCK_OXIDE_PARAMETERS: 21 Gagné and Hawthorne (2015) f-block oxide rows with R0 and B both fitted, consulted next; U(VI) had no row before them.
  • ACTINIDE_PARAMETERS: 41 rows for the actinide states the tables above leave out, An(III) and An(IV) above all: Np(IV)-O (Gilson and Burns 2021), Pu(IV)-O (Diwu et al. 2010) and Zachariasen (1978) for the rest. Consulted last. There is no U(III)-O or Np(III)-O row, so those states have none.
  • COPPER_PARAMETERS: 25 Shields et al. (2000) rows, used for copper in place of every other source, with nitrogen split by its ligand neighbours (one a nitrile, two a pyridine or imine, three an amine) and 16 COPPER_I_LOW_COORDINATION rows for two- and three-coordinate Cu(I). Cu(III) is left out: the paper’s rows are C, N and S only, from 3, 8 and 27 observations, and as a candidate it could not be summed over an O, P or halide donor. Against BOS-Lig’s 83 copper complexes, 6 estimates were right and none adopted before; 66 are right and 77 adopted (62 right) now. Over 1,334 stated copper sites in 627 COD structures, 170 of 223 adopted right became 353 of 402.

estimate_oxidation_state sums the bonds at each state the element has parameters for (tabulated_states) and keeps the state whose own sum is closest to it. A sum counts as a second opinion, in the sense that matters for trust, only if it is a measurement: residual <= BOND_VALENCE_CONSISTENT = 0.25, contributing == bonded (every bond has a parameter), and every_state_covered. The last guard exists because cobalt has no Co(III)-O row, so a cobalt oxide sums over all its bonds only as Co(II) and lands near 2 by default, without having beaten Co(III). A partial sum is not a measurement: four oxygens of a cobalt chelate alone land within 0.18 of +2 while the full six-bond sum misses by more.

Limits: tables are keyed by element and state, not by bonding environment (copper excepted); the main group, promethium and the actinides outside the usual-state table have no row there, so nothing replaces their states; a periodic structure counts its cross-face contacts through periodic_bonded_pairs. io/pipelines/tests/bond_valence_sweep.rs scores the sums against the states a CIF states (set ORBITRON_BV_CIF_DIR).

Stage 5: non-innocent ligands and the dioxolene redox level

Recognition

non_innocent_ligands(snapshot, centre) finds eight classes on a metal from connectivity and, for the nitroxide, one bond length: dithiolene, dioxolene, alpha-diimine, nitroxide, nitrosyl, verdazyl, flavin and an eta4 cyclobutadiene (four carbons on the metal closing a four-membered ring; a Cp’s five and an alkene’s two are not one). The list is short on purpose. These are the classes that account for states the ionic model gets wrong by the ligand’s choice and not by a fault; each has a control in the tests (a dithiocarbamate, an ethane-dithiolate, a catechol with both hydrogens, a bipyridine ring nitrogen and an N-oxide are not flagged).

Class Rule
Dithiolene Two sulfurs on the metal, each with one heavy partner on an sp2 carbon, the two carbons bonded
Dioxolene Two O or N donors on adjacent carbons of one benzene ring, outside any ring of eight or fewer atoms, at least one without a hydrogen; the donor is an O with one heavy partner or an N with at most two
Alpha-diimine Two nitrogens on bonded sp2 carbons that are not benzene carbons, outside any ring of eight or fewer atoms, carbon partners only. A bipyridine is not one
Nitroxide A metal-bound O on an N with two carbons (one sp3), no hydrogen and N-O at most NITROXIDE_N_O = 1.32 Å
Nitrosyl A terminal N on the metal with one O, the O on nothing else
Verdazyl A verdazyl ring (N1-N2-C3-N4-N5-C6, verdazyl_rings) on the metal by N2 or N4, or by a donor two bonds from C3
Flavin A donor in a flavin’s tricyclic core (a quinoxaline fused to a pyrimidine-2,4-dione), or a carbonyl oxygen on it; once per core
Cyclobutadiene Four carbons on the metal that close a four-membered ring

The nitroxide limit is 1.32 Å because a nitroxide’s N-O is shorter than an N-oxide’s (medians 1.30 and 1.34 Å over the CSD’s nitroxide- and N-oxide-named complexes) and a hydroxylamide’s is longer again; a limit at the nitroxide median, 1.30 Å, was too tight. Over 717 CSD entries with a metal-adjacent terminal N-O, 67 were wrong and 53 of those under 1.30 Å.

NITROXIDE_N_O is defined once, in charge_inference/radicals.rs, and non_innocent.rs reads it from orbitron_backbone, so the resolver and the caution use the same limit.

The other readings, said rather than graded

assess_site_trust gives each recognised ligand the charge the ionic reading gave it (IonicReading::ligand_charge_of, over the whole connected ligand) and, where that charge is one of the class’s (NonInnocentKind::charges: 0, -1, -2 for the dithiolene, dioxolene and diimine, 0 and -1 for the nitroxide, +1, 0 and -1 for the nitrosyl and the verdazyl, 0 and -2 for the cyclobutadiene, and for a flavin 0, -1 and -2 shifted up one for each proton its ring nitrogens carry beyond the oxidised parent’s), the ionic model’s state under each other reading: the state less the change in the ligand’s charge. The caution’s sentence ends with them and its ligand entry carries the charge. The grade is not touched, by decision (2026-10-14): it measures agreement with a compound’s name, which for these ligands is a convention, and lowering it would make it a calibrated probability of something else. Measured over the CSD benchmark at revision 28 (2026-10-14), before the verdazyl and flavin classes: 302 of the 1,412 wrong states carry the caution and 185 of those list the name’s state; 136 of a random 3,000 right ones carry it.

The nitroxide is a radical in the resolver

A one-short terminal oxygen is normally an anion. For a nitroxide it is a radical, and the resolver treats it so in four places: nitroxide_oxygens skips the shortfall charge and the fixed-valence anion repair; ligand_fragments adds one electron back per nitroxide so a fragment is odd only beyond its radicals; the per-ligand search exempts a single-bond radical oxygen from the charged atom count (nitroxide_sites); and a quinoid reading of a nitronyl nitroxide, with both oxygens neutral, ranks behind the zwitterion. The count credits the radical to each metal the fragment binds (stage 7).

The dioxolene redox level

core/backbone/src/scene/redox_ligand.rs reads a chelating catecholate or N-aryl 2-amidophenoxide’s redox level from its own bond lengths (Brown, S. N., 2012). The ring bonds and the C-O and C-N bonds were fitted linearly against the ligand’s oxidation state over 109 amidophenolates and 259 catecholates (C1-C2 quadratically); CATECHOLATE has five curves and AMIDOPHENOXIDE eight, recovered exactly from the paper’s spreadsheet (MOSCalculator.xls). metrical_oxidation_state finds the one state that minimises the squared residual: a scan from -4 to 3 in steps of 0.01 and then a golden-section search, so the answer is the same on every run. It reproduces the spreadsheet’s MOS for all 368 training ligands to 5e-5.

Scope follows the paper:

  • The nitrogen must carry an aryl; the ligand must chelate one metal and not bridge; the ring must not be fused (phenanthrenequinone, phenoxazine, quinolin-8-olate); a catecholate with a halogen or a nitro group is skipped; an sp3 ring is skipped.
  • Only fitted_metal: Cr to Zn (24 to 30), Ru to Cd (44 to 48), Os to Au (76 to 79). V(V), Mo(VI) and the like read a dianion as less reduced than it is by pi donation to the metal, which the paper attributes to bonding and not to electron transfer.
  • RedoxLigand::is_radical is a MOS in -1.35 to -0.65, twice the 0.17 spread of the calibration ligands about their nominal state. A MOS near -1.5 is mixed-valence and is left to the ordinary reading.

In the resolver (step 14 of the repairs) the radical ligands lose the pins the repairs gave their donors; the donor with the longer C-X bond (RedoxLigand::pin) takes the single anion; the other donor stays neutral through the neutral argument of ionic_ligand_charges_radical. The reading is kept if no fragment other than these ligands is left odd or unsound that was not before (radical_reading_is_no_worse). Reading every such ligand as a radical would have turned 122 of 506 CSD complexes from right to wrong, so the radical reading is made only where the geometry says -1. With the gate the reported state went from 94.9% to 95.1% right (128 turned right, 11 wrong). Where the name and the geometry disagree it is usually a valence tautomer, and the geometry is the evidence this program has.

site_trust reports each such ligand with its MOS (Caution::NonInnocent with mos_millis), redox_ligand_reports in metal_site.rs lists them, and the count marks an odd total made even by an odd number of them as LigandRadical.

Stage 6: boron cages

core/backbone/src/scene/polyhedral_cage.rs charges boranes, carboranes and metallaboranes by the skeletal-electron count. The ionic model charges an atom short of its valence and a cage vertex never is (a BH in an icosahedron has six partners), so before this module 1,101 of tmQM’s 1,672 boron-cage complexes were unresolved.

Finding the cage

The cage is found from geometry, not from the bond list, because bond inference caps boron and carbon at four bonds. cage_components_with:

Constant Value Meaning
CAGE_EDGE 2.0 Å Two boron or carbon atoms closer than this are neighbours
HETERO_EDGE 2.2 Å The same for a hetero vertex (N, Si, P, S, Ge, As, Se, Sn, Sb, Te) with three cage neighbours
CARBONYL_C_O 1.25 Å A carbon with an oxygen this close is a carbonyl, not a vertex
BRIDGING_HYDROGEN 1.6 Å A hydrogen within this of two vertices bridges them
BRIDGED_B_H, BRIDGED_M_H 1.45, 1.9 Å A B-H-M bridge: the B-H may stretch to 1.45 Å, the M-H to 1.9 Å
METAL_CONTACT 2.75 Å plus the metal’s covalent radius above 1.45 Å A metal this close touches a vertex
VERTEX_METAL 2.45 Å A boron with two cage neighbours, a hydrogen and a metal this close is a vertex
BORON_HYDROGEN 1.45 Å A hydrogen this close is the boron’s own

Candidates are pruned until every boron or hetero atom has at least three neighbours among those left and every carbon at least two boron neighbours (a phenyl ipso carbon, a methyl and a cubane carbon fall away; a carbonyl carbon is never a vertex). Two exceptions keep a boron with two neighbours: one between two cage carbons that has a metal within METAL_CONTACT (an ansa carborane’s substituted boron), and one with a hydrogen of its own and a metal within VERTEX_METAL (the rim of an open ligand, which would otherwise be pruned one atom after another until nine borons read as six). A cage needs at least five vertices and three borons. In the charging path (extended), a set over twelve is split where it is joined only by edges that lie in no triangle (split_linked_cages, taken only if every piece has five vertices and no vertex is lost), so two cages joined by an exo bond are two cages and a fused macropolyhedron stays whole. The 2.75 Å contact was measured over tmQM: a metal on a nido face has five or six vertices between 2.0 and 2.55 Å and the next beyond 2.96 Å; an exo bond has one at 2.1 to 2.3 Å and the next beyond 3.25 Å. The scaled limit came from a tantalum over a dicarbollide face at 2.43, 2.85 and 2.88 Å.

The electron count and the ladder

charge_cage counts, per vertex, the valence electrons (B 3; C, Si, Ge, Sn 4; N, P, As, Sb 5; S, Se, Te 6) less its exo bonds, plus one for each bridging hydrogen. A hetero vertex with no exo bond keeps a radial pair (S gives four, P three). Mingos’s and Jemmis’s statement of Wade’s rule gives the classes: a cage of n vertices needs n + 1 skeletal electron pairs for closo, n + 2 for nido, n + 3 for arachno, n + 4 for hypho. The charge is the shortfall to the smallest class that holds the electrons. A metal contact is a sigma bond, counted as an exo bond, only on a B or C with no other partner (sigma). A vertex that keeps its hydrogen and touches the metal is a face contact: nothing left its electrons, and counting the contact as a second exo bond took two electrons that were never lost and asked for a second dianion.

Two branches:

  • Free or exo-bonded: no metal contact, or one or two sigma contacts and at most two face contacts. The ladder starts at closo. The shortfall, one to three, goes on carbons first, on top of the sigma-bound vertices’ X-type anions. A free cage may be a trianion; a bound one with its X-type anions is at most a dianion, or an early metal came out Ta(VII).
  • Face-bound: the ladder starts at nido, and the shortfall (at most three) goes on the face, carbons first and then the borons nearest the metal. A closo cage of 9 to 12 vertices bound over a face (a B10H10 or B12H12 dianion on Rh, Pt or Ni) is four electrons short of the nido it would be if opened and is taken as the closo dianion, which keeps every vertex. A shortfall of five would be a trianion and no closo ligand is one (GAGGEA, an arachno B9 on ruthenium, regressed when it was allowed). Under nine vertices the metal is more often a vertex of the cluster (CODKUB, a seven-vertex ferracarborane with Fe-B at 1.83 Å), above twelve it is a fused cluster (HOKYEL, an iridium on 18 vertices).

Closo versus nido is never read from the geometry for the cage alone: beyond twelve vertices a 13-vertex cluster is 14 pairs with or without an open face (Kar and Ghosh), so a shape test would be wrong exactly where it is most tempting. That is also why the face is one six-electron site in the count, whatever the sphere assessment made of its carbons (Mingos: a nido fragment is a six-electron donor; Hawthorne: each B9C2H11 2- gives six electrons to Fe(II)). The tmQM sandwiches come out as the textbook has them: Co(III) at 18 for [Co(C2B9H11)2]-, Ni(IV) at 18 for the neutral (Jemmis: Fe 2-, Co 1-, Ni 0).

The cluster rule and its shape filter

For a cage bound over a face to one metal, charge_cage can charge the cage from the cluster the two make. It applies only when all of these hold, in this order:

  1. The complex’s net charge is known (polyhedral_cages_with(atoms, Some(net))), exactly one metal is within the contact limit of at least three vertices.
  2. metal_contribution is exactly BH_LIKE_FRAGMENT = 2: the metal’s group electrons (Z - 18, Z - 36 or Z - 68) plus the electrons its other ligands give counted as neutral molecules (a hydride 1; each connected group of two to eight metal-bound carbon atoms its atom count (a Cp* is its five ring carbons, the methyls are not counted); one carbon 2 with a carbonyl or nitrile and otherwise 1; N, P or As with three substituents 2; O, S or Se with two substituents 2 and with one 1; a halogen 1) less the twelve it keeps non-bonding, v + x - 12. It returns None for a ligand it cannot class, a second metal, more than one fragment, or more than 400 atoms. A fragment that would give three or four (a Ni with Cp, a Pt with three phosphines) is not one the rules settle and the cage keeps the smallest class.
  3. The shortfall short = 2 - net is 1 to 4, the cage then holds held + short electrons (an even number), and its class pairs = (held + short) / 2 - n is nido, arachno or hypho (2 to 4). The cage’s charge is then the complex’s less two.
  4. The cage has at most MAX_SINGLE_POLYHEDRON = 12 vertices.
  5. The shape filter. cluster_shape_class counts the cluster’s edges: cage vertices closer than the edge limits, plus one edge from the metal to each vertex within CLUSTER_CONTACT = 2.6 Å. A closed deltahedron of N vertices (the cage plus the metal) has 3N - 6 edges; the deficit is 0 for closo, 1 to 3 for nido, 4 to 7 for arachno. The rule applies only if the shape reads pairs - 1, one class short of the cage’s.

The edge count is independent of the electrons, which is why it is the filter: that the counts reach 18 is not evidence, since the rule gives the metal two electrons and 18 follows. It is a filter on where the rule applies and not a determination of the cage, because different graphs share an edge count, and the class it reads moves with the metal contact limit. At 2.5 Å four of the fourteen overrides that held before the d10 exclusion are not read as nido (FEWRII among them, its fourth Ru-B contact is 2.53 Å), and at 2.7 Å two of the three left alone (FANVAR, NOWMAP) are. So 2.6 Å is a chosen threshold and not a shown chemical boundary. The first 17 tmQM overrides were never required to match the shape; with the vertex sets the Rust code reads, 14 are consistent with nido and 3 are not (KAYNIL, an 18-vertex cage; FANVAR and NOWMAP, deficit 4), and those three are now left alone.

Support, kept apart. Miller, Weiss and Grimes (1977) count two skeletal electrons from CpCo, which supports the rule for CpCo (BEPSIY, CPCONB, KITDOH by analogy) and says nothing about any cage class. FEWRII, [(C6Me6)RuB10H13]-, is a nido eleven-vertex cluster with an arachno B10H13 3- fragment and Ru(II), in Bown et al.’s words an 18-electron octahedral ruthenium(II) complex. BEPSIY has the formula of nido-5-(CpCo)B9H13 in Barik, Roy and Ghosh. CpIr and (arene)Ru are taken on the same arithmetic and are not independently shown. The Rh thiaboranes come to two skeletal electrons for {RhH(PPh3)2} by the authors’ own convention, which is a derivation and not a quotation.

Provisional status

Where the cluster class overrides the smallest class, PolyhedralCage::decided is set, and three things follow. The count carries Decline::ClusterClass as a named assumption (the total is made on that reading). The trust gets the cluster_charge caution, which does not set the grade (a cage is an organometallic, graded from the organometallic table and held at moderate at most by capped_for_cluster). identify lists cluster_charge among its warnings (PROVISIONAL_CAUTIONS in identify.rs) because no alternative state exists to say the identifier is provisional. Where the cage is bound over a face and the smallest class holds, nothing is flagged; where the contacts are three or four, or the cage is not one polyhedron, the count says Decline::CageClass and gives the total two lower, the next class, as an alternative.

Nickel, palladium and platinum

These were withheld from the rule from revision 21 to 23 (NONCONICAL_D10, with PolyhedralCage::withheld and Decline::ClusterClassWithheld) and are not now. A d10 ML2 fragment has frontier orbitals that are not the three of a conical fragment, and Evans and Mingos find the platinum clusters they treat two electrons short of the rule in carbonyl-metal clusters. The reason it was withheld was that no source gave the count in a borane cage. Three do: Hong et al. (J. Am. Chem. Soc. 2003, 125, 16058) predict 26 skeletal electrons for the nido platinaphosphadicarbaborane (AQUHOJ), Kadlecek et al. (2003, 125, 212) the same for VAFRAW, and Bould et al. (Eur. J. Inorg. Chem. 2017, 4599) count twelve pairs on ten vertices for the nickellathiaborane (PEKTIL), each a complex of M(II) with a dianionic cage (and PEDROF is made from a Pt(II) dichloride). Their counts only work with the fragment at two, as CpCo is. Where the count and the structure disagree (PEKTIL and PEDROF are arachno where the count says nido) the authors put it down to the pseudo-square-planar metal and keep the count, so the charge is taken from the electrons and the class of the shape is not: that is also all this rule uses, since the cluster’s edge count is only a filter on where it applies. The four clusters read M(II) at 18 electrons, provisionally. notes/archive/cluster-rule-citations.md has the papers.

Bridging hydrogens

A hydrogen within BRIDGING_HYDROGEN of two vertices is a bridge and gives its one electron to the cage. A hydrogen on a vertex that already has its own hydrogen and reaches a metal (B-H within 1.45 Å, M-H within 1.9 Å) is also a bridge (FEWRII’s B-H-Ru beside a terminal B-H). Two consequences elsewhere:

  • Stage 1 bonds a hydrogen the metal took alone to its vertex too when the B-H runs just past the cutoff (1.33 Å against 1.31 in OZAREO and VIKDIF); alone on the metal it read as a hydride, a state too high, and left the vertex looking bare.
  • count_one leaves a hydrogen bonded to the metal and to a vertex of a nido cage the metal sits on out of the count, as part of the cage’s six electrons. Only for a cage: on a borane or borohydride, leaving it out moved 7 tmQM counts and lost as many from 18 as it gained.

What the module does not take

A metal at three or four vertices (bridged metallaboranes, where the three-centre bonding cannot be chosen from geometry; the count declines with CageClass), isocloso and hypercloso clusters, macropolyhedral cages (the mno rule), and a cage whose count does not fit. A slipped Ni, Cu or Au sandwich legitimately exceeds 18 (Jemmis), so an over-18 count there is not an error. HOKYEL, DIZKAY and CNIBOB were unresolved when last listed in notes/archive/open-items-closed-through-2026-10-12.md. Nothing here has an oracle for the charges: tmQM’s own SMILES is empty for 85% of the unresolved cage complexes, so the checks are internal consistency and the 18-electron landing for the organometallic ones.

Stage 7: the electron count

count_electrons(snapshot) returns, per metal, an ElectronCount or a Decline. The count is the metal’s d or f electrons at its ionic state plus what each site donates.

Order of work

count_electrons_as_read declines for the whole scene, in this order, with Periodic (a unit cell), NoDeclaredCharge, BondOrdersFromFile. It then asks for ionic_reading; if there is none it tries assumed_lone_counts (at most 400 atoms, every metal a fixed_oxidation_state metal, every site one atom: each donor gives two electrons whatever its charge) and otherwise declines Unresolved. It credits radical ligands to the metals they bind (redox_ligands that are radicals and whose fragment the reading left odd; nitroxide_radicals), finds the cages with the complex’s charge, takes the sites from assess_spheres, and runs count_one per metal followed by agree_with_reported_state. count_electrons replaces Unresolved and NoValenceShell with MissingHydrogens when carbons_short_of_a_neighbour finds the file short of atoms (a pyramidal sp3 carbon whose angles sum under 340 degrees, a bent two-neighbour sp2 carbon under 150, a methyl, a carbon with none; NEIGHBOUR_SLACK 0.35 Å, carbons within METAL_REACH 2.7 Å of a metal skipped). Over tmQM that is 9 of 48 declined and 6 of 2,499 counted, and every one of the nine is a file short of hydrogens.

The valence shell

valence_shell_electrons(z, state) in core/backbone/src/periodic.rs is the ionic-model count a chemist writes: a transition metal’s group less its state (Z - 18 - state for 21 to 30, Z - 36 for 39 to 48, Z - 68 for 72 to 80, Z - 100 for 104 to 112), a lanthanide’s Z - 54 - state, an actinide’s Z - 86 - state. None for a state that leaves the shell below empty or above full, which is why the count says NoValenceShell.

Donors

share_of classifies a site. The ionic charge on the bound atoms decides.

Donation Rule Electrons
Lone A single atom at charge 0 or -1 2
Hydride A hydrogen on the metal alone, charge 0 or -1 2, and the metal’s state one higher
NitrosylCation Linear nitrosyl, ligand charge +1 2
Lone (nitrosyl) Bent nitrosyl, ligand charge -1 2
Dianion An oxo or bent imido, charge -2 4
LinearKetimide N at -1 with one carbon partner of three ligand neighbours, M-N-C at least 160 degrees 4
Triple A linear imido (N at -2, M-N-C at least 160 degrees) or a nitride (charge -3) 6
Hapto A site of several bound atoms: hapticity less the charge on them pi electrons
Acceptor A neutral three-coordinate borane 0
SigmaBond A hydrogen on boron or silicon within bonding distance of the metal, where the B or Si is not itself bonded to the metal 2

An oxo is 4 and not 2 because the metal sits at the state the dianion implies, and the oxygen gives a sigma pair and a pi pair. The hapto rule is pi = hapticity less charge: an eta-5 Cp- is 6, eta-3 allyl- 4, eta-2 alkene 2, eta-6 arene 6, eta-7 C7H7+ 6. A nido cage the metal sits on is one six-electron Hapto site whatever the sphere assessment made of its atoms. The Z-type borane gives nothing (Green’s covalent bond classification): counted as a lone donor it put a diborin’s ring boron at 22 for 18 (EDAKUP) and a Z-borane on Ni, Pd or Pt at 18 where the metal holds 16. three_coordinate_borane finds one by distance, not from the bond list, which caps boron at four partners: three partners within BORON_PARTNER_REACH = 1.25 times the radii sum, none a hydrogen or boron. Counting by bond-graph neighbours moved 376 counts, and borohydrides and metallaboranes went to 14 and 16. Confirmed for metallaboratranes by the maintainer: the CSD names the pyridazine-thione boratranes M(I), as this reading does, and the tris(thioxoimidazolyl)boranes M(III) or Pd(II), counting the M-B bond as an oxidation, which this reading scores wrong. Measured again 2026-10-14 over the CSD benchmark’s 67 metals with a boron on three heavy atoms and no hydrogen: 52 match their names and 11 are named two states above this reading (that convention).

The nitrosyl

A terminal nitrosyl (terminal_nitrosyls: N on exactly one metal and one O, the O on nothing else) is read from the M-N-O angle by nitrosyl_geometry, the Enemark-Feltham convention. At NITROSYL_LINEAR_DEGREES = 150 or more it is NO+, a two-electron donor like CO, written N#O+ in stage 2. Under NITROSYL_BENT_DEGREES = 140 it is NO-, also two electrons. Between, or where the ligands were not read to match the angle, the two differ by two in the metal’s state and the total differs with them, so the count is made as NO+ with NO- two lower as the alternative. Of 1,382 terminal nitrosyls in tmQM, 1,304 are 160 degrees or more (M-N 1.70 Å, N-O 1.18 Å) and 108 sit at 110 to 130 (M-N 1.78 to 1.85 Å, N-O 1.20 to 1.21 Å). With the convention, 814 of 1,017 counted tmQM nitrosyls land on exactly 18.

What the geometry leaves open: Unsettled

Where the geometry decides part of a class and the rest are two readings a pair of electrons apart, a single guessed count was wrong too often (14 to 27% over 18 against 0.3% for the sweep). The count is made on one reading and the other totals travel with it as Unsettled { reason, alternatives }; reading() is CountReading::Unsettled where there are alternatives.

Reason (Decline variant) What is open Counted as Alternatives
OddHapto A hapto site whose bound atoms give an odd pi count: the pi system reaches past the atoms in range (a slipped allyl or ring, a fulvene) pi - 1 (the lower even count) + 2
SideOn An atom within bonding distance behind a bound C or B donor, dropped by stage 1 Not bound + 2 each
SigmaBond A hydrogen on a silicon, or several on a boron already bonded to the metal Not bound (for a silicon bonded to the metal) + 2 each
Nitrosyl An angle in the 140 to 150 band NO+ - 2
CageClass A nido-or-beyond cage at three or four contacts, or over twelve vertices The smallest class - 2
StateOverridden A bond-valence sum or the coordination number replaced the ionic state and no reading of the ligands gives it The ionic state none, the difference is named
Carbene, ChargedDonor(+1), ClusterClass, LoneDonorsAssumed, PiSurplus A convention the total does not depend on, or an assumption The convention none

Side-on is not counted as bound because the CSD bonding an atom is not the atom donating two electrons. Measured over 842 tmQM sites with one dropped atom and an unambiguous node degree, the M-D-X angle separates the CSD’s bonded atoms from the rest:

M-D-X angle Sites The CSD bonds it
Under 70 23 91%
70 to 80 188 94%
80 to 82 78 69%
82 to 84 106 61%
84 to 90 249 40%
90 and over 198 34%

An acute angle is a three-membered ring, which is what side-on binding is. But adding two electrons for every acute contact landed 27% of the sites over 18; by element only the chalcogens and halogens, which keep a lone pair after the bond, gain much (26 of 34 on 18 or 16 against 17 with nothing added), and carbon gains 15 of 38 against 3. SIDE_ON_ACUTE_DEGREES = 80 is therefore only the wording of the decline sentence: it gives the angle, says which side of 80 it is on, and what the CSD does there. Silicon behind a carbon (the beta-Si of CH(SiMe3)2), hydrogen, vertices of a cage the count charges, and a neutral three-coordinate boron (a Z ligand, which has no pair to give) are not side-on partners.

The B-H class is settled by where the boron is: a B-H on a boron not bonded to the metal (a kappa-2-H,N,N scorpionate, an L-type sigma-borane) is bonded in the CSD in 88% of 81 complexes and two electrons put 91 of 112 sites on 18 or 16, against 46 with none, so it is a SigmaBond site. One B-H on a bonded boron is bonded in 42% and two electrons put 60 of 89 over 18, so it is an agostic contact, left out as an agostic C-H is. Several on a bonded boron (a kappa-2- or kappa-3-borohydride) fit neither “all” nor “one”, so they stay open.

Over tmQM, among the unsettled ranges at least one stated total is 18 (16 on a d8 centre) in 68%, against 26 to 28% for the single guessed count, and every total is over 18 in under 0.5%.

Conventions the count assumes

  • A carbene with no heteroatom is a Fischer carbene (neutral, 2 electrons, state s) or a Schrock alkylidene (dianion, 4 electrons, state s + 2). The total is the same, (g - s) + 2 against (g - s - 2) + 4, so only the state is a convention, and the count follows the resolver’s choice (stage 2, alkylidene_is_dianion is its mirror).
  • A donor drawn at +1 (a phosphine as a phosphonium, the dative bond’s formal charges) is a neutral two-electron donor with the metal a state higher.
  • A d0 metal offered more than 18 by its pi donors (oxo, imido, nitrido, linear ketimide) counts (offered - pi).max(18): it has no electrons of its own and nine valence orbitals, so the pi pairs past 18 stay on the ligands. Mo(NAr)2Cl2L2, Re(NR)3X and Cp2Ti(N=CR2)2 read 18 where they read 20 (102 tmQM complexes, all d0). Past d0 an over-18 total can as well be a state the ionic model got wrong (a V(I) oxo, a Ti(II) bis-imido, a Ni(VI) on two dianions are among the 61 that would move), which a cap would hide, so there the message says both.
  • No valence shell. When the ionic state is one the shell cannot hold (Ti(VI), Nb(VII), Au(0) beside a borate, from over-charged ligands) and every donor is a lone one (or an acceptor), the count is made at the reported state: each donor gives two electrons whatever its charge. That is 29 of the 105 such tmQM centres; the others stay declined.
  • agree_with_reported_state. When the reported state is a bond-valence sum’s or the coordination number’s and differs from the count’s: with only lone donors the count is remade at the reported state; otherwise the count stands at the ionic state with StateOverridden naming the difference. Of 616 CSD results where the count at the ionic state read as an ordinary count, the report was right for 465 and the count’s state for 59.

Reading the number

CountReading sets the total against 18, or 16 on a d8 centre. Over (above 18 with a carbon on the metal, a hapticity or a charge is likely wrong) differs from OverWerner (above 18 with none: octahedral Ni(II), Co(II) and Cu(II) are 20 or more and the rule does not hold). FilledShell is a group 11 or 12 metal at d10 with an even total. OddOnClosedShell is an odd total where the declared multiplicity is odd, so the division of charge is wrong somewhere; LigandRadical is an odd total made even by an odd number of radical ligands; FBlock has no rule.

Declines

The hard declines, where no total is given: Periodic, NoDeclaredCharge, BondOrdersFromFile, Unresolved, MissingHydrogens, NoValenceShell, Multinuclear (another metal reachable through bonds), NotAssessed (the sphere assessment gave no sites), a ChargedDonor other than +1, and an OddHapto site with no pi electron to take off. Among tmQM’s 108,541 centres the declines are 0.14%.

Limits: a count is exact only when the division of charge is. bind_sigma_contacts counts a Si-H whose silicon is not bonded to the metal as a SigmaBond site and also offers its two electrons as the alternative, which would count it twice; no fixture reaches that case (the silicon of NUZSUX is bonded to the metal). A multinuclear centre is not attempted because bridging ligands and metal-metal bonds are shared by conventions this does not choose between. About 300 Over organometallics have no consistent bond-valence sum, and the loose estimate would put an 18 beside 47% of correct 18-electron counts too, so no alternative is offered.

Stage 8: the trust grade and its cautions

The grade is how often the reported state was right for metals like this one in the CSD Werner benchmark; the cautions say what the program saw. They are separate: a caution does not set the grade, and where one of them matters to how often the state is right it is a stratum of the table, so that the benchmark measures it.

assess_site_trust(snapshot, atom, evidence, ionic, decision, count) makes both. The cautions come from explain, which is the old rule’s conditions without its grades:

Source Caution
Given, Stated, FormalCharge none
Element Unresolved
IonicModel or BondValence at a fixed-state metal’s state none
BondValence replacing a different ionic state BondValenceFallback, carrying the Override that fired
IonicModel or BondValence, ionic and a measured sum agree none
The two disagree MethodsDisagree
Only the ionic model OneMethod("ionic model")
The ionic model, and a sum that is not a measurement (it leaves out bonds, lands far from its state, or its state was never compared with another) UnmeasuredSum, kind one_method
Only a sum OneMethod("bond-valence sum")
Coordination CoordinationPrior

and, on a computed state at a metal with more than one state, OddElectrons for an odd count on a declared closed shell, Unresolved for an unresolved or MissingHydrogens count, and NonInnocent for each recognised ligand. Then ClusterCharge (the count’s unsettled reason is ClusterClass), IdenticalLigandsDiffer, and UnusualState first for a computed state outside the usual table.

The grade comes from trust_table.rs. TrustFeatures is what a metal is looked up by: the ionic model’s state, a bond-valence sum’s state, the bonds on the metal, the kinds of atom bonded (donor_classes), the hapto groups (hapto: [5, 5] for ferrocene, empty for a Werner complex, keyed with the donors by donor_key), and three flags (a non-innocent ligand, identical ligands read at different charges, an unusual state). trust_table.csv holds, for each combination of element, source, whether the two methods agree, bonds, donors and the flags, and for broader combinations with parts dropped, how many benchmark complexes the reported state was right for. CalibrationTable::estimate reads a metal through them, narrow to broad: each level’s accuracy is pulled toward the broader one’s by a pseudo-count (strength, 10), up to the accuracy over everything. The grade is high at 0.99 or more, moderate at 0.95 or more and low below, by HIGH and MODERATE in trust_table.rs.

domain_of says whether a benchmark describes the metal, and which. A metal-carbon bond with no metal neighbour, on an element CalibrationTable::organometallic() has at least MIN_ELEMENT_COMPLEXES of, is Organometallic and is read through that table (trust_table_organometallic.csv, the same strata over the CSD’s organometallics). Without a declared total charge, in a structure with a unit cell, with a metal neighbour, with a metal-carbon bond on an element the organometallic table lacks (MetalCarbon), or for an element with fewer than MIN_ELEMENT_COMPLEXES Werner complexes, the grade is Uncalibrated and Calibration::domain is the reason. A metal reported at the one state it has in practice is FixedState and is graded by element_estimate, since nothing in its surroundings moves the state, unless it has a metal-carbon bond: the f-block organometallics hold Ln(II) Cp complexes that the element’s Werner rows graded high at +3, so an organometallic takes its stratum in the organometallic table instead. A state the caller gave or the file states is AsStated and has no calibration; none at all is no grade.

To change the table, extract both benchmarks (scripts/csd_werner_benchmark.py’s extract and extract --f-block), take the per-complex features of each with scripts/trust_calibration.py features (or from-baseline, from a scripts/metal_sites_baseline.py capture), and over the two files together check the scores with compare (the held-out part, once) and write the file with table. The organometallic table is the same over extract --organometallic and extract --f-block --organometallic, written with table --organometallic. notes/archive/trust-calibration-analysis.md has the method and the scores. The grade does not see what it cannot recognise: a radical ligand outside the eight classes, a metal-metal bond, a charge declared wrongly; and it measures agreement with a compound name, which for a ligand with more than one reading is a convention.

Two cautions that are warnings, not repairs

identical_ligands_differ. Two ligands with the same atoms and bonding usually carry the same charge, so where the resolver gave them different charges the assignment is suspicious and worth a look. It is a heuristic, not a proof: identical connectivity does not force identical charge, and ligand-based mixed valence is the counterexample (a chromium(III) complex with a semiquinonate and a catecholate of one framework, Chang et al., Inorg. Chem. 2002, doi:10.1021/ic025643p). The benchmark rates below are the evidence for using it as a caution. IonicReading::identical_ligands_read_differently groups ligands by a Weisfeiler-Lehman class (four rounds) of their element graph, hydrogens included, each atom marked with whether it is bonded to a metal, and lists the charges of any group whose members differ. Hydrogens must be in the graph: left out, an amide matches an amine, 27 tmQM complexes are marked and the rate reads 15% against 1%, none of which is real. Over the CSD benchmark’s 1,447 wrong states at the 97.7% step and a random 3,000 of its 62,657 right ones it marks 106 of the wrong (7.3%) and 10 of the right sample (0.3%, about 209 over the whole benchmark), so about a third of the states it marks are wrong against 2.3% overall. Over tmQM’s 4,466 it marks none and over all 108,541 tmQM records 145 (2026-10-16); it marked YAQVUL until revision 39 read that complex’s two diazadienes from their bond lengths, and ICIHOR, a nickel bis(dithiolene) read 0 and -2, until revision 40 read both its ligands as ene-dithiolates. The fixture for it is now GEPSEA, a platinum bis(benzosemiquinonediimine) whose identical ligands are read 0 and -2. It is a caution and not a repair because, forced to agree on the least anionic charge, 51 to 53 of the 106 wrong states would equal the name and all ten right ones marked would be lost. It does not say which ligand, if either, is wrong. YAPZEW-class cases, where one resonance form carries an uncharged four-valent nitrogen, are a representation question and not a convention.

cluster_charge. See stage 6. The evidence is the edge-count consistency and the arithmetic, 14 of the 17 first overrides consistent with nido, and the literature for FEWRII, BEPSIY and the CpCo family. There is no CSD-name figure because the Werner subset has no metal-carbon bond and so no cage complexes.

Both reach MetalState::notes() through PROVISIONAL_CAUTIONS, so identify and the Python bridge warn about them.

Stage 9: the ligand-field picture

ligand_field(centre, donors, d_electrons) is a pure planner: positions and a d count in, levels and fillings out. It is the sigma-only angular overlap model with every donor equal (Schäffer and Jørgensen; Figgis and Hitchman, chapter 3). Each donor adds w f f^T to a 5 by 5 matrix over the real d orbitals, where f holds the sigma overlap of each d orbital with a donor in that direction and w = (r0 / r)^5 for a donor at r, with r0 the mean metal-donor distance, so an elongation weakens the axial pair. The eigenvalues are the d-orbital energies in units of e_sigma, the sigma-antibonding energy of one donor on a d orbital pointing at it.

The textbook limits are the tests: an octahedron is three at 0 and two at 3 e_sigma (Δo = 3 e_sigma), a tetrahedron two at 0 and three at 4/3 e_sigma (Δt = 4/9 Δo), a square plane 3 at 0, 1 at 1, 1 at 3, and an octahedron with the axial pair stretched to 1.15 times the equatorial distance splits eg to 2.545 and 3.829. Levels are grouped within DEGENERACY_TOLERANCE = 0.06. The LFSE is Σ nᵢ (Eᵢ - Ē) in e_sigma and is a property of the model, never called a stabilisation energy. Both the high-spin and the low-spin fillings are given where they differ, with unpaired counts, and a Jahn-Teller flag where a partly filled degenerate level pulls the geometry: strong in a sigma-antibonding level (energy above a quarter of the top level), weak in a nonbonding one. A filling that is exactly zero is snapped to 0.0 so a half-filled shell does not print -0.00.

site_ligand_field declines, with the reason as a sentence, for: a periodic structure; no oxidation state; an f-block metal (spin-orbit coupling dominates); a hapto site; fewer than four or more than seven donors; an electron count that is unsettled (other than StateOverridden) or was declined (other than for a missing charge or file bond orders); an oxo, imido, nitride, linear ketimide or Schrock alkylidene (Dianion, Triple, LinearKetimide); a Z-type borane; a B-H sigma donor; and, where there is no count to say, any N or O within SHORT_MULTIPLE_BOND = 1.75 Å. The model has no pi term and no d-s mixing, and e_sigma is not estimated, so it never chooses a spin state; the report adds a note for 4d and 5d metals (Z = 39 to 48, 72 to 80), which are almost always low spin. On the CSD benchmark 92.4% of entries get a picture.

One asymmetry to know: ligand_field accepts 0 to 10 d electrons and the CLI (ligand_field_report in metal_site.rs) does not filter, so analyze metal-sites writes a trivial picture for a d0 or d10 centre. The desktop’s Scene Info panel passes only a d count of 1 to 9 (ui/shell/src/panels/scene_info/coordination.rs), because none of those gains or loses anything from the field.

orbitron plot ligand-field (core/services/src/plot/ligand_field_overlay.rs) draws the same picture and, with --orbitals, sets it beside a calculation’s d-dominant orbitals with one fitted e_sigma. The orbitals are picked by the share of printed squared coefficients on the metal’s d functions (--min-d-share, default 0.5), which has no overlap in it and so is a guide to character and not a population: among the orbitals above the floor, the five closest in energy are taken, because a bonding partner carries much of the same character. The fit is one scale and an offset per channel, closed form, and its residual is the result.

Stage 10: the coordination assessment

assess_spheres(atoms, bonds) reports what to say about each mononuclear metal’s sphere. It changes nothing: snapping is a separate call the user makes, because coordination number does not determine geometry at four, five, seven, eight or nine donors, exactly the range this project cares about.

Sites. coordination_sites groups the metal’s neighbours into the sites they occupy. Two neighbouring carbons that are bonded to each other are one site (an eta-5 Cp puts five carbons in range, and counting neighbours makes ferrocene ten-coordinate and asks which ten-vertex polyhedron it resembles, a question with no answer); so are the contacts of a six-membered C and B ring with a boron in it (boron_ring_members, each ring atom with at most three partners off the metal). Wider rules, any sp2 atom between two contacts or any C-B bond, moved 210 tmQM counts and 57 for cage-face six-cycles, and were not kept. A site’s position is the centroid of its atoms. CoordinationSite::ring records whether the atoms close a ring, which is how label names an eta-5 Cp or an eta-3 allyl; it says nothing of charge. These sites are what stage 7 counts.

Verdict. verdict_for fits the site offsets to SHAPE’s reference polyhedra (shape_measure.rs, a continuous shape measure after Avnir and Alvarez, with the vertex sets SHAPE prints and each tagged with its SHAPE code). The thresholds are stated as angular deviation and converted by calibration, not taken from corpus percentiles:

Constant Value Means
DRIFTED_ABOVE 3.0 Above it the sphere is recognisably the named shape and visibly not it: donors about 30 degrees, or 1.1 Å, off. Drifted, an offer and never an action
NO_CLEAR_MATCH_ABOVE 20.0 Above it the closest shape is not a description of anything: about 90 degrees off. NoClearMatch
MEANINGLESS_COORDINATION 1 One donor has no shape, so TooFewDonors. Two donors alone always measure zero, so they are fitted with the metal pinned, as SHAPE measures ML2 (before 2026-10-19 this was 2, and a gold(I) bent to 55 degrees passed unassessed)

Below DRIFTED_ABOVE the sphere is Clean. Unrecognised is a donor count with no reference shape. Which references judge a sphere depends on its centre (verdict_references, by CentreClass): a centre with an active lone pair (has_active_lone_pair: As, Sb and Bi(III), Se and Te(IV), Ge, Sn and Pb(II), and no boron donor) is judged against vT-2 and vOC-2, vT-3 and fvOC-3, SS-4 and SP-4, and vOC-5 alone; a two-coordinate Cu, Ag, Au or Hg against L-2 alone; and anything else against the nearest of references_for its count, with the metal pinned at two and three donors. Those shapes come from a CSD census of the lone-pair ions (notes/archive/donor-radii-and-lone-pairs-2026-10.md). The corpus percentiles these replaced let a donor sit 1.3 Å from where it belongs and still read Clean. shape_rows reports every reference for the count (measured_references, which adds fvOC-3 and SS-4 for every centre, though neither ends a shape path), measured both ways: donors only (what the verdict is calibrated on) and with the metal as a vertex (SHAPE’s ML_n convention, the number published tables quote). The reference coordinates are checked against SHAPE’s own Fortran in shape_measure/tests.rs and against Casanova et al.’s table of eight-vertex polyhedra.

Gates. is_mononuclear keeps the assessment off ionic lattices and polynuclear clusters. assess_spheres_in_cell handles a periodic scene by minimum image, by reassembling a molecule that a cell face cuts (assess_reassembled), or, for an extended solid, by the polyhedron of the scene’s bonds and cross-face pairs (assess_extended).

In the report. analyze_metal_sites (core/edit/src/geometry/metal_site.rs) compares each donor distance with tabulated_metal_donor_distance (Shannon radius at the state and coordination number, or a CSD-derived one where Shannon has none at that number or state, plus a measured donor radius, with a mercury(II) chloride, bromide or iodide read at two-coordinate and an actinyl’s equatorial donors ACTINYL_EQUATORIAL_OFFSET further out, which applies where is_actinyl finds two oxo groups. The halides’ radii were fitted on the CSD’s terminal contacts on 2026-10-19, and before that a halide donor had no comparison; over 64,104 single-metal CSD complexes that change and the low-coordinate radii took the flagged sites from 625 to 72, nearly all of them two-coordinate gold(I) that had been compared at six donors. Nitrogen’s radius was refitted there the same day, 1.55 to 1.45, and with the actinyl offset, the mercury rule and the radii for states Shannon lacks took them from 72 to 26; on 2026-10-20 it became 1.43 on a metal of groups 4 to 12 and 1.48 on any other, which took them to 23, measured before ionic_radius read a coordination number midway between two tabulated ones as their mean, later that day) and sets needs_review when the verdict is no_clear_match or a donor is more than DISTANCE_ANOMALY_ANGSTROM = 0.5 Å from its tabulated value, about twice the 0.29 Å residual standard deviation behind the table over 21,870 contacts. A drifted sphere is not flagged: over 242 freely optimised structures the median measure is 4.3 against 3.0, and flagging drift marked 70% of sites and triaged nothing. An actinyl or vanadyl oxo is told from an equatorial donor by a fixed window, OXO_WINDOW = 0.25 Å (core/backbone/src/metal_oxo.rs, applied by reads_as_oxo), about the core distance oxo_core_bond gives for the state, and no further than the core’s longest (1.74 Å for both vanadium cores, the limit of the vanadyl population in Schindler, Hawthorne and Baur 2000; 2.03 for an actinyl); the window was 0.35 first and counted three oxo groups on a uranyl. OxoCore::arrangement separates the axial cores (actinyl, vanadyl: oxo groups drawn out for the verdict, a linearity check on the two-oxo one, an expected count) from the cis one (V(V): none of those, because its oxo groups are 105 to 110 degrees apart by nature, its count varies, and the drawing-out moved 52 of 75 sites away from their shape). Its slots in coordination_template.rs are not a reference polyhedron but angles taken from the CSD, idealised to C2v. Where the template names no core, terminal_oxo(z) gives the element’s M=O and its limit, so an oxygen at or under the limit is oxo for any state. OxoCore::preferred and ceiling carry the coordination numbers the core builds with (vanadium(V) 5 to 6, molybdenum(VI) 6). bent_between is the O=M=O range the report marks for review: 0 to 170 for an actinyl, 140 to 155 for trans-dioxo osmium(VI), whose cis isomers sit below 140. A two-oxo axial core on six neighbours takes the octahedron, whose first two vertices are the trans poles. The drawing-out is applied only when it lowers the measure (verdict_measure): it improves octahedra, bipyramids and trigonal bipyramids and worsens square pyramids with the oxo at the apex.

Stage 11: identifiers and metal_states

identify_scene(scene, inchi_options, smiles_options) writes the formula, InChI, InChIKey and SMILES, each independently best effort, and calls scene_metal_states. Both writers divide a complex with ionic_reading; the InChI writer hands the library the ions and drops the metal bonds, because given the metal bonds the library disconnects them itself and charges the metal once more for every neutral donor (zinc came out +3 under a keto oxygen). A hydride is kept neutral in the identifier because every InChI source that writes one disconnects it as a neutral H.

MetalState carries, per metal: identifier_state (what the identifiers carry, None where the model did not divide the complex), reported_state and its source, reconciled, supplied_state and inferred_state (with a stated state set aside), bond_valence_state (a sum over every bond, where it is consistent and complete), alternatives, side_on_dioxygen, confidence and cautions (the same assess_site_trust, not a second rule). notes() turns it into the warnings both front ends print:

Code When
identifier_state_differs identifier_state is not reported_state: no reading of the ligands gave the reported state, so the identifier is the weaker of the two
supplied_state_conflict A stated state differs from the one inferred; says whether the ligands were read to give it
oxidation_state_ambiguous No state was stated and alternatives is not empty
cluster_charge, identical_ligands_differ The provisional cautions of stage 8

alternatives holds the inferred and stated states that differ from the one carried, and, for a metal with both ends of an O-O bond bound to it, the state two higher if it is usual for the element (a peroxide against two oxides). The bond-valence state is reported beside these but is not one of them: where it disagrees the ionic reading is right 9,088 times to 3,319, so a difference alone is data and not a reason to call the identifier ambiguous.

IDENTIFIER_REVISION is in core/backbone/src/lib.rs, reported beside every identifier by the CLI and Python. It is bumped whenever the same structure and options can give a different identifier; the crate version does not say that, because every commit between two releases reports the same one. core/backbone/IDENTIFIER_REVISIONS.md logs what each revision changed (1 the one-search resolver, 2 per-ligand perception, 4 the nitrosyl convention, 5 the alkylidene convention, 7 the cage ladder, 9 order independence, 20 the cluster rule, 21 its exclusion for Ni, Pd and Pt, 49 the diazadiene read on group 4 metals, 50 quadruple bonds in SMILES, 58 a boron cage bonded along every edge, 59 a declared charge leaving a closed-shell molecule’s SMILES alone; the file has every one). Add a line there in the same commit as the change.

References

Each DOI was checked against its Crossref record (authors, journal, volume, pages). “Where” says what the code uses the paper for. Bold marks a gap between what the repository says and the citation given here.

Reference Where
Brown, I. D.; Altermatt, D. Acta Cryst. B 1985, 41, 244-247. doi:10.1107/S0108768185002063 Bond-valence R0, B, preferred rows. bond_valence.rs
Brese, N. E.; O’Keeffe, M. Acta Cryst. B 1991, 47, 192-197. doi:10.1107/S0108768190011041 Bond-valence rows where Brown and Altermatt have none. bond_valence.rs
Liu, W.; Thorp, H. H. Inorg. Chem. 1993, 32, 4102-4105. doi:10.1021/ic00071a023 COMPLEX_PARAMETERS: V, Mn, Fe, Ni, Cu, Mo complexes
Shields, G. P.; Raithby, P. R.; Allen, F. H.; Motherwell, W. D. S. Acta Cryst. B 2000, 56, 455-465. doi:10.1107/S0108768199015086 COPPER_PARAMETERS, COPPER_I_LOW_COORDINATION
Brown, I. D. Chem. Rev. 2009, 109, 6858-6919. doi:10.1021/cr900053k Background on the bond-valence model. Not cited anywhere in the repository
Chang, H.-C.; Mochizuki, K.; Kitagawa, S. Inorg. Chem. 2002. doi:10.1021/ic025643p Counterexample to “identical connectivity means identical charge”: ligand-based mixed valence in Cr(III) semiquinonate-catecholate complexes. identical_ligands_differ (stage 8). Not cited in the code; volume and pages not checked
Brown, S. N. Inorg. Chem. 2012, 51, 1251-1260. doi:10.1021/ic202764j Metrical oxidation state curves and scope. redox_ligand.rs, site_trust.rs, metal_site.rs
Reeves, M. G.; Wood, P. A.; Parsons, S. Acta Cryst. B 2019, 75, 1096-1105. doi:10.1107/S2052520619013040 The Werner-subset benchmark design, and the 99% agreement figure. scripts/csd_werner_benchmark.py
Rasmussen, M. H.; Strandgaard, M.; Seumer, J.; Hemmingsen, L. K.; Frei, A.; Balcells, D.; Jensen, J. H. J. Cheminform. 2025, 17, 63. doi:10.1186/s13321-025-01008-1 xyz2mol_tm: per-ligand perception, the Hückel charge guess and its thresholds. charge_inference/, huckel.rs
Hoffmann, R. J. Chem. Phys. 1963, 39, 1397-1412. doi:10.1063/1.1734456 Extended Hückel theory. Not cited in the repository; the code names only the parameter set
Ammeter, J. H.; Bürgi, H. B.; Thibeault, J. C.; Hoffmann, R. J. Am. Chem. Soc. 1978, 100, 3686-3692. doi:10.1021/ja00480a005 The weighted Wolfsberg-Helmholz form. Not cited in the repository; the code states the form and K = 1.75
YAeHMOP (G. Landrum), https://github.com/greglandrum/yaehmop, parameter file eht_parms.dat as shipped with RDKit Valence-state ionisation energies and Slater exponents. huckel.rs
Wade, K. J. Chem. Soc. D 1971, 792. doi:10.1039/c29710000792 Skeletal electron pair rule. The repository names “Wade’s rule” and gives no citation; this is the original
Mingos, D. M. P. Acc. Chem. Res. 1984, 17, 311-319. doi:10.1021/ar00105a003 The rule as the module states it; a nido fragment is a six-electron donor. polyhedral_cage.rs, notes
Jemmis, E. D.; Balakrishnarajan, M. M.; Pancharatna, P. D. J. Am. Chem. Soc. 2001, 123, 4313-4323. doi:10.1021/ja003233z The rule for metallaboranes and sandwiches (Fe 2-, Co 1-, Ni 0). The notes say “Jemmis, JACS 2001” with no page; this is the one on metallaboranes and metallocenes
Kar, S.; Ghosh, S. Struct. Bond. 2021, 187, 109-138. doi:10.1007/430_2021_85 A 13-vertex cluster is 14 pairs open or closed, so a shape test cannot choose the class. polyhedral_cage.rs
Evans, D. G.; Mingos, D. M. P. Organometallics 1983, 2, 435-447. doi:10.1021/om00075a015 Nonconical fragments: the electron count can say nido where the structure is arachno, as in PEKTIL and PEDROF. Ni, Pd and Pt were excluded from the cluster rule on it until revision 24
Miller, V. R.; Weiss, R.; Grimes, R. N. J. Am. Chem. Soc. 1977, 99, 5646-5651. doi:10.1021/ja00459a020 CpCo counts as two skeletal electrons. The code cites it as “Grimes”
Maynard, R. B.; Sinn, E.; Grimes, R. N. Inorg. Chem. 1981, 20, 3858-3863. doi:10.1021/ic50225a051 BAWCIL: two skeletal electrons from each BH and Co(C5H5), three from each C-CH3, 26 for an 11-vertex nido cage
Hong, D.; Rathmill, S. E.; Carroll, P. J.; Sneddon, L. G. J. Am. Chem. Soc. 2003, 125, 16058-16071. doi:10.1021/ja037799+ AQUHOJ: a Pt(PPh3)2 nido cluster of 26 skeletal electrons made from the cage dianion and Pt(II)
Kadlecek, D. E.; Shedlow, A. M.; Kang, S. O.; Carroll, P. J.; Sneddon, L. G. J. Am. Chem. Soc. 2003, 125, 212-220. doi:10.1021/ja020945b VAFRAW: 26 skeletal electrons, the cage a four-electron donor to Pt(II)
Bould, J.; Passarelli, V.; Oro, L. A.; Macias, R. Eur. J. Inorg. Chem. 2017, 4599-4617. doi:10.1002/ejic.201700539 PEKTIL: a Ni(II) complex of an arachno {SB8H10} 2- fragment, 12 pairs, arachno where the count says nido
Hawthorne, M. F.; Young, D. C.; Wegner, P. A. J. Am. Chem. Soc. 1965, 87, 1818-1819. doi:10.1021/ja01086a053 Each B9C2H11 2- gives six electrons to Fe(II). Cited in the notes by year only; this is the matching 1965 paper by title, not confirmed against the note
Bown, M.; Fontaine, X. L. R.; Greenwood, N. N.; Kennedy, J. D.; Thornton-Pett, M. J. Chem. Soc., Dalton Trans. 1987, 1169-1174. doi:10.1039/DT9870001169 FEWRII, the ruthenaborane that the cluster rule must reproduce
Barik, S. K.; Roy, D. K.; Ghosh, S. Dalton Trans. 2015, 44, 669-676. doi:10.1039/c4dt03027c nido-5-(Cp*Co)B9H13, the family of BEPSIY
Enemark, J. H.; Feltham, R. D. Coord. Chem. Rev. 1974, 13, 339-406. doi:10.1016/S0010-8545(00)80259-3 The linear NO+ and bent NO- convention. The repository names “Enemark-Feltham” and gives no citation
Schäffer, C. E.; Jørgensen, C. K. Mol. Phys. 1965, 9, 401-412. doi:10.1080/00268976500100551 The angular overlap model. The code cites “Schäffer and Jørgensen” without a paper; this is the AOM paper by those authors
Figgis, B. N.; Hitchman, M. A. Ligand Field Theory and Its Applications, Wiley-VCH, 2000, ch. 3 The model’s matrix form. ligand_field.rs
Casanova, D.; Llunell, M.; Alemany, P.; Alvarez, S. Chem. Eur. J. 2005, 11, 1479-1494. doi:10.1002/chem.200400799 Table 2 checks the reference polyhedra. shape_measure.rs
Llunell, M.; Casanova, D.; Cirera, J.; Alemany, P.; Alvarez, S. SHAPE 2.1 (software) The reference vertex sets and the ML_n convention. shape_measure.rs
Shannon, R. D. Acta Cryst. A 1976, 32, 751-767. doi:10.1107/S0567739476001551 Ionic radii for the distance comparison in the report. core/backbone/src/ionic_radii.rs
Cordero, B. et al. Dalton Trans. 2008, 2832-2838. doi:10.1039/B801115J Covalent radii through curium. periodic.rs
Pyykkö, P.; Atsumi, M. Chem. Eur. J. 2009, 15, 186-197. doi:10.1002/chem.200800987 Covalent radii from berkelium. periodic.rs
Pyykkö, P.; Atsumi, M. Chem. Eur. J. 2009, 15, 12770-12779. doi:10.1002/chem.200901472 Double-bond radii. periodic.rs, the bond-order search
Pyykkö, P.; Riedel, S.; Patzschke, M. Chem. Eur. J. 2005, 11, 3511-3520. doi:10.1002/chem.200401299 Triple-bond radii, the geometry floor under any order raise
Groom, C. R.; Bruno, I. J.; Lightfoot, M. P.; Ward, S. C. Acta Cryst. B 2016, 72, 171-179. doi:10.1107/S2052520616003954 The Cambridge Structural Database. The repository names the CSD and gives no citation
Balcells, D.; Skjelstad, B. B. J. Chem. Inf. Model. 2020, 60, 6135-6146. doi:10.1021/acs.jcim.0c01041; https://github.com/bbskjelstad/tmqm (MIT) tmQM. The paper describes 86k complexes; the 2024 release the repository uses has 108,541, with GFN2-xTB geometries and a SMILES in the ionic model for most
St Michel, R. G.; Jang, R. J.; Garrison, A. G.; Kevlishvili, I.; Kulik, H. J. J. Chem. Inf. Model. 2026, 66, 8336-8348. doi:10.1021/acs.jcim.6c01063; Zenodo 19014026 (CC-BY 4.0) BOS-Lig: ligand charges from CSD complex charges and text-mined states, by consensus. The repository gives the Zenodo record and the journal, not the authors

Not checked against the paper itself: the Hawthorne, Jemmis, Schäffer and Jørgensen, Wade and Enemark and Feltham entries, where the repository names the authors or a year and no page or title, so the match is by authors, year and subject; and Figgis and Hitchman, a book with no DOI. Check those five against the text before a paper cites them.

Conventions that look like bugs

Each of these has been reported as a defect and is a recorded decision.

What you see Why
A linear nitrosyl is NO+, so the metal sits one to two states lower than in tmQM’s reference The Enemark-Feltham convention: linear from 150 degrees is NO+, bent under 140 NO-. tmQM’s reference (xyz2mol_tm) writes every NO as NO- and the metal one to two states higher; adopting the convention cost 53 tmQM InChI matches (3,743 to 3,690). That is a difference of convention, not a defect found. 814 of 1,017 counted nitrosyls land on 18
A hydride is a neutral H in the identifier and H- in the state and count Every InChI source that writes one disconnects it as a neutral H (PubChem’s RuHCl(CO)(PPh3)3, tmQM throughout), so the metal’s charge there is its state less one per hydride. metal_states and the count add them back as every electron-counting convention has it. Over 504 hydrido complexes the CSD name matched the reported state 201 times without the correction and 381 with it
A Fischer carbene is neutral and a heteroatom-free alkylidene is a dianion on groups 3 to 7 and the f block without a carbonyl, neutral otherwise The electron total is the same either way, so only the state is a convention. apply_alkylidene_convention and alkylidene_is_dianion choose by schrock_metal (21 to 25, 39 to 43, 57 to 75, 89 to 103) and the presence of a carbonyl on the metal. A Grubbs ruthenium is Ru(II) at 16 and a Schrock imido-alkylidene Mo(VI) at 14. There is no benchmark that can score it: the CSD set drops metal-carbon bonds, and the total cannot choose. 95 of a random 200 declined sites had a state one too high before it
An NHC, a CAAC or a Fischer carbene carbon is pinned at -2 before perception and taken back Left to perception, an indazolylidene’s carbon took a C=C into the benzo ring and came out a carbanion on Au(II). A carbene is known before perception and not repaired after it
The iminium repair runs in odd fragments, and in even ones only for a chalcogen anion in pairs; not for N=B or beside a short partner other than boron In an even fragment something else balances the N+ and charging it alone moved a cobalamin from Co(III) to Co(II); an aminoborane is neutral R2N=BR2. The neutral form in pairs is what a selone boratrane’s two Se arms need; a single swap made tmQM VUFMIV Rh(-I). With the oximate reading CSD 62,692 against 62,690: four gained, two lost to their names’ conventions (2026-10-14)
A Z-type borane gives no electrons Green’s covalent bond classification. Kept for metallaboratranes by the maintainer’s decision: the CSD names the pyridazine-thione boratranes M(I), as this reading does, and the tris(thioxoimidazolyl)boranes M(III), counting the M-B bond as an oxidation, which this reading scores wrong. Since revision 28 a selone boratrane’s two Se arms are no longer read as two spurious Se-, which put the metal two states high
An ionic state of 0 (or +1 on iron or manganese) is replaced by a bond-valence sum in a lone-pair sphere and kept in an organometallic A zero is what the model returns when it fails to divide the charge, and the Werner subset has no zero-valent complex (none of 187 results at 0 was named 0). A zero-valent phosphine, carbonyl or dinitrogen complex is right at 0, which is_werner_sphere keeps out of the rule, and a sum there is a loose one with no metal-carbon parameters
A bond-valence state on a metal with a carbon bond is dropped unless some reading of the ligands reaches it Against 18 electrons the ionic state lands better for the 258 organometallic sites no reading reached (43% on 18 or 16 against 24%). The exception is an ionic state above the metal’s group number, which no d-block metal reaches
The ionic model answers before bond valence A sum over every bond looks like a measurement and is a tabulated guess about one state. Where the two disagree the ionic model is right 9,088 times to 3,319
A side-on contact, an odd-hapto site or a Si-H is not counted as bound The CSD bonding an atom is not the atom donating two electrons. Each alternative is given beside the count
An over-18 count is flagged only with a carbon on the metal The 18-electron rule is for organometallics. An octahedral Ni(II) at 20 is OverWerner, 1,689 tmQM centres (2026-10-23)
A d0 metal’s count is capped at 18, a d2 metal’s is not A d0 metal has nine orbitals and no electrons of its own; past d0 an over-18 total can be a wrong state, and a cap would hide that
Cyclooctatetraenide is a dianion only on group 3 and the f block On Ti or Zr, 56 tmQM complexes went past a state the metal can hold; on a late metal an eta-4 COT is a neutral diene
A closo cage over a face is a dianion between nine and twelve vertices It is four electrons short of the nido it would be if opened. Under nine the metal is more often a vertex; above twelve it is a fused cluster
The cluster rule is not applied to Ni, Pd or Pt See stage 6. Held back for want of evidence, not because the rule is shown wrong
Zinc is high with an unresolved caution The element has one state, so there is nothing for two methods to disagree about; the caution says the ligands’ charges were not divided
analyze metal-sites draws a ligand-field picture for d0 and d10, the desktop does not The desktop passes d counts 1 to 9 only; the library and the CLI accept 0 to 10

Measuring a change

The suite has never found a defect here by going red. Every real defect in this area came from comparing output with an outside reference or asserting an invariant, so a change here is scored against the benchmarks, whole rows before and after and not totals.

What they are not. None is independent of the corpora the rules were tuned on. The rules were tuned on the CSD Werner subset and on tmQM, and the same data scores them, with no held-out set. The CSD names are the authors’ choice (a valence tautomer is named for one form), the Werner filter drops every metal-carbon bond, nitrosyl and multiple bond, so none of the organometallic rules (cages, alkylidenes, Cp pins, nitrosyls) is scored by it. tmQM’s reference is xyz2mol_tm output, which shares the Hückel method with stage 2 and writes every NO as NO-; its geometries are GFN2-xTB, uniformly long for mercury; every complex is a declared closed-shell singlet. BOS-Lig covers 1,142 tmQM complexes. The 18-electron landing is a rule for organometallics and says nothing about a Werner complex. 97.8% on the CSD Werner set (2026-10-23) is in the published range of other tools (86% to 99%) and is not comparable to it: the reference sets differ, some tools abstain, and the machine-learning models learn the same Roman numerals they are scored on.

Before anything else

Run the tests that pin the area, one toolchain job at a time (do not overlap them with another build):

CARGO_BUILD_JOBS=16 conda run -n orbitron-dev cargo nextest run \
  -p orbitron-backbone -p orbitron-edit -p orbitron-inchi
CARGO_BUILD_JOBS=16 conda run -n orbitron-dev cargo nextest run -p orbitron-cli \
  --test analyze_metal_sites --test ionic_metal_complexes --test identify_metal_state

core/inchi/tests/identifier_order_invariance.rs and identifier_reproducibility.rs are in the first line. They fail when a tie is broken by position or by a hash order.

The benchmarks

Build the release CLI first, then use a fresh canonical cache for every run. Parsed documents are cached by file contents, and a cache written by a different build can hide a change; this has bitten three times, once as a test that passed cold and failed warm:

conda run -n orbitron-dev --no-capture-output cargo build --release -p orbitron-cli --bin orbitron
export ORBITRON_CANONICAL_CACHE=$(mktemp -d ~/scratch/canonical-cache.XXXXXX)

tmQM, electron counts and states per metal. report_electron_counts_over_tmqm in automation/cli/tests/electron_count_tmqm.rs is #[ignore]d. It reports and does not gate. Every tmQM complex is closed-shell, so an odd count is a wrong division of charge. Write a row per metal before the change and after, and compare rows by CSD code:

ORBITRON_TMQM=~/scratch/tmqm/tmQM_X1.full.xyz:~/scratch/tmqm/tmQM_X2.full.xyz:~/scratch/tmqm/tmQM_X3.full.xyz \
ORBITRON_TMQM_LIMIT=0 ORBITRON_TMQM_OUT=~/scratch/tmqm/counts-after.tsv \
conda run -n orbitron-dev cargo nextest run --release -p orbitron-cli \
  --run-ignored all report_electron_counts_over_tmqm --no-capture

The columns are CSD code, element, outcome (counted or the decline’s name), total, state, and the reading or the decline. A change that moves a total is real only if you can say why for a sample of the rows that moved, in both directions. A sweep that moves nothing proves nothing about code the corpus does not reach: eleven mutants on the undecided-carbene arm survived the whole suite because one tmQM complex reaches that arm, and reaches one branch of each condition.

tmQM, identifiers. scripts/metal_complex_benchmark.py compares identify with the ionic SMILES that ships with tmQM (dative bonds removed, then InChI, stereo layers ignored). build needs RDKit; run sets its own fresh cache and unsets ORBITRON_CONFIG.

python3 scripts/metal_complex_benchmark.py --work ~/scratch/metal-benchmark build --tmqm ~/scratch/tmqm/tmQM
python3 scripts/metal_complex_benchmark.py --work ~/scratch/metal-benchmark run --jobs 12
python3 scripts/metal_complex_benchmark.py --work ~/scratch/metal-benchmark score
python3 scripts/metal_complex_benchmark.py --work ~/scratch/metal-benchmark bos --ligands <ligand_charge_dataset_w_octet.csv>
python3 scripts/metal_complex_benchmark.py --work ~/scratch/metal-benchmark bvs --ligands <ligand_charge_dataset_w_octet.csv>

build samples 150 complexes per metal with a fixed seed. score classifies each as a match, a total-charge difference, a formula or connectivity difference, or a charge-placement difference. bos compares the metal’s state with BOS-Lig’s (a hydride counted as H- on both sides); bvs compares analyze metal-sites bond-valence states with it. Judge a change to the ligand reading by the CSD names, not by tmQM’s InChI, because the reference shares its method with the reading it replaces. The stage 3 allocation moved 17 of the 4,466 tmQM InChIs: 2 toward the reference and 12 away, to Ni(II), Cu(II), Pd(II) and Nb(V) where the reference has Ni(IV), Cu(III), Pd(VI) and Nb(I).

CSD Werner subset. scripts/csd_werner_benchmark.py needs a CSD licence. The structures extract writes are licensed data and stay in a scratch directory, never in the repository.

~/CCDC/ccdc-software/csd-python-api/run_csd_python_api \
  scripts/csd_werner_benchmark.py extract ~/scratch/csd-bench
python3 scripts/csd_werner_benchmark.py run ~/scratch/csd-bench ~/scratch/csd-bench/result.tsv --jobs 12
python3 scripts/csd_werner_benchmark.py score ~/scratch/csd-bench/result.tsv

run does not set the cache; the exported variable above passes through. score prints the reported state against the Roman numeral, the ionic model alone (the state in the electron count), the bond-valence sum alone, where the two agree and disagree, accuracy by trust grade, by metal, and the top confusions. Keep each run’s TSV with the commit that made it: a result file differs from HEAD on hundreds of entries when it came from a build between commits.

Invariant sweeps and diagnostics.

  • automation/cli/tests/metal_sites_link_sweep.rs: every metal contact a PDB’s LINK records or an mmCIF’s metalc rows state is a donor analyze metal-sites finds. Set ORBITRON_METAL_PDB_DIR to add full entries.
  • io/pipelines/tests/bond_valence_sweep.rs: bond-valence states against a CIF’s stated states, a report. Set ORBITRON_BV_CIF_DIR.
  • core/inchi/tests/identifier_order_invariance.rs: a gate, over every ionic fixture.

Mutation testing. The suite has been green through every real bug, so ask whether a test would notice a change. Run it workspace-scoped, because a guard for this area often lives in another crate (the CLI tests guard electron_count.rs):

MUTANTS_TEST_WORKSPACE=1 MUTANTS_FILE='core/backbone/src/scene/polyhedral_cage.rs' \
  scripts/nightly-mutants.sh orbitron-backbone

A survivor is not yet a gap: check each against a test in another crate before calling it one (notes/archive/open-items-closed-through-2026-10-12.md). Survivors can be replayed against a narrower test filter such as package(orbitron-edit) | binary(analyze_metal_sites) (notes/archive/electron-counting.md, “Mutation replay”). See Testing & Quality, §11.8 for the method. For charge_inference/, tmQM served as the witness finder, each mutant naming the records whose reading it moves; notes/archive/charge-inference-mutants-2026-10.md has the method, the verdicts and what the 33 mutants without a witness would need.

Adding a fixture

A fixture for a ligand class the reading got wrong is how a rule is pinned.

  1. Put the .xyz in io/pipelines/tests/fixtures/xyz/ionic/, with the declared charge on the comment line (charge=-1, multiplicity= if known) and where it came from (source=tmQM HOTPAJ (GFN2-xTB geometry)). tmQM is MIT and COD is public domain; no CSD structure is committed. .gitattributes already pins io/pipelines/tests/fixtures/xyz/ionic/*.xyz text eol=lf; a new directory needs its own line in the same commit. A record kept only because a mutant moves its reading goes in ionic/mutation-witnesses/ instead, out of reach of the sweeps over ionic/.
  2. Add a row to that directory’s README.md saying what the fixture pins.
  3. Pin the behaviour. automation/cli/tests/ionic_metal_complexes.rs holds the expected InChI charge layers (the division of charge). automation/cli/tests/analyze_metal_sites.rs loads fixtures by name (fixture("ionic/<name>.xyz")) for the state, the count, the trust grade and the declines. identifier_order_invariance picks the file up on its own.
  4. Regenerate the two goldens that walk every XYZ fixture and read their diffs. xyz_canonical_fingerprints_stable walks all of fixtures/xyz and corpus/xyz, and ionic_reading_golden records every fixture’s loaded charges, ionic reading, depiction, charges at the declared total and radical sites. A new file fails both until the goldens have it, and a golden pins whatever the code produced, bugs included:
UPDATE_GOLDEN=1 conda run -n orbitron-dev cargo nextest run -p orbitron-io-pipelines \
  --test canonical_golden_formats --test ionic_reading_golden
  1. Prove the test fires: revert the rule and watch it fail. A test that passes on the unfixed code is the failure mode this repository keeps hitting.

Where to change what

To change File and function What moves Pinned by
Bond cutoff, slack, coordination limits core/backbone/src/scene/utils/bond_inference.rs: pair_slack, max_coordination, the constants at the top Every bond, so every stage The tests in bond_inference.rs, io/pipelines/tests/bond_inference.rs, analyze_metal_sites.rs, the golden fingerprints
Covalent radii core/backbone/src/periodic.rs: ELEMENT_TABLE The same The same, and the periodic tests
A metal-contact drop rule drop_metal_bonds_through_donors, drop_geminal_contacts, drop_bridged_metal_bonds Which contacts are bonds and which stage 7 asks about In bond_inference.rs: a_carboxylate_carbon_is_not_bonded_to_the_metal, a_carboxylate_carbon_barely_behind_its_oxygens_is_still_not_a_donor, a_geminal_contact_across_a_distorted_angle_is_not_a_bond, a_bridged_pair_bonded_to_carbon_keeps_its_metal_bond; core/edit/tests/sphere_corpus.rs
Ligand ranking, shifts, misfit charge_inference/ligand_perception.rs: rank_candidate, best_candidate, length_misfit, CHARGE_SHIFTS, MISFIT_TOLERANCE, CANDIDATES Every ligand’s charge The unit tests beside them (fewer_charges_win_only_within_the_misfit_tolerance, a_candidate_ranks_on_charges_then_fit_then_the_guess_then_the_pins), ionic_metal_complexes.rs
The Hückel guess huckel.rs: the parameter table, charge_from_levels and its thresholds Which of two assignments wins The 20-molecule table against RDKit in huckel.rs tests; the self-overlap test
A new repair charge_inference/reading.rs: resolve_ionic_in_order, after the step it must follow A class of ligand A fixture, ionic_metal_complexes.rs, a control that the repair must leave alone, identifier_order_invariance
Fixed oxidation states fixed_oxidation_state Which metals take a state without the ligands deciding a_fixed_valence_metal_takes_its_oxidation_state_first
The allocation toward a state allocate_to_targets (MAX_DONORS, MAX_CATIONS, MAX_LIGANDS) Identifier states where a sum or prior replaced the ionic state identify_metal_state.rs, analyze_metal_sites.rs
The source order, the usual set, the priors oxidation_state.rs: StateEvidence::decide_with_reason and computed, usual_oxidation_states, COORDINATION_RULES, needs_pi_acceptors, is_werner_sphere Reported states and trust The tests in oxidation_state.rs (every_row_of_the_usual_state_table_is_pinned, the_coordination_table_replaces_exactly_its_classes, the_decision_reads_its_evidence_in_order)
Bond-valence parameters bond_valence.rs: PARAMETERS, COMPLEX_PARAMETERS, LANTHANIDE_COMPLEX_PARAMETERS, F_BLOCK_OXIDE_PARAMETERS, ACTINIDE_PARAMETERS, COPPER_PARAMETERS The second opinion and every grade io/pipelines/tests/bond_valence.rs, bond_valence_sweep.rs. Every row names its paper; do not take a table from a PDF extractor
Non-innocent classes non_innocent.rs: non_innocent_ligands, NITROXIDE_N_O (defined in charge_inference/radicals.rs) Cautions, grades The tests in non_innocent.rs
The dioxolene level redox_ligand.rs: the curves, fitted_metal, is_radical Catecholate and amidophenoxide states the_solver_reproduces_the_published_spreadsheet, a_ligand_at_each_table_geometry_reads_its_state
Cage detection and charge polyhedral_cage.rs: the constants, charge_cage, cluster_shape_class, metal_contribution Cage charges, counts, the cluster_charge caution The tests in polyhedral_cage.rs; a_cage_on_a_bh_like_metal_fragment_is_charged_from_the_cluster in analyze_metal_sites.rs
A donor class or decline electron_count.rs: share_of, count_one, Decline Counts and the unsettled alternatives analyze_metal_sites.rs; report_electron_counts_over_tmqm
Nitrosyl angles NITROSYL_LINEAR_DEGREES, NITROSYL_BENT_DEGREES in charge_inference/radicals.rs Identifier, count and caution together a_nitrosyl_is_linear_from_150_degrees_and_bent_under_140, a_nitrosyl_is_read_from_its_angle
The cautions site_trust.rs: explain, flag_unusual The sentences, not the grade The tests in site_trust.rs
The grade trust_table.csv (written by scripts/trust_calibration.py table), trust_table.rs: HIGH, MODERATE, LEVELS, domain_of in site_trust.rs Every grade trust_table.rs tests; analyze_metal_sites.rs; the held-out scores in notes/archive/trust-calibration-analysis.md
The ligand-field model ligand_field.rs: d_energies, site_ligand_field; the desktop filter in scene_info/coordination.rs Levels, fillings, declines The tests in ligand_field.rs (the textbook limits)
Sphere thresholds and sites sphere_assessment.rs: DRIFTED_ABOVE, NO_CLEAR_MATCH_ABOVE, coordination_sites Verdicts, sites, and so counts core/edit/tests/sphere_corpus.rs; the tests in sphere_assessment.rs
What the identifiers carry IDENTIFIER_REVISION in lib.rs; identify.rs: scene_metal_states, MetalState::notes Warnings, stored identifiers core/inchi/tests/identify.rs, identify_metal_state.rs
The analysis and its report core/edit/src/geometry/metal_site.rs: analyze_metal_sites, MetalSite::report; reports.rs wraps it; the Coordination section in scene_info/coordination.rs and plot ligand-field read the same records Every front end analyze_metal_sites.rs; scripts/metal_sites_baseline.py (byte-identical output over 69,441 files for a refactor)