Files
2026-07-13 13:39:12 +08:00

220 lines
6.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { CompressionStats } from "./types.ts";
import { extractPreservedBlocks } from "./preservation.ts";
import { validateCompression } from "./validation.ts";
import { scoreToken } from "./ultraHeuristic.ts";
export interface CompressionDiffSegment {
type: "same" | "removed" | "added";
text: string;
}
export type HeatmapMode = "ultra" | "universal";
export interface HeatmapToken {
text: string;
score: number;
kept: boolean;
}
export interface CompressionHeatmap {
mode: HeatmapMode;
tokens: HeatmapToken[];
}
export interface CompressionPreviewDiff {
segments: CompressionDiffSegment[];
preservedBlocks: Array<{ kind: string; preview: string }>;
ruleRemovals: string[];
validationWarnings: string[];
validationErrors: string[];
fallbackApplied: boolean;
fallbackReason?: string;
heatmap?: CompressionHeatmap;
}
export interface CompressionPreviewDiffOptions {
maxTokenProduct?: number;
}
export const DEFAULT_MAX_PREVIEW_DIFF_TOKEN_PRODUCT = 1_000_000;
function tokenize(text: string): string[] {
return text.match(/\s+|[^\s]+/g) ?? [];
}
function getDiffSkipWarning(
original: string,
compressed: string,
options: CompressionPreviewDiffOptions = {}
): string | null {
const maxTokenProduct = options.maxTokenProduct ?? DEFAULT_MAX_PREVIEW_DIFF_TOKEN_PRODUCT;
if (maxTokenProduct <= 0) return null;
const originalTokens = tokenize(original).length;
const compressedTokens = tokenize(compressed).length;
if (originalTokens * compressedTokens <= maxTokenProduct) return null;
return `Preview diff omitted because token product ${originalTokens}x${compressedTokens} exceeds safe limit ${maxTokenProduct}.`;
}
export function buildCompressionDiff(
original: string,
compressed: string
): CompressionDiffSegment[] {
const a = tokenize(original);
const b = tokenize(compressed);
const dp = Array.from({ length: a.length + 1 }, () => Array<number>(b.length + 1).fill(0));
for (let i = a.length - 1; i >= 0; i--) {
for (let j = b.length - 1; j >= 0; j--) {
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
const segments: CompressionDiffSegment[] = [];
const push = (type: CompressionDiffSegment["type"], text: string) => {
if (!text) return;
const last = segments[segments.length - 1];
if (last?.type === type) {
last.text += text;
} else {
segments.push({ type, text });
}
};
let i = 0;
let j = 0;
while (i < a.length && j < b.length) {
if (a[i] === b[j]) {
push("same", a[i]);
i++;
j++;
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
push("removed", a[i]);
i++;
} else {
push("added", b[j]);
j++;
}
}
while (i < a.length) push("removed", a[i++]);
while (j < b.length) push("added", b[j++]);
return segments;
}
/**
* Walk original-side diff segments (same + removed; skip added) to build a
* Set of token indices that survived into the compressed output.
*/
function keptIndicesFromSegments(segments: CompressionDiffSegment[]): Set<number> {
const keptSet = new Set<number>();
let cursor = 0;
for (const seg of segments) {
if (seg.type === "added") continue;
const segLen = tokenize(seg.text).length;
if (seg.type === "same") {
for (let k = 0; k < segLen; k++) keptSet.add(cursor + k);
}
cursor += segLen;
}
return keptSet;
}
/**
* Walk original-side diff segments to build [lo, hi] index ranges for removed spans.
*/
function removedRangesFromSegments(segments: CompressionDiffSegment[]): Array<[number, number]> {
const ranges: Array<[number, number]> = [];
let cursor = 0;
for (const seg of segments) {
if (seg.type === "added") continue;
const segLen = tokenize(seg.text).length;
if (seg.type === "removed") ranges.push([cursor, cursor + segLen - 1]);
cursor += segLen;
}
return ranges;
}
/**
* Build a per-token saliency heatmap for the original text.
*
* ultra: score each token using scoreToken (01); kept = token not in a removed-only diff segment.
* universal: score is binary (1 = kept, 0 = removed); kept derived from diff segments.
*/
function buildHeatmap(
mode: HeatmapMode,
original: string,
segments: CompressionDiffSegment[]
): CompressionHeatmap {
const rawTokens = tokenize(original);
if (mode === "universal") {
const keptSet = keptIndicesFromSegments(segments);
return {
mode,
tokens: rawTokens.map((text, idx) => {
const kept = keptSet.has(idx);
return { text, score: kept ? 1 : 0, kept };
}),
};
}
// ultra mode: use scoreToken; kept = not in a purely removed segment position
const removedRanges = removedRangesFromSegments(segments);
return {
mode,
tokens: rawTokens.map((text, idx) => {
const removed = removedRanges.some(([lo, hi]) => idx >= lo && idx <= hi);
return { text, score: scoreToken(text), kept: !removed };
}),
};
}
export function buildCompressionPreviewDiff(
original: string,
compressed: string,
stats: CompressionStats | null | undefined,
options: CompressionPreviewDiffOptions = {},
heatmapMode?: HeatmapMode
): CompressionPreviewDiff {
const validation = validateCompression(original, compressed);
const preserved = extractPreservedBlocks(original).blocks.map((block) => ({
kind: block.kind,
preview: block.content.replace(/\s+/g, " ").slice(0, 120),
}));
const diffSkipWarning = getDiffSkipWarning(original, compressed, options);
const segments: CompressionDiffSegment[] = diffSkipWarning
? [{ type: "same", text: "[diff omitted: input too large]" }]
: buildCompressionDiff(original, compressed);
let fallbackReason: string | undefined;
if (validation.fallbackApplied) {
fallbackReason = validation.errors.length > 0
? `validation-failed: ${validation.errors[0]}`
: "validation-failed";
} else if (stats?.fallbackApplied) {
fallbackReason = "compression-fallback";
}
const result: CompressionPreviewDiff = {
segments,
preservedBlocks: preserved,
ruleRemovals: stats?.rulesApplied ?? [],
validationWarnings: [
...(stats?.validationWarnings ?? []),
...validation.warnings,
...(diffSkipWarning ? [diffSkipWarning] : []),
],
validationErrors: [...(stats?.validationErrors ?? []), ...validation.errors],
fallbackApplied: Boolean(stats?.fallbackApplied || validation.fallbackApplied),
...(fallbackReason && { fallbackReason }),
};
if (heatmapMode) {
result.heatmap = buildHeatmap(heatmapMode, original, segments);
}
return result;
}