220 lines
6.5 KiB
TypeScript
220 lines
6.5 KiB
TypeScript
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 (0–1); 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;
|
||
}
|