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