diff --git a/frontend/src/pages/GekanatorPage.tsx b/frontend/src/pages/GekanatorPage.tsx index 4750555..34cdf54 100644 --- a/frontend/src/pages/GekanatorPage.tsx +++ b/frontend/src/pages/GekanatorPage.tsx @@ -78,6 +78,8 @@ type GameSnapshot = { rejectedPostIds: Set lastGuessQuestionCount: number lastRejectedGuessId: number | null + winningRunTargetId: number | null + winningRunStartAnswerCount: number | null activeGuessId: number | null reviewGuessedPostId: number | null reviewCorrectPostId: number | null } @@ -99,6 +101,8 @@ type StoredGekanatorGame = { rejectedPostIds: number[] lastGuessQuestionCount: number lastRejectedGuessId: number | null + winningRunTargetId?: number | null + winningRunStartAnswerCount?: number | null activeGuessId: number | null reviewGuessedPostId: number | null reviewCorrectPostId: number | null @@ -126,6 +130,7 @@ const minQuestionsBeforeCertainGuess = 5 const certainGuessPercent = 99.5 const runnerUpMaxPercent = .5 const hardMaxQuestions = 80 +const winningRunQuestionLimit = 3 const softenedAnswerWeight = .35 const confidenceTemperature = 6 const gameStorageKey = 'gekanator:game:v1' @@ -191,6 +196,8 @@ const normalizeStoredGame = (game: StoredGekanatorGame): StoredGekanatorGame => normalizeStoredQuestionId (questionId)), recoveredCandidatePosts: game.recoveredCandidatePosts ?? [], recoveryStepCount: game.recoveryStepCount ?? 0, + winningRunTargetId: game.winningRunTargetId ?? null, + winningRunStartAnswerCount: game.winningRunStartAnswerCount ?? null, askedQuestionBank: game.askedQuestionBank?.map (question => ({ ...question, @@ -828,6 +835,135 @@ const chooseQuestion = ({ } +const directWinningRunExampleAnswerFor = ( + question: GekanatorQuestion, + targetPost: Post, +): GekanatorAnswerValue | null => + question.kind !== 'post_similarity' + ? null + : question.exampleAnswers?.[String (targetPost.id) as `${ number }`] ?? null + + +const winningRunPriorityFor = ( + question: GekanatorQuestion, + expected: GekanatorAnswerValue, + targetPost: Post, +): number | null => { + if (question.kind === 'post_similarity') + { + const directAnswer = directWinningRunExampleAnswerFor (question, targetPost) + if (directAnswer === null) + return null + if (expected === 'yes') + return 1 + if (expected === 'no') + return 3 + return null + } + + if (expected === 'yes') + return 0 + if (expected === 'no') + return 2 + + return null +} + + +const chooseWinningRunQuestion = ({ + posts, + fallbackPosts, + questions, + targetPost, + scores, + answers, + askedIds, + gameSeed, +}: { + posts: Post[] + fallbackPosts: Post[] + questions: GekanatorQuestion[] + targetPost: Post + scores: Map + answers: GekanatorAnswerLog[] + askedIds: Set + gameSeed: string +}): GekanatorQuestion | null => { + const ranked = questions + .filter (question => { + if (askedIds.has (question.id)) + return false + if (isQuestionHardFilteredAfterAnswers (question, answers)) + return false + + const expected = expectedAnswerForQuestion (question, targetPost) + return expected !== null && expected !== 'unknown' + }) + .map (question => { + const expected = expectedAnswerForQuestion (question, targetPost) + const priority = + expected === null + ? null + : winningRunPriorityFor (question, expected, targetPost) + if (priority === null) + return null + + const yesCount = posts.filter (post => question.test (post)).length + const matchingCount = + expected === 'yes' || expected === 'partial' + ? yesCount + : posts.length - yesCount + + return { + question, + priority, + matchingCount } + }) + .filter ((item): item is { + question: GekanatorQuestion + priority: number + matchingCount: number } => item !== null) + .sort ((a, b) => { + if (a.priority !== b.priority) + return a.priority - b.priority + + if (a.matchingCount !== b.matchingCount) + return a.matchingCount - b.matchingCount + + if (a.question.priorityWeight !== b.question.priorityWeight) + return b.question.priorityWeight - a.question.priorityWeight + + return a.question.id.localeCompare (b.question.id) + }) + + if (ranked.length > 0) + return ranked[0]?.question ?? null + + return chooseQuestion ({ + posts: fallbackPosts.length > 0 ? fallbackPosts : posts, + questions, + scores, + answers, + askedIds, + gameSeed }) +} + + +const isWinningRunActive = ( + winningRunTargetId: number | null, + winningRunStartAnswerCount: number | null, +): boolean => + winningRunTargetId !== null && winningRunStartAnswerCount !== null + + +const winningRunQuestionCount = ( + answers: GekanatorAnswerLog[], + winningRunStartAnswerCount: number | null, +): number => winningRunStartAnswerCount === null + ? 0 + : Math.max (0, answers.length - winningRunStartAnswerCount) + + const bestPost = (posts: Post[], scores: Map): Post | null => posts .map (post => ({ post, score: scores.get (post.id) ?? 0 })) @@ -896,6 +1032,10 @@ const GekanatorPage: FC = () => { storedGame?.lastGuessQuestionCount ?? 0) const [lastRejectedGuessId, setLastRejectedGuessId] = useState ( storedGame?.lastRejectedGuessId ?? null) + const [winningRunTargetId, setWinningRunTargetId] = useState ( + storedGame?.winningRunTargetId ?? null) + const [winningRunStartAnswerCount, setWinningRunStartAnswerCount] = + useState (storedGame?.winningRunStartAnswerCount ?? null) const [activeGuessId, setActiveGuessId] = useState ( storedGame?.activeGuessId ?? null) const [reviewGuessedPostId, setReviewGuessedPostId] = useState ( @@ -979,6 +1119,8 @@ const GekanatorPage: FC = () => { rejectedPostIds: [...rejectedPostIds], lastGuessQuestionCount, lastRejectedGuessId, + winningRunTargetId, + winningRunStartAnswerCount, activeGuessId, reviewGuessedPostId, reviewCorrectPostId, @@ -1016,6 +1158,8 @@ const GekanatorPage: FC = () => { rejectedPostIds, lastGuessQuestionCount, lastRejectedGuessId, + winningRunTargetId, + winningRunStartAnswerCount, activeGuessId, reviewGuessedPostId, reviewCorrectPostId, @@ -1053,6 +1197,20 @@ const GekanatorPage: FC = () => { const availablePosts = useMemo ( () => posts.filter (post => !(rejectedPostIds.has (post.id))), [posts, rejectedPostIds]) + const winningRunTargetPost = useMemo ( + () => winningRunTargetId === null + ? null + : posts.find (post => post.id === winningRunTargetId) ?? null, + [posts, winningRunTargetId]) + const winningRunQuestionsAsked = winningRunQuestionCount ( + answers, + winningRunStartAnswerCount) + const winningRunActive = + isWinningRunActive (winningRunTargetId, winningRunStartAnswerCount) + && winningRunQuestionsAsked < winningRunQuestionLimit + && eligiblePosts.length === 1 + && eligiblePosts[0]?.id === winningRunTargetId + && winningRunTargetPost !== null const questionPosts = eligiblePosts.length > 1 || questionsSinceLastGuess >= minQuestionsBeforeCertainGuess @@ -1064,13 +1222,23 @@ const GekanatorPage: FC = () => { .sort ((a, b) => b.score - a.score) .slice (0, 3), [eligiblePosts, scores]) - const currentQuestion = chooseQuestion ({ - posts: questionPosts, - questions: scoringQuestions, - scores, - answers, - askedIds, - gameSeed }) + const currentQuestion = winningRunActive && winningRunTargetPost + ? chooseWinningRunQuestion ({ + posts, + fallbackPosts: availablePosts.length > 1 ? availablePosts : posts, + questions: scoringQuestions, + targetPost: winningRunTargetPost, + scores, + answers, + askedIds, + gameSeed }) + : chooseQuestion ({ + posts: questionPosts, + questions: scoringQuestions, + scores, + answers, + askedIds, + gameSeed }) const answerPreviews = useMemo ( () => currentQuestion ? answerOptions.map (option => previewAnswer ({ @@ -1141,6 +1309,8 @@ const GekanatorPage: FC = () => { setRejectedPostIds (new Set ()) setLastGuessQuestionCount (0) setLastRejectedGuessId (null) + setWinningRunTargetId (null) + setWinningRunStartAnswerCount (null) setActiveGuessId (null) setReviewGuessedPostId (null) setReviewCorrectPostId (null) @@ -1299,6 +1469,8 @@ const GekanatorPage: FC = () => { rejectedPostIds: new Set (rejectedPostIds), lastGuessQuestionCount, lastRejectedGuessId, + winningRunTargetId, + winningRunStartAnswerCount, activeGuessId, reviewGuessedPostId, reviewCorrectPostId }]) @@ -1324,6 +1496,20 @@ const GekanatorPage: FC = () => { const nextRecoveredCandidatePosts = recovered.recoveredCandidatePosts const nextScores = recovered.scores const nextEligiblePosts = recovered.eligiblePosts + const currentWinningRunActive = + isWinningRunActive (winningRunTargetId, winningRunStartAnswerCount) + const nextWinningRunTargetId = + nextEligiblePosts.length === 1 + ? nextEligiblePosts[0]?.id ?? null + : null + const nextWinningRunStartAnswerCount = + nextWinningRunTargetId === null + ? null + : currentWinningRunActive + && winningRunTargetId === nextWinningRunTargetId + && winningRunStartAnswerCount !== null + ? winningRunStartAnswerCount + : nextAnswers.length setScores (nextScores) setAskedIds (nextAskedIds) @@ -1332,6 +1518,8 @@ const GekanatorPage: FC = () => { setRecoveryStepCount (recovered.recoveryStepCount) setAskedQuestionBank (nextAskedQuestionBank) setAnswers (nextAnswers) + setWinningRunTargetId (nextWinningRunTargetId) + setWinningRunStartAnswerCount (nextWinningRunStartAnswerCount) const nextGuessablePosts = nextEligiblePosts.length > 0 @@ -1345,6 +1533,13 @@ const GekanatorPage: FC = () => { const topConfidence = nextConfidences[0] ?? null const runnerUpConfidence = nextConfidences[1] ?? null const structurallyCertain = nextEligiblePosts.length === 1 + const winningRunContinues = + nextWinningRunTargetId !== null + && nextWinningRunStartAnswerCount !== null + && nextEligiblePosts.length === 1 + && winningRunQuestionCount ( + nextAnswers, + nextWinningRunStartAnswerCount) < winningRunQuestionLimit const statisticallyCertain = topConfidence !== null && topConfidence.percent >= certainGuessPercent @@ -1357,8 +1552,9 @@ const GekanatorPage: FC = () => { && (structurallyCertain || statisticallyCertain) const shouldGuess = nextQuestionCount >= hardMaxQuestions - || canGuessByQuestionCount - || canGuessEarlyByConfidence + || ( + !(winningRunContinues) + && (canGuessByQuestionCount || canGuessEarlyByConfidence)) if (shouldGuess) { setActiveGuessId (nextGuess?.id ?? null) @@ -1476,6 +1672,8 @@ const GekanatorPage: FC = () => { new Map ( [...recoveredCandidatePosts.entries ()].filter ( ([postId]) => postId !== displayedGuess.id))) + setWinningRunTargetId (null) + setWinningRunStartAnswerCount (null) setActiveGuessId (null) setSearch ('') setSelectingCorrectPost (false) @@ -1501,6 +1699,8 @@ const GekanatorPage: FC = () => { setRejectedPostIds (snapshot.rejectedPostIds) setLastGuessQuestionCount (snapshot.lastGuessQuestionCount) setLastRejectedGuessId (snapshot.lastRejectedGuessId) + setWinningRunTargetId (snapshot.winningRunTargetId) + setWinningRunStartAnswerCount (snapshot.winningRunStartAnswerCount) setActiveGuessId (snapshot.activeGuessId) setReviewGuessedPostId (snapshot.reviewGuessedPostId) setReviewCorrectPostId (snapshot.reviewCorrectPostId) @@ -1525,20 +1725,54 @@ const GekanatorPage: FC = () => { setRecoveredCandidatePosts (recovered.recoveredCandidatePosts) setRecoveryStepCount (recovered.recoveryStepCount) setScores (recovered.scores) + const nextWinningRunTargetId = + recovered.eligiblePosts.length === 1 + ? recovered.eligiblePosts[0]?.id ?? null + : null + const nextWinningRunStartAnswerCount = + nextWinningRunTargetId === null + ? null + : isWinningRunActive (winningRunTargetId, winningRunStartAnswerCount) + && winningRunTargetId === nextWinningRunTargetId + && winningRunStartAnswerCount !== null + ? winningRunStartAnswerCount + : answers.length + const nextWinningRunTargetPost = + nextWinningRunTargetId === null + ? null + : posts.find (post => post.id === nextWinningRunTargetId) ?? null const recoveredGuessablePosts = recovered.eligiblePosts.length > 0 ? recovered.eligiblePosts : availablePosts - const nextQuestion = chooseQuestion ({ - posts: recovered.eligiblePosts.length > 1 - ? recovered.eligiblePosts - : availablePosts, - questions: recovered.scoringQuestions, - scores: recovered.scores, - answers, - askedIds, - gameSeed }) + const nextQuestion = + nextWinningRunTargetPost + && nextWinningRunStartAnswerCount !== null + && winningRunQuestionCount ( + answers, + nextWinningRunStartAnswerCount) < winningRunQuestionLimit + ? chooseWinningRunQuestion ({ + posts, + fallbackPosts: availablePosts.length > 1 ? availablePosts : posts, + questions: recovered.scoringQuestions, + targetPost: nextWinningRunTargetPost, + scores: recovered.scores, + answers, + askedIds, + gameSeed }) + : chooseQuestion ({ + posts: recovered.eligiblePosts.length > 1 + ? recovered.eligiblePosts + : availablePosts, + questions: recovered.scoringQuestions, + scores: recovered.scores, + answers, + askedIds, + gameSeed }) + + setWinningRunTargetId (nextWinningRunTargetId) + setWinningRunStartAnswerCount (nextWinningRunStartAnswerCount) if (nextQuestion) { @@ -1661,6 +1895,39 @@ const GekanatorPage: FC = () => { && !(error) && !(acceptedQuestionsError) + useEffect (() => { + if ( + phase !== 'question' + || isLoading + || acceptedQuestionsLoading + ) + return + + const winningRunFinished = + winningRunTargetId !== null + && winningRunStartAnswerCount !== null + && winningRunQuestionCount (answers, winningRunStartAnswerCount) >= winningRunQuestionLimit + && eligiblePosts.length === 1 + && eligiblePosts[0]?.id === winningRunTargetId + const nextGuess = displayedGuess ?? guess + if (currentQuestion || (!(winningRunFinished) && !(nextGuess))) + return + + setActiveGuessId ((winningRunFinished ? guess : nextGuess)?.id ?? null) + setLastGuessQuestionCount (answers.length) + setPhase ('guess') + }, [ + phase, + currentQuestion, + guess, + displayedGuess, + answers, + eligiblePosts, + winningRunTargetId, + winningRunStartAnswerCount, + isLoading, + acceptedQuestionsLoading]) + return ( @@ -1767,24 +2034,6 @@ const GekanatorPage: FC = () => { )} - {!(isLoading) && phase === 'question' && !(currentQuestion) && ( -
-

- もう十分わかった。 -

- -
)} - {phase === 'guess' && displayedGuess && (

これを想像してゐたね?