From 3f1c6c135b6440283c8686051d26d21b990196b1 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Thu, 18 Jun 2026 00:59:48 +0900 Subject: [PATCH 1/3] #376 --- .../controllers/gekanator_games_controller.rb | 2 +- .../spec/requests/gekanator_learning_spec.rb | 47 +- frontend/src/lib/gekanator.test.ts | 30 +- frontend/src/lib/gekanator.ts | 57 +- .../lib/gekanatorCandidateRecovery.test.ts | 33 +- .../src/lib/gekanatorCandidateRecovery.ts | 44 +- frontend/src/pages/GekanatorPage.tsx | 998 ++++++++++++++---- 7 files changed, 987 insertions(+), 224 deletions(-) diff --git a/backend/app/controllers/gekanator_games_controller.rb b/backend/app/controllers/gekanator_games_controller.rb index 9144ed6..ad3aebf 100644 --- a/backend/app/controllers/gekanator_games_controller.rb +++ b/backend/app/controllers/gekanator_games_controller.rb @@ -50,7 +50,7 @@ class GekanatorGamesController < ApplicationController questions, post_id: game.correct_post_id, user: current_user, - limit: 2) + limit: 6) render json: { questions: selected.map { |question| extra_question_json(question) } diff --git a/backend/spec/requests/gekanator_learning_spec.rb b/backend/spec/requests/gekanator_learning_spec.rb index 9b71440..bdea095 100644 --- a/backend/spec/requests/gekanator_learning_spec.rb +++ b/backend/spec/requests/gekanator_learning_spec.rb @@ -475,28 +475,59 @@ RSpec.describe 'Gekanator learning API', type: :request do end describe 'GET /gekanator/games/:id/extra_questions' do - it 'returns at most two accepted user_suggested post_similarity questions without duplicates' do + it 'returns at most six accepted user_suggested post_similarity questions without duplicates' do sign_in_as admin + lowest = create_post_similarity_question!( + text: 'lowest?', + priority_weight: 0.5 + ) low = create_post_similarity_question!( text: 'low?', priority_weight: 1.0 ) - high = create_post_similarity_question!( - text: 'high?', - priority_weight: 3.0 - ) middle = create_post_similarity_question!( text: 'middle?', + priority_weight: 1.5 + ) + medium_high = create_post_similarity_question!( + text: 'medium high?', priority_weight: 2.0 ) + high = create_post_similarity_question!( + text: 'high?', + priority_weight: 2.5 + ) + higher = create_post_similarity_question!( + text: 'higher?', + priority_weight: 2.8 + ) + highest = create_post_similarity_question!( + text: 'highest?', + priority_weight: 3.0 + ) + overflow = create_post_similarity_question!( + text: 'overflow?', + priority_weight: 2.2 + ) get "/gekanator/games/#{game.id}/extra_questions" expect(response).to have_http_status(:ok) - expect(json['questions'].length).to eq(2) - expect(json['questions'].map { _1['id'] }.uniq.length).to eq(2) - expect(json['questions'].map { _1['id'] }).to all(be_in([low.id, high.id, middle.id])) + expect(json['questions'].length).to eq(6) + expect(json['questions'].map { _1['id'] }.uniq.length).to eq(6) + expect(json['questions'].map { _1['id'] }).to all( + be_in([ + lowest.id, + low.id, + middle.id, + medium_high.id, + high.id, + higher.id, + highest.id, + overflow.id, + ]) + ) end it 'can return questions that already have an example for the correct post' do diff --git a/frontend/src/lib/gekanator.test.ts b/frontend/src/lib/gekanator.test.ts index b5b50ec..f152d98 100644 --- a/frontend/src/lib/gekanator.test.ts +++ b/frontend/src/lib/gekanator.test.ts @@ -4,6 +4,7 @@ import { apiPost } from '@/lib/api' import { buildGekanatorQuestions, expectedAnswerForQuestion, + learnedSemanticSideForPost, questionIdForCondition, restoreGekanatorQuestion, saveGekanatorExtraQuestionAnswers, @@ -188,6 +189,33 @@ describe('expectedAnswerForQuestion', () => { }) }) +describe('learnedSemanticSideForPost', () => { + it('classifies post_similarity examples as positive, negative, or unknown', () => { + const question: StoredGekanatorQuestion = { + id: 'post-similarity:10', + text: '喜多ちゃんが泣いてる?', + kind: 'post_similarity', + source: 'user_suggested', + priorityWeight: 1.2, + condition: { + type: 'post-similarity', + postId: 123, + answer: 'partial', + threshold: 0.65, + }, + exampleAnswers: { + 1: 'yes', + 2: 'probably_no', + }, + } + + expect(learnedSemanticSideForPost(question, post({ id: 1 }))).toBe('positive') + expect(learnedSemanticSideForPost(question, post({ id: 2 }))).toBe('negative') + expect(learnedSemanticSideForPost(question, post({ id: 3 }))).toBe('unknown') + expect(learnedSemanticSideForPost(question, post({ id: 123 }))).toBe('positive') + }) +}) + describe('restoreGekanatorQuestion', () => { it('uses default source and priority weight when omitted', () => { const question = restoreGekanatorQuestion({ @@ -248,7 +276,7 @@ describe('restoreGekanatorQuestion', () => { }) expect(question.test(post({ id: 1 }))).toBe(true) - expect(question.test(post({ id: 2 }))).toBe(false) + expect(question.test(post({ id: 2 }))).toBe(true) }) it('normalizes legacy title-length-greater-than questions', () => { diff --git a/frontend/src/lib/gekanator.ts b/frontend/src/lib/gekanator.ts index 0643451..b5725d6 100644 --- a/frontend/src/lib/gekanator.ts +++ b/frontend/src/lib/gekanator.ts @@ -9,11 +9,24 @@ export type GekanatorAnswerValue = | 'probably_no' | 'unknown' +export type LearnedSemanticSide = + | 'positive' + | 'negative' + | 'unknown' + +export type GekanatorQuestionPurpose = + | 'effective_user_suggested' + | 'learning_user_suggested' + | 'normal' + export type GekanatorAnswerLog = { questionId: string questionText: string questionCondition?: GekanatorQuestionCondition questionMode?: 'normal' | 'winning_run' + questionPurpose?: GekanatorQuestionPurpose + effectiveQuestion?: boolean + learningQuestion?: boolean answer: GekanatorAnswerValue originalAnswer: GekanatorAnswerValue } @@ -163,6 +176,26 @@ const directExampleAnswerFor = ( return null } + +export const isLearnedSemanticQuestion = ( + question: StoredGekanatorQuestion | GekanatorQuestion, +): boolean => + question.kind === 'post_similarity' + && question.source === 'user_suggested' + + +export const learnedSemanticSideForAnswer = ( + answer: GekanatorAnswerValue | null, +): LearnedSemanticSide => { + if (answer === 'yes' || answer === 'partial') + return 'positive' + + if (answer === 'no' || answer === 'probably_no') + return 'negative' + + return 'unknown' +} + const countBy = (values: T[]): Map => { const counts = new Map () values.forEach (value => counts.set (value, (counts.get (value) ?? 0) + 1)) @@ -285,8 +318,8 @@ const questionMatches = ( ): boolean => { const directAnswer = directExampleAnswerFor (question, post) if (directAnswer) - return question.condition.type === 'post-similarity' - ? directAnswer === question.condition.answer + return question.kind === 'post_similarity' + ? learnedSemanticSideForAnswer (directAnswer) === 'positive' : directAnswer === 'yes' switch (question.condition.type) @@ -328,6 +361,11 @@ export const expectedAnswerForQuestion = ( switch (question.condition.type) { + case 'post-similarity': + if (question.condition.postId === post.id) + return question.condition.answer + + return null case 'tag': case 'source': case 'original-year': @@ -338,12 +376,17 @@ export const expectedAnswerForQuestion = ( case 'title-has-ascii': case 'title-contains': return questionMatches (post, question) ? 'yes' : 'no' - case 'post-similarity': - return null } } +export const learnedSemanticSideForPost = ( + question: StoredGekanatorQuestion | GekanatorQuestion | undefined, + post: Post | null, +): LearnedSemanticSide => + learnedSemanticSideForAnswer (expectedAnswerForQuestion (question, post)) + + export const restoreGekanatorQuestion = ( question: StoredGekanatorQuestion, ): GekanatorQuestion => { @@ -423,15 +466,15 @@ export const buildGekanatorQuestions = ( const originalYears = countBy ( posts .map (originalYearOf) - .filter ((year): year is number => year !== null)) + .filter ((year): year is number => year != null)) const originalMonths = countBy ( posts .map (originalMonthOf) - .filter ((month): month is number => month !== null)) + .filter ((month): month is number => month != null)) const originalMonthDays = countBy ( posts .map (originalMonthDayOf) - .filter ((monthDay): monthDay is string => monthDay !== null)) + .filter ((monthDay): monthDay is string => monthDay != null)) const titleLengthMedian = median (posts.map (post => post.title?.length ?? 0)) const titleWordCounts = includeTitleContains diff --git a/frontend/src/lib/gekanatorCandidateRecovery.test.ts b/frontend/src/lib/gekanatorCandidateRecovery.test.ts index 221fb4c..ffad81a 100644 --- a/frontend/src/lib/gekanatorCandidateRecovery.test.ts +++ b/frontend/src/lib/gekanatorCandidateRecovery.test.ts @@ -11,6 +11,7 @@ import type { GekanatorAnswerValue, GekanatorQuestion, } from '@/lib/gekanator' +import type { RecoveredCandidateState } from '@/lib/gekanatorCandidateRecovery' import type { Post } from '@/types' @@ -78,6 +79,15 @@ const answer = ( }) +const recoveredState = ( + answerCountAtRecovery: number, + scoreAtRecovery = 0, +): RecoveredCandidateState => ({ + answerCountAtRecovery, + scoreAtRecovery, +}) + + describe('candidatePostsFor', () => { it('does not hard-filter semantic post_similarity answers', () => { const posts = [post (1), post (2), post (3)] @@ -99,8 +109,8 @@ describe('candidatePostsFor', () => { softenedQuestionIds: new Set (), rejectedPostIds: new Set (), recoveredCandidatePosts: new Map ([ - [1, 1], - [3, 1], + [1, recoveredState (1)], + [3, recoveredState (1)], ]) }) expect(candidates.map (candidate => candidate.id)).toEqual ([1, 2, 3]) @@ -122,8 +132,8 @@ describe('candidatePostsFor', () => { softenedQuestionIds: new Set (), rejectedPostIds: new Set (), recoveredCandidatePosts: new Map ([ - [1, 1], - [3, 1], + [1, recoveredState (1)], + [3, recoveredState (1)], ]) }) expect(candidates.map (candidate => candidate.id)).toEqual ([3]) @@ -142,7 +152,7 @@ describe('candidatePostsFor', () => { answers: [answer (question, 'yes')], softenedQuestionIds: new Set (), rejectedPostIds: new Set ([1]), - recoveredCandidatePosts: new Map ([[1, 1]]) }) + recoveredCandidatePosts: new Map ([[1, recoveredState (1)]]) }) expect(candidates.map (candidate => candidate.id)).toEqual ([2]) }) @@ -209,7 +219,7 @@ describe('recoverCandidatePosts', () => { posts, scores, rejectedPostIds: new Set ([10]), - recoveredCandidatePosts: new Map ([[8, 1]]), + recoveredCandidatePosts: new Map ([[8, recoveredState (1, 8)]]), eligiblePostIds: new Set ([9]), answerCountAtRecovery: 2, recoveryStepCount: 0, @@ -218,7 +228,10 @@ describe('recoverCandidatePosts', () => { expect(recovered?.recoveryStepCount).toBe (1) expect([...(recovered?.recoveredCandidatePosts.keys () ?? [])]) .toEqual ([8, 7, 6, 5, 4]) - expect(recovered?.recoveredCandidatePosts.get (7)).toBe (2) + expect(recovered?.recoveredCandidatePosts.get (7)).toEqual ({ + answerCountAtRecovery: 2, + scoreAtRecovery: 7, + }) }) it('does not add posts when recovered and eligible candidates already hit the target', () => { @@ -230,9 +243,9 @@ describe('recoverCandidatePosts', () => { scores, rejectedPostIds: new Set (), recoveredCandidatePosts: new Map ([ - [1, 1], - [2, 1], - [3, 1], + [1, recoveredState (1, 1)], + [2, recoveredState (1, 2)], + [3, recoveredState (1, 3)], ]), eligiblePostIds: new Set ([4, 5, 6]), answerCountAtRecovery: 2, diff --git a/frontend/src/lib/gekanatorCandidateRecovery.ts b/frontend/src/lib/gekanatorCandidateRecovery.ts index 4d9cf93..aec3123 100644 --- a/frontend/src/lib/gekanatorCandidateRecovery.ts +++ b/frontend/src/lib/gekanatorCandidateRecovery.ts @@ -1,15 +1,21 @@ -import { expectedAnswerForQuestion } from '@/lib/gekanator' +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 } + answerCountAtRecovery: number + scoreAtRecovery: number } + +export type RecoveredCandidateState = { + answerCountAtRecovery: number + scoreAtRecovery: number } -const questionIsFactLikeForHardFiltering = (question: GekanatorQuestion): boolean => - !(question.kind === 'post_similarity' +const questionSupportsAnswerBasedHardFiltering = (question: GekanatorQuestion): boolean => + !(isLearnedSemanticQuestion (question) || (question.kind === 'tag' && question.condition.type === 'tag' && !(question.condition.key.startsWith ('nico:')))) @@ -26,7 +32,7 @@ export const candidatePostsFor = ( answers: GekanatorAnswerLog[] softenedQuestionIds: Set rejectedPostIds: Set - recoveredCandidatePosts: Map }, + recoveredCandidatePosts: Map }, ): Post[] => { const questionById = new Map (questions.map (question => [question.id, question])) @@ -34,10 +40,10 @@ export const candidatePostsFor = ( if (rejectedPostIds.has (post.id)) return false - const answerCountAtRecovery = recoveredCandidatePosts.get (post.id) + const recoveredCandidate = recoveredCandidatePosts.get (post.id) return answers.every ((answer, index) => { - if (answerCountAtRecovery != null && index < answerCountAtRecovery) + if (recoveredCandidate != null && index < recoveredCandidate.answerCountAtRecovery) return true if (softenedQuestionIds.has (answer.questionId)) @@ -46,7 +52,7 @@ export const candidatePostsFor = ( const question = questionById.get (answer.questionId) if (!(question)) return true - if (!(questionIsFactLikeForHardFiltering (question))) + if (!(questionSupportsAnswerBasedHardFiltering (question))) return true switch (answer.answer) @@ -54,8 +60,10 @@ export const candidatePostsFor = ( case 'yes': case 'no': { - const expected = expectedAnswerForQuestion (question, post) - return expected === null || expected === 'unknown' || expected === answer.answer + const expected = learnedSemanticSideForPost (question, post) + return expected === 'unknown' + || (answer.answer === 'yes' && expected === 'positive') + || (answer.answer === 'no' && expected === 'negative') } default: return true @@ -70,15 +78,17 @@ export const hardFilteredPostsForAnswer = ( question: GekanatorQuestion answer: GekanatorAnswerValue }, ): Post[] => { - if (!(questionIsFactLikeForHardFiltering (question))) + if (!(questionSupportsAnswerBasedHardFiltering (question))) return posts if (!(answer === 'yes' || answer === 'no')) return posts return posts.filter (post => { - const expected = expectedAnswerForQuestion (question, post) - return expected == null || expected === 'unknown' || expected === answer + const side = learnedSemanticSideForPost (question, post) + return side === 'unknown' + || (answer === 'yes' && side === 'positive') + || (answer === 'no' && side === 'negative') }) } @@ -112,11 +122,11 @@ export const recoverCandidatePosts = ( recoveryStepCount }: { posts: Post[] scores: Map rejectedPostIds: Set - recoveredCandidatePosts: Map + recoveredCandidatePosts: Map eligiblePostIds: Set answerCountAtRecovery: number recoveryStepCount: number }, -): { recoveredCandidatePosts: Map +): { recoveredCandidatePosts: Map recoveryStepCount: number } | null => { const recovered = new Map (recoveredCandidatePosts) const targetSize = nextRecoveryTargetSize (recoveryStepCount) @@ -140,7 +150,9 @@ export const recoverCandidatePosts = ( if (candidates.length === 0) return null - candidates.forEach (post => recovered.set (post.id, answerCountAtRecovery)) + candidates.forEach (post => recovered.set (post.id, { + answerCountAtRecovery, + scoreAtRecovery: scores.get (post.id) ?? 0 })) return { recoveredCandidatePosts: recovered, recoveryStepCount: recoveryStepCount + 1 } diff --git a/frontend/src/pages/GekanatorPage.tsx b/frontend/src/pages/GekanatorPage.tsx index 2cbe7a5..1eabcd0 100644 --- a/frontend/src/pages/GekanatorPage.tsx +++ b/frontend/src/pages/GekanatorPage.tsx @@ -10,6 +10,8 @@ import { expectedAnswerForQuestion, fetchGekanatorExtraQuestions, fetchGekanatorQuestions, fetchGekanatorPosts, + isLearnedSemanticQuestion, + learnedSemanticSideForPost, normalizeTitleLengthCondition, questionIdForCondition, restoreGekanatorQuestion, @@ -30,11 +32,15 @@ import type { Transition } from 'framer-motion' import type { GekanatorAnswerLog, GekanatorAnswerValue, GekanatorExtraQuestion, + GekanatorQuestionPurpose, GekanatorQuestionCondition, GekanatorQuestionKind, GekanatorQuestion, StoredGekanatorQuestion } from '@/lib/gekanator' -import type { RecoveredCandidatePost } from '@/lib/gekanatorCandidateRecovery' +import type { + RecoveredCandidatePost, + RecoveredCandidateState, +} from '@/lib/gekanatorCandidateRecovery' import type { Post, User } from '@/types' type Phase = @@ -70,7 +76,7 @@ type GameSnapshot = { answers: GekanatorAnswerLog[] askedIds: Set softenedQuestionIds: Set - recoveredCandidatePosts: Map + recoveredCandidatePosts: Map recoveryStepCount: number askedQuestionBank: GekanatorQuestion[] search: string @@ -139,6 +145,12 @@ type QuestionMode = | 'normal' | null +type QuestionSelection = { + question: GekanatorQuestion + questionPurpose: GekanatorQuestionPurpose + effectiveQuestion: boolean + learningQuestion: boolean } + type QuestionBuildMode = | 'split' | 'confirmation' @@ -264,7 +276,10 @@ const normalizeStoredGame = (game: StoredGekanatorGame): StoredGekanatorGame => askedIds: game.askedIds.map (questionId => normalizeStoredQuestionId (questionId)), softenedQuestionIds: (game.softenedQuestionIds .map (questionId => normalizeStoredQuestionId (questionId))), - recoveredCandidatePosts: game.recoveredCandidatePosts ?? [], + recoveredCandidatePosts: (game.recoveredCandidatePosts ?? []).map (item => ({ + ...item, + scoreAtRecovery: item.scoreAtRecovery ?? ( + new Map (game.scores).get (item.postId) ?? 0) })), recoveryStepCount: game.recoveryStepCount ?? 0, winningRunTargetId: game.winningRunTargetId ?? null, winningRunStartAnswerCount: game.winningRunStartAnswerCount ?? null, @@ -443,16 +458,35 @@ const resettableExtraQuestionState = (): { const recoveredCandidateMapFromStored = ( - items: RecoveredCandidatePost[], -): Map => - new Map (items.map (item => [item.postId, item.answerCountAtRecovery])) + items: RecoveredCandidatePost[], + scores: [number, number][], +): Map => { + const storedScores = new Map (scores) + + return new Map (items.map (item => [item.postId, { + answerCountAtRecovery: item.answerCountAtRecovery, + scoreAtRecovery: item.scoreAtRecovery ?? storedScores.get (item.postId) ?? 0 }])) +} const storedRecoveredCandidatesFromMap = ( - recoveredCandidatePosts: Map, + recoveredCandidatePosts: Map, ): RecoveredCandidatePost[] => [...recoveredCandidatePosts.entries ()] - .map (([postId, answerCountAtRecovery]) => ({ postId, answerCountAtRecovery })) + .map (([postId, recoveredCandidate]) => ({ + postId, + answerCountAtRecovery: recoveredCandidate.answerCountAtRecovery, + scoreAtRecovery: recoveredCandidate.scoreAtRecovery })) + + +const learnedSemanticMinKnownRatio = .08 +const learnedSemanticMinKnownCount = 4 +const learnedSemanticMinSideCount = 2 +const targetEffectiveUserSuggestedQuestionRatio = 1 / 3 +const targetLearningUserSuggestedQuestionRatio = 1 / 3 +const targetTotalUserSuggestedQuestionRatio = 2 / 3 +const scoreDropActivationThreshold = 20 +const learningUserSuggestedScoreWeight = .33 const baseDeltaForAnswer = (answer: GekanatorAnswerValue): number => { @@ -551,6 +585,17 @@ const answerWeightFor = ( ): number => softenedQuestionIds.has (questionId) ? softenedAnswerWeight : 1 +const scoreWeightForAnswer = ( + answer: GekanatorAnswerLog, + softenedQuestionIds: Set, +): number => + answerWeightFor (answer.questionId, softenedQuestionIds) + * ( + answer.questionPurpose === 'learning_user_suggested' + ? learningUserSuggestedScoreWeight + : 1) + + const questionDifficulty = (question: GekanatorQuestion): number => { if (question.kind === 'source') return 4 @@ -806,19 +851,297 @@ const humanPriorityOffsetFor = (question: GekanatorQuestion): number => { const isLearnableTagKey = (key: string): boolean => !(key.startsWith ('nico:')) +const isUserSuggestedLearnedSemanticQuestion = ( + question: GekanatorQuestion, +): boolean => isLearnedSemanticQuestion (question) + + +type LearnedSemanticCandidateStats = { + positiveIds: Set + negativeIds: Set + unknownIds: Set + positiveCount: number + negativeCount: number + unknownCount: number + knownCount: number } + + +const learnedSemanticStatsForCandidateIds = ( + { candidateIds, + posts, + question }: { + candidateIds: number[] + posts: Post[] + question: GekanatorQuestion }, +): LearnedSemanticCandidateStats => { + const candidateIdSet = new Set (candidateIds) + const positiveIds = new Set () + const negativeIds = new Set () + const unknownIds = new Set () + + posts.forEach (post => { + if (!(candidateIdSet.has (post.id))) + return + + const side = learnedSemanticSideForPost (question, post) + if (side === 'positive') + { + positiveIds.add (post.id) + return + } + if (side === 'negative') + { + negativeIds.add (post.id) + return + } + + unknownIds.add (post.id) + }) + + return { + positiveIds, + negativeIds, + unknownIds, + positiveCount: positiveIds.size, + negativeCount: negativeIds.size, + unknownCount: unknownIds.size, + knownCount: positiveIds.size + negativeIds.size } +} + + +const learnedSemanticQuestionIsEffectiveForCandidateIds = ( + { candidateIds, + posts, + question }: { + candidateIds: number[] + posts: Post[] + question: GekanatorQuestion }, +): boolean => { + if (!(isUserSuggestedLearnedSemanticQuestion (question))) + return false + + const stats = learnedSemanticStatsForCandidateIds ({ + candidateIds, + posts, + question }) + const minimumKnownCount = Math.max ( + learnedSemanticMinKnownCount, + Math.floor (candidateIds.length * learnedSemanticMinKnownRatio)) + + return stats.knownCount >= minimumKnownCount + && stats.positiveCount >= learnedSemanticMinSideCount + && stats.negativeCount >= learnedSemanticMinSideCount +} + + +const directSemanticAnswerForPost = ( + question: GekanatorQuestion, + post: Post, +): GekanatorAnswerValue | null => { + const direct = question.exampleAnswers?.[String (post.id) as `${ number }`] + if (direct) + return direct + if (question.condition.type === 'post-similarity' && question.condition.postId === post.id) + return question.condition.answer + + return null +} + + +const learnedSemanticLearningValueForTopPosts = ( + { question, + learningTargetPosts, + candidateIds, + posts }: { + question: GekanatorQuestion + learningTargetPosts: Post[] + candidateIds: number[] + posts: Post[] }, +): { missingTopCount: number + knownCount: number + hasLearningValue: boolean } => { + const missingTopCount = + learningTargetPosts.filter ( + post => directSemanticAnswerForPost (question, post) == null).length + const knownCount = learnedSemanticStatsForCandidateIds ({ + candidateIds, + posts, + question }).knownCount + + return { + missingTopCount, + knownCount, + hasLearningValue: missingTopCount > 0 } +} + + +const learningTargetPostsForCandidates = ({ + scoredPosts, + gameSeed, +}: { + scoredPosts: { post: Post; score: number }[] + gameSeed: string +}): Post[] => { + const topPosts = scoredPosts + .slice (0, Math.min (6, scoredPosts.length)) + .map (item => item.post) + const topPostIds = new Set (topPosts.map (post => post.id)) + const sampledPosts = scoredPosts + .filter (item => !(topPostIds.has (item.post.id))) + .map (item => ({ + post: item.post, + weight: deterministicUnitFloat (`${ gameSeed }:learning-sample:${ item.post.id }`) })) + .sort ((a, b) => a.weight - b.weight) + .slice (0, Math.min (4, Math.max (0, scoredPosts.length - topPosts.length))) + .map (item => item.post) + + return [...topPosts, ...sampledPosts] +} + + +const questionPurposeCountsFor = ( + answers: GekanatorAnswerLog[], +): { + effectiveUserSuggestedCount: number + learningUserSuggestedCount: number + normalQuestionCount: number + totalNormalPhaseQuestionCount: number + totalUserSuggestedCount: number } => { + let effectiveUserSuggestedCount = 0 + let learningUserSuggestedCount = 0 + let normalQuestionCount = 0 + + answers.forEach (answer => { + if (answer.questionMode !== 'normal') + return + + switch (answer.questionPurpose) + { + case 'effective_user_suggested': + ++effectiveUserSuggestedCount + return + case 'learning_user_suggested': + ++learningUserSuggestedCount + return + default: + ++normalQuestionCount + } + }) + + return { + effectiveUserSuggestedCount, + learningUserSuggestedCount, + normalQuestionCount, + totalNormalPhaseQuestionCount: + effectiveUserSuggestedCount + learningUserSuggestedCount + normalQuestionCount, + totalUserSuggestedCount: + effectiveUserSuggestedCount + learningUserSuggestedCount } +} + + +const learnedSemanticNarrowPenaltyForStats = ( + candidateCount: number, + stats: LearnedSemanticCandidateStats, +): number => { + const minSide = candidateCount < 10 ? 1 : Math.max (3, candidateCount * .08) + return stats.positiveCount < minSide || stats.negativeCount < minSide ? .15 : 0 +} + + +const learnedSemanticScoreDeltaForExpectedAnswer = ( + userAnswer: GekanatorAnswerValue, + expectedAnswer: GekanatorAnswerValue | null, +): number => { + switch (userAnswer) + { + case 'yes': + if (expectedAnswer === 'yes' || expectedAnswer === 'partial') + return 4 + if (expectedAnswer === 'no' || expectedAnswer === 'probably_no') + return -4 + return 0 + case 'no': + if (expectedAnswer === 'yes' || expectedAnswer === 'partial') + return -4 + if (expectedAnswer === 'no' || expectedAnswer === 'probably_no') + return 4 + return 0 + case 'partial': + if (expectedAnswer === 'yes' || expectedAnswer === 'partial') + return 2 + if (expectedAnswer === 'no') + return -2 + if (expectedAnswer === 'probably_no') + return -1 + return 0 + case 'probably_no': + if (expectedAnswer === 'yes' || expectedAnswer === 'partial') + return -2 + if (expectedAnswer === 'no' || expectedAnswer === 'probably_no') + return 1 + return 0 + case 'unknown': + return 0 + } +} + + +const scoreDropDeltaForRecoveredPost = ( + postId: number, + totalScore: number, + recoveredCandidatePosts: Map, +): number => { + const recoveredCandidate = recoveredCandidatePosts.get (postId) + if (recoveredCandidate == null) + return totalScore + + return totalScore - recoveredCandidate.scoreAtRecovery +} + + +const activeCandidateScoreDropEnabled = (scores: Map): boolean => { + if (scores.size === 0) + return false + + const maxScore = Math.max (...scores.values ()) + return maxScore >= scoreDropActivationThreshold +} + + +const postPassesScoreDrop = ( + { postId, + scores, + recoveredCandidatePosts }: { + postId: number + scores: Map + recoveredCandidatePosts: Map }, +): boolean => { + if (!(activeCandidateScoreDropEnabled (scores))) + return true + + const totalScore = scores.get (postId) ?? 0 + return scoreDropDeltaForRecoveredPost ( + postId, + totalScore, + recoveredCandidatePosts) >= 0 +} + + // `post_similarities` is the score-propagation graph, not the question kind. -const questionUsesPostSimilarityGraphForScoring = ( +const questionUsesPostSimilarityPropagationGraphForScoring = ( question: GekanatorQuestion, ): boolean => - question.kind === 'post_similarity' + (question.kind === 'post_similarity' + && !(isUserSuggestedLearnedSemanticQuestion (question))) || (question.kind === 'tag' && question.condition.type === 'tag' && !(question.condition.key.startsWith ('nico:'))) -const questionIsFactLikeForHardFiltering = ( +const questionSupportsAnswerBasedHardFiltering = ( question: GekanatorQuestion, -): boolean => !(questionUsesPostSimilarityGraphForScoring (question)) +): boolean => !(questionUsesPostSimilarityPropagationGraphForScoring (question)) + && !(isUserSuggestedLearnedSemanticQuestion (question)) const isLearnableQuestionForUserAnswer = (question: GekanatorQuestion): boolean => @@ -960,6 +1283,28 @@ const matchingPostIdsForQuestion = ({ return computed } + +const positiveMatchingPostIdsForQuestion = ( + resolver: QuestionMatchResolver, +): Set => { + if (isUserSuggestedLearnedSemanticQuestion (resolver.question)) + { + const cached = resolver.dynamicMatchIndex?.get (resolver.question.id) + if (cached) + return cached + + const computed = new Set ( + resolver.posts + .filter (post => + learnedSemanticSideForPost (resolver.question, post) === 'positive') + .map (post => post.id)) + resolver.dynamicMatchIndex?.set (resolver.question.id, computed) + return computed + } + + return matchingPostIdsForQuestion (resolver) +} + const matchingPostCountInIds = ({ candidateIds, candidateIdSet, @@ -977,7 +1322,7 @@ const matchingPostCountInIds = ({ question: GekanatorQuestion dynamicMatchIndex?: GekanatorMatchIndex }): number => { - const matched = matchingPostIdsForQuestion ({ + const matched = positiveMatchingPostIdsForQuestion ({ posts, materialIndex, matchIndex, @@ -1013,7 +1358,7 @@ const matchingWeightInCandidates = ( question: GekanatorQuestion dynamicMatchIndex?: GekanatorMatchIndex }, ): number => { - const matched = matchingPostIdsForQuestion ({ + const matched = positiveMatchingPostIdsForQuestion ({ posts, materialIndex, matchIndex, @@ -1037,7 +1382,23 @@ const signatureForCandidateIds = ( question: GekanatorQuestion dynamicMatchIndex?: GekanatorMatchIndex }, ): string => { - const matched = matchingPostIdsForQuestion ({ + if (isUserSuggestedLearnedSemanticQuestion (question)) + { + const postById = new Map (posts.map (post => [post.id, post])) + + return candidateIds.map (postId => { + const post = postById.get (postId) ?? null + const side = learnedSemanticSideForPost (question, post) + if (side === 'positive') + return '1' + if (side === 'negative') + return '0' + + return 'u' + }).join ('') + } + + const matched = positiveMatchingPostIdsForQuestion ({ posts, materialIndex, matchIndex, @@ -1062,7 +1423,7 @@ const postIdsForHardAnswer = ( matchIndex: GekanatorMatchIndex dynamicMatchIndex?: GekanatorMatchIndex }, ): number[] => { - if (!(questionIsFactLikeForHardFiltering (question))) + if (!(questionSupportsAnswerBasedHardFiltering (question))) return candidateIds if (answer === 'unknown' @@ -1072,7 +1433,7 @@ const postIdsForHardAnswer = ( if (answer === 'yes') { - const matched = matchingPostIdsForQuestion ({ + const matched = positiveMatchingPostIdsForQuestion ({ posts, materialIndex, matchIndex, @@ -1083,7 +1444,7 @@ const postIdsForHardAnswer = ( if (answer === 'no') { - const matched = matchingPostIdsForQuestion ({ + const matched = positiveMatchingPostIdsForQuestion ({ posts, materialIndex, matchIndex, @@ -1115,11 +1476,43 @@ const applyQuestionAnswerDeltaToScores = ({ matchIndex: GekanatorMatchIndex dynamicMatchIndex: GekanatorMatchIndex }): void => { + if (isUserSuggestedLearnedSemanticQuestion (question)) + { + posts.forEach (post => { + const delta = learnedSemanticScoreDeltaForExpectedAnswer ( + answer, + expectedAnswerForQuestion (question, post)) + if (delta === 0) + return + + nextScores.set ( + post.id, + (nextScores.get (post.id) ?? 0) + delta * weight) + }) + return + } + const baseDelta = baseDeltaForAnswer (answer) * weight if (baseDelta === 0) return - const matched = matchingPostIdsForQuestion ({ + if (!(questionUsesPostSimilarityPropagationGraphForScoring (question))) + { + posts.forEach (post => { + const delta = learnedSemanticScoreDeltaForExpectedAnswer ( + answer, + expectedAnswerForQuestion (question, post)) + if (delta === 0) + return + + nextScores.set ( + post.id, + (nextScores.get (post.id) ?? 0) + delta * weight) + }) + return + } + + const matched = positiveMatchingPostIdsForQuestion ({ posts, materialIndex, matchIndex, @@ -1132,9 +1525,6 @@ const applyQuestionAnswerDeltaToScores = ({ (nextScores.get (postId) ?? 0) + baseDelta) }) - if (!(questionUsesPostSimilarityGraphForScoring (question))) - return - // `post_similarities` is the propagation graph. Directly matched posts // get only the base delta; only non-direct neighbors get `base delta * cos`. // When several matched posts point at the same neighbor, keep the largest @@ -1430,6 +1820,7 @@ const candidatePostsForState = ({ softenedQuestionIds, rejectedPostIds, recoveredCandidatePosts, + scores, }: { posts: Post[] questionById: Map @@ -1438,7 +1829,8 @@ const candidatePostsForState = ({ answers: GekanatorAnswerLog[] softenedQuestionIds: Set rejectedPostIds: Set - recoveredCandidatePosts: Map + recoveredCandidatePosts: Map + scores: Map }): Post[] => { const dynamicMatchIndex = new Map> () const answerAllowsHardFilter = (answer: GekanatorAnswerValue): boolean => @@ -1448,10 +1840,10 @@ const candidatePostsForState = ({ if (rejectedPostIds.has (post.id)) return false - const answerCountAtRecovery = recoveredCandidatePosts.get (post.id) + const recoveredCandidate = recoveredCandidatePosts.get (post.id) - return answers.every ((answer, index) => { - if (answerCountAtRecovery != null && index < answerCountAtRecovery) + const survivesHardFiltering = answers.every ((answer, index) => { + if (recoveredCandidate != null && index < recoveredCandidate.answerCountAtRecovery) return true if (softenedQuestionIds.has (answer.questionId)) return true @@ -1465,7 +1857,7 @@ const candidatePostsForState = ({ const matched = (() => { if (question) - return matchingPostIdsForQuestion ({ + return positiveMatchingPostIdsForQuestion ({ posts, materialIndex, matchIndex, @@ -1479,7 +1871,7 @@ const candidatePostsForState = ({ const useExpectedAnswer = question != null && usesLearnedTagExamples (question) - if (question && !(questionIsFactLikeForHardFiltering (question))) + if (question && !(questionSupportsAnswerBasedHardFiltering (question))) return true if (matched != null) @@ -1501,6 +1893,13 @@ const candidatePostsForState = ({ const expected = expectedAnswerForQuestion (question, post) return expected == null || expected === 'unknown' || expected === answer.answer }) + if (!(survivesHardFiltering)) + return false + + return postPassesScoreDrop ({ + postId: post.id, + scores, + recoveredCandidatePosts }) }) } @@ -1520,6 +1919,12 @@ const hasDiscriminatingHardSplitForQuestion = ({ if (!(question)) return false + if (isUserSuggestedLearnedSemanticQuestion (question)) + return learnedSemanticQuestionIsEffectiveForCandidateIds ({ + candidateIds, + posts, + question }) + const dynamicMatchIndex = new Map> () const yesCount = matchingPostCountInIds ({ candidateIds, @@ -1558,33 +1963,16 @@ const recalculateScores = ({ if (!(question)) return - const weight = answerWeightFor (answer.questionId, softenedQuestionIds) - const matched = matchingPostIdsForQuestion ({ + const weight = scoreWeightForAnswer (answer, softenedQuestionIds) + applyQuestionAnswerDeltaToScores ({ posts, + question, + answer: answer.answer, + weight, + nextScores, materialIndex, matchIndex, - question, dynamicMatchIndex }) - if (questionUsesPostSimilarityGraphForScoring (question)) - { - applyQuestionAnswerDeltaToScores ({ - posts, - question, - answer: answer.answer, - weight, - nextScores, - materialIndex, - matchIndex, - dynamicMatchIndex }) - return - } - - matched.forEach (postId => { - nextScores.set ( - postId, - (nextScores.get (postId) ?? 0) - + baseDeltaForAnswer (answer.answer) * weight) - }) }) return nextScores @@ -1894,7 +2282,7 @@ const chooseQuestion = ( userPriorWeights: Map materialIndex: GekanatorQuestionMaterialIndex matchIndex: GekanatorMatchIndex }, -): GekanatorQuestion | null => { +): QuestionSelection | null => { const dynamicMatchIndex = new Map> () const invertedSignature = (signature: string): string => @@ -1932,6 +2320,9 @@ const chooseQuestion = ( weightedPosts.map (item => ({ ...item, weight: item.weight / totalWeight })) const weightedEntropy = distributionEntropy ( normalisedWeightedPosts.map (item => item.weight)) + const learningTargetPosts = learningTargetPostsForCandidates ({ + scoredPosts, + gameSeed }) const rank = ( questionsToRank: GekanatorQuestion[], @@ -1964,6 +2355,116 @@ const chooseQuestion = ( if (redundant.has (signature)) return null + if (isUserSuggestedLearnedSemanticQuestion (question)) + { + const stats = learnedSemanticStatsForCandidateIds ({ + candidateIds, + posts, + question }) + const effective = + learnedSemanticQuestionIsEffectiveForCandidateIds ({ + candidateIds, + posts, + question }) + const learningValue = learnedSemanticLearningValueForTopPosts ({ + question, + learningTargetPosts, + candidateIds, + posts }) + if (!(effective) && !(learningValue.hasLearningValue)) + return null + + const contradictionPenalty = contradictionPenaltyFor ({ question, answers }) + const humanOffset = humanPriorityOffsetFor (question) + const sourceBonus = sourcePriorityOffset (question) + const priorityBonus = priorityWeightOffset (question) + const repeatPenalty = (() => { + if (answers.length === 0) + return (recentFirstQuestionPenaltyById.get (question.id) ?? 0) * 4.5 + + return 0 + }) () + const categoryPenalty = questionCategoryPenalty ( + question, + answers.length, + repeatPenalty) + if (!(effective)) + { + return { + question, + score: ( + -(learningValue.missingTopCount * 20) + + learningValue.knownCount * .5 + + contradictionPenalty + + humanOffset + + sourceBonus + + priorityBonus + + categoryPenalty), + narrow: false, + effectiveUserSuggested: false, + learningUserSuggested: true } + } + + const positiveWeight = weightedCandidates.reduce ((sum, item) => + sum + ( + stats.positiveIds.has (item.post.id) + ? item.weight + : 0), + 0) + const negativeWeight = weightedCandidates.reduce ((sum, item) => + sum + ( + stats.negativeIds.has (item.post.id) + ? item.weight + : 0), + 0) + const unknownWeight = Math.max (0, 1 - positiveWeight - negativeWeight) + if (positiveWeight <= 0 || negativeWeight <= 0) + return null + + const positivePosteriorWeights = weightedCandidates + .filter (item => stats.positiveIds.has (item.post.id)) + .map (item => item.weight / positiveWeight) + const negativePosteriorWeights = weightedCandidates + .filter (item => stats.negativeIds.has (item.post.id)) + .map (item => item.weight / negativeWeight) + const unknownPosteriorWeights = + unknownWeight > 0 + ? weightedCandidates + .filter (item => stats.unknownIds.has (item.post.id)) + .map (item => item.weight / unknownWeight) + : [] + const infoGain = + weightedEntropy + - ( + positiveWeight * distributionEntropy (positivePosteriorWeights) + + negativeWeight * distributionEntropy (negativePosteriorWeights) + + unknownWeight * distributionEntropy (unknownPosteriorWeights)) + if (infoGain < (candidates.length >= 10 ? .02 : .008)) + return null + + const weightedSplitScore = Math.abs (.5 - positiveWeight) + const unweightedSplitScore = + Math.abs (candidates.length / 2 - stats.positiveCount) / candidates.length + const narrowPenalty = + learnedSemanticNarrowPenaltyForStats (candidates.length, stats) + const infoGainBonus = -Math.min (1.2, infoGain) * 4 + + return { + question, + score: weightedSplitScore * 100 + + unweightedSplitScore * 8 + + narrowPenalty + + contradictionPenalty + + humanOffset + + sourceBonus + + priorityBonus + + categoryPenalty + + infoGainBonus, + narrow: narrowPenalty > 0, + effectiveUserSuggested: true, + learningUserSuggested: false } + } + const yes = matchingPostCountInIds ({ candidateIds, candidateIdSet, @@ -1991,7 +2492,7 @@ const chooseQuestion = ( if (Math.min (yesWeight, noWeight) < .08) return null - const matched = matchingPostIdsForQuestion ({ + const matched = positiveMatchingPostIdsForQuestion ({ posts, materialIndex, matchIndex, @@ -2062,27 +2563,92 @@ const chooseQuestion = ( + categoryPenalty + priorBonus + infoGainBonus, - narrow: narrowPenalty > 0 } + narrow: narrowPenalty > 0, + effectiveUserSuggested: false, + learningUserSuggested: false } }) .filter ((item): item is { question: GekanatorQuestion score: number - narrow: boolean } => item != null && Number.isFinite (item.score)) + narrow: boolean + effectiveUserSuggested: boolean + learningUserSuggested: boolean } => item != null && Number.isFinite (item.score)) .sort ((a, b) => a.score - b.score) } const unansweredQuestions = questions.filter (question => !(askedIds.has (question.id))) const ranked = rank (unansweredQuestions, scoredPosts, normalisedWeightedPosts) - const pool = ( - ranked.some (item => !(item.narrow)) ? ranked.filter (item => !(item.narrow)) : ranked) + const generalRankedPool = + ranked.some (item => !(item.narrow)) ? ranked.filter (item => !(item.narrow)) : ranked + const effectiveUserSuggestedPool = + ranked + .filter (item => item.effectiveUserSuggested) .slice (0, 16) + const learningUserSuggestedPool = + ranked + .filter (item => item.learningUserSuggested) + .slice (0, 16) + const normalPool = + generalRankedPool + .filter (item => !(item.effectiveUserSuggested) && !(item.learningUserSuggested)) + .slice (0, 16) + const purposeCounts = questionPurposeCountsFor (answers) + const totalNormalPhaseQuestionCount = purposeCounts.totalNormalPhaseQuestionCount + const effectiveRatio = + totalNormalPhaseQuestionCount > 0 + ? purposeCounts.effectiveUserSuggestedCount / totalNormalPhaseQuestionCount + : 0 + const learningRatio = + totalNormalPhaseQuestionCount > 0 + ? purposeCounts.learningUserSuggestedCount / totalNormalPhaseQuestionCount + : 0 + const totalUserSuggestedRatio = + totalNormalPhaseQuestionCount > 0 + ? purposeCounts.totalUserSuggestedCount / totalNormalPhaseQuestionCount + : 0 + let selectedPool = normalPool + let selectedPurpose: GekanatorQuestionPurpose = 'normal' - if (pool.length === 0) + if ( + effectiveRatio < targetEffectiveUserSuggestedQuestionRatio + && totalUserSuggestedRatio < targetTotalUserSuggestedQuestionRatio + && effectiveUserSuggestedPool.length > 0 + ) + { + selectedPool = effectiveUserSuggestedPool + selectedPurpose = 'effective_user_suggested' + } + else if ( + learningRatio < targetLearningUserSuggestedQuestionRatio + && totalUserSuggestedRatio < targetTotalUserSuggestedQuestionRatio + && learningUserSuggestedPool.length > 0 + ) + { + selectedPool = learningUserSuggestedPool + selectedPurpose = 'learning_user_suggested' + } + else if (normalPool.length > 0) + { + selectedPool = normalPool + selectedPurpose = 'normal' + } + else if (effectiveUserSuggestedPool.length > 0) + { + selectedPool = effectiveUserSuggestedPool + selectedPurpose = 'effective_user_suggested' + } + else if (learningUserSuggestedPool.length > 0) + { + selectedPool = learningUserSuggestedPool + selectedPurpose = 'learning_user_suggested' + } + + if (selectedPool.length === 0) return null - const bestScore = pool[0]?.score ?? 0 - const weightedPool = pool.map (item => ({ + const bestScore = selectedPool[0]?.score ?? 0 + const weightedPool = selectedPool.map (item => ({ ...item, weight: Math.exp ((bestScore - item.score) / (answers.length === 0 ? 2.8 : 2.1)) })) const totalPoolWeight = @@ -2097,10 +2663,22 @@ const chooseQuestion = ( { cumulative += item.weight if (target <= cumulative) - return item.question + return { + question: item.question, + questionPurpose: selectedPurpose, + effectiveQuestion: selectedPurpose === 'effective_user_suggested', + learningQuestion: selectedPurpose === 'learning_user_suggested' } } - return weightedPool[weightedPool.length - 1]?.question ?? null + const selectedQuestion = weightedPool[weightedPool.length - 1]?.question + if (selectedQuestion == null) + return null + + return { + question: selectedQuestion, + questionPurpose: selectedPurpose, + effectiveQuestion: selectedPurpose === 'effective_user_suggested', + learningQuestion: selectedPurpose === 'learning_user_suggested' } } @@ -2158,15 +2736,30 @@ const chooseWinningRunQuestion = ({ if (priority == null) return null - const yesCount = matchingPostCountInIds ({ - candidateIds: posts.map (post => post.id), - posts, - materialIndex, - matchIndex, - question, - dynamicMatchIndex }) - const matchingCount = - expected === 'yes' || expected === 'partial' ? yesCount : posts.length - yesCount + let matchingCount = 0 + if (isUserSuggestedLearnedSemanticQuestion (question)) + { + const stats = learnedSemanticStatsForCandidateIds ({ + candidateIds: posts.map (post => post.id), + posts, + question }) + matchingCount = + expected === 'yes' || expected === 'partial' + ? stats.positiveCount + : stats.negativeCount + } + else + { + const yesCount = matchingPostCountInIds ({ + candidateIds: posts.map (post => post.id), + posts, + materialIndex, + matchIndex, + question, + dynamicMatchIndex }) + matchingCount = + expected === 'yes' || expected === 'partial' ? yesCount : posts.length - yesCount + } return { question, @@ -2244,9 +2837,31 @@ const chooseFallbackQuestion = ({ ...questions, ...fallbackQuestions]) .filter (question => + question.source !== 'user_suggested' + && !(askedIds.has (question.id)) && !(isQuestionHardFilteredAfterAnswers (question, answers))) .map (question => { + if (isUserSuggestedLearnedSemanticQuestion (question)) + { + if (!(learnedSemanticQuestionIsEffectiveForCandidateIds ({ + candidateIds, + posts: allPosts, + question }))) + return null + + const stats = learnedSemanticStatsForCandidateIds ({ + candidateIds, + posts: allPosts, + question }) + + return { + question, + knownCount: stats.knownCount, + balance: Math.abs (stats.positiveCount - stats.negativeCount), + humanOffset: humanPriorityOffsetFor (question) } + } + const yesCount = matchingPostCountInIds ({ candidateIds, posts: allPosts, @@ -2352,6 +2967,9 @@ const nextQuestionPlanFor = ( guess: Post | null guessReason: GuessReason | null questionMode: QuestionMode + questionPurpose?: GekanatorQuestionPurpose + effectiveQuestion?: boolean + learningQuestion?: boolean winningRunTargetId: number | null winningRunStartAnswerCount: number | null } => { const guessablePosts = eligiblePosts.length > 0 ? eligiblePosts : availablePosts @@ -2367,6 +2985,9 @@ const nextQuestionPlanFor = ( guess: bestPost (guessablePosts, scores), guessReason: 'hard_max_questions', questionMode: null, + questionPurpose: undefined, + effectiveQuestion: false, + learningQuestion: false, winningRunTargetId, winningRunStartAnswerCount } } @@ -2378,6 +2999,9 @@ const nextQuestionPlanFor = ( guess: bestPost (guessablePosts, scores), guessReason: 'question_count_checkpoint', questionMode: null, + questionPurpose: undefined, + effectiveQuestion: false, + learningQuestion: false, winningRunTargetId, winningRunStartAnswerCount } } @@ -2423,6 +3047,9 @@ const nextQuestionPlanFor = ( guess: bestPost (eligiblePosts, scores), guessReason: 'winning_run_finished', questionMode: null, + questionPurpose: undefined, + effectiveQuestion: false, + learningQuestion: false, winningRunTargetId: nextWinningRunTargetId, winningRunStartAnswerCount: nextWinningRunStartAnswerCount } if (!(nextWinningRunTargetPost) || nextWinningRunStartAnswerCount == null) @@ -2431,6 +3058,9 @@ const nextQuestionPlanFor = ( guess: null, guessReason: null, questionMode: null, + questionPurpose: undefined, + effectiveQuestion: false, + learningQuestion: false, winningRunTargetId: nextWinningRunTargetId, winningRunStartAnswerCount: nextWinningRunStartAnswerCount } @@ -2448,6 +3078,9 @@ const nextQuestionPlanFor = ( guess: null, guessReason: null, questionMode: 'winning_run', + questionPurpose: undefined, + effectiveQuestion: false, + learningQuestion: false, winningRunTargetId: nextWinningRunTargetId, winningRunStartAnswerCount: nextWinningRunStartAnswerCount } return { @@ -2455,6 +3088,9 @@ const nextQuestionPlanFor = ( guess: null, guessReason: null, questionMode: null, + questionPurpose: undefined, + effectiveQuestion: false, + learningQuestion: false, winningRunTargetId: nextWinningRunTargetId, winningRunStartAnswerCount: nextWinningRunStartAnswerCount } } @@ -2463,7 +3099,7 @@ const nextQuestionPlanFor = ( eligiblePosts const evaluationQuestions = buildQuestionsForPosts (evaluationPosts) - const normalQuestion = chooseQuestion ({ + const normalQuestionSelection = chooseQuestion ({ posts: evaluationPosts, questions: evaluationQuestions, scores, @@ -2475,7 +3111,7 @@ const nextQuestionPlanFor = ( materialIndex, matchIndex }) - const fallbackQuestion = normalQuestion ?? chooseFallbackQuestion ({ + const fallbackQuestion = normalQuestionSelection?.question ?? chooseFallbackQuestion ({ posts: evaluationPosts, allPosts: posts, questions: evaluationQuestions, @@ -2492,6 +3128,15 @@ const nextQuestionPlanFor = ( guess: null, guessReason: null, questionMode: 'normal', + questionPurpose: normalQuestionSelection?.question?.id === fallbackQuestion.id + ? normalQuestionSelection.questionPurpose + : 'normal', + effectiveQuestion: normalQuestionSelection?.question?.id === fallbackQuestion.id + ? normalQuestionSelection.effectiveQuestion + : false, + learningQuestion: normalQuestionSelection?.question?.id === fallbackQuestion.id + ? normalQuestionSelection.learningQuestion + : false, winningRunTargetId: nextWinningRunTargetId, winningRunStartAnswerCount: nextWinningRunStartAnswerCount } } @@ -2501,6 +3146,9 @@ const nextQuestionPlanFor = ( guess: null, guessReason: null, questionMode: null, + questionPurpose: undefined, + effectiveQuestion: false, + learningQuestion: false, winningRunTargetId: nextWinningRunTargetId, winningRunStartAnswerCount: nextWinningRunStartAnswerCount } } @@ -3115,8 +3763,11 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { () => new Set (storedGame?.askedIds ?? [])) const [softenedQuestionIds, setSoftenedQuestionIds] = useState> ( () => new Set (storedGame?.softenedQuestionIds ?? [])) - const [recoveredCandidatePosts, setRecoveredCandidatePosts] = useState> ( - () => recoveredCandidateMapFromStored (storedGame?.recoveredCandidatePosts ?? [])) + const [recoveredCandidatePosts, setRecoveredCandidatePosts] = useState< + Map + > (() => recoveredCandidateMapFromStored ( + storedGame?.recoveredCandidatePosts ?? [], + storedGame?.scores ?? [])) const [recoveryStepCount, setRecoveryStepCount] = useState ( storedGame?.recoveryStepCount ?? 0) const [askedQuestionBank, setAskedQuestionBank] = useState ( @@ -3346,9 +3997,10 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { answers, softenedQuestionIds, rejectedPostIds, - recoveredCandidatePosts }), + recoveredCandidatePosts, + scores }), [posts, askedQuestionById, materialIndex, acceptedQuestionMatchIndex, - answers, softenedQuestionIds, rejectedPostIds, recoveredCandidatePosts]) + answers, softenedQuestionIds, rejectedPostIds, recoveredCandidatePosts, scores]) const scoringQuestions = useMemo (() => { return mergeQuestions ([...acceptedQuestions, ...askedQuestionBank]) }, [acceptedQuestions, askedQuestionBank]) @@ -3377,14 +4029,6 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { questionSuggestionEntryMode, selectedSuggestedQuestion, questionSuggestion]) - const canShowNewQuestionSuggestionButton = useMemo (() => { - return questionSuggestionEntryMode === 'search' - && questionSuggestionSearch.trim () !== '' - && searchableSuggestedQuestions.length === 0 - }, [ - questionSuggestionEntryMode, - questionSuggestionSearch, - searchableSuggestedQuestions.length]) const recentFirstQuestionPenaltyById = useMemo (() => { const penalties = new Map () @@ -3548,7 +4192,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { const resetExtraQuestionState = () => { const next = resettableExtraQuestionState () - setExtraQuestions (next.extraQuestions) + setExtraQuestions (next.extraQuestions.slice (0, 2)) setExtraQuestionAnswers (next.extraQuestionAnswers) setExtraQuestionState (next.extraQuestionState) extraQuestionAnswersMutation.reset () @@ -3613,7 +4257,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { nextAskedQuestionBank: GekanatorQuestion[] nextSoftenedQuestionIds: Set nextRejectedPostIds: Set - nextRecoveredCandidatePosts: Map + nextRecoveredCandidatePosts: Map nextRecoveryStepCount: number allowPreQuestionRecovery?: boolean }) => { @@ -3643,7 +4287,8 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { answers: nextAnswers, softenedQuestionIds: recoveredSoftenedQuestionIds, rejectedPostIds: nextRejectedPostIds, - recoveredCandidatePosts }) + recoveredCandidatePosts, + scores: recoveredScores }) let recoveredQuestions = buildQuestionsForCandidateIds ({ candidateIds: recoveredEligiblePosts.map (post => post.id), materialIndex, @@ -3669,7 +4314,8 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { answers: nextAnswers, softenedQuestionIds: recoveredSoftenedQuestionIds, rejectedPostIds: nextRejectedPostIds, - recoveredCandidatePosts }) + recoveredCandidatePosts, + scores: recoveredScores }) recoveredQuestions = buildQuestionsForCandidateIds ({ candidateIds: recoveredEligiblePosts.map (post => post.id), materialIndex, @@ -3699,7 +4345,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { userPriorWeights, materialIndex, matchIndex: acceptedQuestionMatchIndex }) - const fallbackQuestion = nextQuestion ?? chooseFallbackQuestion ({ + const fallbackQuestion = nextQuestion?.question ?? chooseFallbackQuestion ({ posts: recoveredEligiblePosts, allPosts: posts, questions: recoveredScoringQuestions, @@ -3808,6 +4454,9 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { questionText: currentQuestion.text, questionCondition: currentQuestion.condition, questionMode: questionPlan.questionMode ?? undefined, + questionPurpose: questionPlan.questionPurpose, + effectiveQuestion: questionPlan.effectiveQuestion, + learningQuestion: questionPlan.learningQuestion, answer: value, originalAnswer: value }] const nextAskedIds = new Set ([...askedIds, currentQuestion.id]) @@ -4201,7 +4850,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { const questions = await queryClient.fetchQuery ({ queryKey: gekanatorKeys.extraQuestions (gameId, nonce), queryFn: () => fetchGekanatorExtraQuestions (gameId, nonce) }) - setExtraQuestions (questions) + setExtraQuestions (questions.slice (0, 2)) setExtraQuestionState (questions.length > 0 ? 'ready' : 'empty') } catch @@ -4244,14 +4893,6 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { phase === 'intro' ? '投稿を読み込んでいます……' : '前回のグカネータ状態を復元しています……' const questionSuggestionTitle = questionSuggestionEntryMode === 'search' ? 'まず既存質問を探してください。' : '新しい質問を追加します。' - const saveStatusMessage = (() => { - if (!(saved) || learnedExampleCount == null) - return null - if (learnedExampleCount <= 0) - return null - - return `${ learnedExampleCount }件の回答を学習しました` - }) () const introLoading = isLoading || acceptedQuestionsLoading const readyToStart = @@ -4674,10 +5315,6 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {

記録できませんでした。通信状態を確認してもう一度試して。

)} - {saveStatusMessage && ( -

- {saveStatusMessage} -

)} {!(canPersistGame) && (

未ログインのため今回の結果は保存されません。 @@ -4822,10 +5459,6 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {

記録できませんでした。通信状態を確認してもう一度試して。

)} - {saveStatusMessage && ( -

- {saveStatusMessage} -

)}
) - })} + return ( + ) + })} +
- )} - {selectedSuggestedQuestion && ( -

- 既存質問を選択中: {selectedSuggestedQuestion.text} -

)} - {canShowNewQuestionSuggestionButton && ( -
-

- 適切な質問がない場合は新規追加してください。 -

-
- -
-
)} - ) - } + {selectedSuggestedQuestion && ( +

+ 既存質問を選択中: {selectedSuggestedQuestion.text} +

)} + )} + )} {questionSuggestionEntryMode === 'new' && (
新規質問
@@ -4937,36 +5551,47 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { className="min-h-24 w-full rounded border border-yellow-300 bg-white px-3 py-2 dark:border-red-700 dark:bg-red-950" - placeholder="適切な既存質問がない場合だけ新規追加してください。"/> + placeholder="おっと、彼は逃げている?"/> +
)} + {(canSubmitQuestionSuggestion + && !(saveMutation.isPending) + && !(questionSuggestionMutation.isPending)) + && ( + )} + {questionSuggestionEntryMode !== 'new' && ( +
+

+ 適切な質問がない場合は新規追加してください。 +

)} -
@@ -5005,7 +5641,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {

追加学習

-

追加で 2 問まで答えてください。

+

追加で質問に答えてください。

{extraQuestionState === 'loading' && ( -- 2.34.1 From 789e00b2e77553aa81de3346c15ab03cf22fa2bb Mon Sep 17 00:00:00 2001 From: miteruzo Date: Thu, 18 Jun 2026 01:04:50 +0900 Subject: [PATCH 2/3] #376 --- .../spec/requests/gekanator_learning_spec.rb | 65 +++++++++++++++++++ frontend/src/lib/gekanator.test.ts | 8 +++ frontend/src/lib/gekanator.ts | 4 ++ frontend/src/pages/GekanatorPage.test.tsx | 32 +++++++++ 4 files changed, 109 insertions(+) diff --git a/backend/spec/requests/gekanator_learning_spec.rb b/backend/spec/requests/gekanator_learning_spec.rb index bdea095..93928d3 100644 --- a/backend/spec/requests/gekanator_learning_spec.rb +++ b/backend/spec/requests/gekanator_learning_spec.rb @@ -206,6 +206,40 @@ RSpec.describe 'Gekanator learning API', type: :request do expect(example.gekanator_game_id).to eq(json['id']) end + it 'learns accepted post_similarity answers from main game logs' do + sign_in_as admin + + question = create_post_similarity_question!(text: '泣いてる?') + + expect { + post '/gekanator/games', params: { + guessed_post_id: guessed_post.id, + correct_post_id: correct_post.id, + answers: [ + { + question_id: "post-similarity:#{question.id}", + question_text: '泣いてる?', + answer: 'partial', + original_answer: 'partial' + } + ] + } + }.to change { GekanatorQuestionExample.count }.by(1) + + expect(response).to have_http_status(:created) + expect(json['learned_example_count']).to eq(1) + + example = GekanatorQuestionExample.last + expect(example).to have_attributes( + gekanator_question_id: question.id, + post_id: correct_post.id, + user_id: admin.id, + answer: 'partial', + source: 'post_game_answer' + ) + expect(example.gekanator_game_id).to eq(json['id']) + end + it 'does not learn fact questions or nico tag questions from main game logs' do sign_in_as admin @@ -550,6 +584,37 @@ RSpec.describe 'Gekanator learning API', type: :request do expect(json['questions'].map { _1['id'] }).to include(existing.id) end + it 'prioritizes questions the current user has not answered' do + sign_in_as admin + + answered = create_post_similarity_question!( + text: 'already answered?', + priority_weight: 3.0 + ) + GekanatorQuestionExample.create!( + gekanator_question: answered, + post: other_post, + user: admin, + answer: 'yes', + source: 'post_game_extra' + ) + + unanswered = + 6.times.map { |index| + create_post_similarity_question!( + text: "unanswered #{index}?", + priority_weight: 0.5 + ) + } + + get "/gekanator/games/#{game.id}/extra_questions" + + expect(response).to have_http_status(:ok) + expect(json['questions'].map { _1['id'] }).to match_array( + unanswered.map(&:id) + ) + end + it 'can return questions already asked in the game using snake_case question_id' do sign_in_as admin diff --git a/frontend/src/lib/gekanator.test.ts b/frontend/src/lib/gekanator.test.ts index f152d98..dffdbd4 100644 --- a/frontend/src/lib/gekanator.test.ts +++ b/frontend/src/lib/gekanator.test.ts @@ -400,6 +400,10 @@ describe('Gekanator API writers', () => { type: 'tag', key: 'character:喜多郁代', }, + questionMode: 'normal', + questionPurpose: 'effective_user_suggested', + effectiveQuestion: true, + learningQuestion: false, answer: 'yes', originalAnswer: 'partial', }, @@ -424,6 +428,10 @@ describe('Gekanator API writers', () => { type: 'tag', key: 'character:喜多郁代', }, + question_mode: 'normal', + question_purpose: 'effective_user_suggested', + effective_question: true, + learning_question: false, answer: 'yes', original_answer: 'partial', }, diff --git a/frontend/src/lib/gekanator.ts b/frontend/src/lib/gekanator.ts index b5725d6..1299b0c 100644 --- a/frontend/src/lib/gekanator.ts +++ b/frontend/src/lib/gekanator.ts @@ -636,6 +636,10 @@ export const saveGekanatorGame = async ({ question_id: answer.questionId, question_text: answer.questionText, question_condition: answer.questionCondition ?? null, + question_mode: answer.questionMode, + question_purpose: answer.questionPurpose, + effective_question: answer.effectiveQuestion, + learning_question: answer.learningQuestion, answer: answer.answer, original_answer: answer.originalAnswer })) }) diff --git a/frontend/src/pages/GekanatorPage.test.tsx b/frontend/src/pages/GekanatorPage.test.tsx index ce98ff5..b42617f 100644 --- a/frontend/src/pages/GekanatorPage.test.tsx +++ b/frontend/src/pages/GekanatorPage.test.tsx @@ -60,6 +60,14 @@ const gekanatorBackdropSource = gekanatorPageSource.slice ( gekanatorPageSource.indexOf ('const GekanatorBackdrop'), gekanatorPageSource.indexOf ('const expectedAnswerFor')) +const gekanatorChooseQuestionSource = gekanatorPageSource.slice ( + gekanatorPageSource.indexOf ('const chooseQuestion'), + gekanatorPageSource.indexOf ('const winningRunPriorityFor')) + +const gekanatorFallbackQuestionSource = gekanatorPageSource.slice ( + gekanatorPageSource.indexOf ('const chooseFallbackQuestion'), + gekanatorPageSource.indexOf ('const shouldEnterGuessPhase')) + describe('GekanatorBackdrop regression structure', () => { it('keeps displayedBackdropMode as the render-time source of truth', () => { @@ -103,6 +111,30 @@ describe('GekanatorBackdrop regression structure', () => { }) +describe('Gekanator question selection regression structure', () => { + it('prefers normal questions after user_suggested quota has been met', () => { + const normalFallbackIndex = gekanatorChooseQuestionSource.indexOf ( + 'else if (normalPool.length > 0)') + const effectiveFallbackIndex = gekanatorChooseQuestionSource.indexOf ( + 'else if (effectiveUserSuggestedPool.length > 0)') + + expect(normalFallbackIndex).toBeGreaterThan(0) + expect(effectiveFallbackIndex).toBeGreaterThan(0) + expect(normalFallbackIndex).toBeLessThan(effectiveFallbackIndex) + }) + + it('does not let fallback questions bypass user_suggested purpose tracking', () => { + expect(gekanatorFallbackQuestionSource).toContain ( + "question.source !== 'user_suggested'") + }) + + it('does not show a fixed extra-question count in the extra learning UI', () => { + expect(gekanatorPageSource).not.toContain ('追加で 2 問まで答えてください。') + expect(gekanatorPageSource).toContain ('追加で質問に答えてください。') + }) +}) + + describe('isQuestionHardFilteredAfterAnswers', () => { it('blocks only contradictory or redundant month questions after a yes answer', () => { const previous: GekanatorQuestionCondition = { type: 'original-month', month: 12 } -- 2.34.1 From d184659d30dce2a61b1ba25667439352a22968dd Mon Sep 17 00:00:00 2001 From: miteruzo Date: Thu, 18 Jun 2026 01:12:01 +0900 Subject: [PATCH 3/3] #376 --- backend/spec/models/tag_name_sanitisation_rule_spec.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/spec/models/tag_name_sanitisation_rule_spec.rb b/backend/spec/models/tag_name_sanitisation_rule_spec.rb index 654f85f..ea14461 100644 --- a/backend/spec/models/tag_name_sanitisation_rule_spec.rb +++ b/backend/spec/models/tag_name_sanitisation_rule_spec.rb @@ -1,6 +1,10 @@ require 'rails_helper' RSpec.describe TagNameSanitisationRule, type: :model do + before do + described_class.unscoped.delete_all + end + describe '.sanitise' do before do described_class.create!(priority: 10, source_pattern: '_', replacement: '') -- 2.34.1