"""Tests for canary module — Tasks 47-48.""" from __future__ import annotations from services.intelligence_pipeline_v3.canary.influence import ( DivergenceRecord, PromotionStatus, SignalInfluenceConfig, SignalInfluenceTracker, ) from services.intelligence_pipeline_v3.canary.routing import ( CanaryConfig, CanaryRouter, RollbackReason, ) class TestCanaryRouter: """Task 47: Canary compatibility outputs.""" def test_disabled_always_v2(self): router = CanaryRouter(config=CanaryConfig(enabled=False)) assert not router.should_use_v3("doc-001") def test_percentage_routing_deterministic(self): config = CanaryConfig(enabled=True, percentage=50) router = CanaryRouter(config=config) result1 = router.should_use_v3("doc-001") # Reset counters to test determinism router2 = CanaryRouter(config=CanaryConfig(enabled=True, percentage=50)) result2 = router2.should_use_v3("doc-001") assert result1 == result2 def test_trading_excluded_by_default(self): config = CanaryConfig(enabled=True, percentage=100, exclude_trading=True) router = CanaryRouter(config=config) assert not router.should_use_v3("doc-001", is_trading_consumer=True) assert router.should_use_v3("doc-001", is_trading_consumer=False) def test_document_type_filter(self): config = CanaryConfig( enabled=True, percentage=100, document_types={"news", "filing"} ) router = CanaryRouter(config=config) assert router.should_use_v3("doc-001", document_type="news") assert not router.should_use_v3("doc-002", document_type="transcript") def test_rollback_on_error_rate(self): config = CanaryConfig(enabled=True, percentage=20, max_error_rate=0.05) router = CanaryRouter(config=config) event = router.check_rollback(error_rate=0.10) assert event is not None assert event.reason == RollbackReason.ERROR_RATE assert router.config.percentage == 0 # Rolled back def test_rollback_on_latency(self): config = CanaryConfig( enabled=True, percentage=30, max_p95_latency_ms=3000 ) router = CanaryRouter(config=config) event = router.check_rollback(p95_latency_ms=5000) assert event is not None assert event.reason == RollbackReason.LATENCY_THRESHOLD def test_rollback_on_low_availability(self): config = CanaryConfig( enabled=True, percentage=10, min_availability=0.95 ) router = CanaryRouter(config=config) event = router.check_rollback(availability=0.90) assert event is not None assert event.reason == RollbackReason.AVAILABILITY_THRESHOLD def test_rollback_on_low_correctness(self): config = CanaryConfig( enabled=True, percentage=10, min_correctness=0.90 ) router = CanaryRouter(config=config) event = router.check_rollback(correctness=0.85) assert event is not None assert event.reason == RollbackReason.CORRECTNESS_THRESHOLD def test_no_rollback_when_healthy(self): config = CanaryConfig(enabled=True, percentage=50) router = CanaryRouter(config=config) event = router.check_rollback( error_rate=0.01, p95_latency_ms=1000, queue_saturation=0.3, availability=0.99, correctness=0.95, ) assert event is None def test_manual_rollback(self): config = CanaryConfig(enabled=True, percentage=25) router = CanaryRouter(config=config) event = router.manual_rollback("operator requested") assert event.reason == RollbackReason.MANUAL assert event.previous_percentage == 25 assert router.config.percentage == 0 def test_rollback_preserves_audit_records(self): """Rollback changes routing, not stored v3 data.""" config = CanaryConfig(enabled=True, percentage=50) router = CanaryRouter(config=config) # Process some docs router.should_use_v3("doc-001") router.should_use_v3("doc-002") # Rollback router.manual_rollback() # Audit records (rollback events) are preserved assert len(router.rollback_events) == 1 def test_traffic_ratio(self): config = CanaryConfig(enabled=True, percentage=100) router = CanaryRouter(config=config) for i in range(10): router.should_use_v3(f"doc-{i}") assert router.v3_traffic_ratio == 1.0 class TestSignalInfluence: """Task 48: Canary signal influence in paper trading.""" def test_start_paper_trading(self): tracker = SignalInfluenceTracker( config=SignalInfluenceConfig() ) tracker.start_paper_trading() assert tracker.promotion_status == PromotionStatus.PAPER_TRADING def test_record_divergence(self): tracker = SignalInfluenceTracker( config=SignalInfluenceConfig(enabled=True) ) tracker.record_signal(is_v3=True) div = DivergenceRecord.create( document_id="doc-001", v2_recommendation={"direction": "buy"}, v3_recommendation={"direction": "sell"}, divergence_type="direction_opposite", ) tracker.record_divergence(div) assert tracker.divergence_rate == 1.0 def test_extraction_and_trading_separate(self): """Task 48.2: Separate extraction correctness from trading outcomes.""" tracker = SignalInfluenceTracker( config=SignalInfluenceConfig( enabled=True, report_extraction_separately=True, report_trading_separately=True, ) ) tracker.update_extraction_metrics({"entity_f1": 0.92}) tracker.update_trading_metrics({"sharpe": 1.5}) summary = tracker.summary() assert summary["extraction_metrics"]["entity_f1"] == 0.92 assert summary["trading_metrics"]["sharpe"] == 1.5 def test_approval_requires_owner(self): config = SignalInfluenceConfig( enabled=True, require_owner_approval=True, owner_id="owner-1", ) tracker = SignalInfluenceTracker(config=config) # Wrong approver assert not tracker.approve("random-person") # Right approver assert tracker.approve("owner-1") assert tracker.promotion_status == PromotionStatus.APPROVED def test_approval_requires_all_divergences_reviewed(self): config = SignalInfluenceConfig( enabled=True, require_owner_approval=False, max_divergence_rate=1.0, # Allow any rate so we test review requirement ) tracker = SignalInfluenceTracker(config=config) tracker.record_signal(is_v3=True) div = DivergenceRecord.create( "doc-001", {"d": "buy"}, {"d": "sell"}, "opposite" ) tracker.record_divergence(div) # Cannot approve with unreviewed divergences assert not tracker.approve("owner") # Mark reviewed div.reviewed = True assert tracker.approve("owner") def test_reject(self): tracker = SignalInfluenceTracker(config=SignalInfluenceConfig()) tracker.reject("too many divergences") assert tracker.promotion_status == PromotionStatus.REJECTED def test_trading_outcomes_dont_override_correctness(self): """Requirement 16.10: Trading performance cannot override failed gates.""" config = SignalInfluenceConfig( enabled=True, require_owner_approval=False, max_divergence_rate=0.10, ) tracker = SignalInfluenceTracker(config=config) # Simulate 10 v3 signals, 5 divergences (50% rate) for i in range(10): tracker.record_signal(is_v3=True) for i in range(5): tracker.record_divergence( DivergenceRecord.create(f"doc-{i}", {}, {}, "opposite") ) # Even if trading metrics are good, correctness gates fail tracker.update_trading_metrics({"sharpe": 3.0}) assert not tracker.approve("owner")