From cf0c71e164dac90c01f8dbe63ce34d8fc0e3f9a7 Mon Sep 17 00:00:00 2001 From: Zhang Shun Date: Wed, 16 Sep 2026 09:27:16 +0800 Subject: [PATCH] perf: optimize candidate percentile calculation --- docs/backend-development-roadmap.md | 2 +- docs/r01-d1-percentile-performance-report.md | 25 ++++++ .../CandidatePercentileCalculator.java | 45 ++++++++++ .../CompanyScoringDashboardService.java | 17 +--- .../CandidatePercentileCalculatorTest.java | 87 +++++++++++++++++++ 5 files changed, 162 insertions(+), 14 deletions(-) create mode 100644 docs/r01-d1-percentile-performance-report.md create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CandidatePercentileCalculator.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/dashboard/CandidatePercentileCalculatorTest.java diff --git a/docs/backend-development-roadmap.md b/docs/backend-development-roadmap.md index 4d2938f..7c09df5 100644 --- a/docs/backend-development-roadmap.md +++ b/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节为准,移出一期 | diff --git a/docs/r01-d1-percentile-performance-report.md b/docs/r01-d1-percentile-performance-report.md new file mode 100644 index 0000000..529e780 --- /dev/null +++ b/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`,测试立即发现 `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、公网接口、表、索引或依赖变化; +- 未暂存、未提交、未推送。 diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CandidatePercentileCalculator.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CandidatePercentileCalculator.java new file mode 100644 index 0000000..02325f7 --- /dev/null +++ b/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 calculate(List scores) { + Objects.requireNonNull(scores, "scores"); + List scored = scores.stream().filter(Objects::nonNull).sorted().toList(); + if (scored.size() < 2) { + return scores.stream().map(ignored -> (BigDecimal) null).toList(); + } + + Map 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 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); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardService.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardService.java index b45d78d..de7d9c7 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardService.java @@ -423,19 +423,10 @@ public class CompanyScoringDashboardService { } private void computePercentiles(List candidates) { - List 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 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); } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/dashboard/CandidatePercentileCalculatorTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/dashboard/CandidatePercentileCalculatorTest.java new file mode 100644 index 0000000..3f2bbb1 --- /dev/null +++ b/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 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 domain, List 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 legacy(List scores) { + List scored = scores.stream().filter(java.util.Objects::nonNull).toList(); + if (scored.size() < 2) return scores.stream().map(ignored -> (BigDecimal) null).toList(); + List 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 List list(T... values) { + return java.util.Arrays.asList(values); + } +}