diff --git a/docs/source/workflows/design_spaces.rst b/docs/source/workflows/design_spaces.rst index 2ad489747..b2bbd1174 100644 --- a/docs/source/workflows/design_spaces.rst +++ b/docs/source/workflows/design_spaces.rst @@ -333,6 +333,11 @@ Labels provide a way to map a string to a set of ingredient names. For example, salt can be labeled as a solute by specifying the mapping ``{"solute": {"salt"}}``. An ingredient may be given multiple labels, and an ingredient will always be given all applicable labels when present in a formulation. +Every ingredient that can appear in a formulation is listed in ``ingredients``. +The optional ``untested_ingredients`` set is a subset of ``ingredients``: it names those ingredients that are absent from the predictor's training data. +Leave ``untested_ingredients`` empty, or omit it, when there are no such novel ingredients. +When you build the design space with :meth:`~citrine.resources.design_space.DesignSpaceCollection.create_default`, it is populated for you. + Constraints restrict the total number or fractional amount of ingredients in formulations sampled from the design space. There are three types of constraint that can be specified as part of a formulation design space: diff --git a/src/citrine/__version__.py b/src/citrine/__version__.py index 72aa75832..0fd7811c0 100644 --- a/src/citrine/__version__.py +++ b/src/citrine/__version__.py @@ -1 +1 @@ -__version__ = "4.1.1" +__version__ = "4.2.0" diff --git a/src/citrine/informatics/design_spaces/formulation_design_space.py b/src/citrine/informatics/design_spaces/formulation_design_space.py index 480b2ed76..f53cbeeee 100644 --- a/src/citrine/informatics/design_spaces/formulation_design_space.py +++ b/src/citrine/informatics/design_spaces/formulation_design_space.py @@ -30,6 +30,10 @@ class FormulationDesignSpace(Resource['FormulationDesignSpace'], DesignSubspace) labels: Mapping[str, set[str]] | None map from a label to each ingredient that should given that label when it's included in a formulation, e.g., ``{'solvent': {'water', 'alcohol'}}`` + untested_ingredients: set[str] | None + names of ingredients to explore that are absent from the predictor's training + data. This should be a subset of ``ingredients`` and will be empty if there are + no novel input materials. resolution: float, optional Minimum increment used to specify ingredient quantities. Default is 0.0001. @@ -42,6 +46,8 @@ class FormulationDesignSpace(Resource['FormulationDesignSpace'], DesignSubspace) properties.String, properties.Set(properties.String) ), 'labels') + untested_ingredients = properties.Optional( + properties.Set(properties.String), 'untested_ingredients') constraints = properties.Set(properties.Object(Constraint), 'constraints') resolution = properties.Float('resolution') @@ -55,6 +61,7 @@ def __init__(self, ingredients: set[str], constraints: set[Constraint], labels: Mapping[str, set[str]] | None = None, + untested_ingredients: set[str] | None = None, resolution: float = 0.0001): self.name: str = name self.description: str = description @@ -62,6 +69,7 @@ def __init__(self, self.ingredients: set[str] = ingredients self.constraints: set[Constraint] = constraints self.labels: Mapping[str, set[str]] | None = labels + self.untested_ingredients: set[str] | None = untested_ingredients self.resolution: float = resolution def __str__(self): diff --git a/tests/conftest.py b/tests/conftest.py index b7523ec56..aa9b40448 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -60,6 +60,7 @@ def valid_product_design_space_data(): formulation_descriptor=FormulationDescriptor.hierarchical().dump(), ingredients=['foo'], labels={'bar': ['foo']}, + untested_ingredients=['qux'], constraints=[], resolution=0.1 ), @@ -70,6 +71,7 @@ def valid_product_design_space_data(): formulation_descriptor=FormulationDescriptor.hierarchical().dump(), ingredients=['baz'], labels={}, + untested_ingredients=None, constraints=[], resolution=0.1 ) @@ -130,6 +132,7 @@ def valid_formulation_design_space_data(): formulation_descriptor=descriptor.dump(), ingredients=['foo'], labels={'bar': ['foo']}, + untested_ingredients=['qux'], constraints=[constraint.dump()], resolution=0.1 ) diff --git a/tests/informatics/test_design_spaces.py b/tests/informatics/test_design_spaces.py index 691f97d3b..9848cc966 100644 --- a/tests/informatics/test_design_spaces.py +++ b/tests/informatics/test_design_spaces.py @@ -35,6 +35,7 @@ def formulation_design_space() -> FormulationDesignSpace: formulation_descriptor=desc, ingredients={"dog", "cat", "bird"}, labels={"canine": {"dog"}, "feline": {"cat"}}, + untested_ingredients={"fish", "hamster"}, constraints={ IngredientCountConstraint(formulation_descriptor=desc, min=1, max=2) } @@ -79,6 +80,29 @@ def material_node_definition(formulation_design_space) -> MaterialNodeDefinition ) +def test_formulation_initialization(formulation_design_space): + """Make sure the correct fields go to the correct places.""" + assert formulation_design_space.name == 'Formulation DS' + assert formulation_design_space.ingredients == {"dog", "cat", "bird"} + assert formulation_design_space.untested_ingredients == {"fish", "hamster"} + # The untested split survives serialization. + assert set(formulation_design_space.dump()["untested_ingredients"]) \ + == {"fish", "hamster"} + + +def test_formulation_untested_ingredients_default(): + """untested_ingredients is optional and defaults to None.""" + desc = FormulationDescriptor.hierarchical() + ds = FormulationDesignSpace( + name="No untested", + description="Does formulations", + formulation_descriptor=desc, + ingredients={"dog"}, + constraints={IngredientCountConstraint(formulation_descriptor=desc, min=1, max=1)} + ) + assert ds.untested_ingredients is None + + def test_product_initialization(product_design_space): """Make sure the correct fields go to the correct places.""" assert product_design_space.name == 'my design space' diff --git a/tests/resources/test_design_space.py b/tests/resources/test_design_space.py index 50ace6aa1..30bb83351 100644 --- a/tests/resources/test_design_space.py +++ b/tests/resources/test_design_space.py @@ -86,6 +86,7 @@ def test_formulation_build(valid_formulation_design_space_data): assert design_space.formulation_descriptor.key == FormulationKey.HIERARCHICAL.value assert design_space.ingredients == {'foo'} assert design_space.labels == {'bar': {'foo'}} + assert design_space.untested_ingredients == {'qux'} assert len(design_space.constraints) == 1 assert design_space.resolution == 0.1 diff --git a/tests/serialization/test_design_spaces.py b/tests/serialization/test_design_spaces.py index 67b64363e..008eceb54 100644 --- a/tests/serialization/test_design_spaces.py +++ b/tests/serialization/test_design_spaces.py @@ -56,6 +56,7 @@ def test_formulation_deserialization(valid_formulation_design_space_data): assert design_space.formulation_descriptor.key == expected_descriptor.key assert design_space.ingredients == {'foo'} assert design_space.labels == {'bar': {'foo'}} + assert design_space.untested_ingredients == {'qux'} assert len(design_space.constraints) == 1 actual_constraint: IngredientCountConstraint = next(iter(design_space.constraints)) assert actual_constraint.formulation_descriptor == expected_descriptor @@ -69,6 +70,18 @@ def test_formulation_serialization(valid_formulation_design_space_data): design_space_serialization_check(valid_formulation_design_space_data, FormulationDesignSpace) +def test_formulation_without_untested_ingredients(valid_formulation_design_space_data): + """A payload lacking untested_ingredients still deserializes (field is optional). + + Pins backward compatibility with design spaces that predate the field: the + field must stay optional, so older payloads without the key don't fail to build. + """ + data = deepcopy(valid_formulation_design_space_data) + del data['untested_ingredients'] + design_space: FormulationDesignSpace = FormulationDesignSpace.build(data) + assert design_space.untested_ingredients is None + + def test_invalid_design_subspace_type(invalid_design_subspace_data): """Ensures we raise proper exception when an invalid type is used.""" with pytest.raises(ValueError):