A Python and TypeScript library that combines several measurements into one score and shows exactly how it got there.
Try it: pip install assay-engine (Python 3.13 or newer), then run the example below.
Lots of apps rank or grade things: laptops, vendors, job applicants, software releases. The score usually comes from a few lines of arithmetic that mix different units (hours, dollars, kilograms), where sometimes lower is better. When someone asks "why did this one get 0.66?", the answer is buried in code, and if the same score is also computed in a second language, the two versions slowly drift apart.
Assay does that arithmetic for you. You declare each measurement with its own scale and importance, pick one of three ways to combine them, and get back the score plus every step of the math: each measurement's place on its scale, its share, and what it added. The Python and TypeScript packages give the same numbers for the same input. It is a plain calculation library. It makes no network calls and keeps nothing.
Technical docs: Architecture · Getting started for developers · Methods and result fields · Operations and limits
- Install the Python package (Python 3.13 or newer):
pip install assay-engine- Save this as
laptop.pyand runpython laptop.py. It scores a laptop on battery life (higher is better), price and weight (lower is better). Battery and price count twice as much as weight.
from assay import compose, parse_request
def measure(name, value, worst, best, importance):
direction = "higher_is_better" if best > worst else "lower_is_better"
scale = {"minimum": min(worst, best), "maximum": max(worst, best), "direction": direction}
return {"id": name, "label": name, "value": value, "scale": scale, "interval": None, "weight": importance}
laptop = [measure("battery_hours", 10, worst=0, best=20, importance=2),
measure("price_usd", 900, worst=2400, best=400, importance=2),
measure("weight_kg", 1.4, worst=3.0, best=1.0, importance=1)]
result = compose(parse_request({"method": "weighted_mean", "method_version": "laptop.v1",
"clamp": "reject", "components": laptop}))
for row in result.components:
print(f"{row.id:<14}{row.raw:>5g} -> {row.normalized:.2f} of best × {row.coefficient:.0%} share = {row.contribution:.2f}")
print(f"score: {result.score:.2f} out of 1")
print(result.inputs_hash)Real output:
battery_hours 10 -> 0.50 of best × 40% share = 0.20
price_usd 900 -> 0.75 of best × 40% share = 0.30
weight_kg 1.4 -> 0.80 of best × 20% share = 0.16
score: 0.66 out of 1
sha256:e73545373ecea6c81c9e21d70965e29c13a777378cc3e2ad1e917e399766f2c5
Each measurement is placed between its worst and best value (0 is worst, 1 is best), multiplied by its share of the total importance (2 out of 5 is 40%), and the results are added up. The last line is a fingerprint of the exact input. Save it with the score and you can later check that a replay used the same input.
- Optional: run the same score in JavaScript (Node 22.13 or newer). Use the
@nexttag, because npm's default tag still points at an empty placeholder.
npm install @edgeproc/assay@nextSave this as laptop.mjs and run node laptop.mjs:
import { compose, parseRequest } from "@edgeproc/assay";
const measure = (name, value, worst, best, importance) => ({
id: name, label: name, value, interval: null, weight: importance,
scale: { minimum: Math.min(worst, best), maximum: Math.max(worst, best),
direction: best > worst ? "higher_is_better" : "lower_is_better" },
});
const result = compose(parseRequest({
method: "weighted_mean", method_version: "laptop.v1", clamp: "reject",
components: [measure("battery_hours", 10, 0, 20, 2),
measure("price_usd", 900, 2400, 400, 2),
measure("weight_kg", 1.4, 3.0, 1.0, 1)],
}));
console.log(`score: ${result.score.toFixed(2)} out of 1`);
console.log(result.inputs_hash);Real output. The score and the fingerprint match the Python run:
score: 0.66 out of 1
sha256:e73545373ecea6c81c9e21d70965e29c13a777378cc3e2ad1e917e399766f2c5
More examples are in examples/
and the quickstart.
Your code sends a request: the method, a version label you choose for your formula, and
each measurement with its scale. Assay checks the request strictly first. Unknown fields,
missing values, numbers outside their scale, and duplicate names are refused with a short
error code such as assay.out_of_range, and no score comes back. It then combines the
measurements in the order you listed them, using one of three methods: a weighted
average, a plain sum with your own coefficients, or "the weakest measurement decides".
The result keeps every input and every intermediate number, so anyone can redo the math.
The Python and TypeScript packages are written separately and checked against the same
shared test cases.
- It does not decide what is fair or where to draw the line. Your app chooses the measurements, the weights, and what counts as a pass. Assay only does the arithmetic you declared.
- It does not check that your inputs are true. It checks that they are well formed.
- It does not learn. There is no machine learning; the weights are the ones you wrote.
- The fingerprint is not a security feature. Anyone can compute it, so it cannot prove who made a result or that nobody changed it.
- Results contain your raw inputs. Treat a saved result as carefully as the data that went into it.
- It is a prerelease. The API may still change before a stable 0.5.0.
- Assay only computes scores. Sealing evidence about a result is a separate project, Avow, and neither package imports or requires the other.
| If you need | Use |
|---|---|
| A tiny formula nobody will ever question | A few lines of your own code |
| People, not programs, doing the scoring | A spreadsheet |
| Weights learned from data | A machine-learning model |
| Someone else to run and tune the scoring | A hosted scoring service |
| A score computed inside your app that you can explain, replay, and match across Python and TypeScript | Assay |
Status: prerelease. The current versions are
assay-engine0.5.0.dev3 on PyPI and@edgeproc/assay0.5.0-dev.3 on npm. There is no stable release yet.
pip install assay-engine==0.5.0.dev3
npm install @edgeproc/assay@0.5.0-dev.3On PyPI the package is called assay-engine, but you import it as assay. Plain
pip install assay-engine gets the prerelease today because no stable version exists.
On npm, npm install @edgeproc/assay without a version installs an empty placeholder, so
always give the version or the @next tag.
Extras for Python:
pip install "assay-engine[cli]"adds theassaycommand (assay compose,assay explain,assay measure). Without it, the command printsFAIL: assay.cli_extra_missing.pip install "assay-engine[metrics]"adds optional calculators for classification, ranking, calibration, and agreement reports.
Older Python code may use the Python-only deep import assay.composite (SubScore and
composite(...)). It has no TypeScript version and no fingerprint, so new code should
use parse_request() and compose() from the package root.
You need Python 3.13 with uv, and Node 22.13.0 with pnpm 11.5.0 for the TypeScript package. The getting started guide walks through setup, the code layout, and a first change.
git clone https://github.com/hseshadr/assay
cd assay
uv sync --all-extras
uv run poe gateThat runs lint, formatting, strict types, complexity limits, and the Python tests with at least 90% branch coverage. To build both real packages and check that Python and TypeScript give the same numbers:
bash examples/run_composite.sh- Getting started for developers: setup, code map, first change, and pull requests.
- Architecture: the two packages, how a request flows, what the checks cover, and a worked cross-language example.
- Explore the interactive architecture map.
- Methods: the exact math for each method, uncertainty ranges, and every result field.
- Operations: files the command line reads and writes, size limits, calculator settings, and the release process.
- Quickstart: the command line and building both packages from a checkout.
- TypeScript package: the npm package on its own.
- CHANGELOG: what changed in each version.
- SECURITY.md: how to report a vulnerability privately.
- Bugs and feature requests: GitHub Issues.
MIT. See LICENSE.