From 1bedd0803e012bbe3d907637af0aa3f5fb433b16 Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Mon, 27 Jul 2026 12:10:19 +0100 Subject: [PATCH 01/24] JAVA-5990 Add design spec for $scoreFusion aggregation stage builder --- .../2026-07-27-scorefusion-builder-design.md | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-27-scorefusion-builder-design.md diff --git a/docs/superpowers/specs/2026-07-27-scorefusion-builder-design.md b/docs/superpowers/specs/2026-07-27-scorefusion-builder-design.md new file mode 100644 index 0000000000..3119ce4076 --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-scorefusion-builder-design.md @@ -0,0 +1,145 @@ +# Design: `$scoreFusion` aggregation stage builder (JAVA-5990) + +**Ticket:** [JAVA-5990](https://jira.mongodb.org/browse/JAVA-5990), split from +[DRIVERS-3100](https://jira.mongodb.org/browse/DRIVERS-3100) — "Add builder support for `$scoreFusion` stage." + +**Scope decision:** `$scoreFusion` only. `$rankFusion`, the `$score` stage +(JAVA-6202), `$sigmoid`, and `$minMaxScaler` are out of scope, but naming is +chosen so those tickets can reuse types (see Naming). + +**Reference implementations:** C# [mongodb/mongo-csharp-driver#1976](https://github.com/mongodb/mongo-csharp-driver/pull/1976) +(typed enums, weights/expression validation, 8.2 wire gate); PHP +[mongodb/mongo-php-library#1822](https://github.com/mongodb/mongo-php-library/pull/1822) (thin pass-through). + +## Server stage being abstracted + +`$scoreFusion` (MongoDB 8.2+, must be the first stage, single collection): + +```json +{ "$scoreFusion": { + "input": { + "pipelines": { "name1": [ ...stages ], "name2": [ ...stages ] }, + "normalization": "none" | "sigmoid" | "minMaxScaler" + }, + "combination": { + "weights": { "name1": 0.3, "name2": 0.7 }, + "method": "avg" | "expression", + "expression": { "$sum": [ { "$multiply": ["$$name1", 10] }, "$$name2" ] } + }, + "scoreDetails": true +} } +``` + +Server rules the API must respect: + +- `input.pipelines` (required): map of unique names to sub-pipelines on the same collection; at least one. +- `input.normalization` (required): one of `none`, `sigmoid`, `minMaxScaler`. +- `combination` (optional): `weights` is **mutually exclusive** with `expression`; + `expression` requires `method: "expression"`; pipeline names bind as `$$name` variables. +- `scoreDetails` (optional, default `false`). + +## Public API + +All new types live in `com.mongodb.client.model` (driver-core). Javadoc tagged +`@mongodb.server.release 8.2`. Not `@Beta` (stage is GA in 8.2; C# shipped non-beta). + +### `Aggregates` entry points + +```java +public static Bson scoreFusion(List pipelines, ScoreNormalization normalization) +public static Bson scoreFusion(List pipelines, ScoreNormalization normalization, ScoreFusionOptions options) +``` + +### `FusionPipeline` + +`Facet`-style value class holding a named sub-pipeline: static factories +`of(String name, List stages)` and `of(String name, Bson... stages)`, +getters, `equals`/`hashCode`/`toString`. Named without the `Score` prefix so a +future `$rankFusion` builder (identical `input.pipelines` shape) reuses it. + +### `ScoreNormalization` + +`@Sealed` interface with static factories `none()`, `sigmoid()`, `minMaxScaler()` +rendering the camelCase server strings. Named fusion-agnostically because +JAVA-6202 (`$score` stage) has the same `normalization` field and must reuse this +type — **coordinate with the JAVA-6202 implementer**. + +### `ScoreFusionCombination` + +`@Sealed` type making the weights-XOR-expression server rule unrepresentable: + +- `ScoreFusionCombination.weighted(Bson weights)` → emits `combination.weights`; + returns a subtype with optional `.method(...)` for `avg` (server default is sum-like behavior when omitted). +- `ScoreFusionCombination.expression(Bson expression)` → always emits + `method: "expression"` together with `combination.expression`. + +### `ScoreFusionOptions` + +`@Sealed` immutable fluent interface, static factory `scoreFusionOptions()`: + +- `combination(ScoreFusionCombination combination)` +- `scoreDetails(boolean scoreDetails)` +- `option(String name, Object value)` escape hatch (per `SearchOptions`/`VectorSearchOptions` convention). + +### Example + +```java +Bson stage = Aggregates.scoreFusion( + asList( + FusionPipeline.of("vector", vectorSearchStage), + FusionPipeline.of("text", searchStage, Aggregates.limit(10))), + ScoreNormalization.sigmoid(), + scoreFusionOptions() + .combination(ScoreFusionCombination.weighted( + new Document("vector", 0.7).append("text", 0.3))) + .scoreDetails(true)); +``` + +## Implementation + +- Stage rendering via a private static nested `Bson`-implementing class in + `Aggregates` (pattern: `SetWindowFieldsStage`), or the constructible-BSON + helpers used by the search package — match surrounding style. +- Sealed interfaces backed by internal immutable implementations, following + `VectorSearchOptions`. +- Rendering rules: omit `combination` entirely when the options carry none; + emit `scoreDetails` only when `true` (matches C#). +- Validation (fail-fast `notNull`/`isTrueArgument`): pipelines non-null, + non-empty, no null entries; names non-null, non-empty, unique; weights/expression + non-null where required. Server-side rules (stage-must-be-first, + same-collection) are left to the server per driver convention. + +## Wrappers + +- **Scala:** type aliases + companion forwarders in + `org.mongodb.scala.model.package` (pattern: `Facet`) and a `scoreFusion` + forwarder in Scala `Aggregates`; update Scala API-surface test expectations. +- **Kotlin:** uses Java model types directly — no change. +- Pure API addition — no binary-compatibility impact. + +## Testing + +Three layers: + +1. **Unit** (alongside existing `Aggregates` unit tests): exact rendered BSON + for minimal form, each normalization value, weights, weights+method, + expression, scoreDetails, `option(...)` escape hatch; validation failures + (empty/duplicate/blank names, null args). +2. **Functional end-to-end** (`AggregatesTest`, gated + `serverVersionAtLeast(8, 2)`): every option exercised against a real server + using `$match` + raw `$score` BSON sub-pipelines with chosen score values, so + fused results and scores are deterministic and assertable — including + `$$name` expression combination and `{$meta: "scoreDetails"}` projection. + The raw `$score` documents carry a + `// TODO JAVA-6202 replace raw $score documents with the $score builder once available` + comment. +3. **Atlas integration** (`AggregatesSearchIntegrationTest`, gated + `isAtlasSearchTest()`): one realistic hybrid test — one `$search` + one + `$vectorSearch` sub-pipeline fused with normalization + weights, asserting + sensible ranked results. + +## Out of scope / future work + +- `$rankFusion` builder (reuses `FusionPipeline`). +- `$score` stage builder — JAVA-6202 (reuses `ScoreNormalization`). +- `$sigmoid` expression and `$minMaxScaler` window-function builders. From 8f38f91ce83ad6cea7432cce73f9a1230cbc6e9f Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Mon, 27 Jul 2026 12:28:12 +0100 Subject: [PATCH 02/24] JAVA-5990 Add implementation plan for $scoreFusion builder --- .../plans/2026-07-27-scorefusion-builder.md | 1061 +++++++++++++++++ 1 file changed, 1061 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-27-scorefusion-builder.md diff --git a/docs/superpowers/plans/2026-07-27-scorefusion-builder.md b/docs/superpowers/plans/2026-07-27-scorefusion-builder.md new file mode 100644 index 0000000000..0f7e238282 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-scorefusion-builder.md @@ -0,0 +1,1061 @@ +# `$scoreFusion` Aggregation Stage Builder Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add builder support for the `$scoreFusion` aggregation stage (JAVA-5990) to `com.mongodb.client.model`, with Scala wrapper forwarders. + +**Architecture:** Four new public types in `com.mongodb.client.model` — `FusionPipeline` (named sub-pipeline, `Facet`-style), `ScoreNormalization` (`@Sealed` interface + package-private `ScoreNormalizationBson`, modeled on `QuantileMethod`/`QuantileMethodBson`), `ScoreFusionCombination`/`WeightedScoreFusionCombination` and `ScoreFusionOptions` (`@Sealed` interfaces backed by one package-private `ScoreFusionConstructibleBson extends AbstractConstructibleBson`, modeled on `GeoNearConstructibleBson`/`SearchConstructibleBson`) — plus two `Aggregates.scoreFusion(...)` overloads rendering via a private nested `ScoreFusionStage implements Bson`. + +**Tech Stack:** Java 8 source, Gradle, Groovy/Spock unit tests (`AggregatesSpecification.groovy`), JUnit 5 functional tests (`AggregatesTest`), Atlas-gated JUnit 5 test (`AggregatesSearchIntegrationTest`), Scala forwarders. + +**Spec:** `docs/superpowers/specs/2026-07-27-scorefusion-builder-design.md` + +## Global Constraints + +- Java 8 language level only (no `var`, `List.of`, `Stream.toList`, etc.). +- Every new file starts with the Apache license header containing `Copyright 2008-present MongoDB, Inc.` (copy the exact 15-line header from `driver-core/src/main/com/mongodb/client/model/Facet.java`). +- New public API Javadoc: `@since 5.10`, `@mongodb.server.release 8.2`. Not `@Beta`. +- Package `com.mongodb.client.model` is NOT `@NonNullApi` — do not rely on it; use explicit `notNull(...)` runtime checks (`com.mongodb.assertions.Assertions`). +- Do not reformat code outside your changes. Run `./gradlew :driver-core:spotlessApply` if formatting complains (spotless does not cover Java here, but checkstyle does — match surrounding style, 4-space indent, `final` parameters). +- All work on branch `nh/hybrid_search`. Commit after each task. +- Working directory for all commands: `/Users/nabil.hachicha/MongoDB/mongo-java-driver/nabil_wt`. +- Unit test runs: `./gradlew :driver-core:test --tests "AggregatesSpecification"` (Spock). Functional tests need a running MongoDB: `./gradlew :driver-core:test --tests "com.mongodb.client.model.AggregatesTest" -Dorg.mongodb.test.uri="mongodb://localhost:27017"` — if no server is available, compile only (`./gradlew :driver-core:compileTestJava`) and say so; never claim tests passed without running them. + +--- + +### Task 1: `ScoreNormalization` + +**Files:** +- Create: `driver-core/src/main/com/mongodb/client/model/ScoreNormalization.java` +- Create: `driver-core/src/main/com/mongodb/client/model/ScoreNormalizationBson.java` +- Test: `driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy` + +**Interfaces:** +- Consumes: nothing new. +- Produces: `public interface ScoreNormalization` with `static ScoreNormalization none()`, `static ScoreNormalization sigmoid()`, `static ScoreNormalization minMaxScaler()`, `static ScoreNormalization of(BsonValue)`, and instance method `BsonValue toBsonValue()`. Task 4's stage rendering calls `toBsonValue()`. + +- [ ] **Step 1: Write the failing test.** Add to `AggregatesSpecification.groovy` (inside the class, near the other tests; add `import com.mongodb.client.model.ScoreNormalization` is unnecessary — same package): + +```groovy +def 'should create ScoreNormalization'() { + expect: + ScoreNormalization.none().toBsonValue() == new BsonString('none') + ScoreNormalization.sigmoid().toBsonValue() == new BsonString('sigmoid') + ScoreNormalization.minMaxScaler().toBsonValue() == new BsonString('minMaxScaler') + ScoreNormalization.of(new BsonString('sigmoid')).toBsonValue() == new BsonString('sigmoid') +} +``` + +Add `import org.bson.BsonString` to the spec's imports if not present. + +- [ ] **Step 2: Run test to verify it fails.** + +Run: `./gradlew :driver-core:test --tests "AggregatesSpecification"` +Expected: compilation failure — `unable to resolve class ScoreNormalization`. + +- [ ] **Step 3: Implement.** `ScoreNormalization.java` (license header first, as in every new file): + +```java +package com.mongodb.client.model; + +import com.mongodb.annotations.Sealed; +import org.bson.BsonString; +import org.bson.BsonValue; + +import static com.mongodb.assertions.Assertions.notNull; + +/** + * The way in which the scores produced by the {@linkplain Aggregates#scoreFusion(java.util.List, ScoreNormalization, + * ScoreFusionOptions) $scoreFusion} input pipelines are normalized before being combined. + * + * @since 5.10 + * @mongodb.server.release 8.2 + */ +@Sealed +public interface ScoreNormalization { + /** + * Returns a {@link ScoreNormalization} instance representing no normalization. + * + * @return The requested {@link ScoreNormalization}. + */ + static ScoreNormalization none() { + return new ScoreNormalizationBson(new BsonString("none")); + } + + /** + * Returns a {@link ScoreNormalization} instance representing normalization via the sigmoid function. + * + * @return The requested {@link ScoreNormalization}. + */ + static ScoreNormalization sigmoid() { + return new ScoreNormalizationBson(new BsonString("sigmoid")); + } + + /** + * Returns a {@link ScoreNormalization} instance representing min-max scaling of the scores to the range [0, 1]. + * + * @return The requested {@link ScoreNormalization}. + */ + static ScoreNormalization minMaxScaler() { + return new ScoreNormalizationBson(new BsonString("minMaxScaler")); + } + + /** + * Creates a {@link ScoreNormalization} from a {@link BsonValue} in situations when there is no builder method + * that better satisfies your needs. + * This method cannot be used to validate the syntax. + * + * @param normalization A {@link BsonValue} representing the required {@link ScoreNormalization}. + * @return The requested {@link ScoreNormalization}. + */ + static ScoreNormalization of(final BsonValue normalization) { + return new ScoreNormalizationBson(notNull("normalization", normalization)); + } + + /** + * Converts this object to {@link BsonValue}. + * + * @return A {@link BsonValue} representing this {@link ScoreNormalization}. + */ + BsonValue toBsonValue(); +} +``` + +`ScoreNormalizationBson.java` — copy the structure of `QuantileMethodBson.java` (same package): a package-private `final class ScoreNormalizationBson implements ScoreNormalization` holding a `private final BsonValue normalization`, constructor assigning it, `toBsonValue()` returning it, plus `equals`/`hashCode` delegating to the field and `toString()` returning `"ScoreNormalization{normalization=" + normalization + '}'`. + +- [ ] **Step 4: Run test to verify it passes.** + +Run: `./gradlew :driver-core:test --tests "AggregatesSpecification"` +Expected: PASS. + +- [ ] **Step 5: Commit.** + +```bash +git add driver-core/src/main/com/mongodb/client/model/ScoreNormalization.java driver-core/src/main/com/mongodb/client/model/ScoreNormalizationBson.java driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy +git commit -m "JAVA-5990 Add ScoreNormalization for the \$scoreFusion stage" +``` + +--- + +### Task 2: `FusionPipeline` + +**Files:** +- Create: `driver-core/src/main/com/mongodb/client/model/FusionPipeline.java` +- Test: `driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy` + +**Interfaces:** +- Produces: `public final class FusionPipeline` with `static FusionPipeline of(String name, List pipeline)`, `static FusionPipeline of(String name, Bson... pipeline)`, `String getName()`, `List getPipeline()`. Task 4 consumes both getters. Name deliberately fusion-generic so a future `$rankFusion` builder reuses it. + +- [ ] **Step 1: Write the failing test.** + +```groovy +def 'should create FusionPipeline'() { + when: + def pipeline = FusionPipeline.of('p1', match(eq('x', 1)), limit(2)) + + then: + pipeline.name == 'p1' + pipeline.pipeline.size() == 2 + pipeline == FusionPipeline.of('p1', [match(eq('x', 1)), limit(2)]) + + when: + FusionPipeline.of('', match(eq('x', 1))) + + then: + thrown(IllegalArgumentException) + + when: + FusionPipeline.of('p1', []) + + then: + thrown(IllegalArgumentException) +} +``` + +(`match`, `limit`, `eq` are already statically imported in this spec.) + +- [ ] **Step 2: Run test to verify it fails.** `./gradlew :driver-core:test --tests "AggregatesSpecification"` — compilation failure on `FusionPipeline`. + +- [ ] **Step 3: Implement.** `FusionPipeline.java` — model on `Facet.java` but final with static factories: + +```java +package com.mongodb.client.model; + +import org.bson.conversions.Bson; + +import java.util.List; +import java.util.Objects; + +import static com.mongodb.assertions.Assertions.isTrueArgument; +import static com.mongodb.assertions.Assertions.notNull; +import static java.util.Arrays.asList; +import static java.util.Collections.unmodifiableList; + +/** + * A named aggregation pipeline used as an input to a fusion pipeline stage, e.g., + * {@link Aggregates#scoreFusion(List, ScoreNormalization, ScoreFusionOptions) $scoreFusion}. + * The name uniquely identifies the pipeline within the stage and may be referred to + * by other parts of the stage, e.g., as the {@code "$$name"} variable in a + * {@linkplain ScoreFusionCombination#expression(Bson) combination expression}. + * + * @since 5.10 + * @mongodb.server.release 8.2 + */ +public final class FusionPipeline { + private final String name; + private final List pipeline; + + /** + * Creates a new {@link FusionPipeline}. + * + * @param name The non-empty pipeline name, unique within the containing stage. + * @param pipeline The non-empty pipeline. + * @return The requested {@link FusionPipeline}. + */ + public static FusionPipeline of(final String name, final List pipeline) { + return new FusionPipeline(name, pipeline); + } + + /** + * Creates a new {@link FusionPipeline}. + * + * @param name The non-empty pipeline name, unique within the containing stage. + * @param pipeline The non-empty pipeline. + * @return The requested {@link FusionPipeline}. + */ + public static FusionPipeline of(final String name, final Bson... pipeline) { + return new FusionPipeline(name, asList(pipeline)); + } + + private FusionPipeline(final String name, final List pipeline) { + notNull("name", name); + isTrueArgument("name must not be empty", !name.isEmpty()); + notNull("pipeline", pipeline); + isTrueArgument("pipeline must not be empty", !pipeline.isEmpty()); + for (Bson stage : pipeline) { + notNull("stage", stage); + } + this.name = name; + this.pipeline = unmodifiableList(pipeline); + } + + /** + * @return the pipeline name + */ + public String getName() { + return name; + } + + /** + * @return the pipeline + */ + public List getPipeline() { + return pipeline; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + FusionPipeline that = (FusionPipeline) o; + return name.equals(that.name) && pipeline.equals(that.pipeline); + } + + @Override + public int hashCode() { + return Objects.hash(name, pipeline); + } + + @Override + public String toString() { + return "FusionPipeline{" + + "name='" + name + '\'' + + ", pipeline=" + pipeline + + '}'; + } +} +``` + +Note: `unmodifiableList(pipeline)` needs an unchecked cast to `List` — implement as `this.pipeline = unmodifiableList(new java.util.ArrayList(pipeline));` typed `List` internally (defensive copy; adjust the field type to `List` and the getter return type stays `List`). + +- [ ] **Step 4: Run test to verify it passes.** `./gradlew :driver-core:test --tests "AggregatesSpecification"` — PASS. + +- [ ] **Step 5: Commit.** + +```bash +git add driver-core/src/main/com/mongodb/client/model/FusionPipeline.java driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy +git commit -m "JAVA-5990 Add FusionPipeline for fusion pipeline stages" +``` + +--- + +### Task 3: `ScoreFusionCombination`, `WeightedScoreFusionCombination`, `ScoreFusionOptions` + +**Files:** +- Create: `driver-core/src/main/com/mongodb/client/model/ScoreFusionCombination.java` +- Create: `driver-core/src/main/com/mongodb/client/model/WeightedScoreFusionCombination.java` +- Create: `driver-core/src/main/com/mongodb/client/model/ScoreFusionOptions.java` +- Create: `driver-core/src/main/com/mongodb/client/model/ScoreFusionConstructibleBson.java` +- Test: `driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy` + +**Interfaces:** +- Consumes: nothing from earlier tasks (independent of Tasks 1-2). +- Produces (Task 4 consumes `ScoreFusionOptions` as a `Bson` whose document holds the optional top-level `combination`/`scoreDetails` fields): + - `interface ScoreFusionCombination extends Bson` with `static WeightedScoreFusionCombination weighted(Bson weights)` and `static ScoreFusionCombination expression(Bson expression)`. + - `interface WeightedScoreFusionCombination extends ScoreFusionCombination` with `WeightedScoreFusionCombination avg()` (realizes the spec's `.method(avg)`; emits `method: "avg"` — no extra public enum needed since `expression(...)` implies `method: "expression"` automatically and the server default applies when unset). + - `interface ScoreFusionOptions extends Bson` with `static ScoreFusionOptions scoreFusionOptions()`, `ScoreFusionOptions combination(ScoreFusionCombination combination)`, `ScoreFusionOptions scoreDetails(boolean scoreDetails)`, `ScoreFusionOptions option(String name, Object value)`. + +- [ ] **Step 1: Write the failing test.** + +```groovy +def 'should render ScoreFusionCombination and ScoreFusionOptions'() { + expect: + toBson(ScoreFusionCombination.weighted(new Document('p1', 0.3d).append('p2', 0.7d))) == + parse('{weights: {p1: 0.3, p2: 0.7}}') + toBson(ScoreFusionCombination.weighted(new Document('p1', 0.3d)).avg()) == + parse('{weights: {p1: 0.3}, method: "avg"}') + toBson(ScoreFusionCombination.expression(new Document('$sum', ['$$p1', '$$p2']))) == + parse('{method: "expression", expression: {$sum: ["$$p1", "$$p2"]}}') + toBson(scoreFusionOptions()) == parse('{}') + toBson(scoreFusionOptions() + .combination(ScoreFusionCombination.expression(new Document('$sum', ['$$p1', '$$p2']))) + .scoreDetails(true)) == + parse('{combination: {method: "expression", expression: {$sum: ["$$p1", "$$p2"]}}, scoreDetails: true}') + toBson(scoreFusionOptions().option('scoreDetails', true)) == parse('{scoreDetails: true}') +} +``` + +Add static import `com.mongodb.client.model.ScoreFusionOptions.scoreFusionOptions` and (if missing) `org.bson.Document` import to the spec. `toBson`/`parse` already exist in the spec. + +- [ ] **Step 2: Run test to verify it fails.** `./gradlew :driver-core:test --tests "AggregatesSpecification"` — compilation failure. + +- [ ] **Step 3: Implement.** One backing class implements all three interfaces (pattern: `SearchConstructibleBson` implementing many interfaces; base pattern: `GeoNearConstructibleBson`). + +`ScoreFusionConstructibleBson.java` (package-private): + +```java +package com.mongodb.client.model; + +import com.mongodb.annotations.Immutable; +import com.mongodb.internal.client.model.AbstractConstructibleBson; +import org.bson.BsonBoolean; +import org.bson.BsonDocument; +import org.bson.BsonString; +import org.bson.Document; +import org.bson.conversions.Bson; + +import static com.mongodb.assertions.Assertions.notNull; + +final class ScoreFusionConstructibleBson extends AbstractConstructibleBson + implements ScoreFusionOptions, WeightedScoreFusionCombination { + /** + * An {@linkplain Immutable immutable} {@link BsonDocument#isEmpty() empty} instance. + */ + static final ScoreFusionConstructibleBson EMPTY_IMMUTABLE = + new ScoreFusionConstructibleBson(AbstractConstructibleBson.EMPTY_IMMUTABLE); + + ScoreFusionConstructibleBson(final Bson base) { + super(base); + } + + private ScoreFusionConstructibleBson(final Bson base, final Document appended) { + super(base, appended); + } + + @Override + protected ScoreFusionConstructibleBson newSelf(final Bson base, final Document appended) { + return new ScoreFusionConstructibleBson(base, appended); + } + + @Override + public ScoreFusionOptions combination(final ScoreFusionCombination combination) { + return newAppended("combination", notNull("combination", combination)); + } + + @Override + public ScoreFusionOptions scoreDetails(final boolean scoreDetails) { + return newAppended("scoreDetails", BsonBoolean.valueOf(scoreDetails)); + } + + @Override + public ScoreFusionOptions option(final String name, final Object value) { + return newAppended(notNull("name", name), notNull("value", value)); + } + + @Override + public WeightedScoreFusionCombination avg() { + return newAppended("method", new BsonString("avg")); + } +} +``` + +`ScoreFusionCombination.java`: + +```java +package com.mongodb.client.model; + +import com.mongodb.annotations.Sealed; +import org.bson.BsonString; +import org.bson.Document; +import org.bson.conversions.Bson; + +import static com.mongodb.assertions.Assertions.notNull; + +/** + * The way in which the normalized scores produced by the input pipelines of the + * {@linkplain Aggregates#scoreFusion(java.util.List, ScoreNormalization, ScoreFusionOptions) $scoreFusion} + * stage are combined into the final score. The server rejects specifying both + * {@linkplain #weighted(Bson) weights} and an {@linkplain #expression(Bson) expression}, + * which is why they are separate factory methods. + * + * @see ScoreFusionOptions#combination(ScoreFusionCombination) + * @since 5.10 + * @mongodb.server.release 8.2 + */ +@Sealed +public interface ScoreFusionCombination extends Bson { + /** + * Returns a {@link WeightedScoreFusionCombination} combining the scores using per-pipeline weights. + * + * @param weights A document mapping {@linkplain FusionPipeline#getName() pipeline names} to non-negative + * numeric weights. Pipelines not mentioned have the server-default weight 1. + * @return The requested {@link WeightedScoreFusionCombination}. + */ + static WeightedScoreFusionCombination weighted(final Bson weights) { + return new ScoreFusionConstructibleBson(new Document("weights", notNull("weights", weights))); + } + + /** + * Returns a {@link ScoreFusionCombination} combining the scores using a custom expression. + * The normalized, weighted score of each input pipeline is available to the expression + * as the variable named after the pipeline, e.g., {@code "$$name"}. + * + * @param expression The combination expression. + * @return The requested {@link ScoreFusionCombination}. + */ + static ScoreFusionCombination expression(final Bson expression) { + return new ScoreFusionConstructibleBson(new Document("method", new BsonString("expression")) + .append("expression", notNull("expression", expression))); + } +} +``` + +Note: `new ScoreFusionConstructibleBson(new Document(...))` uses the package-private `(Bson base)` constructor. The `Document` two-key chain in `expression(...)`: `new Document("method", ...).append("expression", ...)` returns `Document` — fine. + +`WeightedScoreFusionCombination.java`: + +```java +package com.mongodb.client.model; + +import com.mongodb.annotations.Sealed; + +/** + * A {@linkplain ScoreFusionCombination#weighted(org.bson.conversions.Bson) weighted} {@link ScoreFusionCombination}. + * + * @since 5.10 + * @mongodb.server.release 8.2 + */ +@Sealed +public interface WeightedScoreFusionCombination extends ScoreFusionCombination { + /** + * Returns a new {@link WeightedScoreFusionCombination} that instructs the server to combine the + * weighted scores using their average instead of the default combination method. + * + * @return A new {@link WeightedScoreFusionCombination}. + */ + WeightedScoreFusionCombination avg(); +} +``` + +`ScoreFusionOptions.java`: + +```java +package com.mongodb.client.model; + +import com.mongodb.annotations.Sealed; +import org.bson.conversions.Bson; + +/** + * Represents optional fields of the {@linkplain Aggregates#scoreFusion(java.util.List, ScoreNormalization, + * ScoreFusionOptions) $scoreFusion} pipeline stage of an aggregation pipeline. + * + * @since 5.10 + * @mongodb.server.release 8.2 + */ +@Sealed +public interface ScoreFusionOptions extends Bson { + /** + * Returns {@link ScoreFusionOptions} that represents server defaults. + * + * @return {@link ScoreFusionOptions} that represents server defaults. + */ + static ScoreFusionOptions scoreFusionOptions() { + return ScoreFusionConstructibleBson.EMPTY_IMMUTABLE; + } + + /** + * Creates a new {@link ScoreFusionOptions} with the combination specified. + * If not specified, the server combines the normalized scores using its default method. + * + * @param combination The way in which the normalized scores are combined. + * @return A new {@link ScoreFusionOptions}. + */ + ScoreFusionOptions combination(ScoreFusionCombination combination); + + /** + * Creates a new {@link ScoreFusionOptions} with the scoreDetails flag specified. + * When {@code true}, the server exposes score details via the {@code {$meta: "scoreDetails"}} expression. + * Server default is {@code false}. + * + * @param scoreDetails Whether to include score details. + * @return A new {@link ScoreFusionOptions}. + */ + ScoreFusionOptions scoreDetails(boolean scoreDetails); + + /** + * Creates a new {@link ScoreFusionOptions} with the specified option in situations when there is no builder method + * that better satisfies your needs. + * This method cannot be used to validate the syntax. + * + * @param name The option name. + * @param value The option value. + * @return A new {@link ScoreFusionOptions}. + */ + ScoreFusionOptions option(String name, Object value); +} +``` + +- [ ] **Step 4: Run test to verify it passes.** `./gradlew :driver-core:test --tests "AggregatesSpecification"` — PASS. + +- [ ] **Step 5: Commit.** + +```bash +git add driver-core/src/main/com/mongodb/client/model/ScoreFusionCombination.java driver-core/src/main/com/mongodb/client/model/WeightedScoreFusionCombination.java driver-core/src/main/com/mongodb/client/model/ScoreFusionOptions.java driver-core/src/main/com/mongodb/client/model/ScoreFusionConstructibleBson.java driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy +git commit -m "JAVA-5990 Add ScoreFusionCombination and ScoreFusionOptions" +``` + +--- + +### Task 4: `Aggregates.scoreFusion` stage + +**Files:** +- Modify: `driver-core/src/main/com/mongodb/client/model/Aggregates.java` (public factories near `vectorSearch` around line 1030; nested stage class near `VectorSearchBson` around line 2310) +- Test: `driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy` + +**Interfaces:** +- Consumes: `FusionPipeline.getName()/getPipeline()` (Task 2), `ScoreNormalization.toBsonValue()` (Task 1), `ScoreFusionOptions`/`scoreFusionOptions()` (Task 3). +- Produces: `public static Bson scoreFusion(List pipelines, ScoreNormalization normalization)` and `public static Bson scoreFusion(List pipelines, ScoreNormalization normalization, ScoreFusionOptions options)`. Tasks 5-7 consume these. + +- [ ] **Step 1: Write the failing tests.** + +```groovy +def 'should render $scoreFusion'() { + expect: + toBson(scoreFusion( + [FusionPipeline.of('p1', match(eq('x', 1))), FusionPipeline.of('p2', match(eq('x', 2)))], + ScoreNormalization.sigmoid())) == + parse('''{$scoreFusion: {input: { + pipelines: {p1: [{$match: {x: 1}}], p2: [{$match: {x: 2}}]}, + normalization: "sigmoid"}}}''') + + toBson(scoreFusion( + [FusionPipeline.of('p1', match(eq('x', 1))), FusionPipeline.of('p2', match(eq('x', 2)), limit(5))], + ScoreNormalization.minMaxScaler(), + scoreFusionOptions() + .combination(ScoreFusionCombination.weighted(new Document('p1', 0.3d).append('p2', 0.7d)).avg()) + .scoreDetails(true))) == + parse('''{$scoreFusion: {input: { + pipelines: {p1: [{$match: {x: 1}}], p2: [{$match: {x: 2}}, {$limit: 5}]}, + normalization: "minMaxScaler"}, + combination: {weights: {p1: 0.3, p2: 0.7}, method: "avg"}, + scoreDetails: true}}''') + + toBson(scoreFusion( + [FusionPipeline.of('p1', match(eq('x', 1))), FusionPipeline.of('p2', match(eq('x', 2)))], + ScoreNormalization.none(), + scoreFusionOptions().combination( + ScoreFusionCombination.expression(new Document('$sum', ['$$p1', '$$p2']))))) == + parse('''{$scoreFusion: {input: { + pipelines: {p1: [{$match: {x: 1}}], p2: [{$match: {x: 2}}]}, + normalization: "none"}, + combination: {method: "expression", expression: {$sum: ["$$p1", "$$p2"]}}}}''') +} + +def 'should validate $scoreFusion arguments'() { + when: + scoreFusion([], ScoreNormalization.none()) + + then: + thrown(IllegalArgumentException) + + when: + scoreFusion([FusionPipeline.of('p1', match(eq('x', 1))), FusionPipeline.of('p1', match(eq('x', 2)))], + ScoreNormalization.none()) + + then: + thrown(IllegalArgumentException) + + when: + scoreFusion(null, ScoreNormalization.none()) + + then: + thrown(IllegalArgumentException) + + when: + scoreFusion([FusionPipeline.of('p1', match(eq('x', 1)))], null) + + then: + thrown(IllegalArgumentException) +} +``` + +Add static import `com.mongodb.client.model.Aggregates.scoreFusion` to the spec if `Aggregates.*` is not already imported (the spec statically imports individual `Aggregates` methods — follow that pattern). + +- [ ] **Step 2: Run tests to verify they fail.** `./gradlew :driver-core:test --tests "AggregatesSpecification"` — compilation failure on `scoreFusion`. + +- [ ] **Step 3: Implement.** In `Aggregates.java`, add the two factories after the last `vectorSearch` overload (keep the file's Javadoc style): + +```java + /** + * Creates a {@code $scoreFusion} pipeline stage, which combines the results of the given input pipelines, + * normalizing and combining the scores they produce. It must be the first stage of an aggregation pipeline + * run on a collection, and the input pipelines must be scored selection pipelines on the same collection, + * e.g., starting with a {@link #search(SearchOperator, SearchOptions) $search} + * or {@link #vectorSearch(FieldSearchPath, Iterable, String, long, VectorSearchOptions) $vectorSearch} stage. + * You may use the {@code $meta: "score"} expression to extract the combined score of a document. + * + * @param pipelines The non-empty input pipelines with unique names. + * @param normalization The way in which the scores produced by the input pipelines are normalized. + * @return The {@code $scoreFusion} pipeline stage. + * @mongodb.driver.manual reference/operator/aggregation/scoreFusion/ $scoreFusion + * @mongodb.server.release 8.2 + * @since 5.10 + */ + public static Bson scoreFusion(final List pipelines, final ScoreNormalization normalization) { + return scoreFusion(pipelines, normalization, ScoreFusionOptions.scoreFusionOptions()); + } + + /** + * Creates a {@code $scoreFusion} pipeline stage, which combines the results of the given input pipelines, + * normalizing and combining the scores they produce. It must be the first stage of an aggregation pipeline + * run on a collection, and the input pipelines must be scored selection pipelines on the same collection, + * e.g., starting with a {@link #search(SearchOperator, SearchOptions) $search} + * or {@link #vectorSearch(FieldSearchPath, Iterable, String, long, VectorSearchOptions) $vectorSearch} stage. + * You may use the {@code $meta: "score"} expression to extract the combined score of a document. + * + * @param pipelines The non-empty input pipelines with unique names. + * @param normalization The way in which the scores produced by the input pipelines are normalized. + * @param options Optional {@code $scoreFusion} pipeline stage fields. + * @return The {@code $scoreFusion} pipeline stage. + * @mongodb.driver.manual reference/operator/aggregation/scoreFusion/ $scoreFusion + * @mongodb.server.release 8.2 + * @since 5.10 + */ + public static Bson scoreFusion(final List pipelines, final ScoreNormalization normalization, + final ScoreFusionOptions options) { + notNull("pipelines", pipelines); + isTrueArgument("pipelines must not be empty", !pipelines.isEmpty()); + Set names = new HashSet<>(); + for (FusionPipeline pipeline : pipelines) { + notNull("pipeline", pipeline); + isTrueArgument("pipeline names must be unique", names.add(pipeline.getName())); + } + notNull("normalization", normalization); + notNull("options", options); + return new ScoreFusionStage(pipelines, normalization, options); + } +``` + +Check the imports at the top of `Aggregates.java`: `java.util.HashSet`, `java.util.Set`, `java.util.List` and static `com.mongodb.assertions.Assertions.isTrueArgument`/`notNull` — most already exist; add any missing. + +Add the nested class next to `VectorSearchBson` (match its style): + +```java + private static final class ScoreFusionStage implements Bson { + private final List pipelines; + private final ScoreNormalization normalization; + private final ScoreFusionOptions options; + + ScoreFusionStage(final List pipelines, final ScoreNormalization normalization, + final ScoreFusionOptions options) { + this.pipelines = pipelines; + this.normalization = normalization; + this.options = options; + } + + @Override + public BsonDocument toBsonDocument(final Class documentClass, final CodecRegistry codecRegistry) { + BsonDocument pipelinesDoc = new BsonDocument(); + for (FusionPipeline pipeline : pipelines) { + BsonArray stages = new BsonArray(); + for (Bson stage : pipeline.getPipeline()) { + stages.add(stage.toBsonDocument(documentClass, codecRegistry)); + } + pipelinesDoc.append(pipeline.getName(), stages); + } + BsonDocument specificationDoc = new BsonDocument("input", + new BsonDocument("pipelines", pipelinesDoc) + .append("normalization", normalization.toBsonValue())); + specificationDoc.putAll(options.toBsonDocument(documentClass, codecRegistry)); + return new BsonDocument("$scoreFusion", specificationDoc); + } + + @Override + public String toString() { + return "Stage{name=$scoreFusion" + + ", pipelines=" + pipelines + + ", normalization=" + normalization + + ", options=" + options + + '}'; + } + } +``` + +(`org.bson.BsonArray` import may be missing — add it.) + +- [ ] **Step 4: Run tests to verify they pass.** `./gradlew :driver-core:test --tests "AggregatesSpecification"` — PASS. + +- [ ] **Step 5: Run the module's static checks** (checkstyle/spotbugs catch Javadoc and style errors early): `./gradlew :driver-core:compileJava checkstyleMain` — expected: BUILD SUCCESSFUL. Fix any violations in the files you touched. + +- [ ] **Step 6: Commit.** + +```bash +git add driver-core/src/main/com/mongodb/client/model/Aggregates.java driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy +git commit -m "JAVA-5990 Add Aggregates.scoreFusion pipeline stage builder" +``` + +--- + +### Task 5: Functional end-to-end tests (plain server, 8.2+) + +**Files:** +- Modify: `driver-core/src/test/functional/com/mongodb/client/model/AggregatesTest.java` + +**Interfaces:** +- Consumes: `Aggregates.scoreFusion(...)` (Task 4), `FusionPipeline.of(...)`, `ScoreNormalization.*`, `scoreFusionOptions()`, `ScoreFusionCombination.*`. + +These tests exercise every option against a real server using `$match` + raw `$score` sub-pipelines with chosen score values, so results are deterministic. They are skipped on servers older than 8.2. + +- [ ] **Step 1: Write the tests.** Add to `AggregatesTest` (follow the class's existing style; `getCollectionHelper()` comes from `OperationTest`). Add imports: `org.bson.BsonArray` (if needed), static imports `com.mongodb.client.model.Aggregates.match`, `com.mongodb.client.model.Aggregates.scoreFusion`, `com.mongodb.client.model.Aggregates.project`, `com.mongodb.client.model.Filters.exists`, `com.mongodb.client.model.ScoreFusionOptions.scoreFusionOptions`, and `java.util.stream.Collectors.toList`. + +```java + // TODO JAVA-6202 replace the raw $score documents in these tests with the $score builder once available + private static final Bson SCORE_BY_X = BsonDocument.parse("{$score: {score: '$x'}}"); + private static final Bson SCORE_BY_Y = BsonDocument.parse("{$score: {score: '$y'}}"); + + private void insertScoreFusionDocuments() { + getCollectionHelper().insertDocuments( + "{_id: 1, x: 10, y: 1}", + "{_id: 2, x: 5, y: 2}", + "{_id: 3, x: 1, y: 3}"); + } + + private List idsFor(final Bson scoreFusionStage) { + return getCollectionHelper().aggregate(singletonList(scoreFusionStage)).stream() + .map(doc -> doc.getInteger("_id")) + .collect(toList()); + } + + @ParameterizedTest + @MethodSource("scoreFusionNormalizations") + public void shouldScoreFusionWithEachNormalization(final ScoreNormalization normalization) { + assumeTrue(serverVersionAtLeast(8, 2)); + insertScoreFusionDocuments(); + List ids = idsFor(scoreFusion( + asList( + FusionPipeline.of("byX", match(exists("x")), SCORE_BY_X), + FusionPipeline.of("byY", match(exists("y")), SCORE_BY_Y)), + normalization)); + assertEquals(3, ids.size()); + } + + private static Stream scoreFusionNormalizations() { + return Stream.of(ScoreNormalization.none(), ScoreNormalization.sigmoid(), ScoreNormalization.minMaxScaler()); + } + + @Test + public void shouldScoreFusionWithWeights() { + assumeTrue(serverVersionAtLeast(8, 2)); + insertScoreFusionDocuments(); + // weight only the "byY" pipeline: expected order is descending y + List ids = idsFor(scoreFusion( + asList( + FusionPipeline.of("byX", match(exists("x")), SCORE_BY_X), + FusionPipeline.of("byY", match(exists("y")), SCORE_BY_Y)), + ScoreNormalization.none(), + scoreFusionOptions().combination( + ScoreFusionCombination.weighted(new Document("byX", 0).append("byY", 1))))); + assertEquals(asList(3, 2, 1), ids); + } + + @Test + public void shouldScoreFusionWithWeightsAndAvgMethod() { + assumeTrue(serverVersionAtLeast(8, 2)); + insertScoreFusionDocuments(); + List ids = idsFor(scoreFusion( + asList( + FusionPipeline.of("byX", match(exists("x")), SCORE_BY_X), + FusionPipeline.of("byY", match(exists("y")), SCORE_BY_Y)), + ScoreNormalization.none(), + scoreFusionOptions().combination( + ScoreFusionCombination.weighted(new Document("byX", 1).append("byY", 0)).avg()))); + assertEquals(asList(1, 2, 3), ids); + } + + @Test + public void shouldScoreFusionWithExpressionCombination() { + assumeTrue(serverVersionAtLeast(8, 2)); + insertScoreFusionDocuments(); + // ignore byX, use 10 * byY: expected order is descending y + List ids = idsFor(scoreFusion( + asList( + FusionPipeline.of("byX", match(exists("x")), SCORE_BY_X), + FusionPipeline.of("byY", match(exists("y")), SCORE_BY_Y)), + ScoreNormalization.none(), + scoreFusionOptions().combination(ScoreFusionCombination.expression( + new Document("$sum", asList( + new Document("$multiply", asList("$$byX", 0)), + new Document("$multiply", asList("$$byY", 10)))))))); + assertEquals(asList(3, 2, 1), ids); + } + + @Test + public void shouldScoreFusionWithScoreDetails() { + assumeTrue(serverVersionAtLeast(8, 2)); + insertScoreFusionDocuments(); + List results = getCollectionHelper().aggregate(asList( + scoreFusion( + asList( + FusionPipeline.of("byX", match(exists("x")), SCORE_BY_X), + FusionPipeline.of("byY", match(exists("y")), SCORE_BY_Y)), + ScoreNormalization.sigmoid(), + scoreFusionOptions().scoreDetails(true)), + project(Projections.fields( + Projections.meta("score", "score"), + Projections.meta("scoreDetails", "scoreDetails"))))); + assertEquals(3, results.size()); + Document details = (Document) results.get(0).get("scoreDetails"); + Assertions.assertNotNull(details); + Assertions.assertNotNull(details.get("details")); + } +``` + +Note on helper types: `getCollectionHelper().aggregate(...)` in `AggregatesTest` returns `List` (the class uses a `Document` codec) — verify by looking at an existing test in the file and adjust `idsFor` accordingly (e.g., if it returns `List`, map with `doc.getInt32("_id").getValue()`). Also verify the exact `Projections.meta` signature (`meta(String fieldName, String metaFieldName)`); if only single-arg variants exist, use `new Document("score", new Document("$meta", "score"))`-style raw projections. + +- [ ] **Step 2: Compile.** `./gradlew :driver-core:compileTestJava` — BUILD SUCCESSFUL. + +- [ ] **Step 3: Run against a server if available.** + +Run: `./gradlew :driver-core:test --tests "com.mongodb.client.model.AggregatesTest" -Dorg.mongodb.test.uri="mongodb://localhost:27017"` +Expected: PASS (tests skipped if the server is older than 8.2 — report which happened). If no server is reachable, state that clearly; do not claim the tests ran. + +- [ ] **Step 4: Commit.** + +```bash +git add driver-core/src/test/functional/com/mongodb/client/model/AggregatesTest.java +git commit -m "JAVA-5990 Add functional tests for the \$scoreFusion stage" +``` + +--- + +### Task 6: Atlas hybrid-search integration test + +**Files:** +- Modify: `driver-core/src/test/functional/com/mongodb/client/model/search/AggregatesSearchIntegrationTest.java` + +**Interfaces:** +- Consumes: `Aggregates.scoreFusion(...)`, `FusionPipeline`, `ScoreNormalization`, `ScoreFusionCombination`, `scoreFusionOptions()`. + +One realistic hybrid test: one `$vectorSearch` + one `$search` sub-pipeline on `sample_mflix.embedded_movies`, fused with normalization + weights. Runs only when `isAtlasSearchTest()` is true (Atlas CI variant); locally it will be skipped — that is the expected outcome. + +- [ ] **Step 1: Write the test.** Add to `AggregatesSearchIntegrationTest`, reusing its existing fixtures (`MFLIX_EMBEDDED_MOVIES_NS`, `QUERY_VECTOR`, `collectionHelpers`, `msgSupplier`, `Asserters`). The class-level `@BeforeEach` already applies `assumeTrue(isAtlasSearchTest())`. Check the class Javadoc header for the search index available on `embedded_movies` and use it for the `$search` sub-pipeline (the vector index is `"sample_mflix__embedded_movies"` as used by the existing `vectorSearch` test); if the header lists no text index on `embedded_movies`, use the index it does list for that collection. + +```java + @Test + void scoreFusion() { + assumeTrue(serverVersionAtLeast(8, 2)); + CollectionHelper collectionHelper = collectionHelpers.get(MFLIX_EMBEDDED_MOVIES_NS); + List pipeline = asList( + Aggregates.scoreFusion( + asList( + FusionPipeline.of("vector", Aggregates.vectorSearch( + fieldPath("plot_embedding"), QUERY_VECTOR, "sample_mflix__embedded_movies", LIMIT, + approximateVectorSearchOptions(LIMIT + 1))), + FusionPipeline.of("text", + Aggregates.search(SearchOperator.text(fieldPath("title"), "train"), + searchOptions().index("default")), + Aggregates.limit(LIMIT))), + ScoreNormalization.sigmoid(), + scoreFusionOptions() + .combination(ScoreFusionCombination.weighted( + new Document("vector", 0.7).append("text", 0.3))) + .scoreDetails(true)), + Aggregates.limit(LIMIT)); + List results = collectionHelper.aggregate(pipeline); + Asserters.nonEmpty().accept(results, msgSupplier(pipeline)); + } +``` + +Add whatever imports the file is missing (`FusionPipeline`, `ScoreNormalization`, `ScoreFusionCombination`, static `scoreFusionOptions`, `org.bson.Document` or use `BsonDocument` for weights — `weighted` accepts any `Bson`). + +- [ ] **Step 2: Compile.** `./gradlew :driver-core:compileTestJava` — BUILD SUCCESSFUL. + +- [ ] **Step 3: Verify the test is skipped locally (not failing).** + +Run: `./gradlew :driver-core:test --tests "com.mongodb.client.model.search.AggregatesSearchIntegrationTest.scoreFusion" -Dorg.mongodb.test.uri="mongodb://localhost:27017"` (only if a local server is available) +Expected: test SKIPPED (assumption `isAtlasSearchTest()` fails). Actual Atlas execution happens in the Evergreen Atlas-search variant. + +- [ ] **Step 4: Commit.** + +```bash +git add driver-core/src/test/functional/com/mongodb/client/model/search/AggregatesSearchIntegrationTest.java +git commit -m "JAVA-5990 Add Atlas hybrid search integration test for \$scoreFusion" +``` + +--- + +### Task 7: Scala wrapper + +**Files:** +- Modify: `driver-scala/src/main/scala/org/mongodb/scala/model/Aggregates.scala` (add `scoreFusion` forwarders next to `facet`/`vectorSearch`) +- Modify: `driver-scala/src/main/scala/org/mongodb/scala/model/package.scala` (add type aliases + `FusionPipeline` companion, next to the `Facet` alias around line 343) + +**Interfaces:** +- Consumes: the Java API from Tasks 1-4. +- Produces: `org.mongodb.scala.model.Aggregates.scoreFusion(pipelines: Seq[FusionPipeline], normalization: ScoreNormalization[, options: ScoreFusionOptions]): Bson` and aliases `FusionPipeline`, `ScoreNormalization`, `ScoreFusionCombination`, `WeightedScoreFusionCombination`, `ScoreFusionOptions`. + +- [ ] **Step 1: Add aliases to `package.scala`** (mirror the `Facet` and `VectorSearchOptions` snippets shown at lines ~339-362 and the search package's alias style; `@Sealed` import already exists in the search package — in `model/package.scala` check whether `Sealed` is imported and follow whatever the file does for other sealed aliases): + +```scala + /** + * A named aggregation pipeline used as an input to a fusion pipeline stage, e.g., `\$scoreFusion`. + * + * @note Requires MongoDB 8.2 or greater + * @since 5.10 + */ + type FusionPipeline = com.mongodb.client.model.FusionPipeline + + /** + * A named aggregation pipeline used as an input to a fusion pipeline stage, e.g., `\$scoreFusion`. + * + * @note Requires MongoDB 8.2 or greater + * @since 5.10 + */ + object FusionPipeline { + + /** + * Construct a new instance + * + * @param name the non-empty pipeline name, unique within the containing stage + * @param pipeline the non-empty pipeline + * @return the new FusionPipeline + */ + def apply(name: String, pipeline: Bson*): FusionPipeline = { + com.mongodb.client.model.FusionPipeline.of(name, pipeline.asJava) + } + } + + /** + * The way in which the scores produced by the `\$scoreFusion` input pipelines are normalized before being combined. + * + * @note Requires MongoDB 8.2 or greater + * @since 5.10 + */ + type ScoreNormalization = com.mongodb.client.model.ScoreNormalization + + /** + * The way in which the normalized scores produced by the `\$scoreFusion` input pipelines are combined. + * + * @note Requires MongoDB 8.2 or greater + * @since 5.10 + */ + type ScoreFusionCombination = com.mongodb.client.model.ScoreFusionCombination + + /** + * A weighted `ScoreFusionCombination`. + * + * @note Requires MongoDB 8.2 or greater + * @since 5.10 + */ + type WeightedScoreFusionCombination = com.mongodb.client.model.WeightedScoreFusionCombination + + /** + * Represents optional fields of the `\$scoreFusion` pipeline stage of an aggregation pipeline. + * + * @note Requires MongoDB 8.2 or greater + * @since 5.10 + */ + type ScoreFusionOptions = com.mongodb.client.model.ScoreFusionOptions +``` + +Scala 2.11 is supported and cannot call static methods on Java interfaces directly from all contexts — check how the codebase handles this for `VectorSearchOptions` (search package exposes only the `type` alias, and users call the Java statics or the search package provides forwarder objects). Mirror exactly what `driver-scala/src/main/scala/org/mongodb/scala/model/search/package.scala` does; if it provides companion forwarder objects for sealed option interfaces, add matching ones for `ScoreNormalization`, `ScoreFusionCombination`, and `ScoreFusionOptions`. + +- [ ] **Step 2: Add forwarders to Scala `Aggregates.scala`** (next to `def facet`, using the file's `JAggregates` alias): + +```scala + /** + * Creates a `\$scoreFusion` pipeline stage, which combines the results of the given input pipelines, + * normalizing and combining the scores they produce. + * + * @param pipelines the non-empty input pipelines with unique names + * @param normalization the way in which the scores produced by the input pipelines are normalized + * @return the `\$scoreFusion` pipeline stage + * @see [[https://www.mongodb.com/docs/manual/reference/operator/aggregation/scoreFusion/ \$scoreFusion]] + * @note Requires MongoDB 8.2 or greater + * @since 5.10 + */ + def scoreFusion(pipelines: Seq[FusionPipeline], normalization: ScoreNormalization): Bson = + JAggregates.scoreFusion(pipelines.asJava, normalization) + + /** + * Creates a `\$scoreFusion` pipeline stage, which combines the results of the given input pipelines, + * normalizing and combining the scores they produce. + * + * @param pipelines the non-empty input pipelines with unique names + * @param normalization the way in which the scores produced by the input pipelines are normalized + * @param options optional `\$scoreFusion` pipeline stage fields + * @return the `\$scoreFusion` pipeline stage + * @see [[https://www.mongodb.com/docs/manual/reference/operator/aggregation/scoreFusion/ \$scoreFusion]] + * @note Requires MongoDB 8.2 or greater + * @since 5.10 + */ + def scoreFusion(pipelines: Seq[FusionPipeline], normalization: ScoreNormalization, options: ScoreFusionOptions): Bson = + JAggregates.scoreFusion(pipelines.asJava, normalization, options) +``` + +- [ ] **Step 3: Run the Scala API-surface test and unit tests.** + +Run: `./gradlew :driver-scala:test --tests "*ApiAliasAndCompanionSpec*" --tests "*AggregatesSpec*"` +Expected: `ApiAliasAndCompanionSpec` fails if a new public Java class lacks an alias — add whatever it reports missing (that is the test's job), then re-run until PASS. + +- [ ] **Step 4: Commit.** + +```bash +git add driver-scala/src/main/scala/org/mongodb/scala/model/Aggregates.scala driver-scala/src/main/scala/org/mongodb/scala/model/package.scala +git commit -m "JAVA-5990 Add Scala wrapper for the \$scoreFusion stage builder" +``` + +--- + +### Task 8: Full verification + +**Files:** none new — fixes only if checks fail. + +- [ ] **Step 1: Run the pre-submission checks from AGENTS.md.** + +Run: `./gradlew spotlessApply docs :driver-core:check scalaCheck` +Expected: BUILD SUCCESSFUL. Common failure causes to fix: checkstyle Javadoc violations, spotbugs warnings on the new nested stage class (add an exclusion only if a false positive, matching existing exclusion style in `config/spotbugs/exclude.xml`), Scala API-surface expectations, clirr (should not trigger — additions only). + +- [ ] **Step 2: Re-run driver-core unit tests.** `./gradlew :driver-core:test --tests "AggregatesSpecification"` — PASS. + +- [ ] **Step 3: Commit any fixes.** + +```bash +git add -A ':!diff.txt' && git commit -m "JAVA-5990 Address static analysis and API surface checks" || echo "nothing to fix" +``` + +- [ ] **Step 4: Report.** Summarize: what passed, what was skipped (functional/Atlas tests without a server), and remind that Evergreen patch build should run the Atlas-search variant before merging. From beffb6def51c02a8df4d39a63fddd84566a12b17 Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Mon, 27 Jul 2026 12:57:38 +0100 Subject: [PATCH 03/24] JAVA-5990 Add ScoreNormalization for the $scoreFusion stage --- .../client/model/ScoreNormalization.java | 79 +++++++++++++++++++ .../client/model/ScoreNormalizationBson.java | 56 +++++++++++++ .../model/AggregatesSpecification.groovy | 9 +++ 3 files changed, 144 insertions(+) create mode 100644 driver-core/src/main/com/mongodb/client/model/ScoreNormalization.java create mode 100644 driver-core/src/main/com/mongodb/client/model/ScoreNormalizationBson.java diff --git a/driver-core/src/main/com/mongodb/client/model/ScoreNormalization.java b/driver-core/src/main/com/mongodb/client/model/ScoreNormalization.java new file mode 100644 index 0000000000..1e73afc74d --- /dev/null +++ b/driver-core/src/main/com/mongodb/client/model/ScoreNormalization.java @@ -0,0 +1,79 @@ +/* + * Copyright 2008-present MongoDB, 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. + */ + +package com.mongodb.client.model; + +import com.mongodb.annotations.Sealed; +import org.bson.BsonString; +import org.bson.BsonValue; + +import static com.mongodb.assertions.Assertions.notNull; + +/** + * The way in which the scores produced by the {@linkplain Aggregates#scoreFusion(java.util.List, ScoreNormalization, + * ScoreFusionOptions) $scoreFusion} input pipelines are normalized before being combined. + * + * @since 5.10 + * @mongodb.server.release 8.2 + */ +@Sealed +public interface ScoreNormalization { + /** + * Returns a {@link ScoreNormalization} instance representing no normalization. + * + * @return The requested {@link ScoreNormalization}. + */ + static ScoreNormalization none() { + return new ScoreNormalizationBson(new BsonString("none")); + } + + /** + * Returns a {@link ScoreNormalization} instance representing normalization via the sigmoid function. + * + * @return The requested {@link ScoreNormalization}. + */ + static ScoreNormalization sigmoid() { + return new ScoreNormalizationBson(new BsonString("sigmoid")); + } + + /** + * Returns a {@link ScoreNormalization} instance representing min-max scaling of the scores to the range [0, 1]. + * + * @return The requested {@link ScoreNormalization}. + */ + static ScoreNormalization minMaxScaler() { + return new ScoreNormalizationBson(new BsonString("minMaxScaler")); + } + + /** + * Creates a {@link ScoreNormalization} from a {@link BsonValue} in situations when there is no builder method + * that better satisfies your needs. + * This method cannot be used to validate the syntax. + * + * @param normalization A {@link BsonValue} representing the required {@link ScoreNormalization}. + * @return The requested {@link ScoreNormalization}. + */ + static ScoreNormalization of(final BsonValue normalization) { + return new ScoreNormalizationBson(notNull("normalization", normalization)); + } + + /** + * Converts this object to {@link BsonValue}. + * + * @return A {@link BsonValue} representing this {@link ScoreNormalization}. + */ + BsonValue toBsonValue(); +} diff --git a/driver-core/src/main/com/mongodb/client/model/ScoreNormalizationBson.java b/driver-core/src/main/com/mongodb/client/model/ScoreNormalizationBson.java new file mode 100644 index 0000000000..70c6730146 --- /dev/null +++ b/driver-core/src/main/com/mongodb/client/model/ScoreNormalizationBson.java @@ -0,0 +1,56 @@ +/* + * Copyright 2008-present MongoDB, 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. + */ + +package com.mongodb.client.model; + +import org.bson.BsonValue; + +import java.util.Objects; + +final class ScoreNormalizationBson implements ScoreNormalization { + private final BsonValue normalization; + + ScoreNormalizationBson(final BsonValue normalization) { + this.normalization = normalization; + } + + @Override + public BsonValue toBsonValue() { + return normalization; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ScoreNormalizationBson that = (ScoreNormalizationBson) o; + return Objects.equals(normalization, that.normalization); + } + + @Override + public int hashCode() { + return Objects.hash(normalization); + } + + @Override + public String toString() { + return "ScoreNormalization{normalization=" + normalization + '}'; + } +} diff --git a/driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy b/driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy index f78aefd51b..e46435f16f 100644 --- a/driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy +++ b/driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy @@ -22,6 +22,7 @@ import com.mongodb.client.model.search.SearchCollector import com.mongodb.client.model.search.SearchOperator import org.bson.BsonDocument import org.bson.BsonInt32 +import org.bson.BsonString import org.bson.Document import org.bson.BinaryVector import org.bson.conversions.Bson @@ -1362,4 +1363,12 @@ class AggregatesSpecification extends Specification { replaceRoot('$a1.b').hashCode() == replaceRoot('$a1.b').hashCode() replaceRoot('$a1').hashCode() == replaceRoot('$a1').hashCode() } + + def 'should create ScoreNormalization'() { + expect: + ScoreNormalization.none().toBsonValue() == new BsonString('none') + ScoreNormalization.sigmoid().toBsonValue() == new BsonString('sigmoid') + ScoreNormalization.minMaxScaler().toBsonValue() == new BsonString('minMaxScaler') + ScoreNormalization.of(new BsonString('sigmoid')).toBsonValue() == new BsonString('sigmoid') + } } From 6cf0342d421e1e578f3c99d173b769cb67fd9039 Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Mon, 27 Jul 2026 13:07:52 +0100 Subject: [PATCH 04/24] JAVA-5990 Add FusionPipeline for fusion pipeline stages --- .../mongodb/client/model/FusionPipeline.java | 116 ++++++++++++++++++ .../model/AggregatesSpecification.groovy | 22 ++++ 2 files changed, 138 insertions(+) create mode 100644 driver-core/src/main/com/mongodb/client/model/FusionPipeline.java diff --git a/driver-core/src/main/com/mongodb/client/model/FusionPipeline.java b/driver-core/src/main/com/mongodb/client/model/FusionPipeline.java new file mode 100644 index 0000000000..e852cf80cf --- /dev/null +++ b/driver-core/src/main/com/mongodb/client/model/FusionPipeline.java @@ -0,0 +1,116 @@ +/* + * Copyright 2008-present MongoDB, 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. + */ + +package com.mongodb.client.model; + +import org.bson.conversions.Bson; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import static com.mongodb.assertions.Assertions.isTrueArgument; +import static com.mongodb.assertions.Assertions.notNull; +import static java.util.Arrays.asList; +import static java.util.Collections.unmodifiableList; + +/** + * A named aggregation pipeline used as an input to a fusion pipeline stage, e.g., + * {@link Aggregates#scoreFusion(List, ScoreNormalization, ScoreFusionOptions) $scoreFusion}. + * The name uniquely identifies the pipeline within the stage and may be referred to + * by other parts of the stage, e.g., as the {@code "$$name"} variable in a + * {@linkplain ScoreFusionCombination#expression(Bson) combination expression}. + * + * @since 5.10 + * @mongodb.server.release 8.2 + */ +public final class FusionPipeline { + private final String name; + private final List pipeline; + + /** + * Creates a new {@link FusionPipeline}. + * + * @param name The non-empty pipeline name, unique within the containing stage. + * @param pipeline The non-empty pipeline. + * @return The requested {@link FusionPipeline}. + */ + public static FusionPipeline of(final String name, final List pipeline) { + return new FusionPipeline(name, pipeline); + } + + /** + * Creates a new {@link FusionPipeline}. + * + * @param name The non-empty pipeline name, unique within the containing stage. + * @param pipeline The non-empty pipeline. + * @return The requested {@link FusionPipeline}. + */ + public static FusionPipeline of(final String name, final Bson... pipeline) { + return new FusionPipeline(name, asList(pipeline)); + } + + private FusionPipeline(final String name, final List pipeline) { + notNull("name", name); + isTrueArgument("name must not be empty", !name.isEmpty()); + notNull("pipeline", pipeline); + isTrueArgument("pipeline must not be empty", !pipeline.isEmpty()); + for (Bson stage : pipeline) { + notNull("stage", stage); + } + this.name = name; + this.pipeline = unmodifiableList(new ArrayList(pipeline)); + } + + /** + * @return the pipeline name + */ + public String getName() { + return name; + } + + /** + * @return the pipeline + */ + public List getPipeline() { + return pipeline; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + FusionPipeline that = (FusionPipeline) o; + return name.equals(that.name) && pipeline.equals(that.pipeline); + } + + @Override + public int hashCode() { + return Objects.hash(name, pipeline); + } + + @Override + public String toString() { + return "FusionPipeline{" + + "name='" + name + '\'' + + ", pipeline=" + pipeline + + '}'; + } +} diff --git a/driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy b/driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy index e46435f16f..d3f76c9420 100644 --- a/driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy +++ b/driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy @@ -1371,4 +1371,26 @@ class AggregatesSpecification extends Specification { ScoreNormalization.minMaxScaler().toBsonValue() == new BsonString('minMaxScaler') ScoreNormalization.of(new BsonString('sigmoid')).toBsonValue() == new BsonString('sigmoid') } + + def 'should create FusionPipeline'() { + when: + def pipeline = FusionPipeline.of('p1', match(eq('x', 1)), limit(2)) + + then: + pipeline.name == 'p1' + pipeline.pipeline.size() == 2 + pipeline == FusionPipeline.of('p1', [match(eq('x', 1)), limit(2)]) + + when: + FusionPipeline.of('', match(eq('x', 1))) + + then: + thrown(IllegalArgumentException) + + when: + FusionPipeline.of('p1', []) + + then: + thrown(IllegalArgumentException) + } } From 41f428d116c3cf8f76add2cb60f5065e56debbaf Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Mon, 27 Jul 2026 13:13:48 +0100 Subject: [PATCH 05/24] JAVA-5990 Add ScoreFusionCombination and ScoreFusionOptions --- .../client/model/ScoreFusionCombination.java | 62 +++++++++++++++++ .../model/ScoreFusionConstructibleBson.java | 69 +++++++++++++++++++ .../client/model/ScoreFusionOptions.java | 69 +++++++++++++++++++ .../model/WeightedScoreFusionCombination.java | 36 ++++++++++ .../model/AggregatesSpecification.groovy | 17 +++++ 5 files changed, 253 insertions(+) create mode 100644 driver-core/src/main/com/mongodb/client/model/ScoreFusionCombination.java create mode 100644 driver-core/src/main/com/mongodb/client/model/ScoreFusionConstructibleBson.java create mode 100644 driver-core/src/main/com/mongodb/client/model/ScoreFusionOptions.java create mode 100644 driver-core/src/main/com/mongodb/client/model/WeightedScoreFusionCombination.java diff --git a/driver-core/src/main/com/mongodb/client/model/ScoreFusionCombination.java b/driver-core/src/main/com/mongodb/client/model/ScoreFusionCombination.java new file mode 100644 index 0000000000..da4d488132 --- /dev/null +++ b/driver-core/src/main/com/mongodb/client/model/ScoreFusionCombination.java @@ -0,0 +1,62 @@ +/* + * Copyright 2008-present MongoDB, 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. + */ + +package com.mongodb.client.model; + +import com.mongodb.annotations.Sealed; +import org.bson.BsonString; +import org.bson.Document; +import org.bson.conversions.Bson; + +import static com.mongodb.assertions.Assertions.notNull; + +/** + * The way in which the normalized scores produced by the input pipelines of the + * {@linkplain Aggregates#scoreFusion(java.util.List, ScoreNormalization, ScoreFusionOptions) $scoreFusion} + * stage are combined into the final score. The server rejects specifying both + * {@linkplain #weighted(Bson) weights} and an {@linkplain #expression(Bson) expression}, + * which is why they are separate factory methods. + * + * @see ScoreFusionOptions#combination(ScoreFusionCombination) + * @since 5.10 + * @mongodb.server.release 8.2 + */ +@Sealed +public interface ScoreFusionCombination extends Bson { + /** + * Returns a {@link WeightedScoreFusionCombination} combining the scores using per-pipeline weights. + * + * @param weights A document mapping {@linkplain FusionPipeline#getName() pipeline names} to non-negative + * numeric weights. Pipelines not mentioned have the server-default weight 1. + * @return The requested {@link WeightedScoreFusionCombination}. + */ + static WeightedScoreFusionCombination weighted(final Bson weights) { + return new ScoreFusionConstructibleBson(new Document("weights", notNull("weights", weights))); + } + + /** + * Returns a {@link ScoreFusionCombination} combining the scores using a custom expression. + * The normalized, weighted score of each input pipeline is available to the expression + * as the variable named after the pipeline, e.g., {@code "$$name"}. + * + * @param expression The combination expression. + * @return The requested {@link ScoreFusionCombination}. + */ + static ScoreFusionCombination expression(final Bson expression) { + return new ScoreFusionConstructibleBson(new Document("method", new BsonString("expression")) + .append("expression", notNull("expression", expression))); + } +} diff --git a/driver-core/src/main/com/mongodb/client/model/ScoreFusionConstructibleBson.java b/driver-core/src/main/com/mongodb/client/model/ScoreFusionConstructibleBson.java new file mode 100644 index 0000000000..e7c4fb5683 --- /dev/null +++ b/driver-core/src/main/com/mongodb/client/model/ScoreFusionConstructibleBson.java @@ -0,0 +1,69 @@ +/* + * Copyright 2008-present MongoDB, 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. + */ + +package com.mongodb.client.model; + +import com.mongodb.annotations.Immutable; +import com.mongodb.internal.client.model.AbstractConstructibleBson; +import org.bson.BsonBoolean; +import org.bson.BsonDocument; +import org.bson.BsonString; +import org.bson.Document; +import org.bson.conversions.Bson; + +import static com.mongodb.assertions.Assertions.notNull; + +final class ScoreFusionConstructibleBson extends AbstractConstructibleBson + implements ScoreFusionOptions, WeightedScoreFusionCombination { + /** + * An {@linkplain Immutable immutable} {@link BsonDocument#isEmpty() empty} instance. + */ + static final ScoreFusionConstructibleBson EMPTY_IMMUTABLE = + new ScoreFusionConstructibleBson(AbstractConstructibleBson.EMPTY_IMMUTABLE); + + ScoreFusionConstructibleBson(final Bson base) { + super(base); + } + + private ScoreFusionConstructibleBson(final Bson base, final Document appended) { + super(base, appended); + } + + @Override + protected ScoreFusionConstructibleBson newSelf(final Bson base, final Document appended) { + return new ScoreFusionConstructibleBson(base, appended); + } + + @Override + public ScoreFusionOptions combination(final ScoreFusionCombination combination) { + return newAppended("combination", notNull("combination", combination)); + } + + @Override + public ScoreFusionOptions scoreDetails(final boolean scoreDetails) { + return newAppended("scoreDetails", BsonBoolean.valueOf(scoreDetails)); + } + + @Override + public ScoreFusionOptions option(final String name, final Object value) { + return newAppended(notNull("name", name), notNull("value", value)); + } + + @Override + public WeightedScoreFusionCombination avg() { + return newAppended("method", new BsonString("avg")); + } +} diff --git a/driver-core/src/main/com/mongodb/client/model/ScoreFusionOptions.java b/driver-core/src/main/com/mongodb/client/model/ScoreFusionOptions.java new file mode 100644 index 0000000000..c967a55a25 --- /dev/null +++ b/driver-core/src/main/com/mongodb/client/model/ScoreFusionOptions.java @@ -0,0 +1,69 @@ +/* + * Copyright 2008-present MongoDB, 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. + */ + +package com.mongodb.client.model; + +import com.mongodb.annotations.Sealed; +import org.bson.conversions.Bson; + +/** + * Represents optional fields of the {@linkplain Aggregates#scoreFusion(java.util.List, ScoreNormalization, + * ScoreFusionOptions) $scoreFusion} pipeline stage of an aggregation pipeline. + * + * @since 5.10 + * @mongodb.server.release 8.2 + */ +@Sealed +public interface ScoreFusionOptions extends Bson { + /** + * Returns {@link ScoreFusionOptions} that represents server defaults. + * + * @return {@link ScoreFusionOptions} that represents server defaults. + */ + static ScoreFusionOptions scoreFusionOptions() { + return ScoreFusionConstructibleBson.EMPTY_IMMUTABLE; + } + + /** + * Creates a new {@link ScoreFusionOptions} with the combination specified. + * If not specified, the server combines the normalized scores using its default method. + * + * @param combination The way in which the normalized scores are combined. + * @return A new {@link ScoreFusionOptions}. + */ + ScoreFusionOptions combination(ScoreFusionCombination combination); + + /** + * Creates a new {@link ScoreFusionOptions} with the scoreDetails flag specified. + * When {@code true}, the server exposes score details via the {@code {$meta: "scoreDetails"}} expression. + * Server default is {@code false}. + * + * @param scoreDetails Whether to include score details. + * @return A new {@link ScoreFusionOptions}. + */ + ScoreFusionOptions scoreDetails(boolean scoreDetails); + + /** + * Creates a new {@link ScoreFusionOptions} with the specified option in situations when there is no builder method + * that better satisfies your needs. + * This method cannot be used to validate the syntax. + * + * @param name The option name. + * @param value The option value. + * @return A new {@link ScoreFusionOptions}. + */ + ScoreFusionOptions option(String name, Object value); +} diff --git a/driver-core/src/main/com/mongodb/client/model/WeightedScoreFusionCombination.java b/driver-core/src/main/com/mongodb/client/model/WeightedScoreFusionCombination.java new file mode 100644 index 0000000000..5d073207f6 --- /dev/null +++ b/driver-core/src/main/com/mongodb/client/model/WeightedScoreFusionCombination.java @@ -0,0 +1,36 @@ +/* + * Copyright 2008-present MongoDB, 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. + */ + +package com.mongodb.client.model; + +import com.mongodb.annotations.Sealed; + +/** + * A {@linkplain ScoreFusionCombination#weighted(org.bson.conversions.Bson) weighted} {@link ScoreFusionCombination}. + * + * @since 5.10 + * @mongodb.server.release 8.2 + */ +@Sealed +public interface WeightedScoreFusionCombination extends ScoreFusionCombination { + /** + * Returns a new {@link WeightedScoreFusionCombination} that instructs the server to combine the + * weighted scores using their average instead of the default combination method. + * + * @return A new {@link WeightedScoreFusionCombination}. + */ + WeightedScoreFusionCombination avg(); +} diff --git a/driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy b/driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy index d3f76c9420..25e9f0d093 100644 --- a/driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy +++ b/driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy @@ -102,6 +102,7 @@ import static com.mongodb.client.model.search.SearchPath.fieldPath import static com.mongodb.client.model.search.SearchPath.wildcardPath import static com.mongodb.client.model.search.VectorSearchOptions.approximateVectorSearchOptions import static com.mongodb.client.model.search.VectorSearchOptions.exactVectorSearchOptions +import static com.mongodb.client.model.ScoreFusionOptions.scoreFusionOptions import static java.util.Arrays.asList import static org.bson.BsonDocument.parse @@ -1393,4 +1394,20 @@ class AggregatesSpecification extends Specification { then: thrown(IllegalArgumentException) } + + def 'should render ScoreFusionCombination and ScoreFusionOptions'() { + expect: + toBson(ScoreFusionCombination.weighted(new Document('p1', 0.3d).append('p2', 0.7d))) == + parse('{weights: {p1: 0.3, p2: 0.7}}') + toBson(ScoreFusionCombination.weighted(new Document('p1', 0.3d)).avg()) == + parse('{weights: {p1: 0.3}, method: "avg"}') + toBson(ScoreFusionCombination.expression(new Document('$sum', ['$$p1', '$$p2']))) == + parse('{method: "expression", expression: {$sum: ["$$p1", "$$p2"]}}') + toBson(scoreFusionOptions()) == parse('{}') + toBson(scoreFusionOptions() + .combination(ScoreFusionCombination.expression(new Document('$sum', ['$$p1', '$$p2']))) + .scoreDetails(true)) == + parse('{combination: {method: "expression", expression: {$sum: ["$$p1", "$$p2"]}}, scoreDetails: true}') + toBson(scoreFusionOptions().option('scoreDetails', true)) == parse('{scoreDetails: true}') + } } From e0182eb407fda234d007af9f67ff63a79a8d7bd5 Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Mon, 27 Jul 2026 13:19:48 +0100 Subject: [PATCH 06/24] JAVA-5990 Add Aggregates.scoreFusion pipeline stage builder --- .../com/mongodb/client/model/Aggregates.java | 90 +++++++++++++++++++ .../model/AggregatesSpecification.groovy | 60 +++++++++++++ 2 files changed, 150 insertions(+) diff --git a/driver-core/src/main/com/mongodb/client/model/Aggregates.java b/driver-core/src/main/com/mongodb/client/model/Aggregates.java index 29531e76e1..ff295fc2d6 100644 --- a/driver-core/src/main/com/mongodb/client/model/Aggregates.java +++ b/driver-core/src/main/com/mongodb/client/model/Aggregates.java @@ -46,9 +46,11 @@ import org.bson.codecs.configuration.CodecRegistry; import org.bson.conversions.Bson; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import static com.mongodb.assertions.Assertions.assertTrue; import static com.mongodb.assertions.Assertions.isTrueArgument; @@ -1093,6 +1095,55 @@ public static Bson rerank( return new RerankBson(query, paths, numDocsToRerank, model); } + /** + * Creates a {@code $scoreFusion} pipeline stage, which combines the results of the given input pipelines, + * normalizing and combining the scores they produce. It must be the first stage of an aggregation pipeline + * run on a collection, and the input pipelines must be scored selection pipelines on the same collection, + * e.g., starting with a {@link #search(SearchOperator, SearchOptions) $search} + * or {@link #vectorSearch(FieldSearchPath, Iterable, String, long, VectorSearchOptions) $vectorSearch} stage. + * You may use the {@code $meta: "score"} expression to extract the combined score of a document. + * + * @param pipelines The non-empty input pipelines with unique names. + * @param normalization The way in which the scores produced by the input pipelines are normalized. + * @return The {@code $scoreFusion} pipeline stage. + * @mongodb.driver.manual reference/operator/aggregation/scoreFusion/ $scoreFusion + * @mongodb.server.release 8.2 + * @since 5.10 + */ + public static Bson scoreFusion(final List pipelines, final ScoreNormalization normalization) { + return scoreFusion(pipelines, normalization, ScoreFusionOptions.scoreFusionOptions()); + } + + /** + * Creates a {@code $scoreFusion} pipeline stage, which combines the results of the given input pipelines, + * normalizing and combining the scores they produce. It must be the first stage of an aggregation pipeline + * run on a collection, and the input pipelines must be scored selection pipelines on the same collection, + * e.g., starting with a {@link #search(SearchOperator, SearchOptions) $search} + * or {@link #vectorSearch(FieldSearchPath, Iterable, String, long, VectorSearchOptions) $vectorSearch} stage. + * You may use the {@code $meta: "score"} expression to extract the combined score of a document. + * + * @param pipelines The non-empty input pipelines with unique names. + * @param normalization The way in which the scores produced by the input pipelines are normalized. + * @param options Optional {@code $scoreFusion} pipeline stage fields. + * @return The {@code $scoreFusion} pipeline stage. + * @mongodb.driver.manual reference/operator/aggregation/scoreFusion/ $scoreFusion + * @mongodb.server.release 8.2 + * @since 5.10 + */ + public static Bson scoreFusion(final List pipelines, final ScoreNormalization normalization, + final ScoreFusionOptions options) { + notNull("pipelines", pipelines); + isTrueArgument("pipelines must not be empty", !pipelines.isEmpty()); + Set names = new HashSet<>(); + for (FusionPipeline pipeline : pipelines) { + notNull("pipeline", pipeline); + isTrueArgument("pipeline names must be unique", names.add(pipeline.getName())); + } + notNull("normalization", normalization); + notNull("options", options); + return new ScoreFusionStage(pipelines, normalization, options); + } + /** * Creates an $unset pipeline stage that removes/excludes fields from documents * @@ -2344,6 +2395,45 @@ public String toString() { } } + private static final class ScoreFusionStage implements Bson { + private final List pipelines; + private final ScoreNormalization normalization; + private final ScoreFusionOptions options; + + ScoreFusionStage(final List pipelines, final ScoreNormalization normalization, + final ScoreFusionOptions options) { + this.pipelines = pipelines; + this.normalization = normalization; + this.options = options; + } + + @Override + public BsonDocument toBsonDocument(final Class documentClass, final CodecRegistry codecRegistry) { + BsonDocument pipelinesDoc = new BsonDocument(); + for (FusionPipeline pipeline : pipelines) { + BsonArray stages = new BsonArray(); + for (Bson stage : pipeline.getPipeline()) { + stages.add(stage.toBsonDocument(documentClass, codecRegistry)); + } + pipelinesDoc.append(pipeline.getName(), stages); + } + BsonDocument specificationDoc = new BsonDocument("input", + new BsonDocument("pipelines", pipelinesDoc) + .append("normalization", normalization.toBsonValue())); + specificationDoc.putAll(options.toBsonDocument(documentClass, codecRegistry)); + return new BsonDocument("$scoreFusion", specificationDoc); + } + + @Override + public String toString() { + return "Stage{name=$scoreFusion" + + ", pipelines=" + pipelines + + ", normalization=" + normalization + + ", options=" + options + + '}'; + } + } + private static class RerankBson implements Bson { private final RerankQuery query; private final List paths; diff --git a/driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy b/driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy index 25e9f0d093..37a8f13e86 100644 --- a/driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy +++ b/driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy @@ -69,6 +69,7 @@ import static com.mongodb.client.model.Aggregates.project import static com.mongodb.client.model.Aggregates.replaceRoot import static com.mongodb.client.model.Aggregates.replaceWith import static com.mongodb.client.model.Aggregates.sample +import static com.mongodb.client.model.Aggregates.scoreFusion import static com.mongodb.client.model.Aggregates.search import static com.mongodb.client.model.Aggregates.searchMeta import static com.mongodb.client.model.Aggregates.set @@ -1410,4 +1411,63 @@ class AggregatesSpecification extends Specification { parse('{combination: {method: "expression", expression: {$sum: ["$$p1", "$$p2"]}}, scoreDetails: true}') toBson(scoreFusionOptions().option('scoreDetails', true)) == parse('{scoreDetails: true}') } + + def 'should render $scoreFusion'() { + expect: + toBson(scoreFusion( + [FusionPipeline.of('p1', match(eq('x', 1))), FusionPipeline.of('p2', match(eq('x', 2)))], + ScoreNormalization.sigmoid())) == + parse('''{$scoreFusion: {input: { + pipelines: {p1: [{$match: {x: 1}}], p2: [{$match: {x: 2}}]}, + normalization: "sigmoid"}}}''') + + toBson(scoreFusion( + [FusionPipeline.of('p1', match(eq('x', 1))), FusionPipeline.of('p2', match(eq('x', 2)), limit(5))], + ScoreNormalization.minMaxScaler(), + scoreFusionOptions() + .combination(ScoreFusionCombination.weighted(new Document('p1', 0.3d).append('p2', 0.7d)).avg()) + .scoreDetails(true))) == + parse('''{$scoreFusion: {input: { + pipelines: {p1: [{$match: {x: 1}}], p2: [{$match: {x: 2}}, {$limit: 5}]}, + normalization: "minMaxScaler"}, + combination: {weights: {p1: 0.3, p2: 0.7}, method: "avg"}, + scoreDetails: true}}''') + + toBson(scoreFusion( + [FusionPipeline.of('p1', match(eq('x', 1))), FusionPipeline.of('p2', match(eq('x', 2)))], + ScoreNormalization.none(), + scoreFusionOptions().combination( + ScoreFusionCombination.expression(new Document('$sum', ['$$p1', '$$p2']))))) == + parse('''{$scoreFusion: {input: { + pipelines: {p1: [{$match: {x: 1}}], p2: [{$match: {x: 2}}]}, + normalization: "none"}, + combination: {method: "expression", expression: {$sum: ["$$p1", "$$p2"]}}}}''') + } + + def 'should validate $scoreFusion arguments'() { + when: + scoreFusion([], ScoreNormalization.none()) + + then: + thrown(IllegalArgumentException) + + when: + scoreFusion([FusionPipeline.of('p1', match(eq('x', 1))), FusionPipeline.of('p1', match(eq('x', 2)))], + ScoreNormalization.none()) + + then: + thrown(IllegalArgumentException) + + when: + scoreFusion(null, ScoreNormalization.none()) + + then: + thrown(IllegalArgumentException) + + when: + scoreFusion([FusionPipeline.of('p1', match(eq('x', 1)))], null) + + then: + thrown(IllegalArgumentException) + } } From 3f062bf8e7f82ed71a29c26af23a24f0efa83039 Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Mon, 27 Jul 2026 13:24:53 +0100 Subject: [PATCH 07/24] JAVA-5990 Add functional tests for the $scoreFusion stage --- .../mongodb/client/model/AggregatesTest.java | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/driver-core/src/test/functional/com/mongodb/client/model/AggregatesTest.java b/driver-core/src/test/functional/com/mongodb/client/model/AggregatesTest.java index 5cb70d4e2e..be8b980436 100644 --- a/driver-core/src/test/functional/com/mongodb/client/model/AggregatesTest.java +++ b/driver-core/src/test/functional/com/mongodb/client/model/AggregatesTest.java @@ -42,11 +42,16 @@ import static com.mongodb.client.model.Accumulators.percentile; import static com.mongodb.client.model.Aggregates.geoNear; import static com.mongodb.client.model.Aggregates.group; +import static com.mongodb.client.model.Aggregates.match; +import static com.mongodb.client.model.Aggregates.project; import static com.mongodb.client.model.Aggregates.rerank; +import static com.mongodb.client.model.Aggregates.scoreFusion; import static com.mongodb.client.model.Aggregates.unset; import static com.mongodb.client.model.Aggregates.vectorSearch; +import static com.mongodb.client.model.Filters.exists; import static com.mongodb.client.model.RerankQuery.rerankQuery; import static com.mongodb.client.model.GeoNearOptions.geoNearOptions; +import static com.mongodb.client.model.ScoreFusionOptions.scoreFusionOptions; import static com.mongodb.client.model.Sorts.ascending; import static com.mongodb.client.model.Windows.Bound.UNBOUNDED; import static com.mongodb.client.model.Windows.documents; @@ -54,6 +59,8 @@ import static com.mongodb.client.model.search.VectorSearchOptions.approximateVectorSearchOptions; import static com.mongodb.client.model.search.VectorSearchQuery.textQuery; import static java.util.Arrays.asList; +import static java.util.Collections.singletonList; +import static java.util.stream.Collectors.toList; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.hasSize; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -453,4 +460,104 @@ public void testRerankWithMultiplePathsAndBsonQuery() { "rerank-2" )); } + + // TODO JAVA-6202 replace the raw $score documents in these tests with the $score builder once available + private static final Bson SCORE_BY_X = BsonDocument.parse("{$score: {score: '$x'}}"); + private static final Bson SCORE_BY_Y = BsonDocument.parse("{$score: {score: '$y'}}"); + + private void insertScoreFusionDocuments() { + getCollectionHelper().insertDocuments( + BsonDocument.parse("{_id: 1, x: 10, y: 1}"), + BsonDocument.parse("{_id: 2, x: 5, y: 2}"), + BsonDocument.parse("{_id: 3, x: 1, y: 3}")); + } + + private List idsFor(final Bson scoreFusionStage) { + return getCollectionHelper().aggregate(singletonList(scoreFusionStage)).stream() + .map(doc -> doc.getInt32("_id").getValue()) + .collect(toList()); + } + + @ParameterizedTest + @MethodSource("scoreFusionNormalizations") + public void shouldScoreFusionWithEachNormalization(final ScoreNormalization normalization) { + assumeTrue(serverVersionAtLeast(8, 2)); + insertScoreFusionDocuments(); + List ids = idsFor(scoreFusion( + asList( + FusionPipeline.of("byX", match(exists("x")), SCORE_BY_X), + FusionPipeline.of("byY", match(exists("y")), SCORE_BY_Y)), + normalization)); + assertEquals(3, ids.size()); + } + + private static Stream scoreFusionNormalizations() { + return Stream.of(ScoreNormalization.none(), ScoreNormalization.sigmoid(), ScoreNormalization.minMaxScaler()); + } + + @Test + public void shouldScoreFusionWithWeights() { + assumeTrue(serverVersionAtLeast(8, 2)); + insertScoreFusionDocuments(); + // weight only the "byY" pipeline: expected order is descending y + List ids = idsFor(scoreFusion( + asList( + FusionPipeline.of("byX", match(exists("x")), SCORE_BY_X), + FusionPipeline.of("byY", match(exists("y")), SCORE_BY_Y)), + ScoreNormalization.none(), + scoreFusionOptions().combination( + ScoreFusionCombination.weighted(new Document("byX", 0).append("byY", 1))))); + assertEquals(asList(3, 2, 1), ids); + } + + @Test + public void shouldScoreFusionWithWeightsAndAvgMethod() { + assumeTrue(serverVersionAtLeast(8, 2)); + insertScoreFusionDocuments(); + List ids = idsFor(scoreFusion( + asList( + FusionPipeline.of("byX", match(exists("x")), SCORE_BY_X), + FusionPipeline.of("byY", match(exists("y")), SCORE_BY_Y)), + ScoreNormalization.none(), + scoreFusionOptions().combination( + ScoreFusionCombination.weighted(new Document("byX", 1).append("byY", 0)).avg()))); + assertEquals(asList(1, 2, 3), ids); + } + + @Test + public void shouldScoreFusionWithExpressionCombination() { + assumeTrue(serverVersionAtLeast(8, 2)); + insertScoreFusionDocuments(); + // ignore byX, use 10 * byY: expected order is descending y + List ids = idsFor(scoreFusion( + asList( + FusionPipeline.of("byX", match(exists("x")), SCORE_BY_X), + FusionPipeline.of("byY", match(exists("y")), SCORE_BY_Y)), + ScoreNormalization.none(), + scoreFusionOptions().combination(ScoreFusionCombination.expression( + new Document("$sum", asList( + new Document("$multiply", asList("$$byX", 0)), + new Document("$multiply", asList("$$byY", 10)))))))); + assertEquals(asList(3, 2, 1), ids); + } + + @Test + public void shouldScoreFusionWithScoreDetails() { + assumeTrue(serverVersionAtLeast(8, 2)); + insertScoreFusionDocuments(); + List results = getCollectionHelper().aggregate(asList( + scoreFusion( + asList( + FusionPipeline.of("byX", match(exists("x")), SCORE_BY_X), + FusionPipeline.of("byY", match(exists("y")), SCORE_BY_Y)), + ScoreNormalization.sigmoid(), + scoreFusionOptions().scoreDetails(true)), + project(Projections.fields( + Projections.meta("score", "score"), + Projections.meta("scoreDetails", "scoreDetails"))))); + assertEquals(3, results.size()); + BsonDocument details = results.get(0).getDocument("scoreDetails"); + Assertions.assertNotNull(details); + Assertions.assertNotNull(details.get("details")); + } } From a3049c184dc24857e66df18cd6665cf65ebf4835 Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Mon, 27 Jul 2026 13:29:11 +0100 Subject: [PATCH 08/24] JAVA-5990 Add Atlas hybrid search integration test for $scoreFusion --- .../AggregatesSearchIntegrationTest.java | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/driver-core/src/test/functional/com/mongodb/client/model/search/AggregatesSearchIntegrationTest.java b/driver-core/src/test/functional/com/mongodb/client/model/search/AggregatesSearchIntegrationTest.java index bc34cb0060..b29dc785da 100644 --- a/driver-core/src/test/functional/com/mongodb/client/model/search/AggregatesSearchIntegrationTest.java +++ b/driver-core/src/test/functional/com/mongodb/client/model/search/AggregatesSearchIntegrationTest.java @@ -19,16 +19,21 @@ import com.mongodb.MongoNamespace; import com.mongodb.assertions.Assertions; import com.mongodb.client.model.Aggregates; +import com.mongodb.client.model.FusionPipeline; +import com.mongodb.client.model.ScoreFusionCombination; +import com.mongodb.client.model.ScoreNormalization; import com.mongodb.client.model.geojson.Point; import com.mongodb.client.model.geojson.Position; import com.mongodb.client.test.CollectionHelper; import org.bson.BsonDocument; import org.bson.BsonString; +import org.bson.Document; import org.bson.codecs.BsonDocumentCodec; import org.bson.conversions.Bson; import org.bson.json.JsonWriterSettings; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; @@ -69,6 +74,7 @@ import static com.mongodb.client.model.Projections.metaSearchHighlights; import static com.mongodb.client.model.Projections.metaSearchScore; import static com.mongodb.client.model.Projections.metaVectorSearchScore; +import static com.mongodb.client.model.ScoreFusionOptions.scoreFusionOptions; import static com.mongodb.client.model.search.FuzzySearchOptions.fuzzySearchOptions; import static com.mongodb.client.model.search.SearchCollector.facet; import static com.mongodb.client.model.search.SearchCount.lowerBound; @@ -330,6 +336,30 @@ void vectorSearchSupportedFilters(final VectorSearchOptions vectorSearchOptions) ); } + @Test + void scoreFusion() { + assumeTrue(serverVersionAtLeast(8, 2)); + CollectionHelper collectionHelper = collectionHelpers.get(MFLIX_EMBEDDED_MOVIES_NS); + List pipeline = asList( + Aggregates.scoreFusion( + asList( + FusionPipeline.of("vector", Aggregates.vectorSearch( + fieldPath("plot_embedding"), QUERY_VECTOR, "sample_mflix__embedded_movies", LIMIT, + approximateVectorSearchOptions(LIMIT + 1))), + FusionPipeline.of("text", + Aggregates.search(SearchOperator.text(fieldPath("title"), "train"), + searchOptions().index("sample_mflix__embedded_movies")), + Aggregates.limit(LIMIT))), + ScoreNormalization.sigmoid(), + scoreFusionOptions() + .combination(ScoreFusionCombination.weighted( + new Document("vector", 0.7).append("text", 0.3))) + .scoreDetails(true)), + Aggregates.limit(LIMIT)); + List results = collectionHelper.aggregate(pipeline); + Asserters.nonEmpty().accept(results, msgSupplier(pipeline)); + } + /** * @param stageUnderTestCreator A {@link CustomizableSearchStageCreator} that is used to create both * {@code $search} and {@code $searchMeta} stages. Any combination of an {@link SearchOperator}/{@link SearchCollector} and From a93e08e70cf361be583109b2603ffc96f5319dbd Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Mon, 27 Jul 2026 13:33:09 +0100 Subject: [PATCH 09/24] JAVA-5990 Add Scala wrapper for the $scoreFusion stage builder --- .../org/mongodb/scala/model/Aggregates.scala | 29 +++++++++ .../org/mongodb/scala/model/package.scala | 64 +++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/driver-scala/src/main/scala/org/mongodb/scala/model/Aggregates.scala b/driver-scala/src/main/scala/org/mongodb/scala/model/Aggregates.scala index 31c8c65ec7..ada1690f3c 100644 --- a/driver-scala/src/main/scala/org/mongodb/scala/model/Aggregates.scala +++ b/driver-scala/src/main/scala/org/mongodb/scala/model/Aggregates.scala @@ -176,6 +176,35 @@ object Aggregates { */ def facet(facets: Facet*): Bson = JAggregates.facet(facets.asJava) + /** + * Creates a `\$scoreFusion` pipeline stage, which combines the results of the given input pipelines, + * normalizing and combining the scores they produce. + * + * @param pipelines the non-empty input pipelines with unique names + * @param normalization the way in which the scores produced by the input pipelines are normalized + * @return the `\$scoreFusion` pipeline stage + * @see [[https://www.mongodb.com/docs/manual/reference/operator/aggregation/scoreFusion/ \$scoreFusion]] + * @note Requires MongoDB 8.2 or greater + * @since 5.10 + */ + def scoreFusion(pipelines: Seq[FusionPipeline], normalization: ScoreNormalization): Bson = + JAggregates.scoreFusion(pipelines.asJava, normalization) + + /** + * Creates a `\$scoreFusion` pipeline stage, which combines the results of the given input pipelines, + * normalizing and combining the scores they produce. + * + * @param pipelines the non-empty input pipelines with unique names + * @param normalization the way in which the scores produced by the input pipelines are normalized + * @param options optional `\$scoreFusion` pipeline stage fields + * @return the `\$scoreFusion` pipeline stage + * @see [[https://www.mongodb.com/docs/manual/reference/operator/aggregation/scoreFusion/ \$scoreFusion]] + * @note Requires MongoDB 8.2 or greater + * @since 5.10 + */ + def scoreFusion(pipelines: Seq[FusionPipeline], normalization: ScoreNormalization, options: ScoreFusionOptions): Bson = + JAggregates.scoreFusion(pipelines.asJava, normalization, options) + /** * Creates a `\$graphLookup` pipeline stage for the specified filter * diff --git a/driver-scala/src/main/scala/org/mongodb/scala/model/package.scala b/driver-scala/src/main/scala/org/mongodb/scala/model/package.scala index 7a92009258..0d70d1c0c0 100644 --- a/driver-scala/src/main/scala/org/mongodb/scala/model/package.scala +++ b/driver-scala/src/main/scala/org/mongodb/scala/model/package.scala @@ -361,6 +361,70 @@ package object model { } } + /** + * A named aggregation pipeline used as an input to a fusion pipeline stage, e.g., `\$scoreFusion`. + * + * @note Requires MongoDB 8.2 or greater + * @since 5.10 + */ + type FusionPipeline = com.mongodb.client.model.FusionPipeline + + /** + * A named aggregation pipeline used as an input to a fusion pipeline stage, e.g., `\$scoreFusion`. + * + * @note Requires MongoDB 8.2 or greater + * @since 5.10 + */ + object FusionPipeline { + + /** + * Construct a new instance + * + * @param name the non-empty pipeline name, unique within the containing stage + * @param pipeline the non-empty pipeline + * @return the new FusionPipeline + */ + def apply(name: String, pipeline: Bson*): FusionPipeline = { + com.mongodb.client.model.FusionPipeline.of(name, pipeline.asJava) + } + } + + /** + * The way in which the scores produced by the `\$scoreFusion` input pipelines are normalized before being combined. + * + * @note Requires MongoDB 8.2 or greater + * @since 5.10 + */ + @Sealed + type ScoreNormalization = com.mongodb.client.model.ScoreNormalization + + /** + * The way in which the normalized scores produced by the `\$scoreFusion` input pipelines are combined. + * + * @note Requires MongoDB 8.2 or greater + * @since 5.10 + */ + @Sealed + type ScoreFusionCombination = com.mongodb.client.model.ScoreFusionCombination + + /** + * A weighted `ScoreFusionCombination`. + * + * @note Requires MongoDB 8.2 or greater + * @since 5.10 + */ + @Sealed + type WeightedScoreFusionCombination = com.mongodb.client.model.WeightedScoreFusionCombination + + /** + * Represents optional fields of the `\$scoreFusion` pipeline stage of an aggregation pipeline. + * + * @note Requires MongoDB 8.2 or greater + * @since 5.10 + */ + @Sealed + type ScoreFusionOptions = com.mongodb.client.model.ScoreFusionOptions + /** * A helper to define new fields for the `\$addFields` pipeline stage * From 0498b71eb1dbccfc716080f27dab533bfc099092 Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Mon, 27 Jul 2026 13:39:28 +0100 Subject: [PATCH 10/24] JAVA-5990 Add Scala companion forwarders for scoreFusion types --- .../scala/model/ScoreFusionCombination.scala | 45 ++++++++++++++ .../scala/model/ScoreFusionOptions.scala | 34 +++++++++++ .../scala/model/ScoreNormalization.scala | 58 +++++++++++++++++++ 3 files changed, 137 insertions(+) create mode 100644 driver-scala/src/main/scala/org/mongodb/scala/model/ScoreFusionCombination.scala create mode 100644 driver-scala/src/main/scala/org/mongodb/scala/model/ScoreFusionOptions.scala create mode 100644 driver-scala/src/main/scala/org/mongodb/scala/model/ScoreNormalization.scala diff --git a/driver-scala/src/main/scala/org/mongodb/scala/model/ScoreFusionCombination.scala b/driver-scala/src/main/scala/org/mongodb/scala/model/ScoreFusionCombination.scala new file mode 100644 index 0000000000..cb07bbd9ca --- /dev/null +++ b/driver-scala/src/main/scala/org/mongodb/scala/model/ScoreFusionCombination.scala @@ -0,0 +1,45 @@ +/* + * Copyright 2008-present MongoDB, 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. + */ +package org.mongodb.scala.model + +import com.mongodb.annotations.Sealed +import com.mongodb.client.model.{ ScoreFusionCombination => JScoreFusionCombination } +import org.mongodb.scala.bson.conversions.Bson + +/** + * The way in which the normalized scores produced by the `\$scoreFusion` input pipelines are combined. + * + * @note Requires MongoDB 8.2 or greater + * @since 5.10 + */ +@Sealed object ScoreFusionCombination { + + /** + * Returns a `WeightedScoreFusionCombination` that combines the normalized scores using the given weights. + * + * @param weights the weights + * @return The requested `WeightedScoreFusionCombination`. + */ + def weighted(weights: Bson): WeightedScoreFusionCombination = JScoreFusionCombination.weighted(weights) + + /** + * Returns a `ScoreFusionCombination` that combines the normalized scores using the given expression. + * + * @param expression the expression + * @return The requested `ScoreFusionCombination`. + */ + def expression(expression: Bson): ScoreFusionCombination = JScoreFusionCombination.expression(expression) +} diff --git a/driver-scala/src/main/scala/org/mongodb/scala/model/ScoreFusionOptions.scala b/driver-scala/src/main/scala/org/mongodb/scala/model/ScoreFusionOptions.scala new file mode 100644 index 0000000000..42263d9dea --- /dev/null +++ b/driver-scala/src/main/scala/org/mongodb/scala/model/ScoreFusionOptions.scala @@ -0,0 +1,34 @@ +/* + * Copyright 2008-present MongoDB, 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. + */ +package org.mongodb.scala.model + +import com.mongodb.client.model.{ ScoreFusionOptions => JScoreFusionOptions } + +/** + * Represents optional fields of the `\$scoreFusion` pipeline stage of an aggregation pipeline. + * + * @note Requires MongoDB 8.2 or greater + * @since 5.10 + */ +object ScoreFusionOptions { + + /** + * Returns `ScoreFusionOptions` that represents server defaults. + * + * @return `ScoreFusionOptions` that represents server defaults. + */ + def scoreFusionOptions(): ScoreFusionOptions = JScoreFusionOptions.scoreFusionOptions() +} diff --git a/driver-scala/src/main/scala/org/mongodb/scala/model/ScoreNormalization.scala b/driver-scala/src/main/scala/org/mongodb/scala/model/ScoreNormalization.scala new file mode 100644 index 0000000000..1a6304564a --- /dev/null +++ b/driver-scala/src/main/scala/org/mongodb/scala/model/ScoreNormalization.scala @@ -0,0 +1,58 @@ +/* + * Copyright 2008-present MongoDB, 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. + */ +package org.mongodb.scala.model + +import com.mongodb.annotations.Sealed +import com.mongodb.client.model.{ ScoreNormalization => JScoreNormalization } +import org.bson.BsonValue + +/** + * The way in which the scores produced by the `\$scoreFusion` input pipelines are normalized before being combined. + * + * @note Requires MongoDB 8.2 or greater + * @since 5.10 + */ +@Sealed object ScoreNormalization { + + /** + * Returns a `ScoreNormalization` instance representing no normalization. + * + * @return The requested `ScoreNormalization`. + */ + def none: ScoreNormalization = JScoreNormalization.none() + + /** + * Returns a `ScoreNormalization` instance representing normalization via the sigmoid function. + * + * @return The requested `ScoreNormalization`. + */ + def sigmoid: ScoreNormalization = JScoreNormalization.sigmoid() + + /** + * Returns a `ScoreNormalization` instance representing min-max scaling of the scores to the range [0, 1]. + * + * @return The requested `ScoreNormalization`. + */ + def minMaxScaler: ScoreNormalization = JScoreNormalization.minMaxScaler() + + /** + * Returns a `ScoreNormalization` instance representing the given normalization. + * + * @param normalization the normalization + * @return The requested `ScoreNormalization`. + */ + def of(normalization: BsonValue): ScoreNormalization = JScoreNormalization.of(normalization) +} From 5067a51d98e02b910c5c07edd5d694df44209b2c Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Mon, 27 Jul 2026 14:26:36 +0100 Subject: [PATCH 11/24] JAVA-5990 Reject blank fusion pipeline names and add validation tests --- .../mongodb/client/model/FusionPipeline.java | 2 +- .../model/AggregatesSpecification.groovy | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/driver-core/src/main/com/mongodb/client/model/FusionPipeline.java b/driver-core/src/main/com/mongodb/client/model/FusionPipeline.java index e852cf80cf..ef35f6567d 100644 --- a/driver-core/src/main/com/mongodb/client/model/FusionPipeline.java +++ b/driver-core/src/main/com/mongodb/client/model/FusionPipeline.java @@ -65,7 +65,7 @@ public static FusionPipeline of(final String name, final Bson... pipeline) { private FusionPipeline(final String name, final List pipeline) { notNull("name", name); - isTrueArgument("name must not be empty", !name.isEmpty()); + isTrueArgument("name must not be empty", !name.trim().isEmpty()); notNull("pipeline", pipeline); isTrueArgument("pipeline must not be empty", !pipeline.isEmpty()); for (Bson stage : pipeline) { diff --git a/driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy b/driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy index 37a8f13e86..c983521b0a 100644 --- a/driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy +++ b/driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy @@ -1394,6 +1394,24 @@ class AggregatesSpecification extends Specification { then: thrown(IllegalArgumentException) + + when: + FusionPipeline.of(' ', match(eq('x', 1))) + + then: + thrown(IllegalArgumentException) + + when: + FusionPipeline.of(null, match(eq('x', 1))) + + then: + thrown(IllegalArgumentException) + + when: + FusionPipeline.of('p1', match(eq('x', 1)), null) + + then: + thrown(IllegalArgumentException) } def 'should render ScoreFusionCombination and ScoreFusionOptions'() { From c69cd6258995dcda6d29933cddc7a11bd9d0fa6c Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Mon, 27 Jul 2026 14:27:27 +0100 Subject: [PATCH 12/24] JAVA-5990 Apply scalafmt formatting to scoreFusion forwarder --- .../src/main/scala/org/mongodb/scala/model/Aggregates.scala | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/driver-scala/src/main/scala/org/mongodb/scala/model/Aggregates.scala b/driver-scala/src/main/scala/org/mongodb/scala/model/Aggregates.scala index ada1690f3c..6f3aee8e1c 100644 --- a/driver-scala/src/main/scala/org/mongodb/scala/model/Aggregates.scala +++ b/driver-scala/src/main/scala/org/mongodb/scala/model/Aggregates.scala @@ -202,7 +202,11 @@ object Aggregates { * @note Requires MongoDB 8.2 or greater * @since 5.10 */ - def scoreFusion(pipelines: Seq[FusionPipeline], normalization: ScoreNormalization, options: ScoreFusionOptions): Bson = + def scoreFusion( + pipelines: Seq[FusionPipeline], + normalization: ScoreNormalization, + options: ScoreFusionOptions + ): Bson = JAggregates.scoreFusion(pipelines.asJava, normalization, options) /** From 24376d887cbc417b260d79681f745e455b3560e4 Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Mon, 27 Jul 2026 14:32:38 +0100 Subject: [PATCH 13/24] Remove docs/superpowers from version control --- .../plans/2026-07-27-scorefusion-builder.md | 1061 ----------------- .../2026-07-27-scorefusion-builder-design.md | 145 --- 2 files changed, 1206 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-27-scorefusion-builder.md delete mode 100644 docs/superpowers/specs/2026-07-27-scorefusion-builder-design.md diff --git a/docs/superpowers/plans/2026-07-27-scorefusion-builder.md b/docs/superpowers/plans/2026-07-27-scorefusion-builder.md deleted file mode 100644 index 0f7e238282..0000000000 --- a/docs/superpowers/plans/2026-07-27-scorefusion-builder.md +++ /dev/null @@ -1,1061 +0,0 @@ -# `$scoreFusion` Aggregation Stage Builder Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add builder support for the `$scoreFusion` aggregation stage (JAVA-5990) to `com.mongodb.client.model`, with Scala wrapper forwarders. - -**Architecture:** Four new public types in `com.mongodb.client.model` — `FusionPipeline` (named sub-pipeline, `Facet`-style), `ScoreNormalization` (`@Sealed` interface + package-private `ScoreNormalizationBson`, modeled on `QuantileMethod`/`QuantileMethodBson`), `ScoreFusionCombination`/`WeightedScoreFusionCombination` and `ScoreFusionOptions` (`@Sealed` interfaces backed by one package-private `ScoreFusionConstructibleBson extends AbstractConstructibleBson`, modeled on `GeoNearConstructibleBson`/`SearchConstructibleBson`) — plus two `Aggregates.scoreFusion(...)` overloads rendering via a private nested `ScoreFusionStage implements Bson`. - -**Tech Stack:** Java 8 source, Gradle, Groovy/Spock unit tests (`AggregatesSpecification.groovy`), JUnit 5 functional tests (`AggregatesTest`), Atlas-gated JUnit 5 test (`AggregatesSearchIntegrationTest`), Scala forwarders. - -**Spec:** `docs/superpowers/specs/2026-07-27-scorefusion-builder-design.md` - -## Global Constraints - -- Java 8 language level only (no `var`, `List.of`, `Stream.toList`, etc.). -- Every new file starts with the Apache license header containing `Copyright 2008-present MongoDB, Inc.` (copy the exact 15-line header from `driver-core/src/main/com/mongodb/client/model/Facet.java`). -- New public API Javadoc: `@since 5.10`, `@mongodb.server.release 8.2`. Not `@Beta`. -- Package `com.mongodb.client.model` is NOT `@NonNullApi` — do not rely on it; use explicit `notNull(...)` runtime checks (`com.mongodb.assertions.Assertions`). -- Do not reformat code outside your changes. Run `./gradlew :driver-core:spotlessApply` if formatting complains (spotless does not cover Java here, but checkstyle does — match surrounding style, 4-space indent, `final` parameters). -- All work on branch `nh/hybrid_search`. Commit after each task. -- Working directory for all commands: `/Users/nabil.hachicha/MongoDB/mongo-java-driver/nabil_wt`. -- Unit test runs: `./gradlew :driver-core:test --tests "AggregatesSpecification"` (Spock). Functional tests need a running MongoDB: `./gradlew :driver-core:test --tests "com.mongodb.client.model.AggregatesTest" -Dorg.mongodb.test.uri="mongodb://localhost:27017"` — if no server is available, compile only (`./gradlew :driver-core:compileTestJava`) and say so; never claim tests passed without running them. - ---- - -### Task 1: `ScoreNormalization` - -**Files:** -- Create: `driver-core/src/main/com/mongodb/client/model/ScoreNormalization.java` -- Create: `driver-core/src/main/com/mongodb/client/model/ScoreNormalizationBson.java` -- Test: `driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy` - -**Interfaces:** -- Consumes: nothing new. -- Produces: `public interface ScoreNormalization` with `static ScoreNormalization none()`, `static ScoreNormalization sigmoid()`, `static ScoreNormalization minMaxScaler()`, `static ScoreNormalization of(BsonValue)`, and instance method `BsonValue toBsonValue()`. Task 4's stage rendering calls `toBsonValue()`. - -- [ ] **Step 1: Write the failing test.** Add to `AggregatesSpecification.groovy` (inside the class, near the other tests; add `import com.mongodb.client.model.ScoreNormalization` is unnecessary — same package): - -```groovy -def 'should create ScoreNormalization'() { - expect: - ScoreNormalization.none().toBsonValue() == new BsonString('none') - ScoreNormalization.sigmoid().toBsonValue() == new BsonString('sigmoid') - ScoreNormalization.minMaxScaler().toBsonValue() == new BsonString('minMaxScaler') - ScoreNormalization.of(new BsonString('sigmoid')).toBsonValue() == new BsonString('sigmoid') -} -``` - -Add `import org.bson.BsonString` to the spec's imports if not present. - -- [ ] **Step 2: Run test to verify it fails.** - -Run: `./gradlew :driver-core:test --tests "AggregatesSpecification"` -Expected: compilation failure — `unable to resolve class ScoreNormalization`. - -- [ ] **Step 3: Implement.** `ScoreNormalization.java` (license header first, as in every new file): - -```java -package com.mongodb.client.model; - -import com.mongodb.annotations.Sealed; -import org.bson.BsonString; -import org.bson.BsonValue; - -import static com.mongodb.assertions.Assertions.notNull; - -/** - * The way in which the scores produced by the {@linkplain Aggregates#scoreFusion(java.util.List, ScoreNormalization, - * ScoreFusionOptions) $scoreFusion} input pipelines are normalized before being combined. - * - * @since 5.10 - * @mongodb.server.release 8.2 - */ -@Sealed -public interface ScoreNormalization { - /** - * Returns a {@link ScoreNormalization} instance representing no normalization. - * - * @return The requested {@link ScoreNormalization}. - */ - static ScoreNormalization none() { - return new ScoreNormalizationBson(new BsonString("none")); - } - - /** - * Returns a {@link ScoreNormalization} instance representing normalization via the sigmoid function. - * - * @return The requested {@link ScoreNormalization}. - */ - static ScoreNormalization sigmoid() { - return new ScoreNormalizationBson(new BsonString("sigmoid")); - } - - /** - * Returns a {@link ScoreNormalization} instance representing min-max scaling of the scores to the range [0, 1]. - * - * @return The requested {@link ScoreNormalization}. - */ - static ScoreNormalization minMaxScaler() { - return new ScoreNormalizationBson(new BsonString("minMaxScaler")); - } - - /** - * Creates a {@link ScoreNormalization} from a {@link BsonValue} in situations when there is no builder method - * that better satisfies your needs. - * This method cannot be used to validate the syntax. - * - * @param normalization A {@link BsonValue} representing the required {@link ScoreNormalization}. - * @return The requested {@link ScoreNormalization}. - */ - static ScoreNormalization of(final BsonValue normalization) { - return new ScoreNormalizationBson(notNull("normalization", normalization)); - } - - /** - * Converts this object to {@link BsonValue}. - * - * @return A {@link BsonValue} representing this {@link ScoreNormalization}. - */ - BsonValue toBsonValue(); -} -``` - -`ScoreNormalizationBson.java` — copy the structure of `QuantileMethodBson.java` (same package): a package-private `final class ScoreNormalizationBson implements ScoreNormalization` holding a `private final BsonValue normalization`, constructor assigning it, `toBsonValue()` returning it, plus `equals`/`hashCode` delegating to the field and `toString()` returning `"ScoreNormalization{normalization=" + normalization + '}'`. - -- [ ] **Step 4: Run test to verify it passes.** - -Run: `./gradlew :driver-core:test --tests "AggregatesSpecification"` -Expected: PASS. - -- [ ] **Step 5: Commit.** - -```bash -git add driver-core/src/main/com/mongodb/client/model/ScoreNormalization.java driver-core/src/main/com/mongodb/client/model/ScoreNormalizationBson.java driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy -git commit -m "JAVA-5990 Add ScoreNormalization for the \$scoreFusion stage" -``` - ---- - -### Task 2: `FusionPipeline` - -**Files:** -- Create: `driver-core/src/main/com/mongodb/client/model/FusionPipeline.java` -- Test: `driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy` - -**Interfaces:** -- Produces: `public final class FusionPipeline` with `static FusionPipeline of(String name, List pipeline)`, `static FusionPipeline of(String name, Bson... pipeline)`, `String getName()`, `List getPipeline()`. Task 4 consumes both getters. Name deliberately fusion-generic so a future `$rankFusion` builder reuses it. - -- [ ] **Step 1: Write the failing test.** - -```groovy -def 'should create FusionPipeline'() { - when: - def pipeline = FusionPipeline.of('p1', match(eq('x', 1)), limit(2)) - - then: - pipeline.name == 'p1' - pipeline.pipeline.size() == 2 - pipeline == FusionPipeline.of('p1', [match(eq('x', 1)), limit(2)]) - - when: - FusionPipeline.of('', match(eq('x', 1))) - - then: - thrown(IllegalArgumentException) - - when: - FusionPipeline.of('p1', []) - - then: - thrown(IllegalArgumentException) -} -``` - -(`match`, `limit`, `eq` are already statically imported in this spec.) - -- [ ] **Step 2: Run test to verify it fails.** `./gradlew :driver-core:test --tests "AggregatesSpecification"` — compilation failure on `FusionPipeline`. - -- [ ] **Step 3: Implement.** `FusionPipeline.java` — model on `Facet.java` but final with static factories: - -```java -package com.mongodb.client.model; - -import org.bson.conversions.Bson; - -import java.util.List; -import java.util.Objects; - -import static com.mongodb.assertions.Assertions.isTrueArgument; -import static com.mongodb.assertions.Assertions.notNull; -import static java.util.Arrays.asList; -import static java.util.Collections.unmodifiableList; - -/** - * A named aggregation pipeline used as an input to a fusion pipeline stage, e.g., - * {@link Aggregates#scoreFusion(List, ScoreNormalization, ScoreFusionOptions) $scoreFusion}. - * The name uniquely identifies the pipeline within the stage and may be referred to - * by other parts of the stage, e.g., as the {@code "$$name"} variable in a - * {@linkplain ScoreFusionCombination#expression(Bson) combination expression}. - * - * @since 5.10 - * @mongodb.server.release 8.2 - */ -public final class FusionPipeline { - private final String name; - private final List pipeline; - - /** - * Creates a new {@link FusionPipeline}. - * - * @param name The non-empty pipeline name, unique within the containing stage. - * @param pipeline The non-empty pipeline. - * @return The requested {@link FusionPipeline}. - */ - public static FusionPipeline of(final String name, final List pipeline) { - return new FusionPipeline(name, pipeline); - } - - /** - * Creates a new {@link FusionPipeline}. - * - * @param name The non-empty pipeline name, unique within the containing stage. - * @param pipeline The non-empty pipeline. - * @return The requested {@link FusionPipeline}. - */ - public static FusionPipeline of(final String name, final Bson... pipeline) { - return new FusionPipeline(name, asList(pipeline)); - } - - private FusionPipeline(final String name, final List pipeline) { - notNull("name", name); - isTrueArgument("name must not be empty", !name.isEmpty()); - notNull("pipeline", pipeline); - isTrueArgument("pipeline must not be empty", !pipeline.isEmpty()); - for (Bson stage : pipeline) { - notNull("stage", stage); - } - this.name = name; - this.pipeline = unmodifiableList(pipeline); - } - - /** - * @return the pipeline name - */ - public String getName() { - return name; - } - - /** - * @return the pipeline - */ - public List getPipeline() { - return pipeline; - } - - @Override - public boolean equals(final Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - FusionPipeline that = (FusionPipeline) o; - return name.equals(that.name) && pipeline.equals(that.pipeline); - } - - @Override - public int hashCode() { - return Objects.hash(name, pipeline); - } - - @Override - public String toString() { - return "FusionPipeline{" - + "name='" + name + '\'' - + ", pipeline=" + pipeline - + '}'; - } -} -``` - -Note: `unmodifiableList(pipeline)` needs an unchecked cast to `List` — implement as `this.pipeline = unmodifiableList(new java.util.ArrayList(pipeline));` typed `List` internally (defensive copy; adjust the field type to `List` and the getter return type stays `List`). - -- [ ] **Step 4: Run test to verify it passes.** `./gradlew :driver-core:test --tests "AggregatesSpecification"` — PASS. - -- [ ] **Step 5: Commit.** - -```bash -git add driver-core/src/main/com/mongodb/client/model/FusionPipeline.java driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy -git commit -m "JAVA-5990 Add FusionPipeline for fusion pipeline stages" -``` - ---- - -### Task 3: `ScoreFusionCombination`, `WeightedScoreFusionCombination`, `ScoreFusionOptions` - -**Files:** -- Create: `driver-core/src/main/com/mongodb/client/model/ScoreFusionCombination.java` -- Create: `driver-core/src/main/com/mongodb/client/model/WeightedScoreFusionCombination.java` -- Create: `driver-core/src/main/com/mongodb/client/model/ScoreFusionOptions.java` -- Create: `driver-core/src/main/com/mongodb/client/model/ScoreFusionConstructibleBson.java` -- Test: `driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy` - -**Interfaces:** -- Consumes: nothing from earlier tasks (independent of Tasks 1-2). -- Produces (Task 4 consumes `ScoreFusionOptions` as a `Bson` whose document holds the optional top-level `combination`/`scoreDetails` fields): - - `interface ScoreFusionCombination extends Bson` with `static WeightedScoreFusionCombination weighted(Bson weights)` and `static ScoreFusionCombination expression(Bson expression)`. - - `interface WeightedScoreFusionCombination extends ScoreFusionCombination` with `WeightedScoreFusionCombination avg()` (realizes the spec's `.method(avg)`; emits `method: "avg"` — no extra public enum needed since `expression(...)` implies `method: "expression"` automatically and the server default applies when unset). - - `interface ScoreFusionOptions extends Bson` with `static ScoreFusionOptions scoreFusionOptions()`, `ScoreFusionOptions combination(ScoreFusionCombination combination)`, `ScoreFusionOptions scoreDetails(boolean scoreDetails)`, `ScoreFusionOptions option(String name, Object value)`. - -- [ ] **Step 1: Write the failing test.** - -```groovy -def 'should render ScoreFusionCombination and ScoreFusionOptions'() { - expect: - toBson(ScoreFusionCombination.weighted(new Document('p1', 0.3d).append('p2', 0.7d))) == - parse('{weights: {p1: 0.3, p2: 0.7}}') - toBson(ScoreFusionCombination.weighted(new Document('p1', 0.3d)).avg()) == - parse('{weights: {p1: 0.3}, method: "avg"}') - toBson(ScoreFusionCombination.expression(new Document('$sum', ['$$p1', '$$p2']))) == - parse('{method: "expression", expression: {$sum: ["$$p1", "$$p2"]}}') - toBson(scoreFusionOptions()) == parse('{}') - toBson(scoreFusionOptions() - .combination(ScoreFusionCombination.expression(new Document('$sum', ['$$p1', '$$p2']))) - .scoreDetails(true)) == - parse('{combination: {method: "expression", expression: {$sum: ["$$p1", "$$p2"]}}, scoreDetails: true}') - toBson(scoreFusionOptions().option('scoreDetails', true)) == parse('{scoreDetails: true}') -} -``` - -Add static import `com.mongodb.client.model.ScoreFusionOptions.scoreFusionOptions` and (if missing) `org.bson.Document` import to the spec. `toBson`/`parse` already exist in the spec. - -- [ ] **Step 2: Run test to verify it fails.** `./gradlew :driver-core:test --tests "AggregatesSpecification"` — compilation failure. - -- [ ] **Step 3: Implement.** One backing class implements all three interfaces (pattern: `SearchConstructibleBson` implementing many interfaces; base pattern: `GeoNearConstructibleBson`). - -`ScoreFusionConstructibleBson.java` (package-private): - -```java -package com.mongodb.client.model; - -import com.mongodb.annotations.Immutable; -import com.mongodb.internal.client.model.AbstractConstructibleBson; -import org.bson.BsonBoolean; -import org.bson.BsonDocument; -import org.bson.BsonString; -import org.bson.Document; -import org.bson.conversions.Bson; - -import static com.mongodb.assertions.Assertions.notNull; - -final class ScoreFusionConstructibleBson extends AbstractConstructibleBson - implements ScoreFusionOptions, WeightedScoreFusionCombination { - /** - * An {@linkplain Immutable immutable} {@link BsonDocument#isEmpty() empty} instance. - */ - static final ScoreFusionConstructibleBson EMPTY_IMMUTABLE = - new ScoreFusionConstructibleBson(AbstractConstructibleBson.EMPTY_IMMUTABLE); - - ScoreFusionConstructibleBson(final Bson base) { - super(base); - } - - private ScoreFusionConstructibleBson(final Bson base, final Document appended) { - super(base, appended); - } - - @Override - protected ScoreFusionConstructibleBson newSelf(final Bson base, final Document appended) { - return new ScoreFusionConstructibleBson(base, appended); - } - - @Override - public ScoreFusionOptions combination(final ScoreFusionCombination combination) { - return newAppended("combination", notNull("combination", combination)); - } - - @Override - public ScoreFusionOptions scoreDetails(final boolean scoreDetails) { - return newAppended("scoreDetails", BsonBoolean.valueOf(scoreDetails)); - } - - @Override - public ScoreFusionOptions option(final String name, final Object value) { - return newAppended(notNull("name", name), notNull("value", value)); - } - - @Override - public WeightedScoreFusionCombination avg() { - return newAppended("method", new BsonString("avg")); - } -} -``` - -`ScoreFusionCombination.java`: - -```java -package com.mongodb.client.model; - -import com.mongodb.annotations.Sealed; -import org.bson.BsonString; -import org.bson.Document; -import org.bson.conversions.Bson; - -import static com.mongodb.assertions.Assertions.notNull; - -/** - * The way in which the normalized scores produced by the input pipelines of the - * {@linkplain Aggregates#scoreFusion(java.util.List, ScoreNormalization, ScoreFusionOptions) $scoreFusion} - * stage are combined into the final score. The server rejects specifying both - * {@linkplain #weighted(Bson) weights} and an {@linkplain #expression(Bson) expression}, - * which is why they are separate factory methods. - * - * @see ScoreFusionOptions#combination(ScoreFusionCombination) - * @since 5.10 - * @mongodb.server.release 8.2 - */ -@Sealed -public interface ScoreFusionCombination extends Bson { - /** - * Returns a {@link WeightedScoreFusionCombination} combining the scores using per-pipeline weights. - * - * @param weights A document mapping {@linkplain FusionPipeline#getName() pipeline names} to non-negative - * numeric weights. Pipelines not mentioned have the server-default weight 1. - * @return The requested {@link WeightedScoreFusionCombination}. - */ - static WeightedScoreFusionCombination weighted(final Bson weights) { - return new ScoreFusionConstructibleBson(new Document("weights", notNull("weights", weights))); - } - - /** - * Returns a {@link ScoreFusionCombination} combining the scores using a custom expression. - * The normalized, weighted score of each input pipeline is available to the expression - * as the variable named after the pipeline, e.g., {@code "$$name"}. - * - * @param expression The combination expression. - * @return The requested {@link ScoreFusionCombination}. - */ - static ScoreFusionCombination expression(final Bson expression) { - return new ScoreFusionConstructibleBson(new Document("method", new BsonString("expression")) - .append("expression", notNull("expression", expression))); - } -} -``` - -Note: `new ScoreFusionConstructibleBson(new Document(...))` uses the package-private `(Bson base)` constructor. The `Document` two-key chain in `expression(...)`: `new Document("method", ...).append("expression", ...)` returns `Document` — fine. - -`WeightedScoreFusionCombination.java`: - -```java -package com.mongodb.client.model; - -import com.mongodb.annotations.Sealed; - -/** - * A {@linkplain ScoreFusionCombination#weighted(org.bson.conversions.Bson) weighted} {@link ScoreFusionCombination}. - * - * @since 5.10 - * @mongodb.server.release 8.2 - */ -@Sealed -public interface WeightedScoreFusionCombination extends ScoreFusionCombination { - /** - * Returns a new {@link WeightedScoreFusionCombination} that instructs the server to combine the - * weighted scores using their average instead of the default combination method. - * - * @return A new {@link WeightedScoreFusionCombination}. - */ - WeightedScoreFusionCombination avg(); -} -``` - -`ScoreFusionOptions.java`: - -```java -package com.mongodb.client.model; - -import com.mongodb.annotations.Sealed; -import org.bson.conversions.Bson; - -/** - * Represents optional fields of the {@linkplain Aggregates#scoreFusion(java.util.List, ScoreNormalization, - * ScoreFusionOptions) $scoreFusion} pipeline stage of an aggregation pipeline. - * - * @since 5.10 - * @mongodb.server.release 8.2 - */ -@Sealed -public interface ScoreFusionOptions extends Bson { - /** - * Returns {@link ScoreFusionOptions} that represents server defaults. - * - * @return {@link ScoreFusionOptions} that represents server defaults. - */ - static ScoreFusionOptions scoreFusionOptions() { - return ScoreFusionConstructibleBson.EMPTY_IMMUTABLE; - } - - /** - * Creates a new {@link ScoreFusionOptions} with the combination specified. - * If not specified, the server combines the normalized scores using its default method. - * - * @param combination The way in which the normalized scores are combined. - * @return A new {@link ScoreFusionOptions}. - */ - ScoreFusionOptions combination(ScoreFusionCombination combination); - - /** - * Creates a new {@link ScoreFusionOptions} with the scoreDetails flag specified. - * When {@code true}, the server exposes score details via the {@code {$meta: "scoreDetails"}} expression. - * Server default is {@code false}. - * - * @param scoreDetails Whether to include score details. - * @return A new {@link ScoreFusionOptions}. - */ - ScoreFusionOptions scoreDetails(boolean scoreDetails); - - /** - * Creates a new {@link ScoreFusionOptions} with the specified option in situations when there is no builder method - * that better satisfies your needs. - * This method cannot be used to validate the syntax. - * - * @param name The option name. - * @param value The option value. - * @return A new {@link ScoreFusionOptions}. - */ - ScoreFusionOptions option(String name, Object value); -} -``` - -- [ ] **Step 4: Run test to verify it passes.** `./gradlew :driver-core:test --tests "AggregatesSpecification"` — PASS. - -- [ ] **Step 5: Commit.** - -```bash -git add driver-core/src/main/com/mongodb/client/model/ScoreFusionCombination.java driver-core/src/main/com/mongodb/client/model/WeightedScoreFusionCombination.java driver-core/src/main/com/mongodb/client/model/ScoreFusionOptions.java driver-core/src/main/com/mongodb/client/model/ScoreFusionConstructibleBson.java driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy -git commit -m "JAVA-5990 Add ScoreFusionCombination and ScoreFusionOptions" -``` - ---- - -### Task 4: `Aggregates.scoreFusion` stage - -**Files:** -- Modify: `driver-core/src/main/com/mongodb/client/model/Aggregates.java` (public factories near `vectorSearch` around line 1030; nested stage class near `VectorSearchBson` around line 2310) -- Test: `driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy` - -**Interfaces:** -- Consumes: `FusionPipeline.getName()/getPipeline()` (Task 2), `ScoreNormalization.toBsonValue()` (Task 1), `ScoreFusionOptions`/`scoreFusionOptions()` (Task 3). -- Produces: `public static Bson scoreFusion(List pipelines, ScoreNormalization normalization)` and `public static Bson scoreFusion(List pipelines, ScoreNormalization normalization, ScoreFusionOptions options)`. Tasks 5-7 consume these. - -- [ ] **Step 1: Write the failing tests.** - -```groovy -def 'should render $scoreFusion'() { - expect: - toBson(scoreFusion( - [FusionPipeline.of('p1', match(eq('x', 1))), FusionPipeline.of('p2', match(eq('x', 2)))], - ScoreNormalization.sigmoid())) == - parse('''{$scoreFusion: {input: { - pipelines: {p1: [{$match: {x: 1}}], p2: [{$match: {x: 2}}]}, - normalization: "sigmoid"}}}''') - - toBson(scoreFusion( - [FusionPipeline.of('p1', match(eq('x', 1))), FusionPipeline.of('p2', match(eq('x', 2)), limit(5))], - ScoreNormalization.minMaxScaler(), - scoreFusionOptions() - .combination(ScoreFusionCombination.weighted(new Document('p1', 0.3d).append('p2', 0.7d)).avg()) - .scoreDetails(true))) == - parse('''{$scoreFusion: {input: { - pipelines: {p1: [{$match: {x: 1}}], p2: [{$match: {x: 2}}, {$limit: 5}]}, - normalization: "minMaxScaler"}, - combination: {weights: {p1: 0.3, p2: 0.7}, method: "avg"}, - scoreDetails: true}}''') - - toBson(scoreFusion( - [FusionPipeline.of('p1', match(eq('x', 1))), FusionPipeline.of('p2', match(eq('x', 2)))], - ScoreNormalization.none(), - scoreFusionOptions().combination( - ScoreFusionCombination.expression(new Document('$sum', ['$$p1', '$$p2']))))) == - parse('''{$scoreFusion: {input: { - pipelines: {p1: [{$match: {x: 1}}], p2: [{$match: {x: 2}}]}, - normalization: "none"}, - combination: {method: "expression", expression: {$sum: ["$$p1", "$$p2"]}}}}''') -} - -def 'should validate $scoreFusion arguments'() { - when: - scoreFusion([], ScoreNormalization.none()) - - then: - thrown(IllegalArgumentException) - - when: - scoreFusion([FusionPipeline.of('p1', match(eq('x', 1))), FusionPipeline.of('p1', match(eq('x', 2)))], - ScoreNormalization.none()) - - then: - thrown(IllegalArgumentException) - - when: - scoreFusion(null, ScoreNormalization.none()) - - then: - thrown(IllegalArgumentException) - - when: - scoreFusion([FusionPipeline.of('p1', match(eq('x', 1)))], null) - - then: - thrown(IllegalArgumentException) -} -``` - -Add static import `com.mongodb.client.model.Aggregates.scoreFusion` to the spec if `Aggregates.*` is not already imported (the spec statically imports individual `Aggregates` methods — follow that pattern). - -- [ ] **Step 2: Run tests to verify they fail.** `./gradlew :driver-core:test --tests "AggregatesSpecification"` — compilation failure on `scoreFusion`. - -- [ ] **Step 3: Implement.** In `Aggregates.java`, add the two factories after the last `vectorSearch` overload (keep the file's Javadoc style): - -```java - /** - * Creates a {@code $scoreFusion} pipeline stage, which combines the results of the given input pipelines, - * normalizing and combining the scores they produce. It must be the first stage of an aggregation pipeline - * run on a collection, and the input pipelines must be scored selection pipelines on the same collection, - * e.g., starting with a {@link #search(SearchOperator, SearchOptions) $search} - * or {@link #vectorSearch(FieldSearchPath, Iterable, String, long, VectorSearchOptions) $vectorSearch} stage. - * You may use the {@code $meta: "score"} expression to extract the combined score of a document. - * - * @param pipelines The non-empty input pipelines with unique names. - * @param normalization The way in which the scores produced by the input pipelines are normalized. - * @return The {@code $scoreFusion} pipeline stage. - * @mongodb.driver.manual reference/operator/aggregation/scoreFusion/ $scoreFusion - * @mongodb.server.release 8.2 - * @since 5.10 - */ - public static Bson scoreFusion(final List pipelines, final ScoreNormalization normalization) { - return scoreFusion(pipelines, normalization, ScoreFusionOptions.scoreFusionOptions()); - } - - /** - * Creates a {@code $scoreFusion} pipeline stage, which combines the results of the given input pipelines, - * normalizing and combining the scores they produce. It must be the first stage of an aggregation pipeline - * run on a collection, and the input pipelines must be scored selection pipelines on the same collection, - * e.g., starting with a {@link #search(SearchOperator, SearchOptions) $search} - * or {@link #vectorSearch(FieldSearchPath, Iterable, String, long, VectorSearchOptions) $vectorSearch} stage. - * You may use the {@code $meta: "score"} expression to extract the combined score of a document. - * - * @param pipelines The non-empty input pipelines with unique names. - * @param normalization The way in which the scores produced by the input pipelines are normalized. - * @param options Optional {@code $scoreFusion} pipeline stage fields. - * @return The {@code $scoreFusion} pipeline stage. - * @mongodb.driver.manual reference/operator/aggregation/scoreFusion/ $scoreFusion - * @mongodb.server.release 8.2 - * @since 5.10 - */ - public static Bson scoreFusion(final List pipelines, final ScoreNormalization normalization, - final ScoreFusionOptions options) { - notNull("pipelines", pipelines); - isTrueArgument("pipelines must not be empty", !pipelines.isEmpty()); - Set names = new HashSet<>(); - for (FusionPipeline pipeline : pipelines) { - notNull("pipeline", pipeline); - isTrueArgument("pipeline names must be unique", names.add(pipeline.getName())); - } - notNull("normalization", normalization); - notNull("options", options); - return new ScoreFusionStage(pipelines, normalization, options); - } -``` - -Check the imports at the top of `Aggregates.java`: `java.util.HashSet`, `java.util.Set`, `java.util.List` and static `com.mongodb.assertions.Assertions.isTrueArgument`/`notNull` — most already exist; add any missing. - -Add the nested class next to `VectorSearchBson` (match its style): - -```java - private static final class ScoreFusionStage implements Bson { - private final List pipelines; - private final ScoreNormalization normalization; - private final ScoreFusionOptions options; - - ScoreFusionStage(final List pipelines, final ScoreNormalization normalization, - final ScoreFusionOptions options) { - this.pipelines = pipelines; - this.normalization = normalization; - this.options = options; - } - - @Override - public BsonDocument toBsonDocument(final Class documentClass, final CodecRegistry codecRegistry) { - BsonDocument pipelinesDoc = new BsonDocument(); - for (FusionPipeline pipeline : pipelines) { - BsonArray stages = new BsonArray(); - for (Bson stage : pipeline.getPipeline()) { - stages.add(stage.toBsonDocument(documentClass, codecRegistry)); - } - pipelinesDoc.append(pipeline.getName(), stages); - } - BsonDocument specificationDoc = new BsonDocument("input", - new BsonDocument("pipelines", pipelinesDoc) - .append("normalization", normalization.toBsonValue())); - specificationDoc.putAll(options.toBsonDocument(documentClass, codecRegistry)); - return new BsonDocument("$scoreFusion", specificationDoc); - } - - @Override - public String toString() { - return "Stage{name=$scoreFusion" - + ", pipelines=" + pipelines - + ", normalization=" + normalization - + ", options=" + options - + '}'; - } - } -``` - -(`org.bson.BsonArray` import may be missing — add it.) - -- [ ] **Step 4: Run tests to verify they pass.** `./gradlew :driver-core:test --tests "AggregatesSpecification"` — PASS. - -- [ ] **Step 5: Run the module's static checks** (checkstyle/spotbugs catch Javadoc and style errors early): `./gradlew :driver-core:compileJava checkstyleMain` — expected: BUILD SUCCESSFUL. Fix any violations in the files you touched. - -- [ ] **Step 6: Commit.** - -```bash -git add driver-core/src/main/com/mongodb/client/model/Aggregates.java driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy -git commit -m "JAVA-5990 Add Aggregates.scoreFusion pipeline stage builder" -``` - ---- - -### Task 5: Functional end-to-end tests (plain server, 8.2+) - -**Files:** -- Modify: `driver-core/src/test/functional/com/mongodb/client/model/AggregatesTest.java` - -**Interfaces:** -- Consumes: `Aggregates.scoreFusion(...)` (Task 4), `FusionPipeline.of(...)`, `ScoreNormalization.*`, `scoreFusionOptions()`, `ScoreFusionCombination.*`. - -These tests exercise every option against a real server using `$match` + raw `$score` sub-pipelines with chosen score values, so results are deterministic. They are skipped on servers older than 8.2. - -- [ ] **Step 1: Write the tests.** Add to `AggregatesTest` (follow the class's existing style; `getCollectionHelper()` comes from `OperationTest`). Add imports: `org.bson.BsonArray` (if needed), static imports `com.mongodb.client.model.Aggregates.match`, `com.mongodb.client.model.Aggregates.scoreFusion`, `com.mongodb.client.model.Aggregates.project`, `com.mongodb.client.model.Filters.exists`, `com.mongodb.client.model.ScoreFusionOptions.scoreFusionOptions`, and `java.util.stream.Collectors.toList`. - -```java - // TODO JAVA-6202 replace the raw $score documents in these tests with the $score builder once available - private static final Bson SCORE_BY_X = BsonDocument.parse("{$score: {score: '$x'}}"); - private static final Bson SCORE_BY_Y = BsonDocument.parse("{$score: {score: '$y'}}"); - - private void insertScoreFusionDocuments() { - getCollectionHelper().insertDocuments( - "{_id: 1, x: 10, y: 1}", - "{_id: 2, x: 5, y: 2}", - "{_id: 3, x: 1, y: 3}"); - } - - private List idsFor(final Bson scoreFusionStage) { - return getCollectionHelper().aggregate(singletonList(scoreFusionStage)).stream() - .map(doc -> doc.getInteger("_id")) - .collect(toList()); - } - - @ParameterizedTest - @MethodSource("scoreFusionNormalizations") - public void shouldScoreFusionWithEachNormalization(final ScoreNormalization normalization) { - assumeTrue(serverVersionAtLeast(8, 2)); - insertScoreFusionDocuments(); - List ids = idsFor(scoreFusion( - asList( - FusionPipeline.of("byX", match(exists("x")), SCORE_BY_X), - FusionPipeline.of("byY", match(exists("y")), SCORE_BY_Y)), - normalization)); - assertEquals(3, ids.size()); - } - - private static Stream scoreFusionNormalizations() { - return Stream.of(ScoreNormalization.none(), ScoreNormalization.sigmoid(), ScoreNormalization.minMaxScaler()); - } - - @Test - public void shouldScoreFusionWithWeights() { - assumeTrue(serverVersionAtLeast(8, 2)); - insertScoreFusionDocuments(); - // weight only the "byY" pipeline: expected order is descending y - List ids = idsFor(scoreFusion( - asList( - FusionPipeline.of("byX", match(exists("x")), SCORE_BY_X), - FusionPipeline.of("byY", match(exists("y")), SCORE_BY_Y)), - ScoreNormalization.none(), - scoreFusionOptions().combination( - ScoreFusionCombination.weighted(new Document("byX", 0).append("byY", 1))))); - assertEquals(asList(3, 2, 1), ids); - } - - @Test - public void shouldScoreFusionWithWeightsAndAvgMethod() { - assumeTrue(serverVersionAtLeast(8, 2)); - insertScoreFusionDocuments(); - List ids = idsFor(scoreFusion( - asList( - FusionPipeline.of("byX", match(exists("x")), SCORE_BY_X), - FusionPipeline.of("byY", match(exists("y")), SCORE_BY_Y)), - ScoreNormalization.none(), - scoreFusionOptions().combination( - ScoreFusionCombination.weighted(new Document("byX", 1).append("byY", 0)).avg()))); - assertEquals(asList(1, 2, 3), ids); - } - - @Test - public void shouldScoreFusionWithExpressionCombination() { - assumeTrue(serverVersionAtLeast(8, 2)); - insertScoreFusionDocuments(); - // ignore byX, use 10 * byY: expected order is descending y - List ids = idsFor(scoreFusion( - asList( - FusionPipeline.of("byX", match(exists("x")), SCORE_BY_X), - FusionPipeline.of("byY", match(exists("y")), SCORE_BY_Y)), - ScoreNormalization.none(), - scoreFusionOptions().combination(ScoreFusionCombination.expression( - new Document("$sum", asList( - new Document("$multiply", asList("$$byX", 0)), - new Document("$multiply", asList("$$byY", 10)))))))); - assertEquals(asList(3, 2, 1), ids); - } - - @Test - public void shouldScoreFusionWithScoreDetails() { - assumeTrue(serverVersionAtLeast(8, 2)); - insertScoreFusionDocuments(); - List results = getCollectionHelper().aggregate(asList( - scoreFusion( - asList( - FusionPipeline.of("byX", match(exists("x")), SCORE_BY_X), - FusionPipeline.of("byY", match(exists("y")), SCORE_BY_Y)), - ScoreNormalization.sigmoid(), - scoreFusionOptions().scoreDetails(true)), - project(Projections.fields( - Projections.meta("score", "score"), - Projections.meta("scoreDetails", "scoreDetails"))))); - assertEquals(3, results.size()); - Document details = (Document) results.get(0).get("scoreDetails"); - Assertions.assertNotNull(details); - Assertions.assertNotNull(details.get("details")); - } -``` - -Note on helper types: `getCollectionHelper().aggregate(...)` in `AggregatesTest` returns `List` (the class uses a `Document` codec) — verify by looking at an existing test in the file and adjust `idsFor` accordingly (e.g., if it returns `List`, map with `doc.getInt32("_id").getValue()`). Also verify the exact `Projections.meta` signature (`meta(String fieldName, String metaFieldName)`); if only single-arg variants exist, use `new Document("score", new Document("$meta", "score"))`-style raw projections. - -- [ ] **Step 2: Compile.** `./gradlew :driver-core:compileTestJava` — BUILD SUCCESSFUL. - -- [ ] **Step 3: Run against a server if available.** - -Run: `./gradlew :driver-core:test --tests "com.mongodb.client.model.AggregatesTest" -Dorg.mongodb.test.uri="mongodb://localhost:27017"` -Expected: PASS (tests skipped if the server is older than 8.2 — report which happened). If no server is reachable, state that clearly; do not claim the tests ran. - -- [ ] **Step 4: Commit.** - -```bash -git add driver-core/src/test/functional/com/mongodb/client/model/AggregatesTest.java -git commit -m "JAVA-5990 Add functional tests for the \$scoreFusion stage" -``` - ---- - -### Task 6: Atlas hybrid-search integration test - -**Files:** -- Modify: `driver-core/src/test/functional/com/mongodb/client/model/search/AggregatesSearchIntegrationTest.java` - -**Interfaces:** -- Consumes: `Aggregates.scoreFusion(...)`, `FusionPipeline`, `ScoreNormalization`, `ScoreFusionCombination`, `scoreFusionOptions()`. - -One realistic hybrid test: one `$vectorSearch` + one `$search` sub-pipeline on `sample_mflix.embedded_movies`, fused with normalization + weights. Runs only when `isAtlasSearchTest()` is true (Atlas CI variant); locally it will be skipped — that is the expected outcome. - -- [ ] **Step 1: Write the test.** Add to `AggregatesSearchIntegrationTest`, reusing its existing fixtures (`MFLIX_EMBEDDED_MOVIES_NS`, `QUERY_VECTOR`, `collectionHelpers`, `msgSupplier`, `Asserters`). The class-level `@BeforeEach` already applies `assumeTrue(isAtlasSearchTest())`. Check the class Javadoc header for the search index available on `embedded_movies` and use it for the `$search` sub-pipeline (the vector index is `"sample_mflix__embedded_movies"` as used by the existing `vectorSearch` test); if the header lists no text index on `embedded_movies`, use the index it does list for that collection. - -```java - @Test - void scoreFusion() { - assumeTrue(serverVersionAtLeast(8, 2)); - CollectionHelper collectionHelper = collectionHelpers.get(MFLIX_EMBEDDED_MOVIES_NS); - List pipeline = asList( - Aggregates.scoreFusion( - asList( - FusionPipeline.of("vector", Aggregates.vectorSearch( - fieldPath("plot_embedding"), QUERY_VECTOR, "sample_mflix__embedded_movies", LIMIT, - approximateVectorSearchOptions(LIMIT + 1))), - FusionPipeline.of("text", - Aggregates.search(SearchOperator.text(fieldPath("title"), "train"), - searchOptions().index("default")), - Aggregates.limit(LIMIT))), - ScoreNormalization.sigmoid(), - scoreFusionOptions() - .combination(ScoreFusionCombination.weighted( - new Document("vector", 0.7).append("text", 0.3))) - .scoreDetails(true)), - Aggregates.limit(LIMIT)); - List results = collectionHelper.aggregate(pipeline); - Asserters.nonEmpty().accept(results, msgSupplier(pipeline)); - } -``` - -Add whatever imports the file is missing (`FusionPipeline`, `ScoreNormalization`, `ScoreFusionCombination`, static `scoreFusionOptions`, `org.bson.Document` or use `BsonDocument` for weights — `weighted` accepts any `Bson`). - -- [ ] **Step 2: Compile.** `./gradlew :driver-core:compileTestJava` — BUILD SUCCESSFUL. - -- [ ] **Step 3: Verify the test is skipped locally (not failing).** - -Run: `./gradlew :driver-core:test --tests "com.mongodb.client.model.search.AggregatesSearchIntegrationTest.scoreFusion" -Dorg.mongodb.test.uri="mongodb://localhost:27017"` (only if a local server is available) -Expected: test SKIPPED (assumption `isAtlasSearchTest()` fails). Actual Atlas execution happens in the Evergreen Atlas-search variant. - -- [ ] **Step 4: Commit.** - -```bash -git add driver-core/src/test/functional/com/mongodb/client/model/search/AggregatesSearchIntegrationTest.java -git commit -m "JAVA-5990 Add Atlas hybrid search integration test for \$scoreFusion" -``` - ---- - -### Task 7: Scala wrapper - -**Files:** -- Modify: `driver-scala/src/main/scala/org/mongodb/scala/model/Aggregates.scala` (add `scoreFusion` forwarders next to `facet`/`vectorSearch`) -- Modify: `driver-scala/src/main/scala/org/mongodb/scala/model/package.scala` (add type aliases + `FusionPipeline` companion, next to the `Facet` alias around line 343) - -**Interfaces:** -- Consumes: the Java API from Tasks 1-4. -- Produces: `org.mongodb.scala.model.Aggregates.scoreFusion(pipelines: Seq[FusionPipeline], normalization: ScoreNormalization[, options: ScoreFusionOptions]): Bson` and aliases `FusionPipeline`, `ScoreNormalization`, `ScoreFusionCombination`, `WeightedScoreFusionCombination`, `ScoreFusionOptions`. - -- [ ] **Step 1: Add aliases to `package.scala`** (mirror the `Facet` and `VectorSearchOptions` snippets shown at lines ~339-362 and the search package's alias style; `@Sealed` import already exists in the search package — in `model/package.scala` check whether `Sealed` is imported and follow whatever the file does for other sealed aliases): - -```scala - /** - * A named aggregation pipeline used as an input to a fusion pipeline stage, e.g., `\$scoreFusion`. - * - * @note Requires MongoDB 8.2 or greater - * @since 5.10 - */ - type FusionPipeline = com.mongodb.client.model.FusionPipeline - - /** - * A named aggregation pipeline used as an input to a fusion pipeline stage, e.g., `\$scoreFusion`. - * - * @note Requires MongoDB 8.2 or greater - * @since 5.10 - */ - object FusionPipeline { - - /** - * Construct a new instance - * - * @param name the non-empty pipeline name, unique within the containing stage - * @param pipeline the non-empty pipeline - * @return the new FusionPipeline - */ - def apply(name: String, pipeline: Bson*): FusionPipeline = { - com.mongodb.client.model.FusionPipeline.of(name, pipeline.asJava) - } - } - - /** - * The way in which the scores produced by the `\$scoreFusion` input pipelines are normalized before being combined. - * - * @note Requires MongoDB 8.2 or greater - * @since 5.10 - */ - type ScoreNormalization = com.mongodb.client.model.ScoreNormalization - - /** - * The way in which the normalized scores produced by the `\$scoreFusion` input pipelines are combined. - * - * @note Requires MongoDB 8.2 or greater - * @since 5.10 - */ - type ScoreFusionCombination = com.mongodb.client.model.ScoreFusionCombination - - /** - * A weighted `ScoreFusionCombination`. - * - * @note Requires MongoDB 8.2 or greater - * @since 5.10 - */ - type WeightedScoreFusionCombination = com.mongodb.client.model.WeightedScoreFusionCombination - - /** - * Represents optional fields of the `\$scoreFusion` pipeline stage of an aggregation pipeline. - * - * @note Requires MongoDB 8.2 or greater - * @since 5.10 - */ - type ScoreFusionOptions = com.mongodb.client.model.ScoreFusionOptions -``` - -Scala 2.11 is supported and cannot call static methods on Java interfaces directly from all contexts — check how the codebase handles this for `VectorSearchOptions` (search package exposes only the `type` alias, and users call the Java statics or the search package provides forwarder objects). Mirror exactly what `driver-scala/src/main/scala/org/mongodb/scala/model/search/package.scala` does; if it provides companion forwarder objects for sealed option interfaces, add matching ones for `ScoreNormalization`, `ScoreFusionCombination`, and `ScoreFusionOptions`. - -- [ ] **Step 2: Add forwarders to Scala `Aggregates.scala`** (next to `def facet`, using the file's `JAggregates` alias): - -```scala - /** - * Creates a `\$scoreFusion` pipeline stage, which combines the results of the given input pipelines, - * normalizing and combining the scores they produce. - * - * @param pipelines the non-empty input pipelines with unique names - * @param normalization the way in which the scores produced by the input pipelines are normalized - * @return the `\$scoreFusion` pipeline stage - * @see [[https://www.mongodb.com/docs/manual/reference/operator/aggregation/scoreFusion/ \$scoreFusion]] - * @note Requires MongoDB 8.2 or greater - * @since 5.10 - */ - def scoreFusion(pipelines: Seq[FusionPipeline], normalization: ScoreNormalization): Bson = - JAggregates.scoreFusion(pipelines.asJava, normalization) - - /** - * Creates a `\$scoreFusion` pipeline stage, which combines the results of the given input pipelines, - * normalizing and combining the scores they produce. - * - * @param pipelines the non-empty input pipelines with unique names - * @param normalization the way in which the scores produced by the input pipelines are normalized - * @param options optional `\$scoreFusion` pipeline stage fields - * @return the `\$scoreFusion` pipeline stage - * @see [[https://www.mongodb.com/docs/manual/reference/operator/aggregation/scoreFusion/ \$scoreFusion]] - * @note Requires MongoDB 8.2 or greater - * @since 5.10 - */ - def scoreFusion(pipelines: Seq[FusionPipeline], normalization: ScoreNormalization, options: ScoreFusionOptions): Bson = - JAggregates.scoreFusion(pipelines.asJava, normalization, options) -``` - -- [ ] **Step 3: Run the Scala API-surface test and unit tests.** - -Run: `./gradlew :driver-scala:test --tests "*ApiAliasAndCompanionSpec*" --tests "*AggregatesSpec*"` -Expected: `ApiAliasAndCompanionSpec` fails if a new public Java class lacks an alias — add whatever it reports missing (that is the test's job), then re-run until PASS. - -- [ ] **Step 4: Commit.** - -```bash -git add driver-scala/src/main/scala/org/mongodb/scala/model/Aggregates.scala driver-scala/src/main/scala/org/mongodb/scala/model/package.scala -git commit -m "JAVA-5990 Add Scala wrapper for the \$scoreFusion stage builder" -``` - ---- - -### Task 8: Full verification - -**Files:** none new — fixes only if checks fail. - -- [ ] **Step 1: Run the pre-submission checks from AGENTS.md.** - -Run: `./gradlew spotlessApply docs :driver-core:check scalaCheck` -Expected: BUILD SUCCESSFUL. Common failure causes to fix: checkstyle Javadoc violations, spotbugs warnings on the new nested stage class (add an exclusion only if a false positive, matching existing exclusion style in `config/spotbugs/exclude.xml`), Scala API-surface expectations, clirr (should not trigger — additions only). - -- [ ] **Step 2: Re-run driver-core unit tests.** `./gradlew :driver-core:test --tests "AggregatesSpecification"` — PASS. - -- [ ] **Step 3: Commit any fixes.** - -```bash -git add -A ':!diff.txt' && git commit -m "JAVA-5990 Address static analysis and API surface checks" || echo "nothing to fix" -``` - -- [ ] **Step 4: Report.** Summarize: what passed, what was skipped (functional/Atlas tests without a server), and remind that Evergreen patch build should run the Atlas-search variant before merging. diff --git a/docs/superpowers/specs/2026-07-27-scorefusion-builder-design.md b/docs/superpowers/specs/2026-07-27-scorefusion-builder-design.md deleted file mode 100644 index 3119ce4076..0000000000 --- a/docs/superpowers/specs/2026-07-27-scorefusion-builder-design.md +++ /dev/null @@ -1,145 +0,0 @@ -# Design: `$scoreFusion` aggregation stage builder (JAVA-5990) - -**Ticket:** [JAVA-5990](https://jira.mongodb.org/browse/JAVA-5990), split from -[DRIVERS-3100](https://jira.mongodb.org/browse/DRIVERS-3100) — "Add builder support for `$scoreFusion` stage." - -**Scope decision:** `$scoreFusion` only. `$rankFusion`, the `$score` stage -(JAVA-6202), `$sigmoid`, and `$minMaxScaler` are out of scope, but naming is -chosen so those tickets can reuse types (see Naming). - -**Reference implementations:** C# [mongodb/mongo-csharp-driver#1976](https://github.com/mongodb/mongo-csharp-driver/pull/1976) -(typed enums, weights/expression validation, 8.2 wire gate); PHP -[mongodb/mongo-php-library#1822](https://github.com/mongodb/mongo-php-library/pull/1822) (thin pass-through). - -## Server stage being abstracted - -`$scoreFusion` (MongoDB 8.2+, must be the first stage, single collection): - -```json -{ "$scoreFusion": { - "input": { - "pipelines": { "name1": [ ...stages ], "name2": [ ...stages ] }, - "normalization": "none" | "sigmoid" | "minMaxScaler" - }, - "combination": { - "weights": { "name1": 0.3, "name2": 0.7 }, - "method": "avg" | "expression", - "expression": { "$sum": [ { "$multiply": ["$$name1", 10] }, "$$name2" ] } - }, - "scoreDetails": true -} } -``` - -Server rules the API must respect: - -- `input.pipelines` (required): map of unique names to sub-pipelines on the same collection; at least one. -- `input.normalization` (required): one of `none`, `sigmoid`, `minMaxScaler`. -- `combination` (optional): `weights` is **mutually exclusive** with `expression`; - `expression` requires `method: "expression"`; pipeline names bind as `$$name` variables. -- `scoreDetails` (optional, default `false`). - -## Public API - -All new types live in `com.mongodb.client.model` (driver-core). Javadoc tagged -`@mongodb.server.release 8.2`. Not `@Beta` (stage is GA in 8.2; C# shipped non-beta). - -### `Aggregates` entry points - -```java -public static Bson scoreFusion(List pipelines, ScoreNormalization normalization) -public static Bson scoreFusion(List pipelines, ScoreNormalization normalization, ScoreFusionOptions options) -``` - -### `FusionPipeline` - -`Facet`-style value class holding a named sub-pipeline: static factories -`of(String name, List stages)` and `of(String name, Bson... stages)`, -getters, `equals`/`hashCode`/`toString`. Named without the `Score` prefix so a -future `$rankFusion` builder (identical `input.pipelines` shape) reuses it. - -### `ScoreNormalization` - -`@Sealed` interface with static factories `none()`, `sigmoid()`, `minMaxScaler()` -rendering the camelCase server strings. Named fusion-agnostically because -JAVA-6202 (`$score` stage) has the same `normalization` field and must reuse this -type — **coordinate with the JAVA-6202 implementer**. - -### `ScoreFusionCombination` - -`@Sealed` type making the weights-XOR-expression server rule unrepresentable: - -- `ScoreFusionCombination.weighted(Bson weights)` → emits `combination.weights`; - returns a subtype with optional `.method(...)` for `avg` (server default is sum-like behavior when omitted). -- `ScoreFusionCombination.expression(Bson expression)` → always emits - `method: "expression"` together with `combination.expression`. - -### `ScoreFusionOptions` - -`@Sealed` immutable fluent interface, static factory `scoreFusionOptions()`: - -- `combination(ScoreFusionCombination combination)` -- `scoreDetails(boolean scoreDetails)` -- `option(String name, Object value)` escape hatch (per `SearchOptions`/`VectorSearchOptions` convention). - -### Example - -```java -Bson stage = Aggregates.scoreFusion( - asList( - FusionPipeline.of("vector", vectorSearchStage), - FusionPipeline.of("text", searchStage, Aggregates.limit(10))), - ScoreNormalization.sigmoid(), - scoreFusionOptions() - .combination(ScoreFusionCombination.weighted( - new Document("vector", 0.7).append("text", 0.3))) - .scoreDetails(true)); -``` - -## Implementation - -- Stage rendering via a private static nested `Bson`-implementing class in - `Aggregates` (pattern: `SetWindowFieldsStage`), or the constructible-BSON - helpers used by the search package — match surrounding style. -- Sealed interfaces backed by internal immutable implementations, following - `VectorSearchOptions`. -- Rendering rules: omit `combination` entirely when the options carry none; - emit `scoreDetails` only when `true` (matches C#). -- Validation (fail-fast `notNull`/`isTrueArgument`): pipelines non-null, - non-empty, no null entries; names non-null, non-empty, unique; weights/expression - non-null where required. Server-side rules (stage-must-be-first, - same-collection) are left to the server per driver convention. - -## Wrappers - -- **Scala:** type aliases + companion forwarders in - `org.mongodb.scala.model.package` (pattern: `Facet`) and a `scoreFusion` - forwarder in Scala `Aggregates`; update Scala API-surface test expectations. -- **Kotlin:** uses Java model types directly — no change. -- Pure API addition — no binary-compatibility impact. - -## Testing - -Three layers: - -1. **Unit** (alongside existing `Aggregates` unit tests): exact rendered BSON - for minimal form, each normalization value, weights, weights+method, - expression, scoreDetails, `option(...)` escape hatch; validation failures - (empty/duplicate/blank names, null args). -2. **Functional end-to-end** (`AggregatesTest`, gated - `serverVersionAtLeast(8, 2)`): every option exercised against a real server - using `$match` + raw `$score` BSON sub-pipelines with chosen score values, so - fused results and scores are deterministic and assertable — including - `$$name` expression combination and `{$meta: "scoreDetails"}` projection. - The raw `$score` documents carry a - `// TODO JAVA-6202 replace raw $score documents with the $score builder once available` - comment. -3. **Atlas integration** (`AggregatesSearchIntegrationTest`, gated - `isAtlasSearchTest()`): one realistic hybrid test — one `$search` + one - `$vectorSearch` sub-pipeline fused with normalization + weights, asserting - sensible ranked results. - -## Out of scope / future work - -- `$rankFusion` builder (reuses `FusionPipeline`). -- `$score` stage builder — JAVA-6202 (reuses `ScoreNormalization`). -- `$sigmoid` expression and `$minMaxScaler` window-function builders. From d73530504162681cee070e4eed470ae15e5973f5 Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Mon, 27 Jul 2026 19:54:44 +0100 Subject: [PATCH 14/24] JAVA-5990 Align ScoreNormalization with JAVA-6202 enum implementation Temporarily duplicates the ScoreNormalization enum from PR #2023 so both branches compile independently; once JAVA-6202 merges, the duplicate is dropped in favor of theirs. --- .../com/mongodb/client/model/Aggregates.java | 2 +- .../client/model/ScoreNormalization.java | 63 ++++++------------- .../client/model/ScoreNormalizationBson.java | 56 ----------------- .../mongodb/client/model/AggregatesTest.java | 15 ++--- .../AggregatesSearchIntegrationTest.java | 2 +- .../model/AggregatesSpecification.groovy | 19 ++---- .../scala/model/ScoreNormalization.scala | 34 +++------- 7 files changed, 43 insertions(+), 148 deletions(-) delete mode 100644 driver-core/src/main/com/mongodb/client/model/ScoreNormalizationBson.java diff --git a/driver-core/src/main/com/mongodb/client/model/Aggregates.java b/driver-core/src/main/com/mongodb/client/model/Aggregates.java index ff295fc2d6..ec17764eba 100644 --- a/driver-core/src/main/com/mongodb/client/model/Aggregates.java +++ b/driver-core/src/main/com/mongodb/client/model/Aggregates.java @@ -2419,7 +2419,7 @@ public BsonDocument toBsonDocument(final Class documentCl } BsonDocument specificationDoc = new BsonDocument("input", new BsonDocument("pipelines", pipelinesDoc) - .append("normalization", normalization.toBsonValue())); + .append("normalization", new BsonString(normalization.getValue()))); specificationDoc.putAll(options.toBsonDocument(documentClass, codecRegistry)); return new BsonDocument("$scoreFusion", specificationDoc); } diff --git a/driver-core/src/main/com/mongodb/client/model/ScoreNormalization.java b/driver-core/src/main/com/mongodb/client/model/ScoreNormalization.java index 1e73afc74d..d04a37386c 100644 --- a/driver-core/src/main/com/mongodb/client/model/ScoreNormalization.java +++ b/driver-core/src/main/com/mongodb/client/model/ScoreNormalization.java @@ -16,64 +16,39 @@ package com.mongodb.client.model; -import com.mongodb.annotations.Sealed; -import org.bson.BsonString; -import org.bson.BsonValue; - -import static com.mongodb.assertions.Assertions.notNull; - /** - * The way in which the scores produced by the {@linkplain Aggregates#scoreFusion(java.util.List, ScoreNormalization, - * ScoreFusionOptions) $scoreFusion} input pipelines are normalized before being combined. + * Normalization methods for the {@code $score} pipeline stage. * - * @since 5.10 + * @mongodb.driver.manual reference/operator/aggregation/score/ $score * @mongodb.server.release 8.2 + * @since 5.10 */ -@Sealed -public interface ScoreNormalization { +public enum ScoreNormalization { /** - * Returns a {@link ScoreNormalization} instance representing no normalization. - * - * @return The requested {@link ScoreNormalization}. + * No normalization is applied. */ - static ScoreNormalization none() { - return new ScoreNormalizationBson(new BsonString("none")); - } - + NONE("none"), /** - * Returns a {@link ScoreNormalization} instance representing normalization via the sigmoid function. - * - * @return The requested {@link ScoreNormalization}. + * Normalizes the score to the range [0, 1] by applying the sigmoid function. */ - static ScoreNormalization sigmoid() { - return new ScoreNormalizationBson(new BsonString("sigmoid")); - } - + SIGMOID("sigmoid"), /** - * Returns a {@link ScoreNormalization} instance representing min-max scaling of the scores to the range [0, 1]. - * - * @return The requested {@link ScoreNormalization}. + * Normalizes the score to the range [0, 1] by applying min-max scaling. */ - static ScoreNormalization minMaxScaler() { - return new ScoreNormalizationBson(new BsonString("minMaxScaler")); - } + MIN_MAX_SCALER("minMaxScaler"); - /** - * Creates a {@link ScoreNormalization} from a {@link BsonValue} in situations when there is no builder method - * that better satisfies your needs. - * This method cannot be used to validate the syntax. - * - * @param normalization A {@link BsonValue} representing the required {@link ScoreNormalization}. - * @return The requested {@link ScoreNormalization}. - */ - static ScoreNormalization of(final BsonValue normalization) { - return new ScoreNormalizationBson(notNull("normalization", normalization)); + private final String value; + + ScoreNormalization(final String value) { + this.value = value; } /** - * Converts this object to {@link BsonValue}. + * Returns the value as expected by the server. * - * @return A {@link BsonValue} representing this {@link ScoreNormalization}. + * @return the server value */ - BsonValue toBsonValue(); + public String getValue() { + return value; + } } diff --git a/driver-core/src/main/com/mongodb/client/model/ScoreNormalizationBson.java b/driver-core/src/main/com/mongodb/client/model/ScoreNormalizationBson.java deleted file mode 100644 index 70c6730146..0000000000 --- a/driver-core/src/main/com/mongodb/client/model/ScoreNormalizationBson.java +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright 2008-present MongoDB, 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. - */ - -package com.mongodb.client.model; - -import org.bson.BsonValue; - -import java.util.Objects; - -final class ScoreNormalizationBson implements ScoreNormalization { - private final BsonValue normalization; - - ScoreNormalizationBson(final BsonValue normalization) { - this.normalization = normalization; - } - - @Override - public BsonValue toBsonValue() { - return normalization; - } - - @Override - public boolean equals(final Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - ScoreNormalizationBson that = (ScoreNormalizationBson) o; - return Objects.equals(normalization, that.normalization); - } - - @Override - public int hashCode() { - return Objects.hash(normalization); - } - - @Override - public String toString() { - return "ScoreNormalization{normalization=" + normalization + '}'; - } -} diff --git a/driver-core/src/test/functional/com/mongodb/client/model/AggregatesTest.java b/driver-core/src/test/functional/com/mongodb/client/model/AggregatesTest.java index be8b980436..e76432df49 100644 --- a/driver-core/src/test/functional/com/mongodb/client/model/AggregatesTest.java +++ b/driver-core/src/test/functional/com/mongodb/client/model/AggregatesTest.java @@ -30,6 +30,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.MethodSource; import java.math.RoundingMode; @@ -479,7 +480,7 @@ private List idsFor(final Bson scoreFusionStage) { } @ParameterizedTest - @MethodSource("scoreFusionNormalizations") + @EnumSource(ScoreNormalization.class) public void shouldScoreFusionWithEachNormalization(final ScoreNormalization normalization) { assumeTrue(serverVersionAtLeast(8, 2)); insertScoreFusionDocuments(); @@ -491,10 +492,6 @@ public void shouldScoreFusionWithEachNormalization(final ScoreNormalization norm assertEquals(3, ids.size()); } - private static Stream scoreFusionNormalizations() { - return Stream.of(ScoreNormalization.none(), ScoreNormalization.sigmoid(), ScoreNormalization.minMaxScaler()); - } - @Test public void shouldScoreFusionWithWeights() { assumeTrue(serverVersionAtLeast(8, 2)); @@ -504,7 +501,7 @@ public void shouldScoreFusionWithWeights() { asList( FusionPipeline.of("byX", match(exists("x")), SCORE_BY_X), FusionPipeline.of("byY", match(exists("y")), SCORE_BY_Y)), - ScoreNormalization.none(), + ScoreNormalization.NONE, scoreFusionOptions().combination( ScoreFusionCombination.weighted(new Document("byX", 0).append("byY", 1))))); assertEquals(asList(3, 2, 1), ids); @@ -518,7 +515,7 @@ public void shouldScoreFusionWithWeightsAndAvgMethod() { asList( FusionPipeline.of("byX", match(exists("x")), SCORE_BY_X), FusionPipeline.of("byY", match(exists("y")), SCORE_BY_Y)), - ScoreNormalization.none(), + ScoreNormalization.NONE, scoreFusionOptions().combination( ScoreFusionCombination.weighted(new Document("byX", 1).append("byY", 0)).avg()))); assertEquals(asList(1, 2, 3), ids); @@ -533,7 +530,7 @@ public void shouldScoreFusionWithExpressionCombination() { asList( FusionPipeline.of("byX", match(exists("x")), SCORE_BY_X), FusionPipeline.of("byY", match(exists("y")), SCORE_BY_Y)), - ScoreNormalization.none(), + ScoreNormalization.NONE, scoreFusionOptions().combination(ScoreFusionCombination.expression( new Document("$sum", asList( new Document("$multiply", asList("$$byX", 0)), @@ -550,7 +547,7 @@ public void shouldScoreFusionWithScoreDetails() { asList( FusionPipeline.of("byX", match(exists("x")), SCORE_BY_X), FusionPipeline.of("byY", match(exists("y")), SCORE_BY_Y)), - ScoreNormalization.sigmoid(), + ScoreNormalization.SIGMOID, scoreFusionOptions().scoreDetails(true)), project(Projections.fields( Projections.meta("score", "score"), diff --git a/driver-core/src/test/functional/com/mongodb/client/model/search/AggregatesSearchIntegrationTest.java b/driver-core/src/test/functional/com/mongodb/client/model/search/AggregatesSearchIntegrationTest.java index b29dc785da..618fe34edb 100644 --- a/driver-core/src/test/functional/com/mongodb/client/model/search/AggregatesSearchIntegrationTest.java +++ b/driver-core/src/test/functional/com/mongodb/client/model/search/AggregatesSearchIntegrationTest.java @@ -350,7 +350,7 @@ void scoreFusion() { Aggregates.search(SearchOperator.text(fieldPath("title"), "train"), searchOptions().index("sample_mflix__embedded_movies")), Aggregates.limit(LIMIT))), - ScoreNormalization.sigmoid(), + ScoreNormalization.SIGMOID, scoreFusionOptions() .combination(ScoreFusionCombination.weighted( new Document("vector", 0.7).append("text", 0.3))) diff --git a/driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy b/driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy index c983521b0a..4c1037ceb9 100644 --- a/driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy +++ b/driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy @@ -1366,13 +1366,6 @@ class AggregatesSpecification extends Specification { replaceRoot('$a1').hashCode() == replaceRoot('$a1').hashCode() } - def 'should create ScoreNormalization'() { - expect: - ScoreNormalization.none().toBsonValue() == new BsonString('none') - ScoreNormalization.sigmoid().toBsonValue() == new BsonString('sigmoid') - ScoreNormalization.minMaxScaler().toBsonValue() == new BsonString('minMaxScaler') - ScoreNormalization.of(new BsonString('sigmoid')).toBsonValue() == new BsonString('sigmoid') - } def 'should create FusionPipeline'() { when: @@ -1434,14 +1427,14 @@ class AggregatesSpecification extends Specification { expect: toBson(scoreFusion( [FusionPipeline.of('p1', match(eq('x', 1))), FusionPipeline.of('p2', match(eq('x', 2)))], - ScoreNormalization.sigmoid())) == + ScoreNormalization.SIGMOID)) == parse('''{$scoreFusion: {input: { pipelines: {p1: [{$match: {x: 1}}], p2: [{$match: {x: 2}}]}, normalization: "sigmoid"}}}''') toBson(scoreFusion( [FusionPipeline.of('p1', match(eq('x', 1))), FusionPipeline.of('p2', match(eq('x', 2)), limit(5))], - ScoreNormalization.minMaxScaler(), + ScoreNormalization.MIN_MAX_SCALER, scoreFusionOptions() .combination(ScoreFusionCombination.weighted(new Document('p1', 0.3d).append('p2', 0.7d)).avg()) .scoreDetails(true))) == @@ -1453,7 +1446,7 @@ class AggregatesSpecification extends Specification { toBson(scoreFusion( [FusionPipeline.of('p1', match(eq('x', 1))), FusionPipeline.of('p2', match(eq('x', 2)))], - ScoreNormalization.none(), + ScoreNormalization.NONE, scoreFusionOptions().combination( ScoreFusionCombination.expression(new Document('$sum', ['$$p1', '$$p2']))))) == parse('''{$scoreFusion: {input: { @@ -1464,20 +1457,20 @@ class AggregatesSpecification extends Specification { def 'should validate $scoreFusion arguments'() { when: - scoreFusion([], ScoreNormalization.none()) + scoreFusion([], ScoreNormalization.NONE) then: thrown(IllegalArgumentException) when: scoreFusion([FusionPipeline.of('p1', match(eq('x', 1))), FusionPipeline.of('p1', match(eq('x', 2)))], - ScoreNormalization.none()) + ScoreNormalization.NONE) then: thrown(IllegalArgumentException) when: - scoreFusion(null, ScoreNormalization.none()) + scoreFusion(null, ScoreNormalization.NONE) then: thrown(IllegalArgumentException) diff --git a/driver-scala/src/main/scala/org/mongodb/scala/model/ScoreNormalization.scala b/driver-scala/src/main/scala/org/mongodb/scala/model/ScoreNormalization.scala index 1a6304564a..3e8ce7c79b 100644 --- a/driver-scala/src/main/scala/org/mongodb/scala/model/ScoreNormalization.scala +++ b/driver-scala/src/main/scala/org/mongodb/scala/model/ScoreNormalization.scala @@ -13,46 +13,32 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package org.mongodb.scala.model -import com.mongodb.annotations.Sealed import com.mongodb.client.model.{ ScoreNormalization => JScoreNormalization } -import org.bson.BsonValue /** - * The way in which the scores produced by the `\$scoreFusion` input pipelines are normalized before being combined. + * Normalization methods for the `\$score` pipeline stage. * + * @see [[https://www.mongodb.com/docs/manual/reference/operator/aggregation/score/ \$score]] * @note Requires MongoDB 8.2 or greater * @since 5.10 */ -@Sealed object ScoreNormalization { - - /** - * Returns a `ScoreNormalization` instance representing no normalization. - * - * @return The requested `ScoreNormalization`. - */ - def none: ScoreNormalization = JScoreNormalization.none() +object ScoreNormalization { /** - * Returns a `ScoreNormalization` instance representing normalization via the sigmoid function. - * - * @return The requested `ScoreNormalization`. + * No normalization is applied. */ - def sigmoid: ScoreNormalization = JScoreNormalization.sigmoid() + val NONE: ScoreNormalization = JScoreNormalization.NONE /** - * Returns a `ScoreNormalization` instance representing min-max scaling of the scores to the range [0, 1]. - * - * @return The requested `ScoreNormalization`. + * Normalizes the score to the range [0, 1] by applying the sigmoid function. */ - def minMaxScaler: ScoreNormalization = JScoreNormalization.minMaxScaler() + val SIGMOID: ScoreNormalization = JScoreNormalization.SIGMOID /** - * Returns a `ScoreNormalization` instance representing the given normalization. - * - * @param normalization the normalization - * @return The requested `ScoreNormalization`. + * Normalizes the score to the range [0, 1] by applying min-max scaling. */ - def of(normalization: BsonValue): ScoreNormalization = JScoreNormalization.of(normalization) + val MIN_MAX_SCALER: ScoreNormalization = JScoreNormalization.MIN_MAX_SCALER } From 3c4da1d82dadb6db9bdfeac2316ea786e2e6330c Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Tue, 28 Jul 2026 13:31:17 +0100 Subject: [PATCH 15/24] JAVA-5990 Assert fused scores in $scoreFusion functional tests --- .../mongodb/client/model/AggregatesTest.java | 50 ++++++++++++++----- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/driver-core/src/test/functional/com/mongodb/client/model/AggregatesTest.java b/driver-core/src/test/functional/com/mongodb/client/model/AggregatesTest.java index e76432df49..97855b8740 100644 --- a/driver-core/src/test/functional/com/mongodb/client/model/AggregatesTest.java +++ b/driver-core/src/test/functional/com/mongodb/client/model/AggregatesTest.java @@ -473,60 +473,84 @@ private void insertScoreFusionDocuments() { BsonDocument.parse("{_id: 3, x: 1, y: 3}")); } - private List idsFor(final Bson scoreFusionStage) { - return getCollectionHelper().aggregate(singletonList(scoreFusionStage)).stream() + private List resultsWithScoresFor(final Bson scoreFusionStage) { + return getCollectionHelper().aggregate(asList( + scoreFusionStage, + project(Projections.meta("score", "score")))); + } + + private List idsFor(final List results) { + return results.stream() .map(doc -> doc.getInt32("_id").getValue()) .collect(toList()); } + private List scoresFor(final List results) { + return results.stream() + .map(doc -> doc.getNumber("score").doubleValue()) + .collect(toList()); + } + @ParameterizedTest @EnumSource(ScoreNormalization.class) public void shouldScoreFusionWithEachNormalization(final ScoreNormalization normalization) { assumeTrue(serverVersionAtLeast(8, 2)); insertScoreFusionDocuments(); - List ids = idsFor(scoreFusion( + List results = resultsWithScoresFor(scoreFusion( asList( FusionPipeline.of("byX", match(exists("x")), SCORE_BY_X), FusionPipeline.of("byY", match(exists("y")), SCORE_BY_Y)), normalization)); - assertEquals(3, ids.size()); + assertEquals(3, results.size()); + if (normalization == ScoreNormalization.NONE) { + // the server combines the scores of the two pipelines with avg by default: (x + y) / 2 + assertEquals(asList(5.5, 3.5, 2.0), scoresFor(results)); + } else { + // sigmoid maps each pipeline score into (0, 1) and minMaxScaler into [0, 1], + // so the average of the two is within [0, 1] + scoresFor(results).forEach(score -> Assertions.assertTrue(score >= 0 && score <= 1)); + } } @Test public void shouldScoreFusionWithWeights() { assumeTrue(serverVersionAtLeast(8, 2)); insertScoreFusionDocuments(); - // weight only the "byY" pipeline: expected order is descending y - List ids = idsFor(scoreFusion( + // weight only the "byY" pipeline: expected order is descending y, each score is y / 2 + // because the server combines the weighted scores of the two pipelines with avg by default + List results = resultsWithScoresFor(scoreFusion( asList( FusionPipeline.of("byX", match(exists("x")), SCORE_BY_X), FusionPipeline.of("byY", match(exists("y")), SCORE_BY_Y)), ScoreNormalization.NONE, scoreFusionOptions().combination( ScoreFusionCombination.weighted(new Document("byX", 0).append("byY", 1))))); - assertEquals(asList(3, 2, 1), ids); + assertEquals(asList(3, 2, 1), idsFor(results)); + assertEquals(asList(1.5, 1.0, 0.5), scoresFor(results)); } @Test public void shouldScoreFusionWithWeightsAndAvgMethod() { assumeTrue(serverVersionAtLeast(8, 2)); insertScoreFusionDocuments(); - List ids = idsFor(scoreFusion( + // weight only the "byX" pipeline and average over the two pipelines: each score is x / 2 + List results = resultsWithScoresFor(scoreFusion( asList( FusionPipeline.of("byX", match(exists("x")), SCORE_BY_X), FusionPipeline.of("byY", match(exists("y")), SCORE_BY_Y)), ScoreNormalization.NONE, scoreFusionOptions().combination( ScoreFusionCombination.weighted(new Document("byX", 1).append("byY", 0)).avg()))); - assertEquals(asList(1, 2, 3), ids); + assertEquals(asList(1, 2, 3), idsFor(results)); + assertEquals(asList(5.0, 2.5, 0.5), scoresFor(results)); } @Test public void shouldScoreFusionWithExpressionCombination() { assumeTrue(serverVersionAtLeast(8, 2)); insertScoreFusionDocuments(); - // ignore byX, use 10 * byY: expected order is descending y - List ids = idsFor(scoreFusion( + // ignore byX, use 10 * byY: expected order is descending y, each score is exactly 10 * y + List results = resultsWithScoresFor(scoreFusion( asList( FusionPipeline.of("byX", match(exists("x")), SCORE_BY_X), FusionPipeline.of("byY", match(exists("y")), SCORE_BY_Y)), @@ -535,7 +559,8 @@ public void shouldScoreFusionWithExpressionCombination() { new Document("$sum", asList( new Document("$multiply", asList("$$byX", 0)), new Document("$multiply", asList("$$byY", 10)))))))); - assertEquals(asList(3, 2, 1), ids); + assertEquals(asList(3, 2, 1), idsFor(results)); + assertEquals(asList(30.0, 20.0, 10.0), scoresFor(results)); } @Test @@ -553,6 +578,7 @@ public void shouldScoreFusionWithScoreDetails() { Projections.meta("score", "score"), Projections.meta("scoreDetails", "scoreDetails"))))); assertEquals(3, results.size()); + Assertions.assertTrue(results.get(0).getNumber("score").doubleValue() > 0); BsonDocument details = results.get(0).getDocument("scoreDetails"); Assertions.assertNotNull(details); Assertions.assertNotNull(details.get("details")); From e55d77d70033aecd298094e15cd47fd17d8b23bb Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Tue, 28 Jul 2026 13:53:59 +0100 Subject: [PATCH 16/24] JAVA-5990 Reference the $scoreFusion manual for the default avg combination --- .../functional/com/mongodb/client/model/AggregatesTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/driver-core/src/test/functional/com/mongodb/client/model/AggregatesTest.java b/driver-core/src/test/functional/com/mongodb/client/model/AggregatesTest.java index 97855b8740..58ba0adef5 100644 --- a/driver-core/src/test/functional/com/mongodb/client/model/AggregatesTest.java +++ b/driver-core/src/test/functional/com/mongodb/client/model/AggregatesTest.java @@ -504,6 +504,7 @@ public void shouldScoreFusionWithEachNormalization(final ScoreNormalization norm assertEquals(3, results.size()); if (normalization == ScoreNormalization.NONE) { // the server combines the scores of the two pipelines with avg by default: (x + y) / 2 + // https://www.mongodb.com/docs/manual/reference/operator/aggregation/scoreFusion/ assertEquals(asList(5.5, 3.5, 2.0), scoresFor(results)); } else { // sigmoid maps each pipeline score into (0, 1) and minMaxScaler into [0, 1], @@ -518,6 +519,7 @@ public void shouldScoreFusionWithWeights() { insertScoreFusionDocuments(); // weight only the "byY" pipeline: expected order is descending y, each score is y / 2 // because the server combines the weighted scores of the two pipelines with avg by default + // https://www.mongodb.com/docs/manual/reference/operator/aggregation/scoreFusion/ List results = resultsWithScoresFor(scoreFusion( asList( FusionPipeline.of("byX", match(exists("x")), SCORE_BY_X), From b88d01c901d77569460807efa37161c907729250 Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Tue, 28 Jul 2026 13:57:32 +0100 Subject: [PATCH 17/24] JAVA-5990 Correct sigmoid normalization range to (0, 1) in ScoreNormalization docs --- .../src/main/com/mongodb/client/model/ScoreNormalization.java | 2 +- .../main/scala/org/mongodb/scala/model/ScoreNormalization.scala | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/driver-core/src/main/com/mongodb/client/model/ScoreNormalization.java b/driver-core/src/main/com/mongodb/client/model/ScoreNormalization.java index d04a37386c..73078356f0 100644 --- a/driver-core/src/main/com/mongodb/client/model/ScoreNormalization.java +++ b/driver-core/src/main/com/mongodb/client/model/ScoreNormalization.java @@ -29,7 +29,7 @@ public enum ScoreNormalization { */ NONE("none"), /** - * Normalizes the score to the range [0, 1] by applying the sigmoid function. + * Normalizes the score to the range (0, 1) by applying the sigmoid function. */ SIGMOID("sigmoid"), /** diff --git a/driver-scala/src/main/scala/org/mongodb/scala/model/ScoreNormalization.scala b/driver-scala/src/main/scala/org/mongodb/scala/model/ScoreNormalization.scala index 3e8ce7c79b..0ca1637781 100644 --- a/driver-scala/src/main/scala/org/mongodb/scala/model/ScoreNormalization.scala +++ b/driver-scala/src/main/scala/org/mongodb/scala/model/ScoreNormalization.scala @@ -33,7 +33,7 @@ object ScoreNormalization { val NONE: ScoreNormalization = JScoreNormalization.NONE /** - * Normalizes the score to the range [0, 1] by applying the sigmoid function. + * Normalizes the score to the range (0, 1) by applying the sigmoid function. */ val SIGMOID: ScoreNormalization = JScoreNormalization.SIGMOID From f0f5c42dd4c6fedaf18daf09a95058517ce417ff Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Tue, 28 Jul 2026 13:58:29 +0100 Subject: [PATCH 18/24] JAVA-5990 Make ScoreNormalization docs stage-neutral --- .../src/main/com/mongodb/client/model/ScoreNormalization.java | 3 ++- .../scala/org/mongodb/scala/model/ScoreNormalization.scala | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/driver-core/src/main/com/mongodb/client/model/ScoreNormalization.java b/driver-core/src/main/com/mongodb/client/model/ScoreNormalization.java index 73078356f0..5cad4c7ade 100644 --- a/driver-core/src/main/com/mongodb/client/model/ScoreNormalization.java +++ b/driver-core/src/main/com/mongodb/client/model/ScoreNormalization.java @@ -17,9 +17,10 @@ package com.mongodb.client.model; /** - * Normalization methods for the {@code $score} pipeline stage. + * Normalization methods for the {@code $score} and {@code $scoreFusion} pipeline stages. * * @mongodb.driver.manual reference/operator/aggregation/score/ $score + * @mongodb.driver.manual reference/operator/aggregation/scoreFusion/ $scoreFusion * @mongodb.server.release 8.2 * @since 5.10 */ diff --git a/driver-scala/src/main/scala/org/mongodb/scala/model/ScoreNormalization.scala b/driver-scala/src/main/scala/org/mongodb/scala/model/ScoreNormalization.scala index 0ca1637781..0fbe45a2f4 100644 --- a/driver-scala/src/main/scala/org/mongodb/scala/model/ScoreNormalization.scala +++ b/driver-scala/src/main/scala/org/mongodb/scala/model/ScoreNormalization.scala @@ -19,9 +19,10 @@ package org.mongodb.scala.model import com.mongodb.client.model.{ ScoreNormalization => JScoreNormalization } /** - * Normalization methods for the `\$score` pipeline stage. + * Normalization methods for the `\$score` and `\$scoreFusion` pipeline stages. * * @see [[https://www.mongodb.com/docs/manual/reference/operator/aggregation/score/ \$score]] + * @see [[https://www.mongodb.com/docs/manual/reference/operator/aggregation/scoreFusion/ \$scoreFusion]] * @note Requires MongoDB 8.2 or greater * @since 5.10 */ From 07beadd6f30f87e50b6baa49edf980c2e330622e Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Tue, 28 Jul 2026 14:11:25 +0100 Subject: [PATCH 19/24] JAVA-5990 Assert all scoreDetails fields in the $scoreFusion functional test --- .../com/mongodb/client/model/AggregatesTest.java | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/driver-core/src/test/functional/com/mongodb/client/model/AggregatesTest.java b/driver-core/src/test/functional/com/mongodb/client/model/AggregatesTest.java index 58ba0adef5..0715c8e07d 100644 --- a/driver-core/src/test/functional/com/mongodb/client/model/AggregatesTest.java +++ b/driver-core/src/test/functional/com/mongodb/client/model/AggregatesTest.java @@ -580,9 +580,17 @@ public void shouldScoreFusionWithScoreDetails() { Projections.meta("score", "score"), Projections.meta("scoreDetails", "scoreDetails"))))); assertEquals(3, results.size()); - Assertions.assertTrue(results.get(0).getNumber("score").doubleValue() > 0); - BsonDocument details = results.get(0).getDocument("scoreDetails"); - Assertions.assertNotNull(details); - Assertions.assertNotNull(details.get("details")); + results.forEach(result -> { + double score = result.getNumber("score").doubleValue(); + Assertions.assertTrue(score > 0); + BsonDocument scoreDetails = result.getDocument("scoreDetails"); + // "value" holds the same combined score as the score metadata + assertEquals(score, scoreDetails.getNumber("value").doubleValue()); + Assertions.assertFalse(scoreDetails.getString("description").getValue().isEmpty()); + assertEquals("sigmoid", scoreDetails.getString("normalization").getValue()); + Assertions.assertNotNull(scoreDetails.getDocument("combination")); + // one entry per input pipeline + assertEquals(2, scoreDetails.getArray("details").size()); + }); } } From c5fca8c2ebf2cd3a8262a6cdee6bc614f4e7d768 Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Tue, 28 Jul 2026 14:23:09 +0100 Subject: [PATCH 20/24] JAVA-5990 Enforce server naming constraints for fusion pipeline names --- .../mongodb/client/model/FusionPipeline.java | 11 +++++++++-- .../model/AggregatesSpecification.groovy | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/driver-core/src/main/com/mongodb/client/model/FusionPipeline.java b/driver-core/src/main/com/mongodb/client/model/FusionPipeline.java index ef35f6567d..4c95b8d6b6 100644 --- a/driver-core/src/main/com/mongodb/client/model/FusionPipeline.java +++ b/driver-core/src/main/com/mongodb/client/model/FusionPipeline.java @@ -33,6 +33,8 @@ * The name uniquely identifies the pipeline within the stage and may be referred to * by other parts of the stage, e.g., as the {@code "$$name"} variable in a * {@linkplain ScoreFusionCombination#expression(Bson) combination expression}. + * A name must not be empty, must not start with {@code $}, and must not contain {@code .} + * or the null character. * * @since 5.10 * @mongodb.server.release 8.2 @@ -44,7 +46,8 @@ public final class FusionPipeline { /** * Creates a new {@link FusionPipeline}. * - * @param name The non-empty pipeline name, unique within the containing stage. + * @param name The pipeline name, unique within the containing stage. It must not be empty, + * must not start with {@code $}, and must not contain {@code .} or the null character. * @param pipeline The non-empty pipeline. * @return The requested {@link FusionPipeline}. */ @@ -55,7 +58,8 @@ public static FusionPipeline of(final String name, final List pi /** * Creates a new {@link FusionPipeline}. * - * @param name The non-empty pipeline name, unique within the containing stage. + * @param name The pipeline name, unique within the containing stage. It must not be empty, + * must not start with {@code $}, and must not contain {@code .} or the null character. * @param pipeline The non-empty pipeline. * @return The requested {@link FusionPipeline}. */ @@ -66,6 +70,9 @@ public static FusionPipeline of(final String name, final Bson... pipeline) { private FusionPipeline(final String name, final List pipeline) { notNull("name", name); isTrueArgument("name must not be empty", !name.trim().isEmpty()); + isTrueArgument("name must not start with '$'", !name.startsWith("$")); + isTrueArgument("name must not contain '.'", !name.contains(".")); + isTrueArgument("name must not contain the null character", name.indexOf('\u0000') < 0); notNull("pipeline", pipeline); isTrueArgument("pipeline must not be empty", !pipeline.isEmpty()); for (Bson stage : pipeline) { diff --git a/driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy b/driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy index 4c1037ceb9..86314833b8 100644 --- a/driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy +++ b/driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy @@ -1394,6 +1394,24 @@ class AggregatesSpecification extends Specification { then: thrown(IllegalArgumentException) + when: + FusionPipeline.of('$vector', match(eq('x', 1))) + + then: + thrown(IllegalArgumentException) + + when: + FusionPipeline.of('a.b', match(eq('x', 1))) + + then: + thrown(IllegalArgumentException) + + when: + FusionPipeline.of('a\u0000b', match(eq('x', 1))) + + then: + thrown(IllegalArgumentException) + when: FusionPipeline.of(null, match(eq('x', 1))) From e12a387883ea92d089fd013b439aaad294538e87 Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Tue, 28 Jul 2026 14:55:50 +0100 Subject: [PATCH 21/24] JAVA-5990 Drop stale @Sealed from the ScoreNormalization Scala alias --- .../src/main/scala/org/mongodb/scala/model/package.scala | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/driver-scala/src/main/scala/org/mongodb/scala/model/package.scala b/driver-scala/src/main/scala/org/mongodb/scala/model/package.scala index 0d70d1c0c0..939390ecc9 100644 --- a/driver-scala/src/main/scala/org/mongodb/scala/model/package.scala +++ b/driver-scala/src/main/scala/org/mongodb/scala/model/package.scala @@ -390,12 +390,11 @@ package object model { } /** - * The way in which the scores produced by the `\$scoreFusion` input pipelines are normalized before being combined. + * Normalization methods for the `\$score` and `\$scoreFusion` pipeline stages. * * @note Requires MongoDB 8.2 or greater * @since 5.10 */ - @Sealed type ScoreNormalization = com.mongodb.client.model.ScoreNormalization /** From eba76c0aa27b3501b60da94bf5af4a5627d38652 Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Tue, 28 Jul 2026 14:58:23 +0100 Subject: [PATCH 22/24] JAVA-5990 Align ScoreFusionCombination companion with ScoreFusionOptions (no @Sealed on object) --- .../scala/org/mongodb/scala/model/ScoreFusionCombination.scala | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/driver-scala/src/main/scala/org/mongodb/scala/model/ScoreFusionCombination.scala b/driver-scala/src/main/scala/org/mongodb/scala/model/ScoreFusionCombination.scala index cb07bbd9ca..3a3a9b716b 100644 --- a/driver-scala/src/main/scala/org/mongodb/scala/model/ScoreFusionCombination.scala +++ b/driver-scala/src/main/scala/org/mongodb/scala/model/ScoreFusionCombination.scala @@ -15,7 +15,6 @@ */ package org.mongodb.scala.model -import com.mongodb.annotations.Sealed import com.mongodb.client.model.{ ScoreFusionCombination => JScoreFusionCombination } import org.mongodb.scala.bson.conversions.Bson @@ -25,7 +24,7 @@ import org.mongodb.scala.bson.conversions.Bson * @note Requires MongoDB 8.2 or greater * @since 5.10 */ -@Sealed object ScoreFusionCombination { +object ScoreFusionCombination { /** * Returns a `WeightedScoreFusionCombination` that combines the normalized scores using the given weights. From 1ab5d485ec33f29ce5d4eb069ede2106ac028fb1 Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Tue, 28 Jul 2026 15:06:35 +0100 Subject: [PATCH 23/24] JAVA-5990 Strengthen assertions in the Atlas $scoreFusion integration test --- .../AggregatesSearchIntegrationTest.java | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/driver-core/src/test/functional/com/mongodb/client/model/search/AggregatesSearchIntegrationTest.java b/driver-core/src/test/functional/com/mongodb/client/model/search/AggregatesSearchIntegrationTest.java index 618fe34edb..7bc8fcd89e 100644 --- a/driver-core/src/test/functional/com/mongodb/client/model/search/AggregatesSearchIntegrationTest.java +++ b/driver-core/src/test/functional/com/mongodb/client/model/search/AggregatesSearchIntegrationTest.java @@ -19,6 +19,7 @@ import com.mongodb.MongoNamespace; import com.mongodb.assertions.Assertions; import com.mongodb.client.model.Aggregates; +import com.mongodb.client.model.Projections; import com.mongodb.client.model.FusionPipeline; import com.mongodb.client.model.ScoreFusionCombination; import com.mongodb.client.model.ScoreNormalization; @@ -355,9 +356,28 @@ void scoreFusion() { .combination(ScoreFusionCombination.weighted( new Document("vector", 0.7).append("text", 0.3))) .scoreDetails(true)), - Aggregates.limit(LIMIT)); + Aggregates.limit(LIMIT), + Aggregates.project(Projections.fields( + Projections.meta("score", "score"), + Projections.meta("scoreDetails", "scoreDetails")))); List results = collectionHelper.aggregate(pipeline); Asserters.nonEmpty().accept(results, msgSupplier(pipeline)); + assertTrue(results.size() <= LIMIT, msgSupplier(pipeline)); + double previousScore = Double.MAX_VALUE; + for (BsonDocument result : results) { + double score = result.getNumber("score").doubleValue(); + // each sigmoid-normalized pipeline score is in (0, 1) and the weighted scores are averaged + assertTrue(score > 0 && score < 1, msgSupplier(pipeline)); + // results are ordered by descending fused score + assertTrue(score <= previousScore, msgSupplier(pipeline)); + previousScore = score; + BsonDocument scoreDetails = result.getDocument("scoreDetails"); + assertEquals(score, scoreDetails.getNumber("value").doubleValue(), msgSupplier(pipeline)); + assertEquals("sigmoid", scoreDetails.getString("normalization").getValue(), msgSupplier(pipeline)); + assertFalse(scoreDetails.getString("description").getValue().isEmpty(), msgSupplier(pipeline)); + // one details entry per input pipeline + assertEquals(2, scoreDetails.getArray("details").size(), msgSupplier(pipeline)); + } } /** From df3c366231bd199235f17969763b5d92bb76a9f0 Mon Sep 17 00:00:00 2001 From: Nabil Hachicha Date: Tue, 28 Jul 2026 15:39:56 +0100 Subject: [PATCH 24/24] JAVA-5990 Add Scala rendering tests for $scoreFusion and remove unused imports --- .../mongodb/client/model/AggregatesTest.java | 1 - .../model/AggregatesSpecification.groovy | 1 - .../mongodb/scala/model/AggregatesSpec.scala | 90 +++++++++++++++++++ 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/driver-core/src/test/functional/com/mongodb/client/model/AggregatesTest.java b/driver-core/src/test/functional/com/mongodb/client/model/AggregatesTest.java index 0715c8e07d..755ac52337 100644 --- a/driver-core/src/test/functional/com/mongodb/client/model/AggregatesTest.java +++ b/driver-core/src/test/functional/com/mongodb/client/model/AggregatesTest.java @@ -60,7 +60,6 @@ import static com.mongodb.client.model.search.VectorSearchOptions.approximateVectorSearchOptions; import static com.mongodb.client.model.search.VectorSearchQuery.textQuery; import static java.util.Arrays.asList; -import static java.util.Collections.singletonList; import static java.util.stream.Collectors.toList; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.hasSize; diff --git a/driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy b/driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy index 86314833b8..38a4e9c59c 100644 --- a/driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy +++ b/driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy @@ -22,7 +22,6 @@ import com.mongodb.client.model.search.SearchCollector import com.mongodb.client.model.search.SearchOperator import org.bson.BsonDocument import org.bson.BsonInt32 -import org.bson.BsonString import org.bson.Document import org.bson.BinaryVector import org.bson.conversions.Bson diff --git a/driver-scala/src/test/scala/org/mongodb/scala/model/AggregatesSpec.scala b/driver-scala/src/test/scala/org/mongodb/scala/model/AggregatesSpec.scala index 4969b14969..e1edb4e78e 100644 --- a/driver-scala/src/test/scala/org/mongodb/scala/model/AggregatesSpec.scala +++ b/driver-scala/src/test/scala/org/mongodb/scala/model/AggregatesSpec.scala @@ -908,6 +908,96 @@ class AggregatesSpec extends BaseSpec { ) } + it should "render $scoreFusion" in { + toBson( + Aggregates.scoreFusion( + Seq( + FusionPipeline("p1", Aggregates.filter(Filters.equal("x", 1))), + FusionPipeline("p2", Aggregates.filter(Filters.equal("x", 2))) + ), + ScoreNormalization.SIGMOID + ) + ) should equal( + Document( + """{ + "$scoreFusion": { + "input": { + "pipelines": { + "p1": [{"$match": {"x": 1}}], + "p2": [{"$match": {"x": 2}}] + }, + "normalization": "sigmoid" + } + } + }""" + ) + ) + } + + it should "render $scoreFusion with options" in { + toBson( + Aggregates.scoreFusion( + Seq( + FusionPipeline("p1", Aggregates.filter(Filters.equal("x", 1))), + FusionPipeline("p2", Aggregates.filter(Filters.equal("x", 2))) + ), + ScoreNormalization.MIN_MAX_SCALER, + ScoreFusionOptions + .scoreFusionOptions() + .combination(ScoreFusionCombination.weighted(Document("p1" -> 0.3, "p2" -> 0.7)).avg()) + .scoreDetails(true) + ) + ) should equal( + Document( + """{ + "$scoreFusion": { + "input": { + "pipelines": { + "p1": [{"$match": {"x": 1}}], + "p2": [{"$match": {"x": 2}}] + }, + "normalization": "minMaxScaler" + }, + "combination": {"weights": {"p1": 0.3, "p2": 0.7}, "method": "avg"}, + "scoreDetails": true + } + }""" + ) + ) + } + + it should "render $scoreFusion with expression combination" in { + toBson( + Aggregates.scoreFusion( + Seq( + FusionPipeline("p1", Aggregates.filter(Filters.equal("x", 1))), + FusionPipeline("p2", Aggregates.filter(Filters.equal("x", 2))) + ), + ScoreNormalization.NONE, + ScoreFusionOptions + .scoreFusionOptions() + .combination( + ScoreFusionCombination.expression(Document("""{$sum: ["$$p1", "$$p2"]}""")) + ) + ) + ) should equal( + Document( + """{ + "$scoreFusion": { + "input": { + "pipelines": { + "p1": [{"$match": {"x": 1}}], + "p2": [{"$match": {"x": 2}}] + }, + "normalization": "none" + }, + "combination": {"method": "expression", "expression": {"$sum": ["$$p1", "$$p2"]}} + } + }""" + ) + ) + } + it should "render $unset" in { toBson( Aggregates.unset("title", "author.first")