freepdfeditor/spike/A_verbatim_roundtrip/main.cpp

133 lines
4.2 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
//
// Spike A main: walks a corpus directory, runs the QPDF verbatim round-trip on
// every PDF, and emits a JSON report on stdout. Exit code 0 if the
// byte-identical rate meets the §14 target (≥ 99%), 1 otherwise.
//
// Usage:
// spike_a_verbatim_roundtrip <corpus_dir> [target_rate]
//
// Recursively globs `<corpus_dir>/**/*.pdf`. Output PDFs go into a sibling
// temp directory next to the executable; nothing in the corpus is mutated.
#include "Roundtrip.h"
#include "common/SpikeRunner.h"
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <random>
#include <string>
#include <system_error>
#include <vector>
namespace fs = std::filesystem;
namespace {
std::vector<fs::path> collect_pdfs(const fs::path& root, std::size_t cap)
{
std::vector<fs::path> out;
std::error_code ec;
if (!fs::exists(root, ec)) return out;
for (auto& entry : fs::recursive_directory_iterator(root,
fs::directory_options::skip_permission_denied, ec)) {
if (ec) { ec.clear(); continue; }
if (entry.is_regular_file(ec) &&
entry.path().extension() == ".pdf") {
out.push_back(entry.path());
if (out.size() >= cap) break;
}
}
return out;
}
fs::path make_temp_dir()
{
auto tmp = fs::temp_directory_path();
std::random_device rd;
std::mt19937_64 gen(rd());
auto suffix = std::to_string(gen());
auto p = tmp / ("freepdfeditor-spikeA-" + suffix);
fs::create_directories(p);
return p;
}
} // namespace
int main(int argc, char** argv)
{
if (argc < 2) {
std::fprintf(stderr,
"usage: %s <corpus_dir> [target_rate_default=0.99]\n", argv[0]);
return 2;
}
fs::path corpus = argv[1];
double target = (argc >= 3) ? std::stod(argv[2]) : 0.99;
auto pdfs = collect_pdfs(corpus, 100'000);
if (pdfs.empty()) {
freepdfeditor::spike::SpikeResult r{};
r.spike = "A";
r.name = "QPDF verbatim round-trip";
r.metric_name = "byte_identical_rate";
r.target = target;
r.gate_met = false;
r.notes = "no PDFs found in corpus directory";
return freepdfeditor::spike::emit_json_report(r);
}
auto outdir = make_temp_dir();
std::size_t byte_identical = 0;
std::size_t structurally_same = 0;
std::size_t failed = 0;
std::vector<std::string> failed_samples;
for (std::size_t i = 0; i < pdfs.size(); ++i) {
const auto& in = pdfs[i];
fs::path out = outdir / (std::to_string(i) + ".pdf");
fs::remove(out);
auto fr = freepdfeditor::spike::a::roundtrip(in, out);
switch (fr.outcome) {
case freepdfeditor::spike::a::Outcome::ByteIdentical:
++byte_identical;
break;
case freepdfeditor::spike::a::Outcome::StructurallySame:
++structurally_same;
break;
case freepdfeditor::spike::a::Outcome::Failed:
++failed;
if (failed_samples.size() < 16) {
failed_samples.push_back(in.filename().string());
}
break;
}
fs::remove(out);
}
std::error_code ec;
fs::remove_all(outdir, ec);
double rate = static_cast<double>(byte_identical) /
static_cast<double>(pdfs.size());
// The §14 gate is byte-identity. Structural-same is interesting for the
// report but does not pass the gate; we surface it in notes.
bool gate = rate >= target;
freepdfeditor::spike::SpikeResult r{};
r.spike = "A";
r.name = "QPDF verbatim round-trip";
r.total = pdfs.size();
r.passed = byte_identical;
r.failed = structurally_same;
r.errored = failed;
r.metric_name = "byte_identical_rate";
r.metric_value = rate;
r.target = target;
r.gate_met = gate;
r.failed_samples = failed_samples;
r.notes = "structurally_same files (QPDF rewrote, semantics preserved) count "
"as not-byte-identical for this gate; they are tracked in `failed`";
return freepdfeditor::spike::emit_json_report(r);
}