#41 ウィニング・ラン修正

このコミットが含まれているのは:
2026-06-12 02:07:51 +09:00
コミット 53446807c2
+271 -22
ファイルの表示
@@ -78,6 +78,8 @@ type GameSnapshot = {
rejectedPostIds: Set<number>
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<number, number>
answers: GekanatorAnswerLog[]
askedIds: Set<string>
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<number, number>): 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<number | null> (
storedGame?.lastRejectedGuessId ?? null)
const [winningRunTargetId, setWinningRunTargetId] = useState<number | null> (
storedGame?.winningRunTargetId ?? null)
const [winningRunStartAnswerCount, setWinningRunStartAnswerCount] =
useState<number | null> (storedGame?.winningRunStartAnswerCount ?? null)
const [activeGuessId, setActiveGuessId] = useState<number | null> (
storedGame?.activeGuessId ?? null)
const [reviewGuessedPostId, setReviewGuessedPostId] = useState<number | null> (
@@ -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,7 +1222,17 @@ const GekanatorPage: FC = () => {
.sort ((a, b) => b.score - a.score)
.slice (0, 3),
[eligiblePosts, scores])
const currentQuestion = chooseQuestion ({
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,
@@ -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,12 +1725,43 @@ 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 ({
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,
@@ -1540,6 +1771,9 @@ const GekanatorPage: FC = () => {
askedIds,
gameSeed })
setWinningRunTargetId (nextWinningRunTargetId)
setWinningRunStartAnswerCount (nextWinningRunStartAnswerCount)
if (nextQuestion)
{
setPhase ('question')
@@ -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 (
<MainArea className="bg-yellow-50 dark:bg-red-975">
<Helmet>
@@ -1767,24 +2034,6 @@ const GekanatorPage: FC = () => {
</div>
</div>)}
{!(isLoading) && phase === 'question' && !(currentQuestion) && (
<div className="space-y-4">
<p className="text-xl font-bold">
</p>
<button
type="button"
className="rounded border border-yellow-300 px-4 py-2
hover:bg-yellow-100 dark:border-red-700
dark:hover:bg-red-900"
onClick={() => {
setActiveGuessId (guess?.id ?? null)
setPhase ('guess')
}}>
</button>
</div>)}
{phase === 'guess' && displayedGuess && (
<div className="space-y-4">
<p className="text-xl font-bold">?</p>