QuestionDifficultyCalibrationService.php 43 KB

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