feat(python): add native distribution and release infrastructure
- add the Rust-backed Python API with PyStemmer compatibility - distribute standard compiled models as a separate Python package - generate model artifacts during builds instead of storing them in Git - add GitHub release and Pages-backed package index workflows - add Python tests, benchmarks, documentation, and Gradle integration - refresh the documentation site, branding, and language benchmarks
This commit is contained in:
15
tools/parse-python-release-tag.sh
Executable file
15
tools/parse-python-release-tag.sh
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
tag="${1:-}"
|
||||
|
||||
version_pattern='(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)'
|
||||
|
||||
if [[ "${tag}" =~ ^python@(${version_pattern})$ ]]; then
|
||||
printf 'PYTHON_DISTRIBUTION=radixor\nPYTHON_VERSION=%s\n' "${BASH_REMATCH[1]}"
|
||||
elif [[ "${tag}" =~ ^python-models-standard@(${version_pattern})$ ]]; then
|
||||
printf 'PYTHON_DISTRIBUTION=radixor-models-standard\nPYTHON_VERSION=%s\n' "${BASH_REMATCH[1]}"
|
||||
else
|
||||
echo "Invalid Python release tag: ${tag}" >&2
|
||||
exit 2
|
||||
fi
|
||||
78
tools/run-published-accuracy-benchmarks.sh
Executable file
78
tools/run-published-accuracy-benchmarks.sh
Executable file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
report_date="${1:-$(date +%F)}"
|
||||
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${project_root}"
|
||||
|
||||
classpath_file="build/reports/jmh/jmh-runtime-classpath.txt"
|
||||
if [[ ! -s "${classpath_file}" ]]; then
|
||||
printf 'Missing %s; run ./gradlew writeJmhRuntimeClasspath --no-daemon first.\n' "${classpath_file}" >&2
|
||||
exit 1
|
||||
fi
|
||||
IFS= read -r jmh_classpath < "${classpath_file}"
|
||||
|
||||
tmp_dir="${project_root}/build/tmp/jmh"
|
||||
report_dir="${project_root}/build/reports/jmh"
|
||||
mkdir -p "${tmp_dir}" "${report_dir}"
|
||||
|
||||
accuracy_include='^org\.egothor\.stemmer\.benchmark\.(EnglishHunspellStemmerComparisonBenchmarkQuality|EnglishStemmerComparisonBenchmarkQuality|HunspellStemmerComparisonBenchmarkQuality|StemmerComparisonBenchmarkQuality)\..*$'
|
||||
coverage_include='^org\.egothor\.stemmer\.benchmark\.EnglishRadixorDictionaryCoverageBenchmark\.exactRootAgreement$'
|
||||
accuracy_csv="${report_dir}/stemmer-accuracy-${report_date}.csv"
|
||||
|
||||
common_arguments=(
|
||||
-f 0
|
||||
-wi 0
|
||||
-i 1
|
||||
-r 1ms
|
||||
-t 1
|
||||
-bm avgt
|
||||
-tu ns
|
||||
-rf csv
|
||||
)
|
||||
|
||||
java -Djava.io.tmpdir="${tmp_dir}" -Xms6g -Xmx6g \
|
||||
-cp "${jmh_classpath}" org.openjdk.jmh.Main \
|
||||
"${accuracy_include}" "${common_arguments[@]}" \
|
||||
-rff "${accuracy_csv}" \
|
||||
-o "${report_dir}/stemmer-accuracy-${report_date}.txt"
|
||||
|
||||
for candidate in SNOWBALL_CZECH_DIRECT SNOWBALL_PERSIAN_DIRECT SNOWBALL_POLISH_DIRECT; do
|
||||
if ! awk -F, -v candidate="${candidate}" '
|
||||
NR > 1 {
|
||||
value = $8
|
||||
gsub(/^"|"$/, "", value)
|
||||
if (value == candidate) {
|
||||
found = 1
|
||||
}
|
||||
}
|
||||
END { exit found ? 0 : 1 }
|
||||
' "${accuracy_csv}"; then
|
||||
printf 'Exact-root report omits %s.\n' "${candidate}" >&2
|
||||
exit 1
|
||||
fi
|
||||
for counter in correctMatches evaluatedTokens changedCorrectMatches changedEvaluatedTokens \
|
||||
rootPreservedMatches rootEvaluatedTokens; do
|
||||
if ! awk -F, -v candidate="${candidate}" -v suffix="exactRootAgreement:${counter}" '
|
||||
NR > 1 {
|
||||
benchmark = $1
|
||||
value = $8
|
||||
gsub(/^"|"$/, "", benchmark)
|
||||
gsub(/^"|"$/, "", value)
|
||||
if (value == candidate && benchmark ~ suffix "$") {
|
||||
found = 1
|
||||
}
|
||||
}
|
||||
END { exit found ? 0 : 1 }
|
||||
' "${accuracy_csv}"; then
|
||||
printf 'Exact-root report omits %s for %s.\n' "${counter}" "${candidate}" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
java -Djava.io.tmpdir="${tmp_dir}" -Xms6g -Xmx6g \
|
||||
-cp "${jmh_classpath}" org.openjdk.jmh.Main \
|
||||
"${coverage_include}" "${common_arguments[@]}" \
|
||||
-rff "${report_dir}/english-coverage-accuracy-${report_date}.csv" \
|
||||
-o "${report_dir}/english-coverage-accuracy-${report_date}.txt"
|
||||
174
tools/tests/test_update_benchmark_documentation.py
Normal file
174
tools/tests/test_update_benchmark_documentation.py
Normal file
@@ -0,0 +1,174 @@
|
||||
###############################################################################
|
||||
# Copyright (C) 2026, Leo Galambos
|
||||
# All rights reserved.
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
#
|
||||
# 1. Redistributions of source code must retain the above copyright notice,
|
||||
# this list of conditions and the following disclaimer.
|
||||
#
|
||||
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
# this list of conditions and the following disclaimer in the documentation
|
||||
# and/or other materials provided with the distribution.
|
||||
#
|
||||
# 3. Neither the name of the copyright holder nor the names of its contributors
|
||||
# may be used to endorse or promote products derived from this software
|
||||
# without specific prior written permission.
|
||||
#
|
||||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
# POSSIBILITY OF SUCH DAMAGE.
|
||||
###############################################################################
|
||||
|
||||
"""Tests for deterministic publication of current Java benchmark reports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "update-benchmark-documentation.py"
|
||||
SPEC = importlib.util.spec_from_file_location("update_benchmark_documentation", SCRIPT)
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = MODULE
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
def speed_row(score: float, error: float = 1_000.0) -> dict[str, str]:
|
||||
"""Create the JMH fields consumed by the documentation updater."""
|
||||
return {
|
||||
"Score": str(score),
|
||||
"Score Error (99.9%)": str(error),
|
||||
"Unit": "ns/op",
|
||||
}
|
||||
|
||||
|
||||
class BenchmarkDocumentationTest(unittest.TestCase):
|
||||
"""Covers current-report selection and measured-corpus arithmetic."""
|
||||
|
||||
def test_speed_uses_timing_token_denominator(self) -> None:
|
||||
radixor = MODULE.Key("example.persianRadixor", ())
|
||||
snowball = MODULE.Key(
|
||||
"example.snowballDirect", (("languageCaseName", "PERSIAN"),)
|
||||
)
|
||||
data = MODULE.JmhData(
|
||||
primary={
|
||||
radixor: speed_row(250_000.0),
|
||||
snowball: speed_row(500_000.0),
|
||||
},
|
||||
auxiliary={},
|
||||
)
|
||||
text = """## Speed
|
||||
|
||||
| Stemmer | Benchmark method | Score ms/op | Error ms | ns/token | Relative vs Radixor | Note |
|
||||
| --- | --- | ---: | ---: | ---: | ---: | --- |
|
||||
| Radixor | `persianRadixor` | pending | pending | pending | pending | baseline |
|
||||
| Official Snowball direct | `snowballDirect[PERSIAN]` | pending | pending | pending | pending | direct |
|
||||
|
||||
## Interpretation Notes
|
||||
"""
|
||||
|
||||
updated = MODULE.update_speed_table(text, data, 5_000, "FA_IR")
|
||||
|
||||
self.assertIn("| 0.250 | 0.001 | 50.0 | 1.000 |", updated)
|
||||
self.assertIn("| 0.500 | 0.001 | 100.0 | 2.000 |", updated)
|
||||
self.assertEqual(
|
||||
updated,
|
||||
MODULE.update_speed_table(updated, data, 5_000, "FA_IR"),
|
||||
)
|
||||
|
||||
def test_language_parameter_resolves_ambiguous_method(self) -> None:
|
||||
czech = MODULE.Key(
|
||||
"example.luceneHunspellStemFilter",
|
||||
(("languageCaseName", "CZECH"),),
|
||||
)
|
||||
polish = MODULE.Key(
|
||||
"example.luceneHunspellStemFilter",
|
||||
(("languageCaseName", "POLISH"),),
|
||||
)
|
||||
data = MODULE.JmhData(
|
||||
primary={czech: speed_row(10.0), polish: speed_row(20.0)},
|
||||
auxiliary={},
|
||||
)
|
||||
|
||||
selected = MODULE.select_speed_key("luceneHunspellStemFilter", data, "CS_CZ")
|
||||
|
||||
self.assertEqual(czech, selected)
|
||||
|
||||
def test_partially_pending_speed_row_is_rejected(self) -> None:
|
||||
text = """## Speed
|
||||
|
||||
| Stemmer | Benchmark method | Score ms/op | Error ms | ns/token | Relative vs Radixor | Note |
|
||||
| --- | --- | ---: | ---: | ---: | ---: | --- |
|
||||
| Radixor | `persianRadixor` | pending | 0.001 | pending | pending | baseline |
|
||||
|
||||
## Interpretation Notes
|
||||
"""
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "Partially pending speed row"):
|
||||
MODULE.update_speed_table(
|
||||
text, MODULE.JmhData(primary={}, auxiliary={}), 5_000, "FA_IR"
|
||||
)
|
||||
|
||||
def test_malformed_accuracy_row_is_rejected(self) -> None:
|
||||
text = """## Accuracy
|
||||
|
||||
| Stemmer | All exact | Changed exact | Root preserved | Note |
|
||||
| --- | ---: | ---: | ---: | --- |
|
||||
| Official Snowball direct | 75.00% | 62.500% | 100.000% | direct |
|
||||
|
||||
## Speed
|
||||
"""
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "Malformed accuracy row"):
|
||||
MODULE.update_accuracy_table(
|
||||
text, MODULE.JmhData(primary={}, auxiliary={}), "FA_IR", {}
|
||||
)
|
||||
|
||||
def test_pending_accuracy_row_uses_current_auxiliary_counters(self) -> None:
|
||||
key = MODULE.Key(
|
||||
"example.exactRootAgreement",
|
||||
(("candidateName", "SNOWBALL_PERSIAN_DIRECT"),),
|
||||
)
|
||||
counters = {
|
||||
"correctMatches": 75.0,
|
||||
"evaluatedTokens": 100.0,
|
||||
"changedCorrectMatches": 50.0,
|
||||
"changedEvaluatedTokens": 80.0,
|
||||
"rootPreservedMatches": 20.0,
|
||||
"rootEvaluatedTokens": 20.0,
|
||||
}
|
||||
data = MODULE.JmhData(primary={}, auxiliary={key: counters})
|
||||
text = """## Accuracy
|
||||
|
||||
| Stemmer | All exact | Changed exact | Root preserved | Note |
|
||||
| --- | ---: | ---: | ---: | --- |
|
||||
| Official Snowball direct | pending | pending | pending | direct |
|
||||
|
||||
## Speed
|
||||
"""
|
||||
|
||||
updated = MODULE.update_accuracy_table(text, data, "FA_IR", {})
|
||||
|
||||
self.assertIn("| 75.000% | 62.500% | 100.000% |", updated)
|
||||
self.assertEqual(
|
||||
updated,
|
||||
MODULE.update_accuracy_table(updated, data, "FA_IR", {}),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -104,7 +104,6 @@ def parse_arguments() -> argparse.Namespace:
|
||||
parser.add_argument("--docs-root", type=Path, default=Path("docs"))
|
||||
parser.add_argument("--readme", type=Path, default=Path("README.md"))
|
||||
parser.add_argument("--corpus", type=Path, required=True)
|
||||
parser.add_argument("--old-comparison", type=Path, required=True)
|
||||
parser.add_argument("--accuracy", type=Path, required=True)
|
||||
parser.add_argument("--speed", type=Path, required=True)
|
||||
parser.add_argument("--coverage-accuracy", type=Path, required=True)
|
||||
@@ -165,9 +164,13 @@ def read_corpora(path: Path) -> dict[str, dict[str, object]]:
|
||||
)
|
||||
entry["commands"].append((row["Command class"], int(row["Command count"])))
|
||||
if set(corpora) != set(LANGUAGES.values()):
|
||||
raise ValueError(f"Corpus report languages differ from documentation languages: {sorted(corpora)}")
|
||||
raise ValueError(
|
||||
f"Corpus report languages differ from documentation languages: {sorted(corpora)}"
|
||||
)
|
||||
if any(entry["model"] == "pl-pl-polimorf" for entry in corpora.values()):
|
||||
raise ValueError("The default-model corpus report must not contain pl-pl-polimorf.")
|
||||
raise ValueError(
|
||||
"The default-model corpus report must not contain pl-pl-polimorf."
|
||||
)
|
||||
return corpora
|
||||
|
||||
|
||||
@@ -180,11 +183,11 @@ def render_corpus_sections(language: str, entry: dict[str, object]) -> str:
|
||||
lines = [
|
||||
"## Dictionary Corpus",
|
||||
"",
|
||||
"| Model ID | Model version | Language | Dictionary rows | Complete quality tokens | Already-root tokens | Changed speed tokens |",
|
||||
"| --- | --- | --- | ---: | ---: | ---: | ---: |",
|
||||
"| Model ID | Model version | Language | Dictionary rows | Complete quality tokens | Already-root tokens | Changed tokens | JMH timing tokens |",
|
||||
"| --- | --- | --- | ---: | ---: | ---: | ---: | ---: |",
|
||||
f"| `{entry['model']}` | `{entry['version']}` | `{language}` | {format_integer(int(entry['rows']))} | "
|
||||
f"{format_integer(total)} | {format_integer(int(entry['roots']))} | "
|
||||
f"{format_integer(int(entry['changed']))} |",
|
||||
f"{format_integer(int(entry['changed']))} | {format_integer(int(entry['timing']))} |",
|
||||
"",
|
||||
"## Radixor Patch Command Distribution",
|
||||
"",
|
||||
@@ -205,7 +208,9 @@ def render_corpus_sections(language: str, entry: dict[str, object]) -> str:
|
||||
f"{100.0 * count / total:.3f}% |"
|
||||
)
|
||||
if command_total != total:
|
||||
raise ValueError(f"Patch command count {command_total} differs from corpus total {total} for {language}.")
|
||||
raise ValueError(
|
||||
f"Patch command count {command_total} differs from corpus total {total} for {language}."
|
||||
)
|
||||
return "\n".join(lines) + "\n\n"
|
||||
|
||||
|
||||
@@ -252,17 +257,27 @@ def select_accuracy_key(
|
||||
for key, counters in data.auxiliary.items()
|
||||
if AUXILIARY_NAMES.issubset(counters)
|
||||
and language_words.issubset(
|
||||
words(key.benchmark + " " + " ".join(f"{name} {value}" for name, value in key.parameters))
|
||||
words(
|
||||
key.benchmark
|
||||
+ " "
|
||||
+ " ".join(f"{name} {value}" for name, value in key.parameters)
|
||||
)
|
||||
)
|
||||
]
|
||||
if not matches:
|
||||
raise ValueError(f"No current JMH accuracy row matches language {language} and label {label}.")
|
||||
raise ValueError(
|
||||
f"No current JMH accuracy row matches language {language} and label {label}."
|
||||
)
|
||||
|
||||
label_words = words(label) - language_words
|
||||
|
||||
def score(key: Key) -> tuple[int, int, int, int, int]:
|
||||
identity_words = (
|
||||
words(key.benchmark + " " + " ".join(f"{name} {value}" for name, value in key.parameters))
|
||||
words(
|
||||
key.benchmark
|
||||
+ " "
|
||||
+ " ".join(f"{name} {value}" for name, value in key.parameters)
|
||||
)
|
||||
- language_words
|
||||
)
|
||||
return (
|
||||
@@ -273,9 +288,13 @@ def select_accuracy_key(
|
||||
int(language_words.issubset(words(key.benchmark))),
|
||||
)
|
||||
|
||||
ranked = sorted(((score(key), key) for key in matches), reverse=True, key=lambda item: item[0])
|
||||
ranked = sorted(
|
||||
((score(key), key) for key in matches), reverse=True, key=lambda item: item[0]
|
||||
)
|
||||
if ranked[0][0][0] == 0:
|
||||
raise ValueError(f"No implementation identity words match accuracy label {label} for {language}.")
|
||||
raise ValueError(
|
||||
f"No implementation identity words match accuracy label {label} for {language}."
|
||||
)
|
||||
if len(ranked) > 1 and ranked[0][0] == ranked[1][0]:
|
||||
raise ValueError(
|
||||
f"Ambiguous current JMH accuracy identity for {label} in {language}: "
|
||||
@@ -300,11 +319,31 @@ def update_accuracy_table(
|
||||
) -> str:
|
||||
start = text.index("## Accuracy")
|
||||
end = text.index("## Speed", start)
|
||||
section = text[start:end]
|
||||
section = text[start:end].rstrip()
|
||||
output: list[str] = []
|
||||
for line in section.splitlines():
|
||||
cells = [cell.strip() for cell in line.split("|")[1:-1]]
|
||||
if len(cells) == 5 and all(re.fullmatch(r"\d+\.\d{3}%", cell) for cell in cells[1:4]):
|
||||
measured = len(cells) == 5 and all(
|
||||
re.fullmatch(r"\d+\.\d{3}%", cell) for cell in cells[1:4]
|
||||
)
|
||||
pending = len(cells) == 5 and all(cell == "pending" for cell in cells[1:4])
|
||||
partial_pending = (
|
||||
len(cells) == 5
|
||||
and any(cell == "pending" for cell in cells[1:4])
|
||||
and not pending
|
||||
)
|
||||
if partial_pending:
|
||||
raise ValueError(
|
||||
f"Partially pending accuracy row for {cells[0]} in {language}."
|
||||
)
|
||||
if (
|
||||
len(cells) == 5
|
||||
and cells[0] not in {"Stemmer", "---"}
|
||||
and not measured
|
||||
and not pending
|
||||
):
|
||||
raise ValueError(f"Malformed accuracy row for {cells[0]} in {language}.")
|
||||
if measured or pending:
|
||||
if cells[0] == "Radixor":
|
||||
values = rounded_accuracy(corpus_accuracy(corpus))
|
||||
else:
|
||||
@@ -312,12 +351,8 @@ def update_accuracy_table(
|
||||
values = rounded_accuracy(accuracy(new_data, key))
|
||||
cells[1:4] = [f"{value}%" for value in values]
|
||||
line = "| " + " | ".join(cells) + " |"
|
||||
elif language == "HE_IL" and len(cells) == 5 and cells[0] == "Radixor" and cells[1] == "pending":
|
||||
values = rounded_accuracy(corpus_accuracy(corpus))
|
||||
cells[1:4] = [f"{value}%" for value in values]
|
||||
line = "| " + " | ".join(cells) + " |"
|
||||
output.append(line)
|
||||
replacement = "\n".join(output) + "\n\n"
|
||||
replacement = "\n".join(output).rstrip() + "\n\n"
|
||||
return text[:start] + replacement + text[end:]
|
||||
|
||||
|
||||
@@ -328,9 +363,9 @@ def method_and_parameter(display: str) -> tuple[str, str]:
|
||||
return match.group(1), match.group(2) or ""
|
||||
|
||||
|
||||
def speed_matches(display: str, data: JmhData) -> list[Key]:
|
||||
def speed_matches(display: str, data: JmhData, language: str) -> list[Key]:
|
||||
method, language_case = method_and_parameter(display)
|
||||
return [
|
||||
matches = [
|
||||
key
|
||||
for key, row in data.primary.items()
|
||||
if key.method == method
|
||||
@@ -338,57 +373,54 @@ def speed_matches(display: str, data: JmhData) -> list[Key]:
|
||||
and key not in data.auxiliary
|
||||
and row["Unit"] == "ns/op"
|
||||
]
|
||||
if language_case or len(matches) <= 1:
|
||||
return matches
|
||||
language_words = LANGUAGE_IDENTITY_WORDS[language]
|
||||
return [
|
||||
key
|
||||
for key in matches
|
||||
if language_words.issubset(
|
||||
words(" ".join(value for _, value in key.parameters))
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def closest_speed_key(display: str, score_ms: float, data: JmhData) -> tuple[Key, float]:
|
||||
matches = speed_matches(display, data)
|
||||
if not matches:
|
||||
raise ValueError(f"No JMH speed row matches {display}")
|
||||
selected = min(matches, key=lambda key: abs(float(data.primary[key]["Score"]) / 1_000_000.0 - score_ms))
|
||||
difference = abs(float(data.primary[selected]["Score"]) / 1_000_000.0 - score_ms)
|
||||
return selected, difference
|
||||
|
||||
|
||||
def select_speed_key(display: str, published_score_ms: float, old_data: JmhData, new_data: JmhData) -> Key:
|
||||
current, current_difference = closest_speed_key(display, published_score_ms, new_data)
|
||||
if current_difference < 0.001:
|
||||
return current
|
||||
selected, difference = closest_speed_key(display, published_score_ms, old_data)
|
||||
if difference >= 0.001:
|
||||
raise ValueError(f"Old speed row for {display} differs by {difference:.6f} ms from documentation.")
|
||||
return selected
|
||||
def select_speed_key(display: str, data: JmhData, language: str) -> Key:
|
||||
matches = speed_matches(display, data, language)
|
||||
if len(matches) != 1:
|
||||
raise ValueError(
|
||||
f"Expected one current JMH speed row for {display} in {language}, found {len(matches)}."
|
||||
)
|
||||
return matches[0]
|
||||
|
||||
|
||||
def update_speed_table(
|
||||
text: str,
|
||||
old_data: JmhData,
|
||||
new_data: JmhData,
|
||||
changed_tokens: int,
|
||||
timing_tokens: int,
|
||||
language: str,
|
||||
) -> str:
|
||||
start = text.index("## Speed")
|
||||
end = text.index("## Interpretation Notes", start)
|
||||
section = text[start:end]
|
||||
section = text[start:end].rstrip()
|
||||
parsed: list[tuple[str, list[str] | None, Key | None]] = []
|
||||
radixor_score = math.nan
|
||||
for line in section.splitlines():
|
||||
cells = [cell.strip() for cell in line.split("|")[1:-1]]
|
||||
if len(cells) == 7 and cells[1].startswith("`") and cells[1].endswith("`"):
|
||||
display = cells[1].strip("`")
|
||||
if cells[2] == "pending" and language == "HE_IL":
|
||||
matches = [
|
||||
key
|
||||
for key, row in new_data.primary.items()
|
||||
if key.method == "hebrewRadixor" and key not in new_data.auxiliary and row["Unit"] == "ns/op"
|
||||
]
|
||||
if len(matches) != 1:
|
||||
raise ValueError(f"Expected one Hebrew speed row, found {len(matches)}")
|
||||
key = matches[0]
|
||||
elif re.fullmatch(r"\d+\.\d{3}", cells[2]):
|
||||
key = select_speed_key(display, float(cells[2]), old_data, new_data)
|
||||
else:
|
||||
parsed.append((line, None, None))
|
||||
continue
|
||||
pending = all(cell == "pending" for cell in cells[2:6])
|
||||
measured = all(re.fullmatch(r"\d+\.\d+", cell) for cell in cells[2:6])
|
||||
partial_pending = (
|
||||
any(cell == "pending" for cell in cells[2:6]) and not pending
|
||||
)
|
||||
if partial_pending:
|
||||
raise ValueError(
|
||||
f"Partially pending speed row for {cells[0]} in {language}."
|
||||
)
|
||||
if not pending and not measured:
|
||||
raise ValueError(f"Malformed speed row for {cells[0]} in {language}.")
|
||||
key = select_speed_key(display, new_data, language)
|
||||
if key not in new_data.primary:
|
||||
raise ValueError(f"New JMH report omits speed key {key}")
|
||||
score = float(new_data.primary[key]["Score"])
|
||||
@@ -408,18 +440,17 @@ def update_speed_table(
|
||||
error = float(row["Score Error (99.9%)"])
|
||||
cells[2] = f"{score / 1_000_000.0:.3f}"
|
||||
cells[3] = f"{error / 1_000_000.0:.3f}"
|
||||
cells[4] = f"{score / changed_tokens:.1f}"
|
||||
cells[4] = f"{score / timing_tokens:.1f}"
|
||||
cells[5] = f"{score / radixor_score:.3f}"
|
||||
line = "| " + " | ".join(cells) + " |"
|
||||
output.append(line)
|
||||
replacement = "\n".join(output) + "\n\n"
|
||||
replacement = "\n".join(output).rstrip() + "\n\n"
|
||||
return text[:start] + replacement + text[end:]
|
||||
|
||||
|
||||
def update_language_pages(
|
||||
docs_root: Path,
|
||||
corpora: dict[str, dict[str, object]],
|
||||
old_data: JmhData,
|
||||
accuracy_data: JmhData,
|
||||
speed_data: JmhData,
|
||||
) -> None:
|
||||
@@ -429,7 +460,11 @@ def update_language_pages(
|
||||
text = path.read_text(encoding="utf-8")
|
||||
corpus_start = text.index("## Dictionary Corpus")
|
||||
accuracy_start = text.index("## Accuracy", corpus_start)
|
||||
text = text[:corpus_start] + render_corpus_sections(language, corpora[language]) + text[accuracy_start:]
|
||||
text = (
|
||||
text[:corpus_start]
|
||||
+ render_corpus_sections(language, corpora[language])
|
||||
+ text[accuracy_start:]
|
||||
)
|
||||
text = re.sub(
|
||||
r"Speed uses JMH average time, \d+ warmup iterations, \d+ measurement iterations, "
|
||||
r"\d+ forks?, and 1 thread\.",
|
||||
@@ -439,11 +474,15 @@ def update_language_pages(
|
||||
count=1,
|
||||
)
|
||||
text = update_accuracy_table(text, accuracy_data, language, corpora[language])
|
||||
text = update_speed_table(text, old_data, speed_data, int(corpora[language]["changed"]), language)
|
||||
text = update_speed_table(
|
||||
text, speed_data, int(corpora[language]["timing"]), language
|
||||
)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
|
||||
|
||||
def update_corpora_reference(docs_root: Path, corpora: dict[str, dict[str, object]]) -> None:
|
||||
def update_corpora_reference(
|
||||
docs_root: Path, corpora: dict[str, dict[str, object]]
|
||||
) -> None:
|
||||
path = docs_root / "benchmarks" / "reference" / "corpora.md"
|
||||
text = path.read_text(encoding="utf-8")
|
||||
original_header = "| Language resource |"
|
||||
@@ -453,7 +492,9 @@ def update_corpora_reference(docs_root: Path, corpora: dict[str, dict[str, objec
|
||||
elif current_header in text:
|
||||
table_start = text.index(current_header)
|
||||
else:
|
||||
raise ValueError("The corpora reference contains no recognized corpus-table header.")
|
||||
raise ValueError(
|
||||
"The corpora reference contains no recognized corpus-table header."
|
||||
)
|
||||
table_end = text.index("\n\n", table_start)
|
||||
lines = [
|
||||
"| Default model ID | Version | SHA-256 | Language | Dictionary rows | Total tokens | Already-root tokens | Changed tokens | Speed timing tokens |",
|
||||
@@ -468,7 +509,9 @@ def update_corpora_reference(docs_root: Path, corpora: dict[str, dict[str, objec
|
||||
f"{format_integer(int(entry['changed']))} | {format_integer(int(entry['timing']))} |"
|
||||
)
|
||||
replacement = "\n".join(lines)
|
||||
path.write_text(text[:table_start] + replacement + text[table_end:], encoding="utf-8")
|
||||
path.write_text(
|
||||
text[:table_start] + replacement + text[table_end:], encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def coverage_rows(accuracy_data: JmhData, speed_data: JmhData) -> list[str]:
|
||||
@@ -558,23 +601,31 @@ def update_coverage(
|
||||
"quality/speed envelope: the amount and quality of dictionary knowledge affect stemming precision,\n"
|
||||
"while contracted tries reduce lookup cost in uniform regions of the compiled graph.\n\n"
|
||||
)
|
||||
index.write_text(index_text[:key_start] + key_section + index_text[key_end:], encoding="utf-8")
|
||||
index.write_text(
|
||||
index_text[:key_start] + key_section + index_text[key_end:], encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
arguments = parse_arguments()
|
||||
corpora = read_corpora(arguments.corpus)
|
||||
old_data = read_jmh(arguments.old_comparison)
|
||||
accuracy_data = read_jmh(arguments.accuracy)
|
||||
speed_data = read_jmh(arguments.speed)
|
||||
coverage_accuracy_data = read_jmh(arguments.coverage_accuracy)
|
||||
coverage_speed_data = read_jmh(arguments.coverage_speed)
|
||||
measured_keys = set(accuracy_data.primary) | set(speed_data.primary)
|
||||
if any("PolishPolimorf" in key.benchmark for key in measured_keys):
|
||||
raise ValueError("A published report contains the excluded PolishPolimorf benchmark.")
|
||||
update_language_pages(arguments.docs_root, corpora, old_data, accuracy_data, speed_data)
|
||||
raise ValueError(
|
||||
"A published report contains the excluded PolishPolimorf benchmark."
|
||||
)
|
||||
update_language_pages(arguments.docs_root, corpora, accuracy_data, speed_data)
|
||||
update_corpora_reference(arguments.docs_root, corpora)
|
||||
update_coverage(arguments.docs_root, arguments.readme, coverage_accuracy_data, coverage_speed_data)
|
||||
update_coverage(
|
||||
arguments.docs_root,
|
||||
arguments.readme,
|
||||
coverage_accuracy_data,
|
||||
coverage_speed_data,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user