feat(engine): measure recall against the neighbors that exist

A filtered ground truth pads a query whose predicate matches fewer than k rows with sentinels no search can return, and the strict recall divides by k regardless, so a small slice reads low by construction. attainable_recall leaves the sentinels out of the denominator: the share of the neighbors that exist which were found. A row with nothing attainable scores 1.0; how many such rows there are is a fact about the ground truth, not the search.
This commit is contained in:
Jonathan Shook committed 2026-09-16 07:04:49 +00:00
1 parent d557669a80
commit 085e545787
4 files changed
+105

No files matched your search

@@ -135,6 +135,30 @@ public class ComputeFunctions extends NBBaseComponent {
return (double) common_indices / (double) k;
}
/// Recall against the neighbors that exist. Of the first `k`
/// ground-truth entries, a negative one is a **sentinel** for a
/// neighbor that does not exist — a filtered search over a slice too
/// small for its predicate leaves such rows padded — and is left out
/// of both the numerator and the denominator: the result is the
/// share of the attainable neighbors found in the first `k` results.
/// A row with no attainable neighbor scores `1.0`, since nothing
/// could be found and nothing was missed; how many such rows there
/// are is a fact about the ground truth, reported before a run by
/// its coverage, not a fact about the search. The strict
/// [#recall(int[], int[], int)] divides by `k` regardless and is
/// the measure to read on a ground truth with no sentinels.
public static double attainableRecall(int[] ground_truth, int[] actual, int k) {
if (ground_truth.length < k) {
throw new RuntimeException("ground truth size " + ground_truth.length + " is less than k=" + k);
}
int[] attainable = Arrays.stream(ground_truth, 0, k).filter(entry -> entry >= 0).toArray();
if (attainable.length == 0) return 1.0d;
int[] found = Arrays.stream(actual, 0, Math.min(k, actual.length)).filter(entry -> entry >= 0).toArray();
Arrays.sort(attainable);
Arrays.sort(found);
return (double) Intersections.count(attainable, found) / (double) attainable.length;
}
public static double precision(int[] relevant, int[] actual) {
Arrays.sort(relevant);
Arrays.sort(actual);
@@ -34,6 +34,18 @@ public class RelevancyFunctions {
return new Recall(name, k);
}
public static AttainableRecall attainable_recall(String name, int k, Map<String, String> labels) {
return new AttainableRecall(name, k, labels);
}
public static AttainableRecall attainable_recall(String name, int k, Object... labels) {
return new AttainableRecall(name, k, labels);
}
public static AttainableRecall attainable_recall(String name, int k) {
return new AttainableRecall(name, k);
}
public static Precision precision(String name, int k, Map<String, String> labels) {
return new Precision(name, k, labels);
}
@@ -0,0 +1,54 @@
/*
* Copyright (c) nosqlbench
*
* 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 io.nosqlbench.engine.extensions.computefunctions.relavency;
import io.nosqlbench.engine.extensions.computefunctions.ComputeFunctions;
import io.nosqlbench.nb.api.labels.NBLabels;
import java.util.Map;
/// Recall against the neighbors that exist: the share of a query's
/// **attainable** ground truth found in the first `k` results, where a
/// negative ground-truth entry is a sentinel for a neighbor that does
/// not exist and is left out of the denominator. The strict
/// [Recall] divides by `k` whatever the row holds, so a filtered search
/// whose predicate matches fewer than `k` rows reads low by
/// construction; this function reads what the search could have done.
/// See [ComputeFunctions#attainableRecall(int[], int[], int)].
public class AttainableRecall extends BaseRelevancyFunction {
private final int k;
public AttainableRecall(String name, int k, Object... labels) {
super(name, NBLabels.forKV("k", k).and(NBLabels.forKV(labels)));
this.k = k;
}
public AttainableRecall(String name, int k, Map<String, String> labels) {
super(name, NBLabels.forKV("k", labels).andMap(labels));
this.k = k;
}
@Override
public double apply(int[] relevant, int[] actual) {
return ComputeFunctions.attainableRecall(relevant, actual, k);
}
@Override
public String getUniqueName() {
return getName() + "_" + k;
}
}
@@ -57,6 +57,21 @@ class ComputeFunctionsIntTest {
}
}
@Test
public void attainableRecallLeavesSentinelsOutOfTheDenominator() {
int[] padded = new int[]{3, 7, -1, -1, -1};
assertThat(ComputeFunctions.recall(padded, int_3_7_1_9_5, 5)).as(
"strict recall counts a sentinel as a miss").isCloseTo(0.4d, offset);
assertThat(ComputeFunctions.attainableRecall(padded, int_3_7_1_9_5, 5)).as(
"both attainable neighbors were found").isCloseTo(1.0d, offset);
assertThat(ComputeFunctions.attainableRecall(padded, new int[]{3, 8, -2, -3, -4}, 5)).as(
"one of two attainable neighbors found; padded results never match").isCloseTo(0.5d, offset);
assertThat(ComputeFunctions.attainableRecall(new int[]{-1, -1, -1, -1, -1}, int_3_7_1_9_5, 5)).as(
"nothing attainable, nothing missed").isCloseTo(1.0d, offset);
assertThat(ComputeFunctions.attainableRecall(allInts, int_3_7_1_9_5, 5)).as(
"with no sentinels it is the strict recall").isCloseTo(ComputeFunctions.recall(allInts, int_3_7_1_9_5, 5), offset);
}
@Test
public void testProportionalIntersectionAtSomeK() {
int[] image = new int[]{101,102,103,104,105};