Browse Source

perf: optimize candidate percentile calculation

refactor/r01-structure-performance
Zhang Shun 1 month ago
parent
commit
cf0c71e164
  1. 2
      docs/backend-development-roadmap.md
  2. 25
      docs/r01-d1-percentile-performance-report.md
  3. 45
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CandidatePercentileCalculator.java
  4. 17
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardService.java
  5. 87
      mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/dashboard/CandidatePercentileCalculatorTest.java

2
docs/backend-development-roadmap.md

@ -15,7 +15,7 @@
| S02 Access/Refresh Token 隔离 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | 类型隔离、独立密钥、轮换、重放处置、会话吊销已锁定;OpenAPI 175/213 |
| S03 生产配置与简历隐私 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | V43 禁用原始指纹种子账号;管理员初始化默认关闭且幂等;企业简历下载按隐私决策表授权;Actuator 隔离到管理端口;OpenAPI 176/214 |
| Q01 岗位搜索真实查询覆盖(C02 证据补充) | 已完成实现、跨库一致性整改、独立复验及 Git 检查点(`77dc624`)。新增 54 条真实库测试:H2 Repository 21、H2 Service 9、H2 副作用 4、MySQL 8.4 Specification 15、MySQL 副作用 4、MySQL EXPLAIN 1;全量 1890/1890 | OpenAPI 保持 176/214;前导通配符、filesort 与真实规模执行计划移交 R01 |
| R01 结构与性能收束 | R01-A `0b5b849`、R01-B `15bd98b` 已建本地检查点;R01-C 已完成纯计算规则收束和自动化验证,待独立复验 | overview 状态保留两条历史输入口径;推荐偏好保持 30/20/20/15/15;后续 R01-D/E 独立实施 |
| R01 结构与性能收束 | R01-A `0b5b849`、R01-B `15bd98b`、R01-C `b68519e` 已建本地检查点;R01-D1 已完成百分位 O(n log n) 收束,待独立复验 | overview 与推荐偏好口径已集中;后续继续处理数据导出 N+1、会话未读数和其余性能项 |
| 其余五个功能组 | 技能词云、入职清单/公司知识、薪资计算器/参考、诚信积分/激励、简历查重 | N03–N06、N08仍属一期,细节设计后编码 |
| 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 |
| 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 |

25
docs/r01-d1-percentile-performance-report.md

@ -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、公网接口、表、索引或依赖变化;
- 未暂存、未提交、未推送。

45
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CandidatePercentileCalculator.java

@ -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);
}
}

17
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardService.java

@ -423,19 +423,10 @@ public class CompanyScoringDashboardService {
}
private void computePercentiles(List<CandidateWithScore> candidates) {
List<BigDecimal> scored = candidates.stream()
.map(c -> c.overallScore).filter(Objects::nonNull).collect(Collectors.toList());
if (scored.size() < 2) {
candidates.forEach(c -> c.percentile = null);
return;
}
for (CandidateWithScore c : candidates) {
if (c.overallScore == null) { c.percentile = null; continue; }
long lower = scored.stream().filter(s -> s.compareTo(c.overallScore) < 0).count();
c.percentile = BigDecimal.valueOf(lower)
.divide(BigDecimal.valueOf(scored.size() - 1), 10, RoundingMode.HALF_UP)
.multiply(BigDecimal.valueOf(100))
.setScale(2, RoundingMode.HALF_UP);
List<BigDecimal> percentiles = CandidatePercentileCalculator.calculate(
candidates.stream().map(c -> c.overallScore).toList());
for (int index = 0; index < candidates.size(); index++) {
candidates.get(index).percentile = percentiles.get(index);
}
}

87
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/dashboard/CandidatePercentileCalculatorTest.java

@ -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…
Cancel
Save