Repository: linkml/linkml

Issue:
Tutorial part 1: data.yaml does not validate
**Describe the bug**

Tutorial part 1 schema file personinfo.yaml declares all attributes to be strings. 
However, the example data file data.yaml contains a line:
age: 32
that is recognised as a number.

This kind of error at the very beginning of the tutorial can be very discouraging to newbies.

**To reproduce**
Steps to reproduce the behavior:

$ cd linkml/examples/tutorial/tutorial01/ 
$ linkml-validate -s personinfo.yaml data.yaml
✗ 32 is not of type 'string' in $.age

This would be easy to fix by adding single quotes around the integer, but the other alternative would be to change the schema. 
Also, the age in the file data.json is a number.

 - OS: MacOS 13.4. 
 - python version: 3.11.5
 


Discussion:

Patch:
diff --git a/docs/intro/tutorial01.md b/docs/intro/tutorial01.md
index d7401ef4..49191e5a 100644
--- a/docs/intro/tutorial01.md
+++ b/docs/intro/tutorial01.md
@@ -113,11 +113,13 @@ data.yaml:
 ```yaml
 id: ORCID:1234
 full_name: Clark Kent
-age: 32
+age: "32"
 phone: 555-555-5555
-
 ```
 
+note the values of all these fields are strings, even age; we will later return
+to this example and show how we could model this more naturally as a number.
+
 Validate:
 
 ```bash
@@ -133,7 +135,7 @@ bad-data.yaml:
 ```yaml
 id: ORCID:1234
 full_name: Clark Kent
-age: 32
+age: "32"
 phone: 555-555-5555
 made_up_field: hello
 ```
@@ -162,7 +164,7 @@ data.json:
 {
  "id": "ORCID:1234",
  "full_name": "Clark Kent",
- "age": 32,
+ "age": "32",
  "phone": "555-555-5555"
 }
 ```
diff --git a/docs/intro/tutorial02.md b/docs/intro/tutorial02.md
index e021651b..de173f14 100644
--- a/docs/intro/tutorial02.md
+++ b/docs/intro/tutorial02.md
@@ -48,6 +48,7 @@ classes:
       aliases:
       phone:
       age:
+        range: integer
   Container:
     tree_root: true
     attributes:
@@ -57,6 +58,9 @@ classes:
         range: Person
 ```
 
+This time we are modeling age as an integer. We use the construct
+``range`` to state that the values of ``age`` must be integers.
+
 We introduce a class called `Container`. This doesn't necessarily
 reflect a "real world" entity in our domain, it's just a convenient
 holder for our data.
diff --git a/linkml/generators/jsonschemagen.py b/linkml/generators/jsonschemagen.py
index 5043414b..9cf8aabc 100644
--- a/linkml/generators/jsonschemagen.py
+++ b/linkml/generators/jsonschemagen.py
@@ -15,6 +15,7 @@ from linkml_runtime.linkml_model.meta import (
     EnumDefinition,
     PermissibleValue,
     PermissibleValueText,
+    PresenceEnum,
     SlotDefinition,
     metamodel_version,
 )
@@ -275,7 +276,18 @@ class JsonSchemaGenerator(Generator):
         subschema = JsonSchema()
         for slot in cls.slot_conditions.values():
             prop = self.get_subschema_for_slot(slot, omit_type=True)
-            subschema.add_property(self.aliased_slot_name(slot), prop, properties_required)
+            if slot.value_presence:
+                if slot.value_presence == PresenceEnum(PresenceEnum.PRESENT):
+                    this_properties_required = True
+                elif slot.value_presence == PresenceEnum(PresenceEnum.ABSENT):
+                    this_properties_required = False
+                    # make the slot unsatisfiable
+                    prop["enum"] = []
+                else:
+                    this_properties_required = False
+            else:
+                this_properties_required = properties_required
+            subschema.add_property(self.aliased_slot_name(slot), prop, this_properties_required)
 
         if cls.any_of is not None and len(cls.any_of) > 0:
             subschema["anyOf"] = [
@@ -380,6 +392,11 @@ class JsonSchemaGenerator(Generator):
         constraints.add_keyword("maximum", slot.maximum_value)
         constraints.add_keyword("const", slot.equals_string)
         constraints.add_keyword("const", slot.equals_number)
+        if slot.value_presence:
+            if slot.value_presence == PresenceEnum(PresenceEnum.PRESENT):
+                constraints.add_keyword("required", True)
+            elif slot.value_presence == PresenceEnum(PresenceEnum.ABSENT):
+                constraints.add_keyword("enum", [])
         return constraints
 
     def get_subschema_for_slot(self, slot: SlotDefinition, omit_type: bool = False) -> JsonSchema:
diff --git a/linkml/generators/shaclgen.py b/linkml/generators/shaclgen.py
index 656aaf66..b847739c 100644
--- a/linkml/generators/shaclgen.py
+++ b/linkml/generators/shaclgen.py
@@ -1,5 +1,6 @@
 import logging
 import os
+from dataclasses import dataclass, field
 
 import click
 from linkml_runtime.utils.formatutils import underscore
@@ -21,8 +22,12 @@ LINK_ML_TYPES_DATE = URIRef("http://www.w3.org/2001/XMLSchema#date")
 LINK_ML_TYPES_TIME = URIRef("http://www.w3.org/2001/XMLSchema#time")
 
 
+@dataclass
 class ShaclGenerator(Generator):
     # ClassVars
+    closed: bool = field(default_factory=lambda: True)
+    """True means add 'sh:closed=true' to all shapes, except of mixin shapes and shapes, that have parents"""
+
     generatorname = os.path.basename(__file__)
     generatorversion = "0.0.1"
     valid_formats = ["ttl"]
@@ -63,10 +68,13 @@ class ShaclGenerator(Generator):
             class_uri = URIRef(sv.get_uri(c, expand=True))
             shape_pv(RDF.type, SH.NodeShape)
             shape_pv(SH.targetClass, class_uri)  # TODO
-            if c.mixin or c.abstract:
-                shape_pv(SH.closed, Literal(False))
+            if self.closed:
+                if c.mixin or c.abstract:
+                    shape_pv(SH.closed, Literal(False))
+                else:
+                    shape_pv(SH.closed, Literal(True))
             else:
-                shape_pv(SH.closed, Literal(True))
+                shape_pv(SH.closed, Literal(False))
             if c.title is not None:
                 shape_pv(SH.name, Literal(c.title))
             if c.description is not None:
@@ -154,6 +162,12 @@ class ShaclGenerator(Generator):
 
 
 @shared_arguments(ShaclGenerator)
+@click.option(
+    "--closed/--non-closed",
+    default=True,
+    show_default=True,
+    help="Use '--closed' to generate closed SHACL shapes. Use '--non-closed' to generate open SHACL shapes.",
+)
 @click.version_option(__version__, "-V", "--version")
 @click.command()
 def cli(yamlfile, **args):
diff --git a/linkml/transformers/logical_model_transformer.py b/linkml/transformers/logical_model_transformer.py
new file mode 100644
index 00000000..082cd6db
--- /dev/null
+++ b/linkml/transformers/logical_model_transformer.py
@@ -0,0 +1,710 @@
+"""
+A model transformer that transforms a schema into logical form with inheritance remaoved.
+
+Formally, this transformer generates a logical model of the schema in which inheritance
+of slots/attributes is replaced by boolean conjunctions.
+
+For example, given a slot s and two classes C and D, where C is_a D. If both C and D
+place constraints on s (for example, C may refine the range, or make more restricted
+value constraints), then an attribute is generated for C that for which the conjunction
+of constraints in both classes hold.
+
+These logical constraints are then simplified by translating to disjunctive normal form,
+and applying simplification rules.
+"""
+import logging
+from copy import deepcopy
+from dataclasses import dataclass
+from typing import Any, Callable, Dict, Iterator, List, Union
+
+from linkml_runtime.dumpers import json_dumper
+from linkml_runtime.linkml_model import ClassDefinitionName, SchemaDefinition
+from linkml_runtime.linkml_model.meta import (
+    AnonymousSlotExpression,
+    AnonymousTypeExpression,
+    SlotDefinition,
+    SlotDefinitionName,
+    TypeDefinition,
+)
+
+from linkml.transformers.model_transformer import ModelTransformer
+from linkml.utils import logictools
+
+HERITABLE_METASLOT = [
+    "range",
+    "range_expression",
+    "minimum_value",
+    "maximum_value",
+    "pattern",
+    "structured_pattern",
+    "required",
+    "recommended",
+    "inlined",
+    "inlined_as_list",
+    "all_of",
+    "exactly_one_of",
+    "none_of",
+    "any_of",
+    "unit",
+    "value_presence",
+    "equals_string",
+    "equals_string_in",
+    "equals_number",
+    "equals_expression",
+    "has_member",
+    "all_members",
+]
+"""Metamodel slots that can be rolled down or inherited"""
+
+DIRECT_ROLL_DOWN = ["multivalued", "key", "identifier", "designates_type"]
+"""Additional slots that can be rolled down, but only if they are on a SlotDefinition, not a slot expression"""
+
+HERITABLE_TYPE_METASLOT = [
+    "minimum_value",
+    "maximum_value",
+    "pattern",
+    "structured_pattern",
+    "all_of",
+    "exactly_one_of",
+    "none_of",
+    "any_of",
+    "unit",
+    "repr",
+]
+
+MATCHES_PREDICATE = "matches"
+
+logger = logging.getLogger(__name__)
+
+
+class UnsatisfiableAttribute(ValueError):
+    """The attribute specifies constraints that cannot be satisfied"""
+
+    pass
+
+
+@dataclass
+class LogicalModelTransformer(ModelTransformer):
+    """
+    Flatten a schema by rolling down slots and attributes to subclasses.
+
+    The primary application of this is as an intermediate product to be used in generators.
+    For example, when compiling LinkML to frameworks that either lack inheritance (e.g. JSON-Schema),
+    or only partially support inheritance (e.g. single inheritance), this transformer can be used
+    to generate a schema with attributes that are as complete as possible within that framework.
+
+    To demonstrate, consider a simple schema with a class Person inheriting from Thing:
+
+    >>> from linkml.utils.schema_builder import SchemaBuilder
+    >>> sb = SchemaBuilder()
+    >>> _ = sb.add_class("Thing", slots=["id", "name"])
+    >>> _ = sb.add_class("Person", slots={"age": {"range": "integer"}}, is_a="Thing")
+    >>> _ = sb.add_class("Organization", slots={"category": {"range": "OrganizationType"}}, is_a="Thing")
+    >>> _ = sb.add_enum("OrganizationType", ["commercial", "non-profit"])
+    >>> from linkml.transformers import LogicalModelTransformer
+    >>> tr = LogicalModelTransformer()
+    >>> tr.set_schema(sb.schema)
+    >>> flat_schema = tr.transform()
+    >>> from linkml_runtime.dumpers import yaml_dumper
+    >>> print(yaml_dumper.dumps(flat_schema.classes['Person']))
+    name: Person
+    attributes:
+      id:
+        name: id
+      name:
+        name: name
+      age:
+        name: age
+        range: integer
+
+    ...
+
+    Note that the id and name attributes have been rolled down from Thing to Person.
+
+    This transformer will translate range assertions into any_of assertions, where the class
+    hierarchy is rolled up:
+
+    >>> _ = sb.add_class("Container", slots={"entities": {"range": "Thing", "multivalued": True}})
+    >>> tr.set_schema(sb.schema)
+    >>> flat_schema = tr.transform()
+    >>> print(yaml_dumper.dumps(flat_schema.classes['Container']))
+    name: Container
+    attributes:
+      entities:
+        name: entities
+        multivalued: true
+        any_of:
+        - range: Organization
+        - range: Person
+        - range: Thing
+
+    ...
+
+    If you are compiling to a framework that does have inheritance, then you can specify that these are
+    preserved:
+
+    >>> tr_inh = LogicalModelTransformer(preserve_class_mixins=True, preserve_class_is_a=True)
+    >>> tr_inh.set_schema(sb.schema)
+    >>> flat_schema = tr_inh.transform()
+    >>> print(yaml_dumper.dumps(flat_schema.classes['Container']))
+    name: Container
+    from_schema: http://example.org/test-schema
+    attributes:
+      entities:
+        name: entities
+        multivalued: true
+        range: Thing
+
+    ...
+
+    You can also use any_of directly in the schema:
+
+    >>> _ = sb.add_class("Container2", slots={"entities2": {"multivalued": True,
+    ...                                                     "any_of": [{"range": "Person"},
+    ...                                                                {"range": "Organization"}]}})
+    >>> tr.set_schema(sb.schema)
+    >>> flat_schema = tr.transform()
+    >>> print(yaml_dumper.dumps(flat_schema.classes['Container2']))
+    name: Container2
+    attributes:
+      entities2:
+        name: entities2
+        multivalued: true
+        any_of:
+    ...
+
+    Note that defaults are always applied after reasoning. So if you set a default_range to
+    be string, and use inheritance preservation, the unfolded range expression is still
+    correct:
+
+    >>> sb.schema.default_range = "string"
+    >>> tr_inh.set_schema(sb.schema)
+    >>> flat_schema = tr_inh.transform()
+    >>> print(yaml_dumper.dumps(flat_schema.classes['Container2']))
+    name: Container2
+    from_schema: http://example.org/test-schema
+    attributes:
+      entities2:
+        name: entities2
+        multivalued: true
+        any_of:
+        - range: Organization
+        - range: Person
+
+
+    When compiling to a framework that does not support inheritance, such as JSON Schema, the any_of expression
+    can be directly translated to the same construct in JSON-Schema.
+
+    This transformer will also perform reasoning on the schema, to provide the simplest complete form. Let's add
+    some constraints onto the "age" slot and create a subclass of Person with a constrained age range:
+
+    >>> sb.schema.slots["age"].minimum_value = 0
+    >>> sb.schema.slots["age"].maximum_value = 200
+    >>> _ = sb.add_class("MiddleAged", slot_usage={"age": {"minimum_value": 40, "maximum_value": 60}}, is_a="Person")
+    >>> tr.set_schema(sb.schema)
+    >>> flat_schema = tr.transform()
+    >>> print(yaml_dumper.dumps(flat_schema.classes['MiddleAged'].attributes['age']))
+    name: age
+    range: integer
+    minimum_value: 40
+    maximum_value: 60
+
+    The reasoning engine can also detect unsatisfiable constraints:
+
+    >>> from linkml.transformers.logical_model_transformer import UnsatisfiableAttribute
+    >>> _ = sb.add_class("YoungAdult", slot_usage={"age": {"minimum_value": 40, "maximum_value": 10}}, is_a="Person")
+    >>> tr.set_schema(sb.schema)
+    >>> try:
+    ...   flat_schema = tr.transform()
+    ... except UnsatisfiableAttribute as e:
+    ...   print(e)
+    <BLANKLINE>
+    Attribute YoungAdult.age is unsatisfiable
+    <BLANKLINE>
+
+    Let's get rid of the problematic class:
+
+    >>> del sb.schema.classes["YoungAdult"]
+    >>> _ = sb.add_class("Person2", is_a="Person", slot_usage={"age": {"range": "string"}})
+    >>> tr.set_schema(sb.schema)
+    >>> try:
+    ...   flat_schema = tr.transform()
+    ... except UnsatisfiableAttribute as e:
+    ...   print(e)
+    <BLANKLINE>
+    Attribute Person2.age is unsatisfiable
+    <BLANKLINE>
+
+
+
+    """
+
+    preserve_class_is_a: bool = False
+    """
+    If True, preserve the is_a hierarchy in the flattened schema, and assume that entailments
+    will hold in the transformed schema. If False, the is_a hierarchy will be flattened.
+    """
+
+    preserve_class_mixins: bool = False
+    """
+    If True, preserve the mixin hierarchy in the flattened schema, and assume that entailments
+    will hold in the transformed schema. If False, the mixin hierarchy will be flattened.
+    """
+
+    preserve_type_is_a: bool = False
+    """
+    If True, preserve the is_a hierarchy in the flattened schema, and assume that entailments
+    will hold in the transformed schema. If False, the is_a hierarchy will be flattened.
+    """
+
+    tidy_slots: bool = True
+    """
+    If True, remove all standalone slots from transformed schema. These will have been rolled
+    up into class attributes.
+    """
+
+    tidy_inheritance: bool = True
+    """
+    If True, remove all inheritance from transformed schema. These will have been rolled
+    up into class attributes.
+    """
+
+    tidy_default_range: bool = True
+    """
+    If True, remove the fake "Any" class from the transformed schema. This will be implicit based on
+    a lack of range assignment.
+    """
+
+    def transform(self, tgt_schema_name: str = None, simplify=True, **kwargs) -> Any:
+        """
+        Transform the schema to a logical model, translating inheritance to boolean expressions.
+
+        :param tgt_schema_name:
+        :param simplify: If True (default), simplify the schema after transformation
+        :return:
+        """
+        sv = self.schemaview
+        target_schema = deepcopy(self.source_schema)
+        for tn, typ in target_schema.types.items():
+            ancs = sv.type_ancestors(tn, reflexive=False)
+            logging.debug(f"Unrolling type {tn}, merging {len(ancs)}")
+            if ancs:
+                for type_anc in ancs:
+                    self._merge_type_ancestors(target=typ, source=sv.get_type(type_anc))
+        for sn, slot in target_schema.slots.items():
+            ancs = sv.slot_ancestors(sn, reflexive=False)
+            logging.debug(f"Unrolling slot {sn}, merging {len(ancs)}")
+            if ancs:
+                for slot_anc in ancs:
+                    self._merge_slot_ancestors(target=slot, source=target_schema.slots[slot_anc])
+        for cn, cls in target_schema.classes.items():
+            ancs = sv.class_ancestors(
+                cn,
+                reflexive=True,
+                is_a=not self.preserve_class_is_a,
+                mixins=not self.preserve_class_mixins,
+                depth_first=False,
+            )
+            ancs = list(reversed(ancs))
+            logging.debug(f"Unrolling class {cn}, merging {len(ancs)}")
+            self._roll_down(target_schema, cn, ancs)
+        self.apply_defaults(target_schema)
+        if simplify:
+            self.simplify(target_schema)
+        if self.tidy_slots:
+            target_schema.slots = {}
+        if self.tidy_inheritance:
+            for c in target_schema.classes.values():
+                if not self.preserve_class_is_a:
+                    c.is_a = None
+                if not self.preserve_class_mixins:
+                    c.mixins = []
+            if not self.preserve_type_is_a:
+                for t in target_schema.types.values():
+                    t.is_a = None
+        if self.tidy_default_range:
+            target_schema.default_range = None
+            any_proxy_classes = [
+                c for c in target_schema.classes.values() if c.class_uri == "linkml:Any"
+            ]
+            if any_proxy_classes:
+                for c in any_proxy_classes:
+                    del target_schema.classes[c.name]
+        return target_schema
+
+    def _roll_down(
+        self,
+        target_schema: SchemaDefinition,
+        target_class_name: ClassDefinitionName,
+        ancestors: List[ClassDefinitionName],
+    ):
+        anc_classes = [self.schemaview.get_class(anc) for anc in ancestors]
+        attributes: Dict[SlotDefinitionName, SlotDefinition] = {}
+        for anc in anc_classes:
+            top_level_slots = [(s, target_schema.slots[s]) for s in anc.slots]
+            for slot_name, slot_expr in (
+                list(anc.attributes.items()) + list(anc.slot_usage.items()) + top_level_slots
+            ):
+                if slot_name not in attributes:
+                    attributes[slot_name] = SlotDefinition(slot_name)
+                sx = attributes[slot_name]
+                self._merge_slot_ancestors(target=sx, source=slot_expr)
+        tgt_cls = target_schema.classes[target_class_name]
+        for k, v in attributes.items():
+            tgt_cls.attributes[k] = v
+        tgt_cls.slots = []
+        tgt_cls.slot_usage = {}
+
+    def _merge_slot_ancestors(
+        self,
+        target: Union[AnonymousSlotExpression, SlotDefinition],
+        source: Union[SlotDefinition, AnonymousSlotExpression],
+    ):
+        """
+        Generate all_ofs for slot based on ancestors.
+
+        :param target:
+        :param source:
+        :return:
+        """
+        new_slot_dict = {}
+        for p in HERITABLE_METASLOT:
+            pv = getattr(source, p)
+            if pv is not None and pv != [] and pv != {} and pv is not False:
+                new_slot_dict[p] = pv
+        for p in DIRECT_ROLL_DOWN:
+            pv = getattr(source, p, None)
+            if pv is not None and pv != [] and pv != {} and pv is not False:
+                setattr(target, p, pv)
+        sv = self.schemaview
+        if source.range in sv.all_types():
+            typ = sv.get_type(source.range)
+            for p in set(HERITABLE_TYPE_METASLOT).intersection(HERITABLE_METASLOT):
+                pv = getattr(typ, p)
+                if pv is not None and pv != [] and pv != {} and pv is not False:
+                    new_slot_dict[p] = pv
+        if new_slot_dict:
+            target.all_of.append(AnonymousSlotExpression(**new_slot_dict))
+
+    def _merge_type_ancestors(self, target: TypeDefinition, source: TypeDefinition):
+        """
+        Generate all_ofs for type based on ancestors.
+
+        :param target:
+        :param source:
+        :return:
+        """
+        new_slot_dict = {}
+        for p in HERITABLE_TYPE_METASLOT:
+            pv = getattr(source, p)
+            if pv is not None and pv != [] and pv != {} and pv is not False:
+                new_slot_dict[p] = pv
+        if new_slot_dict:
+            target.all_of.append(AnonymousTypeExpression(**new_slot_dict))
+
+    def apply_defaults(self, target_schema: SchemaDefinition):
+        """
+        Apply defaults to the schema
+
+        :param target_schema:
+        :return:
+        """
+        for cls in target_schema.classes.values():
+            for att in cls.attributes.values():
+                self._apply_defaults_to_attribute(att)
+
+    def _apply_defaults_to_attribute(self, att: SlotDefinition):
+        if not att.range:
+            if not any(self._collection_slot_expressions(att, lambda sx: sx.range)):
+                att.range = self.schemaview.schema.default_range
+
+    def _collection_slot_expressions(
+        self, att: Union[SlotDefinition, AnonymousSlotExpression], filter_function: Callable
+    ) -> Iterator[AnonymousSlotExpression]:
+        """
+        Traverse nested slot expressions yielding those that match the filter function.
+
+        :param att:
+        :return:
+        """
+        if filter_function(att):
+            yield att
+        for sx in att.all_of + att.exactly_one_of + att.none_of + att.any_of:
+            yield from self._collection_slot_expressions(sx, filter_function)
+
+    def simplify(self, target_schema: SchemaDefinition):
+        for cls in target_schema.classes.values():
+            for att in cls.attributes.values():
+                x = self._as_logical_expression(att)
+                logger.debug(f"Translating {cls.name}.{att.name} ==> {x}")
+                x = logictools.simplify_full(x)
+                logger.debug(f"Simplified: {x}")
+                if logictools.is_contradiction(x):
+                    raise UnsatisfiableAttribute(
+                        f"Attribute {cls.name}.{att.name} is unsatisfiable"
+                    )
+                self._simplify_member_ofs(x)
+                logger.debug(f"Simplified member of: {x}")
+                simplified_att = self._from_logical_expression(x)
+                for k, v in simplified_att.__dict__.items():
+                    setattr(att, k, v)
+
+    def _simplify_member_ofs(self, expr: logictools.Expression):
+        if isinstance(expr, logictools.Term) and expr.predicate == "in":
+            elements = expr.operands[1]
+            if elements == logictools.UniversalSet():
+                pass
+            else:
+                expr.operands[1] = self._remove_redundant(elements)
+        elif isinstance(expr, logictools.And):
+            [self._simplify_member_ofs(o) for o in expr.operands]
+        elif isinstance(expr, logictools.Or):
+            [self._simplify_member_ofs(o) for o in expr.operands]
+        elif isinstance(expr, logictools.Not):
+            self._simplify_member_ofs(expr.operand)
+
+    def _remove_redundant(self, elements: List[str]) -> List[str]:
+        sv = self.schemaview
+        redundant = set()
+        if not self.preserve_class_mixins:
+            redundant.update([c.name for c in sv.all_classes().values() if c.mixin])
+            if not self.preserve_class_is_a:
+                redundant.update([c.name for c in sv.all_classes().values() if c.abstract])
+        for x in elements:
+            if x in sv.all_classes():
+                redundant.update(
+                    sv.class_descendants(
+                        x,
+                        reflexive=False,
+                        is_a=self.preserve_class_is_a,
+                        mixins=self.preserve_class_mixins,
+                    )
+                )
+            elif x in sv.all_types():
+                redundant.update(self._type_descendants(x, reflexive=False))
+            elif x in sv.all_enums():
+                redundant.update(self._enum_descendants(x, reflexive=False))
+            else:
+                logger.warning(f"Unknown class {x} in {elements}")
+        return [x for x in elements if x not in redundant]
+
+    def _type_descendants(
+        self, type_name: str, imports=True, reflexive=True, depth_first=True
+    ) -> List[str]:
+        # TODO: move this to schemaview
+        sv = self.schemaview
+        from linkml_runtime.utils.schemaview import _closure
+
+        def type_children(type_name, imports=True):
+            elts = [sv.get_type(x) for x in sv.all_types(imports)]
+            return [x.name for x in elts if x.typeof == type_name]
+
+        return _closure(
+            lambda x: type_children(x, imports=imports),
+            type_name,
+            reflexive=reflexive,
+            depth_first=depth_first,
+        )
+
+    def _enum_descendants(
+        self, enum_name: str, imports=True, reflexive=True, depth_first=True
+    ) -> List[str]:
+        # TODO: move this to schemaview
+        sv = self.schemaview
+        from linkml_runtime.utils.schemaview import _closure
+
+        def enum_children(enum_name, imports=True):
+            elts = [sv.get_enum(x) for x in sv.all_enums(imports)]
+            return [x.name for x in elts if x.is_a == enum_name]
+
+        return _closure(
+            lambda x: enum_children(x, imports=imports),
+            enum_name,
+            reflexive=reflexive,
+            depth_first=depth_first,
+        )
+
+    @property
+    def _value_var(self) -> logictools.Variable:
+        return logictools.Variable("value")
+
+    @property
+    def _type_var(self) -> logictools.Variable:
+        return logictools.Variable("type")
+
+    def _as_logical_expression(
+        self, slot_expression: Union[SlotDefinition, AnonymousSlotExpression]
+    ) -> logictools.Expression:
+        sv = self.schemaview
+        exprs = []
+        value_var = self._value_var
+        type_var = self._type_var
+        if slot_expression.minimum_value is not None:
+            exprs.append(value_var >= slot_expression.minimum_value)
+        if slot_expression.maximum_value is not None:
+            exprs.append(value_var <= slot_expression.maximum_value)
+        if slot_expression.pattern is not None:
+            exprs.append(logictools.Term(MATCHES_PREDICATE, value_var, slot_expression.pattern))
+        if slot_expression.range:
+            rn = slot_expression.range
+            if slot_expression.range in sv.all_classes():
+                rn_cls = sv.get_class(rn)
+                if rn_cls.class_uri == "linkml:Any":
+                    elts = logictools.UniversalSet()
+                else:
+                    elts = sv.class_descendants(rn, reflexive=True)
+            elif slot_expression.range in sv.all_types():
+                elts = self._type_descendants(rn, reflexive=True)
+            else:
+                elts = [rn]
+            exprs.append(logictools.IsIn(type_var, elts))
+        if slot_expression.all_of:
+            exprs.append(
+                logictools.And(
+                    *[self._as_logical_expression(subx) for subx in slot_expression.all_of]
+                )
+            )
+        if slot_expression.exactly_one_of:
+            # TODO: disjointness
+            exprs.append(
+                logictools.Or(
+                    *[self._as_logical_expression(subx) for subx in slot_expression.exactly_one_of]
+                )
+            )
+        if slot_expression.none_of:
+            exprs.append(
+                logictools.Not(
+                    logictools.Or(
+                        *[self._as_logical_expression(subx) for subx in slot_expression.none_of]
+                    )
+                )
+            )
+        if slot_expression.any_of:
+            exprs.append(
+                logictools.Or(
+                    *[self._as_logical_expression(subx) for subx in slot_expression.any_of]
+                )
+            )
+        for p in HERITABLE_METASLOT:
+            if p in [
+                "range",
+                "pattern",
+                "minimum_value",
+                "maximum_value",
+                "all_of",
+                "exactly_one_of",
+                "none_of",
+                "any_of",
+            ]:
+                continue
+            v = getattr(slot_expression, p, None)
+            if v is not None and v != [] and v != {}:
+                exprs.append(logictools.Eq(p, v))
+        return logictools.And(*exprs)
+
+    def _from_logical_expression(self, expr: logictools.Expression) -> AnonymousSlotExpression:
+        if isinstance(expr, logictools.And):
+            all_of = [self._from_logical_expression(e) for e in expr.operands]
+            if len(all_of) == 1:
+                return all_of[0]
+            x = json_dumper.to_dict(all_of[0])
+            remaining_all_of = []
+            for y in all_of[1:]:
+                y = json_dumper.to_dict(y)
+                if set(x.keys()).intersection(set(y.keys())):
+                    remaining_all_of.append(AnonymousSlotExpression(**y))
+                else:
+                    x.update(y)
+            ase = AnonymousSlotExpression(**x)
+            ase.all_of.extend(remaining_all_of)
+            return ase
+        elif isinstance(expr, logictools.Or):
+            return AnonymousSlotExpression(
+                any_of=[self._from_logical_expression(e) for e in expr.operands]
+            )
+        elif isinstance(expr, logictools.Not):
+            return AnonymousSlotExpression(none_of=[self._from_logical_expression(expr.operand)])
+        elif isinstance(expr, logictools.Eq):
+            var = expr.lhs
+            val = expr.rhs
+            return AnonymousSlotExpression(**{var: val})
+        # elif isinstance(expr, logictools.IsIn):
+        #    return AnonymousSlotExpression(range=expr.operands[1])
+        elif isinstance(expr, logictools.Term):
+            var = expr.operands[0]
+            val = expr.operands[1]
+            if var == self._value_var:
+                if expr.predicate == MATCHES_PREDICATE:
+                    return AnonymousSlotExpression(pattern=val)
+                elif expr.predicate == ">=":
+                    return AnonymousSlotExpression(minimum_value=val)
+                elif expr.predicate == ">":
+                    return AnonymousSlotExpression(minimum_value=val + 1)
+                elif expr.predicate == "<=":
+                    return AnonymousSlotExpression(maximum_value=val)
+                elif expr.predicate == "<":
+                    return AnonymousSlotExpression(maximum_value=val - 1)
+                else:
+                    raise ValueError(f"Unknown predicate {expr.predicate} for {var}")
+            elif var == self._type_var:
+                if expr.predicate == "in":
+                    if val == logictools.UniversalSet():
+                        return AnonymousSlotExpression(range="Any")
+                    elif len(val) == 1:
+                        return AnonymousSlotExpression(range=val[0])
+                    else:
+                        return AnonymousSlotExpression(
+                            any_of=[AnonymousSlotExpression(range=v) for v in sorted(val)]
+                        )
+                else:
+                    raise ValueError(f"Unknown predicate {expr.predicate} for {var}")
+            else:
+                raise ValueError(f"Unknown variable {var}")
+        return AnonymousSlotExpression()
+
+    def attribute_as_python_field(self, attribute: SlotDefinition) -> str:
+        """
+        Return a Python field declaration for the given attribute.
+
+        Note: this method may move to pydanticgen, it is currently here for demonstration purposes.
+
+        :param attribute:
+        :return:
+        """
+        return f"{attribute.name}: {self._att_as_python_expression(attribute)}"
+
+    def _att_as_python_expression(
+        self,
+        attribute: Union[SlotDefinition, AnonymousSlotExpression],
+        root_slot: SlotDefinition = None,
+        stack: List = None,
+    ) -> str:
+        # Note: in future versions of the metamodel, multivalued may move to the expression
+        if stack is None:
+            stack = [attribute]
+        if root_slot is None:
+            if isinstance(attribute, SlotDefinition):
+                root_slot = attribute
+            else:
+                raise ValueError(f"root_slot must be provided for {attribute}")
+        multivalued = root_slot.multivalued
+        s = ""
+        if attribute.any_of:
+            union_terms = [
+                self._att_as_python_expression(x, root_slot, stack + [x]) for x in attribute.any_of
+            ]
+            s = f"Union[{', '.join(union_terms)}]"
+        else:
+            if attribute.range:
+                s = attribute.range
+            else:
+                s = "Any"
+            if not attribute.required:
+                s = f"Optional[{s}]"
+            if multivalued:
+                if attribute.inlined and not attribute.inlined_as_list:
+                    s = f'Dict["str", {s}]'
+                else:
+                    # TODO: ref by key
+                    s = f"Collection[{s}]"
+        return s
diff --git a/linkml/transformers/model_transformer.py b/linkml/transformers/model_transformer.py
new file mode 100644
index 00000000..789bc2e0
--- /dev/null
+++ b/linkml/transformers/model_transformer.py
@@ -0,0 +1,26 @@
+from abc import abstractmethod
+from dataclasses import dataclass
+from typing import Any
+
+from linkml_runtime import SchemaView
+from linkml_runtime.linkml_model import ClassDefinitionName, SchemaDefinition
+
+
+@dataclass
+class ModelTransformer:
+    """
+    Base class for all model transformers
+    """
+
+    schemaview: SchemaView = None
+
+    def set_schema(self, schema: SchemaDefinition) -> None:
+        self.schemaview = SchemaView(schema)
+
+    @property
+    def source_schema(self):
+        return self.schemaview.schema
+
+    @abstractmethod
+    def transform(self, tgt_schema_name: str = None, top_class: ClassDefinitionName = None) -> Any:
+        raise NotImplementedError
diff --git a/linkml/utils/logictools.py b/linkml/utils/logictools.py
new file mode 100644
index 00000000..07c4a189
--- /dev/null
+++ b/linkml/utils/logictools.py
@@ -0,0 +1,642 @@
+import operator
+from copy import deepcopy
+from itertools import product
+from typing import Any, Collection, List, Optional, Tuple
+
+
+def member_of(item: Any, collection: Collection[Any]) -> bool:
+    return item in collection
+
+
+OPS = {
+    operator.__lt__: "<",
+    operator.__le__: "<=",
+    operator.__eq__: "==",
+    operator.__ne__: "!=",
+    operator.__gt__: ">",
+    operator.__ge__: ">=",
+    operator.__and__: "&",
+    operator.__or__: "|",
+    operator.__invert__: "~",
+    operator.__mod__: "%",
+    # operator.__contains__: 'in',
+    member_of: "in",
+}
+
+
+class Expression:
+    def __eq__(self, other: "Expression"):
+        if isinstance(other, Expression):
+            return self._ordered_str() == other._ordered_str()
+        else:
+            return False
+
+    def __and__(self, other: "Expression"):
+        return And(self, other)
+
+    def __or__(self, other: "Expression"):
+        return Or(self, other)
+
+    def __invert__(self):
+        return Not(self)
+
+    def __lt__(self, other: Any):
+        return Term("<", self, other)
+
+    def __le__(self, other: Any):
+        return Term("<=", self, other)
+
+    def __ne__(self, other: Any):
+        return Term("!=", self, other)
+
+    def __gt__(self, other: Any):
+        return Term(">", self, other)
+
+    def __ge__(self, other: Any):
+        return Term(">=", self, other)
+
+    def __mod__(self, other: Any):
+        return Term("%", self, other)
+
+    def __contains__(self, other: Any):
+        return Term("contains", self, other)
+
+    def _ordered_str(self, **kwargs):
+        return str(self)
+
+    def __repr__(self):
+        return self._ordered_str()
+
+
+class Variable(Expression):
+    def __init__(self, name: str):
+        self.name = name
+
+    def __str__(self):
+        return str(self.name)
+
+    def _ordered_str(self, **kwargs):
+        return str(self.name)
+
+
+class Top(Expression):
+    def __str__(self):
+        return "T"
+
+    def _ordered_str(self, **kwargs):
+        return str(self)
+
+
+class Bottom(Expression):
+    def __str__(self):
+        return "F"
+
+    def _ordered_str(self, **kwargs):
+        return str(self)
+
+
+class Not(Expression):
+    def __init__(self, operand: Expression):
+        self.operand = operand
+
+    def __str__(self):
+        return f"~{self.operand})"
+
+    def _ordered_str(self, **kwargs):
+        return str(self)
+
+
+class And(Expression):
+    def __init__(self, *operands: Expression):
+        self.operands: List[Expression] = list(operands)
+
+    def __str__(self):
+        return f'({" & ".join(str(operand) for operand in self.operands)})'
+
+    def _ordered_str(self, **kwargs):
+        sorted_operands = sorted([op._ordered_str(**kwargs) for op in self.operands], key=str)
+        return f'({" & ".join(sorted_operands)})'
+
+
+class Or(Expression):
+    def __init__(self, *operands: Expression):
+        self.operands = list(operands)
+
+    def __str__(self):
+        return f'({" | ".join(str(operand) for operand in self.operands)})'
+
+    def _ordered_str(self, pairwise=False):
+        if pairwise and len(self.operands) > 2:
+            return Or(Or(*self.operands[0:2]), Or(*self.operands[2:]))._ordered_str(pairwise=True)
+        sorted_operands = sorted([op._ordered_str(pairwise=True) for op in self.operands], key=str)
+        return f'({" | ".join(sorted_operands)})'
+
+
+class Eq(Expression):
+    def __init__(self, lhs: Expression, rhs: Expression):
+        self.lhs = lhs
+        self.rhs = rhs
+
+    def __str__(self):
+        return f"{str(self.lhs)} = {str(self.rhs)}"
+
+    def _ordered_str(self, **kwargs):
+        return str(self)
+
+
+class Term(Expression):
+    def __init__(self, predicate: str, *operands: Any):
+        self.predicate = predicate
+        self.operands = list(operands)
+
+    def __str__(self):
+        if self.predicate in OPS:
+            return f"{str(self.operands[0])} {OPS[self.predicate]} {str(self.operands[1])}"
+        else:
+            return f'{self.predicate}({", ".join(str(operand) for operand in self.operands)})'
+
+    def _ordered_str(self, **kwargs):
+        return str(self)
+
+
+class IsIn(Term):
+    def __init__(self, element: Expression, collection: List[Any]):
+        self.predicate = "in"
+        self.operands = [element, collection]
+
+
+class Value(Expression):
+    # DEPRECATED
+    def __init__(self, operand: Any):
+        self.operand = operand
+
+    def __str__(self):
+        return str(self.operand)
+
+    def _ordered_str(self, **kwargs):
+        return str(self)
+
+
+class Implies(Expression):
+    def __init__(self, left: Expression, right: Expression):
+        self.left = left
+        self.right = right
+
+    def __str__(self):
+        return f"({self.left} => {self.right})"
+
+    def _ordered_str(self, **kwargs):
+        return f"({self.left._ordered_str(**kwargs)} => {self.right._ordered_str(**kwargs)})"
+
+
+class Iff(Expression):
+    def __init__(self, left: Expression, right: Expression):
+        self.left = left
+        self.right = right
+
+    def __str__(self):
+        return f"({self.left} => {self.right})"
+
+    def _ordered_str(self, **kwargs):
+        return f"({self.left._ordered_str(**kwargs)} iff {self.right._ordered_str(**kwargs)})"
+
+
+class UniversalSet(Expression):
+    def __str__(self):
+        return "__U__"
+
+    def _ordered_str(self, **kwargs):
+        return str(self)
+
+
+# DNF conversion functions
+
+
+def eliminate_implications(expr: Expression) -> Expression:
+    """
+    Eliminate implications and iffs from an expression.
+
+    - ``P => Q is equivalent to ~P | Q``
+    - ``P iff Q is equivalent to (~P | Q) & (P | ~Q)``
+
+    Also recurse through sub-expressions
+
+    :param expr:
+    :return:
+    """
+    if isinstance(expr, Implies):
+        # P => Q is equivalent to ~P | Q
+        return Or(Not(expr.left), expr.right)
+    elif isinstance(expr, Iff):
+        # P iff Q is equivalent to (~P | Q) & (P | ~Q)
+        return And(Or(Not(expr.left), expr.right), Or(expr.left, Not(expr.right)))
+    elif isinstance(expr, And) or isinstance(expr, Or):
+        # Recursively eliminate implications from Ands:
+        # Tr(P1 & P2 & ... & Pn) is equivalent to Tr(P1) & Tr(P2) & ... & Tr(Pn)
+        expr.operands = list(map(eliminate_implications, expr.operands))
+    elif isinstance(expr, Not) and isinstance(expr.operand, (And, Or, Implies, Iff)):
+        # Recursively eliminate implications from Nots:
+        # Tr(~(P1)) is equivalent to ~Tr(P1)
+        expr.operand = eliminate_implications(expr.operand)
+    return expr
+
+
+def move_not_inwards(expr: Expression) -> Expression:
+    """
+    Move negations inwards in an expression.
+
+    :param expr:
+    :return:
+    """
+    if isinstance(expr, Not) and isinstance(expr.operand, Not):
+        return expr.operand.operand
+    elif isinstance(expr, Not) and isinstance(expr.operand, And):
+        return Or(*[Not(operand) for operand in expr.operand.operands])
+    elif isinstance(expr, Not) and isinstance(expr.operand, Or):
+        return And(*[Not(operand) for operand in expr.operand.operands])
+    elif isinstance(expr, And) or isinstance(expr, Or):
+        expr.operands = list(map(move_not_inwards, expr.operands))
+    elif isinstance(expr, Not) and isinstance(expr.operand, (And, Or)):
+        expr.operand = move_not_inwards(expr.operand)
+    return expr
+
+
+def distribute_and_over_or(expr: Expression) -> Expression:
+    """
+    Distributes AND over OR in the given expression according to the distributive laws of Boolean Algebra.
+
+    The function works recursively, transforming each AND operation in the expression.
+
+    Args:
+        expr (Expression): The input expression which is an instance of Expression or its subclasses.
+
+    Returns:
+        Expression: The transformed expression with AND distributed over OR.
+    """
+    if isinstance(expr, And):
+        or_exprs = [operand for operand in expr.operands if isinstance(operand, Or)]
+        other_exprs = [operand for operand in expr.operands if not isinstance(operand, Or)]
+        if or_exprs:
+            new_operands = []
+            for combination in product(*[or_expr.operands for or_expr in or_exprs]):
+                new_operands.append(And(*(other_exprs + list(combination))))
+            return Or(*new_operands)
+        expr.operands = list(map(distribute_and_over_or, expr.operands))
+        # conj over conj
+        and_exprs = [operand for operand in expr.operands if isinstance(operand, And)]
+        if and_exprs:
+            new_operands = []
+            for and_expr in and_exprs:
+                new_operands.extend(and_expr.operands)
+            new_operands.extend(
+                [operand for operand in expr.operands if not isinstance(operand, And)]
+            )
+            return And(*new_operands)
+    elif isinstance(expr, Or):
+        expr.operands = list(map(distribute_and_over_or, expr.operands))
+        # disj over disj
+        or_exprs = [operand for operand in expr.operands if isinstance(operand, Or)]
+        if or_exprs:
+            new_operands = []
+            for or_expr in or_exprs:
+                new_operands.extend(or_expr.operands)
+            new_operands.extend(
+                [operand for operand in expr.operands if not isinstance(operand, Or)]
+            )
+            return Or(*new_operands)
+    elif isinstance(expr, Not):
+        expr.operand = distribute_and_over_or(expr.operand)
+    return expr
+
+
+def is_contradiction(expr: Expression, apply_dnf=False) -> Optional[bool]:
+    if apply_dnf:
+        expr = to_dnf(expr)
+        expr = simplify(expr)
+    if isinstance(expr, And):
+        for i, op_i in enumerate(expr.operands):
+            if isinstance(op_i, Not):
+                for j, op_j in enumerate(expr.operands):
+                    if op_i != op_j:
+                        if op_i.operand == op_j:
+                            return True
+        opx = [is_contradiction(operand) for operand in expr.operands]
+        if any(opx):
+            return True
+        elif any([x is None for x in opx]):
+            return None
+        else:
+            return False
+        # return any([is_contradiction(operand) for operand in expr.operands])
+    elif isinstance(expr, Not):
+        negated_expr = is_contradiction(expr.operand)
+        if negated_expr is None:
+            return None
+        else:
+            return not negated_expr
+    elif isinstance(expr, Or):
+        opx = [is_contradiction(operand) for operand in expr.operands]
+        if all(opx):
+            return True
+        elif any([x is False for x in opx]):
+            return False
+        else:
+            return None
+    if expr == Bottom():
+        return True
+    elif expr == Top():
+        return False
+    elif isinstance(expr, Term) and expr.predicate == "in":
+        if expr.operands[1] == UniversalSet():
+            return False
+        if expr.operands[1] == []:
+            return True
+    return None
+
+
+def evals_to_true(expr: Expression, apply_dnf=False) -> Optional[bool]:
+    """
+    Two-valued logic
+
+    :param expr:
+    :param apply_dnf:
+    :return:
+    """
+    if apply_dnf:
+        expr = to_dnf(expr)
+    if isinstance(expr, And):
+        return all([evals_to_true(operand) for operand in expr.operands])
+    elif isinstance(expr, Not):
+        if is_contradiction(expr.operand) is False:
+            return True
+        else:
+            return False
+    elif isinstance(expr, Or):
+        return any([evals_to_true(operand) for operand in expr.operands])
+    elif expr == Top():
+        return True
+    elif isinstance(expr, IsIn):
+        if expr.operands[1] == UniversalSet():
+            return True
+    else:
+        return False
+
+
+def is_tautology(expr):
+    """
+    Checks if the given propositional logic formula is a tautology.
+
+    A formula is a tautology if it is true under every possible valuation of its variables.
+
+    Args:
+        expr (Expression): The input formula which is an instance of Expression or its subclasses.
+
+    Returns:
+        bool: True if the formula is a tautology, False otherwise.
+    """
+    if isinstance(expr, Or):
+        for i in range(len(expr.operands)):
+            for j in range(i + 1, len(expr.operands)):
+                if (
+                    isinstance(expr.operands[i], Not)
+                    and expr.operands[i].operand == expr.operands[j]
+                ):
+                    return True
+                elif (
+                    isinstance(expr.operands[j], Not)
+                    and expr.operands[j].operand == expr.operands[i]
+                ):
+                    return True
+    elif isinstance(expr, And):
+        for operand in expr.operands:
+            if is_tautology(operand):
+                return True
+    return False
+
+
+def eliminate_redundant(expr: Expression) -> Expression:
+    if isinstance(expr, And):
+        visited = set()
+        new_operands = []
+        for i, operand in enumerate(expr.operands):
+            # if isinstance(operand, Top):
+            #    continue
+            operand = eliminate_redundant(operand)
+            operand_str = operand._ordered_str()
+            if operand_str not in visited:
+                visited.add(operand_str)
+                new_operands.append(operand)
+        expr.operands = new_operands
+    elif isinstance(expr, Or):
+        visited = set()
+        new_operands = []
+        for i, operand in enumerate(expr.operands):
+            if isinstance(operand, Bottom):
+                continue
+            operand = eliminate_redundant(operand)
+            operand_str = operand._ordered_str()
+            if operand_str not in visited:
+                visited.add(operand_str)
+                new_operands.append(operand)
+        expr.operands = new_operands
+    return expr
+
+
+def simplify_full(expr: Expression) -> Expression:
+    simplified = simplify(expr)
+    if str(simplified) != str(expr):
+        return simplify_full(simplified)
+    else:
+        return simplified
+
+
+def simplify(expr: Expression) -> Expression:
+    """
+    Simplifies the given propositional logic formula.
+
+    :param expr:
+    :return:
+    """
+    expr = to_dnf(expr)
+    if expr == ~Bottom():
+        return Top()
+    if expr == ~Top():
+        return Bottom()
+    if is_contradiction(expr, apply_dnf=False):
+        return Bottom()
+    if evals_to_true(expr):
+        return Top()
+    if isinstance(expr, (And, Or)):
+        modified = True
+        while modified:
+            modified = False
+            modifications = []
+            for i, op_i in enumerate(expr.operands):
+                for j, op_j in enumerate(expr.operands):
+                    if i < j:
+                        for composition in COMPOSITIONS[type(expr)]:
+                            new_expr = composition(op_i, op_j)
+                            if new_expr is not None:
+                                modifications.append((i, j, new_expr))
+                            else:
+                                new_expr = composition(op_j, op_i)
+                                if new_expr is not None:
+                                    modifications.append((j, i, new_expr))
+                                else:
+                                    if (
+                                        isinstance(op_i, Term)
+                                        and isinstance(op_j, Term)
+                                        and op_i.operands[0] == op_j.operands[0]
+                                    ):
+                                        new_tpl = compose_operators(
+                                            type(expr),
+                                            op_i.predicate,
+                                            op_i.operands[1],
+                                            op_j.predicate,
+                                            op_j.operands[1],
+                                        )
+                                        if new_tpl is not None:
+                                            new_op, new_arg = new_tpl
+                                            new_expr = Term(OPS[new_op], op_i.operands[0], new_arg)
+                                            modifications.append((i, j, new_expr))
+            if modifications:
+                modified = True
+                for i, j, new_expr in modifications:
+                    expr.operands[i] = new_expr
+                    expr.operands[j] = None
+                expr.operands = [op for op in expr.operands if op is not None]
+        if len(expr.operands) == 1:
+            return simplify(expr.operands[0])
+        else:
+            return type(expr)(*list(map(simplify, expr.operands)))
+    elif isinstance(expr, Not):
+        return Not(simplify(expr.operand))
+    else:
+        return expr
+
+
+COMPOSITIONS = {
+    And: [
+        (lambda x, y: Bottom() if x == ~y else None),
+        (lambda x, _: Bottom() if x == Bottom() else None),
+        (lambda x, y: Bottom() if _unsat(x, y) else None),
+        (lambda x, y: x if x == y else None),
+    ],
+    Or: [
+        lambda x, y: Top() if x == ~y else None,
+        lambda x, _: Top() if x == Top() else None,
+        (lambda x, y: x if x == y else None),
+    ],
+}
+
+
+def _unsat(x: Expression, y: Expression) -> bool:
+    if isinstance(x, Term) and isinstance(y, Term):
+        if x.operands[0] == y.operands[0]:
+            xp = x.predicate
+            yp = y.predicate
+            xv = x.operands[1]
+            yv = y.operands[1]
+            if xp == "<" and yp == ">":
+                if xv <= yv:
+                    return True
+            elif xp == "<" and yp == ">=":
+                if xv < yv:
+                    return True
+            elif xp == "<=" and yp == ">":
+                if xv < yv:
+                    return True
+            elif xp == "<=" and yp == ">=":
+                if xv < yv:
+                    return True
+            elif xp == "in" and yp == "in":
+                if xv == UniversalSet() or yv == UniversalSet():
+                    return False
+                if not set(xv).intersection(set(yv)):
+                    return True
+    return False
+
+
+def compose_operators(boolean_op, op1, v1, op2, v2) -> Optional[Tuple]:
+    rev_op_map = {v: k for k, v in OPS.items()}
+    if not (op1 in rev_op_map and op2 in rev_op_map):
+        return None
+    op1 = rev_op_map[op1]
+    op2 = rev_op_map[op2]
+    if boolean_op == And:
+        if op1 == operator.__lt__ and op2 == operator.__lt__:
+            return operator.__lt__, min(v1, v2)
+        elif op1 == operator.__le__ and op2 == operator.__le__:
+            return operator.__le__, min(v1, v2)
+        elif op1 == operator.__lt__ and op2 == operator.__le__:
+            if v1 <= v2:
+                return operator.__lt__, v1
+            else:
+                return operator.__le__, v2
+        elif op1 == operator.__le__ and op2 == operator.__lt__:
+            if v2 <= v1:
+                return operator.__lt__, v2
+            else:
+                return operator.__le__, v1
+        elif op1 == operator.__gt__ and op2 == operator.__gt__:
+            return operator.__gt__, max(v1, v2)
+        elif op1 == operator.__ge__ and op2 == operator.__ge__:
+            return operator.__ge__, max(v1, v2)
+        elif op1 == operator.__gt__ and op2 == operator.__ge__:
+            if v1 >= v2:
+                return operator.__gt__, v1
+            else:
+                return operator.__ge__, v2
+        elif op1 == operator.__ge__ and op2 == operator.__gt__:
+            if v2 >= v1:
+                return operator.__gt__, v2
+            else:
+                return operator.__ge__, v1
+        elif op1 == member_of and op2 == member_of:
+            if v1 == UniversalSet():
+                return member_of, v2
+            elif v2 == UniversalSet():
+                return member_of, v1
+            else:
+                return member_of, list(set(v1).intersection(v2))
+    elif boolean_op == Or:
+        if op1 == operator.__lt__ and op2 == operator.__lt__:
+            return operator.__lt__, max(v1, v2)
+        elif op1 == operator.__le__ and op2 == operator.__le__:
+            return operator.__le__, max(v1, v2)
+        elif op1 == operator.__lt__ and op2 == operator.__le__:
+            if v1 <= v2:
+                return operator.__le__, v2
+            else:
+                return operator.__lt__, v1
+        elif op1 == operator.__le__ and op2 == operator.__lt__:
+            if v2 <= v1:
+                return operator.__le__, v1
+            else:
+                return operator.__lt__, v2
+        elif op1 == operator.__gt__ and op2 == operator.__gt__:
+            return operator.__gt__, min(v1, v2)
+        elif op1 == operator.__ge__ and op2 == operator.__ge__:
+            return operator.__ge__, min(v1, v2)
+        elif op1 == operator.__gt__ and op2 == operator.__ge__:
+            if v1 >= v2:
+                return operator.__ge__, v2
+            else:
+                return operator.__gt__, v1
+        elif op1 == operator.__ge__ and op2 == operator.__gt__:
+            if v2 >= v1:
+                return operator.__ge__, v1
+            else:
+                return operator.__gt__, v2
+        elif op1 == member_of and op2 == member_of:
+            if v1 == UniversalSet() or v2 == UniversalSet():
+                return member_of, UniversalSet()
+            return member_of, list(set(v1).union(v2))
+
+
+def to_dnf(expr: Expression) -> Expression:
+    expr = deepcopy(expr)
+    expr = eliminate_implications(expr)
+    expr = move_not_inwards(expr)
+    expr = distribute_and_over_or(expr)
+    return expr
diff --git a/linkml/utils/schema_builder.py b/linkml/utils/schema_builder.py
index 3beea349..38f933c2 100644
--- a/linkml/utils/schema_builder.py
+++ b/linkml/utils/schema_builder.py
@@ -1,5 +1,5 @@
 from dataclasses import dataclass
-from typing import Dict, List, Optional, Union
+from typing import Any, Dict, List, Optional, Union
 
 from linkml_runtime.linkml_model import (
     ClassDefinition,
@@ -60,8 +60,8 @@ class SchemaBuilder:
     def add_class(
         self,
         cls: Union[ClassDefinition, Dict, str],
-        slots: List[Union[str, SlotDefinition]] = None,
-        slot_usage: Dict[str, SlotDefinition] = None,
+        slots: Union[Dict, List[Union[str, SlotDefinition]]] = None,
+        slot_usage: Union[Dict[str, SlotDefinition], Dict[str, Any], List[SlotDefinition]] = None,
         replace_if_present=False,
         use_attributes=False,
         **kwargs,
@@ -97,6 +97,10 @@ class SchemaBuilder:
                     raise ValueError("If use_attributes=True then slots must be SlotDefinitions")
         else:
             if slots is not None:
+                if isinstance(slots, dict):
+                    for k, v in slots.items():
+                        cls.slots.append(k)
+                        self.add_slot(SlotDefinition(k, **v), replace_if_present=replace_if_present)
                 for s in slots:
                     cls.slots.append(s.name if isinstance(s, SlotDefinition) else s)
                     if isinstance(s, str) and s in self.schema.slots:
@@ -104,8 +108,18 @@ class SchemaBuilder:
                         continue
                     self.add_slot(s, replace_if_present=replace_if_present)
             if slot_usage:
-                for k, v in slot_usage.items():
-                    cls.slot_usage[k] = v
+                if isinstance(slot_usage, dict):
+                    for k, v in slot_usage.items():
+                        if isinstance(v, dict):
+                            v = SlotDefinition(k, **v)
+                        cls.slot_usage[k] = v
+                elif isinstance(slot_usage, list):
+                    for s in slot_usage:
+                        cls.slot_usage[s.name] = s
+                else:
+                    raise ValueError(
+                        f"slot_usage {slot_usage} must be a dict or list of SlotDefinitions"
+                    )
         for k, v in kwargs.items():
             setattr(cls, k, v)
         return self


Test patch:
diff --git a/tests/test_compliance/test_boolean_slot_compliance.py b/tests/test_compliance/test_boolean_slot_compliance.py
index a5e2419a..8dc57cf6 100644
--- a/tests/test_compliance/test_boolean_slot_compliance.py
+++ b/tests/test_compliance/test_boolean_slot_compliance.py
@@ -315,6 +315,71 @@ def test_none_of(framework, data_name, value, is_valid):
     )
 
 
+@pytest.mark.parametrize(
+    "data_name,instance,is_valid",
+    [
+        ("v0", {}, False),
+        ("v1", {SLOT_S1: "x"}, True),
+        ("v2", {SLOT_S2: "x"}, True),
+        ("v3", {SLOT_S1: "x", SLOT_S2: "x"}, False),
+    ],
+)
+@pytest.mark.parametrize("framework", CORE_FRAMEWORKS)
+@pytest.mark.skip("requires metamodel changes")
+def test_cardinality_in_exactly_one_of(framework, data_name, instance, is_valid):
+    """
+    Tests intersection of cardinality and exactly_one_of.
+
+    TODO: unskip this test when metamodel allows attributes or slot usage on anon expressions.
+
+    :param framework:
+    :param data_name:
+    :param instance:
+    :param is_valid:
+    :return:
+    """
+    classes = {
+        CLASS_C: {
+            "exactly_one_of": [
+                {
+                    "attributes": {
+                        SLOT_S1: {
+                            "required": True,
+                        },
+                    },
+                },
+                {
+                    "attributes": {
+                        SLOT_S2: {
+                            "required": True,
+                        },
+                    },
+                },
+            ],
+        },
+    }
+    schema = validated_schema(
+        test_cardinality_in_exactly_one_of,
+        "default",
+        framework,
+        classes=classes,
+        core_elements=["exactly_one_of", "minimum_value", "maximum_value"],
+    )
+    expected_behavior = ValidationBehavior.IMPLEMENTS
+    if framework != JSON_SCHEMA:
+        expected_behavior = ValidationBehavior.INCOMPLETE
+    check_data(
+        schema,
+        data_name,
+        framework,
+        instance,
+        is_valid,
+        target_class=CLASS_C,
+        expected_behavior=expected_behavior,
+        description=f"validity {is_valid} check for value {instance}",
+    )
+
+
 @pytest.mark.parametrize(
     "value",
     [1, 10, 15, 20, 21],
@@ -542,3 +607,99 @@ def test_union_of(framework, data_name, value, is_valid):
         expected_behavior=expected_behavior,
         description=f"validity {is_valid} check for value {value}",
     )
+
+
+@pytest.mark.parametrize(
+    "data_name,instance,is_valid",
+    [
+        ("v0", {}, False),
+        ("v1", {SLOT_S1: "x"}, True),
+        ("v2", {SLOT_S2: "x"}, True),
+        ("v3", {SLOT_S1: "x", SLOT_S2: "x"}, False),
+    ],
+)
+@pytest.mark.parametrize("multivalued", [False, True])
+@pytest.mark.parametrize("framework", CORE_FRAMEWORKS)
+def test_value_presence_in_rules(framework, multivalued, data_name, instance, is_valid):
+    """
+    Tests intersection of value_presence and rules.
+
+    Uses a class with two slots, s1 and s2, where either s1 or s2 is set,
+    but not both.
+
+    :param framework:
+    :param multivalued:
+    :param data_name:
+    :param instance:
+    :param is_valid:
+    :return:
+    """
+    classes = {
+        CLASS_C: {
+            "attributes": {
+                SLOT_S1: {
+                    "multivalued": multivalued,
+                },
+                SLOT_S2: {
+                    "multivalued": multivalued,
+                },
+            },
+            "rules": [
+                {
+                    "preconditions": {
+                        "slot_conditions": {
+                            SLOT_S1: {
+                                "value_presence": "PRESENT",
+                            },
+                        },
+                    },
+                    "postconditions": {
+                        "slot_conditions": {
+                            SLOT_S2: {
+                                "value_presence": "ABSENT",
+                            },
+                        },
+                    },
+                },
+                {
+                    "preconditions": {
+                        "slot_conditions": {
+                            SLOT_S1: {
+                                "value_presence": "ABSENT",
+                            },
+                        },
+                    },
+                    "postconditions": {
+                        "slot_conditions": {
+                            SLOT_S2: {
+                                "value_presence": "PRESENT",
+                            },
+                        },
+                    },
+                },
+            ],
+        },
+    }
+    schema = validated_schema(
+        test_value_presence_in_rules,
+        f"MV{multivalued}",
+        framework,
+        classes=classes,
+        core_elements=["preconditions", "postconditions", "slot_conditions", "value_presence"],
+    )
+    expected_behavior = ValidationBehavior.IMPLEMENTS
+    if framework != JSON_SCHEMA:
+        if not is_valid:
+            expected_behavior = ValidationBehavior.INCOMPLETE
+    if multivalued:
+        instance = {k: [v] for k, v in instance.items()}
+    check_data(
+        schema,
+        data_name,
+        framework,
+        instance,
+        is_valid,
+        target_class=CLASS_C,
+        expected_behavior=expected_behavior,
+        description=f"validity {is_valid} check for value {instance}",
+    )
diff --git a/tests/test_generators/test_shaclgen.py b/tests/test_generators/test_shaclgen.py
index e5f156bd..8e584111 100644
--- a/tests/test_generators/test_shaclgen.py
+++ b/tests/test_generators/test_shaclgen.py
@@ -17,13 +17,33 @@ EXPECTED = [
     ),
 ]
 
+EXPECTED_closed = [
+    (
+        rdflib.term.URIRef("https://w3id.org/linkml/tests/kitchen_sink/Person"),
+        rdflib.term.URIRef("http://www.w3.org/ns/shacl#closed"),
+        rdflib.term.Literal(
+            "false", datatype=rdflib.term.URIRef("http://www.w3.org/2001/XMLSchema#boolean")
+        ),
+    ),
+]
+
 
 def test_shacl(kitchen_sink_path):
     """tests shacl generation"""
     shaclstr = ShaclGenerator(kitchen_sink_path, mergeimports=True).serialize()
+    do_test(shaclstr, EXPECTED)
+
+
+def test_shacl_closed(kitchen_sink_path):
+    """tests shacl generation"""
+    shaclstr = ShaclGenerator(kitchen_sink_path, mergeimports=True, closed=False).serialize()
+    do_test(shaclstr, EXPECTED_closed)
+
+
+def do_test(shaclstr, expected):
     g = rdflib.Graph()
     g.parse(data=shaclstr)
     triples = list(g.triples((None, None, None)))
-    for et in EXPECTED:
+    for et in expected:
         assert et in triples
     # TODO: test shacl validation; pyshacl requires rdflib6
diff --git a/tests/test_scripts/test_gen_shacl.py b/tests/test_scripts/test_gen_shacl.py
new file mode 100644
index 00000000..41ce3aea
--- /dev/null
+++ b/tests/test_scripts/test_gen_shacl.py
@@ -0,0 +1,19 @@
+import unittest
+
+from linkml.generators import shaclgen
+from tests.test_scripts.environment import env
+from tests.utils.clicktestcase import ClickTestCase
+
+
+class GenShaclTestCase(ClickTestCase):
+    testdir = "genshacl"
+    click_ep = shaclgen.cli
+    prog_name = "gen-shacl"
+    env = env
+
+    def test_help(self):
+        self.do_test("--help", "help")
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tests/test_transformers/test_logical_model_transformer.py b/tests/test_transformers/test_logical_model_transformer.py
new file mode 100644
index 00000000..8768d2d9
--- /dev/null
+++ b/tests/test_transformers/test_logical_model_transformer.py
@@ -0,0 +1,429 @@
+from pathlib import Path
+
+import pytest
+from linkml_runtime import SchemaView
+from linkml_runtime.dumpers import yaml_dumper
+from linkml_runtime.linkml_model import SchemaDefinition, SlotDefinition
+
+from linkml.transformers.logical_model_transformer import (
+    LogicalModelTransformer,
+    UnsatisfiableAttribute,
+)
+from linkml.utils.schema_builder import SchemaBuilder
+
+THIS_DIR = Path(__file__).parent
+OUTPUT_DIR = THIS_DIR / "output"
+
+
+@pytest.mark.parametrize("abstract", [False, True])
+@pytest.mark.parametrize("default_range", [None, "string", "Any"])
+@pytest.mark.parametrize("preserve_class_is_a", [False, True])
+def test_simple(default_range, preserve_class_is_a, abstract):
+    """
+    Test with a simple schema structure involving inheritance.
+
+    :param default_range:
+    :param preserve_class_is_a:
+    :param abstract:
+    :return:
+    """
+    sb = SchemaBuilder()
+    sb.add_class("Thing", abstract=abstract, slots=["id", "name"])
+    sb.add_class("Person", slots={"age": {"range": "integer"}}, is_a="Thing")
+    if default_range == "Any":
+        sb.add_class("Any", class_uri="linkml:Any")
+    sb.add_slot("id", identifier=True, replace_if_present=True)
+    sb.add_slot("name", required=True, replace_if_present=True)
+    tr = LogicalModelTransformer(preserve_class_is_a=preserve_class_is_a)
+    sb.add_defaults()
+    sb.schema.default_range = default_range
+    tr.set_schema(sb.schema)
+    flat_schema = tr.transform()
+    # print(yaml_dumper.dumps(flat_schema))
+    thing = flat_schema.classes["Thing"]
+    actual_default_range = default_range if default_range != "Any" else None
+    assert thing.attributes["name"].range == actual_default_range
+    person = flat_schema.classes["Person"]
+    assert person.attributes["age"].range == "integer"
+    if preserve_class_is_a:
+        assert "id" not in person.attributes
+    else:
+        assert person.attributes["id"].identifier is True
+        assert person.attributes["name"].range == actual_default_range
+        assert person.attributes["name"].required is True
+    sb.add_class("Organization", is_a="Thing")
+    sb.add_class("Container", slots=[SlotDefinition("entities", range="Thing", multivalued=True)])
+    tr.set_schema(sb.schema)
+    flat_schema = tr.transform()
+    # print(yaml_dumper.dumps(flat_schema))
+    container = flat_schema.classes["Container"]
+    entities_att = container.attributes["entities"]
+    if preserve_class_is_a:
+        # range is preserved
+        assert entities_att.range == "Thing"
+    else:
+        # when fully unrolled a non-leaf class as range becomes as any_of ranges
+        assert entities_att.range is None
+        any_of = entities_att.any_of
+        if abstract:
+            # abstract classes should not be included in full enrolled results
+            assert len(any_of) == 2
+            assert {"Organization", "Person"} == {c.range for c in any_of}
+        else:
+            assert len(any_of) == 3
+            assert {"Organization", "Person", "Thing"} == {c.range for c in any_of}
+
+
+@pytest.mark.parametrize("default_range", [None, "string", "Any"])
+@pytest.mark.parametrize("preserve_class_is_a", [False, True])
+def test_unrestricted_range(default_range, preserve_class_is_a):
+    sb = SchemaBuilder()
+    sb.add_class("Thing", slots=["id", "name", "a_number", "foo"])
+    sb.add_class(
+        "Person",
+        slots={"age": {"range": "integer"}},
+        slot_usage={"name": {"range": "string"}},
+        is_a="Thing",
+    )
+    sb.add_class("Any", class_uri="linkml:Any")
+    sb.add_slot("id", identifier=True, replace_if_present=True)
+    sb.add_slot("name", required=True, range="Any", replace_if_present=True)
+    sb.add_slot(
+        "a_number", replace_if_present=True, any_of=[{"range": "integer"}, {"range": "float"}]
+    )
+    sb.add_slot("foo", replace_if_present=True, any_of=[{"range": "string"}, {"range": "Any"}])
+    tr = LogicalModelTransformer(preserve_class_is_a=preserve_class_is_a)
+    sb.schema.default_range = default_range
+    tr.set_schema(sb.schema)
+    flat_schema = tr.transform(simplify=True)
+    print(yaml_dumper.dumps(flat_schema))
+    thing = flat_schema.classes["Thing"]
+    person = flat_schema.classes["Person"]
+    assert person.attributes["age"].range == "integer", "direct assertion"
+    assert person.attributes["name"].range == "string", "range is restricted by slot_usage"
+    assert thing.attributes["name"].range is None, "Any is eliminated"
+    assert thing.attributes["foo"].range is None, "the union of Any and string is Any"
+    if preserve_class_is_a:
+        assert "id" not in person.attributes, "id is assumed to be inherited from Thing"
+        thing_id = thing.attributes["id"]
+        if default_range is None:
+            assert thing_id.range is None, "no default, Any assumed"
+        elif default_range == "Any":
+            assert thing_id.range is None, "Any is eliminated"
+        elif default_range == "string":
+            assert thing_id.range == "string", "unassigned range assigned to default"
+        else:
+            raise ValueError(f"Unexpected default_range: {default_range}")
+    else:
+        person_id = person.attributes["id"]
+        thing_id = thing.attributes["id"]
+        if default_range is None:
+            assert person_id.range is None, "no default, Any assumed"
+            assert thing_id.range is None, "no default, Any assumed"
+        elif default_range == "Any":
+            assert person_id.range is None, "Any is eliminated"
+            assert thing_id.range is None, "Any is eliminated"
+        elif default_range == "string":
+            assert person_id.range == "string", "unassigned range assigned to default"
+            assert thing_id.range == "string", "unassigned range assigned to default"
+        else:
+            raise ValueError(f"Unexpected default_range: {default_range}")
+
+
+def test_unsatifiable():
+    """
+    Test a schema that is unsatisfiable.
+
+    Tests for:
+
+    - incompatible domain and range
+    - incompatible max/min values
+    """
+    sb = SchemaBuilder()
+    sb.add_class("Thing", slots=["id", "name"])
+    sb.add_class("Person", slots={"age": {"range": "integer"}}, is_a="Thing")
+    sb.add_class("Person2", is_a="Person", slot_usage={"age": {"range": "string"}})
+    tr = LogicalModelTransformer()
+    sb.add_defaults()
+    tr.set_schema(sb.schema)
+    with pytest.raises(UnsatisfiableAttribute):
+        _ = tr.transform()
+    del sb.schema.classes["Person2"]
+    tr.set_schema(sb.schema)
+    _ = tr.transform()
+    sb.add_slot("bad_slot", range="integer", minimum_value=-100, maximum_value=-2)
+    age_slot = sb.schema.slots["age"]
+    age_slot.is_a = "bad_slot"
+    age_slot.minimum_value = 0
+    age_slot.maximum_value = 100
+    tr.set_schema(sb.schema)
+    assert sb.schema.slots["age"].minimum_value == 0, "check direct assignment"
+    with pytest.raises(UnsatisfiableAttribute):
+        _ = tr.transform()
+
+
+def test_type_inheritance():
+    """
+    Test that type inheritance is properly unrolled.
+
+    """
+    sb = SchemaBuilder()
+    sb.add_type("AgeType", typeof="integer", minimum_value=0, maximum_value=1000)
+    sb.add_type("CodeType", typeof="string", pattern=r"^[A-Z][a-z]+$")
+    sb.add_type("PersonCodeType", typeof="CodeType", pattern=r"^P[A-Z][a-z]+$")
+    sb.add_type(
+        "FooType",
+        typeof="integer",
+        any_of=[
+            {"minimum_value": 0, "maximum_value": 100},
+            {"minimum_value": 200, "maximum_value": 300},
+        ],
+    )
+    sb.add_slot("id", identifier=True, range="CodeType")
+    sb.add_slot("name", range="string")
+    sb.add_slot("age", range="AgeType")
+    sb.add_slot("foo", range="FooType")
+    sb.add_class("Thing", slots=["id", "name", "foo"])
+    sb.add_class(
+        "Person",
+        slots=["age"],
+        is_a="Thing",
+        slot_usage={"id": {"range": "PersonCodeType"}, "age": {"maximum_value": 200}},
+    )
+    # print(yaml_dumper.dumps(sb.schema))
+    sb.add_defaults()
+    tr = LogicalModelTransformer()
+    tr.set_schema(sb.schema)
+    # flat_schema = tr.transform(simplify=False)
+    # print(yaml_dumper.dumps(flat_schema))
+    flat_schema = tr.transform(simplify=True)
+    # print(yaml_dumper.dumps(flat_schema))
+    p = flat_schema.classes["Person"]
+    id_slot = p.attributes["id"]
+    assert id_slot.range == "PersonCodeType"
+    assert id_slot.identifier is True
+    assert {r"^P[A-Z][a-z]+$", r"^[A-Z][a-z]+$"} == {t.pattern for t in id_slot.all_of}.union(
+        {id_slot.pattern}
+    )
+    age_slot = p.attributes["age"]
+    assert age_slot.range == "AgeType"
+    assert (age_slot.minimum_value, age_slot.maximum_value) == (0, 200)
+    foo_slot = p.attributes["foo"]
+    assert len(foo_slot.any_of) == 2
+    assert {0, 200} == {t.minimum_value for t in foo_slot.any_of}
+
+
+@pytest.mark.parametrize("inlined_as_list", [False, True])
+@pytest.mark.parametrize("inlined", [False, True])
+@pytest.mark.parametrize("multivalued", [False, True])
+@pytest.mark.parametrize("pattern", [None, "^[A-Z][a-z]+$"])
+@pytest.mark.parametrize("preserve_class_is_a", [False, True])
+def test_any_of_with_required(preserve_class_is_a, pattern, multivalued, inlined, inlined_as_list):
+    """
+    Test a schema that uses any_of.
+
+    :param preserve_class_is_a:
+    :param pattern:
+    :param multivalued:
+    :param inlined:
+    :param inlined_as_list:
+    :return:
+    """
+    sb = SchemaBuilder()
+    sb.add_class("Thing", slots=["id", "name"])
+    sb.add_class("Person", slots={"age": {"range": "integer"}}, is_a="Thing")
+    sb.add_class(
+        "Employee",
+        is_a="Person",
+        slots={
+            "salary": {"range": "integer", "required": True},
+            "manager": {"range": "Employee"},
+            "employer": {"range": "Organization"},
+        },
+    )
+
+    sb.add_class("Organization", is_a="Thing")
+    sb.add_class("Company", is_a="Organization")
+    sb.add_slot("id", identifier=True, replace_if_present=True)
+    sb.add_slot("name", required=True, replace_if_present=True)
+    sb.add_class(
+        "Device",
+        slots={
+            "manufactured_by": {
+                "multivalued": multivalued,
+                "any_of": [
+                    {
+                        "range": "Organization",
+                        "pattern": pattern,
+                        "inlined": inlined,
+                        "inlined_as_list": inlined_as_list,
+                        "required": True,
+                    },
+                    {"range": "Person"},
+                ],
+            }
+        },
+    )
+    tr = LogicalModelTransformer(preserve_class_is_a=preserve_class_is_a)
+    sb.add_defaults()
+    tr.set_schema(sb.schema)
+    flat_schema = tr.transform()
+    # print(yaml_dumper.dumps(flat_schema))
+    device = flat_schema.classes["Device"]
+    manufactured_by = device.attributes["manufactured_by"]
+    py_field = tr.attribute_as_python_field(manufactured_by)
+    any_of = manufactured_by.any_of
+    assert len(any_of) == 2
+    required_exprs = [c for c in any_of if c.required]
+    non_required_exprs = [c for c in any_of if not c.required]
+    assert len(required_exprs) == 1
+    assert len(non_required_exprs) == 1
+    required_expr = required_exprs[0]
+    non_required_expr = non_required_exprs[0]
+    if preserve_class_is_a:
+        assert "Organization" == required_expr.range
+        assert "Person" == non_required_expr.range
+        if not multivalued:
+            assert py_field in [
+                "manufactured_by: Union[Organization, Optional[Person]]",
+                "manufactured_by: Union[Optional[Person], Organization]",
+            ]
+        else:
+            # assert py_field == "xx"
+            assert "Collection" in py_field
+    else:
+        assert {"Organization", "Company"} == {c.range for c in required_expr.any_of}
+        assert {"Person", "Employee"} == {c.range for c in non_required_expr.any_of}
+        assert "Employee" in py_field
+        # assert "xx" == py_field
+
+
+@pytest.mark.parametrize("specify_redundant", [False, True])
+@pytest.mark.parametrize("preserve_class_is_a", [False, True])
+def test_deep_schema(specify_redundant, preserve_class_is_a):
+    """
+    Test a schema that uses deep inheritance as well as many boolean combinations of constructs.
+
+    :param specify_redundant:
+    :param preserve_class_is_a:
+    :return:
+    """
+    sb = SchemaBuilder()
+    # Classes: two parallel hierarchies, C, and D.
+    # The C classes will point to the D classes via slot 's'.
+    cns = ["C", "C1", "C2", "C1a", "C1b", "C2a", "C2b"]
+    cns += [cn.replace("C", "D") for cn in cns]
+    # Slots:
+    # - s (connects C to D)
+    # - t (integer), constrained by minimum_value and maximum_value
+    # - u (default string), constrained by pattern
+    # - v (float)
+    sns = ["s", "s1", "s2", "s1a", "s1b", "s2a", "s2b"]
+    sns += (
+        [sn.replace("s", "t") for sn in sns]
+        + [sn.replace("s", "u") for sn in sns]
+        + [sn.replace("s", "v") for sn in sns]
+    )
+    # Mixins: M and N
+    mns = ["M", "M1", "M2", "M1a", "M1b", "M2a", "M2b"]
+    mns += [mn.replace("M", "N") for mn in mns]
+    # Slot assignments:
+    for sn in sns:
+        p = sn[0:-1]
+        p = None if p == "" else p
+        any_of = None
+        range = None
+        # only assert ranges at the roots
+        if specify_redundant or len(sn) == 1:
+            if sn.startswith("s"):
+                range = "D"
+            elif sn.startswith("t"):
+                range = "integer"
+            elif sn.startswith("u"):
+                range = None
+            elif sn.startswith("v"):
+                range = "float"
+            else:
+                raise AssertionError(f"Bad slot name: {sn}")
+        # make the u slot progressively more restricted via pattern as we go down the hierarchy.
+        # each individual assertion is an any_of that constrains EITHER start OR end;
+        # thus the overall effect is a conjunction of disjunctive constraints.
+        if sn.startswith("u"):
+            any_of = [
+                {"pattern": f"^{sn}"},
+                {"pattern": f"{sn}$"},
+            ]
+        sb.add_slot(sn, is_a=p, range=range, any_of=any_of)
+    # identifier slot
+    sb.add_slot("id", identifier=True, range="string")
+    main_s = "s2a"
+    main_t = "t2a"
+    # Mixins
+    for mn in mns:
+        p = mn[0:-1]
+        p = None if p == "" else p
+        # mixin slots on leaf classes
+        if len(mn) == 3:
+            # mixin_slots = [mn.replace("M", "u"), mn.replace("N", "v")]
+            mixin_slots = ["u" + mn[1:], "v" + mn[1:]]
+        else:
+            mixin_slots = []
+        sb.add_class(mn, mixin=True, is_a=p, slots=mixin_slots)
+    # Classes
+    for cn in cns:
+        extra = {}
+        p = cn[0:-1]
+        p = None if p == "" else p
+        if cn == "C":
+            slots = ["id", main_s]
+        elif cn == "D":
+            slots = [main_t]
+        else:
+            slots = []
+        if cn.startswith("C"):
+            # Cx classes point to Dx classes via slot s
+            slot_usage = SlotDefinition(main_s, range=cn.replace("C", "D"))
+            # Cx has mixins Mx and Nx
+            mixins = [cn.replace("C", "M"), cn.replace("C", "N")]
+        else:
+            # D classes are ints with progressively more constrained numeric ranges
+            slot_usage = SlotDefinition(main_t, minimum_value=len(cn), maximum_value=10 - len(cn))
+            mixins = []
+        sb.add_class(cn, is_a=p, slot_usage=[slot_usage], mixins=mixins, slots=slots, **extra)
+    sb.add_defaults()
+    schema = sb.schema
+    local_id = f"SR{specify_redundant}-PCI{preserve_class_is_a}"
+    # print(yaml_dumper.dump(schema, f"/tmp/asserted-schema-{local_id}.yaml"))
+    sv = SchemaView(schema)
+    flattener = LogicalModelTransformer(sv, preserve_class_is_a=preserve_class_is_a)
+    new_schema = flattener.transform(simplify=False)
+    # print(f"## NEW SCHEMA")
+    # print(yaml_dumper.dumps(new_schema))
+    # print(yaml_dumper.dump(new_schema, f"/tmp/trSCHEMA-{local_id}.yaml"))
+    flattener.simplify(new_schema)
+    _dump_schema(new_schema, local_id)
+    # print(yaml_dumper.dump(new_schema, f"/tmp/trSIMPLIFIED-{local_id}.yaml"))
+    class_C = new_schema.classes["C"]
+    class_C1 = new_schema.classes["C1"]
+    class_C1a = new_schema.classes["C1a"]
+    c_s2a = class_C.attributes["s2a"]
+    c1_s2a = class_C1.attributes["s2a"]
+    c1a_u1a = class_C1a.attributes["u1a"]
+    if not preserve_class_is_a:
+        assert {"D", "D1", "D2", "D1a", "D1b", "D2a", "D2b"} == {c.range for c in c_s2a.any_of}
+        assert {"D1", "D1a", "D1b"} == {c.range for c in c1_s2a.any_of}
+        assert c_s2a.range is None
+        assert c1_s2a.range is None
+        assert class_C.attributes["id"].identifier is True
+        assert class_C1.attributes["id"].identifier is True
+    else:
+        assert c_s2a.range == "D"
+        assert c1_s2a.range == "D1"
+        assert class_C.attributes["id"].identifier is True
+        assert "id" not in class_C1.attributes
+    assert len(c1a_u1a.any_of) > 1
+
+
+def _dump_schema(schema: SchemaDefinition, local_id: str) -> None:
+    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
+    print(yaml_dumper.dump(schema, f"{OUTPUT_DIR}/logical-model-{local_id}.yaml"))
diff --git a/tests/test_utils/test_logictools.py b/tests/test_utils/test_logictools.py
new file mode 100644
index 00000000..cd93b940
--- /dev/null
+++ b/tests/test_utils/test_logictools.py
@@ -0,0 +1,167 @@
+import pytest
+
+from linkml.utils.logictools import (
+    And,
+    Bottom,
+    Eq,
+    IsIn,
+    Or,
+    Term,
+    Top,
+    UniversalSet,
+    Value,
+    Variable,
+    is_contradiction,
+    simplify,
+    simplify_full,
+    to_dnf,
+)
+
+a = Variable("a")
+b = Variable("b")
+c = Variable("c")
+d = Variable("d")
+F = Bottom()
+T = Top()
+
+
+@pytest.mark.parametrize(
+    "expr1,expr2,is_eq",
+    [
+        (a, a, True),
+        (a, b, False),
+        (Term("P"), Term("P"), True),
+        (Term("P"), Term("P"), True),
+        (Term("P", a), Term("P", a), True),
+        (Term("P", a, b), Term("P", a, b), True),
+        (Term("P", a, b), Term("P", b, a), False),
+        (a < b, a < b, True),
+        (a < b, b < a, False),
+        (a < b, a > b, False),
+        (Eq(a, b), Eq(a, b), True),
+        (Eq(b, a), Eq(a, b), False),
+    ],
+)
+def test_eq(expr1, expr2, is_eq):
+    if is_eq:
+        assert expr1 == expr2
+    else:
+        assert expr1 != expr2
+
+
+@pytest.mark.parametrize(
+    "expr,expected_contradiction,dnf,simplified",
+    [
+        (T, False, T, T),
+        (F, True, F, F),
+        (~T, True, ~T, F),
+        (~F, False, ~F, T),
+        (~~T, False, T, T),
+        (~~F, True, F, F),
+        (F & F, True, F & F, F),
+        (F & T, True, F & T, F),
+        (F | T, False, F | T, T),
+        (F | F, True, F | F, F),
+        (And(T), False, And(T), T),
+        (Or(T), False, Or(T), T),
+        (a, None, a, a),
+        (And(a), None, And(a), a),
+        (Or(a), None, Or(a), a),
+        (a & a, None, a & a, a),
+        ((a & b) & c, None, And(a, b, c), And(a, b, c)),
+        # (a & (b & c), None, a & (b & c), a & b & c),
+        (a & F, True, a & F, F),
+        (F & a, True, F & a, F),
+        (a & T, None, a & T, a & T),
+        (~a, None, ~a, ~a),
+        (a & ~a, True, a & ~a, F),
+        (~a & a, True, ~a & a, F),
+        (~a & ~a, None, ~a & ~a, ~a),
+        (~(a | b), None, ~a & ~b, ~a & ~b),
+        (~(a & b), None, ~a | ~b, ~a | ~b),
+        (a & (b & ~c), None, And(a, b, ~c), And(a, b, ~c)),
+        (a & (b | ~c), None, (a & b) | (a & ~c), (a & b) | (a & ~c)),
+        ((a & b) | c, None, (a & b) | c, (a & b) | c),
+        ((a | b) & c, None, (a & c) | (b & c), (a & c) | (b & c)),
+        ((a | b) & (c | d), None, Or(a & c, a & d, b & c, b & d), Or(a & c, a & d, b & c, b & d)),
+        (And(a, And(b, c)), None, And(a, b, c), And(a, b, c)),
+        (a & (b & ~a), True, And(a, b, ~a), F),
+        # (a & (b | (c & ~a)), None, ((c & ~a) & a) | (a & b), a),
+        (Or(Or(Or(a, b))), None, Or(a, b), Or(a, b)),
+        (~~a, None, a, a),
+        (a & ~~~a, True, a & ~a, F),
+        (a < b, None, a < b, a < b),
+        (a < Value(5), None, a < Value(5), a < Value(5)),
+        ((a < 5) & (a < 10), None, (a < 5) & (a < 10), a < 5),
+        ((a < 5) & (b < 10), None, (a < 5) & (b < 10), (a < 5) & (b < 10)),
+        ((a < 5) | (a < 10), None, (a < 5) | (a < 10), a < 10),
+        ((a < 10) & (a > 20), True, (a < 10) & (a > 20), F),
+        (((a < 10) & c) & (a > 20), True, And(a < 10, c, a > 20), F),
+        (a % b, None, a % b, a % b),
+        (IsIn(a, UniversalSet()), False, IsIn(a, UniversalSet()), T),
+        (IsIn(a, [1, 2, 3]), None, IsIn(a, [1, 2, 3]), IsIn(a, [1, 2, 3])),
+        (
+            IsIn(a, [1, 2, 3]) & IsIn(a, [2, 3, 4]),
+            None,
+            IsIn(a, [1, 2, 3]) & IsIn(a, [2, 3, 4]),
+            IsIn(a, [2, 3]),
+        ),
+        (
+            IsIn(a, [1, 2, 3]) | IsIn(a, [2, 3, 4]),
+            None,
+            IsIn(a, [1, 2, 3]) | IsIn(a, [2, 3, 4]),
+            IsIn(a, [1, 2, 3, 4]),
+        ),
+        (
+            IsIn(a, [1, 2, 3]) | (IsIn(a, [2, 3, 4]) & IsIn(a, [3, 4, 5])),
+            None,
+            IsIn(a, [1, 2, 3]) | (IsIn(a, [2, 3, 4]) & IsIn(a, [3, 4, 5])),
+            IsIn(a, [1, 2, 3, 4]),
+        ),
+        (
+            IsIn(a, [1, 2, 3]) & (IsIn(a, [0, 1]) | IsIn(a, [3, 4])),
+            None,
+            (IsIn(a, [1, 2, 3]) & IsIn(a, [0, 1])) | (IsIn(a, [1, 2, 3]) & IsIn(a, [3, 4])),
+            IsIn(a, [1, 3]),
+        ),
+        # (a < 5, None, a < 5, a < 5),
+    ],
+)
+def test_logic_functions(expr, expected_contradiction, dnf, simplified):
+    print(expr)
+    assert is_contradiction(expr, apply_dnf=True) == expected_contradiction
+    expr_to_dnf = to_dnf(expr)
+    # print(expr_to_dnf)
+    assert expr_to_dnf == dnf
+    expr_simplified = simplify_full(expr_to_dnf)
+    assert expr_simplified == simplified
+
+
+@pytest.mark.parametrize(
+    "expr,expected",
+    [
+        (a, a),
+        (a & a, a),
+        # ((a & a) & a, a),
+        (a & b, a & b),
+        (a & b, b & a),
+        (a | a, a),
+        (a | b, a | b),
+        (a | b, b | a),
+        (~a, ~a),
+        (a & ~b, a & ~b),
+        (a & ~a, F),
+        (a | ~a, T),
+        ((a | b) & c, (a & c) | (b & c)),
+        ((a | b) & (c | d), Or(a & c, a & d, b & c, b & d)),
+        # ((a | b) & ( ~b), a),
+        (Term("P", a, b), Term("P", a, b)),
+        (Term("<", 5), Term("<", 5)),
+    ],
+)
+def test_solve(expr, expected):
+    print(f"INPUT: {expr._ordered_str()} E={expected}")
+    r = to_dnf(expr)
+    print(f"SIMPLIFYING {r}")
+    simplified = simplify(r)
+    assert simplified == expected