5 changed files with 162 additions and 14 deletions
@ -0,0 +1,25 @@ |
|||
# R01-D1 百分位性能收束报告 |
|||
|
|||
> 状态:R01-D1 已实现并通过自动化与干净构建,待独立复验。 |
|||
|
|||
## 修改范围 |
|||
|
|||
- 新增纯函数 `CandidatePercentileCalculator`; |
|||
- `CompanyScoringDashboardService` 保留原调用位置,仅将百分位计算委托给纯函数; |
|||
- 未修改候选人综合分、排序、分页、过滤、披露门槛或响应结构。 |
|||
|
|||
## 算法与兼容性 |
|||
|
|||
旧实现对每个候选分数再次扫描全部分数,复杂度为 O(n²)。新实现先排序,再以每个分数第一次出现的位置作为“严格低于该分数的数量”,复杂度为 O(n log n)。分母仍为 `有效分数数 - 1`,仍使用 10 位中间精度、HALF_UP,并最终保留两位小数。 |
|||
|
|||
等价测试覆盖空集合、单样本、全 null、输入顺序、null 位置、并列分数,以及由 `{null, 0, 1.0, 1.00, 2.5}` 生成的长度 0–6 全组合,并逐项与旧 O(n²) 公式比较。 |
|||
|
|||
首次实现使用 `HashMap<BigDecimal, ...>`,测试立即发现 `1.0` 与 `1.00` 被错误视为不同键;改为按 `BigDecimal.compareTo` 排序的 `TreeMap` 后恢复旧语义。另一个首次失败来自手写并列夹具把最高分期望误写为 66.67;旧公式预言机证明应为 100.00,修正夹具期望,未改变生产公式。 |
|||
|
|||
## 验证 |
|||
|
|||
- 定向与看板关联测试:28/28,Failures=0、Errors=0、Skipped=0; |
|||
- `mvn clean verify`:2099/2099,Failures=0、Errors=0、Skipped=20,BUILD SUCCESS;20 项为既有环境条件跳过项,R01-D1 定向及关联测试 Skipped=0; |
|||
- OpenAPI:176 paths / 214 operations,All checks passed; |
|||
- 无 Flyway、ErrorCode、公网接口、表、索引或依赖变化; |
|||
- 未暂存、未提交、未推送。 |
|||
@ -0,0 +1,45 @@ |
|||
package com.mydreamjob.candidatescore.dashboard; |
|||
|
|||
import java.math.BigDecimal; |
|||
import java.math.RoundingMode; |
|||
import java.util.ArrayList; |
|||
import java.util.Collections; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.Objects; |
|||
import java.util.TreeMap; |
|||
|
|||
/** Pure O(n log n) percentile calculator preserving the dashboard's historical lower-rank formula. */ |
|||
public final class CandidatePercentileCalculator { |
|||
|
|||
private CandidatePercentileCalculator() { |
|||
} |
|||
|
|||
public static List<BigDecimal> calculate(List<BigDecimal> scores) { |
|||
Objects.requireNonNull(scores, "scores"); |
|||
List<BigDecimal> scored = scores.stream().filter(Objects::nonNull).sorted().toList(); |
|||
if (scored.size() < 2) { |
|||
return scores.stream().map(ignored -> (BigDecimal) null).toList(); |
|||
} |
|||
|
|||
Map<BigDecimal, Long> lowerCountByScore = new TreeMap<>(); |
|||
for (int index = 0; index < scored.size(); index++) { |
|||
BigDecimal score = scored.get(index); |
|||
lowerCountByScore.putIfAbsent(score, (long) index); |
|||
} |
|||
|
|||
BigDecimal denominator = BigDecimal.valueOf(scored.size() - 1L); |
|||
List<BigDecimal> result = new ArrayList<>(scores.size()); |
|||
for (BigDecimal score : scores) { |
|||
if (score == null) { |
|||
result.add(null); |
|||
continue; |
|||
} |
|||
result.add(BigDecimal.valueOf(lowerCountByScore.get(score)) |
|||
.divide(denominator, 10, RoundingMode.HALF_UP) |
|||
.multiply(BigDecimal.valueOf(100)) |
|||
.setScale(2, RoundingMode.HALF_UP)); |
|||
} |
|||
return Collections.unmodifiableList(result); |
|||
} |
|||
} |
|||
@ -0,0 +1,87 @@ |
|||
package com.mydreamjob.candidatescore.dashboard; |
|||
|
|||
import org.junit.jupiter.api.Test; |
|||
|
|||
import java.math.BigDecimal; |
|||
import java.math.RoundingMode; |
|||
import java.util.ArrayList; |
|||
import java.util.List; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.assertEquals; |
|||
import static org.junit.jupiter.api.Assertions.assertThrows; |
|||
|
|||
class CandidatePercentileCalculatorTest { |
|||
|
|||
@Test |
|||
void emptySingleAndNullOnlySamplesRemainUndisclosed() { |
|||
assertEquals(List.of(), CandidatePercentileCalculator.calculate(List.of())); |
|||
assertEquals(list((BigDecimal) null), CandidatePercentileCalculator.calculate(list(decimal("80")))); |
|||
assertEquals(list(null, null), CandidatePercentileCalculator.calculate(list(null, null))); |
|||
} |
|||
|
|||
@Test |
|||
void preservesInputOrderAndNullPositions() { |
|||
assertEquals(list(decimal("100.00"), null, decimal("0.00"), decimal("50.00")), |
|||
CandidatePercentileCalculator.calculate( |
|||
list(decimal("90"), null, decimal("70"), decimal("80")))); |
|||
} |
|||
|
|||
@Test |
|||
void tiedScoresUseStrictlyLowerCount() { |
|||
assertEquals(list(decimal("0.00"), decimal("0.00"), decimal("100.00")), |
|||
CandidatePercentileCalculator.calculate( |
|||
list(decimal("80.0"), decimal("80.00"), decimal("90")))); |
|||
} |
|||
|
|||
@Test |
|||
void matchesHistoricalQuadraticFormulaAcrossGeneratedMatrix() { |
|||
List<BigDecimal> domain = list(null, decimal("0"), decimal("1.0"), decimal("1.00"), decimal("2.5")); |
|||
for (int size = 0; size <= 6; size++) { |
|||
compareEveryCombination(domain, new ArrayList<>(), size); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void nullInputPreservesHistoricalFailFastBehavior() { |
|||
assertThrows(NullPointerException.class, () -> CandidatePercentileCalculator.calculate(null)); |
|||
} |
|||
|
|||
private void compareEveryCombination(List<BigDecimal> domain, List<BigDecimal> current, int targetSize) { |
|||
if (current.size() == targetSize) { |
|||
assertEquals(legacy(current), CandidatePercentileCalculator.calculate(current), current::toString); |
|||
return; |
|||
} |
|||
for (BigDecimal value : domain) { |
|||
current.add(value); |
|||
compareEveryCombination(domain, current, targetSize); |
|||
current.remove(current.size() - 1); |
|||
} |
|||
} |
|||
|
|||
private List<BigDecimal> legacy(List<BigDecimal> scores) { |
|||
List<BigDecimal> scored = scores.stream().filter(java.util.Objects::nonNull).toList(); |
|||
if (scored.size() < 2) return scores.stream().map(ignored -> (BigDecimal) null).toList(); |
|||
List<BigDecimal> result = new ArrayList<>(); |
|||
for (BigDecimal score : scores) { |
|||
if (score == null) { |
|||
result.add(null); |
|||
continue; |
|||
} |
|||
long lower = scored.stream().filter(candidate -> candidate.compareTo(score) < 0).count(); |
|||
result.add(BigDecimal.valueOf(lower) |
|||
.divide(BigDecimal.valueOf(scored.size() - 1L), 10, RoundingMode.HALF_UP) |
|||
.multiply(BigDecimal.valueOf(100)) |
|||
.setScale(2, RoundingMode.HALF_UP)); |
|||
} |
|||
return java.util.Collections.unmodifiableList(result); |
|||
} |
|||
|
|||
private BigDecimal decimal(String value) { |
|||
return new BigDecimal(value); |
|||
} |
|||
|
|||
@SafeVarargs |
|||
private final <T> List<T> list(T... values) { |
|||
return java.util.Arrays.asList(values); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue