# Traffical SDK Test Vectors

This directory contains deterministic test fixtures for validating SDK implementations across languages.

## Purpose

All Traffical SDKs must produce identical results for the same inputs. These test vectors ensure:

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
4. **Contextual scoring** - Softmax scoring with trained model coefficients produces identical allocation selections

## Fixture Structure

```
fixtures/
├── bundle_*.json        # Config bundles to test against
└── expected_*.json      # Expected outputs for each bundle
```

### Bundle Files

Each bundle file contains a complete `ConfigBundle` with:
- Hashing configuration
- Parameters with defaults
- Layers with policies and allocations

### Expected Output Files

Each expected file contains:
- Reference to the bundle file
- Array of test cases with:
  - Input context
  - Expected bucket assignments (for hashing validation)
  - Expected parameter assignments

## Running Tests

### TypeScript (Reference Implementation)

```bash
cd sdk/core-ts
bun test
```

### Other Languages

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`

## Fixture Inventory

| Bundle | Expected | Description |
|--------|----------|-------------|
| `bundle_basic.json` | `expected_basic.json` | Basic parameter resolution with bucket-based allocation |
| `bundle_conditions.json` | `expected_conditions.json` | Condition evaluation for targeting |
| `bundle_conditions_omitted.json` | `expected_conditions_omitted.json` | S5: relational op with an omitted `value` never matches |
| `bundle_edge_policies.json` | `expected_edge_policies.json` | Per-entity policies with edge resolution |
| `bundle_per_layer_unit_key.json` | `expected_per_layer_unit_key.json` | Per-layer unit key override (multi-entity randomization) |
| `bundle_empty_unit_key.json` | `expected_empty_unit_key.json` | S1: empty/whitespace layer unitKey override → skip layer (bucket -1). Hand-authored |
| `bundle_numeric_unit_key.json` | `expected_numeric_unit_key.json` | S2: canonical `Number::toString` stringification of numeric unit keys. Hand-authored |
| `bundle_unicode.json` | `expected_unicode.json` | UTF-8 byte-framing of unit keys and layer IDs |
| `bundle_contextual.json` | `expected_contextual.json` | Contextual bandit scoring with trained model coefficients |
| `bundle_contextual_boundary.json` | `expected_contextual_boundary.json` | Near-gridline softmax selection (drift-sensitive) |
| `bundle_contextual_gamma_zero.json` | `expected_contextual_gamma_zero.json` | S6: `safeGamma = max(gamma, 1e-10)` near-argmax on `gamma = 0` |
| `bundle_contextual_high_floor.json` | `expected_contextual_high_floor.json` | S6: `effectiveFloor = min(floor, 1/n)` when `floor > 1/n` |
| `bundle_contextual_key_differs.json` | `expected_contextual_key_differs.json` | Allocation identity: `contextualModel.coefficients` resolves by allocation **key**, not display `name`. Hand-authored |
| — | `events_conformance.json` | Event payload validation against `events.schema.json` (propensity fields, configVersion) |
| — | `exposure_shape.json` | S4: exposure-event shape emitted by `trackExposure()` (single event, filtered layers, dedup) |

### `bundle_contextual.json` / `expected_contextual.json`

Tests SDK-side contextual bandit scoring. The bundle contains a policy with a `contextualModel` field holding trained linear coefficients (one numeric feature: `engagement_score`, one categorical feature: `device_type`). Test cases cover:

- **High engagement mobile user** - Strong signal favoring treatment_a via softmax
- **Low engagement desktop user** - Balanced probabilities, hash-dependent selection
- **Missing context fields** - Falls back to `missing` coefficient values (scores reduce to intercepts)
- **Unknown categorical value** - Category not in trained values, uses `missing` coefficient

Each test case documents the raw scores, softmax seed, and expected allocation for deterministic verification.

### `bundle_contextual_key_differs.json` / `expected_contextual_key_differs.json`

Locks allocation identity: **`key` is the identifier, `name` is display-only.**

Every allocation in `policy_contextual_key` has a `name` that differs from its `key` (`"Treatment A"` vs `"treatment-a"`), and `contextualModel.coefficients` is keyed by `key`. An implementation that resolves coefficients by `name` finds nothing on every arm, falls back to `defaultAllocationScore`, and silently degrades the trained model to a uniform softmax — a personalization model that appears healthy while doing nothing. The four cases are chosen so that:

- uniform selection lands on a **different** allocation than the correct distribution in **every** case, and
- each of the three arms is the correct answer at least once, so a name-keyed implementation cannot pass by favouring a fixed arm.

`policy_contextual_legacy` in the same bundle is the regression guard: its allocations carry **no `key` at all** (pre-key bundles) and its coefficients are keyed by name, so the required resolution is `key ?? name` rather than `key` alone.

Test cases assert `expectedPolicies`, keyed by policy id — `allocationName`, `allocationKey` (the warehouse join column, which must reach the event payload), and the logged propensity. Asserting the propensity and not just the winner matters: a uniform fallback reports `1/n`, so it fails even on a seed where it happens to select the same arm.

## Hand-authored fixtures — do NOT regenerate

`scripts/generate-fixtures.ts` rewrites `expected_*.json` from the reference JS engine. Three fixtures are deliberately **excluded** from it, because regenerating them would overwrite the decided behaviour with the current implementation's behaviour and turn a failing vector green without fixing anything:

- `expected_empty_unit_key.json` (S1)
- `expected_numeric_unit_key.json` (S2)
- `expected_contextual_key_differs.json` — the values are derived from the coefficients by hand; the reference engine currently resolves coefficients by `name` and would bake the uniform-softmax fallback in as "expected"

If one of these vectors fails, fix the implementation, never the fixture.

### `events_conformance.json`

Event payload conformance vectors for `schemas/events.schema.json`. Unlike the resolution fixtures, these are not bundle/expected pairs: each test case carries a complete event payload and a `valid` flag, and SDK harnesses validate the payload against the schema (expecting acceptance or rejection accordingly). Cases cover:

- **Contextual exposure with propensity** - `linear_contextual` layer entry with `probability` (floored-softmax probability of the chosen allocation), `modelVersion` (timestamp of the coefficients used), and top-level `configVersion`
- **Adaptive exposure with bucket-share probability** - context-free adaptive policy (thompson_bernoulli/epsilon_greedy/ucb1) where `probability` is the chosen allocation's bucket-range share `(bucketRange[1] - bucketRange[0] + 1) / bucketCount`
- **Static exposure without probability** - static policies omit `probability`/`modelVersion` entirely
- **Decision event with configVersion** - top-level `configVersion` on decision events
- **Legacy exposure** - pre-0.6.0 payloads without any of the new fields still validate
- **Bounds rejection** - `probability` of `0` (violates `exclusiveMinimum`) and `1.5` (violates `maximum`) are rejected

### 0.7.0 drift-remediation fixtures

These lock the decisions from the 2026-07 cross-SDK drift audit. Several encode
behavior the current reference JS engine gets wrong (aligned in "Phase 2"); those
are **hand-authored** to the decided semantics and are **not** regenerated by
`scripts/generate-fixtures.ts`.

- **`bundle_empty_unit_key.json` / `expected_empty_unit_key.json` (S1)** — an
  empty (`""`) or whitespace-only (`"   "`) layer `unitKey` override is invalid:
  the layer is skipped (`bucket -1`, no policy, defaults served, no exposure),
  with **no fallback** to the project unit key. The project-keyed layer resolves
  normally. Hand-authored (JS currently falls back — wrong).
- **`bundle_numeric_unit_key.json` / `expected_numeric_unit_key.json` (S2)** —
  numeric unit keys are stringified with the single canonical rule ECMAScript
  `Number::toString`: `9007199254740993` (2^53 + 1) → `"9007199254740992"`;
  `1e21` → `"1e+21"`. Hand-authored so the demonstrative input tokens survive
  (a JSON re-serialize would collapse them to their parsed doubles).
- **`bundle_conditions_omitted.json` / `expected_conditions_omitted.json` (S5)**
  — a relational operator (`gt`/`gte`/`lt`/`lte`) with **no `value`** never
  matches. An omitted-value `gte` policy sits ahead of a valid `gte 100` policy
  to prove the omitted one is always skipped (no coercion to `0`).
- **`bundle_contextual_gamma_zero.json` / `expected_contextual_gamma_zero.json`
  (S6)** — `safeGamma = max(gamma, 1e-10)`: a `gamma` of `0` produces a
  near-argmax-but-defined distribution (`[0.909091, 0.045455, 0.045455]`), not a
  divide-by-zero or an argmax shortcut.
- **`bundle_contextual_high_floor.json` / `expected_contextual_high_floor.json`
  (S6)** — `effectiveFloor = min(floor, 1/n)`: with `floor = 0.5` and `n = 3`
  the applied floor is capped at `1/3`, yielding `[0.524953, 0.237524,
  0.237524]` (a raw-0.5 floor would differ).
- **`exposure_shape.json` (S4)** — the canonical exposure-event shape emitted by
  `trackExposure()`. Each case gives a decision's `resolvedLayers` (some
  `attributionOnly`) plus the in-session `alreadyExposed` dedup state, and the
  exact `expectedEvents`: **one** event carrying only newly-exposed,
  non-`attributionOnly` layers, or **zero** events when nothing survives. Every
  emitted event also validates against `events.schema.json`. Hand-authored.

## Adding New Test Cases

When adding new test vectors:

1. Create or update a bundle file with the configuration to test
2. Use the TypeScript SDK to compute expected outputs
3. Add test cases with clear comments explaining the expected behavior

## Hash Function Reference

Traffical uses the **SHA-256 v2 assignment hash** for bucket computation, over
the **UTF-8 bytes** of a length-framed, domain-separated input string:

```
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
```

`<unitLen>` / `<layerLen>` are the number of UTF-8 bytes of each field value.

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`

