From def6870f06543f285470854deb060fe7ef3d14ed Mon Sep 17 00:00:00 2001 From: miteruzo Date: Fri, 12 Jun 2026 01:35:31 +0900 Subject: [PATCH] =?UTF-8?q?=E3=82=B0=E3=82=AB=E3=83=8D=E3=83=BC=E3=82=BF?= =?UTF-8?q?=20/=20=E8=B3=AA=E5=95=8F=E3=83=91=E3=82=BF=E3=83=BC=E3=83=B3?= =?UTF-8?q?=E8=A6=8B=E7=9B=B4=E3=81=97=20(#41)=20(#365)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewed-on: http://git.miteruzo.com/miteruzo/btrc-hub/pulls/365 Co-authored-by: miteruzo Co-committed-by: miteruzo --- .../controllers/gekanator_games_controller.rb | 66 ++-- .../app/models/gekanator_question_example.rb | 80 +++++ .../gekanator/question_suggestion_promoter.rb | 15 +- ...atistics_to_gekanator_question_examples.rb | 40 +++ backend/db/schema.rb | 4 +- .../spec/requests/gekanator_learning_spec.rb | 54 +++- frontend/src/lib/gekanator.test.ts | 77 +++++ frontend/src/lib/gekanator.ts | 6 +- .../lib/gekanatorCandidateRecovery.test.ts | 151 +++++++++ .../src/lib/gekanatorCandidateRecovery.ts | 146 +++++++++ frontend/src/lib/gekanatorQuestionFilters.ts | 167 ++++++++++ frontend/src/lib/queryKeys.ts | 4 +- frontend/src/pages/GekanatorPage.test.tsx | 154 +++++++++ frontend/src/pages/GekanatorPage.tsx | 303 ++++++++++-------- 14 files changed, 1077 insertions(+), 190 deletions(-) create mode 100644 backend/db/migrate/20260612000000_add_answer_statistics_to_gekanator_question_examples.rb create mode 100644 frontend/src/lib/gekanatorCandidateRecovery.test.ts create mode 100644 frontend/src/lib/gekanatorCandidateRecovery.ts create mode 100644 frontend/src/lib/gekanatorQuestionFilters.ts create mode 100644 frontend/src/pages/GekanatorPage.test.tsx diff --git a/backend/app/controllers/gekanator_games_controller.rb b/backend/app/controllers/gekanator_games_controller.rb index 4f378d0..628f40d 100644 --- a/backend/app/controllers/gekanator_games_controller.rb +++ b/backend/app/controllers/gekanator_games_controller.rb @@ -27,26 +27,20 @@ class GekanatorGamesController < ApplicationController game = GekanatorGame.find_by(id: params[:id]) return head :not_found unless game - asked_ids = Array(game.answers).filter_map { |answer| answer_question_id(answer) } - existing_example_ids = - GekanatorQuestionExample.where(post_id: game.correct_post_id) - .select(:gekanator_question_id) - # Direct examples only for now; post_similarity-based expansion is deferred. questions = GekanatorQuestion .accepted + .includes(:gekanator_question_examples) .where(kind: 'post_similarity', source: 'user_suggested') - .where.not(id: existing_example_ids) - .order(priority_weight: :desc, id: :asc) + .to_a + + selected = weighted_sample_questions( + questions, + post_id: game.correct_post_id, + limit: 2) render json: { - questions: questions.filter_map { |question| - json = extra_question_json(question) - next if asked_ids.include?(json[:id].to_s) - next if asked_ids.include?("post-similarity:#{ json[:id] }") - - json - }.first(2) + questions: selected.map { |question| extra_question_json(question) } } end @@ -84,11 +78,10 @@ class GekanatorGamesController < ApplicationController gekanator_question: question, post: game.correct_post, user: current_user) - example.assign_attributes( - gekanator_game: game, + example.record_answer!( answer: item[:answer], source: 'post_game_extra', - weight: 1.0) + gekanator_game: game) example.save! end end @@ -107,12 +100,41 @@ class GekanatorGamesController < ApplicationController } end - def answer_question_id answer - value = if answer.is_a?(Hash) - answer['question_id'].presence || answer[:question_id].presence || - answer['questionId'].presence || answer[:questionId].presence + def weighted_sample_questions questions, post_id:, limit: + remaining = questions.uniq(&:id) + selected = [] + + while selected.length < limit && remaining.any? + weighted = + remaining.map { |question| + [question, selection_weight_for(question, post_id: post_id)] + } + total_weight = weighted.sum { |_question, weight| weight } + break if total_weight <= 0 + + target = rand * total_weight + cumulative = 0.0 + chosen = + weighted.find do |_question, weight| + cumulative += weight + cumulative >= target + end&.first || weighted.first.first + + selected << chosen + remaining.reject! { |question| question.id == chosen.id } end - value&.to_s + selected + end + + def selection_weight_for question, post_id: + sample_count = + question.gekanator_question_examples.sum { |example| + next 0 unless example.post_id == post_id + + example.sample_count.presence || 1 + } + + question.priority_weight.to_f / (1.0 + sample_count * 0.15) end end diff --git a/backend/app/models/gekanator_question_example.rb b/backend/app/models/gekanator_question_example.rb index 5e55cbd..2b4bfed 100644 --- a/backend/app/models/gekanator_question_example.rb +++ b/backend/app/models/gekanator_question_example.rb @@ -1,5 +1,6 @@ class GekanatorQuestionExample < ApplicationRecord ANSWERS = GekanatorQuestionSuggestion::ANSWERS + NON_UNKNOWN_ANSWERS = ANSWERS - ['unknown'] SOURCES = ['initial_suggestion', 'post_game_extra'].freeze belongs_to :gekanator_question @@ -8,10 +9,89 @@ class GekanatorQuestionExample < ApplicationRecord belongs_to :gekanator_game, optional: true validates :answer, presence: true, inclusion: { in: ANSWERS } + validates :answer_counts, presence: true + validates :sample_count, + presence: true, + numericality: { + only_integer: true, + greater_than: 0 + } validates :source, presence: true, inclusion: { in: SOURCES } validates :weight, presence: true, numericality: { greater_than: 0 } + + before_validation :normalize_learning_state + + def record_answer!(answer:, source:, gekanator_game: nil) + answer = answer.to_s + raise ArgumentError, 'invalid answer' unless ANSWERS.include?(answer) + + counts = normalized_answer_counts + counts[answer] += 1 + + self.answer_counts = counts + self.sample_count = counts.values.sum + self.gekanator_game = gekanator_game if gekanator_game.present? + self.source = source if new_record? + + apply_aggregated_answer!(preferred_answer: answer) + self + end + + private + + def normalize_learning_state + counts = normalized_answer_counts + + if counts.values.sum.zero? && answer.present? + counts[answer] = 1 + end + + self.answer_counts = counts + self.sample_count = counts.values.sum + + apply_aggregated_answer! + end + + def apply_aggregated_answer!(preferred_answer: nil) + counts = normalized_answer_counts + known_counts = counts.slice(*NON_UNKNOWN_ANSWERS) + known_total = known_counts.values.sum + + if known_total.zero? + self.answer = 'unknown' + self.weight = 0.1 + return + else + max_count = known_counts.values.max + candidates = known_counts.select { |_answer, count| count == max_count }.keys + self.answer = + if preferred_answer.present? && candidates.include?(preferred_answer) + preferred_answer + elsif answer.present? && candidates.include?(answer) + answer + else + candidates.first + end + end + + consensus = max_count.to_f / known_total + self.weight = Math.sqrt(known_total) * consensus + end + + def normalized_answer_counts + base = ANSWERS.index_with(0) + + answer_counts.to_h.each do |key, value| + answer_key = key.to_s + next unless ANSWERS.include?(answer_key) + + base[answer_key] = value.to_i + end + + base + end end diff --git a/backend/app/services/gekanator/question_suggestion_promoter.rb b/backend/app/services/gekanator/question_suggestion_promoter.rb index d733a43..61269bf 100644 --- a/backend/app/services/gekanator/question_suggestion_promoter.rb +++ b/backend/app/services/gekanator/question_suggestion_promoter.rb @@ -26,13 +26,16 @@ module Gekanator }, gekanator_question_suggestion: suggestion, created_by: user) - GekanatorQuestionExample.create!( - gekanator_question: question, - post: suggestion.gekanator_game.correct_post, - user: user, - gekanator_game: suggestion.gekanator_game, + example = + GekanatorQuestionExample.new( + gekanator_question: question, + post: suggestion.gekanator_game.correct_post, + user: user) + example.record_answer!( answer: suggestion.answer, - source: 'initial_suggestion') + source: 'initial_suggestion', + gekanator_game: suggestion.gekanator_game) + example.save! suggestion.update!(processed: true) question end diff --git a/backend/db/migrate/20260612000000_add_answer_statistics_to_gekanator_question_examples.rb b/backend/db/migrate/20260612000000_add_answer_statistics_to_gekanator_question_examples.rb new file mode 100644 index 0000000..fad1e9d --- /dev/null +++ b/backend/db/migrate/20260612000000_add_answer_statistics_to_gekanator_question_examples.rb @@ -0,0 +1,40 @@ +class AddAnswerStatisticsToGekanatorQuestionExamples < ActiveRecord::Migration[8.0] + class MigrationGekanatorQuestionExample < ApplicationRecord + self.table_name = 'gekanator_question_examples' + end + + def up + add_column :gekanator_question_examples, + :answer_counts, + :json, + null: true + add_column :gekanator_question_examples, + :sample_count, + :integer, + null: false, + default: 1 + + MigrationGekanatorQuestionExample.reset_column_information + MigrationGekanatorQuestionExample.find_each do |example| + counts = { + 'yes' => 0, + 'no' => 0, + 'partial' => 0, + 'probably_no' => 0, + 'unknown' => 0 + } + counts[example.answer] = 1 if counts.key?(example.answer) + + example.update_columns( + answer_counts: counts, + sample_count: 1) + end + + change_column_null :gekanator_question_examples, :answer_counts, false + end + + def down + remove_column :gekanator_question_examples, :sample_count + remove_column :gekanator_question_examples, :answer_counts + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index a873873..413adae 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_06_10_000000) do +ActiveRecord::Schema[8.0].define(version: 2026_06_12_000000) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -85,6 +85,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_10_000000) do t.float "weight", default: 1.0, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.json "answer_counts", null: false + t.integer "sample_count", default: 1, null: false t.index ["gekanator_game_id"], name: "index_gekanator_question_examples_on_gekanator_game_id" t.index ["gekanator_question_id", "post_id", "user_id"], name: "idx_gekanator_question_examples_on_question_post_user", unique: true t.index ["gekanator_question_id"], name: "index_gekanator_question_examples_on_gekanator_question_id" diff --git a/backend/spec/requests/gekanator_learning_spec.rb b/backend/spec/requests/gekanator_learning_spec.rb index 4d11522..16c5244 100644 --- a/backend/spec/requests/gekanator_learning_spec.rb +++ b/backend/spec/requests/gekanator_learning_spec.rb @@ -275,7 +275,7 @@ 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' do + it 'returns at most two accepted user_suggested post_similarity questions without duplicates' do sign_in_as admin low = create_post_similarity_question!( @@ -295,15 +295,14 @@ RSpec.describe 'Gekanator learning API', type: :request do expect(response).to have_http_status(:ok) expect(json['questions'].length).to eq(2) - expect(json['questions'].map { _1['id'] }).to eq([high.id, middle.id]) - expect(json['questions'].map { _1['id'] }).not_to include(low.id) + 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])) end - it 'does not return questions that already have an example for the correct post' do + it 'can return questions that already have an example for the correct post' do sign_in_as admin existing = create_post_similarity_question!(text: 'already learned?') - fresh = create_post_similarity_question!(text: 'fresh?') GekanatorQuestionExample.create!( gekanator_question: existing, @@ -317,15 +316,13 @@ RSpec.describe 'Gekanator learning API', type: :request do get "/gekanator/games/#{game.id}/extra_questions" expect(response).to have_http_status(:ok) - expect(json['questions'].map { _1['id'] }).to include(fresh.id) - expect(json['questions'].map { _1['id'] }).not_to include(existing.id) + expect(json['questions'].map { _1['id'] }).to include(existing.id) end - it 'does not return questions already asked in the game using snake_case question_id' do + it 'can return questions already asked in the game using snake_case question_id' do sign_in_as admin asked = create_post_similarity_question!(text: 'already asked?') - fresh = create_post_similarity_question!(text: 'fresh?') game.update!( answers: [ { @@ -338,15 +335,13 @@ RSpec.describe 'Gekanator learning API', type: :request do get "/gekanator/games/#{game.id}/extra_questions" expect(response).to have_http_status(:ok) - expect(json['questions'].map { _1['id'] }).to include(fresh.id) - expect(json['questions'].map { _1['id'] }).not_to include(asked.id) + expect(json['questions'].map { _1['id'] }).to include(asked.id) end - it 'does not return questions already asked in the game using camelCase questionId' do + it 'can return questions already asked in the game using camelCase questionId' do sign_in_as admin asked = create_post_similarity_question!(text: 'already asked?') - fresh = create_post_similarity_question!(text: 'fresh?') game.update!( answers: [ { @@ -359,8 +354,7 @@ RSpec.describe 'Gekanator learning API', type: :request do get "/gekanator/games/#{game.id}/extra_questions" expect(response).to have_http_status(:ok) - expect(json['questions'].map { _1['id'] }).to include(fresh.id) - expect(json['questions'].map { _1['id'] }).not_to include(asked.id) + expect(json['questions'].map { _1['id'] }).to include(asked.id) end it 'does not return non-accepted, non-user_suggested, or non-post_similarity questions' do @@ -584,5 +578,35 @@ RSpec.describe 'Gekanator learning API', type: :request do other_post.id.to_s => 'no' ) end + + it 'normalizes legacy title length questions' do + sign_in_as admin + + GekanatorQuestion.create!( + text: '題名が長めの投稿?', + kind: 'title', + source: 'admin_curated', + status: 'accepted', + priority_weight: 1.0, + condition: { + type: 'title-length-greater-than', + length: 20 + }, + created_by: admin + ) + + get '/gekanator/questions' + + expect(response).to have_http_status(:ok) + question_json = json['questions'].find { _1['id'] == 'title:length-at-least:21' } + expect(question_json).to include( + 'text' => 'タイトルは 21 文字以上?', + 'kind' => 'title' + ) + expect(question_json['condition']).to include( + 'type' => 'title-length-at-least', + 'length' => 21 + ) + end end end diff --git a/frontend/src/lib/gekanator.test.ts b/frontend/src/lib/gekanator.test.ts index ac7336f..7666bc8 100644 --- a/frontend/src/lib/gekanator.test.ts +++ b/frontend/src/lib/gekanator.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { apiPost } from '@/lib/api' import { + buildGekanatorQuestions, expectedAnswerForQuestion, restoreGekanatorQuestion, saveGekanatorExtraQuestionAnswers, @@ -146,6 +147,23 @@ describe('expectedAnswerForQuestion', () => { expect(expectedAnswerForQuestion(question, post({ tags: [] }))).toBe('no') }) + + it('ignores example answers for direct title facts', () => { + const question: StoredGekanatorQuestion = { + id: 'title:length-at-least:20', + text: 'タイトルは 20 文字以上?', + kind: 'title', + condition: { + type: 'title-length-at-least', + length: 20, + }, + exampleAnswers: { + 1: 'yes', + }, + } + + expect(expectedAnswerForQuestion(question, post({ id: 1, title: 'short' }))).toBe('no') + }) }) describe('restoreGekanatorQuestion', () => { @@ -187,6 +205,65 @@ describe('restoreGekanatorQuestion', () => { expect(question.test(post({ id: 2 }))).toBe(false) expect(question.test(post({ id: 3 }))).toBe(false) }) + + it('tests a post_similarity question against its configured partial answer', () => { + const question = restoreGekanatorQuestion({ + id: 'post-similarity:10', + text: '喜多ちゃんが泣いてる?', + kind: 'post_similarity', + source: 'user_suggested', + priorityWeight: 1.2, + condition: { + type: 'post-similarity', + postId: 999, + answer: 'partial', + threshold: 0.65, + }, + exampleAnswers: { + 1: 'partial', + 2: 'yes', + }, + }) + + expect(question.test(post({ id: 1 }))).toBe(true) + expect(question.test(post({ id: 2 }))).toBe(false) + }) + + it('normalizes legacy title-length-greater-than questions', () => { + const question = restoreGekanatorQuestion({ + id: 'title:length-greater-than:20', + text: '題名が長めの投稿?', + kind: 'title', + condition: { + type: 'title-length-greater-than', + length: 20, + }, + }) + + expect(question.id).toBe('title:length-at-least:21') + expect(question.condition).toEqual({ + type: 'title-length-at-least', + length: 21, + }) + expect(question.test(post({ title: 'x'.repeat(20) }))).toBe(false) + expect(question.test(post({ title: 'x'.repeat(21) }))).toBe(true) + }) +}) + +describe('buildGekanatorQuestions', () => { + it('builds quantitative title length questions', () => { + const questions = buildGekanatorQuestions([ + post({ id: 1, title: 'a' }), + post({ id: 2, title: 'bb' }), + post({ id: 3, title: 'ccc' }), + post({ id: 4, title: 'dddd' }), + ]) + const titleQuestion = questions.find(question => + question.condition.type === 'title-length-at-least') + + expect(titleQuestion?.text).toMatch(/^タイトルは \d+ 文字以上\?$/) + expect(titleQuestion?.id).toMatch(/^title:length-at-least:\d+$/) + }) }) describe('Gekanator API writers', () => { diff --git a/frontend/src/lib/gekanator.ts b/frontend/src/lib/gekanator.ts index af7d4c6..9f4b62c 100644 --- a/frontend/src/lib/gekanator.ts +++ b/frontend/src/lib/gekanator.ts @@ -372,10 +372,12 @@ export const fetchGekanatorQuestions = async (): Promise => { const data = await apiGet<{ questions: GekanatorExtraQuestion[] }> ( - `/gekanator/games/${ gameId }/extra_questions`) + `/gekanator/games/${ gameId }/extra_questions`, + { params: nonce ? { nonce } : undefined }) return data.questions } diff --git a/frontend/src/lib/gekanatorCandidateRecovery.test.ts b/frontend/src/lib/gekanatorCandidateRecovery.test.ts new file mode 100644 index 0000000..3716f3f --- /dev/null +++ b/frontend/src/lib/gekanatorCandidateRecovery.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest' + +import { + candidatePostsFor, + hardFilteredPostsForAnswer, + recoverCandidatePosts, +} from '@/lib/gekanatorCandidateRecovery' + +import type { + GekanatorAnswerLog, + GekanatorAnswerValue, + GekanatorQuestion, +} from '@/lib/gekanator' +import type { Post } from '@/types' + + +const post = (id: number): Post => ({ + id, + versionNo: 1, + url: `https://example.com/posts/${ id }`, + title: `post ${ id }`, + thumbnail: null, + thumbnailBase: null, + tags: [], + viewed: false, + related: [], + originalCreatedFrom: null, + originalCreatedBefore: null, + createdAt: '2026-06-10T00:00:00.000Z', + updatedAt: '2026-06-10T00:00:00.000Z', + uploadedUser: null, +}) + + +const postSimilarityQuestion = ( + id: string, + answers: Record<`${ number }`, GekanatorAnswerValue>, +): GekanatorQuestion => ({ + id, + text: `${ id }?`, + kind: 'post_similarity', + condition: { + type: 'post-similarity', + postId: 9999, + answer: 'yes', + threshold: 0.65 }, + source: 'user_suggested', + priorityWeight: 1, + exampleAnswers: answers, + test: candidate => answers[String (candidate.id) as `${ number }`] === 'yes', +}) + + +const answer = ( + question: GekanatorQuestion, + value: GekanatorAnswerValue, +): GekanatorAnswerLog => ({ + questionId: question.id, + questionText: question.text, + questionCondition: question.condition, + answer: value, + originalAnswer: value, +}) + + +describe('candidatePostsFor', () => { + it('lets recovered candidates ignore old answers but not later answers', () => { + const posts = [post (1), post (2), post (3)] + const oldQuestion = postSimilarityQuestion ('old', { + 1: 'no', + 2: 'yes', + 3: 'yes', + }) + const laterQuestion = postSimilarityQuestion ('later', { + 1: 'no', + 2: 'no', + 3: 'yes', + }) + + const candidates = candidatePostsFor ({ + posts, + questions: [oldQuestion, laterQuestion], + answers: [answer (oldQuestion, 'yes'), answer (laterQuestion, 'yes')], + softenedQuestionIds: new Set (), + rejectedPostIds: new Set (), + recoveredCandidatePosts: new Map ([ + [1, 1], + [3, 1], + ]) }) + + expect(candidates.map (candidate => candidate.id)).toEqual ([3]) + }) + + it('does not let recovered candidates bypass explicit rejected posts', () => { + const posts = [post (1), post (2)] + const question = postSimilarityQuestion ('question', { + 1: 'yes', + 2: 'yes', + }) + + const candidates = candidatePostsFor ({ + posts, + questions: [question], + answers: [answer (question, 'yes')], + softenedQuestionIds: new Set (), + rejectedPostIds: new Set ([1]), + recoveredCandidatePosts: new Map ([[1, 1]]) }) + + expect(candidates.map (candidate => candidate.id)).toEqual ([2]) + }) +}) + + +describe('hardFilteredPostsForAnswer', () => { + it('returns zero candidates without falling back to the original pool', () => { + const posts = [post (1), post (2)] + const question = postSimilarityQuestion ('question', { + 1: 'yes', + 2: 'yes', + }) + + expect(hardFilteredPostsForAnswer ({ + posts, + question, + answer: 'no', + })).toEqual ([]) + }) +}) + + +describe('recoverCandidatePosts', () => { + it('recovers high-score non-rejected, non-eligible candidates in staged batches', () => { + const posts = Array.from ({ length: 10 }, (_value, index) => post (index + 1)) + const scores = new Map (posts.map (candidate => [candidate.id, candidate.id])) + + const recovered = recoverCandidatePosts ({ + posts, + scores, + rejectedPostIds: new Set ([10]), + recoveredCandidatePosts: new Map ([[8, 1]]), + eligiblePostIds: new Set ([9]), + answerCountAtRecovery: 2, + recoveryStepCount: 0, + }) + + expect(recovered?.recoveryStepCount).toBe (1) + expect([...(recovered?.recoveredCandidatePosts.keys () ?? [])]) + .toEqual ([8, 7, 6, 5, 4, 3, 2]) + expect(recovered?.recoveredCandidatePosts.get (7)).toBe (2) + }) +}) diff --git a/frontend/src/lib/gekanatorCandidateRecovery.ts b/frontend/src/lib/gekanatorCandidateRecovery.ts new file mode 100644 index 0000000..9b89ab3 --- /dev/null +++ b/frontend/src/lib/gekanatorCandidateRecovery.ts @@ -0,0 +1,146 @@ +import { expectedAnswerForQuestion } from '@/lib/gekanator' + +import type { + GekanatorAnswerLog, + GekanatorAnswerValue, + GekanatorQuestion, +} from '@/lib/gekanator' +import type { Post } from '@/types' + + +export type RecoveredCandidatePost = { + postId: number + answerCountAtRecovery: number } + + +export const candidatePostsFor = ({ + posts, + questions, + answers, + softenedQuestionIds, + rejectedPostIds, + recoveredCandidatePosts, +}: { + posts: Post[] + questions: GekanatorQuestion[] + answers: GekanatorAnswerLog[] + softenedQuestionIds: Set + rejectedPostIds: Set + recoveredCandidatePosts: Map +}): Post[] => { + const questionById = new Map (questions.map (question => [question.id, question])) + + return posts.filter (post => { + if (rejectedPostIds.has (post.id)) + return false + + const answerCountAtRecovery = recoveredCandidatePosts.get (post.id) + + return answers.every ((answer, index) => { + if (answerCountAtRecovery !== undefined && index < answerCountAtRecovery) + return true + + if (softenedQuestionIds.has (answer.questionId)) + return true + + const question = questionById.get (answer.questionId) + if (!(question)) + return true + + switch (answer.answer) + { + case 'yes': + case 'no': { + const expected = expectedAnswerForQuestion (question, post) + return expected === null || expected === 'unknown' || expected === answer.answer + } + default: + return true + } + }) + }) +} + + +export const hardFilteredPostsForAnswer = ({ + posts, + question, + answer, +}: { + posts: Post[] + question: GekanatorQuestion + answer: GekanatorAnswerValue +}): Post[] => { + if (answer === 'unknown') + return posts + + return posts.filter (post => { + const expected = expectedAnswerForQuestion (question, post) + return expected === null || expected === 'unknown' || expected === answer + }) +} + + +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 nextRecoveryBatchSize = (recoveryStepCount: number): number => + 6 * (2 ** recoveryStepCount) + + +export const recoverCandidatePosts = ({ + posts, + scores, + rejectedPostIds, + recoveredCandidatePosts, + eligiblePostIds, + answerCountAtRecovery, + recoveryStepCount, +}: { + posts: Post[] + scores: Map + rejectedPostIds: Set + recoveredCandidatePosts: Map + eligiblePostIds: Set + answerCountAtRecovery: number + recoveryStepCount: number +}): { + recoveredCandidatePosts: Map + recoveryStepCount: number +} | null => { + const recovered = new Map (recoveredCandidatePosts) + 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, nextRecoveryBatchSize (recoveryStepCount)) + + if (candidates.length === 0) + return null + + candidates.forEach (post => recovered.set (post.id, answerCountAtRecovery)) + + return { + recoveredCandidatePosts: recovered, + recoveryStepCount: recoveryStepCount + 1 } +} diff --git a/frontend/src/lib/gekanatorQuestionFilters.ts b/frontend/src/lib/gekanatorQuestionFilters.ts new file mode 100644 index 0000000..e358e38 --- /dev/null +++ b/frontend/src/lib/gekanatorQuestionFilters.ts @@ -0,0 +1,167 @@ +import { titleLengthMinimumForCondition } from '@/lib/gekanator' + +import type { + GekanatorAnswerLog, + GekanatorAnswerValue, + GekanatorQuestion, +} from '@/lib/gekanator' + + +export const monthForCondition = ( + condition: GekanatorQuestion['condition'], +): number | null => { + if (condition.type === 'original-month') + return condition.month + + if (condition.type !== 'original-month-day') + return null + + const month = Number (condition.monthDay.split ('-')[0]) + return Number.isInteger (month) ? month : null +} + + +const isTitleLengthContradiction = ( + candidate: GekanatorQuestion['condition'], + previous: GekanatorQuestion['condition'], + answer: GekanatorAnswerValue, +): boolean => { + const candidateLength = titleLengthMinimumForCondition (candidate) + const previousLength = titleLengthMinimumForCondition (previous) + if (candidateLength === null || previousLength === null) + return false + + switch (answer) + { + case 'yes': + return candidateLength <= previousLength + case 'no': + return candidateLength >= previousLength + default: + return false + } +} + + +const isQuestionRedundantAfterAnswers = ( + question: GekanatorQuestion, + answers: GekanatorAnswerLog[], +): boolean => answers.some (answer => { + const previous = answer.questionCondition + return previous !== undefined + && isTitleLengthContradiction (question.condition, previous, answer.answer) +}) + + +const isSourceFactBlocked = ( + candidate: GekanatorQuestion['condition'], + previous: GekanatorQuestion['condition'], + answer: GekanatorAnswerValue, +): boolean => { + if (candidate.type !== 'source' || previous.type !== 'source') + return false + + switch (answer) + { + case 'yes': + return true + case 'no': + return candidate.host === previous.host + default: + return false + } +} + + +const isOriginalYearFactBlocked = ( + candidate: GekanatorQuestion['condition'], + previous: GekanatorQuestion['condition'], + answer: GekanatorAnswerValue, +): boolean => { + if (candidate.type !== 'original-year' || previous.type !== 'original-year') + return false + + switch (answer) + { + case 'yes': + return true + case 'no': + return candidate.year === previous.year + default: + return false + } +} + + +const isOriginalMonthFactBlocked = ( + candidate: GekanatorQuestion['condition'], + previous: GekanatorQuestion['condition'], + answer: GekanatorAnswerValue, +): boolean => { + switch (answer) + { + case 'yes': + if (previous.type === 'original-month') + { + if (candidate.type === 'original-month') + return true + + if (candidate.type === 'original-month-day') + return monthForCondition (candidate) !== previous.month + + return false + } + + if (previous.type === 'original-month-day') + return candidate.type === 'original-month' + || candidate.type === 'original-month-day' + + return false + case 'no': + if (previous.type === 'original-month') + { + if (candidate.type === 'original-month') + return candidate.month === previous.month + + if (candidate.type === 'original-month-day') + return monthForCondition (candidate) === previous.month + + return false + } + + if (previous.type === 'original-month-day') + return candidate.type === 'original-month-day' + && candidate.monthDay === previous.monthDay + + return false + default: + return false + } +} + + +const isFactQuestionBlocked = ( + candidate: GekanatorQuestion['condition'], + previous: GekanatorQuestion['condition'], + answer: GekanatorAnswerValue, +): boolean => { + if (!(answer === 'yes' || answer === 'no')) + return false + + return isSourceFactBlocked (candidate, previous, answer) + || isOriginalYearFactBlocked (candidate, previous, answer) + || isOriginalMonthFactBlocked (candidate, previous, answer) +} + + +export const isQuestionHardFilteredAfterAnswers = ( + question: GekanatorQuestion, + answers: GekanatorAnswerLog[], +): boolean => answers.some (answer => { + const previous = answer.questionCondition + if (previous === undefined) + return false + + return isQuestionRedundantAfterAnswers (question, [answer]) + || isFactQuestionBlocked (question.condition, previous, answer.answer) +}) diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 5e8b837..7e10ab1 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -12,8 +12,8 @@ export const gekanatorKeys = { root: ['gekanator'] as const, posts: () => ['gekanator', 'posts'] as const, questions: () => ['gekanator', 'questions'] as const, - extraQuestions: (gameId: number) => - ['gekanator', 'games', gameId, 'extra-questions'] as const } + extraQuestions: (gameId: number, nonce: string) => + ['gekanator', 'games', gameId, 'extra-questions', nonce] as const } export const tagsKeys = { root: ['tags'] as const, diff --git a/frontend/src/pages/GekanatorPage.test.tsx b/frontend/src/pages/GekanatorPage.test.tsx new file mode 100644 index 0000000..e3c4bfa --- /dev/null +++ b/frontend/src/pages/GekanatorPage.test.tsx @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest' + +import { isQuestionHardFilteredAfterAnswers } from '@/lib/gekanatorQuestionFilters' + +import type { + GekanatorAnswerLog, + GekanatorAnswerValue, + GekanatorQuestion, + GekanatorQuestionCondition, +} from '@/lib/gekanator' + + +const question = ( + condition: GekanatorQuestionCondition, +): GekanatorQuestion => ({ + id: `${ condition.type }:candidate`, + text: 'candidate?', + kind: condition.type === 'source' + ? 'source' + : condition.type.startsWith ('original-') + ? 'original_date' + : condition.type.startsWith ('title-') + ? 'title' + : 'tag', + condition, + source: 'default', + priorityWeight: 1, + test: () => false, +}) + + +const answer = ( + condition: GekanatorQuestionCondition, + value: GekanatorAnswerValue, +): GekanatorAnswerLog => ({ + questionId: 'previous', + questionText: 'previous?', + questionCondition: condition, + answer: value, + originalAnswer: value, +}) + + +const blocked = ( + candidate: GekanatorQuestionCondition, + previous: GekanatorQuestionCondition, + value: GekanatorAnswerValue, +): boolean => + isQuestionHardFilteredAfterAnswers (question (candidate), [answer (previous, value)]) + + +describe('isQuestionHardFilteredAfterAnswers', () => { + it('blocks only contradictory or redundant month questions after a yes answer', () => { + const previous: GekanatorQuestionCondition = { type: 'original-month', month: 12 } + + expect(blocked ({ type: 'original-month', month: 12 }, previous, 'yes')).toBe(true) + expect(blocked ({ type: 'original-month', month: 2 }, previous, 'yes')).toBe(true) + expect(blocked ({ type: 'original-month-day', monthDay: '2-14' }, previous, 'yes')) + .toBe(true) + expect(blocked ({ type: 'original-month-day', monthDay: '12-25' }, previous, 'yes')) + .toBe(false) + expect(blocked ({ type: 'original-year', year: 2024 }, previous, 'yes')).toBe(false) + expect(blocked ({ type: 'source', host: 'example.com' }, previous, 'yes')).toBe(false) + expect(blocked ({ type: 'tag', key: 'character:喜多郁代' }, previous, 'yes')).toBe(false) + }) + + it('blocks same-month facts after a no answer', () => { + const previous: GekanatorQuestionCondition = { type: 'original-month', month: 12 } + + expect(blocked ({ type: 'original-month', month: 12 }, previous, 'no')).toBe(true) + expect(blocked ({ type: 'original-month', month: 2 }, previous, 'no')).toBe(false) + expect(blocked ({ type: 'original-month-day', monthDay: '12-25' }, previous, 'no')) + .toBe(true) + expect(blocked ({ type: 'original-month-day', monthDay: '2-14' }, previous, 'no')) + .toBe(false) + }) + + it('blocks all month and month-day questions after a month-day yes answer', () => { + const previous: GekanatorQuestionCondition = { + type: 'original-month-day', + monthDay: '12-25', + } + + expect(blocked ({ type: 'original-month', month: 12 }, previous, 'yes')).toBe(true) + expect(blocked ({ type: 'original-month', month: 2 }, previous, 'yes')).toBe(true) + expect(blocked ({ type: 'original-month-day', monthDay: '12-25' }, previous, 'yes')) + .toBe(true) + expect(blocked ({ type: 'original-month-day', monthDay: '12-26' }, previous, 'yes')) + .toBe(true) + }) + + it('blocks the same month-day only after a month-day no answer', () => { + const previous: GekanatorQuestionCondition = { + type: 'original-month-day', + monthDay: '12-25', + } + + expect(blocked ({ type: 'original-month-day', monthDay: '12-25' }, previous, 'no')) + .toBe(true) + expect(blocked ({ type: 'original-month-day', monthDay: '12-26' }, previous, 'no')) + .toBe(false) + expect(blocked ({ type: 'original-month', month: 12 }, previous, 'no')).toBe(false) + }) + + it('blocks year and source as single-value facts', () => { + expect(blocked ( + { type: 'original-year', year: 2025 }, + { type: 'original-year', year: 2024 }, + 'yes', + )).toBe(true) + expect(blocked ( + { type: 'original-year', year: 2024 }, + { type: 'original-year', year: 2024 }, + 'no', + )).toBe(true) + expect(blocked ( + { type: 'source', host: 'b.example' }, + { type: 'source', host: 'a.example' }, + 'yes', + )).toBe(true) + expect(blocked ( + { type: 'source', host: 'b.example' }, + { type: 'source', host: 'a.example' }, + 'no', + )).toBe(false) + }) + + it('does not hard-filter partial, probably_no, or unknown fact answers', () => { + const previous: GekanatorQuestionCondition = { type: 'original-month', month: 12 } + const candidate: GekanatorQuestionCondition = { type: 'original-month', month: 2 } + + expect(blocked (candidate, previous, 'partial')).toBe(false) + expect(blocked (candidate, previous, 'probably_no')).toBe(false) + expect(blocked (candidate, previous, 'unknown')).toBe(false) + }) + + it('keeps title-length hard redundancy for yes and no only', () => { + const previous: GekanatorQuestionCondition = { + type: 'title-length-at-least', + length: 30, + } + + expect(blocked ({ type: 'title-length-at-least', length: 20 }, previous, 'yes')) + .toBe(true) + expect(blocked ({ type: 'title-length-at-least', length: 40 }, previous, 'yes')) + .toBe(false) + expect(blocked ({ type: 'title-length-at-least', length: 40 }, previous, 'no')) + .toBe(true) + expect(blocked ({ type: 'title-length-at-least', length: 20 }, previous, 'no')) + .toBe(false) + expect(blocked ({ type: 'title-length-at-least', length: 20 }, previous, 'partial')) + .toBe(false) + }) +}) diff --git a/frontend/src/pages/GekanatorPage.tsx b/frontend/src/pages/GekanatorPage.tsx index 1d0c87f..4750555 100644 --- a/frontend/src/pages/GekanatorPage.tsx +++ b/frontend/src/pages/GekanatorPage.tsx @@ -17,6 +17,12 @@ import { buildGekanatorQuestions, saveGekanatorQuestionSuggestion, storeGekanatorQuestion, titleLengthMinimumForCondition } from '@/lib/gekanator' +import { allConcreteAnswerOptionsExhausted, + candidatePostsFor, + hardFilteredPostsForAnswer, + recoverCandidatePosts } from '@/lib/gekanatorCandidateRecovery' +import { isQuestionHardFilteredAfterAnswers, + monthForCondition } from '@/lib/gekanatorQuestionFilters' import { gekanatorKeys } from '@/lib/queryKeys' import { cn } from '@/lib/utils' @@ -28,6 +34,7 @@ import type { GekanatorAnswerLog, GekanatorQuestionCondition, GekanatorQuestion, StoredGekanatorQuestion } from '@/lib/gekanator' +import type { RecoveredCandidatePost } from '@/lib/gekanatorCandidateRecovery' import type { Post } from '@/types' type Phase = @@ -63,6 +70,8 @@ type GameSnapshot = { answers: GekanatorAnswerLog[] askedIds: Set softenedQuestionIds: Set + recoveredCandidatePosts: Map + recoveryStepCount: number askedQuestionBank: GekanatorQuestion[] search: string selectingCorrectPost: boolean @@ -79,6 +88,8 @@ type StoredGekanatorGame = { answers: GekanatorAnswerLog[] askedIds: string[] softenedQuestionIds: string[] + recoveredCandidatePosts?: RecoveredCandidatePost[] + recoveryStepCount?: number askedQuestionBank?: StoredGekanatorQuestion[] askedQuestionBankIds?: string[] search: string @@ -178,6 +189,8 @@ const normalizeStoredGame = (game: StoredGekanatorGame): StoredGekanatorGame => askedIds: game.askedIds.map (questionId => normalizeStoredQuestionId (questionId)), softenedQuestionIds: game.softenedQuestionIds.map (questionId => normalizeStoredQuestionId (questionId)), + recoveredCandidatePosts: game.recoveredCandidatePosts ?? [], + recoveryStepCount: game.recoveryStepCount ?? 0, askedQuestionBank: game.askedQuestionBank?.map (question => ({ ...question, @@ -280,6 +293,20 @@ const resettableExtraQuestionState = (): { extraQuestionState: 'idle' }) +const recoveredCandidateMapFromStored = ( + items: RecoveredCandidatePost[], +): Map => + new Map (items.map (item => [item.postId, item.answerCountAtRecovery])) + + +const storedRecoveredCandidatesFromMap = ( + recoveredCandidatePosts: Map, +): RecoveredCandidatePost[] => + [...recoveredCandidatePosts.entries ()].map (([postId, answerCountAtRecovery]) => ({ + postId, + answerCountAtRecovery })) + + const deltaFor = (matched: boolean, answer: GekanatorAnswerValue): number => { switch (answer) { @@ -399,48 +426,6 @@ const recalculateScores = ({ } -const candidatePostsFor = ({ - posts, - questions, - answers, - softenedQuestionIds, - rejectedPostIds, -}: { - posts: Post[] - questions: GekanatorQuestion[] - answers: GekanatorAnswerLog[] - softenedQuestionIds: Set - rejectedPostIds: Set -}): Post[] => { - const questionById = new Map (questions.map (question => [question.id, question])) - - return posts.filter (post => { - if (rejectedPostIds.has (post.id)) - return false - - return answers.every (answer => { - if (softenedQuestionIds.has (answer.questionId)) - return true - - const question = questionById.get (answer.questionId) - if (!(question)) - return true - - switch (answer.answer) - { - case 'yes': - case 'no': { - const expected = expectedAnswerForQuestion (question, post) - return expected === null || expected === 'unknown' || expected === answer.answer - } - default: - return true - } - }) - }) -} - - const confidencesFor = (posts: Post[], scores: Map): Confidence[] => { if (posts.length === 0) return [] @@ -489,17 +474,18 @@ const previewAnswer = ({ question: GekanatorQuestion answer: GekanatorAnswerValue }): AnswerPreview => { - const hardFilteredPosts = - answer === 'unknown' - ? posts - : posts.filter (post => { - const expected = expectedAnswerForQuestion (question, post) - return expected === null || expected === 'unknown' || expected === answer - }) - const nextPosts = - hardFilteredPosts.length > 0 - ? hardFilteredPosts - : posts + const nextPosts = hardFilteredPostsForAnswer ({ + posts, + question, + answer }) + if (nextPosts.length === 0) + return { + answer, + top: null, + candidateCount: 0, + effectiveCandidates: 0, + entropy: 0 } + const nextScores = new Map (scores) nextPosts.forEach (post => { const expected = expectedAnswerForQuestion (question, post) @@ -628,52 +614,6 @@ const sameConditionValue = ( } -const monthForCondition = ( - condition: GekanatorQuestion['condition'], -): number | null => { - if (condition.type === 'original-month') - return condition.month - - if (condition.type !== 'original-month-day') - return null - - const month = Number (condition.monthDay.split ('-')[0]) - return Number.isInteger (month) ? month : null -} - - -const isTitleLengthContradiction = ( - candidate: GekanatorQuestion['condition'], - previous: GekanatorQuestion['condition'], - answer: GekanatorAnswerValue, -): boolean => { - const candidateLength = titleLengthMinimumForCondition (candidate) - const previousLength = titleLengthMinimumForCondition (previous) - if (candidateLength === null || previousLength === null) - return false - - switch (answer) - { - case 'yes': - return candidateLength <= previousLength - case 'no': - return candidateLength >= previousLength - default: - return false - } -} - - -const isQuestionRedundantAfterAnswers = ( - question: GekanatorQuestion, - answers: GekanatorAnswerLog[], -): boolean => answers.some (answer => { - const previous = answer.questionCondition - return previous !== undefined - && isTitleLengthContradiction (question.condition, previous, answer.answer) -}) - - const isMonthCrossMatch = ( candidate: GekanatorQuestion['condition'], previous: GekanatorQuestion['condition'], @@ -808,7 +748,7 @@ const chooseQuestion = ({ return questionsToRank .map (question => { - if (isQuestionRedundantAfterAnswers (question, answers)) + if (isQuestionHardFilteredAfterAnswers (question, answers)) return null const signature = signatureFor (question, candidates) @@ -934,6 +874,10 @@ const GekanatorPage: FC = () => { () => new Set (storedGame?.askedIds ?? [])) const [softenedQuestionIds, setSoftenedQuestionIds] = useState> ( () => new Set (storedGame?.softenedQuestionIds ?? [])) + const [recoveredCandidatePosts, setRecoveredCandidatePosts] = useState> ( + () => recoveredCandidateMapFromStored (storedGame?.recoveredCandidatePosts ?? [])) + const [recoveryStepCount, setRecoveryStepCount] = useState ( + storedGame?.recoveryStepCount ?? 0) const [askedQuestionBank, setAskedQuestionBank] = useState ( () => (storedGame?.askedQuestionBank ?? []).map (restoreGekanatorQuestion)) const [storedAskedQuestionBankIds, setStoredAskedQuestionBankIds] = useState ( @@ -1024,6 +968,8 @@ const GekanatorPage: FC = () => { answers, askedIds: [...askedIds], softenedQuestionIds: [...softenedQuestionIds], + recoveredCandidatePosts: storedRecoveredCandidatesFromMap (recoveredCandidatePosts), + recoveryStepCount, askedQuestionBank: askedQuestionBank.map (storeGekanatorQuestion), askedQuestionBankIds: storedAskedQuestionBankIds, search, @@ -1059,6 +1005,8 @@ const GekanatorPage: FC = () => { answers, askedIds, softenedQuestionIds, + recoveredCandidatePosts, + recoveryStepCount, askedQuestionBank, storedAskedQuestionBankIds, search, @@ -1086,8 +1034,10 @@ const GekanatorPage: FC = () => { questions: askedQuestionBank, answers, softenedQuestionIds, - rejectedPostIds }), - [posts, askedQuestionBank, answers, softenedQuestionIds, rejectedPostIds]) + rejectedPostIds, + recoveredCandidatePosts }), + [posts, askedQuestionBank, answers, softenedQuestionIds, + rejectedPostIds, recoveredCandidatePosts]) const questions = useMemo ( () => mergeQuestions ([ ...buildGekanatorQuestions (eligiblePosts.length > 1 ? eligiblePosts : posts), @@ -1100,14 +1050,14 @@ const GekanatorPage: FC = () => { () => new Map (scoringQuestions.map (question => [question.id, question])), [scoringQuestions]) const questionsSinceLastGuess = answers.length - lastGuessQuestionCount - const nonRejectedPosts = useMemo ( + const availablePosts = useMemo ( () => posts.filter (post => !(rejectedPostIds.has (post.id))), [posts, rejectedPostIds]) const questionPosts = eligiblePosts.length > 1 || questionsSinceLastGuess >= minQuestionsBeforeCertainGuess ? eligiblePosts - : nonRejectedPosts + : availablePosts const topScoredPosts = useMemo ( () => eligiblePosts .map (post => ({ post, score: scores.get (post.id) ?? 0 })) @@ -1133,7 +1083,7 @@ const GekanatorPage: FC = () => { const guessablePosts = eligiblePosts.length > 0 ? eligiblePosts - : nonRejectedPosts + : availablePosts const guess = bestPost (guessablePosts, scores) const displayedGuess = posts.find (post => post.id === activeGuessId) ?? guess @@ -1181,6 +1131,8 @@ const GekanatorPage: FC = () => { setAnswers ([]) setAskedIds (new Set ()) setSoftenedQuestionIds (new Set ()) + setRecoveredCandidatePosts (new Map ()) + setRecoveryStepCount (0) setAskedQuestionBank ([]) setSearch ('') setSelectingCorrectPost (false) @@ -1207,14 +1159,26 @@ const GekanatorPage: FC = () => { nextAskedQuestionBank, nextSoftenedQuestionIds, nextRejectedPostIds, + nextRecoveredCandidatePosts, + nextRecoveryStepCount, + allowPreQuestionRecovery, }: { nextAnswers: GekanatorAnswerLog[] nextAskedIds: Set nextAskedQuestionBank: GekanatorQuestion[] nextSoftenedQuestionIds: Set nextRejectedPostIds: Set + nextRecoveredCandidatePosts: Map + nextRecoveryStepCount: number + allowPreQuestionRecovery?: boolean }) => { let recoveredSoftenedQuestionIds = new Set (nextSoftenedQuestionIds) + let recoveredCandidatePosts = new Map (nextRecoveredCandidatePosts) + let recoveredStepCount = nextRecoveryStepCount + const answerCountAtRecovery = + allowPreQuestionRecovery + ? nextAnswers.length + : Math.max (nextAnswers.length - 1, 0) let recoveredScores = recalculateScores ({ posts, questions: nextAskedQuestionBank, @@ -1225,27 +1189,72 @@ const GekanatorPage: FC = () => { questions: nextAskedQuestionBank, answers: nextAnswers, softenedQuestionIds: recoveredSoftenedQuestionIds, - rejectedPostIds: nextRejectedPostIds }) + rejectedPostIds: nextRejectedPostIds, + recoveredCandidatePosts }) let recoveredScoringQuestions = mergeQuestions ([ ...buildGekanatorQuestions ( recoveredEligiblePosts.length > 1 ? recoveredEligiblePosts : posts), ...acceptedQuestions, ...nextAskedQuestionBank]) - while ( - recoveredEligiblePosts.length === 0 - || ( - recoveredEligiblePosts.length !== 1 - && !(chooseQuestion ({ - posts: recoveredEligiblePosts, - questions: recoveredScoringQuestions, - scores: recoveredScores, - answers: nextAnswers, - askedIds: nextAskedIds, - gameSeed }))) - ) + const refreshRecoveredState = () => { + recoveredScores = recalculateScores ({ + posts, + questions: nextAskedQuestionBank, + answers: nextAnswers, + softenedQuestionIds: recoveredSoftenedQuestionIds }) + recoveredEligiblePosts = candidatePostsFor ({ + posts, + questions: nextAskedQuestionBank, + answers: nextAnswers, + softenedQuestionIds: recoveredSoftenedQuestionIds, + rejectedPostIds: nextRejectedPostIds, + recoveredCandidatePosts }) + recoveredScoringQuestions = mergeQuestions ([ + ...buildGekanatorQuestions ( + recoveredEligiblePosts.length > 1 ? recoveredEligiblePosts : posts), + ...acceptedQuestions, + ...nextAskedQuestionBank]) + } + + const needsPreQuestionRecovery = () => { + if (!(allowPreQuestionRecovery) || recoveredEligiblePosts.length === 0) + return false + + const nextQuestion = chooseQuestion ({ + posts: recoveredEligiblePosts, + questions: recoveredScoringQuestions, + scores: recoveredScores, + answers: nextAnswers, + askedIds: nextAskedIds, + gameSeed }) + + return allConcreteAnswerOptionsExhausted (recoveredEligiblePosts, nextQuestion) + } + + while (recoveredEligiblePosts.length === 0 || needsPreQuestionRecovery ()) { - if (nextAnswers.length >= hardMaxQuestions) + const recoveredPosts = recoverCandidatePosts ({ + posts, + scores: recoveredScores, + rejectedPostIds: nextRejectedPostIds, + recoveredCandidatePosts, + eligiblePostIds: new Set (recoveredEligiblePosts.map (post => post.id)), + answerCountAtRecovery, + recoveryStepCount: recoveredStepCount }) + if (recoveredPosts) + { + recoveredCandidatePosts = recoveredPosts.recoveredCandidatePosts + recoveredStepCount = recoveredPosts.recoveryStepCount + refreshRecoveredState () + if (recoveredEligiblePosts.length > 0 && !(needsPreQuestionRecovery ())) + break + } + + if ( + recoveredEligiblePosts.length > 0 + || nextAnswers.length >= hardMaxQuestions + ) break const softened = softenNextQuestionIds ({ @@ -1256,26 +1265,13 @@ const GekanatorPage: FC = () => { break recoveredSoftenedQuestionIds = softened - recoveredScores = recalculateScores ({ - posts, - questions: nextAskedQuestionBank, - answers: nextAnswers, - softenedQuestionIds: recoveredSoftenedQuestionIds }) - recoveredEligiblePosts = candidatePostsFor ({ - posts, - questions: nextAskedQuestionBank, - answers: nextAnswers, - softenedQuestionIds: recoveredSoftenedQuestionIds, - rejectedPostIds: nextRejectedPostIds }) - recoveredScoringQuestions = mergeQuestions ([ - ...buildGekanatorQuestions ( - recoveredEligiblePosts.length > 1 ? recoveredEligiblePosts : posts), - ...acceptedQuestions, - ...nextAskedQuestionBank]) + refreshRecoveredState () } return { softenedQuestionIds: recoveredSoftenedQuestionIds, + recoveredCandidatePosts, + recoveryStepCount: recoveredStepCount, scores: recoveredScores, eligiblePosts: recoveredEligiblePosts, scoringQuestions: recoveredScoringQuestions } @@ -1295,6 +1291,8 @@ const GekanatorPage: FC = () => { answers: [...answers], askedIds: new Set (askedIds), softenedQuestionIds: new Set (softenedQuestionIds), + recoveredCandidatePosts: new Map (recoveredCandidatePosts), + recoveryStepCount, askedQuestionBank: [...askedQuestionBank], search, selectingCorrectPost, @@ -1319,21 +1317,26 @@ const GekanatorPage: FC = () => { nextAskedIds, nextAskedQuestionBank, nextSoftenedQuestionIds: softenedQuestionIds, - nextRejectedPostIds: rejectedPostIds }) + nextRejectedPostIds: rejectedPostIds, + nextRecoveredCandidatePosts: recoveredCandidatePosts, + nextRecoveryStepCount: recoveryStepCount }) const nextSoftenedQuestionIds = recovered.softenedQuestionIds + const nextRecoveredCandidatePosts = recovered.recoveredCandidatePosts const nextScores = recovered.scores const nextEligiblePosts = recovered.eligiblePosts setScores (nextScores) setAskedIds (nextAskedIds) setSoftenedQuestionIds (nextSoftenedQuestionIds) + setRecoveredCandidatePosts (nextRecoveredCandidatePosts) + setRecoveryStepCount (recovered.recoveryStepCount) setAskedQuestionBank (nextAskedQuestionBank) setAnswers (nextAnswers) const nextGuessablePosts = nextEligiblePosts.length > 0 ? nextEligiblePosts - : nonRejectedPosts + : availablePosts const nextGuess = bestPost (nextGuessablePosts, nextScores) const nextQuestionCount = answers.length + 1 const nextQuestionsSinceLastGuess = @@ -1469,6 +1472,10 @@ const GekanatorPage: FC = () => { } setRejectedPostIds (new Set ([...rejectedPostIds, displayedGuess.id])) + setRecoveredCandidatePosts ( + new Map ( + [...recoveredCandidatePosts.entries ()].filter ( + ([postId]) => postId !== displayedGuess.id))) setActiveGuessId (null) setSearch ('') setSelectingCorrectPost (false) @@ -1486,6 +1493,8 @@ const GekanatorPage: FC = () => { setAnswers (snapshot.answers) setAskedIds (snapshot.askedIds) setSoftenedQuestionIds (snapshot.softenedQuestionIds) + setRecoveredCandidatePosts (snapshot.recoveredCandidatePosts) + setRecoveryStepCount (snapshot.recoveryStepCount) setAskedQuestionBank (snapshot.askedQuestionBank) setSearch (snapshot.search) setSelectingCorrectPost (snapshot.selectingCorrectPost) @@ -1507,15 +1516,24 @@ const GekanatorPage: FC = () => { nextAskedIds: askedIds, nextAskedQuestionBank: askedQuestionBank, nextSoftenedQuestionIds: softenedQuestionIds, - nextRejectedPostIds: rejectedPostIds }) + nextRejectedPostIds: rejectedPostIds, + nextRecoveredCandidatePosts: recoveredCandidatePosts, + nextRecoveryStepCount: recoveryStepCount, + allowPreQuestionRecovery: true }) setSoftenedQuestionIds (recovered.softenedQuestionIds) + setRecoveredCandidatePosts (recovered.recoveredCandidatePosts) + setRecoveryStepCount (recovered.recoveryStepCount) setScores (recovered.scores) + const recoveredGuessablePosts = + recovered.eligiblePosts.length > 0 + ? recovered.eligiblePosts + : availablePosts const nextQuestion = chooseQuestion ({ posts: recovered.eligiblePosts.length > 1 ? recovered.eligiblePosts - : nonRejectedPosts, + : availablePosts, questions: recovered.scoringQuestions, scores: recovered.scores, answers, @@ -1528,7 +1546,7 @@ const GekanatorPage: FC = () => { return } - setActiveGuessId (guess?.id ?? null) + setActiveGuessId (bestPost (recoveredGuessablePosts, recovered.scores)?.id ?? null) setPhase ('guess') } @@ -1582,12 +1600,13 @@ const GekanatorPage: FC = () => { setExtraQuestions ([]) setExtraQuestionAnswers ({ }) setPhase ('extra_questions') + const nonce = createGameSeed () try { const questions = await queryClient.fetchQuery ({ - queryKey: gekanatorKeys.extraQuestions (gameId), - queryFn: () => fetchGekanatorExtraQuestions (gameId) }) + queryKey: gekanatorKeys.extraQuestions (gameId, nonce), + queryFn: () => fetchGekanatorExtraQuestions (gameId, nonce) }) setExtraQuestions (questions) setExtraQuestionState (questions.length > 0 ? 'ready' : 'empty') }