Port Mortem 2026 Write-Up Submission · Track D (Python → Rust) · Solo build by Rahul Gupta
When participating in Port Mortem 2026 (a 72-hour hackathon to resurrect and rewrite dead or legacy libraries in memory-safe languages), I picked python-semanticversion for Track D (Python → Rust).
python-semanticversion is a foundational Python package implementing full SemVer 2.0 parsing, comparison, and npm-style range specification (SimpleSpec, NpmSpec, and LegacySpec).
Below is the honest post-mortem of how I ported it to 100% safe Rust (semanticversion-rs), proved behavioral equivalence, fixed subtle edge-case bugs, survived a 6-hour AST set-equality nightmare, and what I’d do differently next time.
1. What I Picked & The Strategy
The goal wasn't just to write a Rust library that looked like SemVer parsing—it was to create a drop-in replacement that could execute the original Python test suite unmodified while delivering multi-fold speedups.
Core Stack & Architecture
-
Language: 100% Safe Rust (
grep -rn unsafe src/returns 0 results). -
Extension Bridge: PyO3 + Maturin producing a native Python extension named
semantic_version. -
Parsing:
regexcrate for version component extraction + custom recursive-descent parser for complex range grammar trees (Clause::AnyOf,Clause::AllOf,Clause::Range).
2. How I Proved Behavioral Equivalence
Claiming a port is "100% compatible" without proof is easy. To ensure zero behavioral divergence, I used a three-tier verification pipeline:
Tier 1: Zero-Edit Pytest Verification
Instead of porting tests to Rust assertions, I compiled the Rust engine as a PyO3 native module (semantic_version). Running pytest tests/original executes the original, unmodified test suite directly against compiled Rust code.
make # maturin develop && pytest tests/original -q
# Result: 54 passed, 16 skipped ("Django not installed"), 586 subtests green
Zero lines of Python test code were modified.
Tier 2: 24,500-Pair Deterministic Differential Fuzzing
I built a seed-based differential oracle (fuzz/differential/driver.py) that generated 24,500 random Version, SimpleSpec, and NpmSpec inputs across both the original Python reference environment and the PyO3 Rust extension.
Field-by-field verification evaluated major, minor, patch, prerelease, build, str, repr, partial, valid, compare, and matches:
- Hard Behavioral Divergences: 0
- Soft Wording Differences (error message text formatting): 1,619 (expected minor string formatting differences).
Tier 3: 2.55 Million Run Crash Fuzzing
Using cargo-fuzz / libFuzzer targeting raw byte slices fed into all entry points:
- Total Executions: 2,554,822 runs in 61 seconds (~41,882 exec/sec).
- Panics / Crashes: 0
3. What Broke: 8 Latent & Port Bugs Caught by Fuzzing
Fuzzing didn't just verify parity; it caught 8 critical bugs during development:
-
u64Arithmetic Overflow Panics: In expressions likepatch + 1, a version like1.2.18446744073709551615caused a panic.-
Fix: Replaced all 18 addition sites across
version.rs,simple_spec.rs, andnpm_spec.rswithsaturating_add(1).
-
Fix: Replaced all 18 addition sites across
-
Empty Prerelease Identifiers (
1.2.3-..): Python rejected empty identifier tokens (..), whereas Rust initially accepted them.-
Fix: Enforced strict token validation in
parse_prerelease_identifiers.
-
Fix: Enforced strict token validation in
-
Empty
||Group Substitution: Python'sNpmSpecparser turns empty||clauses into>=0.0.0, while Rust initially evaluated them asClause::Never.-
Fix: Added zero-length group fallback logic to
AnyOf([AllOf([>=0.0.0])]).
-
Fix: Added zero-length group fallback logic to
-
Wildcard Rejection on
~*/^*: Python explicitly rejects caret/tilde modifiers combined with wildcard majors.- Fix: Inserted a validation gate prior to caret/tilde range expansion.
-
Hyphen Range Bound Fences:
1.0.0-rc.1 - 2.0.0used>for fence lower-bounds instead of>= major.minor.0.-
Fix: Passed
is_upper_boundcontext intoexpand_prerelease_or_hyphen.
-
Fix: Passed
-
Equality vs. Ordering Semantics: SemVer 2.0 dictates that
1.0.0and1.0.0+buildcompare equal for precedence sorting, but are not equal for string equality.-
Fix: Custom
PartialEqcomparing all fields (including build), whileOrd/PartialOrduses precedence keys ignoring build metadata.
-
Fix: Custom
4. The Edge Case That Ate 6 Hours: Python frozenset ASTs vs. Rust Vec<Clause>
The single hardest bug encountered during the 72 hours involved AST node equality in NpmSpec.
In Python's semantic_version, Clause nodes (AllOf, AnyOf) store child clauses in frozenset instances (base.py:745, 808). As a result, AST equality in Python is order-insensitive and auto-deduplicating:
$$\text{AllOf}([A, B]) == \text{AllOf}([B, A])$$
In Rust, however, AllOf(Vec<Clause>) uses standard vector comparison.
When parsing caret ranges like ^1.2.3, Python emitted AllOf([LT 2.0.0, GTE 1.2.3]), while Rust's parser generated AllOf([GTE 1.2.3, LT 2.0.0]).
When running pytest, assertions like:
assert NpmSpec('^1.2.3').clause == NpmSpec('>=1.2.3 <2.0.0').clause
failed miserably! The versions matched identically, but the underlying AST clause trees failed equality checks simply because child nodes were ordered differently in the vector.
The Fix
Instead of forcing Rust's internal parser to match Python's exact construction sequence for every grammar edge case, I implemented set-equality and dedup hashing inside PyO3's Clause binding layer (src/bindings.rs):
// Custom set-equality for PyO3 Clause objects to mirror frozenset semantics
fn clause_eq_python(a: &Clause, b: &Clause) -> bool {
match (a, b) {
(Clause::AllOf(a_nodes), Clause::AllOf(b_nodes)) |
(Clause::AnyOf(a_nodes), Clause::AnyOf(b_nodes)) => {
let set_a: HashSet<_> = a_nodes.iter().collect();
let set_b: HashSet<_> = b_nodes.iter().collect();
set_a == set_b
}
_ => a == b,
}
}
Resolving this unlocked 100% pass rates across test_spec.py without modifying native Rust performance.
5. The Decision I'd Take Back: PyO3 Tuple Allocations for Precedence Comparison (D17)
If I could redo one decision from the hackathon, it would be Decision D17: PyO3 Precedence Key Returns.
To implement Python's _cmp_precedence_key dunder attribute, the PyO3 binding layer constructs Python tuples on the fly:
// Rust binding allocating a PyTuple on every precedence key read:
(self.major, self.minor, self.patch, pre_tuple, build_tuple).into_py(py)
The Performance Cost
Because Python's comparison operators (<, >, <=, >=) rely on comparing these tuple objects, comparison operations executed from Python through PyO3 incurred heap allocation overhead:
| Benchmark Scenario | Latency / Throughput | Speedup vs Python |
|---|---|---|
Native Rust precedence_lt |
386 ns p50 (2.3 Million ops/sec) | N/A (Pure Rust) |
PyO3 _cmp_precedence_key |
Python tuple construction overhead | 0.27× (Slower than Python) |
While parsing and matching achieved 11× to 60× speedups, Python-to-Rust comparison throughput lagged due to object allocations at the boundary.
What I Should Have Done
Instead of delegating comparison logic back to Python tuple comparison, I should have implemented __richcmp__ natively in C/Rust using Version::cmp_precedence_key() directly, bypassing Python tuple creation entirely.
6. Final Benchmark Summary
Benchmarked on an idle Linux VM (Intel x86_64 @ 2.20GHz, 2 physical cores, Rust 1.96, Python 3.11):
| Operation | Rust (Native p50) | Python Baseline | Speedup / Impact |
|---|---|---|---|
Version::parse |
1.91 µs | 21.0 µs | 11× faster |
SimpleSpec::parse |
5.79 µs | 34.7 µs | 6× faster |
NpmSpec::parse |
11.51 µs | 126.6 µs | 11× faster |
match_version (npm) |
1.52 µs | 91.2 µs | 60× faster |
| Peak Memory RSS | 12.5 MB | 15.9 MB | 21% memory reduction |
Conclusion & Code
Porting dead or legacy code isn't just about translating lines of syntax—it's about honoring original behavioral semantics while eliminating memory safety risks and performance bottlenecks.
-
Repository:
rahulgupta0-dev/semanticversion-rs -
Decision Log:
DECISIONS.md(20 detailed entries) -
Submission Spec:
.port-mortem.toml
Special thanks to Hackathon Raptors for organizing Port Mortem 2026!
Top comments (0)