QuestionDifficultyCalibrationService.php 43 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096
  1. <?php
  2. namespace App\Services\Analytics;
  3. use Illuminate\Support\Carbon;
  4. use Illuminate\Support\Facades\Cache;
  5. use Illuminate\Support\Facades\DB;
  6. use Illuminate\Support\Facades\Log;
  7. use Illuminate\Support\Facades\Schema;
  8. /**
  9. * 题目动态难度校准服务(分层基线残差 + 贝叶斯收缩 + 时间衰减)
  10. *
  11. * 目标:
  12. * 1) 判卷后实时吸收对错结果;
  13. * 2) 产出可直接用于组卷的校准难度(0~1);
  14. * 3) 通过先验约束与限幅,避免短期噪声导致难度抖动。
  15. */
  16. class QuestionDifficultyCalibrationService
  17. {
  18. private const TABLE = 'question_difficulty_calibrations';
  19. private const ALGO = 'stratified_residual_eb_v2';
  20. private const HALF_LIFE_DAYS = 45;
  21. private const BETA_PRIOR_A = 2.0;
  22. private const BETA_PRIOR_B = 2.0;
  23. private const SHRINKAGE_M0_MIN = 8.0;
  24. private const SHRINKAGE_M0_MAX = 24.0;
  25. private const RESIDUAL_GAIN_MIN = 1.0;
  26. private const RESIDUAL_GAIN_MAX = 2.2;
  27. private const RESIDUAL_SCALE_DENOM_MIN = 0.08;
  28. private const RESIDUAL_SCALE_DENOM_MAX = 0.20;
  29. private const RECENT_EVENTS_LIMIT = 30;
  30. private const MIN_DIFF = 0.01;
  31. private const MAX_DIFF = 0.99;
  32. private ?bool $tableReady = null;
  33. /** @var array<string, array{by_cat:array<string,float>,fallback:float,all_by_cat:array<string,float},> */
  34. private array $baselineCache = [];
  35. /**
  36. * 按一张试卷内已判分题目触发重估。
  37. *
  38. * @return int 参与更新的题目数
  39. */
  40. public function recalibrateByPaperId(string $paperId): int
  41. {
  42. $paperId = trim($paperId);
  43. if ($paperId === '' || ! $this->isReady()) {
  44. return 0;
  45. }
  46. $questionIds = DB::table('paper_questions')
  47. ->where('paper_id', $paperId)
  48. ->whereNotNull('question_bank_id')
  49. ->whereNotNull('is_correct')
  50. ->pluck('question_bank_id')
  51. ->map(fn ($id) => (int) $id)
  52. ->filter(fn ($id) => $id > 0)
  53. ->unique()
  54. ->values()
  55. ->all();
  56. return $this->recalibrateQuestionIds($questionIds);
  57. }
  58. /**
  59. * 在线逐题更新(无批量重算):仅更新本次判卷触达的题目。
  60. *
  61. * @param list<array<string,mixed>> $questions
  62. */
  63. public function updateOnlineFromPaper(string $paperId, array $questions): int
  64. {
  65. $paperId = trim($paperId);
  66. if ($paperId === '' || $questions === [] || ! $this->isReady()) {
  67. return 0;
  68. }
  69. $paper = DB::table('papers')->where('paper_id', $paperId)->first(['difficulty_category']);
  70. $paperDifficultyCategory = (string) ($paper->difficulty_category ?? 'unknown');
  71. $qidToOutcome = [];
  72. foreach ($questions as $question) {
  73. $qid = (int) ($question['question_id'] ?? $question['question_bank_id'] ?? 0);
  74. if ($qid <= 0) {
  75. continue;
  76. }
  77. $isCorrectArray = $question['is_correct'] ?? [];
  78. if (! is_array($isCorrectArray)) {
  79. $isCorrectArray = [$isCorrectArray ? 1 : 0];
  80. }
  81. $totalSteps = count($isCorrectArray);
  82. if ($totalSteps <= 0) {
  83. continue;
  84. }
  85. $correctSteps = array_sum(array_map(fn ($v) => (int) $v === 1 ? 1 : 0, $isCorrectArray));
  86. $correctRatio = $correctSteps / max(1, $totalSteps);
  87. $outcomeError = 1.0 - $correctRatio;
  88. $qidToOutcome[$qid] = [
  89. 'outcome_error' => $this->clamp((float) $outcomeError, 0.0, 1.0),
  90. 'is_fully_correct' => $correctSteps === $totalSteps ? 1 : 0,
  91. ];
  92. }
  93. if ($qidToOutcome === []) {
  94. return 0;
  95. }
  96. $questionIds = array_keys($qidToOutcome);
  97. $questionTypeRows = DB::table('paper_questions')
  98. ->where('paper_id', $paperId)
  99. ->where(function ($q) use ($questionIds) {
  100. $q->whereIn('question_bank_id', $questionIds)
  101. ->orWhereIn('question_id', $questionIds);
  102. })
  103. ->select(['question_bank_id', 'question_id', 'question_type'])
  104. ->get();
  105. $questionTypeByQid = [];
  106. $canonicalQidByInput = [];
  107. foreach ($questionTypeRows as $row) {
  108. $qt = trim((string) ($row->question_type ?? '')) !== '' ? (string) $row->question_type : 'unknown';
  109. $bankId = (int) ($row->question_bank_id ?? 0);
  110. $questionId = (int) ($row->question_id ?? 0);
  111. if ($bankId > 0 && ! isset($questionTypeByQid[$bankId])) {
  112. $questionTypeByQid[$bankId] = $qt;
  113. $canonicalQidByInput[$bankId] = $bankId;
  114. }
  115. if ($questionId > 0 && ! isset($questionTypeByQid[$questionId])) {
  116. $questionTypeByQid[$questionId] = $qt;
  117. }
  118. if ($questionId > 0 && $bankId > 0) {
  119. $canonicalQidByInput[$questionId] = $bankId;
  120. }
  121. }
  122. $baseDifficultyByQid = DB::table('questions')
  123. ->whereIn('id', $questionIds)
  124. ->pluck('difficulty', 'id')
  125. ->all();
  126. $existingLookupIds = array_values(array_unique(array_merge(
  127. $questionIds,
  128. array_values($canonicalQidByInput)
  129. )));
  130. $existingByQid = DB::table(self::TABLE)
  131. ->whereIn('question_bank_id', $existingLookupIds)
  132. ->get()
  133. ->keyBy('question_bank_id');
  134. $types = array_values(array_unique(array_values($questionTypeByQid)));
  135. $baselines = $this->buildGlobalBaselines($types);
  136. $healthScaleByType = [];
  137. $now = now();
  138. $upserts = [];
  139. foreach ($qidToOutcome as $qid => $outcome) {
  140. $outcomeError = (float) ($outcome['outcome_error'] ?? 1.0);
  141. $isFullyCorrect = (int) ($outcome['is_fully_correct'] ?? 0) === 1 ? 1 : 0;
  142. $canonicalQid = (int) ($canonicalQidByInput[$qid] ?? $qid);
  143. $existing = $existingByQid->get((string) $canonicalQid);
  144. if ($existing === null) {
  145. $existing = $existingByQid->get($canonicalQid);
  146. }
  147. $originalDifficulty = $existing !== null
  148. ? (float) ($existing->original_difficulty ?? 0.5)
  149. : ($this->normalizeDifficultyValue($baseDifficultyByQid[$qid] ?? null) ?? 0.5);
  150. $originalDifficulty = $this->clamp($originalDifficulty, self::MIN_DIFF, self::MAX_DIFF);
  151. $prevDifficulty = $existing !== null
  152. ? (float) ($existing->calibrated_difficulty ?? $originalDifficulty)
  153. : $originalDifficulty;
  154. $prevDifficulty = $this->clamp($prevDifficulty, self::MIN_DIFF, self::MAX_DIFF);
  155. $prevWeightedAttempts = $existing !== null ? (float) ($existing->weighted_attempts ?? 0.0) : 0.0;
  156. $prevWeightedWrong = $existing !== null ? (float) ($existing->weighted_wrong ?? 0.0) : 0.0;
  157. $lastAtRaw = $existing !== null ? ($existing->last_graded_at ?? null) : null;
  158. $existingMeta = [];
  159. if ($existing !== null && ! empty($existing->algorithm_meta)) {
  160. $existingMeta = json_decode((string) $existing->algorithm_meta, true) ?: [];
  161. }
  162. $questionType = $questionTypeByQid[$canonicalQid] ?? ($questionTypeByQid[$qid] ?? 'unknown');
  163. $baselineErr = $this->resolveBaselineErrorRate($questionType, $paperDifficultyCategory, $baselines);
  164. if (! isset($healthScaleByType[$questionType])) {
  165. $healthScaleByType[$questionType] = $this->getHealthScaleForType($questionType);
  166. }
  167. $healthScale = (float) $healthScaleByType[$questionType];
  168. $estimate = $this->estimateOnlineBySingleOutcome(
  169. $originalDifficulty,
  170. $prevDifficulty,
  171. $prevWeightedAttempts,
  172. $prevWeightedWrong,
  173. $outcomeError,
  174. $baselineErr,
  175. $lastAtRaw,
  176. $healthScale
  177. );
  178. $event = $this->buildUpdateEvent(
  179. $outcomeError,
  180. $prevDifficulty,
  181. (float) $estimate['calibrated_difficulty'],
  182. (float) ($estimate['meta']['expected_error_rate'] ?? $baselineErr),
  183. (float) ($estimate['meta']['observed_error_rate'] ?? ($estimate['weighted_error_rate'] ?? 0.5)),
  184. (float) ($estimate['meta']['residual'] ?? 0.0),
  185. $now
  186. );
  187. $meta = array_merge($existingMeta, $estimate['meta'], [
  188. 'mode' => 'online_single_outcome',
  189. 'paper_id' => $paperId,
  190. 'paper_difficulty_category' => $paperDifficultyCategory,
  191. 'question_type' => $questionType,
  192. 'baseline_error_rate' => round($baselineErr, 4),
  193. 'health_scale' => round($healthScale, 4),
  194. ]);
  195. $meta = $this->appendRecentEvent($meta, $event);
  196. $prevAttempts = $existing !== null ? (int) ($existing->attempts ?? 0) : 0;
  197. $prevCorrectCount = $existing !== null ? (int) ($existing->correct_count ?? 0) : 0;
  198. $prevWrongCount = $existing !== null ? (int) ($existing->wrong_count ?? 0) : 0;
  199. $attempts = $prevAttempts + 1;
  200. $correctCount = $prevCorrectCount + ($isFullyCorrect === 1 ? 1 : 0);
  201. // wrong_count 与历史 is_correct 口径对齐:仅“全错”计入 wrong_count。
  202. $wrongCount = $prevWrongCount + ($outcomeError >= 0.9999 ? 1 : 0);
  203. $upserts[] = [
  204. 'question_bank_id' => $canonicalQid,
  205. 'original_difficulty' => round($originalDifficulty, 4),
  206. 'calibrated_difficulty' => round($estimate['calibrated_difficulty'], 4),
  207. 'difficulty_delta' => round($estimate['calibrated_difficulty'] - $originalDifficulty, 4),
  208. 'attempts' => $attempts,
  209. 'correct_count' => $correctCount,
  210. 'wrong_count' => $wrongCount,
  211. 'weighted_attempts' => round($estimate['weighted_attempts'], 4),
  212. 'weighted_wrong' => round($estimate['weighted_wrong'], 4),
  213. 'weighted_error_rate' => round($estimate['weighted_error_rate'], 4),
  214. 'last_graded_at' => $now->toDateTimeString(),
  215. 'algorithm' => self::ALGO.'_online',
  216. 'algorithm_meta' => json_encode($meta, JSON_UNESCAPED_UNICODE),
  217. 'updated_at' => $now,
  218. 'created_at' => $now,
  219. ];
  220. }
  221. if ($upserts === []) {
  222. return 0;
  223. }
  224. DB::table(self::TABLE)->upsert(
  225. $upserts,
  226. ['question_bank_id'],
  227. [
  228. 'original_difficulty',
  229. 'calibrated_difficulty',
  230. 'difficulty_delta',
  231. 'attempts',
  232. 'correct_count',
  233. 'wrong_count',
  234. 'weighted_attempts',
  235. 'weighted_wrong',
  236. 'weighted_error_rate',
  237. 'last_graded_at',
  238. 'algorithm',
  239. 'algorithm_meta',
  240. 'updated_at',
  241. ]
  242. );
  243. Log::info('QuestionDifficultyCalibrationService: 在线逐题更新完成', [
  244. 'paper_id' => $paperId,
  245. 'updated_question_count' => count($upserts),
  246. ]);
  247. return count($upserts);
  248. }
  249. /**
  250. * @param array<int, int|string> $questionIds
  251. * @return int 参与更新的题目数
  252. */
  253. public function recalibrateQuestionIds(array $questionIds): int
  254. {
  255. if (! $this->isReady()) {
  256. return 0;
  257. }
  258. $questionIds = collect($questionIds)
  259. ->map(fn ($id) => (int) $id)
  260. ->filter(fn ($id) => $id > 0)
  261. ->unique()
  262. ->values()
  263. ->all();
  264. if ($questionIds === []) {
  265. return 0;
  266. }
  267. $baseDifficultyById = DB::table('questions')
  268. ->whereIn('id', $questionIds)
  269. ->pluck('difficulty', 'id')
  270. ->all();
  271. $rows = DB::table('paper_questions as pq')
  272. ->join('papers as p', 'p.paper_id', '=', 'pq.paper_id')
  273. ->whereIn('pq.question_bank_id', $questionIds)
  274. ->whereNotNull('pq.is_correct')
  275. ->select([
  276. 'pq.question_bank_id',
  277. 'pq.question_type',
  278. 'pq.is_correct',
  279. 'pq.graded_at',
  280. 'pq.updated_at',
  281. 'pq.created_at',
  282. 'p.difficulty_category',
  283. ])
  284. ->orderBy('pq.question_bank_id')
  285. ->get();
  286. $grouped = [];
  287. foreach ($rows as $row) {
  288. $qid = (int) ($row->question_bank_id ?? 0);
  289. if ($qid <= 0) {
  290. continue;
  291. }
  292. $grouped[$qid] ??= [];
  293. $grouped[$qid][] = [
  294. 'question_type' => (string) ($row->question_type ?? ''),
  295. 'is_correct' => (int) ($row->is_correct ?? 0) === 1 ? 1 : 0,
  296. 'difficulty_category' => $row->difficulty_category ?? null,
  297. 'graded_at' => $row->graded_at ?? null,
  298. 'updated_at' => $row->updated_at ?? null,
  299. 'created_at' => $row->created_at ?? null,
  300. ];
  301. }
  302. $questionTypeById = [];
  303. foreach ($grouped as $qid => $attempts) {
  304. $questionTypeById[$qid] = $this->resolveQuestionType($attempts);
  305. }
  306. $baselines = $this->buildGlobalBaselines(array_values($questionTypeById));
  307. $upserts = [];
  308. $now = now();
  309. foreach ($questionIds as $qid) {
  310. $attempts = $grouped[$qid] ?? [];
  311. if ($attempts === []) {
  312. continue;
  313. }
  314. $originalDifficulty = $this->normalizeDifficultyValue($baseDifficultyById[$qid] ?? null) ?? 0.5;
  315. $questionType = $questionTypeById[$qid] ?? 'unknown';
  316. $estimate = $this->estimateByStratifiedResidual(
  317. $attempts,
  318. $originalDifficulty,
  319. $questionType,
  320. $baselines
  321. );
  322. $upserts[] = [
  323. 'question_bank_id' => $qid,
  324. 'original_difficulty' => round($originalDifficulty, 4),
  325. 'calibrated_difficulty' => round($estimate['calibrated_difficulty'], 4),
  326. 'difficulty_delta' => round($estimate['calibrated_difficulty'] - $originalDifficulty, 4),
  327. 'attempts' => $estimate['attempts'],
  328. 'correct_count' => $estimate['correct_count'],
  329. 'wrong_count' => $estimate['wrong_count'],
  330. 'weighted_attempts' => round($estimate['weighted_attempts'], 4),
  331. 'weighted_wrong' => round($estimate['weighted_wrong'], 4),
  332. 'weighted_error_rate' => $estimate['weighted_error_rate'] === null
  333. ? null
  334. : round($estimate['weighted_error_rate'], 4),
  335. 'last_graded_at' => $estimate['last_graded_at'],
  336. 'algorithm' => self::ALGO,
  337. 'algorithm_meta' => json_encode($estimate['meta'], JSON_UNESCAPED_UNICODE),
  338. 'updated_at' => $now,
  339. 'created_at' => $now,
  340. ];
  341. }
  342. if ($upserts === []) {
  343. return 0;
  344. }
  345. DB::table(self::TABLE)->upsert(
  346. $upserts,
  347. ['question_bank_id'],
  348. [
  349. 'original_difficulty',
  350. 'calibrated_difficulty',
  351. 'difficulty_delta',
  352. 'attempts',
  353. 'correct_count',
  354. 'wrong_count',
  355. 'weighted_attempts',
  356. 'weighted_wrong',
  357. 'weighted_error_rate',
  358. 'last_graded_at',
  359. 'algorithm',
  360. 'algorithm_meta',
  361. 'updated_at',
  362. ]
  363. );
  364. Log::info('QuestionDifficultyCalibrationService: 题目难度已重估入库', [
  365. 'question_count' => count($upserts),
  366. 'algorithm' => self::ALGO,
  367. ]);
  368. return count($upserts);
  369. }
  370. private function resolveQuestionType(array $attempts): string
  371. {
  372. foreach ($attempts as $attempt) {
  373. $type = trim((string) ($attempt['question_type'] ?? ''));
  374. if ($type !== '') {
  375. return $type;
  376. }
  377. }
  378. return 'unknown';
  379. }
  380. /**
  381. * @param array<int, string> $questionTypes
  382. * @return array<string, mixed>
  383. */
  384. private function buildGlobalBaselines(array $questionTypes): array
  385. {
  386. $cacheKey = implode('|', $questionTypes);
  387. if (isset($this->baselineCache[$cacheKey])) {
  388. return $this->baselineCache[$cacheKey];
  389. }
  390. $questionTypes = array_values(array_unique(array_filter(array_map(
  391. fn ($t) => trim((string) $t),
  392. $questionTypes
  393. ))));
  394. sort($questionTypes);
  395. $cacheKeyPersistent = 'difficulty_baselines_v1:'.md5(implode('|', $questionTypes));
  396. $result = Cache::remember($cacheKeyPersistent, now()->addMinutes(10), function () use ($questionTypes) {
  397. $baseQuery = DB::table('paper_questions as pq')
  398. ->join('papers as p', 'p.paper_id', '=', 'pq.paper_id')
  399. ->whereNotNull('pq.is_correct');
  400. $rows = (clone $baseQuery)
  401. ->when($questionTypes !== [], function ($q) use ($questionTypes) {
  402. $q->whereIn('pq.question_type', $questionTypes);
  403. })
  404. ->selectRaw('
  405. COALESCE(NULLIF(pq.question_type, ""), "unknown") as question_type,
  406. COALESCE(NULLIF(CAST(p.difficulty_category as char), ""), "unknown") as difficulty_category,
  407. COUNT(*) as n,
  408. SUM(CASE WHEN pq.is_correct = 0 THEN 1 ELSE 0 END) as wrong
  409. ')
  410. ->groupBy(DB::raw('COALESCE(NULLIF(pq.question_type, ""), "unknown")'))
  411. ->groupBy(DB::raw('COALESCE(NULLIF(CAST(p.difficulty_category as char), ""), "unknown")'))
  412. ->get();
  413. $allRows = (clone $baseQuery)
  414. ->selectRaw('
  415. COALESCE(NULLIF(CAST(p.difficulty_category as char), ""), "unknown") as difficulty_category,
  416. COUNT(*) as n,
  417. SUM(CASE WHEN pq.is_correct = 0 THEN 1 ELSE 0 END) as wrong
  418. ')
  419. ->groupBy(DB::raw('COALESCE(NULLIF(CAST(p.difficulty_category as char), ""), "unknown")'))
  420. ->get();
  421. $result = [
  422. 'type' => [],
  423. 'all' => [
  424. 'by_cat' => [],
  425. 'fallback' => 0.5,
  426. ],
  427. ];
  428. foreach ($rows as $row) {
  429. $type = (string) ($row->question_type ?? 'unknown');
  430. $cat = (string) ($row->difficulty_category ?? 'unknown');
  431. $n = (int) ($row->n ?? 0);
  432. $wrong = (int) ($row->wrong ?? 0);
  433. $result['type'][$type]['by_cat'][$cat] = $this->smoothedRate($wrong, $n);
  434. $result['type'][$type]['n_total'] = (int) (($result['type'][$type]['n_total'] ?? 0) + $n);
  435. $result['type'][$type]['wrong_total'] = (int) (($result['type'][$type]['wrong_total'] ?? 0) + $wrong);
  436. }
  437. foreach ($result['type'] as $type => $v) {
  438. $n = (int) ($v['n_total'] ?? 0);
  439. $wrong = (int) ($v['wrong_total'] ?? 0);
  440. $result['type'][$type]['fallback'] = $this->smoothedRate($wrong, $n);
  441. $result['type'][$type]['by_cat'] = $this->enforceMonotonicCategoryRates(
  442. $result['type'][$type]['by_cat'] ?? []
  443. );
  444. }
  445. $allN = 0;
  446. $allWrong = 0;
  447. foreach ($allRows as $row) {
  448. $cat = (string) ($row->difficulty_category ?? 'unknown');
  449. $n = (int) ($row->n ?? 0);
  450. $wrong = (int) ($row->wrong ?? 0);
  451. $result['all']['by_cat'][$cat] = $this->smoothedRate($wrong, $n);
  452. $allN += $n;
  453. $allWrong += $wrong;
  454. }
  455. $result['all']['by_cat'] = $this->enforceMonotonicCategoryRates($result['all']['by_cat']);
  456. $result['all']['fallback'] = $this->smoothedRate($allWrong, $allN);
  457. return $result;
  458. });
  459. $this->baselineCache[$cacheKey] = $result;
  460. return $result;
  461. }
  462. private function resolveBaselineErrorRate(string $questionType, string $difficultyCategory, array $baselines): float
  463. {
  464. $type = trim($questionType) !== '' ? trim($questionType) : 'unknown';
  465. $cat = trim($difficultyCategory) !== '' ? trim($difficultyCategory) : 'unknown';
  466. $typeByCat = $baselines['type'][$type]['by_cat'] ?? [];
  467. if (array_key_exists($cat, $typeByCat)) {
  468. return (float) $typeByCat[$cat];
  469. }
  470. if (isset($baselines['type'][$type]['fallback'])) {
  471. return (float) $baselines['type'][$type]['fallback'];
  472. }
  473. if (isset($baselines['all']['by_cat'][$cat])) {
  474. return (float) $baselines['all']['by_cat'][$cat];
  475. }
  476. return (float) ($baselines['all']['fallback'] ?? 0.5);
  477. }
  478. private function smoothedRate(int $wrong, int $n): float
  479. {
  480. return ($wrong + self::BETA_PRIOR_A) / max(1e-6, $n + self::BETA_PRIOR_A + self::BETA_PRIOR_B);
  481. }
  482. /**
  483. * 约束 difficulty_category 的基线错误率单调递增(0<=1<=2<=...),
  484. * 保留 unknown 等非数字类别原值。
  485. *
  486. * @param array<string,float> $ratesByCategory
  487. * @return array<string,float>
  488. */
  489. private function enforceMonotonicCategoryRates(array $ratesByCategory): array
  490. {
  491. if ($ratesByCategory === []) {
  492. return $ratesByCategory;
  493. }
  494. $numeric = [];
  495. foreach ($ratesByCategory as $cat => $rate) {
  496. if (preg_match('/^\\d+$/', (string) $cat) === 1) {
  497. $numeric[(int) $cat] = (float) $rate;
  498. }
  499. }
  500. if ($numeric === []) {
  501. return $ratesByCategory;
  502. }
  503. ksort($numeric);
  504. $keys = array_keys($numeric);
  505. $vals = array_values($numeric);
  506. $adj = $this->isotonicIncreasing($vals);
  507. foreach ($keys as $i => $cat) {
  508. $ratesByCategory[(string) $cat] = $adj[$i];
  509. }
  510. return $ratesByCategory;
  511. }
  512. /**
  513. * @param array<int,float> $values
  514. * @return array<int,float>
  515. */
  516. private function isotonicIncreasing(array $values): array
  517. {
  518. $blocks = [];
  519. foreach ($values as $v) {
  520. $blocks[] = ['sum' => (float) $v, 'weight' => 1.0, 'count' => 1];
  521. while (count($blocks) >= 2) {
  522. $k = count($blocks);
  523. $a = $blocks[$k - 2];
  524. $b = $blocks[$k - 1];
  525. $avgA = $a['sum'] / $a['weight'];
  526. $avgB = $b['sum'] / $b['weight'];
  527. if ($avgA <= $avgB) {
  528. break;
  529. }
  530. $blocks[$k - 2] = [
  531. 'sum' => $a['sum'] + $b['sum'],
  532. 'weight' => $a['weight'] + $b['weight'],
  533. 'count' => $a['count'] + $b['count'],
  534. ];
  535. array_pop($blocks);
  536. }
  537. }
  538. $out = [];
  539. foreach ($blocks as $b) {
  540. $avg = (float) ($b['sum'] / max(1e-6, $b['weight']));
  541. for ($i = 0; $i < (int) $b['count']; $i++) {
  542. $out[] = $this->clamp($avg, self::MIN_DIFF, self::MAX_DIFF);
  543. }
  544. }
  545. return $out;
  546. }
  547. /**
  548. * 单次判卷结果的在线更新。
  549. *
  550. * @return array{weighted_attempts:float,weighted_wrong:float,weighted_error_rate:float,calibrated_difficulty:float,meta:array<string,mixed>}
  551. */
  552. private function estimateOnlineBySingleOutcome(
  553. float $originalDifficulty,
  554. float $prevDifficulty,
  555. float $prevWeightedAttempts,
  556. float $prevWeightedWrong,
  557. float $outcomeError,
  558. float $baselineErr,
  559. mixed $lastGradedAtRaw,
  560. float $healthScale
  561. ): array {
  562. $now = Carbon::now();
  563. $days = 0.0;
  564. if ($lastGradedAtRaw !== null && (string) $lastGradedAtRaw !== '') {
  565. try {
  566. $lastAt = Carbon::parse((string) $lastGradedAtRaw);
  567. $days = max(0.0, (float) $lastAt->diffInDays($now));
  568. } catch (\Throwable) {
  569. $days = 0.0;
  570. }
  571. }
  572. $decay = pow(0.5, $days / self::HALF_LIFE_DAYS);
  573. $outcomeError = $this->clamp($outcomeError, 0.0, 1.0);
  574. $wN = max(0.0, $prevWeightedAttempts) * $decay + 1.0;
  575. $wWrong = max(0.0, $prevWeightedWrong) * $decay + $outcomeError;
  576. $obsErr = $wN > 0.0 ? ($wWrong / $wN) : 0.5;
  577. $priorConfidence = min(1.0, max(0.0, $prevWeightedAttempts / 25.0));
  578. $expectedErr = (1.0 - $priorConfidence) * $baselineErr + $priorConfidence * $prevDifficulty;
  579. $residual = $this->clamp($obsErr - $expectedErr, -0.45, 0.45);
  580. $adaptive = $this->buildAdaptivePolicy($wN, $obsErr, $expectedErr, $residual);
  581. $residualGain = (float) $adaptive['residual_gain'] * $healthScale;
  582. $residualScaleDenom = (float) $adaptive['residual_scale_denom'];
  583. $shrinkageM0 = (float) $adaptive['shrinkage_m0'];
  584. $confidence = (float) ($adaptive['confidence'] ?? 0.0);
  585. // 在线模式下不做分段门控,始终可更新,但样本少时步长自动更小。
  586. $maxStep = 0.30 * (0.35 + 0.65 * $confidence) * $healthScale;
  587. $residualScale = min(1.0, abs($residual) / max(1e-6, $residualScaleDenom));
  588. $effectiveStep = $maxStep * $residualScale;
  589. $targetDifficulty = $this->clamp(
  590. $prevDifficulty + $residualGain * $residual,
  591. self::MIN_DIFF,
  592. self::MAX_DIFF
  593. );
  594. $candidateDifficulty = $prevDifficulty + $this->clamp(
  595. $targetDifficulty - $prevDifficulty,
  596. -$effectiveStep,
  597. $effectiveStep
  598. );
  599. $candidateDifficulty = $this->clamp($candidateDifficulty, self::MIN_DIFF, self::MAX_DIFF);
  600. $calibratedDifficulty = ($shrinkageM0 * $prevDifficulty + $wN * $candidateDifficulty) / ($shrinkageM0 + $wN);
  601. $calibratedDifficulty = $this->clamp($calibratedDifficulty, self::MIN_DIFF, self::MAX_DIFF);
  602. return [
  603. 'weighted_attempts' => $wN,
  604. 'weighted_wrong' => $wWrong,
  605. 'weighted_error_rate' => $obsErr,
  606. 'calibrated_difficulty' => $calibratedDifficulty,
  607. 'meta' => [
  608. 'decay_days' => round($days, 4),
  609. 'decay_factor' => round($decay, 6),
  610. 'prev_difficulty' => round($prevDifficulty, 4),
  611. 'original_difficulty' => round($originalDifficulty, 4),
  612. 'observed_error_rate' => round($obsErr, 4),
  613. 'expected_error_rate' => round($expectedErr, 4),
  614. 'residual' => round($residual, 4),
  615. 'health_scale_applied' => round($healthScale, 4),
  616. 'max_step' => round($maxStep, 4),
  617. 'effective_step' => round($effectiveStep, 4),
  618. 'target_difficulty' => round($targetDifficulty, 4),
  619. 'candidate_difficulty' => round($candidateDifficulty, 4),
  620. 'adaptive' => $adaptive,
  621. ],
  622. ];
  623. }
  624. /**
  625. * @param array<int, array<string, mixed>> $attempts
  626. * @param array<string, mixed> $baselines
  627. * @return array<string, mixed>
  628. */
  629. private function estimateByStratifiedResidual(
  630. array $attempts,
  631. float $originalDifficulty,
  632. string $questionType,
  633. array $baselines
  634. ): array {
  635. $now = Carbon::now();
  636. $originalDifficulty = $this->clamp($originalDifficulty, self::MIN_DIFF, self::MAX_DIFF);
  637. $weightedAttempts = 0.0;
  638. $weightedWrong = 0.0;
  639. $weightedExpectedWrong = 0.0;
  640. $correctCount = 0;
  641. $wrongCount = 0;
  642. $lastAt = null;
  643. $byCategory = [];
  644. foreach ($attempts as $attempt) {
  645. $isCorrect = (int) ($attempt['is_correct'] ?? 0) === 1 ? 1 : 0;
  646. $incorrect = 1 - $isCorrect;
  647. if ($isCorrect === 1) {
  648. $correctCount++;
  649. } else {
  650. $wrongCount++;
  651. }
  652. $difficultyCategory = (string) ($attempt['difficulty_category'] ?? 'unknown');
  653. $baselineErr = $this->resolveBaselineErrorRate($questionType, $difficultyCategory, $baselines);
  654. $answeredAt = $attempt['graded_at'] ?? $attempt['updated_at'] ?? $attempt['created_at'] ?? null;
  655. $days = 0.0;
  656. if ($answeredAt !== null && $answeredAt !== '') {
  657. try {
  658. $at = Carbon::parse((string) $answeredAt);
  659. $days = max(0.0, (float) $at->diffInDays($now));
  660. if ($lastAt === null || $at->gt($lastAt)) {
  661. $lastAt = $at;
  662. }
  663. } catch (\Throwable) {
  664. $days = 0.0;
  665. }
  666. }
  667. $w = pow(0.5, $days / self::HALF_LIFE_DAYS);
  668. $weightedAttempts += $w;
  669. $weightedWrong += $w * $incorrect;
  670. $weightedExpectedWrong += $w * $baselineErr;
  671. $key = trim($difficultyCategory) !== '' ? trim($difficultyCategory) : 'unknown';
  672. $byCategory[$key] ??= [
  673. 'attempts' => 0,
  674. 'wrong' => 0,
  675. 'weighted_attempts' => 0.0,
  676. 'weighted_wrong' => 0.0,
  677. 'baseline_error_rate' => $baselineErr,
  678. ];
  679. $byCategory[$key]['attempts']++;
  680. $byCategory[$key]['wrong'] += $incorrect;
  681. $byCategory[$key]['weighted_attempts'] += $w;
  682. $byCategory[$key]['weighted_wrong'] += $w * $incorrect;
  683. }
  684. $weightedErrorRate = $weightedAttempts > 0 ? ($weightedWrong / $weightedAttempts) : null;
  685. $weightedExpectedErrorRate = $weightedAttempts > 0 ? ($weightedExpectedWrong / $weightedAttempts) : null;
  686. $residual = ($weightedErrorRate !== null && $weightedExpectedErrorRate !== null)
  687. ? ($weightedErrorRate - $weightedExpectedErrorRate)
  688. : 0.0;
  689. $adaptive = $this->buildAdaptivePolicy(
  690. $weightedAttempts,
  691. $weightedErrorRate,
  692. $weightedExpectedErrorRate,
  693. $residual
  694. );
  695. $residualGain = (float) $adaptive['residual_gain'];
  696. $residualScaleDenom = (float) $adaptive['residual_scale_denom'];
  697. $shrinkageM0 = (float) $adaptive['shrinkage_m0'];
  698. if ($weightedAttempts < 8) {
  699. $stepLimit = 0.0;
  700. } elseif ($weightedAttempts < 20) {
  701. $stepLimit = 0.08;
  702. } elseif ($weightedAttempts < 60) {
  703. $stepLimit = 0.15;
  704. } else {
  705. $stepLimit = 0.25;
  706. }
  707. $residualScale = min(1.0, abs($residual) / max(1e-6, $residualScaleDenom));
  708. $effectiveStep = $stepLimit * $residualScale;
  709. $targetDifficulty = $this->clamp(
  710. $originalDifficulty + $residualGain * $residual,
  711. self::MIN_DIFF,
  712. self::MAX_DIFF
  713. );
  714. $candidateDifficulty = $originalDifficulty + $this->clamp(
  715. $targetDifficulty - $originalDifficulty,
  716. -$effectiveStep,
  717. $effectiveStep
  718. );
  719. $candidateDifficulty = $this->clamp($candidateDifficulty, self::MIN_DIFF, self::MAX_DIFF);
  720. $calibratedDifficulty = ($weightedAttempts < 8)
  721. ? $originalDifficulty
  722. : (
  723. ($shrinkageM0 * $originalDifficulty + $weightedAttempts * $candidateDifficulty)
  724. / ($shrinkageM0 + $weightedAttempts)
  725. );
  726. $calibratedDifficulty = $this->clamp($calibratedDifficulty, self::MIN_DIFF, self::MAX_DIFF);
  727. foreach ($byCategory as $cat => $stats) {
  728. $wN = (float) ($stats['weighted_attempts'] ?? 0.0);
  729. $wWrong = (float) ($stats['weighted_wrong'] ?? 0.0);
  730. $n = (int) ($stats['attempts'] ?? 0);
  731. $wrong = (int) ($stats['wrong'] ?? 0);
  732. $byCategory[$cat]['error_rate'] = $n > 0 ? round($wrong / $n, 4) : null;
  733. $byCategory[$cat]['weighted_error_rate'] = $wN > 0 ? round($wWrong / $wN, 4) : null;
  734. $byCategory[$cat]['weighted_attempts'] = round($wN, 4);
  735. $byCategory[$cat]['weighted_wrong'] = round($wWrong, 4);
  736. $byCategory[$cat]['baseline_error_rate'] = round((float) ($stats['baseline_error_rate'] ?? 0.5), 4);
  737. }
  738. return [
  739. 'attempts' => count($attempts),
  740. 'correct_count' => $correctCount,
  741. 'wrong_count' => $wrongCount,
  742. 'weighted_attempts' => $weightedAttempts,
  743. 'weighted_wrong' => $weightedWrong,
  744. 'weighted_error_rate' => $weightedErrorRate,
  745. 'last_graded_at' => $lastAt?->toDateTimeString(),
  746. 'calibrated_difficulty' => $calibratedDifficulty,
  747. 'meta' => [
  748. 'algorithm' => self::ALGO,
  749. 'question_type' => $questionType,
  750. 'original_difficulty' => round($originalDifficulty, 4),
  751. 'half_life_days' => self::HALF_LIFE_DAYS,
  752. 'weighted_expected_error_rate' => $weightedExpectedErrorRate !== null
  753. ? round($weightedExpectedErrorRate, 4)
  754. : null,
  755. 'residual' => round($residual, 4),
  756. 'residual_gain' => round($residualGain, 4),
  757. 'residual_scale_denom' => round($residualScaleDenom, 4),
  758. 'step_limit' => round($stepLimit, 4),
  759. 'residual_scale' => round($residualScale, 4),
  760. 'effective_step' => round($effectiveStep, 4),
  761. 'target_difficulty' => round($targetDifficulty, 4),
  762. 'candidate_difficulty' => round($candidateDifficulty, 4),
  763. 'shrinkage_m0' => round($shrinkageM0, 4),
  764. 'adaptive_policy' => $adaptive,
  765. 'by_difficulty_category' => $byCategory,
  766. ],
  767. ];
  768. }
  769. /**
  770. * 基于使用中的样本质量自动调整超参数,无需人工干预。
  771. *
  772. * @return array{residual_gain:float,residual_scale_denom:float,shrinkage_m0:float,confidence:float,signal_strength:float}
  773. */
  774. private function buildAdaptivePolicy(
  775. float $weightedAttempts,
  776. ?float $weightedErrorRate,
  777. ?float $weightedExpectedErrorRate,
  778. float $residual
  779. ): array {
  780. $confidence = min(1.0, max(0.0, $weightedAttempts / 80.0));
  781. // 信号强度由残差大小决定,残差越显著,收敛越快。
  782. $signalStrength = min(1.0, abs($residual) / 0.25);
  783. // 观测与期望偏差显著且样本充足时,提高 gain。
  784. $residualGain = self::RESIDUAL_GAIN_MIN
  785. + (self::RESIDUAL_GAIN_MAX - self::RESIDUAL_GAIN_MIN) * (0.55 * $confidence + 0.45 * $signalStrength);
  786. // 样本越充足,越敏感;信号越强,越敏感。
  787. $residualScaleDenom = self::RESIDUAL_SCALE_DENOM_MAX
  788. - (self::RESIDUAL_SCALE_DENOM_MAX - self::RESIDUAL_SCALE_DENOM_MIN) * (0.6 * $confidence + 0.4 * $signalStrength);
  789. // 收缩强度随置信度下降:样本少时强收缩,样本多时弱收缩。
  790. $shrinkageM0 = self::SHRINKAGE_M0_MAX
  791. - (self::SHRINKAGE_M0_MAX - self::SHRINKAGE_M0_MIN) * $confidence;
  792. // 若观测与期望非常接近,适度回拉避免无意义振荡。
  793. if ($weightedErrorRate !== null && $weightedExpectedErrorRate !== null && abs($weightedErrorRate - $weightedExpectedErrorRate) < 0.01) {
  794. $residualGain = max(self::RESIDUAL_GAIN_MIN, $residualGain * 0.75);
  795. $residualScaleDenom = min(self::RESIDUAL_SCALE_DENOM_MAX, $residualScaleDenom * 1.15);
  796. $shrinkageM0 = min(self::SHRINKAGE_M0_MAX, $shrinkageM0 * 1.10);
  797. }
  798. return [
  799. 'residual_gain' => $this->clamp($residualGain, self::RESIDUAL_GAIN_MIN, self::RESIDUAL_GAIN_MAX),
  800. 'residual_scale_denom' => $this->clamp($residualScaleDenom, self::RESIDUAL_SCALE_DENOM_MIN, self::RESIDUAL_SCALE_DENOM_MAX),
  801. 'shrinkage_m0' => $this->clamp($shrinkageM0, self::SHRINKAGE_M0_MIN, self::SHRINKAGE_M0_MAX),
  802. 'confidence' => round($confidence, 4),
  803. 'signal_strength' => round($signalStrength, 4),
  804. ];
  805. }
  806. /**
  807. * @param array<string,mixed> $meta
  808. * @param array<string,mixed> $event
  809. * @return array<string,mixed>
  810. */
  811. private function appendRecentEvent(array $meta, array $event): array
  812. {
  813. $events = $meta['recent_events'] ?? [];
  814. if (! is_array($events)) {
  815. $events = [];
  816. }
  817. $events[] = $event;
  818. if (count($events) > self::RECENT_EVENTS_LIMIT) {
  819. $events = array_slice($events, -self::RECENT_EVENTS_LIMIT);
  820. }
  821. $meta['recent_events'] = $events;
  822. return $meta;
  823. }
  824. /**
  825. * @return array<string,mixed>
  826. */
  827. private function buildUpdateEvent(
  828. float $outcomeError,
  829. float $predBefore,
  830. float $predAfter,
  831. float $expectedErrorRate,
  832. float $observedErrorRate,
  833. float $residual,
  834. Carbon $now
  835. ): array {
  836. $outcomeError = $this->clamp($outcomeError, 0.0, 1.0);
  837. $p0 = $this->clamp($predBefore, 1e-6, 1.0 - 1e-6);
  838. $p1 = $this->clamp($predAfter, 1e-6, 1.0 - 1e-6);
  839. $brierBefore = ($p0 - $outcomeError) * ($p0 - $outcomeError);
  840. $brierAfter = ($p1 - $outcomeError) * ($p1 - $outcomeError);
  841. $loglossBefore = -($outcomeError * log($p0) + (1.0 - $outcomeError) * log(1.0 - $p0));
  842. $loglossAfter = -($outcomeError * log($p1) + (1.0 - $outcomeError) * log(1.0 - $p1));
  843. return [
  844. 'ts' => $now->toDateTimeString(),
  845. 'outcome_error' => round($outcomeError, 4),
  846. 'pred_before' => round($predBefore, 4),
  847. 'pred_after' => round($predAfter, 4),
  848. 'expected_error_rate' => round($expectedErrorRate, 4),
  849. 'observed_error_rate' => round($observedErrorRate, 4),
  850. 'residual' => round($residual, 4),
  851. 'abs_residual' => round(abs($residual), 4),
  852. 'brier_before' => round($brierBefore, 6),
  853. 'brier_after' => round($brierAfter, 6),
  854. 'logloss_before' => round($loglossBefore, 6),
  855. 'logloss_after' => round($loglossAfter, 6),
  856. ];
  857. }
  858. private function getHealthScaleForType(string $questionType): float
  859. {
  860. $type = trim($questionType) !== '' ? trim($questionType) : 'unknown';
  861. $cacheKey = 'difficulty_health_scale:'.$type;
  862. return Cache::remember($cacheKey, now()->addMinutes(5), function () use ($type) {
  863. $rows = DB::table(self::TABLE)
  864. ->where('updated_at', '>=', now()->subDays(14))
  865. ->select(['algorithm_meta'])
  866. ->get();
  867. $currResiduals = [];
  868. $prevResiduals = [];
  869. $brierDelta = 0.0;
  870. $loglossDelta = 0.0;
  871. $eventCount = 0;
  872. $nowTs = time();
  873. $cutTs = $nowTs - 7 * 86400;
  874. foreach ($rows as $row) {
  875. $meta = json_decode((string) ($row->algorithm_meta ?? ''), true);
  876. if (! is_array($meta)) {
  877. continue;
  878. }
  879. if (($meta['question_type'] ?? 'unknown') !== $type) {
  880. continue;
  881. }
  882. $events = $meta['recent_events'] ?? [];
  883. if (! is_array($events)) {
  884. continue;
  885. }
  886. foreach ($events as $e) {
  887. if (! is_array($e)) {
  888. continue;
  889. }
  890. $ts = isset($e['ts']) ? strtotime((string) $e['ts']) : false;
  891. if ($ts === false) {
  892. continue;
  893. }
  894. $absResidual = abs((float) ($e['residual'] ?? 0.0));
  895. if ($ts >= $cutTs) {
  896. $currResiduals[] = $absResidual;
  897. $brierDelta += (float) ($e['brier_after'] ?? 0.0) - (float) ($e['brier_before'] ?? 0.0);
  898. $loglossDelta += (float) ($e['logloss_after'] ?? 0.0) - (float) ($e['logloss_before'] ?? 0.0);
  899. $eventCount++;
  900. } else {
  901. $prevResiduals[] = $absResidual;
  902. }
  903. }
  904. }
  905. if ($eventCount < 80) {
  906. return 1.0;
  907. }
  908. $avgBrierDelta = $brierDelta / max(1, $eventCount);
  909. $avgLoglossDelta = $loglossDelta / max(1, $eventCount);
  910. $medianCurrent = $this->median($currResiduals);
  911. $medianPrev = $this->median($prevResiduals);
  912. $scale = 1.0;
  913. if ($avgBrierDelta > 0.0 && $avgLoglossDelta > 0.0) {
  914. $scale *= 0.78;
  915. }
  916. if ($avgBrierDelta > 0.003 || $avgLoglossDelta > 0.01) {
  917. $scale *= 0.82;
  918. }
  919. if ($medianPrev !== null && $medianPrev > 0.0 && $medianCurrent !== null && $medianCurrent > $medianPrev * 1.05) {
  920. $scale *= 0.82;
  921. }
  922. $scale = $this->clamp($scale, 0.45, 1.0);
  923. Log::info('QuestionDifficultyCalibrationService: 在线健康监控快照', [
  924. 'question_type' => $type,
  925. 'events_7d' => $eventCount,
  926. 'avg_brier_delta' => round($avgBrierDelta, 6),
  927. 'avg_logloss_delta' => round($avgLoglossDelta, 6),
  928. 'median_abs_residual_7d' => $medianCurrent !== null ? round($medianCurrent, 6) : null,
  929. 'median_abs_residual_prev_7d' => $medianPrev !== null ? round($medianPrev, 6) : null,
  930. 'health_scale' => round($scale, 3),
  931. ]);
  932. return $scale;
  933. });
  934. }
  935. /**
  936. * @param array<int,float> $values
  937. */
  938. private function median(array $values): ?float
  939. {
  940. if ($values === []) {
  941. return null;
  942. }
  943. sort($values);
  944. $n = count($values);
  945. $m = intdiv($n, 2);
  946. if ($n % 2 === 1) {
  947. return (float) $values[$m];
  948. }
  949. return ((float) $values[$m - 1] + (float) $values[$m]) / 2.0;
  950. }
  951. private function normalizeDifficultyValue(mixed $value): ?float
  952. {
  953. if ($value === null || $value === '') {
  954. return null;
  955. }
  956. $raw = (float) $value;
  957. if ($raw > 1.0) {
  958. $raw = $raw / 5.0;
  959. }
  960. return $this->clamp($raw, self::MIN_DIFF, self::MAX_DIFF);
  961. }
  962. private function clamp(float $value, float $min, float $max): float
  963. {
  964. return max($min, min($max, $value));
  965. }
  966. private function isReady(): bool
  967. {
  968. if ($this->tableReady !== null) {
  969. return $this->tableReady;
  970. }
  971. $this->tableReady = Schema::hasTable('paper_questions')
  972. && Schema::hasTable('papers')
  973. && Schema::hasTable('questions')
  974. && Schema::hasTable(self::TABLE);
  975. return $this->tableReady;
  976. }
  977. }