diff --git a/example/lib/profiling/query_profile_report.dart b/example/lib/profiling/query_profile_report.dart new file mode 100644 index 0000000..2195b7e --- /dev/null +++ b/example/lib/profiling/query_profile_report.dart @@ -0,0 +1,71 @@ +import 'dart:convert'; +import 'package:mobile_rag_engine/services/benchmark_service.dart'; +import 'package:mobile_rag_engine/models/benchmark_models.dart'; + +/// Per-segment sample collector; defers stats to BenchmarkService.summarizeSamples. +class SegmentSamples { + final String segment; + final List samplesMs = []; + SegmentSamples(this.segment); + void add(double ms) => samplesMs.add(ms); + void addAll(Iterable ms) => samplesMs.addAll(ms); + + DetailedBenchmarkStats stats() => + BenchmarkService.summarizeSamples(samplesMs, warmupIterations: 0); + + Map toJson() { + final s = stats(); + return {'segment': segment, ...s.toJson()}; + } +} + +/// One scenario run: a (lane, category) cell with its measured segments. +class QueryProfileRun { + final String lane; // 'unfiltered' | 'filtered' + final String category; // 'pure_cold' | 'switching_cold' | 'pure_warm' + final Map segments; + final Map io; // query_metrics snapshot + final Map meta; // device/config + + QueryProfileRun({ + required this.lane, required this.category, + required this.segments, required this.io, required this.meta, + }); + + /// FFI marshalling + isolate context-switch cost = Dart-measured - Rust-internal. + /// Clamped to 0 (clock noise can make it slightly negative). + static double ffiOverheadMs({required double dartMs, required double rustInternalMs}) { + final d = dartMs - rustInternalMs; + return d > 0 ? d : 0.0; + } + + Map toJson() => { + 'lane': lane, + 'category': category, + 'segments': segments.map((k, v) => MapEntry(k, v.toJson())), + 'io': io, + 'meta': meta, + }; +} + +class QueryProfileReport { + final List runs; + QueryProfileReport({required this.runs}); + + Map toJson() => {'runs': runs.map((r) => r.toJson()).toList()}; + String toJsonString() => const JsonEncoder.withIndent(' ').convert(toJson()); + + String toCsv() { + final b = StringBuffer() + ..writeln('lane,category,segment,p50_ms,p95_ms,avg_ms,min_ms,max_ms,stddev_ms,n'); + for (final r in runs) { + for (final seg in r.segments.values) { + final s = seg.stats(); + b.writeln('${r.lane},${r.category},${seg.segment},' + '${s.p50Ms},${s.p95Ms},${s.avgMs},${s.minMs},${s.maxMs},${s.stdDevMs},' + '${s.measuredIterations}'); + } + } + return b.toString(); + } +} diff --git a/example/test/profiling/query_profile_report_test.dart b/example/test/profiling/query_profile_report_test.dart new file mode 100644 index 0000000..3383931 --- /dev/null +++ b/example/test/profiling/query_profile_report_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mobile_rag_engine_example/profiling/query_profile_report.dart'; + +void main() { + test('SegmentSamples aggregates and serializes', () { + final seg = SegmentSamples('embed')..addAll([10, 12, 11, 13, 9]); + final j = seg.toJson(); + expect(j['segment'], 'embed'); + expect(j['p50_ms'], isNotNull); + expect((j['samples_ms'] as List).length, 5); + }); + + test('ffiOverheadMs = dart segment minus rust internal', () { + expect(QueryProfileRun.ffiOverheadMs(dartMs: 5.0, rustInternalMs: 3.2), closeTo(1.8, 1e-6)); + expect(QueryProfileRun.ffiOverheadMs(dartMs: 3.0, rustInternalMs: 3.4), 0.0); + }); + + test('empty SegmentSamples serializes to zero stats', () { + final seg = SegmentSamples('embed'); + final j = seg.toJson(); + expect(j['segment'], 'embed'); + expect(j['p50_ms'], 0.0); + expect((j['samples_ms'] as List).isEmpty, true); + }); + + test('report toCsv has one row per (lane,category,segment) + toJson', () { + final run = QueryProfileRun( + lane: 'unfiltered', category: 'pure_warm', + segments: { + 'embed': SegmentSamples('embed')..addAll([10, 11]), + 'search': SegmentSamples('search')..addAll([2, 3]), + }, + io: const {'scoped_exact_scan_rows': 0}, + meta: const {'device': 'test'}, + ); + final report = QueryProfileReport(runs: [run]); + final csv = report.toCsv(); + expect(csv, contains('lane,category,segment,p50_ms,p95_ms,avg_ms,min_ms,max_ms,stddev_ms,n')); + expect(csv, contains('unfiltered,pure_warm,embed,')); + expect(csv, contains('unfiltered,pure_warm,search,')); + expect((report.toJson()['runs'] as List).length, 1); + }); +}