/* ============================================================ validation.js — 검증(Validation) 탭 다층 독립 검증 결과(data/validation_results.json)를 시각화. ============================================================ */ (function () { 'use strict'; let data = null, synergy = null, uq = null, pers = null, pwr = null, pwrm = null, ode = null, calib = null, ipd = null, exvivo = null, synClin = null, synRev = null, synReT = null, comp = null, scarm = null, qr = null, biph = null, hfoc = null, charts = {}, done = false; const el = id => document.getElementById(id); const VERM = '#c8401f', TEAL = '#1f5d52', INK = '#1b1a16', MUTE = '#6b655a', GOOD = '#1f6d3a', BAD = '#b3361b', WARN = '#9a6a12', GRID = 'rgba(0,0,0,.08)'; async function init() { if (done) return; done = true; try { data = await fetch('data/validation_results.json').then(r => r.json()); } catch (e) { el('val-summary').innerHTML = '

검증 데이터 로드 실패: ' + e + '

'; return; } try { synergy = await fetch('data/synergy_prediction.json').then(r => r.json()); } catch (e) {} try { uq = await fetch('data/bayes_uq_results.json').then(r => r.json()); } catch (e) {} try { pers = await fetch('data/personalize_results.json').then(r => r.json()); } catch (e) {} try { pwr = await fetch('data/power_simulation.json').then(r => r.json()); } catch (e) {} try { pwrm = await fetch('data/power_molecular.json').then(r => r.json()); } catch (e) {} try { ode = await fetch('data/ode_personalize.json').then(r => r.json()); } catch (e) {} try { calib = await fetch('data/mapping_calibration.json').then(r => r.json()); } catch (e) {} try { ipd = await fetch('data/ipd_dryrun.json').then(r => r.json()); } catch (e) {} try { exvivo = await fetch('data/exvivo_validation.json').then(r => r.json()); } catch (e) {} try { comp = await fetch('data/comparator_validation.json').then(r => r.json()); } catch (e) {} try { scarm = await fetch('data/synthetic_control_validation.json').then(r => r.json()); } catch (e) {} try { synClin = await fetch('data/synergy_clinical_test.json').then(r => r.json()); } catch (e) {} try { synRev = await fetch('data/synergy_revised.json').then(r => r.json()); } catch (e) {} try { synReT = await fetch('data/synergy_retest.json').then(r => r.json()); } catch (e) {} try { qr = await fetch('data/quant_recalibration.json').then(r => r.json()); } catch (e) {} try { biph = await fetch('data/biphasic_model.json').then(r => r.json()); } catch (e) {} try { hfoc = await fetch('data/hfoc_calibration_dryrun.json').then(r => r.json()); } catch (e) {} renderSummary(); renderLandscape(); renderMolecular(); renderAgaDp(); renderJak(); renderAaSc(); renderGwas(); renderCandidates(); renderTiming(); renderBenchmark(); renderExvivo(); renderSynergy(); renderSynergyRev(); renderUQ(); renderPersonalize(); renderPower(); renderPowerMol(); renderOde(); renderCalib(); renderIpd(); renderComparator(); renderNAM(); } // 대조군 자격 — 보정곡선 + context 점수표 function renderComparator() { const C = comp; if (!C || !C.calibration) return; const cv = C.calibration, cu = C.context_of_use || {}; const cov90 = (cv.curve.find(x => x.nominal === 0.9) || {}).empirical; const t1 = (cu.tier1_calibrated_comparator || []).length, t2 = (cu.tier2_directional_comparator || []).length, t3 = (cu.tier3_not_yet || []).length; el('val-comp-headline').innerHTML = [ [cv.calibration_error, '보정오차(↓좋음)'], [Math.round((cov90 || 0) * 100) + '%', '90% 커버리지'], [t1 + '개', '보정된 비교군'], [t2 + '개', '방향 비교군'], [t3 + '개', '미달(전향/IPD)'], ].map(([v, l]) => `
${v}
${l}
`).join(''); mk('chart-val-comp-cal', 'line', { labels: cv.curve.map(x => Math.round(x.nominal * 100) + '%'), datasets: [ { label: '경험적 커버리지', data: cv.curve.map(x => Math.round(x.empirical * 100)), borderColor: VERM, backgroundColor: 'rgba(200,64,31,.10)', borderWidth: 2.5, pointRadius: 4, tension: .2, fill: false }, { label: '이상(=명목)', data: cv.curve.map(x => Math.round(x.nominal * 100)), borderColor: INK, borderDash: [5, 4], borderWidth: 1.3, pointRadius: 0 }, ] }, { plugins: { legend: { labels: { color: INK, boxWidth: 14, font: { size: 10 } } }, title: { display: true, text: '보정곡선: 빨강이 점선(이상)에 붙음 = 구간 정직(보정됨)', color: MUTE, font: { size: 11 } } }, scales: { y: { min: 0, max: 100, title: { display: true, text: '경험적 %', color: MUTE }, ticks: { color: MUTE, callback: v => v + '%' }, grid: { color: GRID } }, x: { title: { display: true, text: '명목 신뢰수준', color: MUTE }, ticks: { color: MUTE }, grid: { display: false } } } }); const TC = { 1: GOOD, 2: '#2f63c8', 3: BAD }; const rows = (C.scorecard || []).map(r => `T${r.tier}${r.context}${r.verdict}${r.metric}`).join(''); el('val-comp-table').innerHTML = `${rows}
등급context판정근거
`; // synthetic control arm — 실제 RCT 위약 대비 if (scarm && scarm.diseases) { const dim = 'color:#8a8f98;font-size:11px'; const srows = Object.keys(scarm.diseases).map(dz => { const s = scarm.diseases[dz]; const eq = s.equivalent ? `동등 ✓` : `미입증`; const pstr = s.tost_p_equiv < 0.001 ? 'p<0.001' : 'p=' + s.tost_p_equiv; const consv = (s.tost_p_equiv_conservative_SE != null && s.tost_p_equiv_conservative_SE >= 0.05) ? ` (보수SE p=${s.tost_p_equiv_conservative_SE})` : ''; return `` + `${dz}
트윈평형 ${s.twin_equilibrium_density_pct}%` + `${s.real_placebo_mean}±${s.real_placebo_se} ${s.unit}
${s.n_trials}arm · n=${s.n_subjects}` + `${s.twin_control} (실행)` + `${eq} ${pstr}${consv}` + `raw ${s.effect_twin_raw} (${s.bias_raw_pct}%)
+오버레이 ${s.effect_twin_corrected} (${s.bias_corrected_pct}%)` + ``; }).join(''); const v = scarm.verdict || {}; const aaMae = scarm.diseases.AA && scarm.diseases.AA.nat_overlay_loto_mae; el('val-comp-scarm').innerHTML = `
무작위 대조군(RCT) 검증 — mechanistic synthetic control arm
트윈을 실제 실행해(질환 평형 등록자 모사) 무치료 대조군 readout 도출 = 0 변화(자연사 미모델, 위약데이터 미접촉=held-out·공정). 이 기전 대조군이 실제 RCT 위약 arm과 동등(TOST): ${v.equivalence}. 치료효과 재구성 편향 raw ${v.max_effect_bias_raw_pct}% → 경험 오버레이 보정 후 ${v.max_effect_bias_corrected_pct}%(in-sample).
` + `${srows}
질환실제 위약(arm·n)트윈 대조(실행)동등성(TOST·마진±15)효과재구성: raw / +오버레이
` + `
정직한 경계:회고적(기존 RCT 위약). ② 대조군 평균 재현이지 개인변동(위약 SD) 아님. ③ 동등성은 게시 dispersion=SD 가정; 보수적 SE 가정 시 AGA p≈0.10·AA p≈0.26로 미달(헤드라인 효과-편향비는 가정無, <12%). ④ 자연사 갭(AGA 보수/AA 비보수)을 메우는 경험 오버레이는 트윈 기전 아님·in-sample; 교차시험 일반화는 LOTO 예비${aaMae != null ? `(AA 2arm MAE ${aaMae}%)` : ''}. ⑤ 규제 qualification은 전향+공변량 매칭 필요.
`; } } // ⑨ 동물실험 대체 경로 — NAM 자격 프로그램 Phase 0 function renderNAM() { const host = el('val-nam'); if (!host) return; const dim = 'color:#8a8f98;font-size:11px'; const badge = (txt, bg) => `${txt}`; const parts = []; // 헤드라인: 대체 가능성 정직 프레이밍 parts.push(`
` + `🐭→🧪 쥐 실험 대체? 전체 대체는 불가(신규기전 발견·전신 PK/독성·전임상 안전성은 어떤 모델로도 영구 제외). ` + `특정 효능 어세이 1개(기전기지 JAK 화합물 AA발모)를 인체 HFOC + 정량 트윈 NAM으로 대체하는 경로만 실재. 아래=Phase 0(건식) 결과.
`); // Phase 0-A: 정량 트윈 동역학 전이성 (make-or-break) if (qr && qr.overall) { const o = qr.overall, pass = o.meets_threshold_M2; parts.push(`
① 정량 트윈 동역학 전이성 (대체급 도달 가능성, LOTO)
` + `
` + `
${o.M2_r2_monotone_only != null ? o.M2_r2_monotone_only : o.M2_r2}
M2 형태-전이 R²(단조)
` + `
${o.M2_r2}
M2 전체 R²
` + `
${o.M1_r2}
M1 군-외삽(정보0·약함)
` + `
${pass ? badge('≥0.8 통과', GOOD) : badge('미달', BAD)}
게이트
` + `
` + `
→ 동역학(lag/τ)은 새 화합물에 전이되어 대체급, 단 진폭은 HFOC가 공급(맨손 외삽 M1은 약함) = 분업 검증.
`); } // Phase 0-B: biphasic 결함 폐쇄 if (biph && biph.summary) { const s = biph.summary, fin = (biph.trajectories || {})['finasteride_1mg_5yr_DECLINE'] || {}; parts.push(`
② biphasic 결함 폐쇄 — 단조 1-exp가 못 내던 '상승-후-감소'(후기 자연사 진행)
` + `
내포모델(biphasic⊇단조). 피나 5yr 감소: 단조 R²${fin.mono ? fin.mono.r2 : '–'}(종점 ${fin.mono ? fin.mono.last_pt_err : '–'} hairs 못 따라감) → biphasic은 wane항으로 표현(종점오차 ${s.biphasic_targets_mean_lastpt_err_mono}→${s.biphasic_targets_mean_lastpt_err_biphasic}). 단조 대조 불변(ΔR²~${s.mono_ctrl_mean_abs_r2_diff}). 정직: 표적 3점→표현 시연이지 통계검증 아님.
`); } // Phase 0-C: HFOC 보정 하니스 dry-run (합성) if (hfoc) { const rep = hfoc.representative_rho090 || {}, sw = hfoc.rho_sweep || []; const swStr = sw.filter(x => [0.8, 0.9, 0.95, 1.0].includes(x.rho)).map(x => `ρ${x.rho}:${x.G2_r2}`).join(' · '); parts.push(`
③ HFOC 보정 하니스 — 사전등록 게이트 (⚠ 합성 dry-run, 실 HFOC 아님)
` + `
G1 동역학 적합 R²${rep.G1_kinetic_r2} ${rep.G1_kinetic_r2 >= 0.8 ? badge('통과', GOOD) : ''} · G2 생체외삽(ρ0.9) ${rep.G2_bridge_r2}. ` + `2병목 발견: G2 R² 천장≈ρ²(HFOC↔생체 번역충실도) + 추정잡음(모낭수). ρ스윕 [${swStr}] → G2≥0.8엔 ρ≳0.92 + 충분 모낭/패널. ` + `ρ는 wet 종간 브리지로만 측정 → 하니스는 '준비됨'이지 '대체 입증' 아님.
`); } // 4단계 게이트 사다리 const ladder = [ ['0 건식 타당성', '동역학 전이성·biphasic·하니스', 'M2 R²≥0.8', badge('통과', GOOD)], ['1 습식 파일럿', 'HFOC + JAK 3~5종 → 진폭·bioCV', 'HFOC 적합 R²≥0.8', badge('미수행(wet)', WARN)], ['2 전향·맹검', '사전등록 held-out 예측', '예측 R²≥0.8', badge('⏳', WARN)], ['3 종간 브리지', '마우스 vs NAM vs 인체', 'NAM≥마우스(인체예측)', badge('⏳ 핵심', WARN)], ['4 공인', 'ring trial + ISTAND/OECD', '재현+독립검증', badge('⏳', WARN)], ].map(r => `${r[0]}${r[1]}${r[2]}${r[3]}`).join(''); parts.push(`${ladder}
Phase내용사전 게이트상태
`); // 정직한 경계 parts.push(`
정직: Phase 0(건식)만 완료 — 기술 급소(동역학 전이 R²${qr && qr.overall ? qr.overall.M2_r2_monotone_only : '–'})는 통과했으나 Phase 1~4는 다년·고비용 wet-lab+규제(미수행). 성공해도 AA/JAK 효능 스크린 1개 대체일 뿐, 전체 대체 아님.
`); host.innerHTML = parts.join(''); } // 반증 반영 모델 수정 — 시너지 vs 축겹침(overlap) function renderSynergyRev() { const R = synRev; if (!R || !R.overlap_sweep) return; const sw = R.overlap_sweep; el('val-synrev-note').innerHTML = `모델 수정(반증 반영): 미녹시딜의 Wnt 활성 → 피나와 W축 겹침 도입. 시너지는 overlap≈${R.synergy_crosses_zero_near}에서 가법미만 전환 → 피나×미녹(겹침 0.55)은 가법(IJT 정합). 정정: AR차단×Wnt-agonist도 둘 다 W축이라 중복=무효(이전 제안 오류). 정련된 새 예측: 초가법은 *진짜 직교 축 쌍*(한쪽 Wnt무관 D약물)에서만 생존 — 직교 약물쌍 전향 검정 필요.`; mk('chart-val-synrev', 'line', { labels: sw.map(x => x.overlap), datasets: [ { label: '시너지 초과', data: sw.map(x => x.synergy_excess), borderColor: VERM, backgroundColor: 'rgba(200,64,31,.10)', borderWidth: 2.5, pointRadius: 3, tension: .3, fill: true }, { type: 'line', label: '가법(0)', data: sw.map(() => 0), borderColor: INK, borderDash: [5, 4], borderWidth: 1.2, pointRadius: 0 }, ] }, { plugins: { legend: { labels: { color: INK, boxWidth: 12, font: { size: 10 } } }, title: { display: true, text: '축 겹침↑ → 시너지 소멸 (직교=초가법, 겹침=가법미만)', color: MUTE, font: { size: 11 } } }, scales: { y: { title: { display: true, text: '시너지 초과(Bliss)', color: MUTE }, ticks: { color: MUTE }, grid: { color: GRID } }, x: { title: { display: true, text: 'ARM-2의 W축 겹침(overlap)', color: MUTE }, ticks: { color: MUTE }, grid: { display: false } } } }); // 다중-시험 재검정 — 정직한 verdict (겹침端 가법미만 / 직교축 포함 초가법 시사) if (synReT && synReT.analysis) { const ij = synReT.analysis.IJT2023 || {}, fp = synReT.analysis.FPHL2022 || {}, th = synReT.analysis.TH07 || {}; el('val-retest-note').innerHTML = `재검정(다중 시험): ① IJT 피나×미녹(겹침高, full 2×2) 초가법초과 ${ij.super_additive_excess} = 가법미만(모델 겹침端 정합) · ② FPHL 미녹+스피로(W축) 한계이득 +${fp.spt_add_benefit} < 미녹단독 +${fp.mino_mono}(겹침 정합) · ③ TH07 삼중(직교쌍 피나W×라타노프로스트 비-Wnt D 포함): 삼중 ${th.triple} ≫ 단독합 ${th.mono_sum} → 선형시너지 +${th.linear_synergy} = 초가법.
→ 정련 예측에 시사적 지지: 겹침 쌍=가법미만, 직교축 포함=초가법(대조 정합). 단 확정 아님(TH07은 쌍 아닌 삼중·n3-4·반정량·미녹 침투촉진 교란·산업체) — 깨끗한 W축×Wnt무관 D약물 *쌍* 전향 2×2 필요(예: AR차단×PGF2α/아데노신).`; } } // 실제 ex vivo 인체 모낭 검증 (GSE267664 DHT) function renderExvivo() { const E = exvivo; if (!E || !E.concordance) return; const c = E.concordance, k = E.key_DKK1 || {}; el('val-exvivo-headline').innerHTML = [ [c.n_match + '/' + c.n_test, 'Wnt축 방향 일치'], ['p=' + c.sign_test_p, '부호검정'], [(k.log2fc >= 0 ? '+' : '') + k.log2fc, 'DKK1 log2FC(Wnt길항↑)'], ['n=3', 'ex vivo 모낭'], ].map(([v, l]) => `
${v}
${l}
`).join(''); const m = (E.markers || []).filter(x => x.in_test); mk('chart-val-exvivo', 'bar', { labels: m.map(x => x.gene), datasets: [{ label: 'log2FC (DHT vs control)', data: m.map(x => x.log2fc), backgroundColor: m.map(x => x.match ? GOOD : BAD), borderRadius: 3 }] }, { plugins: { legend: { display: false }, title: { display: true, text: '실제 모낭 DHT 반응이 트윈 Wnt억제 예측과 협응(초록=일치)', color: MUTE, font: { size: 11 } } }, scales: { y: { title: { display: true, text: 'log2FC (DHT vs ctrl)', color: MUTE }, ticks: { color: MUTE }, grid: { color: GRID } }, x: { ticks: { color: INK, font: { size: 10 } }, grid: { display: false } } } }); } // 트랙 B — IPD 사전등록 분석 하니스 (합성 dry-run) function renderIpd() { const I = ipd; if (!I || !I.result) return; const r = I.result; el('val-ipd-headline').innerHTML = [ [r.rmse_pop_mean, 'RMSE 모집단'], [r.rmse_personal_mean, 'RMSE 개인화'], [(r.improve_pct >= 0 ? '+' : '') + r.improve_pct + '%', '개선(불확정)'], [Math.round(r.coverage_personal * 100) + '%', '구간 커버리지'], [r.personal_wins + '/' + r.n_patients, '개인화 우세'], ].map(([v, l]) => `
${v}
${l}
`).join(''); el('val-ipd-verdict').innerHTML = `사전등록 판정: ${r.decision}  (95% CI ${JSON.stringify(r.diff_ci95)} — 0 포함)`; mk('chart-val-ipd', 'bar', { labels: ['모집단', '개인화'], datasets: [{ label: '후기 forecast RMSE', data: [r.rmse_pop_mean, r.rmse_personal_mean], backgroundColor: [MUTE, TEAL], borderRadius: 3 }] }, { plugins: { legend: { display: false }, title: { display: true, text: '개인 수준: 거의 동일(시험암 +40%와 대조) — 합성', color: MUTE, font: { size: 11 } } }, scales: { y: { title: { display: true, text: 'RMSE (SALT)', color: MUTE }, ticks: { color: MUTE }, grid: { color: GRID } }, x: { ticks: { color: INK, font: { size: 12 } }, grid: { display: false } } } }); } // 트랙 C — 매핑 보정 (GWAS 마커중요도 + 섭동) function renderCalib() { const C = calib; if (!C) return; // GWAS 마커 중요도 (mlogp), AGA·AA 색 구분, GW-sig 7.3 기준선 const items = []; ['AGA', 'AA'].forEach(dz => { const g = (C.gwas || {})[dz] || {}; Object.keys(g).forEach(k => { if (g[k] > 0) items.push({ gene: k, dz, mlogp: g[k] }); }); }); items.sort((a, b) => b.mlogp - a.mlogp); const top = items.slice(0, 12); if (top.length) { mk('chart-val-calib-gwas', 'bar', { labels: top.map(x => x.gene + '·' + x.dz), datasets: [ { type: 'line', label: 'GW-sig 7.3', data: top.map(() => 7.3), borderColor: INK, borderDash: [5, 4], borderWidth: 1.2, pointRadius: 0 }, { label: '−log10 p', data: top.map(x => x.mlogp), backgroundColor: top.map(x => x.dz === 'AGA' ? VERM : TEAL), borderRadius: 3 }, ] }, { indexAxis: 'y', plugins: { legend: { labels: { color: INK, boxWidth: 12, font: { size: 10 } } }, title: { display: true, text: 'GWAS 마커 중요도 (빨강 AGA·청록 AA; 7.3=유의)', color: MUTE, font: { size: 11 } } }, scales: { x: { title: { display: true, text: '−log10 p (mlogp)', color: MUTE }, ticks: { color: MUTE }, grid: { color: GRID } }, y: { ticks: { color: INK, font: { size: 10 } }, grid: { display: false } } } }); } // 섭동: DHT Wnt 억제(DKK1/LEF1/AXIN2 logΔ) + JAK-i IFN(vehicle vs jaki) const p = C.perturb || {}; const dht = (p.DHT_Wnt || {}).genes || {}; const jak = p.JAKi_IFN || {}; const dl = ['DKK1', 'LEF1', 'AXIN2'].filter(g => dht[g]); const labels = dl.map(g => 'DHT→' + g).concat(jak.vehicle_IFN != null ? ['JAK전 IFN', 'JAK후 IFN'] : []); const vals = dl.map(g => dht[g].log_delta).concat(jak.vehicle_IFN != null ? [jak.vehicle_IFN, jak.jaki_IFN] : []); const cols = dl.map(g => dht[g].log_delta >= 0 ? BAD : GOOD).concat(jak.vehicle_IFN != null ? [BAD, GOOD] : []); if (labels.length) { mk('chart-val-calib-perturb', 'bar', { labels, datasets: [{ label: '효과(logΔ / IFN시그니처)', data: vals, backgroundColor: cols, borderRadius: 3 }] }, { plugins: { legend: { display: false }, title: { display: true, text: '섭동 보정: DHT→Wnt억제 · JAK-i→IFN감소 (실측)', color: MUTE, font: { size: 11 } } }, scales: { y: { title: { display: true, text: '효과 크기', color: MUTE }, ticks: { color: MUTE }, grid: { color: GRID } }, x: { ticks: { color: INK, font: { size: 9 } }, grid: { display: false } } } }); } } // 분자 readout & 설계 — 무엇이 표본을 줄이는가 function renderPowerMol() { const M = pwrm; if (!M || !M.strategies) return; const s = M.strategies; const shortName = n => n.replace(/\(.*?\)/g, '').replace(/모낭내 |신장기울기/g, '').trim(); const colors = ['#b9b2a6', WARN, TEAL, VERM]; mk('chart-val-powermol', 'bar', { labels: s.map((r, i) => shortName(r.strategy)), datasets: [{ label: '총 모낭(80% 검정력)', data: s.map(r => r.total_follicles), backgroundColor: s.map((_, i) => colors[i % colors.length]), borderRadius: 3 }] }, { indexAxis: 'y', plugins: { legend: { display: false }, title: { display: true, text: '표본 절감은 readout이 아니라 유효 CV↓ 설계에서 (1000→300)', color: MUTE, font: { size: 11 } } }, scales: { x: { title: { display: true, text: '총 모낭 수', color: MUTE }, ticks: { color: MUTE }, grid: { color: GRID } }, y: { ticks: { color: INK, font: { size: 10 } }, grid: { display: false } } } }); } // 실험 검정력 (몬테카를로) function renderPower() { const P = pwr; if (!P || !P.scenarios) return; const rec = P.recommendation || {}; const fmtRec = r => r ? ('공여자 ' + r.donors + '×' + r.follicles_per_group + '모낭') : '비현실'; el('val-pwr-headline').innerHTML = Object.keys(P.scenarios).map(name => { const r = P.scenarios[name].reco_80pct; return `
${fmtRec(r)}
${name}
`; }).join(''); const NF = [10, 15, 20, 30, 40, 60, 80, 120]; const pal = { '기준(E=0.6)': VERM, '낙관(E=0.6,저변동)': TEAL, '보수(E=0.4)': MUTE }; const ds = Object.keys(P.scenarios).map(name => { const g = P.scenarios[name].grid; return { label: name, data: NF.map(nf => Math.round((g['4d_' + nf + 'f'] || 0) * 100)), borderColor: pal[name] || INK, backgroundColor: 'transparent', borderWidth: 2.5, pointRadius: 2, tension: .3 }; }); ds.push({ label: '80% 목표', data: NF.map(() => 80), borderColor: INK, borderDash: [5, 4], borderWidth: 1.2, pointRadius: 0 }); mk('chart-val-power', 'line', { labels: NF.map(n => n + '개'), datasets: ds }, { plugins: { legend: { labels: { color: INK, boxWidth: 14, font: { size: 10 } } }, title: { display: true, text: '검정력 vs 군당 모낭수 (공여자 4)', color: MUTE, font: { size: 11 } } }, scales: { y: { min: 0, max: 100, title: { display: true, text: '검정력 %', color: MUTE }, ticks: { color: MUTE, callback: v => v + '%' }, grid: { color: GRID } }, x: { title: { display: true, text: '군당 모낭 수', color: MUTE }, ticks: { color: MUTE }, grid: { display: false } } } }); } // ODE-수준 분자 개인화 function renderOde() { const O = ode; if (!O || !O.profiles) return; const names = Object.keys(O.profiles); const get = (n, t) => { const r = O.profiles[n].responses; return r && r[t] != null ? r[t] : 0; }; const series = [ { key: '피나스테리드', color: '#2f63c8' }, { key: '미녹시딜', color: WARN }, { key: '병용(피나+미녹)', color: VERM }, { key: 'JAK억제제', color: TEAL }, ]; el('val-ode-headline').innerHTML = names.map(n => { const p = O.profiles[n]; const shift = (p.aa_strength > p.aga_strength) ? 'AA ' + p.aa_strength : ((p.aga_q != null ? p.aga_q : p.aga_strength) + '→' + p.aga_strength); return `
${shift}
${n.split('·')[1] || n} · ${p.recommendation}
`; }).join(''); mk('chart-val-ode', 'bar', { labels: names, datasets: series.map(s => ({ label: s.key, data: names.map(n => get(n, s.key)), backgroundColor: s.color, borderRadius: 3 })) }, { plugins: { legend: { labels: { color: INK, boxWidth: 12, font: { size: 10 } } }, title: { display: true, text: 'GWAS-가중 보정 후 — 프로파일별 예측 회복(분자 층화)', color: MUTE, font: { size: 11 } } }, scales: { y: { title: { display: true, text: '예측 회복(∫anagen)', color: MUTE }, ticks: { color: MUTE }, grid: { color: GRID } }, x: { ticks: { color: INK, font: { size: 10 } }, grid: { display: false } } } }); } // 개인화 + 데이터 동화 function renderPersonalize() { const P = pers; if (!P || !P.forecast_skill) return; const ov = P.forecast_skill.overall || {}; el('val-pers-headline').innerHTML = [ ['+' + (ov.mean_improve_pct || 0) + '%', 'forecast 개선(RMSE↓)'], [(ov.personal_wins || 0) + '/' + (ov.n || 0), '개인화 우세'], [Math.round((ov.cover_personal || 0) * 100) + '%', '개인 커버리지'], [Math.round((ov.cover_pop || 0) * 100) + '%', '모집단 커버리지'], ].map(([v, l]) => `
${v}
${l}
`).join(''); // 동화 시연: 관측 2점(넓음) vs 전체(좁음) 밴드 + 실측 const ex = P.assimilation_example; if (ex && ex.steps && ex.steps.length) { const xy = (xs, ys) => xs.map((x, i) => ({ x, y: ys[i] })); const first = ex.steps[0], last = ex.steps[ex.steps.length - 1]; mk('chart-val-assim', 'line', { datasets: [ { label: '_a', data: xy(first.grid, first.hi), borderColor: 'transparent', backgroundColor: 'rgba(154,106,18,.10)', pointRadius: 0, fill: '+1', order: 4 }, { label: first.n_obs + '점 관측 90%', data: xy(first.grid, first.lo), borderColor: 'transparent', backgroundColor: 'rgba(154,106,18,.10)', pointRadius: 0, fill: false, order: 4 }, { label: '_b', data: xy(last.grid, last.hi), borderColor: 'transparent', backgroundColor: 'rgba(31,93,82,.20)', pointRadius: 0, fill: '+1', order: 3 }, { label: last.n_obs + '점 관측 90%', data: xy(last.grid, last.lo), borderColor: 'transparent', backgroundColor: 'rgba(31,93,82,.20)', pointRadius: 0, fill: false, order: 3 }, { label: '개인화 예측', data: xy(last.grid, last.median), borderColor: TEAL, borderWidth: 2.5, pointRadius: 0, order: 2 }, { label: '실측', data: ex.points.map(p => ({ x: p[0], y: p[1] })), type: 'scatter', borderColor: VERM, backgroundColor: VERM, pointRadius: 4, order: 1 }, ] }, { plugins: { legend: { labels: { color: INK, boxWidth: 12, font: { size: 10 }, filter: it => !it.text.startsWith('_') } }, title: { display: true, text: '관측 늘릴수록 예측띠 수축(데이터 동화)', color: MUTE, font: { size: 11 } } }, scales: { x: { type: 'linear', title: { display: true, text: '개월', color: MUTE }, ticks: { color: MUTE }, grid: { display: false } }, y: { title: { display: true, text: 'SALT %변화', color: MUTE }, ticks: { color: MUTE }, grid: { color: GRID } } } }); } // 궤적별 forecast 개선% const rows = P.forecast_skill.by_trajectory || []; const shortId = s => s.replace(/_NCT\d+/, '').replace(/_/g, ' ').slice(0, 22); mk('chart-val-fskill', 'bar', { labels: rows.map(r => shortId(r.id)), datasets: [{ label: 'forecast 개선%', data: rows.map(r => r.improve_pct), backgroundColor: rows.map(r => r.improve_pct >= 0 ? GOOD : BAD), borderRadius: 3 }] }, { indexAxis: 'y', plugins: { legend: { display: false }, title: { display: true, text: '궤적별 개인화 개선(모집단 대비 RMSE↓)', color: MUTE, font: { size: 11 } } }, scales: { x: { ticks: { color: MUTE, callback: v => v + '%' }, grid: { color: GRID } }, y: { ticks: { color: INK, font: { size: 9 } }, grid: { display: false } } } }); // 합성환자 정확성: 관측↑ → 종점 오차·구간폭↓ const syn = P.synthetic; if (syn && syn.steps) { const st = syn.steps; mk('chart-val-synth', 'line', { labels: st.map(s => s.n_obs + '점'), datasets: [ { label: '종점 예측오차', data: st.map(s => s.endpoint_err), borderColor: VERM, backgroundColor: 'transparent', borderWidth: 2.5, pointRadius: 3, tension: .3 }, { label: '90% 구간폭', data: st.map(s => s.endpoint_width), borderColor: TEAL, borderDash: [5, 4], backgroundColor: 'transparent', borderWidth: 2, pointRadius: 3, tension: .3 }, ] }, { plugins: { legend: { labels: { color: INK, boxWidth: 14, font: { size: 10 } } }, title: { display: true, text: '합성환자(참 종점 ' + syn.true_endpoint + '): 관측↑ → 오차·폭↓ → 참값 수렴', color: MUTE, font: { size: 11 } } }, scales: { y: { title: { display: true, text: 'SALT 단위', color: MUTE }, ticks: { color: MUTE }, grid: { color: GRID } }, x: { ticks: { color: INK }, grid: { display: false } } } }); } } // 반증가능 예측 — 병용 시너지 (ODE AND-게이트에서 창발) function renderSynergy() { const s = synergy; if (!s) return; const h = s.headline; el('val-syn-headline').innerHTML = [ ['+' + h.synergy_excess_auc.toFixed(3), 'Bliss 초과(시너지)'], [h.combo_index_CI.toFixed(2), 'Comb.Index (<1=시너지)'], [h.R_combo_actual.toFixed(2), '실제 병용 회복'], [h.R_combo_bliss.toFixed(2), '가법 기대 회복'], ].map(([v, l]) => `
${v}
${l}
`).join(''); // 실제 per-arm 데이터 검정(5-ARI×미녹 병용) — 정직한 반증 결과 if (synClin && synClin.summary) { const sm = synClin.summary, ds = synClin.data_source || {}; el('val-syn-clinical').innerHTML = `⚠ 실제 per-arm 데이터로 반증 (${ds.pmc || 'IJT 2023'}, 3-arm RCT n=20/군): 트윈은 초가법(synergy)을 예측했으나 실데이터는 강한 가법미만(sub-additive) — 6/6 부위, 평균 초과 ${sm.mean_super_additive_excess} hairs/cm²(피나 추가이득이 피나 단독효과를 크게 밑돎). 핵심 예측 반증. 단 병용>단독(HSA ${sm.hsa_cells})은 충족(병용 임상우월성 방향은 맞음). 해석: 미녹+피나 효과 겹침→AND-게이트 '독립 노드' 전제 미충족. 한계: 단일 소규모·국소 피나·placebo 없음.`; // 시각적 반증: 평균 단독/병용 vs 가법기대 const cells = Object.values(synClin.cells || {}); if (cells.length) { const avg = a => cells.reduce((s, c) => s + c[a], 0) / cells.length; const fns = avg('FNS'), mnx = avg('MNX'), mnf = avg('MNF'); mk('chart-val-synclin', 'bar', { labels: ['피나 단독', '미녹 단독', '병용(실제)', '가법 기대(피나+미녹)'], datasets: [{ label: '24주 모발밀도 증가(평균, hairs/cm²)', data: [fns, mnx, mnf, fns + mnx].map(x => +x.toFixed(2)), backgroundColor: ['#2f63c8', WARN, VERM, INK], borderRadius: 3 }] }, { plugins: { legend: { display: false }, title: { display: true, text: '병용(빨강) ≪ 가법기대(검정) = 가법미만 → 초가법 예측 반증', color: BAD, font: { size: 11 } } }, scales: { y: { title: { display: true, text: 'Δ 모발밀도', color: MUTE }, ticks: { color: MUTE }, grid: { color: GRID } }, x: { ticks: { color: INK, font: { size: 10 } }, grid: { display: false } } } }); } } const dc = s.dose_curve; mk('chart-val-synergy', 'line', { labels: dc.map(x => x.E), datasets: [ { label: '병용(실제)', data: dc.map(x => x.R_combo), borderColor: VERM, borderWidth: 3, pointRadius: 2, tension: .2, fill: '+1', backgroundColor: 'rgba(200,64,31,.12)' }, { label: 'Bliss 가법기대', data: dc.map(x => x.bliss_expected), borderColor: INK, borderDash: [6, 4], borderWidth: 2, pointRadius: 0, tension: .2 }, { label: 'ARM-1 단독', data: dc.map(x => x.R1), borderColor: TEAL, borderWidth: 1.5, pointRadius: 0, tension: .2 }, { label: 'ARM-2 단독', data: dc.map(x => x.R2), borderColor: '#2f63c8', borderWidth: 1.5, pointRadius: 0, tension: .2 }, ] }, { plugins: { legend: { labels: { color: INK, boxWidth: 14, font: { size: 10 } } }, title: { display: true, text: '빨강(실제)이 점선(가법기대) 위 = 시너지', color: MUTE, font: { size: 11 } } }, scales: { x: { title: { display: true, text: '단일팔 효능 E', color: MUTE }, ticks: { color: MUTE }, grid: { display: false } }, y: { title: { display: true, text: '결손 회복분율', color: MUTE }, ticks: { color: MUTE }, grid: { color: GRID } } } }); const sw = s.threshold_sweep; mk('chart-val-synergy-kda', 'line', { labels: sw.map(x => x.KDA), datasets: [{ label: '시너지 초과', data: sw.map(x => x.synergy_excess), borderColor: VERM, backgroundColor: 'rgba(200,64,31,.12)', borderWidth: 2.5, pointRadius: 3, tension: .35, fill: true }] }, { plugins: { legend: { display: false }, title: { display: true, text: 'DP 문턱 KDA — 중간 중증도에서 시너지 최대', color: MUTE, font: { size: 11 } } }, scales: { x: { title: { display: true, text: 'DP 협동 문턱 KDA', color: MUTE }, ticks: { color: MUTE }, grid: { display: false } }, y: { ticks: { color: MUTE }, grid: { color: GRID } } } }); } // 불확실성 정량(UQ) — 보정된 신뢰구간 + 커버리지 before/after function renderUQ() { const u = uq; if (!u || !u.classes) return; const oc = u.overall_coverage || {}; const J = u.classes.JAK_inhibitor || {}; el('val-uq-headline').innerHTML = [ [Math.round((oc.mean_only_empirical || 0) * 100) + '%', '단순구간(과신)'], [Math.round((oc.population_empirical || 0) * 100) + '%', '계층적(보정됨)'], [Math.round((oc.nominal || .9) * 100) + '%', '명목 목표'], [(J.lag_mean != null ? J.lag_mean.toFixed(2) : '–') + 'm', 'JAK lag 사후평균'], ].map(([v, l]) => `
${v}
${l}
`).join(''); const b = (J.post_band) || { months: [], median: [], lo: [], hi: [] }; mk('chart-val-uqband', 'line', { labels: b.months.map(m => m + 'm'), datasets: [ { label: '95% 상한', data: b.hi, borderColor: 'transparent', backgroundColor: 'rgba(31,93,82,.16)', pointRadius: 0, fill: '+1', tension: .3 }, { label: '5% 하한', data: b.lo, borderColor: 'transparent', backgroundColor: 'rgba(31,93,82,.16)', pointRadius: 0, fill: false, tension: .3 }, { label: '중앙(모집단 평균)', data: b.median, borderColor: TEAL, borderWidth: 3, pointRadius: 0, tension: .3 }, ] }, { plugins: { legend: { labels: { color: INK, boxWidth: 12, font: { size: 10 }, filter: it => !it.text.includes('하한') } }, title: { display: true, text: 'JAK 회복 타이밍 — 90% 신뢰띠(보정됨)', color: MUTE, font: { size: 11 } } }, scales: { y: { min: 0, max: 1.1, title: { display: true, text: '정규화 회복도', color: MUTE }, ticks: { color: MUTE }, grid: { color: GRID } }, x: { ticks: { color: MUTE, maxTicksLimit: 8 }, grid: { display: false } } } }); const cls = ['JAK_inhibitor', 'finasteride'].filter(c => u.classes[c] && u.classes[c].loto_coverage); const nm = { JAK_inhibitor: 'JAK', finasteride: '피나스테리드' }; mk('chart-val-coverage', 'bar', { labels: cls.map(c => nm[c] || c), datasets: [ { type: 'line', label: '명목 90%', data: cls.map(() => 90), borderColor: INK, borderDash: [5, 4], borderWidth: 1.5, pointRadius: 0 }, { label: '단순(과신)', data: cls.map(c => Math.round(u.classes[c].loto_coverage.mean_only_empirical * 100)), backgroundColor: BAD, borderRadius: 3 }, { label: '계층적(보정)', data: cls.map(c => Math.round(u.classes[c].loto_coverage.population_empirical * 100)), backgroundColor: GOOD, borderRadius: 3 }, ] }, { plugins: { legend: { labels: { color: INK, boxWidth: 12, font: { size: 10 } } }, title: { display: true, text: '커버리지 검정: 과신 → 보정', color: MUTE, font: { size: 11 } } }, scales: { y: { min: 0, max: 108, title: { display: true, text: '경험적 커버리지 %', color: MUTE }, ticks: { color: MUTE, callback: v => v + '%' }, grid: { color: GRID } }, x: { ticks: { color: INK, font: { size: 11 } }, grid: { display: false } } } }); } // 데이터 지형 function renderLandscape() { const L = data.landscape; if (!L) return; const h = L.headline; el('val-headline').innerHTML = [ [h.gb + ' GB', '수집 데이터'], [h.files, '파일'], [h.datasets_downloaded + '+', '데이터셋'], [h.waves, '탐색 웨이브'], [h.agents, '에이전트'], ].map(([v, l]) => `
${v}
${l}
`).join(''); const m = L.by_modality; mk('chart-val-modality', 'bar', { labels: m.map(x => x.mod), datasets: [{ label: '다운로드', data: m.map(x => x.dl), backgroundColor: TEAL, borderRadius: 3 }, { label: '기록·게이트', data: m.map(x => x.rec), backgroundColor: '#cdbf9f', borderRadius: 3 }] }, { plugins: { legend: { labels: { color: INK, boxWidth: 12, font: { size: 10 } } } }, scales: { x: { stacked: true, ticks: { color: INK, font: { size: 10 } }, grid: { display: false } }, y: { stacked: true, ticks: { color: MUTE }, grid: { color: GRID } } } }); const d = L.by_disease; mk('chart-val-disease', 'bar', { labels: d.map(x => x.d), datasets: [{ data: d.map(x => x.n), backgroundColor: VERM, borderRadius: 3 }] }, { indexAxis: 'y', plugins: { legend: { display: false } }, scales: { x: { ticks: { color: MUTE }, grid: { color: GRID } }, y: { ticks: { color: INK, font: { size: 11 } }, grid: { display: false } } } }); } // 임상 시간축 회복곡선 function renderTiming() { const t = data.timing; if (!t) return; const palette = { '피나스테리드': '#2f63c8', '두타스테리드': VERM, '미녹시딜': WARN, 'JAK억제제': TEAL }; const datasets = Object.keys(t.curves).map(k => ({ label: k, data: t.curves[k], borderColor: palette[k] || INK, backgroundColor: 'transparent', borderWidth: 2.5, pointRadius: 2, tension: .3, })); mk('chart-val-timing', 'line', { labels: t.months.map(m => m < 1 ? m * 4 + '주' : m + 'm'), datasets }, { plugins: { legend: { labels: { color: INK, boxWidth: 14, font: { size: 11 } } } }, scales: { y: { min: -10, max: 105, title: { display: true, text: '회복도 %', color: MUTE }, ticks: { color: MUTE, callback: v => v + '%' }, grid: { color: GRID } }, x: { ticks: { color: MUTE, maxTicksLimit: 9 }, grid: { display: false } } } }); } // Halloy 벤치마크 function renderBenchmark() { const b = data.benchmark; if (!b) return; mk('chart-val-bench', 'bar', { labels: b.labels, datasets: [{ label: 'Halloy 자동자', data: b.Halloy, backgroundColor: MUTE, borderRadius: 3 }, { label: '우리 트윈', data: b['트윈'], backgroundColor: VERM, borderRadius: 3 }] }, { plugins: { legend: { labels: { color: INK, boxWidth: 12 } } }, scales: { y: { ticks: { color: MUTE, callback: v => v + '%' }, grid: { color: GRID } }, x: { ticks: { color: INK, font: { size: 11 } }, grid: { display: false } } } }); } // 1) 다층 요약 카드 function renderSummary() { el('val-summary').innerHTML = (data.summary || []).map(c => { const rows = c.rows.map(r => { const badge = r.strong ? '확증' : '비재현'; const pv = r.p == null ? '' : `p=${fmtP(r.p)}`; return `
${r.design}
${r.metric} ${pv}
${badge}
`; }).join(''); return `
${c.claim}
${rows}
`; }).join(''); } // 2) 분자 검증 유의도 -log10(p) function renderMolecular() { const m = data.molecular || []; const labels = m.map(x => x.t), vals = m.map(x => Math.min(60, -Math.log10(x.p))); const cols = m.map(x => x.dz === 'AA' ? VERM : TEAL); mk('chart-val-mol', 'bar', { labels, datasets: [{ data: vals, backgroundColor: cols, borderRadius: 4 }] }, { indexAxis: 'y', plugins: { legend: { display: false }, tooltip: { callbacks: { label: c => '−log₁₀p = ' + c.parsed.x.toFixed(1) } } }, scales: { x: { title: { display: true, text: '−log₁₀(p) (유의 ≈ 1.3↑)', color: MUTE }, ticks: { color: MUTE }, grid: { color: GRID } }, y: { ticks: { color: INK, font: { size: 11 } }, grid: { display: false } } } }); } // 3) AGA DP Wnt 역전 (grouped bar) function renderAgaDp() { const a = data.aga_dp; if (!a) return; const genes = Object.keys(a.genes), conds = a.conds; const colByCond = { 'Con': MUTE, 'TP': BAD, 'TP+Ab': TEAL }; const datasets = conds.map(cn => ({ label: cn === 'Con' ? '정상' : cn === 'TP' ? 'AGA(TP)' : '치료(TP+Ab)', data: genes.map(g => a.genes[g][cn]), backgroundColor: colByCond[cn] || INK, borderRadius: 3, })); mk('chart-val-agadp', 'bar', { labels: genes, datasets }, { plugins: { legend: { labels: { color: INK, boxWidth: 12, font: { size: 11 } } } }, scales: { y: { title: { display: true, text: 'DP세포 발현(log)', color: MUTE }, ticks: { color: MUTE }, grid: { color: GRID } }, x: { ticks: { color: INK }, grid: { display: false } } } }); } // 4) JAK억제제별 염증신호 function renderJak() { const j = data.jak_drugs; if (!j) return; const labels = Object.keys(j), vals = labels.map(k => j[k]); const cols = vals.map(v => v > 0.3 ? BAD : GOOD); // 높으면 염증 잔존(빨강), 낮으면 억제(녹색) mk('chart-val-jak', 'bar', { labels, datasets: [{ data: vals, backgroundColor: cols, borderRadius: 4 }] }, { plugins: { legend: { display: false }, tooltip: { callbacks: { label: c => 'IFN sig = ' + c.parsed.y.toFixed(2) } } }, scales: { y: { title: { display: true, text: '염증/IFN 시그니처', color: MUTE }, ticks: { color: MUTE }, grid: { color: GRID } }, x: { ticks: { color: INK }, grid: { display: false } } } }); } // 5) AA 단일세포 비재현 (코호트별 T세포 평균%) function renderAaSc() { const s = data.aa_sc; if (!s) return; const mean = a => a && a.length ? a.reduce((x, y) => x + y, 0) / a.length : 0; const cohorts = Object.keys(s); mk('chart-val-aasc', 'bar', { labels: cohorts, datasets: [ { label: 'AA', data: cohorts.map(c => +mean(s[c].AA).toFixed(1)), backgroundColor: VERM, borderRadius: 3 }, { label: '정상', data: cohorts.map(c => +mean(s[c].control).toFixed(1)), backgroundColor: MUTE, borderRadius: 3 }, ] }, { plugins: { legend: { labels: { color: INK, boxWidth: 12 } }, title: { display: true, text: '두 코호트 T세포 비율 불일치 (포획 편향)', color: MUTE, font: { size: 11 } } }, scales: { y: { title: { display: true, text: 'T세포 %', color: MUTE }, ticks: { color: MUTE, callback: v => v + '%' }, grid: { color: GRID } }, x: { ticks: { color: INK, font: { size: 10 } }, grid: { display: false } } } }); } // 6) GWAS 커버리지 도넛 function renderGwas() { const g = data.gwas; if (!g) return; const donut = (id, cov, label) => mk(id, 'doughnut', { labels: ['보유', '누락'], datasets: [{ data: [Math.round(cov * 16), 16 - Math.round(cov * 16)], backgroundColor: [TEAL, '#e6ddcb'], borderColor: '#fbf9f4', borderWidth: 2 }] }, { cutout: '62%', plugins: { legend: { display: false }, tooltip: { callbacks: { label: c => c.label + ': ' + c.parsed } } } }); donut('chart-val-gwasAGA', g.AGA_cov); donut('chart-val-gwasAA', g.AA_cov); const aga = el('chart-val-gwasAGA'); if (aga) aga.parentElement.querySelector('.val-donut-lab').innerHTML = 'AGA
' + Math.round(g.AGA_cov * 100) + '%'; const aa = el('chart-val-gwasAA'); if (aa) aa.parentElement.querySelector('.val-donut-lab').innerHTML = 'AA
' + Math.round(g.AA_cov * 100) + '%'; } // 7) 후보 × 구조 × STRING 표 function renderCandidates() { const c = data.candidates || []; const order = { COHERES: 0, weak: 1, isolated: 2, 'n/a': 3 }; const rows = c.slice().sort((a, b) => (order[a.cohesion] - order[b.cohesion]) || (b.plddt || 0) - (a.plddt || 0)).map(r => { const pb = r.plddt == null ? '—' : r.plddt.toFixed(0); const pcls = r.plddt >= 70 ? 'good' : r.plddt >= 50 ? 'warn' : 'bad'; const coh = { COHERES: '응집', weak: '', isolated: '미응집', 'n/a': '—' }[r.cohesion] || '—'; return `${r.gene}${r.dz}${r.axis} ${pb}${r.edges_hi != null ? r.edges_hi : '–'}${coh}`; }).join(''); el('val-candidates').innerHTML = `${rows}
유전자질환배정 축AlphaFold pLDDTSTRING 고신뢰 엣지축 응집

구조 19/19 보유 · STRING 응집 ${c.filter(x => x.cohesion === 'COHERES').length}/19. GWAS(유전학)+STRING(네트워크)+AlphaFold(구조)가 한 방향으로 모이는 후보가 신뢰도 높음.

`; } function fmtP(p) { return p < 1e-4 ? p.toExponential(0) : p.toPrecision(2); } function mk(id, type, d, opts) { const ctx = el(id); if (!ctx) return; if (charts[id]) charts[id].destroy(); charts[id] = new Chart(ctx, { type, data: d, options: Object.assign({ responsive: true, maintainAspectRatio: false }, opts) }); } window.ValidationTab = { init }; })();