import { AGE_BANDS, GENDERS, LOCATION_OPTIONS, PERSONA_OPTIONS, REGIONS } from "./data"; import type { AgeBandId, CategoricalAnswer, GenderId, LikertAnswer, LocationId, OpenAnswer, PersonaAttributeId, PersonaDimensionId, QuestionStat, RegionId, RegionStat, SiliconConfig, SiliconResult, SurveyQuestion, SyntheticRespondent, WeightedPick, } from "./types"; const REGION_TENDENCY: Record = { seoul: 0.03, busan: -0.02, daegu: -0.08, incheon: 0.01, gwangju: 0.1, daejeon: 0.02, ulsan: -0.03, sejong: 0.04, gyeonggi: 0.02, gangwon: -0.02, chungbuk: -0.01, chungnam: -0.02, jeonbuk: 0.06, jeonnam: 0.08, gyeongbuk: -0.07, gyeongnam: -0.04, jeju: 0.05, }; const AGE_TENDENCY: Record = { "20s": 0.03, "30s": 0.01, "40s": 0, "50s": -0.01, "60plus": -0.02, }; const THEMES = ["물가", "주거", "일자리", "돌봄", "교통", "의료", "지역경제", "교육", "기후", "정치 신뢰"]; const PERSONA_DIMENSIONS: PersonaDimensionId[] = ["occupation", "education", "housing", "marital", "family"]; export function defaultPicks(items: Array<{ id: T; defaultWeight: number }>): WeightedPick[] { return items.map((item) => ({ id: item.id, enabled: true, weight: item.defaultWeight })); } export function simulateSiliconSampling(config: SiliconConfig): SiliconResult { const rand = mulberry32(config.seed); const respondents: SyntheticRespondent[] = []; const likertAnswers: LikertAnswer[] = []; const categoricalAnswers: CategoricalAnswer[] = []; const openAnswers: OpenAnswer[] = []; const likertQuestions = config.questions.filter((question) => question.kind === "likert"); const categoricalQuestions = config.questions.filter((question) => question.kind === "categorical"); const openQuestions = config.questions.filter((question) => question.kind === "open"); const genderPool = buildAllocationPool(config.genders, config.sampleSize, "female", rand); const agePool = buildAllocationPool(config.ages, config.sampleSize, "40s", rand); const locationPool = buildAllocationPool(config.locations, config.sampleSize, "seoul", rand); const personaPools = buildPersonaPools(config.personaAttributes, config.sampleSize, rand); for (let index = 0; index < config.sampleSize; index += 1) { const gender = genderPool[index] || "female"; const age = agePool[index] || "40s"; const location = locationPool[index] || "seoul"; const locationOption = locationOptionOf(location, config.locationOptions); const persona = personaAttributesFromPools(personaPools, index); const respondent = buildRespondent(index, gender, age, locationOption.id, locationOption.parentRegion, locationOption.label, persona, rand); respondents.push(respondent); for (const question of likertQuestions) { likertAnswers.push({ respondentId: respondent.id, questionId: question.id, value: answerLikert(question, respondent, rand), rationale: answerLikertRationale(question, respondent), }); } for (const question of categoricalQuestions) { categoricalAnswers.push({ respondentId: respondent.id, questionId: question.id, ...answerCategorical(question, respondent, rand), }); } for (const question of openQuestions) { openAnswers.push({ respondentId: respondent.id, questionId: question.id, ...answerOpen(question, respondent, rand), }); } } const primaryQuestionId = likertQuestions[0]?.id ?? null; return { config, respondents, likertAnswers, categoricalAnswers, openAnswers, regionStats: buildRegionStats(config.locations, config.locationOptions, respondents, likertAnswers, openAnswers, likertQuestions[0]), questionStats: buildQuestionStats(config.questions, likertAnswers, categoricalAnswers), primaryQuestionId, }; } function buildRespondent( index: number, gender: GenderId, age: AgeBandId, location: LocationId, region: RegionId, locationLabel: string, persona: ReturnType, rand: () => number, ): SyntheticRespondent { const regionBias = REGION_TENDENCY[region] || 0; const ageBias = AGE_TENDENCY[age] || 0; const genderBias = gender === "female" ? 0.015 : gender === "male" ? -0.01 : 0; const personaBias = personaBiases(persona.attributes); const economicAnxiety = clamp01(0.5 + (age === "30s" || age === "40s" ? 0.08 : 0) + (region === "seoul" || region === "gyeonggi" ? 0.04 : 0) + personaBias.anxiety + noise(rand, 0.18)); const trust = clamp01(0.48 + regionBias + ageBias + genderBias + personaBias.trust - economicAnxiety * 0.08 + noise(rand, 0.16)); const participation = clamp01(0.5 + (age === "60plus" ? 0.12 : 0) + (age === "20s" ? -0.06 : 0) + Math.abs(regionBias) * 0.4 + personaBias.participation + noise(rand, 0.14)); return { id: `R${String(index + 1).padStart(4, "0")}`, gender, age, region, location, locationLabel, personaAttributes: persona.attributes, personaLabels: persona.labels, segment: segmentLabel(trust, economicAnxiety, participation), trust, economicAnxiety, participation, }; } function answerLikert(question: SurveyQuestion, respondent: SyntheticRespondent, rand: () => number) { const scale = question.scale || 5; let score = scale / 2 + 0.5; const trustTerm = (respondent.trust - 0.5) * scale * 0.8; const anxietyTerm = (respondent.economicAnxiety - 0.5) * scale * 0.58; const participationTerm = (respondent.participation - 0.5) * scale * 0.32; const regionTerm = (REGION_TENDENCY[respondent.region] || 0) * scale; if (question.id.includes("approval") || question.id.includes("trust")) score += trustTerm + regionTerm; else if (question.id.includes("economic") || question.id.includes("household")) score += -anxietyTerm + trustTerm * 0.28; else if (question.id.includes("climate")) score += participationTerm + (respondent.age === "20s" ? 0.35 : 0) - (respondent.age === "60plus" ? 0.18 : 0); else if (question.id.includes("birth")) score += -anxietyTerm * 0.35 + (respondent.age === "30s" ? -0.18 : 0.08); else if (question.id.includes("news")) score += trustTerm * 0.35 - (respondent.age === "20s" ? 0.1 : 0) + (respondent.age === "60plus" ? 0.15 : 0); else score += trustTerm * 0.35 - anxietyTerm * 0.18; score += noise(rand, scale * 0.36); return Math.max(1, Math.min(scale, Math.round(score))); } function answerLikertRationale(question: SurveyQuestion, respondent: SyntheticRespondent) { const age = labelOf(AGE_BANDS, respondent.age); const gender = labelOf(GENDERS, respondent.gender); const region = labelOf(REGIONS, respondent.region); const persona = respondent.personaLabels.occupation || respondent.segment; if (question.id.includes("economic") || question.id.includes("household")) { return `${region} 거주 ${age} ${gender} ${persona} 응답자로서 생활비와 소득 안정성을 함께 고려했습니다.`; } if (question.id.includes("trust") || question.id.includes("approval")) { return `${region} 거주 ${age} ${gender} ${persona} 응답자로서 제도 신뢰와 최근 정책 체감도를 기준으로 판단했습니다.`; } if (question.id.includes("climate")) { return `${region} 거주 ${age} ${gender} ${persona} 응답자로서 환경 필요성과 비용 부담을 함께 보았습니다.`; } return `${region} 거주 ${age} ${gender} ${persona} 응답자로서 현재 생활 여건과 관심사를 반영했습니다.`; } function answerOpen(question: SurveyQuestion, respondent: SyntheticRespondent, rand: () => number): { text: string; theme: string; rationale: string } { const region = labelOf(REGIONS, respondent.region); const age = labelOf(AGE_BANDS, respondent.age); const occupation = respondent.personaLabels.occupation ? ` ${respondent.personaLabels.occupation}` : ""; let theme = THEMES[Math.floor(rand() * THEMES.length)]; if (respondent.economicAnxiety > 0.68) theme = rand() > 0.5 ? "물가" : "주거"; if (respondent.age === "20s" || respondent.age === "30s") theme = rand() > 0.45 ? "일자리" : "주거"; if (respondent.age === "60plus") theme = rand() > 0.5 ? "의료" : "돌봄"; if (question.id.includes("local")) theme = rand() > 0.5 ? "교통" : "지역경제"; if (question.id.includes("policy")) theme = rand() > 0.5 ? "주거" : "정치 신뢰"; const tone = respondent.trust > 0.58 ? "지금보다 체감 가능한 방식으로 확대되면 좋겠습니다" : "구호보다 실제 집행과 설명이 먼저 필요합니다"; const text = `${region} 거주 ${age}${occupation} 응답자로서 ${theme} 문제가 가장 크게 느껴집니다. ${tone}.`; const rationale = `${region}, ${age}, ${occupation.trim() || respondent.segment} 특성에서 가장 직접적으로 체감되는 이슈를 우선했습니다.`; return { theme, text, rationale }; } function answerCategorical(question: SurveyQuestion, respondent: SyntheticRespondent, rand: () => number): { optionId: string; label: string; rationale: string } { const options = question.options?.length ? question.options : [{ id: "opt_1", label: "기타" }]; const ageTilt = respondent.age === "20s" || respondent.age === "30s" ? 0 : respondent.age === "60plus" ? 2 : 1; const anxietyTilt = respondent.economicAnxiety > 0.6 ? 0 : 1; const index = Math.min(options.length - 1, Math.max(0, Math.floor((rand() * options.length + ageTilt + anxietyTilt) / 3))); const option = options[index] || options[0]; const rationale = `${labelOf(REGIONS, respondent.region)} 거주 ${labelOf(AGE_BANDS, respondent.age)} 응답자로서 현재 생활 여건과 persona 특성에 가장 가까운 선택지를 골랐습니다.`; return { optionId: option.id, label: option.label, rationale }; } function buildRegionStats( locations: WeightedPick[], locationOptions: SiliconConfig["locationOptions"], respondents: SyntheticRespondent[], likertAnswers: LikertAnswer[], openAnswers: OpenAnswer[], primaryQuestion?: SurveyQuestion, ): RegionStat[] { const primaryQuestionId = primaryQuestion?.id ?? null; const scale = primaryQuestion?.scale || 5; const positiveCut = Math.max(3, Math.ceil(scale * 0.7)); const enabledLocations = locations.filter((location) => location.enabled && location.weight > 0); return enabledLocations.map((locationPick) => { const option = locationOptionOf(locationPick.id, locationOptions); const people = respondents.filter((respondent) => respondent.location === option.id); const ids = new Set(people.map((respondent) => respondent.id)); const answers = primaryQuestionId ? likertAnswers.filter((answer) => answer.questionId === primaryQuestionId && ids.has(answer.respondentId)) : []; return { region: option.id, parentRegion: option.parentRegion, label: option.label, respondents: people.length, mean: mean(answers.map((answer) => answer.value)), scale, positiveShare: answers.length ? answers.filter((answer) => answer.value >= positiveCut).length / answers.length : 0, openCount: openAnswers.filter((answer) => ids.has(answer.respondentId)).length, }; }); } function buildQuestionStats(questions: SurveyQuestion[], likertAnswers: LikertAnswer[], categoricalAnswers: CategoricalAnswer[]): QuestionStat[] { return questions.map((question) => { if (question.kind === "open") return { questionId: question.id, title: question.title, kind: "open" }; if (question.kind === "categorical") { const answers = categoricalAnswers.filter((answer) => answer.questionId === question.id); const options = question.options || []; const distribution = options.map((option) => { const count = answers.filter((answer) => answer.optionId === option.id).length; return { optionId: option.id, label: option.label, count, share: answers.length ? count / answers.length : 0 }; }); return { questionId: question.id, title: question.title, kind: "categorical", distribution, }; } const scale = question.scale || 5; const answers = likertAnswers.filter((answer) => answer.questionId === question.id); const distribution = Array.from({ length: scale }, (_, index) => { const value = index + 1; const count = answers.filter((answer) => answer.value === value).length; return { value, count, share: answers.length ? count / answers.length : 0 }; }); const positiveCut = Math.max(3, Math.ceil(scale * 0.7)); return { questionId: question.id, title: question.title, kind: "likert", scale, mean: mean(answers.map((answer) => answer.value)), positiveShare: answers.length ? answers.filter((answer) => answer.value >= positiveCut).length / answers.length : 0, distribution, }; }); } export function groupBreakdown(result: SiliconResult, dimension: "gender" | "age") { const primary = result.primaryQuestionId; if (!primary) return []; const answerByRespondent = new Map(result.likertAnswers.filter((answer) => answer.questionId === primary).map((answer) => [answer.respondentId, answer.value])); const options = dimension === "gender" ? GENDERS : AGE_BANDS; return options.map((option) => { const people = result.respondents.filter((respondent) => respondent[dimension] === option.id); const values = people.map((respondent) => answerByRespondent.get(respondent.id)).filter((value): value is number => typeof value === "number"); return { id: option.id, label: option.label, respondents: people.length, mean: mean(values), }; }); } export function resultBreakdown(result: SiliconResult, questionId: string, dimension: "gender" | "age" | "region" | PersonaDimensionId) { const question = result.config.questions.find((item) => item.id === questionId); if (!question || (question.kind !== "likert" && question.kind !== "categorical")) return []; const valuesByRespondent = new Map(); if (question.kind === "likert") { for (const answer of result.likertAnswers.filter((item) => item.questionId === questionId)) { valuesByRespondent.set(answer.respondentId, answer.value); } } else { for (const answer of result.categoricalAnswers.filter((item) => item.questionId === questionId)) { valuesByRespondent.set(answer.respondentId, answer.optionId); } } const scale = question.scale || 5; const positiveCut = Math.max(3, Math.ceil(scale * 0.7)); const options = dimension === "gender" ? GENDERS.map((option) => ({ id: option.id, label: option.label })) : dimension === "age" ? AGE_BANDS.map((option) => ({ id: option.id, label: option.label })) : dimension === "region" ? result.regionStats.map((option) => ({ id: option.region, label: option.label })) : personaBreakdownOptions(result, dimension); return options.map((option) => { const id = option.id; const label = option.label; const people = result.respondents.filter((respondent) => { if (dimension === "gender") return respondent.gender === id; if (dimension === "age") return respondent.age === id; if (dimension === "region") return respondent.location === id; return respondent.personaAttributes[dimension] === id; }); const values = people.map((respondent) => valuesByRespondent.get(respondent.id)).filter((value): value is number | string => value !== undefined); const numericValues = values.filter((value): value is number => typeof value === "number"); const distribution = question.kind === "likert" ? Array.from({ length: scale }, (_, index) => { const value = index + 1; const count = values.filter((item) => item === value).length; return { id: String(value), label: `${value}점`, count, share: values.length ? count / values.length : 0 }; }) : (question.options || []).map((questionOption) => { const count = values.filter((item) => item === questionOption.id).length; return { id: questionOption.id, label: questionOption.label, count, share: values.length ? count / values.length : 0 }; }); return { id, label, respondents: people.length, mean: mean(numericValues), positiveShare: numericValues.length ? numericValues.filter((value) => value >= positiveCut).length / numericValues.length : 0, distribution, }; }).filter((item) => item.respondents > 0); } function personaBreakdownOptions(result: SiliconResult, dimension: PersonaDimensionId) { const rows = new Map(); for (const respondent of result.respondents) { const id = respondent.personaAttributes[dimension]; if (!id) continue; rows.set(id, { id, label: respondent.personaLabels[dimension] || id }); } return [...rows.values()]; } export function selectedWeightTotal(picks: WeightedPick[]) { return picks.filter((pick) => pick.enabled).reduce((total, pick) => total + Math.max(0, pick.weight), 0); } function pickWeighted(items: WeightedPick[], rand: () => number, fallback: T): T { const enabled = items.filter((item) => item.enabled && item.weight > 0); const total = selectedWeightTotal(enabled); if (!enabled.length || total <= 0) return fallback; let cursor = rand() * total; for (const item of enabled) { cursor -= item.weight; if (cursor <= 0) return item.id; } return enabled[enabled.length - 1].id; } function buildAllocationPool(items: WeightedPick[], targetSize: number, fallback: T, rand: () => number): T[] { const enabled = items.filter((item) => item.enabled && item.weight > 0); if (!enabled.length) return Array.from({ length: targetSize }, () => fallback); const allocations = allocatePickCounts(enabled, targetSize); const pool: T[] = []; for (const item of enabled) { const count = allocations.get(item.id) || 0; for (let index = 0; index < count; index += 1) pool.push(item.id); } while (pool.length < targetSize) pool.push(fallback); shuffle(pool, rand); return pool.slice(0, targetSize); } function allocatePickCounts(items: WeightedPick[], targetSize: number) { const total = items.reduce((sum, item) => sum + Math.max(0, item.weight), 0); if (total <= 0) return new Map(); const rows = items.map((item) => { const raw = Math.max(0, item.weight) / total * targetSize; return { id: item.id, floor: Math.floor(raw), remainder: raw - Math.floor(raw) }; }); let used = rows.reduce((sum, row) => sum + row.floor, 0); for (const row of rows.sort((a, b) => b.remainder - a.remainder)) { if (used >= targetSize) break; row.floor += 1; used += 1; } return new Map(rows.map((row) => [row.id, row.floor])); } function shuffle(items: T[], rand: () => number) { for (let index = items.length - 1; index > 0; index -= 1) { const swapIndex = Math.floor(rand() * (index + 1)); [items[index], items[swapIndex]] = [items[swapIndex], items[index]]; } } function locationOptionOf(id: LocationId, options: SiliconConfig["locationOptions"] = LOCATION_OPTIONS) { return options.find((location) => location.id === id) || LOCATION_OPTIONS.find((location) => location.id === "seoul")!; } function pickPersonaAttributes(items: WeightedPick[], rand: () => number) { const attributes: Partial> = {}; const labels: Partial> = {}; for (const dimension of PERSONA_DIMENSIONS) { const options = PERSONA_OPTIONS.filter((option) => option.dimension === dimension); const picks = items.filter((item) => options.some((option) => option.id === item.id)); const picked = pickWeighted(picks, rand, "" as PersonaAttributeId); if (!picked) continue; const option = PERSONA_OPTIONS.find((candidate) => candidate.id === picked); if (!option) continue; attributes[dimension] = option.id; labels[dimension] = option.label; } return { attributes, labels }; } function buildPersonaPools(items: WeightedPick[], targetSize: number, rand: () => number) { const pools = new Map(); for (const dimension of PERSONA_DIMENSIONS) { const options = PERSONA_OPTIONS.filter((option) => option.dimension === dimension); const picks = items.filter((item) => options.some((option) => option.id === item.id)); pools.set(dimension, buildAllocationPool(picks, targetSize, "" as PersonaAttributeId, rand)); } return pools; } function personaAttributesFromPools(pools: Map, index: number) { const attributes: Partial> = {}; const labels: Partial> = {}; for (const dimension of PERSONA_DIMENSIONS) { const picked = pools.get(dimension)?.[index]; if (!picked) continue; const option = PERSONA_OPTIONS.find((candidate) => candidate.id === picked); if (!option) continue; attributes[dimension] = option.id; labels[dimension] = option.label; } return { attributes, labels }; } function personaBiases(attributes: Partial>) { let trust = 0; let anxiety = 0; let participation = 0; if (attributes.occupation === "occ_self_employed") anxiety += 0.05; if (attributes.occupation === "occ_student") participation += 0.03; if (attributes.occupation === "occ_retired") participation += 0.04; if (attributes.occupation === "occ_professional") trust += 0.02; if (attributes.education === "edu_graduate" || attributes.education === "edu_bachelor") participation += 0.02; if (attributes.housing === "housing_officetel") anxiety += 0.03; if (attributes.family === "family_children") anxiety += 0.02; if (attributes.family === "family_single") participation -= 0.01; return { trust, anxiety, participation }; } function labelOf(items: Array<{ id: T; label: string }>, id: T) { return items.find((item) => item.id === id)?.label || id; } function segmentLabel(trust: number, anxiety: number, participation: number) { if (anxiety > 0.68) return "생활압박층"; if (trust > 0.6 && participation > 0.55) return "제도참여층"; if (trust < 0.42) return "불신/관망층"; if (participation > 0.64) return "고관여층"; return "중도실용층"; } function mean(values: number[]) { return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0; } function noise(rand: () => number, span: number) { return (rand() - 0.5) * span * 2; } function clamp01(value: number) { return Math.max(0, Math.min(1, value)); } function mulberry32(seed: number) { let state = seed >>> 0; return () => { state += 0x6d2b79f5; let t = state; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }