From 3f5b2757c2076c7359da0c393bdf1f4f5fae99b2 Mon Sep 17 00:00:00 2001 From: erinepshovel-code <250928284+erinepshovel-code@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:24:36 +0000 Subject: [PATCH] fix(epac): derive promotion from closed atom, repair unpaired accounting and ordering - molecular attachment sites now derive from the already-closed element gonol's carried unpaired/promoted valence evidence; no periodic-table relookup at construction time. - promoted unpaired accounting: promote the spin-down valence s electron into the first empty valence p and flip it, so every promoted unpaired electron carries m_s = +1 like the ground convention. - subshell ordering: promoted set is canonical (s before p, p ascending m_l). - regression tests for CH4/CO2 promotion flags, promoted carbon/beryllium, ordinary atoms, and configuration serialization. - sealed-shape prediction remains FALSIFIED (existing comparison gate green). - add epac CI workflow (unittest discovery with libs/ucns on PYTHONPATH). --- .github/workflows/epac.yml | 30 ++++++++ research/epac/epac_atomic.py | 15 ++-- research/epac/epac_molecular.py | 50 ++++++++----- research/epac/tests/test_atomic_promotion.py | 75 +++++++++++++++++++ .../epac/tests/test_molecular_affixiation.py | 4 +- 5 files changed, 150 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/epac.yml create mode 100644 research/epac/tests/test_atomic_promotion.py diff --git a/.github/workflows/epac.yml b/.github/workflows/epac.yml new file mode 100644 index 0000000..f56b556 --- /dev/null +++ b/.github/workflows/epac.yml @@ -0,0 +1,30 @@ +name: epac + +on: + pull_request: + paths: + - "research/epac/**" + - "libs/ucns/**" + - ".github/workflows/epac.yml" + push: + branches: [main] + paths: + - "research/epac/**" + - "libs/ucns/**" + - ".github/workflows/epac.yml" + +permissions: + contents: read + +jobs: + epac: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - name: EPAC contract tests + env: + PYTHONPATH: research/epac:libs/ucns/src + run: python -m unittest discover -s research/epac/tests -q diff --git a/research/epac/epac_atomic.py b/research/epac/epac_atomic.py index 82729ce..8bcbd06 100644 --- a/research/epac/epac_atomic.py +++ b/research/epac/epac_atomic.py @@ -214,16 +214,18 @@ def _promoted_unpaired(electrons: tuple[ElectronState, ...]) -> tuple[ElectronSt s_pair = next((pair for pair in s_pairs_by_orbital.values() if len(pair) == 2), None) if s_pair is None or not empty_p_m: return tuple(unpaired) - # Promote one valence s electron into the first empty p m while preserving - # the distinct electron that remains in the s orbital. - promoted_from_s = next((item for item in s_pair if item.m_s == 1), s_pair[0]) + # Promote the spin-down valence s electron into the first empty valence p + # and flip it to spin-up. The spin-up s electron stays behind, so every + # promoted unpaired electron carries m_s = +1, matching the ground-state + # unpaired convention used by _unpaired_valence. + promoted_from_s = next((item for item in s_pair if item.m_s == -1), s_pair[0]) remaining_s = next(item for item in s_pair if item.index != promoted_from_s.index) new_p = ElectronState( index=promoted_from_s.index, n=valence_n, l=1, m_l=empty_p_m[0], - m_s=promoted_from_s.m_s, + m_s=1, shell=f"n{valence_n}", subshell=_subshell_name(valence_n, 1), angular_id=_angular_id(1, empty_p_m[0]), @@ -248,7 +250,10 @@ def _promoted_unpaired(electrons: tuple[ElectronState, ...]) -> tuple[ElectronSt valence=True, paired=False, ) - return tuple([unpaired_s, new_p, *[e for e in unpaired if not (e.l == 0)]]) + promoted = [unpaired_s, new_p, *[e for e in unpaired if e.l != 0]] + # Canonical subshell ordering: s before p, p orbitals by ascending m_l. + promoted.sort(key=lambda electron: (electron.l, electron.m_l)) + return tuple(promoted) def atomic_record(Z: int) -> AtomicRecord: diff --git a/research/epac/epac_molecular.py b/research/epac/epac_molecular.py index e3f8f11..dd28d23 100644 --- a/research/epac/epac_molecular.py +++ b/research/epac/epac_molecular.py @@ -19,7 +19,6 @@ from ucns.direct_mobius import native_mobius_state -from epac_atomic import AtomicRecord from epac_dimensional_arity import ( charged_structure_readout, geometry_from_declared_couplings, @@ -27,7 +26,7 @@ space, topology_structure_readout, ) -from epac_periodic import atomic_of, carried, construct_element_gonol, symbol_of +from epac_periodic import carried, construct_element_gonol, symbol_of from epac_public_gonol import ClosedPublicGonol, PublicGonolReceipt, construct_public_gonol, replay_public_gonol @@ -59,8 +58,23 @@ def _instantiate(composition: tuple[tuple[str, int], ...]) -> tuple[ClosedPublic return tuple(instances) -def _record_for(gonol: ClosedPublicGonol) -> AtomicRecord: - return atomic_of(symbol_of(gonol)) +def _parse_lm(text: str) -> tuple[tuple[int, int], ...]: + """Parse a carried ``*-lm`` option into ``(l, m_l)`` pairs, preserving order.""" + if text in ("", "none"): + return () + pairs: list[tuple[int, int]] = [] + for part in text.split(","): + l_text, m_text = part.split(":") + pairs.append((int(l_text), int(m_text))) + return tuple(pairs) + + +def _unpaired_lm(gonol: ClosedPublicGonol) -> tuple[tuple[int, int], ...]: + return _parse_lm(carried(gonol, "unpaired-valence-lm")) + + +def _promoted_lm(gonol: ClosedPublicGonol) -> tuple[tuple[int, int], ...]: + return _parse_lm(carried(gonol, "promoted-unpaired-lm")) def _choose_center(participants: tuple[ClosedPublicGonol, ...]) -> ClosedPublicGonol | None: @@ -79,15 +93,21 @@ def _choose_center(participants: tuple[ClosedPublicGonol, ...]) -> ClosedPublicG return None -def _attachment_set(record: AtomicRecord, needed: int) -> tuple[tuple[int, int], ...]: - ground = tuple((e.l, e.m_l) for e in record.unpaired_valence) +def _attachment_set(gonol: ClosedPublicGonol, needed: int) -> tuple[tuple[int, int], ...]: + """Attachment sites derive from the already-closed element gonol. + + No periodic-table relookup: the element gonol's carried promotion evidence + is the only promotion source for molecular construction. + """ + + ground = _unpaired_lm(gonol) if len(ground) >= needed: return ground[:needed] - promoted = tuple((e.l, e.m_l) for e in record.promoted_unpaired_valence) + promoted = _promoted_lm(gonol) if len(promoted) >= needed: return promoted[:needed] raise ValueError( - f"{record.symbol} has {len(ground)} unpaired valence electrons; " + f"{symbol_of(gonol)} has {len(ground)} unpaired valence electrons; " f"{needed} attachment sites were requested" ) @@ -182,22 +202,18 @@ def construct_molecule(formula: str) -> MolecularConstruction: center_sites: tuple[tuple[int, int], ...] = () if len(participants) != 2: raise ValueError("symmetric affixiation is declared only for two equal atoms") - left, right = (_record_for(participants[0]), _record_for(participants[1])) ligand_sites = ( - tuple((e.l, e.m_l) for e in left.unpaired_valence), - tuple((e.l, e.m_l) for e in right.unpaired_valence), + _unpaired_lm(participants[0]), + _unpaired_lm(participants[1]), ) used_promotion = False else: ligands = tuple(item for item in participants if item is not center) - center_record = _record_for(center) - ground = tuple((e.l, e.m_l) for e in center_record.unpaired_valence) - ligand_sites = tuple( - tuple((e.l, e.m_l) for e in _record_for(item).unpaired_valence) for item in ligands - ) + ground = _unpaired_lm(center) + ligand_sites = tuple(_unpaired_lm(item) for item in ligands) needed = sum(len(sites) for sites in ligand_sites) used_promotion = needed > len(ground) - center_sites = _attachment_set(center_record, needed) + center_sites = _attachment_set(center, needed) mobius = _mobius_coupling( participants=participants, center=center, diff --git a/research/epac/tests/test_atomic_promotion.py b/research/epac/tests/test_atomic_promotion.py new file mode 100644 index 0000000..d436fed --- /dev/null +++ b/research/epac/tests/test_atomic_promotion.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +EPAC_ROOT = Path(__file__).resolve().parents[1] +STACK_ROOT = EPAC_ROOT.parents[1] +sys.path.insert(0, str(EPAC_ROOT)) +sys.path.insert(0, str(STACK_ROOT / "libs" / "ucns" / "src")) + +from epac_atomic import atomic_record +from epac_periodic import construct_element_gonol, replay_element_gonol + + +class AtomicPromotionTest(unittest.TestCase): + def test_promoted_carbon_unpaired_accounting_and_ordering(self) -> None: + carbon = atomic_record(6) + self.assertEqual(carbon.configuration, "1s2.2s2.2p2") + self.assertEqual(tuple((e.l, e.m_l) for e in carbon.unpaired_valence), ((1, 1), (1, 0))) + promoted = carbon.promoted_unpaired_valence + self.assertEqual(len(promoted), 4) + # Every promoted unpaired electron uses the m_s = +1 convention. + self.assertTrue(all(e.m_s == 1 for e in promoted)) + self.assertEqual(len({e.index for e in promoted}), len(promoted)) + # Canonical subshell ordering: s before p, p orbitals ascending m_l. + self.assertEqual( + tuple((e.l, e.m_l) for e in promoted), + ((0, 0), (1, -1), (1, 0), (1, 1)), + ) + self.assertEqual({e.subshell for e in promoted}, {"2s", "2p"}) + + def test_promoted_beryllium_unpaired_accounting_and_ordering(self) -> None: + beryllium = atomic_record(4) + self.assertEqual(beryllium.configuration, "1s2.2s2") + promoted = beryllium.promoted_unpaired_valence + self.assertEqual(len(promoted), 2) + self.assertTrue(all(e.m_s == 1 for e in promoted)) + self.assertEqual(tuple((e.l, e.m_l) for e in promoted), ((0, 0), (1, 1))) + + def test_ordinary_atoms_do_not_promote_without_an_empty_valence_p(self) -> None: + # Helium has no valence shell; oxygen and nitrogen have no empty + # valence p orbital, so their promoted sets equal their ground sets. + helium = atomic_record(2) + oxygen = atomic_record(8) + nitrogen = atomic_record(7) + self.assertEqual(helium.promoted_unpaired_valence, ()) + self.assertEqual(helium.unpaired_valence, ()) + self.assertEqual( + tuple((e.l, e.m_l) for e in oxygen.promoted_unpaired_valence), + ((1, 0), (1, -1)), + ) + self.assertEqual( + tuple((e.l, e.m_l) for e in oxygen.promoted_unpaired_valence), + tuple((e.l, e.m_l) for e in oxygen.unpaired_valence), + ) + self.assertEqual(len(nitrogen.promoted_unpaired_valence), 3) + self.assertEqual( + tuple((e.l, e.m_l) for e in nitrogen.promoted_unpaired_valence), + tuple((e.l, e.m_l) for e in nitrogen.unpaired_valence), + ) + + def test_configuration_serialization_round_trip(self) -> None: + carbon = construct_element_gonol("C") + options = dict(carbon.gonol.carried_options) + self.assertEqual(options["electron-configuration"], "1s2.2s2.2p2") + self.assertEqual(options["unpaired-valence-lm"], "1:1,1:0") + self.assertEqual(options["promoted-unpaired-count"], "4") + self.assertEqual(options["promoted-unpaired-lm"], "0:0,1:-1,1:0,1:1") + replayed = replay_element_gonol(carbon) + self.assertEqual(carbon.receipt_digest, replayed.receipt_digest) + + +if __name__ == "__main__": + unittest.main() diff --git a/research/epac/tests/test_molecular_affixiation.py b/research/epac/tests/test_molecular_affixiation.py index fa601d6..27722c2 100644 --- a/research/epac/tests/test_molecular_affixiation.py +++ b/research/epac/tests/test_molecular_affixiation.py @@ -32,9 +32,9 @@ def test_unpaired_valence_and_shells_are_used(self) -> None: self.assertFalse(water["ligand_has_p"]) self.assertEqual(water["center_used_atomic_promotion"], False) self.assertEqual(methane["center_used_atomic_promotion"], True) - self.assertEqual(methane["center_unpaired_lm"], ["0:0", "1:-1", "1:1", "1:0"]) + self.assertEqual(methane["center_unpaired_lm"], ["0:0", "1:-1", "1:0", "1:1"]) self.assertTrue(carbon_dioxide["ligand_has_p"]) - self.assertEqual(carbon_dioxide["center_unpaired_lm"], ["0:0", "1:-1", "1:1", "1:0"]) + self.assertEqual(carbon_dioxide["center_unpaired_lm"], ["0:0", "1:-1", "1:0", "1:1"]) self.assertEqual(carbon_dioxide["center_attachment_site_count"], 4) self.assertEqual(carbon_dioxide["ligand_attachment_site_count"], 4)