ファイル
btrc-hub/frontend/src/lib/gekanatorCandidateRecovery.ts
T
みてるぞ ffd28c0f9e グカネータ スコア補正 (#376) (#377)
Reviewed-on: #377
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-06-18 01:15:54 +09:00

160 行
4.8 KiB
TypeScript

import { isLearnedSemanticQuestion,
learnedSemanticSideForPost } from '@/lib/gekanator'
import type { GekanatorAnswerLog, GekanatorAnswerValue, GekanatorQuestion } from '@/lib/gekanator'
import type { Post } from '@/types'
export type RecoveredCandidatePost = {
postId: number
answerCountAtRecovery: number
scoreAtRecovery: number }
export type RecoveredCandidateState = {
answerCountAtRecovery: number
scoreAtRecovery: number }
const questionSupportsAnswerBasedHardFiltering = (question: GekanatorQuestion): boolean =>
!(isLearnedSemanticQuestion (question)
|| (question.kind === 'tag'
&& question.condition.type === 'tag'
&& !(question.condition.key.startsWith ('nico:'))))
export const candidatePostsFor = (
{ posts,
questions,
answers,
softenedQuestionIds,
rejectedPostIds,
recoveredCandidatePosts }: { posts: Post[]
questions: GekanatorQuestion[]
answers: GekanatorAnswerLog[]
softenedQuestionIds: Set<string>
rejectedPostIds: Set<number>
recoveredCandidatePosts: Map<number, RecoveredCandidateState> },
): Post[] => {
const questionById = new Map (questions.map (question => [question.id, question]))
return posts.filter (post => {
if (rejectedPostIds.has (post.id))
return false
const recoveredCandidate = recoveredCandidatePosts.get (post.id)
return answers.every ((answer, index) => {
if (recoveredCandidate != null && index < recoveredCandidate.answerCountAtRecovery)
return true
if (softenedQuestionIds.has (answer.questionId))
return true
const question = questionById.get (answer.questionId)
if (!(question))
return true
if (!(questionSupportsAnswerBasedHardFiltering (question)))
return true
switch (answer.answer)
{
case 'yes':
case 'no':
{
const expected = learnedSemanticSideForPost (question, post)
return expected === 'unknown'
|| (answer.answer === 'yes' && expected === 'positive')
|| (answer.answer === 'no' && expected === 'negative')
}
default:
return true
}
})
})
}
export const hardFilteredPostsForAnswer = (
{ posts, question, answer }: { posts: Post[]
question: GekanatorQuestion
answer: GekanatorAnswerValue },
): Post[] => {
if (!(questionSupportsAnswerBasedHardFiltering (question)))
return posts
if (!(answer === 'yes' || answer === 'no'))
return posts
return posts.filter (post => {
const side = learnedSemanticSideForPost (question, post)
return side === 'unknown'
|| (answer === 'yes' && side === 'positive')
|| (answer === 'no' && side === 'negative')
})
}
const concreteAnswerOptions: GekanatorAnswerValue[] = ['yes', 'no', 'partial', 'probably_no']
export const allConcreteAnswerOptionsExhausted = (
posts: Post[],
question: GekanatorQuestion | null,
): boolean => {
if (!(question))
return false
return concreteAnswerOptions.every (answer =>
hardFilteredPostsForAnswer ({ posts, question, answer }).length === 0)
}
const nextRecoveryTargetSize = (recoveryStepCount: number): number =>
6 * (2 ** recoveryStepCount)
export const recoverCandidatePosts = (
{ posts,
scores,
rejectedPostIds,
recoveredCandidatePosts,
eligiblePostIds,
answerCountAtRecovery,
recoveryStepCount }: { posts: Post[]
scores: Map<number, number>
rejectedPostIds: Set<number>
recoveredCandidatePosts: Map<number, RecoveredCandidateState>
eligiblePostIds: Set<number>
answerCountAtRecovery: number
recoveryStepCount: number },
): { recoveredCandidatePosts: Map<number, RecoveredCandidateState>
recoveryStepCount: number } | null => {
const recovered = new Map (recoveredCandidatePosts)
const targetSize = nextRecoveryTargetSize (recoveryStepCount)
const countedPostIds = new Set ([...eligiblePostIds, ...recovered.keys ()])
const addCount = targetSize - countedPostIds.size
if (addCount <= 0)
{
return { recoveredCandidatePosts: recovered,
recoveryStepCount: recoveryStepCount + 1 }
}
const candidates =
posts
.filter (post => (!(rejectedPostIds.has (post.id))
&& !(eligiblePostIds.has (post.id))
&& !(recovered.has (post.id))))
.sort ((a, b) => ((scores.get (b.id) ?? Number.NEGATIVE_INFINITY)
- (scores.get (a.id) ?? Number.NEGATIVE_INFINITY)))
.slice (0, addCount)
if (candidates.length === 0)
return null
candidates.forEach (post => recovered.set (post.id, {
answerCountAtRecovery,
scoreAtRecovery: scores.get (post.id) ?? 0 }))
return { recoveredCandidatePosts: recovered,
recoveryStepCount: recoveryStepCount + 1 }
}