| |
| |
| |
| |
|
|
| import * as d3 from 'd3'; |
| import type { AnalyzeResponse, FrontendAnalyzeResult, FrontendToken } from '../../shared/api/GLTR_API'; |
| import type { GLTR_Text_Box } from '../../shared/vis/GLTR_Text_Box'; |
| import type { HighlightController } from '../../shared/controllers/highlightController'; |
| import type { TextInputController } from '../../shared/controllers/textInputController'; |
| import type { Histogram } from '../../shared/vis/Histogram'; |
| import type { ScatterPlot } from '../../shared/vis/ScatterPlot'; |
| import type { AppStateManager } from './appStateManager'; |
| import { |
| cloneFrontendToken, |
| mergeTokensForRendering, |
| createRawSnapshot |
| } from '../../shared/cross/tokenUtils'; |
| import { getTokenRawScore, mergeTokenSpansFullyForRendering, normalizeTokenScores } from '../../shared/cross/semanticUtils'; |
| import { |
| validateTokenConsistency, |
| validateTokenProbabilities, |
| validateTokenPredictions |
| } from '../../shared/cross/dataValidation'; |
| import { |
| calculateTextStats, |
| calculateMergedTokenSurprisals, |
| computeAverage, |
| computeP90, |
| type TextStats |
| } from '../../shared/cross/textStatistics'; |
| import { |
| getTokenSurprisalHistogramConfig, |
| getSurprisalProgressConfig, |
| getMatchScoreProgressConfig, |
| getRawScoreNormedHistogramConfig |
| } from "./visualizationConfigs"; |
| import { getSemanticSimilarityColor, HISTOGRAM_MIN_ALPHA } from '../../shared/cross/SurprisalColorConfig'; |
| import { showAlertDialog } from '../../shared/ui/dialog'; |
| import { tr } from '../../shared/lang/i18n-lite'; |
| import { computeExpectedCounts } from './lognormalFit'; |
| import { findSignalThresholdWithLog, type signalFitResult, type SignalThresholdBin } from './signalThresholdDetector'; |
| import { getSemanticAnalysisEnabled } from '../../shared/cross/semanticAnalysisManager'; |
| import { getDigitsMergeEnabled } from '../../shared/cross/digitsMergeManager'; |
| import { getSemanticMatchThreshold } from '../../shared/cross/semanticThresholdManager'; |
| import { applySemanticDebugInfoPanel } from '../../shared/prediction_attribution/core/semanticDebugInfo'; |
|
|
| |
| |
| |
| |
| function signalProbFromBins(scores: number[], bins: SignalThresholdBin[]): number[] { |
| if (scores.length === 0 || bins.length === 0) return []; |
| const tauLefts = bins.map((b) => b.tauLeft); |
| return scores.map((s) => { |
| const i = Math.max(0, Math.min(bins.length - 1, d3.bisectRight(tauLefts, s) - 1)); |
| const b = bins[i]!; |
| if (s < b.tauLeft || s >= b.tauRight) return 0; |
| return b.obsInBin > 0 ? Math.max(0, Math.min(1, (b.obsInBin - b.expInBin) / b.obsInBin)) : 0; |
| }); |
| } |
|
|
| |
| |
| |
| export interface VisualizationDependencies { |
| lmf: GLTR_Text_Box; |
| highlightController: HighlightController; |
| textInputController: TextInputController; |
| stats_frac: Histogram; |
| stats_raw_score_normed: Histogram; |
| stats_surprisal_progress: ScatterPlot; |
| stats_match_score_progress: ScatterPlot; |
| appStateManager: AppStateManager; |
| surprisalColorScale: d3.ScaleSequential<string>; |
| |
| syncModeChrome?: (semanticEnabled: boolean) => void; |
| } |
|
|
| |
| export interface SemanticData { |
| text: string; |
| model?: string; |
| |
| semanticTokenSpansFromApi?: Array<{ |
| offset: [number, number]; |
| raw: string; |
| score: number; |
| rawScore?: number; |
| }>; |
| |
| |
| |
| |
| token_attention: Array<{ |
| offset: [number, number]; |
| raw: string; |
| score: number; |
| rawScore?: number; |
| }>; |
| |
| signalFitResult?: signalFitResult | null; |
| |
| chunkInfos?: Array<{ startOffset: number; endOffset: number; chunkIndex: number; chunkMatchDegree: number; thresholdResult?: signalFitResult }>; |
| |
| full_match_degree?: number; |
| } |
|
|
| |
| function hasSemanticData(data: { token_attention?: unknown[]; chunkInfos?: unknown[] } | null | undefined): boolean { |
| return (data?.token_attention?.length ?? 0) > 0 || (data?.chunkInfos?.length ?? 0) > 0; |
| } |
|
|
| |
| |
| |
| |
| export interface CurrentDataState { |
| |
| infoDensityData: AnalyzeResponse | null; |
| |
| semanticData: SemanticData | null; |
| rawApiResponse: AnalyzeResponse | null; |
| currentSurprisals: number[] | null; |
| currentTokenAvg: number | null; |
| currentTokenP90: number | null; |
| currentTotalSurprisal: number | null; |
| } |
|
|
| |
| |
| |
| export class VisualizationUpdater { |
| private deps: VisualizationDependencies; |
| private currentState: CurrentDataState; |
|
|
| constructor(deps: VisualizationDependencies) { |
| this.deps = deps; |
| this.currentState = { |
| infoDensityData: null, |
| semanticData: null, |
| rawApiResponse: null, |
| currentSurprisals: null, |
| currentTokenAvg: null, |
| currentTokenP90: null, |
| currentTotalSurprisal: null |
| }; |
| } |
|
|
| |
| |
| |
| getCurrentState(): Readonly<CurrentDataState> { |
| return { ...this.currentState }; |
| } |
|
|
| |
| |
| |
| getRawApiResponse(): AnalyzeResponse | null { |
| return this.currentState.rawApiResponse; |
| } |
|
|
| |
| |
| |
| getCurrentData(): AnalyzeResponse | null { |
| const display = this.computeDisplayResult(); |
| if (!display) return null; |
| return { request: { text: display.originalText }, result: display }; |
| } |
|
|
| |
| getMatchedChunks(): Array<{ startOffset: number; endOffset: number; chunkIndex: number; chunkMatchDegree: number }> { |
| const chunkInfos = this.currentState.semanticData?.chunkInfos; |
| if (!chunkInfos?.length) return []; |
| const threshold = getSemanticMatchThreshold(); |
| return chunkInfos.filter((c) => c.chunkMatchDegree >= threshold); |
| } |
|
|
| |
| peekSemanticMatchDegree(): number | null { |
| const sem = this.currentState.semanticData; |
| if (!sem || !hasSemanticData(sem)) return null; |
| if (sem.chunkInfos?.length) { |
| return Math.max(...sem.chunkInfos.map((c) => c.chunkMatchDegree)); |
| } |
| return typeof sem.full_match_degree === 'number' ? sem.full_match_degree : null; |
| } |
|
|
| |
| |
| |
| getCurrentSurprisals(): number[] | null { |
| return this.currentState.currentSurprisals; |
| } |
|
|
| |
| |
| |
| private updateTextMetrics(stats: TextStats | null, modelName?: string | null | undefined): void { |
| this.deps.textInputController.updateTextMetrics(stats, modelName); |
| } |
|
|
| |
| |
| |
| private clearHighlights(options?: { preserveChunkInterval?: boolean }): void { |
| this.deps.highlightController.clearHighlights(options); |
| } |
|
|
| |
| |
| |
| private computeDisplayResult(): (FrontendAnalyzeResult & { |
| rawScoresNormed?: number[]; |
| tokenRawScores?: number[]; |
| chunkInfos?: SemanticData['chunkInfos']; |
| }) | null { |
| const info = this.currentState.infoDensityData; |
| const sem = this.currentState.semanticData; |
| const infoResult = info?.result as FrontendAnalyzeResult | undefined; |
|
|
| if (getSemanticAnalysisEnabled()) { |
| if (sem && hasSemanticData(sem)) { |
| return this.buildSemanticOnlyResult( |
| { model: sem.model }, |
| sem.token_attention, |
| sem.text, |
| sem.chunkInfos |
| ); |
| } |
| return null; |
| } |
| if (infoResult) return { ...infoResult }; |
| return null; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| public updateHistogramVisibilityForPending(mode: 'infoDensity' | 'semantic', text: string, willBeChunked?: boolean): void { |
| const tokenHistogramItem = document.getElementById('token_histogram_item'); |
| const surprisalProgressItem = document.getElementById('surprisal_progress_item'); |
| const rawScoreNormedItem = document.getElementById('raw_score_normed_histogram_item'); |
| const matchScoreProgressItem = document.getElementById('match_score_progress_item'); |
|
|
| const showInfoDensity = mode === 'infoDensity'; |
| const showSemantic = mode === 'semantic'; |
|
|
| if (tokenHistogramItem) tokenHistogramItem.style.display = showInfoDensity ? '' : 'none'; |
| if (surprisalProgressItem) surprisalProgressItem.style.display = showInfoDensity ? '' : 'none'; |
| |
| const showRawScoreHistogram = showSemantic && !willBeChunked; |
| if (rawScoreNormedItem) rawScoreNormedItem.style.display = showRawScoreHistogram ? '' : 'none'; |
| |
| if (matchScoreProgressItem) matchScoreProgressItem.style.display = showSemantic && !!willBeChunked ? '' : 'none'; |
|
|
| |
| if (showInfoDensity && mode === 'infoDensity') { |
| const tokenConfig = getTokenSurprisalHistogramConfig(); |
| this.deps.stats_frac.update({ ...tokenConfig, data: [], colorScale: () => 'transparent' }); |
| const tokenTitle = document.getElementById('token_histogram_title'); |
| if (tokenTitle) tokenTitle.textContent = tokenConfig.label; |
| const progressConfig = getSurprisalProgressConfig(); |
| this.deps.stats_surprisal_progress.update({ ...progressConfig, data: [] }); |
| const progressTitle = document.getElementById('surprisal_progress_title'); |
| if (progressTitle && progressConfig.label) progressTitle.textContent = progressConfig.label; |
| } |
| if (showRawScoreHistogram && mode === 'semantic') { |
| const rawScoreNormedConfig = getRawScoreNormedHistogramConfig(); |
| this.deps.stats_raw_score_normed.update({ ...rawScoreNormedConfig, data: [], colorScale: () => 'transparent' }); |
| const titleEl = document.getElementById('raw_score_normed_histogram_title'); |
| if (titleEl) titleEl.textContent = rawScoreNormedConfig.label; |
| } |
| if (showSemantic && mode === 'semantic' && willBeChunked) { |
| const matchScoreProgressConfig = getMatchScoreProgressConfig(); |
| const docLen = text.length; |
| this.deps.stats_match_score_progress.update({ |
| ...matchScoreProgressConfig, |
| data: [], |
| showMovingAverage: false, |
| chunkLines: [], |
| thresholdLine: getSemanticMatchThreshold(), |
| extent: { x: docLen > 0 ? [0, docLen] : undefined, y: [0, 1] } |
| }); |
| const matchScoreTitleEl = document.getElementById('match_score_progress_title'); |
| if (matchScoreTitleEl && matchScoreProgressConfig.label) matchScoreTitleEl.textContent = matchScoreProgressConfig.label; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| private updateVisualizationInternal(skipLmfUpdate = false): void { |
| const hasInfoDensity = !!this.currentState.infoDensityData; |
| const displayResult = this.computeDisplayResult(); |
| const sem = this.currentState.semanticData; |
| const showInfoDensityCharts = hasInfoDensity && !getSemanticAnalysisEnabled(); |
|
|
| const tokenHistogramItem = document.getElementById('token_histogram_item'); |
| const surprisalProgressItem = document.getElementById('surprisal_progress_item'); |
| const rawScoreNormedItem = document.getElementById('raw_score_normed_histogram_item'); |
|
|
| if (showInfoDensityCharts) { |
| const currentSurprisals = this.currentState.currentSurprisals; |
| const currentTokenAvg = this.currentState.currentTokenAvg; |
| const currentTokenP90 = this.currentState.currentTokenP90; |
| if (currentSurprisals) { |
| const tokenHistogramConfig = getTokenSurprisalHistogramConfig(); |
| this.deps.stats_frac.update({ |
| ...tokenHistogramConfig, |
| data: currentSurprisals, |
| colorScale: this.deps.surprisalColorScale, |
| averageValue: currentTokenAvg ?? undefined, |
| p90Value: currentTokenP90 ?? undefined, |
| p90Label: tokenHistogramConfig.averageLabel, |
| }); |
| const titleElement = document.getElementById('token_histogram_title'); |
| if (titleElement) titleElement.textContent = tokenHistogramConfig.label; |
| } |
| if (currentSurprisals && currentSurprisals.length > 0) { |
| const surprisalProgressConfig = getSurprisalProgressConfig(); |
| this.deps.stats_surprisal_progress.update({ |
| ...surprisalProgressConfig, |
| data: currentSurprisals, |
| }); |
| const surprisalProgressTitleElement = document.getElementById('surprisal_progress_title'); |
| if (surprisalProgressTitleElement && surprisalProgressConfig.label) { |
| surprisalProgressTitleElement.textContent = surprisalProgressConfig.label; |
| } |
| } |
| if (tokenHistogramItem) tokenHistogramItem.style.display = ''; |
| if (surprisalProgressItem) surprisalProgressItem.style.display = ''; |
| } else { |
| if (tokenHistogramItem) tokenHistogramItem.style.display = 'none'; |
| if (surprisalProgressItem) surprisalProgressItem.style.display = 'none'; |
| } |
|
|
| const rawScoresNormed = displayResult?.rawScoresNormed; |
| const validRawScoresNormed = rawScoresNormed?.filter((s) => typeof s === 'number' && isFinite(s)); |
| const signalFitResult = sem?.signalFitResult ?? null; |
| const chunkInfos = sem?.chunkInfos; |
| const isChunkMode = (chunkInfos?.length ?? 0) > 0; |
| const chunksWithThreshold = chunkInfos?.filter((c) => c.thresholdResult != null) ?? []; |
| const usePerChunkThreshold = chunksWithThreshold.length > 0; |
| const thresholdByChunk = usePerChunkThreshold |
| ? new Map(chunksWithThreshold.map((c) => [c.chunkIndex, c.thresholdResult!])) |
| : null; |
| if (validRawScoresNormed && validRawScoresNormed.length > 0) { |
| const rawScoreNormedConfig = getRawScoreNormedHistogramConfig(); |
| const colorScale = (v: number) => getSemanticSimilarityColor(v, HISTOGRAM_MIN_ALPHA); |
| const thresholdForHistogram = usePerChunkThreshold && chunksWithThreshold.length > 0 |
| ? chunksWithThreshold[0]!.thresholdResult! |
| : signalFitResult; |
| |
| const fitResult = validRawScoresNormed.length >= 2 && thresholdForHistogram != null && thresholdForHistogram.confidence > 0 |
| ? { |
| mu: thresholdForHistogram.mu, |
| sigma: thresholdForHistogram.sigma, |
| expectedCounts: computeExpectedCounts( |
| thresholdForHistogram.mu, |
| thresholdForHistogram.sigma, |
| rawScoreNormedConfig.extent as [number, number], |
| rawScoreNormedConfig.no_bins, |
| validRawScoresNormed.length |
| ), |
| } |
| : null; |
| const signalProbs = thresholdForHistogram != null |
| ? signalProbFromBins(validRawScoresNormed, thresholdForHistogram.bins) |
| : []; |
| |
| |
| |
| |
| |
| |
| const rawScoresNormedFull = displayResult!.rawScoresNormed ?? []; |
| const bpeBpeMergedTokens = displayResult?.bpeBpeMergedTokens ?? []; |
|
|
| const getChunkForToken = (tokenIndex: number) => { |
| const token = bpeBpeMergedTokens[tokenIndex]; |
| if (!token || !isChunkMode) return null; |
| const offset = token.offset[0]; |
| return chunkInfos!.find((c) => c.startOffset <= offset && offset < c.endOffset) ?? null; |
| }; |
|
|
| const getThresholdForToken = (i: number): number => { |
| const chunk = getChunkForToken(i); |
| if (chunk && thresholdByChunk != null) { |
| const tr = thresholdByChunk.get(chunk.chunkIndex); |
| if (tr) return tr.threshold; |
| } |
| return signalFitResult?.threshold ?? 0; |
| }; |
|
|
| const getMatchDegreeForToken = (i: number): number => { |
| const chunk = getChunkForToken(i); |
| if (chunk) return chunk.chunkMatchDegree; |
| return sem?.full_match_degree ?? 1; |
| }; |
|
|
| const hasThreshold = signalFitResult != null || thresholdByChunk != null; |
| const pPwValues = hasThreshold |
| ? rawScoresNormedFull.map((s, i) => { |
| const threshold = getThresholdForToken(i); |
| const isAboveThreshold = typeof s === 'number' && isFinite(s) && s > threshold; |
| return isAboveThreshold ? 1 : 0; |
| }) |
| : []; |
| const pwScores = hasThreshold |
| ? rawScoresNormedFull.map((s, i) => { |
| const threshold = getThresholdForToken(i); |
| const isAboveThreshold = typeof s === 'number' && isFinite(s) && s > threshold; |
| const baseScore = isAboveThreshold ? s : 0; |
| const matchDegree = getMatchDegreeForToken(i); |
| return baseScore * matchDegree; |
| }) |
| : []; |
|
|
| const colorSourceEl = document.getElementById('semantic_color_source_select') as HTMLSelectElement | null; |
| const colorSource = colorSourceEl?.value ?? 'pw_score'; |
| const scoresForColor = colorSource === 'signal_probability' ? pPwValues |
| : colorSource === 'pw_score' ? pwScores |
| : (displayResult!.rawScoresNormed ?? []); |
|
|
| |
| const resultWithExt = hasThreshold |
| ? { ...displayResult, signalProbs, pPwValues, pwScores } |
| : displayResult!; |
| this.deps.highlightController.updateCurrentData( |
| hasThreshold |
| ? { result: resultWithExt, signalProbs, pPwValues, pwScores } |
| : { result: resultWithExt } |
| ); |
| if (!skipLmfUpdate) { |
| this.deps.lmf.update({ |
| ...resultWithExt, |
| ...(hasThreshold ? { pwScores } : {}), |
| colorScores: scoresForColor, |
| } as FrontendAnalyzeResult & { pPwValues?: number[]; pwScores?: number[]; colorScores?: number[] }); |
| } |
|
|
| |
| if (!isChunkMode) { |
| const probCurveData = signalProbs.length > 0 |
| ? (() => { |
| const pairs = validRawScoresNormed.map((x, i) => ({ x, y: signalProbs[i]! })).sort((a, b) => a.x - b.x); |
| return { x: pairs.map(p => p.x), y: pairs.map(p => p.y) }; |
| })() |
| : undefined; |
| const signalThresholdPercentile = thresholdForHistogram != null && validRawScoresNormed.length > 0 |
| ? Math.round((validRawScoresNormed.filter((s) => s < thresholdForHistogram.threshold).length / validRawScoresNormed.length) * 100) |
| : undefined; |
| this.deps.stats_raw_score_normed.update({ |
| ...rawScoreNormedConfig, |
| data: validRawScoresNormed, |
| colorScale, |
| fitExpectedCounts: fitResult?.expectedCounts, |
| showProbCurve: true, |
| probCurveData: probCurveData?.x.length ? probCurveData : undefined, |
| signalThreshold: thresholdForHistogram?.threshold ?? undefined, |
| signalThresholdPercentile: signalThresholdPercentile ?? undefined, |
| }); |
| const titleEl = document.getElementById('raw_score_normed_histogram_title'); |
| if (titleEl) titleEl.textContent = rawScoreNormedConfig.label; |
| if (rawScoreNormedItem) rawScoreNormedItem.style.display = ''; |
| } else { |
| if (rawScoreNormedItem) rawScoreNormedItem.style.display = 'none'; |
| } |
| |
| if (isChunkMode) { |
| const matchScoreProgressConfig = getMatchScoreProgressConfig(); |
| const docLen = (displayResult?.originalText ?? '').length; |
| const chunkLines = chunkInfos?.length |
| ? chunkInfos.map((c) => ({ x0: c.startOffset, x1: c.endOffset, y: c.chunkMatchDegree })) |
| : []; |
| const thresholdLine = getSemanticMatchThreshold(); |
| this.deps.stats_match_score_progress.update({ |
| ...matchScoreProgressConfig, |
| data: [], |
| showMovingAverage: false, |
| chunkLines, |
| thresholdLine, |
| chunkInteraction: true, |
| extent: { x: docLen > 0 ? [0, docLen] : undefined, y: [0, 1] } |
| }); |
| const matchScoreTitleEl = document.getElementById('match_score_progress_title'); |
| if (matchScoreTitleEl && matchScoreProgressConfig.label) matchScoreTitleEl.textContent = matchScoreProgressConfig.label; |
| const matchScoreProgressItem = document.getElementById('match_score_progress_item'); |
| if (matchScoreProgressItem) matchScoreProgressItem.style.display = ''; |
| } else { |
| const matchScoreProgressItem = document.getElementById('match_score_progress_item'); |
| if (matchScoreProgressItem) matchScoreProgressItem.style.display = 'none'; |
| } |
| } else { |
| const needLmfUpdate = !!displayResult && (hasInfoDensity || !!validRawScoresNormed?.length || hasSemanticData(sem)); |
| if (displayResult) this.deps.highlightController.updateCurrentData({ result: displayResult }); |
| if (needLmfUpdate && !skipLmfUpdate) { |
| this.deps.lmf.update(displayResult!); |
| } |
| |
| if (getSemanticAnalysisEnabled() && !isChunkMode) { |
| const rawScoreNormedConfig = getRawScoreNormedHistogramConfig(); |
| this.deps.stats_raw_score_normed.update({ ...rawScoreNormedConfig, data: [], colorScale: () => 'transparent' }); |
| const titleEl = document.getElementById('raw_score_normed_histogram_title'); |
| if (titleEl) titleEl.textContent = rawScoreNormedConfig.label; |
| if (rawScoreNormedItem) rawScoreNormedItem.style.display = ''; |
| } else { |
| if (rawScoreNormedItem) rawScoreNormedItem.style.display = 'none'; |
| } |
| |
| if (getSemanticAnalysisEnabled() && isChunkMode) { |
| const matchScoreProgressConfig = getMatchScoreProgressConfig(); |
| const docLen = (displayResult?.originalText ?? '').length; |
| const chunkLines = chunkInfos?.length |
| ? chunkInfos.map((c) => ({ x0: c.startOffset, x1: c.endOffset, y: c.chunkMatchDegree })) |
| : []; |
| const thresholdLine = getSemanticMatchThreshold(); |
| this.deps.stats_match_score_progress.update({ |
| ...matchScoreProgressConfig, |
| data: [], |
| showMovingAverage: false, |
| chunkLines, |
| thresholdLine, |
| chunkInteraction: true, |
| extent: { x: docLen > 0 ? [0, docLen] : undefined, y: [0, 1] } |
| }); |
| const matchScoreTitleEl = document.getElementById('match_score_progress_title'); |
| if (matchScoreTitleEl && matchScoreProgressConfig.label) matchScoreTitleEl.textContent = matchScoreProgressConfig.label; |
| const matchScoreProgressItem = document.getElementById('match_score_progress_item'); |
| if (matchScoreProgressItem) matchScoreProgressItem.style.display = ''; |
| } else { |
| const matchScoreProgressItem = document.getElementById('match_score_progress_item'); |
| if (matchScoreProgressItem) matchScoreProgressItem.style.display = 'none'; |
| } |
| } |
| } |
|
|
| |
| public rerenderHistograms(): void { |
| this.updateVisualizationInternal(false); |
| } |
|
|
| |
| public updateSemanticColorSource(): void { |
| const cd = this.deps.highlightController.getCurrentData(); |
| const r = cd?.result as (FrontendAnalyzeResult & { rawScoresNormed?: number[]; pPwValues?: number[]; pwScores?: number[] }) | undefined; |
| if (!r?.rawScoresNormed?.length) return; |
| const el = document.getElementById('semantic_color_source_select') as HTMLSelectElement | null; |
| const v = el?.value ?? 'pw_score'; |
| const pPwValues = cd!.pPwValues ?? r.pPwValues; |
| const pwScores = cd!.pwScores ?? r.pwScores; |
| const scoresForColor = v === 'signal_probability' ? (pPwValues ?? []) |
| : v === 'pw_score' ? (pwScores ?? []) |
| : r.rawScoresNormed; |
| this.deps.lmf.update({ ...r, pPwValues, pwScores, colorScores: scoresForColor } as FrontendAnalyzeResult & { pPwValues?: number[]; pwScores?: number[]; colorScores?: number[] }); |
| } |
|
|
| |
| public rerenderOnThemeChange(): void { |
| requestAnimationFrame(() => requestAnimationFrame(() => { |
| this.updateVisualizationInternal(true); |
| this.deps.lmf.reRenderCurrent(); |
| })); |
| } |
|
|
| |
| |
| |
| public clearDataOnTextChange(): void { |
| this.currentState.infoDensityData = null; |
| this.currentState.semanticData = null; |
| this.currentState.rawApiResponse = null; |
| this.currentState.currentSurprisals = null; |
| this.currentState.currentTokenAvg = null; |
| this.currentState.currentTokenP90 = null; |
| this.currentState.currentTotalSurprisal = null; |
| this.deps.highlightController.updateCurrentData(null); |
| d3.select('#all_result').style('opacity', 0); |
| this.updateSemanticDebugInfo(); |
| } |
|
|
| |
| private resolvePlainTextFallback(): string { |
| const infoResult = this.currentState.infoDensityData?.result as FrontendAnalyzeResult | undefined; |
| return ( |
| this.currentState.semanticData?.text |
| ?? this.currentState.infoDensityData?.request?.text |
| ?? infoResult?.originalText |
| ?? this.deps.textInputController.getTextValue() |
| ?? '' |
| ); |
| } |
|
|
| |
| |
| |
| private refreshTextAfterDataChange( |
| displayResult: ReturnType<VisualizationUpdater['computeDisplayResult']>, |
| plainTextFallback: string |
| ): void { |
| this.deps.lmf.clearHighlight(); |
| if (displayResult) { |
| this.updateVisualizationInternal(false); |
| } else { |
| this.deps.highlightController.updateCurrentData(null); |
| this.deps.lmf.showPlainText(plainTextFallback); |
| this.updateVisualizationInternal(true); |
| } |
| } |
|
|
| |
| |
| |
| public clearSemanticState(): void { |
| const plainTextFallback = this.resolvePlainTextFallback(); |
| this.currentState.semanticData = null; |
| const rawScoreNormedItem = document.getElementById('raw_score_normed_histogram_item'); |
| if (rawScoreNormedItem) rawScoreNormedItem.style.display = 'none'; |
| const matchScoreProgressItem = document.getElementById('match_score_progress_item'); |
| if (matchScoreProgressItem) matchScoreProgressItem.style.display = 'none'; |
| this.updateSemanticDebugInfo(); |
| const displayResult = this.computeDisplayResult(); |
| this.refreshTextAfterDataChange(displayResult, plainTextFallback); |
| this.deps.appStateManager.updateButtonStates(); |
| this.deps.syncModeChrome?.(getSemanticAnalysisEnabled()); |
| } |
|
|
| |
| |
| |
| public applyDigitsMergeSetting(): void { |
| const digitMerge = getDigitsMergeEnabled(); |
| const info = this.currentState.infoDensityData; |
| if (info?.result) { |
| const fr = info.result as FrontendAnalyzeResult; |
| const text = info.request?.text ?? fr.originalText ?? ''; |
| if (fr.originalTokens?.length && text) { |
| const newMerged = mergeTokensForRendering(fr.originalTokens, text, { digitMerge }); |
| fr.bpeBpeMergedTokens = newMerged; |
| fr.bpe_strings = newMerged; |
| } |
| } |
| const sem = this.currentState.semanticData; |
| if (sem && !sem.chunkInfos?.length && sem.semanticTokenSpansFromApi?.length && sem.text) { |
| const mergedSpans = mergeTokenSpansFullyForRendering( |
| sem.semanticTokenSpansFromApi, |
| sem.text, |
| { digitMerge } |
| ); |
| const normalizedSpans = normalizeTokenScores(mergedSpans); |
| const computedSignalFit = findSignalThresholdWithLog(normalizedSpans); |
| sem.token_attention = normalizedSpans; |
| sem.signalFitResult = computedSignalFit ?? undefined; |
| } |
| const infoResult = this.currentState.infoDensityData?.result as FrontendAnalyzeResult | undefined; |
| const safeText = this.currentState.infoDensityData?.request?.text ?? infoResult?.originalText ?? ''; |
| if (infoResult?.bpeBpeMergedTokens?.length && safeText) { |
| const mergedSurprisals = calculateMergedTokenSurprisals(infoResult.bpeBpeMergedTokens); |
| this.currentState.currentSurprisals = mergedSurprisals; |
| this.currentState.currentTokenAvg = computeAverage(mergedSurprisals); |
| this.currentState.currentTokenP90 = computeP90(mergedSurprisals); |
| } |
| const displayResult = this.computeDisplayResult(); |
| this.refreshTextAfterDataChange(displayResult, this.resolvePlainTextFallback()); |
| this.deps.appStateManager.updateButtonStates(); |
| this.deps.syncModeChrome?.(getSemanticAnalysisEnabled()); |
| } |
|
|
| |
| |
| |
| |
| public syncSemanticUiFromConfig(): void { |
| const enabled = getSemanticAnalysisEnabled(); |
| const el = document.getElementById('semantic_analysis_section'); |
| if (el) el.style.display = enabled ? '' : 'none'; |
| this.deps.lmf.updateOptions({ semanticAnalysisMode: enabled }, false); |
| const displayResult = this.computeDisplayResult(); |
| this.refreshTextAfterDataChange(displayResult, this.resolvePlainTextFallback()); |
| this.deps.appStateManager.updateButtonStates(); |
| this.deps.syncModeChrome?.(enabled); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| updateFromRequest( |
| data: AnalyzeResponse, |
| disableAnimation: boolean = false, |
| options: { enableSave?: boolean } = {} |
| ): void { |
| const { enableSave = true } = options; |
|
|
| const abortDueToInvalidResponse = (message: string) => { |
| console.error(message); |
| showAlertDialog(tr('Error'), message); |
| this.deps.appStateManager.updateState({ hasValidData: false }); |
| this.syncSemanticUiFromConfig(); |
| }; |
|
|
| try { |
| |
| if (!disableAnimation) { |
| this.deps.lmf.updateOptions({ enableRenderAnimation: true }, false); |
| } |
| |
| this.deps.lmf.updateOptions({ |
| semanticAnalysisMode: getSemanticAnalysisEnabled(), |
| }, false); |
|
|
| d3.select('#all_result').style('opacity', 1).style('display', null); |
| this.deps.appStateManager.setIsAnalyzing(false); |
| this.deps.appStateManager.setGlobalLoading(false); |
|
|
| |
| this.deps.lmf.hideLoading(); |
|
|
| |
| if (!data || !data.result) { |
| console.error('Invalid data structure:', data); |
| throw new Error('Invalid API response structure'); |
| } |
|
|
| const result = data.result; |
|
|
| |
| if (!Array.isArray(result.bpe_strings) || result.bpe_strings.length === 0) { |
| abortDueToInvalidResponse(tr('Returned JSON missing valid bpe_strings array, processing cancelled.')); |
| return; |
| } |
| const predTopkError = validateTokenPredictions(result.bpe_strings as Array<{ pred_topk?: [string, number][] }>); |
| if (predTopkError) { |
| abortDueToInvalidResponse(predTopkError); |
| return; |
| } |
| const probabilityError = validateTokenProbabilities(result.bpe_strings as Array<{ real_topk?: [number, number] }>); |
| if (probabilityError) { |
| abortDueToInvalidResponse(probabilityError); |
| return; |
| } |
|
|
| const safeText = data.request.text; |
| const validationError = validateTokenConsistency(result.bpe_strings, safeText, { allowOverlap: true }); |
| if (validationError) { |
| abortDueToInvalidResponse(validationError); |
| return; |
| } |
|
|
| const rawSnapshot = createRawSnapshot(data); |
| const originalTokens = result.bpe_strings.map((token) => cloneFrontendToken(token as FrontendToken)); |
| const bpeBpeMergedTokens = mergeTokensForRendering(originalTokens, safeText, { |
| digitMerge: getDigitsMergeEnabled(), |
| }); |
| const mergedValidationError = validateTokenConsistency(bpeBpeMergedTokens, safeText); |
| if (mergedValidationError) { |
| abortDueToInvalidResponse(mergedValidationError); |
| return; |
| } |
|
|
| const enhancedResult: FrontendAnalyzeResult = { |
| ...result, |
| originalTokens, |
| bpeBpeMergedTokens, |
| bpe_strings: bpeBpeMergedTokens, |
| originalText: safeText, |
| }; |
| data.result = enhancedResult; |
|
|
| |
| this.currentState.infoDensityData = data; |
| this.currentState.rawApiResponse = rawSnapshot; |
| this.updateSemanticDebugInfo(); |
| const displayResult = this.computeDisplayResult(); |
| this.deps.highlightController.updateCurrentData(displayResult ? { result: displayResult } : null); |
|
|
| this.deps.lmf.clearHighlight(); |
| if (displayResult) this.deps.lmf.update(displayResult); |
|
|
| const textStats = calculateTextStats(enhancedResult, safeText); |
|
|
| const mergedSurprisals = calculateMergedTokenSurprisals(enhancedResult.bpeBpeMergedTokens); |
| |
| this.currentState.currentSurprisals = mergedSurprisals; |
| this.currentState.currentTokenAvg = computeAverage(mergedSurprisals); |
| this.currentState.currentTokenP90 = computeP90(mergedSurprisals); |
| this.currentState.currentTotalSurprisal = textStats.totalSurprisal; |
|
|
| |
| const resultModel = data.result.model; |
| this.updateTextMetrics(textStats, resultModel); |
|
|
| |
| if (!disableAnimation) { |
| |
| |
| const tokenCount = enhancedResult.bpe_strings.length; |
| const estimatedAnimationTime = 100 + Math.ceil(tokenCount / 50) * 100; |
| const delayTime = Math.max(2000, estimatedAnimationTime + 500); |
|
|
| setTimeout(() => { |
| this.deps.lmf.updateOptions({ enableRenderAnimation: false }, false); |
| }, delayTime); |
| } |
| } catch (error) { |
| console.error('Error updating visualization:', error); |
| this.deps.appStateManager.setIsAnalyzing(false); |
| this.deps.appStateManager.setGlobalLoading(false); |
| this.deps.appStateManager.updateState({ hasValidData: false }); |
| this.syncSemanticUiFromConfig(); |
| showAlertDialog(tr('Error'), 'Error rendering visualization. Check console for details.'); |
| return; |
| } |
|
|
| |
| this.clearHighlights(); |
|
|
| |
| this.updateVisualizationInternal(); |
|
|
| |
| this.deps.appStateManager.updateState({ hasValidData: true }); |
|
|
| this.syncSemanticUiFromConfig(); |
| } |
|
|
| |
| |
| |
| |
| public handleSemanticResponse( |
| res: { |
| model?: string; |
| token_attention?: Array<{ |
| offset: [number, number]; |
| raw: string; |
| score: number; |
| rawScore?: number; |
| }>; |
| debug_info?: { abbrev?: string; topk_tokens?: string[]; topk_probs?: number[] }; |
| chunkInfos?: Array<{ startOffset: number; endOffset: number; chunkIndex: number; chunkMatchDegree: number; thresholdResult?: signalFitResult }>; |
| full_match_degree?: number; |
| }, |
| text?: string, |
| signalFitResult?: signalFitResult | null |
| ): boolean { |
| const chunkInfos = res?.chunkInfos; |
| const semanticTokens = res?.token_attention; |
| const currentText = text ?? ''; |
|
|
| if (!hasSemanticData(res)) { |
| this.clearSemanticState(); |
| this.rerenderHistograms(); |
| this.deps.lmf.hideLoading(); |
| return true; |
| } |
| if (!currentText) return false; |
|
|
| |
| if (semanticTokens?.length && !chunkInfos?.length) { |
| const err = validateTokenConsistency(semanticTokens!, currentText, { allowOverlap: true }); |
| if (err) { |
| showAlertDialog(tr('Error'), err); |
| return false; |
| } |
| } |
|
|
| |
| const isChunkedSemantic = Boolean(chunkInfos?.length); |
| const semanticTokenSpansFromApi = |
| !isChunkedSemantic && semanticTokens && semanticTokens.length > 0 |
| ? semanticTokens.map((t) => ({ |
| ...t, |
| offset: [t.offset[0], t.offset[1]] as [number, number], |
| })) |
| : undefined; |
| const mergedSpans = isChunkedSemantic |
| ? (semanticTokens ?? []) |
| : mergeTokenSpansFullyForRendering(semanticTokens ?? [], currentText, { |
| digitMerge: getDigitsMergeEnabled(), |
| }); |
| const normalizedSpans = isChunkedSemantic ? mergedSpans : normalizeTokenScores(mergedSpans); |
| const computedSignalFit = isChunkedSemantic |
| ? undefined |
| : findSignalThresholdWithLog(normalizedSpans); |
| const chunkInfosResolved = |
| chunkInfos?.length |
| ? chunkInfos.map((info) => { |
| const slice = normalizedSpans.filter( |
| (t) => t.offset[0] < info.endOffset && t.offset[1] > info.startOffset |
| ); |
| const thresholdResult = |
| slice.length > 0 ? findSignalThresholdWithLog(slice) : null; |
| return { ...info, ...(thresholdResult ? { thresholdResult } : {}) }; |
| }) |
| : chunkInfos; |
|
|
| this.currentState.semanticData = { |
| text: currentText, |
| model: res.model, |
| semanticTokenSpansFromApi, |
| token_attention: normalizedSpans, |
| signalFitResult: signalFitResult ?? computedSignalFit ?? undefined, |
| chunkInfos: chunkInfosResolved, |
| full_match_degree: res.full_match_degree, |
| }; |
| let displayResult: ReturnType<VisualizationUpdater['computeDisplayResult']>; |
| try { |
| displayResult = this.computeDisplayResult(); |
| } catch (e) { |
| this.currentState.semanticData = null; |
| showAlertDialog(tr('Error'), e instanceof Error ? e.message : String(e)); |
| return false; |
| } |
|
|
| d3.select('#all_result').style('opacity', 1).style('display', null); |
| this.deps.lmf.hideLoading(); |
| this.deps.highlightController.updateCurrentData({ result: displayResult }); |
| |
| this.clearHighlights({ preserveChunkInterval: true }); |
| this.updateVisualizationInternal(); |
|
|
| this.updateSemanticDebugInfo(res.debug_info); |
| return true; |
| } |
|
|
| |
| private updateSemanticDebugInfo(di?: { abbrev?: string; topk_tokens?: string[]; topk_probs?: number[] }): void { |
| applySemanticDebugInfoPanel('results', 'semantic_debug_info', { debugInfo: di }); |
| } |
|
|
| private buildSemanticOnlyResult( |
| res: { model?: string }, |
| semanticTokens: Array<{ |
| offset: [number, number]; |
| raw: string; |
| score: number; |
| rawScore?: number; |
| }>, |
| text: string, |
| chunkInfos?: SemanticData['chunkInfos'] |
| ): (FrontendAnalyzeResult & { |
| rawScoresNormed: number[]; |
| tokenRawScores: number[]; |
| chunkInfos?: SemanticData['chunkInfos']; |
| }) | null { |
| const safeText = text ?? ''; |
| if (!safeText) return null; |
| |
| const bpeTokens: FrontendToken[] = semanticTokens.map((t) => ({ |
| offset: t.offset, |
| raw: t.raw, |
| pred_topk: [] |
| })) as FrontendToken[]; |
| const rawScoresNormed = semanticTokens.map((t) => t.score); |
| const tokenRawScores = semanticTokens.map((t) => getTokenRawScore(t)); |
| const cloneRow = (t: FrontendToken): FrontendToken => ({ ...t }); |
| return { |
| model: res.model, |
| bpe_strings: bpeTokens.map(cloneRow), |
| originalTokens: bpeTokens.map(cloneRow), |
| bpeBpeMergedTokens: bpeTokens.map(cloneRow), |
| originalText: safeText, |
| rawScoresNormed, |
| tokenRawScores, |
| chunkInfos |
| }; |
| } |
| } |
|
|