Xteink-X4-crosspoint-reader/test/hyphenation_eval/HyphenationEvaluationTest.cpp
Arthur Tazhitdinov 8824c87490
feat: dict based Hyphenation (#305)
## Summary

* Adds (optional) Hyphenation for English, French, German, Russian
languages

## Additional Context

* Included hyphenation dictionaries add approximately 280kb to the flash
usage (German alone takes 200kb)
* Trie encoded dictionaries are adopted from hypher project
(https://github.com/typst/hypher)
* Soft hyphens (and other explicit hyphens) take precedence over
dict-based hyphenation. Overall, the hyphenation rules are quite
aggressive, as I believe it makes more sense on our smaller screen.

---------

Co-authored-by: Dave Allie <dave@daveallie.com>
2026-01-19 12:56:26 +00:00

388 lines
14 KiB
C++

#include <Utf8.h>
#include <algorithm>
#include <cctype>
#include <cmath>
#include <fstream>
#include <functional>
#include <iostream>
#include <sstream>
#include <string>
#include <vector>
#include "lib/Epub/Epub/hyphenation/HyphenationCommon.h"
#include "lib/Epub/Epub/hyphenation/LanguageHyphenator.h"
#include "lib/Epub/Epub/hyphenation/LanguageRegistry.h"
struct TestCase {
std::string word;
std::string hyphenated;
std::vector<size_t> expectedPositions;
int frequency;
};
struct EvaluationResult {
int truePositives = 0;
int falsePositives = 0;
int falseNegatives = 0;
double precision = 0.0;
double recall = 0.0;
double f1Score = 0.0;
double weightedScore = 0.0;
};
struct LanguageConfig {
std::string cliName;
std::string testDataFile;
const char* primaryTag;
};
const std::vector<LanguageConfig> kSupportedLanguages = {
{"english", "test/hyphenation_eval/resources/english_hyphenation_tests.txt", "en"},
{"french", "test/hyphenation_eval/resources/french_hyphenation_tests.txt", "fr"},
{"german", "test/hyphenation_eval/resources/german_hyphenation_tests.txt", "de"},
{"russian", "test/hyphenation_eval/resources/russian_hyphenation_tests.txt", "ru"},
};
std::vector<size_t> expectedPositionsFromAnnotatedWord(const std::string& annotated) {
std::vector<size_t> positions;
const unsigned char* ptr = reinterpret_cast<const unsigned char*>(annotated.c_str());
size_t codepointIndex = 0;
while (*ptr != 0) {
if (*ptr == '=') {
positions.push_back(codepointIndex);
++ptr;
continue;
}
utf8NextCodepoint(&ptr);
++codepointIndex;
}
return positions;
}
std::vector<TestCase> loadTestData(const std::string& filename) {
std::vector<TestCase> testCases;
std::ifstream file(filename);
if (!file.is_open()) {
std::cerr << "Error: Could not open file " << filename << std::endl;
return testCases;
}
std::string line;
while (std::getline(file, line)) {
if (line.empty() || line[0] == '#') {
continue;
}
std::istringstream iss(line);
std::string word, hyphenated, freqStr;
if (std::getline(iss, word, '|') && std::getline(iss, hyphenated, '|') && std::getline(iss, freqStr, '|')) {
TestCase testCase;
testCase.word = word;
testCase.hyphenated = hyphenated;
testCase.frequency = std::stoi(freqStr);
testCase.expectedPositions = expectedPositionsFromAnnotatedWord(hyphenated);
testCases.push_back(testCase);
}
}
file.close();
return testCases;
}
std::string positionsToHyphenated(const std::string& word, const std::vector<size_t>& positions) {
std::string result;
std::vector<size_t> sortedPositions = positions;
std::sort(sortedPositions.begin(), sortedPositions.end());
const unsigned char* ptr = reinterpret_cast<const unsigned char*>(word.c_str());
size_t codepointIndex = 0;
size_t posIdx = 0;
while (*ptr != 0) {
while (posIdx < sortedPositions.size() && sortedPositions[posIdx] == codepointIndex) {
result.push_back('=');
++posIdx;
}
const unsigned char* current = ptr;
utf8NextCodepoint(&ptr);
result.append(reinterpret_cast<const char*>(current), reinterpret_cast<const char*>(ptr));
++codepointIndex;
}
while (posIdx < sortedPositions.size() && sortedPositions[posIdx] == codepointIndex) {
result.push_back('=');
++posIdx;
}
return result;
}
std::vector<size_t> hyphenateWordWithHyphenator(const std::string& word, const LanguageHyphenator& hyphenator) {
auto cps = collectCodepoints(word);
trimSurroundingPunctuationAndFootnote(cps);
return hyphenator.breakIndexes(cps);
}
std::vector<LanguageConfig> resolveLanguages(const std::string& selection) {
if (selection == "all") {
return kSupportedLanguages;
}
for (const auto& config : kSupportedLanguages) {
if (config.cliName == selection) {
return {config};
}
}
return {};
}
EvaluationResult evaluateWord(const TestCase& testCase,
std::function<std::vector<size_t>(const std::string&)> hyphenateFunc) {
EvaluationResult result;
std::vector<size_t> actualPositions = hyphenateFunc(testCase.word);
std::vector<size_t> expected = testCase.expectedPositions;
std::vector<size_t> actual = actualPositions;
std::sort(expected.begin(), expected.end());
std::sort(actual.begin(), actual.end());
for (size_t pos : actual) {
if (std::find(expected.begin(), expected.end(), pos) != expected.end()) {
result.truePositives++;
} else {
result.falsePositives++;
}
}
for (size_t pos : expected) {
if (std::find(actual.begin(), actual.end(), pos) == actual.end()) {
result.falseNegatives++;
}
}
if (result.truePositives + result.falsePositives > 0) {
result.precision = static_cast<double>(result.truePositives) / (result.truePositives + result.falsePositives);
}
if (result.truePositives + result.falseNegatives > 0) {
result.recall = static_cast<double>(result.truePositives) / (result.truePositives + result.falseNegatives);
}
if (result.precision + result.recall > 0) {
result.f1Score = 2 * result.precision * result.recall / (result.precision + result.recall);
}
// Treat words that contain no hyphenation marks in both the expected data and the
// algorithmic output as perfect matches so they don't drag down the per-word averages.
if (expected.empty() && actual.empty()) {
result.precision = 1.0;
result.recall = 1.0;
result.f1Score = 1.0;
}
double fpPenalty = 2.0;
double fnPenalty = 1.0;
int totalErrors = result.falsePositives * fpPenalty + result.falseNegatives * fnPenalty;
int totalPossible = expected.size() * fpPenalty;
if (totalPossible > 0) {
result.weightedScore = 1.0 - (static_cast<double>(totalErrors) / totalPossible);
result.weightedScore = std::max(0.0, result.weightedScore);
} else if (result.falsePositives == 0) {
result.weightedScore = 1.0;
}
return result;
}
void printResults(const std::string& language, const std::vector<TestCase>& testCases,
const std::vector<std::pair<TestCase, EvaluationResult>>& worstCases, int perfectMatches,
int partialMatches, int completeMisses, double totalPrecision, double totalRecall, double totalF1,
double totalWeighted, int totalTP, int totalFP, int totalFN,
std::function<std::vector<size_t>(const std::string&)> hyphenateFunc) {
std::string lang_upper = language;
if (!lang_upper.empty()) {
lang_upper[0] = std::toupper(lang_upper[0]);
}
std::cout << "================================================================================" << std::endl;
std::cout << lang_upper << " HYPHENATION EVALUATION RESULTS" << std::endl;
std::cout << "================================================================================" << std::endl;
std::cout << std::endl;
std::cout << "Total test cases: " << testCases.size() << std::endl;
std::cout << "Perfect matches: " << perfectMatches << " (" << (perfectMatches * 100.0 / testCases.size()) << "%)"
<< std::endl;
std::cout << "Partial matches: " << partialMatches << std::endl;
std::cout << "Complete misses: " << completeMisses << std::endl;
std::cout << std::endl;
std::cout << "--- Overall Metrics (averaged per word) ---" << std::endl;
std::cout << "Average Precision: " << (totalPrecision / testCases.size() * 100.0) << "%" << std::endl;
std::cout << "Average Recall: " << (totalRecall / testCases.size() * 100.0) << "%" << std::endl;
std::cout << "Average F1 Score: " << (totalF1 / testCases.size() * 100.0) << "%" << std::endl;
std::cout << "Average Weighted Score: " << (totalWeighted / testCases.size() * 100.0) << "% (FP penalty: 2x)"
<< std::endl;
std::cout << std::endl;
std::cout << "--- Overall Metrics (total counts) ---" << std::endl;
std::cout << "True Positives: " << totalTP << std::endl;
std::cout << "False Positives: " << totalFP << " (incorrect hyphenation points)" << std::endl;
std::cout << "False Negatives: " << totalFN << " (missed hyphenation points)" << std::endl;
double overallPrecision = totalTP + totalFP > 0 ? static_cast<double>(totalTP) / (totalTP + totalFP) : 0.0;
double overallRecall = totalTP + totalFN > 0 ? static_cast<double>(totalTP) / (totalTP + totalFN) : 0.0;
double overallF1 = overallPrecision + overallRecall > 0
? 2 * overallPrecision * overallRecall / (overallPrecision + overallRecall)
: 0.0;
std::cout << "Overall Precision: " << (overallPrecision * 100.0) << "%" << std::endl;
std::cout << "Overall Recall: " << (overallRecall * 100.0) << "%" << std::endl;
std::cout << "Overall F1 Score: " << (overallF1 * 100.0) << "%" << std::endl;
std::cout << std::endl;
// Filter out perfect matches from the “worst cases” section so that only actionable failures appear.
auto hasImperfection = [](const EvaluationResult& r) { return r.weightedScore < 0.999999; };
std::vector<std::pair<TestCase, EvaluationResult>> imperfectCases;
imperfectCases.reserve(worstCases.size());
for (const auto& entry : worstCases) {
if (hasImperfection(entry.second)) {
imperfectCases.push_back(entry);
}
}
std::cout << "--- Worst Cases (lowest weighted scores) ---" << std::endl;
int showCount = std::min(10, static_cast<int>(imperfectCases.size()));
for (int i = 0; i < showCount; i++) {
const auto& testCase = imperfectCases[i].first;
const auto& result = imperfectCases[i].second;
std::vector<size_t> actualPositions = hyphenateFunc(testCase.word);
std::string actualHyphenated = positionsToHyphenated(testCase.word, actualPositions);
std::cout << "Word: " << testCase.word << " (freq: " << testCase.frequency << ")" << std::endl;
std::cout << " Expected: " << testCase.hyphenated << std::endl;
std::cout << " Got: " << actualHyphenated << std::endl;
std::cout << " Precision: " << (result.precision * 100.0) << "%"
<< " Recall: " << (result.recall * 100.0) << "%"
<< " F1: " << (result.f1Score * 100.0) << "%"
<< " Weighted: " << (result.weightedScore * 100.0) << "%" << std::endl;
std::cout << " TP: " << result.truePositives << " FP: " << result.falsePositives
<< " FN: " << result.falseNegatives << std::endl;
std::cout << std::endl;
}
// Additional compact list of the worst ~100 words to aid iteration
int compactCount = std::min(100, static_cast<int>(imperfectCases.size()));
if (compactCount > 0) {
std::cout << "--- Compact Worst Cases (" << compactCount << ") ---" << std::endl;
for (int i = 0; i < compactCount; i++) {
const auto& testCase = imperfectCases[i].first;
std::vector<size_t> actualPositions = hyphenateFunc(testCase.word);
std::string actualHyphenated = positionsToHyphenated(testCase.word, actualPositions);
std::cout << testCase.word << " | exp:" << testCase.hyphenated << " | got:" << actualHyphenated << std::endl;
}
std::cout << std::endl;
}
}
int main(int argc, char* argv[]) {
const bool summaryMode = argc <= 1;
const std::string languageSelection = summaryMode ? "all" : argv[1];
std::vector<LanguageConfig> languages = resolveLanguages(languageSelection);
if (languages.empty()) {
std::cerr << "Unknown language: " << languageSelection << std::endl;
return 1;
}
for (const auto& lang : languages) {
const auto* hyphenator = getLanguageHyphenatorForPrimaryTag(lang.primaryTag);
if (!hyphenator) {
std::cerr << "No hyphenator registered for tag: " << lang.primaryTag << std::endl;
continue;
}
const auto hyphenateFunc = [hyphenator](const std::string& word) {
return hyphenateWordWithHyphenator(word, *hyphenator);
};
if (!summaryMode) {
std::cout << "Loading test data from: " << lang.testDataFile << std::endl;
}
std::vector<TestCase> testCases = loadTestData(lang.testDataFile);
if (testCases.empty()) {
std::cerr << "No test cases loaded for " << lang.cliName << ". Skipping." << std::endl;
continue;
}
if (!summaryMode) {
std::cout << "Loaded " << testCases.size() << " test cases for " << lang.cliName << std::endl;
std::cout << std::endl;
}
int perfectMatches = 0;
int partialMatches = 0;
int completeMisses = 0;
double totalPrecision = 0.0;
double totalRecall = 0.0;
double totalF1 = 0.0;
double totalWeighted = 0.0;
int totalTP = 0, totalFP = 0, totalFN = 0;
std::vector<std::pair<TestCase, EvaluationResult>> worstCases;
for (const auto& testCase : testCases) {
EvaluationResult result = evaluateWord(testCase, hyphenateFunc);
totalTP += result.truePositives;
totalFP += result.falsePositives;
totalFN += result.falseNegatives;
totalPrecision += result.precision;
totalRecall += result.recall;
totalF1 += result.f1Score;
totalWeighted += result.weightedScore;
if (result.f1Score == 1.0) {
perfectMatches++;
} else if (result.f1Score > 0.0) {
partialMatches++;
} else {
completeMisses++;
}
worstCases.push_back({testCase, result});
}
if (summaryMode) {
const double averageF1Percent = testCases.empty() ? 0.0 : (totalF1 / testCases.size() * 100.0);
std::cout << lang.cliName << ": " << averageF1Percent << "%" << std::endl;
continue;
}
std::sort(worstCases.begin(), worstCases.end(),
[](const auto& a, const auto& b) { return a.second.weightedScore < b.second.weightedScore; });
printResults(lang.cliName, testCases, worstCases, perfectMatches, partialMatches, completeMisses, totalPrecision,
totalRecall, totalF1, totalWeighted, totalTP, totalFP, totalFN, hyphenateFunc);
}
return 0;
}