Release Minimize Tools 0.2.0
Verify / verify (push) Canceled after 0s

This commit is contained in:
2026-09-02 04:45:12 +02:00
parent 7bd20872a2
commit 87a7ef6ddd
25 changed files with 758 additions and 52 deletions
+148
View File
@@ -2,6 +2,7 @@ import { useMemo, useRef, useState, type ChangeEvent } from "react";
import {
stableStringify,
triggerBlobDownload,
triggerBlobDownloads,
} from "@add-ideas/toolbox-helpers";
import {
byteLength,
@@ -9,7 +10,15 @@ import {
type InputStructure,
type MinimizeProgress,
type MinimizeResult,
type PredicateStability,
} from "../minimize/engine";
import {
bundlePredicateAdapter,
minimizeBundle,
type BundleAdapterProfile,
type BundleFile,
type BundleMinimizeResult,
} from "../minimize/bundle";
import {
createPredicate,
type PredicateKind,
@@ -159,6 +168,13 @@ export function Workbench() {
const [preserveSignature, setPreserveSignature] = useState(true);
const [maxTests, setMaxTests] = useState(500);
const [maxSeconds, setMaxSeconds] = useState(10);
const [stabilityProfile, setStabilityProfile] = useState<
"single" | "majority" | "strict"
>("majority");
const [bundleFiles, setBundleFiles] = useState<BundleFile[]>([]);
const [bundleAdapter, setBundleAdapter] =
useState<BundleAdapterProfile["id"]>("concatenated");
const [bundleResult, setBundleResult] = useState<BundleMinimizeResult>();
const [result, setResult] = useState<MinimizeResult>();
const [resultRecipe, setResultRecipe] = useState<PredicateRecipe>();
const [resultLimits, setResultLimits] = useState<{
@@ -183,6 +199,12 @@ export function Workbench() {
stylesheet,
preserveFailureSignature: preserveSignature,
};
const stability: PredicateStability =
stabilityProfile === "single"
? { samples: 1, requiredPasses: 1 }
: stabilityProfile === "strict"
? { samples: 3, requiredPasses: 3 }
: { samples: 3, requiredPasses: 2 };
const report = useMemo(
() =>
@@ -199,6 +221,8 @@ export function Workbench() {
accepted: result.accepted,
exhausted: result.exhausted,
steps: result.steps,
stability: result.stability,
unstableCandidates: result.unstableCandidates,
},
2,
{ maxTextChars: 2 * 1024 * 1024, maxDepth: 16, maxNodes: 20_000 },
@@ -219,6 +243,19 @@ export function Workbench() {
setError(undefined);
try {
const predicate = createPredicate(recipe, runner.evaluate);
if (bundleFiles.length) {
const next = await minimizeBundle(
bundleFiles,
{ maxTests, maxSeconds, stability },
bundlePredicateAdapter(predicate, bundleAdapter),
nextController.signal,
);
setBundleResult(next);
setResult(undefined);
setResultRecipe(structuredClone(recipe));
setResultLimits({ maxTests, maxSeconds });
return;
}
const next = await minimizeInput(
source,
{
@@ -229,12 +266,14 @@ export function Workbench() {
? Math.min(maxTests, 200)
: maxTests,
maxSeconds,
stability,
},
predicate,
nextController.signal,
setProgress,
);
setResult(next);
setBundleResult(undefined);
setResultRecipe(structuredClone(recipe));
setResultLimits({
maxTests:
@@ -270,6 +309,7 @@ export function Workbench() {
return;
}
setSource(await file.text());
setBundleFiles([]);
setSourceName(file.name);
setStructure(
file.name.toLowerCase().endsWith(".json")
@@ -281,6 +321,27 @@ export function Workbench() {
setError(undefined);
};
const openBundle = async (event: ChangeEvent<HTMLInputElement>) => {
const files = [...(event.target.files ?? [])];
event.target.value = "";
try {
if (files.length > 100)
throw new RangeError("Bundles are limited to 100 files.");
const loaded = await Promise.all(
files.map(async (file) => ({
path: file.webkitRelativePath || file.name,
content: await file.text(),
})),
);
setBundleFiles(loaded);
setError(undefined);
} catch (reason) {
setError(
reason instanceof Error ? reason.message : "Bundle loading failed.",
);
}
};
const applyPreset = (name: "json" | "xml" | "regex") => {
if (name === "json") {
setSource(JSON_SAMPLE);
@@ -349,6 +410,14 @@ export function Workbench() {
onChange={(event) => void openFile(event)}
/>
</label>
<label className="button file-button">
Open file bundle
<input
type="file"
multiple
onChange={(event) => void openBundle(event)}
/>
</label>
</div>
<label>
Structure
@@ -370,6 +439,15 @@ export function Workbench() {
rows={22}
spellCheck={false}
/>
{bundleFiles.length ? (
<div className="bundle-summary">
<strong>{bundleFiles.length} bundle files active</strong>
<p>{bundleFiles.map((file) => file.path).join(" · ")}</p>
<button type="button" onClick={() => setBundleFiles([])}>
Return to single input
</button>
</div>
) : null}
</article>
<article className="panel predicate-panel">
@@ -483,6 +561,37 @@ export function Workbench() {
</label>
) : null}
<div className="compact-grid limits">
<label>
Predicate stability
<select
value={stabilityProfile}
onChange={(event) =>
setStabilityProfile(
event.target.value as typeof stabilityProfile,
)
}
>
<option value="single">Single observation</option>
<option value="majority">Majority (2 of 3)</option>
<option value="strict">Strict (3 of 3)</option>
</select>
</label>
<label>
Bundle predicate adapter
<select
value={bundleAdapter}
disabled={!bundleFiles.length}
onChange={(event) =>
setBundleAdapter(
event.target.value as BundleAdapterProfile["id"],
)
}
>
<option value="concatenated">Delimited concatenation</option>
<option value="json-map">JSON path map</option>
<option value="primary-file">Primary file only</option>
</select>
</label>
<label>
Maximum predicate tests
<input
@@ -591,6 +700,45 @@ export function Workbench() {
</details>
) : null}
</section>
{bundleResult ? (
<section className="panel result-panel" aria-live="polite">
<div className="panel-heading">
<div>
<h2>Minimized bundle</h2>
<p>
{bundleResult.original.length} {bundleResult.minimized.length}{" "}
files · {bundleResult.tests} predicate observations ·{" "}
{bundleResult.unstableCandidates} flaky candidates observed
</p>
</div>
<button
type="button"
onClick={() =>
triggerBlobDownloads(
bundleResult.minimized.map((file) => ({
blob: new Blob([file.content], {
type: "text/plain;charset=utf-8",
}),
filename: file.path.replaceAll("/", "__"),
})),
{ order: "filename", maximumFiles: 100 },
)
}
>
Download retained files
</button>
</div>
<ul className="step-list">
{bundleResult.minimized.map((file) => (
<li key={file.path}>
<code>{file.path}</code> ·{" "}
{byteLength(file.content).toLocaleString()} bytes
</li>
))}
</ul>
</section>
) : null}
</main>
);
}
+296
View File
@@ -0,0 +1,296 @@
import { stableStringify } from "@add-ideas/toolbox-helpers";
import {
MAX_TESTS,
byteLength,
minimizeInput,
type FailurePredicate,
type InputStructure,
type PredicateStability,
type ReductionStep,
} from "./engine";
export const MAX_BUNDLE_FILES = 100;
export const MAX_BUNDLE_BYTES = 4 * 1024 * 1024;
export interface BundleFile {
path: string;
content: string;
structure?: InputStructure;
}
export interface BundleMinimizeOptions {
maxTests: number;
maxSeconds: number;
stability?: PredicateStability;
}
export type BundlePredicate = (
files: readonly BundleFile[],
signal: AbortSignal,
) => boolean | Promise<boolean>;
export interface BundleReductionStep {
stage: "files" | "content";
path?: string;
beforeFiles: number;
afterFiles: number;
beforeBytes: number;
afterBytes: number;
test: number;
contentStep?: ReductionStep["stage"];
}
export interface BundleMinimizeResult {
version: "bundle-ddmin-v1";
original: BundleFile[];
minimized: BundleFile[];
tests: number;
accepted: number;
exhausted: boolean;
stability: PredicateStability;
unstableCandidates: number;
steps: BundleReductionStep[];
}
export interface BundleAdapterProfile {
id: "json-map" | "concatenated" | "primary-file";
label: string;
description: string;
adapt(files: readonly BundleFile[]): string;
}
export const BUNDLE_ADAPTER_PROFILES: readonly BundleAdapterProfile[] = [
{
id: "json-map",
label: "JSON path map",
description: "Presents a deterministic path-to-content JSON object.",
adapt: (files) =>
stableStringify(
Object.fromEntries(files.map((file) => [file.path, file.content])),
),
},
{
id: "concatenated",
label: "Delimited concatenation",
description:
"Presents all files with explicit, deterministic path markers.",
adapt: (files) =>
files
.map((file) => `\n--- toolbox-file:${file.path} ---\n${file.content}`)
.join(""),
},
{
id: "primary-file",
label: "Primary file",
description: "Presents only the lexicographically first retained file.",
adapt: (files) => files[0]?.content ?? "",
},
];
export function bundlePredicateAdapter(
predicate: FailurePredicate,
profileId: BundleAdapterProfile["id"],
): BundlePredicate {
const profile = BUNDLE_ADAPTER_PROFILES.find((item) => item.id === profileId);
if (!profile) throw new TypeError(`Unknown bundle adapter ${profileId}.`);
return (files, signal) => predicate(profile.adapt(files), signal);
}
export async function minimizeBundle(
input: readonly BundleFile[],
options: BundleMinimizeOptions,
predicate: BundlePredicate,
signal: AbortSignal,
): Promise<BundleMinimizeResult> {
const original = normalizeBundle(input);
if (
!Number.isInteger(options.maxTests) ||
options.maxTests < 1 ||
options.maxTests > MAX_TESTS
)
throw new RangeError(`Bundle test budget must be 1${MAX_TESTS}.`);
if (
!Number.isFinite(options.maxSeconds) ||
options.maxSeconds <= 0 ||
options.maxSeconds > 30
)
throw new RangeError(
"Bundle time budget must be above 0 and at most 30 seconds.",
);
const stability = options.stability ?? { samples: 1, requiredPasses: 1 };
if (
!Number.isInteger(stability.samples) ||
stability.samples < 1 ||
stability.samples > 9 ||
!Number.isInteger(stability.requiredPasses) ||
stability.requiredPasses < 1 ||
stability.requiredPasses > stability.samples
)
throw new RangeError("Bundle stability settings are invalid.");
const deadline = Date.now() + options.maxSeconds * 1_000;
let tests = 0;
let accepted = 0;
let exhausted = false;
let unstableCandidates = 0;
let current = original.map((file) => ({ ...file }));
const steps: BundleReductionStep[] = [];
const evaluate = async (candidate: readonly BundleFile[]) => {
let passes = 0;
let observations = 0;
while (observations < stability.samples) {
signal.throwIfAborted();
if (tests >= options.maxTests || Date.now() >= deadline) {
exhausted = true;
return false;
}
tests += 1;
if (await predicate(candidate, signal)) passes += 1;
observations += 1;
}
if (passes > 0 && passes < observations) unstableCandidates += 1;
return passes >= stability.requiredPasses;
};
if (!(await evaluate(current)))
throw new TypeError(
"The original bundle does not stably satisfy the selected predicate.",
);
let granularity = 2;
while (current.length && !exhausted) {
const chunk = Math.ceil(current.length / granularity);
let changed = false;
for (let start = 0; start < current.length; start += chunk) {
const candidate = current
.slice(0, start)
.concat(current.slice(start + chunk));
if (await evaluate(candidate)) {
const before = bundleBytes(current);
const beforeFiles = current.length;
current = candidate;
accepted += 1;
steps.push({
stage: "files",
beforeFiles,
afterFiles: current.length,
beforeBytes: before,
afterBytes: bundleBytes(current),
test: tests,
});
granularity = Math.max(2, granularity - 1);
changed = true;
break;
}
if (exhausted) break;
}
if (!changed) {
if (granularity >= current.length) break;
granularity = Math.min(current.length, granularity * 2);
}
}
for (let index = 0; index < current.length && !exhausted; index += 1) {
const remainingTests = options.maxTests - tests;
const remainingSeconds = (deadline - Date.now()) / 1_000;
if (remainingTests < 1 || remainingSeconds <= 0) {
exhausted = true;
break;
}
const file = current[index]!;
const result = await minimizeInput(
file.content,
{
structure: file.structure ?? inferStructure(file.path),
maxTests: remainingTests,
maxSeconds: Math.min(30, remainingSeconds),
stability,
},
(candidate, childSignal) =>
predicate(
current.map((entry, candidateIndex) =>
candidateIndex === index ? { ...entry, content: candidate } : entry,
),
childSignal,
),
signal,
);
tests += result.tests;
exhausted ||= result.exhausted;
if (result.minimized !== file.content) {
const before = bundleBytes(current);
current[index] = { ...file, content: result.minimized };
accepted += result.accepted;
for (const contentStep of result.steps)
steps.push({
stage: "content",
path: file.path,
beforeFiles: current.length,
afterFiles: current.length,
beforeBytes: before,
afterBytes: bundleBytes(current),
test: tests - result.tests + contentStep.test,
contentStep: contentStep.stage,
});
}
}
return {
version: "bundle-ddmin-v1",
original,
minimized: current,
tests,
accepted,
exhausted,
stability,
unstableCandidates,
steps,
};
}
function normalizeBundle(input: readonly BundleFile[]): BundleFile[] {
if (!input.length) throw new TypeError("A bundle needs at least one file.");
if (input.length > MAX_BUNDLE_FILES)
throw new RangeError(`Bundles are limited to ${MAX_BUNDLE_FILES} files.`);
const paths = new Set<string>();
const result = input.map((file) => {
const path = normalizePath(file.path);
if (paths.has(path)) throw new TypeError(`Duplicate bundle path ${path}.`);
paths.add(path);
if (byteLength(file.content) > 2 * 1024 * 1024)
throw new RangeError(`${path} exceeds the 2 MiB per-file limit.`);
return {
path,
content: file.content,
...(file.structure ? { structure: file.structure } : {}),
};
});
result.sort((left, right) =>
left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
);
if (bundleBytes(result) > MAX_BUNDLE_BYTES)
throw new RangeError("Bundle contents exceed 4 MiB.");
return result;
}
function normalizePath(path: string): string {
const normalized = path.normalize("NFC");
if (
!normalized ||
normalized.includes("\\") ||
normalized.startsWith("/") ||
normalized.split("/").some((part) => !part || part === "." || part === "..")
)
throw new TypeError(`Unsafe bundle path ${path}.`);
return normalized;
}
function bundleBytes(files: readonly BundleFile[]): number {
return files.reduce((sum, file) => sum + byteLength(file.content), 0);
}
function inferStructure(path: string): InputStructure {
const lower = path.toLowerCase();
return lower.endsWith(".json")
? "json"
: lower.endsWith(".xml")
? "xml"
: "text";
}
+40 -2
View File
@@ -11,6 +11,14 @@ export interface MinimizeOptions {
structure: InputStructure;
maxTests: number;
maxSeconds: number;
stability?: PredicateStability;
}
export interface PredicateStability {
/** Independent observations per candidate (19). */
samples: number;
/** Required reproductions within those observations (1samples). */
requiredPasses: number;
}
export interface ReductionStep {
@@ -35,6 +43,8 @@ export interface MinimizeResult {
accepted: number;
exhausted: boolean;
steps: ReductionStep[];
stability: PredicateStability;
unstableCandidates: number;
}
export type FailurePredicate = (
@@ -76,6 +86,18 @@ function assertOptions(options: MinimizeOptions): void {
throw new RangeError(
`Time budget must be above 0 and at most ${MAX_SECONDS} seconds.`,
);
const stability = options.stability ?? { samples: 1, requiredPasses: 1 };
if (
!Number.isInteger(stability.samples) ||
stability.samples < 1 ||
stability.samples > 9 ||
!Number.isInteger(stability.requiredPasses) ||
stability.requiredPasses < 1 ||
stability.requiredPasses > stability.samples
)
throw new RangeError(
"Predicate stability needs 19 samples and 1samples required passes.",
);
}
function splitLines(value: string): string[] {
@@ -256,10 +278,12 @@ export async function minimizeInput(
let tests = 0;
let accepted = 0;
let exhausted = false;
let unstableCandidates = 0;
let current = input;
let stage: ReductionStep["stage"] = "normalize";
const steps: ReductionStep[] = [];
const cache = new Map<string, boolean>();
const stability = options.stability ?? { samples: 1, requiredPasses: 1 };
const preserveValidSyntax =
options.structure === "json"
? (() => {
@@ -282,7 +306,6 @@ export async function minimizeInput(
}
const cached = cache.get(candidate);
if (cached !== undefined) return cached;
tests += 1;
if (
preserveValidSyntax &&
((options.structure === "json" &&
@@ -299,7 +322,20 @@ export async function minimizeInput(
cache.set(candidate, false);
return false;
}
const result = await predicate(candidate, signal);
let passes = 0;
let observations = 0;
while (observations < stability.samples) {
if (tests >= options.maxTests || Date.now() >= deadline) {
exhausted = true;
break;
}
tests += 1;
if (await predicate(candidate, signal)) passes += 1;
observations += 1;
}
const result =
observations === stability.samples && passes >= stability.requiredPasses;
if (passes > 0 && passes < observations) unstableCandidates += 1;
cache.set(candidate, result);
if (tests % 20 === 0)
onProgress?.({ candidate: current, tests, accepted, stage });
@@ -407,5 +443,7 @@ export async function minimizeInput(
accepted,
exhausted,
steps,
stability,
unstableCandidates,
};
}
+40 -2
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1,
"id": "de.add-ideas.minimize-tools",
"name": "Minimize Tools",
"version": "0.1.0",
"version": "0.2.0",
"description": "Reduce failing inputs while preserving the failure locally.",
"entry": "./",
"icon": "./favicon.svg",
@@ -23,11 +23,49 @@
},
"requirements": {
"secureContext": false,
"workers": true,
"workers": false,
"indexedDb": false,
"crossOriginIsolated": false,
"topLevelContext": false
},
"io": {
"accepts": [
{
"mediaType": "text/plain",
"extensions": [".txt"]
},
{
"mediaType": "application/json",
"extensions": [".json"]
},
{
"mediaType": "application/xml",
"extensions": [".xml", ".xsl", ".xslt"]
}
],
"produces": [
{
"mediaType": "text/plain",
"extensions": [".txt"]
},
{
"mediaType": "application/json",
"extensions": [".json"]
},
{
"mediaType": "application/xml",
"extensions": [".xml"]
},
{
"mediaType": "application/zip",
"extensions": [".zip"]
}
]
},
"capabilities": {
"required": [],
"optional": ["workers"]
},
"privacy": {
"processing": "local",
"fileUploads": true,
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = "0.1.0";
export const APP_VERSION = "0.2.0";