From 6bcb53f12f8dc741cce3470bef889d61bd580d23 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Fri, 11 Sep 2026 11:22:45 -0400 Subject: [PATCH] Add the __solved_parameters__ protocol to graph_spec (PyAutoLens#736) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model figures phase 3. A class can now declare the quantities a fit solves for it -- `__solved_parameters__ = ("intensity",)` -- and graph_spec adds them as `solved` rows (provenance `solved-by-fit`, absent from model.info) without PyAutoFit knowing the class. `solved_paths=` is additive too: an entry matching no row is synthesised on its owner instead of being ignored. Rule R7 now drops `af.Model(cls)` for any int/float subclass, so a free `Redshift` is a pill on its galaxy card rather than a one-pill child card. The presentation shows `name · solved` pills and counts them in the footer. Also fixes `Model.constructor_argument_names` leaking the bound `cls` argument of a custom `__new__` into the parameter list, which made `af.Model(Redshift)` overwrite its own `cls` with a ConfigException. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0178yLU9v19GtACcRPFggjGa --- autofit/graph_spec.py | 222 ++++++++++-- autofit/mapper/prior_model/prior_model.py | 24 +- autofit/model_figure/presentation.py | 13 + test_autofit/graph_spec/lens_doubles.py | 11 +- test_autofit/graph_spec/test_solved.py | 327 ++++++++++++++++++ .../model_figure/test_presentation.py | 54 +++ 6 files changed, 616 insertions(+), 35 deletions(-) create mode 100644 test_autofit/graph_spec/test_solved.py diff --git a/autofit/graph_spec.py b/autofit/graph_spec.py index 654b8976a..22622012b 100644 --- a/autofit/graph_spec.py +++ b/autofit/graph_spec.py @@ -107,6 +107,10 @@ ``latent`` A row derived from ``type(analysis).Latent.keys(analysis)``. Not part of the model, hence ``in_model_info=False``. +``solved-by-fit`` + A row a *class* declared through ``__solved_parameters__``, or a + ``solved_paths=`` entry that matched no row and was synthesised. Not part + of the model, hence ``in_model_info=False``. ``user-prior``, ``assertion``, ``hierarchical-draw``, ``observed`` **Reserved and not emitted by phase 1.** ``assertion`` operands are carried by :class:`AssertionEdge` (assertions are edges, never rows); @@ -114,6 +118,30 @@ sharing marker); ``observed`` is phase 4/5 (observed data must not be encoded as ``fixed``). +Class-declared semantics -- the ``__solved_parameters__`` protocol +------------------------------------------------------------------ + +Two class attributes are **probed, never imported** -- this module knows no +domain class names: + +``__exclude_identifier_fields__`` + Attributes a class has already declared "not part of the model's identity" + (see :func:`_skipped_attributes`); they do not leak into the figure either. +``__solved_parameters__`` + A tuple of attribute names: *a class declares the quantities a fit solves + for it* -- e.g. a linear light profile's ``intensity`` -- so the figure can + show them as ``solved`` without PyAutoFit knowing the class. Read off + ``obj.cls`` (:func:`_solved_parameter_names`) and consumed in + :meth:`_Extractor._node`: a declared name with no row and no child of its + own is **appended** as a ``solved`` row with provenance ``solved-by-fit``; + a declared name that *is* an existing row is **re-tagged**, never + duplicated. Anything that is not a tuple/list of ``str`` is ignored. + +The caller-supplied ``solved_paths=`` is the same semantics from the outside, +and is **additive**: a path that matched no row is synthesised as a +``solved-by-fit`` row on its owner node (on the root when the owner is not a +component), never silently ignored. + Relations and the ``sampling`` axis ----------------------------------- @@ -578,12 +606,45 @@ def _numeric(value) -> Optional[float]: return None +def _solved_parameter_names(obj) -> Tuple[str, ...]: + """ + The ``__solved_parameters__`` a component's class declares. + + The protocol probe of the module docstring: *a class declares the quantities + a fit solves for it* -- e.g. a linear light profile's ``intensity`` -- so the + figure can show them as ``solved`` without PyAutoFit knowing the class. Read + off ``obj.cls`` exactly as :func:`_skipped_attributes` reads + ``__exclude_identifier_fields__`` off the type. Subclasses inherit it. + + Anything that is not a tuple (or list) of ``str`` -- a bare string included, + since iterating one would declare its characters -- is ignored, so a + malformed declaration can never corrupt the figure. + """ + declared = getattr(getattr(obj, "cls", None), "__solved_parameters__", ()) + if not isinstance(declared, (tuple, list)): + return () + if not all(isinstance(name, str) for name in declared): + return () + return tuple(declared) + + def _is_dropped_model(obj) -> bool: """ Rule R7 -- ``Model(int)`` / ``Model(float)`` is a row on its owner, never a component of its own. + + A **subclass** of ``int`` / ``float`` is dropped identically: autogalaxy's + ``Redshift(float)`` is a wrapper around one scalar, so ``af.Model(Redshift)`` + is the ordinary ``redshift`` row (free or fixed) on the galaxy that owns it, + not a one-pill child card. The row is named for the **attribute**; the class + name is not shown (see :meth:`_Extractor._int_model_row`). """ - return isinstance(obj, Model) and getattr(obj, "cls", None) in (int, float) + if not isinstance(obj, Model): + return False + cls = getattr(obj, "cls", None) + return cls in (int, float) or ( + isinstance(cls, type) and issubclass(cls, (int, float)) + ) def _cls_name(obj) -> str: @@ -913,6 +974,8 @@ def _node(self, path: Path, obj, name: str, by_path, children_of) -> ComponentNo ) ) + self._declared_solved(path, obj, rows, children) + return ComponentNode( path=path, name=name, @@ -923,6 +986,48 @@ def _node(self, path: Path, obj, name: str, by_path, children_of) -> ComponentNo children=tuple(children), ) + def _declared_solved( + self, + path: Path, + obj, + rows: List[ParamRow], + children: List[ComponentNode], + ) -> None: + """ + Consume the ``__solved_parameters__`` protocol on this component. + + A declared name that is **not** already a row and not a child component + of its own is appended as a ``solved`` row with provenance + ``solved-by-fit`` and ``in_model_info=False`` -- the quantity is absent + from the model, which is exactly what the annotation says. A declared + name that *is* an existing row (a subclass that re-exposes it as a + prior, say) is **re-tagged** in place, never duplicated. + """ + declared = _solved_parameter_names(obj) + if not declared: + return + positions = {row.name: index for index, row in enumerate(rows)} + child_names = {child.path[-1] for child in children if child.path} + for name in declared: + if name in positions: + index = positions[name] + rows[index] = replace( + rows[index], sampling="solved", in_model_info=False + ) + continue + if name in child_names: + continue + rows.append( + ParamRow( + name=name, + path=path + (name,), + sampling="solved", + provenance=Provenance("solved-by-fit"), + in_model_info=False, + ) + ) + positions[name] = len(rows) - 1 + def _promoted_entries(self, path: Path, obj) -> Dict[Path, Any]: entries = {path: obj} for sub_path, sub_obj in obj.path_instance_tuples_for_class( @@ -1235,11 +1340,11 @@ def attach_latents(self, root: ComponentNode) -> ComponentNode: keys = self.latent_keys() if not keys: return root - by_path: Dict[Path, List[ParamRow]] = {} + by_owner: Dict[Path, List[ParamRow]] = {} for key in keys: parts = tuple(key.split(".")) owner, name = parts[:-1], parts[-1] - by_path.setdefault(owner, []).append( + by_owner.setdefault(owner, []).append( ParamRow( name=name, path=owner + (name,), @@ -1248,21 +1353,40 @@ def attach_latents(self, root: ComponentNode) -> ComponentNode: in_model_info=False, ) ) - known = _paths_of(root) - - def _attach(node: ComponentNode) -> ComponentNode: - extra = list(by_path.get(node.path, ())) - if node.path == (): - for owner, rows in by_path.items(): - if owner not in known: - extra.extend(rows) - return replace( - node, - rows=node.rows + tuple(extra), - children=tuple(_attach(child) for child in node.children), - ) + return _attach_rows(root, by_owner) + + # -- solved paths ------------------------------------------------------- - return _attach(root) + def attach_solved_paths(self, root: ComponentNode) -> ComponentNode: + """ + ``solved_paths=`` is **additive**, never silently ignored. + + A path that matched a row is already re-tagged by :meth:`_solved` + during extraction. Any path left over names a quantity that is absent + from the model -- which is the whole point of the annotation -- so it is + synthesised here as a ``solved`` row with provenance ``solved-by-fit`` + on its owner node, or on the root when the owner is not a component. + """ + if not self.solved_paths: + return root + present = _row_paths_of(root) + by_owner: Dict[Path, List[ParamRow]] = {} + # `solved_paths` is a set: sort it so the synthesised rows are emitted + # in a stable order (the determinism contract). + for path in sorted(self.solved_paths): + if not path or path in present: + continue + owner, name = path[:-1], path[-1] + by_owner.setdefault(owner, []).append( + ParamRow( + name=name, + path=path, + sampling="solved", + provenance=Provenance("solved-by-fit"), + in_model_info=False, + ) + ) + return _attach_rows(root, by_owner) def _tuple_sampling(components: Tuple[ParamRow, ...]) -> str: @@ -1287,6 +1411,44 @@ def _paths_of(node: ComponentNode) -> set: return paths +def _row_paths_of(node: ComponentNode) -> set: + """Every row path in the tree, tuple components included.""" + paths = set() + for current in _walk_nodes(node): + for row in current.rows: + paths.add(row.path) + for component in row.components: + paths.add(component.path) + return paths + + +def _attach_rows(root: ComponentNode, by_owner: Dict[Path, List[ParamRow]]): + """ + Attach rows built *outside* the model walk -- latents and unmatched + ``solved_paths`` -- to the node that owns them. + + An owner path that is not a component of the tree cannot carry the row, so + the row goes on the root rather than being dropped. + """ + if not by_owner: + return root + known = _paths_of(root) + + def _attach(node: ComponentNode) -> ComponentNode: + extra = list(by_owner.get(node.path, ())) + if node.path == (): + for owner, rows in by_owner.items(): + if owner not in known: + extra.extend(rows) + return replace( + node, + rows=node.rows + tuple(extra), + children=tuple(_attach(child) for child in node.children), + ) + + return _attach(root) + + # ---------------------------------------------------------------------------- # collapse -- rules R1 / R2 and the safety condition # ---------------------------------------------------------------------------- @@ -1759,11 +1921,16 @@ def from_model( uncollapsed tree. solved_paths Paths -- tuples or dotted strings -- whose rows are re-stated as - ``solved`` and marked absent from ``model.info``. Phase 3 supplies - the domain rules that populate this. + ``solved`` and marked absent from ``model.info``. **Additive**: a + path that matches no row is synthesised as a ``solved-by-fit`` row + on its owner (on the root when the owner is not a component), never + silently ignored. Classes may declare the same thing for + themselves through ``__solved_parameters__`` (module docstring). """ extractor = _Extractor(model, analysis=analysis, solved_paths=solved_paths) - raw_root = extractor.attach_latents(extractor.build_root()) + raw_root = extractor.attach_solved_paths( + extractor.attach_latents(extractor.build_root()) + ) root = ( _collapse_tree(raw_root, _CollapseContext(extractor)) if collapse @@ -1986,15 +2153,21 @@ def _counts(model, spec: GraphSpec, raw_root: ComponentNode) -> Dict[str, int]: """ The reconciling counts. - The row-derived counts (``fixed_leaf_slots``, ``missing``) are of the - **uncollapsed** tree: a plate stands for every one of its members, so + The row-derived counts (``fixed_leaf_slots``, ``missing``, ``solved``) are + of the **uncollapsed** tree: a plate stands for every one of its members, so collapsing must not make a fixed value look absent from the model. Only the component counts are of both trees -- ``components`` after collapse, ``components_raw`` before, and ``plates`` how many of the former stand for more than one of the latter. + + ``solved`` counts the quantities the fit determines that are absent from the + model -- ``__solved_parameters__`` declarations, unmatched ``solved_paths``, + re-tagged rows and latents alike -- per scalar, exactly as ``missing`` is + counted (a tuple row counts its components). """ fixed_leaf_slots = 0 missing = 0 + solved = 0 for node in _walk_nodes(raw_root): for row in node.rows: if row.dimensionality == "tuple": @@ -2003,16 +2176,21 @@ def _counts(model, spec: GraphSpec, raw_root: ComponentNode) -> Dict[str, int]: fixed_leaf_slots += 1 if component.sampling == "missing": missing += 1 + if component.sampling == "solved": + solved += 1 continue if row.sampling == "fixed": fixed_leaf_slots += 1 if row.sampling == "missing": missing += 1 + if row.sampling == "solved": + solved += 1 return { "unique_sampled_scalars": model.prior_count, "fixed_leaf_slots": fixed_leaf_slots, "shared_priors": len(spec.shared), "missing": missing, + "solved": solved, "components_raw": _node_count(raw_root), "components": _node_count(spec.root), "plates": _plate_count(spec.root), diff --git a/autofit/mapper/prior_model/prior_model.py b/autofit/mapper/prior_model/prior_model.py index abccdb8b8..ef45ae856 100644 --- a/autofit/mapper/prior_model/prior_model.py +++ b/autofit/mapper/prior_model/prior_model.py @@ -29,6 +29,7 @@ class_args_dict = dict() + class Model(AbstractPriorModel): """ @DynamicAttrs @@ -348,13 +349,20 @@ def dict(self): def constructor_argument_names(self) -> List[str]: """ The argument names of the constructor of the class of this model. + + The **bound** first argument is not a model parameter and is dropped: + ``self`` for an ordinary ``__init__``, and ``cls`` when + ``inspect.getfullargspec`` resolves the class through a custom + ``__new__`` instead (e.g. ``Redshift(float)``, whose + ``__new__(cls, redshift)`` otherwise made ``cls`` a parameter and + overwrote ``Model.cls`` with its own ``ConfigException``). """ if self.cls not in class_args_dict: try: class_args_dict[self.cls] = [ arg for arg in inspect.getfullargspec(self.cls).args - if arg != "self" + if arg not in ("self", "cls") ] except TypeError: class_args_dict[self.cls] = [] @@ -503,12 +511,10 @@ def _instance_for_arguments( ) for prior_model_tuple in self.direct_prior_model_tuples: prior_model = prior_model_tuple.prior_model - model_arguments[ - prior_model_tuple.name - ] = prior_model.instance_for_arguments( - arguments, - ignore_assertions=ignore_assertions, - xp=xp + model_arguments[prior_model_tuple.name] = ( + prior_model.instance_for_arguments( + arguments, ignore_assertions=ignore_assertions, xp=xp + ) ) prior_arguments = dict() @@ -565,9 +571,7 @@ def _instance_for_arguments( ): if isinstance(value, Model): value = value.instance_for_arguments( - arguments, - ignore_assertions=ignore_assertions, - xp=xp + arguments, ignore_assertions=ignore_assertions, xp=xp ) elif isinstance(value, Constant): value = value.value diff --git a/autofit/model_figure/presentation.py b/autofit/model_figure/presentation.py index c86bec62e..7fca54b47 100644 --- a/autofit/model_figure/presentation.py +++ b/autofit/model_figure/presentation.py @@ -537,6 +537,11 @@ def _text(row, state, context) -> str: return f"{name} = {expression}" if state == "missing": return f"{name} · missing" + if state == "solved": + # A solved quantity is absent from the model, so there is no prior and + # no value to summarise -- the annotation *is* the whole text, at every + # detail level. + return f"{name} · solved" if state == "fixed-varies": return f"{name} · fixed, varies by member" if context.detail != "priors": @@ -919,6 +924,14 @@ def _footer(spec, hidden_fixed: int) -> str: ] if counts.get("missing"): parts.append(f"{counts['missing']} missing") + if counts.get("solved"): + parts.append( + _plural( + counts["solved"], + "parameter solved during fitting", + "parameters solved during fitting", + ) + ) plates = counts.get("plates") or 0 if plates: # What the plates stand for, not how many boxes the tree lost: the diff --git a/test_autofit/graph_spec/lens_doubles.py b/test_autofit/graph_spec/lens_doubles.py index cfed4c32d..cab7209c0 100644 --- a/test_autofit/graph_spec/lens_doubles.py +++ b/test_autofit/graph_spec/lens_doubles.py @@ -24,9 +24,14 @@ Known divergences from today's libraries, kept because the epic's acceptance model needs them (see ``PyAutoFit#1605``): -* ``Hilbert`` no longer takes ``areas_factor`` in autoarray -- the modern - signature is ``(pixels, weight_power, weight_floor)``. The epic's ``missing`` - case is that parameter, so the double keeps it. +* ``Hilbert`` no longer takes ``areas_factor`` in autoarray -- the modern image + mesh signature is ``(pixels, weight_power, weight_floor)``, and + ``areas_factor`` now lives on the **mesh** instead (``Delaunay``, + ``DelaunayNN`` and ``KNN`` all take it, defaulting to ``0.5``). The epic's + ``missing`` case *is* that parameter, so the double deliberately keeps its old + ``Hilbert.areas_factor`` shape: what is asserted here is the ``missing`` + state, not where autoarray currently declares the parameter (the real-class + version of the same assertion lives in PyAutoGalaxy's semantics tests). * the group dataset shipped with the workspace has *two* extra-galaxy centres; the epic's acceptance model has **eight**, so eight distinct fixed centres are used here. diff --git a/test_autofit/graph_spec/test_solved.py b/test_autofit/graph_spec/test_solved.py new file mode 100644 index 000000000..7c84e7ba5 --- /dev/null +++ b/test_autofit/graph_spec/test_solved.py @@ -0,0 +1,327 @@ +""" +The ``solved`` semantics: the ``__solved_parameters__`` protocol, the additive +``solved_paths=`` and rule R7's float subclasses. + +PyAutoFit must never know a lens class name, so a *class* declares the +quantities a fit solves for it -- a linear light profile's ``intensity``, a +``PointSolved``'s ``centre``, a pixelization's ``reconstruction`` -- and this +module reads that declaration off ``obj.cls`` exactly as it reads +``__exclude_identifier_fields__`` off the type. The doubles here carry the same +shapes as the real profiles (``test_autofit/graph_spec/lens_doubles.py``: +autofit imports autonerves only, never autogalaxy or autolens). +""" + +import itertools +import json + +import autofit as af +from autofit.graph_spec import GraphSpec, graph_spec_from + +from .lens_doubles import Galaxy, sersic + + +class LinearGaussian: + """ + A linear light profile: ``intensity`` is solved by the inversion and is + absent from the model entirely, so the class declares it. + """ + + __solved_parameters__ = ("intensity",) + + def __init__(self, centre=(0.0, 0.0), sigma=1.0): + self.centre = centre + self.sigma = sigma + + +class LinearSersic(LinearGaussian): + """A subclass inherits the declaration, as ``lp_linear.Sersic`` does.""" + + def __init__(self, centre=(0.0, 0.0), sigma=1.0, sersic_index=4.0): + super().__init__(centre=centre, sigma=sigma) + self.sersic_index = sersic_index + + +class PointSolved: + """Zero parameters: the centre is solved analytically.""" + + __solved_parameters__ = ("centre",) + + +class Reexposed: + """A class that declares a name which *is* one of its own slots.""" + + __solved_parameters__ = ("sigma",) + + def __init__(self, centre=(0.0, 0.0), sigma=1.0): + self.centre = centre + self.sigma = sigma + + +class Malformed: + """A declaration that is not a tuple of ``str`` is ignored, never obeyed.""" + + __solved_parameters__ = "intensity" + + def __init__(self, sigma=1.0): + self.sigma = sigma + + +class Redshift(float): + """ + The shape of autogalaxy's ``Redshift`` -- a thin ``float`` subclass whose + ``__new__`` takes the value, so ``af.Model(Redshift)`` is one scalar. + """ + + def __new__(cls, redshift): + return float.__new__(cls, redshift) + + def __init__(self, redshift): + float.__init__(redshift) + + +def linear_gaussian(sigma=1.0, cls=LinearGaussian): + model = af.Model(cls) + model.centre.centre_0 = af.GaussianPrior(mean=0.0, sigma=0.1) + model.centre.centre_1 = af.GaussianPrior(mean=0.0, sigma=0.1) + model.sigma = sigma + if cls is LinearSersic: + model.sersic_index = af.UniformPrior(lower_limit=0.8, upper_limit=8.0) + return model + + +# ---------------------------------------------------------------- the protocol + + +def test_a_declared_solved_parameter_is_appended_as_a_row(): + spec = GraphSpec.from_model(af.Collection(bulge=linear_gaussian())) + node = spec.node(("bulge",)) + + # Appended after the model's own slots, in declaration order. + assert [row.name for row in node.rows] == ["centre", "sigma", "intensity"] + + row = spec.row(("bulge", "intensity")) + assert row.sampling == "solved" + assert row.provenance.kind == "solved-by-fit" + # Solved quantities have no counterpart in `model.info` at all. + assert row.in_model_info is False + assert spec.path_index["bulge/intensity"] == () + assert "intensity" not in af.Collection(bulge=linear_gaussian()).info + assert spec.counts["solved"] == 1 + # The model's own counts are untouched: nothing was added to the model. + assert spec.counts["unique_sampled_scalars"] == 2 + + +def test_a_subclass_inherits_the_declaration(): + spec = GraphSpec.from_model(af.Collection(bulge=linear_gaussian(cls=LinearSersic))) + + assert [row.name for row in spec.node(("bulge",)).rows] == [ + "centre", + "sigma", + "sersic_index", + "intensity", + ] + assert spec.row(("bulge", "intensity")).sampling == "solved" + assert spec.counts["solved"] == 1 + + +def test_a_class_with_no_slots_at_all_is_a_card_of_one_solved_row(): + """``PointSolved`` has zero parameters -- without the declaration the box + would read "no parameters", which misreports an analytically solved centre.""" + spec = GraphSpec.from_model(af.Collection(point=af.Model(PointSolved))) + (row,) = spec.node(("point",)).rows + + assert (row.name, row.sampling) == ("centre", "solved") + assert row.provenance.kind == "solved-by-fit" + assert row.in_model_info is False + assert spec.counts["solved"] == 1 + assert spec.counts["unique_sampled_scalars"] == 0 + + +def test_a_declared_name_that_is_a_real_slot_is_retagged_not_duplicated(): + profile = af.Model(Reexposed) + profile.sigma = af.UniformPrior(lower_limit=0.0, upper_limit=5.0) + spec = GraphSpec.from_model(af.Collection(profile=profile)) + rows = spec.node(("profile",)).rows + + assert [row.name for row in rows] == ["centre", "sigma"] + row = spec.row(("profile", "sigma")) + assert row.sampling == "solved" + assert row.in_model_info is False + # Re-tagged in place: it is still the model's own prior, not a synthesised + # row, so its provenance and prior identity survive. + assert row.provenance.kind == "config-default" + assert row.prior_id is not None + assert spec.path_index["profile/sigma"] == () + assert spec.counts["solved"] == 1 + + +def test_a_malformed_declaration_is_ignored(): + spec = GraphSpec.from_model(af.Collection(profile=af.Model(Malformed))) + + assert [row.name for row in spec.node(("profile",)).rows] == ["sigma"] + assert spec.counts["solved"] == 0 + + +def test_a_declared_name_that_is_a_child_component_is_not_shadowed(): + """A component of its own is never replaced by a solved row.""" + + class Owner: + __solved_parameters__ = ("bulge",) + + def __init__(self, bulge=None): + self.bulge = bulge + + spec = GraphSpec.from_model(af.Model(Owner, bulge=sersic())) + + assert spec.root.rows == () + assert [child.name for child in spec.root.children] == ["bulge"] + assert spec.counts["solved"] == 0 + + +# ---------------------------------------------------------------- solved_paths + + +def test_an_unmatched_solved_path_is_synthesised_on_its_owner(): + model = af.Collection(gaussian=af.Model(af.ex.Gaussian)) + spec = GraphSpec.from_model(model, solved_paths=("gaussian.intensity",)) + + assert [row.name for row in spec.node(("gaussian",)).rows] == [ + "centre", + "normalization", + "sigma", + "intensity", + ] + row = spec.row(("gaussian", "intensity")) + assert row.sampling == "solved" + assert row.provenance.kind == "solved-by-fit" + assert row.in_model_info is False + assert spec.path_index["gaussian/intensity"] == () + assert spec.counts["solved"] == 1 + + +def test_an_unmatched_solved_path_whose_owner_is_not_a_component_lands_on_root(): + """Never silently ignored: the row goes on the root rather than nowhere.""" + model = af.Collection(gaussian=af.Model(af.ex.Gaussian)) + spec = GraphSpec.from_model(model, solved_paths=("nowhere.thing",)) + + (row,) = spec.root.rows + assert (row.name, row.path) == ("thing", ("nowhere", "thing")) + assert row.sampling == "solved" + assert row.provenance.kind == "solved-by-fit" + assert spec.path_index["nowhere/thing"] == () + assert spec.counts["solved"] == 1 + + +def test_a_matched_solved_path_is_still_retagged_in_place(): + model = af.Collection(gaussian=af.Model(af.ex.Gaussian)) + spec = GraphSpec.from_model(model, solved_paths=("gaussian.normalization",)) + + assert [row.name for row in spec.node(("gaussian",)).rows] == [ + "centre", + "normalization", + "sigma", + ] + row = spec.row(("gaussian", "normalization")) + assert row.sampling == "solved" + assert row.in_model_info is False + assert spec.counts["solved"] == 1 + + +def test_solved_paths_and_the_protocol_compose(): + model = af.Collection(bulge=linear_gaussian()) + spec = GraphSpec.from_model(model, solved_paths=("bulge.flux",)) + + assert [row.name for row in spec.node(("bulge",)).rows] == [ + "centre", + "sigma", + "intensity", + "flux", + ] + assert spec.counts["solved"] == 2 + + +# ---------------------------------------------------------------- rule R7 + + +def test_a_float_subclass_model_is_a_row_on_its_owner(): + """ + Rule R7 widened: ``af.Model(Redshift)`` -- a ``float`` subclass -- is the + ordinary ``redshift`` pill on the galaxy that owns it, never a one-pill + child card. The row is named for the attribute; the class name is not shown. + """ + redshift = af.Model(Redshift) + # A custom `__new__(cls, ...)` must not make `cls` a model parameter, or the + # wrapper's own class is overwritten and R7 cannot see the float subclass. + assert redshift.cls is Redshift + redshift.redshift = af.UniformPrior(lower_limit=0.0, upper_limit=3.0) + + model = af.Collection( + galaxies=af.Collection(lens=af.Model(Galaxy, redshift=redshift, bulge=sersic())) + ) + spec = GraphSpec.from_model(model) + lens = spec.node(("galaxies", "lens")) + + assert [child.name for child in lens.children] == ["bulge"] + row = spec.row(("galaxies", "lens", "redshift")) + assert row.sampling == "free" + assert row.prior_cls_name == "UniformPrior" + assert row.in_model_info is True + assert spec.path_index["galaxies/lens/redshift"] == ( + ("galaxies", "lens", "redshift"), + ) + + +def test_a_fixed_float_subclass_model_is_a_fixed_row(): + redshift = af.Model(Redshift) + redshift.redshift = 0.5 + + spec = GraphSpec.from_model(af.Model(Galaxy, redshift=redshift, bulge=sersic())) + row = spec.row(("redshift",)) + + assert (row.sampling, row.value) == ("fixed", 0.5) + assert spec.node(("redshift",)) is None + + +# ---------------------------------------------------------------- collapse + + +def test_a_plate_of_solved_members_collapses_and_says_so(): + """ + Thirty linear Gaussians -- the MGE shape. The solved row is part of every + member's R1 signature, so the plate still forms, and the ``repeats`` line + names the state. + """ + basis = af.Collection(linear_gaussian(0.1 * (index + 1)) for index in range(30)) + spec = GraphSpec.from_model(af.Collection(basis=basis)) + node = spec.node(("basis", "0")) + + assert node.plate is not None + assert node.plate.count == 30 + assert node.plate.representative_key == "0 - 29" + (repeats,) = node.plate.repeats + assert "intensity solved" in repeats + assert spec.counts["plates"] == 1 + # The count is of the *uncollapsed* tree: thirty solved intensities. + assert spec.counts["solved"] == 30 + assert spec.path_index["basis/0/intensity"] == () + + +# ---------------------------------------------------------------- determinism + + +def test_the_spec_is_byte_stable_with_solved_rows(): + def _build(): + af.ModelObject._ids = itertools.count() + af.Prior._ids = itertools.count() + model = af.Collection( + bulge=linear_gaussian(), + point=af.Model(PointSolved), + gaussian=af.Model(af.ex.Gaussian), + ) + return json.dumps( + graph_spec_from( + model, solved_paths=("gaussian.flux", "nowhere.thing") + ).to_dict() + ) + + assert _build() == _build() diff --git a/test_autofit/model_figure/test_presentation.py b/test_autofit/model_figure/test_presentation.py index 1e02d5e0b..ad48a6c3b 100644 --- a/test_autofit/model_figure/test_presentation.py +++ b/test_autofit/model_figure/test_presentation.py @@ -447,3 +447,57 @@ def test_the_footer_defines_its_counts(): assert "6 shared priors (unique variables, not references)" in presentation.footer assert "2 plates standing for 60 components" in presentation.footer assert "totals include every hidden and collapsed element" in presentation.footer + + +# ---------------------------------------------------------------- solved + + +def test_a_solved_pill_says_solved_at_every_detail_level(): + """ + A solved quantity is absent from the model: there is no prior to summarise + and no value to print, so the annotation *is* the pill's whole text -- + including under ``detail="priors"``, where every other pill gains a summary. + """ + from test_autofit.graph_spec.test_solved import linear_gaussian + + model = af.Collection(bulge=linear_gaussian()) + + for detail in ("names", "priors"): + presentation = _presentation(model, detail=detail) + pill = _pill(presentation, "bulge/intensity") + + # `solved` is the dashed state of the vocabulary. + assert pill.state == "solved" + assert pill.text == "intensity · solved" + + +def test_an_unmatched_solved_path_is_drawn_too(): + presentation = _presentation( + af.Collection(gaussian=af.Model(af.ex.Gaussian)), + solved_paths=("gaussian.intensity",), + ) + pill = _pill(presentation, "gaussian/intensity") + + assert (pill.state, pill.text) == ("solved", "intensity · solved") + + +def test_the_solved_legend_and_footer_entries_appear_only_when_a_solved_row_does(): + from test_autofit.graph_spec.test_solved import linear_gaussian + + without = _presentation(af.Model(af.ex.Gaussian)) + assert "solved during fitting" not in without.legend + assert "solved during fitting" not in without.footer + + with_solved = _presentation(af.Collection(bulge=linear_gaussian())) + assert "solved during fitting (dashed)" in with_solved.legend + assert "1 parameter solved during fitting" in with_solved.footer + + +def test_the_footer_counts_every_solved_parameter_a_plate_stands_for(): + from test_autofit.graph_spec.test_solved import linear_gaussian + + basis = af.Collection(linear_gaussian(0.1 * (index + 1)) for index in range(30)) + presentation = _presentation(af.Collection(basis=basis)) + + assert "30 parameters solved during fitting" in presentation.footer + assert "intensity solved" in _card(presentation, "basis/0").note