mirror of
https://github.com/zadam/trilium.git
synced 2025-12-05 23:14:24 +01:00
218 lines
6.9 KiB
TypeScript
218 lines
6.9 KiB
TypeScript
/**
|
|
* A/B Testing utilities for comparing search backend performance
|
|
*/
|
|
|
|
import SearchContext from "./search_context.js";
|
|
import type { SearchParams } from "./services/types.js";
|
|
import performanceMonitor from "./performance_monitor.js";
|
|
import log from "../log.js";
|
|
import optionService from "../options.js";
|
|
|
|
export interface ABTestResult {
|
|
query: string;
|
|
typescriptTime: number;
|
|
sqliteTime: number;
|
|
typescriptResults: number;
|
|
sqliteResults: number;
|
|
resultsMatch: boolean;
|
|
speedup: number;
|
|
winner: "typescript" | "sqlite" | "tie";
|
|
}
|
|
|
|
class ABTestingService {
|
|
private enabled: boolean = false;
|
|
private sampleRate: number = 0.1; // 10% of searches by default
|
|
private results: ABTestResult[] = [];
|
|
private maxResults: number = 1000;
|
|
|
|
constructor() {
|
|
this.updateSettings();
|
|
}
|
|
|
|
updateSettings() {
|
|
try {
|
|
this.enabled = optionService.getOptionBool("searchSqliteEnabled");
|
|
// Could add a separate AB testing option if needed
|
|
} catch {
|
|
this.enabled = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Determines if we should run an A/B test for this query
|
|
*/
|
|
shouldRunTest(): boolean {
|
|
if (!this.enabled) {
|
|
return false;
|
|
}
|
|
|
|
// Random sampling
|
|
return Math.random() < this.sampleRate;
|
|
}
|
|
|
|
/**
|
|
* Run the same search query with both backends and compare results
|
|
*/
|
|
async runComparison(query: string, params: SearchParams): Promise<ABTestResult | null> {
|
|
if (!this.shouldRunTest()) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
// Dynamically import to avoid circular dependencies
|
|
const searchModule = await import("./services/search.js");
|
|
|
|
// Run with TypeScript backend
|
|
const tsContext = new SearchContext({ ...params, forceBackend: "typescript" });
|
|
const tsTimer = performanceMonitor.startTimer();
|
|
const tsResults = searchModule.default.findResultsWithQuery(query, tsContext);
|
|
const tsTime = tsTimer();
|
|
|
|
// Run with SQLite backend
|
|
const sqliteContext = new SearchContext({ ...params, forceBackend: "sqlite" });
|
|
const sqliteTimer = performanceMonitor.startTimer();
|
|
const sqliteResults = searchModule.default.findResultsWithQuery(query, sqliteContext);
|
|
const sqliteTime = sqliteTimer();
|
|
|
|
// Compare results
|
|
const tsNoteIds = new Set(tsResults.map(r => r.noteId));
|
|
const sqliteNoteIds = new Set(sqliteResults.map(r => r.noteId));
|
|
|
|
// Check if results match (same notes found)
|
|
const resultsMatch = tsNoteIds.size === sqliteNoteIds.size &&
|
|
[...tsNoteIds].every(id => sqliteNoteIds.has(id));
|
|
|
|
// Calculate speedup
|
|
const speedup = tsTime / sqliteTime;
|
|
|
|
// Determine winner
|
|
let winner: "typescript" | "sqlite" | "tie";
|
|
if (speedup > 1.2) {
|
|
winner = "sqlite";
|
|
} else if (speedup < 0.83) {
|
|
winner = "typescript";
|
|
} else {
|
|
winner = "tie";
|
|
}
|
|
|
|
const result: ABTestResult = {
|
|
query: query.substring(0, 100),
|
|
typescriptTime: tsTime,
|
|
sqliteTime: sqliteTime,
|
|
typescriptResults: tsResults.length,
|
|
sqliteResults: sqliteResults.length,
|
|
resultsMatch,
|
|
speedup,
|
|
winner
|
|
};
|
|
|
|
this.recordResult(result);
|
|
|
|
// Log significant differences
|
|
if (!resultsMatch) {
|
|
log.info(`A/B test found different results for query "${query.substring(0, 50)}": TS=${tsResults.length}, SQLite=${sqliteResults.length}`);
|
|
}
|
|
|
|
if (Math.abs(speedup - 1) > 0.5) {
|
|
log.info(`A/B test significant performance difference: ${winner} is ${Math.abs(speedup - 1).toFixed(1)}x faster for query "${query.substring(0, 50)}"`);
|
|
}
|
|
|
|
return result;
|
|
} catch (error) {
|
|
log.error(`A/B test failed: ${error}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private recordResult(result: ABTestResult) {
|
|
this.results.push(result);
|
|
|
|
// Keep only the last N results
|
|
if (this.results.length > this.maxResults) {
|
|
this.results = this.results.slice(-this.maxResults);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get summary statistics from A/B tests
|
|
*/
|
|
getSummary(): {
|
|
totalTests: number;
|
|
avgSpeedup: number;
|
|
typescriptWins: number;
|
|
sqliteWins: number;
|
|
ties: number;
|
|
mismatchRate: number;
|
|
recommendation: string;
|
|
} {
|
|
if (this.results.length === 0) {
|
|
return {
|
|
totalTests: 0,
|
|
avgSpeedup: 1,
|
|
typescriptWins: 0,
|
|
sqliteWins: 0,
|
|
ties: 0,
|
|
mismatchRate: 0,
|
|
recommendation: "No A/B test data available"
|
|
};
|
|
}
|
|
|
|
const totalTests = this.results.length;
|
|
const avgSpeedup = this.results.reduce((sum, r) => sum + r.speedup, 0) / totalTests;
|
|
const typescriptWins = this.results.filter(r => r.winner === "typescript").length;
|
|
const sqliteWins = this.results.filter(r => r.winner === "sqlite").length;
|
|
const ties = this.results.filter(r => r.winner === "tie").length;
|
|
const mismatches = this.results.filter(r => !r.resultsMatch).length;
|
|
const mismatchRate = mismatches / totalTests;
|
|
|
|
let recommendation: string;
|
|
if (mismatchRate > 0.1) {
|
|
recommendation = "High mismatch rate detected - SQLite search may have accuracy issues";
|
|
} else if (avgSpeedup > 1.5) {
|
|
recommendation = `SQLite is ${avgSpeedup.toFixed(1)}x faster on average - consider enabling`;
|
|
} else if (avgSpeedup < 0.67) {
|
|
recommendation = `TypeScript is ${(1/avgSpeedup).toFixed(1)}x faster on average - keep using TypeScript`;
|
|
} else {
|
|
recommendation = "Both backends perform similarly - choice depends on other factors";
|
|
}
|
|
|
|
return {
|
|
totalTests,
|
|
avgSpeedup,
|
|
typescriptWins,
|
|
sqliteWins,
|
|
ties,
|
|
mismatchRate,
|
|
recommendation
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get recent test results
|
|
*/
|
|
getRecentResults(count: number = 100): ABTestResult[] {
|
|
return this.results.slice(-count);
|
|
}
|
|
|
|
/**
|
|
* Clear all test results
|
|
*/
|
|
reset() {
|
|
this.results = [];
|
|
}
|
|
|
|
/**
|
|
* Set the sampling rate for A/B tests
|
|
*/
|
|
setSampleRate(rate: number) {
|
|
if (rate < 0 || rate > 1) {
|
|
throw new Error("Sample rate must be between 0 and 1");
|
|
}
|
|
this.sampleRate = rate;
|
|
}
|
|
}
|
|
|
|
// Singleton instance
|
|
const abTestingService = new ABTestingService();
|
|
|
|
export default abTestingService; |