Repository: caikit/caikit
Issue: data model oneof fields are not sorted

## Description

The nlp interface unit tests fail for rules data model where the value type is incorrectly set.

## Discussion

Example:

```
dict_val = {"k1": 1, "k2": 2}
msg = dm.ViewProperty(dict_val)
msg.aql_property["k1"].which_oneof("value")
'float_val'
```

## Acceptance Criteria

<!-- Remove any that don't apply -->

- [ ] Unit tests cover new/changed code
- [x] Examples build against new/changed code
- [ ] READMEs are updated
- [ ] Type of [semantic version](https://semver.org/) change is identified


Changed files:
tests/core/data_model/test_dataobject.py, caikit/core/data_model/base.py

diff --git a/caikit/core/data_model/base.py b/caikit/core/data_model/base.py
index ba38843..018e4ec 100644
--- a/caikit/core/data_model/base.py
+++ b/caikit/core/data_model/base.py
@@ -22,13 +22,13 @@
 # Standard
 from dataclasses import dataclass
 from enum import Enum
-from typing import Any, Dict, Optional, Type, Union
+from typing import Any, Dict, List, Optional, Type, Union
 import base64
 import json
 
 # Third Party
 from google.protobuf import json_format
-from google.protobuf.descriptor import Descriptor
+from google.protobuf.descriptor import Descriptor, FieldDescriptor, OneofDescriptor
 from google.protobuf.internal import type_checkers as proto_type_checkers
 from google.protobuf.message import Message as ProtoMessageType
 
@@ -66,6 +66,17 @@ class _DataBaseMetaClass(type):
     _BACKEND_ATTR = "_backend"
     _WHICH_ONEOF_ATTR = "_which_oneof"
 
+    # When inferring which field in a oneof a given value should be used for
+    # based on the python type, we need to check types in order with bool first,
+    # ints next, then floats values that fit a "more flexible" type don't
+    # accidentally get assigned to the wrong field. These are the lists of int
+    # and bool type values in protobuf.
+    _PROTO_TYPE_ORDER = [FieldDescriptor.TYPE_BOOL] + [
+        val
+        for name, val in vars(FieldDescriptor).items()
+        if name.startswith("TYPE_") and "INT" in name
+    ]
+
     def __new__(mcs, name, bases, attrs):
         """When constructing a new data model class, we set the 'fields' class variable from the
         protobufs descriptor and then set the '__slots__' magic class attribute to fields.  This
@@ -186,8 +197,12 @@ class _DataBaseMetaClass(type):
         # NOTE: protobuf makes an interesting use of oneof to wrap types that
         #   should be explicitly optional. We don't want to consider these
         #   oneofs in the general oneof handling.
+
+        # Sort the names of the fields in this map to ensure that ordering is
+        # correct such that bool < int < float
+
         cls._fields_oneofs_map = {
-            oneof_name: [field.name for field in oneof.fields]
+            oneof_name: mcs._sorted_oneof_field_names(oneof)
             for oneof_name, oneof in cls._proto_class.DESCRIPTOR.oneofs_by_name.items()
             if len(oneof.fields) != 1 or oneof.name != f"_{oneof.fields[0].name}"
         }
@@ -409,6 +424,23 @@ class _DataBaseMetaClass(type):
         setattr(__init__, "__doc__", docstring)
         return __init__
 
+    @classmethod
+    def _sorted_oneof_field_names(mcs, oneof: OneofDescriptor) -> List[str]:
+        """Helper to get the list of oneof fields while ensuring field names are
+        sorted such that bool < int < float. This ensures that when iterating
+        fields for which_oneof inference, lower-precedence types take
+        precedence.
+        """
+        return [
+            field.name
+            for field in sorted(
+                oneof.fields,
+                key=lambda fld: mcs._PROTO_TYPE_ORDER.index(fld.type)
+                if fld.type in mcs._PROTO_TYPE_ORDER
+                else len(mcs._PROTO_TYPE_ORDER),
+            )
+        ]
+
 
 class DataBase(metaclass=_DataBaseMetaClass):
     """Base class for all structures in the data model.
@@ -518,6 +550,8 @@ class DataBase(metaclass=_DataBaseMetaClass):
         NOTE: In the case where fields within a oneof have the same type, the
           first field whose type matches will be used!
         """
+        # NOTE: The list of field names are guaranteed to be sorted so that
+        #   bool < int < float
         for field_name in cls._fields_oneofs_map.get(oneof_name, []):
             if cls._is_valid_type_for_field(field_name, oneof_val):
                 return field_name
@@ -556,6 +590,11 @@ class DataBase(metaclass=_DataBaseMetaClass):
         ]:
             return False
 
+        # If the field is a bool field, only accept python bools. Proto is ok to
+        # accept ints, but we are stricter than that.
+        if field_descriptor.type == field_descriptor.TYPE_BOOL:
+            return isinstance(val, bool)
+
         # If it's a primitive, use protobuf type checkers
         checker = proto_type_checkers.GetTypeChecker(field_descriptor)
         try:
@@ -592,6 +631,7 @@ class DataBase(metaclass=_DataBaseMetaClass):
             protobufs
                 A DataBase object.
         """
+        error.type_check("<COR45207671E>", ProtoMessageType, proto=proto)
         if cls._proto_class.DESCRIPTOR.name != proto.DESCRIPTOR.name:
             error(
                 "<COR71783894E>",
diff --git a/tests/core/data_model/test_dataobject.py b/tests/core/data_model/test_dataobject.py
index aa8151d..402e5bf 100644
--- a/tests/core/data_model/test_dataobject.py
+++ b/tests/core/data_model/test_dataobject.py
@@ -569,6 +569,29 @@ def test_dataobject_oneof_from_backend():
     assert msg2.which_oneof("foo") == "foo_int"
 
 
+def test_dataobject_oneof_numeric_type_precedence():
+    """Make sure that when inferring the which field from the python type, the
+    value of bool < int < float is respected
+    """
+
+    @dataobject
+    class Foo(DataObjectBase):
+        value: Union[
+            # NOTE: The order matters here. Since float is declared first, it
+            #   would naturally occur before int without correct sorting
+            Annotated[float, OneofField("float_val")],
+            Annotated[int, OneofField("int_val")],
+            Annotated[bool, OneofField("bool_val")],
+        ]
+
+    foo_bool = Foo(True)
+    assert foo_bool.which_oneof("value") == "bool_val"
+    foo_int = Foo(123)
+    assert foo_int.which_oneof("value") == "int_val"
+    foo_float = Foo(1.23)
+    assert foo_float.which_oneof("value") == "float_val"
+
+
 def test_dataobject_round_trip_json():
     """Make sure that a dataobject class can serialize to/from json"""