diff --git a/.gitignore b/.gitignore index 99d435b6e9c..c0fdd58d338 100644 --- a/.gitignore +++ b/.gitignore @@ -264,3 +264,6 @@ Desktop.ini # AgentReady reports .agentready/ + +# Claude Code project settings +.claude/ diff --git a/protos/feast/serving/GrpcServer.proto b/protos/feast/serving/GrpcServer.proto index 19caf0b7119..44d7c393b7c 100644 --- a/protos/feast/serving/GrpcServer.proto +++ b/protos/feast/serving/GrpcServer.proto @@ -1,6 +1,7 @@ syntax = "proto3"; import "feast/serving/ServingService.proto"; +import "feast/types/Value.proto"; option java_package = "feast.proto.serving"; option java_outer_classname = "GrpcServerAPIProto"; @@ -11,6 +12,7 @@ message PushRequest { string stream_feature_view = 2; bool allow_registry_cache = 3; string to = 4; + map typed_features = 5; } message PushResponse { @@ -21,6 +23,7 @@ message WriteToOnlineStoreRequest { map features = 1; string feature_view_name = 2; bool allow_registry_cache = 3; + map typed_features = 4; } message WriteToOnlineStoreResponse { diff --git a/sdk/python/feast/infra/contrib/grpc_server.py b/sdk/python/feast/infra/contrib/grpc_server.py index b6ed6cb25d4..4c042051225 100644 --- a/sdk/python/feast/infra/contrib/grpc_server.py +++ b/sdk/python/feast/infra/contrib/grpc_server.py @@ -1,11 +1,11 @@ import logging import threading +from collections.abc import Mapping from concurrent import futures from typing import Optional, Union import grpc import pandas as pd -from grpc_health.v1 import health, health_pb2_grpc from feast.data_source import PushMode from feast.errors import FeatureServiceNotFoundException, PushSourceNotFoundException @@ -34,6 +34,20 @@ def parse(features): return pd.DataFrame.from_dict(df) +def parse_typed(typed_features): + df = {} + for key, value in typed_features.items(): + val_case = value.WhichOneof("val") + if val_case is None or val_case == "null_val": + df[key] = [None] + else: + raw = getattr(value, val_case) + if hasattr(raw, "val"): + raw = dict(raw.val) if isinstance(raw.val, Mapping) else list(raw.val) + df[key] = [raw] + return pd.DataFrame.from_dict(df) + + class GrpcFeatureServer(GrpcFeatureServerServicer): fs: FeatureStore @@ -49,7 +63,17 @@ def __init__(self, fs: FeatureStore, registry_ttl_sec: int = 5): def Push(self, request, context): try: - df = parse(request.features) + if request.features and request.typed_features: + context.set_code(grpc.StatusCode.INVALID_ARGUMENT) + context.set_details( + "Only one of features or typed_features may be set, not both" + ) + return PushResponse(status=False) + df = ( + parse_typed(request.typed_features) + if request.typed_features + else parse(request.features) + ) if request.to == "offline": to = PushMode.OFFLINE elif request.to == "online": @@ -62,7 +86,7 @@ def Push(self, request, context): f"'online_and_offline']." ) self.fs.push( - push_source_name=request.push_source_name, + push_source_name=request.stream_feature_view, df=df, allow_registry_cache=request.allow_registry_cache, to=to, @@ -84,7 +108,17 @@ def WriteToOnlineStore(self, request, context): "write_to_online_store is deprecated. Please consider using Push instead" ) try: - df = parse(request.features) + if request.features and request.typed_features: + context.set_code(grpc.StatusCode.INVALID_ARGUMENT) + context.set_details( + "Only one of features or typed_features may be set, not both" + ) + return WriteToOnlineStoreResponse(status=False) + df = ( + parse_typed(request.typed_features) + if request.typed_features + else parse(request.features) + ) self.fs.write_to_online_store( feature_view_name=request.feature_view_name, df=df, @@ -94,7 +128,7 @@ def WriteToOnlineStore(self, request, context): logger.exception(str(e)) context.set_code(grpc.StatusCode.INTERNAL) context.set_details(str(e)) - return PushResponse(status=False) + return WriteToOnlineStoreResponse(status=False) return WriteToOnlineStoreResponse(status=True) def GetOnlineFeatures(self, request: GetOnlineFeaturesRequest, context): @@ -136,6 +170,8 @@ def get_grpc_server( max_workers: int, registry_ttl_sec: int, ): + from grpc_health.v1 import health, health_pb2_grpc + logger.info(f"Initializing gRPC server on {address}") server = grpc.server(futures.ThreadPoolExecutor(max_workers=max_workers)) add_GrpcFeatureServerServicer_to_server( diff --git a/sdk/python/feast/protos/feast/serving/GrpcServer_pb2.py b/sdk/python/feast/protos/feast/serving/GrpcServer_pb2.py index dcf91563185..586938289ae 100644 --- a/sdk/python/feast/protos/feast/serving/GrpcServer_pb2.py +++ b/sdk/python/feast/protos/feast/serving/GrpcServer_pb2.py @@ -13,9 +13,10 @@ from feast.protos.feast.serving import ServingService_pb2 as feast_dot_serving_dot_ServingService__pb2 +from feast.protos.feast.types import Value_pb2 as feast_dot_types_dot_Value__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1e\x66\x65\x61st/serving/GrpcServer.proto\x1a\"feast/serving/ServingService.proto\"\xb3\x01\n\x0bPushRequest\x12,\n\x08\x66\x65\x61tures\x18\x01 \x03(\x0b\x32\x1a.PushRequest.FeaturesEntry\x12\x1b\n\x13stream_feature_view\x18\x02 \x01(\t\x12\x1c\n\x14\x61llow_registry_cache\x18\x03 \x01(\x08\x12\n\n\x02to\x18\x04 \x01(\t\x1a/\n\rFeaturesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x1e\n\x0cPushResponse\x12\x0e\n\x06status\x18\x01 \x01(\x08\"\xc1\x01\n\x19WriteToOnlineStoreRequest\x12:\n\x08\x66\x65\x61tures\x18\x01 \x03(\x0b\x32(.WriteToOnlineStoreRequest.FeaturesEntry\x12\x19\n\x11\x66\x65\x61ture_view_name\x18\x02 \x01(\t\x12\x1c\n\x14\x61llow_registry_cache\x18\x03 \x01(\x08\x1a/\n\rFeaturesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\",\n\x1aWriteToOnlineStoreResponse\x12\x0e\n\x06status\x18\x01 \x01(\x08\x32\xf1\x01\n\x11GrpcFeatureServer\x12%\n\x04Push\x12\x0c.PushRequest\x1a\r.PushResponse\"\x00\x12M\n\x12WriteToOnlineStore\x12\x1a.WriteToOnlineStoreRequest\x1a\x1b.WriteToOnlineStoreResponse\x12\x66\n\x11GetOnlineFeatures\x12\'.feast.serving.GetOnlineFeaturesRequest\x1a(.feast.serving.GetOnlineFeaturesResponseB]\n\x13\x66\x65\x61st.proto.servingB\x12GrpcServerAPIProtoZ2github.com/feast-dev/feast/go/protos/feast/servingb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1e\x66\x65\x61st/serving/GrpcServer.proto\x1a\"feast/serving/ServingService.proto\x1a\x17\x66\x65\x61st/types/Value.proto\"\xb6\x02\n\x0bPushRequest\x12,\n\x08\x66\x65\x61tures\x18\x01 \x03(\x0b\x32\x1a.PushRequest.FeaturesEntry\x12\x1b\n\x13stream_feature_view\x18\x02 \x01(\t\x12\x1c\n\x14\x61llow_registry_cache\x18\x03 \x01(\x08\x12\n\n\x02to\x18\x04 \x01(\t\x12\x37\n\x0etyped_features\x18\x05 \x03(\x0b\x32\x1f.PushRequest.TypedFeaturesEntry\x1a/\n\rFeaturesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x1aH\n\x12TypedFeaturesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12!\n\x05value\x18\x02 \x01(\x0b\x32\x12.feast.types.Value:\x02\x38\x01\"\x1e\n\x0cPushResponse\x12\x0e\n\x06status\x18\x01 \x01(\x08\"\xd2\x02\n\x19WriteToOnlineStoreRequest\x12:\n\x08\x66\x65\x61tures\x18\x01 \x03(\x0b\x32(.WriteToOnlineStoreRequest.FeaturesEntry\x12\x19\n\x11\x66\x65\x61ture_view_name\x18\x02 \x01(\t\x12\x1c\n\x14\x61llow_registry_cache\x18\x03 \x01(\x08\x12\x45\n\x0etyped_features\x18\x04 \x03(\x0b\x32-.WriteToOnlineStoreRequest.TypedFeaturesEntry\x1a/\n\rFeaturesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x1aH\n\x12TypedFeaturesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12!\n\x05value\x18\x02 \x01(\x0b\x32\x12.feast.types.Value:\x02\x38\x01\",\n\x1aWriteToOnlineStoreResponse\x12\x0e\n\x06status\x18\x01 \x01(\x08\x32\xf1\x01\n\x11GrpcFeatureServer\x12%\n\x04Push\x12\x0c.PushRequest\x1a\r.PushResponse\"\x00\x12M\n\x12WriteToOnlineStore\x12\x1a.WriteToOnlineStoreRequest\x1a\x1b.WriteToOnlineStoreResponse\x12\x66\n\x11GetOnlineFeatures\x12\'.feast.serving.GetOnlineFeaturesRequest\x1a(.feast.serving.GetOnlineFeaturesResponseB]\n\x13\x66\x65\x61st.proto.servingB\x12GrpcServerAPIProtoZ2github.com/feast-dev/feast/go/protos/feast/servingb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -25,20 +26,28 @@ _globals['DESCRIPTOR']._serialized_options = b'\n\023feast.proto.servingB\022GrpcServerAPIProtoZ2github.com/feast-dev/feast/go/protos/feast/serving' _globals['_PUSHREQUEST_FEATURESENTRY']._options = None _globals['_PUSHREQUEST_FEATURESENTRY']._serialized_options = b'8\001' + _globals['_PUSHREQUEST_TYPEDFEATURESENTRY']._options = None + _globals['_PUSHREQUEST_TYPEDFEATURESENTRY']._serialized_options = b'8\001' _globals['_WRITETOONLINESTOREREQUEST_FEATURESENTRY']._options = None _globals['_WRITETOONLINESTOREREQUEST_FEATURESENTRY']._serialized_options = b'8\001' - _globals['_PUSHREQUEST']._serialized_start=71 - _globals['_PUSHREQUEST']._serialized_end=250 - _globals['_PUSHREQUEST_FEATURESENTRY']._serialized_start=203 - _globals['_PUSHREQUEST_FEATURESENTRY']._serialized_end=250 - _globals['_PUSHRESPONSE']._serialized_start=252 - _globals['_PUSHRESPONSE']._serialized_end=282 - _globals['_WRITETOONLINESTOREREQUEST']._serialized_start=285 - _globals['_WRITETOONLINESTOREREQUEST']._serialized_end=478 - _globals['_WRITETOONLINESTOREREQUEST_FEATURESENTRY']._serialized_start=203 - _globals['_WRITETOONLINESTOREREQUEST_FEATURESENTRY']._serialized_end=250 - _globals['_WRITETOONLINESTORERESPONSE']._serialized_start=480 - _globals['_WRITETOONLINESTORERESPONSE']._serialized_end=524 - _globals['_GRPCFEATURESERVER']._serialized_start=527 - _globals['_GRPCFEATURESERVER']._serialized_end=768 + _globals['_WRITETOONLINESTOREREQUEST_TYPEDFEATURESENTRY']._options = None + _globals['_WRITETOONLINESTOREREQUEST_TYPEDFEATURESENTRY']._serialized_options = b'8\001' + _globals['_PUSHREQUEST']._serialized_start=96 + _globals['_PUSHREQUEST']._serialized_end=406 + _globals['_PUSHREQUEST_FEATURESENTRY']._serialized_start=285 + _globals['_PUSHREQUEST_FEATURESENTRY']._serialized_end=332 + _globals['_PUSHREQUEST_TYPEDFEATURESENTRY']._serialized_start=334 + _globals['_PUSHREQUEST_TYPEDFEATURESENTRY']._serialized_end=406 + _globals['_PUSHRESPONSE']._serialized_start=408 + _globals['_PUSHRESPONSE']._serialized_end=438 + _globals['_WRITETOONLINESTOREREQUEST']._serialized_start=441 + _globals['_WRITETOONLINESTOREREQUEST']._serialized_end=779 + _globals['_WRITETOONLINESTOREREQUEST_FEATURESENTRY']._serialized_start=285 + _globals['_WRITETOONLINESTOREREQUEST_FEATURESENTRY']._serialized_end=332 + _globals['_WRITETOONLINESTOREREQUEST_TYPEDFEATURESENTRY']._serialized_start=334 + _globals['_WRITETOONLINESTOREREQUEST_TYPEDFEATURESENTRY']._serialized_end=406 + _globals['_WRITETOONLINESTORERESPONSE']._serialized_start=781 + _globals['_WRITETOONLINESTORERESPONSE']._serialized_end=825 + _globals['_GRPCFEATURESERVER']._serialized_start=828 + _globals['_GRPCFEATURESERVER']._serialized_end=1069 # @@protoc_insertion_point(module_scope) diff --git a/sdk/python/feast/protos/feast/serving/GrpcServer_pb2.pyi b/sdk/python/feast/protos/feast/serving/GrpcServer_pb2.pyi index 54964f46e58..9c1f6a0d493 100644 --- a/sdk/python/feast/protos/feast/serving/GrpcServer_pb2.pyi +++ b/sdk/python/feast/protos/feast/serving/GrpcServer_pb2.pyi @@ -4,6 +4,7 @@ isort:skip_file """ import builtins import collections.abc +import feast.protos.feast.types.Value_pb2 import google.protobuf.descriptor import google.protobuf.internal.containers import google.protobuf.message @@ -34,15 +35,35 @@ class PushRequest(google.protobuf.message.Message): ) -> None: ... def ClearField(self, field_name: typing_extensions.Literal["key", b"key", "value", b"value"]) -> None: ... + class TypedFeaturesEntry(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + KEY_FIELD_NUMBER: builtins.int + VALUE_FIELD_NUMBER: builtins.int + key: builtins.str + @property + def value(self) -> feast.protos.feast.types.Value_pb2.Value: ... + def __init__( + self, + *, + key: builtins.str = ..., + value: feast.protos.feast.types.Value_pb2.Value | None = ..., + ) -> None: ... + def HasField(self, field_name: typing_extensions.Literal["value", b"value"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["key", b"key", "value", b"value"]) -> None: ... + FEATURES_FIELD_NUMBER: builtins.int STREAM_FEATURE_VIEW_FIELD_NUMBER: builtins.int ALLOW_REGISTRY_CACHE_FIELD_NUMBER: builtins.int TO_FIELD_NUMBER: builtins.int + TYPED_FEATURES_FIELD_NUMBER: builtins.int @property def features(self) -> google.protobuf.internal.containers.ScalarMap[builtins.str, builtins.str]: ... stream_feature_view: builtins.str allow_registry_cache: builtins.bool to: builtins.str + @property + def typed_features(self) -> google.protobuf.internal.containers.MessageMap[builtins.str, feast.protos.feast.types.Value_pb2.Value]: ... def __init__( self, *, @@ -50,8 +71,9 @@ class PushRequest(google.protobuf.message.Message): stream_feature_view: builtins.str = ..., allow_registry_cache: builtins.bool = ..., to: builtins.str = ..., + typed_features: collections.abc.Mapping[builtins.str, feast.protos.feast.types.Value_pb2.Value] | None = ..., ) -> None: ... - def ClearField(self, field_name: typing_extensions.Literal["allow_registry_cache", b"allow_registry_cache", "features", b"features", "stream_feature_view", b"stream_feature_view", "to", b"to"]) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["allow_registry_cache", b"allow_registry_cache", "features", b"features", "stream_feature_view", b"stream_feature_view", "to", b"to", "typed_features", b"typed_features"]) -> None: ... global___PushRequest = PushRequest @@ -87,21 +109,42 @@ class WriteToOnlineStoreRequest(google.protobuf.message.Message): ) -> None: ... def ClearField(self, field_name: typing_extensions.Literal["key", b"key", "value", b"value"]) -> None: ... + class TypedFeaturesEntry(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + KEY_FIELD_NUMBER: builtins.int + VALUE_FIELD_NUMBER: builtins.int + key: builtins.str + @property + def value(self) -> feast.protos.feast.types.Value_pb2.Value: ... + def __init__( + self, + *, + key: builtins.str = ..., + value: feast.protos.feast.types.Value_pb2.Value | None = ..., + ) -> None: ... + def HasField(self, field_name: typing_extensions.Literal["value", b"value"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["key", b"key", "value", b"value"]) -> None: ... + FEATURES_FIELD_NUMBER: builtins.int FEATURE_VIEW_NAME_FIELD_NUMBER: builtins.int ALLOW_REGISTRY_CACHE_FIELD_NUMBER: builtins.int + TYPED_FEATURES_FIELD_NUMBER: builtins.int @property def features(self) -> google.protobuf.internal.containers.ScalarMap[builtins.str, builtins.str]: ... feature_view_name: builtins.str allow_registry_cache: builtins.bool + @property + def typed_features(self) -> google.protobuf.internal.containers.MessageMap[builtins.str, feast.protos.feast.types.Value_pb2.Value]: ... def __init__( self, *, features: collections.abc.Mapping[builtins.str, builtins.str] | None = ..., feature_view_name: builtins.str = ..., allow_registry_cache: builtins.bool = ..., + typed_features: collections.abc.Mapping[builtins.str, feast.protos.feast.types.Value_pb2.Value] | None = ..., ) -> None: ... - def ClearField(self, field_name: typing_extensions.Literal["allow_registry_cache", b"allow_registry_cache", "feature_view_name", b"feature_view_name", "features", b"features"]) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["allow_registry_cache", b"allow_registry_cache", "feature_view_name", b"feature_view_name", "features", b"features", "typed_features", b"typed_features"]) -> None: ... global___WriteToOnlineStoreRequest = WriteToOnlineStoreRequest diff --git a/sdk/python/tests/unit/test_grpc_server_write_protos.py b/sdk/python/tests/unit/test_grpc_server_write_protos.py new file mode 100644 index 00000000000..31c3e5c84c8 --- /dev/null +++ b/sdk/python/tests/unit/test_grpc_server_write_protos.py @@ -0,0 +1,159 @@ +import pytest + +from feast.infra.contrib.grpc_server import parse_typed +from feast.protos.feast.serving.GrpcServer_pb2 import ( + PushRequest, + WriteToOnlineStoreRequest, +) +from feast.protos.feast.types.Value_pb2 import ( + Int64List, + Map, + Null, + StringSet, + Value, +) + + +def test_push_request_string_features(): + request = PushRequest( + features={"driver_id": "1001", "conv_rate": "0.5"}, + stream_feature_view="driver_stats", + to="online", + ) + assert request.features["driver_id"] == "1001" + assert request.features["conv_rate"] == "0.5" + assert len(request.typed_features) == 0 + + +def test_push_request_typed_features(): + request = PushRequest( + typed_features={ + "driver_id": Value(int64_val=1001), + "conv_rate": Value(float_val=0.5), + "active": Value(bool_val=True), + "label": Value(string_val="fast"), + }, + stream_feature_view="driver_stats", + to="online", + ) + assert request.typed_features["driver_id"].int64_val == 1001 + assert request.typed_features["conv_rate"].float_val == pytest.approx(0.5) + assert request.typed_features["active"].bool_val is True + assert request.typed_features["label"].string_val == "fast" + assert len(request.features) == 0 + + +def test_push_request_typed_features_val_case(): + """WhichOneof('val') returns the correct field name for each value type.""" + cases = [ + (Value(int32_val=1), "int32_val"), + (Value(int64_val=2), "int64_val"), + (Value(float_val=1.0), "float_val"), + (Value(double_val=2.0), "double_val"), + (Value(bool_val=True), "bool_val"), + (Value(string_val="x"), "string_val"), + ] + for value, expected_case in cases: + assert value.WhichOneof("val") == expected_case + + +def test_write_to_online_store_request_string_features(): + request = WriteToOnlineStoreRequest( + features={"driver_id": "1001", "avg_daily_trips": "10"}, + feature_view_name="driver_hourly_stats", + ) + assert request.features["driver_id"] == "1001" + assert request.features["avg_daily_trips"] == "10" + assert len(request.typed_features) == 0 + + +def test_write_to_online_store_request_typed_features(): + request = WriteToOnlineStoreRequest( + typed_features={ + "driver_id": Value(int64_val=1001), + "avg_daily_trips": Value(int32_val=10), + "conv_rate": Value(float_val=0.42), + }, + feature_view_name="driver_hourly_stats", + ) + assert request.typed_features["driver_id"].int64_val == 1001 + assert request.typed_features["avg_daily_trips"].int32_val == 10 + assert request.typed_features["conv_rate"].float_val == pytest.approx(0.42) + assert len(request.features) == 0 + + +def test_push_request_string_and_typed_features_are_independent(): + """Setting features does not affect typed_features and vice versa.""" + r1 = PushRequest( + features={"driver_id": "1001"}, stream_feature_view="s", to="online" + ) + r2 = PushRequest( + typed_features={"driver_id": Value(int64_val=1001)}, + stream_feature_view="s", + to="online", + ) + assert len(r1.typed_features) == 0 + assert len(r2.features) == 0 + + +def test_write_to_online_store_string_and_typed_features_are_independent(): + r1 = WriteToOnlineStoreRequest( + features={"driver_id": "1001"}, feature_view_name="fv" + ) + r2 = WriteToOnlineStoreRequest( + typed_features={"driver_id": Value(int64_val=1001)}, feature_view_name="fv" + ) + assert len(r1.typed_features) == 0 + assert len(r2.features) == 0 + + +def test_parse_typed_null_val_becomes_none(): + """Value(null_val=NULL) must produce None in the DataFrame, not the integer 0.""" + df = parse_typed( + { + "present": Value(int64_val=42), + "missing": Value(null_val=Null.NULL), + } + ) + assert df["present"].iloc[0] == 42 + assert df["missing"].iloc[0] is None + + +def test_parse_typed_unset_val_becomes_none(): + """A Value with no oneof field set (WhichOneof returns None) must also produce None.""" + df = parse_typed({"empty": Value()}) + assert df["empty"].iloc[0] is None + + +def test_parse_typed_list_val_unwrapped_to_python_list(): + """Compound list values are unwrapped from their protobuf wrapper to a plain list.""" + df = parse_typed( + { + "ids": Value(int64_list_val=Int64List(val=[1, 2, 3])), + } + ) + assert df["ids"].iloc[0] == [1, 2, 3] + + +def test_parse_typed_set_val_unwrapped_to_python_list(): + """Compound set values are unwrapped from their protobuf wrapper to a plain list.""" + df = parse_typed( + { + "tags": Value(string_set_val=StringSet(val=["a", "b"])), + } + ) + assert sorted(df["tags"].iloc[0]) == ["a", "b"] + + +def test_parse_typed_map_val_unwrapped_to_python_dict(): + """Map values are unwrapped from their protobuf Map wrapper to a plain dict.""" + df = parse_typed( + { + "scores": Value( + map_val=Map(val={"x": Value(float_val=1.0), "y": Value(float_val=2.0)}) + ), + } + ) + result = df["scores"].iloc[0] + assert isinstance(result, dict) + assert set(result.keys()) == {"x", "y"}