"""Unit tests for latency, throughput, token, CPU, GPU, and memory metrics. Validates: Requirements 16.3, 16.4 """ from __future__ import annotations import pytest from services.intelligence_pipeline_v3.evaluation.resource_metrics import ( ResourceEvaluationReport, StageTimingRecord, compute_cpu_metrics, compute_efficiency_metrics, compute_gpu_metrics, compute_latency_metrics, compute_memory_metrics, compute_percentile, compute_throughput_metrics, compute_token_usage_metrics, evaluate_resources, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _record( document_id: str = "doc-1", stage_name: str = "extraction", start_time: float = 0.0, end_time: float = 1.0, input_tokens: int = 100, output_tokens: int = 50, gpu_memory_mb: float = 0.0, cpu_seconds: float = 0.5, gpu_seconds: float = 0.0, ) -> StageTimingRecord: return StageTimingRecord( document_id=document_id, stage_name=stage_name, start_time=start_time, end_time=end_time, input_tokens=input_tokens, output_tokens=output_tokens, gpu_memory_mb=gpu_memory_mb, cpu_seconds=cpu_seconds, gpu_seconds=gpu_seconds, ) # --------------------------------------------------------------------------- # Percentile Helper Tests # --------------------------------------------------------------------------- class TestComputePercentile: def test_single_value(self) -> None: assert compute_percentile([5.0], 50.0) == 5.0 assert compute_percentile([5.0], 0.0) == 5.0 assert compute_percentile([5.0], 100.0) == 5.0 def test_two_values_median(self) -> None: result = compute_percentile([1.0, 3.0], 50.0) assert result == 2.0 def test_known_percentiles(self) -> None: values = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0] p50 = compute_percentile(values, 50.0) assert abs(p50 - 5.5) < 1e-9 def test_unsorted_input(self) -> None: values = [5.0, 1.0, 3.0, 2.0, 4.0] p50 = compute_percentile(values, 50.0) assert p50 == 3.0 def test_p0_returns_min(self) -> None: values = [3.0, 1.0, 2.0] assert compute_percentile(values, 0.0) == 1.0 def test_p100_returns_max(self) -> None: values = [3.0, 1.0, 2.0] assert compute_percentile(values, 100.0) == 3.0 def test_empty_raises(self) -> None: with pytest.raises(ValueError, match="empty"): compute_percentile([], 50.0) def test_out_of_range_raises(self) -> None: with pytest.raises(ValueError, match="between 0 and 100"): compute_percentile([1.0], 101.0) with pytest.raises(ValueError, match="between 0 and 100"): compute_percentile([1.0], -1.0) # --------------------------------------------------------------------------- # StageTimingRecord Tests # --------------------------------------------------------------------------- class TestStageTimingRecord: def test_duration(self) -> None: r = _record(start_time=1.0, end_time=3.5) assert r.duration_seconds == 2.5 def test_total_tokens(self) -> None: r = _record(input_tokens=100, output_tokens=50) assert r.total_tokens == 150 def test_frozen(self) -> None: r = _record() with pytest.raises(Exception): r.document_id = "other" # type: ignore[misc] # --------------------------------------------------------------------------- # Latency Metrics # --------------------------------------------------------------------------- class TestLatencyMetrics: def test_empty_records(self) -> None: overall, per_stage = compute_latency_metrics([]) assert overall.count == 0 assert overall.mean == 0.0 assert per_stage == [] def test_single_document_single_stage(self) -> None: records = [_record(start_time=0.0, end_time=2.0)] overall, per_stage = compute_latency_metrics(records) assert overall.count == 1 assert overall.mean == 2.0 assert overall.max == 2.0 assert overall.p50 == 2.0 assert len(per_stage) == 1 assert per_stage[0].stage_name == "extraction" def test_multiple_documents(self) -> None: records = [ _record(document_id="doc-1", start_time=0.0, end_time=1.0), _record(document_id="doc-2", start_time=0.0, end_time=3.0), _record(document_id="doc-3", start_time=0.0, end_time=2.0), ] overall, _ = compute_latency_metrics(records) assert overall.count == 3 assert overall.mean == 2.0 assert overall.max == 3.0 assert overall.min == 1.0 def test_multi_stage_document(self) -> None: """Document duration is from earliest start to latest end.""" records = [ _record(document_id="doc-1", stage_name="segmentation", start_time=0.0, end_time=1.0), _record(document_id="doc-1", stage_name="extraction", start_time=1.0, end_time=3.0), _record(document_id="doc-1", stage_name="sentiment", start_time=3.0, end_time=4.0), ] overall, per_stage = compute_latency_metrics(records) # Total document duration: 0 -> 4 = 4 seconds assert overall.count == 1 assert overall.mean == 4.0 assert len(per_stage) == 3 def test_per_stage_breakdown(self) -> None: records = [ _record(document_id="doc-1", stage_name="extraction", start_time=0.0, end_time=2.0), _record(document_id="doc-2", stage_name="extraction", start_time=0.0, end_time=4.0), _record(document_id="doc-1", stage_name="sentiment", start_time=2.0, end_time=2.5), ] _, per_stage = compute_latency_metrics(records) stage_map = {s.stage_name: s for s in per_stage} assert stage_map["extraction"].invocation_count == 2 assert stage_map["extraction"].latency.mean == 3.0 assert stage_map["sentiment"].invocation_count == 1 # --------------------------------------------------------------------------- # Throughput Metrics # --------------------------------------------------------------------------- class TestThroughputMetrics: def test_empty_records(self) -> None: result = compute_throughput_metrics([]) assert result.total_documents == 0 assert result.documents_per_minute == 0.0 def test_single_document(self) -> None: records = [_record(start_time=0.0, end_time=60.0)] result = compute_throughput_metrics(records) assert result.total_documents == 1 assert result.total_wall_seconds == 60.0 assert abs(result.documents_per_minute - 1.0) < 1e-9 assert abs(result.documents_per_hour - 60.0) < 1e-9 def test_multiple_documents(self) -> None: records = [ _record(document_id="doc-1", start_time=0.0, end_time=10.0), _record(document_id="doc-2", start_time=5.0, end_time=15.0), _record(document_id="doc-3", start_time=10.0, end_time=30.0), ] result = compute_throughput_metrics(records) assert result.total_documents == 3 assert result.total_wall_seconds == 30.0 # 3 docs / 30 seconds = 0.1 docs/sec = 6 docs/min assert abs(result.documents_per_minute - 6.0) < 1e-9 assert abs(result.documents_per_hour - 360.0) < 1e-9 def test_zero_duration(self) -> None: """All records start and end at same time.""" records = [_record(start_time=5.0, end_time=5.0)] result = compute_throughput_metrics(records) assert result.documents_per_minute == 0.0 # --------------------------------------------------------------------------- # Token Usage Metrics # --------------------------------------------------------------------------- class TestTokenUsageMetrics: def test_empty_records(self) -> None: result = compute_token_usage_metrics([]) assert result.total_tokens == 0 assert result.per_stage == {} def test_single_record(self) -> None: records = [_record(input_tokens=200, output_tokens=80)] result = compute_token_usage_metrics(records) assert result.total_input_tokens == 200 assert result.total_output_tokens == 80 assert result.total_tokens == 280 assert result.mean_input_tokens_per_document == 200.0 assert result.mean_output_tokens_per_document == 80.0 assert result.mean_total_tokens_per_document == 280.0 def test_multiple_documents_and_stages(self) -> None: records = [ _record(document_id="doc-1", stage_name="extraction", input_tokens=100, output_tokens=50), _record(document_id="doc-1", stage_name="sentiment", input_tokens=50, output_tokens=20), _record(document_id="doc-2", stage_name="extraction", input_tokens=150, output_tokens=60), ] result = compute_token_usage_metrics(records) assert result.total_input_tokens == 300 assert result.total_output_tokens == 130 assert result.total_tokens == 430 # 2 documents assert result.mean_input_tokens_per_document == 150.0 assert result.mean_output_tokens_per_document == 65.0 def test_per_stage_breakdown(self) -> None: records = [ _record(document_id="doc-1", stage_name="extraction", input_tokens=100, output_tokens=50), _record(document_id="doc-2", stage_name="extraction", input_tokens=200, output_tokens=100), _record(document_id="doc-1", stage_name="sentiment", input_tokens=30, output_tokens=10), ] result = compute_token_usage_metrics(records) assert "extraction" in result.per_stage assert "sentiment" in result.per_stage ext = result.per_stage["extraction"] assert ext.count == 2 assert ext.total_input_tokens == 300 assert ext.mean_input_tokens == 150.0 sent = result.per_stage["sentiment"] assert sent.count == 1 assert sent.total_tokens == 40 # --------------------------------------------------------------------------- # CPU Metrics # --------------------------------------------------------------------------- class TestCpuMetrics: def test_empty_records(self) -> None: result = compute_cpu_metrics([]) assert result.total_cpu_seconds == 0.0 def test_single_record(self) -> None: records = [_record(cpu_seconds=2.5)] result = compute_cpu_metrics(records) assert result.total_cpu_seconds == 2.5 assert result.mean_cpu_seconds_per_document == 2.5 assert result.peak_cpu_seconds == 2.5 def test_multiple_documents(self) -> None: records = [ _record(document_id="doc-1", stage_name="extraction", cpu_seconds=1.0), _record(document_id="doc-1", stage_name="sentiment", cpu_seconds=0.5), _record(document_id="doc-2", stage_name="extraction", cpu_seconds=3.0), ] result = compute_cpu_metrics(records) assert result.total_cpu_seconds == 4.5 # doc-1: 1.5, doc-2: 3.0 assert result.mean_cpu_seconds_per_document == 2.25 assert result.peak_cpu_seconds == 3.0 # --------------------------------------------------------------------------- # GPU Metrics # --------------------------------------------------------------------------- class TestGpuMetrics: def test_empty_records(self) -> None: result = compute_gpu_metrics([]) assert result.total_gpu_seconds == 0.0 assert result.gpu_utilization_percent == 0.0 def test_no_gpu_usage(self) -> None: records = [_record(gpu_seconds=0.0, gpu_memory_mb=0.0)] result = compute_gpu_metrics(records) assert result.total_gpu_seconds == 0.0 assert result.peak_gpu_memory_mb == 0.0 assert result.mean_gpu_memory_mb == 0.0 def test_with_gpu_usage(self) -> None: records = [ _record( document_id="doc-1", start_time=0.0, end_time=10.0, gpu_seconds=5.0, gpu_memory_mb=4096.0, ), _record( document_id="doc-2", start_time=10.0, end_time=20.0, gpu_seconds=3.0, gpu_memory_mb=8192.0, ), ] result = compute_gpu_metrics(records) assert result.total_gpu_seconds == 8.0 assert result.mean_gpu_seconds_per_document == 4.0 assert result.peak_gpu_memory_mb == 8192.0 assert result.mean_gpu_memory_mb == 6144.0 # 8 gpu-seconds / 20 wall-seconds = 40% assert abs(result.gpu_utilization_percent - 40.0) < 1e-9 def test_utilization_capped_at_100(self) -> None: """Parallel GPU stages could sum to more than wall time.""" records = [ _record( document_id="doc-1", start_time=0.0, end_time=1.0, gpu_seconds=5.0, gpu_memory_mb=1000.0, ), ] result = compute_gpu_metrics(records) assert result.gpu_utilization_percent == 100.0 # --------------------------------------------------------------------------- # Memory Metrics # --------------------------------------------------------------------------- class TestMemoryMetrics: def test_empty_records_no_samples(self) -> None: result = compute_memory_metrics([]) assert result.peak_rss_memory_mb == 0.0 assert result.mean_working_set_mb == 0.0 def test_with_rss_samples(self) -> None: records = [_record(gpu_memory_mb=5000.0)] # RSS samples take precedence result = compute_memory_metrics(records, rss_samples_mb=[100.0, 200.0, 300.0]) assert result.peak_rss_memory_mb == 300.0 assert result.mean_working_set_mb == 200.0 def test_fallback_to_gpu_memory(self) -> None: records = [ _record(gpu_memory_mb=4096.0), _record(gpu_memory_mb=8192.0), ] result = compute_memory_metrics(records) assert result.peak_rss_memory_mb == 8192.0 assert result.mean_working_set_mb == 6144.0 def test_zero_gpu_memory_treated_as_no_data(self) -> None: records = [_record(gpu_memory_mb=0.0)] result = compute_memory_metrics(records) assert result.peak_rss_memory_mb == 0.0 assert result.mean_working_set_mb == 0.0 # --------------------------------------------------------------------------- # Efficiency Metrics # --------------------------------------------------------------------------- class TestEfficiencyMetrics: def test_empty_records(self) -> None: result = compute_efficiency_metrics([]) assert result.tokens_per_second == 0.0 assert result.documents_per_gpu_second == 0.0 assert result.fast_path_fraction == 0.0 assert result.adjudication_fraction == 0.0 def test_tokens_per_second(self) -> None: records = [ _record( start_time=0.0, end_time=10.0, input_tokens=500, output_tokens=500, ), ] result = compute_efficiency_metrics(records) # 1000 tokens / 10 seconds = 100 tokens/sec assert abs(result.tokens_per_second - 100.0) < 1e-9 def test_documents_per_gpu_second(self) -> None: records = [ _record(document_id="doc-1", gpu_seconds=2.0), _record(document_id="doc-2", gpu_seconds=3.0), ] result = compute_efficiency_metrics(records) # 2 docs / 5 gpu-seconds = 0.4 docs/gpu-sec assert abs(result.documents_per_gpu_second - 0.4) < 1e-9 def test_no_gpu_usage_infinite_docs(self) -> None: """When no GPU time, documents_per_gpu_second should be 0 (avoid division by zero).""" records = [_record(gpu_seconds=0.0)] result = compute_efficiency_metrics(records) assert result.documents_per_gpu_second == 0.0 def test_fast_path_vs_adjudication_split(self) -> None: records = [ _record(stage_name="extraction", cpu_seconds=2.0, gpu_seconds=0.0), _record(stage_name="sentiment", cpu_seconds=1.0, gpu_seconds=0.0), _record(stage_name="adjudication", cpu_seconds=0.5, gpu_seconds=3.0), ] result = compute_efficiency_metrics(records) assert result.fast_path_cpu_seconds == 3.0 assert result.adjudication_cpu_seconds == 0.5 assert result.fast_path_gpu_seconds == 0.0 assert result.adjudication_gpu_seconds == 3.0 # Fast: 3.0, Adj: 3.5, Total: 6.5 assert abs(result.fast_path_fraction - 3.0 / 6.5) < 1e-9 assert abs(result.adjudication_fraction - 3.5 / 6.5) < 1e-9 def test_adjudication_stage_detection(self) -> None: """Various adjudication stage name patterns should be detected.""" records = [ _record(stage_name="9b_adjudication", cpu_seconds=1.0, gpu_seconds=1.0), _record(stage_name="semantic_adjudication", cpu_seconds=1.0, gpu_seconds=1.0), _record(stage_name="my_adjudicator_stage", cpu_seconds=1.0, gpu_seconds=1.0), ] result = compute_efficiency_metrics(records) assert result.adjudication_cpu_seconds == 3.0 assert result.adjudication_gpu_seconds == 3.0 assert result.fast_path_cpu_seconds == 0.0 # --------------------------------------------------------------------------- # Full Evaluation Report # --------------------------------------------------------------------------- class TestEvaluateResources: def test_empty_records(self) -> None: report = evaluate_resources([]) assert report.document_count == 0 assert report.latency.count == 0 assert report.throughput.total_documents == 0 def test_complete_report(self) -> None: records = [ _record( document_id="doc-1", stage_name="extraction", start_time=0.0, end_time=2.0, input_tokens=200, output_tokens=100, cpu_seconds=1.0, gpu_seconds=0.5, gpu_memory_mb=4096.0, ), _record( document_id="doc-1", stage_name="adjudication", start_time=2.0, end_time=5.0, input_tokens=500, output_tokens=200, cpu_seconds=0.2, gpu_seconds=2.5, gpu_memory_mb=8000.0, ), _record( document_id="doc-2", stage_name="extraction", start_time=5.0, end_time=7.0, input_tokens=180, output_tokens=90, cpu_seconds=0.8, gpu_seconds=0.3, gpu_memory_mb=3500.0, ), ] report = evaluate_resources(records) assert isinstance(report, ResourceEvaluationReport) assert report.document_count == 2 # Latency: doc-1 = 5s, doc-2 = 2s assert report.latency.count == 2 assert report.latency.max == 5.0 assert report.latency.min == 2.0 # Throughput: 2 docs / 7 seconds assert report.throughput.total_documents == 2 assert report.throughput.total_wall_seconds == 7.0 # Token usage assert report.token_usage.total_input_tokens == 880 assert report.token_usage.total_output_tokens == 390 assert report.token_usage.total_tokens == 1270 # CPU assert report.cpu.total_cpu_seconds == 2.0 # GPU assert report.gpu.total_gpu_seconds == 3.3 assert report.gpu.peak_gpu_memory_mb == 8000.0 # Memory (fallback to GPU memory) assert report.memory.peak_rss_memory_mb == 8000.0 # Efficiency assert report.efficiency.adjudication_gpu_seconds == 2.5 assert report.efficiency.fast_path_cpu_seconds == 1.8 def test_with_rss_samples(self) -> None: records = [_record(gpu_memory_mb=5000.0)] report = evaluate_resources(records, rss_samples_mb=[512.0, 1024.0, 768.0]) assert report.memory.peak_rss_memory_mb == 1024.0 assert abs(report.memory.mean_working_set_mb - 768.0) < 1e-9 def test_per_stage_latency_sorted(self) -> None: records = [ _record(stage_name="z_stage", start_time=0.0, end_time=1.0), _record(stage_name="a_stage", start_time=1.0, end_time=2.0), ] report = evaluate_resources(records) stage_names = [s.stage_name for s in report.per_stage_latency] assert stage_names == ["a_stage", "z_stage"]