Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 4 additions & 4 deletions benchmarks/harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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());
Expand Down
33 changes: 33 additions & 0 deletions benchmarks/linear.json
Original file line number Diff line number Diff line change
@@ -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"
}
21 changes: 21 additions & 0 deletions docs/2026-09-28-codex-cloud-handoff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Codex Cloud handoff, 2026-09-28

## Current work

Continue [PR #85](https://github.com/DoodlesEpic/GraphPrime/pull/85) from `feat/linear-sieve` into `dev`. It adds a selectable linear sieve for issue #34. Eratosthenes remains the default. The algorithm explanation sits beside Stats in wide windows, with equal card widths. The input, algorithm selector, and Calculate button share a row.

The latest verified commit before this handoff is `c2d1187`. The [build and performance run](https://github.com/DoodlesEpic/GraphPrime/actions/runs/36033532381) passed on Linux, macOS, and Windows. CodeQL and dependency review also passed. These are results from September 24, not new checks. The AppImage was opened locally before the final card placement change, so that layout still needs visual review.

The user asked to review the PR personally. Keep it open until they explicitly approve merging. Leave `main`, release tags, and issue #34 unchanged.

## Cloud environment

Select the `feat/linear-sieve` branch. Use Node.js 24, Yarn 4.18 through Corepack, and Rust 1.98.1 with rustfmt and Clippy. On Debian or Ubuntu, install `libwebkit2gtk-4.1-dev`, `libayatana-appindicator3-dev`, `librsvg2-dev`, `libxdo-dev`, `libssl-dev`, `patchelf`, `squashfs-tools`, `webkit2gtk-driver`, `xvfb`, `dbus-x11`, `python3-gi`, and `gir1.2-gtk-3.0`. Run `corepack enable` and `yarn install --immutable` during setup. CI uses Ubuntu 22.04 for Linux packages.

Relevant checks are `yarn check`, `yarn lint`, `yarn test`, `cargo fmt --manifest-path src-tauri/Cargo.toml --check`, `cargo clippy --release --locked --all-targets --manifest-path src-tauri/Cargo.toml -- -D warnings`, and the performance commands in `docs/performance.md`. For a packaged Linux GUI check, run `yarn build:linux` and then the AppImage through `dbus-run-session -- xvfb-run -a python3 tests/smoke-linux.py <AppImage path>`. The Linux CI job shows the exact build and test sequence.

## Work after PR review

After the user approves the PR, merge it into `dev` while preserving commits, sync the authorized remotes, and verify CI. Keep issue #34 open because algorithm selection and explanation are only part of that issue. The previously planned documentation cleanup is a separate branch from an updated `dev`: simplify the five existing docs, including release notes, while preserving commands, links, performance criteria, and download instructions. Do not edit `README.md` without explicit authorization. Follow `AGENTS.md` and do not commit `RELEASE_PLAN.md`.

Local uncommitted changes were not transferred: `Cargo.toml` only adds empty `features = []` lists, and the four generated Tauri schemas have formatting changes without JSON content changes. Neither is needed for this PR. The schemas remain tracked because Tauri uses them for capability editing in IDEs.
11 changes: 11 additions & 0 deletions docs/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -52,6 +58,11 @@ For a deliberate investigation, use `--baseline <commit>`. Update the pinned
baseline only in a reviewed task explaining the accepted performance tradeoff,
and retain the before/after report. Do not refresh it automatically after a merge.

To compare algorithms directly, add `--baseline-command calculate` to the linear
command above. This compares the candidate's linear sieve with primal from the
linear reference commit. The report names both commands. A slower alternative
can fail this diagnostic comparison without failing its own regression check.

## Branch workflow

Start each task from an up-to-date `dev` and create a dedicated branch. Commit
Expand Down
21 changes: 16 additions & 5 deletions scripts/benchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@
import tempfile

ROOT = Path(__file__).resolve().parents[1]
COMMANDS = ("calculate", "calculate_linear")


def command(args, **kwargs):
return subprocess.check_output(args, text=True, **kwargs).strip()


def validate_config(config):
if config.get("command", "calculate") not in COMMANDS:
raise ValueError("Unsupported calculation command")
if not isinstance(config["rounds"], int) or config["rounds"] < 5:
raise ValueError("At least five rounds are required")
if not 100 <= config["sample_ms"] <= 10000:
Expand Down Expand Up @@ -48,7 +51,7 @@ def assess(baseline, candidate, max_slowdown):
"regression": regression, "ratios": ratios}


def prepare(destination, revision=None):
def prepare(destination, revision=None, command_name="calculate"):
if revision:
archive = subprocess.check_output(["git", "archive", revision], cwd=ROOT)
with tarfile.open(fileobj=io.BytesIO(archive)) as source:
Expand All @@ -66,6 +69,7 @@ def prepare(destination, revision=None):
(assets / "index.html").write_text("<!doctype html><title>Benchmark</title>")
main = destination / "src-tauri/src/main.rs"
with main.open("a") as output:
output.write(f'\n#[cfg(test)]\nuse {command_name} as benchmark_calculate;\n')
output.write('\n#[cfg(test)]\n#[path = "benchmark_harness.rs"]\nmod performance;\n')
shutil.copyfile(ROOT / "benchmarks/harness.rs", main.with_name("benchmark_harness.rs"))

Expand Down Expand Up @@ -101,17 +105,23 @@ def measure(binary, case, sample_ms):
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--baseline", help="Override the pinned baseline for a deliberate comparison")
parser.add_argument("--config", type=Path, default=ROOT / "benchmarks/config.json")
parser.add_argument("--baseline-command", choices=COMMANDS,
help="Compare different algorithms without changing their regression gates")
parser.add_argument("--output", type=Path, default=ROOT / "test-results/performance")
args = parser.parse_args()
config = json.loads((ROOT / "benchmarks/config.json").read_text())
config = json.loads(args.config.read_text())
validate_config(config)
candidate_command = config.get("command", "calculate")
baseline_command = args.baseline_command or candidate_command
baseline = command(["git", "rev-parse", "--verify", (args.baseline or config["baseline"]) + "^{commit}"], cwd=ROOT)
output = args.output.resolve()
output.mkdir(parents=True, exist_ok=True)
report = {"baseline": baseline, "candidate": command(["git", "rev-parse", "HEAD"], cwd=ROOT),
"dirty": bool(command(["git", "status", "--porcelain"], cwd=ROOT)),
"platform": platform.platform(), "rustc": command(["rustc", "-Vv"]),
"config": config, "results": []}
"config": config, "baseline_command": baseline_command,
"candidate_command": candidate_command, "results": []}
# Compile with all available CPUs; pin only the subsequent measurements.
target = Path(os.environ.get("CARGO_TARGET_DIR", ROOT / "src-tauri/target")).resolve()
with tempfile.TemporaryDirectory(prefix="graphprime-benchmark-") as temporary:
Expand All @@ -121,7 +131,7 @@ def main():
print(f"Building {label} in release mode...", flush=True)
source = work / label
source.mkdir()
prepare(source, revision)
prepare(source, revision, baseline_command if label == "baseline" else candidate_command)
binaries[label] = work / f"{label}-benchmark"
build(source, target, binaries[label], output / f"{label}-build.jsonl")
if hasattr(os, "sched_getaffinity"):
Expand All @@ -139,7 +149,8 @@ def main():
print(f'{case["operation"]}/{case["limit"]}: {result["slowdown"]:+.1%}'
f' {"FAIL" if result["regression"] else "PASS"}', flush=True)
(output / "report.json").write_text(json.dumps(report, indent=2) + "\n")
lines = ["## Performance comparison", "", f"Baseline: `{baseline}`", "",
lines = [f"## Performance comparison: {candidate_command}", "",
f"Baseline: `{baseline}` (`{baseline_command}`)", "",
"| Workload | Baseline (ms) | Candidate (ms) | Change | Result |",
"| --- | ---: | ---: | ---: | --- |"]
for result in report["results"]:
Expand Down
48 changes: 46 additions & 2 deletions src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_clipboard_manager::init())
.invoke_handler(tauri::generate_handler![calculate])
.invoke_handler(tauri::generate_handler![calculate, calculate_linear])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Expand All @@ -26,9 +26,37 @@ async fn calculate(x: u64) -> Vec<u64> {
.collect()
}

// Based on Jayadev Misra's smallest-divisor invariant: https://www.cs.utexas.edu/~misra/scannedPdf.dir/ProgramExplanation.pdf
#[tauri::command]
async fn calculate_linear(x: u64) -> Vec<u64> {
if x < 2 {
return Vec::new();
}

let limit = x as usize;
let mut composite = vec![false; limit + 1];
let mut primes = Vec::new();
for n in 2..=limit {
if !composite[n] {
primes.push(n as u64);
}
for &prime in &primes {
let p = prime as usize;
if p > limit / n {
break;
}
composite[n * p] = true;
if n % p == 0 {
break;
}
}
}
primes
}

#[cfg(test)]
mod tests {
use super::calculate;
use super::{calculate, calculate_linear};

#[test]
fn calculates_small_sequences_and_boundaries() {
Expand Down Expand Up @@ -59,6 +87,22 @@ mod tests {
expected,
"limit {limit}"
);
assert_eq!(
tauri::async_runtime::block_on(calculate_linear(limit)),
expected,
"linear limit {limit}"
);
}
}

#[test]
fn linear_sieve_matches_eratosthenes_for_large_limits() {
for limit in [100_000, 1_000_000] {
assert_eq!(
tauri::async_runtime::block_on(calculate_linear(limit)),
tauri::async_runtime::block_on(calculate(limit)),
"limit {limit}"
);
}
}

Expand Down
Loading
Loading