diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 0ccece7..c10b3f0 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -29,6 +29,8 @@ jobs:
run: python3 -m unittest discover -s tests -p 'test_*.py'
- name: Compare performance with the pinned reference
run: python3 scripts/benchmark.py
+ - name: Compare linear sieve with its pinned reference
+ run: python3 scripts/benchmark.py --config benchmarks/linear.json --output test-results/performance/linear
- name: Upload performance evidence
if: always()
uses: actions/upload-artifact@v7
diff --git a/AGENTS.md b/AGENTS.md
index f0d1dcf..93a1c5d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -7,3 +7,12 @@
- Never commit `RELEASE_PLAN.md`.
- Explain progress to the user throughout the work.
- See `docs/performance.md` for benchmark execution and baseline changes.
+
+# Code and documentation
+
+- Keep code simple, concise, and easy for humans to read. Avoid unnecessary abstractions without sacrificing performance.
+- Write direct, concise documentation. Avoid em dashes, excessive semicolons, filler, and verbose AI-style prose.
+- Never change `README.md` without the programmer's explicit authorization.
+- Name implementation reports and plans in `docs` as `YYYY-MM-DD-topic.md` and include the date in a heading.
+- Use the actual date of the documented event or result. Do not present historical results as current checks.
+- Keep versioned release-note filenames. Permanent guides may also keep their existing names.
diff --git a/benchmarks/harness.rs b/benchmarks/harness.rs
index 946072c..e80a04d 100644
--- a/benchmarks/harness.rs
+++ b/benchmarks/harness.rs
@@ -19,7 +19,7 @@ fn sample() {
.parse()
.unwrap(),
);
- let primes = tauri::async_runtime::block_on(super::calculate(limit));
+ let primes = tauri::async_runtime::block_on(super::benchmark_calculate(limit));
let (count, last) = match limit {
1_000 => (168, 997),
100_000 => (9_592, 99_991),
@@ -32,9 +32,9 @@ fn sample() {
assert!(primes.windows(2).all(|pair| pair[0] < pair[1]));
let run = || match operation.as_str() {
"calculate" => {
- black_box(tauri::async_runtime::block_on(super::calculate(black_box(
- limit,
- ))));
+ black_box(tauri::async_runtime::block_on(super::benchmark_calculate(
+ black_box(limit),
+ )));
}
"serialize" => {
black_box(serde_json::to_vec(black_box(&primes)).unwrap());
diff --git a/benchmarks/linear.json b/benchmarks/linear.json
new file mode 100644
index 0000000..ac0a022
--- /dev/null
+++ b/benchmarks/linear.json
@@ -0,0 +1,33 @@
+{
+ "baseline": "c6a0ac9665146f48381378f365c62214e0c2a903",
+ "rounds": 9,
+ "sample_ms": 300,
+ "max_slowdown": 0.2,
+ "cases": [
+ {
+ "operation": "calculate",
+ "limit": 1000
+ },
+ {
+ "operation": "calculate",
+ "limit": 100000
+ },
+ {
+ "operation": "calculate",
+ "limit": 1000000
+ },
+ {
+ "operation": "calculate",
+ "limit": 10000000
+ },
+ {
+ "operation": "serialize",
+ "limit": 100000
+ },
+ {
+ "operation": "serialize",
+ "limit": 1000000
+ }
+ ],
+ "command": "calculate_linear"
+}
diff --git a/docs/performance.md b/docs/performance.md
index 1c5f81b..6c300b2 100644
--- a/docs/performance.md
+++ b/docs/performance.md
@@ -6,6 +6,11 @@ command and JSON serialization with commit
The reference is pinned in `benchmarks/config.json`: moving `dev` or `main` does
not silently reset the budget and allow a series of small slowdowns to accumulate.
+The linear sieve has a separate reference in `benchmarks/linear.json`, pinned to
+its first implementation, `c6a0ac9665146f48381378f365c62214e0c2a903`. Both algorithms
+use the same workloads and regression budget. The linear sieve is an educational
+alternative and does not need to match Eratosthenes in speed.
+
Both revisions are compiled with `cargo test --release --locked`, their own
Cargo manifests and lockfiles, and the same installed Rust toolchain. A shared
measurement harness is injected into temporary copies; it never replaces the
@@ -40,6 +45,7 @@ With Python 3.10+, Git, Rust and the normal Tauri native build dependencies:
```sh
python3 -m unittest discover -s tests -p 'test_*.py'
python3 scripts/benchmark.py
+python3 scripts/benchmark.py --config benchmarks/linear.json --output test-results/performance/linear
```
The candidate includes local Rust edits. The baseline commit must be available
@@ -52,6 +58,11 @@ For a deliberate investigation, use `--baseline
+ {#if algorithm === "calculate"} + Marks multiples of each prime to find all primes up to your limit. Its work grows as O(n + log log n). The optimized implementation is the default for fast calculations. + {:else} + Marks each composite once using its smallest prime factor. Its work grows as O(n), but + it may use more memory and run slower than the optimized Eratosthenes sieve. + {/if} + Both algorithms return the same exact primes. Larger limits require more time and memory. +
+{primes.length} prime numbers calculated up to {lastFinalValue}
diff --git a/tests/smoke-linux.py b/tests/smoke-linux.py index 536b3fd..1686a39 100644 --- a/tests/smoke-linux.py +++ b/tests/smoke-linux.py @@ -150,45 +150,59 @@ def ready(): screenshot("initial") print("PASS: bundled CSS, dynamic editor styles, initial SVG graph", flush=True) - for limit in [1, 2, 30, 1000, 10000, 100000, 100]: - calculate(limit) - if limit == 1: - assert "No prime numbers in this range." in js("return document.body.innerText") - else: - graph = ".chart canvas" if limit >= 10000 else ".frappe-chart svg" - wait_for(lambda: js("return !!document.querySelector(arguments[0])", graph), "graph renders") - if limit == 10000: - js("window.smokeCanvas = document.querySelector('.chart canvas')") - if limit == 100000: - assert js("return window.smokeCanvas === document.querySelector('.chart canvas')") - click('[aria-label="Toggle graph fullscreen"]') - wait_for(lambda: js("return !!document.querySelector('.fullscreen .chart')"), "scientific fullscreen") - click('[aria-label="Toggle graph fullscreen"]') - wait_for(lambda: js("return !document.querySelector('.fullscreen')"), "exit scientific fullscreen") - js("document.querySelector('.chart').scrollIntoView()") - screenshot("scientific") - - check_clipboard(expected_primes(100)) - print("PASS: native clipboard", flush=True) - - js("window.smokeEditor = document.querySelector('.cm-editor')") - for label in ["Toggle primes fullscreen", "Toggle graph fullscreen"]: - click(f'[aria-label="{label}"]') - wait_for(lambda: js("return document.querySelectorAll('.fullscreen').length === 1"), label) - screenshot(label.lower().replace(" ", "-")) - click(f'[aria-label="{label}"]') - wait_for(lambda: js("return !document.querySelector('.fullscreen')"), "exit fullscreen") - - assert js("return window.smokeEditor === document.querySelector('.cm-editor')") - for chart_type in ["dygraph", "frappe", "dygraph", "frappe"]: + assert js("return document.querySelector('#algorithm').value") == "calculate" + for algorithm, heading in [("calculate", "Sieve of Eratosthenes"), + ("calculate_linear", "Linear sieve")]: + previous_stats = js("return document.querySelector('[aria-labelledby=stats-heading]').textContent") js(""" - const select = document.querySelector('select'); + const select = document.querySelector('#algorithm'); select.value = arguments[0]; select.dispatchEvent(new Event('change', {bubbles: true})); - """, chart_type) - selector = ".chart canvas" if chart_type == "dygraph" else ".frappe-chart svg" - wait_for(lambda: js("return !!document.querySelector(arguments[0])", selector), "chart switch") - print("PASS: fullscreen and repeated chart switching", flush=True) + """, algorithm) + wait_for(lambda: js("return document.querySelector('#algorithm-heading').textContent.trim()") == heading, + "algorithm explanation updates") + assert js("return document.querySelector('[aria-labelledby=stats-heading]').textContent") == previous_stats + js("window.scrollTo(0, 0)") + screenshot(algorithm) + for limit in [1, 2, 30, 1000, 10000, 100000, 100]: + calculate(limit) + if limit == 1: + assert "No prime numbers in this range." in js("return document.body.innerText") + else: + graph = ".chart canvas" if limit >= 10000 else ".frappe-chart svg" + wait_for(lambda: js("return !!document.querySelector(arguments[0])", graph), "graph renders") + if limit == 10000: + js("window.smokeCanvas = document.querySelector('.chart canvas')") + if limit == 100000: + assert js("return window.smokeCanvas === document.querySelector('.chart canvas')") + click('[aria-label="Toggle graph fullscreen"]') + wait_for(lambda: js("return !!document.querySelector('.fullscreen .chart')"), "scientific fullscreen") + click('[aria-label="Toggle graph fullscreen"]') + wait_for(lambda: js("return !document.querySelector('.fullscreen')"), "exit scientific fullscreen") + js("document.querySelector('.chart').scrollIntoView()") + screenshot(f"{algorithm}-scientific") + + check_clipboard(expected_primes(100)) + print("PASS: native clipboard", flush=True) + + js("window.smokeEditor = document.querySelector('.cm-editor')") + for label in ["Toggle primes fullscreen", "Toggle graph fullscreen"]: + click(f'[aria-label="{label}"]') + wait_for(lambda: js("return document.querySelectorAll('.fullscreen').length === 1"), label) + screenshot(algorithm + "-" + label.lower().replace(" ", "-")) + click(f'[aria-label="{label}"]') + wait_for(lambda: js("return !document.querySelector('.fullscreen')"), "exit fullscreen") + + assert js("return window.smokeEditor === document.querySelector('.cm-editor')") + for chart_type in ["dygraph", "frappe", "dygraph", "frappe"]: + js(""" + const select = document.querySelector('[aria-label="Graph type"]'); + select.value = arguments[0]; + select.dispatchEvent(new Event('change', {bubbles: true})); + """, chart_type) + selector = ".chart canvas" if chart_type == "dygraph" else ".frappe-chart svg" + wait_for(lambda: js("return !!document.querySelector(arguments[0])", selector), "chart switch") + print("PASS: fullscreen and repeated chart switching", flush=True) js("window.scrollTo(0, 0)") screenshot("final") diff --git a/tests/test_benchmark.py b/tests/test_benchmark.py index 35d7703..b5fb5a3 100644 --- a/tests/test_benchmark.py +++ b/tests/test_benchmark.py @@ -16,10 +16,15 @@ def test_invalid_configuration_cannot_disable_the_gate(self): config = json.loads((benchmark.ROOT / "benchmarks/config.json").read_text()) benchmark.validate_config(config) for key, value in [("rounds", 0), ("sample_ms", 0), ("max_slowdown", float("nan")), - ("max_slowdown", 1), ("cases", [])]: + ("max_slowdown", 1), ("cases", []), ("command", "unknown")]: with self.subTest(key=key, value=value), self.assertRaises(ValueError): benchmark.validate_config(dict(config, **{key: value})) + def test_both_algorithm_configurations_are_valid(self): + for name in ("config.json", "linear.json"): + config = json.loads((benchmark.ROOT / "benchmarks" / name).read_text()) + benchmark.validate_config(config) + def test_equal_performance_and_improvements_pass(self): for candidate in ([100] * 9, [70] * 9): self.assertFalse(benchmark.assess([100] * 9, candidate, 0.2)["regression"])