"""Sweeps and tornado data.""" from __future__ import annotations import pytest from genesyscalc.meters import TokenPrice from genesyscalc.model import CalculatorInputs, calculate from genesyscalc.sensitivity import ( DRIVERS, driver_value, set_driver, sweep, tornado, ) from genesyscalc.tts import TtsUsage from genesyscalc.usage import DeflectionMix, VoiceBotUsage, Volumes @pytest.fixture def base() -> CalculatorInputs: return CalculatorInputs( volumes=Volumes(voice_inbound_monthly=100_000, voice_outbound_monthly=20_000), users=500, enabled=frozenset( {"agent_copilot_named", "bots_voice", "virtual_agent"} ), mix=DeflectionMix(bot_only_share=0.30, virtual_agent_share=0.15), price=TokenPrice(), voice_bot=VoiceBotUsage(calls_per_month=30_000, avg_bot_seconds_per_call=40), tts=TtsUsage(calls_monthly=100_000), ) def test_driver_round_trip(base: CalculatorInputs) -> None: assert driver_value(base, "users") == 500 assert driver_value(set_driver(base, "users", 750), "users") == 750 def test_nested_driver_round_trip(base: CalculatorInputs) -> None: assert driver_value(base, "concession_pct") == 0.0 moved = set_driver(base, "concession_pct", 0.25) assert moved.price.concession_pct == pytest.approx(0.25) assert base.price.concession_pct == 0.0 # inputs are frozen; no mutation def test_integer_drivers_stay_integers(base: CalculatorInputs) -> None: assert isinstance(set_driver(base, "users", 512.6).users, int) assert set_driver(base, "users", 512.6).users == 513 def test_unknown_driver_is_named(base: CalculatorInputs) -> None: with pytest.raises(KeyError, match="not a known driver"): driver_value(base, "vibes") def test_sweep_is_monotone_in_token_price(base: CalculatorInputs) -> None: rows = sweep(base, "token_price", [0.5, 1.0, 1.5, 2.0]) costs = [r["token_cost_annual"] for r in rows] assert costs == sorted(costs) assert len(rows) == 4 def test_sweep_carries_the_driver_value(base: CalculatorInputs) -> None: rows = sweep(base, "concession_pct", [0.0, 0.25]) assert rows[0]["concession_pct"] == 0.0 assert rows[1]["grand_total_annual"] < rows[0]["grand_total_annual"] def test_tornado_is_sorted_by_swing(base: CalculatorInputs) -> None: rows = tornado(base, ["token_price", "users", "bot_seconds", "concession_pct"]) swings = [r["swing"] for r in rows] assert swings == sorted(swings, reverse=True) assert all(r["swing"] >= 0 for r in rows) def test_tornado_brackets_the_baseline(base: CalculatorInputs) -> None: baseline = calculate(base).grand_total_annual for row in tornado(base, ["token_price", "users"]): assert row["low"] <= baseline <= row["high"] assert row["baseline"] == pytest.approx(baseline) def test_tornado_skips_inactive_levers() -> None: """No TTS and no voice bot in the scenario — those levers are noise.""" plain = CalculatorInputs( volumes=Volumes(voice_inbound_monthly=1_000), users=10, enabled=frozenset({"agent_copilot_named"}), ) drivers = [r["driver"] for r in tornado(plain, list(DRIVERS))] assert "bot_seconds" not in drivers assert "tts_chars_per_call" not in drivers assert "users" in drivers def test_tornado_skips_a_lever_that_would_break_the_partition() -> None: """Raising a share past the partition is illegal, not a data point.""" saturated = CalculatorInputs( volumes=Volumes(voice_inbound_monthly=1_000), users=10, enabled=frozenset({"virtual_agent"}), mix=DeflectionMix(bot_only_share=0.5, virtual_agent_share=0.5), ) drivers = [r["driver"] for r in tornado(saturated, ["virtual_agent_share"])] assert drivers == [] def test_invalid_delta_rejected(base: CalculatorInputs) -> None: with pytest.raises(ValueError, match="delta"): tornado(base, ["users"], delta=1.5)