Skip to content

Level ordering is not reproducible across runs: index-based cross-referencing of ionization targets between charge-state runs silently mismatches levels #835

Description

@tzy20

Summary

Level ordering in FAC output is not reproducible between independent runs, because levels are sorted purely by energy (CompareLevels, faclib/structure.c:3407, default _sort_levs = 1) and two runs with different configuration spaces yield slightly different energies for the same physical levels. This makes a very natural workflow — cross-referencing level indices between per-charge-state runs — silently mis-identify levels. It cost our group a subtle, three-year-latent bug in an NLTE code, and I suspect other index-based post-processing pipelines are exposed to the same trap.

Setup (a common DIY collisional-radiative workflow)

One run per charge state, in separate directories: the run for ion charge q includes the (q+1)-ion target configurations (needed for AITable/RRTable), so its .lev file contains two blocks — block 1: the q-ion levels; block 2: the (q+1)-ion target levels. The next directory runs the (q+1) ion as its primary species (its own block 1).

A natural way to stitch charge states together is to map the free-level indices in the rr/ai tables into the next run's level list by a flat index offset, implicitly assuming:

block 2 of run q and block 1 of run q+1 enumerate the same levels in the same order.

Observation: that assumption fails for every adjacent charge pair we checked

For our N (Z=7) and Ne (Z=10) datasets, block 2 of run q and block 1 of run q+1 contain the same set of levels but in different orders. Positional agreement of configuration names between the two blocks, per adjacent pair:

  • N: 15.6% / 39.8% / 57.6% / 66.2% / 45.5% / 21.1% (q = 0…5)
  • Ne: 26.6% / 18.7% / 25.7% / 33.5% / 54.6% / 84.6% / 67.9% / 55.8% / 15.8% (q = 0…8)

The mechanism is clear from the source: with the default _sort_levs = 1, CompareLevels orders purely by energy. The two runs diagonalize with different configuration lists (each run also carries its own ionization targets, etc.), so near-degenerate levels reshuffle; for strongly mixed levels even the dominant-component name can differ between runs. Within each run everything is perfectly self-consistent — we verified that the (free index, 2J) pairs in the rr file agree with that run's own block 2 for 133,876/133,876 rows — the inconsistency only appears across runs.

The failure is silent: indices stay in range, thresholds are still read from the rr rows themselves (so they look right), and only the identity of the final level is wrong. In our code, 52–62% of bound-free channels had their final level mis-assigned (mostly within the same complex — near-degenerate reshuffles — with ~10% landing in a different complex). Two independent re-implementations inherited the same assumption and agreed with each other, so internal cross-checks never caught it; it was finally exposed by the third-party consistency check "2J from the rr row vs 2J of the level the index points to".

Suggestions

Any of the following would remove the trap (roughly in decreasing order of usefulness):

  1. A canonical, run-independent sort option, e.g. structure:sort_levs = 3: sort by (ncomplex, name, 2J, energy). The sorting infrastructure and the option switch already exist (structure:sort_levs = 0/1/2), so this looks like a small, backward-compatible addition — though label instability for strongly mixed levels means a perfect canonical order is subtle, and you will know best whether name-based keys are robust enough here.
  2. Stable level identifiers usable as join keys across runs (emitted in .lev and referenced by rr/ai).
  3. At minimum, a prominent documentation warning that level order/indices are not comparable across independent runs, and that cross-run joins must match on (name, 2J, energy) rather than position.

Workaround (for anyone who hits this)

Match block-2 rows of run q to block-1 rows of run q+1 by (configuration name, 2J) groups, pairing within each group in energy order (after subtracting each block's own minimum energy), with nearest-energy assignment for the leftovers where labels swapped. This restored 100.000% cross-run 2J consistency for both our N and Ne datasets.

Version

Data generated with FAC 1.1.5-era builds; the sorting logic is unchanged at current master (6196889). Happy to provide the input decks, the two .lev files of a failing pair, or the comparison script — and thanks for maintaining FAC.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions