diff --git a/ext/couchbase b/ext/couchbase index ebe2721b..b08e9f89 160000 --- a/ext/couchbase +++ b/ext/couchbase @@ -1 +1 @@ -Subproject commit ebe2721b90d12a085d112a055b6519659fa4f90e +Subproject commit b08e9f89ccfd95be21823b53a1c1b6a12841c4c2 diff --git a/ext/rcb_search.cxx b/ext/rcb_search.cxx index 108db9e1..5e29ab0a 100644 --- a/ext/rcb_search.cxx +++ b/ext/rcb_search.cxx @@ -29,6 +29,7 @@ #include #include #include +#include #include #include @@ -926,6 +927,28 @@ cb_Backend_document_search(VALUE self, cb_extract_option_bool(req.include_locations, options, "include_locations"); cb_extract_option_bool(req.show_request, options, "show_request"); + if (VALUE scoring = rb_hash_aref(options, rb_id2sym(rb_intern("scoring"))); !NIL_P(scoring)) { + cb_check_type(scoring, T_HASH); + VALUE mode = rb_hash_aref(scoring, rb_id2sym(rb_intern("mode"))); + cb_check_type(mode, T_SYMBOL); + VALUE params = rb_hash_aref(scoring, rb_id2sym(rb_intern("params"))); + if (ID mode_type = rb_sym2id(mode); mode_type == rb_intern("none")) { + req.scoring = core::search_scoring_none{}; + } else if (mode_type == rb_intern("reciprocal_rank_fusion")) { + core::search_scoring_reciprocal_rank_fusion rrf{}; + cb_extract_option_number(rrf.rank_constant, params, "rank_constant"); + cb_extract_option_number(rrf.window_size, params, "window_size"); + req.scoring = rrf; + } else if (mode_type == rb_intern("relative_score_fusion")) { + core::search_scoring_relative_score_fusion rsf{}; + cb_extract_option_number(rsf.window_size, params, "window_size"); + req.scoring = rsf; + } else { + throw ruby_exception(rb_eArgError, + rb_sprintf("unknown search scoring mode: %+" PRIsVALUE, mode)); + } + } + if (VALUE vector_options = rb_hash_aref(search_request, rb_id2sym(rb_intern("vector_search"))); !NIL_P(vector_options)) { cb_check_type(vector_options, T_HASH); diff --git a/fit-performer/lib/fit/performer/commands/search/options_builder.rb b/fit-performer/lib/fit/performer/commands/search/options_builder.rb index fd066048..713de7d0 100644 --- a/fit-performer/lib/fit/performer/commands/search/options_builder.rb +++ b/fit-performer/lib/fit/performer/commands/search/options_builder.rb @@ -178,6 +178,39 @@ def set_serializer self end + + def set_disable_scoring + return self unless @raw_options.has_disable_scoring? + + @options.disable_scoring = @raw_options.disable_scoring + + self + end + + def set_scoring + return self unless @raw_options.has_scoring? + + proto_scoring = @raw_options.scoring + + case proto_scoring.mode + when :reciprocal_rank_fusion + @options.scoring = Couchbase::SearchScoring.reciprocal_rank_fusion do |s| + proto_rrf = proto_scoring.reciprocal_rank_fusion + s.rank_constant = proto_rrf.rank_constant if proto_rrf.has_rank_constant? + s.window_size = proto_rrf.window_size if proto_rrf.has_window_size? + end + when :relative_score_fusion + @options.scoring = Couchbase::SearchScoring.relative_score_fusion do |s| + proto_rsf = proto_scoring.relative_score_fusion + s.window_size = proto_rsf.window_size if proto_rsf.has_window_size? + end + when :none + @options.scoring = Couchbase::SearchScoring.none + else + raise PerformerError, "Unknown search scoring mode `#{proto_scoring.mode}`" + end + self + end end end end diff --git a/fit-performer/lib/fit/performer/commands/search/search_command.rb b/fit-performer/lib/fit/performer/commands/search/search_command.rb index 4bfb358c..ceb5fdc7 100644 --- a/fit-performer/lib/fit/performer/commands/search/search_command.rb +++ b/fit-performer/lib/fit/performer/commands/search/search_command.rb @@ -56,6 +56,8 @@ def set_options .set_raw .set_include_locations .set_serializer + .set_disable_scoring + .set_scoring .set_parent_span(@get_span_fn) @cmd_args.append(builder.options) end diff --git a/fit-performer/lib/fit/performer/commands/search/search_query_command.rb b/fit-performer/lib/fit/performer/commands/search/search_query_command.rb index 31acb9e0..66b9c2a2 100644 --- a/fit-performer/lib/fit/performer/commands/search/search_query_command.rb +++ b/fit-performer/lib/fit/performer/commands/search/search_query_command.rb @@ -58,6 +58,8 @@ def set_options .set_facets .set_raw .set_include_locations + .set_disable_scoring + .set_scoring .set_parent_span(@get_span_fn) @cmd_args.append(builder.options) end diff --git a/fit-performer/lib/fit/performer/service.rb b/fit-performer/lib/fit/performer/service.rb index ba9f2029..783f6f58 100644 --- a/fit-performer/lib/fit/performer/service.rb +++ b/fit-performer/lib/fit/performer/service.rb @@ -77,6 +77,7 @@ class Service < FIT::Protocol::PerformerService::Service :SDK_OBSERVABILITY_RFC_REV_24, :SDK_STABLE_OTEL_SEMANTIC_CONVENTIONS, :SDK_STABLE_OTEL_SEMANTIC_CONVENTIONS_EMITTED_BY_DEFAULT, + :SDK_SEARCH_SCORE_FUSION, ].freeze # We don't currently support transactions. However, the driver calls transactions_factory_create during the diff --git a/fit-performer/proto/.clang-format b/fit-performer/proto/.clang-format new file mode 100644 index 00000000..67b70222 --- /dev/null +++ b/fit-performer/proto/.clang-format @@ -0,0 +1,7 @@ +# -*- mode: yaml; -*- + +# The .proto files are copied verbatim from the FIT protocol repository, so they +# must not be reformatted by clang-format (which formats .proto files by default) +--- +DisableFormat: true +SortIncludes: false diff --git a/fit-performer/proto/sdk.caps.proto b/fit-performer/proto/sdk.caps.proto index bd6df94a..b3b10ccf 100644 --- a/fit-performer/proto/sdk.caps.proto +++ b/fit-performer/proto/sdk.caps.proto @@ -152,4 +152,14 @@ enum Caps { // The SDK supports the RESUMING status in the result of functions_status() of the eventing management API. SDK_EVENTING_RESUMING_FUNCTION_STATUS = 40; + + // The SDK supports hybrid search score fusion (SDK-RFC 52). + SDK_SEARCH_SCORE_FUSION = 41; + + // SDK handles query code 2120. + SDK_QUERY_2120 = 42; + + // The SDK has support for the Collection.GetReplica API. This includes the new DocumentNotFoundOnReplicaException, + // ReplicaIndexOutOfBoundsException and ReplicaIndexCurrentlyUnavailableException exceptions. + SDK_GET_REPLICA = 43; } diff --git a/fit-performer/proto/sdk.cluster.query.index_manager.proto b/fit-performer/proto/sdk.cluster.query.index_manager.proto index 4aa8fe17..eb389820 100644 --- a/fit-performer/proto/sdk.cluster.query.index_manager.proto +++ b/fit-performer/proto/sdk.cluster.query.index_manager.proto @@ -8,8 +8,6 @@ option ruby_package = "FIT::Protocol::SDK::Cluster::Query::IndexManager"; option java_multiple_files = true; import "sdk.query.index_manager.proto"; -import "sdk.query.index_manager.options.proto"; - // This file is for QueryIndexManager: cluster.queryIndexes() // For Collection-level query index manager (collection.queryIndexes()), see sdk.collection.query.index_manager.proto. diff --git a/fit-performer/proto/sdk.kv.commands.proto b/fit-performer/proto/sdk.kv.commands.proto index fa8a4c67..90764f60 100644 --- a/fit-performer/proto/sdk.kv.commands.proto +++ b/fit-performer/proto/sdk.kv.commands.proto @@ -13,6 +13,7 @@ import "shared.content.proto"; import "shared.basic.proto"; import "google/protobuf/duration.proto"; import "streams.top_level.proto"; +import "sdk.kv.replicas.proto"; // Performer will return a MutationResult @@ -68,6 +69,15 @@ message GetAnyReplica { shared.ContentAs content_as = 3; } +// Performer will return a GetReplicaResult +message GetReplica { + shared.DocLocation location = 1; + replicas.GetReplicaStrategy strategy = 2; + optional GetReplicaOptions options = 3; + + shared.ContentAs content_as = 4; +} + // Performer will return an empty result with success set message Unlock { shared.DocLocation location = 1; @@ -145,4 +155,4 @@ message GetReplicaResult { // Not part of the SDK response - identifies what stream this is from. // This is only needed when returning the result of collection.getAllReplicas() optional string stream_id = 5; -} \ No newline at end of file +} diff --git a/fit-performer/proto/sdk.kv.options.proto b/fit-performer/proto/sdk.kv.options.proto index ad956fa6..5c8dc003 100644 --- a/fit-performer/proto/sdk.kv.options.proto +++ b/fit-performer/proto/sdk.kv.options.proto @@ -98,4 +98,10 @@ message GetAnyReplicaOptions { optional shared.ReadPreference read_preference = 3; optional string parent_span_id = 4; -} \ No newline at end of file +} + +message GetReplicaOptions { + optional int32 timeout_msecs = 1; + optional shared.Transcoder transcoder = 2; + optional string parent_span_id = 3; +} diff --git a/fit-performer/proto/sdk.kv.replicas.proto b/fit-performer/proto/sdk.kv.replicas.proto new file mode 100644 index 00000000..c517da16 --- /dev/null +++ b/fit-performer/proto/sdk.kv.replicas.proto @@ -0,0 +1,30 @@ +syntax = "proto3"; + +package protocol.sdk.kv.replicas; +option csharp_namespace = "Couchbase.Grpc.Protocol.Sdk.Kv.Replicas"; +option java_package = "com.couchbase.client.protocol.sdk.kv.replicas"; +option go_package = "github.com/couchbaselabs/transactions-fit-performer/protocol/sdk/kv/replicas"; +option ruby_package = "FIT::Protocol::SDK::KV::Replicas"; +option java_multiple_files = true; + +enum ReplicaIndex { + FIRST = 0; + SECOND = 1; + THIRD = 2; +} + +message GetReplicaStrategyFromIndexOptions { + optional bool wrap = 1; +} + +// Equivalent to a GetReplicaStrategy.fromIndex call in the SDK +message GetReplicaStrategyFromIndex { + ReplicaIndex index = 1; + optional GetReplicaStrategyFromIndexOptions options = 2; +} + +message GetReplicaStrategy { + oneof strategy { + GetReplicaStrategyFromIndex from_index = 1; + } +} diff --git a/fit-performer/proto/sdk.search.proto b/fit-performer/proto/sdk.search.proto index e65a6e12..ba95a1f9 100644 --- a/fit-performer/proto/sdk.search.proto +++ b/fit-performer/proto/sdk.search.proto @@ -89,6 +89,26 @@ enum VectorQueryCombination { OR = 1; } +message SearchScoringReciprocalRankFusion { + optional uint32 rank_constant = 1; + optional uint32 window_size = 2; +} + +message SearchScoringRelativeScoreFusion { + optional uint32 window_size = 1; +} + +message SearchScoringNone { +} + +// Controls the top-level `score` field in the FTS request. +message SearchScoring { + oneof mode { + SearchScoringReciprocalRankFusion reciprocal_rank_fusion = 1; + SearchScoringRelativeScoreFusion relative_score_fusion = 2; + SearchScoringNone none = 3; + } +} // Executing a `cluster.searchQuery()` FTS query. // @@ -319,6 +339,8 @@ message SearchOptions { map raw = 12; optional bool include_locations = 13; optional protocol.shared.JsonSerializer serialize = 14; + optional SearchScoring scoring = 15; + optional bool disable_scoring = 16; } enum MatchOperator { diff --git a/fit-performer/proto/sdk.workload.proto b/fit-performer/proto/sdk.workload.proto index 330fc5a7..643b34c4 100644 --- a/fit-performer/proto/sdk.workload.proto +++ b/fit-performer/proto/sdk.workload.proto @@ -118,6 +118,8 @@ message CollectionLevelCommand { kv.GetAllReplicas get_all_replicas = 31; + kv.GetReplica get_replica = 33; + kv.lookup_in.LookupInAnyReplica lookup_in_any_replica = 19; BinaryCollectionLevelCommand binary = 32; diff --git a/fit-performer/proto/shared.exceptions.proto b/fit-performer/proto/shared.exceptions.proto index d739c1f2..03fe3a8e 100644 --- a/fit-performer/proto/shared.exceptions.proto +++ b/fit-performer/proto/shared.exceptions.proto @@ -84,6 +84,9 @@ enum CouchbaseExceptionType { SDK_XATTR_UNKNOWN_VIRTUAL_ATTRIBUTE_EXCEPTION = 127; SDK_XATTR_CANNOT_MODIFY_VIRTUAL_ATTRIBUTE_EXCEPTION = 128; SDK_XATTR_NO_ACCESS_EXCEPTION = 130; + SDK_DOCUMENT_NOT_FOUND_ON_REPLICA_EXCEPTION = 132; + SDK_REPLICA_INDEX_OUT_OF_BOUNDS_EXCEPTION = 133; + SDK_REPLICA_INDEX_CURRENTLY_UNAVAILABLE_EXCEPTION = 134; // Query SDK_PLANNING_FAILURE_EXCEPTION = 201; diff --git a/lib/couchbase/options.rb b/lib/couchbase/options.rb index 8740f2e9..5b778c6d 100644 --- a/lib/couchbase/options.rb +++ b/lib/couchbase/options.rb @@ -2329,7 +2329,12 @@ class Search < Base attr_accessor :highlight_style # @return [Symbol] attr_accessor :highlight_fields # @return [Array] attr_accessor :fields # @return [Array] - attr_accessor :disable_scoring # @return [Boolean] + + # @deprecated Use +scoring+ with {SearchScoring.none} instead + # @return [Boolean] + attr_accessor :disable_scoring + + attr_accessor :scoring # @return [SearchScoring, nil] attr_accessor :include_locations # @return [Boolean] attr_accessor :collections # @return [Array, nil] attr_accessor :sort # @return [Array] @@ -2346,7 +2351,12 @@ class Search < Base # @param [Array] fields list of field values which should be retrieved for result documents, provided they # were stored while indexing # @param [MutationState] mutation_state the mutation tokens this query should be consistent with - # @param [Boolean] disable_scoring If set to true, the server will not perform any scoring on the hits + # @param [Boolean] disable_scoring DEPRECATED: Use +scoring+ with {SearchScoring.none} instead. If set to true, + # the server will not perform any scoring on the hits. + # @param [SearchScoring, nil] scoring specifies the scoring mode used for the request. For a + # hybrid search (a traditional FTS query combined with one or more vector queries) a fusion strategy + # controls how the FTS and vector result sets are merged into a single ranked list. Must not be used + # together with +disable_scoring+. # @param [Boolean] include_locations UNCOMMITTED: If set to true, will include the vector of search_location in rows # @param [Array, nil] collections list of collections by which to filter the results # @param [Array] sort Ordering rules to apply to the results. The list might contain @@ -2372,6 +2382,7 @@ def initialize(limit: nil, fields: nil, mutation_state: nil, disable_scoring: false, + scoring: nil, include_locations: false, collections: nil, sort: nil, @@ -2389,6 +2400,7 @@ def initialize(limit: nil, @highlight_fields = highlight_fields @fields = fields @disable_scoring = disable_scoring + @scoring = scoring @include_locations = include_locations @collections = collections @sort = sort @@ -2432,14 +2444,22 @@ def scan_consistency=(level) # @return [Symbol] attr_reader :scan_consistency + # @api private + def validate_scoring + raise Error::InvalidArgument, "disable_scoring and scoring must not be used together" if @disable_scoring && !@scoring.nil? + end + # @api private def to_backend(show_request: nil) + validate_scoring + { timeout: Utils::Time.extract_duration(@timeout), limit: @limit, skip: @skip, explain: @explain, disable_scoring: @disable_scoring, + scoring: @scoring&.to_backend, include_locations: @include_locations, collections: @collections, highlight_style: @highlight_style, diff --git a/lib/couchbase/protostellar/request_generator/search.rb b/lib/couchbase/protostellar/request_generator/search.rb index d2e2776f..1d0861bd 100644 --- a/lib/couchbase/protostellar/request_generator/search.rb +++ b/lib/couchbase/protostellar/request_generator/search.rb @@ -16,6 +16,7 @@ require "google/protobuf/well_known_types" +require "couchbase/errors" require "couchbase/search_options" require "couchbase/protostellar/generated/search/v1/search_pb" @@ -48,7 +49,7 @@ def search_query_request(index_name, query, options) scan_consistency: SCAN_CONSISTENCY_MAP[options.scan_consistency], include_explanation: options.explain, highlight_style: HIGHLIGHT_STYLE_MAP[options.highlight_style], - disable_scoring: options.disable_scoring, + disable_scoring: get_disable_scoring(options), include_locations: options.include_locations, } @@ -344,6 +345,21 @@ def get_sort(options) end end + # The only scoring mode supported by the protocol is {Couchbase::SearchScoring.none}. + # This will be updated to support all types of scoring once the protostellar protocol supports them. + def get_disable_scoring(options) + return options.disable_scoring if options.scoring.nil? + + options.validate_scoring + + unless options.scoring.is_a?(Couchbase::SearchScoring::SearchScoringNone) + raise Couchbase::Error::FeatureNotAvailable, + "The #{Protostellar::NAME} protocol does not support #{options.scoring.class} scoring" + end + + true + end + def get_facets(options) return {} if options.facets.nil? diff --git a/lib/couchbase/search_options.rb b/lib/couchbase/search_options.rb index 2d6edacc..2e7fae72 100644 --- a/lib/couchbase/search_options.rb +++ b/lib/couchbase/search_options.rb @@ -25,8 +25,6 @@ class SearchRequest # @overload new(vector_search) # Will run a +VectorSearch+ # @param [VectorSearch] vector_search - # - # @!macro uncommitted def initialize(search) case search when SearchQuery @@ -57,8 +55,6 @@ def search_query(query) # @param [VectorSearch] query # # @return [SearchRequest] for chaining purposes - # - # @!macro uncommitted def vector_search(query) raise Error::InvalidArgument, "A VectorSearch has already been specified" unless @vector_search.nil? @@ -1046,7 +1042,6 @@ def to_h end end - # @!macro uncommitted class VectorSearch # Constructs a +VectorSearch+ instance, which allows one or more individual vector queries to be executed. # @@ -1065,7 +1060,6 @@ def to_backend end end - # @!macro uncommitted class VectorQuery # @return [Integer, nil] attr_accessor :num_candidates @@ -1259,6 +1253,101 @@ def to_json(*) end end + # +SearchScoring+ specifies the scoring mode used for a search request. For a hybrid search (a traditional FTS + # query combined with one or more vector queries) a fusion strategy controls how the FTS and vector result + # sets are merged into a single ranked list. + # + class SearchScoring + # @yieldparam [SearchScoringReciprocalRankFusion] + # + # @!macro uncommitted + # + # @return [SearchScoringReciprocalRankFusion] + def self.reciprocal_rank_fusion(&) + SearchScoringReciprocalRankFusion.new(&) + end + + # @yieldparam [SearchScoringRelativeScoreFusion] + # + # @!macro uncommitted + # + # @return [SearchScoringRelativeScoreFusion] + def self.relative_score_fusion(&) + SearchScoringRelativeScoreFusion.new(&) + end + + # @return [SearchScoringNone] + def self.none + SearchScoringNone.new + end + + # +SearchScoringReciprocalRankFusion+ merges the FTS and vector result sets of a hybrid search by rank rather + # than raw score. + # + # @!macro uncommitted + class SearchScoringReciprocalRankFusion < SearchScoring + # @return [Integer, nil] the rank constant used when merging the result sets (the server defaults this to 60) + attr_accessor :rank_constant + + # @return [Integer, nil] how many results per list are considered for fusion (the server defaults this to the + # request limit) + attr_accessor :window_size + + # @yieldparam [SearchScoringReciprocalRankFusion] + def initialize + super + yield self if block_given? + end + + # @api private + def to_backend + { + mode: :reciprocal_rank_fusion, + params: { + rank_constant: @rank_constant, + window_size: @window_size, + }.compact, + } + end + end + + # +SearchScoringRelativeScoreFusion+ merges the FTS and vector result sets of a hybrid search by normalized + # score. + # + # @!macro uncommitted + class SearchScoringRelativeScoreFusion < SearchScoring + # @return [Integer, nil] how many results per list are considered for fusion (the server defaults this to the + # request limit) + attr_accessor :window_size + + # @yieldparam [SearchScoringRelativeScoreFusion] + def initialize + super + yield self if block_given? + end + + # @api private + def to_backend + { + mode: :relative_score_fusion, + params: { + window_size: @window_size, + }.compact, + } + end + end + + # +SearchScoringNone+ disables scoring. + class SearchScoringNone < SearchScoring + # @api private + def to_backend + { + mode: :none, + } + end + end + end + class SearchFacet # @param [String] field_name # @return [SearchFacetTerm] diff --git a/test/search_scoring_test.rb b/test/search_scoring_test.rb new file mode 100644 index 00000000..ae9209a6 --- /dev/null +++ b/test/search_scoring_test.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true + +# Copyright 2026-present Couchbase, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require_relative "test_helper" + +require "couchbase/protostellar/request_generator/search" + +module Couchbase + class SearchScoringTest < Minitest::Test + include TestUtilities + + def setup + # Do nothing + end + + def teardown + # Do nothing + end + + def test_reciprocal_rank_fusion_without_parameters + scoring = SearchScoring.reciprocal_rank_fusion + + assert_instance_of SearchScoring::SearchScoringReciprocalRankFusion, scoring + assert_kind_of SearchScoring, scoring + assert_equal({mode: :reciprocal_rank_fusion, params: {}}, scoring.to_backend) + end + + def test_reciprocal_rank_fusion_with_parameters + scoring = SearchScoring.reciprocal_rank_fusion do |s| + s.rank_constant = 10 + s.window_size = 50 + end + + assert_equal 10, scoring.rank_constant + assert_equal 50, scoring.window_size + assert_equal({mode: :reciprocal_rank_fusion, params: {rank_constant: 10, window_size: 50}}, scoring.to_backend) + end + + def test_reciprocal_rank_fusion_omits_unset_parameters + scoring = SearchScoring.reciprocal_rank_fusion do |s| + s.rank_constant = 10 + end + + assert_equal({mode: :reciprocal_rank_fusion, params: {rank_constant: 10}}, scoring.to_backend) + + scoring = SearchScoring.reciprocal_rank_fusion do |s| + s.window_size = 50 + end + + assert_equal({mode: :reciprocal_rank_fusion, params: {window_size: 50}}, scoring.to_backend) + end + + def test_reciprocal_rank_fusion_parameters_set_after_construction + scoring = SearchScoring.reciprocal_rank_fusion + scoring.rank_constant = 3 + scoring.window_size = 7 + + assert_equal({mode: :reciprocal_rank_fusion, params: {rank_constant: 3, window_size: 7}}, scoring.to_backend) + end + + def test_relative_score_fusion_without_parameters + scoring = SearchScoring.relative_score_fusion + + assert_instance_of SearchScoring::SearchScoringRelativeScoreFusion, scoring + assert_kind_of SearchScoring, scoring + assert_equal({mode: :relative_score_fusion, params: {}}, scoring.to_backend) + end + + def test_relative_score_fusion_with_parameters + scoring = SearchScoring.relative_score_fusion do |s| + s.window_size = 20 + end + + assert_equal 20, scoring.window_size + assert_equal({mode: :relative_score_fusion, params: {window_size: 20}}, scoring.to_backend) + end + + def test_scoring_none + scoring = SearchScoring.none + + assert_instance_of SearchScoring::SearchScoringNone, scoring + assert_kind_of SearchScoring, scoring + assert_equal({mode: :none}, scoring.to_backend) + end + + def test_search_options_without_scoring + options = Options::Search.new(limit: 10) + + assert_nil options.scoring + assert_nil options.to_backend[:scoring] + refute options.to_backend[:disable_scoring] + end + + def test_search_options_with_scoring + options = Options::Search.new( + limit: 10, + scoring: SearchScoring.reciprocal_rank_fusion { |s| s.rank_constant = 60 }, + ) + + assert_equal({mode: :reciprocal_rank_fusion, params: {rank_constant: 60}}, options.to_backend[:scoring]) + refute options.to_backend[:disable_scoring] + end + + def test_search_options_with_scoring_set_after_construction + options = Options::Search.new(limit: 10) + options.scoring = SearchScoring.none + + assert_equal({mode: :none}, options.to_backend[:scoring]) + end + + def test_search_options_with_disable_scoring + options = Options::Search.new(limit: 10, disable_scoring: true) + + assert options.to_backend[:disable_scoring] + assert_nil options.to_backend[:scoring] + end + + def test_search_options_with_both_disable_scoring_and_scoring + options = Options::Search.new(limit: 10, disable_scoring: true, scoring: SearchScoring.none) + + assert_raises(Error::InvalidArgument) do + options.to_backend + end + end + + def test_search_options_with_disable_scoring_set_after_construction + options = Options::Search.new(limit: 10, scoring: SearchScoring.relative_score_fusion) + options.disable_scoring = true + + assert_raises(Error::InvalidArgument) do + options.to_backend + end + end + + def test_protostellar_without_scoring + refute protostellar_request(Options::Search.new(limit: 10)).disable_scoring + end + + def test_protostellar_with_disable_scoring + assert protostellar_request(Options::Search.new(limit: 10, disable_scoring: true)).disable_scoring + end + + def test_protostellar_with_scoring_none + assert protostellar_request(Options::Search.new(limit: 10, scoring: SearchScoring.none)).disable_scoring + end + + def test_protostellar_with_both_disable_scoring_and_scoring_none + options = Options::Search.new(limit: 10, disable_scoring: true, scoring: SearchScoring.none) + + assert_raises(Error::InvalidArgument) do + protostellar_request(options) + end + end + + def test_protostellar_with_fusion_scoring + [SearchScoring.reciprocal_rank_fusion, SearchScoring.relative_score_fusion].each do |scoring| + options = Options::Search.new(limit: 10, scoring: scoring) + + assert_raises(Error::FeatureNotAvailable) do + protostellar_request(options) + end + end + end + + def test_protostellar_with_both_disable_scoring_and_fusion_scoring + options = Options::Search.new(limit: 10, disable_scoring: true, scoring: SearchScoring.reciprocal_rank_fusion) + + # The invalid combination of options must be reported regardless of whether the scoring mode is supported + assert_raises(Error::InvalidArgument) do + protostellar_request(options) + end + end + + private + + def protostellar_request(options) + generator = Protostellar::RequestGenerator::Search.new + generator.search_query_request("index", Cluster::SearchQuery.match_all, options).proto_request + end + end +end