Skip to content

feat: Go, Rust, Python, Java/Kotlin and Swift ports with shared test vectors - #3

Merged
pranav2012 merged 11 commits into
mainfrom
feat/multi-language
Oct 17, 2026
Merged

pranav2012 merged 11 commits into
mainfrom
feat/multi-language

Conversation

@pranav2012

Copy link
Copy Markdown
Owner

Summary

Ports abtestify to Go, Rust, Python, Java (which also serves Kotlin) and Swift. Every port produces byte-for-byte the same assignments as the TypeScript implementation, and one release publishes all of them with the same version. The TypeScript package stays where it is; the npm tarball is unchanged (still only dist).

Language Location API Registry
Go go/ GetBucket(Params) (Result, error). Optional fields are pointers (abtestify.Float64, abtestify.String); returns *ValidationError{Field} tag go/vX.Y.Z
Rust rust/ get_bucket(&Params) -> Result<Assignment, Error>, with a Params::new(..) builder and Default. Zero deps (own SHA-256), MSRV 1.60 crates.io
Python python/ get_ab_experiment_bucket(experiment_name, unique_id, *, treatment_split=50, ...) -> Assignment (frozen dataclass). Uses hashlib, typed with py.typed, 3.9+ PyPI (trusted publishing)
Java/Kotlin java/ ABTestify.getBucket(Params) / getBucket(name, id), Params.builder(..), an immutable Assignment, and InvalidParamsException extends IllegalArgumentException. Java 8 bytecode Maven Central io.github.pranav2012:abtestify
Swift Package.swift (root) + swift/ ABTestify.bucket(experimentName:uniqueId:...) throws -> Assignment. Zero deps, no CryptoKit, builds on Linux the existing plain x.y.z release tags

How parity is verified

test-vectors/generate.mjs writes test-vectors/vectors.json from the TypeScript implementation. Assignments come from the built dist/index.js; the internal bucketFromTop/bucketOf from src/bucket.ts are compiled with esbuild. The generator uses a seeded PRNG, so its output is deterministic.

  • 2,859 hashing cases: ASCII, Japanese, emoji, ZWJ sequences, combining marks (NFC vs NFD), NUL and astral-plane characters, IDs up to 10,000 chars across the SHA-256 padding boundaries, splits and exposures of 0, 100 and fractions like 33.3, thresholds set exactly on each user's bucket, legacyHashing both ways, and custom variant names (including the empty string).
  • 4,919 bucket mapping cases (top64Hex to bucket): 0, 1, 2^53±1, 2^63±1, 2^64−1, ties at every exponent from 2^53 to 2^63 (e.g. 2^54+2, 2^63+1536), and values at and ±1/±2/±2048 around the exact float boundary of ~400 buckets, plus the naive ceil(k·2^64/10000) boundaries. I checked that a truncating conversion fails 1,658 of these, ties-away fails 209, and exact integer math fails 1,667.
  • 26 error cases: negative, >100, ±1e308, NaN and ±Infinity (as strings) for both percentages, empty IDs, and the order of the checks.

bucket.ts now exposes bucketFromTop (a pure refactor). A new jest test checks the TypeScript implementation against the vectors, and CI regenerates the file and fails if it differs from the committed copy. Java's unsigned conversion uses ((x >>> 1) | (x & 1)) * 2.0 and is also checked against BigInteger.doubleValue() for every bucket case.

Local test results (macOS arm64)

  • TypeScript: yarn test passes: 52 tests, including the vector suite. Lint, prettier and type-check are clean.
  • Go: go test ./... passes on arm64, and on amd64 under Rosetta. 386 runs in CI only.
  • Rust: cargo test passes (5 unit tests + 1 doctest). Clippy and fmt are clean, cargo package succeeds, and the library builds on Rust 1.60.
  • Python: pytest passes on 3.13 (7 tests + 26 subtests). mypy --strict is clean and the wheel/sdist build. 3.9 to 3.12 run in CI only.
  • Java: ./gradlew test passes on JDK 17 (bytecode major 52), and publishToMavenLocal produces the jar, sources, javadoc and POM. Signing is not tested locally because there's no gpg here.
  • Swift: swift test passes on macOS with Swift 6.3. Linux (swift:6.0 container) runs in CI only.
  • Workflows: actionlint is clean, apart from pre-existing shellcheck info notes in the untouched npm version-bump script.

CI and release

  • pr-verify.yml runs these jobs in parallel: typescript (with the vectors-fresh check), go (1.18 and stable, plus 386), rust and rust-msrv, python 3.9 to 3.13 plus a package check, java, swift-linux and swift-macos.
  • release.yml keeps the npm job and its version logic unchanged, apart from bumping checkout and setup-node to v4. After the GitHub release (whose tag is also the Swift release), these jobs run with the same new_version:
    • tag-go pushes go/v<version>.
    • publish-crates publishes to crates.io.
    • publish-pypi publishes through trusted publishing in environment pypi.
    • publish-maven publishes to Maven Central through the vanniktech plugin with in-memory signing.
  • Each registry job skips with a ::warning:: until its credentials exist. PyPI is gated on the variable vars.PYPI_ENABLED == 'true'.

Owner checklist before merging (details in RELEASING.md)

  • crates.io: verify your email, create a token with publish-new/publish-update, and add it as the secret CARGO_REGISTRY_TOKEN.
  • PyPI: add a pending publisher (project abtestify, owner pranav2012, repo abtestify, workflow release.yml, environment pypi), create the GitHub environment pypi, and set the repo variable PYPI_ENABLED=true.
  • Maven Central: create a Central Portal account (signing in with GitHub verifies io.github.pranav2012), generate a user token, and add it as MAVEN_CENTRAL_USERNAME / MAVEN_CENTRAL_PASSWORD.
  • GPG: create a key with a passphrase, upload the public key to keyserver.ubuntu.com, and add the armored private key as SIGNING_KEY and the passphrase as SIGNING_PASSWORD.
  • Decide the version. package.json is 0.2.0 and the latest release is 0.2.1, so merging as-is releases 0.2.2 everywhere. Set package.json to 0.3.0 first if you want a minor release for the new ports.
  • Any secret you skip only skips that registry; npm still releases.

test-vectors/generate.mjs writes vectors.json from the TypeScript
implementation: 2,859 hashing cases (ASCII, unicode, long ids, fractional
and boundary splits, legacy hashing, custom variant names), 4,919 bucket
mapping cases for crafted 64-bit values, and 26 error cases.

bucket.ts now exposes bucketFromTop so the float conversion can be tested
directly. A jest test checks the TypeScript implementation against the
vectors so they cannot drift.
Module github.com/pranav2012/abtestify/go with GetBucket(Params) (Result, error).
Nil pointer fields take the defaults. Tests run every shared vector, and pass
on arm64 and amd64.
Keeps git, eslint and prettier away from Rust, Python, Gradle and SwiftPM
build directories and the port sources.
Crate abtestify with get_bucket(&Params) -> Result<Assignment, Error>, a Params
builder with Default, rustdoc and crates.io metadata. Zero dependencies: it
carries a small SHA-256. Unit tests run every shared vector.
Package abtestify (hatchling, Python 3.9+, typed with py.typed) with
get_ab_experiment_bucket(experiment_name, unique_id, *, ...) returning a frozen
Assignment dataclass. Uses hashlib, no dependencies. unittest tests, also run
by pytest, cover every shared vector.
Gradle (Kotlin DSL) project for io.github.pranav2012:abtestify, Java 8
bytecode, no runtime dependencies. ABTestify.getBucket(Params) with a builder,
an immutable Assignment and InvalidParamsException (an
IllegalArgumentException). Unsigned 64-bit to double conversion is proven
against BigInteger.doubleValue and the bucket vectors.

Publishing goes to Maven Central through the Central Portal with the
com.vanniktech.maven.publish plugin and in-memory GPG signing.
Package.swift sits at the repository root, as SwiftPM requires, and points at
swift/Sources/ABTestify and swift/Tests/ABTestifyTests. ABTestify.bucket(...)
throws -> Assignment. A small SHA-256 keeps it dependency-free and buildable
on Linux without CryptoKit. Tests run every shared vector.
README covers install and usage for npm, Go, Rust, Python, Java/Kotlin and
Swift, the cross-language guarantee and how the shared test vectors enforce it.
RELEASING.md has the owner's one-time registry setup and how releases flow.
pr-verify runs TypeScript, Go (1.18, stable, 386), Rust (stable and MSRV 1.60),
Python 3.9 to 3.13, Java and Swift (Linux and macOS) against the shared vectors,
and fails if vectors.json is stale.

release.yml keeps the npm job and its version logic, then publishes the same
version to crates.io, PyPI (trusted publishing) and Maven Central and pushes
the go/v tag. Each registry job skips with a warning until its credentials
or the PYPI_ENABLED variable exist.
It reads each token hidden and pipes it to gh secret set, creates the pypi
environment and PYPI_ENABLED, and can generate and publish the GPG key.
@pranav2012
pranav2012 merged commit af88512 into main Oct 17, 2026
14 checks passed
@pranav2012
pranav2012 deleted the feat/multi-language branch October 17, 2026 00:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant