Skip to content

feat(contributions): implement deterministic contribution score normalisation engine - #393

Open
diegoveme wants to merge 1 commit into
Adamantine-guild:mainfrom
diegoveme:feat/contribution-score-normalisation
Open

feat(contributions): implement deterministic contribution score normalisation engine#393
diegoveme wants to merge 1 commit into
Adamantine-guild:mainfrom
diegoveme:feat/contribution-score-normalisation

Conversation

@diegoveme

Copy link
Copy Markdown

Description

Adds @guildpass/contribution-normalisation, a standalone scoring primitive that
converts heterogeneous weighted contribution metrics onto a single bounded
basis-point scale.

Raw contribution metrics operate on incompatible scales, so they cannot be
combined directly. This package defines how each metric is projected onto one
common scale and how those projections are combined, using exact bigint
arithmetic so the result is reproducible.

Each metric is normalised with a single exact division:

normalisedBps = ((value - minimum) * 10000) / (target - minimum)

Weighted contributions are then accumulated exactly and divided once at the end:

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. Because configured weights must total exactly 10000 bps,
the final score is bounded to 0–10000 by construction: each normalisedBps is
at most 10000, so the accumulator is at most 10000 * Σ weightBps_i.

Defined behaviours

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

A zero-range metric is a defined step function rather than a division by zero.
Exceeding the target earns no credit beyond it, so one outsized metric cannot
inflate a score past 10000 bps or compensate for a metric that was never met.

Weight totals other than exactly 10000 bps are rejected rather than rescaled,
because silently rescaling would make a configuration error look like a low
score.

Determinism

  • Every comparison, multiplication and division is bigint arithmetic. No
    threshold is computed in floating point.
  • Bounded outputs (normalisedBps, weightBps, scoreBps) are returned as
    number, where conversion from bigint is exact. Unbounded quantities
    (value, minimum, target) stay bigint and are never converted.
  • Metric keys must be unique and the returned breakdown is sorted by key, so
    reordered inputs produce deeply equal results, not merely equal scores.

Linked Issue

Closes #387

Type of Change

  • 🐛 Bug fix (API or policy engine)
  • ✨ New feature / endpoint
  • 📝 Documentation / OpenAPI spec update
  • 🔧 Chore / refactor / dependency update
  • 🧪 Tests only
  • ⛓️ Smart contract change (requires extra review)

Changes Made

  • packages/contribution-normalisation/src/index.ts — the engine:
    normaliseContributionScore, the ContributionMetric / MetricBreakdown /
    ContributionScoreResult contracts, the MetricClamping union, the
    SCORE_SCALE_BPS constant and ValidationError with stable codes.
  • packages/contribution-normalisation/src/index.test.ts — 36 unit tests.
  • packages/contribution-normalisation/package.json,
    packages/contribution-normalisation/tsconfig.json — mirror the existing
    primitive packages exactly (no dependencies, tsc build, node --test over
    dist).
  • docs/contribution-normalisation.md — the documented scoring model, following
    the structure of docs/webhook-verification.md.

Nothing outside the new package and its doc is touched. No README change: the
README.md tree documents the aspirational V2 architecture and lists none of
the existing primitive packages, so adding this one there would be inconsistent
with its siblings.

pnpm-lock.yaml is intentionally not modified. The package declares no
dependencies, and pnpm install --frozen-lockfile was verified to pass against
this branch, so the CI install step is unaffected.

Test Evidence

pnpm --filter @guildpass/contribution-normalisation test:

✔ normaliseContributionScore (5.8876ms)
ℹ tests 36
ℹ suites 9
ℹ pass 36
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 72.0078

Full workspace, matching the Core CI sequence — pnpm install --frozen-lockfile,
pnpm typecheck, pnpm build, pnpm test — all pass with exit code 0.

Coverage maps to the acceptance criteria:

Acceptance criterion Tests
Metrics normalised deterministically normalisation (4)
Exact integer weighted combination weighted combination (5)
Scores stay inside the documented range maxed-metrics and all-below-minimum cases
Below-minimum / above-target behaviour defined clamping (5)
Invalid ranges rejected INVALID_RANGE, NEGATIVE_VALUE, NEGATIVE_RANGE_BOUND, INVALID_VALUE
Invalid weights rejected INVALID_WEIGHT ×3, INVALID_TOTAL_WEIGHT ×2
Large bigint metrics supported large values (3), up to 10^40
Order independence with unique keys order independence (2), asserted with deepStrictEqual
Per-metric calculation details returned explainability (1)
Boundary values, weighting, clamping, large numbers boundary values (3) plus the above

General Checklist

  • I have read CONTRIBUTING.md
  • This PR is linked to an open issue
  • pnpm typecheck passes
  • pnpm build passes
  • pnpm test passes — all tests green
  • pnpm lint — no lint script exists in this workspace, so this step was
    not run
  • No Prisma schema changes in this PR
  • No API endpoints added, so no OpenAPI change is required
  • No secrets, keys, or wallet addresses introduced
  • No new environment variables, so .env.example is unchanged
  • New behaviour documented in docs/contribution-normalisation.md

Additional Notes

The package satisfies the issue's independence requirement: it has no
dependencies, performs no persistence, and does not reference the reward engine,
contribution persistence or any other campaign work. It computes a score and
returns it.

One small heads-up while reading the docs, in case it is useful: CONTRIBUTING.md
looks like it has drifted behind the V2 rebuild. It documents an npm-based
setup (npm install, npm run typecheck, npm run lint) and a workspace layout
of policy-engine, sdk-lite and packages/contracts, none of which are in the
tree today. The prerequisites also list Node 18+ and npm 9+, while the root
package.json pins pnpm@11.16.0 and Node >=24, and core-ci.yml runs on Node
24. It is the first thing a new contributor reads, so it may be worth a refresh
when there is time.

For this PR I followed .github/workflows/core-ci.yml and the root
package.json rather than the npm commands in CONTRIBUTING.md, so that what
I verified locally matches what CI actually runs.

…lisation engine

Adds a standalone scoring primitive that converts heterogeneous weighted
contribution metrics onto one bounded basis-point scale.

- Normalises each metric with exact bigint arithmetic; no floating-point
  threshold calculation anywhere in the scoring path
- Defines clamping for values below the minimum and above the target, and
  treats a zero-range metric as a step function rather than a division by zero
- Combines weighted metrics into a single accumulator and floors once, so
  truncation does not grow with the number of metrics
- Rejects invalid ranges, negative inputs, duplicate keys and weight totals
  other than 10000 bps via ValidationError with a stable code
- Returns per-metric intermediate results and sorts the breakdown by key, so
  reordered inputs produce deeply equal results
- Supports metrics far beyond Number.MAX_SAFE_INTEGER
- Documents the scoring model in docs/contribution-normalisation.md
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.

Build a deterministic contribution score normalisation engine

1 participant