diff --git a/docs/contribution-normalisation.md b/docs/contribution-normalisation.md new file mode 100644 index 0000000..1fff2e0 --- /dev/null +++ b/docs/contribution-normalisation.md @@ -0,0 +1,158 @@ +# Contribution Score Normalisation + +`@guildpass/contribution-normalisation` converts heterogeneous weighted +contribution metrics into a single bounded score. It is a standalone +mathematical primitive with no dependency on contribution persistence, badges, +rewards or any HTTP, Prisma or Redis layer. + +Communities may eventually combine metrics such as merged commits, forum +participation and completed tasks. Those raw metrics operate on incompatible +scales, so they cannot be summed directly. This package defines how each metric +is projected onto one common scale and how the projections are combined. + +## Score scale + +All normalised values and the final score are expressed in **basis points**, +from `0` to `10000`, where `10000` bps means 100.00%. The bound is exported as +`SCORE_SCALE_BPS`. + +## Metric model + +Each metric declares the range over which it earns credit: + +```ts +interface ContributionMetric { + key: string; // unique within a single call + value: bigint; // raw measurement + minimum: bigint; // value scoring 0 bps + target: bigint; // value scoring 10000 bps + weightBps: number; // share of the final score, 0 to 10000 +} +``` + +`minimum`, `target` and `value` must all be non-negative, and `target` must be +greater than or equal to `minimum`. Callers scoring a signed quantity, such as a +net figure that can fall below zero, must offset it into a non-negative range +before normalising. + +## Normalisation + +A metric with a non-empty range normalises to: + +```text +normalisedBps = ((value - minimum) * 10000) / (target - minimum) +``` + +The division is an exact `bigint` division and therefore floors. A value falling +between two basis points always resolves downwards, so the same input never +produces two different scores. + +### Clamping rules + +Every metric reports which rule produced its value, through the `clamping` +field: + +| Condition | `normalisedBps` | `clamping` | +| --------- | --------------- | ---------- | +| `minimum < value < target` | proportional | `in-range` | +| `value == minimum` | `0` | `in-range` | +| `value == target` | `10000` | `in-range` | +| `value < minimum` | `0` | `below-minimum` | +| `value > target` | `10000` | `above-target` | +| `minimum == target` and `value >= target` | `10000` | `zero-range-met` | +| `minimum == target` and `value < target` | `0` | `zero-range-below` | + +Exceeding the target earns no credit beyond the target. The scale is bounded, so +one outsized metric cannot inflate a score past `10000` bps or compensate for a +metric that was never met. + +A zero-range metric, where `minimum` equals `target`, is a defined case rather +than a division by zero. It behaves as a step function: reaching the target +scores the full scale, anything below scores nothing. This is the natural +reading of a pass/fail requirement expressed through the same interface. + +## Weighted combination + +Configured weights must total exactly `10000` bps. A partial total is rejected +rather than rescaled, because silently rescaling would make a configuration +error look like a low score. + +Weighted contributions are accumulated exactly and divided once, at the end: + +```text +scoreBps = (Σ normalisedBps_i * weightBps_i) / 10000 +``` + +Flooring once over the exact sum, rather than once per metric, keeps the +truncation error below one basis point in total instead of letting it grow with +the number of metrics. + +The result is bounded by construction: each `normalisedBps` is at most `10000`, +so the accumulator is at most `10000 * Σ weightBps_i = 10000 * 10000`, and the +final division yields at most `10000`. + +## Determinism + +- Every comparison, multiplication and division is `bigint` arithmetic. No + threshold is computed in floating point. +- Normalised values, weights and the final score are returned as `number` + because they are bounded by `10000`, where the conversion from `bigint` is + exact. Unbounded quantities — the raw `value`, `minimum` and `target` — are + returned as `bigint` and are never converted. +- Metric keys must be unique, and the returned breakdown is sorted by key. Two + calls with the same metrics in a different array order produce deeply equal + results, not merely equal scores. + +## Usage + +```ts +import { normaliseContributionScore } from '@guildpass/contribution-normalisation'; + +const result = normaliseContributionScore([ + { key: 'commits', value: 50n, minimum: 0n, target: 100n, weightBps: 6000 }, + { key: 'reviews', value: 25n, minimum: 0n, target: 100n, weightBps: 4000 }, +]); + +result.scoreBps; // 4000 +result.totalWeightBps; // 10000 +result.metrics[0]; +// { +// key: 'commits', +// value: 50n, +// minimum: 0n, +// target: 100n, +// weightBps: 6000, +// clamping: 'in-range', +// normalisedBps: 5000, +// weightedScoreBps: 3000, +// } +``` + +Every metric returns its inputs alongside its intermediate results, so a score +can be audited without re-running the engine. `weightedScoreBps` is floored per +metric for readability; the final score floors once over the exact sum, so these +per-metric figures may total slightly below `scoreBps`. The exact final score is +always reproducible from `normalisedBps` and `weightBps` using the combination +formula above. + +## Failure codes + +Invalid configuration is rejected before any scoring runs, by throwing a +`ValidationError` carrying a `code`: + +- `EMPTY_METRICS` +- `INVALID_KEY` +- `DUPLICATE_KEY` +- `INVALID_VALUE` +- `INVALID_RANGE` +- `NEGATIVE_VALUE` +- `NEGATIVE_RANGE_BOUND` +- `INVALID_WEIGHT` +- `INVALID_TOTAL_WEIGHT` + +## Scope + +This package computes a score and returns it. It does not persist contributions, +track them over time, decide how metrics are collected, or award badges, roles +or rewards. Those belong to the contribution and reward engines, which may +consume this primitive. diff --git a/packages/contribution-normalisation/package.json b/packages/contribution-normalisation/package.json new file mode 100644 index 0000000..a9b175e --- /dev/null +++ b/packages/contribution-normalisation/package.json @@ -0,0 +1,19 @@ +{ + "name": "@guildpass/contribution-normalisation", + "version": "2.0.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "node --test dist/**/*.test.js" + } +} diff --git a/packages/contribution-normalisation/src/index.test.ts b/packages/contribution-normalisation/src/index.test.ts new file mode 100644 index 0000000..752a862 --- /dev/null +++ b/packages/contribution-normalisation/src/index.test.ts @@ -0,0 +1,387 @@ +import { describe, it } from 'node:test'; +import * as assert from 'node:assert'; +import { + normaliseContributionScore, + SCORE_SCALE_BPS, + ValidationError, + ContributionMetric, +} from './index.js'; + +function expectValidationError(code: string) { + return (error: unknown): boolean => { + assert.ok(error instanceof ValidationError, 'expected a ValidationError'); + assert.strictEqual(error.code, code); + return true; + }; +} + +/** A single fully weighted metric, so the score equals its normalised value. */ +function singleMetric( + overrides: Partial = {} +): ContributionMetric[] { + return [ + { + key: 'commits', + value: 50n, + minimum: 0n, + target: 100n, + weightBps: SCORE_SCALE_BPS, + ...overrides, + }, + ]; +} + +describe('normaliseContributionScore', () => { + it('exposes the documented score scale', () => { + assert.strictEqual(SCORE_SCALE_BPS, 10000); + }); + + describe('normalisation', () => { + it('normalises a mid-range value to its exact proportion', () => { + const result = normaliseContributionScore(singleMetric()); + + assert.strictEqual(result.scoreBps, 5000); + assert.strictEqual(result.totalWeightBps, 10000); + assert.strictEqual(result.metrics[0]?.normalisedBps, 5000); + assert.strictEqual(result.metrics[0]?.clamping, 'in-range'); + }); + + it('is deterministic across repeated calls', () => { + const first = normaliseContributionScore(singleMetric({ value: 37n })); + const second = normaliseContributionScore(singleMetric({ value: 37n })); + + assert.deepStrictEqual(first, second); + }); + + it('floors a value that falls between two basis points', () => { + // 1 / 3 of the range is 3333.33... bps, which must resolve downwards. + const result = normaliseContributionScore( + singleMetric({ value: 1n, minimum: 0n, target: 3n }) + ); + + assert.strictEqual(result.metrics[0]?.normalisedBps, 3333); + assert.strictEqual(result.scoreBps, 3333); + }); + + it('normalises against a non-zero minimum', () => { + const result = normaliseContributionScore( + singleMetric({ value: 75n, minimum: 50n, target: 150n }) + ); + + assert.strictEqual(result.metrics[0]?.normalisedBps, 2500); + }); + }); + + describe('boundary values', () => { + it('scores 0 bps when the value equals the minimum', () => { + const result = normaliseContributionScore(singleMetric({ value: 0n })); + + assert.strictEqual(result.metrics[0]?.normalisedBps, 0); + assert.strictEqual(result.metrics[0]?.clamping, 'in-range'); + assert.strictEqual(result.scoreBps, 0); + }); + + it('scores the full scale when the value equals the target', () => { + const result = normaliseContributionScore(singleMetric({ value: 100n })); + + assert.strictEqual(result.metrics[0]?.normalisedBps, 10000); + assert.strictEqual(result.metrics[0]?.clamping, 'in-range'); + assert.strictEqual(result.scoreBps, 10000); + }); + + it('scores one basis point below the target correctly', () => { + const result = normaliseContributionScore( + singleMetric({ value: 9999n, minimum: 0n, target: 10000n }) + ); + + assert.strictEqual(result.metrics[0]?.normalisedBps, 9999); + }); + }); + + describe('clamping', () => { + it('clamps a value below the minimum to 0 bps', () => { + const result = normaliseContributionScore( + singleMetric({ value: 10n, minimum: 50n, target: 100n }) + ); + + assert.strictEqual(result.metrics[0]?.normalisedBps, 0); + assert.strictEqual(result.metrics[0]?.clamping, 'below-minimum'); + }); + + it('clamps a value above the target to the full scale', () => { + const result = normaliseContributionScore(singleMetric({ value: 5000n })); + + assert.strictEqual(result.metrics[0]?.normalisedBps, 10000); + assert.strictEqual(result.metrics[0]?.clamping, 'above-target'); + }); + + it('treats a zero-range metric as met when the value reaches the target', () => { + const result = normaliseContributionScore( + singleMetric({ value: 7n, minimum: 7n, target: 7n }) + ); + + assert.strictEqual(result.metrics[0]?.normalisedBps, 10000); + assert.strictEqual(result.metrics[0]?.clamping, 'zero-range-met'); + }); + + it('treats a zero-range metric as unmet when the value is below the target', () => { + const result = normaliseContributionScore( + singleMetric({ value: 6n, minimum: 7n, target: 7n }) + ); + + assert.strictEqual(result.metrics[0]?.normalisedBps, 0); + assert.strictEqual(result.metrics[0]?.clamping, 'zero-range-below'); + }); + + it('treats a zero-range metric as met when the value exceeds the target', () => { + const result = normaliseContributionScore( + singleMetric({ value: 99n, minimum: 7n, target: 7n }) + ); + + assert.strictEqual(result.metrics[0]?.normalisedBps, 10000); + assert.strictEqual(result.metrics[0]?.clamping, 'zero-range-met'); + }); + }); + + describe('weighted combination', () => { + it('combines weighted metrics exactly', () => { + const result = normaliseContributionScore([ + { key: 'commits', value: 50n, minimum: 0n, target: 100n, weightBps: 6000 }, + { key: 'reviews', value: 25n, minimum: 0n, target: 100n, weightBps: 4000 }, + ]); + + // 5000 bps at 60% plus 2500 bps at 40% is 3000 + 1000. + assert.strictEqual(result.scoreBps, 4000); + assert.strictEqual(result.metrics[0]?.weightedScoreBps, 3000); + assert.strictEqual(result.metrics[1]?.weightedScoreBps, 1000); + }); + + it('floors the combined score once rather than per metric', () => { + // Each metric normalises to 3333 bps and weighs 50%, so each weighted + // contribution is 1666.5 bps. Flooring per metric would yield 3332. + const result = normaliseContributionScore([ + { key: 'a', value: 1n, minimum: 0n, target: 3n, weightBps: 5000 }, + { key: 'b', value: 1n, minimum: 0n, target: 3n, weightBps: 5000 }, + ]); + + assert.strictEqual(result.scoreBps, 3333); + assert.strictEqual(result.metrics[0]?.weightedScoreBps, 1666); + assert.strictEqual(result.metrics[1]?.weightedScoreBps, 1666); + }); + + it('accepts a zero-weighted metric without affecting the score', () => { + const result = normaliseContributionScore([ + { key: 'counted', value: 50n, minimum: 0n, target: 100n, weightBps: 10000 }, + { key: 'ignored', value: 100n, minimum: 0n, target: 100n, weightBps: 0 }, + ]); + + assert.strictEqual(result.scoreBps, 5000); + assert.strictEqual(result.metrics[1]?.normalisedBps, 10000); + assert.strictEqual(result.metrics[1]?.weightedScoreBps, 0); + }); + + it('keeps the score inside the documented range when every metric is maxed', () => { + const result = normaliseContributionScore([ + { key: 'a', value: 999n, minimum: 0n, target: 10n, weightBps: 3333 }, + { key: 'b', value: 999n, minimum: 0n, target: 10n, weightBps: 3333 }, + { key: 'c', value: 999n, minimum: 0n, target: 10n, weightBps: 3334 }, + ]); + + assert.strictEqual(result.scoreBps, 10000); + }); + + it('keeps the score at zero when every metric is below its minimum', () => { + const result = normaliseContributionScore([ + { key: 'a', value: 0n, minimum: 10n, target: 20n, weightBps: 5000 }, + { key: 'b', value: 1n, minimum: 10n, target: 20n, weightBps: 5000 }, + ]); + + assert.strictEqual(result.scoreBps, 0); + }); + }); + + describe('large values', () => { + it('supports metrics far beyond Number.MAX_SAFE_INTEGER', () => { + const result = normaliseContributionScore( + singleMetric({ value: 10n ** 40n, minimum: 0n, target: 2n * 10n ** 40n }) + ); + + assert.strictEqual(result.metrics[0]?.normalisedBps, 5000); + assert.strictEqual(result.scoreBps, 5000); + }); + + it('stays exact with a large minimum offset', () => { + const minimum = 10n ** 30n; + const result = normaliseContributionScore( + singleMetric({ + value: minimum + 25n * 10n ** 18n, + minimum, + target: minimum + 10n ** 20n, + }) + ); + + assert.strictEqual(result.metrics[0]?.normalisedBps, 2500); + }); + + it('returns large raw values unchanged in the breakdown', () => { + const value = 12345678901234567890123456789n; + const result = normaliseContributionScore( + singleMetric({ value, minimum: 0n, target: value }) + ); + + assert.strictEqual(result.metrics[0]?.value, value); + assert.strictEqual(result.metrics[0]?.target, value); + }); + }); + + describe('order independence', () => { + it('produces an identical result for reordered metrics', () => { + const metrics: ContributionMetric[] = [ + { key: 'commits', value: 50n, minimum: 0n, target: 100n, weightBps: 5000 }, + { key: 'reviews', value: 30n, minimum: 0n, target: 60n, weightBps: 3000 }, + { key: 'tasks', value: 9n, minimum: 0n, target: 12n, weightBps: 2000 }, + ]; + + const ordered = normaliseContributionScore(metrics); + const shuffled = normaliseContributionScore([ + metrics[2]!, + metrics[0]!, + metrics[1]!, + ]); + + assert.deepStrictEqual(shuffled, ordered); + }); + + it('sorts the breakdown by metric key', () => { + const result = normaliseContributionScore([ + { key: 'zeta', value: 1n, minimum: 0n, target: 1n, weightBps: 5000 }, + { key: 'alpha', value: 1n, minimum: 0n, target: 1n, weightBps: 5000 }, + ]); + + assert.deepStrictEqual( + result.metrics.map((metric) => metric.key), + ['alpha', 'zeta'] + ); + }); + }); + + describe('explainability', () => { + it('returns the inputs alongside the calculated values', () => { + const result = normaliseContributionScore([ + { key: 'commits', value: 40n, minimum: 10n, target: 90n, weightBps: 10000 }, + ]); + + assert.deepStrictEqual(result.metrics[0], { + key: 'commits', + value: 40n, + minimum: 10n, + target: 90n, + weightBps: 10000, + clamping: 'in-range', + normalisedBps: 3750, + weightedScoreBps: 3750, + }); + }); + }); + + describe('validation', () => { + it('rejects an empty metric list', () => { + assert.throws( + () => normaliseContributionScore([]), + expectValidationError('EMPTY_METRICS') + ); + }); + + it('rejects an empty metric key', () => { + assert.throws( + () => normaliseContributionScore(singleMetric({ key: '' })), + expectValidationError('INVALID_KEY') + ); + }); + + it('rejects duplicate metric keys', () => { + assert.throws( + () => + normaliseContributionScore([ + { key: 'commits', value: 1n, minimum: 0n, target: 2n, weightBps: 5000 }, + { key: 'commits', value: 2n, minimum: 0n, target: 2n, weightBps: 5000 }, + ]), + expectValidationError('DUPLICATE_KEY') + ); + }); + + it('rejects a target below the minimum', () => { + assert.throws( + () => normaliseContributionScore(singleMetric({ minimum: 100n, target: 10n })), + expectValidationError('INVALID_RANGE') + ); + }); + + it('rejects a negative value', () => { + assert.throws( + () => normaliseContributionScore(singleMetric({ value: -1n })), + expectValidationError('NEGATIVE_VALUE') + ); + }); + + it('rejects a negative range bound', () => { + assert.throws( + () => normaliseContributionScore(singleMetric({ minimum: -10n, target: 10n })), + expectValidationError('NEGATIVE_RANGE_BOUND') + ); + }); + + it('rejects a non-bigint value supplied from untyped callers', () => { + assert.throws( + () => + normaliseContributionScore( + singleMetric({ value: 50 as unknown as bigint }) + ), + expectValidationError('INVALID_VALUE') + ); + }); + + it('rejects a weight above the full scale', () => { + assert.throws( + () => normaliseContributionScore(singleMetric({ weightBps: 10001 })), + expectValidationError('INVALID_WEIGHT') + ); + }); + + it('rejects a negative weight', () => { + assert.throws( + () => normaliseContributionScore(singleMetric({ weightBps: -1 })), + expectValidationError('INVALID_WEIGHT') + ); + }); + + it('rejects a fractional weight', () => { + assert.throws( + () => normaliseContributionScore(singleMetric({ weightBps: 5000.5 })), + expectValidationError('INVALID_WEIGHT') + ); + }); + + it('rejects weights totalling less than the full scale', () => { + assert.throws( + () => + normaliseContributionScore([ + { key: 'a', value: 1n, minimum: 0n, target: 2n, weightBps: 5000 }, + { key: 'b', value: 1n, minimum: 0n, target: 2n, weightBps: 4000 }, + ]), + expectValidationError('INVALID_TOTAL_WEIGHT') + ); + }); + + it('rejects weights totalling more than the full scale', () => { + assert.throws( + () => + normaliseContributionScore([ + { key: 'a', value: 1n, minimum: 0n, target: 2n, weightBps: 6000 }, + { key: 'b', value: 1n, minimum: 0n, target: 2n, weightBps: 5000 }, + ]), + expectValidationError('INVALID_TOTAL_WEIGHT') + ); + }); + }); +}); diff --git a/packages/contribution-normalisation/src/index.ts b/packages/contribution-normalisation/src/index.ts new file mode 100644 index 0000000..d15f255 --- /dev/null +++ b/packages/contribution-normalisation/src/index.ts @@ -0,0 +1,244 @@ +/** + * Deterministic contribution score normalisation. + * + * Converts heterogeneous weighted contribution metrics, which may operate on + * incompatible raw scales, into a single bounded score expressed in basis + * points. Every threshold comparison and every division is performed with + * `bigint` arithmetic, so results are exactly reproducible and independent of + * floating-point behaviour. + * + * The engine is a pure scoring primitive: it performs no persistence, emits no + * events and has no dependency on contribution, reward or badge systems. + */ + +/** Upper bound of the normalised scale: 10000 basis points equals 100.00%. */ +export const SCORE_SCALE_BPS = 10000; + +const SCORE_SCALE = BigInt(SCORE_SCALE_BPS); + +/** + * Which clamping rule produced a metric's normalised value. + * + * - `in-range` — the raw value sat between `minimum` and `target` inclusive. + * - `below-minimum` — the raw value was under `minimum` and scored 0 bps. + * - `above-target` — the raw value exceeded `target` and scored 10000 bps. + * - `zero-range-below` — `minimum` equalled `target` and the value was under + * it, scoring 0 bps. + * - `zero-range-met` — `minimum` equalled `target` and the value reached it, + * scoring 10000 bps. + */ +export type MetricClamping = + | 'in-range' + | 'below-minimum' + | 'above-target' + | 'zero-range-below' + | 'zero-range-met'; + +/** A single raw contribution metric and its configured scoring range. */ +export interface ContributionMetric { + /** Stable identifier, unique within a single call. */ + key: string; + /** Raw measurement. Must not be negative. */ + value: bigint; + /** Value scoring 0 bps. Must not be negative. */ + minimum: bigint; + /** Value scoring 10000 bps. Must be greater than or equal to `minimum`. */ + target: bigint; + /** Share of the final score, in basis points. Integer between 0 and 10000. */ + weightBps: number; +} + +/** Per-metric intermediate results, returned for explainability. */ +export interface MetricBreakdown { + key: string; + value: bigint; + minimum: bigint; + target: bigint; + weightBps: number; + /** Clamping rule that produced `normalisedBps`. */ + clamping: MetricClamping; + /** Normalised position within the range, from 0 to 10000 bps. */ + normalisedBps: number; + /** + * This metric's weighted contribution, floored independently. Informational + * only: the final score floors once over the exact sum, so these figures may + * total slightly less than `scoreBps`. + */ + weightedScoreBps: number; +} + +/** Result of a normalisation run. */ +export interface ContributionScoreResult { + /** Final combined score, from 0 to 10000 bps. */ + scoreBps: number; + /** Sum of the configured weights. Always 10000 for a successful run. */ + totalWeightBps: number; + /** Per-metric breakdown, ordered by `key` so results are order-independent. */ + metrics: MetricBreakdown[]; +} + +/** Raised when metric or weight configuration is rejected before scoring. */ +export class ValidationError extends Error { + constructor(public code: string, message: string) { + super(message); + this.name = 'ValidationError'; + } +} + +function assertBigInt( + candidate: unknown, + code: string, + label: string, + key: string +): asserts candidate is bigint { + if (typeof candidate !== 'bigint') { + throw new ValidationError(code, `${label} for metric ${key} must be a bigint`); + } +} + +function validateMetric(metric: ContributionMetric, seenKeys: Set): void { + if (typeof metric.key !== 'string' || metric.key.length === 0) { + throw new ValidationError('INVALID_KEY', 'Metric key must be a non-empty string'); + } + + if (seenKeys.has(metric.key)) { + throw new ValidationError('DUPLICATE_KEY', `Duplicate metric key: ${metric.key}`); + } + seenKeys.add(metric.key); + + assertBigInt(metric.value, 'INVALID_VALUE', 'Value', metric.key); + assertBigInt(metric.minimum, 'INVALID_RANGE', 'Minimum', metric.key); + assertBigInt(metric.target, 'INVALID_RANGE', 'Target', metric.key); + + if (metric.value < 0n) { + throw new ValidationError( + 'NEGATIVE_VALUE', + `Negative value for metric ${metric.key}` + ); + } + + if (metric.minimum < 0n || metric.target < 0n) { + throw new ValidationError( + 'NEGATIVE_RANGE_BOUND', + `Negative range bound for metric ${metric.key}` + ); + } + + if (metric.target < metric.minimum) { + throw new ValidationError( + 'INVALID_RANGE', + `Target is below minimum for metric ${metric.key}` + ); + } + + if ( + typeof metric.weightBps !== 'number' || + !Number.isInteger(metric.weightBps) || + metric.weightBps < 0 || + metric.weightBps > SCORE_SCALE_BPS + ) { + throw new ValidationError( + 'INVALID_WEIGHT', + `Weight for metric ${metric.key} must be an integer between 0 and ${SCORE_SCALE_BPS} basis points` + ); + } +} + +/** + * Normalises one metric into the 0 to 10000 bps scale. + * + * Uses a single exact `bigint` division, floored, so a value sitting between + * two basis points always resolves downwards. + */ +function normaliseMetric(metric: ContributionMetric): { + clamping: MetricClamping; + normalised: bigint; +} { + const range = metric.target - metric.minimum; + + if (range === 0n) { + return metric.value >= metric.target + ? { clamping: 'zero-range-met', normalised: SCORE_SCALE } + : { clamping: 'zero-range-below', normalised: 0n }; + } + + if (metric.value < metric.minimum) { + return { clamping: 'below-minimum', normalised: 0n }; + } + + if (metric.value > metric.target) { + return { clamping: 'above-target', normalised: SCORE_SCALE }; + } + + return { + clamping: 'in-range', + normalised: ((metric.value - metric.minimum) * SCORE_SCALE) / range, + }; +} + +/** + * Combines weighted contribution metrics into a single bounded score. + * + * Each metric is normalised into the 0 to 10000 bps scale, multiplied by its + * weight, and accumulated exactly. The accumulator is divided by the scale once + * at the end, so no intermediate rounding is carried between metrics. Because + * the configured weights must total exactly 10000 bps, the result is always + * within 0 to 10000 bps. + * + * Metric order does not affect the result: keys must be unique and the returned + * breakdown is sorted by key. + * + * @throws {ValidationError} If any metric or the total weight is invalid. + */ +export function normaliseContributionScore( + metrics: readonly ContributionMetric[] +): ContributionScoreResult { + if (!Array.isArray(metrics) || metrics.length === 0) { + throw new ValidationError('EMPTY_METRICS', 'At least one metric is required'); + } + + const seenKeys = new Set(); + const breakdown: MetricBreakdown[] = []; + let totalWeightBps = 0; + let weightedTotal = 0n; + + for (const metric of metrics) { + validateMetric(metric, seenKeys); + + const { clamping, normalised } = normaliseMetric(metric); + const weighted = normalised * BigInt(metric.weightBps); + + totalWeightBps += metric.weightBps; + weightedTotal += weighted; + + breakdown.push({ + key: metric.key, + value: metric.value, + minimum: metric.minimum, + target: metric.target, + weightBps: metric.weightBps, + clamping, + normalisedBps: Number(normalised), + weightedScoreBps: Number(weighted / SCORE_SCALE), + }); + } + + if (totalWeightBps !== SCORE_SCALE_BPS) { + throw new ValidationError( + 'INVALID_TOTAL_WEIGHT', + `Configured weights must total ${SCORE_SCALE_BPS} basis points, received ${totalWeightBps}` + ); + } + + breakdown.sort((a, b) => { + if (a.key < b.key) return -1; + if (a.key > b.key) return 1; + return 0; + }); + + return { + scoreBps: Number(weightedTotal / SCORE_SCALE), + totalWeightBps, + metrics: breakdown, + }; +} diff --git a/packages/contribution-normalisation/tsconfig.json b/packages/contribution-normalisation/tsconfig.json new file mode 100644 index 0000000..c99ec7b --- /dev/null +++ b/packages/contribution-normalisation/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +}