npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@traffical/sdk-spec

v0.7.0

Published

Language-agnostic specifications for Traffical SDKs - schemas and test vectors

Downloads

330

Readme

Traffical SDK Specification

Language-agnostic specifications for implementing Traffical SDKs.

Overview

This repository contains:

  • JSON Schemas - Type definitions for configuration bundles and events
  • Test Vectors - Deterministic test fixtures for validating SDK implementations

All Traffical SDKs must implement these specifications to ensure consistent behavior across languages.

Schemas

config-bundle.schema.json

The complete configuration bundle that SDKs fetch and cache. Contains:

  • Organization and project identifiers
  • Hashing configuration (unit key, bucket count)
  • Parameters with defaults and layer membership
  • Layers with policies and allocations
  • Conditions for targeting

events.schema.json

Event payloads sent to the Traffical API:

  • Exposure events (when a user is assigned to a variant)
  • Decision events (parameter resolutions, with per-layer metadata)
  • Track events (custom user actions)

Exposure and decision events carry an optional top-level configVersion — the config bundle version the SDK evaluated against. Per-layer entries (ExposureLayerInfo) carry optional propensity fields:

  • probability — propensity of the CHOSEN allocation at decision time, in (0, 1]. For linear_contextual policies this is the floored-softmax probability of the chosen allocation; for other adaptive policies (thompson_bernoulli / epsilon_greedy / ucb1) it is the chosen allocation's bucket-range share ((bucketRange[1] - bucketRange[0] + 1) / bucketCount); for per-entity dynamic allocations it is the weight the SDK actually used. Static policies omit the field entirely.
  • modelVersion — only for linear_contextual: the model timestamp of the coefficients used (trainingSummary.generatedAt or the bundle model's equivalent).

See test-vectors/fixtures/events_conformance.json for payload conformance vectors covering these fields.

traffical-config.schema.json

Local project configuration file (.traffical/config.yaml) schema.

Test Vectors

The test-vectors/ directory contains deterministic test fixtures for validating SDK implementations.

Purpose

All Traffical SDKs must produce identical results for the same inputs:

  1. Hashing consistency - The SHA-256 v2 assignment hash produces the same bucket across all implementations
  2. Resolution correctness - Parameter resolution follows the layered priority system
  3. Condition evaluation - Context predicates are evaluated identically

Fixture Structure

test-vectors/
├── fixtures/
│   ├── bundle_*.json        # Config bundles to test against
│   └── expected_*.json      # Expected outputs for each bundle
└── README.md

Running Tests

Each SDK implementation should:

  1. Load the bundle JSON
  2. For each test case:
    • Compute buckets and verify against expectedHashing
    • Resolve parameters and verify against expectedAssignments

Hash Function Reference

Traffical uses the SHA-256 v2 assignment hash for bucket computation. This replaced FNV-1a, which passed single-layer uniformity but failed cross-experiment independence with realistic UUID/ULID unit keys and lay_* layer IDs (assignment in one layer could predict assignment in another).

Bucket assignment contract

input  = "traffical:assignment:v2|u:<unitLen>:<unitKeyValue>|l:<layerLen>:<layerId>"
digest = SHA256(UTF-8 bytes of input)
hashInt = first 64 bits of digest, unsigned big-endian
bucket  = hashInt % bucketCount
  • Versioned (v2) and domain-separated (traffical:assignment) so the contract is explicit and cannot collide with other hashes.
  • Length-framed: <unitLen> and <layerLen> are the number of UTF-8 bytes of each field value (not UTF-16 code units or grapheme clusters), so values containing : or | cannot create ambiguous inputs and every SDK frames identically across languages.
  • UTF-8 byte domain: the digest is computed over the UTF-8 bytes of the framed input. See test-vectors/fixtures/bundle_unicode.json for conformance fixtures that lock UTF-8 byte framing of both unit keys and layer IDs.

Example

  • Input: "traffical:assignment:v2|u:8:user-abc|l:8:layer_ui"
  • SHA-256 first 64 bits (big-endian): 3044655943265667177
  • Bucket (mod 1000): 177

Weighted selection

Per-entity and contextual selection use the same SHA-256 primitive on a seed string ("<entityId>:<unitKeyValue>:<policyId>" or "ctx:<unitKeyValue>:<policyId>"): the first 64 bits of the digest are reduced to a uniform value in [0, 1) via (hashInt mod 2^53) / 2^53, which is then used to walk the cumulative weights.

Normative behavior (0.7.0)

The sections below are the normative cross-SDK behavior contract introduced in spec 0.7.0 (drift-remediation). Every Traffical SDK MUST implement them exactly; they are testable against the conformance vectors in test-vectors/. The key words MUST, MUST NOT, SHOULD, and MAY are used per RFC 2119.

A few of these rules deliberately contradict the current reference JS engine (js-sdk/packages/core), which is the 1-of-4 outlier on those points; the engine is aligned to this spec in a follow-up ("Phase 2"). Such cases are called out inline so implementers do not "match JS" where JS is wrong.

Condition evaluation

A policy's conditions are context predicates that gate eligibility. All conditions in the array are AND-ed: a policy is eligible only when every condition matches. An empty (or absent) conditions array always matches.

Field lookup (dot-notation, nested)

condition.field is resolved against the evaluation context using dot-notation nested lookup:

  • The field is split on . into path segments.
  • Each segment indexes into the current value only when that value is a (non-null) object; array elements are addressed by their numeric-string index (tags.0).
  • If any segment is reached on a value that is null, undefined, or a non-object primitive, the lookup yields undefined (the "field is absent" signal). SDKs MUST NOT throw on a missing path.

Examples: user.plancontext.user.plan; tags.0 → the first element of context.tags.

iOS today does flat lookup only; it is aligned to nested lookup in Phase 2, and iOS device-info fields will be emitted as typed values.

Strict typing (no coercion)

Comparisons are strictly typed. There is no "42" == 42 coercion anywhere.

  • eq / neq — strict identity. The types must match: "42" does not equal 42. neq is the exact negation.
  • gt / gte / lt / lte (relational) — match only when the resolved context value is a number and the condition's value is a number. If the context value is not numeric (missing, string, boolean, object), the condition does not match.
  • contains / startsWith / endsWith / regex (string ops) — match only when both the context value and value are strings. regex compiles value as a regular expression and matches against the context string; an invalid pattern does not match (SDKs MUST NOT throw). Regex dialect is the host platform's (ECMAScript / PCRE / ICU / Python re); authors SHOULD keep patterns to the common subset.
  • in / nin — membership by strict equality against condition.values. If values is not an array, in does not match and nin does match.
  • exists / notExistsexists matches when the resolved value is neither undefined nor null; notExists is its negation. No value is read.

iOS today uses loose coercion (e.g. string device-info appBuildNumber matching a numeric gte 500). It is aligned to strict typing in Phase 2.

Omitted condition value (never-match)

For the relational operators gt, gte, lt, and lte, if the condition carries no value (the key is absent), the condition MUST NEVER match, regardless of the context value. SDKs MUST NOT coerce a missing threshold to 0 (or any other default).

Python currently coerces a missing threshold to 0.0; it is the outlier and is fixed in Phase 2.

Conformance vectors: test-vectors/fixtures/bundle_conditions_omitted.json / expected_conditions_omitted.json place an omitted-value gte policy ahead of a valid gte 100 policy and prove the omitted policy is always skipped.

Operator reference

eq, neq, in, nin, gt, gte, lt, lte, contains, startsWith, endsWith, regex, exists, notExists. An unknown operator MUST fail safe (no match).

Known gap: version-string comparison

Comparing version strings (semver, e.g. appVersion gte "2.10.0") is not yet specified. Under the strict rules above, a relational op against a string context value does not match, so semver comparison currently has no defined operator. A dedicated version-comparison operator is a future addition and is intentionally out of scope for 0.7.0; do not emulate it via lexical string comparison.

Unit key values

The unit key value is the context field named by hashing.unitKey (or a layer's unitKey override), read from the evaluation context and fed to the assignment hash.

Canonical stringification (S2)

A unit key value read from context MUST be stringified with a single canonical rule: ECMAScript Number::toString (equivalently, String() applied to the value parsed from JSON). This guarantees a numeric unit key produces the same string — and therefore the same bucket — on every SDK.

  • Integers render without a decimal point or exponent within their integer range: 42"42", 100"100". A JSON 42.0 parses to the number 42 and renders "42".
  • Non-integers render minimally: 1.5"1.5", 0.1"0.1".
  • Very large / very small magnitudes use exponential form exactly as Number::toString specifies: 1e21"1e+21", 1e-7"1e-7".
  • -0 renders "0". Booleans render "true" / "false".

Python's engine/strings.py is the reference port of this rule for non-JS SDKs; other SDKs MUST match its output byte-for-byte.

iOS today stringifies numeric keys via String(Int64(n)), which is trapping — it can crash on values ≥ 2^63 and truncates fractionals. It is replaced with the canonical rule in Phase 2.

Conformance vectors: test-vectors/fixtures/bundle_numeric_unit_key.json / expected_numeric_unit_key.json lock the canonical form for 9007199254740993 (2^53 + 1 → "9007199254740992") and 1e21 (→ "1e+21").

Empty layer unit-key override (skip the layer) (S1)

A layer MAY carry a unitKey override to bucket on a different context field (multi-entity randomization). If a layer's unitKey override is empty or whitespace-only, the override is invalid and the SDK MUST:

  1. Skip the layer — emit its resolution row with bucket = -1, match no policy, and leave the layer's parameters at their defaults.
  2. Record no exposure for that layer.
  3. NOT fall back to the project-level hashing.unitKey for that layer.
  4. NOT crash and NOT reject the whole bundle — every other layer resolves normally.

This is distinct from a valid override naming a field that happens to be absent from the context: that layer is likewise skipped (bucket -1), but for the missing-value reason. The empty/whitespace override string itself is the invalid-configuration case S1 governs.

The current reference JS engine treats an empty-string layer override as falsy and falls back to the project unit key — this is WRONG per this spec. JS is the 1-of-4 outlier and is corrected in Phase 2.

Conformance vectors: test-vectors/fixtures/bundle_empty_unit_key.json / expected_empty_unit_key.json (hand-authored to the decided skip semantics — both an empty "" and a whitespace " " override are skipped with bucket -1, while the project-keyed layer resolves normally).

Contextual Model Resolution

Policies with algorithm: "linear_contextual" ship a trained model in the bundle via the contextualModel field on BundlePolicy. When present, the SDK uses this model to compute personalized allocation probabilities instead of the standard bucket-based assignment.

Discriminator note. The algorithm field (linear_contextual, thompson_bernoulli, epsilon_greedy, ucb1) is an optional, server-provided informational label — it is defined on BundlePolicy in config-bundle.schema.json purely as telemetry/metadata and carries no resolution semantics. The SDK-side discriminator for contextual scoring is the presence of contextualModel: if a policy carries a trained contextualModel, the SDK runs the scoring pipeline below; otherwise it falls through to bucket-based resolution (see Graceful Degradation). An SDK never needs to read algorithm to resolve a policy.

Scoring Pipeline

  1. Linear score per allocation: score = intercept + SUM(coef_i * feature_i). For each allocation, look up its BundleAllocationCoefficients. If an allocation has no coefficients, use defaultAllocationScore.
  2. Softmax: Convert raw scores to probabilities using temperature gamma. Lower gamma is more deterministic (exploitative), higher gamma is more uniform (explorative).
  3. Probability floor: Enforce actionProbabilityFloor as a minimum probability for any allocation to ensure continued exploration. Clamp below-floor entries and renormalize.
  4. Deterministic selection: Use weightedSelection(probabilities, seed) with seed "ctx:" + unitKeyValue + ":" + policyId to deterministically select an allocation via the SHA-256 v2 hash.

Feature Types

  • Numeric: score += coef * contextValue. When the context field is missing or non-numeric, missing is added instead.
  • Categorical: score += values[contextValue]. When the context field is missing or the value is not in the values map, missing is added instead.

Graceful Degradation

If contextualModel is absent on a policy (no trained model yet), the SDK falls through to standard bucket-based resolution using the allocation bucket ranges. This means newly-created contextual policies serve uniform traffic until the first training run publishes coefficients.

Numerical guards (S6)

The softmax and floor steps have two mandatory guards so that all SDKs produce the same probabilities at the edges of the parameter space:

  • safeGamma = max(gamma, 1e-10) — the softmax temperature used for scaling is safeGamma, never the raw gamma. A gamma of 0 (or any value below 1e-10) is clamped to 1e-10, yielding a near-deterministic-but-defined distribution. SDKs MUST NOT divide by a zero temperature, and MUST NOT substitute an argmax shortcut or reset the temperature to 1.
  • effectiveFloor = min(floor, 1/n) — where floor is actionProbabilityFloor and n is the number of allocations. The floor applied per allocation is effectiveFloor, so flooring can never demand more than 100% of the probability mass. Each probability is raised to effectiveFloor, then the vector is renormalized. When floor <= 0, flooring is skipped.

iOS today maps gamma = 0 to temperature 1 (then argmax) and omits the min(floor, 1/n) cap. It is aligned to these guards in Phase 2.

Conformance vectors: bundle_contextual_gamma_zero.json / expected_contextual_gamma_zero.json lock the gamma = 0 near-argmax distribution ([0.909091, 0.045455, 0.045455]), and bundle_contextual_high_floor.json / expected_contextual_high_floor.json lock the floor = 0.5, n = 3 capped distribution (effectiveFloor = 1/3[0.524953, 0.237524, 0.237524], NOT a raw-0.5 floor).

Model version sourcing (S7)

When an SDK emits an exposure/decision event for a linear_contextual selection, layers[].modelVersion MUST be sourced as:

modelVersion = contextualModel.generatedAt ?? contextualModel.modelVersion

generatedAt (an ISO 8601 date-time, added to BundleContextualModel in Part A) is the canonical source; the legacy modelVersion label is the only fallback. There is no further fallback to policy.stateVersion — if both generatedAt and modelVersion are absent, the SDK MUST omit modelVersion entirely rather than emit a wrong label.

The current reference JS engine still falls back to policy.stateVersion (resolution/engine.ts); that fallback is removed in Phase 2.

Test Vectors

See test-vectors/fixtures/bundle_contextual.json and expected_contextual.json for conformance test cases covering numeric features, categorical features, missing context fields, and unknown categorical values. Near-gridline softmax behavior is locked by bundle_contextual_boundary.json / expected_contextual_boundary.json, and the S6 numerical guards (safeGamma, effectiveFloor) by bundle_contextual_gamma_zero.json and bundle_contextual_high_floor.json.

Exposure event contract

trackExposure() records that a unit actually saw the parameters resolved by a decide(). The exposure stream feeds treatment-on-the-treated metrics and carries the 0.6.0 propensity rows (layers[].probability / modelVersion), so its shape MUST be identical across SDKs.

Canonical shape (S4)

  • One exposure event per trackExposure() call. SDKs MUST NOT emit one event per layer.
  • The event's layers[] contains only newly-exposed, non-attributionOnly layers:
    • attributionOnly layers — layers resolved for attribution but whose parameters were not requested by this decision — MUST be excluded (they would inflate exposure for experiments the unit never saw).
    • Layers already exposed for this unit in the current session (see dedup below) MUST be excluded.
  • If, after that filtering, no layers remain, the SDK MUST emit no event.
  • Each surviving layer row carries its propensity fields (probability, modelVersion) exactly as produced by resolution.

This is the shape the Node SDK emits today. js-client and iOS currently emit one event per newly-deduped layer, each carrying the full unfiltered layers array (including attributionOnly); php and python emit a single event with no dedup. All four are aligned to the canonical shape in Phase 2.

Conformance vectors: test-vectors/fixtures/exposure_shape.json gives, for each decision (resolved layers + alreadyExposed dedup state), the exact expectedEvents trackExposure() must emit — one event with attributionOnly and already-exposed layers filtered out, or zero events when nothing survives.

Session deduplication (on by default)

Exposure dedup is on by default. A (unit, layer, allocation) combination already exposed within the current session is suppressed from subsequent events.

  • Client SDKs (browser / mobile): a persisted 30-minute session window (default TTL), surviving across decide()/trackExposure() calls and page loads, cleared when the unit changes (identify).
  • Server SDKs: a bounded in-memory LRU with a TTL (never an unbounded map), so long-lived processes cannot leak memory.
  • Dedup is configurable: an opt-out flag (deduplicateExposures) and a TTL knob (exposureSessionTtlMs) MUST exist on every SDK.

SDK-side dedup is a volume optimization only. The ingestion pipeline is authoritative for final deduplication and counting; SDKs MUST NOT assume their local dedup is exact across processes or devices.

SDK runtime behavior

The spec defines data and math; this section defines the network and lifecycle behavior every SDK MUST implement so runtime semantics don't drift.

Fail-open (including malformed bundles) (S8)

An SDK MUST never crash or throw out of decide() / getParams() because configuration is unavailable or bad. It degrades to the best available source in this order: last-good cached bundle → localConfig (build-time baked bundle, if provided) → caller-supplied defaults.

Fail-open applies to both:

  • Fetch failures — HTTP 404, network error, or timeout on the config request.
  • Malformed bundles — a fetched bundle that fails schema validation, has bucketCount < 1, or is missing hashing.unitKey. A malformed bundle MUST be discarded (keep the last-good bundle, or fall through to localConfig / defaults); it MUST NOT replace a good cached bundle and MUST NOT crash the SDK.

The empty layer unitKey override (S1) is handled at the layer level (skip that layer), not by failing the whole bundle.

Caching, refresh, and jitter

  • The SDK caches the last-good bundle and refreshes on an interval (default 60s).
  • The refresh interval MUST be applied with jitter (±10% recommended) to avoid synchronized thundering-herd refetches across clients.
  • When the config response carries suggestedRefreshMs, the SDK MUST honor it in place of the default interval. (PHP/iOS currently parse-and-ignore it; aligned in Phase 2.)

Event delivery

Event delivery standardizes on the Python SDK's model:

  • Bounded queue — events are buffered in a bounded in-memory queue. When the queue is full the SDK drops (oldest-first) rather than growing without limit. Unbounded requeue-on-failure is forbidden.
  • Batching — flush when the batch reaches the batch size (default 10) or when the flush interval (default 30s) elapses, whichever comes first.
  • Retry with backoff — transient delivery failures (network, 5xx, timeout) are retried with exponential backoff, bounded in attempts; events stay in the bounded queue between attempts.
  • Auth kill-switch — on an HTTP 401, the SDK MUST permanently disable event delivery (stop retrying, stop buffering) for the process lifetime rather than spin on a credential that will never succeed.

Mandatory timeouts

SDKs MUST set explicit request timeouts and MUST NOT rely on platform defaults:

| Request | Default timeout | |-------------------|-----------------| | Config fetch | 10s | | Event delivery | 10s | | Server resolve | 5s |

Server evaluation mode

In server-evaluated mode the SDK MUST resolve each call with the per-call context passed to that decide() / getParams() / resolve call — not a snapshot captured at initialization, and not an empty context. A unit key is required per call. (JS server-mode currently resolves once with an empty context; corrected in Phase 2.)

SDK Implementations

| Language | Repository | |----------|------------| | JavaScript/TypeScript | traffical/js-sdk | | Go | Coming soon | | Java | Coming soon |

License

MIT License - see LICENSE for details.