vibe/tests/core/experiments/test_manager.py
Mathias Gesbert 228f3c65a9
v2.10.0 (#697)
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Corentin André <corentin.andre@mistral.ai>
Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com>
Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com>
Co-authored-by: Maxime Dolores <maxime.dolores@ext.mistral.ai>
Co-authored-by: Nelson PROIA <144663685+Nelson-PROIA@users.noreply.github.com>
Co-authored-by: Peter Evers <pevers90@gmail.com>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Quentin <quentin.torroba@mistral.ai>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: MichisGitIsKing <MichisGitIsKing@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
2026-05-19 11:56:25 +02:00

427 lines
15 KiB
Python

from __future__ import annotations
from typing import Any
import pytest
from vibe.core.experiments.active import DEFAULT_VARIANTS, ExperimentName
from vibe.core.experiments.client import RemoteEvalClient
from vibe.core.experiments.manager import ExperimentManager, hash_api_key
from vibe.core.experiments.models import EvalResponse, ExperimentAttributes
class _StubClient(RemoteEvalClient):
def __init__(self, response: EvalResponse | None) -> None:
self._response = response
self.calls: list[ExperimentAttributes] = []
async def evaluate(self, attributes: ExperimentAttributes) -> EvalResponse | None:
self.calls.append(attributes)
return self._response
async def aclose(self) -> None:
pass
def _attrs() -> ExperimentAttributes:
return ExperimentAttributes(
userId="x", entrypoint="cli", agent_version="0", os="darwin"
)
def _response(features: dict[str, Any]) -> EvalResponse:
return EvalResponse.model_validate({"features": features})
def test_hash_api_key_is_stable_and_anonymous() -> None:
a = hash_api_key("sk-abc")
b = hash_api_key("sk-abc")
assert a == b
assert "sk-" not in a
assert len(a) == 32
def test_hash_api_key_differs_per_key() -> None:
assert hash_api_key("sk-abc") != hash_api_key("sk-def")
@pytest.mark.asyncio
async def test_get_variant_returns_default_when_uninitialized() -> None:
manager = ExperimentManager(client=_StubClient(None))
assert manager.get_variant(ExperimentName.SYSTEM_PROMPT) == "cli"
@pytest.mark.asyncio
async def test_get_variant_or_none_returns_none_when_unassigned() -> None:
manager = ExperimentManager(client=_StubClient(None))
assert manager.get_variant_or_none(ExperimentName.SYSTEM_PROMPT) is None
@pytest.mark.asyncio
async def test_get_variant_or_none_returns_override() -> None:
manager = ExperimentManager(
client=_StubClient(None), overrides={ExperimentName.SYSTEM_PROMPT.value: "lean"}
)
assert manager.get_variant_or_none(ExperimentName.SYSTEM_PROMPT) == "lean"
@pytest.mark.asyncio
async def test_get_variant_or_none_returns_resolved_value() -> None:
response = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [{"force": "explore", "tracks": []}],
}
})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(_attrs())
assert manager.get_variant_or_none(ExperimentName.SYSTEM_PROMPT) == "explore"
@pytest.mark.asyncio
async def test_get_variant_returns_resolved_value() -> None:
response = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [{"force": "cli_v2", "tracks": []}],
}
})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(_attrs())
assert manager.get_variant(ExperimentName.SYSTEM_PROMPT) == "cli_v2"
@pytest.mark.asyncio
async def test_overrides_take_precedence_over_remote() -> None:
response = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [{"force": "cli_v2", "tracks": []}],
}
})
manager = ExperimentManager(
client=_StubClient(response),
overrides={ExperimentName.SYSTEM_PROMPT.value: "forced"},
)
await manager.initialize(_attrs())
assert manager.get_variant(ExperimentName.SYSTEM_PROMPT) == "forced"
@pytest.mark.asyncio
async def test_get_variant_falls_back_when_remote_returns_no_match() -> None:
response = _response({"some_other_feature": {"defaultValue": True}})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(_attrs())
assert manager.get_variant(ExperimentName.SYSTEM_PROMPT) == "cli"
@pytest.mark.asyncio
async def test_assignments_uses_resolved_variant_value() -> None:
response = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [
{
"force": "cli_v2",
"tracks": [
{
"experiment": {"key": ExperimentName.SYSTEM_PROMPT.value},
"result": {
"key": "1",
"variationId": 1,
"inExperiment": True,
},
}
],
}
],
}
})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(_attrs())
assignments = manager.assignments()
assert assignments == {ExperimentName.SYSTEM_PROMPT.value: "cli_v2"}
@pytest.mark.asyncio
async def test_assignments_prefers_track_result_value() -> None:
# When the GrowthBook payload carries an explicit per-arm value, it wins
# over the rule's force value — it's the most precise label for the user.
response = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [
{
"force": "cli_v2",
"tracks": [
{
"experiment": {"key": ExperimentName.SYSTEM_PROMPT.value},
"result": {
"key": "1",
"variationId": 1,
"value": "cli_v3",
"inExperiment": True,
},
}
],
}
],
}
})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(_attrs())
assert manager.assignments() == {ExperimentName.SYSTEM_PROMPT.value: "cli_v3"}
@pytest.mark.asyncio
async def test_assignments_keys_on_feature_id_when_experiment_key_differs() -> None:
# GrowthBook feature ID and experiment key can diverge. Telemetry must
# key on the feature ID (matches ExperimentName) so overrides, lookups
# and downstream analysis stay consistent.
response = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [
{
"force": "cli_v2",
"tracks": [
{
"experiment": {"key": "vibe-code-cli-system-prompt"},
"result": {
"key": "1",
"variationId": 1,
"inExperiment": True,
"featureId": ExperimentName.SYSTEM_PROMPT.value,
},
}
],
}
],
}
})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(_attrs())
assignments = manager.assignments()
assert assignments == {ExperimentName.SYSTEM_PROMPT.value: "cli_v2"}
assert "vibe-code-cli-system-prompt" not in assignments
@pytest.mark.asyncio
async def test_initialize_does_nothing_on_failed_eval() -> None:
manager = ExperimentManager(client=_StubClient(None))
await manager.initialize(_attrs())
assert manager.assignments() == {}
for name in ExperimentName:
assert manager.get_variant(name) == DEFAULT_VARIANTS[name]
def test_experiment_attributes_default_custom_system_prompt_to_false() -> None:
attrs = ExperimentAttributes(
userId="x", entrypoint="cli", agent_version="0", os="darwin"
)
assert attrs.custom_system_prompt is False
assert attrs.model_dump(exclude_none=True)["custom_system_prompt"] is False
def test_experiment_attributes_serializes_custom_system_prompt() -> None:
attrs = ExperimentAttributes(
userId="x",
entrypoint="cli",
agent_version="0",
os="darwin",
custom_system_prompt=True,
)
assert attrs.model_dump(exclude_none=True)["custom_system_prompt"] is True
def test_export_state_returns_none_before_initialize() -> None:
manager = ExperimentManager(client=_StubClient(None))
assert manager.export_state() is None
@pytest.mark.asyncio
async def test_export_state_returns_response_after_initialize() -> None:
response = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [{"force": "cli_v2", "tracks": []}],
}
})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(_attrs())
assert manager.export_state() == response
def test_hydrate_does_not_call_client() -> None:
response = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [{"force": "cli_v2", "tracks": []}],
}
})
stub = _StubClient(response)
manager = ExperimentManager(client=stub)
manager.hydrate(response)
assert stub.calls == []
def test_hydrate_makes_get_variant_match_initialized_manager() -> None:
response = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [
{
"force": "cli_v2",
"tracks": [
{
"experiment": {"key": ExperimentName.SYSTEM_PROMPT.value},
"result": {
"key": "1",
"variationId": 1,
"inExperiment": True,
},
}
],
}
],
}
})
hydrated = ExperimentManager(client=_StubClient(None))
hydrated.hydrate(response)
assert hydrated.get_variant(ExperimentName.SYSTEM_PROMPT) == "cli_v2"
assert hydrated.assignments() == {ExperimentName.SYSTEM_PROMPT.value: "cli_v2"}
@pytest.mark.asyncio
async def test_initialize_twice_replaces_response() -> None:
# Session reset re-runs initialize_experiments, which calls manager.initialize
# again. The second response must replace the first so the new session sees
# the up-to-date variant assignment.
first = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [{"force": "explore", "tracks": []}],
}
})
second = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [{"force": "lean", "tracks": []}],
}
})
class _ScriptedClient(RemoteEvalClient):
def __init__(self, responses: list[EvalResponse]) -> None:
self._responses = responses
self.call_count = 0
async def evaluate(
self, attributes: ExperimentAttributes
) -> EvalResponse | None:
response = self._responses[self.call_count]
self.call_count += 1
return response
async def aclose(self) -> None:
pass
client = _ScriptedClient([first, second])
manager = ExperimentManager(client=client)
await manager.initialize(_attrs())
assert manager.get_variant(ExperimentName.SYSTEM_PROMPT) == "explore"
await manager.initialize(_attrs())
assert manager.get_variant(ExperimentName.SYSTEM_PROMPT) == "lean"
assert client.call_count == 2
@pytest.mark.asyncio
async def test_assignments_excludes_tracks_not_in_experiment() -> None:
# Holdouts and forced overrides come back with inExperiment=False.
# We must NOT report them as active experiment participants — that would
# pollute downstream A/B analysis since the assignment map IS the
# exposure record (no separate exposure event).
response = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [
{
"force": "cli",
"tracks": [
{
"experiment": {"key": ExperimentName.SYSTEM_PROMPT.value},
"result": {
"key": "0",
"variationId": 0,
"inExperiment": False,
},
}
],
}
],
}
})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(_attrs())
assert manager.assignments() == {}
@pytest.mark.asyncio
async def test_assignments_excludes_tracks_with_missing_in_experiment() -> None:
# Defensive: if the proxy returns a track without inExperiment, treat it
# as "not confirmed in the experiment" rather than silently counting it.
response = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [
{
"force": "cli_v2",
"tracks": [
{
"experiment": {"key": ExperimentName.SYSTEM_PROMPT.value},
"result": {"key": "1", "variationId": 1},
}
],
}
],
}
})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(_attrs())
assert manager.assignments() == {}
@pytest.mark.asyncio
async def test_initialize_drops_unknown_features() -> None:
# GrowthBook returns every feature defined in the org. Only the ones
# listed in ExperimentName should survive into manager state and the
# persisted snapshot.
response = _response({
ExperimentName.SYSTEM_PROMPT.value: {"defaultValue": "cli"},
"unrelated_org_flag": {"defaultValue": True},
"neko": {"defaultValue": False},
"gbdemo-checkout-layout": {"defaultValue": "dev"},
})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(_attrs())
snapshot = manager.export_state()
assert snapshot is not None
assert set(snapshot.features.keys()) == {ExperimentName.SYSTEM_PROMPT.value}
def test_hydrate_drops_unknown_features() -> None:
# When resuming a session saved by an older vibe version, the snapshot
# may contain experiments that have since been retired. Filter them on
# hydrate so the in-memory state matches the current ExperimentName.
legacy_response = _response({
ExperimentName.SYSTEM_PROMPT.value: {"defaultValue": "cli"},
"retired_experiment": {"defaultValue": "control"},
})
manager = ExperimentManager(client=_StubClient(None))
manager.hydrate(legacy_response)
snapshot = manager.export_state()
assert snapshot is not None
assert set(snapshot.features.keys()) == {ExperimentName.SYSTEM_PROMPT.value}