Skip to content

llms.txt — rasuvaeff/yii3-ab-testing ​

The compact reference the package ships for AI coding assistants. Raw file: llms.txt.

markdown
# yii3-ab-testing

## Install

```bash
composer require rasuvaeff/yii3-ab-testing
```

## Rules

- Experiment/variant names: `/^[a-z][a-z0-9_-]*$/`
- Hash: `hexdec(substr(sha256(salt . ':' . subjectId), 0, 8))` — 32-bit unsigned
- Bucketing algorithm v1 is compatibility-stable; patch/minor releases must not change its hash, sorting, or boundaries
- assign()/is() are pure — no auto-tracking
- AssignmentResolver::resolve() is the shared deterministic/sticky resolver contract; AbTesting implements it
- isWithContext() is the context-aware boolean check; existing is() remains context-free
- Conversion goal must contain a non-whitespace character; AbTesting rejects invalid goals before calling a tracker
- Variant weights must be non-negative integers; Experiment's constructor rejects non-int and negative weights with InvalidExperimentException. Zero is valid (variant defined, no traffic). A negative weight would still clear `totalWeight > 0` while making the preceding variant unreachable. The total is re-checked too: a sum above PHP_INT_MAX overflows array_sum() to float and is rejected by both Experiment and WeightedHashAssignmentStrategy
- Disabled experiment → fallbackVariant
- Forced variant must be in experiment's variant list
- AbTesting requires an ExperimentProvider (ConfigExperimentProvider or a `-db` backend); core di does NOT bind it
- ExposureTracker/ConversionTracker take an ExposureEvent/ConversionEvent, NOT an Assignment. Default to no-op Null*; core di does NOT bind them. Built-in sinks: Logger* (PSR-3, emit the canonical schema v2 row under an `event` key = the log-shipping delivery path), Null*; bind one in app config
- Unknown experiment → InvalidExperimentException; unknown forced variant → InvalidVariantException
- ExperimentRegistry is lazy: provider queried on first access, memoized; reset() re-reads (worker runtimes)
- Requires 64-bit PHP (hash bucket exceeds PHP_INT_MAX on 32-bit)
- Config experiments get a canonical SHA-256 configurationId; DB/runtime providers may supply an explicit string ID. Assignment propagates it; on events it is `experimentRevision`.
- Assignment has NO boolean flags. Two enums: reason (DecisionReason: assigned|forced|fallback_disabled|fallback_targeting_mismatch) and source (AssignmentSource: computed|store). Methods isForced()/isFallback()/isTargetingMismatch()/isSticky() are derived; they are METHODS, not properties
- Only DecisionReason::Assigned counts as participation; reports exclude forced and both fallbacks
- eventId and occurredAt are minted once by the facade and must never be regenerated downstream — eventId is the pipeline's dedup key
- dimensions serialize as a JSON STRING, not a nested object: the outbox exporter rejects nested payload fields and both delivery paths must produce identical rows
- Context attributes reach storage only via AnalyticsContextPolicy; the default allows nothing
- Do NOT write to analytics storage from the request path. Two delivery paths: durable (outbox + worker) and log-shipping (logger sink + Vector/Fluent Bit)
- DeduplicatingExposureTracker suppresses duplicate (experiment, subjectId, experimentRevision) exposures within its lifetime; bind request-scoped or reset() per request
- DeduplicatingExposureTracker implements FlushableTracker and forwards flush() to a flushable delegate

## API

### Experiment

```php
$exp = new Experiment(
    name: 'checkout-button',
    enabled: true,
    salt: 'checkout-v1',
    fallbackVariant: 'control',
    variants: ['control' => 50, 'green' => 50],
    targeting: null, // optional TargetingRule; null = no restriction
    configurationId: null, // optional source identity; config provider derives a hash
);

// Targeting rules: restrict experiment to a subset of subjects.
// Subjects that don't match get fallbackVariant with isFallback=true, isTargetingMismatch=true.
// forcedVariant bypasses targeting. Disabled experiment check runs before targeting.
use Rasuvaeff\Yii3AbTesting\EnvironmentTargetingRule;
use Rasuvaeff\Yii3AbTesting\AttributeTargetingRule;
use Rasuvaeff\Yii3AbTesting\AndTargetingRule;
use Rasuvaeff\Yii3AbTesting\OrTargetingRule;

$targeting = new AndTargetingRule(rules: [
    new EnvironmentTargetingRule(environments: ['production']),
    new AttributeTargetingRule(attribute: 'plan', value: 'pro'),
]);
// OrTargetingRule: any rule matches. Empty rules list throws InvalidArgumentException.
```

### ExperimentProvider (experiment source)

```php
// interface: getExperiments(): array<string, Experiment>
$provider = new ConfigExperimentProvider(config: [
    'checkout-button' => [
        'enabled' => true,
        'salt' => 'checkout-v1',
        'fallbackVariant' => 'control',
        'variants' => ['control' => 50, 'green' => 50],
        'targeting' => [
            'type' => 'environment',
            'values' => ['production'],
        ],
    ],
]);
// or a database-backed provider from yii3-ab-testing-db
```

### AbTesting facade

```php
$ab = new AbTesting(
    provider: $provider,
    strategy: new WeightedHashAssignmentStrategy(),
    exposureTracker: new NullExposureTracker(),
    conversionTracker: new NullConversionTracker(),
);

$assignment = $ab->assign(experiment: 'checkout-button', subjectId: $userId);
$assignment->variant;              // 'control' or 'green'
$assignment->reason;               // DecisionReason::Assigned
$assignment->source;               // AssignmentSource::Computed
$assignment->isForced();           // false — method, not property
$assignment->isTargetingMismatch();// false

$ab->is(experiment: 'checkout-button', variant: 'green', subjectId: $userId); // bool
$ab->isWithContext(
    experiment: 'checkout-button',
    variant: 'green',
    subjectId: $userId,
    context: $context,
); // bool

// Forced (QA):
$ab->assign(experiment: 'checkout-button', subjectId: $userId, forcedVariant: 'green');

// Tracking (explicit; both return the recorded event):
$exposure = $ab->trackExposure($assignment);                    // ExposureEvent
$ab->trackConversion($assignment, goal: 'purchase', exposure: $exposure);
// Conversion in a LATER request — carry the receipt, do not re-resolve:
$ab->trackConversionForReceipt($exposure->receipt(), goal: 'purchase', context: $ctx);
```

### Assignment

```php
$a = new Assignment(experiment: 'exp', variant: 'green', subjectId: 'u1');
$a->isVariant('green');        // true
$a->reason;                    // DecisionReason (assigned by default)
$a->source;                    // AssignmentSource (computed by default)
$a->isForced();                // METHOD, not property
$a->isFallback();              // true for either fallback reason
$a->isTargetingMismatch();     // true when subject excluded by TargetingRule
$a->isSticky();                // true when served from an AssignmentStore
$a->context;                   // ?AssignmentContext (null unless passed to assign())
$a->configurationId;           // ?string definition hash/revision identity
```

### ExperimentRegistry

```php
$registry = $ab->getRegistry();
$registry->get('checkout-button'); // Experiment, throws InvalidExperimentException if unknown
$registry->has('checkout-button'); // bool
$registry->all();                  // array<string, Experiment>
$registry->reset();                // drop memo; provider re-read on next access
```

### AssignmentContext (optional)

```php
// Carried into Assignment; may affect targeting eligibility, never the hash bucket.
$ctx = AssignmentContext::forEnvironment('production')->withAttribute('country', 'DE');
$assignment = $ab->assign(experiment: 'exp', subjectId: 'u1', context: $ctx);
// getters: getEnvironment(), getAttributes(), getAttribute(name)
// only assign() takes a context (is() does not)
// isWithContext() performs the boolean check with context-aware targeting
```

### Trackers

```php
interface ExposureTracker {
    public function trackExposure(ExposureEvent $event): void;
}

// Request-scoped exposure deduplication. assign()/resolve() remain pure.
$deduplicated = new DeduplicatingExposureTracker(tracker: $exposure);
$deduplicated->trackExposure($event);
$deduplicated->reset(); // request boundary in long-running workers

interface ConversionTracker {
    public function trackConversion(ConversionEvent $event): void;
}

// Built-in sinks (bind one in app config — core di binds none):
//   NullExposureTracker / NullConversionTracker     — no-op defaults
//   LoggerExposureTracker / LoggerConversionTracker — the log-shipping path:
//     one PSR-3 record per event whose context is {'event': <canonical row>};
//     ctor(LoggerInterface, string $level = LogLevel::INFO, EventSerializer)
$exposure = new LoggerExposureTracker($psrLogger);

// Buffering sinks implement FlushableTracker (no in-family implementation left;
// call flush() once at request end.
interface FlushableTracker {
    public function flush(): void;
}

// Fan one event out to several sinks (variadic). One source must own each
// tracker key in di, so apps bind these in their own root-layer config.
// Composites implement FlushableTracker and propagate flush() to flushable inners.
$exposure = new CompositeExposureTracker($trackerA, $trackerB);
$conversion = new CompositeConversionTracker($trackerA, $trackerB);
```

### Events (schema v2)

```php
// Built by the facade; sinks receive these, never an Assignment.
final readonly class ExposureEvent {
    public string $eventId;                 // non-empty; the pipeline dedup key
    public DateTimeImmutable $occurredAt;   // normalised to UTC on construction
    public string $experiment, $variant, $subjectId;
    public DecisionReason $reason;
    public AssignmentSource $source;
    public ?string $experimentRevision;     // null = unknown, never ''
    public string $environment;             // '' = no context (legitimate)
    public array $dimensions;               // array<non-empty-string, scalar>
    public function isAnalyzable(): bool;   // reason === Assigned
    public function receipt(): AssignmentReceipt;
}
// ConversionEvent: same fields + string $goal + ?string $exposureEventId.
// Blank values throw InvalidArgumentException: 'Event field "goal" must not be empty'.

// Receipt travels in a cookie/session so a later conversion keeps the variant
// the visitor actually saw. Small on purpose: no environment, no dimensions.
$receipt->toArray();                        // ['eid','at','e','v','s','r','src','rev']
AssignmentReceipt::fromArray($data);        // re-validates everything; never trusts input
```

### EventSerializer

```php
// The single wire format. Row shapes are published as importable Psalm types:
//   @psalm-import-type AbExposureRow from EventSerializer
// Adapters import them, so a renamed field breaks their psalm — treat the
// aliases as public API: major releases only (roave cannot see docblocks).
$row = (new CanonicalEventSerializer())->exposure($event);
// ['v'=>2,'event_id'=>…,'occurred_at'=>'Y-m-d H:i:s.v','experiment'=>…,'variant'=>…,
//  'subject_id'=>…,'decision_reason'=>…,'assignment_source'=>…,
//  'experiment_revision'=>string(''=absent),'environment'=>string,'dimensions'=>'{}']
// conversion() adds 'goal' and 'exposure_event_id'.
// Every value scalar; dimension keys sorted for byte-reproducible output.
```

### Event identifiers

```php
interface EventIdGenerator { public function generate(): string; }
// Uuid7EventIdGenerator   — default, no dependencies, ctor(ClockInterface).
//   SystemClock is the default PSR-20 clock (UTC); inject a fixed one in tests.
// SymfonyUidEventIdGenerator — needs symfony/uid    (suggest)
// RamseyUuidEventIdGenerator — needs ramsey/uuid    (suggest)
// No auto-detection: bind the one you want. Format is NOT part of the contract
// (ULID/snowflake fine) — the analytics column is a String, not a UUID.
// Ordering holds between milliseconds, not within one.
```

### Analytics dimensions

```php
interface AnalyticsContextPolicy { public function apply(?AssignmentContext $c): array; }
new AllowListAnalyticsContextPolicy(
    allowedAttributes: ['country', 'user'],
    renamedAttributes: ['country' => 'geo'],   // stored column name
    redactedAttributes: ['user'],              // column kept, value '[redacted]'
);
// Default construction allows NOTHING. Unlisted attributes are dropped, not redacted.
// Identifiers must match /^[A-Za-z_]\w*\z/; duplicate destinations throw.
```

### Attribution contract

```php
AttributionWindow::default();          // 7 days; ofDays(int) / ofSeconds(int); positive only
RepeatedConversionPolicy::FirstOnly;   // default: one per (subject, goal, experiment, revision)
RepeatedConversionPolicy::All;         // count every conversion (value metrics)
// Reports count only DecisionReason::Assigned; a subject with two variants in one
// revision is excluded as a data-quality signal, never silently reduced to the first.
```

### AssignmentStore (sticky variant, optional)

```php
// Pure contract (no HTTP). Pins a subject to a variant across weight changes.
// Implementations: yii3-ab-testing-web (cookie/session). assign() stays pure.
interface AssignmentStore {
    public function get(string $experiment, string $subjectId): ?string;
    public function put(string $experiment, string $subjectId, string $variant): void;
}

// Extension for stores that drop a pin when the definition changes. A plain
// store replays a variant drawn from bucket boundaries that no longer exist;
// getForConfiguration() returns null when the stored variant belongs to a
// different configurationId. Implemented by the web cookie store and by
// DbAssignmentStore. Lives in the core so sibling adapters need not depend on
// each other.
interface ConfigurationAwareAssignmentStore extends AssignmentStore {
    public function getForConfiguration(
        string $experiment,
        string $subjectId,
        ?string $configurationId,
    ): ?string;

    public function putForConfiguration(
        string $experiment,
        string $subjectId,
        string $variant,
        ?string $configurationId,
    ): void;
}

interface AssignmentResolver {
    public function resolve(
        string $experiment,
        string $subjectId,
        ?string $forcedVariant = null,
        ?AssignmentContext $context = null,
    ): Assignment;
}
```

### TargetingRuleCodecRegistry

Shared PHP-config / DB-JSON tagged representation. The registry ships with
`BuiltInTargetingRuleCodec` (environment, attribute, and/or) and takes extra
codecs variadically:

```php
$registry = new TargetingRuleCodecRegistry(codecs: $customCodec);
$rule = $registry->decode([
    'type' => 'and',
    'rules' => [
        ['type' => 'environment', 'values' => ['production']],
        ['type' => 'attribute', 'attribute' => 'plan', 'value' => 'pro'],
    ],
]);
$data = $registry->encode($rule); // array<string, mixed>
```

`TargetingRuleCodec` extension methods: `supports(string $type): bool`,
`supportsRule(TargetingRule $rule): bool`, `decode(array $data, Closure $decode):
TargetingRule`, and `encode(TargetingRule $rule, Closure $encode): array`.
Targeting decoding accepts at most 64 nested levels and throws
`InvalidArgumentException` for deeper trees.

### Yii3 DI config

Core `config/di.php` binds only `AbTesting` + `AssignmentStrategy`. It does NOT
bind `ExperimentProvider` / `ExposureTracker` / `ConversionTracker` — one source
owns each (app or backend), or `yiisoft/config` throws `Duplicate key`.

Define experiments in app's `config/params.php`:

```php
return [
    'rasuvaeff/yii3-ab-testing' => [
        'experiments' => [
            'checkout-button' => [
                'enabled' => true,
                'salt' => 'checkout-v1',
                'fallbackVariant' => 'control',
                'variants' => ['control' => 50, 'green' => 50],
            ],
        ],
    ],
];
```

Then bind the provider once in app `config/common/di/*.php` (or install
`yii3-ab-testing-db`, which binds it for you):

```php
return [
    ExperimentProvider::class => [
        'class' => ConfigExperimentProvider::class,
        '__construct()' => ['config' => $params['rasuvaeff/yii3-ab-testing']['experiments']],
    ],
];
```