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

このコミットが含まれているのは:
2026-06-12 02:07:51 +09:00
コミット 53446807c2
+285 -36
ファイルの表示
@@ -78,6 +78,8 @@ type GameSnapshot = {
rejectedPostIds: Set<number> rejectedPostIds: Set<number>
lastGuessQuestionCount: number lastGuessQuestionCount: number
lastRejectedGuessId: number | null lastRejectedGuessId: number | null
winningRunTargetId: number | null
winningRunStartAnswerCount: number | null
activeGuessId: number | null activeGuessId: number | null
reviewGuessedPostId: number | null reviewGuessedPostId: number | null
reviewCorrectPostId: number | null } reviewCorrectPostId: number | null }
@@ -99,6 +101,8 @@ type StoredGekanatorGame = {
rejectedPostIds: number[] rejectedPostIds: number[]
lastGuessQuestionCount: number lastGuessQuestionCount: number
lastRejectedGuessId: number | null lastRejectedGuessId: number | null
winningRunTargetId?: number | null
winningRunStartAnswerCount?: number | null
activeGuessId: number | null activeGuessId: number | null
reviewGuessedPostId: number | null reviewGuessedPostId: number | null
reviewCorrectPostId: number | null reviewCorrectPostId: number | null
@@ -126,6 +130,7 @@ const minQuestionsBeforeCertainGuess = 5
const certainGuessPercent = 99.5 const certainGuessPercent = 99.5
const runnerUpMaxPercent = .5 const runnerUpMaxPercent = .5
const hardMaxQuestions = 80 const hardMaxQuestions = 80
const winningRunQuestionLimit = 3
const softenedAnswerWeight = .35 const softenedAnswerWeight = .35
const confidenceTemperature = 6 const confidenceTemperature = 6
const gameStorageKey = 'gekanator:game:v1' const gameStorageKey = 'gekanator:game:v1'
@@ -191,6 +196,8 @@ const normalizeStoredGame = (game: StoredGekanatorGame): StoredGekanatorGame =>
normalizeStoredQuestionId (questionId)), normalizeStoredQuestionId (questionId)),
recoveredCandidatePosts: game.recoveredCandidatePosts ?? [], recoveredCandidatePosts: game.recoveredCandidatePosts ?? [],
recoveryStepCount: game.recoveryStepCount ?? 0, recoveryStepCount: game.recoveryStepCount ?? 0,
winningRunTargetId: game.winningRunTargetId ?? null,
winningRunStartAnswerCount: game.winningRunStartAnswerCount ?? null,
askedQuestionBank: game.askedQuestionBank?.map (question => askedQuestionBank: game.askedQuestionBank?.map (question =>
({ ({
...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 => const bestPost = (posts: Post[], scores: Map<number, number>): Post | null =>
posts posts
.map (post => ({ post, score: scores.get (post.id) ?? 0 })) .map (post => ({ post, score: scores.get (post.id) ?? 0 }))
@@ -896,6 +1032,10 @@ const GekanatorPage: FC = () => {
storedGame?.lastGuessQuestionCount ?? 0) storedGame?.lastGuessQuestionCount ?? 0)
const [lastRejectedGuessId, setLastRejectedGuessId] = useState<number | null> ( const [lastRejectedGuessId, setLastRejectedGuessId] = useState<number | null> (
storedGame?.lastRejectedGuessId ?? 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> ( const [activeGuessId, setActiveGuessId] = useState<number | null> (
storedGame?.activeGuessId ?? null) storedGame?.activeGuessId ?? null)
const [reviewGuessedPostId, setReviewGuessedPostId] = useState<number | null> ( const [reviewGuessedPostId, setReviewGuessedPostId] = useState<number | null> (
@@ -979,6 +1119,8 @@ const GekanatorPage: FC = () => {
rejectedPostIds: [...rejectedPostIds], rejectedPostIds: [...rejectedPostIds],
lastGuessQuestionCount, lastGuessQuestionCount,
lastRejectedGuessId, lastRejectedGuessId,
winningRunTargetId,
winningRunStartAnswerCount,
activeGuessId, activeGuessId,
reviewGuessedPostId, reviewGuessedPostId,
reviewCorrectPostId, reviewCorrectPostId,
@@ -1016,6 +1158,8 @@ const GekanatorPage: FC = () => {
rejectedPostIds, rejectedPostIds,
lastGuessQuestionCount, lastGuessQuestionCount,
lastRejectedGuessId, lastRejectedGuessId,
winningRunTargetId,
winningRunStartAnswerCount,
activeGuessId, activeGuessId,
reviewGuessedPostId, reviewGuessedPostId,
reviewCorrectPostId, reviewCorrectPostId,
@@ -1053,6 +1197,20 @@ const GekanatorPage: FC = () => {
const availablePosts = useMemo ( const availablePosts = useMemo (
() => posts.filter (post => !(rejectedPostIds.has (post.id))), () => posts.filter (post => !(rejectedPostIds.has (post.id))),
[posts, rejectedPostIds]) [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 = const questionPosts =
eligiblePosts.length > 1 eligiblePosts.length > 1
|| questionsSinceLastGuess >= minQuestionsBeforeCertainGuess || questionsSinceLastGuess >= minQuestionsBeforeCertainGuess
@@ -1064,13 +1222,23 @@ const GekanatorPage: FC = () => {
.sort ((a, b) => b.score - a.score) .sort ((a, b) => b.score - a.score)
.slice (0, 3), .slice (0, 3),
[eligiblePosts, scores]) [eligiblePosts, scores])
const currentQuestion = chooseQuestion ({ const currentQuestion = winningRunActive && winningRunTargetPost
posts: questionPosts, ? chooseWinningRunQuestion ({
questions: scoringQuestions, posts,
scores, fallbackPosts: availablePosts.length > 1 ? availablePosts : posts,
answers, questions: scoringQuestions,
askedIds, targetPost: winningRunTargetPost,
gameSeed }) scores,
answers,
askedIds,
gameSeed })
: chooseQuestion ({
posts: questionPosts,
questions: scoringQuestions,
scores,
answers,
askedIds,
gameSeed })
const answerPreviews = useMemo ( const answerPreviews = useMemo (
() => currentQuestion () => currentQuestion
? answerOptions.map (option => previewAnswer ({ ? answerOptions.map (option => previewAnswer ({
@@ -1141,6 +1309,8 @@ const GekanatorPage: FC = () => {
setRejectedPostIds (new Set ()) setRejectedPostIds (new Set ())
setLastGuessQuestionCount (0) setLastGuessQuestionCount (0)
setLastRejectedGuessId (null) setLastRejectedGuessId (null)
setWinningRunTargetId (null)
setWinningRunStartAnswerCount (null)
setActiveGuessId (null) setActiveGuessId (null)
setReviewGuessedPostId (null) setReviewGuessedPostId (null)
setReviewCorrectPostId (null) setReviewCorrectPostId (null)
@@ -1299,6 +1469,8 @@ const GekanatorPage: FC = () => {
rejectedPostIds: new Set (rejectedPostIds), rejectedPostIds: new Set (rejectedPostIds),
lastGuessQuestionCount, lastGuessQuestionCount,
lastRejectedGuessId, lastRejectedGuessId,
winningRunTargetId,
winningRunStartAnswerCount,
activeGuessId, activeGuessId,
reviewGuessedPostId, reviewGuessedPostId,
reviewCorrectPostId }]) reviewCorrectPostId }])
@@ -1324,6 +1496,20 @@ const GekanatorPage: FC = () => {
const nextRecoveredCandidatePosts = recovered.recoveredCandidatePosts const nextRecoveredCandidatePosts = recovered.recoveredCandidatePosts
const nextScores = recovered.scores const nextScores = recovered.scores
const nextEligiblePosts = recovered.eligiblePosts 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) setScores (nextScores)
setAskedIds (nextAskedIds) setAskedIds (nextAskedIds)
@@ -1332,6 +1518,8 @@ const GekanatorPage: FC = () => {
setRecoveryStepCount (recovered.recoveryStepCount) setRecoveryStepCount (recovered.recoveryStepCount)
setAskedQuestionBank (nextAskedQuestionBank) setAskedQuestionBank (nextAskedQuestionBank)
setAnswers (nextAnswers) setAnswers (nextAnswers)
setWinningRunTargetId (nextWinningRunTargetId)
setWinningRunStartAnswerCount (nextWinningRunStartAnswerCount)
const nextGuessablePosts = const nextGuessablePosts =
nextEligiblePosts.length > 0 nextEligiblePosts.length > 0
@@ -1345,6 +1533,13 @@ const GekanatorPage: FC = () => {
const topConfidence = nextConfidences[0] ?? null const topConfidence = nextConfidences[0] ?? null
const runnerUpConfidence = nextConfidences[1] ?? null const runnerUpConfidence = nextConfidences[1] ?? null
const structurallyCertain = nextEligiblePosts.length === 1 const structurallyCertain = nextEligiblePosts.length === 1
const winningRunContinues =
nextWinningRunTargetId !== null
&& nextWinningRunStartAnswerCount !== null
&& nextEligiblePosts.length === 1
&& winningRunQuestionCount (
nextAnswers,
nextWinningRunStartAnswerCount) < winningRunQuestionLimit
const statisticallyCertain = const statisticallyCertain =
topConfidence !== null topConfidence !== null
&& topConfidence.percent >= certainGuessPercent && topConfidence.percent >= certainGuessPercent
@@ -1357,8 +1552,9 @@ const GekanatorPage: FC = () => {
&& (structurallyCertain || statisticallyCertain) && (structurallyCertain || statisticallyCertain)
const shouldGuess = const shouldGuess =
nextQuestionCount >= hardMaxQuestions nextQuestionCount >= hardMaxQuestions
|| canGuessByQuestionCount || (
|| canGuessEarlyByConfidence !(winningRunContinues)
&& (canGuessByQuestionCount || canGuessEarlyByConfidence))
if (shouldGuess) if (shouldGuess)
{ {
setActiveGuessId (nextGuess?.id ?? null) setActiveGuessId (nextGuess?.id ?? null)
@@ -1476,6 +1672,8 @@ const GekanatorPage: FC = () => {
new Map ( new Map (
[...recoveredCandidatePosts.entries ()].filter ( [...recoveredCandidatePosts.entries ()].filter (
([postId]) => postId !== displayedGuess.id))) ([postId]) => postId !== displayedGuess.id)))
setWinningRunTargetId (null)
setWinningRunStartAnswerCount (null)
setActiveGuessId (null) setActiveGuessId (null)
setSearch ('') setSearch ('')
setSelectingCorrectPost (false) setSelectingCorrectPost (false)
@@ -1501,6 +1699,8 @@ const GekanatorPage: FC = () => {
setRejectedPostIds (snapshot.rejectedPostIds) setRejectedPostIds (snapshot.rejectedPostIds)
setLastGuessQuestionCount (snapshot.lastGuessQuestionCount) setLastGuessQuestionCount (snapshot.lastGuessQuestionCount)
setLastRejectedGuessId (snapshot.lastRejectedGuessId) setLastRejectedGuessId (snapshot.lastRejectedGuessId)
setWinningRunTargetId (snapshot.winningRunTargetId)
setWinningRunStartAnswerCount (snapshot.winningRunStartAnswerCount)
setActiveGuessId (snapshot.activeGuessId) setActiveGuessId (snapshot.activeGuessId)
setReviewGuessedPostId (snapshot.reviewGuessedPostId) setReviewGuessedPostId (snapshot.reviewGuessedPostId)
setReviewCorrectPostId (snapshot.reviewCorrectPostId) setReviewCorrectPostId (snapshot.reviewCorrectPostId)
@@ -1525,20 +1725,54 @@ const GekanatorPage: FC = () => {
setRecoveredCandidatePosts (recovered.recoveredCandidatePosts) setRecoveredCandidatePosts (recovered.recoveredCandidatePosts)
setRecoveryStepCount (recovered.recoveryStepCount) setRecoveryStepCount (recovered.recoveryStepCount)
setScores (recovered.scores) 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 = const recoveredGuessablePosts =
recovered.eligiblePosts.length > 0 recovered.eligiblePosts.length > 0
? recovered.eligiblePosts ? recovered.eligiblePosts
: availablePosts : availablePosts
const nextQuestion = chooseQuestion ({ const nextQuestion =
posts: recovered.eligiblePosts.length > 1 nextWinningRunTargetPost
? recovered.eligiblePosts && nextWinningRunStartAnswerCount !== null
: availablePosts, && winningRunQuestionCount (
questions: recovered.scoringQuestions, answers,
scores: recovered.scores, nextWinningRunStartAnswerCount) < winningRunQuestionLimit
answers, ? chooseWinningRunQuestion ({
askedIds, posts,
gameSeed }) 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) if (nextQuestion)
{ {
@@ -1661,6 +1895,39 @@ const GekanatorPage: FC = () => {
&& !(error) && !(error)
&& !(acceptedQuestionsError) && !(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 ( return (
<MainArea className="bg-yellow-50 dark:bg-red-975"> <MainArea className="bg-yellow-50 dark:bg-red-975">
<Helmet> <Helmet>
@@ -1767,24 +2034,6 @@ const GekanatorPage: FC = () => {
</div> </div>
</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 && ( {phase === 'guess' && displayedGuess && (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-xl font-bold">?</p> <p className="text-xl font-bold">?</p>