| |
| |
| |
| |
|
|
| 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 { getAttentionRawScore, mergeAttentionTokensFullyForRendering, 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'; |
|
|
| |
| export class TokenBoundaryInconsistentError extends Error { |
| constructor() { |
| super('Tokenizer results inconsistent: semantic and info-density token boundaries differ.'); |
| this.name = 'TokenBoundaryInconsistentError'; |
| } |
| } |
|
|
| |
| |
| |
| |
| 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>; |
| } |
|
|
| |
| export interface SemanticData { |
| text: string; |
| model?: string; |
| |
| semanticTokenAttentionFromApi?: 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 }; |
| } |
|
|
| |
| |
| |
| 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(): void { |
| this.deps.highlightController.clearHighlights(); |
| } |
|
|
| |
| |
| |
| private computeDisplayResult(): (FrontendAnalyzeResult & { |
| rawScoresNormed?: number[]; |
| attentionRawScores?: number[]; |
| chunkInfos?: SemanticData['chunkInfos']; |
| }) | null { |
| const info = this.currentState.infoDensityData; |
| const sem = this.currentState.semanticData; |
| const infoResult = info?.result as FrontendAnalyzeResult | undefined; |
| const infoText = info?.request?.text ?? infoResult?.originalText ?? ''; |
| const semText = sem?.text ?? ''; |
|
|
| if (infoResult && sem && infoText === semText && hasSemanticData(sem)) { |
| const infoMerged = infoResult.bpeBpeMergedTokens ?? infoResult.bpe_strings; |
| if (infoMerged?.length) { |
| |
| if (sem.token_attention?.length) { |
| const boundaryError = this.checkSemanticAlignsWithInfo(sem.token_attention, infoMerged, semText); |
| if (boundaryError) { |
| const { aSample, bSample, aNext, bNext, textBefore, textAt, textAfter } = boundaryError; |
| console.warn( |
| '[联合模式] 两种分析的分词token边界不一致:\n' + |
| ' 语义分析:', aSample, '\n' + |
| ' 信息密度:', bSample, '\n' + |
| ' 语义后一个:', aNext, '\n' + |
| ' 信息后一个:', bNext, '\n' + |
| ' 位置附近原文:', JSON.stringify(textBefore), '|', JSON.stringify(textAt), '|', JSON.stringify(textAfter) |
| ); |
| showAlertDialog(tr('Error'), tr('Tokenizer results inconsistent: semantic and info-density token boundaries differ.')); |
| this.currentState.semanticData = null; |
| throw new TokenBoundaryInconsistentError(); |
| } |
| } |
| |
| const tokenAttention = sem.token_attention ?? []; |
| const { unionTokens, scoresForUnion, rawScoresForUnion } = tokenAttention.length |
| ? this.mergeBpeWithSemanticBeyond(infoMerged, tokenAttention) |
| : (() => { |
| const m = this.mapTokenAttentionToMerged(infoMerged, []); |
| return { |
| unionTokens: infoMerged, |
| scoresForUnion: m.scores, |
| rawScoresForUnion: m.rawScores, |
| }; |
| })(); |
| return { |
| ...infoResult, |
| bpeBpeMergedTokens: unionTokens, |
| bpe_strings: unionTokens, |
| rawScoresNormed: scoresForUnion, |
| attentionRawScores: rawScoresForUnion, |
| chunkInfos: sem.chunkInfos, |
| }; |
| } |
| } |
| |
| if (sem && hasSemanticData(sem)) { |
| return this.buildSemanticOnlyResult({ model: sem.model }, sem.token_attention, sem.text, sem.chunkInfos); |
| } |
| if (infoResult) return { ...infoResult, chunkInfos: sem?.chunkInfos ?? undefined }; |
| 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 infoText = this.currentState.infoDensityData?.request?.text ?? ''; |
| const semText = this.currentState.semanticData?.text ?? ''; |
| const semanticQueryOn = getSemanticAnalysisEnabled(); |
|
|
| let showInfoDensity = false; |
| let showSemantic = false; |
|
|
| if (mode === 'infoDensity') { |
| |
| showInfoDensity = !semanticQueryOn; |
| showSemantic = |
| semanticQueryOn && |
| hasSemanticData(this.currentState.semanticData) && |
| semText === text; |
| } else { |
| showSemantic = true; |
| showInfoDensity = |
| !semanticQueryOn && |
| !!(this.currentState.infoDensityData && infoText === text); |
| } |
|
|
| 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!; |
| if (fitResult != null) { |
| this.deps.highlightController.updateCurrentData({ result: resultWithExt, signalProbs, pPwValues, pwScores }); |
| if (!skipLmfUpdate) { |
| this.deps.lmf.update({ ...resultWithExt, pwScores, colorScores: scoresForColor } as FrontendAnalyzeResult & { pPwValues?: number[]; pwScores?: number[]; colorScores?: number[] }); |
| } |
| } else { |
| this.deps.highlightController.updateCurrentData({ result: resultWithExt }); |
| if (!skipLmfUpdate) { |
| this.deps.lmf.update({ ...resultWithExt, 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[] }) | undefined; |
| if (!r?.rawScoresNormed?.length) return; |
| const el = document.getElementById('semantic_color_source_select') as HTMLSelectElement | null; |
| const v = el?.value ?? 'pw_score'; |
| const scoresForColor = v === 'signal_probability' ? (cd!.pPwValues ?? []) |
| : v === 'pw_score' ? (cd!.pwScores ?? []) |
| : r.rawScoresNormed; |
| this.deps.lmf.update({ ...r, pPwValues: cd!.pPwValues, pwScores: cd!.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(); |
| } |
|
|
| |
| |
| |
| public clearSemanticState(): void { |
| 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(); |
| } |
|
|
| |
| |
| |
| 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.semanticTokenAttentionFromApi?.length && sem.text) { |
| const mergedAttention = mergeAttentionTokensFullyForRendering( |
| sem.semanticTokenAttentionFromApi, |
| sem.text, |
| { digitMerge } |
| ); |
| const normalizedAttention = normalizeTokenScores(mergedAttention); |
| const computedSignalFit = findSignalThresholdWithLog(normalizedAttention); |
| sem.token_attention = normalizedAttention; |
| 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); |
| } |
| let displayResult: ReturnType<VisualizationUpdater['computeDisplayResult']>; |
| try { |
| displayResult = this.computeDisplayResult(); |
| } catch (e) { |
| if (e instanceof TokenBoundaryInconsistentError) { |
| displayResult = this.computeDisplayResult(); |
| } else { |
| console.error(e); |
| return; |
| } |
| } |
| this.deps.highlightController.updateCurrentData(displayResult ? { result: displayResult } : null); |
| this.deps.lmf.clearHighlight(); |
| if (displayResult) this.deps.lmf.update(displayResult); |
| this.updateVisualizationInternal(); |
| this.deps.appStateManager.updateButtonStates(); |
| } |
|
|
| |
| |
| |
| |
| 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); |
| if (!enabled) { |
| |
| 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.deps.highlightController.updateCurrentData(displayResult ? { result: displayResult } : null); |
| if (!displayResult) { |
| d3.select('#all_result').style('opacity', 0); |
| this.deps.appStateManager.updateState({ hasValidData: false }); |
| } |
| } |
| |
| this.updateVisualizationInternal(false); |
| |
| this.deps.appStateManager.updateButtonStates(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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(); |
| let displayResult: ReturnType<VisualizationUpdater['computeDisplayResult']>; |
| try { |
| displayResult = this.computeDisplayResult(); |
| } catch (e) { |
| if (e instanceof TokenBoundaryInconsistentError) { |
| displayResult = this.computeDisplayResult(); |
| } else { |
| throw e; |
| } |
| } |
| 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 tokenAttention = res?.token_attention; |
| const currentText = text ?? ''; |
|
|
| if (!hasSemanticData(res)) { |
| this.clearSemanticState(); |
| this.rerenderHistograms(); |
| this.deps.lmf.hideLoading(); |
| return true; |
| } |
| if (!currentText) return false; |
|
|
| |
| if (tokenAttention?.length && !chunkInfos?.length) { |
| const err = validateTokenConsistency(tokenAttention!, currentText, { allowOverlap: true }); |
| if (err) { |
| showAlertDialog(tr('Error'), err); |
| return false; |
| } |
| } |
|
|
| |
| const isChunkedSemantic = Boolean(chunkInfos?.length); |
| const semanticTokenAttentionFromApi = |
| !isChunkedSemantic && tokenAttention && tokenAttention.length > 0 |
| ? tokenAttention.map((t) => ({ |
| ...t, |
| offset: [t.offset[0], t.offset[1]] as [number, number], |
| })) |
| : undefined; |
| const mergedAttention = isChunkedSemantic |
| ? (tokenAttention ?? []) |
| : mergeAttentionTokensFullyForRendering(tokenAttention ?? [], currentText, { |
| digitMerge: getDigitsMergeEnabled(), |
| }); |
| const normalizedAttention = isChunkedSemantic ? mergedAttention : normalizeTokenScores(mergedAttention); |
| const computedSignalFit = isChunkedSemantic |
| ? undefined |
| : findSignalThresholdWithLog(normalizedAttention); |
| const chunkInfosResolved = |
| chunkInfos?.length |
| ? chunkInfos.map((info) => { |
| const slice = normalizedAttention.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, |
| semanticTokenAttentionFromApi, |
| token_attention: normalizedAttention, |
| 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; |
| if (e instanceof TokenBoundaryInconsistentError) { |
| this.deps.lmf.hideLoading(); |
| this.rerenderHistograms(); |
| return false; |
| } |
| 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.deps.lmf.clearHighlight(); |
| this.clearHighlights(); |
| 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 }, |
| tokenAttention: Array<{ |
| offset: [number, number]; |
| raw: string; |
| score: number; |
| rawScore?: number; |
| }>, |
| text: string, |
| chunkInfos?: SemanticData['chunkInfos'] |
| ): (FrontendAnalyzeResult & { |
| rawScoresNormed: number[]; |
| attentionRawScores: number[]; |
| chunkInfos?: SemanticData['chunkInfos']; |
| }) | null { |
| const safeText = text ?? ''; |
| if (!safeText) return null; |
| |
| const bpeTokens: FrontendToken[] = tokenAttention.map((t) => ({ |
| offset: t.offset, |
| raw: t.raw, |
| pred_topk: [] |
| })) as FrontendToken[]; |
| const rawScoresNormed = tokenAttention.map((t) => t.score); |
| const attentionRawScores = tokenAttention.map((t) => getAttentionRawScore(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, |
| attentionRawScores, |
| chunkInfos |
| }; |
| } |
|
|
| |
| |
| |
| |
| private checkSemanticAlignsWithInfo( |
| tokenAttention: Array<{ offset: [number, number]; raw?: string }>, |
| infoMerged: Array<{ offset: [number, number] }>, |
| text: string |
| ): { firstBadIdx: number; aSample: string; bSample: string; aNext: string; bNext: string; textBefore: string; textAt: string; textAfter: string } | null { |
| const boundaries = new Set<number>([0]); |
| for (const t of infoMerged) boundaries.add(t.offset[1]); |
| const infoEnd = infoMerged.length > 0 ? infoMerged[infoMerged.length - 1]!.offset[1] : 0; |
| const totalChars = text.length; |
| const ctx = 30; |
| const esc = (s: string) => JSON.stringify(s).slice(1, -1); |
| const fmt = (t: { offset: [number, number]; raw?: string }, idx: number) => { |
| const raw = (t as { raw?: string }).raw ?? text.slice(t.offset[0], t.offset[1]); |
| const s = raw.slice(0, 20) + (raw.length > 20 ? '…' : ''); |
| return `第${idx}个token分词 [字符${t.offset[0]}-${t.offset[1]}] "${esc(s)}"`; |
| }; |
| for (let i = 0; i < tokenAttention.length; i++) { |
| const [as, ae] = tokenAttention[i].offset; |
| if (as < 0 || ae > totalChars || ae <= as) continue; |
| if (ae > infoEnd) continue; |
| if (!boundaries.has(as) || !boundaries.has(ae)) { |
| const raw = (tokenAttention[i] as { raw?: string }).raw ?? ''; |
| const infoIdx = infoMerged.findIndex(t => t.offset[0] <= as && as < t.offset[1]); |
| const infoAt = infoIdx >= 0 ? infoMerged[infoIdx]! : null; |
| const rawShort = (raw || text.slice(as, ae)).slice(0, 20); |
| const infoRaw = infoAt ? (text.slice(infoAt.offset[0], infoAt.offset[1]).slice(0, 20) || '') : ''; |
| const nextSem = tokenAttention[i + 1]; |
| const nextInfo = infoIdx >= 0 && infoIdx + 1 < infoMerged.length ? infoMerged[infoIdx + 1]! : null; |
| return { |
| firstBadIdx: i, |
| aSample: `第${i}个token分词 [字符${as}-${ae}] "${esc(rawShort)}${rawShort.length >= 20 ? '…' : ''}"`, |
| bSample: infoAt ? `同一位置token分词 [字符${infoAt.offset[0]}-${infoAt.offset[1]}] "${esc(infoRaw)}${infoRaw.length >= 20 ? '…' : ''}"` : '无对应', |
| aNext: nextSem ? fmt(nextSem, i + 1) : '无', |
| bNext: nextInfo ? fmt(nextInfo, infoIdx + 1) : '无', |
| textBefore: text.slice(Math.max(0, as - ctx), as), |
| textAt: text.slice(as, ae), |
| textAfter: text.slice(ae, Math.min(totalChars, ae + ctx)), |
| }; |
| } |
| } |
| return null; |
| } |
|
|
| |
| |
| |
| |
| private mergeBpeWithSemanticBeyond( |
| bpeMerged: FrontendToken[], |
| tokenAttention: Array<{ |
| offset: [number, number]; |
| raw: string; |
| score: number; |
| rawScore?: number; |
| }> |
| ): { |
| unionTokens: FrontendToken[]; |
| scoresForUnion: (number | undefined)[]; |
| rawScoresForUnion: (number | undefined)[]; |
| } { |
| const infoEnd = bpeMerged.length > 0 ? bpeMerged[bpeMerged.length - 1]!.offset[1] : 0; |
| const beyond = tokenAttention.filter((t) => t.offset[0] >= infoEnd); |
| if (beyond.length === 0) { |
| const { scores, rawScores } = this.mapTokenAttentionToMerged(bpeMerged, tokenAttention); |
| return { |
| unionTokens: bpeMerged, |
| scoresForUnion: scores, |
| rawScoresForUnion: rawScores, |
| }; |
| } |
| |
| const beyondRenormed = normalizeTokenScores(beyond.map((t) => ({ ...t, score: getAttentionRawScore(t) }))); |
| const semanticAsFrontend: FrontendToken[] = beyondRenormed.map((t) => ({ |
| offset: [t.offset[0], t.offset[1]], |
| raw: t.raw, |
| real_topk: [0, 1] as [number, number], |
| pred_topk: [], |
| })); |
| const unionTokens = [...bpeMerged, ...semanticAsFrontend]; |
| const { scores: infoScores, rawScores: infoRawScores } = this.mapTokenAttentionToMerged( |
| bpeMerged, |
| tokenAttention |
| ); |
| const beyondScores: (number | undefined)[] = beyondRenormed.map((t) => |
| Number.isFinite(t.score) ? t.score : undefined |
| ); |
| const beyondRawScores: (number | undefined)[] = beyondRenormed.map((t) => { |
| const r = getAttentionRawScore(t); |
| return Number.isFinite(r) ? r : undefined; |
| }); |
| const scoresForUnion = [...infoScores, ...beyondScores]; |
| const rawScoresForUnion = [...infoRawScores, ...beyondRawScores]; |
| return { unionTokens, scoresForUnion, rawScoresForUnion }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| private mapTokenAttentionToMerged( |
| bpeBpeMergedTokens: Array<{ offset: [number, number] }>, |
| tokenAttention: Array<{ offset: [number, number]; score: number; rawScore?: number }> |
| ): { |
| scores: (number | undefined)[]; |
| rawScores: (number | undefined)[]; |
| } { |
| const n = bpeBpeMergedTokens.length; |
| const scores: number[] = new Array(n).fill(0); |
| const rawScores: number[] = new Array(n).fill(0); |
| const weights: number[] = new Array(n).fill(0); |
|
|
| let j = 0; |
| for (const attn of tokenAttention) { |
| const [as, ae] = attn.offset; |
| const rawPart = getAttentionRawScore(attn); |
| while (j < n && bpeBpeMergedTokens[j].offset[1] <= as) j++; |
| for (let k = j; k < n && bpeBpeMergedTokens[k].offset[0] < ae; k++) { |
| const [s, e] = bpeBpeMergedTokens[k].offset; |
| |
| const overlap = Math.min(e, ae) - Math.max(s, as); |
| scores[k] += attn.score * overlap; |
| rawScores[k] += rawPart * overlap; |
| weights[k] += overlap; |
| } |
| } |
|
|
| const norm = (vals: number[]) => vals.map((v, i) => (weights[i] > 0 ? v / weights[i] : undefined)); |
| return { |
| scores: norm(scores), |
| rawScores: norm(rawScores), |
| }; |
| } |
| } |
|
|
|
|