Repository: terrapower/armi

Issue:
Plugin hook for initial axial expansion
There isn't an easy way to override the initial axial expansion performed on assemblies at construction. Providing a mechanism like this would be useful.

I propose a plugin hook like
```python
@staticmethod
@HOOKSPEC
def initialAssemblyAxialExpansion(assems: list[Assembly], cs: Settings) -> bool:
    pass
```
where
- `assems` is an iterable of all assemblies in the `core`, or rather all assemblies being constructed at this invocation, and
- `cs` is the case settings for this run

We don't want to perform multiple expansions, so when iterating over the hooks, we would cease iteration after the first call returned true. An example of this would be if the inputs heights considered hot flag is true, the default implementation would not perform expansion at construction and return false.

The default implementation would call this section of code

https://github.com/terrapower/armi/blob/6fd89e1b095c6290195967674758df0c64434caa/armi/reactor/blueprints/__init__.py#L314-L332

and be marked with the [`trylast`](https://pluggy.readthedocs.io/en/stable/index.html#call-time-order) argument. This would allow other plugins to potentially perform their expansion earlier, and then return `True` to signify no other expansion should take place.


Discussion:
john-science: I only have two small things on this PR.  Take a look.

So... the purpose of this design is two-fold?

1. Developers can now drop in their own `AxialExpansionChanger` subclasses.
2. Developers can now opt to not have an `AxialExpansionChanger` changer at all, by just not having this plugin hook in any of their plugins?

Do I have the goals right?

IF I do, I guess I have some questions:

1. What happens if you use this plugin hook in more than one plugin?
    - I assume this has to fail, right?
    - Have you tested that?
    - How does it fail?
2. Does ARMI run if zero plugins implement this hook?
    - I want to make sure this change doesn't break for reactors with no assemblies.

albeanth: > After CI passes, I think this is ready for show time, if you guys do.

This is apart of a larger downstream slough of changes, so let's hold tight till those are ready? 

Patch:
diff --git a/armi/operators/operator.py b/armi/operators/operator.py
index 759ddbfa..a7e9e39c 100644
--- a/armi/operators/operator.py
+++ b/armi/operators/operator.py
@@ -34,12 +34,12 @@ from armi import interfaces
 from armi import runLog
 from armi.bookkeeping import memoryProfiler
 from armi.bookkeeping.report import reportingUtils
-from armi.operators import settingsValidation
 from armi.operators.runTypes import RunTypes
 from armi.physics.fuelCycle.settings import CONF_SHUFFLE_LOGIC
 from armi.physics.neutronics.globalFlux.globalFluxInterface import (
     GlobalFluxInterfaceUsingExecuters,
 )
+from armi.settings import settingsValidation
 from armi.settings.fwSettings.globalSettings import (
     CONF_TIGHT_COUPLING,
     CONF_TIGHT_COUPLING_MAX_ITERS,
diff --git a/armi/operators/settingsValidation.py b/armi/operators/settingsValidation.py
index 324f2300..949b0e09 100644
--- a/armi/operators/settingsValidation.py
+++ b/armi/operators/settingsValidation.py
@@ -1,4 +1,4 @@
-# Copyright 2019 TerraPower, LLC
+# Copyright 2024 TerraPower, LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -11,795 +11,19 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
+"""This is a placeholder file that only exists to provide backwards compatibility.
 
-"""
-A system to check user settings for validity and provide users with meaningful
-suggestions to fix.
+Notes
+-----
+The actual ``settingsValidation.py`` module has been move to ``armi/settings/``. For now, this file will
+provide backwards compatibility.
 
-This allows developers to define a rich set of rules and suggestions for user settings.
-These then pop up during initialization of a run, either on the command line or as
-dialogues in the GUI. They say things like: "Your ___ setting has the value ___, which
-is impossible. Would you like to switch to ___?"
+Warning
+-------
+DeprecationWarning: This file will disappear in early 2025.
 """
-import itertools
-import os
-import re
-import shutil
-
-from armi import context
-from armi import getPluginManagerOrFail
-from armi import runLog
-from armi.physics import neutronics
-from armi.reactor import geometry
-from armi.reactor import systemLayoutInput
-from armi.settings.settingsIO import (
-    prompt,
-    RunLogPromptCancel,
-    RunLogPromptUnresolvable,
-)
-from armi.utils import directoryChangers
-from armi.utils import pathTools
-from armi.utils.mathematics import expandRepeatedFloats
-
-
-class Query:
-    """
-    An individual setting validator.
-
-    .. impl:: Rules to validate and customize a setting's behavior.
-        :id: I_ARMI_SETTINGS_RULES
-        :implements: R_ARMI_SETTINGS_RULES
-
-        This class is meant to represent a generic validation test against a setting.
-        The goal is: developers create new settings and they want to make sure those
-        settings are used correctly. As an implementation, users pass in a
-        ``condition`` function to this class that returns ``True`` or ``False`` based
-        on the setting name and value. And then this class has a ``resolve`` method
-        which tests if the condition is met. Optionally, this class also contains a
-        ``correction`` function that allows users to automatically correct a bad
-        setting, if the developers can find a clear path forward.
-    """
-
-    def __init__(self, condition, statement, question, correction):
-        """
-        Construct a query.
-
-        Parameters
-        ----------
-        condition : callable
-            A callable that returns True or False. If True, then the query activates
-            its question and potential correction.
-        statement : str
-            A statement of the problem indicated by a True condition
-        question : str
-            A question asking the user for confirmation of the proposed
-            fix.
-        correction : callable
-            A callable that when called fixes the situation. See
-            :py:meth:`Inspector.NO_ACTION` for no-ops.
-        """
-        self.condition = condition
-        self.statement = statement
-        self.question = question
-        self.correction = correction
-        # True if the query is `passed` and does not result in an immediate failure
-        self.corrected = False
-        self._passed = False
-        self.autoResolved = True
-
-    def __repr__(self):
-        # Add representation so that it's possible to identify which one
-        # is being referred to when there are errors.
-        return "<Query: {}>".format(self.statement)
-
-    def __bool__(self):
-        try:
-            return bool(self.condition())
-        except TypeError:
-            runLog.error(
-                f"Invalid setting validation query. Update validator for: {self})"
-            )
-            raise
-
-    __nonzero__ = __bool__  # Python2 compatibility
-
-    def isCorrective(self):
-        return self.correction is not Inspector.NO_ACTION
-
-    def resolve(self):
-        """Standard i/o prompt for resolution of an individual query."""
-        if context.MPI_RANK != 0:
-            return
-
-        if self.condition():
-            try:
-                if self.isCorrective():
-                    try:
-                        make_correction = prompt(
-                            "INSPECTOR: " + self.statement,
-                            self.question,
-                            "YES_NO",
-                            "NO_DEFAULT",
-                            "CANCEL",
-                        )
-                        if make_correction:
-                            self.correction()
-                            self.corrected = True
-                        self._passed = True
-                    except RunLogPromptCancel as ki:
-                        raise KeyboardInterrupt from ki
-                else:
-                    try:
-                        continue_submission = prompt(
-                            "INSPECTOR: " + self.statement,
-                            "Continue?",
-                            "YES_NO",
-                            "NO_DEFAULT",
-                            "CANCEL",
-                        )
-                        if not continue_submission:
-                            raise KeyboardInterrupt
-                    except RunLogPromptCancel as ki:
-                        raise KeyboardInterrupt from ki
-            except RunLogPromptUnresolvable:
-                self.autoResolved = False
-                self._passed = True
-
-
-class Inspector:
-    """
-    This manages queries which assert certain states of the data model, generally presenting
-    themselves to the user, offering information on the potential problem, a question
-    and the action to take on an affirmative and negative answer from the user.
-
-    In practice very useful for making sure setting values are as intended and without
-    bad interplay with one another.
-
-    One Inspector will contain multiple Queries and be associated directly with an
-    :py:class:`~armi.operators.operator.Operator`.
-    """
-
-    @staticmethod
-    def NO_ACTION():
-        """Convenience callable used to generate Queries that can't be easily auto-resolved."""
-        return None
-
-    def __init__(self, cs):
-        """
-        Construct an inspector.
-
-        Parameters
-        ----------
-        cs : Settings
-        """
-        self.queries = []
-        self.cs = cs
-        self.geomType = None
-        self.coreSymmetry = None
-        self._inspectBlueprints()
-        self._setGeomType()
-        self._inspectSettings()
-
-        # Gather and attach validators from all plugins
-        # This runs on all registered plugins, not just active ones.
-        pluginQueries = getPluginManagerOrFail().hook.defineSettingsValidators(
-            inspector=self
-        )
-        for queries in pluginQueries:
-            self.queries.extend(queries)
-
-    def run(self, cs=None):
-        """
-        Run through each query and deal with it if possible.
-
-        Returns
-        -------
-        correctionsMade : bool
-            Whether or not anything was updated.
-
-        Raises
-        ------
-        RuntimeError
-            When a programming error causes queries to loop.
-        """
-        if context.MPI_RANK != 0:
-            return False
-
-        # the following attribute changes will alter what the queries investigate when resolved
-        correctionsMade = False
-        self.cs = cs or self.cs
-        runLog.debug("{} executing queries.".format(self.__class__.__name__))
-        if not any(self.queries):
-            runLog.debug(
-                "{} found no problems with the current state.".format(
-                    self.__class__.__name__
-                )
-            )
-        else:
-            for query in self.queries:
-                query.resolve()
-                if query.corrected:
-                    correctionsMade = True
-            issues = [
-                query
-                for query in self.queries
-                if query and (query.isCorrective() and not query._passed)
-            ]
-            if any(issues):
-                # something isn't resolved or was unresolved by changes
-                raise RuntimeError(
-                    "The input inspection did not resolve all queries, "
-                    "some issues are creating cyclic resolutions: {}".format(issues)
-                )
-            runLog.debug("{} has finished querying.".format(self.__class__.__name__))
-
-        if correctionsMade:
-            # find unused file path to store original settings as to avoid overwrite
-            strSkeleton = "{}_old".format(self.cs.path.split(".yaml")[0])
-            for num in itertools.count():
-                if num == 0:
-                    renamePath = f"{strSkeleton}.yaml"
-                else:
-                    renamePath = f"{strSkeleton}{num}.yaml"
-                if not self._csRelativePathExists(renamePath):
-                    break
-            # preserve old file before saving settings file
-            runLog.important(
-                f"Preserving original settings file by renaming `{renamePath}`"
-            )
-            shutil.copy(self.cs.path, renamePath)
-            # save settings file
-            self.cs.writeToYamlFile(self.cs.path)
-
-        return correctionsMade
-
-    def addQuery(self, condition, statement, question, correction):
-        """Convenience method, query must be resolved, else run fails."""
-        if not callable(correction):
-            raise ValueError(
-                'Query for "{}" malformed. Expecting callable.'.format(statement)
-            )
-        self.queries.append(Query(condition, statement, question, correction))
-
-    def addQueryBadLocationWillLikelyFail(self, settingName):
-        """Add a query indicating the current path for ``settingName`` does not exist and will likely fail."""
-        self.addQuery(
-            lambda: not os.path.exists(pathTools.armiAbsPath(self.cs[settingName])),
-            "Setting {} points to nonexistent location\n{}\nFailure extremely likely".format(
-                settingName, self.cs[settingName]
-            ),
-            "",
-            self.NO_ACTION,
-        )
-
-    def addQueryCurrentSettingMayNotSupportFeatures(self, settingName):
-        """Add a query that the current value for ``settingName`` may not support certain features."""
-        self.addQuery(
-            lambda: self.cs[settingName] != self.cs.getSetting(settingName).default,
-            "{} set as:\n{}\nUsing this location instead of the default location\n{}\n"
-            "may not support certain functions.".format(
-                settingName,
-                self.cs[settingName],
-                self.cs.getSetting(settingName).default,
-            ),
-            "Revert to default location?",
-            lambda: self._assignCS(
-                settingName, self.cs.getSetting(settingName).default
-            ),
-        )
-
-    def _assignCS(self, key, value):
-        """Lambda assignment workaround."""
-        # this type of assignment works, but be mindful of
-        # scoping when trying different methods
-        runLog.extra(f"Updating setting `{key}` to `{value}`")
-        self.cs[key] = value
-
-    def _raise(self):
-        raise KeyboardInterrupt("Input inspection has been interrupted.")
-
-    def _inspectBlueprints(self):
-        """Blueprints early error detection and old format conversions."""
-        from armi.physics.neutronics.settings import CONF_LOADING_FILE
-
-        # if there is a blueprints object, we don't need to check for a file
-        if self.cs.filelessBP:
-            return
-
-        self.addQuery(
-            lambda: not self.cs[CONF_LOADING_FILE],
-            "No blueprints file loaded. Run will probably fail.",
-            "",
-            self.NO_ACTION,
-        )
-
-        self.addQuery(
-            lambda: not self._csRelativePathExists(self.cs[CONF_LOADING_FILE]),
-            "Blueprints file {} not found. Run will fail.".format(
-                self.cs[CONF_LOADING_FILE]
-            ),
-            "",
-            self.NO_ACTION,
-        )
-
-    def _csRelativePathExists(self, filename):
-        csRelativePath = self._csRelativePath(filename)
-        return os.path.exists(csRelativePath) and os.path.isfile(csRelativePath)
-
-    def _csRelativePath(self, filename):
-        return os.path.join(self.cs.inputDirectory, filename)
-
-    def _setGeomType(self):
-        if self.cs["geomFile"]:
-            with directoryChangers.DirectoryChanger(
-                self.cs.inputDirectory, dumpOnException=False
-            ):
-                geom = systemLayoutInput.SystemLayoutInput()
-                geom.readGeomFromFile(self.cs["geomFile"])
-
-            self.geomType, self.coreSymmetry = geom.geomType, geom.symmetry
-
-    def _correctCyclesToZeroBurnup(self):
-        self._assignCS("nCycles", 1)
-        self._assignCS("burnSteps", 0)
-        self._assignCS("cycleLength", None)
-        self._assignCS("cycleLengths", None)
-        self._assignCS("availabilityFactor", None)
-        self._assignCS("availabilityFactors", None)
-        self._assignCS("cycles", [])
-
-    def _checkForBothSimpleAndDetailedCyclesInputs(self):
-        """
-        Because the only way to check if a setting has been "entered" is to check
-        against the default, if the user specifies all the simple cycle settings
-        _exactly_ as the defaults, this won't be caught. But, it would be very
-        coincidental for the user to _specify_ all the default values when
-        performing any real analysis.
-
-        Also, we must bypass the `Settings` getter and reach directly
-        into the underlying `__settings` dict to avoid triggering an error
-        at this stage in the run. Otherwise an error will inherently be raised
-        if the detailed cycles input is used because the simple cycles inputs
-        have defaults. We don't care that those defaults are there, we only
-        have a problem with those defaults being _used_, which will be caught
-        later on.
-        """
-        bothCyclesInputTypesPresent = (
-            self.cs._Settings__settings["cycleLength"].value
-            != self.cs._Settings__settings["cycleLength"].default
-            or self.cs._Settings__settings["cycleLengths"].value
-            != self.cs._Settings__settings["cycleLengths"].default
-            or self.cs._Settings__settings["burnSteps"].value
-            != self.cs._Settings__settings["burnSteps"].default
-            or self.cs._Settings__settings["availabilityFactor"].value
-            != self.cs._Settings__settings["availabilityFactor"].default
-            or self.cs._Settings__settings["availabilityFactors"].value
-            != self.cs._Settings__settings["availabilityFactors"].default
-            or self.cs._Settings__settings["powerFractions"].value
-            != self.cs._Settings__settings["powerFractions"].default
-        ) and self.cs["cycles"] != []
-
-        return bothCyclesInputTypesPresent
-
-    def _inspectSettings(self):
-        """Check settings for inconsistencies."""
-        from armi import operators
-        from armi.physics.neutronics.settings import (
-            CONF_BC_COEFFICIENT,
-            CONF_BOUNDARIES,
-            CONF_XS_KERNEL,
-            CONF_XS_SCATTERING_ORDER,
-        )
-
-        self.addQueryBadLocationWillLikelyFail("operatorLocation")
-
-        self.addQuery(
-            lambda: self.cs["outputFileExtension"] == "pdf" and self.cs["genReports"],
-            "Output files of '.pdf' format are not supported by the reporting HTML generator. '.pdf' "
-            "images will not be included.",
-            "Switch to '.png'?",
-            lambda: self._assignCS("outputFileExtension", "png"),
-        )
-
-        self.addQuery(
-            lambda: (
-                (
-                    self.cs["beta"]
-                    and isinstance(self.cs["beta"], list)
-                    and not self.cs["decayConstants"]
-                )
-                or (self.cs["decayConstants"] and not self.cs["beta"])
-            ),
-            "Both beta components and decay constants should be provided if either are "
-            "being supplied.",
-            "",
-            self.NO_ACTION,
-        ),
-
-        self.addQuery(
-            lambda: self.cs["skipCycles"] > 0 and not self.cs["reloadDBName"],
-            "You have chosen to do a restart case without specifying a database to load from. "
-            "Run will load from output files, if they exist but burnup, etc. will not be updated.",
-            "",
-            self.NO_ACTION,
-        )
-
-        self.addQuery(
-            lambda: self.cs["runType"] != operators.RunTypes.SNAPSHOTS
-            and self.cs["loadStyle"] == "fromDB"
-            and self.cs["startCycle"] == 0
-            and self.cs["startNode"] == 0,
-            "Starting from cycle 0, and time node 0 was chosen. Restart runs load from "
-            "the time node just before the restart. There is no time node to load from "
-            "before cycle 0 node 0. Either switch to the snapshot operator, start from "
-            "a different time step or load from inputs rather than database as "
-            "`loadStyle`.",
-            "",
-            self.NO_ACTION,
-        )
-
-        self.addQuery(
-            lambda: self.cs["runType"] == operators.RunTypes.SNAPSHOTS
-            and not (self.cs["dumpSnapshot"] or self.cs["defaultSnapshots"]),
-            "The Snapshots operator was specified, but no dump snapshots were chosen."
-            "Please specify snapshot steps with the `dumpSnapshot` setting.",
-            "",
-            self.NO_ACTION,
-        )
-
-        self.addQuery(
-            lambda: self.cs.caseTitle.lower()
-            == os.path.splitext(os.path.basename(self.cs["reloadDBName"].lower()))[0],
-            "Snapshot DB ({0}) and main DB ({1}) cannot have the same name."
-            "Change name of settings file and resubmit.".format(
-                self.cs["reloadDBName"], self.cs.caseTitle
-            ),
-            "",
-            self.NO_ACTION,
-        )
-
-        self.addQuery(
-            lambda: self.cs["reloadDBName"] != ""
-            and not os.path.exists(self.cs["reloadDBName"]),
-            "Reload database {} does not exist. \nPlease point to an existing DB, "
-            "or set to empty and load from input.".format(self.cs["reloadDBName"]),
-            "",
-            self.NO_ACTION,
-        )
-
-        def _willBeCopiedFrom(fName):
-            return any(
-                fName == os.path.split(copyFile)[1]
-                for copyFile in self.cs["copyFilesFrom"]
-            )
-
-        self.addQuery(
-            lambda: self.cs["explicitRepeatShuffles"]
-            and not self._csRelativePathExists(self.cs["explicitRepeatShuffles"])
-            and not _willBeCopiedFrom(self.cs["explicitRepeatShuffles"]),
-            "The specified repeat shuffle file `{0}` does not exist, and won't be copied. "
-            "Run will crash.".format(self.cs["explicitRepeatShuffles"]),
-            "",
-            self.NO_ACTION,
-        )
-
-        self.addQuery(
-            lambda: not self.cs["power"] and not self.cs["powerDensity"],
-            "No power or powerDensity set. You must always start by importing a base settings file.",
-            "",
-            self.NO_ACTION,
-        )
-
-        self.addQuery(
-            lambda: self.cs["power"] > 0 and self.cs["powerDensity"] > 0,
-            "The power and powerDensity are both set, please note the power will be used as the truth.",
-            "",
-            self.NO_ACTION,
-        )
-
-        # The gamma cross sections generated for MC2-3 by ANL were done with NJOY with
-        # P3 scattering. MC2-3 would have to be modified and the gamma cross sections
-        # re-generated with NJOY for MC2-3 to allow any other scattering order with
-        # gamma cross sections enabled.
-        self.addQuery(
-            lambda: (
-                "MC2v3" in self.cs[CONF_XS_KERNEL]
-                and neutronics.gammaXsAreRequested(self.cs)
-                and self.cs[CONF_XS_SCATTERING_ORDER] != 3
-            ),
-            "MC2-3 will crash if a scattering order is not set to 3 when generating gamma XS.",
-            f"Would you like to set the `{CONF_XS_SCATTERING_ORDER}` to 3?",
-            lambda: self._assignCS(CONF_XS_SCATTERING_ORDER, 3),
-        )
-
-        self.addQuery(
-            lambda: self.cs["outputCacheLocation"]
-            and not os.path.exists(self.cs["outputCacheLocation"]),
-            "`outputCacheLocation` path {} does not exist. Please specify a location that exists.".format(
-                self.cs["outputCacheLocation"]
-            ),
-            "",
-            self.NO_ACTION,
-        )
-
-        self.addQuery(
-            lambda: (
-                not self.cs["tightCoupling"]
-                and self.cs["tightCouplingMaxNumIters"] != 4
-            ),
-            "You've requested a non default number of tight coupling iterations but left tightCoupling: False."
-            "Do you want to set tightCoupling to True?",
-            "",
-            lambda: self._assignCS("tightCoupling", True),
-        )
-
-        self.addQuery(
-            lambda: (not self.cs["tightCoupling"] and self.cs["tightCouplingSettings"]),
-            "You've requested non default tight coupling settings but tightCoupling: False."
-            "Do you want to set tightCoupling to True?",
-            "",
-            lambda: self._assignCS("tightCoupling", True),
-        )
-
-        self.addQuery(
-            lambda: self.cs["startCycle"]
-            and self.cs["nCycles"] < self.cs["startCycle"],
-            "nCycles must be greater than or equal to startCycle in restart cases. nCycles"
-            " is the _total_ number of cycles in the completed run (i.e. restarted +"
-            " continued cycles). Please update the case settings.",
-            "",
-            self.NO_ACTION,
-        )
-
-        self.addQuery(
-            lambda: self.cs["nCycles"] in [0, None],
-            "Cannot run 0 cycles. Set burnSteps to 0 to activate a single time-independent case.",
-            "Set 1 cycle and 0 burnSteps for single time-independent case?",
-            self._correctCyclesToZeroBurnup,
-        )
-
-        self.addQuery(
-            self._checkForBothSimpleAndDetailedCyclesInputs,
-            "If specifying detailed cycle history with `cycles`, you may not"
-            " also use any of the simple cycle history inputs `cycleLength(s)`,"
-            " `burnSteps`, `availabilityFactor(s)`, or `powerFractions`."
-            " Using the detailed cycle history.",
-            "",
-            self.NO_ACTION,
-        )
-
-        def _factorsAreValid(factors, maxVal=1.0):
-            try:
-                expandedList = expandRepeatedFloats(factors)
-            except (ValueError, IndexError):
-                return False
-            return (
-                all(0.0 <= val <= maxVal for val in expandedList)
-                and len(expandedList) == self.cs["nCycles"]
-            )
-
-        if self.cs["cycles"] == []:
-            self.addQuery(
-                lambda: (
-                    self.cs["availabilityFactors"]
-                    and not _factorsAreValid(self.cs["availabilityFactors"])
-                ),
-                "`availabilityFactors` was not set to a list compatible with the number of cycles. "
-                "Please update input or use constant duration.",
-                "Use constant availability factor specified in `availabilityFactor` setting?",
-                lambda: self._assignCS("availabilityFactors", []),
-            )
-
-            self.addQuery(
-                lambda: (
-                    self.cs["powerFractions"]
-                    and not _factorsAreValid(self.cs["powerFractions"])
-                ),
-                "`powerFractions` was not set to a compatible list. "
-                "Please update input or use full power at all cycles.",
-                "Use full power for all cycles?",
-                lambda: self._assignCS("powerFractions", []),
-            )
-
-            self.addQuery(
-                lambda: (
-                    self.cs["cycleLengths"]
-                    and not _factorsAreValid(self.cs["cycleLengths"], maxVal=1e10)
-                ),
-                "`cycleLengths` was not set to a list compatible with the number of cycles."
-                " Please update input or use constant duration.",
-                "Use constant cycle length specified in `cycleLength` setting?",
-                lambda: self._assignCS("cycleLengths", []),
-            )
-
-            self.addQuery(
-                lambda: (
-                    self.cs["runType"] == operators.RunTypes.STANDARD
-                    and self.cs["burnSteps"] == 0
-                    and (
-                        (
-                            len(self.cs["cycleLengths"]) > 1
-                            if self.cs["cycleLengths"] is not None
-                            else False
-                        )
-                        or self.cs["nCycles"] > 1
-                    )
-                ),
-                "Cannot run multi-cycle standard cases with 0 burnSteps per cycle. Please update settings.",
-                "",
-                self.NO_ACTION,
-            )
-
-            def decayCyclesHaveInputThatWillBeIgnored():
-                """Check if there is any decay-related input that will be ignored."""
-                try:
-                    powerFracs = expandRepeatedFloats(self.cs["powerFractions"])
-                    availabilities = expandRepeatedFloats(
-                        self.cs["availabilityFactors"]
-                    ) or ([self.cs["availabilityFactor"]] * self.cs["nCycles"])
-                except Exception:
-                    return True
-
-                # This will be a full decay step and any power fraction will be ignored. May be ok.
-                return any(
-                    pf > 0.0 and af == 0.0 for pf, af in zip(powerFracs, availabilities)
-                )
-
-            self.addQuery(
-                lambda: (
-                    self.cs["cycleLengths"]
-                    and self.cs["powerFractions"]
-                    and decayCyclesHaveInputThatWillBeIgnored()
-                    and not self.cs["cycles"]
-                ),
-                "At least one cycle has a non-zero power fraction but an availability of zero. Please "
-                "update the input.",
-                "",
-                self.NO_ACTION,
-            )
-
-        self.addQuery(
-            lambda: self.cs["operatorLocation"]
-            and self.cs["runType"] != operators.RunTypes.STANDARD,
-            "The `runType` setting is set to `{0}` but there is a `custom operator location` defined".format(
-                self.cs["runType"]
-            ),
-            "",
-            self.NO_ACTION,
-        )
-
-        self.addQuery(
-            lambda: self.cs["operatorLocation"]
-            and self.cs["runType"] != operators.RunTypes.STANDARD,
-            "The `runType` setting is set to `{0}` but there is a `custom operator location` defined".format(
-                self.cs["runType"]
-            ),
-            "",
-            self.NO_ACTION,
-        )
-
-        self.addQuery(
-            lambda: self.cs["skipCycles"] > 0
-            and not os.path.exists(self.cs.caseTitle + ".restart.dat"),
-            "This is a restart case, but the required restart file {0}.restart.dat is not found".format(
-                self.cs.caseTitle
-            ),
-            "",
-            self.NO_ACTION,
-        )
-
-        self.addQuery(
-            lambda: self.cs["deferredInterfacesCycle"] > self.cs["nCycles"],
-            "The deferred interface activation cycle exceeds set cycle occurrence. "
-            "Interfaces will not be activated in this run!",
-            "",
-            self.NO_ACTION,
-        )
-
-        self.addQuery(
-            lambda: (
-                self.cs[CONF_BOUNDARIES] != neutronics.GENERAL_BC
-                and self.cs[CONF_BC_COEFFICIENT]
-            ),
-            f"General neutronic boundary condition was not selected, but `{CONF_BC_COEFFICIENT}` was defined. "
-            f"Please enable `Generalized` neutronic boundary condition or disable `{CONF_BC_COEFFICIENT}`.",
-            "",
-            self.NO_ACTION,
-        )
-
-        self.addQuery(
-            lambda: self.cs["geomFile"]
-            and str(self.geomType) not in geometry.VALID_GEOMETRY_TYPE,
-            "{} is not a valid geometry Please update geom type on the geom file. "
-            "Valid (case insensitive) geom types are: {}".format(
-                self.geomType, geometry.VALID_GEOMETRY_TYPE
-            ),
-            "",
-            self.NO_ACTION,
-        )
-
-        self.addQuery(
-            lambda: self.cs["geomFile"]
-            and not geometry.checkValidGeomSymmetryCombo(
-                self.geomType, self.coreSymmetry
-            ),
-            "{}, {} is not a valid geometry and symmetry combination. Please update "
-            "either geometry or symmetry on the geom file.".format(
-                str(self.geomType), str(self.coreSymmetry)
-            ),
-            "",
-            self.NO_ACTION,
-        )
-
-
-def createQueryRevertBadPathToDefault(inspector, settingName, initialLambda=None):
-    """
-    Return a query to revert a bad path to its default.
-
-    Parameters
-    ----------
-    inspector: Inspector
-        the inspector who's settings are being queried
-    settingName: str
-        name of the setting to inspect
-    initialLambda: None or callable function
-        If ``None``, the callable argument for :py:meth:`addQuery` is does the setting's path exist.
-        If more complicated callable arguments are needed, they can be passed in as the ``initialLambda`` setting.
-    """
-    if initialLambda is None:
-        initialLambda = lambda: (
-            not os.path.exists(pathTools.armiAbsPath(inspector.cs[settingName]))
-            and inspector.cs.getSetting(settingName).offDefault
-        )  # solution is to revert to default
-
-    query = Query(
-        initialLambda,
-        "Setting {} points to a nonexistent location:\n{}".format(
-            settingName, inspector.cs[settingName]
-        ),
-        "Revert to default location?",
-        inspector.cs.getSetting(settingName).revertToDefault,
-    )
-    return query
-
-
-def validateVersion(versionThis: str, versionRequired: str) -> bool:
-    """Helper function to allow users to verify that their version matches the settings file.
-
-    Parameters
-    ----------
-    versionThis: str
-        The version of this ARMI, App, or Plugin.
-        This MUST be in the form: 1.2.3
-    versionRequired: str
-        The version to compare against, say in a Settings file.
-        This must be in one of the forms: 1.2.3, 1.2, or 1
-
-    Returns
-    -------
-    bool
-        Does this version match the version in the Settings file/object?
-    """
-    fullV = "\d+\.\d+\.\d+"
-    medV = "\d+\.\d+"
-    minV = "\d+"
+# ruff: noqa: F401
 
-    if versionRequired == "uncontrolled":
-        # This default flag means we don't want to check the version.
-        return True
-    elif re.search(fullV, versionThis) is None:
-        raise ValueError(
-            "The input version ({0}) does not match the required format: {1}".format(
-                versionThis, fullV
-            )
-        )
-    elif re.search(fullV, versionRequired) is not None:
-        return versionThis == versionRequired
-    elif re.search(medV, versionRequired) is not None:
-        return ".".join(versionThis.split(".")[:2]) == versionRequired
-    elif re.search(minV, versionRequired) is not None:
-        return versionThis.split(".")[0] == versionRequired
-    else:
-        raise ValueError(
-            "The required version is not a valid format: {}".format(versionRequired)
-        )
+from armi.settings.settingsValidation import createQueryRevertBadPathToDefault
+from armi.settings.settingsValidation import Inspector
+from armi.settings.settingsValidation import Query
diff --git a/armi/physics/fuelCycle/settings.py b/armi/physics/fuelCycle/settings.py
index 52a9c9d0..647a279a 100644
--- a/armi/physics/fuelCycle/settings.py
+++ b/armi/physics/fuelCycle/settings.py
@@ -12,8 +12,8 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 """Settings for generic fuel cycle code."""
-from armi.operators import settingsValidation
 from armi.settings import setting
+from armi.settings import settingsValidation
 
 CONF_ASSEM_ROTATION_STATIONARY = "assemblyRotationStationary"
 CONF_ASSEMBLY_ROTATION_ALG = "assemblyRotationAlgorithm"
diff --git a/armi/physics/fuelPerformance/settings.py b/armi/physics/fuelPerformance/settings.py
index ec7b6ac1..edef934f 100644
--- a/armi/physics/fuelPerformance/settings.py
+++ b/armi/physics/fuelPerformance/settings.py
@@ -14,8 +14,8 @@
 
 """Settings related to fuel performance."""
 
-from armi.operators.settingsValidation import Query
 from armi.settings import setting
+from armi.settings.settingsValidation import Query
 
 
 CONF_AXIAL_EXPANSION = "axialExpansion"
diff --git a/armi/physics/neutronics/fissionProductModel/fissionProductModelSettings.py b/armi/physics/neutronics/fissionProductModel/fissionProductModelSettings.py
index be372a5b..d23e1b14 100644
--- a/armi/physics/neutronics/fissionProductModel/fissionProductModelSettings.py
+++ b/armi/physics/neutronics/fissionProductModel/fissionProductModelSettings.py
@@ -93,7 +93,7 @@ def defineSettings():
 def getFissionProductModelSettingValidators(inspector):
     """The standard helper method, to provide validators to the fission product model."""
     # Import the Query class here to avoid circular imports.
-    from armi.operators.settingsValidation import Query
+    from armi.settings.settingsValidation import Query
 
     queries = []
 
diff --git a/armi/physics/neutronics/settings.py b/armi/physics/neutronics/settings.py
index fcbb49c1..f9e23cab 100644
--- a/armi/physics/neutronics/settings.py
+++ b/armi/physics/neutronics/settings.py
@@ -16,11 +16,11 @@
 import os
 
 from armi import runLog
-from armi.operators import settingsValidation
+from armi.physics.neutronics import LatticePhysicsFrequency
 from armi.physics.neutronics.const import NEUTRON
 from armi.physics.neutronics.energyGroups import GROUP_STRUCTURE
-from armi.physics.neutronics import LatticePhysicsFrequency
 from armi.settings import setting
+from armi.settings import settingsValidation
 from armi.utils import directoryChangers
 from armi.settings.fwSettings.globalSettings import (
     CONF_DETAILED_AXIAL_EXPANSION,
diff --git a/armi/plugins.py b/armi/plugins.py
index 77d77d4f..31c72a17 100644
--- a/armi/plugins.py
+++ b/armi/plugins.py
@@ -127,6 +127,7 @@ from armi.utils import flags
 
 if TYPE_CHECKING:
     from armi.reactor.composites import Composite
+    from armi.reactor.converters.axialExpansionChanger import AxialExpansionChanger
 
 
 HOOKSPEC = pluggy.HookspecMarker("armi")
@@ -662,6 +663,45 @@ class ArmiPlugin:
         and a ``"sfp"`` lookup, triggered to run after all other hooks have been run.
         """
 
+    @staticmethod
+    @HOOKSPEC(firstresult=True)
+    def getAxialExpansionChanger() -> type["AxialExpansionChanger"]:
+        """Produce the class responsible for performing axial expansion.
+
+        Plugins can provide this hook to override or negate axial expansion.
+        Will be used during initial construction of the core and assemblies, and
+        can be a class to perform custom axial expansion routines.
+
+        The first object returned that is not ``None`` will be used.
+        Plugins are encouraged to add the ``tryfirst=True`` arguments to their
+        ``HOOKIMPL`` invocations to make sure their specific are earlier in the
+        hook call sequence.
+
+        Returns
+        -------
+        type of :class:`armi.reactor.converters.axialExpansionChanger.AxialExpansionChanger`
+
+        Notes
+        -----
+        This hook **should not** provide an instance of the class. The construction
+        of the changer will be handled by applications and plugins that need it.
+
+        This hook should only be provided by one additional plugin in your application. Otherwise
+        the `order of hook execution <https://pluggy.readthedocs.io/en/stable/index.html#call-time-order>`_
+        may not provide the behavior you expect.
+
+        Examples
+        --------
+        >>> class MyPlugin(ArmiPlugin):
+        ...     @staticmethod
+        ...     @HOOKIMPL(tryfirst=True)
+        ...     def getAxialExpansionChanger():
+        ...         from myproject.physics import BespokeAxialExpansion
+        ...
+        ...         return BespokeAxialExpansion
+
+        """
+
 
 class UserPlugin(ArmiPlugin):
     """
diff --git a/armi/reactor/__init__.py b/armi/reactor/__init__.py
index 30cf6e77..cfb43c15 100644
--- a/armi/reactor/__init__.py
+++ b/armi/reactor/__init__.py
@@ -90,9 +90,9 @@ class ReactorPlugin(plugins.ArmiPlugin):
 
     @staticmethod
     @plugins.HOOKIMPL(trylast=True)
-    def defineSystemBuilders() -> Dict[
-        str, Callable[[str], Union["Core", "SpentFuelPool"]]
-    ]:
+    def defineSystemBuilders() -> (
+        Dict[str, Callable[[str], Union["Core", "SpentFuelPool"]]]
+    ):
         from armi.reactor.reactors import Core
         from armi.reactor.assemblyLists import SpentFuelPool
 
@@ -100,3 +100,10 @@ class ReactorPlugin(plugins.ArmiPlugin):
             "core": Core,
             "sfp": SpentFuelPool,
         }
+
+    @staticmethod
+    @plugins.HOOKIMPL(trylast=True)
+    def getAxialExpansionChanger():
+        from armi.reactor.converters.axialExpansionChanger import AxialExpansionChanger
+
+        return AxialExpansionChanger
diff --git a/armi/reactor/blueprints/__init__.py b/armi/reactor/blueprints/__init__.py
index a1da6275..a17ddbdf 100644
--- a/armi/reactor/blueprints/__init__.py
+++ b/armi/reactor/blueprints/__init__.py
@@ -151,7 +151,7 @@ class _BlueprintsPluginCollector(yamlize.objects.ObjectType):
         else:
             pluginSections = pm.hook.defineBlueprintsSections()
             for plug in pluginSections:
-                for (attrName, section, resolver) in plug:
+                for attrName, section, resolver in plug:
                     assert isinstance(section, yamlize.Attribute)
                     if attrName in attrs:
                         raise plugins.PluginError(
@@ -326,10 +326,12 @@ class Blueprints(yamlize.Object, metaclass=_BlueprintsPluginCollector):
                     for a in list(self.assemblies.values())
                     if not any(a.hasFlags(f) for f in assemsToSkip)
                 )
-                axialExpansionChanger.axialExpansionChanger.expandColdDimsToHot(
-                    assemsToExpand,
-                    cs[CONF_DETAILED_AXIAL_EXPANSION],
-                )
+                axialExpander = getPluginManagerOrFail().hook.getAxialExpansionChanger()
+                if axialExpander is not None:
+                    axialExpander.expandColdDimsToHot(
+                        assemsToExpand,
+                        cs[CONF_DETAILED_AXIAL_EXPANSION],
+                    )
 
             getPluginManagerOrFail().hook.afterConstructionOfAssemblies(
                 assemblies=self.assemblies.values(), cs=cs
diff --git a/armi/reactor/converters/axialExpansionChanger/axialExpansionChanger.py b/armi/reactor/converters/axialExpansionChanger/axialExpansionChanger.py
index 390305be..d7369d25 100644
--- a/armi/reactor/converters/axialExpansionChanger/axialExpansionChanger.py
+++ b/armi/reactor/converters/axialExpansionChanger/axialExpansionChanger.py
@@ -52,71 +52,6 @@ def makeAssemsAbleToSnapToUniformMesh(
         a.makeAxialSnapList(referenceAssembly)
 
 
-def expandColdDimsToHot(
-    assems: list,
-    isDetailedAxialExpansion: bool,
-    referenceAssembly=None,
-):
-    """
-    Expand BOL assemblies, resolve disjoint axial mesh (if needed), and update block BOL heights.
-
-    .. impl:: Perform expansion during core construction based on block heights at a specified temperature.
-        :id: I_ARMI_INP_COLD_HEIGHT
-        :implements: R_ARMI_INP_COLD_HEIGHT
-
-        This method is designed to be used during core construction to axially thermally expand the
-        assemblies to their "hot" temperatures (as determined by ``Thot`` values in blueprints).
-        First, The Assembly is prepared for axial expansion via ``setAssembly``. In
-        ``applyColdHeightMassIncrease``, the number densities on each Component is adjusted to
-        reflect that Assembly inputs are at cold (i.e., ``Tinput``) temperatures. To expand to
-        the requested hot temperatures, thermal expansion factors are then computed in
-        ``computeThermalExpansionFactors``. Finally, the Assembly is axially thermally expanded in
-        ``axiallyExpandAssembly``.
-
-        If the setting ``detailedAxialExpansion`` is ``False``, then each Assembly gets its Block mesh
-        set to match that of the "reference" Assembly (see ``getDefaultReferenceAssem`` and ``setBlockMesh``).
-
-        Once the Assemblies are axially expanded, the Block BOL heights are updated. To account for the change in
-        Block volume from axial expansion, ``completeInitialLoading`` is called to update any volume-dependent
-        Block information.
-
-    Parameters
-    ----------
-    assems: list[:py:class:`Assembly <armi.reactor.assemblies.Assembly>`]
-        list of assemblies to be thermally expanded
-    isDetailedAxialExpansion: bool
-        If False, assemblies will be forced to conform to the reference mesh after expansion
-    referenceAssembly: :py:class:`Assembly <armi.reactor.assemblies.Assembly>`, optional
-        Assembly whose mesh other meshes will conform to if isDetailedAxialExpansion is False.
-        If not provided, will assume the finest mesh assembly which is typically fuel.
-
-    Notes
-    -----
-    Calling this method will result in an increase in mass via applyColdHeightMassIncrease!
-
-    See Also
-    --------
-    :py:meth:`armi.reactor.converters.axialExpansionChanger.axialExpansionChanger.AxialExpansionChanger.applyColdHeightMassIncrease`
-    """
-    assems = list(assems)
-    if not referenceAssembly:
-        referenceAssembly = getDefaultReferenceAssem(assems)
-    axialExpChanger = AxialExpansionChanger(isDetailedAxialExpansion)
-    for a in assems:
-        axialExpChanger.setAssembly(a, expandFromTinputToThot=True)
-        axialExpChanger.applyColdHeightMassIncrease()
-        axialExpChanger.expansionData.computeThermalExpansionFactors()
-        axialExpChanger.axiallyExpandAssembly()
-    if not isDetailedAxialExpansion:
-        for a in assems:
-            a.setBlockMesh(referenceAssembly.getAxialMesh())
-    # update block BOL heights to reflect hot heights
-    for a in assems:
-        for b in a:
-            b.p.heightBOL = b.getHeight()
-            b.completeInitialLoading()
-
-
 class AxialExpansionChanger:
     """
     Axially expand or contract assemblies or an entire core.
@@ -148,6 +83,71 @@ class AxialExpansionChanger:
         self.linked = None
         self.expansionData = None
 
+    @classmethod
+    def expandColdDimsToHot(
+        cls,
+        assems: list,
+        isDetailedAxialExpansion: bool,
+        referenceAssembly=None,
+    ):
+        """Expand BOL assemblies, resolve disjoint axial mesh (if needed), and update block BOL heights.
+
+        .. impl:: Perform expansion during core construction based on block heights at a specified temperature.
+            :id: I_ARMI_INP_COLD_HEIGHT
+            :implements: R_ARMI_INP_COLD_HEIGHT
+
+            This method is designed to be used during core construction to axially thermally expand the
+            assemblies to their "hot" temperatures (as determined by ``Thot`` values in blueprints).
+            First, The Assembly is prepared for axial expansion via ``setAssembly``. In
+            ``applyColdHeightMassIncrease``, the number densities on each Component is adjusted to
+            reflect that Assembly inputs are at cold (i.e., ``Tinput``) temperatures. To expand to
+            the requested hot temperatures, thermal expansion factors are then computed in
+            ``computeThermalExpansionFactors``. Finally, the Assembly is axially thermally expanded in
+            ``axiallyExpandAssembly``.
+
+            If the setting ``detailedAxialExpansion`` is ``False``, then each Assembly gets its Block mesh
+            set to match that of the "reference" Assembly (see ``getDefaultReferenceAssem`` and ``setBlockMesh``).
+
+            Once the Assemblies are axially expanded, the Block BOL heights are updated. To account for the change in
+            Block volume from axial expansion, ``completeInitialLoading`` is called to update any volume-dependent
+            Block information.
+
+        Parameters
+        ----------
+        assems: list[:py:class:`Assembly <armi.reactor.assemblies.Assembly>`]
+            list of assemblies to be thermally expanded
+        isDetailedAxialExpansion: bool
+            If False, assemblies will be forced to conform to the reference mesh after expansion
+        referenceAssembly: :py:class:`Assembly <armi.reactor.assemblies.Assembly>`, optional
+            Assembly whose mesh other meshes will conform to if isDetailedAxialExpansion is False.
+            If not provided, will assume the finest mesh assembly which is typically fuel.
+
+        Notes
+        -----
+        Calling this method will result in an increase in mass via applyColdHeightMassIncrease!
+
+        See Also
+        --------
+        :py:meth:`armi.reactor.converters.axialExpansionChanger.axialExpansionChanger.AxialExpansionChanger.applyColdHeightMassIncrease`
+        """
+        assems = list(assems)
+        if not referenceAssembly:
+            referenceAssembly = getDefaultReferenceAssem(assems)
+        axialExpChanger = cls(isDetailedAxialExpansion)
+        for a in assems:
+            axialExpChanger.setAssembly(a, expandFromTinputToThot=True)
+            axialExpChanger.applyColdHeightMassIncrease()
+            axialExpChanger.expansionData.computeThermalExpansionFactors()
+            axialExpChanger.axiallyExpandAssembly()
+        if not isDetailedAxialExpansion:
+            for a in assems:
+                a.setBlockMesh(referenceAssembly.getAxialMesh())
+        # update block BOL heights to reflect hot heights
+        for a in assems:
+            for b in a:
+                b.p.heightBOL = b.getHeight()
+                b.completeInitialLoading()
+
     def performPrescribedAxialExpansion(
         self, a, componentLst: list, percents: list, setFuel=True
     ):
diff --git a/armi/settings/settingsValidation.py b/armi/settings/settingsValidation.py
new file mode 100644
index 00000000..ed11f5b3
--- /dev/null
+++ b/armi/settings/settingsValidation.py
@@ -0,0 +1,803 @@
+# Copyright 2019 TerraPower, LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+A system to check user settings for validity and provide users with meaningful
+suggestions to fix.
+
+This allows developers to define a rich set of rules and suggestions for user settings.
+These then pop up during initialization of a run, either on the command line or as
+dialogues in the GUI. They say things like: "Your ___ setting has the value ___, which
+is impossible. Would you like to switch to ___?"
+"""
+import itertools
+import os
+import re
+import shutil
+
+from armi import context
+from armi import getPluginManagerOrFail
+from armi import runLog
+from armi.physics import neutronics
+from armi.reactor import geometry
+from armi.reactor import systemLayoutInput
+from armi.settings.settingsIO import (
+    prompt,
+    RunLogPromptCancel,
+    RunLogPromptUnresolvable,
+)
+from armi.utils import directoryChangers
+from armi.utils import pathTools
+from armi.utils.mathematics import expandRepeatedFloats
+
+
+class Query:
+    """
+    An individual setting validator.
+
+    .. impl:: Rules to validate and customize a setting's behavior.
+        :id: I_ARMI_SETTINGS_RULES
+        :implements: R_ARMI_SETTINGS_RULES
+
+        This class is meant to represent a generic validation test against a setting.
+        The goal is: developers create new settings and they want to make sure those
+        settings are used correctly. As an implementation, users pass in a
+        ``condition`` function to this class that returns ``True`` or ``False`` based
+        on the setting name and value. And then this class has a ``resolve`` method
+        which tests if the condition is met. Optionally, this class also contains a
+        ``correction`` function that allows users to automatically correct a bad
+        setting, if the developers can find a clear path forward.
+    """
+
+    def __init__(self, condition, statement, question, correction):
+        """
+        Construct a query.
+
+        Parameters
+        ----------
+        condition : callable
+            A callable that returns True or False. If True, then the query activates
+            its question and potential correction.
+        statement : str
+            A statement of the problem indicated by a True condition
+        question : str
+            A question asking the user for confirmation of the proposed
+            fix.
+        correction : callable
+            A callable that when called fixes the situation. See
+            :py:meth:`Inspector.NO_ACTION` for no-ops.
+        """
+        self.condition = condition
+        self.statement = statement
+        self.question = question
+        self.correction = correction
+        # True if the query is `passed` and does not result in an immediate failure
+        self.corrected = False
+        self._passed = False
+        self.autoResolved = True
+
+    def __repr__(self):
+        # Add representation so that it's possible to identify which one
+        # is being referred to when there are errors.
+        return "<Query: {}>".format(self.statement)
+
+    def __bool__(self):
+        try:
+            return bool(self.condition())
+        except TypeError:
+            runLog.error(
+                f"Invalid setting validation query. Update validator for: {self})"
+            )
+            raise
+
+    def isCorrective(self):
+        return self.correction is not Inspector.NO_ACTION
+
+    def resolve(self):
+        """Standard i/o prompt for resolution of an individual query."""
+        if context.MPI_RANK != 0:
+            return
+
+        if self.condition():
+            try:
+                if self.isCorrective():
+                    try:
+                        make_correction = prompt(
+                            "INSPECTOR: " + self.statement,
+                            self.question,
+                            "YES_NO",
+                            "NO_DEFAULT",
+                            "CANCEL",
+                        )
+                        if make_correction:
+                            self.correction()
+                            self.corrected = True
+                        self._passed = True
+                    except RunLogPromptCancel as ki:
+                        raise KeyboardInterrupt from ki
+                else:
+                    try:
+                        continue_submission = prompt(
+                            "INSPECTOR: " + self.statement,
+                            "Continue?",
+                            "YES_NO",
+                            "NO_DEFAULT",
+                            "CANCEL",
+                        )
+                        if not continue_submission:
+                            raise KeyboardInterrupt
+                    except RunLogPromptCancel as ki:
+                        raise KeyboardInterrupt from ki
+            except RunLogPromptUnresolvable:
+                self.autoResolved = False
+                self._passed = True
+
+
+class Inspector:
+    """
+    This manages queries which assert certain states of the data model, generally presenting
+    themselves to the user, offering information on the potential problem, a question
+    and the action to take on an affirmative and negative answer from the user.
+
+    In practice very useful for making sure setting values are as intended and without
+    bad interplay with one another.
+
+    One Inspector will contain multiple Queries and be associated directly with an
+    :py:class:`~armi.operators.operator.Operator`.
+    """
+
+    @staticmethod
+    def NO_ACTION():
+        """Convenience callable used to generate Queries that can't be easily auto-resolved."""
+        return None
+
+    def __init__(self, cs):
+        """
+        Construct an inspector.
+
+        Parameters
+        ----------
+        cs : Settings
+        """
+        self.queries = []
+        self.cs = cs
+        self.geomType = None
+        self.coreSymmetry = None
+        self._inspectBlueprints()
+        self._setGeomType()
+        self._inspectSettings()
+
+        # Gather and attach validators from all plugins
+        # This runs on all registered plugins, not just active ones.
+        pluginQueries = getPluginManagerOrFail().hook.defineSettingsValidators(
+            inspector=self
+        )
+        for queries in pluginQueries:
+            self.queries.extend(queries)
+
+    def run(self, cs=None):
+        """
+        Run through each query and deal with it if possible.
+
+        Returns
+        -------
+        correctionsMade : bool
+            Whether or not anything was updated.
+
+        Raises
+        ------
+        RuntimeError
+            When a programming error causes queries to loop.
+        """
+        if context.MPI_RANK != 0:
+            return False
+
+        # the following attribute changes will alter what the queries investigate when resolved
+        correctionsMade = False
+        self.cs = cs or self.cs
+        runLog.debug("{} executing queries.".format(self.__class__.__name__))
+        if not any(self.queries):
+            runLog.debug(
+                "{} found no problems with the current state.".format(
+                    self.__class__.__name__
+                )
+            )
+        else:
+            for query in self.queries:
+                query.resolve()
+                if query.corrected:
+                    correctionsMade = True
+            issues = [
+                query
+                for query in self.queries
+                if query and (query.isCorrective() and not query._passed)
+            ]
+            if any(issues):
+                # something isn't resolved or was unresolved by changes
+                raise RuntimeError(
+                    "The input inspection did not resolve all queries, "
+                    "some issues are creating cyclic resolutions: {}".format(issues)
+                )
+            runLog.debug("{} has finished querying.".format(self.__class__.__name__))
+
+        if correctionsMade:
+            # find unused file path to store original settings as to avoid overwrite
+            strSkeleton = "{}_old".format(self.cs.path.split(".yaml")[0])
+            for num in itertools.count():
+                if num == 0:
+                    renamePath = f"{strSkeleton}.yaml"
+                else:
+                    renamePath = f"{strSkeleton}{num}.yaml"
+                if not self._csRelativePathExists(renamePath):
+                    break
+            # preserve old file before saving settings file
+            runLog.important(
+                f"Preserving original settings file by renaming `{renamePath}`"
+            )
+            shutil.copy(self.cs.path, renamePath)
+            # save settings file
+            self.cs.writeToYamlFile(self.cs.path)
+
+        return correctionsMade
+
+    def addQuery(self, condition, statement, question, correction):
+        """Convenience method, query must be resolved, else run fails."""
+        if not callable(correction):
+            raise ValueError(
+                'Query for "{}" malformed. Expecting callable.'.format(statement)
+            )
+        self.queries.append(Query(condition, statement, question, correction))
+
+    def addQueryBadLocationWillLikelyFail(self, settingName):
+        """Add a query indicating the current path for ``settingName`` does not exist and will likely fail."""
+        self.addQuery(
+            lambda: not os.path.exists(pathTools.armiAbsPath(self.cs[settingName])),
+            "Setting {} points to nonexistent location\n{}\nFailure extremely likely".format(
+                settingName, self.cs[settingName]
+            ),
+            "",
+            self.NO_ACTION,
+        )
+
+    def addQueryCurrentSettingMayNotSupportFeatures(self, settingName):
+        """Add a query that the current value for ``settingName`` may not support certain features."""
+        self.addQuery(
+            lambda: self.cs[settingName] != self.cs.getSetting(settingName).default,
+            "{} set as:\n{}\nUsing this location instead of the default location\n{}\n"
+            "may not support certain functions.".format(
+                settingName,
+                self.cs[settingName],
+                self.cs.getSetting(settingName).default,
+            ),
+            "Revert to default location?",
+            lambda: self._assignCS(
+                settingName, self.cs.getSetting(settingName).default
+            ),
+        )
+
+    def _assignCS(self, key, value):
+        """Lambda assignment workaround."""
+        # this type of assignment works, but be mindful of
+        # scoping when trying different methods
+        runLog.extra(f"Updating setting `{key}` to `{value}`")
+        self.cs[key] = value
+
+    def _raise(self):
+        raise KeyboardInterrupt("Input inspection has been interrupted.")
+
+    def _inspectBlueprints(self):
+        """Blueprints early error detection and old format conversions."""
+        from armi.physics.neutronics.settings import CONF_LOADING_FILE
+
+        # if there is a blueprints object, we don't need to check for a file
+        if self.cs.filelessBP:
+            return
+
+        self.addQuery(
+            lambda: not self.cs[CONF_LOADING_FILE],
+            "No blueprints file loaded. Run will probably fail.",
+            "",
+            self.NO_ACTION,
+        )
+
+        self.addQuery(
+            lambda: not self._csRelativePathExists(self.cs[CONF_LOADING_FILE]),
+            "Blueprints file {} not found. Run will fail.".format(
+                self.cs[CONF_LOADING_FILE]
+            ),
+            "",
+            self.NO_ACTION,
+        )
+
+    def _csRelativePathExists(self, filename):
+        csRelativePath = self._csRelativePath(filename)
+        return os.path.exists(csRelativePath) and os.path.isfile(csRelativePath)
+
+    def _csRelativePath(self, filename):
+        return os.path.join(self.cs.inputDirectory, filename)
+
+    def _setGeomType(self):
+        if self.cs["geomFile"]:
+            with directoryChangers.DirectoryChanger(
+                self.cs.inputDirectory, dumpOnException=False
+            ):
+                geom = systemLayoutInput.SystemLayoutInput()
+                geom.readGeomFromFile(self.cs["geomFile"])
+
+            self.geomType, self.coreSymmetry = geom.geomType, geom.symmetry
+
+    def _correctCyclesToZeroBurnup(self):
+        self._assignCS("nCycles", 1)
+        self._assignCS("burnSteps", 0)
+        self._assignCS("cycleLength", None)
+        self._assignCS("cycleLengths", None)
+        self._assignCS("availabilityFactor", None)
+        self._assignCS("availabilityFactors", None)
+        self._assignCS("cycles", [])
+
+    def _checkForBothSimpleAndDetailedCyclesInputs(self):
+        """
+        Because the only way to check if a setting has been "entered" is to check
+        against the default, if the user specifies all the simple cycle settings
+        _exactly_ as the defaults, this won't be caught. But, it would be very
+        coincidental for the user to _specify_ all the default values when
+        performing any real analysis.
+
+        Also, we must bypass the `Settings` getter and reach directly
+        into the underlying `__settings` dict to avoid triggering an error
+        at this stage in the run. Otherwise an error will inherently be raised
+        if the detailed cycles input is used because the simple cycles inputs
+        have defaults. We don't care that those defaults are there, we only
+        have a problem with those defaults being _used_, which will be caught
+        later on.
+        """
+        bothCyclesInputTypesPresent = (
+            self.cs._Settings__settings["cycleLength"].value
+            != self.cs._Settings__settings["cycleLength"].default
+            or self.cs._Settings__settings["cycleLengths"].value
+            != self.cs._Settings__settings["cycleLengths"].default
+            or self.cs._Settings__settings["burnSteps"].value
+            != self.cs._Settings__settings["burnSteps"].default
+            or self.cs._Settings__settings["availabilityFactor"].value
+            != self.cs._Settings__settings["availabilityFactor"].default
+            or self.cs._Settings__settings["availabilityFactors"].value
+            != self.cs._Settings__settings["availabilityFactors"].default
+            or self.cs._Settings__settings["powerFractions"].value
+            != self.cs._Settings__settings["powerFractions"].default
+        ) and self.cs["cycles"] != []
+
+        return bothCyclesInputTypesPresent
+
+    def _inspectSettings(self):
+        """Check settings for inconsistencies."""
+        from armi import operators
+        from armi.physics.neutronics.settings import (
+            CONF_BC_COEFFICIENT,
+            CONF_BOUNDARIES,
+            CONF_XS_KERNEL,
+            CONF_XS_SCATTERING_ORDER,
+        )
+
+        self.addQueryBadLocationWillLikelyFail("operatorLocation")
+
+        self.addQuery(
+            lambda: self.cs["outputFileExtension"] == "pdf" and self.cs["genReports"],
+            "Output files of '.pdf' format are not supported by the reporting HTML generator. '.pdf' "
+            "images will not be included.",
+            "Switch to '.png'?",
+            lambda: self._assignCS("outputFileExtension", "png"),
+        )
+
+        self.addQuery(
+            lambda: (
+                (
+                    self.cs["beta"]
+                    and isinstance(self.cs["beta"], list)
+                    and not self.cs["decayConstants"]
+                )
+                or (self.cs["decayConstants"] and not self.cs["beta"])
+            ),
+            "Both beta components and decay constants should be provided if either are "
+            "being supplied.",
+            "",
+            self.NO_ACTION,
+        ),
+
+        self.addQuery(
+            lambda: self.cs["skipCycles"] > 0 and not self.cs["reloadDBName"],
+            "You have chosen to do a restart case without specifying a database to load from. "
+            "Run will load from output files, if they exist but burnup, etc. will not be updated.",
+            "",
+            self.NO_ACTION,
+        )
+
+        self.addQuery(
+            lambda: self.cs["runType"] != operators.RunTypes.SNAPSHOTS
+            and self.cs["loadStyle"] == "fromDB"
+            and self.cs["startCycle"] == 0
+            and self.cs["startNode"] == 0,
+            "Starting from cycle 0, and time node 0 was chosen. Restart runs load from "
+            "the time node just before the restart. There is no time node to load from "
+            "before cycle 0 node 0. Either switch to the snapshot operator, start from "
+            "a different time step or load from inputs rather than database as "
+            "`loadStyle`.",
+            "",
+            self.NO_ACTION,
+        )
+
+        self.addQuery(
+            lambda: self.cs["runType"] == operators.RunTypes.SNAPSHOTS
+            and not (self.cs["dumpSnapshot"] or self.cs["defaultSnapshots"]),
+            "The Snapshots operator was specified, but no dump snapshots were chosen."
+            "Please specify snapshot steps with the `dumpSnapshot` setting.",
+            "",
+            self.NO_ACTION,
+        )
+
+        self.addQuery(
+            lambda: self.cs.caseTitle.lower()
+            == os.path.splitext(os.path.basename(self.cs["reloadDBName"].lower()))[0],
+            "Snapshot DB ({0}) and main DB ({1}) cannot have the same name."
+            "Change name of settings file and resubmit.".format(
+                self.cs["reloadDBName"], self.cs.caseTitle
+            ),
+            "",
+            self.NO_ACTION,
+        )
+
+        self.addQuery(
+            lambda: self.cs["reloadDBName"] != ""
+            and not os.path.exists(self.cs["reloadDBName"]),
+            "Reload database {} does not exist. \nPlease point to an existing DB, "
+            "or set to empty and load from input.".format(self.cs["reloadDBName"]),
+            "",
+            self.NO_ACTION,
+        )
+
+        def _willBeCopiedFrom(fName):
+            return any(
+                fName == os.path.split(copyFile)[1]
+                for copyFile in self.cs["copyFilesFrom"]
+            )
+
+        self.addQuery(
+            lambda: self.cs["explicitRepeatShuffles"]
+            and not self._csRelativePathExists(self.cs["explicitRepeatShuffles"])
+            and not _willBeCopiedFrom(self.cs["explicitRepeatShuffles"]),
+            "The specified repeat shuffle file `{0}` does not exist, and won't be copied. "
+            "Run will crash.".format(self.cs["explicitRepeatShuffles"]),
+            "",
+            self.NO_ACTION,
+        )
+
+        self.addQuery(
+            lambda: not self.cs["power"] and not self.cs["powerDensity"],
+            "No power or powerDensity set. You must always start by importing a base settings file.",
+            "",
+            self.NO_ACTION,
+        )
+
+        self.addQuery(
+            lambda: self.cs["power"] > 0 and self.cs["powerDensity"] > 0,
+            "The power and powerDensity are both set, please note the power will be used as the truth.",
+            "",
+            self.NO_ACTION,
+        )
+
+        # The gamma cross sections generated for MC2-3 by ANL were done with NJOY with
+        # P3 scattering. MC2-3 would have to be modified and the gamma cross sections
+        # re-generated with NJOY for MC2-3 to allow any other scattering order with
+        # gamma cross sections enabled.
+        self.addQuery(
+            lambda: (
+                "MC2v3" in self.cs[CONF_XS_KERNEL]
+                and neutronics.gammaXsAreRequested(self.cs)
+                and self.cs[CONF_XS_SCATTERING_ORDER] != 3
+            ),
+            "MC2-3 will crash if a scattering order is not set to 3 when generating gamma XS.",
+            f"Would you like to set the `{CONF_XS_SCATTERING_ORDER}` to 3?",
+            lambda: self._assignCS(CONF_XS_SCATTERING_ORDER, 3),
+        )
+
+        self.addQuery(
+            lambda: self.cs["outputCacheLocation"]
+            and not os.path.exists(self.cs["outputCacheLocation"]),
+            "`outputCacheLocation` path {} does not exist. Please specify a location that exists.".format(
+                self.cs["outputCacheLocation"]
+            ),
+            "",
+            self.NO_ACTION,
+        )
+
+        self.addQuery(
+            lambda: (
+                not self.cs["tightCoupling"]
+                and self.cs["tightCouplingMaxNumIters"] != 4
+            ),
+            "You've requested a non default number of tight coupling iterations but left tightCoupling: False."
+            "Do you want to set tightCoupling to True?",
+            "",
+            lambda: self._assignCS("tightCoupling", True),
+        )
+
+        self.addQuery(
+            lambda: (not self.cs["tightCoupling"] and self.cs["tightCouplingSettings"]),
+            "You've requested non default tight coupling settings but tightCoupling: False."
+            "Do you want to set tightCoupling to True?",
+            "",
+            lambda: self._assignCS("tightCoupling", True),
+        )
+
+        self.addQuery(
+            lambda: self.cs["startCycle"]
+            and self.cs["nCycles"] < self.cs["startCycle"],
+            "nCycles must be greater than or equal to startCycle in restart cases. nCycles"
+            " is the _total_ number of cycles in the completed run (i.e. restarted +"
+            " continued cycles). Please update the case settings.",
+            "",
+            self.NO_ACTION,
+        )
+
+        self.addQuery(
+            lambda: self.cs["nCycles"] in [0, None],
+            "Cannot run 0 cycles. Set burnSteps to 0 to activate a single time-independent case.",
+            "Set 1 cycle and 0 burnSteps for single time-independent case?",
+            self._correctCyclesToZeroBurnup,
+        )
+
+        self.addQuery(
+            self._checkForBothSimpleAndDetailedCyclesInputs,
+            "If specifying detailed cycle history with `cycles`, you may not"
+            " also use any of the simple cycle history inputs `cycleLength(s)`,"
+            " `burnSteps`, `availabilityFactor(s)`, or `powerFractions`."
+            " Using the detailed cycle history.",
+            "",
+            self.NO_ACTION,
+        )
+
+        def _factorsAreValid(factors, maxVal=1.0):
+            try:
+                expandedList = expandRepeatedFloats(factors)
+            except (ValueError, IndexError):
+                return False
+            return (
+                all(0.0 <= val <= maxVal for val in expandedList)
+                and len(expandedList) == self.cs["nCycles"]
+            )
+
+        if self.cs["cycles"] == []:
+            self.addQuery(
+                lambda: (
+                    self.cs["availabilityFactors"]
+                    and not _factorsAreValid(self.cs["availabilityFactors"])
+                ),
+                "`availabilityFactors` was not set to a list compatible with the number of cycles. "
+                "Please update input or use constant duration.",
+                "Use constant availability factor specified in `availabilityFactor` setting?",
+                lambda: self._assignCS("availabilityFactors", []),
+            )
+
+            self.addQuery(
+                lambda: (
+                    self.cs["powerFractions"]
+                    and not _factorsAreValid(self.cs["powerFractions"])
+                ),
+                "`powerFractions` was not set to a compatible list. "
+                "Please update input or use full power at all cycles.",
+                "Use full power for all cycles?",
+                lambda: self._assignCS("powerFractions", []),
+            )
+
+            self.addQuery(
+                lambda: (
+                    self.cs["cycleLengths"]
+                    and not _factorsAreValid(self.cs["cycleLengths"], maxVal=1e10)
+                ),
+                "`cycleLengths` was not set to a list compatible with the number of cycles."
+                " Please update input or use constant duration.",
+                "Use constant cycle length specified in `cycleLength` setting?",
+                lambda: self._assignCS("cycleLengths", []),
+            )
+
+            self.addQuery(
+                lambda: (
+                    self.cs["runType"] == operators.RunTypes.STANDARD
+                    and self.cs["burnSteps"] == 0
+                    and (
+                        (
+                            len(self.cs["cycleLengths"]) > 1
+                            if self.cs["cycleLengths"] is not None
+                            else False
+                        )
+                        or self.cs["nCycles"] > 1
+                    )
+                ),
+                "Cannot run multi-cycle standard cases with 0 burnSteps per cycle. Please update settings.",
+                "",
+                self.NO_ACTION,
+            )
+
+            def decayCyclesHaveInputThatWillBeIgnored():
+                """Check if there is any decay-related input that will be ignored."""
+                try:
+                    powerFracs = expandRepeatedFloats(self.cs["powerFractions"])
+                    availabilities = expandRepeatedFloats(
+                        self.cs["availabilityFactors"]
+                    ) or ([self.cs["availabilityFactor"]] * self.cs["nCycles"])
+                except Exception:
+                    return True
+
+                # This will be a full decay step and any power fraction will be ignored. May be ok.
+                return any(
+                    pf > 0.0 and af == 0.0 for pf, af in zip(powerFracs, availabilities)
+                )
+
+            self.addQuery(
+                lambda: (
+                    self.cs["cycleLengths"]
+                    and self.cs["powerFractions"]
+                    and decayCyclesHaveInputThatWillBeIgnored()
+                    and not self.cs["cycles"]
+                ),
+                "At least one cycle has a non-zero power fraction but an availability of zero. Please "
+                "update the input.",
+                "",
+                self.NO_ACTION,
+            )
+
+        self.addQuery(
+            lambda: self.cs["operatorLocation"]
+            and self.cs["runType"] != operators.RunTypes.STANDARD,
+            "The `runType` setting is set to `{0}` but there is a `custom operator location` defined".format(
+                self.cs["runType"]
+            ),
+            "",
+            self.NO_ACTION,
+        )
+
+        self.addQuery(
+            lambda: self.cs["operatorLocation"]
+            and self.cs["runType"] != operators.RunTypes.STANDARD,
+            "The `runType` setting is set to `{0}` but there is a `custom operator location` defined".format(
+                self.cs["runType"]
+            ),
+            "",
+            self.NO_ACTION,
+        )
+
+        self.addQuery(
+            lambda: self.cs["skipCycles"] > 0
+            and not os.path.exists(self.cs.caseTitle + ".restart.dat"),
+            "This is a restart case, but the required restart file {0}.restart.dat is not found".format(
+                self.cs.caseTitle
+            ),
+            "",
+            self.NO_ACTION,
+        )
+
+        self.addQuery(
+            lambda: self.cs["deferredInterfacesCycle"] > self.cs["nCycles"],
+            "The deferred interface activation cycle exceeds set cycle occurrence. "
+            "Interfaces will not be activated in this run!",
+            "",
+            self.NO_ACTION,
+        )
+
+        self.addQuery(
+            lambda: (
+                self.cs[CONF_BOUNDARIES] != neutronics.GENERAL_BC
+                and self.cs[CONF_BC_COEFFICIENT]
+            ),
+            f"General neutronic boundary condition was not selected, but `{CONF_BC_COEFFICIENT}` was defined. "
+            f"Please enable `Generalized` neutronic boundary condition or disable `{CONF_BC_COEFFICIENT}`.",
+            "",
+            self.NO_ACTION,
+        )
+
+        self.addQuery(
+            lambda: self.cs["geomFile"]
+            and str(self.geomType) not in geometry.VALID_GEOMETRY_TYPE,
+            "{} is not a valid geometry Please update geom type on the geom file. "
+            "Valid (case insensitive) geom types are: {}".format(
+                self.geomType, geometry.VALID_GEOMETRY_TYPE
+            ),
+            "",
+            self.NO_ACTION,
+        )
+
+        self.addQuery(
+            lambda: self.cs["geomFile"]
+            and not geometry.checkValidGeomSymmetryCombo(
+                self.geomType, self.coreSymmetry
+            ),
+            "{}, {} is not a valid geometry and symmetry combination. Please update "
+            "either geometry or symmetry on the geom file.".format(
+                str(self.geomType), str(self.coreSymmetry)
+            ),
+            "",
+            self.NO_ACTION,
+        )
+
+
+def createQueryRevertBadPathToDefault(inspector, settingName, initialLambda=None):
+    """
+    Return a query to revert a bad path to its default.
+
+    Parameters
+    ----------
+    inspector: Inspector
+        the inspector who's settings are being queried
+    settingName: str
+        name of the setting to inspect
+    initialLambda: None or callable function
+        If ``None``, the callable argument for :py:meth:`addQuery` is does the setting's path exist.
+        If more complicated callable arguments are needed, they can be passed in as the ``initialLambda`` setting.
+    """
+    if initialLambda is None:
+        initialLambda = lambda: (
+            not os.path.exists(pathTools.armiAbsPath(inspector.cs[settingName]))
+            and inspector.cs.getSetting(settingName).offDefault
+        )  # solution is to revert to default
+
+    query = Query(
+        initialLambda,
+        "Setting {} points to a nonexistent location:\n{}".format(
+            settingName, inspector.cs[settingName]
+        ),
+        "Revert to default location?",
+        inspector.cs.getSetting(settingName).revertToDefault,
+    )
+    return query
+
+
+def validateVersion(versionThis: str, versionRequired: str) -> bool:
+    """Helper function to allow users to verify that their version matches the settings file.
+
+    Parameters
+    ----------
+    versionThis: str
+        The version of this ARMI, App, or Plugin.
+        This MUST be in the form: 1.2.3
+    versionRequired: str
+        The version to compare against, say in a Settings file.
+        This must be in one of the forms: 1.2.3, 1.2, or 1
+
+    Returns
+    -------
+    bool
+        Does this version match the version in the Settings file/object?
+    """
+    fullV = "\d+\.\d+\.\d+"
+    medV = "\d+\.\d+"
+    minV = "\d+"
+
+    if versionRequired == "uncontrolled":
+        # This default flag means we don't want to check the version.
+        return True
+    elif re.search(fullV, versionThis) is None:
+        raise ValueError(
+            "The input version ({0}) does not match the required format: {1}".format(
+                versionThis, fullV
+            )
+        )
+    elif re.search(fullV, versionRequired) is not None:
+        return versionThis == versionRequired
+    elif re.search(medV, versionRequired) is not None:
+        return ".".join(versionThis.split(".")[:2]) == versionRequired
+    elif re.search(minV, versionRequired) is not None:
+        return versionThis.split(".")[0] == versionRequired
+    else:
+        raise ValueError(
+            "The required version is not a valid format: {}".format(versionRequired)
+        )
diff --git a/doc/release/0.4.rst b/doc/release/0.4.rst
index 6a8955bc..24bc4973 100644
--- a/doc/release/0.4.rst
+++ b/doc/release/0.4.rst
@@ -11,13 +11,17 @@ New Features
 #. ARMI now supports Python 3.12. (`PR#1813 <https://github.com/terrapower/armi/pull/1813>`_)
 #. Removing the ``tabulate`` dependency by ingesting it to ``armi.utils.tabulate``. (`PR#1811 <https://github.com/terrapower/armi/pull/1811>`_)
 #. Adding ``--skip-inspection`` flag to ``CompareCases`` CLI. (`PR#1842 <https://github.com/terrapower/armi/pull/1842>`_)
+#. Allow merging a component with zero area into another component (`PR#1858 <https://github.com/terrapower/armi/pull/1858>`_)
 #. Provide utilities for determining location of a rotated object in a hexagonal lattice (``getIndexOfRotatedCell``). (`PR#1846 <https://github.com/terrapower/armi/1846`)
 #. Allow merging a component with zero area into another component. (`PR#1858 <https://github.com/terrapower/armi/pull/1858>`_)
+#. Plugins can provide the ``getAxialExpansionChanger`` hook to customize axial expansion.
+   (`PR#1870 <https://github.com/terrapower/armi/pull/1870`_)
 #. TBD
 
 API Changes
 -----------
 #. Removing flags ``CORE`` and ``REACTOR``. (`PR#1835 <https://github.com/terrapower/armi/pull/1835>`_)
+#. Moving ``settingsValidation`` from ``operators`` to ``settings``. (`PR#1895 <https://github.com/terrapower/armi/pull/1895>`_)
 #. Removing deprecated method ``prepSearch``. (`PR#1845 <https://github.com/terrapower/armi/pull/1845>`_)
 #. Removing unused function ``SkippingXsGen_BuChangedLessThanTolerance``. (`PR#1845 <https://github.com/terrapower/armi/pull/1845>`_)
 #. Allow for unknown Flags when opening a DB. (`PR#1844 <https://github.com/terrapower/armi/pull/1835>`_)


Test patch:
diff --git a/armi/physics/neutronics/tests/test_neutronicsPlugin.py b/armi/physics/neutronics/tests/test_neutronicsPlugin.py
index d10c510a..00c855ad 100644
--- a/armi/physics/neutronics/tests/test_neutronicsPlugin.py
+++ b/armi/physics/neutronics/tests/test_neutronicsPlugin.py
@@ -19,7 +19,6 @@ import unittest
 from ruamel.yaml import YAML
 
 from armi import getPluginManagerOrFail, settings, tests
-from armi.operators import settingsValidation
 from armi.physics import neutronics
 from armi.physics.neutronics.const import CONF_CROSS_SECTION
 from armi.physics.neutronics.settings import (
@@ -33,8 +32,9 @@ from armi.physics.neutronics.settings import (
     CONF_LATTICE_PHYSICS_FREQUENCY,
     getNeutronicsSettingValidators,
 )
-from armi.settings.fwSettings.globalSettings import CONF_RUN_TYPE
 from armi.settings import caseSettings
+from armi.settings import settingsValidation
+from armi.settings.fwSettings.globalSettings import CONF_RUN_TYPE
 from armi.tests import TEST_ROOT
 from armi.tests.test_plugins import TestPlugin
 from armi.utils import directoryChangers
diff --git a/armi/operators/tests/test_inspectors.py b/armi/settings/tests/test_inspectors.py
similarity index 98%
rename from armi/operators/tests/test_inspectors.py
rename to armi/settings/tests/test_inspectors.py
index 3645bf0a..fe636175 100644
--- a/armi/operators/tests/test_inspectors.py
+++ b/armi/settings/tests/test_inspectors.py
@@ -19,8 +19,8 @@ import unittest
 from armi import context
 from armi import operators
 from armi import settings
-from armi.operators import settingsValidation
-from armi.operators.settingsValidation import createQueryRevertBadPathToDefault
+from armi.settings import settingsValidation
+from armi.settings.settingsValidation import createQueryRevertBadPathToDefault
 from armi.utils import directoryChangers
 
 
diff --git a/armi/settings/tests/test_settings.py b/armi/settings/tests/test_settings.py
index a82a0142..518a8bd6 100644
--- a/armi/settings/tests/test_settings.py
+++ b/armi/settings/tests/test_settings.py
@@ -26,7 +26,6 @@ from armi import getApp
 from armi import getPluginManagerOrFail
 from armi import plugins
 from armi import settings
-from armi.operators.settingsValidation import Inspector, validateVersion
 from armi.physics.fuelCycle import FuelHandlerPlugin
 from armi.physics.fuelCycle.settings import CONF_CIRCULAR_RING_ORDER
 from armi.physics.fuelCycle.settings import CONF_SHUFFLE_LOGIC
@@ -34,6 +33,7 @@ from armi.physics.neutronics.settings import CONF_NEUTRONICS_KERNEL
 from armi.reactor.flags import Flags
 from armi.settings import caseSettings
 from armi.settings import setting
+from armi.settings.settingsValidation import Inspector, validateVersion
 from armi.tests import TEST_ROOT, ARMI_RUN_PATH
 from armi.utils import directoryChangers
 from armi.utils.customExceptions import NonexistentSetting
diff --git a/armi/tests/test_plugins.py b/armi/tests/test_plugins.py
index 3654721f..209fe733 100644
--- a/armi/tests/test_plugins.py
+++ b/armi/tests/test_plugins.py
@@ -26,6 +26,7 @@ from armi import interfaces
 from armi import plugins
 from armi import settings
 from armi import utils
+from armi.reactor.converters.axialExpansionChanger import AxialExpansionChanger
 from armi.physics.neutronics import NeutronicsPlugin
 from armi.reactor.blocks import Block
 from armi.reactor.flags import Flags
@@ -42,13 +43,27 @@ class PluginFlags1(plugins.ArmiPlugin):
         return {"SUPER_FLAG": utils.flags.auto()}
 
 
+class SillyAxialExpansionChanger(AxialExpansionChanger):
+    """Fake, test-specific axial expansion changer that a plugin will register."""
+
+
+class SillyAxialPlugin(plugins.ArmiPlugin):
+    """Trivial plugin that implements the axial expansion hook."""
+
+    @staticmethod
+    @plugins.HOOKIMPL
+    def getAxialExpansionChanger() -> type[SillyAxialExpansionChanger]:
+        return SillyAxialExpansionChanger
+
+
 class TestPluginRegistration(unittest.TestCase):
     def setUp(self):
         """
         Manipulate the standard App. We can't just configure our own, since the
         pytest environment bleeds between tests.
         """
-        self._backupApp = deepcopy(getApp())
+        self.app = getApp()
+        self._backupApp = deepcopy(self.app)
 
     def tearDown(self):
         """Restore the App to its original state."""
@@ -92,6 +107,18 @@ class TestPluginRegistration(unittest.TestCase):
         # show the flag exists now
         self.assertEqual(type(Flags.SUPER_FLAG._value), int)
 
+    def test_axialExpansionHook(self):
+        """Test that plugins can override the axial expansion of assemblies via a hook."""
+        pm = self.app.pluginManager
+        first = pm.hook.getAxialExpansionChanger()
+        # By default, make sure we get the armi-shipped expansion class
+        self.assertIs(first, AxialExpansionChanger)
+        pm.register(SillyAxialPlugin)
+        second = pm.hook.getAxialExpansionChanger()
+        # Registering a plugin that implements the hook means we get
+        # that plugin's axial expander
+        self.assertIs(second, SillyAxialExpansionChanger)
+
 
 class TestPluginBasics(unittest.TestCase):
     def test_defineParameters(self):